From a8731a4195ee5334e2428e3ea30b2d7d27902aaf Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Thu, 16 Oct 2025 12:16:08 +0200 Subject: [PATCH] use existing project model & added categories and wikimedia projects --- input/admin.py | 226 ++++++------------ input/forms.py | 196 ++++++--------- .../0100_projectcategory_wikimedia_project.py | 77 ++++++ .../0100_projectrequest_projectsdeclined.py | 67 ------ ..._wikimedia_project_categories_and_other.py | 34 +++ input/models.py | 214 +++++++++-------- input/static/js/project-categories.js | 30 +++ .../admin/input/project/change_form.html | 10 + input/templates/input/forms/extern.html | 2 +- input/utils/migrations.py | 2 + input/views.py | 6 +- 11 files changed, 424 insertions(+), 440 deletions(-) create mode 100644 input/migrations/0100_projectcategory_wikimedia_project.py delete mode 100644 input/migrations/0100_projectrequest_projectsdeclined.py create mode 100644 input/migrations/0101_wikimedia_project_categories_and_other.py create mode 100644 input/static/js/project-categories.js create mode 100644 input/templates/admin/input/project/change_form.html create mode 100644 input/utils/migrations.py 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