diff --git a/mailer/bilf-mail-council.html b/mailer/bilf-mail-council.html index 00fe63a5..82bb148e 100644 --- a/mailer/bilf-mail-council.html +++ b/mailer/bilf-mail-council.html @@ -2,7 +2,7 @@ - Ny kollegie-bilbokning {{ booking.date }} + Ny utskotts-bilbokning {{ booking.date }}
@@ -23,7 +23,7 @@

Hej Bilf!,

-

En ny kollegie-bilbokning har gjorts!

+

En ny utskotts-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.

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 might 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 guild's SMTP server and feed the carrier pidgeons

diff --git a/mailer/bilf-mail-private.html b/mailer/bilf-mail-private.html index 0c4af9b6..de65ce48 100644 --- a/mailer/bilf-mail-private.html +++ b/mailer/bilf-mail-private.html @@ -2,7 +2,7 @@ - Ny privat bilbokning {{ booking.date }} + {{ title_sv }} {{ booking.date }}
@@ -23,8 +23,8 @@

Hej Bilf!,

-

En ny PRIVAT bilbokning har gjorts!

-

F-älgen är bokad av {{ booking.name }} den {{ booking.date }} klockan {{ booking.time }}. HANTERA!

+

{{ heading_sv }}

+

{{ body_sv }}

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

@@ -48,8 +48,8 @@

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!

+

{{ heading_en }}

+

{{ body_en }}

Best regards,
The little gnomes that inhabit the guild's SMTP server and feed the carrier pidgeons

diff --git a/mailer/bilf_mailer.py b/mailer/bilf_mailer.py index e70fb698..7ffc743b 100644 --- a/mailer/bilf_mailer.py +++ b/mailer/bilf_mailer.py @@ -1,8 +1,11 @@ import os import html as python_html +import uuid +from datetime import datetime, timezone +from typing import Literal 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 ( @@ -10,17 +13,68 @@ ) from mailer.mail_core import send_mail_to_address +BILF_ADDRESS = "bil@fsektionen.se" -def bilf_mailer(booking: CarBooking_DB) -> None: +BookingAction = Literal["created", "updated", "deleted", "moved_to_council"] - path = os.getcwd() +# Actions that remove the private booking from BilF's calendar +CANCEL_ACTIONS = ("deleted", "moved_to_council") + +# Texts for private booking mails, keyed by action. {{ booking.* }} placeholders are filled in afterwards. +PRIVATE_TEXTS: dict[str, dict[str, str]] = { + "created": { + "subject": "Ny PRIVAT bilbokning / New PRIVATE car booking", + "title_sv": "Ny privat bilbokning", + "heading_sv": "En ny PRIVAT bilbokning har gjorts!", + "body_sv": "F-älgen är bokad av {{ booking.name }} den {{ booking.date }} klockan {{ booking.time }}. HANTERA!", + "heading_en": "A new PRIVATE booking has been made!", + "body_en": "The car is booked by {{ booking.name }} on {{ booking.date }} at {{ booking.time }}. TAKE CARE OF IT!", + }, + "updated": { + "subject": "Updated private car booking / Uppdaterad privat bilbokning", + "title_sv": "Uppdaterad privat bilbokning", + "heading_sv": "En PRIVAT bilbokning har uppdaterats!", + "body_sv": "Bokningen av {{ booking.name }} gäller nu den {{ booking.date }} klockan {{ booking.time }}. HANTERA!", + "heading_en": "A PRIVATE booking has been updated!", + "body_en": "The booking by {{ booking.name }} is now on {{ booking.date }} at {{ booking.time }}. TAKE CARE OF IT!", + }, + "deleted": { + "subject": "Avbokad PRIVAT bilbokning / Cancelled PRIVATE car booking", + "title_sv": "Avbokad privat bilbokning", + "heading_sv": "En PRIVAT bilbokning har avbokats!", + "body_sv": "Bokningen av {{ booking.name }} den {{ booking.date }} klockan {{ booking.time }} har tagits bort.", + "heading_en": "A PRIVATE booking has been cancelled!", + "body_en": "The booking by {{ booking.name }} on {{ booking.date }} at {{ booking.time }} has been deleted.", + }, + "moved_to_council": { + "subject": "PRIVAT bilbokning flyttad till utskott / PRIVATE car booking moved to council", + "title_sv": "Privat bilbokning flyttad till utskott", + "heading_sv": "En PRIVAT bilbokning har gjorts om till en utskottsbokning!", + "body_sv": ( + "Bokningen av {{ booking.name }} den {{ booking.date }} klockan {{ booking.time }} " + "är nu en utskottsbokning för {{ booking.council_sv }}." + ), + "heading_en": "A PRIVATE booking has been changed to a council booking!", + "body_en": ( + "The booking by {{ booking.name }} on {{ booking.date }} at {{ booking.time }} " + "is now a council booking for {{ booking.council_en }}." + ), + }, +} + +# ICS SEQUENCE must increase with every revision of an event, so derive it from the send time +SEQUENCE_EPOCH = datetime(2026, 1, 1, tzinfo=timezone.utc) - 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() + +def render_bilf_mail(booking: CarBooking_DB, action: BookingAction = "created") -> str: + template_name = "bilf-mail-private.html" if booking.personal or action in CANCEL_ACTIONS else "bilf-mail-council.html" + template_path = os.path.join(os.path.dirname(__file__), template_name) + + with open(template_path, "r", encoding="utf-8") as f: + html = f.read() + + for key, text in PRIVATE_TEXTS[action].items(): + html = html.replace("{{ " + key + " }}", text) stockholm_tz = ZoneInfo("Europe/Stockholm") date_string = booking.start_time.astimezone(stockholm_tz).strftime("%Y-%m-%d") @@ -29,22 +83,118 @@ 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änt utskott") - msg = MIMEText(html, "html", "utf-8") + return html - msg["From"] = STANDARD_SENDER - msg["To"] = "bil@fsektionen.se" - if booking.personal: - msg["Subject"] = "Ny PRIVAT bilbokning / New PRIVATE car booking" + +def fold_ics_line(line: str) -> str: + # lines longer than 75 octets are split with CRLF followed by a space + parts: list[str] = [] + current = "" + limit = 75 + for char in line: + if len((current + char).encode("utf-8")) > limit: + parts.append(current) + current = "" + limit = 74 # continuation lines start with a space + current += char + parts.append(current) + return "\r\n ".join(parts) + + +def render_bilf_ics(booking: CarBooking_DB, action: BookingAction = "created") -> str: + now = datetime.now(timezone.utc) + 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 = now.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") + sequence = int((now - SEQUENCE_EPOCH).total_seconds()) + if action in CANCEL_ACTIONS: + method, event_status = "CANCEL", "CANCELLED" else: - msg["Subject"] = "Ny kollegie-bilbokning / New council car booking" + method, event_status = "REQUEST", "CONFIRMED" if booking.confirmed else "TENTATIVE" + summary = { + "created": "Private car booking", + "updated": "Updated private car booking", + "deleted": "Cancelled private car booking", + "moved_to_council": "Private car booking moved to council", + }[action] + 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("\r", "").replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") + ) + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//F-sektionen//Car Booking Mailer//EN", + "CALSCALE:GREGORIAN", + f"METHOD:{method}", + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{dtstamp}", + f"DTSTART:{dtstart}", + f"DTEND:{dtend}", + f"SUMMARY:{escape_ics(summary)}", + f"DESCRIPTION:{escape_ics(description)}", + f"ORGANIZER;CN=F-sektionen:mailto:{STANDARD_SENDER}", + f"ATTENDEE;CN=BilF;RSVP=FALSE:mailto:{BILF_ADDRESS}", + f"SEQUENCE:{sequence}", + f"STATUS:{event_status}", + "END:VEVENT", + "END:VCALENDAR", + ] + return "".join(fold_ics_line(line) + "\r\n" for line in lines) + + +def build_bilf_mail(booking: CarBooking_DB, action: BookingAction = "created") -> MIMEText | MIMEMultipart: + html = render_bilf_mail(booking, action) + + if booking.personal or action in CANCEL_ACTIONS: + msg = MIMEMultipart("mixed") + html_part = MIMEText(html, "html", "utf-8") + msg.attach(html_part) + + calendar_part = MIMEText(render_bilf_ics(booking, action), "calendar", "utf-8") + calendar_part.set_param("method", "CANCEL" if action in CANCEL_ACTIONS else "REQUEST") + calendar_part.add_header("Content-Disposition", "attachment; filename=bilbokning.ics") + msg.attach(calendar_part) + + msg["Subject"] = PRIVATE_TEXTS[action]["subject"] + else: + msg = MIMEText(html, "html", "utf-8") + msg["Subject"] = "Ny utskotts-bilbokning / New council car booking" + + msg["From"] = STANDARD_SENDER + msg["To"] = BILF_ADDRESS + + return msg + + +def send_bilf_mail(msg: MIMEText | MIMEMultipart) -> None: + send_mail_to_address(BILF_ADDRESS, msg) + - send_mail_to_address("bil@fsektionen.se", msg) +def bilf_mailer(booking: CarBooking_DB, action: BookingAction = "created") -> None: + send_bilf_mail(build_bilf_mail(booking, action)) diff --git a/routes/car_renting_router.py b/routes/car_renting_router.py index 24168694..baab3277 100644 --- a/routes/car_renting_router.py +++ b/routes/car_renting_router.py @@ -6,6 +6,7 @@ from user.permission import Permission from db_models.user_model import User_DB from db_models.car_booking_model import CarBooking_DB +from mailer import bilf_mailer car_router = APIRouter() @@ -67,8 +68,12 @@ def remove_car_booking( if car_booking is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Booking not found.") if (car_booking.user == current_user) or manage_permission: + # Build the cancellation before deleting, while the booking's relationships can still be loaded + cancel_mail = bilf_mailer.build_bilf_mail(car_booking, action="deleted") if car_booking.personal else None db.delete(car_booking) db.commit() + if cancel_mail is not None: + bilf_mailer.send_bilf_mail(cancel_mail) return car_booking raise HTTPException(status.HTTP_401_UNAUTHORIZED) diff --git a/services/car_renting_service.py b/services/car_renting_service.py index f3215a2b..23ec1f42 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 @@ -255,6 +254,7 @@ def booking_update( status.HTTP_400_BAD_REQUEST, detail="Council ID is required when making non-personal booking." ) + was_personal = car_booking.personal car_booking.confirmed = booking_confirmed for attr in ["description", "start_time", "end_time", "personal", "council_id"]: @@ -264,4 +264,9 @@ def booking_update( db.commit() + if car_booking.personal: + bilf_mailer.bilf_mailer(car_booking, action="updated") + elif was_personal: + bilf_mailer.bilf_mailer(car_booking, action="moved_to_council") + return car_booking diff --git a/tests/test_car_bookings.py b/tests/test_car_bookings.py index 8d3643e9..95c09373 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,143 @@ 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, + confirmed=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, action="updated") + assert "Updated private car booking" in captured["msg"]["Subject"] + + +def _private_booking(**kwargs): + fields = dict( + booking_id=7, + personal=True, + confirmed=False, + 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, + ) + fields.update(kwargs) + return SimpleNamespace(**fields) + + +def _ics_fields(ics): + unfolded = ics.replace("\r\n ", "") + return dict(line.split(":", 1) for line in unfolded.split("\r\n") if line) + + +def test_private_booking_ics_status_sequence_and_format(monkeypatch): + ics = bilf_mailer_module.render_bilf_ics(_private_booking(user=SimpleNamespace(first_name="A" * 80, last_name="B"))) + assert "\r\n" in ics and "\n" not in ics.replace("\r\n", "") + assert all(len(line.encode("utf-8")) <= 75 for line in ics.split("\r\n")) + fields = _ics_fields(ics) + assert fields["METHOD"] == "REQUEST" + assert fields["STATUS"] == "TENTATIVE" + assert fields["ORGANIZER;CN=F-sektionen"] == "mailto:no-reply@fsektionen.se" + assert fields["ATTENDEE;CN=BilF;RSVP=FALSE"] == "mailto:bil@fsektionen.se" + + assert _ics_fields(bilf_mailer_module.render_bilf_ics(_private_booking(confirmed=True)))["STATUS"] == "CONFIRMED" + + first = bilf_mailer_module.datetime(2030, 1, 1, tzinfo=timezone.utc) + later = bilf_mailer_module.datetime(2030, 1, 2, tzinfo=timezone.utc) + + class FakeDatetime(bilf_mailer_module.datetime): + current = first + + @classmethod + def now(cls, tz=None): + return cls.current + + monkeypatch.setattr(bilf_mailer_module, "datetime", FakeDatetime) + seq_first = int(_ics_fields(bilf_mailer_module.render_bilf_ics(_private_booking(), "updated"))["SEQUENCE"]) + FakeDatetime.current = later + seq_later = int(_ics_fields(bilf_mailer_module.render_bilf_ics(_private_booking(), "updated"))["SEQUENCE"]) + assert seq_later > seq_first + + cancel = _ics_fields(bilf_mailer_module.render_bilf_ics(_private_booking(), "deleted")) + assert cancel["METHOD"] == "CANCEL" + assert cancel["STATUS"] == "CANCELLED" + + +def test_private_booking_delete_sends_cancel(client, admin_token, monkeypatch): + sent = [] + monkeypatch.setattr(bilf_mailer_module, "send_mail_to_address", lambda address, msg: sent.append(msg)) + + resp = create_booking( + client, admin_token, stockholm_dt(2030, 1, 8, 10), stockholm_dt(2030, 1, 8, 12), "private", personal=True + ) + assert resp.status_code in (200, 201) + booking_id = resp.json()["booking_id"] + + resp = client.delete(f"/car/{booking_id}", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert "Cancelled" in sent[-1]["Subject"] + calendar_part = next(part for part in sent[-1].walk() if part.get_content_type() == "text/calendar") + payload = calendar_part.get_payload(decode=True).decode("utf-8") + assert "METHOD:CANCEL" in payload + assert f"UID:car-booking-{booking_id}@fsektionen.se" in payload + + +def test_private_to_council_update_sends_cancel(client, admin_token, admin_council_id, monkeypatch): + sent = [] + monkeypatch.setattr(bilf_mailer_module, "send_mail_to_address", lambda address, msg: sent.append(msg)) + + resp = create_booking( + client, admin_token, stockholm_dt(2030, 1, 8, 10), stockholm_dt(2030, 1, 8, 12), "private", personal=True + ) + booking_id = resp.json()["booking_id"] + + resp = patch_booking(client, admin_token, booking_id, description="still private") + assert resp.status_code == 200 + assert "Updated" in sent[-1]["Subject"] + + resp = patch_booking(client, admin_token, booking_id, personal=False, council_id=admin_council_id) + assert resp.status_code == 200 + assert "moved to council" in sent[-1]["Subject"] + assert len(sent) == 3 + + +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)