From bf0affcdc69595247144485e8f65b2bcf71df05f Mon Sep 17 00:00:00 2001 From: carsten Date: Mon, 28 Sep 2026 19:43:47 +0200 Subject: [PATCH] Add mail template CRUD (task 7a), per-route and per-event-type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MailTemplate holds subject + body_html with plain {{variable}} placeholders (no loops/conditionals — the user edits these directly and is comfortable generating list-shaped content like the wine catalog in code instead, per the real "Kvitering" email reviewed in old-emails/). One template per (route, event_type), matching the three CLAUDE.md mail-events (round_announced, order_confirmed, payment_confirmed); UNIQUE(route_id, event_type) enforces that and IntegrityError is caught as 409, same pattern as prior tasks. Route gains nullable sender_name/sender_email — the real old emails show a per-route sender identity (e.g. "Finn Gram " for this route), so it lives on Route rather than being duplicated across each of its templates. This is data + CRUD only. Actual Postal sending, the round-announce action, and per-participant send logging are task 7b; a Postal webhook receiver for delivery status is task 7c — both need real Postal API access, still to be arranged. old-emails/ (real historical order/participant data) is gitignored, same treatment as old_participants.json. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + app/dependencies.py | 12 +++ app/main.py | 3 +- app/models/mail_template.py | 55 ++++++++++++++ app/models/route.py | 6 ++ app/routers/mail_templates.py | 73 +++++++++++++++++++ ...dd_mail_templates_and_route_sender_info.py | 46 ++++++++++++ 7 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 app/models/mail_template.py create mode 100644 app/routers/mail_templates.py create mode 100644 migrations/versions/a6f09a1a4779_add_mail_templates_and_route_sender_info.py diff --git a/.gitignore b/.gitignore index e5139f0..7f22ade 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist/ # Raw personal-data exports used for one-off migrations — not for version control old_participants.json +old-emails/ diff --git a/app/dependencies.py b/app/dependencies.py index 308b563..a4a21be 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -6,6 +6,7 @@ from sqlmodel import Session, select from app.core.security import decode_access_token from app.db import SessionDep +from app.models.mail_template import MailTemplate from app.models.participant import Participant from app.models.purchase_round import PurchaseRound from app.models.route import Route @@ -94,3 +95,14 @@ def get_wine_offering_in_organization(session: Session, wine_offering_id: int, o if wine_offering is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wine offering not found") return wine_offering + + +def get_mail_template_in_organization(session: Session, template_id: int, organization_id: int) -> MailTemplate: + mail_template = session.exec( + select(MailTemplate) + .join(Route) + .where(MailTemplate.id == template_id, Route.organization_id == organization_id) + ).first() + if mail_template is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mail template not found") + return mail_template diff --git a/app/main.py b/app/main.py index bffab66..7f92fd3 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from sqlalchemy import text from app.db import SessionDep -from app.routers import auth, participants, purchase_rounds, wine_categories, wine_offerings +from app.routers import auth, mail_templates, participants, purchase_rounds, wine_categories, wine_offerings app = FastAPI(title="Vinindkøb Admin API") app.include_router(auth.router) @@ -10,6 +10,7 @@ app.include_router(participants.router) app.include_router(purchase_rounds.router) app.include_router(wine_offerings.router) app.include_router(wine_categories.router) +app.include_router(mail_templates.router) @app.get("/health") diff --git a/app/models/mail_template.py b/app/models/mail_template.py new file mode 100644 index 0000000..a4dfd78 --- /dev/null +++ b/app/models/mail_template.py @@ -0,0 +1,55 @@ +from enum import Enum +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Column, UniqueConstraint +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.route import Route + + +class MailEventType(str, Enum): + ROUND_ANNOUNCED = "round_announced" + ORDER_CONFIRMED = "order_confirmed" + PAYMENT_CONFIRMED = "payment_confirmed" + + +class MailTemplateBase(SQLModel): + event_type: MailEventType = Field( + sa_column=Column( + SAEnum( + MailEventType, + name="mail_event_type", + native_enum=False, + values_callable=lambda enum_cls: [member.value for member in enum_cls], + ), + nullable=False, + ), + ) + subject: str + body_html: str + + +class MailTemplate(MailTemplateBase, table=True): + __tablename__ = "mail_template" + __table_args__ = (UniqueConstraint("route_id", "event_type", name="uq_mail_template_route_event"),) + + id: Optional[int] = Field(default=None, primary_key=True) + route_id: int = Field(foreign_key="route.id", ondelete="CASCADE") + + route: "Route" = Relationship(back_populates="mail_templates") + + +class MailTemplateCreate(MailTemplateBase): + route_id: int + + +class MailTemplateUpdate(SQLModel): + subject: Optional[str] = None + body_html: Optional[str] = None + + +class MailTemplatePublic(MailTemplateBase): + id: int + route_id: int diff --git a/app/models/route.py b/app/models/route.py index 8d87c90..b56b8f7 100644 --- a/app/models/route.py +++ b/app/models/route.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, List, Optional from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: + from app.models.mail_template import MailTemplate from app.models.organization import Organization from app.models.participant import Participant from app.models.purchase_round import PurchaseRound @@ -13,6 +14,8 @@ class RouteBase(SQLModel): meeting_info: Optional[str] = Field(default=None) contact_person: Optional[str] = Field(default=None) contact_phone: Optional[str] = Field(default=None) + sender_name: Optional[str] = Field(default=None) + sender_email: Optional[str] = Field(default=None) class Route(RouteBase, table=True): @@ -28,3 +31,6 @@ class Route(RouteBase, table=True): purchase_rounds: List["PurchaseRound"] = Relationship( back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"} ) + mail_templates: List["MailTemplate"] = Relationship( + back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"} + ) diff --git a/app/routers/mail_templates.py b/app/routers/mail_templates.py new file mode 100644 index 0000000..5a65c13 --- /dev/null +++ b/app/routers/mail_templates.py @@ -0,0 +1,73 @@ +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from app.db import SessionDep +from app.dependencies import CurrentUser, get_mail_template_in_organization, get_route_in_organization +from app.models.mail_template import MailTemplate, MailTemplateCreate, MailTemplatePublic, MailTemplateUpdate +from app.models.route import Route + +router = APIRouter(prefix="/mail-templates", tags=["mail-templates"]) + + +def _commit_or_conflict(session: Session) -> None: + try: + session.commit() + except IntegrityError as exc: + session.rollback() + if "uq_mail_template_route_event" in str(getattr(exc, "orig", exc)): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A mail template for this event type already exists on this route", + ) from exc + raise + + +@router.post("", response_model=MailTemplatePublic, status_code=status.HTTP_201_CREATED) +def create_mail_template(payload: MailTemplateCreate, session: SessionDep, current_user: CurrentUser) -> MailTemplate: + get_route_in_organization(session, payload.route_id, current_user.organization_id) + mail_template = MailTemplate.model_validate(payload) + session.add(mail_template) + _commit_or_conflict(session) + session.refresh(mail_template) + return mail_template + + +@router.get("", response_model=list[MailTemplatePublic]) +def list_mail_templates( + session: SessionDep, + current_user: CurrentUser, + route_id: Optional[int] = None, +) -> list[MailTemplate]: + statement = select(MailTemplate).join(Route).where(Route.organization_id == current_user.organization_id) + if route_id is not None: + statement = statement.where(MailTemplate.route_id == route_id) + statement = statement.order_by(MailTemplate.id) + return list(session.exec(statement).all()) + + +@router.get("/{template_id}", response_model=MailTemplatePublic) +def get_mail_template(template_id: int, session: SessionDep, current_user: CurrentUser) -> MailTemplate: + return get_mail_template_in_organization(session, template_id, current_user.organization_id) + + +@router.patch("/{template_id}", response_model=MailTemplatePublic) +def update_mail_template( + template_id: int, payload: MailTemplateUpdate, session: SessionDep, current_user: CurrentUser +) -> MailTemplate: + mail_template = get_mail_template_in_organization(session, template_id, current_user.organization_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(mail_template, field, value) + session.add(mail_template) + _commit_or_conflict(session) + session.refresh(mail_template) + return mail_template + + +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_mail_template(template_id: int, session: SessionDep, current_user: CurrentUser) -> None: + mail_template = get_mail_template_in_organization(session, template_id, current_user.organization_id) + session.delete(mail_template) + session.commit() diff --git a/migrations/versions/a6f09a1a4779_add_mail_templates_and_route_sender_info.py b/migrations/versions/a6f09a1a4779_add_mail_templates_and_route_sender_info.py new file mode 100644 index 0000000..5678830 --- /dev/null +++ b/migrations/versions/a6f09a1a4779_add_mail_templates_and_route_sender_info.py @@ -0,0 +1,46 @@ +"""add mail templates and route sender info + +Revision ID: a6f09a1a4779 +Revises: 7b5f332b7ab3 +Create Date: 2026-09-28 19:42:53.055326 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'a6f09a1a4779' +down_revision: Union[str, Sequence[str], None] = '7b5f332b7ab3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('mail_template', + sa.Column('event_type', sa.Enum('round_announced', 'order_confirmed', 'payment_confirmed', name='mail_event_type', native_enum=False), nullable=False), + sa.Column('subject', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('body_html', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('route_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['route_id'], ['route.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('route_id', 'event_type', name='uq_mail_template_route_event') + ) + op.add_column('route', sa.Column('sender_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('route', sa.Column('sender_email', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('route', 'sender_email') + op.drop_column('route', 'sender_name') + op.drop_table('mail_template') + # ### end Alembic commands ###