Add mail template CRUD (task 7a), per-route and per-event-type
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 <finn@vinindkoeb.dk>"
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 <noreply@anthropic.com>
This commit is contained in:
parent
acaa2383e9
commit
bf0affcdc6
7 changed files with 195 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ dist/
|
||||||
|
|
||||||
# Raw personal-data exports used for one-off migrations — not for version control
|
# Raw personal-data exports used for one-off migrations — not for version control
|
||||||
old_participants.json
|
old_participants.json
|
||||||
|
old-emails/
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.security import decode_access_token
|
from app.core.security import decode_access_token
|
||||||
from app.db import SessionDep
|
from app.db import SessionDep
|
||||||
|
from app.models.mail_template import MailTemplate
|
||||||
from app.models.participant import Participant
|
from app.models.participant import Participant
|
||||||
from app.models.purchase_round import PurchaseRound
|
from app.models.purchase_round import PurchaseRound
|
||||||
from app.models.route import Route
|
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:
|
if wine_offering is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wine offering not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wine offering not found")
|
||||||
return wine_offering
|
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
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.db import SessionDep
|
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 = FastAPI(title="Vinindkøb Admin API")
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
|
@ -10,6 +10,7 @@ app.include_router(participants.router)
|
||||||
app.include_router(purchase_rounds.router)
|
app.include_router(purchase_rounds.router)
|
||||||
app.include_router(wine_offerings.router)
|
app.include_router(wine_offerings.router)
|
||||||
app.include_router(wine_categories.router)
|
app.include_router(wine_categories.router)
|
||||||
|
app.include_router(mail_templates.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
55
app/models/mail_template.py
Normal file
55
app/models/mail_template.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, List, Optional
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from app.models.mail_template import MailTemplate
|
||||||
from app.models.organization import Organization
|
from app.models.organization import Organization
|
||||||
from app.models.participant import Participant
|
from app.models.participant import Participant
|
||||||
from app.models.purchase_round import PurchaseRound
|
from app.models.purchase_round import PurchaseRound
|
||||||
|
|
@ -13,6 +14,8 @@ class RouteBase(SQLModel):
|
||||||
meeting_info: Optional[str] = Field(default=None)
|
meeting_info: Optional[str] = Field(default=None)
|
||||||
contact_person: Optional[str] = Field(default=None)
|
contact_person: Optional[str] = Field(default=None)
|
||||||
contact_phone: 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):
|
class Route(RouteBase, table=True):
|
||||||
|
|
@ -28,3 +31,6 @@ class Route(RouteBase, table=True):
|
||||||
purchase_rounds: List["PurchaseRound"] = Relationship(
|
purchase_rounds: List["PurchaseRound"] = Relationship(
|
||||||
back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
|
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"}
|
||||||
|
)
|
||||||
|
|
|
||||||
73
app/routers/mail_templates.py
Normal file
73
app/routers/mail_templates.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -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 ###
|
||||||
Loading…
Add table
Reference in a new issue