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:
parent
589dcbcdf5
commit
d2abe70333
11 changed files with 415 additions and 0 deletions
48
app/models/order.py
Normal file
48
app/models/order.py
Normal 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
24
app/models/order_line.py
Normal 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")
|
||||
20
app/models/organization.py
Normal file
20
app/models/organization.py
Normal 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
26
app/models/participant.py
Normal 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"}
|
||||
)
|
||||
63
app/models/purchase_round.py
Normal file
63
app/models/purchase_round.py
Normal 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
30
app/models/route.py
Normal 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
23
app/models/user.py
Normal 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")
|
||||
19
app/models/wine_category.py
Normal file
19
app/models/wine_category.py
Normal 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")
|
||||
27
app/models/wine_offering.py
Normal file
27
app/models/wine_offering.py
Normal 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")
|
||||
|
|
@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
|
|
|||
134
migrations/versions/31918c010919_add_core_domain_models.py
Normal file
134
migrations/versions/31918c010919_add_core_domain_models.py
Normal 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 ###
|
||||
Loading…
Add table
Reference in a new issue