| 1 |
1 |
"""SQLAlchemy models — UPGRADE-PLAN §1.4 (users · email_tokens · api_keys · usage_daily · usage_minute · audit_log). |
| 2 |
2 |
|
|
3 |
+`create_all()` creates missing tables; `migrate()` adds the columns introduced after the first deployment |
|
4 |
+with idempotent `ALTER TABLE … ADD COLUMN` statements (SQLite has no `IF NOT EXISTS` for columns, so the |
|
5 |
+current schema is read with `PRAGMA table_info`). Both run at import time. |
|
6 |
+ |
| 3 |
7 |
Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 4 |
8 |
""" |
| 5 |
9 |
from __future__ import annotations |
| 6 |
10 |
|
|
11 |
+import logging |
| 7 |
12 |
from datetime import date, datetime, timezone |
| 8 |
13 |
|
| 9 |
|
−from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, Text |
|
14 |
+from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, Text, text |
| 10 |
15 |
from sqlalchemy.orm import Mapped, mapped_column, relationship |
| 11 |
16 |
|
| 12 |
|
−from core.db import Base, create_all |
|
17 |
+from core.db import Base, create_all, engine |
|
18 |
+ |
|
19 |
+log = logging.getLogger("hfmarketdata.accounts") |
| 13 |
20 |
|
| 14 |
21 |
ROLES = ("user", "admin") |
| 15 |
22 |
TIERS = ("free", "high_usage", "unlimited") |
| 16 |
|
−USER_STATUSES = ("invited", "active", "disabled") |
|
23 |
+USER_STATUSES = ("invited", "active", "disabled", "deleted") |
| 17 |
24 |
KEY_STATUSES = ("active", "revoked") |
| 18 |
|
−TOKEN_KINDS = ("verify", "reset", "invite") |
|
25 |
+TOKEN_KINDS = ("verify", "reset", "invite", "email_change") |
|
26 |
+KEY_SCOPES = ("data",) # reserved for the future; every key has ["data"] today |
|
27 |
+DEFAULT_SCOPES = '["data"]' |
| 19 |
28 |
|
| 20 |
29 |
|
| 21 |
30 |
def utcnow() -> datetime: |
| 31 |
40 |
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) # argon2; NULL until invite accepted |
| 32 |
41 |
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |
| 33 |
42 |
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") # user | admin |
| 34 |
|
− tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free") # free | high_usage |
| 35 |
|
− status: Mapped[str] = mapped_column(String(16), nullable=False, default="invited") # invited | active | disabled |
|
43 |
+ tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free") # free | high_usage | unlimited |
|
44 |
+ status: Mapped[str] = mapped_column(String(16), nullable=False, default="invited") # invited | active | disabled | deleted |
| 36 |
45 |
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) |
| 37 |
46 |
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |
|
47 |
+ # --- added 2026-09 (see migrate()) --- |
|
48 |
+ failed_logins: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") |
|
49 |
+ locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |
|
50 |
+ session_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") # bumped to revoke every cookie |
|
51 |
+ quota_alerts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") # 1 = e-mail at 80 % / 100 % / first 429 |
|
52 |
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |
| 38 |
53 |
|
| 39 |
54 |
keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan") |
| 40 |
55 |
tokens: Mapped[list[EmailToken]] = relationship(back_populates="user", cascade="all, delete-orphan") |
| 49 |
64 |
|
| 50 |
65 |
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) |
| 51 |
66 |
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) |
| 52 |
|
− kind: Mapped[str] = mapped_column(String(16), nullable=False) # verify | reset | invite |
|
67 |
+ kind: Mapped[str] = mapped_column(String(16), nullable=False) # verify | reset | invite | email_change |
| 53 |
68 |
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) |
| 54 |
69 |
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) |
| 55 |
70 |
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |
| 56 |
|
− # JSON: {"link": "…"} when the e-mail could not be delivered (no Resend key) so admins/CLI can print it |
|
71 |
+ # JSON: {"link": "…"} when the e-mail could not be delivered (no Resend key) so admins/CLI can print it; |
|
72 |
+ # {"new_email": "…"} for email_change tokens |
| 57 |
73 |
meta: Mapped[str | None] = mapped_column(Text, nullable=True) |
| 58 |
74 |
|
| 59 |
75 |
user: Mapped[User] = relationship(back_populates="tokens") |
| 72 |
88 |
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) |
| 73 |
89 |
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |
| 74 |
90 |
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |
|
91 |
+ # --- added 2026-09 (see migrate()) --- |
|
92 |
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # NULL = never |
|
93 |
+ last_used_ip: Mapped[str | None] = mapped_column(String(16), nullable=True) # hashed (same formula as ip:<hash>) |
|
94 |
+ note: Mapped[str | None] = mapped_column(String(500), nullable=True) |
|
95 |
+ scopes: Mapped[str] = mapped_column(String(200), nullable=False, default=DEFAULT_SCOPES, server_default=DEFAULT_SCOPES) # JSON list |
| 75 |
96 |
|
| 76 |
97 |
user: Mapped[User] = relationship(back_populates="keys") |
| 77 |
98 |
|
| 79 |
100 |
def principal(self) -> str: |
| 80 |
101 |
return f"key:{self.id}" |
| 81 |
102 |
|
|
103 |
+ def is_expired(self, at: datetime | None = None) -> bool: |
|
104 |
+ return self.expires_at is not None and self.expires_at <= (at or utcnow()) |
|
105 |
+ |
| 82 |
106 |
|
| 83 |
107 |
class UsageDaily(Base): |
| 84 |
108 |
__tablename__ = "usage_daily" |
| 100 |
124 |
principal: Mapped[str] = mapped_column(String(64), primary_key=True) |
| 101 |
125 |
requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0) |
| 102 |
126 |
rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0) |
|
127 |
+ # --- added 2026-09 (see migrate()) — 0 for minutes folded before the upgrade --- |
|
128 |
+ rows_parquet: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") |
|
129 |
+ bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") |
|
130 |
+ status_429: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") |
| 103 |
131 |
|
| 104 |
132 |
|
| 105 |
133 |
class AuditLog(Base): |
| 115 |
143 |
|
| 116 |
144 |
Index("ix_usage_daily_principal", UsageDaily.principal) |
| 117 |
145 |
Index("ix_usage_minute_principal", UsageMinute.principal) |
|
146 |
+Index("ix_audit_log_actor", AuditLog.actor) |
|
147 |
+Index("ix_audit_log_target", AuditLog.target) |
|
148 |
+ |
|
149 |
+# (table, column, SQL type + default) — every entry must be safe to re-run: the column is only added when absent. |
|
150 |
+MIGRATIONS: tuple[tuple[str, str, str], ...] = ( |
|
151 |
+ ("users", "failed_logins", "INTEGER NOT NULL DEFAULT 0"), |
|
152 |
+ ("users", "locked_until", "DATETIME"), |
|
153 |
+ ("users", "session_version", "INTEGER NOT NULL DEFAULT 1"), |
|
154 |
+ ("users", "quota_alerts", "INTEGER NOT NULL DEFAULT 1"), |
|
155 |
+ ("users", "deleted_at", "DATETIME"), |
|
156 |
+ ("api_keys", "expires_at", "DATETIME"), |
|
157 |
+ ("api_keys", "last_used_ip", "VARCHAR(16)"), |
|
158 |
+ ("api_keys", "note", "VARCHAR(500)"), |
|
159 |
+ ("api_keys", "scopes", f"VARCHAR(200) NOT NULL DEFAULT '{DEFAULT_SCOPES}'"), |
|
160 |
+ ("usage_minute", "rows_parquet", "INTEGER NOT NULL DEFAULT 0"), |
|
161 |
+ ("usage_minute", "bytes", "INTEGER NOT NULL DEFAULT 0"), |
|
162 |
+ ("usage_minute", "status_429", "INTEGER NOT NULL DEFAULT 0"), |
|
163 |
+) |
|
164 |
+ |
|
165 |
+ |
|
166 |
+def migrate() -> list[str]: |
|
167 |
+ """Add the columns listed in MIGRATIONS when they are missing. Idempotent; returns the columns added.""" |
|
168 |
+ added: list[str] = [] |
|
169 |
+ with engine.begin() as con: |
|
170 |
+ for table, column, ddl in MIGRATIONS: |
|
171 |
+ existing = {row[1] for row in con.execute(text(f"PRAGMA table_info({table})")).fetchall()} |
|
172 |
+ if not existing or column in existing: |
|
173 |
+ continue |
|
174 |
+ con.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")) |
|
175 |
+ added.append(f"{table}.{column}") |
|
176 |
+ for name, table, column in (("ix_audit_log_actor", "audit_log", "actor"), ("ix_audit_log_target", "audit_log", "target")): |
|
177 |
+ con.execute(text(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({column})")) |
|
178 |
+ if added: |
|
179 |
+ log.info("accounts schema migrated: added %s", ", ".join(added)) |
|
180 |
+ return added |
|
181 |
+ |
| 118 |
182 |
|
| 119 |
183 |
create_all() |
|
184 |
+migrate() |