added proxies for declined & requested projects

This commit is contained in:
Oliver Zander 2025-10-16 15:38:41 +02:00
parent 4efab724ca
commit 7e4d197384
7 changed files with 211 additions and 193 deletions

View File

@ -11,6 +11,8 @@ from .models import (
Account, Account,
Project, Project,
ProjectCategory, ProjectCategory,
ProjectRequest,
ProjectDeclined,
WikimediaProject, WikimediaProject,
HonoraryCertificate, HonoraryCertificate,
Library, Library,
@ -126,6 +128,21 @@ class ProjectAdmin(admin.ModelAdmin):
class Media: class Media:
js = ('dropdown/js/otrs_link.js',) 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) @admin.register(BusinessCard)
class BusinessCardAdmin(admin.ModelAdmin): class BusinessCardAdmin(admin.ModelAdmin):

View File

@ -131,6 +131,13 @@ class BaseApplicationForm(FdbForm):
settings.DATAPROTECTION, settings.FOERDERRICHTLINIEN)) 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): class BaseProjectForm(ModelForm):
categories = { categories = {
'categories': ProjectCategory, 'categories': ProjectCategory,
@ -205,6 +212,14 @@ class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
'all': ('css/dateFieldNoNowShortcutInTravels.css',) '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 = { HOTEL_CHOICES = {
'TRUE': mark_safe('Hotelzimmer benötigt'), 'TRUE': mark_safe('Hotelzimmer benötigt'),

View File

@ -14,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='project', model_name='project',
name='categories', 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( migrations.AddField(
model_name='project', model_name='project',
@ -24,7 +24,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='project', model_name='project',
name='wikimedia_projects', 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( migrations.AddField(
model_name='project', model_name='project',

View File

@ -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'},
),
]

View File

@ -1,15 +1,12 @@
from contextlib import suppress from contextlib import suppress
from datetime import date from datetime import date
from functools import partial
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator
from django.db import models from django.db import models
from django.db.models.signals import pre_save from django.db.models.signals import pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.forms import ModelMultipleChoiceField, CheckboxSelectMultiple from django.forms import ModelMultipleChoiceField, CheckboxSelectMultiple
from django.forms.models import ModelChoiceIterator
from django.utils.functional import cached_property, classproperty from django.utils.functional import cached_property, classproperty
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -89,6 +86,10 @@ class Account(models.Model):
def __str__(self): def __str__(self):
return f'{self.code} {self.description}' return f'{self.code} {self.description}'
@property
def has_subaccounts(self):
return self.code == '21111'
class BaseProjectCategory(models.Model): class BaseProjectCategory(models.Model):
OTHER: str OTHER: str
@ -128,10 +129,10 @@ class WikimediaProject(BaseProjectCategory):
verbose_name_plural = 'Wikimedia Projekte' verbose_name_plural = 'Wikimedia Projekte'
class ProductCategoryChoiceIterator(ModelChoiceIterator): class ProductCategoryChoiceIterator(ModelMultipleChoiceField.iterator):
def __iter__(self): def __iter__(self):
yield from ModelChoiceIterator.__iter__(self) yield from ModelMultipleChoiceField.iterator.__iter__(self)
yield f'{self.field.other.id}', self.field.other.name yield f'{self.field.other.id}', self.field.other.name
@ -219,61 +220,62 @@ class Project(Volunteer):
project_of_year = models.IntegerField(default=0) project_of_year = models.IntegerField(default=0)
end_quartal = models.CharField(max_length=15, null=True, blank=True, verbose_name='Quartal Projekt Ende') 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''' def save(self, *, using=None, **kwargs):
# we don't call save with args/kwargs to avoid UNIQUE CONSTRAINT errors kwargs['using'] = using
# 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))
if self.end: if self.end:
self.end_quartal = f'Q{self.end.month // 4 + 1}' 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 super().save(**kwargs)
return f'{self.pid} {self.name}'
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): def clean(self):
if (self.start and self.end) and (self.end < self.start): 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): class Intern(Volunteer):
'''abstract base class for data entry from /intern (except Project)''' '''abstract base class for data entry from /intern (except Project)'''
request_url = models.URLField(max_length=2000, verbose_name='Antrag (URL)') 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) project = models.ForeignKey(Project, null=True, blank=True, on_delete=models.SET_NULL)
def __str__(self): def __str__(self):
return 'Certificate for ' + self.realname return f'Certificate for {self.realname}'
TRANSPORT_CHOICES = { TRANSPORT_CHOICES = {
@ -561,21 +579,6 @@ class BusinessCard(TermsConsentMixin, Extern):
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen') 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 = { MODELS = {
TYPE_BIB: Library, TYPE_BIB: Library,
TYPE_ELIT: ELiterature, TYPE_ELIT: ELiterature,

View File

@ -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()

View File

@ -1,5 +1,4 @@
from datetime import date from datetime import date
from unittest import skip
from django.test import TestCase from django.test import TestCase
@ -8,8 +7,12 @@ from input.models import HonoraryCertificate, Project, Account, Literature
class ModelTestCase(TestCase): class ModelTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.account = Account.objects.create(code='1234', description='blabla')
def test_set_granted(self): 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') obj = HonoraryCertificate.objects.create(realname='hurzel', email='hurzel@web.de')
self.assertEqual(obj.granted, None) self.assertEqual(obj.granted, None)
HonoraryCertificate.set_granted(obj.pk, True) HonoraryCertificate.set_granted(obj.pk, True)
@ -17,11 +20,8 @@ class ModelTestCase(TestCase):
self.assertEqual(obj2.granted, True) self.assertEqual(obj2.granted, True)
def test_project_of_year(self): def test_project_of_year(self):
''' test if the finance id is resettet ad start of year''' """ test if the finance id is resettet ad start of year """
acc = Account.objects.create() acc = self.account
acc.code='1234'
acc.description='blabla'
acc.save()
startdate = date(2022, 1, 1) startdate = date(2022, 1, 1)
obj = Project.objects.create(account=acc, name='testproject', start=startdate) obj = Project.objects.create(account=acc, name='testproject', start=startdate)
self.assertEqual(obj.project_of_year, 1) self.assertEqual(obj.project_of_year, 1)
@ -35,39 +35,54 @@ class ModelTestCase(TestCase):
obj3 = Project.objects.create(account=acc, name='testproject2', start=startdate) obj3 = Project.objects.create(account=acc, name='testproject2', start=startdate)
self.assertEqual(obj3.project_of_year, 3) 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): def test_finance_id(self):
''' test if the finance counting is correct''' """ test if the finance counting is correct """
acc = Account.objects.create(code='1234', description='blabla') acc = self.account
startdate = date(2022, 1, 1) startdate = date(2022, 1, 1)
obj = Project.objects.create(account=acc, name='testproject', start=startdate) 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) 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) olddate = date(2021, 12, 31)
obj4 = Project.objects.create(account=acc, name='testproject2', start=olddate) obj4 = Project.objects.create(account=acc, name='testproject2', start=olddate)
obj3 = Project.objects.create(account=acc, name='testproject2', start=startdate) 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): def test_financed_id_for_subaccounts(self):
# ''' test if the pid counting is correct ''' account = Account.objects.create(code='21111', description='has subaccounts')
# acc = Account.objects.create(code='1234', description='blabla') obj = Project.objects.create(account=account, name='test', start=date(2025, 1, 1))
# startdate = date(2022,1,1)
# obj = Project.objects.create(account= acc, name='testproject', start=startdate) self.assertEqual(obj.finance_id, f'{account.code}-001')
# self.assertEqual(obj.pid,"1234001")
# self.assertEqual(obj.account.code,"1234") def test_finance_id_later(self):
# obj = Project.objects.create(name='test', start=date(2025, 1, 1))
# obj2 = Project.objects.create(account= acc, name='testproject2', start=startdate)
# self.assertEqual(obj2.pid,"1234002") self.assertFalse(obj.finance_id)
#
# olddate = date(2021,12,31) obj.account = self.account
# obj4 = Project.objects.create(account= acc, name='testproject2', start=olddate) obj.save()
#
# obj3 = Project.objects.create(account= acc, name='testproject2', start=startdate) self.assertTrue(obj.finance_id)
# self.assertEqual(obj3.pid,"1234004")
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): def test_literature(self):
obj = Literature.objects.create(cost='100', notes='jolo', selfbuy_give_data=False) obj = Literature.objects.create(cost='100', notes='jolo', selfbuy_give_data=False)