File size: 10,376 Bytes
78738de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
"""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()