feat(auth): add local user and household models

This commit is contained in:
Piotr Oleszczyk 2026-03-12 14:45:43 +01:00
parent e29d62f949
commit 04daadccda
13 changed files with 178 additions and 17 deletions

View file

@ -0,0 +1 @@
['household_memberships', 'households', 'users']

View file

@ -0,0 +1 @@
False

View file

@ -6,6 +6,7 @@ from .enums import (
DayTime, DayTime,
EvidenceLevel, EvidenceLevel,
GroomingAction, GroomingAction,
HouseholdRole,
IngredientFunction, IngredientFunction,
MedicationKind, MedicationKind,
OverallSkinState, OverallSkinState,
@ -14,6 +15,7 @@ from .enums import (
ProductCategory, ProductCategory,
RemainingLevel, RemainingLevel,
ResultFlag, ResultFlag,
Role,
RoutineRole, RoutineRole,
SexAtBirth, SexAtBirth,
SkinConcern, SkinConcern,
@ -24,6 +26,8 @@ from .enums import (
UsageFrequency, UsageFrequency,
) )
from .health import LabResult, MedicationEntry, MedicationUsage from .health import LabResult, MedicationEntry, MedicationUsage
from .household import Household
from .household_membership import HouseholdMembership
from .pricing import PricingRecalcJob from .pricing import PricingRecalcJob
from .product import ( from .product import (
ActiveIngredient, ActiveIngredient,
@ -42,6 +46,7 @@ from .skincare import (
SkinConditionSnapshotBase, SkinConditionSnapshotBase,
SkinConditionSnapshotPublic, SkinConditionSnapshotPublic,
) )
from .user import User
__all__ = [ __all__ = [
# ai logs # ai logs
@ -54,6 +59,7 @@ __all__ = [
"DayTime", "DayTime",
"EvidenceLevel", "EvidenceLevel",
"GroomingAction", "GroomingAction",
"HouseholdRole",
"IngredientFunction", "IngredientFunction",
"MedicationKind", "MedicationKind",
"OverallSkinState", "OverallSkinState",
@ -62,6 +68,7 @@ __all__ = [
"RemainingLevel", "RemainingLevel",
"ProductCategory", "ProductCategory",
"ResultFlag", "ResultFlag",
"Role",
"RoutineRole", "RoutineRole",
"SexAtBirth", "SexAtBirth",
"SkinConcern", "SkinConcern",
@ -74,6 +81,8 @@ __all__ = [
"LabResult", "LabResult",
"MedicationEntry", "MedicationEntry",
"MedicationUsage", "MedicationUsage",
"Household",
"HouseholdMembership",
# product # product
"ActiveIngredient", "ActiveIngredient",
"Product", "Product",
@ -85,6 +94,7 @@ __all__ = [
"ProductWithInventory", "ProductWithInventory",
"PricingRecalcJob", "PricingRecalcJob",
"UserProfile", "UserProfile",
"User",
# routine # routine
"GroomingSchedule", "GroomingSchedule",
"Routine", "Routine",

View file

@ -10,10 +10,11 @@ from .domain import Domain
class AICallLog(SQLModel, table=True): class AICallLog(SQLModel, table=True):
__tablename__ = "ai_call_logs" __tablename__ = "ai_call_logs" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset() __domains__: ClassVar[frozenset[Domain]] = frozenset()
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=utc_now, nullable=False) created_at: datetime = Field(default_factory=utc_now, nullable=False)
endpoint: str = Field(index=True) endpoint: str = Field(index=True)
model: str model: str

View file

@ -29,6 +29,16 @@ class UsageFrequency(str, Enum):
AS_NEEDED = "as_needed" AS_NEEDED = "as_needed"
class Role(str, Enum):
ADMIN = "admin"
MEMBER = "member"
class HouseholdRole(str, Enum):
OWNER = "owner"
MEMBER = "member"
class ProductCategory(str, Enum): class ProductCategory(str, Enum):
CLEANSER = "cleanser" CLEANSER = "cleanser"
TONER = "toner" TONER = "toner"

View file

@ -11,10 +11,11 @@ from .enums import MedicationKind, ResultFlag
class MedicationEntry(SQLModel, table=True): class MedicationEntry(SQLModel, table=True):
__tablename__ = "medication_entries" __tablename__ = "medication_entries" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH})
record_id: UUID = Field(default_factory=uuid4, primary_key=True) record_id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
kind: MedicationKind = Field(index=True) kind: MedicationKind = Field(index=True)
@ -43,10 +44,11 @@ class MedicationEntry(SQLModel, table=True):
class MedicationUsage(SQLModel, table=True): class MedicationUsage(SQLModel, table=True):
__tablename__ = "medication_usages" __tablename__ = "medication_usages" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH})
record_id: UUID = Field(default_factory=uuid4, primary_key=True) record_id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
medication_record_id: UUID = Field( medication_record_id: UUID = Field(
foreign_key="medication_entries.record_id", index=True foreign_key="medication_entries.record_id", index=True
) )
@ -78,10 +80,11 @@ class MedicationUsage(SQLModel, table=True):
class LabResult(SQLModel, table=True): class LabResult(SQLModel, table=True):
__tablename__ = "lab_results" __tablename__ = "lab_results" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH})
record_id: UUID = Field(default_factory=uuid4, primary_key=True) record_id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
collected_at: datetime = Field(index=True) collected_at: datetime = Field(index=True)
test_code: str = Field(index=True, regex=r"^\d+-\d$") test_code: str = Field(index=True, regex=r"^\d+-\d$")

View file

@ -0,0 +1,36 @@
# pyright: reportImportCycles=false
from datetime import datetime
from typing import TYPE_CHECKING, ClassVar
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime
from sqlmodel import Field, Relationship, SQLModel
from .base import utc_now
from .domain import Domain
if TYPE_CHECKING:
from .household_membership import HouseholdMembership # pyright: ignore[reportImportCycles]
class Household(SQLModel, table=True):
__tablename__ = "households" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset()
id: UUID = Field(default_factory=uuid4, primary_key=True)
created_at: datetime = Field(default_factory=utc_now, nullable=False)
updated_at: datetime = Field(
default_factory=utc_now,
sa_column=Column(
DateTime(timezone=True),
default=utc_now,
onupdate=utc_now,
nullable=False,
),
)
memberships: list["HouseholdMembership"] = Relationship(
back_populates="household",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
)

View file

@ -0,0 +1,45 @@
# pyright: reportImportCycles=false
from datetime import datetime
from typing import TYPE_CHECKING, ClassVar
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime, UniqueConstraint
from sqlmodel import Field, Relationship, SQLModel
from .base import utc_now
from .domain import Domain
from .enums import HouseholdRole
if TYPE_CHECKING:
from .household import Household # pyright: ignore[reportImportCycles]
from .user import User # pyright: ignore[reportImportCycles]
class HouseholdMembership(SQLModel, table=True):
__tablename__ = "household_memberships" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset()
__table_args__ = (
UniqueConstraint("user_id", name="uq_household_memberships_user_id"),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="users.id", index=True, ondelete="CASCADE")
household_id: UUID = Field(
foreign_key="households.id", index=True, ondelete="CASCADE"
)
role: HouseholdRole = Field(default=HouseholdRole.MEMBER, index=True)
created_at: datetime = Field(default_factory=utc_now, nullable=False)
updated_at: datetime = Field(
default_factory=utc_now,
sa_column=Column(
DateTime(timezone=True),
default=utc_now,
onupdate=utc_now,
nullable=False,
),
)
user: "User" = Relationship(back_populates="household_membership")
household: "Household" = Relationship(back_populates="memberships")

View file

@ -1,4 +1,5 @@
from datetime import date, datetime from datetime import date, datetime
from enum import Enum
from typing import Any, ClassVar, Optional, cast from typing import Any, ClassVar, Optional, cast
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@ -72,7 +73,9 @@ class ProductContext(SQLModel):
def _ev(v: object) -> str: def _ev(v: object) -> str:
"""Return enum value or string as-is (handles both DB-loaded dicts and Python enums).""" """Return enum value or string as-is (handles both DB-loaded dicts and Python enums)."""
return v.value if hasattr(v, "value") else str(v) # type: ignore[union-attr] if isinstance(v, Enum):
return str(v.value)
return str(v)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -136,10 +139,11 @@ class ProductBase(SQLModel):
class Product(ProductBase, table=True): class Product(ProductBase, table=True):
__tablename__ = "products" __tablename__ = "products" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
short_id: str = Field( short_id: str = Field(
max_length=8, max_length=8,
unique=True, unique=True,
@ -230,8 +234,8 @@ class Product(ProductBase, table=True):
*, *,
computed_price_tier: PriceTier | None = None, computed_price_tier: PriceTier | None = None,
price_per_use_pln: float | None = None, price_per_use_pln: float | None = None,
) -> dict: ) -> dict[str, Any]:
ctx: dict = { ctx: dict[str, Any] = {
"id": str(self.id), "id": str(self.id),
"name": self.name, "name": self.name,
"brand": self.brand, "brand": self.brand,
@ -273,7 +277,7 @@ class Product(ProductBase, table=True):
if isinstance(a, dict): if isinstance(a, dict):
actives_ctx.append(a) actives_ctx.append(a)
else: else:
a_dict: dict = {"name": a.name} a_dict: dict[str, Any] = {"name": a.name}
if a.percent is not None: if a.percent is not None:
a_dict["percent"] = a.percent a_dict["percent"] = a.percent
if a.functions: if a.functions:
@ -342,8 +346,10 @@ class Product(ProductBase, table=True):
inv for inv in (self.inventory or []) if inv.is_opened and inv.opened_at inv for inv in (self.inventory or []) if inv.is_opened and inv.opened_at
] ]
if opened_items: if opened_items:
most_recent = max(opened_items, key=lambda x: x.opened_at) most_recent = max(opened_items, key=lambda x: cast(date, x.opened_at))
ctx["days_since_opened"] = (date.today() - most_recent.opened_at).days ctx["days_since_opened"] = (
date.today() - cast(date, most_recent.opened_at)
).days
except Exception: except Exception:
pass pass
@ -351,12 +357,14 @@ class Product(ProductBase, table=True):
class ProductInventory(SQLModel, table=True): class ProductInventory(SQLModel, table=True):
__tablename__ = "product_inventory" __tablename__ = "product_inventory" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
product_id: UUID = Field(foreign_key="products.id", index=True, ondelete="CASCADE") product_id: UUID = Field(foreign_key="products.id", index=True, ondelete="CASCADE")
is_household_shared: bool = Field(default=False)
is_opened: bool = Field(default=False) is_opened: bool = Field(default=False)
opened_at: date | None = Field(default=None) opened_at: date | None = Field(default=None)
finished_at: date | None = Field(default=None) finished_at: date | None = Field(default=None)

View file

@ -11,12 +11,13 @@ from .enums import SexAtBirth
class UserProfile(SQLModel, table=True): class UserProfile(SQLModel, table=True):
__tablename__ = "user_profiles" __tablename__ = "user_profiles" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset( __domains__: ClassVar[frozenset[Domain]] = frozenset(
{Domain.HEALTH, Domain.SKINCARE} {Domain.HEALTH, Domain.SKINCARE}
) )
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
birth_date: date | None = Field(default=None) birth_date: date | None = Field(default=None)
sex_at_birth: SexAtBirth | None = Field( sex_at_birth: SexAtBirth | None = Field(
default=None, default=None,

View file

@ -14,7 +14,7 @@ if TYPE_CHECKING:
class Routine(SQLModel, table=True): class Routine(SQLModel, table=True):
__tablename__ = "routines" __tablename__ = "routines" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@ -23,6 +23,7 @@ class Routine(SQLModel, table=True):
) )
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
routine_date: date = Field(index=True) routine_date: date = Field(index=True)
part_of_day: PartOfDay = Field(index=True) part_of_day: PartOfDay = Field(index=True)
notes: str | None = Field(default=None) notes: str | None = Field(default=None)
@ -45,20 +46,22 @@ class Routine(SQLModel, table=True):
class GroomingSchedule(SQLModel, table=True): class GroomingSchedule(SQLModel, table=True):
__tablename__ = "grooming_schedule" __tablename__ = "grooming_schedule" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
day_of_week: int = Field(ge=0, le=6, index=True) # 0 = poniedziałek, 6 = niedziela day_of_week: int = Field(ge=0, le=6, index=True) # 0 = poniedziałek, 6 = niedziela
action: GroomingAction action: GroomingAction
notes: str | None = Field(default=None) notes: str | None = Field(default=None)
class RoutineStep(SQLModel, table=True): class RoutineStep(SQLModel, table=True):
__tablename__ = "routine_steps" __tablename__ = "routine_steps" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
routine_id: UUID = Field(foreign_key="routines.id", index=True) routine_id: UUID = Field(foreign_key="routines.id", index=True)
product_id: UUID | None = Field(default=None, foreign_key="products.id", index=True) product_id: UUID | None = Field(default=None, foreign_key="products.id", index=True)
order_index: int = Field(ge=0) order_index: int = Field(ge=0)

View file

@ -51,11 +51,12 @@ class SkinConditionSnapshot(SkinConditionSnapshotBase, table=True):
i kontekstu rutyny. Wszystkie metryki numeryczne w skali 15. i kontekstu rutyny. Wszystkie metryki numeryczne w skali 15.
""" """
__tablename__ = "skin_condition_snapshots" __tablename__ = "skin_condition_snapshots" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE}) __domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
__table_args__ = (UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),) __table_args__ = (UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),)
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
# Override: add index for table context # Override: add index for table context
snapshot_date: date = Field(index=True) snapshot_date: date = Field(index=True)

View file

@ -0,0 +1,41 @@
# pyright: reportImportCycles=false
from datetime import datetime
from typing import TYPE_CHECKING, ClassVar
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime, String, UniqueConstraint
from sqlmodel import Field, Relationship, SQLModel
from .base import utc_now
from .domain import Domain
from .enums import Role
if TYPE_CHECKING:
from .household_membership import HouseholdMembership # pyright: ignore[reportImportCycles]
class User(SQLModel, table=True):
__tablename__ = "users" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset()
__table_args__ = (
UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
oidc_issuer: str = Field(sa_column=Column(String(length=512), nullable=False))
oidc_subject: str = Field(sa_column=Column(String(length=512), nullable=False))
role: Role = Field(default=Role.MEMBER, index=True)
created_at: datetime = Field(default_factory=utc_now, nullable=False)
updated_at: datetime = Field(
default_factory=utc_now,
sa_column=Column(
DateTime(timezone=True),
default=utc_now,
onupdate=utc_now,
nullable=False,
),
)
household_membership: "HouseholdMembership" = Relationship(back_populates="user")