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.contrib import admin
|
||||||
from django.http import HttpResponse
|
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 (
|
from .models import (
|
||||||
Account,
|
Account,
|
||||||
|
|
@ -160,6 +168,145 @@ class ListAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = 'granted_date'
|
date_hierarchy = 'granted_date'
|
||||||
readonly_fields = ['service_id']
|
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
|
# commented out because of the individual registering to control displays in admin panel
|
||||||
|
|
||||||
#admin.site.register([
|
#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.html import format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from .models import ProjectRequest, PROJECT_CATEGORIES, WIKIMEDIA_CHOICES
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
TYPE_CHOICES,
|
TYPE_CHOICES,
|
||||||
|
|
@ -304,3 +305,117 @@ class ListForm(BaseApplicationForm, CommonOrderMixin):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
self.fields['address'].initial = ''
|
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.db import models
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.safestring import mark_safe
|
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',
|
EMAIL_STATES = {'NONE': 'noch keine Mail versendet',
|
||||||
'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet',
|
'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet',
|
||||||
|
|
@ -20,8 +22,8 @@ class TermsConsentMixin(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class Volunteer(models.Model):
|
class Volunteer(models.Model):
|
||||||
realname = models.CharField(max_length=200, null=True, verbose_name="Realname",
|
realname = models.CharField(max_length=200, null=True, verbose_name='Realname',
|
||||||
help_text="Bitte gib deinen Vornamen und deinen Nachnamen ein.", default='')
|
help_text='Bitte gib deinen Vornamen und deinen Nachnamen ein.', default='')
|
||||||
email = models.EmailField(max_length=200, null=True, verbose_name='E-Mail-Adresse',
|
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.'))
|
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 '''
|
''' abstract basis class for all data entered by extern volunteers '''
|
||||||
|
|
||||||
username = models.CharField(max_length=200, null=True, verbose_name='Benutzer_innenname',
|
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
|
# the following Fields are not supposed to be edited by users
|
||||||
service_id = models.CharField(max_length=15, null=True, blank=True)
|
service_id = models.CharField(max_length=15, null=True, blank=True)
|
||||||
|
|
@ -70,25 +72,25 @@ class ConcreteExtern(Extern):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class Account(models.Model):
|
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)
|
null=False, primary_key = True)
|
||||||
description = models.CharField('Beschreibung', max_length=60, default='NO DESCRIPTION')
|
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):
|
def __str__(self):
|
||||||
return f"{self.code} {self.description}"
|
return f'{self.code} {self.description}'
|
||||||
|
|
||||||
class Project(Volunteer):
|
class Project(Volunteer):
|
||||||
end_mail_send = models.BooleanField(default=False, verbose_name='Keine Projektabschlussmail schicken')
|
end_mail_send = models.BooleanField(default=False, verbose_name='Keine Projektabschlussmail schicken')
|
||||||
name = models.CharField(max_length=200, verbose_name='Name des Projekts')
|
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)
|
start = models.DateField('Startdatum', null=True)
|
||||||
end = models.DateField('Erwartetes Projektende', null=True)
|
end = models.DateField('Erwartetes Projektende', null=True)
|
||||||
otrs = models.URLField(max_length=300, null=True, verbose_name='OTRS-Link')
|
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")
|
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")
|
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")
|
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")
|
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")
|
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_estimated = models.IntegerField(blank=True, null=True, verbose_name='Teilnehmende angefragt')
|
||||||
participants_real = models.IntegerField(blank=True, null=True, verbose_name='Teilnehmende ausgezählt')
|
participants_real = models.IntegerField(blank=True, null=True, verbose_name='Teilnehmende ausgezählt')
|
||||||
insurance = models.BooleanField(default=False, verbose_name='Haftpflichtversicherung')
|
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)
|
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')
|
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')
|
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
|
# 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')
|
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)
|
finance_id = models.CharField(max_length=15, null= True, blank=True)
|
||||||
project_of_year = models.IntegerField(default=0)
|
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):
|
def save(self,*args,**kwargs):
|
||||||
|
|
@ -118,7 +120,7 @@ class Project(Volunteer):
|
||||||
# but maybe there is a better solution?
|
# but maybe there is a better solution?
|
||||||
|
|
||||||
if not self.pk:
|
if not self.pk:
|
||||||
print ("NO PK THERE");
|
print ('NO PK THERE');
|
||||||
generate_finance_id=True
|
generate_finance_id=True
|
||||||
super().save()
|
super().save()
|
||||||
else:
|
else:
|
||||||
|
|
@ -136,7 +138,7 @@ class Project(Volunteer):
|
||||||
|
|
||||||
|
|
||||||
if generate_finance_id:
|
if generate_finance_id:
|
||||||
print ("MUST GENERATE FINANCE ID")
|
print ('MUST GENERATE FINANCE ID')
|
||||||
year = self.start.year
|
year = self.start.year
|
||||||
projects = Project.objects.filter(start__year=year)
|
projects = Project.objects.filter(start__year=year)
|
||||||
if not projects:
|
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)
|
#self.pid = str(self.start.year) + '-' + str(self.account.code) + str(self.project_of_year).zfill(3)
|
||||||
else:
|
else:
|
||||||
# get the project of year number of latest entry
|
# 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
|
# add one to value of latest entry
|
||||||
self.project_of_year = int(projects.project_of_year) + 1
|
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)
|
# 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:
|
else:
|
||||||
self.finance_id = str(self.account.code)
|
self.finance_id = str(self.account.code)
|
||||||
|
|
||||||
# print (("Current PID",self.pid))
|
# print (('Current PID',self.pid))
|
||||||
|
|
||||||
if not 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(8)
|
||||||
# self.pid = str(self.account.code) + str(self.pk).zfill(3)
|
# 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:
|
if self.end:
|
||||||
self.end_quartal = f'Q{self.end.month // 4 + 1}'
|
self.end_quartal = f'Q{self.end.month // 4 + 1}'
|
||||||
|
|
@ -169,7 +171,7 @@ class Project(Volunteer):
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.pid} {self.name}"
|
return f'{self.pid} {self.name}'
|
||||||
|
|
||||||
|
|
||||||
class Intern(Volunteer):
|
class Intern(Volunteer):
|
||||||
|
|
@ -188,7 +190,7 @@ class HonoraryCertificate(Intern):
|
||||||
|
|
||||||
project = models.ForeignKey(Project, null=True, blank=True, on_delete=models.SET_NULL)
|
project = models.ForeignKey(Project, null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Certificate for " + self.realname
|
return 'Certificate for ' + self.realname
|
||||||
|
|
||||||
|
|
||||||
TRANSPORT_CHOICES = {'BAHN': 'Bahn',
|
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:')
|
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:')
|
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)')
|
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')
|
checkin = models.DateField(blank=True, null=True, verbose_name='Anreise')
|
||||||
checkout = models.DateField(blank=True, null=True, verbose_name='Abreise')
|
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')
|
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')
|
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
|
||||||
project_end = models.DateField(blank=True, null=True, verbose_name='Projektende')
|
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
|
# 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.db.models.signals import pre_save
|
||||||
from django.dispatch import receiver
|
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):
|
def getProjectEnd(sender, instance, **kwargs):
|
||||||
#instance.project_end = instance.project.end
|
#instance.project_end = instance.project.end
|
||||||
|
|
||||||
|
|
@ -246,9 +248,9 @@ def getProjectEnd(sender, instance, **kwargs):
|
||||||
#abstract base class for Library and IFG
|
#abstract base class for Library and IFG
|
||||||
class Grant(Extern):
|
class Grant(Extern):
|
||||||
cost = models.CharField(max_length=10, verbose_name='Kosten',
|
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',
|
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:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
@ -271,6 +273,7 @@ TYPE_LIST = 'LIST'
|
||||||
TYPE_TRAV = 'TRAV'
|
TYPE_TRAV = 'TRAV'
|
||||||
TYPE_SOFT = 'SOFT'
|
TYPE_SOFT = 'SOFT'
|
||||||
TYPE_VIS = 'VIS'
|
TYPE_VIS = 'VIS'
|
||||||
|
TYPE_PROJ_LT_1000 = 'PROJ_LT_1000'
|
||||||
|
|
||||||
TYPE_CHOICES = {
|
TYPE_CHOICES = {
|
||||||
TYPE_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'),
|
TYPE_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'),
|
||||||
|
|
@ -282,6 +285,7 @@ TYPE_CHOICES = {
|
||||||
TYPE_TRAV: type_link('Reisekostenerstattungen', 'Reisekosten'),
|
TYPE_TRAV: type_link('Reisekostenerstattungen', 'Reisekosten'),
|
||||||
TYPE_SOFT: type_link('Software-Stipendien', 'Softwarestipendium'),
|
TYPE_SOFT: type_link('Software-Stipendien', 'Softwarestipendium'),
|
||||||
TYPE_VIS: type_link('E-Mail-Adressen_und_Visitenkarten#Visitenkarten', 'Visitenkarten'),
|
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
|
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)
|
type = models.CharField(max_length=4, choices=LIBRARY_TYPE_CHOICES, default=TYPE_BIB)
|
||||||
library = models.CharField(max_length=200)
|
library = models.CharField(max_length=200)
|
||||||
duration = models.CharField(max_length=100, verbose_name="Dauer")
|
duration = models.CharField(max_length=100, verbose_name='Dauer')
|
||||||
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):
|
def __str__(self):
|
||||||
return self.library
|
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):
|
class Literature(TermsConsentMixin, Grant):
|
||||||
info = models.CharField(max_length=500, verbose_name='Informationen zum Werk',
|
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>\
|
help_text=mark_safe('Bitte gib alle Informationen zum benötigten Werk an,<br>\
|
||||||
die eine eindeutige Identifizierung ermöglichen (Autor, Titel, Verlag, ISBN, ...)"))
|
die eine eindeutige Identifizierung ermöglichen (Autor, Titel, Verlag, ISBN, ...)'))
|
||||||
source = models.CharField(max_length=200, verbose_name='Bezugsquelle',
|
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 = 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_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='',\
|
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>\
|
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."))
|
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")
|
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
|
||||||
|
|
||||||
class IFG(Grant):
|
class IFG(Grant):
|
||||||
url = models.URLField(max_length=2000, verbose_name="URL",
|
url = models.URLField(max_length=2000, verbose_name='URL',
|
||||||
help_text="Bitte gib den Link zu deiner Anfrage bei Frag den Staat an.")
|
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")
|
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "IFG-Anfrage von " + self.realname
|
return 'IFG-Anfrage von ' + self.realname
|
||||||
|
|
||||||
DOMAIN_CHOICES = {'PEDIA': '@wikipedia.de',
|
DOMAIN_CHOICES = {'PEDIA': '@wikipedia.de',
|
||||||
'BOOKS': '@wikibooks.de',
|
'BOOKS': '@wikibooks.de',
|
||||||
|
|
@ -381,17 +385,17 @@ class Email(TermsConsentMixin, Domain):
|
||||||
address = models.CharField(max_length=50,
|
address = models.CharField(max_length=50,
|
||||||
choices=MAIL_CHOICES.items(),
|
choices=MAIL_CHOICES.items(),
|
||||||
default='USERNAME', verbose_name='Adressbestandteil',
|
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')
|
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):
|
class List(TermsConsentMixin, Domain):
|
||||||
address = models.CharField(max_length=50, default='NO_ADDRESS',
|
address = models.CharField(max_length=50, default='NO_ADDRESS',
|
||||||
verbose_name="Adressbestandteil für Projektmailingliste",
|
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."))
|
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")
|
intern_notes = models.TextField(max_length=1000, blank=True, verbose_name='interne Anmerkungen')
|
||||||
|
|
||||||
PROJECT_CHOICE = {'PEDIA': 'Wikipedia',
|
PROJECT_CHOICE = {'PEDIA': 'Wikipedia',
|
||||||
'SOURCE': 'Wikisource',
|
'SOURCE': 'Wikisource',
|
||||||
|
|
@ -412,24 +416,142 @@ class BusinessCard(TermsConsentMixin, Extern):
|
||||||
help_text='Für welches Wikimedia-Projekt möchtest Du Visitenkarten?')
|
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='',
|
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>\
|
wie sie (auch in der entsprechenden Reihenfolge) auf den Visitenkarten stehen sollen<br>\
|
||||||
(z.B. Vorname Nachname, Benutzer:/Benutzerin:, Benutzer-/-innenname, Anschrift,<br>\
|
(z.B. Vorname Nachname, Benutzer:/Benutzerin:, Benutzer-/-innenname, Anschrift,<br>\
|
||||||
Telefonnummer, E-Mail-Adresse usw.). Trenne die einzelnen Angaben durch Zeilenumbrüche.<br>\
|
Telefonnummer, E-Mail-Adresse usw.). Trenne die einzelnen Angaben durch Zeilenumbrüche.<br>\
|
||||||
Hinweis: Telefonnummern bilden wir üblicherweise im internationalen Format gemäß<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>\
|
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(),
|
variant = models.CharField(max_length=5, choices=BC_VARIANT.items(),
|
||||||
default='NOPIC', verbose_name='Variante',
|
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">\
|
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>' ))
|
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',
|
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.'))
|
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 = {
|
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>
|
<strong>Projektförderung</strong>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">Projektförderung</a>
|
<a href="{% url 'projektfoerderung-unter-1000' %}">Projektförderung</a>
|
||||||
mit einer Gesamtsumme unter 1.000,— EUR
|
mit einer Gesamtsumme unter 1.000,— EUR
|
||||||
</li>
|
</li>
|
||||||
<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
|
mit einer Gesamtsumme ab 1.000,— EUR
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from .forms import (
|
||||||
EmailForm,
|
EmailForm,
|
||||||
ListForm,
|
ListForm,
|
||||||
BusinessCardForm,
|
BusinessCardForm,
|
||||||
|
ProjectRequestForm,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
MODELS,
|
MODELS,
|
||||||
|
|
@ -34,6 +35,7 @@ from .models import (
|
||||||
TYPE_LIT,
|
TYPE_LIT,
|
||||||
TYPE_LIST,
|
TYPE_LIST,
|
||||||
TYPE_MAIL,
|
TYPE_MAIL,
|
||||||
|
TYPE_PROJ_LT_1000,
|
||||||
TYPE_SOFT,
|
TYPE_SOFT,
|
||||||
TYPE_TRAV,
|
TYPE_TRAV,
|
||||||
TYPE_VIS,
|
TYPE_VIS,
|
||||||
|
|
@ -88,6 +90,7 @@ TYPES = [
|
||||||
ApplicationType(TYPE_TRAV, 'reisekosten', TravelForm),
|
ApplicationType(TYPE_TRAV, 'reisekosten', TravelForm),
|
||||||
ApplicationType(TYPE_SOFT, 'softwarestipendium', SoftwareForm),
|
ApplicationType(TYPE_SOFT, 'softwarestipendium', SoftwareForm),
|
||||||
ApplicationType(TYPE_VIS, 'visitenkarten', BusinessCardForm),
|
ApplicationType(TYPE_VIS, 'visitenkarten', BusinessCardForm),
|
||||||
|
ApplicationType(TYPE_PROJ_LT_1000, 'projektfoerderung-unter-1000', ProjectRequestForm),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue