Spaces:
Sleeping
Sleeping
| """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 | |
| 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() | |