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
|
||||
old_participants.json
|
||||
old-emails/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
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
|
||||
|
||||
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"}
|
||||
)
|
||||
|
|
|
|||
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