From 5f2d99c037c0940676acc2322c22193057b4a9f4 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 7 Oct 2025 19:43:20 +0200 Subject: [PATCH 01/13] Add robust mail attachment caching and file attachment support with TTL and MIME detection --- input/mail_attachments.py | 130 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 input/mail_attachments.py diff --git a/input/mail_attachments.py b/input/mail_attachments.py new file mode 100644 index 0000000..46e7f26 --- /dev/null +++ b/input/mail_attachments.py @@ -0,0 +1,130 @@ +import hashlib +import os +import time +import urllib.request +import urllib.parse +import mimetypes +from pathlib import Path +from typing import Iterable, List, Tuple +from django.conf import settings +from django.core.mail import EmailMultiAlternatives + +def _ensure_cache_dir() -> Path: + """ + Ensure that the cache directory for attachments 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 + + +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: + age = time.time() - path.stat().st_mtime + return age < ttl_seconds + except FileNotFoundError: + return False + +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 + + 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 + +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(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, [])) + + result: List[Tuple[Path, str]] = [] + for item in urls: + if isinstance(item, tuple): + url, filename = item + else: + url, filename = item, _filename_from_url(item) + + 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 attach_files(message: EmailMultiAlternatives, files: Iterable[Tuple[Path, str]]) -> None: + """ + Attach files to the EmailMultiAlternatives message. + 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' + + with open(path, 'rb') as f: + message.attach(filename, f.read(), ctype) From 1ad4970cbca43e65eed5385337e18695a0142da9 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 7 Oct 2025 19:44:28 +0200 Subject: [PATCH 02/13] Add decision mailers for applicants and staff with approval/denial templates --- input/mailer.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 input/mailer.py diff --git a/input/mailer.py b/input/mailer.py new file mode 100644 index 0000000..d3df345 --- /dev/null +++ b/input/mailer.py @@ -0,0 +1,87 @@ +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import get_template +from django.utils.html import strip_tags +from .models import TYPE_CHOICES + + +def _type_labels(choice: str): + """ + Resolve the human-readable type label. + Returns (HTML label, plain text label). + """ + html = TYPE_CHOICES.get(choice, choice) + plain = strip_tags(str(html)) + return html, plain + + +def _decision_context(obj, choice_code: str) -> dict: + """ + Build a minimal, consistent context for decision mails (applicant & staff). + Also exposes the full project object as 'project' for template access. + """ + type_html, type_plain = _type_labels(choice_code) + realname = getattr(obj, 'realname', '') or getattr(obj, 'email', '') + return { + 'data': { + 'realname': realname, + 'typestring': type_html, + 'typestring_plain': type_plain, + 'name': getattr(obj, 'name', None), + }, + 'project': obj, + } + + +def send_decision_mail(obj, choice_code: str, granted: bool) -> None: + """ + Send a decision email to the applicant after manual approval/denial. + Uses: input/approval_granted.(txt|html) or input/approval_denied.(txt|html) + """ + recipient = getattr(obj, 'email', None) + if not recipient: + return # no recipient -> skip + + ctx = _decision_context(obj, choice_code) + base = 'input/approval_granted' if granted else 'input/approval_denied' + + project_name = getattr(obj, 'name', None) or '(ohne Projektnamen)' + decision_word = 'bewilligt' if granted else 'abgelehnt' + subject = f'Deine Förderanfrage „{project_name}“ – {decision_word}' + + txt = get_template(f'{base}.txt').render(ctx) + html = get_template(f'{base}.html').render(ctx) + + msg = EmailMultiAlternatives( + subject, + txt, + settings.IF_EMAIL, + [recipient], + ) + msg.attach_alternative(html, 'text/html') + msg.send() + + +def send_staff_decision_mail(obj, choice_code: str, granted: bool) -> None: + """ + Send a decision email to the internal team (staff) after approval/denial. + Uses: input/approval_granted_staff.(txt|html) or input/approval_denied_staff.(txt|html) + """ + ctx = _decision_context(obj, choice_code) + base = 'input/approval_granted_staff' if granted else 'input/approval_denied_staff' + + project_name = getattr(obj, 'name', None) or '(ohne Projektnamen)' + decision_word = 'bewilligt' if granted else 'abgelehnt' + subject = f'Entscheidung: {project_name} ({decision_word})' + + txt = get_template(f'{base}.txt').render(ctx) + html = get_template(f'{base}.html').render(ctx) + + msg = EmailMultiAlternatives( + subject, + txt, + settings.IF_EMAIL, + [settings.IF_EMAIL], + ) + msg.attach_alternative(html, 'text/html') + msg.send() From f98894b250aef414e94336830cf0e4d8a6b28a7e Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 7 Oct 2025 19:46:25 +0200 Subject: [PATCH 03/13] Enhance ApplicationView to send applicant/staff emails with attachments and improved subjects --- input/views.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/input/views.py b/input/views.py index 3eb4bc6..4bd5c3a 100755 --- a/input/views.py +++ b/input/views.py @@ -11,6 +11,8 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.views.generic import TemplateView from django.views.generic.edit import FormView +from django.utils.html import strip_tags +from input.mail_attachments import collect_attachment_paths, attach_files from .forms import ( BaseApplicationForm, @@ -252,18 +254,32 @@ class ApplicationView(FormView): txt1 = get_template('input/ifg_volunteer_mail.txt').render(context) html1 = get_template('input/ifg_volunteer_mail.html').render(context) msg1 = EmailMultiAlternatives( - 'Formular ausgefüllt', txt1, settings.IF_EMAIL, [data['email']] + 'Deine Förderanfrage bei Wikimedia Deutschland', txt1, settings.IF_EMAIL, [data['email']] ) msg1.attach_alternative(html1, 'text/html') + applicant_files = collect_attachment_paths(kind='applicant', choice=self.type_code) + attach_files(msg1, applicant_files) msg1.send() - + type_label_html = TYPE_CHOICES.get(self.type_code, self.type_code) + type_label = strip_tags(str(type_label_html)) # Mail to IF txt2 = get_template('input/if_mail.txt').render(context) html2 = get_template('input/if_mail.html').render(context) + applicant_name = ( + getattr(modell, 'username', None) + or data.get('username') + or getattr(modell, 'realname', None) + or data.get('realname') + or getattr(modell, 'email', None) + or data.get('email') + or 'Unbekannt' + ) msg2 = EmailMultiAlternatives( - 'Formular ausgefüllt', txt2, settings.IF_EMAIL, [settings.IF_EMAIL] + f'Anfrage {type_label} von {applicant_name}', txt2, settings.IF_EMAIL, [settings.IF_EMAIL] ) msg2.attach_alternative(html2, 'text/html') + staff_files = collect_attachment_paths(kind='staff', choice=self.type_code) + attach_files(msg2, staff_files) msg2.send() except BadHeaderError: From 319a06833ec36ab2a6d11d8fd6c0aa1e1bb24b6e Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 7 Oct 2025 19:49:12 +0200 Subject: [PATCH 04/13] Add mail attachment settings with cache config, TTL, and URL-based attachment mapping --- foerderbarometer/settings.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/foerderbarometer/settings.py b/foerderbarometer/settings.py index 0141963..fcbf851 100644 --- a/foerderbarometer/settings.py +++ b/foerderbarometer/settings.py @@ -165,3 +165,33 @@ NUTZUNGSBEDINGUNGEN_MAILINGLISTEN = 'static/input/nutzungsbedingungen-mailinglis NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM = 'static/input/nutzungsbedingungen-literaturstipendium.pdf' 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_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 + }, + 'staff': { + # Global attachments for all staff emails + 'ALL': [], + # Example: 'IFG': ['https://example.com/internal-guideline.pdf'] + }, +} From 621941c6e450f4cc9fe2eee911cb9f1402e7df11 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 7 Oct 2025 19:53:29 +0200 Subject: [PATCH 05/13] Update and add email templates for applicant and staff notifications --- input/templates/input/approval_denied.html | 9 +++++ input/templates/input/approval_denied.txt | 5 +++ .../input/approval_denied_staff.html | 7 ++++ .../templates/input/approval_denied_staff.txt | 3 ++ input/templates/input/approval_granted.html | 10 ++++++ input/templates/input/approval_granted.txt | 7 ++++ .../input/approval_granted_staff.html | 7 ++++ .../input/approval_granted_staff.txt | 3 ++ input/templates/input/if_mail.html | 28 ++++++++------- input/templates/input/ifg_volunteer_mail.html | 35 +++++++++++++------ 10 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 input/templates/input/approval_denied.html create mode 100644 input/templates/input/approval_denied.txt create mode 100644 input/templates/input/approval_denied_staff.html create mode 100644 input/templates/input/approval_denied_staff.txt create mode 100644 input/templates/input/approval_granted.html create mode 100644 input/templates/input/approval_granted.txt create mode 100644 input/templates/input/approval_granted_staff.html create mode 100644 input/templates/input/approval_granted_staff.txt diff --git a/input/templates/input/approval_denied.html b/input/templates/input/approval_denied.html new file mode 100644 index 0000000..6343d74 --- /dev/null +++ b/input/templates/input/approval_denied.html @@ -0,0 +1,9 @@ + + +

Hallo {{ data.realname }},

+ +

Deine Förderanfrage {{project.name}} wurde leider abgelehnt.

+ +

Fragen? community@wikimedia.de

+ + diff --git a/input/templates/input/approval_denied.txt b/input/templates/input/approval_denied.txt new file mode 100644 index 0000000..55a8df6 --- /dev/null +++ b/input/templates/input/approval_denied.txt @@ -0,0 +1,5 @@ +Hallo {{ data.realname }}, + +deine Förderanfrage {{project.name}} wurde leider abgelehnt. + +Fragen? community@wikimedia.de diff --git a/input/templates/input/approval_denied_staff.html b/input/templates/input/approval_denied_staff.html new file mode 100644 index 0000000..3281b63 --- /dev/null +++ b/input/templates/input/approval_denied_staff.html @@ -0,0 +1,7 @@ + + +

Hallo Team Community-Konferenzen & Förderung,

+ +

Die Förderanfrage {{project.name}} von {{ data.realname }} wurde abgelehnt.

+ + \ No newline at end of file diff --git a/input/templates/input/approval_denied_staff.txt b/input/templates/input/approval_denied_staff.txt new file mode 100644 index 0000000..2a96ae9 --- /dev/null +++ b/input/templates/input/approval_denied_staff.txt @@ -0,0 +1,3 @@ +Hallo Team Community-Konferenzen & Förderung, + +die Förderanfrage {{project.name}} von {{ data.realname }} wurde abgelehnt. diff --git a/input/templates/input/approval_granted.html b/input/templates/input/approval_granted.html new file mode 100644 index 0000000..c768a73 --- /dev/null +++ b/input/templates/input/approval_granted.html @@ -0,0 +1,10 @@ + + +

Hallo {{ data.realname }},

+ +

Deine Förderanfrage {{project.name}} wurde bewilligt.

+ +

Das Team Community-Konferenzen & Förderung meldet sich bald bei dir.
+ Fragen? community@wikimedia.de

+ + diff --git a/input/templates/input/approval_granted.txt b/input/templates/input/approval_granted.txt new file mode 100644 index 0000000..c1f3c24 --- /dev/null +++ b/input/templates/input/approval_granted.txt @@ -0,0 +1,7 @@ +Hallo {{ data.realname }}, + +deine Förderanfrage {{project.name}} wurde bewilligt. + +Das Team Community-Konferenzen & Förderung meldet sich bald bei dir. +Fragen? community@wikimedia.de + diff --git a/input/templates/input/approval_granted_staff.html b/input/templates/input/approval_granted_staff.html new file mode 100644 index 0000000..d315c00 --- /dev/null +++ b/input/templates/input/approval_granted_staff.html @@ -0,0 +1,7 @@ + + +

Hallo Team Community-Konferenzen & Förderung,

+ +

Die Förderanfrage {{project.name}} von {{ data.realname }} wurde bewilligt.

+ + \ No newline at end of file diff --git a/input/templates/input/approval_granted_staff.txt b/input/templates/input/approval_granted_staff.txt new file mode 100644 index 0000000..5fd989b --- /dev/null +++ b/input/templates/input/approval_granted_staff.txt @@ -0,0 +1,3 @@ +Hallo Team Community-Konferenzen & Förderung, + +die Förderanfrage {{project.name}} von {{ data.realname }} wurde bewilligt. diff --git a/input/templates/input/if_mail.html b/input/templates/input/if_mail.html index 92107f9..31237ba 100755 --- a/input/templates/input/if_mail.html +++ b/input/templates/input/if_mail.html @@ -1,10 +1,11 @@ -Hallo Team Communitys und Engagement, -

-es gab einen neuen Antrag von {{data.realname}}. -

-Der Nutzer mit dem Username {{data.username}} ({{data.email}}) fragt ein_e {{data.type_label|striptags}} an.
+

Hallo Team Community-Konferenzen & Förderung,

+ +

es gibt eine neue Anfrage von {{ data.realname }}.

+ +

{{ data.username|default:data.realname }} ({{ data.email }}) fragt an: {{ data.type_label|striptags }}

+ {% if data.choice in data.grant %}
Vorraussichtliche Kosten: {{data.cost}}
Anmerkungen: {{data.notes}} {% endif %} {% if data.choice in data.domain %}
@@ -24,9 +25,8 @@ Wikimedia-Projekt: {{data.project}}
Persönliche Daten: {{data.data}}
Variante: {{data.variant}}
Sendungsadrese: {{data.send_to}}
{% endif %} -

-Zum Eintrag in der Förderdatenbank: +

Zum Eintrag in der Förderdatenbank: {% if data.choice == 'BIB' %} {{data.url_prefix}}/admin/input/library/{{data.pk}}/change {% elif data.choice == 'ELIT'%} @@ -46,13 +46,15 @@ Zum Eintrag in der Förderdatenbank: {% elif data.choice == 'VIS'%} {{data.url_prefix}}/admin/input/businesscard/{{data.pk}}/change {% endif %} -

+

-Zum Genehmigen hier klicken: {{data.url_prefix}}{% url 'authorize' data.choice data.pk %} -

-Zu Ablehnen hier klicken: {{data.url_prefix}}{% url 'deny' data.choice data.pk %} -

-Stets zu Diensten, Deine Förderdatenbank +

Zum Genehmigen hier klicken: + {{data.url_prefix}}{% url 'authorize' data.choice data.pk %} +

+ +

Zum Ablehnen hier klicken: + {{data.url_prefix}}{% url 'deny' data.choice data.pk %} +

diff --git a/input/templates/input/ifg_volunteer_mail.html b/input/templates/input/ifg_volunteer_mail.html index bd7f989..5652db1 100755 --- a/input/templates/input/ifg_volunteer_mail.html +++ b/input/templates/input/ifg_volunteer_mail.html @@ -1,8 +1,10 @@ -Hallo {{data.realname}}, -

-wir haben Deine Anfrage ({{data.type_label|striptags}}) erhalten.
+

Hallo {{ data.username|default:data.realname }},

+ +

vielen Dank für deine Anfrage ({{ data.type_label|striptags }}), die bei uns eingegangen ist.

+ +Dies ist eine automatisch generierte E-Mail. Im Folgenden findest du deine Formulareingaben nochmals zu deiner Übersicht:
{% if data.choice in data.grant %}
Vorraussichtliche Kosten: {{data.cost}}
Anmerkungen: {{data.notes}} {% endif %} {% if data.choice in data.domain %}
@@ -22,13 +24,24 @@ Wikimedia-Projekt: {{data.project}}
Persönliche Daten: {{data.data}}
Variante: {{data.variant}}
Sendungsadrese: {{data.send_to}} {% endif %}
-

-Das Team Comunitys und Engagement wird sich um die Bearbeitung deiner Anfrage kümmern
-und sich in den nächsten Tagen bei dir melden. Solltest du Rückfragen haben,
-wende dich gern an community@wikimedia.de.
-

-Viele Grüße, dein freundliches aber komplett unmenschliches automatisches -Formularbeantwortungssystem. +

Das Team Community-Konferenzen & Förderung wird sich um deine Anfrage kümmern und sich in den nächsten Tagen bei dir melden. Wenn du Fragen hast, wende dich gern jederzeit an community@wikimedia.de.

+ +

+ --
+ Wikimedia Deutschland e. V. | Tempelhofer Ufer 23–24 | 10963 Berlin
+ Zentrale: +49 30 5771162-0
+ wikimedia.de +

+ +

+ Unsere Vision ist eine Welt, in der alle Menschen am Wissen der Menschheit teilhaben, es nutzen und mehren können. Helfen Sie uns dabei!
+ spenden.wikimedia.de +

+ +

Wikimedia Deutschland – Gesellschaft zur Förderung Freien Wissens e. V. Eingetragen im Vereinsregister des Amtsgerichts Charlottenburg, VR 23855 B. Als gemeinnützig anerkannt durch das Finanzamt für Körperschaften I Berlin, Steuernummer 27/029/42207. Geschäftsführende Vorständin: Franziska Heine.

+ +

Datenschutzerklärung:
+ Soweit Sie uns personenbezogene Daten mitteilen, verarbeiten wir diese Daten gemäß unserer Datenschutzerklärung .

- + \ No newline at end of file From 5d107dab96f3e404a283b786315ab0eb48a7b9a2 Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Fri, 17 Oct 2025 11:42:06 +0200 Subject: [PATCH 06/13] split form processing in multiple methods --- input/views.py | 53 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/input/views.py b/input/views.py index 4bd5c3a..7525a00 100755 --- a/input/views.py +++ b/input/views.py @@ -210,15 +210,28 @@ class ApplicationView(FormView): - Return the "done" response. """ + data = self.prepare_data(form) + obj = self.save_obj(form, data) + + if response := self.send_mail(obj, data): + return response + + return done(self.request) + + def prepare_data(self, form): # Collect cleaned data and mark the current type - data = form.cleaned_data.copy() - data['choice'] = self.type_code + + data = {**form.cleaned_data, 'choice': self.type_code} # Special rule for literature applications if self.type_code == TYPE_LIT and data.get('selfbuy') == 'TRUE': data['selfbuy_give_data'] = 'False' + return data + + def save_obj(self, form, data): # Save model instance + modell = form.save(commit=False) # Username from session if present @@ -228,6 +241,7 @@ class ApplicationView(FormView): # Copy common fields if provided by the form if 'realname' in data: modell.realname = data['realname'] + if 'email' in data: modell.email = data['email'] @@ -240,11 +254,16 @@ class ApplicationView(FormView): modell.selfbuy_give_data = data['selfbuy_give_data'] modell.save() + if hasattr(form, 'save_m2m'): form.save_m2m() + return modell + + def send_mail(self, obj, data): # Prepare minimal mail context and send mails - data['pk'] = modell.pk + + data['pk'] = obj.pk data['url_prefix'] = settings.EMAIL_URL_PREFIX data['type_label'] = self.type_info.label context = {'data': data} @@ -260,22 +279,14 @@ class ApplicationView(FormView): applicant_files = collect_attachment_paths(kind='applicant', choice=self.type_code) attach_files(msg1, applicant_files) msg1.send() - type_label_html = TYPE_CHOICES.get(self.type_code, self.type_code) - type_label = strip_tags(str(type_label_html)) + type_label_html = self.type_info.label + type_label_plain = strip_tags(type_label_html) # Mail to IF txt2 = get_template('input/if_mail.txt').render(context) html2 = get_template('input/if_mail.html').render(context) - applicant_name = ( - getattr(modell, 'username', None) - or data.get('username') - or getattr(modell, 'realname', None) - or data.get('realname') - or getattr(modell, 'email', None) - or data.get('email') - or 'Unbekannt' - ) + applicant_name = self.get_recipient_name(obj, data) msg2 = EmailMultiAlternatives( - f'Anfrage {type_label} von {applicant_name}', txt2, settings.IF_EMAIL, [settings.IF_EMAIL] + f'Anfrage {type_label_plain} von {applicant_name}', txt2, settings.IF_EMAIL, [settings.IF_EMAIL] ) msg2.attach_alternative(html2, 'text/html') staff_files = collect_attachment_paths(kind='staff', choice=self.type_code) @@ -283,10 +294,16 @@ class ApplicationView(FormView): msg2.send() except BadHeaderError: - modell.delete() + obj.delete() return HttpResponse('Invalid header found. Data not saved!') except SMTPException: - modell.delete() + obj.delete() return HttpResponse('Error in sending mails (probably wrong adress?). Data not saved!') - return done(self.request) + @staticmethod + def get_recipient_name(obj, data): + for field in 'username', 'realname', 'email': + if name := getattr(obj, field, None) or data.get(field): + return name + + return 'Unbekannt' From 76ba63002dbfd8bc59a5d470952bf5c5f36bce76 Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Fri, 17 Oct 2025 12:06:23 +0200 Subject: [PATCH 07/13] clean up mail code --- input/{mailer.py => utils/mail/__init__.py} | 12 ++++- .../mail/attachments.py} | 3 ++ input/views.py | 53 +++++++++---------- 3 files changed, 40 insertions(+), 28 deletions(-) rename input/{mailer.py => utils/mail/__init__.py} (92%) rename input/{mail_attachments.py => utils/mail/attachments.py} (99%) diff --git a/input/mailer.py b/input/utils/mail/__init__.py similarity index 92% rename from input/mailer.py rename to input/utils/mail/__init__.py index d3df345..db3a53c 100644 --- a/input/mailer.py +++ b/input/utils/mail/__init__.py @@ -2,7 +2,17 @@ from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template.loader import get_template from django.utils.html import strip_tags -from .models import TYPE_CHOICES + +from input.models import TYPE_CHOICES + +from .attachments import collect_attachment_paths, attach_files + +__all__ = [ + 'collect_attachment_paths', + 'attach_files', + 'send_decision_mail', + 'send_staff_decision_mail', +] def _type_labels(choice: str): diff --git a/input/mail_attachments.py b/input/utils/mail/attachments.py similarity index 99% rename from input/mail_attachments.py rename to input/utils/mail/attachments.py index 46e7f26..0916f36 100644 --- a/input/mail_attachments.py +++ b/input/utils/mail/attachments.py @@ -4,11 +4,14 @@ import time import urllib.request import urllib.parse import mimetypes + from pathlib import Path from typing import Iterable, List, Tuple + from django.conf import settings from django.core.mail import EmailMultiAlternatives + def _ensure_cache_dir() -> Path: """ Ensure that the cache directory for attachments exists. diff --git a/input/views.py b/input/views.py index 7525a00..2c6292d 100755 --- a/input/views.py +++ b/input/views.py @@ -12,7 +12,8 @@ from django.contrib.auth.decorators import login_required from django.views.generic import TemplateView from django.views.generic.edit import FormView from django.utils.html import strip_tags -from input.mail_attachments import collect_attachment_paths, attach_files + +from input.utils.mail import collect_attachment_paths, attach_files from .forms import ( BaseApplicationForm, @@ -263,42 +264,40 @@ class ApplicationView(FormView): def send_mail(self, obj, data): # Prepare minimal mail context and send mails + type_label_html = self.type_info.label + type_label_plain = strip_tags(type_label_html) + data['pk'] = obj.pk data['url_prefix'] = settings.EMAIL_URL_PREFIX - data['type_label'] = self.type_info.label + data['type_label'] = type_label_html + context = {'data': data} - try: - # Mail to applicant - txt1 = get_template('input/ifg_volunteer_mail.txt').render(context) - html1 = get_template('input/ifg_volunteer_mail.html').render(context) - msg1 = EmailMultiAlternatives( - 'Deine Förderanfrage bei Wikimedia Deutschland', txt1, settings.IF_EMAIL, [data['email']] - ) - msg1.attach_alternative(html1, 'text/html') - applicant_files = collect_attachment_paths(kind='applicant', choice=self.type_code) - attach_files(msg1, applicant_files) - msg1.send() - type_label_html = self.type_info.label - type_label_plain = strip_tags(type_label_html) - # Mail to IF - txt2 = get_template('input/if_mail.txt').render(context) - html2 = get_template('input/if_mail.html').render(context) - applicant_name = self.get_recipient_name(obj, data) - msg2 = EmailMultiAlternatives( - f'Anfrage {type_label_plain} von {applicant_name}', txt2, settings.IF_EMAIL, [settings.IF_EMAIL] - ) - msg2.attach_alternative(html2, 'text/html') - staff_files = collect_attachment_paths(kind='staff', choice=self.type_code) - attach_files(msg2, staff_files) - msg2.send() + applicant_name = self.get_recipient_name(obj, data) + applicant_subject = 'Deine Förderanfrage bei Wikimedia Deutschland' + staff_subject = f'Anfrage {type_label_plain} von {applicant_name}' + + try: + self.send_email('applicant', 'ifg_volunteer_mail', applicant_subject, data['email'], context) + self.send_email('staff', 'if_mail', staff_subject, settings.IF_EMAIL, context) except BadHeaderError: obj.delete() return HttpResponse('Invalid header found. Data not saved!') except SMTPException: obj.delete() - return HttpResponse('Error in sending mails (probably wrong adress?). Data not saved!') + return HttpResponse('Error in sending mails (probably wrong address?). Data not saved!') + + def send_email(self, kind, template_name, subject, recipient, context): + plain = get_template(f'input/{template_name}.txt').render(context) + html = get_template(f'input/{template_name}.html').render(context) + email = EmailMultiAlternatives(subject, plain, settings.IF_EMAIL, [recipient]) + applicant_files = collect_attachment_paths(kind=kind, choice=self.type_code) + + email.attach_alternative(html, 'text/html') + attach_files(email, applicant_files) + + return email.send() @staticmethod def get_recipient_name(obj, data): From e9b60d7205aee011558f3f86023a5ef6ccdf135a Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Fri, 17 Oct 2025 14:15:56 +0200 Subject: [PATCH 08/13] send decision mails on grant --- input/admin.py | 34 ++++++++++++++++++++++++++++------ input/utils/mail/__init__.py | 6 +++--- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/input/admin.py b/input/admin.py index aa9e9ea..534097c 100755 --- a/input/admin.py +++ b/input/admin.py @@ -1,9 +1,11 @@ import csv from django.contrib import admin -from django.db import models +from django.db import models, transaction from django.http import HttpResponse +from input.utils.mail import send_decision_mail, send_staff_decision_mail + from .forms import BaseProjectForm from .models import ( Account, @@ -22,6 +24,7 @@ from .models import ( BusinessCard, List, Literature, + TYPE_PROJ, ) @@ -100,8 +103,7 @@ class ProjectAdminForm(BaseProjectForm): return cleaned_data -@admin.register(Project) -class ProjectAdmin(admin.ModelAdmin): +class BaseProjectAdmin(admin.ModelAdmin): save_as = True form = ProjectAdminForm search_fields = ('name', 'pid','finance_id', 'realname', 'start', 'end', 'participants_estimated', 'participants_real', 'cost', 'status', 'end_quartal') @@ -158,19 +160,39 @@ class ProjectAdmin(admin.ModelAdmin): class Media: js = ('dropdown/js/otrs_link.js',) - granted = True + granted: bool def get_queryset(self, request): return super().get_queryset(request).filter(granted=self.granted) +@admin.register(Project) +class ProjectAdmin(BaseProjectAdmin): + granted = True + + @admin.register(ProjectRequest) -class ProjectRequestAdmin(ProjectAdmin): +class ProjectRequestAdmin(BaseProjectAdmin): granted = None + def save_model(self, request, obj: ProjectRequest, form: ProjectAdminForm, change: bool): + super().save_model(request, obj, form, change) + + if obj.granted is None: + return None + + transaction.on_commit(lambda: self.send_decision_mails(obj)) + + return obj.granted + + @staticmethod + def send_decision_mails(obj): + send_decision_mail(obj, TYPE_PROJ, obj.granted) + send_staff_decision_mail(obj, TYPE_PROJ, obj.granted) + @admin.register(ProjectDeclined) -class ProjectDeclinedAdmin(ProjectAdmin): +class ProjectDeclinedAdmin(BaseProjectAdmin): granted = False diff --git a/input/utils/mail/__init__.py b/input/utils/mail/__init__.py index db3a53c..1a12737 100644 --- a/input/utils/mail/__init__.py +++ b/input/utils/mail/__init__.py @@ -21,7 +21,7 @@ def _type_labels(choice: str): Returns (HTML label, plain text label). """ html = TYPE_CHOICES.get(choice, choice) - plain = strip_tags(str(html)) + plain = strip_tags(html) return html, plain @@ -35,8 +35,8 @@ def _decision_context(obj, choice_code: str) -> dict: return { 'data': { 'realname': realname, - 'typestring': type_html, - 'typestring_plain': type_plain, + 'type_label': type_html, + 'type_label_plain': type_plain, 'name': getattr(obj, 'name', None), }, 'project': obj, From 1e075fda687497381c72e98597a96dee02f9d7ff Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Fri, 17 Oct 2025 14:18:20 +0200 Subject: [PATCH 09/13] made declined projects read only --- input/admin.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/input/admin.py b/input/admin.py index 534097c..8620f0c 100755 --- a/input/admin.py +++ b/input/admin.py @@ -86,7 +86,7 @@ class ProjectAdminForm(BaseProjectForm): super().__init__(*args, **kwargs) for field, model in self.categories.items(): - if self.initial[f'{field}_other']: + if self.initial.get(f'{field}_other'): self.initial[field] = [*self.initial[field], model.other] def clean(self): @@ -195,6 +195,12 @@ class ProjectRequestAdmin(BaseProjectAdmin): class ProjectDeclinedAdmin(BaseProjectAdmin): granted = False + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + @admin.register(BusinessCard) class BusinessCardAdmin(RequestURLBeforeInternNotesMixin, admin.ModelAdmin): From 5a5962b6193a6e7ecef9c476b1fa365a52068cb0 Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Fri, 17 Oct 2025 14:51:18 +0200 Subject: [PATCH 10/13] unified mailing --- input/management/commands/sendmails.py | 83 +++++-------------- .../{input => mails}/approval_denied.html | 0 .../{input => mails}/approval_denied.txt | 0 .../approval_denied_staff.html | 0 .../approval_denied_staff.txt | 0 .../{input => mails}/approval_granted.html | 0 .../{input => mails}/approval_granted.txt | 0 .../approval_granted_staff.html | 0 .../approval_granted_staff.txt | 0 .../{input => mails}/if_end_of_project.html | 0 .../{input => mails}/if_end_of_project.txt | 0 .../if_end_of_project_approved.html | 0 .../if_end_of_project_approved.txt | 0 .../if_end_of_project_orginformed.html | 0 .../if_end_of_project_orginformed.txt | 0 input/templates/{input => mails}/if_mail.html | 0 input/templates/{input => mails}/if_mail.txt | 0 .../if_not_of_project_approved.html | 0 .../if_not_of_project_approved.txt | 0 .../{input => mails}/ifg_volunteer_mail.html | 0 .../{input => mails}/ifg_volunteer_mail.txt | 0 .../{input => mails}/survey_mail.html | 0 .../{input => mails}/survey_mail.txt | 0 input/utils/mail/__init__.py | 51 ++++++------ input/views.py | 14 ++-- 25 files changed, 54 insertions(+), 94 deletions(-) rename input/templates/{input => mails}/approval_denied.html (100%) rename input/templates/{input => mails}/approval_denied.txt (100%) rename input/templates/{input => mails}/approval_denied_staff.html (100%) rename input/templates/{input => mails}/approval_denied_staff.txt (100%) rename input/templates/{input => mails}/approval_granted.html (100%) rename input/templates/{input => mails}/approval_granted.txt (100%) rename input/templates/{input => mails}/approval_granted_staff.html (100%) rename input/templates/{input => mails}/approval_granted_staff.txt (100%) rename input/templates/{input => mails}/if_end_of_project.html (100%) rename input/templates/{input => mails}/if_end_of_project.txt (100%) rename input/templates/{input => mails}/if_end_of_project_approved.html (100%) rename input/templates/{input => mails}/if_end_of_project_approved.txt (100%) rename input/templates/{input => mails}/if_end_of_project_orginformed.html (100%) rename input/templates/{input => mails}/if_end_of_project_orginformed.txt (100%) rename input/templates/{input => mails}/if_mail.html (100%) rename input/templates/{input => mails}/if_mail.txt (100%) rename input/templates/{input => mails}/if_not_of_project_approved.html (100%) rename input/templates/{input => mails}/if_not_of_project_approved.txt (100%) rename input/templates/{input => mails}/ifg_volunteer_mail.html (100%) rename input/templates/{input => mails}/ifg_volunteer_mail.txt (100%) rename input/templates/{input => mails}/survey_mail.html (100%) rename input/templates/{input => mails}/survey_mail.txt (100%) diff --git a/input/management/commands/sendmails.py b/input/management/commands/sendmails.py index 15cac47..654d46a 100755 --- a/input/management/commands/sendmails.py +++ b/input/management/commands/sendmails.py @@ -2,13 +2,13 @@ from datetime import date, timedelta from django.core.management import CommandError from django.core.management.base import BaseCommand -from django.template.loader import get_template from django.core.mail import BadHeaderError -from django.core.mail import EmailMultiAlternatives from django.conf import settings from input.models import Project, Library, HonoraryCertificate, Travel, Email,\ BusinessCard, List, IFG, Literature +from input.utils.mail import send_email + class Command(BaseCommand): ''' mails will be sent here: @@ -34,15 +34,11 @@ class Command(BaseCommand): 'name': name, 'pid': pid, 'SURVEY_PREFIX': settings.SURVEY_PREFIX, } - txt_mail_template = get_template('input/survey_mail.txt') - html_mail_template = get_template('input/survey_mail.html') + + subject = 'Dein Feedback zur Förderung durch Wikimedia Deutschland' + try: - subject, from_email, to = 'Dein Feedback zur Förderung durch Wikimedia Deutschland', settings.IF_EMAIL, email - text_content = txt_mail_template.render(context) - html_content = html_mail_template.render(context) - msg = EmailMultiAlternatives(subject, text_content, from_email, [to], bcc=[settings.SURVEY_EMAIL]) - msg.attach_alternative(html_content, "text/html") - msg.send() + send_email('survey_mail', context, subject, email, bcc=[settings.SURVEY_EMAIL]) #print('survey mail would have been send') #survey_mail = EmailMessage('Dein Feedback zur Förderung durch Wikimedia Deutschland', @@ -70,21 +66,14 @@ class Command(BaseCommand): .exclude(end_mail_send = True)\ .filter(mail_state = 'NONE') - txt_mail_template = get_template('input/if_end_of_project.txt') - html_mail_template = get_template('input/if_end_of_project.html') - + subject = 'Projektende erreicht' + recipient = settings.IF_EMAIL for project in old: - context = {'project': project} - context['URL_PREFIX'] = settings.EMAIL_URL_PREFIX + context = {'project': project, 'URL_PREFIX': settings.EMAIL_URL_PREFIX} try: - subject, from_email, to = 'Projektende erreicht', settings.IF_EMAIL, settings.IF_EMAIL - text_content = txt_mail_template.render(context) - html_content = html_mail_template.render(context) - msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) - msg.attach_alternative(html_content, "text/html") - msg.send() + send_email('if_end_of_project', context, subject, recipient) #print('end of project mail would have been sent') #send_mail('Projektende erreicht', @@ -110,33 +99,19 @@ class Command(BaseCommand): approved_end = Project.objects.filter(status = 'END')\ .exclude(end_mail_send = True)\ .filter(mail_state = 'INF') - txt_mail_template = get_template('input/if_end_of_project_approved.txt') - html_mail_template = get_template('input/if_end_of_project_approved.html') - txt_informMail_template = get_template('input/if_end_of_project_orginformed.txt') - html_informMail_template = get_template('input/if_end_of_project_orginformed.html') # send the mail to project.email, which would be the mail of the volunteer filling out the form for project in approved_end: - context = {'project': project} - context['URL_PREFIX'] = settings.EMAIL_URL_PREFIX - + context = {'project': project, 'URL_PREFIX': settings.EMAIL_URL_PREFIX} try: - subject, from_email, to = 'Projektende erreicht', settings.IF_EMAIL, project.email - text_content = txt_mail_template.render(context) - html_content = html_mail_template.render(context) - msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) - msg.attach_alternative(html_content, "text/html") - msg.send() + send_email('if_end_of_project_approved', context, 'Projektende erreicht', project.email) + #print('if and of project approved mail would have been sent') - inform_subject, inform_from_email, inform_to = 'Projektorganisator*in wurde informiert', settings.IF_EMAIL, settings.IF_EMAIL - inform_text_content = txt_informMail_template.render(context) - inform_html_content = html_informMail_template.render(context) - inform_msg = EmailMultiAlternatives(inform_subject, inform_text_content, inform_from_email, [inform_to]) - inform_msg.attach_alternative(html_content, "text/html") - inform_msg.send() + send_email('if_end_of_project_orginformed', context, 'Projektorganisator*in wurde informiert', settings.IF_EMAIL) + #print('if end of project orginformed mail would have been sent') #send_mail('Projektende erreicht', @@ -168,25 +143,15 @@ class Command(BaseCommand): .exclude(end_mail_send = True)\ .filter(mail_state = 'INF') - html_mail_template = get_template('input/if_not_of_project_approved.html') - txt_mail_template = get_template('input/if_not_of_project_approved.txt') - - txt_informMail_template = get_template('input/if_end_of_project_orginformed.txt') - html_informMail_template = get_template('input/if_end_of_project_orginformed.html') # send the mail to project.email, which would be the mail of the volunteer that filled out the form for project in approved_notHappened: - context = {'project': project} - context['URL_PREFIX'] = settings.EMAIL_URL_PREFIX - try: - subject, from_email, to = 'Projektende erreicht', settings.IF_EMAIL, project.email - text_content = txt_mail_template.render(context) - html_content = html_mail_template.render(context) - msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) - msg.attach_alternative(html_content, "text/html") - msg.send() - #print('if not of project approved end mail would have been sent') + context = {'project': project, 'URL_PREFIX': settings.EMAIL_URL_PREFIX} + try: + send_email('if_not_of_project_approved', context, 'Projektende erreicht', project.email) + + #print('if not of project approved end mail would have been sent') #send_mail('Projektende erreicht', # mail_template.render(context), @@ -194,12 +159,8 @@ class Command(BaseCommand): # [project.email], # fail_silently=False) - inform_subject, inform_from_email, inform_to = 'Projektorganisator*in wurde informiert', settings.IF_EMAIL, settings.IF_EMAIL - inform_text_content = txt_informMail_template.render(context) - inform_html_content = html_informMail_template.render(context) - inform_msg = EmailMultiAlternatives(inform_subject, inform_text_content, inform_from_email, [inform_to]) - inform_msg.attach_alternative(html_content, "text/html") - inform_msg.send() + send_email('if_end_of_project_orginformed', context, 'Projektorganisator*in wurde informiert', settings.IF_EMAIL) + #print('if not of project approved end mail orginformed would have been sent') #send_mail('Projektorganisator*in wurde informiert', diff --git a/input/templates/input/approval_denied.html b/input/templates/mails/approval_denied.html similarity index 100% rename from input/templates/input/approval_denied.html rename to input/templates/mails/approval_denied.html diff --git a/input/templates/input/approval_denied.txt b/input/templates/mails/approval_denied.txt similarity index 100% rename from input/templates/input/approval_denied.txt rename to input/templates/mails/approval_denied.txt diff --git a/input/templates/input/approval_denied_staff.html b/input/templates/mails/approval_denied_staff.html similarity index 100% rename from input/templates/input/approval_denied_staff.html rename to input/templates/mails/approval_denied_staff.html diff --git a/input/templates/input/approval_denied_staff.txt b/input/templates/mails/approval_denied_staff.txt similarity index 100% rename from input/templates/input/approval_denied_staff.txt rename to input/templates/mails/approval_denied_staff.txt diff --git a/input/templates/input/approval_granted.html b/input/templates/mails/approval_granted.html similarity index 100% rename from input/templates/input/approval_granted.html rename to input/templates/mails/approval_granted.html diff --git a/input/templates/input/approval_granted.txt b/input/templates/mails/approval_granted.txt similarity index 100% rename from input/templates/input/approval_granted.txt rename to input/templates/mails/approval_granted.txt diff --git a/input/templates/input/approval_granted_staff.html b/input/templates/mails/approval_granted_staff.html similarity index 100% rename from input/templates/input/approval_granted_staff.html rename to input/templates/mails/approval_granted_staff.html diff --git a/input/templates/input/approval_granted_staff.txt b/input/templates/mails/approval_granted_staff.txt similarity index 100% rename from input/templates/input/approval_granted_staff.txt rename to input/templates/mails/approval_granted_staff.txt diff --git a/input/templates/input/if_end_of_project.html b/input/templates/mails/if_end_of_project.html similarity index 100% rename from input/templates/input/if_end_of_project.html rename to input/templates/mails/if_end_of_project.html diff --git a/input/templates/input/if_end_of_project.txt b/input/templates/mails/if_end_of_project.txt similarity index 100% rename from input/templates/input/if_end_of_project.txt rename to input/templates/mails/if_end_of_project.txt diff --git a/input/templates/input/if_end_of_project_approved.html b/input/templates/mails/if_end_of_project_approved.html similarity index 100% rename from input/templates/input/if_end_of_project_approved.html rename to input/templates/mails/if_end_of_project_approved.html diff --git a/input/templates/input/if_end_of_project_approved.txt b/input/templates/mails/if_end_of_project_approved.txt similarity index 100% rename from input/templates/input/if_end_of_project_approved.txt rename to input/templates/mails/if_end_of_project_approved.txt diff --git a/input/templates/input/if_end_of_project_orginformed.html b/input/templates/mails/if_end_of_project_orginformed.html similarity index 100% rename from input/templates/input/if_end_of_project_orginformed.html rename to input/templates/mails/if_end_of_project_orginformed.html diff --git a/input/templates/input/if_end_of_project_orginformed.txt b/input/templates/mails/if_end_of_project_orginformed.txt similarity index 100% rename from input/templates/input/if_end_of_project_orginformed.txt rename to input/templates/mails/if_end_of_project_orginformed.txt diff --git a/input/templates/input/if_mail.html b/input/templates/mails/if_mail.html similarity index 100% rename from input/templates/input/if_mail.html rename to input/templates/mails/if_mail.html diff --git a/input/templates/input/if_mail.txt b/input/templates/mails/if_mail.txt similarity index 100% rename from input/templates/input/if_mail.txt rename to input/templates/mails/if_mail.txt diff --git a/input/templates/input/if_not_of_project_approved.html b/input/templates/mails/if_not_of_project_approved.html similarity index 100% rename from input/templates/input/if_not_of_project_approved.html rename to input/templates/mails/if_not_of_project_approved.html diff --git a/input/templates/input/if_not_of_project_approved.txt b/input/templates/mails/if_not_of_project_approved.txt similarity index 100% rename from input/templates/input/if_not_of_project_approved.txt rename to input/templates/mails/if_not_of_project_approved.txt diff --git a/input/templates/input/ifg_volunteer_mail.html b/input/templates/mails/ifg_volunteer_mail.html similarity index 100% rename from input/templates/input/ifg_volunteer_mail.html rename to input/templates/mails/ifg_volunteer_mail.html diff --git a/input/templates/input/ifg_volunteer_mail.txt b/input/templates/mails/ifg_volunteer_mail.txt similarity index 100% rename from input/templates/input/ifg_volunteer_mail.txt rename to input/templates/mails/ifg_volunteer_mail.txt diff --git a/input/templates/input/survey_mail.html b/input/templates/mails/survey_mail.html similarity index 100% rename from input/templates/input/survey_mail.html rename to input/templates/mails/survey_mail.html diff --git a/input/templates/input/survey_mail.txt b/input/templates/mails/survey_mail.txt similarity index 100% rename from input/templates/input/survey_mail.txt rename to input/templates/mails/survey_mail.txt diff --git a/input/utils/mail/__init__.py b/input/utils/mail/__init__.py index 1a12737..25945ce 100644 --- a/input/utils/mail/__init__.py +++ b/input/utils/mail/__init__.py @@ -8,6 +8,8 @@ from input.models import TYPE_CHOICES from .attachments import collect_attachment_paths, attach_files __all__ = [ + 'build_email', + 'send_email', 'collect_attachment_paths', 'attach_files', 'send_decision_mail', @@ -15,6 +17,27 @@ __all__ = [ ] +def build_email(template_name: str, context: dict, subject: str, *recipients: str, **kwargs): + body = get_template(f'mails/{template_name}.txt').render(context) + html = get_template(f'mails/{template_name}.html').render(context) + + kwargs.setdefault('from_email', settings.IF_EMAIL) + + kwargs['subject'] = subject + kwargs['body'] = body + kwargs['to'] = recipients + + email = EmailMultiAlternatives(**kwargs) + + email.attach_alternative(html, 'text/html') + + return email + + +def send_email(template_name: str, context: dict, subject: str, *recipients: str, fail_silently=False, **kwargs): + return build_email(template_name, context, subject, *recipients, **kwargs).send(fail_silently) + + def _type_labels(choice: str): """ Resolve the human-readable type label. @@ -53,23 +76,13 @@ def send_decision_mail(obj, choice_code: str, granted: bool) -> None: return # no recipient -> skip ctx = _decision_context(obj, choice_code) - base = 'input/approval_granted' if granted else 'input/approval_denied' + template_suffix = 'granted' if granted else 'denied' project_name = getattr(obj, 'name', None) or '(ohne Projektnamen)' decision_word = 'bewilligt' if granted else 'abgelehnt' subject = f'Deine Förderanfrage „{project_name}“ – {decision_word}' - txt = get_template(f'{base}.txt').render(ctx) - html = get_template(f'{base}.html').render(ctx) - - msg = EmailMultiAlternatives( - subject, - txt, - settings.IF_EMAIL, - [recipient], - ) - msg.attach_alternative(html, 'text/html') - msg.send() + return send_email(f'approval_{template_suffix}', ctx, subject, recipient) def send_staff_decision_mail(obj, choice_code: str, granted: bool) -> None: @@ -78,20 +91,10 @@ def send_staff_decision_mail(obj, choice_code: str, granted: bool) -> None: Uses: input/approval_granted_staff.(txt|html) or input/approval_denied_staff.(txt|html) """ ctx = _decision_context(obj, choice_code) - base = 'input/approval_granted_staff' if granted else 'input/approval_denied_staff' + template_suffix = 'granted' if granted else 'denied' project_name = getattr(obj, 'name', None) or '(ohne Projektnamen)' decision_word = 'bewilligt' if granted else 'abgelehnt' subject = f'Entscheidung: {project_name} ({decision_word})' - txt = get_template(f'{base}.txt').render(ctx) - html = get_template(f'{base}.html').render(ctx) - - msg = EmailMultiAlternatives( - subject, - txt, - settings.IF_EMAIL, - [settings.IF_EMAIL], - ) - msg.attach_alternative(html, 'text/html') - msg.send() + return send_email(f'approval_{template_suffix}_staff', ctx, subject, settings.IF_EMAIL) diff --git a/input/views.py b/input/views.py index 2c6292d..e56264e 100755 --- a/input/views.py +++ b/input/views.py @@ -5,15 +5,14 @@ from django.shortcuts import render from django.http import HttpResponse, Http404 from django.utils.functional import cached_property from django.utils.safestring import mark_safe -from django.core.mail import BadHeaderError, EmailMultiAlternatives -from django.template.loader import get_template +from django.core.mail import BadHeaderError from django.conf import settings from django.contrib.auth.decorators import login_required 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 +from input.utils.mail import collect_attachment_paths, attach_files, build_email from .forms import ( BaseApplicationForm, @@ -288,16 +287,13 @@ class ApplicationView(FormView): obj.delete() return HttpResponse('Error in sending mails (probably wrong address?). Data not saved!') - def send_email(self, kind, template_name, subject, recipient, context): - plain = get_template(f'input/{template_name}.txt').render(context) - html = get_template(f'input/{template_name}.html').render(context) - email = EmailMultiAlternatives(subject, plain, settings.IF_EMAIL, [recipient]) + 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) - email.attach_alternative(html, 'text/html') attach_files(email, applicant_files) - return email.send() + return email.send(fail_silently) @staticmethod def get_recipient_name(obj, data): From b3484965b36aa2b1ffb0f27c84c6e2559dbab1ea Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Fri, 17 Oct 2025 15:14:16 +0200 Subject: [PATCH 11/13] unified approval mails --- input/admin.py | 9 +-- ...ed.html => approval_denied_applicant.html} | 0 ...nied.txt => approval_denied_applicant.txt} | 0 ...d.html => approval_granted_applicant.html} | 0 ...ted.txt => approval_granted_applicant.txt} | 0 input/utils/mail/__init__.py | 70 +++++++------------ 6 files changed, 29 insertions(+), 50 deletions(-) rename input/templates/mails/{approval_denied.html => approval_denied_applicant.html} (100%) rename input/templates/mails/{approval_denied.txt => approval_denied_applicant.txt} (100%) rename input/templates/mails/{approval_granted.html => approval_granted_applicant.html} (100%) rename input/templates/mails/{approval_granted.txt => approval_granted_applicant.txt} (100%) diff --git a/input/admin.py b/input/admin.py index 8620f0c..39bf13b 100755 --- a/input/admin.py +++ b/input/admin.py @@ -4,7 +4,7 @@ from django.contrib import admin from django.db import models, transaction from django.http import HttpResponse -from input.utils.mail import send_decision_mail, send_staff_decision_mail +from input.utils.mail import send_decision_mails from .forms import BaseProjectForm from .models import ( @@ -181,15 +181,10 @@ class ProjectRequestAdmin(BaseProjectAdmin): if obj.granted is None: return None - transaction.on_commit(lambda: self.send_decision_mails(obj)) + transaction.on_commit(lambda: send_decision_mails(obj)) return obj.granted - @staticmethod - def send_decision_mails(obj): - send_decision_mail(obj, TYPE_PROJ, obj.granted) - send_staff_decision_mail(obj, TYPE_PROJ, obj.granted) - @admin.register(ProjectDeclined) class ProjectDeclinedAdmin(BaseProjectAdmin): diff --git a/input/templates/mails/approval_denied.html b/input/templates/mails/approval_denied_applicant.html similarity index 100% rename from input/templates/mails/approval_denied.html rename to input/templates/mails/approval_denied_applicant.html diff --git a/input/templates/mails/approval_denied.txt b/input/templates/mails/approval_denied_applicant.txt similarity index 100% rename from input/templates/mails/approval_denied.txt rename to input/templates/mails/approval_denied_applicant.txt diff --git a/input/templates/mails/approval_granted.html b/input/templates/mails/approval_granted_applicant.html similarity index 100% rename from input/templates/mails/approval_granted.html rename to input/templates/mails/approval_granted_applicant.html diff --git a/input/templates/mails/approval_granted.txt b/input/templates/mails/approval_granted_applicant.txt similarity index 100% rename from input/templates/mails/approval_granted.txt rename to input/templates/mails/approval_granted_applicant.txt diff --git a/input/utils/mail/__init__.py b/input/utils/mail/__init__.py index 25945ce..ec25b3f 100644 --- a/input/utils/mail/__init__.py +++ b/input/utils/mail/__init__.py @@ -1,9 +1,8 @@ from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template.loader import get_template -from django.utils.html import strip_tags -from input.models import TYPE_CHOICES +from input.models import Project from .attachments import collect_attachment_paths, attach_files @@ -12,8 +11,9 @@ __all__ = [ 'send_email', 'collect_attachment_paths', 'attach_files', - 'send_decision_mail', + 'send_applicant_decision_mail', 'send_staff_decision_mail', + 'send_decision_mails', ] @@ -38,63 +38,47 @@ def send_email(template_name: str, context: dict, subject: str, *recipients: str return build_email(template_name, context, subject, *recipients, **kwargs).send(fail_silently) -def _type_labels(choice: str): - """ - Resolve the human-readable type label. - Returns (HTML label, plain text label). - """ - html = TYPE_CHOICES.get(choice, choice) - plain = strip_tags(html) - return html, plain - - -def _decision_context(obj, choice_code: str) -> dict: +def get_decision_mail_context(obj: Project): """ Build a minimal, consistent context for decision mails (applicant & staff). - Also exposes the full project object as 'project' for template access. """ - type_html, type_plain = _type_labels(choice_code) - realname = getattr(obj, 'realname', '') or getattr(obj, 'email', '') + return { - 'data': { - 'realname': realname, - 'type_label': type_html, - 'type_label_plain': type_plain, - 'name': getattr(obj, 'name', None), - }, 'project': obj, + 'data': { + 'realname': obj.realname or obj.email, + 'name': obj.name, + }, } -def send_decision_mail(obj, choice_code: str, granted: bool) -> None: +def send_base_decision_mail(obj: Project, scope: str, subject: str, recipient: str): + context = get_decision_mail_context(obj) + decision = 'granted' if obj.granted else 'denied' + 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) + + +def send_applicant_decision_mail(obj: Project): """ Send a decision email to the applicant after manual approval/denial. - Uses: input/approval_granted.(txt|html) or input/approval_denied.(txt|html) """ - recipient = getattr(obj, 'email', None) - if not recipient: - return # no recipient -> skip - ctx = _decision_context(obj, choice_code) - template_suffix = 'granted' if granted else 'denied' + if recipient := obj.email: + return send_base_decision_mail(obj, 'applicant', 'Deine Förderanfrage „{name}“ – {decision}', recipient) - project_name = getattr(obj, 'name', None) or '(ohne Projektnamen)' - decision_word = 'bewilligt' if granted else 'abgelehnt' - subject = f'Deine Förderanfrage „{project_name}“ – {decision_word}' - - return send_email(f'approval_{template_suffix}', ctx, subject, recipient) + return 0 -def send_staff_decision_mail(obj, choice_code: str, granted: bool) -> None: +def send_staff_decision_mail(obj: Project): """ Send a decision email to the internal team (staff) after approval/denial. - Uses: input/approval_granted_staff.(txt|html) or input/approval_denied_staff.(txt|html) """ - ctx = _decision_context(obj, choice_code) - template_suffix = 'granted' if granted else 'denied' - project_name = getattr(obj, 'name', None) or '(ohne Projektnamen)' - decision_word = 'bewilligt' if granted else 'abgelehnt' - subject = f'Entscheidung: {project_name} ({decision_word})' + return send_base_decision_mail(obj, 'staff', 'Entscheidung: {name} ({decision})', settings.IF_EMAIL) - return send_email(f'approval_{template_suffix}_staff', ctx, subject, settings.IF_EMAIL) + +def send_decision_mails(obj: Project): + return send_applicant_decision_mail(obj) + send_staff_decision_mail(obj) From 7fcde34897ddc0c5816e1f3691bd1d80fcbbd2ee Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Fri, 17 Oct 2025 16:04:14 +0200 Subject: [PATCH 12/13] clean up mail attachment code --- foerderbarometer/constants.py | 33 +++++++++++++ foerderbarometer/settings.py | 45 ++++++++--------- input/models.py | 14 ++---- input/utils/mail/attachments.py | 86 +++++++++++++++++++-------------- 4 files changed, 105 insertions(+), 73 deletions(-) create mode 100644 foerderbarometer/constants.py diff --git a/foerderbarometer/constants.py b/foerderbarometer/constants.py new file mode 100644 index 0000000..0eef96f --- /dev/null +++ b/foerderbarometer/constants.py @@ -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, +] diff --git a/foerderbarometer/settings.py b/foerderbarometer/settings.py index fcbf851..c51fc4a 100644 --- a/foerderbarometer/settings.py +++ b/foerderbarometer/settings.py @@ -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: [], }, } diff --git a/input/models.py b/input/models.py index 567e675..45f38e9 100755 --- a/input/models.py +++ b/input/models.py @@ -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', @@ -403,17 +406,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'), diff --git a/input/utils/mail/attachments.py b/input/utils/mail/attachments.py index 0916f36..4873365 100644 --- a/input/utils/mail/attachments.py +++ b/input/utils/mail/attachments.py @@ -5,20 +5,25 @@ import urllib.request import urllib.parse import mimetypes +from contextlib import suppress from pathlib import Path from typing import Iterable, List, Tuple from django.conf import settings from django.core.mail import EmailMultiAlternatives +from foerderbarometer.constants import * + def _ensure_cache_dir() -> Path: """ Ensure that the cache directory for attachments 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 @@ -31,6 +36,7 @@ def _cached_filename_for(url: str) -> str: # 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 @@ -38,20 +44,24 @@ 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: + +def download_with_cache(url: str, *, timeout: float = 10.0, chunk_size: int = 64 * 1024, 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)) + ttl = settings.MAIL_ATTACHMENT_TTL_SECONDS filename = _cached_filename_for(url) path = cache_dir / filename @@ -59,11 +69,11 @@ def download_with_cache(url: str, *, timeout: float = 10.0, size_cap_bytes: int return path 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: @@ -74,60 +84,62 @@ def download_with_cache(url: str, *, timeout: float = 10.0, size_cap_bytes: int break os.replace(tmp_path, path) return path - except Exception: + except Exception as exc: # Best-effort cleanup of partial file - try: + with suppress(Exception): if tmp_path.exists(): tmp_path.unlink(missing_ok=True) - except Exception: - pass - # Re-raise to let caller decide - raise -def _filename_from_url(url: str) -> str: + # Re-raise to let caller decide + raise exc + + +def get_filename_from_url(url: str) -> str: """ - Derive a display filename from URL path as a fallback when none provided in settings. + Derive a display filename from URL path. """ + parsed = urllib.parse.urlparse(url) name = Path(parsed.path).name or 'attachment' + return name -def collect_attachment_paths(kind: str, choice: str) -> List[Tuple[Path, str]]: +def collect_attachment_paths(recipient: str, type_code: 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, [])) - result: List[Tuple[Path, str]] = [] - for item in urls: - if isinstance(item, tuple): - url, filename = item - else: - url, filename = item, _filename_from_url(item) + assert recipient in RECIPIENTS + assert type_code in TYPES - 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 + config = settings.MAIL_ATTACHMENT_URLS[recipient] + urls = [*config[TYPE_ALL], *config.get(type_code, [])] + + return [ + (download_with_cache(url), get_filename_from_url(url)) + for url in urls + ] + + +def get_mime_type(filename: str, path: Path): + for value in filename, path: + mime_type, _ = mimetypes.guess_type(value) + + if mime_type: + return mime_type + + return 'application/octet-stream' def attach_files(message: EmailMultiAlternatives, files: Iterable[Tuple[Path, str]]) -> None: """ Attach files to the EmailMultiAlternatives message. - MIME type is guessed from filename; falls back to application/octet-stream. + MIME type is guessed from filename or 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' + mime_type = get_mime_type(filename, path) with open(path, 'rb') as f: - message.attach(filename, f.read(), ctype) + message.attach(filename, f.read(), mime_type) From 1c980924732ebd7b62d7deb4554547d55d26c381 Mon Sep 17 00:00:00 2001 From: Oliver Zander Date: Fri, 17 Oct 2025 17:22:52 +0200 Subject: [PATCH 13/13] improved attachment download code --- input/utils/mail/attachments.py | 129 ++++++++++---------------------- input/views.py | 2 +- 2 files changed, 40 insertions(+), 91 deletions(-) diff --git a/input/utils/mail/attachments.py b/input/utils/mail/attachments.py index 4873365..27d9cf5 100644 --- a/input/utils/mail/attachments.py +++ b/input/utils/mail/attachments.py @@ -1,46 +1,35 @@ -import hashlib import os +import posixpath import time -import urllib.request -import urllib.parse import mimetypes -from contextlib import suppress +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 * +PathList = list[Path] -def _ensure_cache_dir() -> 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) + directory = Path(directory) - return cache_dir + 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. """ @@ -53,93 +42,53 @@ def _is_fresh(path: Path, ttl_seconds: int) -> bool: return time.time() - mtime < ttl_seconds -def download_with_cache(url: str, *, timeout: float = 10.0, chunk_size: int = 64 * 1024, 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. - """ +def get_attachment(url: str) -> Path: + filepath = urlparse(url).path + filename = posixpath.basename(filepath) + destination = ensure_dir(settings.MAIL_ATTACHMENT_CACHE_DIR) / filename - cache_dir = _ensure_cache_dir() - ttl = settings.MAIL_ATTACHMENT_TTL_SECONDS - filename = _cached_filename_for(url) - path = cache_dir / filename + if is_fresh(destination, settings.MAIL_ATTACHMENT_TTL_SECONDS): + return destination - if _is_fresh(path, ttl): - return path + return download_attachment(url, destination) - tmp_path = path.with_suffix(path.suffix + '.part') + +def download_attachment(url: str, destination: Path) -> Path: + filepath = destination.with_suffix('.tmp') 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 - 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 as exc: - # Best-effort cleanup of partial file - with suppress(Exception): - if tmp_path.exists(): - tmp_path.unlink(missing_ok=True) + urlretrieve(url, filepath) + os.replace(filepath, destination) + finally: + filepath.unlink(missing_ok=True) - # Re-raise to let caller decide - raise exc + return destination -def get_filename_from_url(url: str) -> str: - """ - Derive a display filename from URL path. - """ - - parsed = urllib.parse.urlparse(url) - name = Path(parsed.path).name or 'attachment' - - return name - - -def collect_attachment_paths(recipient: str, type_code: str) -> List[Tuple[Path, str]]: - """ - Return a list of (path, filename) for attachments based on settings.MAIL_ATTACHMENT_URLS. - """ - +def collect_attachment_paths(recipient: str, type_code: str) -> PathList: assert recipient in RECIPIENTS assert type_code in TYPES config = settings.MAIL_ATTACHMENT_URLS[recipient] urls = [*config[TYPE_ALL], *config.get(type_code, [])] - return [ - (download_with_cache(url), get_filename_from_url(url)) - for url in urls - ] + return [get_attachment(url) for url in urls] -def get_mime_type(filename: str, path: Path): - for value in filename, path: - mime_type, _ = mimetypes.guess_type(value) +def get_mime_type(path: Path) -> str: + mime_type, encoding = mimetypes.guess_type(path) - if mime_type: - return mime_type - - return 'application/octet-stream' + return mime_type or 'application/octet-stream' -def attach_files(message: EmailMultiAlternatives, files: Iterable[Tuple[Path, str]]) -> None: +def attach_files(message: EmailMultiAlternatives, files: list[Path]): """ Attach files to the EmailMultiAlternatives message. - MIME type is guessed from filename or path; falls back to application/octet-stream. + MIME type is guessed from path; falls back to application/octet-stream. """ - for path, filename in files: - mime_type = get_mime_type(filename, path) + for path in files: + mime_type = get_mime_type(path) - with open(path, 'rb') as f: - message.attach(filename, f.read(), mime_type) + with open(path, 'rb') as fp: + message.attach(path.name, fp.read(), mime_type) diff --git a/input/views.py b/input/views.py index e56264e..d92b2f9 100755 --- a/input/views.py +++ b/input/views.py @@ -289,7 +289,7 @@ 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) + applicant_files = collect_attachment_paths(kind, self.type_code) attach_files(email, applicant_files)