manasu / models.py
puneethasshankar's picture
initial commit
bbeeaa3 verified
Raw
History Blame Contribute Delete
20.5 kB
"""
SQLAlchemy 2.0 models β€” mirrors the Drizzle schema in schema.ts exactly.
Enums, table names, and column names are kept identical so both sides
of the stack read from the same Postgres tables without confusion.
"""
import enum
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
# ---------------------------------------------------------------------------
# Python enums β€” must match the pgEnum values in schema.ts exactly
# Uses 3-tier naming: Primary β†’ Secondary β†’ Tertiary
# ---------------------------------------------------------------------------
class PrimaryEmotion(str, enum.Enum):
"""Tier 1: Inner ring of the emotion wheel."""
Bad = "Bad"
Afraid = "Afraid"
Angry = "Angry"
Disgust = "Disgust"
Sad = "Sad"
Happy = "Happy"
Surprise = "Surprise"
class SecondaryEmotion(str, enum.Enum):
"""Tier 2: Middle ring of the emotion wheel."""
# Bad
Boredom = "Boredom"
Busy = "Busy"
Stress = "Stress"
Tired = "Tired"
# Afraid
Scared = "Scared"
Anxious = "Anxious"
Insecure = "Insecure"
Weak = "Weak"
Shaky = "Shaky"
Nervous = "Nervous"
# Angry
Mistrust = "Mistrust"
Shame = "Shame"
Jealous = "Jealous"
Mad = "Mad"
Irritation = "Irritation"
Frustration = "Frustration"
Distant = "Distant"
Critical = "Critical"
# Disgust
Disapproval = "Disapproval"
Disdain = "Disdain"
Sick = "Sick"
Repulsion = "Repulsion"
# Sad
Hurt = "Hurt"
Depression = "Depression"
Guilty = "Guilty"
Despair = "Despair"
Vulnerable = "Vulnerable"
Lonely = "Lonely"
# Happy
Hope = "Hope"
Trust = "Trust"
Care = "Care"
Powerful = "Powerful"
Acceptance = "Acceptance"
Proud = "Proud"
Curiosity = "Curiosity"
Content = "Content"
Playful = "Playful"
# Surprise
Excitement = "Excitement"
Amazement = "Amazement"
Confusion = "Confusion"
Shock = "Shock"
class TertiaryEmotion(str, enum.Enum):
"""Tier 3: Outer ring of the emotion wheel."""
# Bad > Boredom
Absent = "Absent"
Apathy = "Apathy"
# Bad > Busy
Buzzy = "Buzzy"
Pressure = "Pressure"
# Bad > Stress
Overwhelmed = "Overwhelmed"
Out_of_control = "Out of control"
# Bad > Tired
Sleepy = "Sleepy"
Blurry = "Blurry"
# Afraid > Scared
Helpless = "Helpless"
Fearful = "Fearful"
# Afraid > Anxious
Overwhelm = "Overwhelm"
Worry = "Worry"
# Afraid > Insecure
Small = "Small"
Inferior = "Inferior"
# Afraid > Weak
Hollow = "Hollow"
Empty = "Empty"
# Afraid > Shaky
Trembling = "Trembling"
Unstable = "Unstable"
# Afraid > Nervous
Tight = "Tight"
Vulnerable = "Vulnerable"
# Angry > Mistrust
Exhaustion = "Exhaustion"
Resentment = "Resentment"
# Angry > Shame
Humiliation = "Humiliation"
Embarrassment = "Embarrassment"
# Angry > Jealous
Indignant = "Indignant"
Bitter = "Bitter"
# Angry > Mad
Furious = "Furious"
Rage = "Rage"
# Angry > Irritation
Aggressive = "Aggressive"
Hostile = "Hostile"
# Angry > Frustration
Tense = "Tense"
Annoyance = "Annoyance"
# Angry > Distant
Withdrawn = "Withdrawn"
Numb = "Numb"
# Angry > Critical
Skeptical = "Skeptical"
Dismissive = "Dismissive"
# Disgust > Disapproval
Judgment = "Judgment"
Disgust_Shock = "Shock"
# Disgust > Disdain
Revulsion = "Revulsion"
Yucky = "Yucky"
# Disgust > Sick
Nausea = "Nausea"
Awful = "Awful"
# Disgust > Repulsion
Horror = "Horror"
Hesitance = "Hesitance"
# Sad > Hurt
Pain = "Pain"
Disappointment = "Disappointment"
# Sad > Depression
Heavy = "Heavy"
Weight = "Weight"
# Sad > Guilty
Remorseful = "Remorseful"
Guilt_Shame = "Shame"
# Sad > Despair
Powerless = "Powerless"
Grief = "Grief"
# Sad > Vulnerable
Fragile = "Fragile"
Fragile_Shaky = "Shaky"
# Sad > Lonely
Longing = "Longing"
Achy = "Achy"
# Happy > Hope
Inspiration = "Inspiration"
Openness = "Openness"
# Happy > Trust
Safety = "Safety"
Tenderness = "Tenderness"
# Happy > Care
Gratitude = "Gratitude"
Peaceful = "Peaceful"
# Happy > Powerful
Creative = "Creative"
Courageous = "Courageous"
# Happy > Acceptance
Importance = "Importance"
Respect = "Respect"
# Happy > Proud
Confident = "Confident"
Strong = "Strong"
# Happy > Curiosity
Willingness = "Willingness"
Interest = "Interest"
# Happy > Content
Joy = "Joy"
Free = "Free"
# Happy > Playful
Mischievous = "Mischievous"
Arousal = "Arousal"
# Surprise > Excitement
Energetic = "Energetic"
Eager = "Eager"
# Surprise > Amazement
Awe = "Awe"
Astonishment = "Astonishment"
# Surprise > Confusion
Dizzy = "Dizzy"
Unclear = "Unclear"
# Surprise > Shock
Dismay = "Dismay"
Uncomfortable = "Uncomfortable"
class UserRole(str, enum.Enum):
client = "client"
therapist = "therapist"
admin = "admin"
class TimeOfDay(str, enum.Enum):
morning = "morning"
afternoon = "afternoon"
evening = "evening"
night = "night"
class QuoteSource(str, enum.Enum):
generated = "generated"
curated = "curated"
class Reaction(str, enum.Enum):
helped = "helped"
missed = "missed"
saved = "saved"
class TherapistRole(str, enum.Enum):
therapist = "therapist"
admin = "admin"
class AlertType(str, enum.Enum):
low_mood_streak = "low_mood_streak"
high_intensity = "high_intensity"
no_checkin = "no_checkin" # client hasn't checked in for N days
class PushType(str, enum.Enum):
quote = "quote"
exercise = "exercise"
# ---------------------------------------------------------------------------
# ORM Models
# ---------------------------------------------------------------------------
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
name: Mapped[str | None] = mapped_column(String(100))
role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), default=UserRole.client, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
google_id: Mapped[str | None] = mapped_column(String(255), unique=True)
avatar_url: Mapped[str | None] = mapped_column(String(500))
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())
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user")
class Session(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
time_of_day: Mapped[TimeOfDay | None] = mapped_column(Enum(TimeOfDay, name="time_of_day"))
notes: 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="sessions")
emotion_logs: Mapped[list["EmotionLog"]] = relationship("EmotionLog", back_populates="session")
quote_deliveries: Mapped[list["QuoteDelivery"]] = relationship("QuoteDelivery", back_populates="session")
class EmotionLog(Base):
__tablename__ = "emotion_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False)
primary_emotion: Mapped[PrimaryEmotion] = mapped_column(Enum(PrimaryEmotion, name="primary_emotion"), nullable=False)
secondary_emotion: Mapped[SecondaryEmotion] = mapped_column(Enum(SecondaryEmotion, name="secondary_emotion"), nullable=False)
tertiary_emotion: Mapped[TertiaryEmotion | None] = mapped_column(Enum(TertiaryEmotion, name="tertiary_emotion"))
intensity: Mapped[int | None] = mapped_column(Integer, default=3)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
session: Mapped["Session"] = relationship("Session", back_populates="emotion_logs")
class Quote(Base):
__tablename__ = "quotes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
content: Mapped[str] = mapped_column(Text, nullable=False)
author: Mapped[str | None] = mapped_column(String(200))
source: Mapped[QuoteSource] = mapped_column(Enum(QuoteSource, name="quote_source"), default=QuoteSource.generated, nullable=False)
flagged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
tags: Mapped[list] = mapped_column(JSONB, default=list)
target_primary_emotions: Mapped[list] = mapped_column(JSONB, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
deliveries: Mapped[list["QuoteDelivery"]] = relationship("QuoteDelivery", back_populates="quote")
class QuoteDelivery(Base):
__tablename__ = "quote_deliveries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False)
quote_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("quotes.id", ondelete="SET NULL"))
prompt_snapshot: Mapped[str | None] = mapped_column(Text)
was_skipped: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
reaction: Mapped[Reaction | None] = mapped_column(Enum(Reaction, name="reaction"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
session: Mapped["Session"] = relationship("Session", back_populates="quote_deliveries")
quote: Mapped["Quote | None"] = relationship("Quote", back_populates="deliveries")
# ---------------------------------------------------------------------------
# Therapist β€” separate table, not a role flag on User.
# Keeps client and therapist auth concerns cleanly separated.
# ---------------------------------------------------------------------------
class Therapist(Base):
__tablename__ = "therapists"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True)
license_number: Mapped[str | None] = mapped_column(String(100))
bio: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User")
clients: Mapped[list["TherapistClient"]] = relationship("TherapistClient", back_populates="therapist")
quote_packs: Mapped[list["QuotePack"]] = relationship("QuotePack", back_populates="therapist")
pushes: Mapped[list["TherapistPush"]] = relationship("TherapistPush", back_populates="therapist")
alerts: Mapped[list["MoodAlert"]] = relationship("MoodAlert", back_populates="therapist")
# ---------------------------------------------------------------------------
# TherapistClient β€” join table with metadata
# ---------------------------------------------------------------------------
class TherapistClient(Base):
__tablename__ = "therapist_clients"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
therapist_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("therapists.id", ondelete="CASCADE"), nullable=False)
# therapist.user_id β†’ the therapist's own users.id
# client_id β†’ the client's users.id
# both are users, differentiated by users.role
client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
linked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
therapist: Mapped["Therapist"] = relationship("Therapist", back_populates="clients")
client: Mapped["User"] = relationship("User")
# Unique constraint β€” one therapist ↔ one client link, no duplicates
__table_args__ = (
UniqueConstraint("therapist_id", "client_id", name="uq_therapist_client"),
)
# ---------------------------------------------------------------------------
# QuotePack β€” therapist-curated sets (Story 4)
# ---------------------------------------------------------------------------
class QuotePack(Base):
__tablename__ = "quote_packs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
therapist_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("therapists.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False) # "Burnout Pack"
description: Mapped[str | None] = mapped_column(Text)
tags: Mapped[list] = mapped_column(JSONB, default=list) # ["anxiety", "burnout"]
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
therapist: Mapped["Therapist"] = relationship("Therapist", back_populates="quote_packs")
quotes: Mapped[list["QuotePackItem"]] = relationship("QuotePackItem", back_populates="pack")
class QuotePackItem(Base):
__tablename__ = "quote_pack_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
pack_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("quote_packs.id", ondelete="CASCADE"), nullable=False)
quote_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False)
order: Mapped[int | None] = mapped_column(Integer) # display order within pack
pack: Mapped["QuotePack"] = relationship("QuotePack", back_populates="quotes")
quote: Mapped["Quote"] = relationship("Quote")
# ---------------------------------------------------------------------------
# TherapistPush β€” therapist pushes quote/exercise to a client (Story 3)
# ---------------------------------------------------------------------------
class TherapistPush(Base):
__tablename__ = "therapist_pushes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
therapist_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("therapists.id", ondelete="CASCADE"), nullable=False)
client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
quote_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("quotes.id", ondelete="SET NULL"))
pack_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("quote_packs.id", ondelete="SET NULL"))
content: Mapped[str | None] = mapped_column(Text) # freeform if not from a pack
push_type: Mapped[PushType] = mapped_column(Enum(PushType, name="push_type"), nullable=False)
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reaction: Mapped[Reaction | None] = mapped_column(Enum(Reaction, name="reaction"))
therapist: Mapped["Therapist"] = relationship("Therapist", back_populates="pushes")
client: Mapped["User"] = relationship("User")
# ---------------------------------------------------------------------------
# MoodAlert β€” auto-generated when low mood threshold is crossed (Story 5)
# ---------------------------------------------------------------------------
class MoodAlert(Base):
__tablename__ = "mood_alerts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
therapist_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("therapists.id", ondelete="CASCADE"), nullable=False)
client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
alert_type: Mapped[AlertType] = mapped_column(Enum(AlertType, name="alert_type"), nullable=False)
reason: Mapped[str] = mapped_column(Text, nullable=False) # human-readable: "5/7 days: Sad, Afraid"
is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
triggered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
therapist: Mapped["Therapist"] = relationship("Therapist", back_populates="alerts")
client: Mapped["User"] = relationship("User")
# ---------------------------------------------------------------------------
# LinkRequest β€” client requesting to link with a therapist
# ---------------------------------------------------------------------------
class LinkRequest(Base):
__tablename__ = "link_requests"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
therapist_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("therapists.id", ondelete="CASCADE"), nullable=False)
client_email: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(20), default="pending")
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
therapist: Mapped["Therapist"] = relationship("Therapist")
resolver: Mapped["User | None"] = relationship("User", foreign_keys=[resolved_by])