use existing project model & added categories and wikimedia projects

This commit is contained in:
Oliver Zander 2025-10-16 12:16:08 +02:00
parent cf81a45231
commit a8731a4195
11 changed files with 424 additions and 440 deletions

View File

@ -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,
# ])

View File

@ -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 <a href="https://de.wikipedia.org/wiki/Wikipedia:F%C3%B6rderung/Versicherung"> Unfall- und Haftpflichtversicherung</a> von Wikimedia Deutschland in Anspruch nehmen?'),
'notes': format_html(
'Falls du noch weitere Informationen hast, teile sie gern an dieser Stelle mit uns. Für umfangreichere Informationen, schreibe uns eine E-Mail an <a href="mailto:community@wikimedia.de">community@wikimedia.de</a>.'),
}
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', []))

View File

@ -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,
),
]

View File

@ -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<br>Wikimedia Deutschland bei Rückfragen oder für<br>die Zusage kontaktieren kann.', max_length=200, null=True, verbose_name='E-Mail-Adresse')),
('granted', models.BooleanField(null=True, verbose_name='bewilligt')),
('granted_date', models.DateField(null=True, verbose_name='bewilligt am')),
('survey_mail_date', models.DateField(blank=True, null=True, verbose_name='Umfragemail wurde verschickt am')),
('mail_state', models.CharField(choices=[('NONE', 'noch keine Mail versendet'), ('INF', 'die Benachrichtigung zur Projektabschlussmail wurde versendet'), ('CLOSE', 'die Projektabschlussmail wurde versendet'), ('END', 'alle automatischen Mails, auch surveyMail, wurden versendet')], default='NONE', max_length=6)),
('survey_mail_send', models.BooleanField(default=False, verbose_name='Keine Umfragemail schicken')),
('name', models.CharField(max_length=200, verbose_name='Name des Projekts')),
('description', models.TextField(max_length=500, verbose_name='Kurzbeschreibung des Projekts')),
('categories', models.JSONField(default=list, verbose_name='Projektkategorie')),
('categories_other', models.CharField(blank=True, max_length=200, 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'),
},
),
]

View File

@ -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)'),
),
]

View File

@ -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,

View File

@ -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);

View File

@ -0,0 +1,10 @@
{% extends 'admin/change_form.html' %}
{% block extrastyle %}
{{ block.super }}
<style>
.related-widget-wrapper div label {
width: auto;
}
</style>
{% endblock %}

View File

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

View File

@ -0,0 +1,2 @@
def get_queryset(apps, schema_editor, *model):
return apps.get_model(*model).objects.using(schema_editor.connection.alias)

View File

@ -14,6 +14,7 @@ from django.views.generic.edit import FormView
from .forms import (
BaseApplicationForm,
ProjectForm,
LibraryForm,
ELiteratureForm,
SoftwareForm,
@ -23,7 +24,6 @@ from .forms import (
EmailForm,
ListForm,
BusinessCardForm,
ProjectRequestForm,
)
from .models import (
MODELS,
@ -35,7 +35,7 @@ from .models import (
TYPE_LIT,
TYPE_LIST,
TYPE_MAIL,
TYPE_PROJ_LT_1000,
TYPE_PROJ,
TYPE_SOFT,
TYPE_TRAV,
TYPE_VIS,
@ -81,7 +81,7 @@ class ApplicationType(NamedTuple):
PROJECT_FUNDING = [
ApplicationType(TYPE_PROJ_LT_1000, 'projektfoerderung-unter-1000', ProjectRequestForm),
ApplicationType(TYPE_PROJ, 'projektfoerderung', ProjectForm),
]
SERVICES = [