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

View file

@ -10,10 +10,11 @@ from .domain import Domain
class AICallLog(SQLModel, table=True):
__tablename__ = "ai_call_logs"
__tablename__ = "ai_call_logs" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset()
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)
endpoint: str = Field(index=True)
model: str

View file

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

View file

@ -11,10 +11,11 @@ from .enums import MedicationKind, ResultFlag
class MedicationEntry(SQLModel, table=True):
__tablename__ = "medication_entries"
__tablename__ = "medication_entries" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH})
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)
@ -43,10 +44,11 @@ class MedicationEntry(SQLModel, table=True):
class MedicationUsage(SQLModel, table=True):
__tablename__ = "medication_usages"
__tablename__ = "medication_usages" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH})
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(
foreign_key="medication_entries.record_id", index=True
)
@ -78,10 +80,11 @@ class MedicationUsage(SQLModel, table=True):
class LabResult(SQLModel, table=True):
__tablename__ = "lab_results"
__tablename__ = "lab_results" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.HEALTH})
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)
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 enum import Enum
from typing import Any, ClassVar, Optional, cast
from uuid import UUID, uuid4
@ -72,7 +73,9 @@ class ProductContext(SQLModel):
def _ev(v: object) -> str:
"""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):
__tablename__ = "products"
__tablename__ = "products" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
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(
max_length=8,
unique=True,
@ -230,8 +234,8 @@ class Product(ProductBase, table=True):
*,
computed_price_tier: PriceTier | None = None,
price_per_use_pln: float | None = None,
) -> dict:
ctx: dict = {
) -> dict[str, Any]:
ctx: dict[str, Any] = {
"id": str(self.id),
"name": self.name,
"brand": self.brand,
@ -273,7 +277,7 @@ class Product(ProductBase, table=True):
if isinstance(a, dict):
actives_ctx.append(a)
else:
a_dict: dict = {"name": a.name}
a_dict: dict[str, Any] = {"name": a.name}
if a.percent is not None:
a_dict["percent"] = a.percent
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
]
if opened_items:
most_recent = max(opened_items, key=lambda x: x.opened_at)
ctx["days_since_opened"] = (date.today() - most_recent.opened_at).days
most_recent = max(opened_items, key=lambda x: cast(date, x.opened_at))
ctx["days_since_opened"] = (
date.today() - cast(date, most_recent.opened_at)
).days
except Exception:
pass
@ -351,12 +357,14 @@ class Product(ProductBase, table=True):
class ProductInventory(SQLModel, table=True):
__tablename__ = "product_inventory"
__tablename__ = "product_inventory" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
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")
is_household_shared: bool = Field(default=False)
is_opened: bool = Field(default=False)
opened_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):
__tablename__ = "user_profiles"
__tablename__ = "user_profiles" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset(
{Domain.HEALTH, Domain.SKINCARE}
)
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)
sex_at_birth: SexAtBirth | None = Field(
default=None,

View file

@ -14,7 +14,7 @@ if TYPE_CHECKING:
class Routine(SQLModel, table=True):
__tablename__ = "routines"
__tablename__ = "routines" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
__table_args__ = (
UniqueConstraint(
@ -23,6 +23,7 @@ class Routine(SQLModel, table=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)
part_of_day: PartOfDay = Field(index=True)
notes: str | None = Field(default=None)
@ -45,20 +46,22 @@ class Routine(SQLModel, table=True):
class GroomingSchedule(SQLModel, table=True):
__tablename__ = "grooming_schedule"
__tablename__ = "grooming_schedule" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
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
action: GroomingAction
notes: str | None = Field(default=None)
class RoutineStep(SQLModel, table=True):
__tablename__ = "routine_steps"
__tablename__ = "routine_steps" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
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)
product_id: UUID | None = Field(default=None, foreign_key="products.id", index=True)
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.
"""
__tablename__ = "skin_condition_snapshots"
__tablename__ = "skin_condition_snapshots" # pyright: ignore[reportAssignmentType]
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
__table_args__ = (UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),)
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
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")