Add Postal sending, "annoncér runde" action, and MailLog (task 7b)
POST /purchase-rounds/{id}/announce renders the route's round_announced
MailTemplate with simple {{variable}} substitution (app/services/
mail_rendering.py) for every active participant on the route, sends
via Postal's real HTTP API (app/services/postal.py — request/response
shapes verified directly against docs.postalserver.io, not guessed),
and records one MailLog row per attempt (sent/failed, Postal's
message id + token for the future webhook in 7c, denormalized
rendered_subject so the log reflects what was actually sent even if
the template changes later). A failed send for one participant doesn't
abort the rest — each attempt is isolated and committed individually.
Route gained sender_name/sender_email in 7a; both are now populated
for the real route from the historical emails ("Finn Gram - Fælles
Vinindkøb <finn@vinindkoeb.dk>") directly in the DB, since there's no
Route CRUD API yet.
Verified end-to-end against the real Postal instance: a real test
email was sent and received via an isolated throwaway Organization/
Route/Participant/PurchaseRound/MailTemplate sandbox (never the real
route's 308 participants), MailLog captured the correct Postal message
id/token, and a temporarily-invalid API key produced a clean
sent:0/failed:1 result with Postal's actual error message stored,
not a 500. All test data removed afterward; real route/participant
data confirmed untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
bf0affcdc6
commit
34ed5c28d7
14 changed files with 328 additions and 1 deletions
|
|
@ -3,3 +3,5 @@ ENVIRONMENT=development
|
||||||
SECRET_KEY=change-me # generér med: openssl rand -hex 32
|
SECRET_KEY=change-me # generér med: openssl rand -hex 32
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=120
|
ACCESS_TOKEN_EXPIRE_MINUTES=120
|
||||||
ELEVATION_EXPIRE_MINUTES=5
|
ELEVATION_EXPIRE_MINUTES=5
|
||||||
|
POSTAL_BASE_URL=https://postal.example.com
|
||||||
|
POSTAL_API_KEY=change-me
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ class Settings(BaseSettings):
|
||||||
secret_key: str
|
secret_key: str
|
||||||
access_token_expire_minutes: int = 120
|
access_token_expire_minutes: int = 120
|
||||||
elevation_expire_minutes: int = 5
|
elevation_expire_minutes: int = 5
|
||||||
|
postal_base_url: str
|
||||||
|
postal_api_key: str
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|
|
||||||
|
|
@ -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, 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 = FastAPI(title="Vinindkøb Admin API")
|
||||||
app.include_router(auth.router)
|
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_offerings.router)
|
||||||
app.include_router(wine_categories.router)
|
app.include_router(wine_categories.router)
|
||||||
app.include_router(mail_templates.router)
|
app.include_router(mail_templates.router)
|
||||||
|
app.include_router(mail_logs.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
73
app/models/mail_log.py
Normal file
73
app/models/mail_log.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -5,6 +5,7 @@ from sqlalchemy import UniqueConstraint
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from app.models.mail_log import MailLog
|
||||||
from app.models.order import Order
|
from app.models.order import Order
|
||||||
from app.models.route import Route
|
from app.models.route import Route
|
||||||
|
|
||||||
|
|
@ -36,6 +37,9 @@ class Participant(ParticipantBase, table=True):
|
||||||
orders: List["Order"] = Relationship(
|
orders: List["Order"] = Relationship(
|
||||||
back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
|
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):
|
class ParticipantCreate(ParticipantBase):
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from sqlalchemy import Enum as SAEnum
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from app.models.mail_log import MailLog
|
||||||
from app.models.order import Order
|
from app.models.order import Order
|
||||||
from app.models.route import Route
|
from app.models.route import Route
|
||||||
from app.models.wine_offering import WineOffering
|
from app.models.wine_offering import WineOffering
|
||||||
|
|
@ -61,6 +62,9 @@ class PurchaseRound(PurchaseRoundBase, table=True):
|
||||||
orders: List["Order"] = Relationship(
|
orders: List["Order"] = Relationship(
|
||||||
back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
|
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):
|
class PurchaseRoundCreate(PurchaseRoundBase):
|
||||||
|
|
|
||||||
46
app/routers/mail_logs.py
Normal file
46
app/routers/mail_logs.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
@ -12,6 +13,9 @@ from app.dependencies import (
|
||||||
oauth2_scheme,
|
oauth2_scheme,
|
||||||
require_elevated_superuser,
|
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 (
|
from app.models.purchase_round import (
|
||||||
PurchaseRound,
|
PurchaseRound,
|
||||||
PurchaseRoundCreate,
|
PurchaseRoundCreate,
|
||||||
|
|
@ -21,6 +25,8 @@ from app.models.purchase_round import (
|
||||||
)
|
)
|
||||||
from app.models.route import Route
|
from app.models.route import Route
|
||||||
from app.models.wine_offering import WineOffering
|
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"])
|
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)
|
_commit_or_conflict(session)
|
||||||
session.refresh(new_round)
|
session.refresh(new_round)
|
||||||
return 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)}
|
||||||
|
|
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
12
app/services/mail_rendering.py
Normal file
12
app/services/mail_rendering.py
Normal file
|
|
@ -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")
|
||||||
47
app/services/postal.py
Normal file
47
app/services/postal.py
Normal file
|
|
@ -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"]
|
||||||
50
migrations/versions/25684eb2aed5_add_mail_log.py
Normal file
50
migrations/versions/25684eb2aed5_add_mail_log.py
Normal file
|
|
@ -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 ###
|
||||||
|
|
@ -6,6 +6,7 @@ requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alembic>=1.20.0",
|
"alembic>=1.20.0",
|
||||||
"fastapi[standard]>=0.141.1",
|
"fastapi[standard]>=0.141.1",
|
||||||
|
"httpx>=0.28.1",
|
||||||
"psycopg[binary]>=3.3.6",
|
"psycopg[binary]>=3.3.6",
|
||||||
"pwdlib[argon2]>=0.3.1",
|
"pwdlib[argon2]>=0.3.1",
|
||||||
"pydantic-settings>=2.15.0",
|
"pydantic-settings>=2.15.0",
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -1238,6 +1238,7 @@ source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pwdlib", extra = ["argon2"] },
|
{ name = "pwdlib", extra = ["argon2"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
|
@ -1255,6 +1256,7 @@ dev = [
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.20.0" },
|
{ name = "alembic", specifier = ">=1.20.0" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" },
|
||||||
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.1" },
|
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.1" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue