From 9ea39def4deb2f319a58b4be74b4ac52a03330c7 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:15:10 +0100 Subject: [PATCH 01/17] build(deps): add deps for data scheme --- pyproject.toml | 1 + uv.lock | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c59e0a0..8c4181e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "asyncpg>=0.31.0", "authlib>=1.6.5", "cryptography>=46.0.3", + "email-validator>=2.2.0", "fastapi>=0.124.0", "pydantic-settings>=2.12.0", "pydantic>=2.12.5", diff --git a/uv.lock b/uv.lock index ecb79cb..468ad7d 100644 --- a/uv.lock +++ b/uv.lock @@ -329,6 +329,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.124.0" @@ -353,6 +375,7 @@ dependencies = [ { name = "asyncpg" }, { name = "authlib" }, { name = "cryptography" }, + { name = "email-validator" }, { name = "fastapi" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -371,6 +394,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "authlib", specifier = ">=1.6.5" }, { name = "cryptography", specifier = ">=46.0.3" }, + { name = "email-validator", specifier = ">=2.2.0" }, { name = "fastapi", specifier = ">=0.124.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, From 31a4423047ff258055bb1899246dfd4e6618e32b Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:16:25 +0100 Subject: [PATCH 02/17] feat(data-models): add customer pydantic and data scheme --- src/app/services/customers/__init__.py | 5 + src/app/services/customers/models/__init__.py | 32 ++++ src/app/services/customers/models/database.py | 148 ++++++++++++++++++ src/app/services/customers/models/schemas.py | 99 ++++++++++++ .../services/customers/services/__init__.py | 12 ++ .../services/customers/services/database.py | 108 +++++++++++++ 6 files changed, 404 insertions(+) create mode 100644 src/app/services/customers/__init__.py create mode 100644 src/app/services/customers/models/__init__.py create mode 100644 src/app/services/customers/models/database.py create mode 100644 src/app/services/customers/models/schemas.py create mode 100644 src/app/services/customers/services/__init__.py create mode 100644 src/app/services/customers/services/database.py diff --git a/src/app/services/customers/__init__.py b/src/app/services/customers/__init__.py new file mode 100644 index 0000000..5e8368c --- /dev/null +++ b/src/app/services/customers/__init__.py @@ -0,0 +1,5 @@ +""" +Customers Service Package + +Handles customer profiles and addresses. +""" diff --git a/src/app/services/customers/models/__init__.py b/src/app/services/customers/models/__init__.py new file mode 100644 index 0000000..e349b11 --- /dev/null +++ b/src/app/services/customers/models/__init__.py @@ -0,0 +1,32 @@ +""" +Customers Models Package + +Exports ORM models and Pydantic schemas for the customers service. +""" + +from .database import AddressDB, CustomerDB +from .schemas import ( + AddressBase, + AddressCreate, + AddressResponse, + AddressUpdate, + CustomerBase, + CustomerCreate, + CustomerResponse, + CustomerUpdate, +) + +__all__ = [ + # ORM models + "CustomerDB", + "AddressDB", + # Pydantic schemas + "CustomerBase", + "CustomerCreate", + "CustomerUpdate", + "CustomerResponse", + "AddressBase", + "AddressCreate", + "AddressUpdate", + "AddressResponse", +] diff --git a/src/app/services/customers/models/database.py b/src/app/services/customers/models/database.py new file mode 100644 index 0000000..de8a3e8 --- /dev/null +++ b/src/app/services/customers/models/database.py @@ -0,0 +1,148 @@ +""" +Customers Database Models + +SQLAlchemy ORM models for the customers service. +These define the DB schema only — no business logic lives here. +""" + +from uuid import UUID, uuid4 + +from sqlalchemy import Boolean, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class CustomerDB(Base, TimestampMixin): + """ + Customer database model. + + One customer per Keycloak user. Created automatically on first + authenticated request via GET /customers/me. + + Columns: + id: Internal UUID primary key. + keycloak_user_id: The 'sub' claim from the Keycloak JWT. Unique. + email: Customer email address. Unique. + first_name: Given name. + last_name: Family name. + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "customers" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + keycloak_user_id: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + doc="Keycloak subject claim (sub) — links DB record to identity provider", + ) + + email: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + doc="Customer email address", + ) + + first_name: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="Given name", + ) + + last_name: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="Family name", + ) + + def __repr__(self) -> str: + return ( + f"CustomerDB(id={self.id}, email={self.email!r}, " + f"keycloak_user_id={self.keycloak_user_id!r})" + ) + + +class AddressDB(Base, TimestampMixin): + """ + Address database model. + + A customer may have multiple addresses. At most one may be the default. + + Columns: + id: Internal UUID primary key. + customer_id: FK → customers.id. + street: Street name and house number. + city: City name. + zip_code: Postal / ZIP code. + country: ISO 3166-1 alpha-2 country code (e.g. 'DE'). + is_default: Whether this is the customer's default shipping address. + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "addresses" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + customer_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("customers.id", ondelete="CASCADE"), + nullable=False, + index=True, + doc="FK to the owning customer", + ) + + street: Mapped[str] = mapped_column( + String(255), + nullable=False, + doc="Street name and house number", + ) + + city: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="City name", + ) + + zip_code: Mapped[str] = mapped_column( + String(20), + nullable=False, + doc="Postal / ZIP code", + ) + + country: Mapped[str] = mapped_column( + String(2), + nullable=False, + doc="ISO 3166-1 alpha-2 country code (e.g. 'DE')", + ) + + is_default: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + doc="Whether this is the customer's default shipping address", + ) + + def __repr__(self) -> str: + return ( + f"AddressDB(id={self.id}, customer_id={self.customer_id}, " + f"city={self.city!r}, country={self.country!r})" + ) diff --git a/src/app/services/customers/models/schemas.py b/src/app/services/customers/models/schemas.py new file mode 100644 index 0000000..8dc13e6 --- /dev/null +++ b/src/app/services/customers/models/schemas.py @@ -0,0 +1,99 @@ +""" +Customers Pydantic Schemas + +API-level input/output validation for the customers service. +Completely independent of SQLAlchemy — only used in routers and functions. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +# ============================================================================ +# Customer Schemas +# ============================================================================ + + +class CustomerBase(BaseModel): + """Shared customer fields used in create and response schemas.""" + + keycloak_user_id: str = Field( + ..., + description="Keycloak subject claim (sub) from JWT", + ) + email: EmailStr = Field(..., description="Customer email address") + first_name: str = Field(..., min_length=1, max_length=100, description="Given name") + last_name: str = Field(..., min_length=1, max_length=100, description="Family name") + + +class CustomerCreate(CustomerBase): + """Schema for creating a new customer record. Used internally on first login.""" + + pass + + +class CustomerUpdate(BaseModel): + """Schema for partial customer updates. All fields optional.""" + + email: EmailStr | None = None + first_name: str | None = Field(default=None, min_length=1, max_length=100) + last_name: str | None = Field(default=None, min_length=1, max_length=100) + + +class CustomerResponse(CustomerBase): + """Customer response schema returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal customer UUID") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + +# ============================================================================ +# Address Schemas +# ============================================================================ + + +class AddressBase(BaseModel): + """Shared address fields.""" + + street: str = Field(..., min_length=1, max_length=255, description="Street and house number") + city: str = Field(..., min_length=1, max_length=100, description="City name") + zip_code: str = Field(..., min_length=1, max_length=20, description="Postal / ZIP code") + country: str = Field( + ..., + min_length=2, + max_length=2, + description="ISO 3166-1 alpha-2 country code (e.g. 'DE')", + ) + is_default: bool = Field(default=False, description="Whether this is the default shipping address") + + +class AddressCreate(AddressBase): + """Schema for creating a new address. customer_id is injected from the auth token.""" + + pass + + +class AddressUpdate(BaseModel): + """Schema for partial address updates. All fields optional.""" + + street: str | None = Field(default=None, min_length=1, max_length=255) + city: str | None = Field(default=None, min_length=1, max_length=100) + zip_code: str | None = Field(default=None, min_length=1, max_length=20) + country: str | None = Field(default=None, min_length=2, max_length=2) + is_default: bool | None = None + + +class AddressResponse(AddressBase): + """Address response schema returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal address UUID") + customer_id: UUID = Field(..., description="UUID of the owning customer") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/customers/services/__init__.py b/src/app/services/customers/services/__init__.py new file mode 100644 index 0000000..e1f97d5 --- /dev/null +++ b/src/app/services/customers/services/__init__.py @@ -0,0 +1,12 @@ +""" +Customers Services Package +""" + +from .database import AddressRepository, CustomerRepository, get_address_repository, get_customer_repository + +__all__ = [ + "CustomerRepository", + "AddressRepository", + "get_customer_repository", + "get_address_repository", +] diff --git a/src/app/services/customers/services/database.py b/src/app/services/customers/services/database.py new file mode 100644 index 0000000..f659eb4 --- /dev/null +++ b/src/app/services/customers/services/database.py @@ -0,0 +1,108 @@ +""" +Customers Database Services + +Repositories for customer and address data access. +Business logic (e.g. enforcing one default address per customer) +belongs in functions/, not here. +""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.database import AddressDB, CustomerDB + + +class CustomerRepository(BaseRepository[CustomerDB]): + """ + Repository for customer database operations. + + Inherits standard CRUD from BaseRepository. + Add customer-specific queries here as the service grows. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(CustomerDB, session) + + async def get_by_keycloak_id(self, keycloak_user_id: str) -> CustomerDB | None: + """ + Look up a customer by their Keycloak subject claim. + + Used on every authenticated request to resolve the customer record. + + Args: + keycloak_user_id: The 'sub' claim from the Keycloak JWT. + + Returns: + CustomerDB instance or None if not yet registered. + """ + return await self.get_by(keycloak_user_id=keycloak_user_id) + + async def get_by_email(self, email: str) -> CustomerDB | None: + """ + Look up a customer by email address. + + Args: + email: Customer email address. + + Returns: + CustomerDB instance or None. + """ + return await self.get_by(email=email) + + +class AddressRepository(BaseRepository[AddressDB]): + """ + Repository for address database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(AddressDB, session) + + async def get_all_for_customer(self, customer_id: UUID) -> list[AddressDB]: + """ + Return all addresses belonging to a customer. + + Args: + customer_id: UUID of the customer. + + Returns: + List of AddressDB instances (may be empty). + """ + stmt = select(self.model).where(self.model.customer_id == customer_id) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_default_for_customer(self, customer_id: UUID) -> AddressDB | None: + """ + Return the default address for a customer, if one exists. + + Args: + customer_id: UUID of the customer. + + Returns: + Default AddressDB instance or None. + """ + stmt = select(self.model).where( + self.model.customer_id == customer_id, + self.model.is_default.is_(True), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +# --------------------------------------------------------------------------- +# Dependency injection factories +# --------------------------------------------------------------------------- + + +def get_customer_repository(session: AsyncSession) -> CustomerRepository: + """Factory for CustomerRepository — use with FastAPI Depends.""" + return CustomerRepository(session) + + +def get_address_repository(session: AsyncSession) -> AddressRepository: + """Factory for AddressRepository — use with FastAPI Depends.""" + return AddressRepository(session) From ff6874dea3e1b81432fef1dd55e88a827f3e7973 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:16:57 +0100 Subject: [PATCH 03/17] feat(data-models): add inventory pydantic and data scheme --- src/app/services/inventory/__init__.py | 5 + src/app/services/inventory/models/__init__.py | 32 ++++ src/app/services/inventory/models/database.py | 168 ++++++++++++++++++ src/app/services/inventory/models/schemas.py | 92 ++++++++++ .../services/inventory/services/__init__.py | 17 ++ .../services/inventory/services/database.py | 124 +++++++++++++ 6 files changed, 438 insertions(+) create mode 100644 src/app/services/inventory/__init__.py create mode 100644 src/app/services/inventory/models/__init__.py create mode 100644 src/app/services/inventory/models/database.py create mode 100644 src/app/services/inventory/models/schemas.py create mode 100644 src/app/services/inventory/services/__init__.py create mode 100644 src/app/services/inventory/services/database.py diff --git a/src/app/services/inventory/__init__.py b/src/app/services/inventory/__init__.py new file mode 100644 index 0000000..aa7f77b --- /dev/null +++ b/src/app/services/inventory/__init__.py @@ -0,0 +1,5 @@ +""" +Inventory Service Package + +Handles stock levels and reservations. +""" diff --git a/src/app/services/inventory/models/__init__.py b/src/app/services/inventory/models/__init__.py new file mode 100644 index 0000000..0b1e914 --- /dev/null +++ b/src/app/services/inventory/models/__init__.py @@ -0,0 +1,32 @@ +""" +Inventory Models Package + +Exports ORM models and Pydantic schemas for the inventory service. +""" + +from .database import InventoryItemDB, StockReservationDB +from .schemas import ( + InventoryItemBase, + InventoryItemCreate, + InventoryItemResponse, + InventoryItemUpdate, + ReservationStatus, + StockReservationBase, + StockReservationCreate, + StockReservationResponse, +) + +__all__ = [ + # ORM models + "InventoryItemDB", + "StockReservationDB", + # Pydantic schemas + "InventoryItemBase", + "InventoryItemCreate", + "InventoryItemUpdate", + "InventoryItemResponse", + "ReservationStatus", + "StockReservationBase", + "StockReservationCreate", + "StockReservationResponse", +] diff --git a/src/app/services/inventory/models/database.py b/src/app/services/inventory/models/database.py new file mode 100644 index 0000000..88d1904 --- /dev/null +++ b/src/app/services/inventory/models/database.py @@ -0,0 +1,168 @@ +""" +Inventory Database Models + +SQLAlchemy ORM models for stock levels and reservations. + +Design decisions: +- inventory_items.sku is a soft reference to items.sku (no FK constraint). + This keeps the inventory service decoupled from the item service. + Referential integrity is enforced at the application layer. +- stock_reservations.order_id is a soft reference to orders.id for the + same reason: avoids circular FK dependencies at the DB level. +- DB constraints enforce on_hand >= 0, reserved >= 0, on_hand >= reserved. + These are the last line of defence against overselling. +""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import CheckConstraint, ForeignKey, Integer, String, DateTime, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class InventoryItemDB(Base, TimestampMixin): + """ + Inventory item database model. + + Tracks available (on_hand) and reserved stock for a single SKU. + 'reserved' counts units currently held by active StockReservations. + 'on_hand' is the physical quantity in the warehouse. + + Invariant (enforced by DB constraint): on_hand >= reserved >= 0 + + Columns: + id: UUID primary key. + sku: SKU string — soft reference to items.sku. + on_hand: Physical units in the warehouse. + reserved: Units locked by active reservations (subset of on_hand). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "inventory_items" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + sku: Mapped[str] = mapped_column( + String(100), + unique=True, + nullable=False, + index=True, + doc="Stock Keeping Unit — matches items.sku", + ) + + on_hand: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default=text("0"), + doc="Physical stock count in the warehouse", + ) + + reserved: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default=text("0"), + doc="Units locked by active reservations", + ) + + __table_args__ = ( + CheckConstraint("on_hand >= 0", name="ck_inventory_items_on_hand_non_negative"), + CheckConstraint("reserved >= 0", name="ck_inventory_items_reserved_non_negative"), + CheckConstraint("on_hand >= reserved", name="ck_inventory_items_on_hand_gte_reserved"), + ) + + def __repr__(self) -> str: + return ( + f"InventoryItemDB(id={self.id}, sku={self.sku!r}, " + f"on_hand={self.on_hand}, reserved={self.reserved})" + ) + + +class StockReservationDB(Base, TimestampMixin): + """ + Stock reservation database model. + + Created when a customer starts checkout. Holds stock until payment + is confirmed (COMMITTED) or the reservation expires (EXPIRED/RELEASED). + + Lifecycle: + ACTIVE → COMMITTED (payment succeeded, inventory committed) + ACTIVE → RELEASED (payment failed or cart abandoned) + ACTIVE → EXPIRED (TTL exceeded, cleaned up by background job) + + Columns: + id: UUID primary key. + inventory_item_id: FK → inventory_items.id. + order_id: Soft reference to orders.id (no DB FK). + quantity: Number of units reserved. + expires_at: When this reservation automatically expires. + status: Current lifecycle state (see ReservationStatus enum). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "stock_reservations" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + inventory_item_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("inventory_items.id", ondelete="RESTRICT"), + nullable=False, + index=True, + doc="FK to the inventory item being reserved", + ) + + order_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + nullable=False, + index=True, + doc="Soft reference to orders.id (no DB FK to avoid circular dependency)", + ) + + quantity: Mapped[int] = mapped_column( + Integer, + nullable=False, + doc="Number of units reserved", + ) + + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + index=True, + doc="Timestamp after which this reservation is considered expired", + ) + + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="active", + server_default=text("'active'"), + index=True, + doc="Reservation status: active | committed | expired | released", + ) + + __table_args__ = ( + CheckConstraint("quantity > 0", name="ck_stock_reservations_quantity_positive"), + ) + + def __repr__(self) -> str: + return ( + f"StockReservationDB(id={self.id}, order_id={self.order_id}, " + f"quantity={self.quantity}, status={self.status!r})" + ) diff --git a/src/app/services/inventory/models/schemas.py b/src/app/services/inventory/models/schemas.py new file mode 100644 index 0000000..7fe71a5 --- /dev/null +++ b/src/app/services/inventory/models/schemas.py @@ -0,0 +1,92 @@ +""" +Inventory Pydantic Schemas + +API-level input/output validation for the inventory service. +""" + +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================ +# Enums +# ============================================================================ + + +class ReservationStatus(str, Enum): + """Lifecycle states of a stock reservation.""" + + ACTIVE = "active" + COMMITTED = "committed" + EXPIRED = "expired" + RELEASED = "released" + + +# ============================================================================ +# InventoryItem Schemas +# ============================================================================ + + +class InventoryItemBase(BaseModel): + """Shared inventory item fields.""" + + sku: str = Field(..., min_length=1, max_length=100, description="SKU — matches items.sku") + on_hand: int = Field(..., ge=0, description="Physical stock count in the warehouse") + reserved: int = Field(default=0, ge=0, description="Units locked by active reservations") + + +class InventoryItemCreate(InventoryItemBase): + """Schema for creating a new inventory item record.""" + + pass + + +class InventoryItemUpdate(BaseModel): + """Schema for partial inventory updates. All fields optional.""" + + on_hand: int | None = Field(default=None, ge=0) + reserved: int | None = Field(default=None, ge=0) + + +class InventoryItemResponse(InventoryItemBase): + """Inventory item response schema returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal inventory item UUID") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + +# ============================================================================ +# StockReservation Schemas +# ============================================================================ + + +class StockReservationBase(BaseModel): + """Shared stock reservation fields.""" + + inventory_item_id: UUID = Field(..., description="UUID of the reserved inventory item") + order_id: UUID = Field(..., description="UUID of the associated order") + quantity: int = Field(..., gt=0, description="Number of units reserved") + expires_at: datetime = Field(..., description="Expiry timestamp for this reservation") + + +class StockReservationCreate(StockReservationBase): + """Schema for creating a new stock reservation. Status is always ACTIVE on create.""" + + pass + + +class StockReservationResponse(StockReservationBase): + """Stock reservation response schema returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal reservation UUID") + status: ReservationStatus = Field(..., description="Current reservation lifecycle state") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/inventory/services/__init__.py b/src/app/services/inventory/services/__init__.py new file mode 100644 index 0000000..5ad12dd --- /dev/null +++ b/src/app/services/inventory/services/__init__.py @@ -0,0 +1,17 @@ +""" +Inventory Services Package +""" + +from .database import ( + InventoryRepository, + StockReservationRepository, + get_inventory_repository, + get_stock_reservation_repository, +) + +__all__ = [ + "InventoryRepository", + "StockReservationRepository", + "get_inventory_repository", + "get_stock_reservation_repository", +] diff --git a/src/app/services/inventory/services/database.py b/src/app/services/inventory/services/database.py new file mode 100644 index 0000000..1242254 --- /dev/null +++ b/src/app/services/inventory/services/database.py @@ -0,0 +1,124 @@ +""" +Inventory Database Services + +Repositories for inventory items and stock reservations. + +Important: The actual reservation/commit/release logic lives in +functions/ (Phase 1), NOT here. Repositories only handle raw data access. +""" + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.database import InventoryItemDB, StockReservationDB + + +class InventoryRepository(BaseRepository[InventoryItemDB]): + """ + Repository for inventory item database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(InventoryItemDB, session) + + async def get_by_sku(self, sku: str) -> InventoryItemDB | None: + """ + Look up an inventory item by SKU. + + Args: + sku: The SKU string to look up. + + Returns: + InventoryItemDB instance or None if not tracked. + """ + return await self.get_by(sku=sku) + + async def get_available_quantity(self, sku: str) -> int: + """ + Return the number of units available for reservation (on_hand - reserved). + + Uses a single column SELECT instead of fetching the full ORM object — + important when called repeatedly during checkout under load. + + Args: + sku: The SKU string. + + Returns: + Available quantity. Returns 0 if the SKU is not tracked. + """ + stmt = select( + (self.model.on_hand - self.model.reserved).label("available") + ).where(self.model.sku == sku) + result = await self.session.execute(stmt) + available = result.scalar_one_or_none() + if available is None: + return 0 + return max(0, available) + + +class StockReservationRepository(BaseRepository[StockReservationDB]): + """ + Repository for stock reservation database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(StockReservationDB, session) + + async def get_active_for_order(self, order_id: UUID) -> list[StockReservationDB]: + """ + Return all ACTIVE reservations for a given order. + + Args: + order_id: UUID of the order. + + Returns: + List of active StockReservationDB instances. + """ + stmt = select(self.model).where( + and_( + self.model.order_id == order_id, + self.model.status == "active", + ) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_expired_active(self, now: datetime) -> list[StockReservationDB]: + """ + Return all ACTIVE reservations whose expiry timestamp has passed. + + Used by the background cleanup job (Phase 1 — expire_reservations). + + Args: + now: Current UTC datetime. + + Returns: + List of expired-but-still-active StockReservationDB instances. + """ + stmt = select(self.model).where( + and_( + self.model.status == "active", + self.model.expires_at <= now, + ) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + +# --------------------------------------------------------------------------- +# Dependency injection factories +# --------------------------------------------------------------------------- + + +def get_inventory_repository(session: AsyncSession) -> InventoryRepository: + """Factory for InventoryRepository — use with FastAPI Depends.""" + return InventoryRepository(session) + + +def get_stock_reservation_repository(session: AsyncSession) -> StockReservationRepository: + """Factory for StockReservationRepository — use with FastAPI Depends.""" + return StockReservationRepository(session) From eeff063792cb169e12550735746821cbee415fe9 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:17:19 +0100 Subject: [PATCH 04/17] feat(data-models): add orders pydantic and data scheme --- src/app/services/orders/__init__.py | 5 + src/app/services/orders/models/__init__.py | 34 ++++ src/app/services/orders/models/database.py | 154 +++++++++++++++++++ src/app/services/orders/models/schemas.py | 139 +++++++++++++++++ src/app/services/orders/services/__init__.py | 17 ++ src/app/services/orders/services/database.py | 132 ++++++++++++++++ 6 files changed, 481 insertions(+) create mode 100644 src/app/services/orders/__init__.py create mode 100644 src/app/services/orders/models/__init__.py create mode 100644 src/app/services/orders/models/database.py create mode 100644 src/app/services/orders/models/schemas.py create mode 100644 src/app/services/orders/services/__init__.py create mode 100644 src/app/services/orders/services/database.py diff --git a/src/app/services/orders/__init__.py b/src/app/services/orders/__init__.py new file mode 100644 index 0000000..5fffcb2 --- /dev/null +++ b/src/app/services/orders/__init__.py @@ -0,0 +1,5 @@ +""" +Orders Service Package + +Handles order lifecycle from DRAFT to SHIPPED. +""" diff --git a/src/app/services/orders/models/__init__.py b/src/app/services/orders/models/__init__.py new file mode 100644 index 0000000..4bb0d89 --- /dev/null +++ b/src/app/services/orders/models/__init__.py @@ -0,0 +1,34 @@ +""" +Orders Models Package + +Exports ORM models and Pydantic schemas for the orders service. +""" + +from .database import OrderDB, OrderItemDB +from .schemas import ( + OrderBase, + OrderCreate, + OrderDetailResponse, + OrderItemBase, + OrderItemCreate, + OrderItemResponse, + OrderResponse, + OrderStatus, + OrderUpdate, +) + +__all__ = [ + # ORM models + "OrderDB", + "OrderItemDB", + # Pydantic schemas + "OrderStatus", + "OrderBase", + "OrderCreate", + "OrderUpdate", + "OrderResponse", + "OrderDetailResponse", + "OrderItemBase", + "OrderItemCreate", + "OrderItemResponse", +] diff --git a/src/app/services/orders/models/database.py b/src/app/services/orders/models/database.py new file mode 100644 index 0000000..40e6aa3 --- /dev/null +++ b/src/app/services/orders/models/database.py @@ -0,0 +1,154 @@ +""" +Orders Database Models + +SQLAlchemy ORM models for orders and order line items. + +Design decisions: +- OrderItemDB.unit_price is a price SNAPSHOT taken at checkout time. + It deliberately does NOT reference items.price — prices can change + after an order is placed and the order must reflect what the customer paid. +- OrderDB.customer_id is a hard FK to customers.id (CASCADE on delete). +- OrderItemDB.order_id is a hard FK to orders.id (CASCADE on delete). +- Orders support soft deletion via SoftDeleteMixin (deleted_at field). +""" + +from uuid import UUID, uuid4 + +from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, SoftDeleteMixin, TimestampMixin + + +class OrderDB(Base, TimestampMixin, SoftDeleteMixin): + """ + Order database model. + + Represents the lifecycle of a single customer purchase. + + Status transitions (enforced by application layer, not DB): + DRAFT → PENDING_PAYMENT → PAID → READY_TO_SHIP → SHIPPED + PENDING_PAYMENT / DRAFT → CANCELLED + + Columns: + id: UUID primary key. + customer_id: FK → customers.id. + status: Current order status (see OrderStatus enum). + total_amount: Order total in smallest currency unit (e.g. cents). + currency: ISO 4217 currency code (e.g. 'EUR'). + deleted_at: Soft delete timestamp — inherited from SoftDeleteMixin. + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "orders" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + customer_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("customers.id", ondelete="CASCADE"), + nullable=False, + index=True, + doc="FK to the customer who placed this order", + ) + + status: Mapped[str] = mapped_column( + String(25), + nullable=False, + default="draft", + server_default=text("'draft'"), + index=True, + doc="Order status: draft | pending_payment | paid | ready_to_ship | shipped | cancelled", + ) + + total_amount: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + doc="Order total in smallest currency unit (e.g. cents)", + ) + + currency: Mapped[str] = mapped_column( + String(3), + nullable=False, + doc="ISO 4217 currency code (e.g. 'EUR')", + ) + + __table_args__ = ( + CheckConstraint("total_amount >= 0", name="ck_orders_total_amount_non_negative"), + ) + + def __repr__(self) -> str: + return ( + f"OrderDB(id={self.id}, customer_id={self.customer_id}, " + f"status={self.status!r}, total_amount={self.total_amount})" + ) + + +class OrderItemDB(Base, TimestampMixin): + """ + Order item database model. + + Represents a single line item (SKU + quantity + snapshot price) within an order. + + Columns: + id: UUID primary key. + order_id: FK → orders.id. + sku: SKU string — price snapshot, NOT a FK to items. + quantity: Number of units ordered. + unit_price: Price per unit at time of order (in smallest currency unit). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "order_items" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + order_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("orders.id", ondelete="CASCADE"), + nullable=False, + index=True, + doc="FK to the parent order", + ) + + sku: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="SKU at time of order — price snapshot, not a live FK", + ) + + quantity: Mapped[int] = mapped_column( + Integer, + nullable=False, + doc="Number of units ordered", + ) + + unit_price: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + doc="Price per unit at checkout time (in smallest currency unit)", + ) + + __table_args__ = ( + CheckConstraint("quantity > 0", name="ck_order_items_quantity_positive"), + CheckConstraint("unit_price >= 0", name="ck_order_items_unit_price_non_negative"), + ) + + def __repr__(self) -> str: + return ( + f"OrderItemDB(id={self.id}, order_id={self.order_id}, " + f"sku={self.sku!r}, quantity={self.quantity}, unit_price={self.unit_price})" + ) diff --git a/src/app/services/orders/models/schemas.py b/src/app/services/orders/models/schemas.py new file mode 100644 index 0000000..7a56adb --- /dev/null +++ b/src/app/services/orders/models/schemas.py @@ -0,0 +1,139 @@ +""" +Orders Pydantic Schemas + +API-level input/output validation for the orders service. +""" + +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================ +# Enums +# ============================================================================ + + +class OrderStatus(str, Enum): + """ + Order lifecycle states. + + Valid transitions (enforced by application layer): + DRAFT → PENDING_PAYMENT (checkout starts) + PENDING_PAYMENT → PAID (webhook: payment_succeeded) + PENDING_PAYMENT → CANCELLED (webhook: payment_failed / timeout) + DRAFT → CANCELLED (customer cancels) + PAID → READY_TO_SHIP (shipment created) + READY_TO_SHIP → SHIPPED (handed to carrier) + """ + + DRAFT = "draft" + PENDING_PAYMENT = "pending_payment" + PAID = "paid" + READY_TO_SHIP = "ready_to_ship" + SHIPPED = "shipped" + CANCELLED = "cancelled" + + +# ============================================================================ +# OrderItem Schemas +# ============================================================================ + + +class OrderItemBase(BaseModel): + """Shared order item fields.""" + + sku: str = Field(..., min_length=1, max_length=100, description="SKU of the ordered item") + quantity: int = Field(..., gt=0, description="Number of units ordered") + unit_price: int = Field(..., ge=0, description="Price per unit at checkout time (in cents)") + + +class OrderItemCreate(BaseModel): + """ + Schema for adding a line item during order creation. + + unit_price is NOT provided by the client — it is looked up from the + item service at checkout time and stored as an immutable snapshot. + """ + + sku: str = Field(..., min_length=1, max_length=100, description="SKU of the item to order") + quantity: int = Field(..., gt=0, description="Number of units to order") + + +class OrderItemResponse(OrderItemBase): + """Order item response schema.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal order item UUID") + order_id: UUID = Field(..., description="UUID of the parent order") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + +# ============================================================================ +# Order Schemas +# ============================================================================ + + +class OrderBase(BaseModel): + """Shared order fields.""" + + customer_id: UUID = Field(..., description="UUID of the customer") + currency: str = Field( + ..., + min_length=3, + max_length=3, + description="ISO 4217 currency code (e.g. 'EUR')", + ) + + +class OrderCreate(BaseModel): + """ + Schema for creating a new draft order. + + customer_id is injected from the Keycloak token — never from the + request body — to prevent customers from creating orders for others. + """ + + items: list[OrderItemCreate] = Field( + ..., + min_length=1, + description="Line items to include in the order", + ) + currency: str = Field( + default="EUR", + min_length=3, + max_length=3, + description="ISO 4217 currency code", + ) + + +class OrderUpdate(BaseModel): + """Schema for status updates. Used by admin endpoints.""" + + status: OrderStatus | None = None + + +class OrderResponse(OrderBase): + """Order summary response (list view).""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal order UUID") + status: OrderStatus = Field(..., description="Current order status") + total_amount: int = Field(..., description="Order total in cents") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + deleted_at: datetime | None = Field(default=None, description="Soft delete timestamp") + + +class OrderDetailResponse(OrderResponse): + """Order detail response (single order view) — includes line items.""" + + items: list[OrderItemResponse] = Field( + default_factory=list, + description="Line items of this order", + ) diff --git a/src/app/services/orders/services/__init__.py b/src/app/services/orders/services/__init__.py new file mode 100644 index 0000000..650100b --- /dev/null +++ b/src/app/services/orders/services/__init__.py @@ -0,0 +1,17 @@ +""" +Orders Services Package +""" + +from .database import ( + OrderItemRepository, + OrderRepository, + get_order_item_repository, + get_order_repository, +) + +__all__ = [ + "OrderRepository", + "OrderItemRepository", + "get_order_repository", + "get_order_item_repository", +] diff --git a/src/app/services/orders/services/database.py b/src/app/services/orders/services/database.py new file mode 100644 index 0000000..cb127b3 --- /dev/null +++ b/src/app/services/orders/services/database.py @@ -0,0 +1,132 @@ +""" +Orders Database Services + +Repositories for orders and order line items. + +Note: Status transition validation belongs in functions/ (Phase 1), not here. +The repositories are intentionally "dumb" — they perform data access only. +""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.database import OrderDB, OrderItemDB +from ..models.schemas import OrderStatus + + +class OrderRepository(BaseRepository[OrderDB]): + """ + Repository for order database operations. + + All list queries automatically exclude soft-deleted orders + (deleted_at IS NOT NULL). Use get() directly if you need + to access a deleted record for audit purposes. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(OrderDB, session) + + async def get_by_customer( + self, + customer_id: UUID, + skip: int = 0, + limit: int = 50, + ) -> list[OrderDB]: + """ + Return all non-deleted orders for a specific customer, newest first. + + Used for the customer-facing order history endpoint. + + Args: + customer_id: UUID of the customer. + skip: Pagination offset. + limit: Maximum number of results. + + Returns: + List of OrderDB instances (soft-deleted orders excluded). + """ + stmt = ( + select(self.model) + .where( + self.model.customer_id == customer_id, + self.model.deleted_at.is_(None), + ) + .order_by(self.model.created_at.desc()) + .offset(skip) + .limit(limit) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_status( + self, + status: OrderStatus, + skip: int = 0, + limit: int = 50, + ) -> list[OrderDB]: + """ + Return all non-deleted orders with a given status, newest first. + + Used for admin order management (filter by status). + + Args: + status: OrderStatus enum value to filter by. + skip: Pagination offset. + limit: Maximum number of results. + + Returns: + List of OrderDB instances (soft-deleted orders excluded). + """ + stmt = ( + select(self.model) + .where( + self.model.status == status.value, + self.model.deleted_at.is_(None), + ) + .order_by(self.model.created_at.desc()) + .offset(skip) + .limit(limit) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + +class OrderItemRepository(BaseRepository[OrderItemDB]): + """ + Repository for order item database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(OrderItemDB, session) + + async def get_all_for_order(self, order_id: UUID) -> list[OrderItemDB]: + """ + Return all line items for a given order. + + Args: + order_id: UUID of the parent order. + + Returns: + List of OrderItemDB instances. + """ + stmt = select(self.model).where(self.model.order_id == order_id) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + +# --------------------------------------------------------------------------- +# Dependency injection factories +# --------------------------------------------------------------------------- + + +def get_order_repository(session: AsyncSession) -> OrderRepository: + """Factory for OrderRepository — use with FastAPI Depends.""" + return OrderRepository(session) + + +def get_order_item_repository(session: AsyncSession) -> OrderItemRepository: + """Factory for OrderItemRepository — use with FastAPI Depends.""" + return OrderItemRepository(session) From 319e8ed7fac4049bcdb3c1a0acf1b9b22cbc65d9 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:17:40 +0100 Subject: [PATCH 05/17] feat(data-models): add payments pydantic and data scheme --- src/app/services/payments/__init__.py | 5 + src/app/services/payments/models/__init__.py | 23 ++++ src/app/services/payments/models/database.py | 100 ++++++++++++++++++ src/app/services/payments/models/schemas.py | 67 ++++++++++++ .../services/payments/services/__init__.py | 7 ++ .../services/payments/services/database.py | 57 ++++++++++ 6 files changed, 259 insertions(+) create mode 100644 src/app/services/payments/__init__.py create mode 100644 src/app/services/payments/models/__init__.py create mode 100644 src/app/services/payments/models/database.py create mode 100644 src/app/services/payments/models/schemas.py create mode 100644 src/app/services/payments/services/__init__.py create mode 100644 src/app/services/payments/services/database.py diff --git a/src/app/services/payments/__init__.py b/src/app/services/payments/__init__.py new file mode 100644 index 0000000..628e920 --- /dev/null +++ b/src/app/services/payments/__init__.py @@ -0,0 +1,5 @@ +""" +Payments Service Package + +Handles payment records and status tracking. +""" diff --git a/src/app/services/payments/models/__init__.py b/src/app/services/payments/models/__init__.py new file mode 100644 index 0000000..1b2e5f7 --- /dev/null +++ b/src/app/services/payments/models/__init__.py @@ -0,0 +1,23 @@ +""" +Payments Models Package +""" + +from .database import PaymentDB +from .schemas import ( + PaymentBase, + PaymentCreate, + PaymentProvider, + PaymentResponse, + PaymentStatus, + PaymentUpdate, +) + +__all__ = [ + "PaymentDB", + "PaymentStatus", + "PaymentProvider", + "PaymentBase", + "PaymentCreate", + "PaymentUpdate", + "PaymentResponse", +] diff --git a/src/app/services/payments/models/database.py b/src/app/services/payments/models/database.py new file mode 100644 index 0000000..b6131f6 --- /dev/null +++ b/src/app/services/payments/models/database.py @@ -0,0 +1,100 @@ +""" +Payments Database Models + +SQLAlchemy ORM model for payment records. + +Design decisions: +- One payment per order (UNIQUE on order_id). A new payment record is created + for a retry, but the old one is retained for audit purposes. +- provider_reference is UNIQUE to ensure a single PSP transaction maps to + exactly one payment record — critical for idempotent webhook handling. +- order_id is a hard FK to orders.id. +""" + +from uuid import UUID, uuid4 + +from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class PaymentDB(Base, TimestampMixin): + """ + Payment database model. + + Tracks the payment intent/session created with the PSP and its outcome. + + Columns: + id: UUID primary key. + order_id: FK → orders.id. UNIQUE (one payment per order). + provider: PSP provider name (e.g. 'stripe'). + provider_reference: PSP-side ID (e.g. Stripe PaymentIntent ID). UNIQUE. + amount: Charged amount in smallest currency unit (e.g. cents). + currency: ISO 4217 currency code (e.g. 'EUR'). + status: Payment status (see PaymentStatus enum). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "payments" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + order_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("orders.id", ondelete="RESTRICT"), + nullable=False, + doc="FK to the associated order — one payment per order (indexed via UniqueConstraint)", + ) + + provider: Mapped[str] = mapped_column( + String(50), + nullable=False, + doc="PSP provider (e.g. 'stripe')", + ) + + provider_reference: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + doc="PSP-side transaction ID (e.g. Stripe pi_xxx) — unique for idempotency", + ) + + amount: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + doc="Charged amount in smallest currency unit (e.g. cents)", + ) + + currency: Mapped[str] = mapped_column( + String(3), + nullable=False, + doc="ISO 4217 currency code (e.g. 'EUR')", + ) + + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="pending", + server_default=text("'pending'"), + index=True, + doc="Payment status: pending | succeeded | failed | refunded", + ) + + __table_args__ = ( + UniqueConstraint("order_id", name="uq_payments_order_id"), + ) + + def __repr__(self) -> str: + return ( + f"PaymentDB(id={self.id}, order_id={self.order_id}, " + f"provider={self.provider!r}, status={self.status!r})" + ) diff --git a/src/app/services/payments/models/schemas.py b/src/app/services/payments/models/schemas.py new file mode 100644 index 0000000..2ce1652 --- /dev/null +++ b/src/app/services/payments/models/schemas.py @@ -0,0 +1,67 @@ +""" +Payments Pydantic Schemas +""" + +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================ +# Enums +# ============================================================================ + + +class PaymentStatus(str, Enum): + """Payment lifecycle states.""" + + PENDING = "pending" + SUCCEEDED = "succeeded" + FAILED = "failed" + REFUNDED = "refunded" + + +class PaymentProvider(str, Enum): + """Supported payment service providers.""" + + STRIPE = "stripe" + + +# ============================================================================ +# Payment Schemas +# ============================================================================ + + +class PaymentBase(BaseModel): + """Shared payment fields.""" + + order_id: UUID = Field(..., description="UUID of the associated order") + provider: PaymentProvider = Field(..., description="PSP provider") + provider_reference: str = Field(..., description="PSP-side transaction ID") + amount: int = Field(..., ge=0, description="Charged amount in cents") + currency: str = Field(..., min_length=3, max_length=3, description="ISO 4217 currency code") + + +class PaymentCreate(PaymentBase): + """Schema for creating a new payment record (called internally when PSP session is created).""" + + pass + + +class PaymentUpdate(BaseModel): + """Schema for updating payment status — used by the webhook handler.""" + + status: PaymentStatus | None = None + + +class PaymentResponse(PaymentBase): + """Payment response schema.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal payment UUID") + status: PaymentStatus = Field(..., description="Current payment status") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/payments/services/__init__.py b/src/app/services/payments/services/__init__.py new file mode 100644 index 0000000..e05b222 --- /dev/null +++ b/src/app/services/payments/services/__init__.py @@ -0,0 +1,7 @@ +""" +Payments Services Package +""" + +from .database import PaymentRepository, get_payment_repository + +__all__ = ["PaymentRepository", "get_payment_repository"] diff --git a/src/app/services/payments/services/database.py b/src/app/services/payments/services/database.py new file mode 100644 index 0000000..5b5d240 --- /dev/null +++ b/src/app/services/payments/services/database.py @@ -0,0 +1,57 @@ +""" +Payments Database Services + +Repository for payment data access. +""" + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.database import PaymentDB + + +class PaymentRepository(BaseRepository[PaymentDB]): + """ + Repository for payment database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(PaymentDB, session) + + async def get_by_order(self, order_id: UUID) -> PaymentDB | None: + """ + Return the payment record for a given order. + + Args: + order_id: UUID of the associated order. + + Returns: + PaymentDB instance or None. + """ + return await self.get_by(order_id=order_id) + + async def get_by_provider_reference(self, provider_reference: str) -> PaymentDB | None: + """ + Return a payment by its PSP-side reference ID. + + Used by the webhook handler to look up which payment is being updated. + + Args: + provider_reference: PSP transaction ID (e.g. Stripe pi_xxx). + + Returns: + PaymentDB instance or None. + """ + return await self.get_by(provider_reference=provider_reference) + + +# --------------------------------------------------------------------------- +# Dependency injection factory +# --------------------------------------------------------------------------- + + +def get_payment_repository(session: AsyncSession) -> PaymentRepository: + """Factory for PaymentRepository — use with FastAPI Depends.""" + return PaymentRepository(session) From 90bdbafbcbdf6b5c839d7bcc21fc60ca83301007 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:18:01 +0100 Subject: [PATCH 06/17] feat(data-models): add shipments pydantic and data scheme --- src/app/services/shipments/__init__.py | 5 + src/app/services/shipments/models/__init__.py | 25 +++++ src/app/services/shipments/models/database.py | 99 +++++++++++++++++++ src/app/services/shipments/models/schemas.py | 90 +++++++++++++++++ .../services/shipments/services/__init__.py | 7 ++ .../services/shipments/services/database.py | 43 ++++++++ 6 files changed, 269 insertions(+) create mode 100644 src/app/services/shipments/__init__.py create mode 100644 src/app/services/shipments/models/__init__.py create mode 100644 src/app/services/shipments/models/database.py create mode 100644 src/app/services/shipments/models/schemas.py create mode 100644 src/app/services/shipments/services/__init__.py create mode 100644 src/app/services/shipments/services/database.py diff --git a/src/app/services/shipments/__init__.py b/src/app/services/shipments/__init__.py new file mode 100644 index 0000000..afc704f --- /dev/null +++ b/src/app/services/shipments/__init__.py @@ -0,0 +1,5 @@ +""" +Shipments Service Package + +Handles shipment records and carrier label tracking. +""" diff --git a/src/app/services/shipments/models/__init__.py b/src/app/services/shipments/models/__init__.py new file mode 100644 index 0000000..12a3793 --- /dev/null +++ b/src/app/services/shipments/models/__init__.py @@ -0,0 +1,25 @@ +""" +Shipments Models Package +""" + +from .database import ShipmentDB +from .schemas import ( + Carrier, + LabelFormat, + ShipmentBase, + ShipmentCreate, + ShipmentResponse, + ShipmentStatus, + ShipmentUpdate, +) + +__all__ = [ + "ShipmentDB", + "Carrier", + "LabelFormat", + "ShipmentStatus", + "ShipmentBase", + "ShipmentCreate", + "ShipmentUpdate", + "ShipmentResponse", +] diff --git a/src/app/services/shipments/models/database.py b/src/app/services/shipments/models/database.py new file mode 100644 index 0000000..c92ba35 --- /dev/null +++ b/src/app/services/shipments/models/database.py @@ -0,0 +1,99 @@ +""" +Shipments Database Models + +SQLAlchemy ORM model for shipment records. + +Design decisions: +- One shipment per order (UNIQUE on order_id). +- tracking_number and label_url are nullable — they are populated once the + carrier label is created (either manually in Phase 2 or via DHL in Phase 3). +- The CarrierAdapter interface (Phase 3) will write to this table via the + ShipmentRepository — the DB model does not need to change between phases. +""" + +from uuid import UUID, uuid4 + +from sqlalchemy import ForeignKey, String, Text, UniqueConstraint, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class ShipmentDB(Base, TimestampMixin): + """ + Shipment database model. + + Created when an admin marks an order as ready to ship. + Stores carrier, tracking info and label reference. + + Columns: + id: UUID primary key. + order_id: FK → orders.id. UNIQUE (one shipment per order). + carrier: Carrier name (see Carrier enum, e.g. 'dhl' | 'manual'). + tracking_number: Carrier tracking number — NULL until label is created. + label_url: URL/path to label file in storage — NULL until created. + label_format: Label format: 'pdf' | 'zpl' | NULL. + status: Shipment status (see ShipmentStatus enum). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "shipments" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + order_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("orders.id", ondelete="RESTRICT"), + nullable=False, + doc="FK to the associated order — one shipment per order (indexed via UniqueConstraint)", + ) + + carrier: Mapped[str] = mapped_column( + String(50), + nullable=False, + doc="Carrier identifier: 'manual' | 'dhl'", + ) + + tracking_number: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + doc="Carrier tracking number — populated after label creation", + ) + + label_url: Mapped[str | None] = mapped_column( + Text, + nullable=True, + doc="URL or storage path to the label file", + ) + + label_format: Mapped[str | None] = mapped_column( + String(10), + nullable=True, + doc="Label file format: 'pdf' | 'zpl' | NULL", + ) + + status: Mapped[str] = mapped_column( + String(25), + nullable=False, + default="pending", + server_default=text("'pending'"), + index=True, + doc="Shipment status: pending | label_created | handed_over", + ) + + __table_args__ = ( + UniqueConstraint("order_id", name="uq_shipments_order_id"), + ) + + def __repr__(self) -> str: + return ( + f"ShipmentDB(id={self.id}, order_id={self.order_id}, " + f"carrier={self.carrier!r}, status={self.status!r})" + ) diff --git a/src/app/services/shipments/models/schemas.py b/src/app/services/shipments/models/schemas.py new file mode 100644 index 0000000..04a33c8 --- /dev/null +++ b/src/app/services/shipments/models/schemas.py @@ -0,0 +1,90 @@ +""" +Shipments Pydantic Schemas +""" + +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================ +# Enums +# ============================================================================ + + +class Carrier(str, Enum): + """Supported shipping carriers.""" + + MANUAL = "manual" + DHL = "dhl" + + +class LabelFormat(str, Enum): + """Supported shipping label formats.""" + + PDF = "pdf" + ZPL = "zpl" + + +class ShipmentStatus(str, Enum): + """ + Shipment lifecycle states. + + PENDING → shipment record created, no label yet + LABEL_CREATED → carrier label generated, ready for pick & pack + HANDED_OVER → physically handed to carrier / pickup scan confirmed + """ + + PENDING = "pending" + LABEL_CREATED = "label_created" + HANDED_OVER = "handed_over" + + +# ============================================================================ +# Shipment Schemas +# ============================================================================ + + +class ShipmentBase(BaseModel): + """Shared shipment fields.""" + + order_id: UUID = Field(..., description="UUID of the associated order") + carrier: Carrier = Field(..., description="Shipping carrier") + + +class ShipmentCreate(ShipmentBase): + """ + Schema for creating a new shipment record. + + tracking_number may be provided immediately for manual carriers. + For automated carriers (DHL), it is populated later by the label job. + """ + + tracking_number: str | None = Field(default=None, description="Carrier tracking number") + label_url: str | None = Field(default=None, description="Label file URL/path in storage") + label_format: LabelFormat | None = Field(default=None, description="Label file format") + + +class ShipmentUpdate(BaseModel): + """Schema for updating a shipment record (e.g. after label creation).""" + + tracking_number: str | None = None + label_url: str | None = None + label_format: LabelFormat | None = None + status: ShipmentStatus | None = None + + +class ShipmentResponse(ShipmentBase): + """Shipment response schema.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal shipment UUID") + tracking_number: str | None = Field(default=None, description="Carrier tracking number") + label_url: str | None = Field(default=None, description="Label file URL/path") + label_format: LabelFormat | None = Field(default=None, description="Label file format") + status: ShipmentStatus = Field(..., description="Current shipment status") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/shipments/services/__init__.py b/src/app/services/shipments/services/__init__.py new file mode 100644 index 0000000..b249d8d --- /dev/null +++ b/src/app/services/shipments/services/__init__.py @@ -0,0 +1,7 @@ +""" +Shipments Services Package +""" + +from .database import ShipmentRepository, get_shipment_repository + +__all__ = ["ShipmentRepository", "get_shipment_repository"] diff --git a/src/app/services/shipments/services/database.py b/src/app/services/shipments/services/database.py new file mode 100644 index 0000000..72eb732 --- /dev/null +++ b/src/app/services/shipments/services/database.py @@ -0,0 +1,43 @@ +""" +Shipments Database Services + +Repository for shipment data access. +""" + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.database import ShipmentDB + + +class ShipmentRepository(BaseRepository[ShipmentDB]): + """ + Repository for shipment database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(ShipmentDB, session) + + async def get_by_order(self, order_id: UUID) -> ShipmentDB | None: + """ + Return the shipment record for a given order. + + Args: + order_id: UUID of the associated order. + + Returns: + ShipmentDB instance or None if no shipment has been created yet. + """ + return await self.get_by(order_id=order_id) + + +# --------------------------------------------------------------------------- +# Dependency injection factory +# --------------------------------------------------------------------------- + + +def get_shipment_repository(session: AsyncSession) -> ShipmentRepository: + """Factory for ShipmentRepository — use with FastAPI Depends.""" + return ShipmentRepository(session) From 3c4d6cd91ea7d2fb5c899edc6e1d2e3e08b5e382 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:18:19 +0100 Subject: [PATCH 07/17] feat(data-models): add webhooks pydantic and data scheme --- src/app/services/webhooks/__init__.py | 5 + src/app/services/webhooks/models/__init__.py | 12 +++ src/app/services/webhooks/models/database.py | 93 +++++++++++++++++++ src/app/services/webhooks/models/schemas.py | 33 +++++++ .../services/webhooks/services/__init__.py | 7 ++ .../services/webhooks/services/database.py | 57 ++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 src/app/services/webhooks/__init__.py create mode 100644 src/app/services/webhooks/models/__init__.py create mode 100644 src/app/services/webhooks/models/database.py create mode 100644 src/app/services/webhooks/models/schemas.py create mode 100644 src/app/services/webhooks/services/__init__.py create mode 100644 src/app/services/webhooks/services/database.py diff --git a/src/app/services/webhooks/__init__.py b/src/app/services/webhooks/__init__.py new file mode 100644 index 0000000..d89b7d2 --- /dev/null +++ b/src/app/services/webhooks/__init__.py @@ -0,0 +1,5 @@ +""" +Webhooks Service Package + +Handles incoming webhook events and idempotency. +""" diff --git a/src/app/services/webhooks/models/__init__.py b/src/app/services/webhooks/models/__init__.py new file mode 100644 index 0000000..cfcf152 --- /dev/null +++ b/src/app/services/webhooks/models/__init__.py @@ -0,0 +1,12 @@ +""" +Webhooks Models Package +""" + +from .database import WebhookEventDB +from .schemas import WebhookEventCreate, WebhookEventResponse + +__all__ = [ + "WebhookEventDB", + "WebhookEventCreate", + "WebhookEventResponse", +] diff --git a/src/app/services/webhooks/models/database.py b/src/app/services/webhooks/models/database.py new file mode 100644 index 0000000..a4602a9 --- /dev/null +++ b/src/app/services/webhooks/models/database.py @@ -0,0 +1,93 @@ +""" +Webhooks Database Models + +SQLAlchemy ORM model for the webhook event inbox. + +Purpose: Idempotency. Before processing any incoming webhook event, the +handler checks this table. If the (provider, event_id) pair already exists, +the event is a duplicate and the handler returns 200 immediately without +side effects. Otherwise it inserts a row and processes the event inside the +same DB transaction — guaranteeing exactly-once processing. +""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import DateTime, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base + + +class WebhookEventDB(Base): + """ + Webhook event inbox model. + + Stores every received and accepted webhook event for idempotency and audit. + + Columns: + id: UUID primary key. + provider: Source of the webhook (e.g. 'stripe'). + event_id: Provider-side event ID (e.g. Stripe evt_xxx). + payload: Raw event payload stored as JSONB for auditability. + processed_at: Timestamp when the event was successfully processed. + NULL means the event was received but processing failed. + created_at: When the event was first received. + + Constraints: + UNIQUE(provider, event_id) — prevents duplicate processing. + """ + + __tablename__ = "webhook_events" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + provider: Mapped[str] = mapped_column( + String(50), + nullable=False, + index=True, + doc="Webhook source provider (e.g. 'stripe')", + ) + + event_id: Mapped[str] = mapped_column( + String(255), + nullable=False, + index=True, + doc="Provider-side event ID — unique per provider", + ) + + payload: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + doc="Raw event payload for auditability", + ) + + processed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + doc="When the event was successfully processed (NULL = not yet processed)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + doc="When the event record was created", + ) + + __table_args__ = ( + UniqueConstraint("provider", "event_id", name="uq_webhook_events_provider_event_id"), + ) + + def __repr__(self) -> str: + return ( + f"WebhookEventDB(id={self.id}, provider={self.provider!r}, " + f"event_id={self.event_id!r}, processed_at={self.processed_at})" + ) diff --git a/src/app/services/webhooks/models/schemas.py b/src/app/services/webhooks/models/schemas.py new file mode 100644 index 0000000..3a99f25 --- /dev/null +++ b/src/app/services/webhooks/models/schemas.py @@ -0,0 +1,33 @@ +""" +Webhooks Pydantic Schemas +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class WebhookEventCreate(BaseModel): + """ + Schema for inserting a new webhook event record. + + Used internally by the webhook handler — never exposed to the public API. + """ + + provider: str = Field(..., description="Webhook source provider (e.g. 'stripe')") + event_id: str = Field(..., description="Provider-side event ID") + payload: dict = Field(..., description="Raw event payload") + + +class WebhookEventResponse(BaseModel): + """Webhook event response schema — for audit/admin views.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal event UUID") + provider: str = Field(..., description="Webhook source provider") + event_id: str = Field(..., description="Provider-side event ID") + payload: dict = Field(..., description="Raw event payload") + processed_at: datetime | None = Field(default=None, description="Processing timestamp") + created_at: datetime = Field(..., description="Record creation timestamp") diff --git a/src/app/services/webhooks/services/__init__.py b/src/app/services/webhooks/services/__init__.py new file mode 100644 index 0000000..4f600a4 --- /dev/null +++ b/src/app/services/webhooks/services/__init__.py @@ -0,0 +1,7 @@ +""" +Webhooks Services Package +""" + +from .database import WebhookEventRepository, get_webhook_event_repository + +__all__ = ["WebhookEventRepository", "get_webhook_event_repository"] diff --git a/src/app/services/webhooks/services/database.py b/src/app/services/webhooks/services/database.py new file mode 100644 index 0000000..920cce4 --- /dev/null +++ b/src/app/services/webhooks/services/database.py @@ -0,0 +1,57 @@ +""" +Webhooks Database Services + +Repository for webhook event inbox. + +The key operation here is is_duplicate() — it must be called inside the +same DB transaction as the event processing to guarantee exactly-once semantics. +""" + +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.database import WebhookEventDB + + +class WebhookEventRepository(BaseRepository[WebhookEventDB]): + """ + Repository for webhook event inbox operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(WebhookEventDB, session) + + async def is_duplicate(self, provider: str, event_id: str) -> bool: + """ + Check whether an event has already been received. + + This is the idempotency guard. Must be called inside the same + transaction that processes the event, so a concurrent duplicate + hits the UNIQUE constraint rather than passing through. + + Args: + provider: Webhook source (e.g. 'stripe'). + event_id: Provider-side event ID (e.g. Stripe evt_xxx). + + Returns: + True if a record with this (provider, event_id) already exists. + """ + stmt = select(self.model.id).where( + and_( + self.model.provider == provider, + self.model.event_id == event_id, + ) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() is not None + + +# --------------------------------------------------------------------------- +# Dependency injection factory +# --------------------------------------------------------------------------- + + +def get_webhook_event_repository(session: AsyncSession) -> WebhookEventRepository: + """Factory for WebhookEventRepository — use with FastAPI Depends.""" + return WebhookEventRepository(session) From e404e1132ccdb25c4522ecf4153137344876b55e Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:19:17 +0100 Subject: [PATCH 08/17] feat(data-models): parse db setup create_all to initiator --- src/app/chore/lifespan.py | 1 + src/app/db_models.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/app/db_models.py diff --git a/src/app/chore/lifespan.py b/src/app/chore/lifespan.py index 13104af..7eb0398 100644 --- a/src/app/chore/lifespan.py +++ b/src/app/chore/lifespan.py @@ -9,6 +9,7 @@ from fastapi import FastAPI +import app.db_models # noqa: F401 — registers all ORM models with Base.metadata from app.shared.database.base import Base from app.shared.database.engine import close_database, get_engine, init_database diff --git a/src/app/db_models.py b/src/app/db_models.py new file mode 100644 index 0000000..269e85e --- /dev/null +++ b/src/app/db_models.py @@ -0,0 +1,38 @@ +""" +Database Model Registry + +Imports ALL SQLAlchemy ORM models so that Base.metadata knows about every +table. This module must be imported before calling Base.metadata.create_all(). + +Why this file exists: + SQLAlchemy's Base.metadata only knows about a table after its ORM class + has been imported. If a model is never imported, its table is never created. + Centralising all imports here means you only need to import this one module + to guarantee that create_all() sees the full schema. + +Usage: + import app.db_models # noqa: F401 — side-effect import + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) +""" + +# Item store (partner service) +from app.services.crud_item_store.models.database import ItemDB # noqa: F401 + +# Customers +from app.services.customers.models.database import CustomerDB, AddressDB # noqa: F401 + +# Inventory +from app.services.inventory.models.database import InventoryItemDB, StockReservationDB # noqa: F401 + +# Orders +from app.services.orders.models.database import OrderDB, OrderItemDB # noqa: F401 + +# Payments +from app.services.payments.models.database import PaymentDB # noqa: F401 + +# Webhooks +from app.services.webhooks.models.database import WebhookEventDB # noqa: F401 + +# Shipments +from app.services.shipments.models.database import ShipmentDB # noqa: F401 From 01308e004b41bbaad853313d6f86cf674cff092d Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:31:01 +0100 Subject: [PATCH 09/17] refactor(crud_item_store): rename files to avoid confusion --- src/app/services/crud_item_store/crud_item_store.py | 4 ++-- src/app/services/crud_item_store/functions/__init__.py | 4 ++-- .../functions/{transformations.py => item_transformations.py} | 0 .../functions/{validation.py => item_validation.py} | 0 src/app/services/crud_item_store/models/__init__.py | 4 ++-- .../crud_item_store/models/{database.py => item_db_models.py} | 0 .../crud_item_store/models/{item.py => item_models.py} | 0 src/app/services/crud_item_store/responses/__init__.py | 2 +- .../crud_item_store/responses/{docs.py => item_docs.py} | 2 +- .../responses/{items.py => items_response_models.py} | 0 src/app/services/crud_item_store/routers/__init__.py | 4 ++-- .../crud_item_store/routers/{items.py => items_router.py} | 2 +- src/app/services/crud_item_store/services/__init__.py | 2 +- .../services/{database.py => item_db_service.py} | 0 14 files changed, 12 insertions(+), 12 deletions(-) rename src/app/services/crud_item_store/functions/{transformations.py => item_transformations.py} (100%) rename src/app/services/crud_item_store/functions/{validation.py => item_validation.py} (100%) rename src/app/services/crud_item_store/models/{database.py => item_db_models.py} (100%) rename src/app/services/crud_item_store/models/{item.py => item_models.py} (100%) rename src/app/services/crud_item_store/responses/{docs.py => item_docs.py} (99%) rename src/app/services/crud_item_store/responses/{items.py => items_response_models.py} (100%) rename src/app/services/crud_item_store/routers/{items.py => items_router.py} (99%) rename src/app/services/crud_item_store/services/{database.py => item_db_service.py} (100%) diff --git a/src/app/services/crud_item_store/crud_item_store.py b/src/app/services/crud_item_store/crud_item_store.py index b2ea11f..0f75726 100644 --- a/src/app/services/crud_item_store/crud_item_store.py +++ b/src/app/services/crud_item_store/crud_item_store.py @@ -19,7 +19,7 @@ from fastapi import APIRouter # Import routers -from .routers import items +from .routers import items_router # Create the main router for this service router = APIRouter( @@ -28,6 +28,6 @@ ) # Include sub-routers -router.include_router(items.router) +router.include_router(items_router.router) __all__ = ["router"] diff --git a/src/app/services/crud_item_store/functions/__init__.py b/src/app/services/crud_item_store/functions/__init__.py index a69ea4a..a82b8c7 100644 --- a/src/app/services/crud_item_store/functions/__init__.py +++ b/src/app/services/crud_item_store/functions/__init__.py @@ -4,8 +4,8 @@ Business logic and transformation functions for items. """ -from .transformations import db_to_response, prepare_item_update_data -from .validation import check_duplicate_field, validate_update_conflicts +from .item_transformations import db_to_response, prepare_item_update_data +from .item_validation import check_duplicate_field, validate_update_conflicts __all__ = [ "db_to_response", diff --git a/src/app/services/crud_item_store/functions/transformations.py b/src/app/services/crud_item_store/functions/item_transformations.py similarity index 100% rename from src/app/services/crud_item_store/functions/transformations.py rename to src/app/services/crud_item_store/functions/item_transformations.py diff --git a/src/app/services/crud_item_store/functions/validation.py b/src/app/services/crud_item_store/functions/item_validation.py similarity index 100% rename from src/app/services/crud_item_store/functions/validation.py rename to src/app/services/crud_item_store/functions/item_validation.py diff --git a/src/app/services/crud_item_store/models/__init__.py b/src/app/services/crud_item_store/models/__init__.py index 5195e71..a5ee342 100644 --- a/src/app/services/crud_item_store/models/__init__.py +++ b/src/app/services/crud_item_store/models/__init__.py @@ -5,8 +5,8 @@ Response models are in the responses/ directory. """ -from .database import ItemDB -from .item import ( +from .item_db_models import ItemDB +from .item_models import ( DimensionUnit, DimensionsModel, IdentifiersModel, diff --git a/src/app/services/crud_item_store/models/database.py b/src/app/services/crud_item_store/models/item_db_models.py similarity index 100% rename from src/app/services/crud_item_store/models/database.py rename to src/app/services/crud_item_store/models/item_db_models.py diff --git a/src/app/services/crud_item_store/models/item.py b/src/app/services/crud_item_store/models/item_models.py similarity index 100% rename from src/app/services/crud_item_store/models/item.py rename to src/app/services/crud_item_store/models/item_models.py diff --git a/src/app/services/crud_item_store/responses/__init__.py b/src/app/services/crud_item_store/responses/__init__.py index 4b6c660..70199fc 100644 --- a/src/app/services/crud_item_store/responses/__init__.py +++ b/src/app/services/crud_item_store/responses/__init__.py @@ -5,7 +5,7 @@ Combines shared response structures with feature-specific models. """ -from .items import ItemResponse +from .items_response_models import ItemResponse __all__ = [ "ItemResponse", diff --git a/src/app/services/crud_item_store/responses/docs.py b/src/app/services/crud_item_store/responses/item_docs.py similarity index 99% rename from src/app/services/crud_item_store/responses/docs.py rename to src/app/services/crud_item_store/responses/item_docs.py index b1fffdc..6c5cd67 100644 --- a/src/app/services/crud_item_store/responses/docs.py +++ b/src/app/services/crud_item_store/responses/item_docs.py @@ -10,7 +10,7 @@ PaginatedResponse, ValidationErrorResponse, ) -from ..responses import ItemResponse +from . import ItemResponse # ============================================================================ diff --git a/src/app/services/crud_item_store/responses/items.py b/src/app/services/crud_item_store/responses/items_response_models.py similarity index 100% rename from src/app/services/crud_item_store/responses/items.py rename to src/app/services/crud_item_store/responses/items_response_models.py diff --git a/src/app/services/crud_item_store/routers/__init__.py b/src/app/services/crud_item_store/routers/__init__.py index c665580..c0447e6 100644 --- a/src/app/services/crud_item_store/routers/__init__.py +++ b/src/app/services/crud_item_store/routers/__init__.py @@ -4,6 +4,6 @@ Exports all routers for the item-store service. """ -from . import items +from . import items_router -__all__ = ["items"] +__all__ = ["items_router"] diff --git a/src/app/services/crud_item_store/routers/items.py b/src/app/services/crud_item_store/routers/items_router.py similarity index 99% rename from src/app/services/crud_item_store/routers/items.py rename to src/app/services/crud_item_store/routers/items_router.py index f3d8b61..3f33e2b 100644 --- a/src/app/services/crud_item_store/routers/items.py +++ b/src/app/services/crud_item_store/routers/items_router.py @@ -15,7 +15,7 @@ from app.shared.responses import PaginatedResponse, PageInfo from ..models import ItemCreate, ItemStatus, ItemUpdate from ..responses import ItemResponse -from ..responses.docs import ( +from ..responses.item_docs import ( CREATE_ITEM_RESPONSES, GET_ITEM_RESPONSES, LIST_ITEMS_RESPONSES, diff --git a/src/app/services/crud_item_store/services/__init__.py b/src/app/services/crud_item_store/services/__init__.py index 0e2cd68..a7fd35a 100644 --- a/src/app/services/crud_item_store/services/__init__.py +++ b/src/app/services/crud_item_store/services/__init__.py @@ -2,6 +2,6 @@ Init file for services """ -from .database import ItemRepository, get_item_repository +from .item_db_service import ItemRepository, get_item_repository __all__ = ["ItemRepository", "get_item_repository"] diff --git a/src/app/services/crud_item_store/services/database.py b/src/app/services/crud_item_store/services/item_db_service.py similarity index 100% rename from src/app/services/crud_item_store/services/database.py rename to src/app/services/crud_item_store/services/item_db_service.py From b19572ba0dd34084d9b699a9525bc0a4bbb67665 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:31:36 +0100 Subject: [PATCH 10/17] fix(data-models): run linter --- src/app/db_models.py | 1 - src/app/services/customers/models/schemas.py | 12 ++++++++--- .../services/customers/services/__init__.py | 7 ++++++- src/app/services/inventory/models/database.py | 8 ++++++-- src/app/services/inventory/models/schemas.py | 20 ++++++++++++++----- .../services/inventory/services/database.py | 4 +++- src/app/services/orders/models/database.py | 8 ++++++-- src/app/services/orders/models/schemas.py | 16 +++++++++++---- src/app/services/payments/models/database.py | 4 +--- src/app/services/payments/models/schemas.py | 4 +++- .../services/payments/services/database.py | 4 +++- src/app/services/shipments/models/database.py | 4 +--- src/app/services/shipments/models/schemas.py | 20 ++++++++++++++----- src/app/services/webhooks/models/database.py | 4 +++- src/app/services/webhooks/models/schemas.py | 4 +++- 15 files changed, 86 insertions(+), 34 deletions(-) diff --git a/src/app/db_models.py b/src/app/db_models.py index 269e85e..7b5d486 100644 --- a/src/app/db_models.py +++ b/src/app/db_models.py @@ -17,7 +17,6 @@ """ # Item store (partner service) -from app.services.crud_item_store.models.database import ItemDB # noqa: F401 # Customers from app.services.customers.models.database import CustomerDB, AddressDB # noqa: F401 diff --git a/src/app/services/customers/models/schemas.py b/src/app/services/customers/models/schemas.py index 8dc13e6..db2f295 100644 --- a/src/app/services/customers/models/schemas.py +++ b/src/app/services/customers/models/schemas.py @@ -60,16 +60,22 @@ class CustomerResponse(CustomerBase): class AddressBase(BaseModel): """Shared address fields.""" - street: str = Field(..., min_length=1, max_length=255, description="Street and house number") + street: str = Field( + ..., min_length=1, max_length=255, description="Street and house number" + ) city: str = Field(..., min_length=1, max_length=100, description="City name") - zip_code: str = Field(..., min_length=1, max_length=20, description="Postal / ZIP code") + zip_code: str = Field( + ..., min_length=1, max_length=20, description="Postal / ZIP code" + ) country: str = Field( ..., min_length=2, max_length=2, description="ISO 3166-1 alpha-2 country code (e.g. 'DE')", ) - is_default: bool = Field(default=False, description="Whether this is the default shipping address") + is_default: bool = Field( + default=False, description="Whether this is the default shipping address" + ) class AddressCreate(AddressBase): diff --git a/src/app/services/customers/services/__init__.py b/src/app/services/customers/services/__init__.py index e1f97d5..6bdefa6 100644 --- a/src/app/services/customers/services/__init__.py +++ b/src/app/services/customers/services/__init__.py @@ -2,7 +2,12 @@ Customers Services Package """ -from .database import AddressRepository, CustomerRepository, get_address_repository, get_customer_repository +from .database import ( + AddressRepository, + CustomerRepository, + get_address_repository, + get_customer_repository, +) __all__ = [ "CustomerRepository", diff --git a/src/app/services/inventory/models/database.py b/src/app/services/inventory/models/database.py index 88d1904..c64bb59 100644 --- a/src/app/services/inventory/models/database.py +++ b/src/app/services/inventory/models/database.py @@ -77,8 +77,12 @@ class InventoryItemDB(Base, TimestampMixin): __table_args__ = ( CheckConstraint("on_hand >= 0", name="ck_inventory_items_on_hand_non_negative"), - CheckConstraint("reserved >= 0", name="ck_inventory_items_reserved_non_negative"), - CheckConstraint("on_hand >= reserved", name="ck_inventory_items_on_hand_gte_reserved"), + CheckConstraint( + "reserved >= 0", name="ck_inventory_items_reserved_non_negative" + ), + CheckConstraint( + "on_hand >= reserved", name="ck_inventory_items_on_hand_gte_reserved" + ), ) def __repr__(self) -> str: diff --git a/src/app/services/inventory/models/schemas.py b/src/app/services/inventory/models/schemas.py index 7fe71a5..6a4c9f4 100644 --- a/src/app/services/inventory/models/schemas.py +++ b/src/app/services/inventory/models/schemas.py @@ -33,9 +33,13 @@ class ReservationStatus(str, Enum): class InventoryItemBase(BaseModel): """Shared inventory item fields.""" - sku: str = Field(..., min_length=1, max_length=100, description="SKU — matches items.sku") + sku: str = Field( + ..., min_length=1, max_length=100, description="SKU — matches items.sku" + ) on_hand: int = Field(..., ge=0, description="Physical stock count in the warehouse") - reserved: int = Field(default=0, ge=0, description="Units locked by active reservations") + reserved: int = Field( + default=0, ge=0, description="Units locked by active reservations" + ) class InventoryItemCreate(InventoryItemBase): @@ -69,10 +73,14 @@ class InventoryItemResponse(InventoryItemBase): class StockReservationBase(BaseModel): """Shared stock reservation fields.""" - inventory_item_id: UUID = Field(..., description="UUID of the reserved inventory item") + inventory_item_id: UUID = Field( + ..., description="UUID of the reserved inventory item" + ) order_id: UUID = Field(..., description="UUID of the associated order") quantity: int = Field(..., gt=0, description="Number of units reserved") - expires_at: datetime = Field(..., description="Expiry timestamp for this reservation") + expires_at: datetime = Field( + ..., description="Expiry timestamp for this reservation" + ) class StockReservationCreate(StockReservationBase): @@ -87,6 +95,8 @@ class StockReservationResponse(StockReservationBase): model_config = ConfigDict(from_attributes=True) id: UUID = Field(..., description="Internal reservation UUID") - status: ReservationStatus = Field(..., description="Current reservation lifecycle state") + status: ReservationStatus = Field( + ..., description="Current reservation lifecycle state" + ) created_at: datetime = Field(..., description="Record creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/inventory/services/database.py b/src/app/services/inventory/services/database.py index 1242254..e8a29b0 100644 --- a/src/app/services/inventory/services/database.py +++ b/src/app/services/inventory/services/database.py @@ -119,6 +119,8 @@ def get_inventory_repository(session: AsyncSession) -> InventoryRepository: return InventoryRepository(session) -def get_stock_reservation_repository(session: AsyncSession) -> StockReservationRepository: +def get_stock_reservation_repository( + session: AsyncSession, +) -> StockReservationRepository: """Factory for StockReservationRepository — use with FastAPI Depends.""" return StockReservationRepository(session) diff --git a/src/app/services/orders/models/database.py b/src/app/services/orders/models/database.py index 40e6aa3..38094ef 100644 --- a/src/app/services/orders/models/database.py +++ b/src/app/services/orders/models/database.py @@ -81,7 +81,9 @@ class OrderDB(Base, TimestampMixin, SoftDeleteMixin): ) __table_args__ = ( - CheckConstraint("total_amount >= 0", name="ck_orders_total_amount_non_negative"), + CheckConstraint( + "total_amount >= 0", name="ck_orders_total_amount_non_negative" + ), ) def __repr__(self) -> str: @@ -144,7 +146,9 @@ class OrderItemDB(Base, TimestampMixin): __table_args__ = ( CheckConstraint("quantity > 0", name="ck_order_items_quantity_positive"), - CheckConstraint("unit_price >= 0", name="ck_order_items_unit_price_non_negative"), + CheckConstraint( + "unit_price >= 0", name="ck_order_items_unit_price_non_negative" + ), ) def __repr__(self) -> str: diff --git a/src/app/services/orders/models/schemas.py b/src/app/services/orders/models/schemas.py index 7a56adb..b96ce03 100644 --- a/src/app/services/orders/models/schemas.py +++ b/src/app/services/orders/models/schemas.py @@ -45,9 +45,13 @@ class OrderStatus(str, Enum): class OrderItemBase(BaseModel): """Shared order item fields.""" - sku: str = Field(..., min_length=1, max_length=100, description="SKU of the ordered item") + sku: str = Field( + ..., min_length=1, max_length=100, description="SKU of the ordered item" + ) quantity: int = Field(..., gt=0, description="Number of units ordered") - unit_price: int = Field(..., ge=0, description="Price per unit at checkout time (in cents)") + unit_price: int = Field( + ..., ge=0, description="Price per unit at checkout time (in cents)" + ) class OrderItemCreate(BaseModel): @@ -58,7 +62,9 @@ class OrderItemCreate(BaseModel): item service at checkout time and stored as an immutable snapshot. """ - sku: str = Field(..., min_length=1, max_length=100, description="SKU of the item to order") + sku: str = Field( + ..., min_length=1, max_length=100, description="SKU of the item to order" + ) quantity: int = Field(..., gt=0, description="Number of units to order") @@ -127,7 +133,9 @@ class OrderResponse(OrderBase): total_amount: int = Field(..., description="Order total in cents") created_at: datetime = Field(..., description="Record creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") - deleted_at: datetime | None = Field(default=None, description="Soft delete timestamp") + deleted_at: datetime | None = Field( + default=None, description="Soft delete timestamp" + ) class OrderDetailResponse(OrderResponse): diff --git a/src/app/services/payments/models/database.py b/src/app/services/payments/models/database.py index b6131f6..f1d62a9 100644 --- a/src/app/services/payments/models/database.py +++ b/src/app/services/payments/models/database.py @@ -89,9 +89,7 @@ class PaymentDB(Base, TimestampMixin): doc="Payment status: pending | succeeded | failed | refunded", ) - __table_args__ = ( - UniqueConstraint("order_id", name="uq_payments_order_id"), - ) + __table_args__ = (UniqueConstraint("order_id", name="uq_payments_order_id"),) def __repr__(self) -> str: return ( diff --git a/src/app/services/payments/models/schemas.py b/src/app/services/payments/models/schemas.py index 2ce1652..2a2da0c 100644 --- a/src/app/services/payments/models/schemas.py +++ b/src/app/services/payments/models/schemas.py @@ -41,7 +41,9 @@ class PaymentBase(BaseModel): provider: PaymentProvider = Field(..., description="PSP provider") provider_reference: str = Field(..., description="PSP-side transaction ID") amount: int = Field(..., ge=0, description="Charged amount in cents") - currency: str = Field(..., min_length=3, max_length=3, description="ISO 4217 currency code") + currency: str = Field( + ..., min_length=3, max_length=3, description="ISO 4217 currency code" + ) class PaymentCreate(PaymentBase): diff --git a/src/app/services/payments/services/database.py b/src/app/services/payments/services/database.py index 5b5d240..16e38b6 100644 --- a/src/app/services/payments/services/database.py +++ b/src/app/services/payments/services/database.py @@ -32,7 +32,9 @@ async def get_by_order(self, order_id: UUID) -> PaymentDB | None: """ return await self.get_by(order_id=order_id) - async def get_by_provider_reference(self, provider_reference: str) -> PaymentDB | None: + async def get_by_provider_reference( + self, provider_reference: str + ) -> PaymentDB | None: """ Return a payment by its PSP-side reference ID. diff --git a/src/app/services/shipments/models/database.py b/src/app/services/shipments/models/database.py index c92ba35..abf562c 100644 --- a/src/app/services/shipments/models/database.py +++ b/src/app/services/shipments/models/database.py @@ -88,9 +88,7 @@ class ShipmentDB(Base, TimestampMixin): doc="Shipment status: pending | label_created | handed_over", ) - __table_args__ = ( - UniqueConstraint("order_id", name="uq_shipments_order_id"), - ) + __table_args__ = (UniqueConstraint("order_id", name="uq_shipments_order_id"),) def __repr__(self) -> str: return ( diff --git a/src/app/services/shipments/models/schemas.py b/src/app/services/shipments/models/schemas.py index 04a33c8..9b9d178 100644 --- a/src/app/services/shipments/models/schemas.py +++ b/src/app/services/shipments/models/schemas.py @@ -62,9 +62,15 @@ class ShipmentCreate(ShipmentBase): For automated carriers (DHL), it is populated later by the label job. """ - tracking_number: str | None = Field(default=None, description="Carrier tracking number") - label_url: str | None = Field(default=None, description="Label file URL/path in storage") - label_format: LabelFormat | None = Field(default=None, description="Label file format") + tracking_number: str | None = Field( + default=None, description="Carrier tracking number" + ) + label_url: str | None = Field( + default=None, description="Label file URL/path in storage" + ) + label_format: LabelFormat | None = Field( + default=None, description="Label file format" + ) class ShipmentUpdate(BaseModel): @@ -82,9 +88,13 @@ class ShipmentResponse(ShipmentBase): model_config = ConfigDict(from_attributes=True) id: UUID = Field(..., description="Internal shipment UUID") - tracking_number: str | None = Field(default=None, description="Carrier tracking number") + tracking_number: str | None = Field( + default=None, description="Carrier tracking number" + ) label_url: str | None = Field(default=None, description="Label file URL/path") - label_format: LabelFormat | None = Field(default=None, description="Label file format") + label_format: LabelFormat | None = Field( + default=None, description="Label file format" + ) status: ShipmentStatus = Field(..., description="Current shipment status") created_at: datetime = Field(..., description="Record creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/webhooks/models/database.py b/src/app/services/webhooks/models/database.py index a4602a9..c053a4e 100644 --- a/src/app/services/webhooks/models/database.py +++ b/src/app/services/webhooks/models/database.py @@ -83,7 +83,9 @@ class WebhookEventDB(Base): ) __table_args__ = ( - UniqueConstraint("provider", "event_id", name="uq_webhook_events_provider_event_id"), + UniqueConstraint( + "provider", "event_id", name="uq_webhook_events_provider_event_id" + ), ) def __repr__(self) -> str: diff --git a/src/app/services/webhooks/models/schemas.py b/src/app/services/webhooks/models/schemas.py index 3a99f25..9aa2d97 100644 --- a/src/app/services/webhooks/models/schemas.py +++ b/src/app/services/webhooks/models/schemas.py @@ -29,5 +29,7 @@ class WebhookEventResponse(BaseModel): provider: str = Field(..., description="Webhook source provider") event_id: str = Field(..., description="Provider-side event ID") payload: dict = Field(..., description="Raw event payload") - processed_at: datetime | None = Field(default=None, description="Processing timestamp") + processed_at: datetime | None = Field( + default=None, description="Processing timestamp" + ) created_at: datetime = Field(..., description="Record creation timestamp") From e7194b67717e37713f59f778affbc5d2993d4968 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:39:06 +0100 Subject: [PATCH 11/17] refactor(data-models): rename files to avoid confusion --- src/app/db_models.py | 6 ------ src/app/services/customers/models/__init__.py | 4 ++-- .../models/{database.py => customers_db_models.py} | 0 .../customers/models/{schemas.py => customers_models.py} | 0 src/app/services/customers/services/__init__.py | 2 +- .../services/{database.py => customers_db_service.py} | 2 +- src/app/services/inventory/models/__init__.py | 4 ++-- .../models/{database.py => inventory_db_models.py} | 0 .../inventory/models/{schemas.py => inventory_models.py} | 0 src/app/services/inventory/services/__init__.py | 2 +- .../services/{database.py => customers_db_service.py} | 2 +- src/app/services/orders/models/__init__.py | 4 ++-- .../orders/models/{database.py => orders_db_models.py} | 0 .../services/orders/models/{schemas.py => orders_models.py} | 0 src/app/services/orders/services/__init__.py | 2 +- .../orders/services/{database.py => orders_db_service.py} | 4 ++-- src/app/services/payments/models/__init__.py | 4 ++-- .../payments/models/{database.py => payments_db_models.py} | 0 .../payments/models/{schemas.py => payments_models.py} | 0 src/app/services/payments/services/__init__.py | 2 +- .../services/{database.py => payments_db_service.py} | 2 +- src/app/services/shipments/models/__init__.py | 4 ++-- .../models/{database.py => shipments_db_models.py} | 0 .../shipments/models/{schemas.py => shipments_models.py} | 0 src/app/services/shipments/services/__init__.py | 2 +- .../services/{database.py => shipments_db_service.py} | 2 +- src/app/services/webhooks/models/__init__.py | 4 ++-- .../webhooks/models/{database.py => webhooks_db_models.py} | 0 .../webhooks/models/{schemas.py => webhooks_models.py} | 0 src/app/services/webhooks/services/__init__.py | 2 +- .../services/{database.py => webhooks_db_service.py} | 2 +- 31 files changed, 25 insertions(+), 31 deletions(-) rename src/app/services/customers/models/{database.py => customers_db_models.py} (100%) rename src/app/services/customers/models/{schemas.py => customers_models.py} (100%) rename src/app/services/customers/services/{database.py => customers_db_service.py} (98%) rename src/app/services/inventory/models/{database.py => inventory_db_models.py} (100%) rename src/app/services/inventory/models/{schemas.py => inventory_models.py} (100%) rename src/app/services/inventory/services/{database.py => customers_db_service.py} (98%) rename src/app/services/orders/models/{database.py => orders_db_models.py} (100%) rename src/app/services/orders/models/{schemas.py => orders_models.py} (100%) rename src/app/services/orders/services/{database.py => orders_db_service.py} (97%) rename src/app/services/payments/models/{database.py => payments_db_models.py} (100%) rename src/app/services/payments/models/{schemas.py => payments_models.py} (100%) rename src/app/services/payments/services/{database.py => payments_db_service.py} (96%) rename src/app/services/shipments/models/{database.py => shipments_db_models.py} (100%) rename src/app/services/shipments/models/{schemas.py => shipments_models.py} (100%) rename src/app/services/shipments/services/{database.py => shipments_db_service.py} (95%) rename src/app/services/webhooks/models/{database.py => webhooks_db_models.py} (100%) rename src/app/services/webhooks/models/{schemas.py => webhooks_models.py} (100%) rename src/app/services/webhooks/services/{database.py => webhooks_db_service.py} (97%) diff --git a/src/app/db_models.py b/src/app/db_models.py index 7b5d486..1776bc2 100644 --- a/src/app/db_models.py +++ b/src/app/db_models.py @@ -19,19 +19,13 @@ # Item store (partner service) # Customers -from app.services.customers.models.database import CustomerDB, AddressDB # noqa: F401 # Inventory -from app.services.inventory.models.database import InventoryItemDB, StockReservationDB # noqa: F401 # Orders -from app.services.orders.models.database import OrderDB, OrderItemDB # noqa: F401 # Payments -from app.services.payments.models.database import PaymentDB # noqa: F401 # Webhooks -from app.services.webhooks.models.database import WebhookEventDB # noqa: F401 # Shipments -from app.services.shipments.models.database import ShipmentDB # noqa: F401 diff --git a/src/app/services/customers/models/__init__.py b/src/app/services/customers/models/__init__.py index e349b11..0ad0db7 100644 --- a/src/app/services/customers/models/__init__.py +++ b/src/app/services/customers/models/__init__.py @@ -4,8 +4,8 @@ Exports ORM models and Pydantic schemas for the customers service. """ -from .database import AddressDB, CustomerDB -from .schemas import ( +from .customers_db_models import AddressDB, CustomerDB +from .customers_models import ( AddressBase, AddressCreate, AddressResponse, diff --git a/src/app/services/customers/models/database.py b/src/app/services/customers/models/customers_db_models.py similarity index 100% rename from src/app/services/customers/models/database.py rename to src/app/services/customers/models/customers_db_models.py diff --git a/src/app/services/customers/models/schemas.py b/src/app/services/customers/models/customers_models.py similarity index 100% rename from src/app/services/customers/models/schemas.py rename to src/app/services/customers/models/customers_models.py diff --git a/src/app/services/customers/services/__init__.py b/src/app/services/customers/services/__init__.py index 6bdefa6..275d08e 100644 --- a/src/app/services/customers/services/__init__.py +++ b/src/app/services/customers/services/__init__.py @@ -2,7 +2,7 @@ Customers Services Package """ -from .database import ( +from .customers_db_service import ( AddressRepository, CustomerRepository, get_address_repository, diff --git a/src/app/services/customers/services/database.py b/src/app/services/customers/services/customers_db_service.py similarity index 98% rename from src/app/services/customers/services/database.py rename to src/app/services/customers/services/customers_db_service.py index f659eb4..df3d5ff 100644 --- a/src/app/services/customers/services/database.py +++ b/src/app/services/customers/services/customers_db_service.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository -from ..models.database import AddressDB, CustomerDB +from ..models.customers_db_models import AddressDB, CustomerDB class CustomerRepository(BaseRepository[CustomerDB]): diff --git a/src/app/services/inventory/models/__init__.py b/src/app/services/inventory/models/__init__.py index 0b1e914..c3cda26 100644 --- a/src/app/services/inventory/models/__init__.py +++ b/src/app/services/inventory/models/__init__.py @@ -4,8 +4,8 @@ Exports ORM models and Pydantic schemas for the inventory service. """ -from .database import InventoryItemDB, StockReservationDB -from .schemas import ( +from .inventory_db_models import InventoryItemDB, StockReservationDB +from .inventory_models import ( InventoryItemBase, InventoryItemCreate, InventoryItemResponse, diff --git a/src/app/services/inventory/models/database.py b/src/app/services/inventory/models/inventory_db_models.py similarity index 100% rename from src/app/services/inventory/models/database.py rename to src/app/services/inventory/models/inventory_db_models.py diff --git a/src/app/services/inventory/models/schemas.py b/src/app/services/inventory/models/inventory_models.py similarity index 100% rename from src/app/services/inventory/models/schemas.py rename to src/app/services/inventory/models/inventory_models.py diff --git a/src/app/services/inventory/services/__init__.py b/src/app/services/inventory/services/__init__.py index 5ad12dd..1446882 100644 --- a/src/app/services/inventory/services/__init__.py +++ b/src/app/services/inventory/services/__init__.py @@ -2,7 +2,7 @@ Inventory Services Package """ -from .database import ( +from .customers_db_service import ( InventoryRepository, StockReservationRepository, get_inventory_repository, diff --git a/src/app/services/inventory/services/database.py b/src/app/services/inventory/services/customers_db_service.py similarity index 98% rename from src/app/services/inventory/services/database.py rename to src/app/services/inventory/services/customers_db_service.py index e8a29b0..7c47a7c 100644 --- a/src/app/services/inventory/services/database.py +++ b/src/app/services/inventory/services/customers_db_service.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository -from ..models.database import InventoryItemDB, StockReservationDB +from ..models.inventory_db_models import InventoryItemDB, StockReservationDB class InventoryRepository(BaseRepository[InventoryItemDB]): diff --git a/src/app/services/orders/models/__init__.py b/src/app/services/orders/models/__init__.py index 4bb0d89..2afbb10 100644 --- a/src/app/services/orders/models/__init__.py +++ b/src/app/services/orders/models/__init__.py @@ -4,8 +4,8 @@ Exports ORM models and Pydantic schemas for the orders service. """ -from .database import OrderDB, OrderItemDB -from .schemas import ( +from .orders_db_models import OrderDB, OrderItemDB +from .orders_models import ( OrderBase, OrderCreate, OrderDetailResponse, diff --git a/src/app/services/orders/models/database.py b/src/app/services/orders/models/orders_db_models.py similarity index 100% rename from src/app/services/orders/models/database.py rename to src/app/services/orders/models/orders_db_models.py diff --git a/src/app/services/orders/models/schemas.py b/src/app/services/orders/models/orders_models.py similarity index 100% rename from src/app/services/orders/models/schemas.py rename to src/app/services/orders/models/orders_models.py diff --git a/src/app/services/orders/services/__init__.py b/src/app/services/orders/services/__init__.py index 650100b..27f203d 100644 --- a/src/app/services/orders/services/__init__.py +++ b/src/app/services/orders/services/__init__.py @@ -2,7 +2,7 @@ Orders Services Package """ -from .database import ( +from .orders_db_service import ( OrderItemRepository, OrderRepository, get_order_item_repository, diff --git a/src/app/services/orders/services/database.py b/src/app/services/orders/services/orders_db_service.py similarity index 97% rename from src/app/services/orders/services/database.py rename to src/app/services/orders/services/orders_db_service.py index cb127b3..39cc279 100644 --- a/src/app/services/orders/services/database.py +++ b/src/app/services/orders/services/orders_db_service.py @@ -13,8 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository -from ..models.database import OrderDB, OrderItemDB -from ..models.schemas import OrderStatus +from ..models.orders_db_models import OrderDB, OrderItemDB +from ..models.orders_models import OrderStatus class OrderRepository(BaseRepository[OrderDB]): diff --git a/src/app/services/payments/models/__init__.py b/src/app/services/payments/models/__init__.py index 1b2e5f7..d19d7a7 100644 --- a/src/app/services/payments/models/__init__.py +++ b/src/app/services/payments/models/__init__.py @@ -2,8 +2,8 @@ Payments Models Package """ -from .database import PaymentDB -from .schemas import ( +from .payments_db_models import PaymentDB +from .payments_models import ( PaymentBase, PaymentCreate, PaymentProvider, diff --git a/src/app/services/payments/models/database.py b/src/app/services/payments/models/payments_db_models.py similarity index 100% rename from src/app/services/payments/models/database.py rename to src/app/services/payments/models/payments_db_models.py diff --git a/src/app/services/payments/models/schemas.py b/src/app/services/payments/models/payments_models.py similarity index 100% rename from src/app/services/payments/models/schemas.py rename to src/app/services/payments/models/payments_models.py diff --git a/src/app/services/payments/services/__init__.py b/src/app/services/payments/services/__init__.py index e05b222..99c6efa 100644 --- a/src/app/services/payments/services/__init__.py +++ b/src/app/services/payments/services/__init__.py @@ -2,6 +2,6 @@ Payments Services Package """ -from .database import PaymentRepository, get_payment_repository +from .payments_db_service import PaymentRepository, get_payment_repository __all__ = ["PaymentRepository", "get_payment_repository"] diff --git a/src/app/services/payments/services/database.py b/src/app/services/payments/services/payments_db_service.py similarity index 96% rename from src/app/services/payments/services/database.py rename to src/app/services/payments/services/payments_db_service.py index 16e38b6..e472d80 100644 --- a/src/app/services/payments/services/database.py +++ b/src/app/services/payments/services/payments_db_service.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository -from ..models.database import PaymentDB +from ..models.payments_db_models import PaymentDB class PaymentRepository(BaseRepository[PaymentDB]): diff --git a/src/app/services/shipments/models/__init__.py b/src/app/services/shipments/models/__init__.py index 12a3793..94f1f92 100644 --- a/src/app/services/shipments/models/__init__.py +++ b/src/app/services/shipments/models/__init__.py @@ -2,8 +2,8 @@ Shipments Models Package """ -from .database import ShipmentDB -from .schemas import ( +from .shipments_db_models import ShipmentDB +from .shipments_models import ( Carrier, LabelFormat, ShipmentBase, diff --git a/src/app/services/shipments/models/database.py b/src/app/services/shipments/models/shipments_db_models.py similarity index 100% rename from src/app/services/shipments/models/database.py rename to src/app/services/shipments/models/shipments_db_models.py diff --git a/src/app/services/shipments/models/schemas.py b/src/app/services/shipments/models/shipments_models.py similarity index 100% rename from src/app/services/shipments/models/schemas.py rename to src/app/services/shipments/models/shipments_models.py diff --git a/src/app/services/shipments/services/__init__.py b/src/app/services/shipments/services/__init__.py index b249d8d..b3c7d05 100644 --- a/src/app/services/shipments/services/__init__.py +++ b/src/app/services/shipments/services/__init__.py @@ -2,6 +2,6 @@ Shipments Services Package """ -from .database import ShipmentRepository, get_shipment_repository +from .shipments_db_service import ShipmentRepository, get_shipment_repository __all__ = ["ShipmentRepository", "get_shipment_repository"] diff --git a/src/app/services/shipments/services/database.py b/src/app/services/shipments/services/shipments_db_service.py similarity index 95% rename from src/app/services/shipments/services/database.py rename to src/app/services/shipments/services/shipments_db_service.py index 72eb732..beabe7e 100644 --- a/src/app/services/shipments/services/database.py +++ b/src/app/services/shipments/services/shipments_db_service.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository -from ..models.database import ShipmentDB +from ..models.shipments_db_models import ShipmentDB class ShipmentRepository(BaseRepository[ShipmentDB]): diff --git a/src/app/services/webhooks/models/__init__.py b/src/app/services/webhooks/models/__init__.py index cfcf152..4e0c809 100644 --- a/src/app/services/webhooks/models/__init__.py +++ b/src/app/services/webhooks/models/__init__.py @@ -2,8 +2,8 @@ Webhooks Models Package """ -from .database import WebhookEventDB -from .schemas import WebhookEventCreate, WebhookEventResponse +from .webhooks_db_models import WebhookEventDB +from .webhooks_models import WebhookEventCreate, WebhookEventResponse __all__ = [ "WebhookEventDB", diff --git a/src/app/services/webhooks/models/database.py b/src/app/services/webhooks/models/webhooks_db_models.py similarity index 100% rename from src/app/services/webhooks/models/database.py rename to src/app/services/webhooks/models/webhooks_db_models.py diff --git a/src/app/services/webhooks/models/schemas.py b/src/app/services/webhooks/models/webhooks_models.py similarity index 100% rename from src/app/services/webhooks/models/schemas.py rename to src/app/services/webhooks/models/webhooks_models.py diff --git a/src/app/services/webhooks/services/__init__.py b/src/app/services/webhooks/services/__init__.py index 4f600a4..3a44a8a 100644 --- a/src/app/services/webhooks/services/__init__.py +++ b/src/app/services/webhooks/services/__init__.py @@ -2,6 +2,6 @@ Webhooks Services Package """ -from .database import WebhookEventRepository, get_webhook_event_repository +from .webhooks_db_service import WebhookEventRepository, get_webhook_event_repository __all__ = ["WebhookEventRepository", "get_webhook_event_repository"] diff --git a/src/app/services/webhooks/services/database.py b/src/app/services/webhooks/services/webhooks_db_service.py similarity index 97% rename from src/app/services/webhooks/services/database.py rename to src/app/services/webhooks/services/webhooks_db_service.py index 920cce4..71611ab 100644 --- a/src/app/services/webhooks/services/database.py +++ b/src/app/services/webhooks/services/webhooks_db_service.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository -from ..models.database import WebhookEventDB +from ..models.webhooks_db_models import WebhookEventDB class WebhookEventRepository(BaseRepository[WebhookEventDB]): From b9dfedb611aabbb629780a2f55fb05befa3e6407 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 4 Mar 2026 19:45:24 +0100 Subject: [PATCH 12/17] refactor(data-models): fix create db call --- src/app/db_models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/db_models.py b/src/app/db_models.py index 1776bc2..107fd81 100644 --- a/src/app/db_models.py +++ b/src/app/db_models.py @@ -17,15 +17,22 @@ """ # Item store (partner service) +from app.services.crud_item_store.models.item_db_models import ItemDB # noqa: F401 # Customers +from app.services.customers.models.customers_db_models import AddressDB, CustomerDB # noqa: F401 # Inventory +from app.services.inventory.models.inventory_db_models import InventoryItemDB, StockReservationDB # noqa: F401 # Orders +from app.services.orders.models.orders_db_models import OrderDB, OrderItemDB # noqa: F401 # Payments +from app.services.payments.models.payments_db_models import PaymentDB # noqa: F401 # Webhooks +from app.services.webhooks.models.webhooks_db_models import WebhookEventDB # noqa: F401 # Shipments +from app.services.shipments.models.shipments_db_models import ShipmentDB # noqa: F401 From 667f35f0144dd97ddc8dcafcbfdf76e4f0d36cdb Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Thu, 5 Mar 2026 09:22:31 +0100 Subject: [PATCH 13/17] refactor(data-models): remove any redundant db functions - implement logger --- .../functions/item_validation.py | 7 +++ .../crud_item_store/routers/items_router.py | 8 +++ .../services/item_db_service.py | 11 ++++ .../services/customers_db_service.py | 60 ------------------- .../services/inventory/services/__init__.py | 2 +- ..._db_service.py => inventory_db_service.py} | 18 ++---- .../orders/services/orders_db_service.py | 19 ++---- .../payments/services/payments_db_service.py | 30 ---------- .../services/shipments_db_service.py | 14 ----- .../webhooks/services/webhooks_db_service.py | 28 --------- 10 files changed, 38 insertions(+), 159 deletions(-) rename src/app/services/inventory/services/{customers_db_service.py => inventory_db_service.py} (92%) diff --git a/src/app/services/crud_item_store/functions/item_validation.py b/src/app/services/crud_item_store/functions/item_validation.py index 21ae37c..6f46012 100644 --- a/src/app/services/crud_item_store/functions/item_validation.py +++ b/src/app/services/crud_item_store/functions/item_validation.py @@ -8,9 +8,12 @@ from uuid import UUID from app.shared.exceptions import duplicate_entry +from app.shared.logger import get_logger from ..models import ItemDB from ..services import ItemRepository +logger = get_logger(__name__) + async def check_duplicate_field( repo: ItemRepository, @@ -44,6 +47,10 @@ async def check_duplicate_field( >>> # Can check any field on the model >>> await check_duplicate_field(repo, "name", "Test Product") """ + logger.debug( + "Checking for duplicate field value", + extra={"field_name": field_name, "exclude_uuid": str(exclude_uuid) if exclude_uuid else None}, + ) # Use the repository's generic field_exists method # This will raise ValueError if field doesn't exist on the model exists = await repo.field_exists(field_name, field_value, exclude_uuid=exclude_uuid) diff --git a/src/app/services/crud_item_store/routers/items_router.py b/src/app/services/crud_item_store/routers/items_router.py index 3f33e2b..3981620 100644 --- a/src/app/services/crud_item_store/routers/items_router.py +++ b/src/app/services/crud_item_store/routers/items_router.py @@ -30,7 +30,9 @@ validate_update_conflicts, prepare_item_update_data, ) +from app.shared.logger import get_logger +logger = get_logger(__name__) router = APIRouter() @@ -87,6 +89,7 @@ async def create_item( custom=item.custom, system=item.system.model_dump(), ) + logger.info("Item created", extra={"uuid": str(created.uuid), "sku": created.sku}) return db_to_response(created) @@ -117,6 +120,7 @@ async def get_item( DatabaseError (500): If database operation fails """ repo = get_item_repository(session) + logger.debug("Fetching item", extra={"uuid": str(item_uuid)}) item = await repo.get(item_uuid) if not item: @@ -159,6 +163,7 @@ async def list_items( DatabaseError (500): If database operation fails """ repo = get_item_repository(session) + logger.debug("Listing items", extra={"skip": skip, "limit": limit, "status": status_filter.value if status_filter else None}) # Apply filters filters = {} @@ -211,6 +216,7 @@ async def get_item_by_sku( DatabaseError (500): If database operation fails """ repo = get_item_repository(session) + logger.debug("Fetching item by SKU", extra={"sku": sku}) item = await repo.get_by(sku=sku) if not item: @@ -265,6 +271,7 @@ async def update_item( # Update item updated = await repo.update(item_uuid, **update_data) + logger.info("Item updated", extra={"uuid": str(item_uuid)}) return db_to_response(updated) @@ -298,3 +305,4 @@ async def delete_item( raise entity_not_found("Item", item_uuid) await repo.delete(item_uuid) + logger.info("Item deleted", extra={"uuid": str(item_uuid)}) diff --git a/src/app/services/crud_item_store/services/item_db_service.py b/src/app/services/crud_item_store/services/item_db_service.py index 7b981a1..7c99395 100644 --- a/src/app/services/crud_item_store/services/item_db_service.py +++ b/src/app/services/crud_item_store/services/item_db_service.py @@ -12,8 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository +from app.shared.logger import get_logger from ..models import ItemDB +logger = get_logger(__name__) + class ItemRepository(BaseRepository[ItemDB]): """ @@ -72,6 +75,10 @@ async def search( >>> # Search by brand and status >>> items = await repo.search(brand="TestBrand", status="active") """ + logger.debug( + "Searching items", + extra={"name": name, "status": status, "brand": brand, "skip": skip, "limit": limit}, + ) stmt = select(self.model) conditions = [] @@ -124,6 +131,10 @@ async def field_exists( ... "slug", "red-chair", exclude_uuid=item_uuid ... ) """ + logger.debug( + "Checking field existence", + extra={"field_name": field_name, "exclude_uuid": str(exclude_uuid) if exclude_uuid else None}, + ) # Validate field exists on model if not hasattr(self.model, field_name): raise ValueError(f"Field '{field_name}' does not exist on ItemDB model") diff --git a/src/app/services/customers/services/customers_db_service.py b/src/app/services/customers/services/customers_db_service.py index df3d5ff..dd88e34 100644 --- a/src/app/services/customers/services/customers_db_service.py +++ b/src/app/services/customers/services/customers_db_service.py @@ -6,9 +6,6 @@ belongs in functions/, not here. """ -from uuid import UUID - -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository @@ -26,32 +23,6 @@ class CustomerRepository(BaseRepository[CustomerDB]): def __init__(self, session: AsyncSession) -> None: super().__init__(CustomerDB, session) - async def get_by_keycloak_id(self, keycloak_user_id: str) -> CustomerDB | None: - """ - Look up a customer by their Keycloak subject claim. - - Used on every authenticated request to resolve the customer record. - - Args: - keycloak_user_id: The 'sub' claim from the Keycloak JWT. - - Returns: - CustomerDB instance or None if not yet registered. - """ - return await self.get_by(keycloak_user_id=keycloak_user_id) - - async def get_by_email(self, email: str) -> CustomerDB | None: - """ - Look up a customer by email address. - - Args: - email: Customer email address. - - Returns: - CustomerDB instance or None. - """ - return await self.get_by(email=email) - class AddressRepository(BaseRepository[AddressDB]): """ @@ -61,37 +32,6 @@ class AddressRepository(BaseRepository[AddressDB]): def __init__(self, session: AsyncSession) -> None: super().__init__(AddressDB, session) - async def get_all_for_customer(self, customer_id: UUID) -> list[AddressDB]: - """ - Return all addresses belonging to a customer. - - Args: - customer_id: UUID of the customer. - - Returns: - List of AddressDB instances (may be empty). - """ - stmt = select(self.model).where(self.model.customer_id == customer_id) - result = await self.session.execute(stmt) - return list(result.scalars().all()) - - async def get_default_for_customer(self, customer_id: UUID) -> AddressDB | None: - """ - Return the default address for a customer, if one exists. - - Args: - customer_id: UUID of the customer. - - Returns: - Default AddressDB instance or None. - """ - stmt = select(self.model).where( - self.model.customer_id == customer_id, - self.model.is_default.is_(True), - ) - result = await self.session.execute(stmt) - return result.scalar_one_or_none() - # --------------------------------------------------------------------------- # Dependency injection factories diff --git a/src/app/services/inventory/services/__init__.py b/src/app/services/inventory/services/__init__.py index 1446882..3a3da09 100644 --- a/src/app/services/inventory/services/__init__.py +++ b/src/app/services/inventory/services/__init__.py @@ -2,7 +2,7 @@ Inventory Services Package """ -from .customers_db_service import ( +from .inventory_db_service import ( InventoryRepository, StockReservationRepository, get_inventory_repository, diff --git a/src/app/services/inventory/services/customers_db_service.py b/src/app/services/inventory/services/inventory_db_service.py similarity index 92% rename from src/app/services/inventory/services/customers_db_service.py rename to src/app/services/inventory/services/inventory_db_service.py index 7c47a7c..eeea1f6 100644 --- a/src/app/services/inventory/services/customers_db_service.py +++ b/src/app/services/inventory/services/inventory_db_service.py @@ -14,8 +14,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository +from app.shared.logger import get_logger from ..models.inventory_db_models import InventoryItemDB, StockReservationDB +logger = get_logger(__name__) + class InventoryRepository(BaseRepository[InventoryItemDB]): """ @@ -25,18 +28,6 @@ class InventoryRepository(BaseRepository[InventoryItemDB]): def __init__(self, session: AsyncSession) -> None: super().__init__(InventoryItemDB, session) - async def get_by_sku(self, sku: str) -> InventoryItemDB | None: - """ - Look up an inventory item by SKU. - - Args: - sku: The SKU string to look up. - - Returns: - InventoryItemDB instance or None if not tracked. - """ - return await self.get_by(sku=sku) - async def get_available_quantity(self, sku: str) -> int: """ Return the number of units available for reservation (on_hand - reserved). @@ -50,6 +41,7 @@ async def get_available_quantity(self, sku: str) -> int: Returns: Available quantity. Returns 0 if the SKU is not tracked. """ + logger.debug("Getting available quantity", extra={"sku": sku}) stmt = select( (self.model.on_hand - self.model.reserved).label("available") ).where(self.model.sku == sku) @@ -78,6 +70,7 @@ async def get_active_for_order(self, order_id: UUID) -> list[StockReservationDB] Returns: List of active StockReservationDB instances. """ + logger.debug("Getting active reservations for order", extra={"order_id": str(order_id)}) stmt = select(self.model).where( and_( self.model.order_id == order_id, @@ -99,6 +92,7 @@ async def get_expired_active(self, now: datetime) -> list[StockReservationDB]: Returns: List of expired-but-still-active StockReservationDB instances. """ + logger.debug("Getting expired active reservations") stmt = select(self.model).where( and_( self.model.status == "active", diff --git a/src/app/services/orders/services/orders_db_service.py b/src/app/services/orders/services/orders_db_service.py index 39cc279..d59e1fa 100644 --- a/src/app/services/orders/services/orders_db_service.py +++ b/src/app/services/orders/services/orders_db_service.py @@ -13,9 +13,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository +from app.shared.logger import get_logger from ..models.orders_db_models import OrderDB, OrderItemDB from ..models.orders_models import OrderStatus +logger = get_logger(__name__) + class OrderRepository(BaseRepository[OrderDB]): """ @@ -48,6 +51,7 @@ async def get_by_customer( Returns: List of OrderDB instances (soft-deleted orders excluded). """ + logger.debug("Getting orders for customer", extra={"customer_id": str(customer_id), "skip": skip, "limit": limit}) stmt = ( select(self.model) .where( @@ -80,6 +84,7 @@ async def get_by_status( Returns: List of OrderDB instances (soft-deleted orders excluded). """ + logger.debug("Getting orders by status", extra={"status": status.value, "skip": skip, "limit": limit}) stmt = ( select(self.model) .where( @@ -102,20 +107,6 @@ class OrderItemRepository(BaseRepository[OrderItemDB]): def __init__(self, session: AsyncSession) -> None: super().__init__(OrderItemDB, session) - async def get_all_for_order(self, order_id: UUID) -> list[OrderItemDB]: - """ - Return all line items for a given order. - - Args: - order_id: UUID of the parent order. - - Returns: - List of OrderItemDB instances. - """ - stmt = select(self.model).where(self.model.order_id == order_id) - result = await self.session.execute(stmt) - return list(result.scalars().all()) - # --------------------------------------------------------------------------- # Dependency injection factories diff --git a/src/app/services/payments/services/payments_db_service.py b/src/app/services/payments/services/payments_db_service.py index e472d80..2cc3577 100644 --- a/src/app/services/payments/services/payments_db_service.py +++ b/src/app/services/payments/services/payments_db_service.py @@ -4,8 +4,6 @@ Repository for payment data access. """ -from uuid import UUID - from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository @@ -20,34 +18,6 @@ class PaymentRepository(BaseRepository[PaymentDB]): def __init__(self, session: AsyncSession) -> None: super().__init__(PaymentDB, session) - async def get_by_order(self, order_id: UUID) -> PaymentDB | None: - """ - Return the payment record for a given order. - - Args: - order_id: UUID of the associated order. - - Returns: - PaymentDB instance or None. - """ - return await self.get_by(order_id=order_id) - - async def get_by_provider_reference( - self, provider_reference: str - ) -> PaymentDB | None: - """ - Return a payment by its PSP-side reference ID. - - Used by the webhook handler to look up which payment is being updated. - - Args: - provider_reference: PSP transaction ID (e.g. Stripe pi_xxx). - - Returns: - PaymentDB instance or None. - """ - return await self.get_by(provider_reference=provider_reference) - # --------------------------------------------------------------------------- # Dependency injection factory diff --git a/src/app/services/shipments/services/shipments_db_service.py b/src/app/services/shipments/services/shipments_db_service.py index beabe7e..6148ab8 100644 --- a/src/app/services/shipments/services/shipments_db_service.py +++ b/src/app/services/shipments/services/shipments_db_service.py @@ -4,8 +4,6 @@ Repository for shipment data access. """ -from uuid import UUID - from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository @@ -20,18 +18,6 @@ class ShipmentRepository(BaseRepository[ShipmentDB]): def __init__(self, session: AsyncSession) -> None: super().__init__(ShipmentDB, session) - async def get_by_order(self, order_id: UUID) -> ShipmentDB | None: - """ - Return the shipment record for a given order. - - Args: - order_id: UUID of the associated order. - - Returns: - ShipmentDB instance or None if no shipment has been created yet. - """ - return await self.get_by(order_id=order_id) - # --------------------------------------------------------------------------- # Dependency injection factory diff --git a/src/app/services/webhooks/services/webhooks_db_service.py b/src/app/services/webhooks/services/webhooks_db_service.py index 71611ab..7072a54 100644 --- a/src/app/services/webhooks/services/webhooks_db_service.py +++ b/src/app/services/webhooks/services/webhooks_db_service.py @@ -2,12 +2,8 @@ Webhooks Database Services Repository for webhook event inbox. - -The key operation here is is_duplicate() — it must be called inside the -same DB transaction as the event processing to guarantee exactly-once semantics. """ -from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository @@ -22,30 +18,6 @@ class WebhookEventRepository(BaseRepository[WebhookEventDB]): def __init__(self, session: AsyncSession) -> None: super().__init__(WebhookEventDB, session) - async def is_duplicate(self, provider: str, event_id: str) -> bool: - """ - Check whether an event has already been received. - - This is the idempotency guard. Must be called inside the same - transaction that processes the event, so a concurrent duplicate - hits the UNIQUE constraint rather than passing through. - - Args: - provider: Webhook source (e.g. 'stripe'). - event_id: Provider-side event ID (e.g. Stripe evt_xxx). - - Returns: - True if a record with this (provider, event_id) already exists. - """ - stmt = select(self.model.id).where( - and_( - self.model.provider == provider, - self.model.event_id == event_id, - ) - ) - result = await self.session.execute(stmt) - return result.scalar_one_or_none() is not None - # --------------------------------------------------------------------------- # Dependency injection factory From 86889c005ae162c1b3d54ade5c31c3148551c430 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Thu, 5 Mar 2026 09:30:31 +0100 Subject: [PATCH 14/17] fix(crud_item_store): fix validation_error responses --- .../crud_item_store/responses/item_docs.py | 96 ++++++++----------- 1 file changed, 42 insertions(+), 54 deletions(-) diff --git a/src/app/services/crud_item_store/responses/item_docs.py b/src/app/services/crud_item_store/responses/item_docs.py index 6c5cd67..355a60a 100644 --- a/src/app/services/crud_item_store/responses/item_docs.py +++ b/src/app/services/crud_item_store/responses/item_docs.py @@ -23,15 +23,13 @@ "status_code": 422, "error_code": "invalid_input", "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["path", "item_uuid"], - "msg": "Input should be a valid UUID", - "type": "uuid_parsing", - } - ] - }, + "validation_errors": [ + { + "loc": ["path", "item_uuid"], + "msg": "Input should be a valid UUID", + "type": "uuid_parsing", + } + ], } ITEM_NOT_FOUND_EXAMPLE = { @@ -114,15 +112,13 @@ "status_code": 422, "error_code": "invalid_input", "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["body", "price", "amount"], - "msg": "Input should be greater than or equal to 0", - "type": "greater_than_equal", - } - ] - }, + "validation_errors": [ + { + "loc": ["body", "price", "amount"], + "msg": "Input should be greater than or equal to 0", + "type": "greater_than_equal", + } + ], }, }, } @@ -213,15 +209,13 @@ "status_code": 422, "error_code": "invalid_input", "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["query", "skip"], - "msg": "Input should be greater than or equal to 0", - "type": "greater_than_equal", - } - ] - }, + "validation_errors": [ + { + "loc": ["query", "skip"], + "msg": "Input should be greater than or equal to 0", + "type": "greater_than_equal", + } + ], }, }, "invalid_limit": { @@ -232,15 +226,13 @@ "status_code": 422, "error_code": "invalid_input", "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["query", "limit"], - "msg": "Input should be less than or equal to 100", - "type": "less_than_equal", - } - ] - }, + "validation_errors": [ + { + "loc": ["query", "limit"], + "msg": "Input should be less than or equal to 100", + "type": "less_than_equal", + } + ], }, }, "invalid_status": { @@ -251,15 +243,13 @@ "status_code": 422, "error_code": "invalid_input", "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["query", "status"], - "msg": "Input should be 'draft', 'active' or 'archived'", - "type": "enum", - } - ] - }, + "validation_errors": [ + { + "loc": ["query", "status"], + "msg": "Input should be 'draft', 'active' or 'archived'", + "type": "enum", + } + ], }, }, } @@ -395,15 +385,13 @@ "status_code": 422, "error_code": "invalid_input", "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["body", "status"], - "msg": "Input should be 'draft', 'active' or 'archived'", - "type": "enum", - } - ] - }, + "validation_errors": [ + { + "loc": ["body", "status"], + "msg": "Input should be 'draft', 'active' or 'archived'", + "type": "enum", + } + ], }, }, } From 9c0df8990f0fc8138d895fd37494669e6daa3850 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Thu, 5 Mar 2026 09:41:54 +0100 Subject: [PATCH 15/17] refactor(crud_item_store): implement dynamic responses --- .../crud_item_store/responses/item_docs.py | 516 ++++++------------ .../responses/items_response_models.py | 53 +- 2 files changed, 207 insertions(+), 362 deletions(-) diff --git a/src/app/services/crud_item_store/responses/item_docs.py b/src/app/services/crud_item_store/responses/item_docs.py index 355a60a..33f4a62 100644 --- a/src/app/services/crud_item_store/responses/item_docs.py +++ b/src/app/services/crud_item_store/responses/item_docs.py @@ -1,8 +1,8 @@ """ OpenAPI Documentation for Item Store Endpoints -Contains response schemas, examples, and documentation for all item endpoints. -Separates API documentation from business logic to keep routers clean. +All error examples are built programmatically from the actual response model +instances so they can never drift from the real API output. """ from app.shared.responses import ( @@ -11,185 +11,169 @@ ValidationErrorResponse, ) from . import ItemResponse +from .items_response_models import _ITEM_EXAMPLE -# ============================================================================ -# Common Error Examples -# ============================================================================ - -INVALID_UUID_EXAMPLE = { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "validation_errors": [ - { - "loc": ["path", "item_uuid"], - "msg": "Input should be a valid UUID", - "type": "uuid_parsing", - } - ], -} +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- -ITEM_NOT_FOUND_EXAMPLE = { - "success": False, - "message": "Item with ID '123e4567-e89b-12d3-a456-426614174000' not found", - "status_code": 404, - "error_code": "entity_not_found", - "error_category": "not_found", - "details": { - "entity_type": "Item", - "entity_id": "123e4567-e89b-12d3-a456-426614174000", - }, -} +def _err(status: int, code: str, category: str, message: str, details: dict | None = None) -> dict: + """Build an error example dict from an actual ErrorResponse instance.""" + return ErrorResponse( + status_code=status, + error_code=code, + error_category=category, + message=message, + details=details, + ).model_dump(mode="json", exclude_none=True) -DUPLICATE_SKU_EXAMPLE = { - "success": False, - "message": "Item with sku='CHAIR-001' already exists", - "status_code": 422, - "error_code": "duplicate_entry", - "error_category": "validation", - "details": {"entity_type": "Item", "field": "sku", "value": "CHAIR-001"}, -} -DUPLICATE_SLUG_EXAMPLE = { - "success": False, - "message": "Item with slug='red-chair' already exists", - "status_code": 422, - "error_code": "duplicate_entry", - "error_category": "validation", - "details": {"entity_type": "Item", "field": "slug", "value": "red-chair"}, +def _validation_err(message: str, errors: list[dict]) -> dict: + """Build a validation error example dict from an actual ValidationErrorResponse instance.""" + return ValidationErrorResponse( + message=message, + validation_errors=errors, + ).model_dump(mode="json", exclude_none=True) + + +# --------------------------------------------------------------------------- +# Shared error examples +# --------------------------------------------------------------------------- + +INVALID_UUID_EXAMPLE = _validation_err( + message="Validation failed", + errors=[{"loc": ["path", "item_uuid"], "msg": "Input should be a valid UUID", "type": "uuid_parsing"}], +) + +ITEM_NOT_FOUND_EXAMPLE = _err( + status=404, + code="entity_not_found", + category="not_found", + message="Item with ID '123e4567-e89b-12d3-a456-426614174000' not found", + details={"entity_type": "Item", "entity_id": "123e4567-e89b-12d3-a456-426614174000"}, +) + +DUPLICATE_SKU_EXAMPLE = _err( + status=422, + code="duplicate_entry", + category="validation", + message="Item with sku='CHAIR-001' already exists", + details={"entity_type": "Item", "field": "sku", "value": "CHAIR-001"}, +) + +DUPLICATE_SLUG_EXAMPLE = _err( + status=422, + code="duplicate_entry", + category="validation", + message="Item with slug='red-chair' already exists", + details={"entity_type": "Item", "field": "slug", "value": "red-chair"}, +) + +DATABASE_ERROR_EXAMPLE = _err( + status=500, + code="database_query_error", + category="database", + message="Database operation failed", + details={"error_type": "DatabaseError"}, +) + +INTERNAL_ERROR_EXAMPLE = _err( + status=500, + code="internal_error", + category="internal", + message="An unexpected error occurred", + details={"error_type": "ValueError"}, +) + +_INVALID_BODY_EXAMPLE = _validation_err( + message="Validation failed", + errors=[{"loc": ["body", "price", "amount"], "msg": "Input should be greater than or equal to 0", "type": "greater_than_equal"}], +) + +_INVALID_PAGINATION_SKIP = _validation_err( + message="Validation failed", + errors=[{"loc": ["query", "skip"], "msg": "Input should be greater than or equal to 0", "type": "greater_than_equal"}], +) + +_INVALID_PAGINATION_LIMIT = _validation_err( + message="Validation failed", + errors=[{"loc": ["query", "limit"], "msg": "Input should be less than or equal to 100", "type": "less_than_equal"}], +) + +_INVALID_STATUS_PARAM = _validation_err( + message="Validation failed", + errors=[{"loc": ["query", "status"], "msg": "Input should be 'draft', 'active' or 'archived'", "type": "enum"}], +) + + +# --------------------------------------------------------------------------- +# Shared response blocks (reused across endpoints) +# --------------------------------------------------------------------------- + +_500_BLOCK = { + "description": "Database or internal server error", + "model": ErrorResponse, + "content": { + "application/json": { + "examples": { + "database_error": {"summary": "Database error", "value": DATABASE_ERROR_EXAMPLE}, + "internal_error": {"summary": "Unexpected error", "value": INTERNAL_ERROR_EXAMPLE}, + } + } + }, } -DATABASE_ERROR_EXAMPLE = { - "success": False, - "message": "Database operation failed", - "status_code": 500, - "error_code": "database_query_error", - "error_category": "database", - "details": {"error_type": "DatabaseError"}, +_404_BLOCK = { + "description": "Item not found", + "model": ErrorResponse, + "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, } -INTERNAL_ERROR_EXAMPLE = { - "success": False, - "message": "An unexpected error occurred", - "status_code": 500, - "error_code": "internal_error", - "error_category": "internal", - "details": {"error_type": "ValueError"}, +_422_UUID_BLOCK = { + "description": "Invalid UUID format", + "model": ValidationErrorResponse, + "content": {"application/json": {"example": INVALID_UUID_EXAMPLE}}, } -# ============================================================================ -# Create Item Documentation -# ============================================================================ +# --------------------------------------------------------------------------- +# Per-endpoint response dicts +# --------------------------------------------------------------------------- CREATE_ITEM_RESPONSES = { 201: { "description": "Item created successfully", "model": ItemResponse, + "content": {"application/json": {"example": _ITEM_EXAMPLE}}, }, 422: { - "description": "Validation error - duplicate SKU/slug or invalid input data", + "description": "Duplicate SKU/slug or invalid input data", "model": ValidationErrorResponse, "content": { "application/json": { "examples": { - "duplicate_sku": { - "summary": "Duplicate SKU", - "value": DUPLICATE_SKU_EXAMPLE, - }, - "duplicate_slug": { - "summary": "Duplicate slug", - "value": DUPLICATE_SLUG_EXAMPLE, - }, - "invalid_input": { - "summary": "Invalid input data", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "validation_errors": [ - { - "loc": ["body", "price", "amount"], - "msg": "Input should be greater than or equal to 0", - "type": "greater_than_equal", - } - ], - }, - }, - } - } - }, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, + "duplicate_sku": {"summary": "Duplicate SKU", "value": DUPLICATE_SKU_EXAMPLE}, + "duplicate_slug": {"summary": "Duplicate slug", "value": DUPLICATE_SLUG_EXAMPLE}, + "invalid_input": {"summary": "Invalid input data", "value": _INVALID_BODY_EXAMPLE}, } } }, }, + 500: _500_BLOCK, } - -# ============================================================================ -# Get Item by UUID Documentation -# ============================================================================ - GET_ITEM_RESPONSES = { 200: { "description": "Item retrieved successfully", "model": ItemResponse, + "content": {"application/json": {"example": _ITEM_EXAMPLE}}, }, - 404: { - "description": "Item not found", - "model": ErrorResponse, - "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, - }, - 422: { - "description": "Invalid UUID format", - "model": ValidationErrorResponse, - "content": {"application/json": {"example": INVALID_UUID_EXAMPLE}}, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } - } - }, - }, + 404: _404_BLOCK, + 422: _422_UUID_BLOCK, + 500: _500_BLOCK, } - -# ============================================================================ -# List Items Documentation -# ============================================================================ - LIST_ITEMS_RESPONSES = { 200: { "description": "Items retrieved successfully", @@ -201,258 +185,68 @@ "content": { "application/json": { "examples": { - "invalid_skip": { - "summary": "Negative skip value", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "validation_errors": [ - { - "loc": ["query", "skip"], - "msg": "Input should be greater than or equal to 0", - "type": "greater_than_equal", - } - ], - }, - }, - "invalid_limit": { - "summary": "Limit out of range", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "validation_errors": [ - { - "loc": ["query", "limit"], - "msg": "Input should be less than or equal to 100", - "type": "less_than_equal", - } - ], - }, - }, - "invalid_status": { - "summary": "Invalid status value", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "validation_errors": [ - { - "loc": ["query", "status"], - "msg": "Input should be 'draft', 'active' or 'archived'", - "type": "enum", - } - ], - }, - }, - } - } - }, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, + "invalid_skip": {"summary": "Negative skip value", "value": _INVALID_PAGINATION_SKIP}, + "invalid_limit": {"summary": "Limit out of range", "value": _INVALID_PAGINATION_LIMIT}, + "invalid_status": {"summary": "Invalid status value", "value": _INVALID_STATUS_PARAM}, } } }, }, + 500: _500_BLOCK, } - -# ============================================================================ -# Get Item by SKU Documentation -# ============================================================================ - GET_ITEM_BY_SKU_RESPONSES = { 200: { "description": "Item retrieved successfully", "model": ItemResponse, + "content": {"application/json": {"example": _ITEM_EXAMPLE}}, }, 404: { "description": "Item not found", "model": ErrorResponse, "content": { "application/json": { - "example": { - "success": False, - "message": "Item with ID 'CHAIR-001' not found", - "status_code": 404, - "error_code": "entity_not_found", - "error_category": "not_found", - "details": {"entity_type": "Item", "entity_id": "CHAIR-001"}, - } - } - }, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } + "example": _err( + status=404, + code="entity_not_found", + category="not_found", + message="Item with ID 'CHAIR-RED-001' not found", + details={"entity_type": "Item", "entity_id": "CHAIR-RED-001"}, + ) } }, }, + 500: _500_BLOCK, } - -# ============================================================================ -# Update Item Documentation -# ============================================================================ - UPDATE_ITEM_RESPONSES = { 200: { "description": "Item updated successfully", "model": ItemResponse, + "content": {"application/json": {"example": _ITEM_EXAMPLE}}, }, - 404: { - "description": "Item not found", - "model": ErrorResponse, - "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, - }, + 404: _404_BLOCK, 422: { - "description": "Validation error - duplicate SKU/slug, invalid UUID, or invalid input data", + "description": "Duplicate SKU/slug, invalid UUID, or invalid input data", "model": ValidationErrorResponse, "content": { "application/json": { "examples": { - "duplicate_sku": { - "summary": "SKU conflicts with another item", - "value": { - "success": False, - "message": "Item with sku='CHAIR-002' already exists", - "status_code": 422, - "error_code": "duplicate_entry", - "error_category": "validation", - "details": { - "entity_type": "Item", - "field": "sku", - "value": "CHAIR-002", - }, - }, - }, - "duplicate_slug": { - "summary": "Slug conflicts with another item", - "value": { - "success": False, - "message": "Item with slug='blue-chair' already exists", - "status_code": 422, - "error_code": "duplicate_entry", - "error_category": "validation", - "details": { - "entity_type": "Item", - "field": "slug", - "value": "blue-chair", - }, - }, - }, - "invalid_uuid": { - "summary": "Invalid UUID format", - "value": INVALID_UUID_EXAMPLE, - }, - "invalid_input": { - "summary": "Invalid field values", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "validation_errors": [ - { - "loc": ["body", "status"], - "msg": "Input should be 'draft', 'active' or 'archived'", - "type": "enum", - } - ], - }, - }, - } - } - }, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, + "duplicate_sku": {"summary": "SKU conflicts with another item", "value": DUPLICATE_SKU_EXAMPLE}, + "duplicate_slug": {"summary": "Slug conflicts with another item", "value": DUPLICATE_SLUG_EXAMPLE}, + "invalid_uuid": {"summary": "Invalid UUID format", "value": INVALID_UUID_EXAMPLE}, + "invalid_input": {"summary": "Invalid field values", "value": _INVALID_BODY_EXAMPLE}, } } }, }, + 500: _500_BLOCK, } - -# ============================================================================ -# Delete Item Documentation -# ============================================================================ - DELETE_ITEM_RESPONSES = { - 204: { - "description": "Item deleted successfully", - }, - 404: { - "description": "Item not found", - "model": ErrorResponse, - "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, - }, - 422: { - "description": "Invalid UUID format", - "model": ValidationErrorResponse, - "content": {"application/json": {"example": INVALID_UUID_EXAMPLE}}, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } - } - }, - }, + 204: {"description": "Item deleted successfully"}, + 404: _404_BLOCK, + 422: _422_UUID_BLOCK, + 500: _500_BLOCK, } + diff --git a/src/app/services/crud_item_store/responses/items_response_models.py b/src/app/services/crud_item_store/responses/items_response_models.py index a0c18dd..41ae99b 100644 --- a/src/app/services/crud_item_store/responses/items_response_models.py +++ b/src/app/services/crud_item_store/responses/items_response_models.py @@ -12,6 +12,54 @@ from ..models import ItemBase +_ITEM_EXAMPLE = { + "uuid": "2f61e8db-1234-4abc-8def-426614174000", + "sku": "CHAIR-RED-001", + "status": "active", + "name": "Red Wooden Chair", + "slug": "red-wooden-chair", + "short_description": "A comfortable red wooden chair.", + "description": "

Handcrafted red wooden chair with ergonomic design.

", + "categories": ["a1b2c3d4-0000-0000-0000-000000000001"], + "brand": "WoodCraft", + "price": { + "amount": 4999, + "currency": "EUR", + "includes_tax": True, + "original_amount": 5999, + "tax_class": "standard", + }, + "media": { + "main_image": "https://cdn.example.com/items/chair-red-001.jpg", + "gallery": [ + "https://cdn.example.com/items/chair-red-001-side.jpg", + "https://cdn.example.com/items/chair-red-001-back.jpg", + ], + }, + "inventory": { + "stock_quantity": 42, + "stock_status": "in_stock", + "allow_backorder": False, + }, + "shipping": { + "is_physical": True, + "weight": {"value": 4.5, "unit": "kg"}, + "dimensions": {"width": 45.0, "height": 90.0, "length": 45.0, "unit": "cm"}, + "shipping_class": "standard", + }, + "attributes": {"color": "red", "material": "oak"}, + "identifiers": { + "barcode": "4006381333931", + "manufacturer_part_number": "WC-CHAIR-RED-01", + "country_of_origin": "DE", + }, + "custom": {}, + "system": {"log_table": None}, + "created_at": "2026-01-15T10:30:00Z", + "updated_at": "2026-02-20T14:00:00Z", +} + + class ItemResponse(ItemBase): """ Schema for item API responses. @@ -24,4 +72,7 @@ class ItemResponse(ItemBase): created_at: datetime = Field(..., description="Item creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={"example": _ITEM_EXAMPLE}, + ) From ffe198ce3b2261753c28d1742ca34d4277447d3a Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Thu, 5 Mar 2026 09:42:45 +0100 Subject: [PATCH 16/17] test(crud_item_store): fix syntax error --- tests/test_responses_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_responses_module.py b/tests/test_responses_module.py index 00e45be..6ac76f6 100644 --- a/tests/test_responses_module.py +++ b/tests/test_responses_module.py @@ -247,8 +247,8 @@ def test_validation_error_response_defaults(): assert response.success is False assert response.status_code == 422 - assert response.error_code == "VALIDATION_ERROR" - assert response.error_category == "VALIDATION" + assert response.error_code == "invalid_input" + assert response.error_category == "validation" def test_validation_error_response_with_errors(): From 09b528e275601212e485f9be84a5f5aaf2cc24b0 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Thu, 5 Mar 2026 09:49:23 +0100 Subject: [PATCH 17/17] fix(data-models): run linter --- src/app/db_models.py | 1 - .../functions/item_validation.py | 5 +- .../crud_item_store/responses/item_docs.py | 111 ++++++++++++++---- .../crud_item_store/routers/items_router.py | 9 +- .../services/item_db_service.py | 13 +- .../services/inventory_db_service.py | 4 +- .../orders/services/orders_db_service.py | 10 +- 7 files changed, 125 insertions(+), 28 deletions(-) diff --git a/src/app/db_models.py b/src/app/db_models.py index 107fd81..83311e1 100644 --- a/src/app/db_models.py +++ b/src/app/db_models.py @@ -23,7 +23,6 @@ from app.services.customers.models.customers_db_models import AddressDB, CustomerDB # noqa: F401 # Inventory -from app.services.inventory.models.inventory_db_models import InventoryItemDB, StockReservationDB # noqa: F401 # Orders from app.services.orders.models.orders_db_models import OrderDB, OrderItemDB # noqa: F401 diff --git a/src/app/services/crud_item_store/functions/item_validation.py b/src/app/services/crud_item_store/functions/item_validation.py index 6f46012..4a3d5d0 100644 --- a/src/app/services/crud_item_store/functions/item_validation.py +++ b/src/app/services/crud_item_store/functions/item_validation.py @@ -49,7 +49,10 @@ async def check_duplicate_field( """ logger.debug( "Checking for duplicate field value", - extra={"field_name": field_name, "exclude_uuid": str(exclude_uuid) if exclude_uuid else None}, + extra={ + "field_name": field_name, + "exclude_uuid": str(exclude_uuid) if exclude_uuid else None, + }, ) # Use the repository's generic field_exists method # This will raise ValueError if field doesn't exist on the model diff --git a/src/app/services/crud_item_store/responses/item_docs.py b/src/app/services/crud_item_store/responses/item_docs.py index 33f4a62..96b4e48 100644 --- a/src/app/services/crud_item_store/responses/item_docs.py +++ b/src/app/services/crud_item_store/responses/item_docs.py @@ -18,7 +18,10 @@ # Helpers # --------------------------------------------------------------------------- -def _err(status: int, code: str, category: str, message: str, details: dict | None = None) -> dict: + +def _err( + status: int, code: str, category: str, message: str, details: dict | None = None +) -> dict: """Build an error example dict from an actual ErrorResponse instance.""" return ErrorResponse( status_code=status, @@ -43,7 +46,13 @@ def _validation_err(message: str, errors: list[dict]) -> dict: INVALID_UUID_EXAMPLE = _validation_err( message="Validation failed", - errors=[{"loc": ["path", "item_uuid"], "msg": "Input should be a valid UUID", "type": "uuid_parsing"}], + errors=[ + { + "loc": ["path", "item_uuid"], + "msg": "Input should be a valid UUID", + "type": "uuid_parsing", + } + ], ) ITEM_NOT_FOUND_EXAMPLE = _err( @@ -51,7 +60,10 @@ def _validation_err(message: str, errors: list[dict]) -> dict: code="entity_not_found", category="not_found", message="Item with ID '123e4567-e89b-12d3-a456-426614174000' not found", - details={"entity_type": "Item", "entity_id": "123e4567-e89b-12d3-a456-426614174000"}, + details={ + "entity_type": "Item", + "entity_id": "123e4567-e89b-12d3-a456-426614174000", + }, ) DUPLICATE_SKU_EXAMPLE = _err( @@ -88,22 +100,46 @@ def _validation_err(message: str, errors: list[dict]) -> dict: _INVALID_BODY_EXAMPLE = _validation_err( message="Validation failed", - errors=[{"loc": ["body", "price", "amount"], "msg": "Input should be greater than or equal to 0", "type": "greater_than_equal"}], + errors=[ + { + "loc": ["body", "price", "amount"], + "msg": "Input should be greater than or equal to 0", + "type": "greater_than_equal", + } + ], ) _INVALID_PAGINATION_SKIP = _validation_err( message="Validation failed", - errors=[{"loc": ["query", "skip"], "msg": "Input should be greater than or equal to 0", "type": "greater_than_equal"}], + errors=[ + { + "loc": ["query", "skip"], + "msg": "Input should be greater than or equal to 0", + "type": "greater_than_equal", + } + ], ) _INVALID_PAGINATION_LIMIT = _validation_err( message="Validation failed", - errors=[{"loc": ["query", "limit"], "msg": "Input should be less than or equal to 100", "type": "less_than_equal"}], + errors=[ + { + "loc": ["query", "limit"], + "msg": "Input should be less than or equal to 100", + "type": "less_than_equal", + } + ], ) _INVALID_STATUS_PARAM = _validation_err( message="Validation failed", - errors=[{"loc": ["query", "status"], "msg": "Input should be 'draft', 'active' or 'archived'", "type": "enum"}], + errors=[ + { + "loc": ["query", "status"], + "msg": "Input should be 'draft', 'active' or 'archived'", + "type": "enum", + } + ], ) @@ -117,8 +153,14 @@ def _validation_err(message: str, errors: list[dict]) -> dict: "content": { "application/json": { "examples": { - "database_error": {"summary": "Database error", "value": DATABASE_ERROR_EXAMPLE}, - "internal_error": {"summary": "Unexpected error", "value": INTERNAL_ERROR_EXAMPLE}, + "database_error": { + "summary": "Database error", + "value": DATABASE_ERROR_EXAMPLE, + }, + "internal_error": { + "summary": "Unexpected error", + "value": INTERNAL_ERROR_EXAMPLE, + }, } } }, @@ -153,9 +195,18 @@ def _validation_err(message: str, errors: list[dict]) -> dict: "content": { "application/json": { "examples": { - "duplicate_sku": {"summary": "Duplicate SKU", "value": DUPLICATE_SKU_EXAMPLE}, - "duplicate_slug": {"summary": "Duplicate slug", "value": DUPLICATE_SLUG_EXAMPLE}, - "invalid_input": {"summary": "Invalid input data", "value": _INVALID_BODY_EXAMPLE}, + "duplicate_sku": { + "summary": "Duplicate SKU", + "value": DUPLICATE_SKU_EXAMPLE, + }, + "duplicate_slug": { + "summary": "Duplicate slug", + "value": DUPLICATE_SLUG_EXAMPLE, + }, + "invalid_input": { + "summary": "Invalid input data", + "value": _INVALID_BODY_EXAMPLE, + }, } } }, @@ -185,9 +236,18 @@ def _validation_err(message: str, errors: list[dict]) -> dict: "content": { "application/json": { "examples": { - "invalid_skip": {"summary": "Negative skip value", "value": _INVALID_PAGINATION_SKIP}, - "invalid_limit": {"summary": "Limit out of range", "value": _INVALID_PAGINATION_LIMIT}, - "invalid_status": {"summary": "Invalid status value", "value": _INVALID_STATUS_PARAM}, + "invalid_skip": { + "summary": "Negative skip value", + "value": _INVALID_PAGINATION_SKIP, + }, + "invalid_limit": { + "summary": "Limit out of range", + "value": _INVALID_PAGINATION_LIMIT, + }, + "invalid_status": { + "summary": "Invalid status value", + "value": _INVALID_STATUS_PARAM, + }, } } }, @@ -232,10 +292,22 @@ def _validation_err(message: str, errors: list[dict]) -> dict: "content": { "application/json": { "examples": { - "duplicate_sku": {"summary": "SKU conflicts with another item", "value": DUPLICATE_SKU_EXAMPLE}, - "duplicate_slug": {"summary": "Slug conflicts with another item", "value": DUPLICATE_SLUG_EXAMPLE}, - "invalid_uuid": {"summary": "Invalid UUID format", "value": INVALID_UUID_EXAMPLE}, - "invalid_input": {"summary": "Invalid field values", "value": _INVALID_BODY_EXAMPLE}, + "duplicate_sku": { + "summary": "SKU conflicts with another item", + "value": DUPLICATE_SKU_EXAMPLE, + }, + "duplicate_slug": { + "summary": "Slug conflicts with another item", + "value": DUPLICATE_SLUG_EXAMPLE, + }, + "invalid_uuid": { + "summary": "Invalid UUID format", + "value": INVALID_UUID_EXAMPLE, + }, + "invalid_input": { + "summary": "Invalid field values", + "value": _INVALID_BODY_EXAMPLE, + }, } } }, @@ -249,4 +321,3 @@ def _validation_err(message: str, errors: list[dict]) -> dict: 422: _422_UUID_BLOCK, 500: _500_BLOCK, } - diff --git a/src/app/services/crud_item_store/routers/items_router.py b/src/app/services/crud_item_store/routers/items_router.py index 3981620..fb7a698 100644 --- a/src/app/services/crud_item_store/routers/items_router.py +++ b/src/app/services/crud_item_store/routers/items_router.py @@ -163,7 +163,14 @@ async def list_items( DatabaseError (500): If database operation fails """ repo = get_item_repository(session) - logger.debug("Listing items", extra={"skip": skip, "limit": limit, "status": status_filter.value if status_filter else None}) + logger.debug( + "Listing items", + extra={ + "skip": skip, + "limit": limit, + "status": status_filter.value if status_filter else None, + }, + ) # Apply filters filters = {} diff --git a/src/app/services/crud_item_store/services/item_db_service.py b/src/app/services/crud_item_store/services/item_db_service.py index 7c99395..6e12d2e 100644 --- a/src/app/services/crud_item_store/services/item_db_service.py +++ b/src/app/services/crud_item_store/services/item_db_service.py @@ -77,7 +77,13 @@ async def search( """ logger.debug( "Searching items", - extra={"name": name, "status": status, "brand": brand, "skip": skip, "limit": limit}, + extra={ + "name": name, + "status": status, + "brand": brand, + "skip": skip, + "limit": limit, + }, ) stmt = select(self.model) conditions = [] @@ -133,7 +139,10 @@ async def field_exists( """ logger.debug( "Checking field existence", - extra={"field_name": field_name, "exclude_uuid": str(exclude_uuid) if exclude_uuid else None}, + extra={ + "field_name": field_name, + "exclude_uuid": str(exclude_uuid) if exclude_uuid else None, + }, ) # Validate field exists on model if not hasattr(self.model, field_name): diff --git a/src/app/services/inventory/services/inventory_db_service.py b/src/app/services/inventory/services/inventory_db_service.py index eeea1f6..0e8ffa7 100644 --- a/src/app/services/inventory/services/inventory_db_service.py +++ b/src/app/services/inventory/services/inventory_db_service.py @@ -70,7 +70,9 @@ async def get_active_for_order(self, order_id: UUID) -> list[StockReservationDB] Returns: List of active StockReservationDB instances. """ - logger.debug("Getting active reservations for order", extra={"order_id": str(order_id)}) + logger.debug( + "Getting active reservations for order", extra={"order_id": str(order_id)} + ) stmt = select(self.model).where( and_( self.model.order_id == order_id, diff --git a/src/app/services/orders/services/orders_db_service.py b/src/app/services/orders/services/orders_db_service.py index d59e1fa..28aa755 100644 --- a/src/app/services/orders/services/orders_db_service.py +++ b/src/app/services/orders/services/orders_db_service.py @@ -51,7 +51,10 @@ async def get_by_customer( Returns: List of OrderDB instances (soft-deleted orders excluded). """ - logger.debug("Getting orders for customer", extra={"customer_id": str(customer_id), "skip": skip, "limit": limit}) + logger.debug( + "Getting orders for customer", + extra={"customer_id": str(customer_id), "skip": skip, "limit": limit}, + ) stmt = ( select(self.model) .where( @@ -84,7 +87,10 @@ async def get_by_status( Returns: List of OrderDB instances (soft-deleted orders excluded). """ - logger.debug("Getting orders by status", extra={"status": status.value, "skip": skip, "limit": limit}) + logger.debug( + "Getting orders by status", + extra={"status": status.value, "skip": skip, "limit": limit}, + ) stmt = ( select(self.model) .where(