forked from beba/foerderbarometer
Merge branch 'feature/project-funding-under-1000' of gitlab.cosmocode.de:wikimedia/foerderbarometer into cosmocode
This commit is contained in:
commit
4dbf3749d7
147
input/admin.py
147
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([
|
||||
|
|
|
|||
115
input/forms.py
115
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,
|
||||
|
|
@ -304,3 +305,117 @@ class ListForm(BaseApplicationForm, CommonOrderMixin):
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['address'].initial = ''
|
||||
|
||||
|
||||
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 <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>.'),
|
||||
}
|
||||
|
||||
# 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', []))
|
||||
|
|
|
|||
|
|
@ -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<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, 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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(),
|
||||
),
|
||||
]
|
||||
220
input/models.py
220
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',
|
||||
|
|
@ -20,8 +22,8 @@ class TermsConsentMixin(models.Model):
|
|||
|
||||
|
||||
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<br>Wikimedia Deutschland bei Rückfragen oder für<br>die Zusage kontaktieren kann.'))
|
||||
|
||||
|
|
@ -50,7 +52,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)
|
||||
|
|
@ -70,25 +72,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')
|
||||
|
|
@ -98,7 +100,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
|
||||
|
|
@ -106,7 +108,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):
|
||||
|
|
@ -118,7 +120,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:
|
||||
|
|
@ -136,7 +138,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:
|
||||
|
|
@ -144,7 +146,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)
|
||||
|
|
@ -155,12 +157,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}'
|
||||
|
|
@ -169,7 +171,7 @@ class Project(Volunteer):
|
|||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pid} {self.name}"
|
||||
return f'{self.pid} {self.name}'
|
||||
|
||||
|
||||
class Intern(Volunteer):
|
||||
|
|
@ -188,7 +190,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',
|
||||
|
|
@ -210,7 +212,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')
|
||||
|
|
@ -221,12 +223,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
|
||||
|
||||
|
|
@ -246,9 +248,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
|
||||
|
|
@ -271,6 +273,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'),
|
||||
|
|
@ -282,6 +285,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
|
||||
|
|
@ -296,8 +300,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
|
||||
|
|
@ -335,25 +339,25 @@ SELFBUY_CHOICES = {'TRUE': mark_safe('Ich möchte das Werk selbst kaufen und per
|
|||
|
||||
class Literature(TermsConsentMixin, 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,<br>\
|
||||
die eine eindeutige Identifizierung ermöglichen (Autor, Titel, Verlag, ISBN, ...)"))
|
||||
help_text=mark_safe('Bitte gib alle Informationen zum benötigten Werk an,<br>\
|
||||
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<br>\
|
||||
help_text=mark_safe('Bitte gib hier alle persönlichen Daten an, die wir benötigen, um das Werk<br>\
|
||||
für dich zu kaufen und es dir anschließend zu schicken (z.B. Vorname Nachname, Anschrift, <br>\
|
||||
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',
|
||||
|
|
@ -381,17 +385,17 @@ class Email(TermsConsentMixin, 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,<br>der sich vor der Domain befinden soll."))
|
||||
help_text=mark_safe('Bitte gib hier den gewünschten Adressbestandteil an,<br>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(TermsConsentMixin, 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,<br>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,<br>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',
|
||||
|
|
@ -412,24 +416,142 @@ class BusinessCard(TermsConsentMixin, 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,<br>\
|
||||
help_text=mark_safe('Bitte gib hier alle persönlichen Daten an, und zwar genau so,<br>\
|
||||
wie sie (auch in der entsprechenden Reihenfolge) auf den Visitenkarten stehen sollen<br>\
|
||||
(z.B. Vorname Nachname, Benutzer:/Benutzerin:, Benutzer-/-innenname, Anschrift,<br>\
|
||||
Telefonnummer, E-Mail-Adresse usw.). Trenne die einzelnen Angaben durch Zeilenumbrüche.<br>\
|
||||
Hinweis: Telefonnummern bilden wir üblicherweise im internationalen Format gemäß<br>\
|
||||
DIN 5008 ab. Als anzugebende E-Mail-Adresse empfehlen wir dir eine Wikimedia-Projekt-<br>\
|
||||
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: <a href="https://upload.wikimedia.org/wikipedia/commons/c/cd/Muster_Visitenkarten_WMDE_2018.jpg">\
|
||||
mit Bild</a> <a href="https://upload.wikimedia.org/wikipedia/commons/d/d3/Muster_Visitenkarte_WMDE.png">ohne Bild</a>' ))
|
||||
|
||||
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<br> Deutschland ausgewählten Dienstleister (z. B. <a href="wir-machen-druck.de">wir-machen-druck.de</a>) zum Zwecke des direkten <br> 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 = [
|
||||
'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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -10,11 +10,11 @@
|
|||
<strong>Projektförderung</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#">Projektförderung</a>
|
||||
<a href="{% url 'projektfoerderung-unter-1000' %}">Projektförderung</a>
|
||||
mit einer Gesamtsumme unter 1.000,— EUR
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'info-foerderprojekt-ab-1000' %}">Projektförderung</a>
|
||||
<a href="{% url 'extern' type='projektfoerderung-unter-1000' %}">Projektförderung</a>
|
||||
mit einer Gesamtsumme ab 1.000,— EUR
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from .forms import (
|
|||
EmailForm,
|
||||
ListForm,
|
||||
BusinessCardForm,
|
||||
ProjectRequestForm,
|
||||
)
|
||||
from .models import (
|
||||
MODELS,
|
||||
|
|
@ -34,6 +35,7 @@ from .models import (
|
|||
TYPE_LIT,
|
||||
TYPE_LIST,
|
||||
TYPE_MAIL,
|
||||
TYPE_PROJ_LT_1000,
|
||||
TYPE_SOFT,
|
||||
TYPE_TRAV,
|
||||
TYPE_VIS,
|
||||
|
|
@ -88,6 +90,7 @@ TYPES = [
|
|||
ApplicationType(TYPE_TRAV, 'reisekosten', TravelForm),
|
||||
ApplicationType(TYPE_SOFT, 'softwarestipendium', SoftwareForm),
|
||||
ApplicationType(TYPE_VIS, 'visitenkarten', BusinessCardForm),
|
||||
ApplicationType(TYPE_PROJ_LT_1000, 'projektfoerderung-unter-1000', ProjectRequestForm),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue