Merge branch 'feature/project-funding-under-1000' of gitlab.cosmocode.de:wikimedia/foerderbarometer into cosmocode

This commit is contained in:
Oliver Zander 2025-10-15 11:26:46 +02:00
commit 4dbf3749d7
8 changed files with 634 additions and 51 deletions

View File

@ -2,6 +2,14 @@ import csv
from django.contrib import admin
from django.http import HttpResponse
from .models import ProjectRequest, ProjectsDeclined
from .forms import ProjectRequestAdminForm
from django.db import models
from django import forms
from django.contrib import messages
from .services import approve_project_request, decline_project_request
from django.contrib.admin.helpers import ActionForm
from .models import (
Account,
@ -160,6 +168,145 @@ class ListAdmin(admin.ModelAdmin):
date_hierarchy = 'granted_date'
readonly_fields = ['service_id']
class ApproveActionForm(ActionForm):
"""
Extra control rendered next to the bulk actions dropdown.
Admin must choose an Account (Kostenstelle) when approving requests.
"""
account_code = forms.ModelChoiceField(queryset=Account.objects.all(), required=True,
label='Kostenstelle (Account)')
@admin.register(ProjectRequest)
class ProjectRequestAdmin(admin.ModelAdmin):
"""
Admin for incoming project requests (< 1000 EUR).
- Uses a custom ModelForm to render JSON-backed fields (checkbox multiselects).
- Provides two bulk actions: approve (moves to Projects) and decline (moves to Projects_declined).
- Enforces that an Account must be selected for approval.
"""
form = ProjectRequestAdminForm
save_as = True
list_display = ('name', 'realname', 'email', 'start', 'end', 'cost', 'decision')
list_filter = ('decision', 'insurance',)
search_fields = ('name', 'realname', 'email')
readonly_fields = ('decision', 'decision_date', 'decided_by')
# Make text areas more comfortable to edit in admin
formfield_overrides = {
# Increase rows for TextField edit widgets
models.TextField: {'widget': forms.Textarea(attrs={'rows': 5, 'cols': 80})},
}
# Show actions at the top (common UX in Django admin)
actions = ['approve_selected', 'decline_selected']
actions_on_top = True
actions_on_bottom = False
action_form = ApproveActionForm
@admin.action(description='Bewilligen → nach „Projects“')
def approve_selected(self, request, queryset):
"""
Bulk-approve selected requests:
- Requires an Account (Kostenstelle) chosen via ApproveActionForm.
- Delegates the creation/move logic to the service layer.
- Counts successes and reports via Django messages.
"""
account_pk = request.POST.get('account_code')
if not account_pk:
self.message_user(
request,
'Bitte eine Kostenstelle auswählen.',
level=messages.ERROR
)
return
try:
account = Account.objects.get(pk=account_pk)
except Account.DoesNotExist:
self.message_user(
request,
f'Unbekannte Kostenstelle (Account pk={account_pk}).',
level=messages.ERROR
)
return
decided_by = request.user.get_username()
ok, failed = 0, 0
for req in queryset:
try:
# Service call is atomic and locks the row (select_for_update)
approve_project_request(req.id, decided_by, account.code)
ok += 1
except Exception as exc:
failed += 1
# Show a concise per-object error; keep details in server logs if needed
self.message_user(
request,
f'Fehler beim Bewilligen von „{req}“: {exc}',
level=messages.ERROR
)
if ok:
self.message_user(
request,
f'{ok} Antrag/Anträge bewilligt und als Project angelegt.',
level=messages.SUCCESS
)
if failed:
self.message_user(
request,
f'{failed} Antrag/Anträge konnten nicht bewilligt werden.',
level=messages.WARNING
)
@admin.action(description='Ablehnen → nach „Projects_declined“')
def decline_selected(self, request, queryset):
"""
Bulk-decline selected requests:
- Archives a minimal snapshot to Projects_declined (per ticket).
- Delegates the move logic to the service layer.
"""
ok, failed = 0, 0
for req in queryset:
try:
decline_project_request(req.id, reason='')
ok += 1
except Exception as exc:
failed += 1
self.message_user(
request,
f'Fehler beim Ablehnen von „{req}“: {exc}',
level=messages.ERROR
)
if ok:
self.message_user(
request,
f'{ok} Antrag/Anträge abgelehnt → „Projects_declined“',
level=messages.WARNING
)
if failed:
self.message_user(
request,
f'{failed} Antrag/Anträge konnten nicht abgelehnt werden.',
level=messages.ERROR
)
@admin.register(ProjectsDeclined)
class ProjectsDeclinedAdmin(admin.ModelAdmin):
"""
Read-only-ish list of declined requests for auditing.
"""
list_display = ('name', 'realname', 'email', 'decision_date')
search_fields = ('name', 'realname', 'email')
date_hierarchy = 'decision_date'
# commented out because of the individual registering to control displays in admin panel
#admin.site.register([

View File

@ -5,6 +5,7 @@ from django.forms.renderers import DjangoTemplates
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django import forms
from .models import ProjectRequest, PROJECT_CATEGORIES, WIKIMEDIA_CHOICES
from .models import (
TYPE_CHOICES,
@ -304,3 +305,117 @@ class ListForm(BaseApplicationForm, CommonOrderMixin):
super().__init__(*args, **kwargs)
self.fields['address'].initial = ''
class ProjectRequestForm(CommonOrderMixin, forms.ModelForm):
"""
Public-facing form for < 1000 EUR project requests.
Key points:
- JSONField-backed multi-selects are exposed as MultipleChoiceField with checkbox widgets.
- We return `list(...)` in clean_* so the JSONField gets a native list.
- Extra UX tweaks: textareas for long text, number inputs with min/max/step, help_texts with links via format_html.
"""
# Expose JSON-backed categories as a checkbox multi-select
categories = forms.MultipleChoiceField(
choices=[(c, c) for c in PROJECT_CATEGORIES],
widget=forms.CheckboxSelectMultiple,
label='Projektkategorie',
help_text='In welche dieser Kategorien lässt sich dein Projekt einordnen?'
)
# Expose JSON-backed wikimedia_projects as a checkbox multi-select
wikimedia_projects = forms.MultipleChoiceField(
choices=[(w, w) for w in WIKIMEDIA_CHOICES],
widget=forms.CheckboxSelectMultiple,
label='Wikimedia Projekt(e)',
help_text='Auf welches Wikimedia-Projekt bezieht sich dein Vorhaben?',
)
class Meta:
model = ProjectRequest
fields = [
'realname', 'email',
'name', 'description',
'categories', 'categories_other',
'wikimedia_projects', 'wikimedia_other',
'start', 'end', 'participants_estimated',
'page', 'group', 'location',
'cost', 'insurance', 'notes',
]
# Widgets are chosen for better UX and to gently guide valid inputs in the browser
widgets = {
'start': AdminDateWidget(),
'end': AdminDateWidget(),
# Long-text fields as textareas with sensible row counts
'description': forms.Textarea(attrs={'rows': 5}),
'notes': forms.Textarea(attrs={'rows': 6}),
# Integer-like fields: browser-side constraints (server still validates in the model)
'participants_estimated': forms.NumberInput(attrs={'min': 0, 'step': 1}),
'cost': forms.NumberInput(attrs={'min': 0, 'max': 1000, 'step': 1}),
}
# Human-readable help_texts; use format_html for safe HTML (links)
help_texts = {
'name': 'Bitte gib einen Namen für das Projekt an.',
'description': 'Bitte beschreibe kurz, was die Ziele deines Projekts sind.',
'participants_estimated': 'Wie viele Personen werden ungefähr an diesem Projekt teilnehmen?',
'page': 'Bitte gib einen Link zur Projektseite in den Wikimedia-Projekten an, wenn vorhanden.',
'group': 'Sofern zutreffend: Bitte gib an, welche Personen das Projekt gemeinsam mit dir organisieren.',
'location': 'Sofern zutreffend: Bitte gib hier den Ort an, an welchem das Projekt stattfinden wird.',
'cost': 'Wie hoch werden die Projektkosten voraussichtlich sein? Bitte gib diese auf volle Euro gerundet an.',
'insurance': format_html(
'Möchtest du die <a href="https://de.wikipedia.org/wiki/Wikipedia:F%C3%B6rderung/Versicherung"> Unfall- und Haftpflichtversicherung</a> von Wikimedia Deutschland in Anspruch nehmen?'),
'notes': format_html(
'Falls du noch weitere Informationen hast, teile sie gern an dieser Stelle mit uns. Für umfangreichere Informationen, schreibe uns eine E-Mail an <a href="mailto:community@wikimedia.de">community@wikimedia.de</a>.'),
}
# Persist multi-selects as Python lists so JSONField stores a JSON array
def clean_categories(self):
return list(self.cleaned_data.get('categories', []))
def clean_wikimedia_projects(self):
return list(self.cleaned_data.get('wikimedia_projects', []))
class ProjectRequestAdminForm(forms.ModelForm):
"""
Admin form for ProjectRequest.
Key points:
- Same checkbox multi-selects for JSON-backed fields to improve admin UX.
- Keep fields="__all__" so admin users can inspect/set workflow fields if needed.
- Do NOT add extra business logic here; validation lives in the model's clean().
"""
categories = forms.MultipleChoiceField(
choices=[(c, c) for c in PROJECT_CATEGORIES],
widget=forms.CheckboxSelectMultiple,
label='Projektkategorie'
)
wikimedia_projects = forms.MultipleChoiceField(
choices=[(w, w) for w in WIKIMEDIA_CHOICES],
widget=forms.CheckboxSelectMultiple,
label='Wikimedia Projekt(e)'
)
class Meta:
model = ProjectRequest
fields = "__all__"
# Make longer texts easier to edit in the admin UI
widgets = {
'description': forms.Textarea(attrs={'rows': 5}),
'notes': forms.Textarea(attrs={'rows': 6}),
}
# Ensure JSONField receives a list
def clean_categories(self):
return list(self.cleaned_data.get('categories', []))
def clean_wikimedia_projects(self):
return list(self.cleaned_data.get('wikimedia_projects', []))

View File

@ -0,0 +1,64 @@
# Generated by Django 5.2.5 on 2025-09-24 16:58
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('input', '0098_add_eliterature_and_software_proxies'),
]
operations = [
migrations.CreateModel(
name='ProjectRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('realname', models.CharField(default='', help_text='Bitte gib deinen Vornamen und deinen Nachnamen ein.', max_length=200, null=True, verbose_name='Realname')),
('email', models.EmailField(help_text='Bitte gib deine E-Mail-Adresse ein, damit dich<br>Wikimedia Deutschland bei Rückfragen oder für<br>die Zusage kontaktieren kann.', max_length=200, null=True, verbose_name='E-Mail-Adresse')),
('granted', models.BooleanField(null=True, verbose_name='bewilligt')),
('granted_date', models.DateField(null=True, verbose_name='bewilligt am')),
('survey_mail_date', models.DateField(blank=True, null=True, verbose_name='Umfragemail wurde verschickt am')),
('mail_state', models.CharField(choices=[('NONE', 'noch keine Mail versendet'), ('INF', 'die Benachrichtigung zur Projektabschlussmail wurde versendet'), ('CLOSE', 'die Projektabschlussmail wurde versendet'), ('END', 'alle automatischen Mails, auch surveyMail, wurden versendet')], default='NONE', max_length=6)),
('survey_mail_send', models.BooleanField(default=False, verbose_name='Keine Umfragemail schicken')),
('name', models.CharField(max_length=200, verbose_name='Name des Projekts')),
('description', models.TextField(max_length=500, verbose_name='Kurzbeschreibung des Projekts')),
('categories', models.JSONField(default=list, verbose_name='Projektkategorie')),
('categories_other', models.CharField(blank=True, max_length=200, null=True, verbose_name='Projektkategorie: Sonstiges (kurz)')),
('wikimedia_projects', models.JSONField(default=list, verbose_name='Wikimedia Projekt(e)')),
('wikimedia_other', models.CharField(blank=True, max_length=200, null=True, verbose_name='Wikimedia-Projekt: Anderes (kurz)')),
('start', models.DateField(verbose_name='Startdatum')),
('end', models.DateField(verbose_name='Erwartetes Projektende')),
('participants_estimated', models.IntegerField(validators=[django.core.validators.MinValueValidator(0)], verbose_name='Teilnehmende angefragt')),
('page', models.URLField(blank=True, max_length=2000, null=True, verbose_name='Link zur Projektseite')),
('group', models.CharField(blank=True, max_length=2000, null=True, verbose_name='Mitorganisierende')),
('location', models.CharField(blank=True, max_length=2000, null=True, verbose_name='Ort/Adresse/Location')),
('cost', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Kosten (EUR, ganzzahlig)')),
('insurance', models.BooleanField(default=False, verbose_name='Haftpflichtversicherung gewünscht?')),
('notes', models.TextField(blank=True, max_length=2000, null=True, verbose_name='Anmerkungen')),
('decision', models.CharField(choices=[('OPEN', 'offen'), ('APPROVED', 'bewilligt'), ('DECLINED', 'abgelehnt')], default='OPEN', max_length=10)),
('decision_date', models.DateField(blank=True, null=True)),
('decided_by', models.CharField(blank=True, max_length=100, null=True, verbose_name='Entschieden von')),
],
options={
'verbose_name': 'Projektförderungs-Antrag (< 1000 EUR)',
'verbose_name_plural': 'Projects_requested',
},
),
migrations.CreateModel(
name='ProjectsDeclined',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('original_request_id', models.IntegerField()),
('name', models.CharField(max_length=200)),
('realname', models.CharField(max_length=200)),
('email', models.EmailField(max_length=254)),
('decision_date', models.DateField()),
('reason', models.TextField(blank=True, null=True)),
],
options={
'verbose_name_plural': 'Projects_declined',
},
),
]

View File

@ -0,0 +1,57 @@
# Generated by Django 5.2.5 on 2025-09-28 22:27
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('input', '0099_projectrequest_projectsdeclined'),
]
operations = [
migrations.AlterModelOptions(
name='projectrequest',
options={'ordering': ('-id',), 'verbose_name': 'Projektförderungs-Antrag (< 1000 EUR)', 'verbose_name_plural': 'Projects_requested'},
),
migrations.AlterModelOptions(
name='projectsdeclined',
options={'ordering': ('-decision_date', '-id'), 'verbose_name_plural': 'Projects_declined'},
),
migrations.AlterField(
model_name='projectrequest',
name='cost',
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Höhe der Projektkosten'),
),
migrations.AlterField(
model_name='projectrequest',
name='decision',
field=models.CharField(choices=[('OPEN', 'offen'), ('APPROVED', 'bewilligt'), ('DECLINED', 'abgelehnt')], db_index=True, default='OPEN', max_length=10),
),
migrations.AlterField(
model_name='projectrequest',
name='decision_date',
field=models.DateField(blank=True, db_index=True, null=True),
),
migrations.AlterField(
model_name='projectrequest',
name='insurance',
field=models.BooleanField(default=False, verbose_name='Versicherung gewünscht?'),
),
migrations.AlterField(
model_name='projectrequest',
name='location',
field=models.CharField(blank=True, max_length=2000, null=True, verbose_name='Ort'),
),
migrations.AlterField(
model_name='projectrequest',
name='participants_estimated',
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)], verbose_name='Zahl der Teilnehmenden'),
),
migrations.AlterField(
model_name='projectsdeclined',
name='original_request_id',
field=models.PositiveIntegerField(),
),
]

View File

@ -3,6 +3,8 @@ from datetime import date
from django.db import models
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
EMAIL_STATES = {'NONE': 'noch keine Mail versendet',
'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet',
@ -20,8 +22,8 @@ class TermsConsentMixin(models.Model):
class Volunteer(models.Model):
realname = models.CharField(max_length=200, null=True, verbose_name="Realname",
help_text="Bitte gib deinen Vornamen und deinen Nachnamen ein.", default='')
realname = models.CharField(max_length=200, null=True, verbose_name='Realname',
help_text='Bitte gib deinen Vornamen und deinen Nachnamen ein.', default='')
email = models.EmailField(max_length=200, null=True, verbose_name='E-Mail-Adresse',
help_text=mark_safe('Bitte gib deine E-Mail-Adresse ein, damit dich<br>Wikimedia Deutschland bei Rückfragen oder für<br>die Zusage kontaktieren kann.'))
@ -50,7 +52,7 @@ class Extern(Volunteer):
''' abstract basis class for all data entered by extern volunteers '''
username = models.CharField(max_length=200, null=True, verbose_name='Benutzer_innenname',
help_text=mark_safe("Wikimedia Benutzer_innenname"))
help_text=mark_safe('Wikimedia Benutzer_innenname'))
# the following Fields are not supposed to be edited by users
service_id = models.CharField(max_length=15, null=True, blank=True)
@ -70,25 +72,25 @@ class ConcreteExtern(Extern):
pass
class Account(models.Model):
code = models.CharField('Kostenstelle', max_length=5, default="DEF",
code = models.CharField('Kostenstelle', max_length=5, default='DEF',
null=False, primary_key = True)
description = models.CharField('Beschreibung', max_length=60, default='NO DESCRIPTION')
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')
def __str__(self):
return f"{self.code} {self.description}"
return f'{self.code} {self.description}'
class Project(Volunteer):
end_mail_send = models.BooleanField(default=False, verbose_name='Keine Projektabschlussmail schicken')
name = models.CharField(max_length=200, verbose_name='Name des Projekts')
description = models.CharField(max_length=500, verbose_name="Kurzbeschreibung", null=True)
description = models.CharField(max_length=500, verbose_name='Kurzbeschreibung', null=True)
start = models.DateField('Startdatum', null=True)
end = models.DateField('Erwartetes Projektende', null=True)
otrs = models.URLField(max_length=300, null=True, verbose_name='OTRS-Link')
plan = models.URLField(max_length=2000, null=True, blank=True, verbose_name="Link zum Förderplan")
page = models.URLField(max_length=2000, null=True, blank=True, verbose_name="Link zur Projektseite")
urls = models.CharField(max_length=2000, null=True, blank=True, verbose_name="Weitere Links")
group = models.CharField(max_length=2000, null=True, blank=True, verbose_name="Mitorganisierende")
location = models.CharField(max_length=2000, null=True, blank=True, verbose_name="Ort/Adresse/Location")
plan = models.URLField(max_length=2000, null=True, blank=True, verbose_name='Link zum Förderplan')
page = models.URLField(max_length=2000, null=True, blank=True, verbose_name='Link zur Projektseite')
urls = models.CharField(max_length=2000, null=True, blank=True, verbose_name='Weitere Links')
group = models.CharField(max_length=2000, null=True, blank=True, verbose_name='Mitorganisierende')
location = models.CharField(max_length=2000, null=True, blank=True, verbose_name='Ort/Adresse/Location')
participants_estimated = models.IntegerField(blank=True, null=True, verbose_name='Teilnehmende angefragt')
participants_real = models.IntegerField(blank=True, null=True, verbose_name='Teilnehmende ausgezählt')
insurance = models.BooleanField(default=False, verbose_name='Haftpflichtversicherung')
@ -98,7 +100,7 @@ class Project(Volunteer):
account = models.ForeignKey('Account', on_delete=models.CASCADE, null=True, to_field='code', db_constraint = False)
granted_from = models.CharField(max_length=100,null=True,verbose_name='Bewilligt von')
notes = models.TextField(max_length=1000,null=True,blank=True,verbose_name='Anmerkungen')
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')
# the following Fields are not supposed to be edited by users
@ -106,7 +108,7 @@ class Project(Volunteer):
status = models.CharField(max_length=3,choices=(('RUN', 'läuft'),('END','beendet'),('NOT','nicht stattgefunden')),default='RUN')
finance_id = models.CharField(max_length=15, null= True, blank=True)
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):
@ -118,7 +120,7 @@ class Project(Volunteer):
# but maybe there is a better solution?
if not self.pk:
print ("NO PK THERE");
print ('NO PK THERE');
generate_finance_id=True
super().save()
else:
@ -136,7 +138,7 @@ class Project(Volunteer):
if generate_finance_id:
print ("MUST GENERATE FINANCE ID")
print ('MUST GENERATE FINANCE ID')
year = self.start.year
projects = Project.objects.filter(start__year=year)
if not projects:
@ -144,7 +146,7 @@ class Project(Volunteer):
#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]
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)
@ -155,12 +157,12 @@ class Project(Volunteer):
else:
self.finance_id = str(self.account.code)
# print (("Current PID",self.pid))
# 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))
print (('Hallo Leute! Ich save jetzt mal MIT PID DANN!!!',self.pid))
if self.end:
self.end_quartal = f'Q{self.end.month // 4 + 1}'
@ -169,7 +171,7 @@ class Project(Volunteer):
def __str__(self):
return f"{self.pid} {self.name}"
return f'{self.pid} {self.name}'
class Intern(Volunteer):
@ -188,7 +190,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 'Certificate for ' + self.realname
TRANSPORT_CHOICES = {'BAHN': 'Bahn',
@ -210,7 +212,7 @@ class Travel(Extern):
project_name = models.CharField(max_length=50, null=True, blank=True, verbose_name='Projektname:')
transport = models.CharField(max_length=5, choices=TRANSPORT_CHOICES.items(), default='BAHN', verbose_name='Transportmittel:')
other_transport = models.CharField(max_length=200, null=True, blank=True, verbose_name='Sonstige Transportmittel (mit Begründung)')
travelcost = models.CharField(max_length=10, default="0", verbose_name='Fahrtkosten')
travelcost = models.CharField(max_length=10, default='0', verbose_name='Fahrtkosten')
checkin = models.DateField(blank=True, null=True, verbose_name='Anreise')
checkout = models.DateField(blank=True, null=True, verbose_name='Abreise')
payed_for_hotel_by = models.CharField(max_length=4, choices=PAYEDBY_CHOICES.items(), blank=True, null=True, verbose_name='Kostenauslage Hotel durch')
@ -221,12 +223,12 @@ class Travel(Extern):
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
project_end = models.DateField(blank=True, null=True, verbose_name='Projektende')
# use content type model to get the end date for the project foreign key
project_end_quartal = models.CharField(max_length=15, null=True, blank=True, verbose_name="Quartal Projekt Ende")
project_end_quartal = models.CharField(max_length=15, null=True, blank=True, verbose_name='Quartal Projekt Ende')
from django.db.models.signals import pre_save
from django.dispatch import receiver
@receiver(pre_save, sender=Travel, dispatch_uid="get_project_end")
@receiver(pre_save, sender=Travel, dispatch_uid='get_project_end')
def getProjectEnd(sender, instance, **kwargs):
#instance.project_end = instance.project.end
@ -246,9 +248,9 @@ def getProjectEnd(sender, instance, **kwargs):
#abstract base class for Library and IFG
class Grant(Extern):
cost = models.CharField(max_length=10, verbose_name='Kosten',
help_text="Bitte gib die ungefähr zu erwartenden Kosten in Euro an.")
help_text='Bitte gib die ungefähr zu erwartenden Kosten in Euro an.')
notes = models.TextField(max_length=1000, blank=True, verbose_name='Anmerkungen',
help_text="Bitte gib an wofür Du das Stipendium verwenden willst.")
help_text='Bitte gib an wofür Du das Stipendium verwenden willst.')
class Meta:
abstract = True
@ -271,6 +273,7 @@ TYPE_LIST = 'LIST'
TYPE_TRAV = 'TRAV'
TYPE_SOFT = 'SOFT'
TYPE_VIS = 'VIS'
TYPE_PROJ_LT_1000 = 'PROJ_LT_1000'
TYPE_CHOICES = {
TYPE_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'),
@ -282,6 +285,7 @@ TYPE_CHOICES = {
TYPE_TRAV: type_link('Reisekostenerstattungen', 'Reisekosten'),
TYPE_SOFT: type_link('Software-Stipendien', 'Softwarestipendium'),
TYPE_VIS: type_link('E-Mail-Adressen_und_Visitenkarten#Visitenkarten', 'Visitenkarten'),
TYPE_PROJ_LT_1000: type_link('Projektplanung', 'Projektförderung unter 1000 EUR'),
}
LIBRARY_TYPES = TYPE_BIB, TYPE_ELIT, TYPE_SOFT
@ -296,8 +300,8 @@ class Library(Grant):
type = models.CharField(max_length=4, choices=LIBRARY_TYPE_CHOICES, default=TYPE_BIB)
library = models.CharField(max_length=200)
duration = models.CharField(max_length=100, verbose_name="Dauer")
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name="interne Anmerkungen")
duration = models.CharField(max_length=100, verbose_name='Dauer')
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
def __str__(self):
return self.library
@ -335,25 +339,25 @@ SELFBUY_CHOICES = {'TRUE': mark_safe('Ich möchte das Werk selbst kaufen und per
class Literature(TermsConsentMixin, Grant):
info = models.CharField(max_length=500, verbose_name='Informationen zum Werk',
help_text=mark_safe("Bitte gib alle Informationen zum benötigten Werk an,<br>\
die eine eindeutige Identifizierung ermöglichen (Autor, Titel, Verlag, ISBN, ...)"))
help_text=mark_safe('Bitte gib alle Informationen zum benötigten Werk an,<br>\
die eine eindeutige Identifizierung ermöglichen (Autor, Titel, Verlag, ISBN, ...)'))
source = models.CharField(max_length=200, verbose_name='Bezugsquelle',
help_text="Bitte gib an, wo du das Werk kaufen möchtest.")
help_text='Bitte gib an, wo du das Werk kaufen möchtest.')
selfbuy = models.CharField( max_length=10, verbose_name='Selbstkauf?', choices=SELFBUY_CHOICES.items(), default='TRUE')
selfbuy_give_data = models.BooleanField(verbose_name=mark_safe('Datenweitergabe erlauben'), help_text=mark_safe('Ich stimme der Weitergabe meiner Daten (Name, Postadresse) an den von mir angegebenen Anbieter/Dienstleister zu.'))
selfbuy_data = models.TextField(max_length=1000, verbose_name='Persönliche Daten sowie Adresse', default='',\
help_text=mark_safe("Bitte gib hier alle persönlichen Daten an, die wir benötigen, um das Werk<br>\
help_text=mark_safe('Bitte gib hier alle persönlichen Daten an, die wir benötigen, um das Werk<br>\
für dich zu kaufen und es dir anschließend zu schicken (z.B. Vorname Nachname, Anschrift, <br>\
Telefonnummer, E-Mail-Adresse usw.). Trenne die einzelnen Angaben durch Zeilenumbrüche."))
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name="interne Anmerkungen")
Telefonnummer, E-Mail-Adresse usw.). Trenne die einzelnen Angaben durch Zeilenumbrüche.'))
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
class IFG(Grant):
url = models.URLField(max_length=2000, verbose_name="URL",
help_text="Bitte gib den Link zu deiner Anfrage bei Frag den Staat an.")
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name="interne Anmerkungen")
url = models.URLField(max_length=2000, verbose_name='URL',
help_text='Bitte gib den Link zu deiner Anfrage bei Frag den Staat an.')
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
def __str__(self):
return "IFG-Anfrage von " + self.realname
return 'IFG-Anfrage von ' + self.realname
DOMAIN_CHOICES = {'PEDIA': '@wikipedia.de',
'BOOKS': '@wikibooks.de',
@ -381,17 +385,17 @@ class Email(TermsConsentMixin, Domain):
address = models.CharField(max_length=50,
choices=MAIL_CHOICES.items(),
default='USERNAME', verbose_name='Adressbestandteil',
help_text=mark_safe("Bitte gib hier den gewünschten Adressbestandteil an,<br>der sich vor der Domain befinden soll."))
help_text=mark_safe('Bitte gib hier den gewünschten Adressbestandteil an,<br>der sich vor der Domain befinden soll.'))
other = models.CharField(max_length=50,blank=True,null=True, verbose_name="Sonstiges")
other = models.CharField(max_length=50,blank=True,null=True, verbose_name='Sonstiges')
adult = models.CharField( max_length=10, verbose_name='Volljährigkeit', choices=ADULT_CHOICES.items(), default='FALSE')
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 List(TermsConsentMixin, Domain):
address = models.CharField(max_length=50, default='NO_ADDRESS',
verbose_name="Adressbestandteil für Projektmailingliste",
help_text=mark_safe("Bitte gib hier den gewünschten Adressbestandteil an,<br>der sich vor der Domain befinden soll."))
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name="interne Anmerkungen")
verbose_name='Adressbestandteil für Projektmailingliste',
help_text=mark_safe('Bitte gib hier den gewünschten Adressbestandteil an,<br>der sich vor der Domain befinden soll.'))
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
PROJECT_CHOICE = {'PEDIA': 'Wikipedia',
'SOURCE': 'Wikisource',
@ -412,24 +416,142 @@ class BusinessCard(TermsConsentMixin, Extern):
help_text='Für welches Wikimedia-Projekt möchtest Du Visitenkarten?')
data = models.TextField(max_length=1000, verbose_name='Persönliche Daten für die Visitenkarten', default='',
help_text=mark_safe("Bitte gib hier alle persönlichen Daten an, und zwar genau so,<br>\
help_text=mark_safe('Bitte gib hier alle persönlichen Daten an, und zwar genau so,<br>\
wie sie (auch in der entsprechenden Reihenfolge) auf den Visitenkarten stehen sollen<br>\
(z.B. Vorname Nachname, Benutzer:/Benutzerin:, Benutzer-/-innenname, Anschrift,<br>\
Telefonnummer, E-Mail-Adresse usw.). Trenne die einzelnen Angaben durch Zeilenumbrüche.<br>\
Hinweis: Telefonnummern bilden wir üblicherweise im internationalen Format gemäß<br>\
DIN 5008 ab. Als anzugebende E-Mail-Adresse empfehlen wir dir eine Wikimedia-Projekt-<br>\
Adresse, die du ebenfalls beantragen kannst, sofern du nicht bereits eine besitzt."))
Adresse, die du ebenfalls beantragen kannst, sofern du nicht bereits eine besitzt.'))
variant = models.CharField(max_length=5, choices=BC_VARIANT.items(),
default='NOPIC', verbose_name='Variante',
help_text=mark_safe('so sehen die Varianten aus: <a href="https://upload.wikimedia.org/wikipedia/commons/c/cd/Muster_Visitenkarten_WMDE_2018.jpg">\
mit Bild</a> <a href="https://upload.wikimedia.org/wikipedia/commons/d/d3/Muster_Visitenkarte_WMDE.png">ohne Bild</a>' ))
url_of_pic = models.CharField(max_length=200, verbose_name='Url des Bildes', default='', help_text="Bitte gib die Wikimedia-Commons-URL des Bildes an.")
url_of_pic = models.CharField(max_length=200, verbose_name='Url des Bildes', default='', help_text='Bitte gib die Wikimedia-Commons-URL des Bildes an.')
sent_to = models.TextField(max_length=1000, verbose_name='Versandadresse',
default='', help_text="Bitte gib den Namen und die vollständige Adresse ein, an welche die Visitenkarten geschickt werden sollen.")
default='', help_text='Bitte gib den Namen und die vollständige Adresse ein, an welche die Visitenkarten geschickt werden sollen.')
send_data_to_print = models.BooleanField(default=False, verbose_name=mark_safe('Datenweitergabe erlauben'), help_text=mark_safe('Hiermit erlaube ich die Weitergabe meiner Daten (Name, Postadresse) an den von Wikimedia<br> Deutschland ausgewählten Dienstleister (z. B. <a href="wir-machen-druck.de">wir-machen-druck.de</a>) zum Zwecke des direkten <br> Versands der Druckerzeugnisse an mich.'))
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')
PROJECT_CATEGORIES = [
'Erstellung und Weiterentwicklung von Inhalten für die Wikimedia-Projekte',
'Aufklärung über die Wikimedia-Projekte',
'Formate zur Ansprache, Gewinnung und Bindung von Ehrenamtlichen für die Wikimedia-Projekte',
'Beteiligung von Menschen, die einen erschwerten Zugang zum Engagement in den Wikimedia-Projekten haben',
'Vernetzung und Austausch innerhalb der Communitys oder zwischen den Communitys und externen Partner*innen',
'Vermittlung von Kompetenzen, die die ehrenamtliche Arbeit stärken',
'Stärkung einer respektvollen, konstruktiven Kommunikationskultur und der Wertschätzung in den Wikimedia-Projekten',
'Verbesserung der Selbstorganisation in Bezug auf interne Regeln, Strukturen und Prozesse der Wikimedia-Projektcommunitys',
'Ehrenamtliche Aktivitäten, die der Erstellung, Pflege und Weiterentwicklung von Tools oder sonstigen technischen Verbesserungen dienen',
'Sonstiges'
]
WIKIMEDIA_CHOICES = ['Wikipedia', 'Wikimedia Commons', 'Wikidata', 'Anderes']
class Decision(models.TextChoices):
OPEN = 'OPEN', 'offen'
APPROVED = 'APPROVED', 'bewilligt'
DECLINED = 'DECLINED', 'abgelehnt'
# Application for project funding < 1000 EUR
class ProjectRequest(Volunteer):
name = models.CharField(max_length=200, verbose_name='Name des Projekts')
description = models.TextField(max_length=500, verbose_name='Kurzbeschreibung des Projekts')
# Multi-select: stored pragmatically as JSON
categories = models.JSONField(default=list, verbose_name='Projektkategorie')
categories_other = models.CharField(max_length=200, null=True, blank=True,
verbose_name='Projektkategorie: Sonstiges (kurz)')
wikimedia_projects = models.JSONField(default=list, verbose_name='Wikimedia Projekt(e)')
wikimedia_other = models.CharField(max_length=200, null=True, blank=True,
verbose_name='Wikimedia-Projekt: Anderes (kurz)'
)
start = models.DateField('Startdatum')
end = models.DateField('Erwartetes Projektende')
participants_estimated = models.IntegerField(verbose_name='Zahl der Teilnehmenden',
validators=[MinValueValidator(0)])
page = models.URLField(max_length=2000, null=True, blank=True, verbose_name='Link zur Projektseite')
group = models.CharField(max_length=2000, null=True, blank=True, verbose_name='Mitorganisierende')
location = models.CharField(max_length=2000, null=True, blank=True, verbose_name='Ort')
cost = models.IntegerField(verbose_name='Höhe der Projektkosten',
validators=[MinValueValidator(0), MaxValueValidator(1000)])
insurance = models.BooleanField(default=False, verbose_name='Versicherung gewünscht?')
notes = models.TextField(max_length=2000, null=True, blank=True, verbose_name='Anmerkungen')
# Workflow fields (used only for the request process)
decision = models.CharField(
max_length=10, choices=Decision.choices, default=Decision.OPEN, db_index=True
)
decision_date = models.DateField(null=True, blank=True, db_index=True)
decided_by = models.CharField(max_length=100, null=True, blank=True, verbose_name='Entschieden von')
class Meta:
verbose_name = 'Projektförderungs-Antrag (< 1000 EUR)'
verbose_name_plural = 'Projects_requested'
ordering = ('-id',)
def __str__(self):
return f'[Antrag] {self.name} ({self.realname})'
def clean(self):
super().clean()
# 1) Additional guard if MaxValueValidator is removed
if self.cost is not None and self.cost > 1000:
raise ValidationError({
'cost': ('Bitte beachte, dass für Projektkosten über 1.000 EUR '
'ein öffentlicher Projektplan erforderlich ist '
'(siehe Wikipedia:Förderung/Projektplanung).')
})
# 2) Required and allowed values
if not self.categories:
raise ValidationError({'categories': 'Bitte wähle mindestens eine Projektkategorie.'})
unknown = set(self.categories) - set(PROJECT_CATEGORIES)
if unknown:
raise ValidationError({'categories': f'Unzulässige Kategorie(n): {", ".join(unknown)}'})
if not self.wikimedia_projects:
raise ValidationError({'wikimedia_projects': 'Bitte wähle mindestens ein Wikimedia-Projekt.'})
unknown_w = set(self.wikimedia_projects) - set(WIKIMEDIA_CHOICES)
if unknown_w:
raise ValidationError({'wikimedia_projects': f'Ungültige Auswahl: {", ".join(unknown_w)}'})
# 3) Require short text for “Sonstiges/Anderes”
if 'Sonstiges' in self.categories and not (self.categories_other and self.categories_other.strip()):
raise ValidationError({'categories_other': 'Bitte kurz beschreiben (Sonstiges).'})
if 'Anderes' in self.wikimedia_projects and not (self.wikimedia_other and self.wikimedia_other.strip()):
raise ValidationError({'wikimedia_other': 'Bitte kurz angeben (Anderes).'})
# 4) Date consistency
if self.start and self.end and self.end < self.start:
raise ValidationError({'end': 'Erwartetes Projektende darf nicht vor dem Startdatum liegen.'})
# Archive table for declined applications (no PID/Finance-ID)
class ProjectsDeclined(models.Model):
original_request_id = models.PositiveIntegerField()
name = models.CharField(max_length=200)
realname = models.CharField(max_length=200)
email = models.EmailField()
decision_date = models.DateField()
reason = models.TextField(null=True, blank=True)
class Meta:
verbose_name_plural = 'Projects_declined'
ordering = ('-decision_date', '-id')
def __str__(self):
return f'[Abgelehnt] {self.name} {self.decision_date}'
MODELS = {

75
input/services.py Normal file
View File

@ -0,0 +1,75 @@
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

@ -10,11 +10,11 @@
<strong>Projektförderung</strong>
<ul>
<li>
<a href="#">Projektförderung</a>
<a href="{% url 'projektfoerderung-unter-1000' %}">Projektförderung</a>
mit einer Gesamtsumme unter 1.000,— EUR
</li>
<li>
<a href="{% url 'info-foerderprojekt-ab-1000' %}">Projektförderung</a>
<a href="{% url 'extern' type='projektfoerderung-unter-1000' %}">Projektförderung</a>
mit einer Gesamtsumme ab 1.000,— EUR
</li>
</ul>

View File

@ -23,6 +23,7 @@ from .forms import (
EmailForm,
ListForm,
BusinessCardForm,
ProjectRequestForm,
)
from .models import (
MODELS,
@ -34,6 +35,7 @@ from .models import (
TYPE_LIT,
TYPE_LIST,
TYPE_MAIL,
TYPE_PROJ_LT_1000,
TYPE_SOFT,
TYPE_TRAV,
TYPE_VIS,
@ -88,6 +90,7 @@ TYPES = [
ApplicationType(TYPE_TRAV, 'reisekosten', TravelForm),
ApplicationType(TYPE_SOFT, 'softwarestipendium', SoftwareForm),
ApplicationType(TYPE_VIS, 'visitenkarten', BusinessCardForm),
ApplicationType(TYPE_PROJ_LT_1000, 'projektfoerderung-unter-1000', ProjectRequestForm),
]