added RAG, Multiuser, TG bot
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.db.base import engine
|
||||
|
||||
|
||||
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
|
||||
inspector = inspect(engine)
|
||||
if table not in inspector.get_table_names():
|
||||
return
|
||||
columns = {col["name"] for col in inspector.get_columns(table)}
|
||||
if column in columns:
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text(ddl))
|
||||
|
||||
|
||||
def run_fitness_migrations() -> None:
|
||||
inspector = inspect(engine)
|
||||
|
||||
if "fitness_profiles" in inspector.get_table_names():
|
||||
_add_column_if_missing(
|
||||
"fitness_profiles",
|
||||
"baseline_steps",
|
||||
"ALTER TABLE fitness_profiles ADD COLUMN baseline_steps INTEGER",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"fitness_profiles",
|
||||
"baseline_workout_kcal",
|
||||
"ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT",
|
||||
)
|
||||
|
||||
if "workout_logs" in inspector.get_table_names():
|
||||
_add_column_if_missing(
|
||||
"workout_logs",
|
||||
"active_calories",
|
||||
"ALTER TABLE workout_logs ADD COLUMN active_calories FLOAT",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"workout_logs",
|
||||
"total_calories",
|
||||
"ALTER TABLE workout_logs ADD COLUMN total_calories FLOAT",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"workout_logs",
|
||||
"steps",
|
||||
"ALTER TABLE workout_logs ADD COLUMN steps INTEGER",
|
||||
)
|
||||
|
||||
if "step_logs" not in inspector.get_table_names():
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE step_logs ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
||||
"steps INTEGER DEFAULT 0, "
|
||||
"active_calories FLOAT, "
|
||||
"source VARCHAR(32) DEFAULT 'manual', "
|
||||
"notes TEXT DEFAULT ''"
|
||||
")"
|
||||
)
|
||||
)
|
||||
|
||||
if "body_metrics" in inspector.get_table_names():
|
||||
_add_column_if_missing(
|
||||
"body_metrics",
|
||||
"neck_cm",
|
||||
"ALTER TABLE body_metrics ADD COLUMN neck_cm FLOAT",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"body_metrics",
|
||||
"hip_cm",
|
||||
"ALTER TABLE body_metrics ADD COLUMN hip_cm FLOAT",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"body_metrics",
|
||||
"body_fat_method",
|
||||
"ALTER TABLE body_metrics ADD COLUMN body_fat_method VARCHAR(16)",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"body_metrics",
|
||||
"whr",
|
||||
"ALTER TABLE body_metrics ADD COLUMN whr FLOAT",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"body_metrics",
|
||||
"lbm_kg",
|
||||
"ALTER TABLE body_metrics ADD COLUMN lbm_kg FLOAT",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
"body_metrics",
|
||||
"ffmi",
|
||||
"ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT",
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.auth.tokens import hash_token
|
||||
from app.character.card import DEFAULT_CARD, normalize_card
|
||||
from app.config import get_settings
|
||||
from app.db.base import engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TENANT_TABLES = (
|
||||
"chat_sessions",
|
||||
"user_profile",
|
||||
"memory_facts",
|
||||
"fitness_profiles",
|
||||
"body_metrics",
|
||||
"food_logs",
|
||||
"water_logs",
|
||||
"workout_logs",
|
||||
"step_logs",
|
||||
"fitness_reminders",
|
||||
"shopping_lists",
|
||||
"reminders",
|
||||
"documents",
|
||||
"pomodoro_cycles",
|
||||
"pomodoro_sessions",
|
||||
"project_bindings",
|
||||
"work_items",
|
||||
)
|
||||
|
||||
LEGACY_CARD_PATH = Path("./data/character.json")
|
||||
|
||||
|
||||
def _table_exists(name: str) -> bool:
|
||||
return name in inspect(engine).get_table_names()
|
||||
|
||||
|
||||
def _columns(table: str) -> set[str]:
|
||||
if not _table_exists(table):
|
||||
return set()
|
||||
return {col["name"] for col in inspect(engine).get_columns(table)}
|
||||
|
||||
|
||||
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
|
||||
if column in _columns(table):
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text(ddl))
|
||||
|
||||
|
||||
def _ensure_users_table() -> None:
|
||||
if _table_exists("users"):
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE users ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"username VARCHAR(64) NOT NULL UNIQUE, "
|
||||
"display_name VARCHAR(255) DEFAULT '', "
|
||||
"api_token_hash VARCHAR(64) NOT NULL, "
|
||||
"is_active BOOLEAN DEFAULT 1, "
|
||||
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
||||
")"
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_api_token_hash ON users (api_token_hash)"))
|
||||
|
||||
|
||||
def _ensure_character_cards_table() -> None:
|
||||
if _table_exists("character_cards"):
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE character_cards ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, "
|
||||
"card_json TEXT DEFAULT '{}', "
|
||||
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
||||
")"
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_character_cards_user_id ON character_cards (user_id)"))
|
||||
|
||||
|
||||
def _add_user_id_columns() -> None:
|
||||
for table in TENANT_TABLES:
|
||||
if not _table_exists(table):
|
||||
continue
|
||||
_add_column_if_missing(
|
||||
table,
|
||||
"user_id",
|
||||
f"ALTER TABLE {table} ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
|
||||
)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)"))
|
||||
|
||||
|
||||
def _ensure_default_user() -> tuple[int, str | None]:
|
||||
settings = get_settings()
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("SELECT id FROM users ORDER BY id LIMIT 1")).fetchone()
|
||||
if row:
|
||||
return int(row[0]), None
|
||||
|
||||
username = settings.default_user_username or "owner"
|
||||
display_name = settings.default_user_display_name or username
|
||||
plain_token = (settings.default_api_token or "").strip()
|
||||
generated = False
|
||||
if not plain_token:
|
||||
plain_token = secrets.token_urlsafe(32)
|
||||
generated = True
|
||||
|
||||
token_hash = hash_token(plain_token)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO users (id, username, display_name, api_token_hash, is_active) "
|
||||
"VALUES (1, :username, :display_name, :token_hash, 1)"
|
||||
),
|
||||
{"username": username, "display_name": display_name, "token_hash": token_hash},
|
||||
)
|
||||
if generated:
|
||||
logger.warning(
|
||||
"DEFAULT_API_TOKEN not set — generated token for user '%s': %s",
|
||||
username,
|
||||
plain_token,
|
||||
)
|
||||
return 1, plain_token
|
||||
return 1, None
|
||||
|
||||
|
||||
def _backfill_user_id(default_user_id: int = 1) -> None:
|
||||
with engine.begin() as conn:
|
||||
for table in TENANT_TABLES:
|
||||
if not _table_exists(table):
|
||||
continue
|
||||
conn.execute(
|
||||
text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"),
|
||||
{"uid": default_user_id},
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_shopping_unique() -> None:
|
||||
if not _table_exists("shopping_lists"):
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_shopping_lists_user_name ON shopping_lists (user_id, name)"))
|
||||
|
||||
|
||||
def _rebuild_project_bindings_unique() -> None:
|
||||
if not _table_exists("project_bindings"):
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_project_bindings_user_slug "
|
||||
"ON project_bindings (user_id, taiga_slug)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _import_character_card(user_id: int) -> None:
|
||||
with engine.begin() as conn:
|
||||
existing = conn.execute(
|
||||
text("SELECT id FROM character_cards WHERE user_id = :uid"),
|
||||
{"uid": user_id},
|
||||
).fetchone()
|
||||
if existing:
|
||||
return
|
||||
|
||||
card = normalize_card(DEFAULT_CARD)
|
||||
if LEGACY_CARD_PATH.is_file():
|
||||
try:
|
||||
raw = json.loads(LEGACY_CARD_PATH.read_text(encoding="utf-8"))
|
||||
card = normalize_card(raw)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
conn.execute(
|
||||
text("INSERT INTO character_cards (user_id, card_json) VALUES (:uid, :json)"),
|
||||
{"uid": user_id, "json": json.dumps(card, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_qdrant_user_id(default_user_id: int = 1) -> None:
|
||||
settings = get_settings()
|
||||
if not settings.rag_enabled:
|
||||
return
|
||||
try:
|
||||
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, COLLECTION_SUMMARIES, _client
|
||||
except Exception:
|
||||
logger.exception("Qdrant backfill skipped")
|
||||
return
|
||||
|
||||
try:
|
||||
client = _client()
|
||||
except Exception:
|
||||
logger.warning('Qdrant unavailable, skipping user_id backfill')
|
||||
return
|
||||
|
||||
for collection in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
|
||||
try:
|
||||
if not client.collection_exists(collection):
|
||||
continue
|
||||
except Exception:
|
||||
logger.warning('Qdrant unavailable for collection %s', collection)
|
||||
continue
|
||||
offset = None
|
||||
while True:
|
||||
points, offset = client.scroll(
|
||||
collection_name=collection,
|
||||
limit=100,
|
||||
offset=offset,
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
)
|
||||
if not points:
|
||||
break
|
||||
missing = [point.id for point in points if (point.payload or {}).get("user_id") is None]
|
||||
if missing:
|
||||
client.set_payload(
|
||||
collection_name=collection,
|
||||
payload={"user_id": default_user_id},
|
||||
points=missing,
|
||||
)
|
||||
if offset is None:
|
||||
break
|
||||
logger.info("Qdrant user_id backfill completed for user_id=%s", default_user_id)
|
||||
|
||||
|
||||
def run_multi_user_migrations() -> str | None:
|
||||
"""Returns newly generated API token if any."""
|
||||
_ensure_users_table()
|
||||
_ensure_character_cards_table()
|
||||
_add_user_id_columns()
|
||||
user_id, new_token = _ensure_default_user()
|
||||
_backfill_user_id(user_id)
|
||||
_rebuild_shopping_unique()
|
||||
_rebuild_project_bindings_unique()
|
||||
_import_character_card(user_id)
|
||||
_backfill_qdrant_user_id(user_id)
|
||||
return new_token
|
||||
+396
-299
@@ -1,299 +1,396 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
||||
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()
|
||||
)
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(
|
||||
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
|
||||
role: Mapped[str] = mapped_column(String(32))
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class PomodoroCycle(Base):
|
||||
__tablename__ = "pomodoro_cycles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
||||
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
||||
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
|
||||
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class PomodoroSession(Base):
|
||||
__tablename__ = "pomodoro_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class TaigaProject(Base):
|
||||
__tablename__ = "taiga_projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ProjectBinding(Base):
|
||||
__tablename__ = "project_bindings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profile"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class MemoryFact(Base):
|
||||
__tablename__ = "memory_facts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(32), default="user")
|
||||
session_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
importance: Mapped[int] = mapped_column(Integer, default=3)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=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 SessionSummary(Base):
|
||||
__tablename__ = "session_summaries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class FitnessProfile(Base):
|
||||
__tablename__ = "fitness_profiles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
sex: Mapped[str] = mapped_column(String(16), default="male")
|
||||
age: Mapped[int] = mapped_column(Integer, default=30)
|
||||
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
||||
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
|
||||
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
|
||||
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
|
||||
water_l: Mapped[float] = mapped_column(Float, default=2.5)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class BodyMetric(Base):
|
||||
__tablename__ = "body_metrics"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
weight_kg: Mapped[float] = mapped_column(Float)
|
||||
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class FoodLog(Base):
|
||||
__tablename__ = "food_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
calories: Mapped[float] = mapped_column(Float, default=0)
|
||||
protein_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
fat_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
carbs_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
source: Mapped[str] = mapped_column(String(32), default="llm")
|
||||
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class WaterLog(Base):
|
||||
__tablename__ = "water_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class WorkoutLog(Base):
|
||||
__tablename__ = "workout_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
|
||||
|
||||
class FitnessReminder(Base):
|
||||
__tablename__ = "fitness_reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kind: Mapped[str] = mapped_column(String(32))
|
||||
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ShoppingList(Base):
|
||||
__tablename__ = "shopping_lists"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
sort_order: 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()
|
||||
)
|
||||
|
||||
items: Mapped[list["ShoppingListItem"]] = relationship(
|
||||
back_populates="shopping_list",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
|
||||
)
|
||||
|
||||
|
||||
class ShoppingListItem(Base):
|
||||
__tablename__ = "shopping_list_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
list_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
text: Mapped[str] = mapped_column(String(500))
|
||||
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
unit: Mapped[str] = mapped_column(String(64), default="")
|
||||
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
|
||||
|
||||
|
||||
class Reminder(Base):
|
||||
__tablename__ = "reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
recurrence: Mapped[str] = mapped_column(String(16), default="none")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
|
||||
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 AssistantState(Base):
|
||||
__tablename__ = "assistant_state"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WorkItem(Base):
|
||||
__tablename__ = "work_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
||||
raw_text: Mapped[str] = mapped_column(Text, default="")
|
||||
title: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
api_token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class CharacterCard(Base):
|
||||
__tablename__ = "character_cards"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
card_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
||||
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()
|
||||
)
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(
|
||||
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
|
||||
role: Mapped[str] = mapped_column(String(32))
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class PomodoroCycle(Base):
|
||||
__tablename__ = "pomodoro_cycles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
||||
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
||||
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
|
||||
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class PomodoroSession(Base):
|
||||
__tablename__ = "pomodoro_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class TaigaProject(Base):
|
||||
__tablename__ = "taiga_projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ProjectBinding(Base):
|
||||
__tablename__ = "project_bindings"
|
||||
__table_args__ = (UniqueConstraint("user_id", "taiga_slug", name="uq_project_bindings_user_slug"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profile"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class MemoryFact(Base):
|
||||
__tablename__ = "memory_facts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(32), default="user")
|
||||
session_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
importance: Mapped[int] = mapped_column(Integer, default=3)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=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 SessionSummary(Base):
|
||||
__tablename__ = "session_summaries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class FitnessProfile(Base):
|
||||
__tablename__ = "fitness_profiles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
sex: Mapped[str] = mapped_column(String(16), default="male")
|
||||
age: Mapped[int] = mapped_column(Integer, default=30)
|
||||
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
||||
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
|
||||
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
|
||||
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||
baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
|
||||
water_l: Mapped[float] = mapped_column(Float, default=2.5)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class BodyMetric(Base):
|
||||
__tablename__ = "body_metrics"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
weight_kg: Mapped[float] = mapped_column(Float)
|
||||
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
body_fat_method: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
neck_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
hip_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
whr: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
lbm_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
ffmi: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class FoodLog(Base):
|
||||
__tablename__ = "food_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
calories: Mapped[float] = mapped_column(Float, default=0)
|
||||
protein_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
fat_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
carbs_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
source: Mapped[str] = mapped_column(String(32), default="llm")
|
||||
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class StepLog(Base):
|
||||
__tablename__ = "step_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
steps: Mapped[int] = mapped_column(Integer, default=0)
|
||||
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(32), default="manual")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class WaterLog(Base):
|
||||
__tablename__ = "water_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class WorkoutLog(Base):
|
||||
__tablename__ = "workout_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
total_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
|
||||
|
||||
class FitnessReminder(Base):
|
||||
__tablename__ = "fitness_reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
kind: Mapped[str] = mapped_column(String(32))
|
||||
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ShoppingList(Base):
|
||||
__tablename__ = "shopping_lists"
|
||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_shopping_lists_user_name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
sort_order: 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()
|
||||
)
|
||||
|
||||
items: Mapped[list["ShoppingListItem"]] = relationship(
|
||||
back_populates="shopping_list",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
|
||||
)
|
||||
|
||||
|
||||
class ShoppingListItem(Base):
|
||||
__tablename__ = "shopping_list_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
list_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
text: Mapped[str] = mapped_column(String(500))
|
||||
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
unit: Mapped[str] = mapped_column(String(64), default="")
|
||||
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
|
||||
|
||||
|
||||
class Reminder(Base):
|
||||
__tablename__ = "reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
recurrence: Mapped[str] = mapped_column(String(16), default="none")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
|
||||
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 AssistantState(Base):
|
||||
__tablename__ = "assistant_state"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WorkItem(Base):
|
||||
__tablename__ = "work_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
||||
raw_text: Mapped[str] = mapped_column(Text, default="")
|
||||
title: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), default="")
|
||||
filename: Mapped[str] = mapped_column(String(255), default="")
|
||||
content_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
chunks: Mapped[list["DocumentChunk"]] = relationship(
|
||||
back_populates="document",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="DocumentChunk.chunk_index",
|
||||
)
|
||||
|
||||
|
||||
class DocumentChunk(Base):
|
||||
__tablename__ = "document_chunks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
document_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("documents.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
document: Mapped["Document"] = relationship(back_populates="chunks")
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
||||
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()
|
||||
)
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(
|
||||
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
|
||||
role: Mapped[str] = mapped_column(String(32))
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class PomodoroCycle(Base):
|
||||
__tablename__ = "pomodoro_cycles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
||||
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
||||
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
|
||||
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class PomodoroSession(Base):
|
||||
__tablename__ = "pomodoro_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class TaigaProject(Base):
|
||||
__tablename__ = "taiga_projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ProjectBinding(Base):
|
||||
__tablename__ = "project_bindings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profile"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class MemoryFact(Base):
|
||||
__tablename__ = "memory_facts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(32), default="user")
|
||||
session_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
importance: Mapped[int] = mapped_column(Integer, default=3)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=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 SessionSummary(Base):
|
||||
__tablename__ = "session_summaries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class FitnessProfile(Base):
|
||||
__tablename__ = "fitness_profiles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
sex: Mapped[str] = mapped_column(String(16), default="male")
|
||||
age: Mapped[int] = mapped_column(Integer, default=30)
|
||||
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
||||
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
|
||||
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
|
||||
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
|
||||
water_l: Mapped[float] = mapped_column(Float, default=2.5)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class BodyMetric(Base):
|
||||
__tablename__ = "body_metrics"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
weight_kg: Mapped[float] = mapped_column(Float)
|
||||
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class FoodLog(Base):
|
||||
__tablename__ = "food_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
calories: Mapped[float] = mapped_column(Float, default=0)
|
||||
protein_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
fat_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
carbs_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
source: Mapped[str] = mapped_column(String(32), default="llm")
|
||||
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class WaterLog(Base):
|
||||
__tablename__ = "water_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class WorkoutLog(Base):
|
||||
__tablename__ = "workout_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
|
||||
|
||||
class FitnessReminder(Base):
|
||||
__tablename__ = "fitness_reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kind: Mapped[str] = mapped_column(String(32))
|
||||
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ShoppingList(Base):
|
||||
__tablename__ = "shopping_lists"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
sort_order: 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()
|
||||
)
|
||||
|
||||
items: Mapped[list["ShoppingListItem"]] = relationship(
|
||||
back_populates="shopping_list",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
|
||||
)
|
||||
|
||||
|
||||
class ShoppingListItem(Base):
|
||||
__tablename__ = "shopping_list_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
list_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
text: Mapped[str] = mapped_column(String(500))
|
||||
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
unit: Mapped[str] = mapped_column(String(64), default="")
|
||||
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
|
||||
|
||||
|
||||
class Reminder(Base):
|
||||
__tablename__ = "reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
recurrence: Mapped[str] = mapped_column(String(16), default="none")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
|
||||
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 AssistantState(Base):
|
||||
__tablename__ = "assistant_state"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WorkItem(Base):
|
||||
__tablename__ = "work_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
||||
raw_text: Mapped[str] = mapped_column(Text, default="")
|
||||
title: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
Reference in New Issue
Block a user