|
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)