From b3c5c69bcf75cccb733b6218f49d26f494e8fe82 Mon Sep 17 00:00:00 2001 From: Vic <74256936+Armadillan@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:29:37 +0000 Subject: [PATCH 1/2] fix: correct HTML in email templates feature: ICS calendar invites for private bookings --- mailer/bilf-mail-council.html | 4 +- mailer/bilf-mail-private.html | 2 +- mailer/bilf_mailer.py | 98 +++++++++++++++++++++++++++------ services/car_renting_service.py | 6 +- tests/test_car_bookings.py | 51 +++++++++++++++++ 5 files changed, 140 insertions(+), 21 deletions(-) diff --git a/mailer/bilf-mail-council.html b/mailer/bilf-mail-council.html index 6f39aa43..3ef05fce 100644 --- a/mailer/bilf-mail-council.html +++ b/mailer/bilf-mail-council.html @@ -24,7 +24,7 @@

Hej Bilf!,

En ny kollegie-bilbokning har gjorts!

-

F-älgen är bokad av {{ booking.name }} från {{ booking.council_sv }} den {{ booking.date }} klockan {{ booking.time }}. Detta bör hantera sig självt men vi tänkte att du kanske vill veta./p> +

F-älgen är bokad av {{ booking.name }} från {{ booking.council_sv }} den {{ booking.date }} klockan {{ booking.time }}. Detta bör hantera sig självt men vi tänkte att du kanske vill veta.

Med Vänliga hälsningar,
De små tomtarna som sitter i sektionens SMTP-server och matar brevduvorna

@@ -49,7 +49,7 @@

Hello BilF!,

A new council booking has been made!

-

The car is booked by {{ booking.name }} from {{ booking.council_en }} on {{ booking.date }} at {{ booking.time}}. It should handle itself but we though you migh want to know.

+

The car is booked by {{ booking.name }} from {{ booking.council_en }} on {{ booking.date }} at {{ booking.time }}. It should handle itself, but we thought you might want to know.

Best regards,
The little gnomes that inhabit the guilds SMTP server and feed the carrier pidgeons

diff --git a/mailer/bilf-mail-private.html b/mailer/bilf-mail-private.html index 8bf565ae..ccd8a3f5 100644 --- a/mailer/bilf-mail-private.html +++ b/mailer/bilf-mail-private.html @@ -49,7 +49,7 @@

Hello BilF!,

A new PRIVATE booking has been made!

-

The car is booked by {{ booking.name }} on {{ booking.date }} at {{ booking.time}}. TAKE CARE OF IT!

+

The car is booked by {{ booking.name }} on {{ booking.date }} at {{ booking.time }}. TAKE CARE OF IT!

Best regards,
The little gnomes that inhabit the guilds SMTP server and feed the carrier pidgeons

diff --git a/mailer/bilf_mailer.py b/mailer/bilf_mailer.py index e70fb698..6767f736 100644 --- a/mailer/bilf_mailer.py +++ b/mailer/bilf_mailer.py @@ -1,8 +1,10 @@ import os import html as python_html +import uuid +from datetime import datetime, timezone from zoneinfo import ZoneInfo - +from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from db_models.car_booking_model import CarBooking_DB from mailer.mail_constants import ( @@ -11,16 +13,12 @@ from mailer.mail_core import send_mail_to_address -def bilf_mailer(booking: CarBooking_DB) -> None: - - path = os.getcwd() +def render_bilf_mail(booking: CarBooking_DB) -> str: + template_name = "bilf-mail-private.html" if booking.personal else "bilf-mail-council.html" + template_path = os.path.join(os.path.dirname(__file__), template_name) - if booking.personal: - with open(f"{path}/mailer/bilf-mail-private.html", "r", encoding="utf-8") as f: - html = f.read() - else: - with open(f"{path}/mailer/bilf-mail-council.html", "r", encoding="utf-8") as f: - html = f.read() + with open(template_path, "r", encoding="utf-8") as f: + html = f.read() stockholm_tz = ZoneInfo("Europe/Stockholm") date_string = booking.start_time.astimezone(stockholm_tz).strftime("%Y-%m-%d") @@ -29,22 +27,90 @@ def bilf_mailer(booking: CarBooking_DB) -> None: html = html.replace( "{{ booking.name }}", python_html.escape(booking.user.first_name + " " + booking.user.last_name, quote=True) ) - html = html.replace("{{ booking.date }}", date_string) - html = html.replace("{{ booking.time }}", time_string) if booking.council is not None: html = html.replace("{{ booking.council_en }}", python_html.escape(booking.council.name_en, quote=True)) html = html.replace("{{ booking.council_sv }}", python_html.escape(booking.council.name_sv, quote=True)) + else: + html = html.replace("{{ booking.council_en }}", "Unknown council") + html = html.replace("{{ booking.council_sv }}", "Okänd nämnd") - msg = MIMEText(html, "html", "utf-8") + return html + + +def render_bilf_ics(booking: CarBooking_DB, is_update: bool = False) -> str: + booking_id = getattr(booking, "booking_id", None) + uid = ( + f"car-booking-{booking_id}@fsektionen.se" + if booking_id is not None + else f"car-booking-{uuid.uuid4()}@fsektionen.se" + ) + dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + dtstart = booking.start_time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + dtend = booking.end_time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + summary = "Updated private car booking" if is_update else "Private car booking" + name = f"{booking.user.first_name} {booking.user.last_name}" + description = ( + f"Private car booking for {name}.\n" + f"Start: {booking.start_time.astimezone(ZoneInfo('Europe/Stockholm')).strftime('%Y-%m-%d %H:%M')}\n" + f"End: {booking.end_time.astimezone(ZoneInfo('Europe/Stockholm')).strftime('%Y-%m-%d %H:%M')}" + ) + + def escape_ics(value: str) -> str: + return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") + + return ( + "\n".join( + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//F-sektionen//Car Booking Mailer//EN", + "CALSCALE:GREGORIAN", + "METHOD:REQUEST", + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{dtstamp}", + f"DTSTART:{dtstart}", + f"DTEND:{dtend}", + f"SUMMARY:{escape_ics(summary)}", + f"DESCRIPTION:{escape_ics(description)}", + "ORGANIZER:mailto:bil@fsektionen.se", + f"ATTENDEE;CN={escape_ics(name)}:mailto:bil@fsektionen.se", + f"SEQUENCE:{1 if is_update else 0}", + "STATUS:CONFIRMED", + "END:VEVENT", + "END:VCALENDAR", + ] + ) + + "\n" + ) + + +def bilf_mailer(booking: CarBooking_DB, is_update: bool = False) -> None: + html = render_bilf_mail(booking) - msg["From"] = STANDARD_SENDER - msg["To"] = "bil@fsektionen.se" if booking.personal: - msg["Subject"] = "Ny PRIVAT bilbokning / New PRIVATE car booking" + msg = MIMEMultipart("mixed") + html_part = MIMEText(html, "html", "utf-8") + msg.attach(html_part) + + calendar_part = MIMEText(render_bilf_ics(booking, is_update=is_update), "calendar", "utf-8") + calendar_part.set_param("method", "REQUEST") + calendar_part.add_header("Content-Disposition", "attachment; filename=bilbokning.ics") + msg.attach(calendar_part) + + msg["Subject"] = ( + "Updated private car booking / Uppdaterad privat bilbokning" + if is_update + else ("Ny PRIVAT bilbokning / New PRIVATE car booking") + ) else: + msg = MIMEText(html, "html", "utf-8") msg["Subject"] = "Ny kollegie-bilbokning / New council car booking" + msg["From"] = STANDARD_SENDER + msg["To"] = "bil@fsektionen.se" + send_mail_to_address("bil@fsektionen.se", msg) diff --git a/services/car_renting_service.py b/services/car_renting_service.py index f3215a2b..0da08ec9 100644 --- a/services/car_renting_service.py +++ b/services/car_renting_service.py @@ -146,11 +146,10 @@ def create_new_booking( db.add(db_booking) db.flush() + db.commit() bilf_mailer.bilf_mailer(db_booking) - db.commit() - return db_booking @@ -264,4 +263,7 @@ def booking_update( db.commit() + if car_booking.personal: + bilf_mailer.bilf_mailer(car_booking, is_update=True) + return car_booking diff --git a/tests/test_car_bookings.py b/tests/test_car_bookings.py index 8d3643e9..938e3a3a 100644 --- a/tests/test_car_bookings.py +++ b/tests/test_car_bookings.py @@ -1,7 +1,10 @@ # type: ignore +from types import SimpleNamespace + import pytest from main import app from datetime import datetime, timedelta, timezone +from mailer import bilf_mailer as bilf_mailer_module from .basic_factories import auth_headers # Helper to get Stockholm local time (UTC+1 or UTC+2 DST, but for simplicity, use UTC+1) @@ -30,6 +33,54 @@ def patch_booking(client, token, booking_id, **kwargs): return client.patch(f"/car/{booking_id}", json=kwargs, headers=auth_headers(token)) +def test_private_booking_email_includes_ics_and_update_notice(monkeypatch): + captured = {} + + def fake_send_mail_to_address(address, msg): + captured["address"] = address + captured["msg"] = msg + + monkeypatch.setattr(bilf_mailer_module, "send_mail_to_address", fake_send_mail_to_address) + + booking = SimpleNamespace( + booking_id=42, + personal=True, + start_time=datetime(2030, 1, 8, 9, 0, tzinfo=timezone.utc), + end_time=datetime(2030, 1, 8, 12, 0, tzinfo=timezone.utc), + user=SimpleNamespace(first_name="Ada", last_name="Lovelace"), + council=None, + ) + + bilf_mailer_module.bilf_mailer(booking) + assert captured["address"] == "bil@fsektionen.se" + assert captured["msg"].get_content_type() == "multipart/mixed" + calendar_part = next(part for part in captured["msg"].walk() if part.get_content_type() == "text/calendar") + payload = calendar_part.get_payload(decode=True).decode("utf-8") + assert "BEGIN:VCALENDAR" in payload + assert "UID:car-booking-42@fsektionen.se" in payload + assert "DTSTART:" in payload + assert "DTEND:" in payload + + bilf_mailer_module.bilf_mailer(booking, is_update=True) + assert "Updated private car booking" in captured["msg"]["Subject"] + + +def test_council_booking_creation_sends_email(client, admin_token, admin_council_id, monkeypatch): + sent_bookings = [] + monkeypatch.setattr(bilf_mailer_module, "bilf_mailer", lambda booking: sent_bookings.append(booking)) + + start = stockholm_dt(2030, 1, 8, 10) + end = stockholm_dt(2030, 1, 8, 12) + response = create_booking( + client, admin_token, start, end, "council booking", council_id=admin_council_id + ) + + assert response.status_code in (200, 201) + assert len(sent_bookings) == 1 + assert sent_bookings[0].personal is False + assert sent_bookings[0].council_id == admin_council_id + + def test_admin_autoconfirm_council(client, admin_token, admin_council_id): start = stockholm_dt(2030, 1, 8, 10) # Tuesday end = stockholm_dt(2030, 1, 8, 12) From d94ea72b8e4ea3d1d07e356ee19dddf966c84e37 Mon Sep 17 00:00:00 2001 From: Vic <74256936+Armadillan@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:41:46 +0000 Subject: [PATCH 2/2] fixed ChatGPT not knowing the correct terminology --- mailer/bilf_mailer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mailer/bilf_mailer.py b/mailer/bilf_mailer.py index 6767f736..65049e68 100644 --- a/mailer/bilf_mailer.py +++ b/mailer/bilf_mailer.py @@ -35,7 +35,7 @@ def render_bilf_mail(booking: CarBooking_DB) -> str: html = html.replace("{{ booking.council_sv }}", python_html.escape(booking.council.name_sv, quote=True)) else: html = html.replace("{{ booking.council_en }}", "Unknown council") - html = html.replace("{{ booking.council_sv }}", "Okänd nämnd") + html = html.replace("{{ booking.council_sv }}", "Okänt utskott") return html