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
http://localhost:8000/
http://localhost:8000/intern/ (login required)
http://localhost:8000/admin/ (login reqiured)
## docker compose development setup
@ -43,6 +44,7 @@ Create your superuser account with
You can access the application via
http://localhost:8000/
http://localhost:8000/intern/ (login required)
http://localhost:8000/admin/ (login required)
## 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"
## 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.
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:
* `TYPE_BIB` Bibliotheksstipendium
* `TYPE_ELIT` eLiteraturstipendium
* `TYPE_SOFT` Softwarestipendium
* `TYPE_MAIL` E-Mail-Adresse
* `TYPE_IFG` Kostenübernahme IFG-Anfrage
* `TYPE_LIT` Literaturstipendium
* `TYPE_LIST` Mailingliste
* `TYPE_TRAV` Reisekosten
* `TYPE_VIS` Visitenkarten
* `TYPE_PROJ` Projektförderung
For further details see `foerderbarometer/settings.py`
asgiref==3.2.10
Django==3.1.2
gunicorn==20.0.4
mysqlclient==2.1.1
sqlparse==0.4.3
whitenoise==6.2.0
asgiref==3.2.10
Authlib==1.2.1
certifi==2023.7.22
cffi==1.16.0
chardet==5.2.0
charset-normalizer==3.3.0
cryptography==41.0.4
idna==3.4
pycparser==2.21
pytz==2023.3.post1
requests==2.31.0
six==1.16.0
typing_extensions==4.8.0
urllib3==2.0.6
## 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 .constants import *
BASE_DIR = Path(__file__).parents[1]
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_VISITENKARTEN = 'static/input/nutzungsbedingungen-visitenkarten.pdf'
MAIL_ATTACHMENT_CACHE_DIR = env('MAIL_ATTACHMENT_CACHE_DIR', BASE_DIR / 'var' / 'mail-attachments')
MAIL_ATTACHMENT_TTL_SECONDS = env('MAIL_ATTACHMENT_TTL_SECONDS', 24 * 60 * 60)
# Directory where downloaded attachments will be cached
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 = {
RECIPIENT_APPLICANT: {
TYPE_ALL: [],
TYPE_VIS: [
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-visitenkarten.pdf',
],
TYPE_MAIL: [
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mail.pdf',
],
TYPE_LIST: [
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mailinglisten.pdf',
],
TYPE_LIT: [
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-literaturstipendium.pdf',
],
'applicant': {
# Global attachments for all applicant emails
'ALL': [],
# Special attachments for specific services:
'VIS': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-visitenkarten.pdf', 'Nutzungsbedingungen-Visitenkarten.pdf')], # Business cards
'MAIL': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mail.pdf', 'Nutzungsbedingungen-Mail.pdf')], # Emails
'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
},
RECIPIENT_STAFF: {
TYPE_ALL: [],
'staff': {
# 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.conf import settings
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.utils.html import format_html
from django.utils.safestring import mark_safe
from .models import (
TYPE_CHOICES,
Project,
ProjectCategory,
WikimediaProject,
ConcreteVolunteer,
ConcreteExtern,
IFG,
Library,
ELiterature,
Software,
HonoraryCertificate,
Travel,
Email,
Literature,
@ -23,33 +27,108 @@ from .models import (
class TableFormRenderer(DjangoTemplates):
"""
Set in settings as the default form renderer.
"""
form_template_name = 'django/forms/table.html'
class RadioField(forms.ChoiceField):
widget = forms.RadioSelect
class BaseApplicationForm(ModelForm):
"""
Base form for all external applications.
"""
class FdbForm(ModelForm):
'''this base class provides the required css class for all forms'''
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,
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
),
help_text='Bitte gib deinen Vor- und Nachnamen ein.'
)
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(
@ -87,7 +166,7 @@ class BaseProjectForm(ModelForm):
return cleaned_data
class ProjectForm(BaseProjectForm, BaseApplicationForm):
class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
OPTIONAL_FIELDS = {
'categories_other',
'wikimedia_projects_other',
@ -107,8 +186,6 @@ class ProjectForm(BaseProjectForm, BaseApplicationForm):
class Meta:
model = Project
fields = [
'realname',
'email',
'name',
'description',
'categories',
@ -150,10 +227,10 @@ HOTEL_CHOICES = {
}
class TravelForm(BaseApplicationForm):
class TravelForm(BaseApplicationForm, CommonOrderMixin):
# 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
def __init__(self, *args, **kwargs):
@ -167,17 +244,9 @@ class TravelForm(BaseApplicationForm):
class Meta:
model = Travel
fields = [
'realname',
'email',
'project_name',
'transport',
'travelcost',
'checkin',
'checkout',
'hotel',
'notes',
]
fields = ['project_name', 'transport', 'travelcost', 'checkin', 'checkout', 'hotel', 'notes']
exclude = ('granted', 'granted_date', 'survey_mail_send', 'realname', 'email', 'survey_mail_date', 'project',
'request_url', 'payed_for_hotel_by', 'payed_for_travel_by', 'intern_notes', 'mail_state')
widgets = {
'checkin': AdminDateWidget,
'checkout': AdminDateWidget,
@ -190,18 +259,12 @@ class TravelForm(BaseApplicationForm):
}
class LibraryForm(BaseApplicationForm):
class LibraryForm(BaseApplicationForm, CommonOrderMixin):
class Meta:
model = Library
fields = [
'realname',
'email',
'cost',
'library',
'duration',
'notes',
]
fields = ['cost', 'library', 'duration', 'notes', 'survey_mail_send']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -223,32 +286,43 @@ class SoftwareForm(LibraryForm):
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:
model = IFG
fields = [
'realname',
'email',
'cost',
'url',
'notes',
]
fields = ['cost', 'url', 'notes']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
class TermsForm(BaseApplicationForm):
terms_accepted_label = 'Ich stimme den <a href="{}">Nutzungsbedingungen</a> zu.'
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN
class CheckForm(FdbForm):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['terms_accepted'].required = True
self.fields['terms_accepted'].label = format_html(self.terms_accepted_label, self.terms_accepted_url)
# Check if the model field 'terms_accepted' is present
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):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM
class LiteratureForm(BaseApplicationForm, CommonOrderMixin):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -256,18 +330,8 @@ class LiteratureForm(TermsForm):
class Meta:
model = Literature
fields = [
'realname',
'email',
'cost',
'info',
'source',
'notes',
'selfbuy',
'selfbuy_data',
'selfbuy_give_data',
'terms_accepted',
]
fields = ['cost', 'info', 'source', 'notes', 'selfbuy', 'selfbuy_data', 'selfbuy_give_data', 'terms_accepted']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
class Media:
js = ('dropdown/js/literature.js',)
@ -279,8 +343,8 @@ ADULT_CHOICES = {
}
class EmailForm(TermsForm):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE
class EmailForm(BaseApplicationForm, CommonOrderMixin):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE
# this is the code, to change required to false if needed
def __init__(self, *args, **kwargs):
@ -288,27 +352,20 @@ class EmailForm(TermsForm):
self.fields['adult'].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
class Meta:
model = Email
fields = [
'realname',
'email',
'domain',
'address',
'other',
'adult',
'terms_accepted',
]
fields = ['domain', 'address', 'other', 'adult', 'terms_accepted']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
class Media:
js = ('dropdown/js/mail.js',)
class BusinessCardForm(TermsForm):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN
class BusinessCardForm(BaseApplicationForm, CommonOrderMixin):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN
# this is the code, to change required to false if needed
def __init__(self, *args, **kwargs):
@ -318,34 +375,20 @@ class BusinessCardForm(TermsForm):
class Meta:
model = BusinessCard
fields = [
'realname',
'email',
'project',
'data',
'variant',
'url_of_pic',
'send_data_to_print',
'sent_to',
'terms_accepted',
]
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
fields = ['project', 'data', 'variant', 'url_of_pic', 'send_data_to_print', 'sent_to', 'terms_accepted']
class Media:
js = ('dropdown/js/businessCard.js',)
class ListForm(TermsForm):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN
class ListForm(BaseApplicationForm, CommonOrderMixin):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN
class Meta:
model = List
fields = [
'realname',
'email',
'domain',
'address',
'terms_accepted',
]
fields = ['domain', 'address', 'terms_accepted']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
def __init__(self, *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.safestring import mark_safe
from foerderbarometer.constants import *
EMAIL_STATES = {
'NONE': 'noch keine Mail versendet',
'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet',
@ -25,7 +22,7 @@ EMAIL_STATES = {
class TermsConsentMixin(models.Model):
"""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:
abstract = True
@ -203,14 +200,6 @@ class ProjectCategoryField(models.ManyToManyField):
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):
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_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'),
TYPE_ELIT: type_link('Zugang_zu_Fachliteratur#eLiteraturstipendium', 'eLiteraturstipendium'),

View File

@ -1,17 +1,10 @@
{% extends 'input/base.html' %}
{% block head_extra %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Projektförderung ab 1.000,— EUR</title>
<style>
.wm-main {
max-width: 80vw;
margin: 0 auto;
text-align: center;
}
</style>
{% endblock %}
{% block content %}
</head>
<body>
<h1>Projektförderung mit einer Gesamtsumme ab 1.000,— EUR</h1>
<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
@ -21,4 +14,5 @@
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.
</p>
{% endblock %}
</body>
</html>

View File

@ -1,3 +1,2 @@
from .admin import AdminTestCase
from .models import ModelTestCase
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_give_data': True,
'check': True,
'terms_accepted': True,
})
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 .attachments import collect_and_attach
from .attachments import collect_attachment_paths, attach_files
__all__ = [
'build_email',
'send_email',
'collect_and_attach',
'collect_attachment_paths',
'attach_files',
'send_applicant_decision_mail',
'send_staff_decision_mail',
'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'
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):

View File

@ -1,98 +1,133 @@
import hashlib
import os
import posixpath
import time
import urllib.request
import urllib.parse
import mimetypes
from os import PathLike
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import urlretrieve
from typing import Iterable, List, Tuple
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from foerderbarometer.constants import *
PathList = list[Path]
def ensure_dir(directory: PathLike) -> Path:
def _ensure_cache_dir() -> Path:
"""
Ensure that the given directory exists.
Ensure that the cache directory for attachments exists.
Creates it recursively if it doesn't.
"""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
return directory
cache_dir = Path(settings.MAIL_ATTACHMENT_CACHE_DIR)
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
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.
"""
try:
mtime = path.stat().st_mtime
age = time.time() - path.stat().st_mtime
return age < ttl_seconds
except FileNotFoundError:
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:
filepath = urlparse(url).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')
if _is_fresh(path, ttl):
return path
tmp_path = path.with_suffix(path.suffix + '.part')
try:
urlretrieve(url, filepath)
os.replace(filepath, destination)
finally:
filepath.unlink(missing_ok=True)
with urllib.request.urlopen(url, timeout=timeout) as resp, open(tmp_path, 'wb') as f:
# Read in chunks up to size_cap_bytes
remaining = size_cap_bytes
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:
assert recipient in RECIPIENTS
assert type_code in TYPES
def collect_attachment_paths(kind: str, choice: str) -> List[Tuple[Path, str]]:
"""
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]
urls = [*config[TYPE_ALL], *config.get(type_code, [])]
result: List[Tuple[Path, str]] = []
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:
mime_type, encoding = mimetypes.guess_type(path)
return mime_type or 'application/octet-stream'
def attach_files(message: EmailMultiAlternatives, files: list[Path]):
def attach_files(message: EmailMultiAlternatives, files: Iterable[Tuple[Path, str]]) -> None:
"""
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:
mime_type = get_mime_type(path)
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))
with open(path, 'rb') as f:
message.attach(filename, f.read(), ctype)

View File

@ -12,7 +12,7 @@ from django.views.generic import TemplateView
from django.views.generic.edit import FormView
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 (
BaseApplicationForm,
@ -289,8 +289,9 @@ class ApplicationView(FormView):
def send_email(self, kind, template_name, subject, recipient, context, *, fail_silently=False):
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)