diff --git a/input/admin.py b/input/admin.py
index 3f0c76d..cae46e4 100755
--- a/input/admin.py
+++ b/input/admin.py
@@ -2,18 +2,16 @@ 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 .forms import BaseProjectForm
from .models import (
Account,
Project,
+ ProjectCategory,
+ WikimediaProject,
HonoraryCertificate,
Library,
ELiterature,
@@ -46,21 +44,89 @@ export_as_csv.short_description = "Ausgewähltes zu CSV exportieren"
admin.site.add_action(export_as_csv)
+
+@admin.register(ProjectCategory, WikimediaProject)
+class ProjectCategoryAdmin(admin.ModelAdmin):
+ list_display = ['name', 'order', 'project_count']
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).annotate(
+ project_count=models.Count('projects'),
+ )
+
+ @admin.display(description='# Projekte', ordering='project_count')
+ def project_count(self, obj):
+ return obj.project_count
+
+
+class ProjectAdminForm(BaseProjectForm):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ for field, model in self.categories.items():
+ if self.initial[f'{field}_other']:
+ self.initial[field] = [*self.initial[field], model.other]
+
+
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
- save_as = True
+ form = ProjectAdminForm
search_fields = ('name', 'pid','finance_id', 'realname', 'start', 'end', 'participants_estimated', 'participants_real', 'cost', 'status', 'end_quartal')
list_display = ('name', 'pid','finance_id', 'realname', 'start', 'end', 'participants_estimated', 'participants_real', 'cost', 'status', 'end_quartal')
- fields = ('realname', 'email', 'granted', 'granted_date', 'mail_state', 'end_mail_send', 'survey_mail_send', 'survey_mail_date', 'name', 'description', 'pid', 'finance_id', 'start', 'end', 'otrs', 'plan', 'page', 'urls', 'group', 'location', 'participants_estimated', 'participants_real', 'insurance', 'insurance_technic', 'support', 'cost', 'account', 'granted_from', 'notes', 'intern_notes', 'status', 'project_of_year', 'end_quartal')
- # action = ['export_as_csv']
date_hierarchy = 'end'
readonly_fields = ('end_quartal', 'project_of_year', 'pid', 'finance_id')
+ fieldsets = [
+ ('Kontakt', {'fields': (
+ 'realname',
+ 'email',
+ )}),
+ ('Projekt', {'fields': (
+ 'name',
+ 'description',
+ 'start',
+ 'end',
+ 'otrs',
+ 'plan',
+ 'page',
+ 'urls',
+ 'group',
+ 'location',
+ 'participants_estimated',
+ 'participants_real',
+ 'insurance',
+ 'insurance_technic',
+ 'support',
+ 'cost',
+ 'categories',
+ 'categories_other',
+ 'wikimedia_projects',
+ 'wikimedia_projects_other',
+ 'notes',
+ )}),
+ ('Mailing', {'fields': (
+ 'mail_state',
+ 'end_mail_send',
+ 'survey_mail_send',
+ 'survey_mail_date',
+ )}),
+ ('Bewilligung', {'fields': (
+ 'granted',
+ 'granted_date',
+ 'granted_from',
+ 'intern_notes',
+ )}),
+ ('Accounting', {'fields': (
+ 'account',
+ 'status',
+ *readonly_fields,
+ )}),
+ ]
class Media:
js = ('dropdown/js/otrs_link.js',)
-
@admin.register(BusinessCard)
class BusinessCardAdmin(admin.ModelAdmin):
save_as = True
@@ -176,145 +242,3 @@ class ApproveActionForm(ActionForm):
"""
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([
-# Account,
-# HonoraryCertificate,
-# Library,
-# IFG,
-# Travel,
-# Email,
-# List,
-# ])
diff --git a/input/forms.py b/input/forms.py
index c6074fb..6733f5f 100755
--- a/input/forms.py
+++ b/input/forms.py
@@ -6,11 +6,11 @@ from django.forms.renderers import DjangoTemplates
from django.utils.html import format_html
from django.utils.safestring import mark_safe
-from .models import ProjectRequest, PROJECT_CATEGORIES, WIKIMEDIA_CHOICES
-
from .models import (
TYPE_CHOICES,
Project,
+ ProjectCategory,
+ WikimediaProject,
ConcreteVolunteer,
ConcreteExtern,
IFG,
@@ -35,20 +35,6 @@ class FdbForm(ModelForm):
required_css_class = 'required'
-class ProjectForm(FdbForm):
- # start = DateField(widget=AdminDateWidget())
-
- class Meta:
- model = Project
- exclude = ('pid', 'project_of_year', 'finance_id', 'granted', 'granted_date', 'realname', 'email', \
- 'end_mail_send', 'status', 'persons', 'survey_mail_date', 'mail_state')
- widgets = {'start': AdminDateWidget(),
- 'end': AdminDateWidget(), }
-
- class Media:
- js = ('dropdown/js/otrs_link.js',)
-
-
class CommonOrderMixin(forms.Form):
"""
Ensures a consistent field order for all forms that inherit from this mixin.
@@ -145,6 +131,81 @@ class BaseApplicationForm(FdbForm):
settings.DATAPROTECTION, settings.FOERDERRICHTLINIEN))
+class BaseProjectForm(ModelForm):
+ categories = {
+ 'categories': ProjectCategory,
+ 'wikimedia_projects': WikimediaProject,
+ }
+
+ class Media:
+ js = ('dropdown/js/otrs_link.js', 'js/project-categories.js')
+
+ def clean(self):
+ cleaned_data = ModelForm.clean(self)
+
+ if self.errors:
+ return cleaned_data
+
+ for field, model in self.categories.items():
+ field_other = f'{field}_other'
+ values = cleaned_data[field]
+
+ if model.other in values:
+ if not cleaned_data[field_other]:
+ self.add_error(field_other, f'Bitte geben Sie einen Wert an oder deselektieren Sie "{model.OTHER}".')
+ else:
+ cleaned_data[field_other] = ''
+
+ return cleaned_data
+
+
+class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
+ OPTIONAL_FIELDS = {
+ 'categories_other',
+ 'wikimedia_projects_other',
+ 'page',
+ 'group',
+ 'location',
+ 'insurance',
+ 'notes',
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ for field in set(self.fields) - self.OPTIONAL_FIELDS:
+ self.fields[field].required = True
+
+ class Meta:
+ model = Project
+ fields = [
+ 'name',
+ 'description',
+ 'categories',
+ 'categories_other',
+ 'wikimedia_projects',
+ 'wikimedia_projects_other',
+ 'start',
+ 'end',
+ 'participants_estimated',
+ 'page',
+ 'group',
+ 'location',
+ 'cost',
+ 'insurance',
+ 'notes',
+ ]
+ widgets = {
+ 'start': AdminDateWidget,
+ 'end': AdminDateWidget,
+ }
+
+ class Media:
+ css = {
+ 'all': ('css/dateFieldNoNowShortcutInTravels.css',)
+ }
+
+
HOTEL_CHOICES = {
'TRUE': mark_safe('Hotelzimmer benötigt'),
'FALSE': mark_safe('Kein Hotelzimmer benötigt'),
@@ -318,106 +379,3 @@ class ListForm(BaseApplicationForm, CommonOrderMixin):
super().__init__(*args, **kwargs)
self.fields['address'].initial = ''
-
-
-class ProjectRequestForm(BaseApplicationForm, CommonOrderMixin):
- """
- Public-facing form for < 1000 EUR project requests.
-
- Key points:
- - JSONField-backed multi-selects are exposed as MultipleChoiceField with checkbox widgets.
- - 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.'),
- }
-
-
-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(n)'
- )
- wikimedia_projects = forms.MultipleChoiceField(
- choices=[(w, w) for w in WIKIMEDIA_CHOICES],
- widget=forms.CheckboxSelectMultiple,
- label='Wikimedia Projekt(e)'
- )
-
- class Meta:
- # 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/0100_projectcategory_wikimedia_project.py b/input/migrations/0100_projectcategory_wikimedia_project.py
new file mode 100644
index 0000000..1160183
--- /dev/null
+++ b/input/migrations/0100_projectcategory_wikimedia_project.py
@@ -0,0 +1,77 @@
+# Generated by Django 5.2.5 on 2025-10-15 13:02
+from functools import partial
+
+from django.db import migrations, models
+
+from input.utils.migrations import get_queryset
+
+DEFAULT_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',
+]
+
+DEFAULT_WIKIMEDIA_PROJECTS = [
+ 'Wikipedia',
+ 'Wikimedia Commons',
+ 'Wikidata',
+]
+
+
+def create_default_objs(model, defaults, apps, schema_editor):
+ queryset = get_queryset(apps, schema_editor, 'input', model)
+
+ queryset.bulk_create([
+ queryset.model(name=name, order=order * 10)
+ for order, name in enumerate(defaults, 1)
+ ])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('input', '0099_add_terms_accepted'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ProjectCategory',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=200, verbose_name='Name')),
+ ('order', models.PositiveIntegerField(verbose_name='Reihenfolge')),
+ ],
+ options={
+ 'verbose_name': 'Projektkategorie',
+ 'verbose_name_plural': 'Projektkategorien',
+ 'ordering': ['order'],
+ },
+ ),
+ migrations.RunPython(
+ code=partial(create_default_objs, 'ProjectCategory', DEFAULT_PROJECT_CATEGORIES),
+ reverse_code=migrations.RunPython.noop,
+ ),
+ migrations.CreateModel(
+ name='WikimediaProject',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=200, verbose_name='Name')),
+ ('order', models.PositiveIntegerField(verbose_name='Reihenfolge')),
+ ],
+ options={
+ 'verbose_name': 'Wikimedia Projekt',
+ 'verbose_name_plural': 'Wikimedia Projekte',
+ 'ordering': ['order'],
+ },
+ ),
+ migrations.RunPython(
+ code=partial(create_default_objs, 'WikimediaProject', DEFAULT_WIKIMEDIA_PROJECTS),
+ reverse_code=migrations.RunPython.noop,
+ ),
+ ]
diff --git a/input/migrations/0100_projectrequest_projectsdeclined.py b/input/migrations/0100_projectrequest_projectsdeclined.py
deleted file mode 100644
index 688855a..0000000
--- a/input/migrations/0100_projectrequest_projectsdeclined.py
+++ /dev/null
@@ -1,67 +0,0 @@
-# Generated by Django 5.2.5 on 2025-10-15 10:14
-
-from django.db import migrations, models
-
-from input.models import validate_cost
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('input', '0099_add_terms_accepted'),
- ]
-
- 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, 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, verbose_name='Wikimedia-Projekt: Anderes (kurz)')),
- ('start', models.DateField(verbose_name='Startdatum')),
- ('end', models.DateField(verbose_name='Erwartetes Projektende')),
- ('participants_estimated', models.PositiveIntegerField(verbose_name='Zahl der Teilnehmenden')),
- ('page', models.URLField(blank=True, max_length=2000, verbose_name='Link zur Projektseite')),
- ('group', models.CharField(blank=True, max_length=2000, verbose_name='Mitorganisierende')),
- ('location', models.CharField(blank=True, max_length=2000, verbose_name='Ort')),
- ('cost', models.PositiveIntegerField(validators=[validate_cost], verbose_name='Höhe der Projektkosten')),
- ('insurance', models.BooleanField(default=False, verbose_name='Versicherung gewünscht?')),
- ('notes', models.TextField(blank=True, max_length=2000, verbose_name='Anmerkungen')),
- ('decision', models.CharField(choices=[('OPEN', 'offen'), ('APPROVED', 'bewilligt'), ('DECLINED', 'abgelehnt')], db_index=True, default='OPEN', max_length=10)),
- ('decision_date', models.DateField(blank=True, db_index=True, null=True)),
- ('decided_by', models.CharField(blank=True, max_length=100, verbose_name='Entschieden von')),
- ],
- options={
- 'verbose_name': 'Projektförderungs-Antrag (< 1000 EUR)',
- 'verbose_name_plural': 'Projects_requested',
- 'ordering': ('-id',),
- },
- ),
- migrations.CreateModel(
- name='ProjectsDeclined',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('original_request_id', models.PositiveIntegerField()),
- ('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',
- 'ordering': ('-decision_date', '-id'),
- },
- ),
- ]
diff --git a/input/migrations/0101_wikimedia_project_categories_and_other.py b/input/migrations/0101_wikimedia_project_categories_and_other.py
new file mode 100644
index 0000000..a35a844
--- /dev/null
+++ b/input/migrations/0101_wikimedia_project_categories_and_other.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.2.5 on 2025-10-15 15:12
+
+import input.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('input', '0100_projectcategory_wikimedia_project'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='project',
+ name='categories',
+ field=input.models.ProjectCategoryField(blank=True, related_name='projects', to='input.projectcategory', verbose_name='Projektkategorien'),
+ ),
+ migrations.AddField(
+ model_name='project',
+ name='categories_other',
+ field=models.CharField(blank=True, max_length=200, verbose_name='Projektkategorien (Sonstiges)'),
+ ),
+ migrations.AddField(
+ model_name='project',
+ name='wikimedia_projects',
+ field=input.models.ProjectCategoryField(blank=True, related_name='projects', to='input.wikimediaproject', verbose_name='Wikimedia Projekte'),
+ ),
+ migrations.AddField(
+ model_name='project',
+ name='wikimedia_projects_other',
+ field=models.CharField(blank=True, max_length=200, verbose_name='Wikimedia Projekte (Anderes)'),
+ ),
+ ]
diff --git a/input/models.py b/input/models.py
index 2b415c6..9ad3ed7 100755
--- a/input/models.py
+++ b/input/models.py
@@ -1,11 +1,16 @@
+from contextlib import suppress
from datetime import date
+from functools import partial
+from django import forms
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator
from django.db import models
from django.db.models.signals import pre_save
from django.dispatch import receiver
+from django.forms import ModelMultipleChoiceField, CheckboxSelectMultiple
+from django.forms.models import ModelChoiceIterator
+from django.utils.functional import cached_property, classproperty
from django.utils.html import format_html
from django.utils.safestring import mark_safe
@@ -85,6 +90,102 @@ class Account(models.Model):
return f'{self.code} {self.description}'
+class BaseProjectCategory(models.Model):
+ OTHER: str
+
+ name = models.CharField('Name', max_length=200)
+ order = models.PositiveIntegerField('Reihenfolge')
+
+ class Meta:
+ abstract = True
+ ordering = ['order']
+
+ def __str__(self):
+ return self.name
+
+ @cached_property
+ def project_count(self):
+ return self.projects.count()
+
+ @classproperty
+ def other(cls):
+ return cls(id=0, name=cls.OTHER)
+
+
+class ProjectCategory(BaseProjectCategory):
+ OTHER = 'Sonstiges'
+
+ class Meta(BaseProjectCategory.Meta):
+ verbose_name = 'Projektkategorie'
+ verbose_name_plural = 'Projektkategorien'
+
+
+class WikimediaProject(BaseProjectCategory):
+ OTHER = 'Anderes'
+
+ class Meta(BaseProjectCategory.Meta):
+ verbose_name = 'Wikimedia Projekt'
+ verbose_name_plural = 'Wikimedia Projekte'
+
+
+class ProductCategoryChoiceIterator(ModelChoiceIterator):
+
+ def __iter__(self):
+ yield from ModelChoiceIterator.__iter__(self)
+ yield f'{self.field.other.id}', self.field.other.name
+
+
+class ProductCategoryFormField(ModelMultipleChoiceField):
+ widget = CheckboxSelectMultiple
+ iterator = ProductCategoryChoiceIterator
+
+ def __init__(self, *, other, **kwargs):
+ super().__init__(**kwargs)
+ self.other = other
+
+ def _check_values(self, value, *, other=False):
+ with suppress(TypeError):
+ value = set(value)
+
+ if other := f'{self.other.id}' in value:
+ value.remove(f'{self.other.id}')
+
+ queryset = super()._check_values(value)
+
+ if other:
+ return [*queryset, self.other]
+
+ return list(queryset)
+
+
+class ProjectCategoryField(models.ManyToManyField):
+
+ def __init__(self, to, **kwargs):
+ kwargs['to'] = to
+ kwargs['related_name'] = 'projects'
+
+ super().__init__(**kwargs)
+
+ self.other_field = models.CharField(max_length=200, blank=True)
+
+ def contribute_to_class(self, cls, name, **kwargs):
+ super().contribute_to_class(cls, name, **kwargs)
+
+ model, other_field = self.remote_field.model, self.other_field
+
+ if not isinstance(model, str):
+ self.verbose_name = self._verbose_name = verbose_name = model._meta.verbose_name_plural
+ other_field.verbose_name = other_field._verbose_name = f'{verbose_name} ({model.OTHER})'
+
+ other_field.contribute_to_class(cls, f'{name}_other')
+
+ def formfield(self, **kwargs):
+ kwargs['form_class'] = ProductCategoryFormField
+ kwargs['other'] = self.remote_field.model.other
+
+ return super().formfield(**kwargs)
+
+
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')
@@ -108,6 +209,9 @@ class Project(Volunteer):
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')
+ categories = ProjectCategoryField(ProjectCategory)
+ wikimedia_projects = ProjectCategoryField(WikimediaProject)
+
# the following Fields are not supposed to be edited by users
pid = models.CharField(max_length=15, null=True, blank=True)
status = models.CharField(max_length=3,choices=(('RUN', 'läuft'),('END','beendet'),('NOT','nicht stattgefunden')),default='RUN')
@@ -171,6 +275,14 @@ class Project(Volunteer):
def __str__(self):
return f'{self.pid} {self.name}'
+ def clean(self):
+ if (self.start and self.end) and (self.end < self.start):
+ raise forms.ValidationError({
+ 'end': [
+ forms.ValidationError('Das erwartete Projektende muss nach dem Startdatum liegen.'),
+ ],
+ })
+
class Intern(Volunteer):
'''abstract base class for data entry from /intern (except Project)'''
@@ -267,7 +379,7 @@ TYPE_LIST = 'LIST'
TYPE_TRAV = 'TRAV'
TYPE_SOFT = 'SOFT'
TYPE_VIS = 'VIS'
-TYPE_PROJ_LT_1000 = 'PROJ_LT_1000'
+TYPE_PROJ = 'PROJ'
TYPE_CHOICES = {
TYPE_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'),
@@ -279,7 +391,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'),
+ TYPE_PROJ: type_link('Projektplanung', 'Projektförderung unter 1000 EUR'),
}
LIBRARY_TYPES = TYPE_BIB, TYPE_ELIT, TYPE_SOFT
@@ -449,22 +561,6 @@ class BusinessCard(TermsConsentMixin, Extern):
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'
@@ -480,86 +576,6 @@ validate_cost = MaxValueValidator(
),
)
-
-# Application for project funding < 1000 EUR
-class ProjectRequest(Volunteer):
- name = models.CharField('Name des Projekts', max_length=200)
- description = models.TextField('Kurzbeschreibung des Projekts', max_length=500)
-
- # Multi-select: stored pragmatically as JSON
- categories = models.JSONField('Projektkategorie', default=list)
- categories_other = models.CharField('Projektkategorie: Sonstiges (kurz)', max_length=200, blank=True)
- wikimedia_projects = models.JSONField('Wikimedia Projekt(e)', default=list)
- wikimedia_other = models.CharField('Wikimedia-Projekt: Anderes (kurz)', max_length=200, blank=True)
-
- start = models.DateField('Startdatum')
- end = models.DateField('Erwartetes Projektende')
- participants_estimated = models.PositiveIntegerField('Zahl der Teilnehmenden')
-
- page = models.URLField('Link zur Projektseite', max_length=2000, blank=True)
- group = models.CharField('Mitorganisierende', max_length=2000, blank=True)
- location = models.CharField('Ort', max_length=2000, blank=True)
-
- cost = models.PositiveIntegerField('Höhe der Projektkosten', validators=[validate_cost])
- insurance = models.BooleanField('Versicherung gewünscht?', default=False)
- notes = models.TextField('Anmerkungen', max_length=2000, blank=True)
-
- # 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('Entschieden von', max_length=100, blank=True)
-
- 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):
- # 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 = {
TYPE_BIB: Library,
TYPE_ELIT: ELiterature,
diff --git a/input/static/js/project-categories.js b/input/static/js/project-categories.js
new file mode 100644
index 0000000..63e99d1
--- /dev/null
+++ b/input/static/js/project-categories.js
@@ -0,0 +1,30 @@
+(function ($) {
+ $(function () {
+ $('#id_categories, #id_wikimedia_projects').each(function () {
+ const otherCheckbox = $(this).find('input[value=0]');
+ const otherInputSelector = '#'.concat(this.id, '_other');
+ const otherInput = $(otherInputSelector);
+ const otherLabelSelector = 'label'.concat('[for="', this.id, '_other"]');
+ const otherLabel = $(otherLabelSelector);
+
+ const toggle = function () {
+ const checked = otherCheckbox.prop('checked');
+
+ otherInput.prop('disabled', !checked);
+ otherInput.prop('required', checked);
+ otherLabel.toggleClass('required', checked);
+ otherLabel.css('opacity', checked ? 1 : 0.3);
+
+ if (checked) {
+ otherInput.focus();
+ } else {
+ otherInput.val('');
+ }
+ };
+
+ toggle();
+
+ otherCheckbox.on('change', toggle);
+ });
+ });
+})(django.jQuery);
diff --git a/input/templates/admin/input/project/change_form.html b/input/templates/admin/input/project/change_form.html
new file mode 100644
index 0000000..55d00d5
--- /dev/null
+++ b/input/templates/admin/input/project/change_form.html
@@ -0,0 +1,10 @@
+{% extends 'admin/change_form.html' %}
+
+{% block extrastyle %}
+ {{ block.super }}
+
+{% endblock %}
diff --git a/input/templates/input/forms/extern.html b/input/templates/input/forms/extern.html
index 0e99a75..5a6ec9c 100755
--- a/input/templates/input/forms/extern.html
+++ b/input/templates/input/forms/extern.html
@@ -10,7 +10,7 @@
Projektförderung