feat: add per-project portal gate columns + migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Project portal auth (Phase 1).
|
||||||
|
|
||||||
|
Adds the per-project portal gate columns to `projects`:
|
||||||
|
- portal_enabled (BOOLEAN, default 0)
|
||||||
|
- portal_password_hash (TEXT, nullable)
|
||||||
|
- portal_link_token (TEXT, nullable) [+ unique index]
|
||||||
|
|
||||||
|
Idempotent. Run once per existing DB:
|
||||||
|
docker exec terra-view-terra-view-1 python3 backend/migrate_add_project_portal_auth.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_COLUMNS = {
|
||||||
|
"portal_enabled": "BOOLEAN DEFAULT 0",
|
||||||
|
"portal_password_hash": "TEXT",
|
||||||
|
"portal_link_token": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
possible_paths = [Path("data/seismo_fleet.db"), Path("data/sfm.db"), Path("data/seismo.db")]
|
||||||
|
db_path = next((p for p in possible_paths if p.exists()), None)
|
||||||
|
if db_path is None:
|
||||||
|
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||||
|
print("A fresh DB created via models.py will include these columns automatically.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA table_info(projects)")
|
||||||
|
existing = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
for col, ddl in _COLUMNS.items():
|
||||||
|
if col in existing:
|
||||||
|
print(f"○ Column already exists: projects.{col}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cursor.execute(f"ALTER TABLE projects ADD COLUMN {col} {ddl}")
|
||||||
|
print(f"✓ Added column: projects.{col} ({ddl})")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"✗ Failed to add projects.{col}: {e}")
|
||||||
|
|
||||||
|
# Unique index on the link token (separate from ADD COLUMN; idempotent via IF NOT EXISTS).
|
||||||
|
try:
|
||||||
|
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_projects_portal_link_token "
|
||||||
|
"ON projects (portal_link_token)")
|
||||||
|
print("✓ Ensured unique index: ix_projects_portal_link_token")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"✗ Failed to create index: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("\n✓ Project portal-auth migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -193,6 +193,10 @@ class Project(Base):
|
|||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
|
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
|
||||||
|
# --- Client portal (Phase 1: per-project link + password gate) ---
|
||||||
|
portal_enabled = Column(Boolean, default=False) # is the portal open for this project
|
||||||
|
portal_password_hash = Column(String, nullable=True) # argon2 hash of the shared password
|
||||||
|
portal_link_token = Column(String, nullable=True, unique=True, index=True) # unguessable token in the secure link
|
||||||
site_address = Column(String, nullable=True)
|
site_address = Column(String, nullable=True)
|
||||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||||
start_date = Column(Date, nullable=True)
|
start_date = Column(Date, nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import sqlite3
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(db_file):
|
||||||
|
conn = sqlite3.connect(db_file)
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||||
|
conn.close()
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_adds_columns_and_is_idempotent(tmp_path, monkeypatch):
|
||||||
|
db_file = tmp_path / "seismo_fleet.db"
|
||||||
|
conn = sqlite3.connect(db_file)
|
||||||
|
conn.execute("CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path) # migration resolves data/ relative to cwd
|
||||||
|
(tmp_path / "data").mkdir()
|
||||||
|
(tmp_path / "data" / "seismo_fleet.db").write_bytes(db_file.read_bytes())
|
||||||
|
|
||||||
|
mod = importlib.import_module("backend.migrate_add_project_portal_auth")
|
||||||
|
mod.migrate()
|
||||||
|
cols = _columns(tmp_path / "data" / "seismo_fleet.db")
|
||||||
|
assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= cols
|
||||||
|
|
||||||
|
mod.migrate() # second run must not raise
|
||||||
|
assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= _columns(tmp_path / "data" / "seismo_fleet.db")
|
||||||
Reference in New Issue
Block a user