Spaces:
Sleeping
Sleeping
Commit ·
ac7ea7c
1
Parent(s): bec7397
Complete Phase 7: session users, RBAC, training export, and UAT hardening.
Browse filesPer-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 +5 -0
- DEPLOYMENT.md +22 -0
- DEV_SETUP.md +2 -0
- Dockerfile +1 -1
- app/dda/admin_routes.py +97 -0
- app/dda/bootstrap.py +7 -1
- app/dda/dda_auth.py +140 -0
- app/dda/detect_service.py +8 -1
- app/dda/job_runner.py +1 -0
- app/dda/jobs_routes.py +6 -7
- app/dda/local_routes.py +10 -1
- app/dda/reports_routes.py +5 -8
- app/dda/review_routes.py +6 -8
- app/dda/training_routes.py +95 -0
- app/main.py +55 -7
- app/models.py +1 -0
.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=
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 90 |
"status": "ok" if model.get("available") else "degraded",
|
| 91 |
-
"version": "2.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)")
|