diff --git a/input/admin.py b/input/admin.py index cae46e4..181a994 100755 --- a/input/admin.py +++ b/input/admin.py @@ -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): diff --git a/input/forms.py b/input/forms.py index 6733f5f..e896057 100755 --- a/input/forms.py +++ b/input/forms.py @@ -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 Wikipedia:Förderung/Projektplanung).""", + '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'), diff --git a/input/migrations/0101_wikimedia_project_categories_and_other.py b/input/migrations/0101_wikimedia_project_categories_and_other.py index a35a844..9190355 100644 --- a/input/migrations/0101_wikimedia_project_categories_and_other.py +++ b/input/migrations/0101_wikimedia_project_categories_and_other.py @@ -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', diff --git a/input/migrations/0102_project_request_declined.py b/input/migrations/0102_project_request_declined.py new file mode 100644 index 0000000..2c76cca --- /dev/null +++ b/input/migrations/0102_project_request_declined.py @@ -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'}, + ), + ] diff --git a/input/models.py b/input/models.py index 9ad3ed7..b6d7caf 100755 --- a/input/models.py +++ b/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, diff --git a/input/services.py b/input/services.py deleted file mode 100644 index b6b9c6d..0000000 --- a/input/services.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/input/tests/models.py b/input/tests/models.py index 26dabd4..476204e 100755 --- a/input/tests/models.py +++ b/input/tests/models.py @@ -1,5 +1,4 @@ from datetime import date -from unittest import skip from django.test import TestCase @@ -8,66 +7,82 @@ 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''' - obj = HonoraryCertificate.objects.create(realname='hurzel',email='hurzel@web.de') - self.assertEqual(obj.granted,None) + """ 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) obj2 = HonoraryCertificate.objects.get(pk=obj.pk) - self.assertEqual(obj2.granted,True) + 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() - startdate = date(2022,1,1) - obj = Project.objects.create(account= acc, name='testproject', start=startdate) - self.assertEqual(obj.project_of_year,1) + """ 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) - obj2 = Project.objects.create(account= acc, name='testproject2', start=startdate) - self.assertEqual(obj2.project_of_year,2) + obj2 = Project.objects.create(account=acc, name='testproject2', start=startdate) + self.assertEqual(obj2.project_of_year, 2) - olddate = date(2021,12,31) - obj4 = Project.objects.create(account= acc, name='testproject2', start=olddate) + 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.project_of_year,3) + 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') - startdate = date(2022,1,1) - obj = Project.objects.create(account= acc, name='testproject', start=startdate) - self.assertEqual(obj.finance_id,"1234001") + """ 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, "1234") - obj2 = Project.objects.create(account= acc, name='testproject2', start=startdate) - self.assertEqual(obj2.finance_id,"1234002") + obj2 = Project.objects.create(account=acc, name='testproject2', start=startdate) + self.assertEqual(obj2.finance_id, "1234") - olddate = date(2021,12,31) - obj4 = Project.objects.create(account= acc, name='testproject2', start=olddate) + 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") + obj3 = Project.objects.create(account=acc, name='testproject2', start=startdate) + 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)