diff --git a/backend/migrate_add_project_portal_auth.py b/backend/migrate_add_project_portal_auth.py new file mode 100644 index 0000000..68ce2d4 --- /dev/null +++ b/backend/migrate_add_project_portal_auth.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 8c5d5e9..df50836 100644 --- a/backend/models.py +++ b/backend/models.py @@ -193,6 +193,10 @@ class Project(Base): # Project metadata 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 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_coordinates = Column(String, nullable=True) # "lat,lon" start_date = Column(Date, nullable=True) diff --git a/tests/test_portal_migration.py b/tests/test_portal_migration.py new file mode 100644 index 0000000..c993849 --- /dev/null +++ b/tests/test_portal_migration.py @@ -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")