diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..26375ed --- /dev/null +++ b/app/models/order.py @@ -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"} + ) diff --git a/app/models/order_line.py b/app/models/order_line.py new file mode 100644 index 0000000..91f82c4 --- /dev/null +++ b/app/models/order_line.py @@ -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") diff --git a/app/models/organization.py b/app/models/organization.py new file mode 100644 index 0000000..edec208 --- /dev/null +++ b/app/models/organization.py @@ -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") diff --git a/app/models/participant.py b/app/models/participant.py new file mode 100644 index 0000000..9c5a8bc --- /dev/null +++ b/app/models/participant.py @@ -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"} + ) diff --git a/app/models/purchase_round.py b/app/models/purchase_round.py new file mode 100644 index 0000000..2289f61 --- /dev/null +++ b/app/models/purchase_round.py @@ -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"} + ) diff --git a/app/models/route.py b/app/models/route.py new file mode 100644 index 0000000..8d87c90 --- /dev/null +++ b/app/models/route.py @@ -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"} + ) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..3d19981 --- /dev/null +++ b/app/models/user.py @@ -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") diff --git a/app/models/wine_category.py b/app/models/wine_category.py new file mode 100644 index 0000000..12c4053 --- /dev/null +++ b/app/models/wine_category.py @@ -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") diff --git a/app/models/wine_offering.py b/app/models/wine_offering.py new file mode 100644 index 0000000..aaa673d --- /dev/null +++ b/app/models/wine_offering.py @@ -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") diff --git a/migrations/script.py.mako b/migrations/script.py.mako index 1101630..697cf67 100644 --- a/migrations/script.py.mako +++ b/migrations/script.py.mako @@ -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. diff --git a/migrations/versions/31918c010919_add_core_domain_models.py b/migrations/versions/31918c010919_add_core_domain_models.py new file mode 100644 index 0000000..f7d7921 --- /dev/null +++ b/migrations/versions/31918c010919_add_core_domain_models.py @@ -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 ###