File size: 10,189 Bytes
a3e8661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c07d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3e8661
 
8c07d66
a3e8661
 
 
 
 
8c07d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3e8661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb96ae5
 
 
 
 
 
a3e8661
 
 
 
 
 
 
 
 
 
 
 
 
f3a26a6
 
 
 
a3e8661
 
 
 
 
8c07d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3a26a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3e8661
 
 
 
8c07d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3e8661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb96ae5
 
 
 
a3e8661
f3a26a6
 
 
 
 
 
a3e8661
 
 
 
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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""SQLAlchemy database models and session factory for the Voice Training app.

Tables
------
users   – registered accounts with hashed passwords
results – GRBAS analysis results linked to a user
"""

import os
from datetime import datetime

from sqlalchemy import (
    Boolean, Column, DateTime, ForeignKey, Integer, String, Text, create_engine,
)
from sqlalchemy.orm import declarative_base, relationship, sessionmaker

# ---------------------------------------------------------------------------
# Engine / session
# ---------------------------------------------------------------------------

_BASE_DIR = os.path.dirname(os.path.abspath(__file__))


def _resolve_data_dir() -> str:
    """Directory that holds mutable app state (the SQLite file).

    On Hugging Face Spaces the container filesystem is rebuilt on every deploy,
    so a DB written next to the code loses every user account and every saved
    analysis. If a persistent volume is mounted (HF persistent storage mounts it
    at /data) put the DB there; otherwise fall back to the app directory, which
    keeps local development unchanged.
    """
    override = os.environ.get("APP_DATA_DIR")
    if override:
        try:
            os.makedirs(override, exist_ok=True)
            if os.access(override, os.W_OK):
                return override
        except OSError:
            pass
    # Only adopt /data when it is already mounted — creating it ourselves would
    # look persistent while living inside the disposable container layer.
    if os.path.isdir("/data") and os.access("/data", os.W_OK):
        return "/data"
    return _BASE_DIR


DATA_DIR = _resolve_data_dir()
DB_IS_PERSISTENT = bool(os.environ.get("DATABASE_URL")) or DATA_DIR != _BASE_DIR

DATABASE_URL = os.environ.get(
    "DATABASE_URL",
    f"sqlite:///{os.path.join(DATA_DIR, 'app.db')}",
)
# Railway provides postgres:// but SQLAlchemy requires postgresql://
if DATABASE_URL.startswith("postgres://"):
    DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)


def _adopt_legacy_sqlite() -> None:
    """Move an app.db that predates DATA_DIR onto the persistent volume.

    Without this, switching a running deployment to persistent storage would
    look like every user and every saved analysis had vanished.
    """
    if DATA_DIR == _BASE_DIR or not DATABASE_URL.startswith("sqlite:///"):
        return
    target = DATABASE_URL[len("sqlite:///"):]
    legacy = os.path.join(_BASE_DIR, "app.db")
    if os.path.exists(target) or not os.path.exists(legacy):
        return
    import shutil

    try:
        shutil.copy2(legacy, target)
    except OSError:
        pass


_adopt_legacy_sqlite()

engine = create_engine(
    DATABASE_URL,
    connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()


# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True, nullable=False)
    full_name = Column(String, nullable=True)
    hashed_password = Column(String, nullable=False)
    is_admin = Column(Boolean, default=False)
    # role: 1=super_admin, 2=admin, 3=user
    role = Column(Integer, default=3, nullable=False)
    # Unique class code for admins (role 1 & 2)
    class_code = Column(String, nullable=True, unique=True, index=True)
    # Class code the user joined (for role 3 users)
    joined_class_code = Column(String, nullable=True, index=True)
    # Timestamp at which the user accepted the Terms of Service + Privacy Policy.
    # Null means consent was never given (legacy rows pre-dating the consent flow).
    terms_accepted_at = Column(DateTime, nullable=True)
    # Version of the terms text the user accepted, so a future ToS update can
    # detect users who need to re-consent.
    terms_version = Column(String, nullable=True)
    created_at = Column(DateTime, default=datetime.utcnow)

    results = relationship("Result", back_populates="user", cascade="all, delete-orphan")


class Result(Base):
    __tablename__ = "results"

    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    title = Column(String, nullable=False)
    raw_scores = Column(Text, nullable=False)   # JSON string
    recommendation = Column(Text, nullable=True)
    # Links this GRBAS analysis to the neck-effort recording captured in the same
    # session (see NeckRecording.session_id), so the two modalities can be paired
    # for model training. Null for analyses with no neck recording.
    session_id = Column(String, index=True, nullable=True)
    created_at = Column(DateTime, default=datetime.utcnow)

    user = relationship("User", back_populates="results")


class AppSetting(Base):
    """Small key/value store for server-generated settings.

    Holds the JWT signing secret when the deployment does not provide one, so
    it is stable across restarts (tokens survive) without ever falling back to
    a value committed to the repo.
    """

    __tablename__ = "app_settings"

    key = Column(String, primary_key=True)
    value = Column(Text, nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)


class AllowedEmail(Base):
    """Emails an admin has invited into the app.

    The allowlist used to be a text file baked into the Docker image, so every
    email an admin added through the dashboard was silently reverted on the next
    deploy. Keeping it in the DB means an invite lives exactly as long as the
    rest of the data does.

    ``class_code`` is the inviting admin's class: an invited user is attached to
    that class on registration, so their history shows up in that admin's
    dashboard without anyone typing a class code.
    """

    __tablename__ = "allowed_emails"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String, unique=True, index=True, nullable=False)  # stored lowercase
    full_name = Column(String, nullable=True)
    class_code = Column(String, index=True, nullable=True)
    added_by = Column(String, nullable=True)     # username of the admin who invited
    created_at = Column(DateTime, default=datetime.utcnow)


class NeckRecording(Base):
    """Per-recording neck-effort feature time-series, captured for model training.

    Stores only DERIVED geometric features (no raw video frames) plus a
    ``session_id`` that links this series to the GRBAS ``Result`` and — once
    Phase 3 lands — the audio clip in object storage (``audio_uri``). The feature
    series is anonymous: a face cannot be reconstructed from it.
    """
    __tablename__ = "neck_recordings"

    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    session_id = Column(String, index=True, nullable=False)
    feature_series = Column(Text, nullable=False)   # JSON: list of per-frame samples
    summary = Column(Text, nullable=True)           # JSON: recording_summary()
    frame_count = Column(Integer, default=0)
    # Research-data consent for this specific recording. Audio is only stored
    # (Phase 3) when this is True; the anonymous feature series is always kept.
    consent_research = Column(Boolean, default=False)
    audio_uri = Column(String, nullable=True)       # object-storage URL, filled in Phase 3
    created_at = Column(DateTime, default=datetime.utcnow)

    user = relationship("User")


# ---------------------------------------------------------------------------
# DB initialisation
# ---------------------------------------------------------------------------

def get_or_create_setting(key: str, factory) -> str:
    """Return the stored value for ``key``, creating it from ``factory`` once.

    Concurrent workers can race here; the loser of the race re-reads the row the
    winner committed, so every worker ends up with the same value.
    """
    session = SessionLocal()
    try:
        row = session.get(AppSetting, key)
        if row is not None:
            return row.value

        value = factory()
        session.add(AppSetting(key=key, value=value))
        try:
            session.commit()
        except Exception:
            session.rollback()
            row = session.get(AppSetting, key)
            if row is None:
                raise
            return row.value
        return value
    finally:
        session.close()


def init_db() -> None:
    """Create all tables if they do not already exist (idempotent)."""
    Base.metadata.create_all(bind=engine)
    _migrate_add_columns()


def _migrate_add_columns() -> None:
    """Add new columns to existing SQLite tables if they are missing."""
    from sqlalchemy import text, inspect

    insp = inspect(engine)
    existing_cols = {c["name"] for c in insp.get_columns("users")}

    migrations = []
    if "role" not in existing_cols:
        migrations.append("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 3")
    if "class_code" not in existing_cols:
        migrations.append("ALTER TABLE users ADD COLUMN class_code VARCHAR")
    if "joined_class_code" not in existing_cols:
        migrations.append("ALTER TABLE users ADD COLUMN joined_class_code VARCHAR")
    if "terms_accepted_at" not in existing_cols:
        migrations.append("ALTER TABLE users ADD COLUMN terms_accepted_at TIMESTAMP")
    if "terms_version" not in existing_cols:
        migrations.append("ALTER TABLE users ADD COLUMN terms_version VARCHAR")

    # results table: link to neck-effort recording session
    if insp.has_table("results"):
        result_cols = {c["name"] for c in insp.get_columns("results")}
        if "session_id" not in result_cols:
            migrations.append("ALTER TABLE results ADD COLUMN session_id VARCHAR")

    if migrations:
        with engine.begin() as conn:
            for stmt in migrations:
                conn.execute(text(stmt))