poolcoach / app /db.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
10.4 kB
"""Tầng DB của app — engine + session + 4 model nền móng (BRIEF 04/08/2026)
+ `drill_attempts` (F3 MVP, bàn giao 11 cùng ngày).
Nguyên tắc thiết kế (không phải fallback tuỳ chọn): **không set `DATABASE_URL`
→ DB tắt hẳn** — `enabled()` False, app boot và chạy Y HỆT bản không DB. Lý do:
HF Space rebuild từ `main` bất kỳ lúc nào và KHÔNG có Postgres; mọi thứ ở đây
phải vô hình với Space cho tới khi có DB thật.
Module này cố ý KHÔNG import `app.main` hay `poolcoach_rl`: alembic/env.py và
scripts/seed_db.py import nó để lấy metadata/models, và không được vì thế mà
kéo cả pooltool (~40s JIT) vào một lệnh migrate.
Schema rút gọn từ FullVision §7 — 4 bảng nền móng + `drill_attempts`. Mọi bảng thuộc
quán mang `tenant_id` ngay từ đầu (self-host = 1 tenant, không rẽ nhánh code
về sau — FullVision §6). RLS/users/billing chưa làm.
Kiểu cột giữ generic (`sa.JSON`, `DateTime` naive-UTC) để CÙNG schema chạy
được cả Postgres (dev thật) lẫn SQLite in-memory (pytest, không cần Docker).
"""
from __future__ import annotations
import os
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Iterator, Optional
import sqlalchemy as sa
from sqlalchemy.orm import (DeclarativeBase, Mapped, Session, mapped_column,
sessionmaker)
from sqlalchemy.pool import StaticPool
def utcnow() -> datetime:
"""UTC **naive** — chuẩn thời gian duy nhất của tầng DB.
SQLite trả datetime naive kể cả khi lưu aware; nếu code lúc so sánh aware
lúc naive thì `expires_at < now` sẽ ném TypeError tuỳ backend. Chọn một
chuẩn và giữ nguyên: cột DateTime không timezone, giá trị luôn UTC.
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
# ------------------------------------------------------------------ models
class Base(DeclarativeBase):
pass
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(sa.String(120))
created_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
class Table(Base):
"""Bàn bi-a vật lý trong quán (không phải bảng DB)."""
__tablename__ = "tables"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[int] = mapped_column(sa.ForeignKey("tenants.id"))
name: Mapped[str] = mapped_column(sa.String(120))
status: Mapped[str] = mapped_column(sa.String(32), default="active")
created_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
class GuestSession(Base):
"""Phiên khách quét QR ở bàn — id là uuid4 (bearer id, phải không đoán được)."""
__tablename__ = "guest_sessions"
id: Mapped[str] = mapped_column(sa.String(36), primary_key=True,
default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[int] = mapped_column(sa.ForeignKey("tenants.id"))
table_id: Mapped[int] = mapped_column(sa.ForeignKey("tables.id"))
created_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
expires_at: Mapped[datetime] = mapped_column(sa.DateTime())
class Recommendation(Base):
"""Log một lần `/api/recommend` — ghi best-effort, đọc để phân tích sau.
`tenant_id` nullable: request hiện chưa mang phiên/tenant (QR session chưa
nối vào recommend — BRIEF sau); log nặc danh vẫn có giá trị phân tích.
"""
__tablename__ = "recommendations"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[Optional[int]] = mapped_column(
sa.ForeignKey("tenants.id"), nullable=True)
# Text tự do, KHÔNG ForeignKey CÓ CHỦ ĐÍCH: bảng `scans` thuộc BRIEF F2
# sau — FK bây giờ sẽ chặn client gửi scan_id trước khi bảng đó tồn tại.
scan_id: Mapped[Optional[str]] = mapped_column(sa.Text, nullable=True)
balls: Mapped[dict] = mapped_column(sa.JSON)
edited: Mapped[Optional[bool]] = mapped_column(sa.Boolean, nullable=True)
engine_ver: Mapped[str] = mapped_column(sa.String(64))
shot: Mapped[Optional[dict]] = mapped_column(sa.JSON, nullable=True)
alternatives: Mapped[Optional[list]] = mapped_column(sa.JSON, nullable=True)
latency_ms: Mapped[int] = mapped_column(sa.Integer)
created_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
class DrillAttempt(Base):
"""Một lượt tập drill TỰ KHAI đạt/trượt (F3 MVP degraded — chưa có CV chấm).
`drill_id` là TEXT tự do, KHÔNG ForeignKey CÓ CHỦ ĐÍCH: drill chuẩn nằm
trong file JSON của repo (`app/drills.json`), không có bảng `drills` —
bảng đó (tenant/HLV soạn) thuộc BRIEF sau khi có auth. `guest_session_id`
cũng KHÔNG FK: guest session TTL 24h và có thể bị dọn — lượt tập phải
sống lâu hơn phiên sinh ra nó (cùng nếp `scan_id` của recommendations).
`scan_before`/`scan_after` CHƯA có — chấm bằng CV là F2, BRIEF sau;
`detail` JSON tự do để client ghi chú (ví dụ số rep, cảm nhận).
"""
__tablename__ = "drill_attempts"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[Optional[int]] = mapped_column(
sa.ForeignKey("tenants.id"), nullable=True)
guest_session_id: Mapped[Optional[str]] = mapped_column(
sa.String(36), nullable=True)
drill_id: Mapped[str] = mapped_column(sa.Text)
result: Mapped[str] = mapped_column(sa.String(8)) # "pass" | "fail"
detail: Mapped[Optional[dict]] = mapped_column(sa.JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
class AnalyzerVideo(Base):
"""Một video đã nạp vào Analyzer đa cú (lát A1, 13/08/2026).
`id` là uuid hex do route sinh (cùng chuỗi làm tên thư mục kết quả
`data/analyzer/{id}/`). Nguồn sự thật của KẾT QUẢ là file trên đĩa
(shots.json + shot JSON per cú — nếp BG31 sau vụ mất kết quả Redis
TTL BG29b); DB là sổ ghi DANH SÁCH để tra cứu/phân tích sau, ghi
best-effort như `recommendations` — DB tắt/hỏng không chặn luồng.
"""
__tablename__ = "analyzer_videos"
id: Mapped[str] = mapped_column(sa.String(32), primary_key=True)
tenant_id: Mapped[Optional[int]] = mapped_column(
sa.ForeignKey("tenants.id"), nullable=True)
filename: Mapped[Optional[str]] = mapped_column(sa.Text, nullable=True)
status: Mapped[str] = mapped_column(sa.String(16), default="queued")
n_shots: Mapped[Optional[int]] = mapped_column(sa.Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
class AnalyzerShot(Base):
"""Một cú máy cắt được từ video (lát A1). `video_id` KHÔNG ForeignKey
CÓ CHỦ ĐÍCH (nếp `scan_id`/`drill_id`): row video ghi best-effort có
thể vắng mặt mà danh sách cú vẫn phải ghi được. `result` là JSON kết
quả phân tích RÚT GỌN (tham số + tin cậy, không track điểm — track đầy
đủ nằm file trên đĩa, cùng lý do trajectories không vào log
recommendations)."""
__tablename__ = "analyzer_shots"
id: Mapped[int] = mapped_column(primary_key=True)
video_id: Mapped[str] = mapped_column(sa.String(32), index=True)
shot_idx: Mapped[int] = mapped_column(sa.Integer)
t_start_s: Mapped[Optional[float]] = mapped_column(sa.Float, nullable=True)
t_end_s: Mapped[Optional[float]] = mapped_column(sa.Float, nullable=True)
status: Mapped[str] = mapped_column(sa.String(16), default="queued")
reason: Mapped[Optional[str]] = mapped_column(sa.Text, nullable=True)
result: Mapped[Optional[dict]] = mapped_column(sa.JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(sa.DateTime(), default=utcnow)
# --------------------------------------------------------- engine + session
_engine: sa.Engine | None = None
_session_factory: sessionmaker | None = None
def setup(url: str | None = None) -> bool:
"""Khởi tạo engine từ `url` (test) hoặc env `DATABASE_URL` (chạy thật).
Trả về True nếu DB bật. Gọi lại được (idempotent) — engine cũ dispose
trước khi tạo mới, nên test fixture setup/teardown thoải mái.
"""
teardown()
url = url or os.environ.get("DATABASE_URL")
if not url:
return False
global _engine, _session_factory
kwargs: dict = {}
if url.startswith("sqlite"):
# TestClient chạy endpoint sync trong threadpool → connection SQLite
# bị dùng từ thread khác thread tạo nó.
kwargs["connect_args"] = {"check_same_thread": False}
if ":memory:" in url:
# In-memory: mỗi connection MỚI là một DB rỗng mới — ép mọi
# session dùng chung đúng một connection.
kwargs["poolclass"] = StaticPool
_engine = sa.create_engine(url, **kwargs)
_session_factory = sessionmaker(bind=_engine, expire_on_commit=False)
return True
def teardown() -> None:
"""Tắt DB (dispose engine). Sau đó `enabled()` False."""
global _engine, _session_factory
if _engine is not None:
_engine.dispose()
_engine = None
_session_factory = None
def enabled() -> bool:
return _session_factory is not None
def get_engine() -> sa.Engine | None:
return _engine
@contextmanager
def session() -> Iterator[Session]:
"""Session commit-hoặc-rollback. Gọi khi DB tắt là bug của caller —
caller phải check `enabled()` trước (route trả 503, log thì bỏ qua)."""
if _session_factory is None:
raise RuntimeError("DB chưa bật — thiếu DATABASE_URL (check enabled() trước)")
s = _session_factory()
try:
yield s
s.commit()
except Exception:
s.rollback()
raise
finally:
s.close()