From 04daadccdad2c710ae57bf74ab8359a9dd15cd6c Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 14:45:43 +0100 Subject: [PATCH] feat(auth): add local user and household models --- .../evidence/task-T1-identity-models.txt | 1 + .../evidence/task-T1-sharing-default.txt | 1 + backend/innercontext/models/__init__.py | 10 +++++ backend/innercontext/models/ai_log.py | 3 +- backend/innercontext/models/enums.py | 10 +++++ backend/innercontext/models/health.py | 9 ++-- backend/innercontext/models/household.py | 36 +++++++++++++++ .../models/household_membership.py | 45 +++++++++++++++++++ backend/innercontext/models/product.py | 24 ++++++---- backend/innercontext/models/profile.py | 3 +- backend/innercontext/models/routine.py | 9 ++-- backend/innercontext/models/skincare.py | 3 +- backend/innercontext/models/user.py | 41 +++++++++++++++++ 13 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 .sisyphus/evidence/task-T1-identity-models.txt create mode 100644 .sisyphus/evidence/task-T1-sharing-default.txt create mode 100644 backend/innercontext/models/household.py create mode 100644 backend/innercontext/models/household_membership.py create mode 100644 backend/innercontext/models/user.py diff --git a/.sisyphus/evidence/task-T1-identity-models.txt b/.sisyphus/evidence/task-T1-identity-models.txt new file mode 100644 index 0000000..6dc2b1c --- /dev/null +++ b/.sisyphus/evidence/task-T1-identity-models.txt @@ -0,0 +1 @@ +['household_memberships', 'households', 'users'] diff --git a/.sisyphus/evidence/task-T1-sharing-default.txt b/.sisyphus/evidence/task-T1-sharing-default.txt new file mode 100644 index 0000000..bc59c12 --- /dev/null +++ b/.sisyphus/evidence/task-T1-sharing-default.txt @@ -0,0 +1 @@ +False diff --git a/backend/innercontext/models/__init__.py b/backend/innercontext/models/__init__.py index a6d31e3..3333966 100644 --- a/backend/innercontext/models/__init__.py +++ b/backend/innercontext/models/__init__.py @@ -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", diff --git a/backend/innercontext/models/ai_log.py b/backend/innercontext/models/ai_log.py index 7dfa457..9411f4a 100644 --- a/backend/innercontext/models/ai_log.py +++ b/backend/innercontext/models/ai_log.py @@ -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 diff --git a/backend/innercontext/models/enums.py b/backend/innercontext/models/enums.py index 01adf39..b5135ab 100644 --- a/backend/innercontext/models/enums.py +++ b/backend/innercontext/models/enums.py @@ -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" diff --git a/backend/innercontext/models/health.py b/backend/innercontext/models/health.py index 419fb11..c18c0f6 100644 --- a/backend/innercontext/models/health.py +++ b/backend/innercontext/models/health.py @@ -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$") diff --git a/backend/innercontext/models/household.py b/backend/innercontext/models/household.py new file mode 100644 index 0000000..bf9f2c3 --- /dev/null +++ b/backend/innercontext/models/household.py @@ -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"}, + ) diff --git a/backend/innercontext/models/household_membership.py b/backend/innercontext/models/household_membership.py new file mode 100644 index 0000000..1d9b133 --- /dev/null +++ b/backend/innercontext/models/household_membership.py @@ -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") diff --git a/backend/innercontext/models/product.py b/backend/innercontext/models/product.py index 56f678b..3883c74 100644 --- a/backend/innercontext/models/product.py +++ b/backend/innercontext/models/product.py @@ -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) diff --git a/backend/innercontext/models/profile.py b/backend/innercontext/models/profile.py index fab7fd0..71dc04a 100644 --- a/backend/innercontext/models/profile.py +++ b/backend/innercontext/models/profile.py @@ -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, diff --git a/backend/innercontext/models/routine.py b/backend/innercontext/models/routine.py index cdd5796..c08b8df 100644 --- a/backend/innercontext/models/routine.py +++ b/backend/innercontext/models/routine.py @@ -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) diff --git a/backend/innercontext/models/skincare.py b/backend/innercontext/models/skincare.py index 9eed99c..2d81a53 100644 --- a/backend/innercontext/models/skincare.py +++ b/backend/innercontext/models/skincare.py @@ -51,11 +51,12 @@ class SkinConditionSnapshot(SkinConditionSnapshotBase, table=True): i kontekstu rutyny. Wszystkie metryki numeryczne w skali 1–5. """ - __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) diff --git a/backend/innercontext/models/user.py b/backend/innercontext/models/user.py new file mode 100644 index 0000000..e61a48d --- /dev/null +++ b/backend/innercontext/models/user.py @@ -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")