Define core domain models and add first Alembic migration

Nine SQLModel table models covering the Organization/Route hierarchy
(WineCategory, Organization, User, Route, Participant, PurchaseRound,
WineOffering, Order, OrderLine), with explicit tablenames, FK cascade
behavior, and a CHECK constraint preventing a PurchaseRound from
reaching open/closed status without its opens_at/order_deadline_at/
pickup_at dates set. Also fixes the Alembic script template to import
sqlmodel (needed for autogenerate's AutoString column type).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-27 18:51:04 +02:00
parent 589dcbcdf5
commit d2abe70333
11 changed files with 415 additions and 0 deletions

48
app/models/order.py Normal file
View file

@ -0,0 +1,48 @@
from datetime import datetime, timezone
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import Column
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.order_line import OrderLine
from app.models.participant import Participant
from app.models.purchase_round import PurchaseRound
class PaymentStatus(str, Enum):
UNPAID = "unpaid"
PAID = "paid"
class OrderBase(SQLModel):
payment_status: PaymentStatus = Field(
default=PaymentStatus.UNPAID,
sa_column=Column(
SAEnum(
PaymentStatus,
name="payment_status",
native_enum=False,
values_callable=lambda enum_cls: [member.value for member in enum_cls],
),
nullable=False,
),
)
paid_at: Optional[datetime] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Order(OrderBase, table=True):
__tablename__ = "orders"
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")
participant: "Participant" = Relationship(back_populates="orders")
purchase_round: "PurchaseRound" = Relationship(back_populates="orders")
order_lines: List["OrderLine"] = Relationship(
back_populates="order", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)

24
app/models/order_line.py Normal file
View file

@ -0,0 +1,24 @@
from typing import TYPE_CHECKING, Optional
from sqlalchemy import CheckConstraint
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.order import Order
from app.models.wine_offering import WineOffering
class OrderLineBase(SQLModel):
quantity: int = Field(gt=0)
class OrderLine(OrderLineBase, table=True):
__tablename__ = "order_line"
__table_args__ = (CheckConstraint("quantity > 0", name="ck_order_line_quantity_positive"),)
id: Optional[int] = Field(default=None, primary_key=True)
order_id: int = Field(foreign_key="orders.id", ondelete="CASCADE")
wine_offering_id: int = Field(foreign_key="wine_offering.id", ondelete="RESTRICT")
order: "Order" = Relationship(back_populates="order_lines")
wine_offering: "WineOffering" = Relationship(back_populates="order_lines")

View file

@ -0,0 +1,20 @@
from typing import TYPE_CHECKING, List, Optional
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.route import Route
from app.models.user import User
class OrganizationBase(SQLModel):
name: str = Field(unique=True)
class Organization(OrganizationBase, table=True):
__tablename__ = "organization"
id: Optional[int] = Field(default=None, primary_key=True)
users: List["User"] = Relationship(back_populates="organization")
routes: List["Route"] = Relationship(back_populates="organization")

26
app/models/participant.py Normal file
View file

@ -0,0 +1,26 @@
from typing import TYPE_CHECKING, List, Optional
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.order import Order
from app.models.route import Route
class ParticipantBase(SQLModel):
name: str
email: str
phone: Optional[str] = Field(default=None)
is_active: bool = Field(default=True)
class Participant(ParticipantBase, table=True):
__tablename__ = "participant"
id: Optional[int] = Field(default=None, primary_key=True)
route_id: int = Field(foreign_key="route.id", ondelete="CASCADE")
route: "Route" = Relationship(back_populates="participants")
orders: List["Order"] = Relationship(
back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)

View file

@ -0,0 +1,63 @@
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import CheckConstraint, Column
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.order import Order
from app.models.route import Route
from app.models.wine_offering import WineOffering
class PurchaseRoundStatus(str, Enum):
DRAFT = "draft"
OPEN = "open"
CLOSED = "closed"
class PurchaseRoundBase(SQLModel):
name: str
status: PurchaseRoundStatus = Field(
default=PurchaseRoundStatus.DRAFT,
sa_column=Column(
SAEnum(
PurchaseRoundStatus,
name="purchase_round_status",
native_enum=False,
values_callable=lambda enum_cls: [member.value for member in enum_cls],
),
nullable=False,
),
)
opens_at: Optional[datetime] = Field(default=None)
order_deadline_at: Optional[datetime] = Field(default=None)
pickup_at: Optional[datetime] = Field(default=None)
eur_dkk_rate: Optional[Decimal] = Field(default=None, max_digits=10, decimal_places=4)
intro_text: Optional[str] = Field(default=None)
pickup_info_text: Optional[str] = Field(default=None)
class PurchaseRound(PurchaseRoundBase, table=True):
__tablename__ = "purchase_round"
__table_args__ = (
CheckConstraint(
"status = 'draft' OR "
"(opens_at IS NOT NULL AND order_deadline_at IS NOT NULL AND pickup_at IS NOT NULL)",
name="ck_purchase_round_dates_required_unless_draft",
),
)
id: Optional[int] = Field(default=None, primary_key=True)
route_id: int = Field(foreign_key="route.id", ondelete="CASCADE")
route: "Route" = Relationship(back_populates="purchase_rounds")
wine_offerings: List["WineOffering"] = Relationship(
back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
orders: List["Order"] = Relationship(
back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)

30
app/models/route.py Normal file
View file

@ -0,0 +1,30 @@
from typing import TYPE_CHECKING, List, Optional
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.organization import Organization
from app.models.participant import Participant
from app.models.purchase_round import PurchaseRound
class RouteBase(SQLModel):
name: str
meeting_info: Optional[str] = Field(default=None)
contact_person: Optional[str] = Field(default=None)
contact_phone: Optional[str] = Field(default=None)
class Route(RouteBase, table=True):
__tablename__ = "route"
id: Optional[int] = Field(default=None, primary_key=True)
organization_id: int = Field(foreign_key="organization.id", ondelete="RESTRICT")
organization: "Organization" = Relationship(back_populates="routes")
participants: List["Participant"] = Relationship(
back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
purchase_rounds: List["PurchaseRound"] = Relationship(
back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)

23
app/models/user.py Normal file
View file

@ -0,0 +1,23 @@
from typing import TYPE_CHECKING, Optional
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.organization import Organization
class UserBase(SQLModel):
email: str = Field(unique=True, index=True)
name: str
is_active: bool = Field(default=True)
is_superadmin: bool = Field(default=False)
class User(UserBase, table=True):
__tablename__ = "users"
id: Optional[int] = Field(default=None, primary_key=True)
hashed_password: str
organization_id: int = Field(foreign_key="organization.id", ondelete="RESTRICT")
organization: "Organization" = Relationship(back_populates="users")

View file

@ -0,0 +1,19 @@
from typing import TYPE_CHECKING, List, Optional
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.wine_offering import WineOffering
class WineCategoryBase(SQLModel):
name: str = Field(unique=True, index=True)
sort_order: int = Field(default=0)
class WineCategory(WineCategoryBase, table=True):
__tablename__ = "wine_category"
id: Optional[int] = Field(default=None, primary_key=True)
wine_offerings: List["WineOffering"] = Relationship(back_populates="category")

View file

@ -0,0 +1,27 @@
from decimal import Decimal
from typing import TYPE_CHECKING, List, Optional
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.order_line import OrderLine
from app.models.purchase_round import PurchaseRound
from app.models.wine_category import WineCategory
class WineOfferingBase(SQLModel):
name: str
price: Decimal = Field(max_digits=10, decimal_places=2)
is_organic: bool = Field(default=False)
class WineOffering(WineOfferingBase, table=True):
__tablename__ = "wine_offering"
id: Optional[int] = Field(default=None, primary_key=True)
purchase_round_id: int = Field(foreign_key="purchase_round.id", ondelete="CASCADE")
category_id: int = Field(foreign_key="wine_category.id", ondelete="RESTRICT")
purchase_round: "PurchaseRound" = Relationship(back_populates="wine_offerings")
category: "WineCategory" = Relationship(back_populates="wine_offerings")
order_lines: List["OrderLine"] = Relationship(back_populates="wine_offering")

View file

@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel
${imports if imports else ""} ${imports if imports else ""}
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.

View file

@ -0,0 +1,134 @@
"""add core domain models
Revision ID: 31918c010919
Revises:
Create Date: 2026-09-27 18:45:45.613766
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '31918c010919'
down_revision: Union[str, Sequence[str], None] = None
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('organization',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('wine_category',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_wine_category_name'), 'wine_category', ['name'], unique=True)
op.create_table('route',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('meeting_info', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('contact_person', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('contact_phone', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('users',
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_superadmin', sa.Boolean(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('organization_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('participant',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('phone', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_active', sa.Boolean(), 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')
)
op.create_table('purchase_round',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sa.Enum('draft', 'open', 'closed', name='purchase_round_status', native_enum=False), nullable=False),
sa.Column('opens_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=True),
sa.Column('order_deadline_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=True),
sa.Column('pickup_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=True),
sa.Column('eur_dkk_rate', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('intro_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('pickup_info_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('route_id', sa.Integer(), nullable=False),
sa.CheckConstraint("status = 'draft' OR (opens_at IS NOT NULL AND order_deadline_at IS NOT NULL AND pickup_at IS NOT NULL)", name='ck_purchase_round_dates_required_unless_draft'),
sa.ForeignKeyConstraint(['route_id'], ['route.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('orders',
sa.Column('payment_status', sa.Enum('unpaid', 'paid', name='payment_status', native_enum=False), nullable=False),
sa.Column('paid_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=True),
sa.Column('created_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=False),
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.ForeignKeyConstraint(['participant_id'], ['participant.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['purchase_round_id'], ['purchase_round.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('wine_offering',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('is_organic', sa.Boolean(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('purchase_round_id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['wine_category.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['purchase_round_id'], ['purchase_round.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('order_line',
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('wine_offering_id', sa.Integer(), nullable=False),
sa.CheckConstraint('quantity > 0', name='ck_order_line_quantity_positive'),
sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['wine_offering_id'], ['wine_offering.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('order_line')
op.drop_table('wine_offering')
op.drop_table('orders')
op.drop_table('purchase_round')
op.drop_table('participant')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_table('route')
op.drop_index(op.f('ix_wine_category_name'), table_name='wine_category')
op.drop_table('wine_category')
op.drop_table('organization')
# ### end Alembic commands ###