coderuday21 Cursor commited on
Commit
ac7ea7c
·
1 Parent(s): bec7397

Complete Phase 7: session users, RBAC, training export, and UAT hardening.

Browse files

Per-browser session isolation, role-based upload, false-positive training export, admin diagnostics, enhanced health, and UAT checklist.

Co-authored-by: Cursor <cursoragent@cursor.com>

.env.example CHANGED
@@ -32,3 +32,8 @@
32
  # Department export API (optional — FR-08)
33
  # DEPT_API_URL=https://dept.example.gov/api/changes
34
  # DEPT_API_KEY=your-api-key
 
 
 
 
 
 
32
  # Department export API (optional — FR-08)
33
  # DEPT_API_URL=https://dept.example.gov/api/changes
34
  # DEPT_API_KEY=your-api-key
35
+
36
+ # DDA admin account (optional — UAT / ops)
37
+ # DDA_ADMIN_EMAIL=admin@example.com
38
+ # DDA_ADMIN_PASSWORD=change-me
39
+ # DDA_TRAINING_EXPORT_KEY=secret-key-for-fp-export
DEPLOYMENT.md CHANGED
@@ -140,11 +140,33 @@ Set these in each Space’s **Settings → Repository secrets / Variables** if n
140
  | `DATABASE_URL` | PostgreSQL instead of SQLite (optional) |
141
  | `SMTP_USER` / `SMTP_PASS` | Email notifications via Gmail SMTP |
142
  | `EMAIL_API_URL` | Custom email API (default in code) |
 
 
 
143
 
144
  Dev Space can omit `SECRET_KEY` (login is disabled on both Spaces).
145
 
146
  ---
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  ## Quick reference
149
 
150
  ```powershell
 
140
  | `DATABASE_URL` | PostgreSQL instead of SQLite (optional) |
141
  | `SMTP_USER` / `SMTP_PASS` | Email notifications via Gmail SMTP |
142
  | `EMAIL_API_URL` | Custom email API (default in code) |
143
+ | `DEPT_API_URL` / `DEPT_API_KEY` | Departmental submit API (optional) |
144
+ | `DDA_ADMIN_EMAIL` / `DDA_ADMIN_PASSWORD` | Seed admin user on dev (role: admin) |
145
+ | `DDA_TRAINING_EXPORT_KEY` | Header `X-DDA-Training-Key` for false-positive export |
146
 
147
  Dev Space can omit `SECRET_KEY` (login is disabled on both Spaces).
148
 
149
  ---
150
 
151
+ ## UAT checklist (satdetect-dev)
152
+
153
+ Run before promoting any DDA feature to production:
154
+
155
+ 1. **Health** — `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` ≥ 0
156
+ 2. **Library** — Upload GeoTIFF to year folder; Refresh shows image; thumb loads
157
+ 3. **Compare** — Select T1/T2; Run Detection completes (async job or sync fallback)
158
+ 4. **Viewer** — Slider / T1 / T2 / Overlay modes; click region to locate
159
+ 5. **Review** — Confirm and False Positive; Export confirmed CSV; Submit confirmed
160
+ 6. **Reports** — PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
161
+ 7. **Session isolation** — Two browsers see separate history (per-session cookie)
162
+ 8. **Admin** — `GET /api/dda/admin/status` (analyst+); stale job reconcile after restart
163
+ 9. **Training export** — Mark false positives → `GET /api/dda/training/export` (admin or export key)
164
+ 10. **Security** — Path traversal blocked (`../` in library path); upload size limit enforced
165
+
166
+ Sign-off: DDA stakeholder approves sample runs on dev Space before any push to `satdetect` production.
167
+
168
+ ---
169
+
170
  ## Quick reference
171
 
172
  ```powershell
DEV_SETUP.md CHANGED
@@ -158,6 +158,8 @@ Expected: `"appMode": "dda"`, `"status": "ok"`.
158
  3. **Reports** — history, PDF download, browser report at `/dda/reports/{id}`.
159
  4. **Bell icon** — in-app notifications for completed jobs.
160
  5. **Review (FR-08)** — Confirm / False Positive per region, export confirmed CSV, submit to dept API (`DEPT_API_URL`).
 
 
161
 
162
  ---
163
 
 
158
  3. **Reports** — history, PDF download, browser report at `/dda/reports/{id}`.
159
  4. **Bell icon** — in-app notifications for completed jobs.
160
  5. **Review (FR-08)** — Confirm / False Positive per region, export confirmed CSV, submit to dept API (`DEPT_API_URL`).
161
+ 6. **Session users** — Each browser gets isolated history via `dda_session_id` cookie (no login required).
162
+ 7. **Admin** — Optional `DDA_ADMIN_EMAIL` / `DDA_ADMIN_PASSWORD` for admin role; `GET /api/dda/admin/status`.
163
 
164
  ---
165
 
Dockerfile CHANGED
@@ -21,7 +21,7 @@ WORKDIR /app
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
- ARG APP_BUILD=36
25
  ENV MAX_GEOTIFF_MB=5120
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
 
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
+ ARG APP_BUILD=37
25
  ENV MAX_GEOTIFF_MB=5120
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
app/dda/admin_routes.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DDA admin diagnostics and maintenance (Phase 7)."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import shutil
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
9
+ from sqlalchemy.orm import Session
10
+
11
+ from ..database import DATA_DIR, get_db
12
+ from ..models import DetectionRun, User
13
+ from .dda_auth import current_dda_user, get_user_role, require_min_role
14
+ from .job_runner import is_job_runner_busy, reconcile_stale_jobs
15
+ from .local_library import scan_images
16
+ from .models import DetectionJob, RegionReview
17
+
18
+ logger = logging.getLogger(__name__)
19
+ router = APIRouter()
20
+
21
+
22
+ def _require_dda():
23
+ from .config import IS_DDA_MODE
24
+ if not IS_DDA_MODE:
25
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
26
+
27
+
28
+ @router.get("/admin/status")
29
+ def admin_status(
30
+ request: Request,
31
+ db: Session = Depends(get_db),
32
+ user: User = Depends(current_dda_user),
33
+ ):
34
+ """System status for UAT / ops (analyst+)."""
35
+ _require_dda()
36
+ require_min_role(user, db, "analyst")
37
+
38
+ runs = db.query(DetectionRun).count()
39
+ jobs_queued = db.query(DetectionJob).filter(DetectionJob.status == "queued").count()
40
+ jobs_running = db.query(DetectionJob).filter(DetectionJob.status == "running").count()
41
+ fp_count = db.query(RegionReview).filter(RegionReview.status == "false_positive").count()
42
+
43
+ disk = shutil.disk_usage(DATA_DIR)
44
+ return {
45
+ "role": get_user_role(db, user),
46
+ "libraryImages": len(scan_images()),
47
+ "detectionRuns": runs,
48
+ "jobsQueued": jobs_queued,
49
+ "jobsRunning": jobs_running,
50
+ "runnerBusy": is_job_runner_busy(),
51
+ "falsePositiveArchive": fp_count,
52
+ "dataDir": str(DATA_DIR),
53
+ "diskFreeGb": round(disk.free / (1024 ** 3), 2),
54
+ "diskTotalGb": round(disk.total / (1024 ** 3), 2),
55
+ }
56
+
57
+
58
+ @router.post("/admin/reconcile-jobs")
59
+ def admin_reconcile_jobs(
60
+ request: Request,
61
+ db: Session = Depends(get_db),
62
+ user: User = Depends(current_dda_user),
63
+ ):
64
+ _require_dda()
65
+ require_min_role(user, db, "admin")
66
+ fixed = reconcile_stale_jobs(db)
67
+ return {"ok": True, "staleRunningFixed": fixed}
68
+
69
+
70
+ @router.delete("/admin/purge-runs")
71
+ def admin_purge_old_runs(
72
+ request: Request,
73
+ days: int = Query(90, ge=7, le=3650),
74
+ db: Session = Depends(get_db),
75
+ user: User = Depends(current_dda_user),
76
+ ):
77
+ """Delete detection runs older than N days (admin only)."""
78
+ _require_dda()
79
+ require_min_role(user, db, "admin")
80
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
81
+ old = db.query(DetectionRun).filter(DetectionRun.created_at < cutoff).all()
82
+ count = 0
83
+ overlays_root = DATA_DIR
84
+ for run in old:
85
+ db.query(RegionReview).filter(RegionReview.run_id == run.id).delete()
86
+ for job in db.query(DetectionJob).filter(DetectionJob.run_id == run.id).all():
87
+ job.run_id = None
88
+ for path_attr in ("overlay_path", "before_full_path", "before_thumb_path", "after_thumb_path", "after_full_path"):
89
+ path_val = getattr(run, path_attr, None)
90
+ if path_val:
91
+ f = overlays_root / path_val
92
+ if f.exists():
93
+ f.unlink(missing_ok=True)
94
+ db.delete(run)
95
+ count += 1
96
+ db.commit()
97
+ return {"ok": True, "purgedRuns": count, "olderThanDays": days}
app/dda/bootstrap.py CHANGED
@@ -5,12 +5,15 @@ from sqlalchemy import text as sa_text
5
 
6
  from ..database import engine
7
  from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders, is_hf_hosted
 
8
  from .jobs_routes import router as jobs_router
9
  from .library_routes import router as library_router
10
  from .local_routes import router as local_router
11
  from .reports_routes import router as reports_router
12
  from .review_routes import router as review_router
 
13
  from .seed import seed_delhi_hierarchy
 
14
 
15
  logger = logging.getLogger(__name__)
16
 
@@ -47,6 +50,7 @@ def init_dda_database():
47
  db = SessionLocal()
48
  try:
49
  seed_delhi_hierarchy(db)
 
50
  from .job_runner import reconcile_stale_jobs
51
  reconcile_stale_jobs(db)
52
  finally:
@@ -73,5 +77,7 @@ def setup_dda(app: FastAPI) -> None:
73
  app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
74
  app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
75
  app.include_router(review_router, prefix="/api/dda", tags=["dda-review"])
 
 
76
  app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
77
- logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, reports, review, local folder)")
 
5
 
6
  from ..database import engine
7
  from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders, is_hf_hosted
8
+ from .admin_routes import router as admin_router
9
  from .jobs_routes import router as jobs_router
10
  from .library_routes import router as library_router
11
  from .local_routes import router as local_router
12
  from .reports_routes import router as reports_router
13
  from .review_routes import router as review_router
14
+ from .training_routes import router as training_router
15
  from .seed import seed_delhi_hierarchy
16
+ from .dda_auth import seed_dda_admin
17
 
18
  logger = logging.getLogger(__name__)
19
 
 
50
  db = SessionLocal()
51
  try:
52
  seed_delhi_hierarchy(db)
53
+ seed_dda_admin(db)
54
  from .job_runner import reconcile_stale_jobs
55
  reconcile_stale_jobs(db)
56
  finally:
 
77
  app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
78
  app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
79
  app.include_router(review_router, prefix="/api/dda", tags=["dda-review"])
80
+ app.include_router(training_router, prefix="/api/dda", tags=["dda-training"])
81
+ app.include_router(admin_router, prefix="/api/dda", tags=["dda-admin"])
82
  app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
83
+ logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, reports, review, training, admin, local)")
app/dda/dda_auth.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DDA session users and RBAC (Phase 7)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Iterable, Optional
6
+
7
+ from fastapi import Depends, HTTPException, Request
8
+ from sqlalchemy.orm import Session
9
+
10
+ from ..auth import get_or_create_guest_user, get_user_by_id, get_user_from_token
11
+ from ..database import get_db
12
+ from ..models import User
13
+
14
+ DDA_SESSION_COOKIE = "dda_session_id"
15
+ COOKIE_NAME = "satellite_token"
16
+
17
+ ROLE_RANK = {
18
+ "viewer": 0,
19
+ "uploader": 1,
20
+ "analyst": 2,
21
+ "admin": 3,
22
+ }
23
+
24
+ TRAINING_EXPORT_KEY = os.environ.get("DDA_TRAINING_EXPORT_KEY", "").strip()
25
+
26
+
27
+ def _session_email(session_id: str) -> str:
28
+ safe = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64]
29
+ return f"session-{safe}@dda.local"
30
+
31
+
32
+ def get_user_role(db: Session, user: User) -> str:
33
+ role = getattr(user, "role", None)
34
+ if role:
35
+ return str(role)
36
+ try:
37
+ from sqlalchemy import text as sa_text
38
+ row = db.execute(
39
+ sa_text("SELECT role FROM users WHERE id = :uid"),
40
+ {"uid": user.id},
41
+ ).fetchone()
42
+ if row and row[0]:
43
+ return str(row[0])
44
+ except Exception:
45
+ pass
46
+ return "analyst"
47
+
48
+
49
+ def get_dda_user(request: Request, db: Session) -> User:
50
+ """Resolve user: JWT login > per-browser session > shared guest."""
51
+ auth_header = request.headers.get("authorization", "")
52
+ if auth_header.lower().startswith("bearer "):
53
+ token = auth_header.split(" ", 1)[1].strip()
54
+ user = get_user_from_token(token, db)
55
+ if user:
56
+ return user
57
+
58
+ token = request.cookies.get(COOKIE_NAME)
59
+ if token:
60
+ user = get_user_from_token(token, db)
61
+ if user and not user.email.startswith("__guest__"):
62
+ return user
63
+
64
+ session_id = request.cookies.get(DDA_SESSION_COOKIE)
65
+ if session_id:
66
+ email = _session_email(session_id)
67
+ from ..auth import get_user_by_email, get_password_hash
68
+ user = get_user_by_email(db, email)
69
+ if user:
70
+ return user
71
+ user = User(
72
+ email=email,
73
+ hashed_password=get_password_hash("session-not-used"),
74
+ full_name=f"Session {session_id[:8]}",
75
+ )
76
+ db.add(user)
77
+ db.commit()
78
+ db.refresh(user)
79
+ try:
80
+ from sqlalchemy import text as sa_text
81
+ db.execute(
82
+ sa_text("UPDATE users SET role = 'analyst' WHERE id = :uid"),
83
+ {"uid": user.id},
84
+ )
85
+ db.commit()
86
+ except Exception:
87
+ db.rollback()
88
+ return user
89
+
90
+ return get_or_create_guest_user(db)
91
+
92
+
93
+ def current_dda_user(request: Request, db: Session = Depends(get_db)) -> User:
94
+ return get_dda_user(request, db)
95
+
96
+
97
+ def require_min_role(user: User, db: Session, minimum: str) -> None:
98
+ role = get_user_role(db, user)
99
+ need = ROLE_RANK.get(minimum, 99)
100
+ have = ROLE_RANK.get(role, 0)
101
+ if have < need:
102
+ raise HTTPException(
103
+ status_code=403,
104
+ detail=f"Role '{role}' cannot perform this action (requires {minimum} or higher).",
105
+ )
106
+
107
+
108
+ def require_admin_or_key(request: Request, user: User, db: Session) -> None:
109
+ key = request.headers.get("x-dda-training-key", "").strip()
110
+ if TRAINING_EXPORT_KEY and key == TRAINING_EXPORT_KEY:
111
+ return
112
+ require_min_role(user, db, "admin")
113
+
114
+
115
+ def seed_dda_admin(db: Session) -> None:
116
+ """Optional admin account from env (dev/UAT)."""
117
+ email = os.environ.get("DDA_ADMIN_EMAIL", "").strip()
118
+ password = os.environ.get("DDA_ADMIN_PASSWORD", "").strip()
119
+ if not email or not password:
120
+ return
121
+ from ..auth import get_password_hash, get_user_by_email
122
+ user = get_user_by_email(db, email)
123
+ if not user:
124
+ user = User(
125
+ email=email,
126
+ hashed_password=get_password_hash(password),
127
+ full_name=os.environ.get("DDA_ADMIN_NAME", "DDA Admin"),
128
+ )
129
+ db.add(user)
130
+ db.commit()
131
+ db.refresh(user)
132
+ try:
133
+ from sqlalchemy import text as sa_text
134
+ db.execute(
135
+ sa_text("UPDATE users SET role = 'admin' WHERE id = :uid"),
136
+ {"uid": user.id},
137
+ )
138
+ db.commit()
139
+ except Exception:
140
+ db.rollback()
app/dda/detect_service.py CHANGED
@@ -79,10 +79,17 @@ def run_detection_and_save(
79
  notify_email: Optional[str] = None,
80
  max_size: Optional[int] = None,
81
  geo_bounds_path: Optional[Path] = None,
 
82
  ) -> dict:
83
  from ..detection_engine import run_detection
84
 
85
- user = get_or_create_guest_user(db)
 
 
 
 
 
 
86
  detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
87
  if min_region_area is not None:
88
  min_region_area = int(max(50, min(10000, min_region_area)))
 
79
  notify_email: Optional[str] = None,
80
  max_size: Optional[int] = None,
81
  geo_bounds_path: Optional[Path] = None,
82
+ user_id: Optional[int] = None,
83
  ) -> dict:
84
  from ..detection_engine import run_detection
85
 
86
+ if user_id:
87
+ from ..auth import get_user_by_id
88
+ user = get_user_by_id(db, user_id)
89
+ if not user:
90
+ user = get_or_create_guest_user(db)
91
+ else:
92
+ user = get_or_create_guest_user(db)
93
  detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
94
  if min_region_area is not None:
95
  min_region_area = int(max(50, min(10000, min_region_area)))
app/dda/job_runner.py CHANGED
@@ -84,6 +84,7 @@ def _run_job_sync(job_id: int) -> None:
84
  notify_email=job.notify_email or params.get("notify_email"),
85
  max_size=get_detection_max_side(),
86
  geo_bounds_path=base_file,
 
87
  )
88
 
89
  job.status = "completed"
 
84
  notify_email=job.notify_email or params.get("notify_email"),
85
  max_size=get_detection_max_side(),
86
  geo_bounds_path=base_file,
87
+ user_id=job.created_by,
88
  )
89
 
90
  job.status = "completed"
app/dda/jobs_routes.py CHANGED
@@ -5,12 +5,12 @@ import json
5
  import logging
6
  from typing import Optional
7
 
8
- from fastapi import APIRouter, Depends, Form, HTTPException, Query
9
  from sqlalchemy.orm import Session
10
 
11
- from ..auth import get_or_create_guest_user
12
  from ..database import get_db
13
- from ..models import DetectionRun
 
14
  from .job_runner import (
15
  create_local_folder_job,
16
  enqueue_detection_job,
@@ -44,6 +44,7 @@ async def create_job(
44
  min_region_area: Optional[int] = Form(150),
45
  notify_email: Optional[str] = Form(None),
46
  db: Session = Depends(get_db),
 
47
  ):
48
  """Queue async detection from local library paths. Returns immediately with jobId."""
49
  _require_dda()
@@ -68,7 +69,6 @@ async def create_job(
68
  detail="Another detection job is already running. Wait for it to finish, then try again.",
69
  )
70
 
71
- user = get_or_create_guest_user(db)
72
  if not title.strip():
73
  from pathlib import Path
74
  title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
@@ -99,9 +99,8 @@ async def create_job(
99
 
100
 
101
  @router.get("/jobs/{job_id}")
102
- def get_job(job_id: int, db: Session = Depends(get_db)):
103
  _require_dda()
104
- user = get_or_create_guest_user(db)
105
  job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
106
  if not job:
107
  raise HTTPException(status_code=404, detail="Job not found")
@@ -173,12 +172,12 @@ def list_jobs(
173
  status: Optional[str] = Query(None),
174
  limit: int = Query(20, ge=1, le=100),
175
  db: Session = Depends(get_db),
 
176
  ):
177
  """Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
178
  _require_dda()
179
  from .job_runner import reconcile_stale_jobs
180
  reconcile_stale_jobs(db)
181
- user = get_or_create_guest_user(db)
182
  q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
183
  if status:
184
  q = q.filter(DetectionJob.status == status)
 
5
  import logging
6
  from typing import Optional
7
 
8
+ from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
9
  from sqlalchemy.orm import Session
10
 
 
11
  from ..database import get_db
12
+ from ..models import DetectionRun, User
13
+ from .dda_auth import current_dda_user
14
  from .job_runner import (
15
  create_local_folder_job,
16
  enqueue_detection_job,
 
44
  min_region_area: Optional[int] = Form(150),
45
  notify_email: Optional[str] = Form(None),
46
  db: Session = Depends(get_db),
47
+ user: User = Depends(current_dda_user),
48
  ):
49
  """Queue async detection from local library paths. Returns immediately with jobId."""
50
  _require_dda()
 
69
  detail="Another detection job is already running. Wait for it to finish, then try again.",
70
  )
71
 
 
72
  if not title.strip():
73
  from pathlib import Path
74
  title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
 
99
 
100
 
101
  @router.get("/jobs/{job_id}")
102
+ def get_job(job_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
103
  _require_dda()
 
104
  job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
105
  if not job:
106
  raise HTTPException(status_code=404, detail="Job not found")
 
172
  status: Optional[str] = Query(None),
173
  limit: int = Query(20, ge=1, le=100),
174
  db: Session = Depends(get_db),
175
+ user: User = Depends(current_dda_user),
176
  ):
177
  """Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
178
  _require_dda()
179
  from .job_runner import reconcile_stale_jobs
180
  reconcile_stale_jobs(db)
 
181
  q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
182
  if status:
183
  q = q.filter(DetectionJob.status == status)
app/dda/local_routes.py CHANGED
@@ -3,12 +3,14 @@ import logging
3
  from pathlib import Path
4
  from typing import Optional
5
 
6
- from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
7
  from fastapi.responses import FileResponse
8
  from PIL import Image
9
  from sqlalchemy.orm import Session
10
 
11
  from ..database import get_db
 
 
12
  from .detect_service import run_detection_and_save
13
  from .geotiff_io import load_rgb_pil
14
 
@@ -140,11 +142,15 @@ def local_thumb(path: str = Query(...)):
140
 
141
  @router.post("/local/upload")
142
  async def local_upload(
 
143
  file: UploadFile = File(...),
144
  year: int = Form(...),
 
 
145
  ):
146
  """Upload GeoTIFF into persistent library_sources/YEAR/ (required on HF)."""
147
  _require_dda()
 
148
  if year < 1990 or year > 2100:
149
  raise HTTPException(status_code=400, detail="year must be between 1990 and 2100")
150
 
@@ -199,6 +205,7 @@ def local_rescan():
199
 
200
  @router.post("/detect/from-library")
201
  async def detect_from_library(
 
202
  base_path: str = Form(...),
203
  comparison_path: str = Form(...),
204
  method: str = Form("AI-Based Deep Learning"),
@@ -211,6 +218,7 @@ async def detect_from_library(
211
  min_region_area: Optional[int] = Form(150),
212
  notify_email: Optional[str] = Form(None),
213
  db: Session = Depends(get_db),
 
214
  ):
215
  """Run change detection on two library images by relative path (e.g. 2025/aerial.tif)."""
216
  _require_dda()
@@ -262,6 +270,7 @@ async def detect_from_library(
262
  notify_email=notify_email,
263
  max_size=max_side,
264
  geo_bounds_path=base_file,
 
265
  )
266
  except Exception as exc:
267
  logger.exception("Library detection failed for %s vs %s", base_norm, comp_norm)
 
3
  from pathlib import Path
4
  from typing import Optional
5
 
6
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
7
  from fastapi.responses import FileResponse
8
  from PIL import Image
9
  from sqlalchemy.orm import Session
10
 
11
  from ..database import get_db
12
+ from ..models import User
13
+ from .dda_auth import current_dda_user, require_min_role
14
  from .detect_service import run_detection_and_save
15
  from .geotiff_io import load_rgb_pil
16
 
 
142
 
143
  @router.post("/local/upload")
144
  async def local_upload(
145
+ request: Request,
146
  file: UploadFile = File(...),
147
  year: int = Form(...),
148
+ db: Session = Depends(get_db),
149
+ user: User = Depends(current_dda_user),
150
  ):
151
  """Upload GeoTIFF into persistent library_sources/YEAR/ (required on HF)."""
152
  _require_dda()
153
+ require_min_role(user, db, "uploader")
154
  if year < 1990 or year > 2100:
155
  raise HTTPException(status_code=400, detail="year must be between 1990 and 2100")
156
 
 
205
 
206
  @router.post("/detect/from-library")
207
  async def detect_from_library(
208
+ request: Request,
209
  base_path: str = Form(...),
210
  comparison_path: str = Form(...),
211
  method: str = Form("AI-Based Deep Learning"),
 
218
  min_region_area: Optional[int] = Form(150),
219
  notify_email: Optional[str] = Form(None),
220
  db: Session = Depends(get_db),
221
+ user: User = Depends(current_dda_user),
222
  ):
223
  """Run change detection on two library images by relative path (e.g. 2025/aerial.tif)."""
224
  _require_dda()
 
270
  notify_email=notify_email,
271
  max_size=max_side,
272
  geo_bounds_path=base_file,
273
+ user_id=user.id,
274
  )
275
  except Exception as exc:
276
  logger.exception("Library detection failed for %s vs %s", base_norm, comp_norm)
app/dda/reports_routes.py CHANGED
@@ -9,10 +9,10 @@ from fastapi.responses import Response
9
  from pydantic import BaseModel, EmailStr
10
  from sqlalchemy.orm import Session
11
 
12
- from ..auth import get_or_create_guest_user
13
  from ..database import get_db
14
- from ..models import DetectionRun
15
  from ..notifier import send_notification
 
16
  from .config import get_public_base_url
17
  from .report_pdf import build_report_dict, generate_report_pdf
18
  from .review_service import load_regions, merge_reviews
@@ -38,10 +38,9 @@ def _get_user_run(db: Session, run_id: int, user_id: int) -> DetectionRun:
38
 
39
 
40
  @router.get("/reports/{run_id}")
41
- def get_report(run_id: int, db: Session = Depends(get_db)):
42
  """JSON payload for the standalone report page and API clients."""
43
  _require_dda()
44
- user = get_or_create_guest_user(db)
45
  run = _get_user_run(db, run_id, user.id)
46
  regions = merge_reviews(db, run.id, load_regions(run))
47
  data = build_report_dict(run, include_overlay_b64=True, regions=regions)
@@ -50,10 +49,9 @@ def get_report(run_id: int, db: Session = Depends(get_db)):
50
 
51
 
52
  @router.get("/reports/{run_id}/pdf")
53
- def download_report_pdf(run_id: int, db: Session = Depends(get_db)):
54
  """Download detection report as PDF."""
55
  _require_dda()
56
- user = get_or_create_guest_user(db)
57
  run = _get_user_run(db, run_id, user.id)
58
  regions = merge_reviews(db, run.id, load_regions(run))
59
  try:
@@ -75,10 +73,9 @@ class ReportNotifyBody(BaseModel):
75
 
76
 
77
  @router.post("/reports/{run_id}/notify")
78
- def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get_db)):
79
  """Email report summary with link to the browser report page."""
80
  _require_dda()
81
- user = get_or_create_guest_user(db)
82
  run = _get_user_run(db, run_id, user.id)
83
  regions = merge_reviews(db, run.id, load_regions(run))
84
  report_url = f"{get_public_base_url()}/dda/reports/{run.id}"
 
9
  from pydantic import BaseModel, EmailStr
10
  from sqlalchemy.orm import Session
11
 
 
12
  from ..database import get_db
13
+ from ..models import DetectionRun, User
14
  from ..notifier import send_notification
15
+ from .dda_auth import current_dda_user
16
  from .config import get_public_base_url
17
  from .report_pdf import build_report_dict, generate_report_pdf
18
  from .review_service import load_regions, merge_reviews
 
38
 
39
 
40
  @router.get("/reports/{run_id}")
41
+ def get_report(run_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
42
  """JSON payload for the standalone report page and API clients."""
43
  _require_dda()
 
44
  run = _get_user_run(db, run_id, user.id)
45
  regions = merge_reviews(db, run.id, load_regions(run))
46
  data = build_report_dict(run, include_overlay_b64=True, regions=regions)
 
49
 
50
 
51
  @router.get("/reports/{run_id}/pdf")
52
+ def download_report_pdf(run_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
53
  """Download detection report as PDF."""
54
  _require_dda()
 
55
  run = _get_user_run(db, run_id, user.id)
56
  regions = merge_reviews(db, run.id, load_regions(run))
57
  try:
 
73
 
74
 
75
  @router.post("/reports/{run_id}/notify")
76
+ def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
77
  """Email report summary with link to the browser report page."""
78
  _require_dda()
 
79
  run = _get_user_run(db, run_id, user.id)
80
  regions = merge_reviews(db, run.id, load_regions(run))
81
  report_url = f"{get_public_base_url()}/dda/reports/{run.id}"
app/dda/review_routes.py CHANGED
@@ -9,9 +9,9 @@ from fastapi.responses import Response
9
  from pydantic import BaseModel
10
  from sqlalchemy.orm import Session
11
 
12
- from ..auth import get_or_create_guest_user
13
  from ..database import get_db
14
- from ..models import DetectionRun
 
15
  from .dept_export import ExportResult, get_exporter, regions_to_csv_rows
16
  from .review_service import (
17
  filter_regions_by_review,
@@ -53,10 +53,10 @@ def patch_region_review(
53
  region_id: int,
54
  body: RegionReviewBody,
55
  db: Session = Depends(get_db),
 
56
  ):
57
  """Mark a region as confirmed or false positive."""
58
  _require_dda()
59
- user = get_or_create_guest_user(db)
60
  run = _get_user_run(db, run_id, user.id)
61
  if body.reviewStatus not in ("confirmed", "false_positive", "pending"):
62
  raise HTTPException(status_code=400, detail="reviewStatus must be confirmed, false_positive, or pending")
@@ -74,9 +74,8 @@ def patch_region_review(
74
 
75
 
76
  @router.get("/reports/{run_id}/review-summary")
77
- def get_review_summary(run_id: int, db: Session = Depends(get_db)):
78
  _require_dda()
79
- user = get_or_create_guest_user(db)
80
  run = _get_user_run(db, run_id, user.id)
81
  regions = merge_reviews(db, run.id, load_regions(run))
82
  return {"runId": run.id, "summary": review_summary(regions)}
@@ -87,10 +86,10 @@ def export_csv(
87
  run_id: int,
88
  confirmed: int = Query(0, ge=0, le=1),
89
  db: Session = Depends(get_db),
 
90
  ):
91
  """Export regions as CSV. Use ?confirmed=1 for confirmed-only."""
92
  _require_dda()
93
- user = get_or_create_guest_user(db)
94
  run = _get_user_run(db, run_id, user.id)
95
  regions = merge_reviews(db, run.id, load_regions(run))
96
  if confirmed:
@@ -108,10 +107,9 @@ def export_csv(
108
 
109
 
110
  @router.post("/reports/{run_id}/submit")
111
- def submit_confirmed(run_id: int, db: Session = Depends(get_db)):
112
  """Submit confirmed regions to departmental API (or file fallback)."""
113
  _require_dda()
114
- user = get_or_create_guest_user(db)
115
  run = _get_user_run(db, run_id, user.id)
116
  regions = merge_reviews(db, run.id, load_regions(run))
117
  confirmed = filter_regions_by_review(regions, "confirmed")
 
9
  from pydantic import BaseModel
10
  from sqlalchemy.orm import Session
11
 
 
12
  from ..database import get_db
13
+ from ..models import DetectionRun, User
14
+ from .dda_auth import current_dda_user
15
  from .dept_export import ExportResult, get_exporter, regions_to_csv_rows
16
  from .review_service import (
17
  filter_regions_by_review,
 
53
  region_id: int,
54
  body: RegionReviewBody,
55
  db: Session = Depends(get_db),
56
+ user: User = Depends(current_dda_user),
57
  ):
58
  """Mark a region as confirmed or false positive."""
59
  _require_dda()
 
60
  run = _get_user_run(db, run_id, user.id)
61
  if body.reviewStatus not in ("confirmed", "false_positive", "pending"):
62
  raise HTTPException(status_code=400, detail="reviewStatus must be confirmed, false_positive, or pending")
 
74
 
75
 
76
  @router.get("/reports/{run_id}/review-summary")
77
+ def get_review_summary(run_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
78
  _require_dda()
 
79
  run = _get_user_run(db, run_id, user.id)
80
  regions = merge_reviews(db, run.id, load_regions(run))
81
  return {"runId": run.id, "summary": review_summary(regions)}
 
86
  run_id: int,
87
  confirmed: int = Query(0, ge=0, le=1),
88
  db: Session = Depends(get_db),
89
+ user: User = Depends(current_dda_user),
90
  ):
91
  """Export regions as CSV. Use ?confirmed=1 for confirmed-only."""
92
  _require_dda()
 
93
  run = _get_user_run(db, run_id, user.id)
94
  regions = merge_reviews(db, run.id, load_regions(run))
95
  if confirmed:
 
107
 
108
 
109
  @router.post("/reports/{run_id}/submit")
110
+ def submit_confirmed(run_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
111
  """Submit confirmed regions to departmental API (or file fallback)."""
112
  _require_dda()
 
113
  run = _get_user_run(db, run_id, user.id)
114
  regions = merge_reviews(db, run.id, load_regions(run))
115
  confirmed = filter_regions_by_review(regions, "confirmed")
app/dda/training_routes.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """False-positive training archive export (FR-08 Phase 2 / Phase 7)."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import io
6
+ import json
7
+ import logging
8
+
9
+ from fastapi import APIRouter, Depends, Query, Request
10
+ from fastapi.responses import Response
11
+ from sqlalchemy.orm import Session
12
+
13
+ from ..database import get_db
14
+ from ..models import DetectionRun, User
15
+ from .dda_auth import current_dda_user, require_admin_or_key
16
+ from .geo_regions import region_lat_lng
17
+ from .models import RegionReview
18
+ from .review_service import load_regions
19
+
20
+ logger = logging.getLogger(__name__)
21
+ router = APIRouter()
22
+
23
+
24
+ def _require_dda():
25
+ from .config import IS_DDA_MODE
26
+ if not IS_DDA_MODE:
27
+ from fastapi import HTTPException
28
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
29
+
30
+
31
+ @router.get("/training/export")
32
+ def export_false_positives(
33
+ request: Request,
34
+ fmt: str = Query("csv"),
35
+ db: Session = Depends(get_db),
36
+ user: User = Depends(current_dda_user),
37
+ ):
38
+ """Export all false-positive regions for model training (admin or export key)."""
39
+ _require_dda()
40
+ require_admin_or_key(request, user, db)
41
+
42
+ if fmt not in ("csv", "json"):
43
+ from fastapi import HTTPException
44
+ raise HTTPException(status_code=400, detail="fmt must be csv or json")
45
+
46
+ reviews = (
47
+ db.query(RegionReview)
48
+ .filter(RegionReview.status == "false_positive")
49
+ .order_by(RegionReview.reviewed_at.desc())
50
+ .all()
51
+ )
52
+ rows = []
53
+ for rev in reviews:
54
+ run = db.query(DetectionRun).filter(DetectionRun.id == rev.run_id).first()
55
+ if not run:
56
+ continue
57
+ regions = load_regions(run)
58
+ region = next((r for r in regions if int(r.get("id", -1)) == rev.region_id), None)
59
+ if not region:
60
+ continue
61
+ lat, lng = region_lat_lng(region)
62
+ rows.append({
63
+ "runId": run.id,
64
+ "runTitle": run.title,
65
+ "regionId": rev.region_id,
66
+ "ddaChangeType": region.get("ddaChangeType") or region.get("objectType"),
67
+ "internalType": region.get("internalObjectType") or region.get("objectType"),
68
+ "confidence": region.get("confidence"),
69
+ "areaPx": region.get("area"),
70
+ "latitude": lat,
71
+ "longitude": lng,
72
+ "notes": rev.notes or "",
73
+ "reviewedAt": rev.reviewed_at.isoformat() if rev.reviewed_at else None,
74
+ })
75
+
76
+ if fmt == "json":
77
+ return {"count": len(rows), "falsePositives": rows}
78
+
79
+ buf = io.StringIO()
80
+ writer = csv.writer(buf)
81
+ writer.writerow([
82
+ "run_id", "run_title", "region_id", "dda_change_type", "internal_type",
83
+ "confidence", "area_px", "latitude", "longitude", "notes", "reviewed_at",
84
+ ])
85
+ for r in rows:
86
+ writer.writerow([
87
+ r["runId"], r["runTitle"], r["regionId"], r["ddaChangeType"], r["internalType"],
88
+ r.get("confidence"), r.get("areaPx"), r.get("latitude"), r.get("longitude"),
89
+ r.get("notes"), r.get("reviewedAt"),
90
+ ])
91
+ return Response(
92
+ content=buf.getvalue(),
93
+ media_type="text/csv",
94
+ headers={"Content-Disposition": 'attachment; filename="dda_false_positives.csv"'},
95
+ )
app/main.py CHANGED
@@ -8,7 +8,7 @@ from pathlib import Path
8
  from typing import Optional
9
 
10
  from sqlalchemy import text as sa_text
11
- from fastapi import FastAPI, Depends, File, Form, HTTPException, UploadFile
12
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
13
  from fastapi.staticfiles import StaticFiles
14
  from pydantic import BaseModel
@@ -75,10 +75,31 @@ except Exception as e:
75
  import logging
76
  logging.getLogger("uvicorn.error").warning("Startup migration skipped: %s", e)
77
 
78
- app = FastAPI(title="AI Change Detection", version="2.3.0-dda" if IS_DDA_MODE else "2.2.0")
79
  setup_dda(app)
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  @app.get("/health")
83
  def health():
84
  """Health check + AdaptFormer model status (HF Spaces + diagnostics)."""
@@ -86,14 +107,25 @@ def health():
86
  from .model_inference import get_model_status
87
 
88
  model = get_model_status()
89
- return {
90
  "status": "ok" if model.get("available") else "degraded",
91
- "version": "2.3.0-dda" if IS_DDA_MODE else "2.2.1",
92
  "appMode": "dda" if IS_DDA_MODE else "legacy",
93
  "spaceId": os.environ.get("SPACE_ID", ""),
94
  "server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
95
  "adaptFormer": model,
96
  }
 
 
 
 
 
 
 
 
 
 
 
97
 
98
 
99
  @app.on_event("startup")
@@ -448,9 +480,14 @@ def serve_overlay(path: str):
448
  # --- History ---
449
  @app.get("/api/history")
450
  def history(
 
451
  db: Session = Depends(get_db),
452
  ):
453
- user = get_or_create_guest_user(db)
 
 
 
 
454
  runs = db.query(DetectionRun).filter(DetectionRun.user_id == user.id).order_by(DetectionRun.created_at.desc()).limit(100).all()
455
  return [
456
  {
@@ -475,10 +512,15 @@ def history(
475
  @app.get("/api/history/{run_id}")
476
  def get_run(
477
  run_id: int,
 
478
  db: Session = Depends(get_db),
479
  ):
480
  """Fetch a single run by id for opening from history (result view with slider, table, zoom)."""
481
- user = get_or_create_guest_user(db)
 
 
 
 
482
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
483
  if not run:
484
  raise HTTPException(status_code=404, detail="Run not found")
@@ -545,9 +587,15 @@ def notify_run(
545
  @app.delete("/api/history/{run_id}")
546
  def delete_run(
547
  run_id: int,
 
548
  db: Session = Depends(get_db),
549
  ):
550
- user = get_or_create_guest_user(db)
 
 
 
 
 
551
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
552
  if not run:
553
  raise HTTPException(status_code=404, detail="Run not found")
 
8
  from typing import Optional
9
 
10
  from sqlalchemy import text as sa_text
11
+ from fastapi import FastAPI, Depends, File, Form, HTTPException, Request, UploadFile
12
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
13
  from fastapi.staticfiles import StaticFiles
14
  from pydantic import BaseModel
 
75
  import logging
76
  logging.getLogger("uvicorn.error").warning("Startup migration skipped: %s", e)
77
 
78
+ app = FastAPI(title="AI Change Detection", version="2.4.0-dda" if IS_DDA_MODE else "2.2.0")
79
  setup_dda(app)
80
 
81
 
82
+ if IS_DDA_MODE:
83
+ from .dda.dda_auth import DDA_SESSION_COOKIE
84
+
85
+ @app.middleware("http")
86
+ async def dda_session_middleware(request: Request, call_next):
87
+ session_id = request.cookies.get(DDA_SESSION_COOKIE)
88
+ new_session = not session_id
89
+ if new_session:
90
+ session_id = str(uuid.uuid4())
91
+ response = await call_next(request)
92
+ if new_session:
93
+ response.set_cookie(
94
+ DDA_SESSION_COOKIE,
95
+ session_id,
96
+ httponly=True,
97
+ max_age=60 * 60 * 24 * 30,
98
+ samesite="lax",
99
+ )
100
+ return response
101
+
102
+
103
  @app.get("/health")
104
  def health():
105
  """Health check + AdaptFormer model status (HF Spaces + diagnostics)."""
 
107
  from .model_inference import get_model_status
108
 
109
  model = get_model_status()
110
+ payload = {
111
  "status": "ok" if model.get("available") else "degraded",
112
+ "version": "2.4.0-dda" if IS_DDA_MODE else "2.2.1",
113
  "appMode": "dda" if IS_DDA_MODE else "legacy",
114
  "spaceId": os.environ.get("SPACE_ID", ""),
115
  "server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
116
  "adaptFormer": model,
117
  }
118
+ if IS_DDA_MODE:
119
+ try:
120
+ from .dda.job_runner import is_job_runner_busy
121
+ from .dda.local_library import scan_images
122
+ payload["dda"] = {
123
+ "libraryImages": len(scan_images()),
124
+ "jobRunnerBusy": is_job_runner_busy(),
125
+ }
126
+ except Exception as exc:
127
+ payload["dda"] = {"error": str(exc)[:200]}
128
+ return payload
129
 
130
 
131
  @app.on_event("startup")
 
480
  # --- History ---
481
  @app.get("/api/history")
482
  def history(
483
+ request: Request,
484
  db: Session = Depends(get_db),
485
  ):
486
+ if IS_DDA_MODE:
487
+ from .dda.dda_auth import get_dda_user
488
+ user = get_dda_user(request, db)
489
+ else:
490
+ user = get_or_create_guest_user(db)
491
  runs = db.query(DetectionRun).filter(DetectionRun.user_id == user.id).order_by(DetectionRun.created_at.desc()).limit(100).all()
492
  return [
493
  {
 
512
  @app.get("/api/history/{run_id}")
513
  def get_run(
514
  run_id: int,
515
+ request: Request,
516
  db: Session = Depends(get_db),
517
  ):
518
  """Fetch a single run by id for opening from history (result view with slider, table, zoom)."""
519
+ if IS_DDA_MODE:
520
+ from .dda.dda_auth import get_dda_user
521
+ user = get_dda_user(request, db)
522
+ else:
523
+ user = get_or_create_guest_user(db)
524
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
525
  if not run:
526
  raise HTTPException(status_code=404, detail="Run not found")
 
587
  @app.delete("/api/history/{run_id}")
588
  def delete_run(
589
  run_id: int,
590
+ request: Request,
591
  db: Session = Depends(get_db),
592
  ):
593
+ if IS_DDA_MODE:
594
+ from .dda.dda_auth import get_dda_user, require_min_role
595
+ user = get_dda_user(request, db)
596
+ require_min_role(user, db, "analyst")
597
+ else:
598
+ user = get_or_create_guest_user(db)
599
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
600
  if not run:
601
  raise HTTPException(status_code=404, detail="Run not found")
app/models.py CHANGED
@@ -16,6 +16,7 @@ class User(Base):
16
  email = Column(String(255), unique=True, index=True, nullable=False)
17
  hashed_password = Column(String(255), nullable=False)
18
  full_name = Column(String(255), default="")
 
19
  created_at = Column(DateTime, default=_utcnow)
20
 
21
  detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
 
16
  email = Column(String(255), unique=True, index=True, nullable=False)
17
  hashed_password = Column(String(255), nullable=False)
18
  full_name = Column(String(255), default="")
19
+ role = Column(String(32), default="analyst")
20
  created_at = Column(DateTime, default=_utcnow)
21
 
22
  detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")