Compare commits

..

No commits in common. "ebd7ebd3fd9087c9bb06c2c36e4d76f281adccf2" and "b3484965b36aa2b1ffb0f27c84c6e2559dbab1ea" have entirely different histories.

13 changed files with 322 additions and 405 deletions

View File

@ -25,6 +25,7 @@ run the development server with
access via access via
http://localhost:8000/ http://localhost:8000/
http://localhost:8000/intern/ (login required)
http://localhost:8000/admin/ (login reqiured) http://localhost:8000/admin/ (login reqiured)
## docker compose development setup ## docker compose development setup
@ -43,6 +44,7 @@ Create your superuser account with
You can access the application via You can access the application via
http://localhost:8000/ http://localhost:8000/
http://localhost:8000/intern/ (login required)
http://localhost:8000/admin/ (login required) http://localhost:8000/admin/ (login required)
## additional admin functionality ## additional admin functionality
@ -54,24 +56,28 @@ entries to a csv file
- There is a new button in the bottom of every Project to "save as new" - There is a new button in the bottom of every Project to "save as new"
## mail attachments ## versions used in development
For all mails, attachments can be defined as URLs. These URLs are fetched and chached when sending the mail and attached to the mail. asgiref==3.2.10
Django==3.1.2
Configuration is done via the `ATTACHMENT_URLS` setting. Attachments can be set for user (`RECIPIENT_APPLICANT`) and staff (`RECIPIENT_STAFF`) mails. The following mail types exist: gunicorn==20.0.4
mysqlclient==2.1.1
* `TYPE_BIB` Bibliotheksstipendium sqlparse==0.4.3
* `TYPE_ELIT` eLiteraturstipendium whitenoise==6.2.0
* `TYPE_SOFT` Softwarestipendium asgiref==3.2.10
* `TYPE_MAIL` E-Mail-Adresse Authlib==1.2.1
* `TYPE_IFG` Kostenübernahme IFG-Anfrage certifi==2023.7.22
* `TYPE_LIT` Literaturstipendium cffi==1.16.0
* `TYPE_LIST` Mailingliste chardet==5.2.0
* `TYPE_TRAV` Reisekosten charset-normalizer==3.3.0
* `TYPE_VIS` Visitenkarten cryptography==41.0.4
* `TYPE_PROJ` Projektförderung idna==3.4
pycparser==2.21
For further details see `foerderbarometer/settings.py` pytz==2023.3.post1
requests==2.31.0
six==1.16.0
typing_extensions==4.8.0
urllib3==2.0.6
## testing ## testing

View File

@ -1,33 +0,0 @@
TYPE_ALL = 'ALL'
TYPE_BIB = 'BIB' # Bibliotheksstipendium
TYPE_ELIT = 'ELIT' # eLiteraturstipendium
TYPE_SOFT = 'SOFT' # Softwarestipendium
TYPE_MAIL = 'MAIL' # E-Mail-Adresse
TYPE_IFG = 'IFG' # Kostenübernahme IFG-Anfrage
TYPE_LIT = 'LIT' # Literaturstipendium
TYPE_LIST = 'LIST' # Mailingliste
TYPE_TRAV = 'TRAV' # Reisekosten
TYPE_VIS = 'VIS' # Visitenkarten
TYPE_PROJ = 'PROJ' # Projektförderung
TYPES = [
TYPE_BIB,
TYPE_ELIT,
TYPE_SOFT,
TYPE_MAIL,
TYPE_IFG,
TYPE_LIT,
TYPE_LIST,
TYPE_TRAV,
TYPE_VIS,
TYPE_PROJ,
]
RECIPIENT_APPLICANT = 'applicant'
RECIPIENT_STAFF = 'staff'
RECIPIENTS = [
RECIPIENT_APPLICANT,
RECIPIENT_STAFF,
]

View File

@ -6,8 +6,6 @@ from dotenv import load_dotenv
from input.utils.settings import env, password_validators from input.utils.settings import env, password_validators
from .constants import *
BASE_DIR = Path(__file__).parents[1] BASE_DIR = Path(__file__).parents[1]
load_dotenv(BASE_DIR / '.env') load_dotenv(BASE_DIR / '.env')
@ -168,25 +166,32 @@ NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM = 'static/input/nutzungsbedingungen-lite
NUTZUNGSBEDINGUNGEN_OTRS = 'static/input/2025_Nutzungsvereinbarung_OTRS.docx.pdf' NUTZUNGSBEDINGUNGEN_OTRS = 'static/input/2025_Nutzungsvereinbarung_OTRS.docx.pdf'
NUTZUNGSBEDINGUNGEN_VISITENKARTEN = 'static/input/nutzungsbedingungen-visitenkarten.pdf' NUTZUNGSBEDINGUNGEN_VISITENKARTEN = 'static/input/nutzungsbedingungen-visitenkarten.pdf'
MAIL_ATTACHMENT_CACHE_DIR = env('MAIL_ATTACHMENT_CACHE_DIR', BASE_DIR / 'var' / 'mail-attachments') # Directory where downloaded attachments will be cached
MAIL_ATTACHMENT_TTL_SECONDS = env('MAIL_ATTACHMENT_TTL_SECONDS', 24 * 60 * 60) MAIL_ATTACHMENT_CACHE_DIR = BASE_DIR / 'var' / 'mail_attachments'
# Cache TTL (default: 1 day)
MAIL_ATTACHMENT_TTL_SECONDS = 24 * 60 * 60
# File attachments via URL:
# - "applicant": attachments for emails sent to applicants
# - "staff": attachments for emails sent to the team (community@wikimedia.de)
#
# Top-level keys: "applicant" / "staff"
# Second-level keys: service code ("choice") or "ALL" for global attachments
# that should be included in all emails of this type.
MAIL_ATTACHMENT_URLS = { MAIL_ATTACHMENT_URLS = {
RECIPIENT_APPLICANT: { 'applicant': {
TYPE_ALL: [], # Global attachments for all applicant emails
TYPE_VIS: [ 'ALL': [],
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-visitenkarten.pdf', # Special attachments for specific services:
], 'VIS': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-visitenkarten.pdf', 'Nutzungsbedingungen-Visitenkarten.pdf')], # Business cards
TYPE_MAIL: [ 'MAIL': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mail.pdf', 'Nutzungsbedingungen-Mail.pdf')], # Emails
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mail.pdf', 'LIST': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mailinglisten.pdf', 'Nutzungsbedingungen-Mailinglisten.pdf')], # Mailing lists
], 'LIT': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-literaturstipendium.pdf', 'Nutzungsbedingungen-Literaturstipendium.pdf')], # Literature grants
TYPE_LIST: [
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mailinglisten.pdf',
],
TYPE_LIT: [
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-literaturstipendium.pdf',
],
}, },
RECIPIENT_STAFF: { 'staff': {
TYPE_ALL: [], # Global attachments for all staff emails
'ALL': [],
# Example: 'IFG': ['https://example.com/internal-guideline.pdf']
}, },
} }

View File

@ -1,19 +1,23 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.admin.widgets import AdminDateWidget from django.contrib.admin.widgets import AdminDateWidget
from django.forms import ModelForm from django.forms import ModelForm, ChoiceField, RadioSelect, BooleanField, CharField, EmailField
from django.forms.renderers import DjangoTemplates 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 .models import ( from .models import (
TYPE_CHOICES,
Project, Project,
ProjectCategory, ProjectCategory,
WikimediaProject, WikimediaProject,
ConcreteVolunteer,
ConcreteExtern,
IFG, IFG,
Library, Library,
ELiterature, ELiterature,
Software, Software,
HonoraryCertificate,
Travel, Travel,
Email, Email,
Literature, Literature,
@ -23,33 +27,108 @@ from .models import (
class TableFormRenderer(DjangoTemplates): class TableFormRenderer(DjangoTemplates):
"""
Set in settings as the default form renderer.
"""
form_template_name = 'django/forms/table.html' form_template_name = 'django/forms/table.html'
class RadioField(forms.ChoiceField): class FdbForm(ModelForm):
widget = forms.RadioSelect '''this base class provides the required css class for all forms'''
class BaseApplicationForm(ModelForm):
"""
Base form for all external applications.
"""
required_css_class = 'required' required_css_class = 'required'
check = forms.BooleanField(
class CommonOrderMixin(forms.Form):
"""
Ensures a consistent field order for all forms that inherit from this mixin.
The goal is to always render:
- "realname" and "email" fields at the top,
- all other fields in the middle (in the order Django provides),
- "check" (terms/privacy checkbox) at the bottom.
This keeps the UX consistent across all forms without repeating field_order logic.
"""
# Fields that should always appear first in the form
field_order_head = ('realname', 'email')
# Fields that should always appear last in the form
field_order_tail = ('check',) # rename if your checkbox field is called differently
def __init__(self, *args, **kwargs):
"""
Override the default form initialization to reorder fields.
The method:
1. Put the 'head' fields first.
2. Put all other fields in the middle.
3. Put the 'tail' fields last.
Non-existing fields are ignored by `order_fields`.
"""
super().__init__(*args, **kwargs)
ordered = {*self.field_order_head, *self.field_order_tail}
self.order_fields([
*self.field_order_head,
*[field for field in self.fields if field not in ordered],
*self.field_order_tail,
])
class ExternForm(FdbForm):
choice = ChoiceField(choices=TYPE_CHOICES.items(), widget=RadioSelect,
label='Was möchtest Du beantragen?')
check = BooleanField(required=True,
label=format_html("Ich stimme den <a href='{}' target='_blank' rel='noopener'>Datenschutzbestimmungen</a> und der<br> <a href='{}' target='_blank' rel='noopener'>Richtlinie zur Förderung der Communitys</a> zu",
settings.DATAPROTECTION, settings.FOERDERRICHTLINIEN))
class Meta:
model = ConcreteExtern
exclude = ('username', 'granted', 'granted_date', 'survey_mail_send', 'service_id', 'survey_mail_date', 'mail_state')
INTERN_CHOICES = {
'PRO': 'Projektsteckbrief',
'HON': 'Ehrenamtsbescheinigung, Akkreditierung oder Redaktionsbestätigung',
'TRAV': 'Reisekostenerstattung',
}
class InternForm(FdbForm):
choice = ChoiceField(choices=INTERN_CHOICES.items(), widget=RadioSelect,
label='Was möchtest Du eingeben?')
class Meta:
model = ConcreteVolunteer
exclude = ('granted', 'granted_date', 'survey_mail_send', 'survey_mail_date', 'mail_state')
class BaseApplicationForm(FdbForm):
"""
Base form for all external applications.
- Adds standard fields that must appear in every form:
* realname (applicant's full name)
* email (contact address for notifications)
* check (confirmation of data protection and funding policy)
- Ensures consistency across all application types.
"""
realname = CharField(
label='Realname',
required=True, required=True,
label=format_html( help_text='Bitte gib deinen Vor- und Nachnamen ein.'
"""Ich stimme den <a href="{}" target="_blank" rel="noopener">Datenschutzbestimmungen</a> und der<br>
<a href="{}" target="_blank" rel="noopener">Richtlinie zur Förderung der Communitys</a> zu.""",
settings.DATAPROTECTION,
settings.FOERDERRICHTLINIEN
),
) )
email = EmailField(
label='E-Mail-Adresse',
required=True,
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.'
)
check = BooleanField(required=True,
label=format_html(
"Ich stimme den <a href='{}' target='_blank' rel='noopener'>Datenschutzbestimmungen</a> und der<br> <a href='{}' target='_blank' rel='noopener'>Richtlinie zur Förderung der Communitys</a> zu",
settings.DATAPROTECTION, settings.FOERDERRICHTLINIEN))
PROJECT_COST_GT_1000_MESSAGE = format_html( PROJECT_COST_GT_1000_MESSAGE = format_html(
@ -87,7 +166,7 @@ class BaseProjectForm(ModelForm):
return cleaned_data return cleaned_data
class ProjectForm(BaseProjectForm, BaseApplicationForm): class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
OPTIONAL_FIELDS = { OPTIONAL_FIELDS = {
'categories_other', 'categories_other',
'wikimedia_projects_other', 'wikimedia_projects_other',
@ -107,8 +186,6 @@ class ProjectForm(BaseProjectForm, BaseApplicationForm):
class Meta: class Meta:
model = Project model = Project
fields = [ fields = [
'realname',
'email',
'name', 'name',
'description', 'description',
'categories', 'categories',
@ -150,10 +227,10 @@ HOTEL_CHOICES = {
} }
class TravelForm(BaseApplicationForm): class TravelForm(BaseApplicationForm, CommonOrderMixin):
# TODO: add some javascript to show/hide other-field # TODO: add some javascript to show/hide other-field
hotel = RadioField(label='Hotelzimmer benötigt', choices=HOTEL_CHOICES) hotel = ChoiceField(label='Hotelzimmer benötigt:', choices=HOTEL_CHOICES.items(), widget=RadioSelect())
# this is the code, to change required to false if needed # this is the code, to change required to false if needed
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -167,17 +244,9 @@ class TravelForm(BaseApplicationForm):
class Meta: class Meta:
model = Travel model = Travel
fields = [ fields = ['project_name', 'transport', 'travelcost', 'checkin', 'checkout', 'hotel', 'notes']
'realname', exclude = ('granted', 'granted_date', 'survey_mail_send', 'realname', 'email', 'survey_mail_date', 'project',
'email', 'request_url', 'payed_for_hotel_by', 'payed_for_travel_by', 'intern_notes', 'mail_state')
'project_name',
'transport',
'travelcost',
'checkin',
'checkout',
'hotel',
'notes',
]
widgets = { widgets = {
'checkin': AdminDateWidget, 'checkin': AdminDateWidget,
'checkout': AdminDateWidget, 'checkout': AdminDateWidget,
@ -190,18 +259,12 @@ class TravelForm(BaseApplicationForm):
} }
class LibraryForm(BaseApplicationForm): class LibraryForm(BaseApplicationForm, CommonOrderMixin):
class Meta: class Meta:
model = Library model = Library
fields = [ fields = ['cost', 'library', 'duration', 'notes', 'survey_mail_send']
'realname', exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
'email',
'cost',
'library',
'duration',
'notes',
]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -223,32 +286,43 @@ class SoftwareForm(LibraryForm):
model = Software model = Software
class IFGForm(BaseApplicationForm): class HonoraryCertificateForm(FdbForm):
class Meta:
model = HonoraryCertificate
fields = ['request_url', 'project']
exclude = ['intern_notes']
class Media:
js = ('dropdown/js/otrs_link.js',)
class IFGForm(BaseApplicationForm, CommonOrderMixin):
class Meta: class Meta:
model = IFG model = IFG
fields = [ fields = ['cost', 'url', 'notes']
'realname', exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
'email',
'cost',
'url',
'notes',
]
class TermsForm(BaseApplicationForm): class CheckForm(FdbForm):
terms_accepted_label = 'Ich stimme den <a href="{}">Nutzungsbedingungen</a> zu.' termstoaccept = settings.NUTZUNGSBEDINGUNGEN
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['terms_accepted'].required = True # Check if the model field 'terms_accepted' is present
self.fields['terms_accepted'].label = format_html(self.terms_accepted_label, self.terms_accepted_url) if 'terms_accepted' in self.fields:
# Make the field required (HTML5 validation)
self.fields['terms_accepted'].required = True
# Set custom label with link to terms
self.fields['terms_accepted'].label = format_html(
"Ich stimme den <a href='{}'>Nutzungsbedingungen</a> zu",
self.termstoaccept
)
class LiteratureForm(TermsForm): class LiteratureForm(BaseApplicationForm, CommonOrderMixin):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM termstoaccept = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -256,18 +330,8 @@ class LiteratureForm(TermsForm):
class Meta: class Meta:
model = Literature model = Literature
fields = [ fields = ['cost', 'info', 'source', 'notes', 'selfbuy', 'selfbuy_data', 'selfbuy_give_data', 'terms_accepted']
'realname', exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
'email',
'cost',
'info',
'source',
'notes',
'selfbuy',
'selfbuy_data',
'selfbuy_give_data',
'terms_accepted',
]
class Media: class Media:
js = ('dropdown/js/literature.js',) js = ('dropdown/js/literature.js',)
@ -279,8 +343,8 @@ ADULT_CHOICES = {
} }
class EmailForm(TermsForm): class EmailForm(BaseApplicationForm, CommonOrderMixin):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE termstoaccept = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE
# this is the code, to change required to false if needed # this is the code, to change required to false if needed
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -288,27 +352,20 @@ class EmailForm(TermsForm):
self.fields['adult'].required = True self.fields['adult'].required = True
self.fields['other'].required = True self.fields['other'].required = True
adult = RadioField(label='Volljährigkeit', choices=ADULT_CHOICES) adult = ChoiceField(label='Volljährigkeit', choices=ADULT_CHOICES.items(), widget=RadioSelect())
# TODO: add some javascript to show/hide other-field # TODO: add some javascript to show/hide other-field
class Meta: class Meta:
model = Email model = Email
fields = [ fields = ['domain', 'address', 'other', 'adult', 'terms_accepted']
'realname', exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
'email',
'domain',
'address',
'other',
'adult',
'terms_accepted',
]
class Media: class Media:
js = ('dropdown/js/mail.js',) js = ('dropdown/js/mail.js',)
class BusinessCardForm(TermsForm): class BusinessCardForm(BaseApplicationForm, CommonOrderMixin):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN termstoaccept = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN
# this is the code, to change required to false if needed # this is the code, to change required to false if needed
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -318,34 +375,20 @@ class BusinessCardForm(TermsForm):
class Meta: class Meta:
model = BusinessCard model = BusinessCard
fields = [ exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
'realname', fields = ['project', 'data', 'variant', 'url_of_pic', 'send_data_to_print', 'sent_to', 'terms_accepted']
'email',
'project',
'data',
'variant',
'url_of_pic',
'send_data_to_print',
'sent_to',
'terms_accepted',
]
class Media: class Media:
js = ('dropdown/js/businessCard.js',) js = ('dropdown/js/businessCard.js',)
class ListForm(TermsForm): class ListForm(BaseApplicationForm, CommonOrderMixin):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN termstoaccept = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN
class Meta: class Meta:
model = List model = List
fields = [ fields = ['domain', 'address', 'terms_accepted']
'realname', exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
'email',
'domain',
'address',
'terms_accepted',
]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -11,9 +11,6 @@ from django.utils.functional import cached_property, classproperty
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 foerderbarometer.constants import *
EMAIL_STATES = { EMAIL_STATES = {
'NONE': 'noch keine Mail versendet', 'NONE': 'noch keine Mail versendet',
'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet', 'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet',
@ -25,7 +22,7 @@ EMAIL_STATES = {
class TermsConsentMixin(models.Model): class TermsConsentMixin(models.Model):
"""Abstract mixin to add a terms_accepted field for documenting user consent.""" """Abstract mixin to add a terms_accepted field for documenting user consent."""
terms_accepted = models.BooleanField(default=False, verbose_name='Nutzungsbedingungen zugestimmt') terms_accepted = models.BooleanField(default=False, verbose_name="Nutzungsbedingungen zugestimmt")
class Meta: class Meta:
abstract = True abstract = True
@ -203,14 +200,6 @@ class ProjectCategoryField(models.ManyToManyField):
return super().formfield(**kwargs) return super().formfield(**kwargs)
def save_form_data(self, instance, data):
data = list(data)
with suppress(ValueError):
data.remove(self.remote_field.model.other)
return super().save_form_data(instance, data)
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')
@ -414,6 +403,17 @@ def type_link(path, label):
) )
TYPE_BIB = 'BIB'
TYPE_ELIT = 'ELIT'
TYPE_MAIL = 'MAIL'
TYPE_IFG = 'IFG'
TYPE_LIT = 'LIT'
TYPE_LIST = 'LIST'
TYPE_TRAV = 'TRAV'
TYPE_SOFT = 'SOFT'
TYPE_VIS = 'VIS'
TYPE_PROJ = 'PROJ'
TYPE_CHOICES = { TYPE_CHOICES = {
TYPE_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'), TYPE_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'),
TYPE_ELIT: type_link('Zugang_zu_Fachliteratur#eLiteraturstipendium', 'eLiteraturstipendium'), TYPE_ELIT: type_link('Zugang_zu_Fachliteratur#eLiteraturstipendium', 'eLiteraturstipendium'),

View File

@ -1,17 +1,10 @@
{% extends 'input/base.html' %} <!doctype html>
<html lang="de">
{% block head_extra %} <head>
<meta charset="utf-8">
<title>Projektförderung ab 1.000,— EUR</title> <title>Projektförderung ab 1.000,— EUR</title>
<style> </head>
.wm-main { <body>
max-width: 80vw;
margin: 0 auto;
text-align: center;
}
</style>
{% endblock %}
{% block content %}
<h1>Projektförderung mit einer Gesamtsumme ab 1.000,— EUR</h1> <h1>Projektförderung mit einer Gesamtsumme ab 1.000,— EUR</h1>
<p>Vielen Dank für dein Interesse an einer Projektförderung!<br> <p>Vielen Dank für dein Interesse an einer Projektförderung!<br>
Für Projektförderungen mit einer Gesamtsumme ab 1.000,— EUR ist ein öffentlicher Projektplan Für Projektförderungen mit einer Gesamtsumme ab 1.000,— EUR ist ein öffentlicher Projektplan
@ -21,4 +14,5 @@
Für Fragen steht dir das Team Community-Konferenzen &amp; Förderung gern unter Für Fragen steht dir das Team Community-Konferenzen &amp; Förderung gern unter
<a href="mailto:community@wikimedia.de">community@wikimedia.de</a> zur Verfügung. <a href="mailto:community@wikimedia.de">community@wikimedia.de</a> zur Verfügung.
</p> </p>
{% endblock %} </body>
</html>

View File

@ -1,3 +1,2 @@
from .admin import AdminTestCase
from .models import ModelTestCase from .models import ModelTestCase
from .views import AuthenticatedViewTestCase, AnonymousViewTestCase from .views import AuthenticatedViewTestCase, AnonymousViewTestCase

View File

@ -1,115 +0,0 @@
import datetime
from django.core import mail
from django.forms import model_to_dict
from django.test import TestCase
from input.models import (
Project,
ProjectRequest,
ProjectDeclined,
Library,
ELiterature,
Email,
IFG,
Literature,
List,
Travel,
Software,
BusinessCard, ProjectCategory, WikimediaProject, Account,
)
from input.utils.admin import admin_url
from input.utils.testing import request, create_superuser, login
class AdminTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = user = create_superuser('admin', first_name='Max', last_name='Mustermann')
cls.data = data = {
'realname': f'{user.first_name} {user.last_name}',
'email': user.email,
}
cls.objs = [
ProjectCategory.objects.order_by('?').first(),
WikimediaProject.objects.order_by('?').first(),
Project.objects.create(**data, granted=True),
ProjectRequest.objects.create(**data, granted=None),
ProjectDeclined.objects.create(**data, granted=False),
Library.objects.create(**data, library='Test'),
ELiterature.objects.create(**data, library='Test'),
Software.objects.create(**data, library='Test'),
Email.objects.create(**data),
IFG.objects.create(**data),
Literature.objects.create(**data, selfbuy_give_data=False),
List.objects.create(**data),
Travel.objects.create(**data),
BusinessCard.objects.create(**data),
]
def setUp(self):
login(self)
def test_changelists(self):
for obj in self.objs:
model = type(obj)
url = admin_url(model, 'changelist')
with self.subTest(model=model):
request(self, url)
def test_change_views(self):
for obj in self.objs:
model = type(obj)
url = admin_url(model, 'change', obj.id)
with self.subTest(model=model):
request(self, url)
def test_display_values(self):
for obj in self.objs:
model = type(obj)
with self.subTest(model=model):
self.assertTrue(f'{obj}')
def test_grant_project_request(self):
account = Account.objects.create(code='test')
category = ProjectCategory.objects.first()
wikimedia = WikimediaProject.objects.first()
obj = ProjectRequest.objects.create(
**self.data,
name='Test',
description='Test',
otrs='https://example.com',
granted=None,
start=datetime.date(2025, 1, 1),
end=datetime.date(2026, 1, 1),
)
obj.categories.add(category)
obj.wikimedia_projects.add(wikimedia)
url = admin_url(ProjectRequest, 'change', obj.id)
expected_url = admin_url(ProjectRequest, 'changelist')
data = {
**model_to_dict(obj),
'granted': True,
'granted_date': obj.start,
'granted_from': self.user.username,
'account': account.code,
'categories': [category.id],
'wikimedia_projects': [wikimedia.id],
}
for key in list(data):
if data[key] is None:
data.pop(key)
with self.captureOnCommitCallbacks(execute=True):
request(self, url, expected_url=expected_url, data=data)
self.assertEqual(len(mail.outbox), 2)

View File

@ -90,7 +90,6 @@ class AnonymousViewTestCase(TestCase):
'selfbuy_data': 'NONE', 'selfbuy_data': 'NONE',
'selfbuy_give_data': True, 'selfbuy_give_data': True,
'check': True, 'check': True,
'terms_accepted': True,
}) })
def test_extern_lit_without_consent_fails(self): def test_extern_lit_without_consent_fails(self):

View File

@ -1,18 +0,0 @@
from django.contrib import admin
from django.db.models import Model
from django.urls import reverse
def admin_url(model: type[Model], view: str, *args, site=None, **kwargs) -> str:
return reverse(admin_url_name(model, view, site=site), args=args, kwargs=kwargs)
def admin_url_name(model: type[Model], view: str, *, site=None) -> str:
namespace = (site or admin.site).name
view_name = admin_view_name(model, view)
return f'{namespace}:{view_name}'
def admin_view_name(model: type[Model], view: str) -> str:
return f'{model._meta.app_label}_{model._meta.model_name}_{view}'

View File

@ -4,12 +4,13 @@ from django.template.loader import get_template
from input.models import Project from input.models import Project
from .attachments import collect_and_attach from .attachments import collect_attachment_paths, attach_files
__all__ = [ __all__ = [
'build_email', 'build_email',
'send_email', 'send_email',
'collect_and_attach', 'collect_attachment_paths',
'attach_files',
'send_applicant_decision_mail', 'send_applicant_decision_mail',
'send_staff_decision_mail', 'send_staff_decision_mail',
'send_decision_mails', 'send_decision_mails',
@ -57,7 +58,7 @@ def send_base_decision_mail(obj: Project, scope: str, subject: str, recipient: s
decision_label = 'bewilligt' if obj.granted else 'abgelehnt' decision_label = 'bewilligt' if obj.granted else 'abgelehnt'
subject = subject.format(name=obj.name, decision=decision_label) subject = subject.format(name=obj.name, decision=decision_label)
return send_email(f'approval_{decision}_{scope}', context, subject, recipient) return send_email(f'approval_{scope}_{decision}', context, subject, recipient)
def send_applicant_decision_mail(obj: Project): def send_applicant_decision_mail(obj: Project):

View File

@ -1,98 +1,133 @@
import hashlib
import os import os
import posixpath
import time import time
import urllib.request
import urllib.parse
import mimetypes import mimetypes
from os import PathLike
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from typing import Iterable, List, Tuple
from urllib.request import urlretrieve
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from foerderbarometer.constants import *
PathList = list[Path] def _ensure_cache_dir() -> Path:
def ensure_dir(directory: PathLike) -> Path:
""" """
Ensure that the given directory exists. Ensure that the cache directory for attachments exists.
Creates it recursively if it doesn't. Creates it recursively if it doesn't.
""" """
cache_dir = Path(settings.MAIL_ATTACHMENT_CACHE_DIR)
directory = Path(directory) cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
directory.mkdir(parents=True, exist_ok=True)
return directory
def is_fresh(path: Path, ttl_seconds: int) -> bool: def _cached_filename_for(url: str) -> str:
"""
Generate a unique cache filename for the given URL (hash + original suffix if present).
"""
h = hashlib.sha1(url.encode('utf-8')).hexdigest()[:16]
parsed = urllib.parse.urlparse(url)
# path part only (without query/fragment)
name = Path(parsed.path).name # e.g. 'foo.pdf'
suffix = Path(name).suffix # e.g. '.pdf'
return f'{h}{suffix}' if suffix else h
def _is_fresh(path: Path, ttl_seconds: int) -> bool:
""" """
Check if the cached file exists and is still fresh within TTL. Check if the cached file exists and is still fresh within TTL.
""" """
try: try:
mtime = path.stat().st_mtime age = time.time() - path.stat().st_mtime
return age < ttl_seconds
except FileNotFoundError: except FileNotFoundError:
return False return False
else:
return time.time() - mtime < ttl_seconds
def download_with_cache(url: str, *, timeout: float = 10.0, size_cap_bytes: int = 8 * 1024 * 1024) -> Path:
"""
Download the file from the given URL into the cache directory, or return the cached
file if it's still fresh. Uses a temporary '.part' file and atomic replace.
A simple size cap protects against unexpectedly large downloads.
"""
cache_dir = _ensure_cache_dir()
ttl = int(getattr(settings, 'MAIL_ATTACHMENT_TTL_SECONDS', 86400))
filename = _cached_filename_for(url)
path = cache_dir / filename
def get_attachment(url: str) -> Path: if _is_fresh(path, ttl):
filepath = urlparse(url).path return path
filename = posixpath.basename(filepath)
destination = ensure_dir(settings.MAIL_ATTACHMENT_CACHE_DIR) / filename
if is_fresh(destination, settings.MAIL_ATTACHMENT_TTL_SECONDS):
return destination
return download_attachment(url, destination)
def download_attachment(url: str, destination: Path) -> Path:
filepath = destination.with_suffix('.tmp')
tmp_path = path.with_suffix(path.suffix + '.part')
try: try:
urlretrieve(url, filepath) with urllib.request.urlopen(url, timeout=timeout) as resp, open(tmp_path, 'wb') as f:
os.replace(filepath, destination) # Read in chunks up to size_cap_bytes
finally: remaining = size_cap_bytes
filepath.unlink(missing_ok=True) chunk_size = 64 * 1024
while True:
chunk = resp.read(min(chunk_size, remaining))
if not chunk:
break
f.write(chunk)
remaining -= len(chunk)
if remaining <= 0:
break
os.replace(tmp_path, path)
return path
except Exception:
# Best-effort cleanup of partial file
try:
if tmp_path.exists():
tmp_path.unlink(missing_ok=True)
except Exception:
pass
# Re-raise to let caller decide
raise
return destination def _filename_from_url(url: str) -> str:
"""
Derive a display filename from URL path as a fallback when none provided in settings.
"""
parsed = urllib.parse.urlparse(url)
name = Path(parsed.path).name or 'attachment'
return name
def collect_attachment_paths(recipient: str, type_code: str) -> PathList: def collect_attachment_paths(kind: str, choice: str) -> List[Tuple[Path, str]]:
assert recipient in RECIPIENTS """
assert type_code in TYPES Return a list of (path, filename) for attachments based on settings.MAIL_ATTACHMENT_URLS.
Supports both 'url' strings and (url, filename) tuples.
"""
cfg = getattr(settings, 'MAIL_ATTACHMENT_URLS', {})
channel = cfg.get(kind, {})
urls: list = []
urls.extend(channel.get('ALL', []))
urls.extend(channel.get(choice, []))
config = settings.MAIL_ATTACHMENT_URLS[recipient] result: List[Tuple[Path, str]] = []
urls = [*config[TYPE_ALL], *config.get(type_code, [])] for item in urls:
if isinstance(item, tuple):
url, filename = item
else:
url, filename = item, _filename_from_url(item)
return [get_attachment(url) for url in urls] path = download_with_cache(url)
# Only append if the file exists (download_with_cache raises on error by default)
result.append((path, filename))
return result
def get_mime_type(path: Path) -> str: def attach_files(message: EmailMultiAlternatives, files: Iterable[Tuple[Path, str]]) -> None:
mime_type, encoding = mimetypes.guess_type(path)
return mime_type or 'application/octet-stream'
def attach_files(message: EmailMultiAlternatives, files: list[Path]):
""" """
Attach files to the EmailMultiAlternatives message. Attach files to the EmailMultiAlternatives message.
MIME type is guessed from path; falls back to application/octet-stream. MIME type is guessed from filename; falls back to application/octet-stream.
""" """
for path, filename in files:
# Guess MIME type from final filename first; fallback to path suffix
ctype, _ = mimetypes.guess_type(filename)
if not ctype:
ctype, _ = mimetypes.guess_type(str(path))
ctype = ctype or 'application/octet-stream'
for path in files: with open(path, 'rb') as f:
mime_type = get_mime_type(path) message.attach(filename, f.read(), ctype)
with open(path, 'rb') as fp:
message.attach(path.name, fp.read(), mime_type)
def collect_and_attach(email: EmailMultiAlternatives, recipient: str, type_code: str):
return attach_files(email, collect_attachment_paths(recipient, type_code))

View File

@ -12,7 +12,7 @@ from django.views.generic import TemplateView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from django.utils.html import strip_tags from django.utils.html import strip_tags
from input.utils.mail import build_email, collect_and_attach from input.utils.mail import collect_attachment_paths, attach_files, build_email
from .forms import ( from .forms import (
BaseApplicationForm, BaseApplicationForm,
@ -289,8 +289,9 @@ class ApplicationView(FormView):
def send_email(self, kind, template_name, subject, recipient, context, *, fail_silently=False): def send_email(self, kind, template_name, subject, recipient, context, *, fail_silently=False):
email = build_email(template_name, context, subject, recipient) email = build_email(template_name, context, subject, recipient)
applicant_files = collect_attachment_paths(kind=kind, choice=self.type_code)
collect_and_attach(email, kind, self.type_code) attach_files(email, applicant_files)
return email.send(fail_silently) return email.send(fail_silently)