From 011e262df6abd13c8bdf0ac709832a8aa8054ba4 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 29 Sep 2025 00:18:40 +0200 Subject: [PATCH 1/8] Add ProjectRequest and ProjectsDeclined models for project funding under 1000 EUR --- input/models.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/input/models.py b/input/models.py index c0a8949..551965f 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', @@ -261,6 +263,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'), @@ -272,6 +275,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 @@ -422,6 +426,124 @@ class BusinessCard(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' + 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 = { TYPE_BIB: Library, TYPE_ELIT: ELiterature, From 4e6906e31822638961ca803c59030afcc3e6eeaf Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 29 Sep 2025 00:22:47 +0200 Subject: [PATCH 2/8] Add ProjectRequestForm and ProjectRequestAdminForm with JSON-backed multi-selects and help texts --- input/forms.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/input/forms.py b/input/forms.py index cd9e281..4b32d60 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, @@ -306,3 +307,117 @@ class ListForm(BaseApplicationForm, CommonOrderMixin): model = List fields = ['domain', 'address'] exclude = ['intern_notes', 'survey_mail_send','mail_state'] + + +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', [])) From 14717c8318fa76c109dd953522de1c59e52e956a Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 29 Sep 2025 00:25:16 +0200 Subject: [PATCH 3/8] Add admin integration for ProjectRequest with approve/decline actions and custom ApproveActionForm --- input/admin.py | 147 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/input/admin.py b/input/admin.py index 7da1e6f..555f6a0 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([ From cc55b17cff68251f91ee87e4d937397ce1b9d461 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 29 Sep 2025 00:27:09 +0200 Subject: [PATCH 4/8] Add service layer for approving and declining ProjectRequest with transactional logic --- input/services.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 input/services.py 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 From 98f84145763b3334ce3bb5318d8247050c6f7c42 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 29 Sep 2025 00:32:09 +0200 Subject: [PATCH 5/8] Add migrations for ProjectRequest and ProjectsDeclined models with updated options and field constraints --- .../0099_projectrequest_projectsdeclined.py | 64 +++++++++++++++++++ ...0_alter_projectrequest_options_and_more.py | 57 +++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 input/migrations/0099_projectrequest_projectsdeclined.py create mode 100644 input/migrations/0100_alter_projectrequest_options_and_more.py 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(), + ), + ] From 46240fedc12d7abe0c19be666f851249b860b354 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 29 Sep 2025 00:33:42 +0200 Subject: [PATCH 6/8] Add public route, view, and navigation link for project funding requests under 1000 EUR --- input/templates/input/forms/extern.html | 2 +- input/urls.py | 4 +++- input/views.py | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/input/templates/input/forms/extern.html b/input/templates/input/forms/extern.html index 0566f2b..6992f94 100755 --- a/input/templates/input/forms/extern.html +++ b/input/templates/input/forms/extern.html @@ -10,7 +10,7 @@ Projektförderung
  • - Projektförderung + Projektförderung mit einer Gesamtsumme unter 1.000,— EUR
  • diff --git a/input/urls.py b/input/urls.py index 3d89297..3d1fc4a 100755 --- a/input/urls.py +++ b/input/urls.py @@ -5,7 +5,7 @@ from .views import ( index, done, export, authorize, deny, TravelApplicationView, IFGApplicationView, EmailApplicationView, LiteratureApplicationView, ListApplicationView, BusinessCardApplicationView, - LibraryApplicationView, ELiteratureApplicationView, SoftwareApplicationView, + LibraryApplicationView, ELiteratureApplicationView, SoftwareApplicationView, ProjectFundingLt1kApplicationView ) urlpatterns = [ @@ -35,6 +35,8 @@ urlpatterns = [ path('extern/bibliotheksstipendium/', LibraryApplicationView.as_view(), name='bibliotheksstipendium'), path('extern/eliteraturstipendium/', ELiteratureApplicationView.as_view(), name='eliteraturstipendium'), path('extern/softwarestipendium/', SoftwareApplicationView.as_view(), name='softwarestipendium'), + path('extern/projektfoerderung-unter-1000/', ProjectFundingLt1kApplicationView.as_view(), + name='projektfoerderung-unter-1000'), # JavaScript translations for date widgets, etc. path('jsi18n/', JavaScriptCatalog.as_view(), name='jsi18n'), diff --git a/input/views.py b/input/views.py index 04b5dc8..88ed431 100755 --- a/input/views.py +++ b/input/views.py @@ -21,6 +21,7 @@ from .forms import ( EmailForm, ListForm, BusinessCardForm, + ProjectRequestForm, ) from .models import TYPE_CHOICES, MODELS, TYPE_BIB, TYPE_ELIT, TYPE_SOFT @@ -253,3 +254,8 @@ class ListApplicationView(BaseApplicationView): class BusinessCardApplicationView(BaseApplicationView): form_class = BusinessCardForm type_code = 'VIS' + + +class ProjectFundingLt1kApplicationView(BaseApplicationView): + form_class = ProjectRequestForm + type_code = 'PROJ_LT_1000' \ No newline at end of file From c9959988cbf2820fb946da88d3cba5abcc60f140 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 29 Sep 2025 00:40:20 +0200 Subject: [PATCH 7/8] Fix: use Django URL tag for project funding link instead of hardcoded path --- input/templates/input/forms/extern.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/input/templates/input/forms/extern.html b/input/templates/input/forms/extern.html index 6992f94..fa966ae 100755 --- a/input/templates/input/forms/extern.html +++ b/input/templates/input/forms/extern.html @@ -10,7 +10,7 @@ Projektförderung
    • - Projektförderung + Projektförderung mit einer Gesamtsumme unter 1.000,— EUR
    • From cda7d7a79d6eef7db99508188c37c862bdd0af76 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 29 Sep 2025 00:51:13 +0200 Subject: [PATCH 8/8] Switch to single quotes across models.py for consistent string formatting --- input/models.py | 102 ++++++++++++++++++++++++------------------------ 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/input/models.py b/input/models.py index 551965f..17f4819 100755 --- a/input/models.py +++ b/input/models.py @@ -12,8 +12,8 @@ EMAIL_STATES = {'NONE': 'noch keine Mail versendet', 'END': 'alle automatischen Mails, auch surveyMail, wurden versendet'} 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.')) @@ -42,7 +42,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) @@ -62,25 +62,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') @@ -90,7 +90,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 @@ -98,7 +98,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): @@ -110,7 +110,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: @@ -128,7 +128,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: @@ -136,7 +136,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) @@ -147,12 +147,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}' @@ -161,7 +161,7 @@ class Project(Volunteer): def __str__(self): - return f"{self.pid} {self.name}" + return f'{self.pid} {self.name}' class Intern(Volunteer): @@ -180,7 +180,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', @@ -202,7 +202,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') @@ -213,12 +213,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 @@ -238,9 +238,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 @@ -290,8 +290,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 @@ -329,25 +329,25 @@ SELFBUY_CHOICES = {'TRUE': mark_safe('Ich möchte das Werk selbst kaufen und per class Literature(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', @@ -375,17 +375,17 @@ class Email(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(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', @@ -406,24 +406,24 @@ class BusinessCard(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 = [ @@ -489,7 +489,7 @@ class ProjectRequest(Volunteer): ordering = ('-id',) def __str__(self): - return f"[Antrag] {self.name} ({self.realname})" + return f'[Antrag] {self.name} ({self.realname})' def clean(self): super().clean() @@ -541,7 +541,7 @@ class ProjectsDeclined(models.Model): ordering = ('-decision_date', '-id') def __str__(self): - return f"[Abgelehnt] {self.name} – {self.decision_date}" + return f'[Abgelehnt] {self.name} – {self.decision_date}' MODELS = {