forked from beba/foerderbarometer
added proxies for declined & requested projects
This commit is contained in:
parent
4efab724ca
commit
7e4d197384
|
|
@ -11,6 +11,8 @@ from .models import (
|
|||
Account,
|
||||
Project,
|
||||
ProjectCategory,
|
||||
ProjectRequest,
|
||||
ProjectDeclined,
|
||||
WikimediaProject,
|
||||
HonoraryCertificate,
|
||||
Library,
|
||||
|
|
@ -126,6 +128,21 @@ class ProjectAdmin(admin.ModelAdmin):
|
|||
class Media:
|
||||
js = ('dropdown/js/otrs_link.js',)
|
||||
|
||||
granted = True
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).filter(granted=self.granted)
|
||||
|
||||
|
||||
@admin.register(ProjectRequest)
|
||||
class ProjectRequestAdmin(ProjectAdmin):
|
||||
granted = None
|
||||
|
||||
|
||||
@admin.register(ProjectDeclined)
|
||||
class ProjectDeclinedAdmin(ProjectAdmin):
|
||||
granted = False
|
||||
|
||||
|
||||
@admin.register(BusinessCard)
|
||||
class BusinessCardAdmin(admin.ModelAdmin):
|
||||
|
|
|
|||
|
|
@ -131,6 +131,13 @@ class BaseApplicationForm(FdbForm):
|
|||
settings.DATAPROTECTION, settings.FOERDERRICHTLINIEN))
|
||||
|
||||
|
||||
PROJECT_COST_GT_1000_MESSAGE = format_html(
|
||||
"""Bitte beachte, dass für Projektkosten über 1.000 € ein öffentlicher Projektplan erforderlich
|
||||
ist (siehe <a href="{0}" target="blank_">Wikipedia:Förderung/Projektplanung)</a>.""",
|
||||
'https://de.wikipedia.org/wiki/Wikipedia:F%C3%B6rderung/Projektplanung'
|
||||
)
|
||||
|
||||
|
||||
class BaseProjectForm(ModelForm):
|
||||
categories = {
|
||||
'categories': ProjectCategory,
|
||||
|
|
@ -205,6 +212,14 @@ class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
|
|||
'all': ('css/dateFieldNoNowShortcutInTravels.css',)
|
||||
}
|
||||
|
||||
def clean_cost(self):
|
||||
cost = self.cleaned_data['cost']
|
||||
|
||||
if cost > 1000:
|
||||
raise forms.ValidationError(PROJECT_COST_GT_1000_MESSAGE, code='cost-gt-1000')
|
||||
|
||||
return cost
|
||||
|
||||
|
||||
HOTEL_CHOICES = {
|
||||
'TRUE': mark_safe('Hotelzimmer benötigt'),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='categories',
|
||||
field=input.models.ProjectCategoryField(blank=True, related_name='projects', to='input.projectcategory', verbose_name='Projektkategorien'),
|
||||
field=input.models.ProjectCategoryField(related_name='projects', to='input.projectcategory', verbose_name='Projektkategorien'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
|
|
@ -24,7 +24,7 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='wikimedia_projects',
|
||||
field=input.models.ProjectCategoryField(blank=True, related_name='projects', to='input.wikimediaproject', verbose_name='Wikimedia Projekte'),
|
||||
field=input.models.ProjectCategoryField(related_name='projects', to='input.wikimediaproject', verbose_name='Wikimedia Projekte'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 5.2.5 on 2025-10-16 13:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('input', '0101_wikimedia_project_categories_and_other'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProjectDeclined',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Projekt (abgelehnt)',
|
||||
'verbose_name_plural': 'Projekte (abgelehnt)',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('input.project',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectRequest',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Projekt (beantragt)',
|
||||
'verbose_name_plural': 'Projekte (beantragt)',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('input.project',),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='project',
|
||||
options={'verbose_name': 'Projekt', 'verbose_name_plural': 'Projekte'},
|
||||
),
|
||||
]
|
||||
141
input/models.py
141
input/models.py
|
|
@ -1,15 +1,12 @@
|
|||
from contextlib import suppress
|
||||
from datetime import date
|
||||
from functools import partial
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db import models
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.forms import ModelMultipleChoiceField, CheckboxSelectMultiple
|
||||
from django.forms.models import ModelChoiceIterator
|
||||
from django.utils.functional import cached_property, classproperty
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
|
|
@ -89,6 +86,10 @@ class Account(models.Model):
|
|||
def __str__(self):
|
||||
return f'{self.code} {self.description}'
|
||||
|
||||
@property
|
||||
def has_subaccounts(self):
|
||||
return self.code == '21111'
|
||||
|
||||
|
||||
class BaseProjectCategory(models.Model):
|
||||
OTHER: str
|
||||
|
|
@ -128,10 +129,10 @@ class WikimediaProject(BaseProjectCategory):
|
|||
verbose_name_plural = 'Wikimedia Projekte'
|
||||
|
||||
|
||||
class ProductCategoryChoiceIterator(ModelChoiceIterator):
|
||||
class ProductCategoryChoiceIterator(ModelMultipleChoiceField.iterator):
|
||||
|
||||
def __iter__(self):
|
||||
yield from ModelChoiceIterator.__iter__(self)
|
||||
yield from ModelMultipleChoiceField.iterator.__iter__(self)
|
||||
yield f'{self.field.other.id}', self.field.other.name
|
||||
|
||||
|
||||
|
|
@ -219,61 +220,62 @@ class Project(Volunteer):
|
|||
project_of_year = models.IntegerField(default=0)
|
||||
end_quartal = models.CharField(max_length=15, null=True, blank=True, verbose_name='Quartal Projekt Ende')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
class Meta:
|
||||
verbose_name = 'Projekt'
|
||||
verbose_name_plural = 'Projekte'
|
||||
|
||||
generate_finance_id = False
|
||||
def __str__(self):
|
||||
return f'{self.pid or self.id} {self.name}'
|
||||
|
||||
'''we generate the autogenerated fields here'''
|
||||
# we don't call save with args/kwargs to avoid UNIQUE CONSTRAINT errors
|
||||
# but maybe there is a better solution?
|
||||
|
||||
if not self.pk:
|
||||
print('NO PK THERE')
|
||||
generate_finance_id = True
|
||||
super().save()
|
||||
else:
|
||||
orig = type(self).objects.get(pk=self.pk) # Originaldaten aus der DB abrufen
|
||||
if orig.start.year != self.start.year:
|
||||
generate_finance_id = True
|
||||
if orig.account.code != self.account.code:
|
||||
if str(self.account.code) == '21111':
|
||||
generate_finance_id = True
|
||||
else:
|
||||
self.finance_id = str(self.account.code)
|
||||
|
||||
if generate_finance_id:
|
||||
print('MUST GENERATE FINANCE ID')
|
||||
year = self.start.year
|
||||
projects = Project.objects.filter(start__year=year)
|
||||
if not projects:
|
||||
self.project_of_year = 1
|
||||
# self.pid = str(self.start.year) + '-' + str(self.account.code) + str(self.project_of_year).zfill(3)
|
||||
else:
|
||||
# get the project of year number of latest entry
|
||||
projects = projects.order_by('-project_of_year')[0]
|
||||
# add one to value of latest entry
|
||||
self.project_of_year = int(projects.project_of_year) + 1
|
||||
# self.pid = str(self.start.year) + '-' + str(self.account.code) + str(self.project_of_year).zfill(3)
|
||||
|
||||
if str(self.account.code) == '21111':
|
||||
self.finance_id = str(self.account.code) + '-' + str(self.project_of_year).zfill(3)
|
||||
else:
|
||||
self.finance_id = str(self.account.code)
|
||||
|
||||
# print (('Current PID',self.pid))
|
||||
|
||||
if not self.pid:
|
||||
self.pid = str(self.account.code) + str(self.pk).zfill(8)
|
||||
# self.pid = str(self.account.code) + str(self.pk).zfill(3)
|
||||
print(('Hallo Leute! Ich save jetzt mal MIT PID DANN!!!', self.pid))
|
||||
def save(self, *, using=None, **kwargs):
|
||||
kwargs['using'] = using
|
||||
|
||||
if self.end:
|
||||
self.end_quartal = f'Q{self.end.month // 4 + 1}'
|
||||
else:
|
||||
self.end_quartal = ''
|
||||
|
||||
super().save()
|
||||
if not self.account:
|
||||
self.finance_id = ''
|
||||
self.project_of_year = 0
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.pid} {self.name}'
|
||||
return super().save(**kwargs)
|
||||
|
||||
if self.should_generate_finance_id():
|
||||
self.generate_finance_id()
|
||||
|
||||
super().save(**kwargs)
|
||||
|
||||
if not self.pid:
|
||||
self.pid = f'{self.account.code}-{self.id:08d}'
|
||||
super().save(update_fields=['pid'], using=using)
|
||||
|
||||
def should_generate_finance_id(self):
|
||||
if self.id is None:
|
||||
return True
|
||||
|
||||
if not self.finance_id:
|
||||
return True
|
||||
|
||||
start, account_id = type(self).objects.values_list('start', 'account').get(id=self.id)
|
||||
|
||||
return not (self.start.year == start.year and self.account_id == account_id)
|
||||
|
||||
def generate_finance_id(self):
|
||||
"""
|
||||
This is an improved version of the old code for generating a finance id.
|
||||
There is still no protection by constraints against duplicate finance ids!
|
||||
"""
|
||||
|
||||
queryset = Project.objects.exclude(id=self.id).filter(start__year=self.start.year)
|
||||
max_project_of_year = queryset.aggregate(max=models.Max('project_of_year')).get('max') or 0
|
||||
|
||||
self.project_of_year = project_of_year = max_project_of_year + 1
|
||||
|
||||
if self.account.has_subaccounts:
|
||||
self.finance_id = f'{self.account.code}-{project_of_year:03d}'
|
||||
else:
|
||||
self.finance_id = self.account.code
|
||||
|
||||
def clean(self):
|
||||
if (self.start and self.end) and (self.end < self.start):
|
||||
|
|
@ -284,6 +286,22 @@ class Project(Volunteer):
|
|||
})
|
||||
|
||||
|
||||
class ProjectRequest(Project):
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = 'Projekt (beantragt)'
|
||||
verbose_name_plural = 'Projekte (beantragt)'
|
||||
|
||||
|
||||
class ProjectDeclined(Project):
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = 'Projekt (abgelehnt)'
|
||||
verbose_name_plural = 'Projekte (abgelehnt)'
|
||||
|
||||
|
||||
class Intern(Volunteer):
|
||||
'''abstract base class for data entry from /intern (except Project)'''
|
||||
request_url = models.URLField(max_length=2000, verbose_name='Antrag (URL)')
|
||||
|
|
@ -304,7 +322,7 @@ class HonoraryCertificate(Intern):
|
|||
project = models.ForeignKey(Project, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
|
||||
def __str__(self):
|
||||
return 'Certificate for ' + self.realname
|
||||
return f'Certificate for {self.realname}'
|
||||
|
||||
|
||||
TRANSPORT_CHOICES = {
|
||||
|
|
@ -561,21 +579,6 @@ class BusinessCard(TermsConsentMixin, Extern):
|
|||
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
|
||||
|
||||
|
||||
class Decision(models.TextChoices):
|
||||
OPEN = 'OPEN', 'offen'
|
||||
APPROVED = 'APPROVED', 'bewilligt'
|
||||
DECLINED = 'DECLINED', 'abgelehnt'
|
||||
|
||||
|
||||
validate_cost = MaxValueValidator(
|
||||
limit_value=100,
|
||||
message=(
|
||||
'Bitte beachte, dass für Projektkosten über 1.000 EUR '
|
||||
'ein öffentlicher Projektplan erforderlich ist '
|
||||
'(siehe Wikipedia:Förderung/Projektplanung).'
|
||||
),
|
||||
)
|
||||
|
||||
MODELS = {
|
||||
TYPE_BIB: Library,
|
||||
TYPE_ELIT: ELiterature,
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
from datetime import date
|
||||
from django.db import transaction
|
||||
from .models import ProjectRequest, ProjectsDeclined, Project, Account
|
||||
|
||||
def approve_project_request(request_id: int, decided_by: str, account_code: str) -> Project:
|
||||
# Use a DB transaction so either all changes commit, or none (atomic workflow).
|
||||
with transaction.atomic():
|
||||
# SELECT ... FOR UPDATE: lock the row to avoid concurrent approvals/declines.
|
||||
req = ProjectRequest.objects.select_for_update().get(pk=request_id)
|
||||
|
||||
# Mark the request as approved and persist the decision metadata.
|
||||
req.decision = 'APPROVED'
|
||||
req.decision_date = date.today() # For DateField a date is fine; prefer timezone.localdate() if TZ-sensitive.
|
||||
req.decided_by = decided_by
|
||||
req.save()
|
||||
|
||||
# The Account (Kostenstelle) must be assigned by WMDE in the admin workflow.
|
||||
# .get() will raise DoesNotExist/MultipleObjectsReturned if data integrity is broken.
|
||||
account = Account.objects.get(code=account_code)
|
||||
|
||||
# Create the actual Project from the request data.
|
||||
# Project.save() will generate pid/finance_id/end_quartal according to your model logic.
|
||||
proj = Project.objects.create(
|
||||
realname=req.realname,
|
||||
email=req.email,
|
||||
end_mail_send=False,
|
||||
name=req.name,
|
||||
description=req.description,
|
||||
start=req.start,
|
||||
end=req.end,
|
||||
page=req.page,
|
||||
group=req.group,
|
||||
location=req.location,
|
||||
participants_estimated=req.participants_estimated,
|
||||
insurance=req.insurance,
|
||||
cost=req.cost,
|
||||
account=account,
|
||||
notes=req.notes,
|
||||
granted=True,
|
||||
granted_date=date.today(), # Consider timezone.localdate() if you care about time zones.
|
||||
granted_from=decided_by,
|
||||
)
|
||||
|
||||
# After successful creation we remove the original request (it has been fulfilled).
|
||||
# Because we're inside an atomic block, both operations succeed/fail together.
|
||||
req.delete()
|
||||
|
||||
# Return the created project for further processing in the caller if needed.
|
||||
return proj
|
||||
|
||||
|
||||
def decline_project_request(request_id: int, reason: str | None = None):
|
||||
# Same transactional guarantees for declines.
|
||||
with transaction.atomic():
|
||||
# Lock the row to prevent concurrent decisions.
|
||||
req = ProjectRequest.objects.select_for_update().get(pk=request_id)
|
||||
|
||||
# Mark as declined and persist decision date.
|
||||
req.decision = 'DECLINED'
|
||||
req.decision_date = date.today()
|
||||
req.save()
|
||||
|
||||
# Archive minimal relevant information in a dedicated table.
|
||||
# No pid/finance_id should be created for declined items.
|
||||
ProjectsDeclined.objects.create(
|
||||
original_request_id=req.id,
|
||||
name=req.name,
|
||||
realname=req.realname,
|
||||
email=req.email,
|
||||
decision_date=req.decision_date,
|
||||
reason=reason or '',
|
||||
)
|
||||
|
||||
# Remove the original request after archiving.
|
||||
req.delete()
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
from datetime import date
|
||||
from unittest import skip
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
|
@ -8,8 +7,12 @@ from input.models import HonoraryCertificate, Project, Account, Literature
|
|||
|
||||
class ModelTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.account = Account.objects.create(code='1234', description='blabla')
|
||||
|
||||
def test_set_granted(self):
|
||||
'''test if the model function set_granted() works as intended'''
|
||||
""" test if the model function set_granted() works as intended """
|
||||
obj = HonoraryCertificate.objects.create(realname='hurzel', email='hurzel@web.de')
|
||||
self.assertEqual(obj.granted, None)
|
||||
HonoraryCertificate.set_granted(obj.pk, True)
|
||||
|
|
@ -17,11 +20,8 @@ class ModelTestCase(TestCase):
|
|||
self.assertEqual(obj2.granted, True)
|
||||
|
||||
def test_project_of_year(self):
|
||||
''' test if the finance id is resettet ad start of year'''
|
||||
acc = Account.objects.create()
|
||||
acc.code='1234'
|
||||
acc.description='blabla'
|
||||
acc.save()
|
||||
""" test if the finance id is resettet ad start of year """
|
||||
acc = self.account
|
||||
startdate = date(2022, 1, 1)
|
||||
obj = Project.objects.create(account=acc, name='testproject', start=startdate)
|
||||
self.assertEqual(obj.project_of_year, 1)
|
||||
|
|
@ -35,39 +35,54 @@ class ModelTestCase(TestCase):
|
|||
obj3 = Project.objects.create(account=acc, name='testproject2', start=startdate)
|
||||
self.assertEqual(obj3.project_of_year, 3)
|
||||
|
||||
@skip('Finance ID generation has been changed and this test has not been adapted accordingly.')
|
||||
def test_finance_id(self):
|
||||
''' test if the finance counting is correct'''
|
||||
acc = Account.objects.create(code='1234', description='blabla')
|
||||
""" test if the finance counting is correct """
|
||||
acc = self.account
|
||||
startdate = date(2022, 1, 1)
|
||||
obj = Project.objects.create(account=acc, name='testproject', start=startdate)
|
||||
self.assertEqual(obj.finance_id,"1234001")
|
||||
self.assertEqual(obj.finance_id, "1234")
|
||||
|
||||
obj2 = Project.objects.create(account=acc, name='testproject2', start=startdate)
|
||||
self.assertEqual(obj2.finance_id,"1234002")
|
||||
self.assertEqual(obj2.finance_id, "1234")
|
||||
|
||||
olddate = date(2021, 12, 31)
|
||||
obj4 = Project.objects.create(account=acc, name='testproject2', start=olddate)
|
||||
|
||||
obj3 = Project.objects.create(account=acc, name='testproject2', start=startdate)
|
||||
self.assertEqual(obj3.finance_id,"1234003")
|
||||
self.assertEqual(obj3.finance_id, "1234")
|
||||
|
||||
# def test_pid(self):
|
||||
# ''' test if the pid counting is correct '''
|
||||
# acc = Account.objects.create(code='1234', description='blabla')
|
||||
# startdate = date(2022,1,1)
|
||||
# obj = Project.objects.create(account= acc, name='testproject', start=startdate)
|
||||
# self.assertEqual(obj.pid,"1234001")
|
||||
# self.assertEqual(obj.account.code,"1234")
|
||||
#
|
||||
# obj2 = Project.objects.create(account= acc, name='testproject2', start=startdate)
|
||||
# self.assertEqual(obj2.pid,"1234002")
|
||||
#
|
||||
# olddate = date(2021,12,31)
|
||||
# obj4 = Project.objects.create(account= acc, name='testproject2', start=olddate)
|
||||
#
|
||||
# obj3 = Project.objects.create(account= acc, name='testproject2', start=startdate)
|
||||
# self.assertEqual(obj3.pid,"1234004")
|
||||
def test_financed_id_for_subaccounts(self):
|
||||
account = Account.objects.create(code='21111', description='has subaccounts')
|
||||
obj = Project.objects.create(account=account, name='test', start=date(2025, 1, 1))
|
||||
|
||||
self.assertEqual(obj.finance_id, f'{account.code}-001')
|
||||
|
||||
def test_finance_id_later(self):
|
||||
obj = Project.objects.create(name='test', start=date(2025, 1, 1))
|
||||
|
||||
self.assertFalse(obj.finance_id)
|
||||
|
||||
obj.account = self.account
|
||||
obj.save()
|
||||
|
||||
self.assertTrue(obj.finance_id)
|
||||
|
||||
def test_pid(self):
|
||||
""" test if the pid counting is correct """
|
||||
acc = self.account
|
||||
startdate = date(2022, 1, 1)
|
||||
obj = Project.objects.create(account=acc, name='testproject', start=startdate)
|
||||
self.assertEqual(obj.pid, "1234-00000001")
|
||||
self.assertEqual(obj.account.code, "1234")
|
||||
|
||||
obj2 = Project.objects.create(account=acc, name='testproject2', start=startdate)
|
||||
self.assertEqual(obj2.pid, "1234-00000002")
|
||||
|
||||
olddate = date(2021, 12, 31)
|
||||
obj4 = Project.objects.create(account=acc, name='testproject2', start=olddate)
|
||||
|
||||
obj3 = Project.objects.create(account=acc, name='testproject2', start=startdate)
|
||||
self.assertEqual(obj3.pid, "1234-00000004")
|
||||
|
||||
def test_literature(self):
|
||||
obj = Literature.objects.create(cost='100', notes='jolo', selfbuy_give_data=False)
|
||||
|
|
|
|||
Loading…
Reference in New Issue