Compare commits

..

10 Commits

Author SHA1 Message Date
Oliver Zander ebd7ebd3fd added admin tests 2025-10-21 15:35:24 +02:00
Oliver Zander a750f0d7d2 fixed template name 2025-10-21 15:35:12 +02:00
Oliver Zander 864df9613a cleaned up forms & fixed missing terms field 2025-10-20 15:39:33 +02:00
Oliver Zander 1dbd38dc4a remove other option before saving 2025-10-20 14:45:25 +02:00
Oliver Zander 2c79732200 use base template for project funding info page 2025-10-20 14:45:25 +02:00
Andreas Gohr e8848b0c97 README improvements
* removed dependency infos - those are in requirements.txt
* added info on mail attachments
* removed obsolete /intern endpoint
2025-10-20 11:39:21 +02:00
Oliver Zander 7365218adb added attachment helper 2025-10-17 17:31:51 +02:00
Oliver Zander c751a9fc37 Merge branch 'WM-4' into 'cosmocode'
[WM-4] (3) E-Mail Versand

Closes WM-4

See merge request wikimedia/foerderbarometer!9
2025-10-17 17:25:21 +02:00
Oliver Zander 1c98092473 improved attachment download code 2025-10-17 17:22:52 +02:00
Oliver Zander 7fcde34897 clean up mail attachment code 2025-10-17 16:04:14 +02:00
13 changed files with 402 additions and 319 deletions

View File

@ -25,7 +25,6 @@ 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
@ -44,7 +43,6 @@ 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
@ -56,28 +54,24 @@ entries to a csv file
- There is a new button in the bottom of every Project to "save as new"
## versions used in development
## mail attachments
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
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`
## testing

View File

@ -0,0 +1,33 @@
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,6 +6,8 @@ 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')
@ -166,32 +168,25 @@ NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM = 'static/input/nutzungsbedingungen-lite
NUTZUNGSBEDINGUNGEN_OTRS = 'static/input/2025_Nutzungsvereinbarung_OTRS.docx.pdf'
NUTZUNGSBEDINGUNGEN_VISITENKARTEN = 'static/input/nutzungsbedingungen-visitenkarten.pdf'
# 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_CACHE_DIR = env('MAIL_ATTACHMENT_CACHE_DIR', BASE_DIR / 'var' / 'mail-attachments')
MAIL_ATTACHMENT_TTL_SECONDS = env('MAIL_ATTACHMENT_TTL_SECONDS', 24 * 60 * 60)
MAIL_ATTACHMENT_URLS = {
'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_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',
],
},
'staff': {
# Global attachments for all staff emails
'ALL': [],
# Example: 'IFG': ['https://example.com/internal-guideline.pdf']
RECIPIENT_STAFF: {
TYPE_ALL: [],
},
}

View File

@ -1,23 +1,19 @@
from django import forms
from django.conf import settings
from django.contrib.admin.widgets import AdminDateWidget
from django.forms import ModelForm, ChoiceField, RadioSelect, BooleanField, CharField, EmailField
from django.forms import ModelForm
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,
@ -27,108 +23,33 @@ from .models import (
class TableFormRenderer(DjangoTemplates):
"""
Set in settings as the default form renderer.
"""
form_template_name = 'django/forms/table.html'
class FdbForm(ModelForm):
'''this base class provides the required css class for all forms'''
required_css_class = 'required'
class RadioField(forms.ChoiceField):
widget = forms.RadioSelect
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):
class BaseApplicationForm(ModelForm):
"""
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_css_class = 'required'
check = forms.BooleanField(
required=True,
help_text='Bitte gib deinen Vor- und Nachnamen ein.'
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
),
)
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(
@ -166,7 +87,7 @@ class BaseProjectForm(ModelForm):
return cleaned_data
class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
class ProjectForm(BaseProjectForm, BaseApplicationForm):
OPTIONAL_FIELDS = {
'categories_other',
'wikimedia_projects_other',
@ -186,6 +107,8 @@ class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
class Meta:
model = Project
fields = [
'realname',
'email',
'name',
'description',
'categories',
@ -227,10 +150,10 @@ HOTEL_CHOICES = {
}
class TravelForm(BaseApplicationForm, CommonOrderMixin):
class TravelForm(BaseApplicationForm):
# TODO: add some javascript to show/hide other-field
hotel = ChoiceField(label='Hotelzimmer benötigt:', choices=HOTEL_CHOICES.items(), widget=RadioSelect())
hotel = RadioField(label='Hotelzimmer benötigt', choices=HOTEL_CHOICES)
# this is the code, to change required to false if needed
def __init__(self, *args, **kwargs):
@ -244,9 +167,17 @@ class TravelForm(BaseApplicationForm, CommonOrderMixin):
class Meta:
model = Travel
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')
fields = [
'realname',
'email',
'project_name',
'transport',
'travelcost',
'checkin',
'checkout',
'hotel',
'notes',
]
widgets = {
'checkin': AdminDateWidget,
'checkout': AdminDateWidget,
@ -259,12 +190,18 @@ class TravelForm(BaseApplicationForm, CommonOrderMixin):
}
class LibraryForm(BaseApplicationForm, CommonOrderMixin):
class LibraryForm(BaseApplicationForm):
class Meta:
model = Library
fields = ['cost', 'library', 'duration', 'notes', 'survey_mail_send']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
fields = [
'realname',
'email',
'cost',
'library',
'duration',
'notes',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -286,43 +223,32 @@ class SoftwareForm(LibraryForm):
model = Software
class HonoraryCertificateForm(FdbForm):
class IFGForm(BaseApplicationForm):
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 = ['cost', 'url', 'notes']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
fields = [
'realname',
'email',
'cost',
'url',
'notes',
]
class CheckForm(FdbForm):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN
class TermsForm(BaseApplicationForm):
terms_accepted_label = 'Ich stimme den <a href="{}">Nutzungsbedingungen</a> zu.'
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 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
)
self.fields['terms_accepted'].required = True
self.fields['terms_accepted'].label = format_html(self.terms_accepted_label, self.terms_accepted_url)
class LiteratureForm(BaseApplicationForm, CommonOrderMixin):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM
class LiteratureForm(TermsForm):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -330,8 +256,18 @@ class LiteratureForm(BaseApplicationForm, CommonOrderMixin):
class Meta:
model = Literature
fields = ['cost', 'info', 'source', 'notes', 'selfbuy', 'selfbuy_data', 'selfbuy_give_data', 'terms_accepted']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
fields = [
'realname',
'email',
'cost',
'info',
'source',
'notes',
'selfbuy',
'selfbuy_data',
'selfbuy_give_data',
'terms_accepted',
]
class Media:
js = ('dropdown/js/literature.js',)
@ -343,8 +279,8 @@ ADULT_CHOICES = {
}
class EmailForm(BaseApplicationForm, CommonOrderMixin):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE
class EmailForm(TermsForm):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE
# this is the code, to change required to false if needed
def __init__(self, *args, **kwargs):
@ -352,20 +288,27 @@ class EmailForm(BaseApplicationForm, CommonOrderMixin):
self.fields['adult'].required = True
self.fields['other'].required = True
adult = ChoiceField(label='Volljährigkeit', choices=ADULT_CHOICES.items(), widget=RadioSelect())
adult = RadioField(label='Volljährigkeit', choices=ADULT_CHOICES)
# TODO: add some javascript to show/hide other-field
class Meta:
model = Email
fields = ['domain', 'address', 'other', 'adult', 'terms_accepted']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
fields = [
'realname',
'email',
'domain',
'address',
'other',
'adult',
'terms_accepted',
]
class Media:
js = ('dropdown/js/mail.js',)
class BusinessCardForm(BaseApplicationForm, CommonOrderMixin):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN
class BusinessCardForm(TermsForm):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN
# this is the code, to change required to false if needed
def __init__(self, *args, **kwargs):
@ -375,20 +318,34 @@ class BusinessCardForm(BaseApplicationForm, CommonOrderMixin):
class Meta:
model = BusinessCard
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
fields = ['project', 'data', 'variant', 'url_of_pic', 'send_data_to_print', 'sent_to', 'terms_accepted']
fields = [
'realname',
'email',
'project',
'data',
'variant',
'url_of_pic',
'send_data_to_print',
'sent_to',
'terms_accepted',
]
class Media:
js = ('dropdown/js/businessCard.js',)
class ListForm(BaseApplicationForm, CommonOrderMixin):
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN
class ListForm(TermsForm):
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN
class Meta:
model = List
fields = ['domain', 'address', 'terms_accepted']
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
fields = [
'realname',
'email',
'domain',
'address',
'terms_accepted',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

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

View File

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

115
input/tests/admin.py Normal file
View File

@ -0,0 +1,115 @@
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,6 +90,7 @@ class AnonymousViewTestCase(TestCase):
'selfbuy_data': 'NONE',
'selfbuy_give_data': True,
'check': True,
'terms_accepted': True,
})
def test_extern_lit_without_consent_fails(self):

18
input/utils/admin.py Normal file
View File

@ -0,0 +1,18 @@
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,13 +4,12 @@ from django.template.loader import get_template
from input.models import Project
from .attachments import collect_attachment_paths, attach_files
from .attachments import collect_and_attach
__all__ = [
'build_email',
'send_email',
'collect_attachment_paths',
'attach_files',
'collect_and_attach',
'send_applicant_decision_mail',
'send_staff_decision_mail',
'send_decision_mails',
@ -58,7 +57,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_{scope}_{decision}', context, subject, recipient)
return send_email(f'approval_{decision}_{scope}', context, subject, recipient)
def send_applicant_decision_mail(obj: Project):

View File

@ -1,133 +1,98 @@
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 typing import Iterable, List, Tuple
from urllib.parse import urlparse
from urllib.request import urlretrieve
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from foerderbarometer.constants import *
def _ensure_cache_dir() -> Path:
PathList = list[Path]
def ensure_dir(directory: PathLike) -> Path:
"""
Ensure that the cache directory for attachments exists.
Ensure that the given directory exists.
Creates it recursively if it doesn't.
"""
cache_dir = Path(settings.MAIL_ATTACHMENT_CACHE_DIR)
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
return directory
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:
def is_fresh(path: Path, ttl_seconds: int) -> bool:
"""
Check if the cached file exists and is still fresh within TTL.
"""
try:
age = time.time() - path.stat().st_mtime
return age < ttl_seconds
mtime = path.stat().st_mtime
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
if _is_fresh(path, ttl):
return path
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')
tmp_path = path.with_suffix(path.suffix + '.part')
try:
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
urlretrieve(url, filepath)
os.replace(filepath, destination)
finally:
filepath.unlink(missing_ok=True)
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
return destination
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, []))
def collect_attachment_paths(recipient: str, type_code: str) -> PathList:
assert recipient in RECIPIENTS
assert type_code in TYPES
result: List[Tuple[Path, str]] = []
for item in urls:
if isinstance(item, tuple):
url, filename = item
else:
url, filename = item, _filename_from_url(item)
config = settings.MAIL_ATTACHMENT_URLS[recipient]
urls = [*config[TYPE_ALL], *config.get(type_code, [])]
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
return [get_attachment(url) for url in urls]
def attach_files(message: EmailMultiAlternatives, files: Iterable[Tuple[Path, str]]) -> None:
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]):
"""
Attach files to the EmailMultiAlternatives message.
MIME type is guessed from filename; falls back to application/octet-stream.
MIME type is guessed from path; 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'
with open(path, 'rb') as f:
message.attach(filename, f.read(), ctype)
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))

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 collect_attachment_paths, attach_files, build_email
from input.utils.mail import build_email, collect_and_attach
from .forms import (
BaseApplicationForm,
@ -289,9 +289,8 @@ 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)
attach_files(email, applicant_files)
collect_and_attach(email, kind, self.type_code)
return email.send(fail_silently)