|
|
# backend/app/models.py
|
|
|
# 주의: `from __future__ import annotations` 를 쓰지 않는다 —
|
|
|
# SQLAlchemy 2.x 가 Relationship 의 list["Project"] 문자열 주석을 해석하지 못해
|
|
|
# 매퍼 초기화에 실패한다(ForwardRef 로 평가되도록 둔다).
|
|
|
from datetime import UTC, date, datetime
|
|
|
from enum import Enum
|
|
|
from typing import Optional
|
|
|
|
|
|
from sqlmodel import Field, Relationship, SQLModel
|
|
|
|
|
|
|
|
|
def now() -> datetime:
|
|
|
return datetime.now(UTC)
|
|
|
|
|
|
|
|
|
# ---------- enums (값은 CONTRACT 고정) ----------
|
|
|
class TaskStatus(str, Enum):
|
|
|
todo = "todo"
|
|
|
doing = "doing"
|
|
|
waiting = "waiting"
|
|
|
review = "review"
|
|
|
done = "done"
|
|
|
|
|
|
|
|
|
class Prio(str, Enum):
|
|
|
high = "높음"
|
|
|
normal = "보통"
|
|
|
low = "낮음"
|
|
|
|
|
|
|
|
|
class InboxKind(str, Enum):
|
|
|
text = "text"
|
|
|
voice = "voice"
|
|
|
image = "image"
|
|
|
|
|
|
|
|
|
class InboxStatus(str, Enum):
|
|
|
new = "new"
|
|
|
classified = "classified"
|
|
|
confirmed = "confirmed"
|
|
|
dismissed = "dismissed"
|
|
|
|
|
|
|
|
|
class ClsType(str, Enum):
|
|
|
task = "task"
|
|
|
event = "event"
|
|
|
idea = "idea"
|
|
|
|
|
|
|
|
|
class Sphere(str, Enum):
|
|
|
work = "work"
|
|
|
life = "life"
|
|
|
|
|
|
|
|
|
# ---------- core ----------
|
|
|
class Person(SQLModel, table=True):
|
|
|
__tablename__ = "person"
|
|
|
id: str = Field(primary_key=True) # 예: "jiwoo"
|
|
|
name: str
|
|
|
initial: str
|
|
|
color: str # 예: "var(--blue)" / "oklch(0.66 0.13 200)"
|
|
|
is_me: bool = False
|
|
|
|
|
|
|
|
|
class Folder(SQLModel, table=True):
|
|
|
__tablename__ = "folder"
|
|
|
id: str = Field(primary_key=True) # "work" / "life"
|
|
|
name: str # "업무" / "개인"
|
|
|
tone: str = "ink" # tone 집합: blue|violet|coral|green|amber|ink|faint
|
|
|
icon: str = "folder" # "folder" / "heart"
|
|
|
sort_order: int = 0
|
|
|
is_system: bool = True
|
|
|
|
|
|
projects: list["Project"] = Relationship(back_populates="folder")
|
|
|
|
|
|
|
|
|
class Project(SQLModel, table=True):
|
|
|
__tablename__ = "project"
|
|
|
id: str = Field(primary_key=True) # "biz", "biz-okr", ...
|
|
|
folder_id: str = Field(foreign_key="folder.id")
|
|
|
parent_id: Optional[str] = Field(default=None, foreign_key="project.id") # 무한 중첩
|
|
|
name: str
|
|
|
tone: str = "ink"
|
|
|
sort_order: int = 0
|
|
|
pinned: bool = False # 즐겨찾기
|
|
|
|
|
|
folder: Optional[Folder] = Relationship(back_populates="projects")
|
|
|
parent: Optional["Project"] = Relationship(
|
|
|
back_populates="children",
|
|
|
sa_relationship_kwargs={"remote_side": "Project.id"},
|
|
|
)
|
|
|
children: list["Project"] = Relationship(back_populates="parent")
|
|
|
tasks: list["Task"] = Relationship(back_populates="project")
|
|
|
|
|
|
|
|
|
class Task(SQLModel, table=True):
|
|
|
__tablename__ = "task"
|
|
|
id: str = Field(primary_key=True) # "k1", "kx3", ...
|
|
|
project_id: str = Field(foreign_key="project.id")
|
|
|
parent_id: Optional[str] = Field(default=None, foreign_key="task.id") # 무한 중첩 하위작업
|
|
|
title: str
|
|
|
status: TaskStatus = TaskStatus.todo
|
|
|
assignee_id: Optional[str] = Field(default=None, foreign_key="person.id")
|
|
|
due: Optional[date] = None # 시드는 "06-08" → 2026-06-08 로 적재
|
|
|
prio: Prio = Prio.normal
|
|
|
notes: str = "" # HTML 허용
|
|
|
est: str = ""
|
|
|
delegated: bool = False
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
updated_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
project: Optional[Project] = Relationship(back_populates="tasks")
|
|
|
parent: Optional["Task"] = Relationship(
|
|
|
back_populates="children",
|
|
|
sa_relationship_kwargs={"remote_side": "Task.id"},
|
|
|
)
|
|
|
children: list["Task"] = Relationship(back_populates="parent")
|
|
|
comments: list["TaskComment"] = Relationship(back_populates="task")
|
|
|
assignee: Optional[Person] = Relationship()
|
|
|
|
|
|
|
|
|
class TaskComment(SQLModel, table=True):
|
|
|
__tablename__ = "task_comment"
|
|
|
id: str = Field(primary_key=True) # TEXT PK 예: "c1"
|
|
|
task_id: str = Field(foreign_key="task.id")
|
|
|
person_id: str = Field(foreign_key="person.id")
|
|
|
text: str
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
task: Optional[Task] = Relationship(back_populates="comments")
|
|
|
person: Optional[Person] = Relationship()
|
|
|
|
|
|
|
|
|
# ---------- inbox (연합의 핵심) ----------
|
|
|
class InboxItem(SQLModel, table=True):
|
|
|
__tablename__ = "inbox_item"
|
|
|
id: str = Field(primary_key=True) # "s1", ...
|
|
|
kind: InboxKind = InboxKind.text
|
|
|
raw: str
|
|
|
status: InboxStatus = InboxStatus.new
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
materialized_task_id: Optional[str] = Field(default=None, foreign_key="task.id")
|
|
|
|
|
|
classifications: list["InboxClassification"] = Relationship(back_populates="item")
|
|
|
|
|
|
|
|
|
class InboxClassification(SQLModel, table=True):
|
|
|
__tablename__ = "inbox_classification"
|
|
|
id: str = Field(primary_key=True) # TEXT PK 예: "cls1"
|
|
|
inbox_item_id: str = Field(foreign_key="inbox_item.id") # 1:1 최신
|
|
|
type: ClsType
|
|
|
sphere: Sphere
|
|
|
project_id: Optional[str] = Field(default=None, foreign_key="project.id")
|
|
|
proj_label: str = "" # 예: "개인 › 여행 — 한국"
|
|
|
tone: str = "ink"
|
|
|
due_text: str = "" # 예: "출발 전 · ~6/14"
|
|
|
when_text: str = "" # 예: "오늘 21:00 빈 시간 추천"
|
|
|
extra: str = "" # 예: "가격 추적 알림 켜둠"
|
|
|
reason: str = "" # 사람이 읽는 한국어 설명
|
|
|
confidence: float = 0.0
|
|
|
model: str = "" # 사용된 provider/model 표기
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
item: Optional[InboxItem] = Relationship(back_populates="classifications")
|
|
|
|
|
|
|
|
|
# ---------- 대시보드 읽기전용 시드 ----------
|
|
|
class Event(SQLModel, table=True):
|
|
|
__tablename__ = "event"
|
|
|
id: str = Field(primary_key=True) # TEXT PK 예: "e1"
|
|
|
time: str
|
|
|
title: str
|
|
|
tag: str = ""
|
|
|
dur: str = ""
|
|
|
tone: str = "ink" # tone 키 값('blue' 등). 'var(--blue)' 저장 금지
|
|
|
soon: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class Approval(SQLModel, table=True):
|
|
|
__tablename__ = "approval"
|
|
|
id: str = Field(primary_key=True) # "a1", ...
|
|
|
icon: str
|
|
|
tone: str
|
|
|
risk: str # "low" | "high"
|
|
|
time: str
|
|
|
title: str
|
|
|
detail: str = ""
|
|
|
cta: str = ""
|
|
|
alt: str = ""
|
|
|
undo_label: str = ""
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class Goal(SQLModel, table=True):
|
|
|
__tablename__ = "goal"
|
|
|
id: str = Field(primary_key=True) # TEXT PK 예: "g1"
|
|
|
title: str
|
|
|
pct: int
|
|
|
sub: str = ""
|
|
|
tone: str = "blue" # tone 키 값('blue' 등). 'var(--blue)' 저장 금지
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class Briefing(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 아침 브리핑 + 날씨/출근/수면 + saved_today/today_routed."""
|
|
|
|
|
|
__tablename__ = "briefing"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True) # 단일 row id=1
|
|
|
today: str = "" # 히어로 날짜 라벨 (예: "6월 7일 일요일")
|
|
|
weather_temp: int = 0 # 현재 기온 (예: 24)
|
|
|
weather_cond: str = "" # "맑음 · 한낮 28°" — "한낮 28°"는 일 최고기온
|
|
|
weather_icon: str = "cloudSun" # 'cloudSun' 저장 → 표시 시 'sun' 으로 매핑
|
|
|
commute: str = ""
|
|
|
sleep: str = ""
|
|
|
note: str = "" # HTML 허용
|
|
|
saved_today: str = "" # "47분"
|
|
|
today_routed: int = 0 # 7
|