from datetime import date, datetime, time from uuid import uuid4 from sqlalchemy import ( JSON, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, Time, UniqueConstraint, func, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): """Declarative base.""" class User(Base): """User entity.""" __tablename__ = "users" id: Mapped[str] = mapped_column(String(255), primary_key=True) name: Mapped[str] = mapped_column(String(255), nullable=False) avatar_url: Mapped[str | None] = mapped_column(String(500)) provider: Mapped[str] = mapped_column(String(50), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) stories: Mapped[list["Story"]] = relationship( "Story", back_populates="user", cascade="all, delete-orphan" ) child_profiles: Mapped[list["ChildProfile"]] = relationship( "ChildProfile", back_populates="user", cascade="all, delete-orphan" ) class Story(Base): """Story entity.""" __tablename__ = "stories" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) user_id: Mapped[str] = mapped_column( String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) child_profile_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("child_profiles.id", ondelete="SET NULL"), nullable=True ) universe_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True ) title: Mapped[str] = mapped_column(String(255), nullable=False) story_text: Mapped[str | None] = mapped_column(Text, nullable=True) pages: Mapped[list[dict] | None] = mapped_column(JSON, default=list) cover_prompt: Mapped[str | None] = mapped_column(Text) image_url: Mapped[str | None] = mapped_column(String(500)) mode: Mapped[str] = mapped_column(String(20), nullable=False, default="generated") generation_status: Mapped[str] = mapped_column( String(32), nullable=False, default="narrative_ready" ) text_status: Mapped[str] = mapped_column( String(32), nullable=False, default="ready" ) image_status: Mapped[str] = mapped_column( String(32), nullable=False, default="not_requested" ) audio_status: Mapped[str] = mapped_column( String(32), nullable=False, default="not_requested" ) audio_path: Mapped[str | None] = mapped_column(String(500)) last_error: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) user: Mapped["User"] = relationship("User", back_populates="stories") child_profile: Mapped["ChildProfile | None"] = relationship("ChildProfile") story_universe: Mapped["StoryUniverse | None"] = relationship("StoryUniverse") @property def retryable_assets(self) -> list[str]: """Assets that can be completed or retried from the current persisted state.""" assets: list[str] = [] image_is_busy_or_ready = self.image_status in {"ready", "generating"} if not image_is_busy_or_ready: if self.mode == "storybook": pages = self.pages or [] has_missing_page_image = any( isinstance(page, dict) and page.get("image_prompt") and not page.get("image_url") for page in pages ) if (self.cover_prompt and not self.image_url) or has_missing_page_image: assets.append("image") elif self.cover_prompt: assets.append("image") audio_is_busy_or_ready = self.audio_status in {"ready", "generating"} if self.story_text and not audio_is_busy_or_ready: assets.append("audio") return assets def _uuid() -> str: return str(uuid4()) class GenerationJob(Base): """User-visible generation attempt that can be inspected after the request returns.""" __tablename__ = "generation_jobs" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) story_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True ) output_mode: Mapped[str] = mapped_column(String(32), nullable=False) input_type: Mapped[str] = mapped_column(String(32), nullable=False) status: Mapped[str] = mapped_column(String(32), nullable=False, default="running", index=True) current_step: Mapped[str] = mapped_column( String(64), nullable=False, default="request_accepted" ) request_payload: Mapped[dict] = mapped_column(JSON, default=dict) result_snapshot: Mapped[dict] = mapped_column(JSON, default=dict) error_message: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) class GenerationJobEvent(Base): """Append-only event emitted by a generation job.""" __tablename__ = "generation_job_events" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) job_id: Mapped[str] = mapped_column( String(36), ForeignKey("generation_jobs.id", ondelete="CASCADE"), nullable=False, index=True ) story_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True ) event_type: Mapped[str] = mapped_column(String(64), nullable=False) status: Mapped[str] = mapped_column(String(32), nullable=False) message: Mapped[str | None] = mapped_column(Text) event_metadata: Mapped[dict] = mapped_column(JSON, default=dict) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) class ChildProfile(Base): """Child profile entity.""" __tablename__ = "child_profiles" __table_args__ = (UniqueConstraint("user_id", "name", name="uq_child_profile_user_name"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) name: Mapped[str] = mapped_column(String(50), nullable=False) avatar_url: Mapped[str | None] = mapped_column(String(500)) birth_date: Mapped[date | None] = mapped_column(Date) gender: Mapped[str | None] = mapped_column(String(10)) interests: Mapped[list[str]] = mapped_column(JSON, default=list) growth_themes: Mapped[list[str]] = mapped_column(JSON, default=list) reading_preferences: Mapped[dict] = mapped_column(JSON, default=dict) stories_count: Mapped[int] = mapped_column(Integer, default=0) total_reading_time: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) user: Mapped["User"] = relationship("User", back_populates="child_profiles") story_universes: Mapped[list["StoryUniverse"]] = relationship( "StoryUniverse", back_populates="child_profile", cascade="all, delete-orphan" ) @property def age(self) -> int | None: if not self.birth_date: return None today = date.today() return today.year - self.birth_date.year - ( (today.month, today.day) < (self.birth_date.month, self.birth_date.day) ) class StoryUniverse(Base): """Story universe entity.""" __tablename__ = "story_universes" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) child_profile_id: Mapped[str] = mapped_column( String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True ) name: Mapped[str] = mapped_column(String(100), nullable=False) protagonist: Mapped[dict] = mapped_column(JSON, nullable=False) recurring_characters: Mapped[list] = mapped_column(JSON, default=list) world_settings: Mapped[dict] = mapped_column(JSON, default=dict) achievements: Mapped[list] = mapped_column(JSON, default=list) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) child_profile: Mapped["ChildProfile"] = relationship( "ChildProfile", back_populates="story_universes" ) class ReadingEvent(Base): """Reading event entity.""" __tablename__ = "reading_events" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) child_profile_id: Mapped[str] = mapped_column( String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True ) story_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True ) event_type: Mapped[str] = mapped_column(String(20), nullable=False) reading_time: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) class PushConfig(Base): """Push configuration entity.""" __tablename__ = "push_configs" __table_args__ = ( UniqueConstraint("child_profile_id", name="uq_push_config_child"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) child_profile_id: Mapped[str] = mapped_column( String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True ) push_time: Mapped[time | None] = mapped_column(Time) push_days: Mapped[list[int]] = mapped_column(JSON, default=list) enabled: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) class PushEvent(Base): """Push event entity.""" __tablename__ = "push_events" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) child_profile_id: Mapped[str] = mapped_column( String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True ) trigger_type: Mapped[str] = mapped_column(String(20), nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False) reason: Mapped[str | None] = mapped_column(Text) sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) class MemoryItem(Base): """Memory item entity with time decay metadata.""" __tablename__ = "memory_items" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) child_profile_id: Mapped[str] = mapped_column( String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True ) universe_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True, index=True ) type: Mapped[str] = mapped_column(String(50), nullable=False) value: Mapped[dict] = mapped_column(JSON, nullable=False) base_weight: Mapped[float] = mapped_column(Float, default=1.0) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) ttl_days: Mapped[int | None] = mapped_column(Integer)