forked from beba/foerderbarometer
use existing project model & added categories and wikimedia projects
This commit is contained in:
parent
cf81a45231
commit
a8731a4195
226
input/admin.py
226
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,
|
||||
# ])
|
||||
|
|
|
|||
196
input/forms.py
196
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 <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', []))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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)'),
|
||||
),
|
||||
]
|
||||
214
input/models.py
214
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{% extends 'admin/change_form.html' %}
|
||||
|
||||
{% block extrastyle %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.related-widget-wrapper div label {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
def get_queryset(apps, schema_editor, *model):
|
||||
return apps.get_model(*model).objects.using(schema_editor.connection.alias)
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue