diff --git a/.env.example b/.env.example index 3083dc4..283a6a1 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,5 @@ ENVIRONMENT=development SECRET_KEY=change-me # generér med: openssl rand -hex 32 ACCESS_TOKEN_EXPIRE_MINUTES=120 ELEVATION_EXPIRE_MINUTES=5 +POSTAL_BASE_URL=https://postal.example.com +POSTAL_API_KEY=change-me diff --git a/app/core/config.py b/app/core/config.py index 7670015..9a461b5 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -9,6 +9,8 @@ class Settings(BaseSettings): secret_key: str access_token_expire_minutes: int = 120 elevation_expire_minutes: int = 5 + postal_base_url: str + postal_api_key: str model_config = SettingsConfigDict( env_file=".env", diff --git a/app/main.py b/app/main.py index 7f92fd3..00ec5ab 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, mail_templates, participants, purchase_rounds, wine_categories, wine_offerings +from app.routers import auth, mail_logs, mail_templates, participants, purchase_rounds, wine_categories, wine_offerings app = FastAPI(title="Vinindkøb Admin API") app.include_router(auth.router) @@ -11,6 +11,7 @@ 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.include_router(mail_logs.router) @app.get("/health") diff --git a/app/models/mail_log.py b/app/models/mail_log.py new file mode 100644 index 0000000..a5ccb74 --- /dev/null +++ b/app/models/mail_log.py @@ -0,0 +1,73 @@ +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, Relationship, SQLModel + +from app.models.mail_template import MailEventType + +if TYPE_CHECKING: + from app.models.participant import Participant + from app.models.purchase_round import PurchaseRound + + +class MailLogStatus(str, Enum): + SENT = "sent" + FAILED = "failed" + DELIVERED = "delivered" # udfyldes først af webhook i opgave 7c + BOUNCED = "bounced" # udfyldes først af webhook i opgave 7c + + +class MailLog(SQLModel, table=True): + __tablename__ = "mail_log" + + id: Optional[int] = Field(default=None, primary_key=True) + participant_id: int = Field(foreign_key="participant.id", ondelete="CASCADE") + purchase_round_id: int = Field(foreign_key="purchase_round.id", ondelete="CASCADE") + mail_template_id: Optional[int] = Field(default=None, foreign_key="mail_template.id", ondelete="SET NULL") + + 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, + ) + ) + rendered_subject: str + status: MailLogStatus = Field( + sa_column=Column( + SAEnum( + MailLogStatus, + name="mail_log_status", + native_enum=False, + values_callable=lambda enum_cls: [member.value for member in enum_cls], + ), + nullable=False, + ) + ) + postal_message_id: Optional[int] = Field(default=None) + postal_token: Optional[str] = Field(default=None) + error_message: Optional[str] = Field(default=None) + sent_at: Optional[datetime] = Field(default=None) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + participant: "Participant" = Relationship(back_populates="mail_logs") + purchase_round: "PurchaseRound" = Relationship(back_populates="mail_logs") + + +class MailLogPublic(SQLModel): + id: int + participant_id: int + purchase_round_id: int + event_type: MailEventType + rendered_subject: str + status: MailLogStatus + error_message: Optional[str] + sent_at: Optional[datetime] + created_at: datetime diff --git a/app/models/participant.py b/app/models/participant.py index 7da337c..65cfafb 100644 --- a/app/models/participant.py +++ b/app/models/participant.py @@ -5,6 +5,7 @@ from sqlalchemy import UniqueConstraint from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: + from app.models.mail_log import MailLog from app.models.order import Order from app.models.route import Route @@ -36,6 +37,9 @@ class Participant(ParticipantBase, table=True): orders: List["Order"] = Relationship( back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"} ) + mail_logs: List["MailLog"] = Relationship( + back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"} + ) class ParticipantCreate(ParticipantBase): diff --git a/app/models/purchase_round.py b/app/models/purchase_round.py index c5c0563..41eba42 100644 --- a/app/models/purchase_round.py +++ b/app/models/purchase_round.py @@ -8,6 +8,7 @@ from sqlalchemy import Enum as SAEnum from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: + from app.models.mail_log import MailLog from app.models.order import Order from app.models.route import Route from app.models.wine_offering import WineOffering @@ -61,6 +62,9 @@ class PurchaseRound(PurchaseRoundBase, table=True): orders: List["Order"] = Relationship( back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"} ) + mail_logs: List["MailLog"] = Relationship( + back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"} + ) class PurchaseRoundCreate(PurchaseRoundBase): diff --git a/app/routers/mail_logs.py b/app/routers/mail_logs.py new file mode 100644 index 0000000..e55e905 --- /dev/null +++ b/app/routers/mail_logs.py @@ -0,0 +1,46 @@ +from typing import Optional + +from fastapi import APIRouter, HTTPException, status +from sqlmodel import select + +from app.db import SessionDep +from app.dependencies import CurrentUser +from app.models.mail_log import MailLog, MailLogPublic +from app.models.purchase_round import PurchaseRound +from app.models.route import Route + +router = APIRouter(prefix="/mail-logs", tags=["mail-logs"]) + + +@router.get("", response_model=list[MailLogPublic]) +def list_mail_logs( + session: SessionDep, + current_user: CurrentUser, + participant_id: Optional[int] = None, + purchase_round_id: Optional[int] = None, +) -> list[MailLog]: + statement = ( + select(MailLog) + .join(PurchaseRound) + .join(Route) + .where(Route.organization_id == current_user.organization_id) + ) + if participant_id is not None: + statement = statement.where(MailLog.participant_id == participant_id) + if purchase_round_id is not None: + statement = statement.where(MailLog.purchase_round_id == purchase_round_id) + statement = statement.order_by(MailLog.id.desc()) + return list(session.exec(statement).all()) + + +@router.get("/{log_id}", response_model=MailLogPublic) +def get_mail_log(log_id: int, session: SessionDep, current_user: CurrentUser) -> MailLog: + mail_log = session.exec( + select(MailLog) + .join(PurchaseRound) + .join(Route) + .where(MailLog.id == log_id, Route.organization_id == current_user.organization_id) + ).first() + if mail_log is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mail log not found") + return mail_log diff --git a/app/routers/purchase_rounds.py b/app/routers/purchase_rounds.py index ce18167..8d287ff 100644 --- a/app/routers/purchase_rounds.py +++ b/app/routers/purchase_rounds.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -12,6 +13,9 @@ from app.dependencies import ( oauth2_scheme, require_elevated_superuser, ) +from app.models.mail_log import MailLog, MailLogStatus +from app.models.mail_template import MailEventType, MailTemplate +from app.models.participant import Participant from app.models.purchase_round import ( PurchaseRound, PurchaseRoundCreate, @@ -21,6 +25,8 @@ from app.models.purchase_round import ( ) from app.models.route import Route from app.models.wine_offering import WineOffering +from app.services.mail_rendering import format_datetime_da, render_template +from app.services.postal import PostalSendError, send_mail router = APIRouter(prefix="/purchase-rounds", tags=["purchase-rounds"]) @@ -132,3 +138,80 @@ def copy_purchase_round(source_round_id: int, session: SessionDep, current_user: _commit_or_conflict(session) session.refresh(new_round) return new_round + + +@router.post("/{round_id}/announce") +def announce_purchase_round(round_id: int, session: SessionDep, current_user: CurrentUser) -> dict: + purchase_round = get_round_in_organization(session, round_id, current_user.organization_id) + if purchase_round.status != PurchaseRoundStatus.OPEN: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Round must be open before announcing") + + route = purchase_round.route + if not route.sender_name or not route.sender_email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Route has no sender_name/sender_email configured", + ) + + template = session.exec( + select(MailTemplate).where( + MailTemplate.route_id == route.id, MailTemplate.event_type == MailEventType.ROUND_ANNOUNCED + ) + ).first() + if template is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No round_announced template configured for this route", + ) + + participants = session.exec( + select(Participant).where(Participant.route_id == route.id, Participant.is_active == True) # noqa: E712 + ).all() + + sent = failed = 0 + for participant in participants: + variables = { + "participant_name": participant.name, + "route_name": route.name, + "round_name": purchase_round.name, + "opens_at": format_datetime_da(purchase_round.opens_at) if purchase_round.opens_at else "", + "order_deadline_at": format_datetime_da(purchase_round.order_deadline_at) + if purchase_round.order_deadline_at + else "", + "pickup_at": format_datetime_da(purchase_round.pickup_at) if purchase_round.pickup_at else "", + "intro_text": purchase_round.intro_text or "", + "pickup_info_text": purchase_round.pickup_info_text or "", + } + rendered_subject = render_template(template.subject, variables) + rendered_body = render_template(template.body_html, variables) + + log = MailLog( + participant_id=participant.id, + purchase_round_id=purchase_round.id, + mail_template_id=template.id, + event_type=MailEventType.ROUND_ANNOUNCED, + rendered_subject=rendered_subject, + status=MailLogStatus.FAILED, + ) + try: + postal_id, postal_token = send_mail( + to=participant.email, + from_name=route.sender_name, + from_email=route.sender_email, + subject=rendered_subject, + html_body=rendered_body, + tag="round_announced", + ) + log.status = MailLogStatus.SENT + log.postal_message_id = postal_id + log.postal_token = postal_token + log.sent_at = datetime.now(timezone.utc) + sent += 1 + except PostalSendError as exc: + log.error_message = str(exc) + failed += 1 + + session.add(log) + session.commit() + + return {"sent": sent, "failed": failed, "total_participants": len(participants)} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/mail_rendering.py b/app/services/mail_rendering.py new file mode 100644 index 0000000..71f4e51 --- /dev/null +++ b/app/services/mail_rendering.py @@ -0,0 +1,12 @@ +import re +from datetime import datetime + +_PLACEHOLDER_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}") + + +def render_template(text: str, variables: dict[str, str]) -> str: + return _PLACEHOLDER_RE.sub(lambda m: str(variables.get(m.group(1), m.group(0))), text) + + +def format_datetime_da(dt: datetime) -> str: + return dt.strftime("%d-%m-%Y kl. %H:%M") diff --git a/app/services/postal.py b/app/services/postal.py new file mode 100644 index 0000000..fdaa604 --- /dev/null +++ b/app/services/postal.py @@ -0,0 +1,47 @@ +import httpx + +from app.core.config import get_settings + + +class PostalSendError(Exception): + pass + + +def send_mail( + *, + to: str, + from_name: str, + from_email: str, + subject: str, + html_body: str, + tag: str, +) -> tuple[int, str]: + """Sender én e-mail via Postal. Returnerer (postal_message_id, token). + Rejser PostalSendError ved fejl.""" + settings = get_settings() + try: + response = httpx.post( + f"{settings.postal_base_url}/api/v1/send/message", + headers={ + "X-Server-API-Key": settings.postal_api_key, + "Content-Type": "application/json", + }, + json={ + "to": [to], + "from": f"{from_name} <{from_email}>", + "sender": from_email, + "subject": subject, + "html_body": html_body, + "tag": tag, + }, + timeout=10.0, + ) + data = response.json() + except httpx.HTTPError as exc: + raise PostalSendError(str(exc)) from exc + + if data.get("status") != "success": + raise PostalSendError(str(data.get("data", data))) + + message_info = data["data"]["messages"][to] + return message_info["id"], message_info["token"] diff --git a/migrations/versions/25684eb2aed5_add_mail_log.py b/migrations/versions/25684eb2aed5_add_mail_log.py new file mode 100644 index 0000000..f37efa5 --- /dev/null +++ b/migrations/versions/25684eb2aed5_add_mail_log.py @@ -0,0 +1,50 @@ +"""add mail log + +Revision ID: 25684eb2aed5 +Revises: a6f09a1a4779 +Create Date: 2026-09-28 20:13:42.902152 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '25684eb2aed5' +down_revision: Union[str, Sequence[str], None] = 'a6f09a1a4779' +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_log', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('participant_id', sa.Integer(), nullable=False), + sa.Column('purchase_round_id', sa.Integer(), nullable=False), + sa.Column('mail_template_id', sa.Integer(), nullable=True), + sa.Column('event_type', sa.Enum('round_announced', 'order_confirmed', 'payment_confirmed', name='mail_event_type', native_enum=False), nullable=False), + sa.Column('rendered_subject', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sa.Enum('sent', 'failed', 'delivered', 'bounced', name='mail_log_status', native_enum=False), nullable=False), + sa.Column('postal_message_id', sa.Integer(), nullable=True), + sa.Column('postal_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('sent_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=True), + sa.Column('created_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=False), + sa.ForeignKeyConstraint(['mail_template_id'], ['mail_template.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['participant_id'], ['participant.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['purchase_round_id'], ['purchase_round.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('mail_log') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 046c6dd..bf3d629 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.13" dependencies = [ "alembic>=1.20.0", "fastapi[standard]>=0.141.1", + "httpx>=0.28.1", "psycopg[binary]>=3.3.6", "pwdlib[argon2]>=0.3.1", "pydantic-settings>=2.15.0", diff --git a/uv.lock b/uv.lock index 2158a28..1add891 100644 --- a/uv.lock +++ b/uv.lock @@ -1238,6 +1238,7 @@ source = { virtual = "." } dependencies = [ { name = "alembic" }, { name = "fastapi", extra = ["standard"] }, + { name = "httpx" }, { name = "psycopg", extra = ["binary"] }, { name = "pwdlib", extra = ["argon2"] }, { name = "pydantic-settings" }, @@ -1255,6 +1256,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.20.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.1" }, { name = "pydantic-settings", specifier = ">=2.15.0" },