diff --git a/input/admin.py b/input/admin.py
index 6c8b0a8..3f0c76d 100755
--- a/input/admin.py
+++ b/input/admin.py
@@ -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([
diff --git a/input/forms.py b/input/forms.py
index ac50446..4679d37 100755
--- a/input/forms.py
+++ b/input/forms.py
@@ -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 Unfall- und Haftpflichtversicherung 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 community@wikimedia.de.'),
+ }
+
+ # 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', []))
diff --git a/input/migrations/0099_projectrequest_projectsdeclined.py b/input/migrations/0099_projectrequest_projectsdeclined.py
new file mode 100644
index 0000000..016b043
--- /dev/null
+++ b/input/migrations/0099_projectrequest_projectsdeclined.py
@@ -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
Wikimedia Deutschland bei Rückfragen oder für
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',
+ },
+ ),
+ ]
diff --git a/input/migrations/0100_alter_projectrequest_options_and_more.py b/input/migrations/0100_alter_projectrequest_options_and_more.py
new file mode 100644
index 0000000..cc07c49
--- /dev/null
+++ b/input/migrations/0100_alter_projectrequest_options_and_more.py
@@ -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(),
+ ),
+ ]
diff --git a/input/models.py b/input/models.py
index 3c8b91c..4cbc556 100755
--- a/input/models.py
+++ b/input/models.py
@@ -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
Wikimedia Deutschland bei Rückfragen oder für
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,
\
- die eine eindeutige Identifizierung ermöglichen (Autor, Titel, Verlag, ISBN, ...)"))
+ help_text=mark_safe('Bitte gib alle Informationen zum benötigten Werk an,
\
+ 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
\
+ help_text=mark_safe('Bitte gib hier alle persönlichen Daten an, die wir benötigen, um das Werk
\
für dich zu kaufen und es dir anschließend zu schicken (z.B. Vorname Nachname, Anschrift,
\
- 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,
der sich vor der Domain befinden soll."))
+ help_text=mark_safe('Bitte gib hier den gewünschten Adressbestandteil an,
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,
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,
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,
\
+ help_text=mark_safe('Bitte gib hier alle persönlichen Daten an, und zwar genau so,
\
wie sie (auch in der entsprechenden Reihenfolge) auf den Visitenkarten stehen sollen
\
(z.B. Vorname Nachname, Benutzer:/Benutzerin:, Benutzer-/-innenname, Anschrift,
\
Telefonnummer, E-Mail-Adresse usw.). Trenne die einzelnen Angaben durch Zeilenumbrüche.
\
Hinweis: Telefonnummern bilden wir üblicherweise im internationalen Format gemäß
\
DIN 5008 ab. Als anzugebende E-Mail-Adresse empfehlen wir dir eine Wikimedia-Projekt-
\
- 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: \
mit Bild ohne Bild' ))
- 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
Deutschland ausgewählten Dienstleister (z. B. wir-machen-druck.de) zum Zwecke des direkten
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 = {
diff --git a/input/services.py b/input/services.py
new file mode 100644
index 0000000..b6b9c6d
--- /dev/null
+++ b/input/services.py
@@ -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()
\ No newline at end of file
diff --git a/input/templates/input/forms/extern.html b/input/templates/input/forms/extern.html
index a2c0385..7ccc443 100755
--- a/input/templates/input/forms/extern.html
+++ b/input/templates/input/forms/extern.html
@@ -10,11 +10,11 @@
Projektförderung