Opgave 8b: domænebaseret rute-opløsning + tilmeldings-endpoint
Offentlige endpoints afgør nu ruten via Host-headeren (Route.public_domain) i stedet for route_id i URL'en, så én frontend kan betjene flere organisationers domæner via reverse proxy. GET /public/current-round returnerer altid 200 med current_round: null når ingen runde er åben (i stedet for 404), og nyt POST /public/signup lader deltagere tilmelde sig med kun navn+email uden at røre et eksisterende telefonnummer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5db65fb268
commit
7d767ec7c3
4 changed files with 173 additions and 55 deletions
51
CLAUDE.md
51
CLAUDE.md
|
|
@ -49,6 +49,13 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
||||||
- `sender_name`/`sender_email` — afsenderidentitet til mails,
|
- `sender_name`/`sender_email` — afsenderidentitet til mails,
|
||||||
forskellig pr. rute (fx "Finn Gram - Fælles Vinindkøb
|
forskellig pr. rute (fx "Finn Gram - Fælles Vinindkøb
|
||||||
<finn@vinindkoeb.dk>")
|
<finn@vinindkoeb.dk>")
|
||||||
|
- `public_domain` (unik, nullable) — det domæne den offentlige
|
||||||
|
bestillingsside bruger for denne rute (fx `vinindkoeb.dk`).
|
||||||
|
Opgave 8b: en reverse proxy sender alle organisationers domæner til
|
||||||
|
samme backend, som selv afgør rute ud fra request'ens
|
||||||
|
`Host`-header (ingen `route_id` i URL'en for de offentlige
|
||||||
|
endpoints) — ét frontend-build kan dermed betjene flere
|
||||||
|
organisationer
|
||||||
|
|
||||||
- **Participant** — tilhører en Route
|
- **Participant** — tilhører en Route
|
||||||
- `name`, `email`, `phone`, `is_active`
|
- `name`, `email`, `phone`, `is_active`
|
||||||
|
|
@ -64,6 +71,11 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
||||||
**overskrives** `name`/`phone`/`is_active` fra formularen ved hver
|
**overskrives** `name`/`phone`/`is_active` fra formularen ved hver
|
||||||
bestilling (ingen login = bestillingsformularen er selve vejen man
|
bestilling (ingen login = bestillingsformularen er selve vejen man
|
||||||
holder sine oplysninger opdateret); ny email = ny deltager-række
|
holder sine oplysninger opdateret); ny email = ny deltager-række
|
||||||
|
- Kan også oprettes/opdateres via det separate tilmeldings-endpoint
|
||||||
|
(opgave 8b, `POST /public/signup`, kun navn+email) — bevidst
|
||||||
|
forskellig fra bestillingsflowet: **rører aldrig `phone`**, så en
|
||||||
|
ren tilmelding ikke kan slette et telefonnummer givet ved en
|
||||||
|
tidligere bestilling
|
||||||
|
|
||||||
- **PurchaseRound** — tilhører en Route
|
- **PurchaseRound** — tilhører en Route
|
||||||
- `name` (frit, fx "Forår 2026" — intet sæson/år-felt, da det ikke
|
- `name` (frit, fx "Forår 2026" — intet sæson/år-felt, da det ikke
|
||||||
|
|
@ -84,11 +96,11 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
||||||
|
|
||||||
- **Order** — deltager + runde
|
- **Order** — deltager + runde
|
||||||
- `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at`
|
- `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at`
|
||||||
- Oprettes via den offentlige bestillings-API (opgave 8a, ingen
|
- Oprettes via den offentlige bestillings-API (opgave 8a/8b, ingen
|
||||||
login); admin-ordreoversigt + "markér betalt" er stadig opgave 9
|
login); admin-ordreoversigt + "markér betalt" er stadig opgave 9
|
||||||
|
|
||||||
- **OrderLine** — ordre + wine_offering + antal
|
- **OrderLine** — ordre + wine_offering + antal
|
||||||
- Samme som Order — skrives af opgave 8a's bestillings-endpoint
|
- Samme som Order — skrives af det offentlige bestillings-endpoint
|
||||||
|
|
||||||
- **MailTemplate** — tilhører Route, én pr. `(route, event_type)`
|
- **MailTemplate** — tilhører Route, én pr. `(route, event_type)`
|
||||||
- `event_type`: `round_announced` / `order_confirmed` /
|
- `event_type`: `round_announced` / `order_confirmed` /
|
||||||
|
|
@ -114,14 +126,15 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
||||||
statusopdateres løbende via Postal-webhook (`POST /webhooks/postal`,
|
statusopdateres løbende via Postal-webhook (`POST /webhooks/postal`,
|
||||||
RSA-SHA256-signatur verificeret mod DKIM-nøglen fra DNS).
|
RSA-SHA256-signatur verificeret mod DKIM-nøglen fra DNS).
|
||||||
2. Ordre afgivet → kvittering til deltager. **Implementeret** (opgave
|
2. Ordre afgivet → kvittering til deltager. **Implementeret** (opgave
|
||||||
8a): `POST /public/routes/{route_id}/orders` (ingen login) genbruger
|
8a, omlagt i 8b): `POST /public/orders` (ingen login, ingen
|
||||||
7b's render/send/log-mønster for én deltager. Manglende skabelon
|
`route_id` i URL'en — ruten afgøres af `Host`-headeren, se Route
|
||||||
blokerer bevidst **ikke** bestillingen (kun `/announce`'s admin-flow
|
ovenfor) genbruger 7b's render/send/log-mønster for én deltager.
|
||||||
fejler hårdt på det) — en konfigurationsfejl må aldrig stoppe en
|
Manglende skabelon blokerer bevidst **ikke** bestillingen (kun
|
||||||
rigtig kundes ordre. Kvitteringen genskaber bevidst det gamle
|
`/announce`'s admin-flow fejler hårdt på det) — en konfigurationsfejl
|
||||||
systems fulde vinkatalog-layout (efterligner den fysiske
|
må aldrig stoppe en rigtig kundes ordre. Kvitteringen genskaber
|
||||||
bestillingsseddel ved vinbonden). *Notifikation til admin ved ny
|
bevidst det gamle systems fulde vinkatalog-layout (efterligner den
|
||||||
ordre findes ikke endnu.*
|
fysiske bestillingsseddel ved vinbonden). *Notifikation til admin
|
||||||
|
ved ny ordre findes ikke endnu.*
|
||||||
3. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail
|
3. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail
|
||||||
til deltager (opgave 9 — bygger videre på samme mønster)
|
til deltager (opgave 9 — bygger videre på samme mønster)
|
||||||
|
|
||||||
|
|
@ -132,7 +145,7 @@ endnu.
|
||||||
|
|
||||||
## Fase 1 — nuværende scope
|
## Fase 1 — nuværende scope
|
||||||
|
|
||||||
**Færdige opgaver (1-7, 8a):**
|
**Færdige opgaver (1-7, 8a-b):**
|
||||||
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
||||||
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
||||||
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
||||||
|
|
@ -148,11 +161,23 @@ endnu.
|
||||||
- **7b**: Afsendelse via Postal + `MailLog`
|
- **7b**: Afsendelse via Postal + `MailLog`
|
||||||
(`POST /purchase-rounds/{id}/announce`, `GET /mail-logs`)
|
(`POST /purchase-rounds/{id}/announce`, `GET /mail-logs`)
|
||||||
- **7c**: Postal-webhook til leveringsstatus (`POST /webhooks/postal`)
|
- **7c**: Postal-webhook til leveringsstatus (`POST /webhooks/postal`)
|
||||||
8. "Flyt offentlig bestillingsformular til nyt API" — delt i to:
|
8. "Flyt offentlig bestillingsformular til nyt API" — delt i tre:
|
||||||
- **8a**: ✅ Backend-API (`GET /public/routes/{id}/current-round`,
|
- **8a**: ✅ Backend-API (`GET /public/routes/{id}/current-round`,
|
||||||
`POST /public/routes/{id}/orders`), ad-hoc deltageroprettelse,
|
`POST /public/routes/{id}/orders`), ad-hoc deltageroprettelse,
|
||||||
kvitteringsmail
|
kvitteringsmail
|
||||||
- **8b**: Selve den nye offentlige frontend — brugeren har ikke
|
- **8b**: ✅ Domænebaseret rute-opløsning + tilmeldings-endpoint.
|
||||||
|
`route_id` fjernet fra alle offentlige URL'er (var forældet, før
|
||||||
|
nogen rigtig frontend var bygget mod det) til fordel for
|
||||||
|
`Route.public_domain` + `Host`-header-opslag (se Route ovenfor).
|
||||||
|
`GET /public/current-round` returnerer nu altid `200`
|
||||||
|
(`current_round: null` når ingen runde er åben) i stedet for en
|
||||||
|
`404`, så frontenden kan skifte mellem bestillings- og
|
||||||
|
tilmeldingsvisning uden fejlhåndtering. Nyt
|
||||||
|
`POST /public/signup` (kun navn+email, se Participant ovenfor) —
|
||||||
|
dækker både "ingen runde åben → vis tilmelding" og "runde åben →
|
||||||
|
link til tilmelding uden bestilling" (selve linket/UI-skiftet er
|
||||||
|
en 8c-detalje).
|
||||||
|
- **8c**: Selve den nye offentlige frontend — brugeren har ikke
|
||||||
adgang til den gamle frontends kode (hårdt koblet til det gamle
|
adgang til den gamle frontends kode (hårdt koblet til det gamle
|
||||||
API), så en ny bygges. Egen stack-beslutning, ikke taget endnu.
|
API), så en ny bygges. Egen stack-beslutning, ikke taget endnu.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class RouteBase(SQLModel):
|
||||||
contact_phone: Optional[str] = Field(default=None)
|
contact_phone: Optional[str] = Field(default=None)
|
||||||
sender_name: Optional[str] = Field(default=None)
|
sender_name: Optional[str] = Field(default=None)
|
||||||
sender_email: Optional[str] = Field(default=None)
|
sender_email: Optional[str] = Field(default=None)
|
||||||
|
public_domain: Optional[str] = Field(default=None, unique=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
class Route(RouteBase, table=True):
|
class Route(RouteBase, table=True):
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
@ -14,7 +14,7 @@ from app.models.mail_log import MailLog, MailLogStatus
|
||||||
from app.models.mail_template import MailEventType, MailTemplate
|
from app.models.mail_template import MailEventType, MailTemplate
|
||||||
from app.models.order import Order, OrderConfirmation, OrderPublic
|
from app.models.order import Order, OrderConfirmation, OrderPublic
|
||||||
from app.models.order_line import OrderLine, OrderLinePublic
|
from app.models.order_line import OrderLine, OrderLinePublic
|
||||||
from app.models.participant import Participant, _normalize_email
|
from app.models.participant import Participant, ParticipantPublic, _normalize_email
|
||||||
from app.models.purchase_round import PurchaseRound, PurchaseRoundStatus
|
from app.models.purchase_round import PurchaseRound, PurchaseRoundStatus
|
||||||
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
|
||||||
|
|
@ -55,6 +55,16 @@ class PublicOrderSubmission(SQLModel):
|
||||||
order_lines: list[PublicOrderLineInput]
|
order_lines: list[PublicOrderLineInput]
|
||||||
|
|
||||||
|
|
||||||
|
class PublicSignupInput(SQLModel):
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def _validate_email(cls, v: str) -> str:
|
||||||
|
return _normalize_email(v)
|
||||||
|
|
||||||
|
|
||||||
class PublicWineCategory(SQLModel):
|
class PublicWineCategory(SQLModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|
@ -69,10 +79,7 @@ class PublicWineOffering(SQLModel):
|
||||||
category: PublicWineCategory
|
category: PublicWineCategory
|
||||||
|
|
||||||
|
|
||||||
class PublicRoundInfo(SQLModel):
|
class PublicRoundDetails(SQLModel):
|
||||||
route_id: int
|
|
||||||
route_name: str
|
|
||||||
meeting_info: Optional[str]
|
|
||||||
round_id: int
|
round_id: int
|
||||||
round_name: str
|
round_name: str
|
||||||
opens_at: Optional[datetime]
|
opens_at: Optional[datetime]
|
||||||
|
|
@ -84,6 +91,21 @@ class PublicRoundInfo(SQLModel):
|
||||||
wine_offerings: list[PublicWineOffering]
|
wine_offerings: list[PublicWineOffering]
|
||||||
|
|
||||||
|
|
||||||
|
class PublicPageInfo(SQLModel):
|
||||||
|
route_name: str
|
||||||
|
meeting_info: Optional[str]
|
||||||
|
current_round: Optional[PublicRoundDetails]
|
||||||
|
|
||||||
|
|
||||||
|
def get_route_from_domain(request: Request, session: Session) -> Route:
|
||||||
|
host = request.headers.get("host", "")
|
||||||
|
domain = host.split(":")[0] # fjern evt. port
|
||||||
|
route = session.exec(select(Route).where(Route.public_domain == domain)).first()
|
||||||
|
if route is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No route configured for domain {domain!r}")
|
||||||
|
return route
|
||||||
|
|
||||||
|
|
||||||
def _get_open_round(session: Session, route_id: int) -> Optional[PurchaseRound]:
|
def _get_open_round(session: Session, route_id: int) -> Optional[PurchaseRound]:
|
||||||
return session.exec(
|
return session.exec(
|
||||||
select(PurchaseRound)
|
select(PurchaseRound)
|
||||||
|
|
@ -127,14 +149,41 @@ def _get_or_create_participant(session: Session, route_id: int, data: PublicPart
|
||||||
return participant
|
return participant
|
||||||
|
|
||||||
|
|
||||||
@router.get("/routes/{route_id}/current-round", response_model=PublicRoundInfo)
|
def _signup_participant(session: Session, route_id: int, data: PublicSignupInput) -> Participant:
|
||||||
def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo:
|
existing = session.exec(
|
||||||
route = session.get(Route, route_id)
|
select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
|
||||||
if route is None:
|
).first()
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found")
|
if existing is not None:
|
||||||
purchase_round = _get_open_round(session, route_id)
|
existing.name = data.name
|
||||||
|
existing.is_active = True # tilmelding er en eksplicit "ja tak til mails"-handling
|
||||||
|
session.add(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
participant = Participant(name=data.name, email=data.email, phone=None, is_active=True, route_id=route_id)
|
||||||
|
session.add(participant)
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
if "uq_participant_route_email" in str(getattr(exc, "orig", exc)):
|
||||||
|
existing = session.exec(
|
||||||
|
select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
existing.name = data.name
|
||||||
|
existing.is_active = True
|
||||||
|
session.add(existing)
|
||||||
|
return existing
|
||||||
|
raise
|
||||||
|
return participant
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current-round", response_model=PublicPageInfo)
|
||||||
|
def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo:
|
||||||
|
route = get_route_from_domain(request, session)
|
||||||
|
purchase_round = _get_open_round(session, route.id)
|
||||||
if purchase_round is None:
|
if purchase_round is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route")
|
return PublicPageInfo(route_name=route.name, meeting_info=route.meeting_info, current_round=None)
|
||||||
|
|
||||||
offerings = session.exec(
|
offerings = session.exec(
|
||||||
select(WineOffering)
|
select(WineOffering)
|
||||||
|
|
@ -142,33 +191,43 @@ def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo:
|
||||||
.options(selectinload(WineOffering.category))
|
.options(selectinload(WineOffering.category))
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
return PublicRoundInfo(
|
return PublicPageInfo(
|
||||||
route_id=route.id,
|
|
||||||
route_name=route.name,
|
route_name=route.name,
|
||||||
meeting_info=route.meeting_info,
|
meeting_info=route.meeting_info,
|
||||||
round_id=purchase_round.id,
|
current_round=PublicRoundDetails(
|
||||||
round_name=purchase_round.name,
|
round_id=purchase_round.id,
|
||||||
opens_at=purchase_round.opens_at,
|
round_name=purchase_round.name,
|
||||||
order_deadline_at=purchase_round.order_deadline_at,
|
opens_at=purchase_round.opens_at,
|
||||||
pickup_at=purchase_round.pickup_at,
|
order_deadline_at=purchase_round.order_deadline_at,
|
||||||
intro_text=purchase_round.intro_text,
|
pickup_at=purchase_round.pickup_at,
|
||||||
pickup_info_text=purchase_round.pickup_info_text,
|
intro_text=purchase_round.intro_text,
|
||||||
eur_dkk_rate=purchase_round.eur_dkk_rate,
|
pickup_info_text=purchase_round.pickup_info_text,
|
||||||
wine_offerings=[
|
eur_dkk_rate=purchase_round.eur_dkk_rate,
|
||||||
PublicWineOffering(
|
wine_offerings=[
|
||||||
id=o.id,
|
PublicWineOffering(
|
||||||
name=o.name,
|
id=o.id,
|
||||||
price=o.price,
|
name=o.name,
|
||||||
is_organic=o.is_organic,
|
price=o.price,
|
||||||
category=PublicWineCategory(
|
is_organic=o.is_organic,
|
||||||
id=o.category.id, name=o.category.name, sort_order=o.category.sort_order
|
category=PublicWineCategory(
|
||||||
),
|
id=o.category.id, name=o.category.name, sort_order=o.category.sort_order
|
||||||
)
|
),
|
||||||
for o in offerings
|
)
|
||||||
],
|
for o in offerings
|
||||||
|
],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup", response_model=ParticipantPublic, status_code=status.HTTP_201_CREATED)
|
||||||
|
def signup(payload: PublicSignupInput, request: Request, session: SessionDep) -> Participant:
|
||||||
|
route = get_route_from_domain(request, session)
|
||||||
|
participant = _signup_participant(session, route.id, payload)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(participant)
|
||||||
|
return participant
|
||||||
|
|
||||||
|
|
||||||
def _send_order_confirmation(
|
def _send_order_confirmation(
|
||||||
session: Session,
|
session: Session,
|
||||||
route: Route,
|
route: Route,
|
||||||
|
|
@ -252,12 +311,10 @@ def _send_order_confirmation(
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/routes/{route_id}/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED)
|
@router.post("/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED)
|
||||||
def submit_order(route_id: int, payload: PublicOrderSubmission, session: SessionDep) -> OrderConfirmation:
|
def submit_order(payload: PublicOrderSubmission, request: Request, session: SessionDep) -> OrderConfirmation:
|
||||||
route = session.get(Route, route_id)
|
route = get_route_from_domain(request, session)
|
||||||
if route is None:
|
purchase_round = _get_open_round(session, route.id)
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found")
|
|
||||||
purchase_round = _get_open_round(session, route_id)
|
|
||||||
if purchase_round is None:
|
if purchase_round is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route")
|
||||||
if not payload.order_lines:
|
if not payload.order_lines:
|
||||||
|
|
@ -277,7 +334,7 @@ def submit_order(route_id: int, payload: PublicOrderSubmission, session: Session
|
||||||
detail=f"wine_offering_id(s) not part of the current round: {sorted(missing)}",
|
detail=f"wine_offering_id(s) not part of the current round: {sorted(missing)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
participant = _get_or_create_participant(session, route_id, payload.participant)
|
participant = _get_or_create_participant(session, route.id, payload.participant)
|
||||||
|
|
||||||
order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id)
|
order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id)
|
||||||
session.add(order)
|
session.add(order)
|
||||||
|
|
|
||||||
35
migrations/versions/6ec72407c24e_add_route_public_domain.py
Normal file
35
migrations/versions/6ec72407c24e_add_route_public_domain.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""add route public_domain
|
||||||
|
|
||||||
|
Revision ID: 6ec72407c24e
|
||||||
|
Revises: 5935c5a3039e
|
||||||
|
Create Date: 2026-09-28 22:31:51.337919
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6ec72407c24e'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '5935c5a3039e'
|
||||||
|
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.add_column('route', sa.Column('public_domain', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
op.create_index(op.f('ix_route_public_domain'), 'route', ['public_domain'], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_route_public_domain'), table_name='route')
|
||||||
|
op.drop_column('route', 'public_domain')
|
||||||
|
# ### end Alembic commands ###
|
||||||
Loading…
Add table
Reference in a new issue