diff --git a/.gitignore b/.gitignore
index 96d78ee4b09be01bd6542780bbc77b37269a4801..378fb7a26f3a24a90fd12b085104fb8bf13b0d43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,9 +14,6 @@ data/library_sources/**/*.tif
data/library_sources/**/*.tiff
data/library_sources/**/*.TIF
data/library_sources/**/*.TIFF
-data/library_sources/**/*.png
-data/library_sources/**/*.jpg
-data/library_sources/**/*.jpeg
!library_sources/**/.gitkeep
.env
data/.secret_key
diff --git a/Dockerfile b/Dockerfile
index 387db1c69c69173e8044790dc4d8411ff46281f0..95b3a9491e2d4de877a82467b54fc49bcfb32588 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,7 +21,7 @@ WORKDIR /app
# Build-time info + cache-bust:
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
-ARG APP_BUILD=38
+ARG APP_BUILD=37
ENV MAX_GEOTIFF_MB=15360
ENV APP_BUILD=${APP_BUILD}
ENV GDAL_CONFIG=/usr/bin/gdal-config
diff --git a/app/auth.py b/app/auth.py
index c51e1d5bbf79d2d0f7274b17733aa0aafa5960ec..3a75a1cc188fa0675bc0e78eee9df66c9fad07ba 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -201,6 +201,14 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+def revoke_user_sessions(user: User) -> None:
+ """Invalidate every previously-issued session token for `user` (any tab,
+ device, or leaked copy) by bumping token_version — get_user_from_token()
+ rejects any token whose embedded 'tv' no longer matches. Caller commits.
+ """
+ user.token_version = int(user.token_version or 0) + 1
+
+
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 30
@@ -371,7 +379,14 @@ def get_user_from_token(token: str, db: Session, *, allow_pending_otp: bool = Fa
return None
except JWTError:
return None
- return get_user_by_id(db, user_id)
+ user = get_user_by_id(db, user_id)
+ if user is None:
+ return None
+ if purpose == "session" and int(payload.get("tv", 0)) != int(user.token_version or 0):
+ # Token was issued before a logout / password change bumped
+ # token_version — reject it even though it hasn't expired yet.
+ return None
+ return user
LOGIN_OTP_EXPIRE_MINUTES = 10
diff --git a/app/database.py b/app/database.py
index f39c19d41f0cc98e0832a6295cd7451da138e4e2..ef703904e7c1718dec94cf27c1fceef24aded147 100644
--- a/app/database.py
+++ b/app/database.py
@@ -28,15 +28,28 @@ if DATABASE_URL.startswith("sqlite"):
# timeout = busy wait while another connection holds the write lock
# (progress updates vs the long-running detection worker).
connect_args = {"check_same_thread": False, "timeout": 60.0}
+elif DATABASE_URL.startswith("mysql"):
+ # connect_timeout: fail a broken/unreachable connection attempt in 10s
+ # instead of hanging the request (and the page) indefinitely — matters
+ # for a remote MySQL host reached over the network.
+ connect_args = {"connect_timeout": 10}
+
+_is_sqlite = DATABASE_URL.startswith("sqlite")
# pool_pre_ping: checks each connection before use and transparently reconnects
# if it's gone stale — matters for a remote DB (MySQL/Postgres) reached over
# the network, where idle connections can be dropped by the server or a
# firewall; irrelevant (and skipped) for local SQLite.
+# pool_recycle: proactively discard and replace any pooled connection older
+# than 280s — shorter than the "wait_timeout" most managed MySQL hosts use to
+# silently drop idle connections server-side, so the pool never hands out a
+# connection the server has already closed (seen in production as
+# "OperationalError 2013: Lost connection to MySQL server during query").
engine = create_engine(
DATABASE_URL,
connect_args=connect_args,
- pool_pre_ping=not DATABASE_URL.startswith("sqlite"),
+ pool_pre_ping=not _is_sqlite,
+ pool_recycle=280 if not _is_sqlite else -1,
)
diff --git a/app/dda/auto_detect.py b/app/dda/auto_detect.py
index e4f4c9933e2804c6d098526c3788a6e382a69704..2aa323314eddcc6167f121ea347111f16b1044ad 100644
--- a/app/dda/auto_detect.py
+++ b/app/dda/auto_detect.py
@@ -17,6 +17,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..database import SessionLocal
@@ -257,13 +258,18 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
live_keys = set()
now = _utcnow()
for pair in pairs:
- key = pair_key_for(pair["before_path"], pair["after_path"])
+ before, after = pair["before_path"], pair["after_path"]
+ if not before or not after or before == after:
+ continue
+ key = pair_key_for(before, after)
+ if key in live_keys:
+ continue
live_keys.add(key)
row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
if row:
row.group_label = pair["label"]
- row.before_path = pair["before_path"]
- row.after_path = pair["after_path"]
+ row.before_path = before
+ row.after_path = after
row.interval_days = interval
row.enabled = True
row.updated_at = now
@@ -271,8 +277,8 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
db.add(AutoPairSchedule(
pair_key=key,
group_label=pair["label"],
- before_path=pair["before_path"],
- after_path=pair["after_path"],
+ before_path=before,
+ after_path=after,
interval_days=interval,
enabled=True,
))
@@ -285,7 +291,11 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
if row.pair_key not in live_keys:
row.enabled = False
row.updated_at = now
- db.commit()
+ try:
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ logger.warning("Auto-pair schedule sync hit a duplicate pair_key; it will retry on the next tick.")
def _pending_job_for_paths(db: Session, before: str, after: str) -> Optional[DetectionJob]:
diff --git a/app/dda/bootstrap.py b/app/dda/bootstrap.py
index d27a2c8b8886801c0853a281a2cc3a95d4ba5217..da95974198841e3fbc0877d3a12f3545d75aaf46 100644
--- a/app/dda/bootstrap.py
+++ b/app/dda/bootstrap.py
@@ -103,10 +103,37 @@ def init_dda_database():
"ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0",
"ALTER TABLE users ADD COLUMN totp_confirmed_at DATETIME DEFAULT NULL",
"ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL",
+ "ALTER TABLE users ADD COLUMN token_version INTEGER DEFAULT 0",
"ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
+ "ALTER TABLE detection_runs ADD COLUMN detection_sensitivity FLOAT DEFAULT NULL",
+ "ALTER TABLE detection_runs ADD COLUMN min_region_area INTEGER DEFAULT NULL",
"ALTER TABLE dda_roles ADD COLUMN is_active BOOLEAN DEFAULT 1",
"ALTER TABLE dda_audit_logs ADD COLUMN role VARCHAR(64) DEFAULT ''",
"ALTER TABLE dda_audit_logs ADD COLUMN module VARCHAR(64) DEFAULT ''",
+ "ALTER TABLE dda_image_library ADD COLUMN zone_id INTEGER DEFAULT NULL",
+ "ALTER TABLE dda_image_library ADD COLUMN village_id INTEGER DEFAULT NULL",
+ "ALTER TABLE dda_image_library ADD COLUMN year INTEGER DEFAULT NULL",
+ "ALTER TABLE dda_image_library ADD COLUMN source VARCHAR(32) DEFAULT ''",
+ "ALTER TABLE dda_exception_logs ADD COLUMN user_role VARCHAR(64) DEFAULT NULL",
+ ):
+ try:
+ conn.execute(sa_text(stmt))
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ # Enforce "every image has a zone and village" at the database
+ # level, not just in application code — this used to be
+ # optional, which is how thousands of images ended up
+ # "Unassigned" (bulk upload with no location columns, and the
+ # filesystem sync auto-indexing files with none to give).
+ # MySQL-only (SQLite can't ALTER a column's nullability); this
+ # fails harmlessly and keeps retrying on every startup until any
+ # pre-existing NULL rows are cleaned up, at which point it
+ # sticks — same best-effort pattern as every other statement
+ # above.
+ for stmt in (
+ "ALTER TABLE dda_image_library MODIFY COLUMN zone_id INTEGER NOT NULL",
+ "ALTER TABLE dda_image_library MODIFY COLUMN village_id INTEGER NOT NULL",
):
try:
conn.execute(sa_text(stmt))
@@ -128,6 +155,19 @@ def init_dda_database():
conn.commit()
except Exception:
conn.rollback()
+ for stmt in (
+ "CREATE INDEX IF NOT EXISTS ix_dda_image_library_zone_id "
+ "ON dda_image_library (zone_id)",
+ "CREATE INDEX IF NOT EXISTS ix_dda_image_library_village_id "
+ "ON dda_image_library (village_id)",
+ "CREATE INDEX IF NOT EXISTS ix_dda_image_library_year "
+ "ON dda_image_library (year)",
+ ):
+ try:
+ conn.execute(sa_text(stmt))
+ conn.commit()
+ except Exception:
+ conn.rollback()
_migrate_detection_jobs_nullable()
except Exception as exc:
logger.warning("DDA schema migration skipped: %s", exc)
diff --git a/app/dda/dashboard_routes.py b/app/dda/dashboard_routes.py
index 9d78704625e210a0621aab87a1d5892505e6147b..e02e85609dc74bbb8def8240a84f71d27868332b 100644
--- a/app/dda/dashboard_routes.py
+++ b/app/dda/dashboard_routes.py
@@ -8,6 +8,7 @@ different dashboards, exactly like they already see different Reports lists.
"""
from __future__ import annotations
+import json
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -28,6 +29,12 @@ router = APIRouter()
UNASSIGNED = "Unassigned"
DEFAULT_ROLE = "analyst"
+# Fixed taxonomy every region is already classified into — see
+# app/dda/change_type_map.py::map_to_dda_change_type. Kept here (not
+# imported) purely to pin a stable display order across every chart/table
+# that breaks a count down by change type.
+CHANGE_TYPE_ORDER = ["New Construction", "Demolition", "Extension", "Vegetation Change", "Other"]
+
# Matches DEFAULT_ROLES rank order in app/dda/rbac/seed.py — keeps the role
# breakdown in a stable, meaningful order instead of alphabetical/count order.
ROLE_ORDER = ["viewer", "uploader", "analyst", "admin"]
@@ -62,6 +69,17 @@ def _run_thumbs(run: DetectionRun) -> dict:
}
+def _confirmed_rate(confirmed: int, false_positive: int) -> Optional[float]:
+ """'Accuracy' for a detection: of the regions a human has actually
+ adjudicated as real-or-not, what share were real? None (not 0%) when
+ nothing's been reviewed yet, so the UI can show "not yet reviewed"
+ instead of a misleading 0%."""
+ denom = confirmed + false_positive
+ if denom == 0:
+ return None
+ return round(100 * confirmed / denom, 1)
+
+
def _run_summary(row: dict) -> dict:
run = row["run"]
return {
@@ -76,11 +94,49 @@ def _run_summary(row: dict) -> dict:
"unclassified": row["unclassified"],
"confirmed": row["confirmed"],
"falsePositive": row["falsePositive"],
+ "submitted": row["submitted"],
+ "confirmedRate": _confirmed_rate(row["confirmed"], row["falsePositive"]),
+ "changeTypes": row["changeTypes"],
"createdAt": _isoformat_ist(run.created_at),
**_run_thumbs(run),
}
+def _image_counts_by_location(db: Session) -> tuple[dict[str, int], dict[tuple[str, str], int]]:
+ """Image Library counts grouped by zone name and by (zone, village) name
+ — joined through the same DdaZone/DdaVillage masters the dashboard's
+ zone/village strings already come from, so the names line up exactly
+ with what dashboard_regions()/dashboard_villages() group by."""
+ from sqlalchemy import func as sa_func
+
+ from .models import DdaVillage, DdaZone
+ from .tree.models import ImageLibrary
+
+ by_zone: dict[str, int] = defaultdict(int)
+ by_village: dict[tuple[str, str], int] = defaultdict(int)
+
+ zone_rows = (
+ db.query(DdaZone.name, sa_func.count(ImageLibrary.id))
+ .join(ImageLibrary, ImageLibrary.zone_id == DdaZone.id)
+ .group_by(DdaZone.name)
+ .all()
+ )
+ for name, cnt in zone_rows:
+ by_zone[name] = cnt
+
+ village_rows = (
+ db.query(DdaZone.name, DdaVillage.name, sa_func.count(ImageLibrary.id))
+ .join(DdaVillage, DdaVillage.zone_id == DdaZone.id)
+ .join(ImageLibrary, ImageLibrary.village_id == DdaVillage.id)
+ .group_by(DdaZone.name, DdaVillage.name)
+ .all()
+ )
+ for zone_name, village_name, cnt in village_rows:
+ by_village[(zone_name, village_name)] = cnt
+
+ return by_zone, by_village
+
+
def _accessible_run_filter(db: Session, user: User):
"""Same run-visibility rule as GET /api/history in app/main.py: the
user's own runs, plus any run that came from the auto-schedule (visible
@@ -101,51 +157,144 @@ def _accessible_run_filter(db: Session, user: User):
return filt
-def _load_dataset(db: Session, user: User) -> list[dict]:
+def _parse_date_bound(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
+ """Parse a 'YYYY-MM-DD' filter bound (as sent by an ) as
+ an IST calendar day — every timestamp in this app is shown in IST, so a
+ user picking "7 Sep" means the whole IST day, not UTC midnight — then
+ convert to UTC since DetectionRun.created_at is stored in UTC."""
+ if not value or not value.strip():
+ return None
+ try:
+ d = datetime.strptime(value.strip(), "%Y-%m-%d")
+ except ValueError:
+ raise HTTPException(status_code=400, detail=f"Invalid date '{value}', expected YYYY-MM-DD")
+ if end_of_day:
+ d = d + timedelta(days=1, microseconds=-1)
+ return d.replace(tzinfo=_IST).astimezone(timezone.utc)
+
+
+def _live_locations_for(db: Session, run_ids: list[int]) -> dict[int, tuple[str, str]]:
+ """Zone/village for runs that still have a blank one, re-derived on every
+ call from the run's original before/after image paths (kept on its
+ DetectionJob) and those images' *current* zone/village. This is what
+ makes assigning a location to an image in the Image Library immediately
+ reflected on the dashboard for every past run that used it — no separate
+ backfill step, no stale copy to re-sync."""
+ if not run_ids:
+ return {}
+ from .tree.image_service import resolve_run_location
+
+ jobs = db.query(DetectionJob).filter(DetectionJob.run_id.in_(run_ids)).all()
+ out: dict[int, tuple[str, str]] = {}
+ for job in jobs:
+ try:
+ params = json.loads(job.params_json or "{}")
+ except json.JSONDecodeError:
+ continue
+ base_path = params.get("base_path", "")
+ comparison_path = params.get("comparison_path", "")
+ if not base_path and not comparison_path:
+ continue
+ out[job.run_id] = resolve_run_location(db, base_path, comparison_path)
+ return out
+
+
+def _run_change_type_counts(run: DetectionRun) -> dict[str, int]:
+ """How many of this run's regions fall into each fixed change-type
+ category (New Construction/Demolition/Extension/Vegetation Change/
+ Other) — already computed per region at detection time (see
+ change_type_map.py) and stored in regions_json; this just tallies it."""
+ try:
+ regions = json.loads(run.regions_json or "[]")
+ except json.JSONDecodeError:
+ return {}
+ counts: dict[str, int] = defaultdict(int)
+ for region in regions:
+ if region.get("suppressed"):
+ continue
+ ctype = region.get("ddaChangeType") or "Other"
+ counts[ctype] += 1
+ return dict(counts)
+
+
+def _merge_change_type_counts(items: list[dict]) -> dict[str, int]:
+ """Sum per-run changeTypes dicts into one, ordered per CHANGE_TYPE_ORDER
+ with any unrecognized label appended at the end rather than dropped."""
+ total: dict[str, int] = defaultdict(int)
+ for i in items:
+ for ctype, cnt in i["changeTypes"].items():
+ total[ctype] += cnt
+ ordered = {k: total[k] for k in CHANGE_TYPE_ORDER if k in total}
+ ordered.update({k: v for k, v in total.items() if k not in ordered})
+ return ordered
+
+
+def _load_dataset(
+ db: Session,
+ user: User,
+ *,
+ date_from: Optional[str] = None,
+ date_to: Optional[str] = None,
+) -> list[dict]:
"""One query for every accessible run + one grouped query for
classification counts; every endpoint below aggregates from this in
Python — mirrors the simple query style already used by
admin_routes.admin_status(). Runs with a blank zone/village are bucketed
- under "Unassigned" so totals reconcile.
+ under "Unassigned" so totals reconcile, after first trying to live-derive
+ one from the source images (see _live_locations_for). ``date_from``/
+ ``date_to`` are optional 'YYYY-MM-DD' bounds on created_at (inclusive on
+ both ends).
"""
- runs = (
- db.query(DetectionRun)
- .filter(_accessible_run_filter(db, user))
- .order_by(DetectionRun.created_at.asc())
- .all()
- )
+ from_dt = _parse_date_bound(date_from)
+ to_dt = _parse_date_bound(date_to, end_of_day=True)
+
+ query = db.query(DetectionRun).filter(_accessible_run_filter(db, user))
+ if from_dt:
+ query = query.filter(DetectionRun.created_at >= from_dt)
+ if to_dt:
+ query = query.filter(DetectionRun.created_at <= to_dt)
+ runs = query.order_by(DetectionRun.created_at.asc()).all()
counts = bulk_classification_counts(db)
# Same role resolution as dda_auth.get_user_role (raw User.role string,
# falling back to "analyst") but bulk-loaded to avoid a query per run.
roles_by_user = {uid: (role or DEFAULT_ROLE) for uid, role in db.query(User.id, User.role).all()}
+ blank_ids = [r.id for r in runs if not (r.zone or "").strip() or not (r.village or "").strip()]
+ live = _live_locations_for(db, blank_ids)
rows = []
for run in runs:
+ zone = (run.zone or "").strip()
+ village = (run.village or "").strip()
+ if not zone or not village:
+ auto_zone, auto_village = live.get(run.id, ("", ""))
+ zone = zone or auto_zone
+ village = village or auto_village
c = counts.get(run.id, {})
classified = c.get("confirmed", 0) + c.get("false_positive", 0) + c.get("submitted", 0)
unclassified = max(0, (run.regions_count or 0) - classified)
rows.append({
"run": run,
- "zone": (run.zone or "").strip() or UNASSIGNED,
- "village": (run.village or "").strip() or UNASSIGNED,
+ "zone": zone or UNASSIGNED,
+ "village": village or UNASSIGNED,
"role": roles_by_user.get(run.user_id, DEFAULT_ROLE),
"confirmed": c.get("confirmed", 0),
"falsePositive": c.get("false_positive", 0),
"submitted": c.get("submitted", 0),
"unclassified": unclassified,
+ "changeTypes": _run_change_type_counts(run),
})
return rows
-@router.get("/dashboard/summary")
-def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
- _require_dda()
- rows = _load_dataset(db, user)
-
+def _summary_from_rows(rows: list[dict], db: Session, user: User) -> dict:
total_detected = sum((r["run"].regions_count or 0) for r in rows)
pct_values = [r["run"].change_percentage for r in rows if r["run"].change_percentage is not None]
zones = {r["zone"] for r in rows}
villages = {(r["zone"], r["village"]) for r in rows}
+ from .models import DdaVillage, DdaZone
+ zones_total = db.query(DdaZone).count()
+ villages_total = db.query(DdaVillage).count()
+
return {
"totalRuns": len(rows),
"totalDetectedChanges": total_detected,
@@ -156,43 +305,46 @@ def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(curren
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
"totalRegions": len(zones),
"totalVillages": len(villages),
+ "zonesTotal": zones_total,
+ "villagesTotal": villages_total,
+ "changeTypes": _merge_change_type_counts(rows),
"jobsQueued": db.query(DetectionJob).filter(DetectionJob.status == "queued", DetectionJob.created_by == user.id).count(),
"jobsRunning": db.query(DetectionJob).filter(DetectionJob.status == "running", DetectionJob.created_by == user.id).count(),
"latestRun": _run_summary(rows[-1]) if rows else None,
}
-@router.get("/dashboard/regions")
-def dashboard_regions(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
- _require_dda()
- rows = _load_dataset(db, user)
+def _regions_from_rows(rows: list[dict], db: Session) -> list[dict]:
by_zone: dict[str, list] = defaultdict(list)
for r in rows:
by_zone[r["zone"]].append(r)
+ image_counts_by_zone, _ = _image_counts_by_location(db)
out = []
for zone, items in by_zone.items():
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
+ confirmed = sum(i["confirmed"] for i in items)
+ false_positive = sum(i["falsePositive"] for i in items)
out.append({
"zone": zone,
"runsCount": len(items),
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
"unclassified": sum(i["unclassified"] for i in items),
- "confirmed": sum(i["confirmed"] for i in items),
- "falsePositive": sum(i["falsePositive"] for i in items),
+ "confirmed": confirmed,
+ "falsePositive": false_positive,
+ "submitted": sum(i["submitted"] for i in items),
+ "confirmedRate": _confirmed_rate(confirmed, false_positive),
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
"villagesCount": len({i["village"] for i in items}),
+ "imageCount": image_counts_by_zone.get(zone, 0),
+ "changeTypes": _merge_change_type_counts(items),
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
})
out.sort(key=lambda x: x["runsCount"], reverse=True)
- return {"regions": out}
+ return out
-@router.get("/dashboard/roles")
-def dashboard_roles(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
- """Detection activity grouped by the role of the user who ran it."""
- _require_dda()
- rows = _load_dataset(db, user)
+def _roles_from_rows(rows: list[dict]) -> list[dict]:
by_role: dict[str, list] = defaultdict(list)
for r in rows:
by_role[r["role"]].append(r)
@@ -215,50 +367,42 @@ def dashboard_roles(db: Session = Depends(get_db), user: User = Depends(current_
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
})
out.sort(key=lambda x: (_rank(x["role"]), x["role"]))
- return {"roles": out}
+ return out
-@router.get("/dashboard/villages")
-def dashboard_villages(
- zone: Optional[str] = Query(None),
- db: Session = Depends(get_db),
- user: User = Depends(current_dda_user),
-):
- _require_dda()
- rows = _load_dataset(db, user)
+def _villages_from_rows(rows: list[dict], db: Session, zone: Optional[str] = None) -> list[dict]:
if zone:
rows = [r for r in rows if r["zone"] == zone]
by_village: dict[tuple, list] = defaultdict(list)
for r in rows:
by_village[(r["zone"], r["village"])].append(r)
+ _, image_counts_by_village = _image_counts_by_location(db)
out = []
for (z, v), items in by_village.items():
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
+ confirmed = sum(i["confirmed"] for i in items)
+ false_positive = sum(i["falsePositive"] for i in items)
out.append({
"zone": z,
"village": v,
"runsCount": len(items),
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
"unclassified": sum(i["unclassified"] for i in items),
- "confirmed": sum(i["confirmed"] for i in items),
- "falsePositive": sum(i["falsePositive"] for i in items),
+ "confirmed": confirmed,
+ "falsePositive": false_positive,
+ "submitted": sum(i["submitted"] for i in items),
+ "confirmedRate": _confirmed_rate(confirmed, false_positive),
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
+ "imageCount": image_counts_by_village.get((z, v), 0),
+ "changeTypes": _merge_change_type_counts(items),
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
})
out.sort(key=lambda x: x["runsCount"], reverse=True)
- return {"villages": out}
+ return out
-@router.get("/dashboard/trend")
-def dashboard_trend(
- months: int = Query(6, ge=1, le=24),
- db: Session = Depends(get_db),
- user: User = Depends(current_dda_user),
-):
- _require_dda()
- rows = _load_dataset(db, user)
-
+def _trend_from_rows(rows: list[dict], months: int = 6) -> list[dict]:
now = datetime.now(timezone.utc)
keys = []
y, m = now.year, now.month
@@ -281,7 +425,7 @@ def dashboard_trend(
counts[key]["runsCount"] += 1
counts[key]["detectedChanges"] += (r["run"].regions_count or 0)
- buckets = [
+ return [
{
"period": f"{y}-{m:02d}",
"label": f"{MONTH_NAMES[m]} {y}",
@@ -290,7 +434,87 @@ def dashboard_trend(
}
for (y, m) in keys
]
- return {"buckets": buckets}
+
+
+@router.get("/dashboard/bootstrap")
+def dashboard_bootstrap(
+ date_from: Optional[str] = Query(None),
+ date_to: Optional[str] = Query(None),
+ months: int = Query(6, ge=1, le=24),
+ db: Session = Depends(get_db),
+ user: User = Depends(current_dda_user),
+):
+ """Single payload for home dashboard — one `_load_dataset` pass."""
+ _require_dda()
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
+ return {
+ "summary": _summary_from_rows(rows, db, user),
+ "roles": _roles_from_rows(rows),
+ "regions": _regions_from_rows(rows, db),
+ "villages": _villages_from_rows(rows, db),
+ "trend": {"buckets": _trend_from_rows(rows, months=months)},
+ }
+
+
+@router.get("/dashboard/summary")
+def dashboard_summary(
+ date_from: Optional[str] = Query(None),
+ date_to: Optional[str] = Query(None),
+ db: Session = Depends(get_db),
+ user: User = Depends(current_dda_user),
+):
+ _require_dda()
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
+ return _summary_from_rows(rows, db, user)
+
+
+@router.get("/dashboard/regions")
+def dashboard_regions(
+ date_from: Optional[str] = Query(None),
+ date_to: Optional[str] = Query(None),
+ db: Session = Depends(get_db),
+ user: User = Depends(current_dda_user),
+):
+ _require_dda()
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
+ return {"regions": _regions_from_rows(rows, db)}
+
+
+@router.get("/dashboard/roles")
+def dashboard_roles(
+ date_from: Optional[str] = Query(None),
+ date_to: Optional[str] = Query(None),
+ db: Session = Depends(get_db),
+ user: User = Depends(current_dda_user),
+):
+ """Detection activity grouped by the role of the user who ran it."""
+ _require_dda()
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
+ return {"roles": _roles_from_rows(rows)}
+
+
+@router.get("/dashboard/villages")
+def dashboard_villages(
+ zone: Optional[str] = Query(None),
+ date_from: Optional[str] = Query(None),
+ date_to: Optional[str] = Query(None),
+ db: Session = Depends(get_db),
+ user: User = Depends(current_dda_user),
+):
+ _require_dda()
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
+ return {"villages": _villages_from_rows(rows, db, zone=zone)}
+
+
+@router.get("/dashboard/trend")
+def dashboard_trend(
+ months: int = Query(6, ge=1, le=24),
+ db: Session = Depends(get_db),
+ user: User = Depends(current_dda_user),
+):
+ _require_dda()
+ rows = _load_dataset(db, user)
+ return {"buckets": _trend_from_rows(rows, months=months)}
@router.get("/dashboard/location")
@@ -310,15 +534,28 @@ def dashboard_location(
previous = history[-2] if len(history) > 1 else None
comparison = None
+ summary = None
if latest and previous:
+ change_delta = (
+ round(latest["changePercentage"] - previous["changePercentage"], 2)
+ if latest["changePercentage"] is not None and previous["changePercentage"] is not None
+ else None
+ )
+ regions_delta = latest["regionsCount"] - previous["regionsCount"]
+ rate_delta = (
+ round(latest["confirmedRate"] - previous["confirmedRate"], 1)
+ if latest["confirmedRate"] is not None and previous["confirmedRate"] is not None
+ else None
+ )
comparison = {
- "changePercentageDelta": (
- round(latest["changePercentage"] - previous["changePercentage"], 2)
- if latest["changePercentage"] is not None and previous["changePercentage"] is not None
- else None
- ),
- "regionsCountDelta": latest["regionsCount"] - previous["regionsCount"],
+ "changePercentageDelta": change_delta,
+ "regionsCountDelta": regions_delta,
+ "confirmedDelta": latest["confirmed"] - previous["confirmed"],
+ "falsePositiveDelta": latest["falsePositive"] - previous["falsePositive"],
+ "unclassifiedDelta": latest["unclassified"] - previous["unclassified"],
+ "confirmedRateDelta": rate_delta,
}
+ summary = _build_comparison_summary(latest, previous, comparison)
return {
"zone": zone,
@@ -327,17 +564,112 @@ def dashboard_location(
"latest": latest,
"previous": previous,
"comparison": comparison,
+ "summary": summary,
"history": list(reversed(history)),
}
+def _build_comparison_summary(latest: dict, previous: dict, comparison: dict) -> str:
+ """Plain-English recap of how the latest run compares to the previous
+ one at this location — change area, region count, and review accuracy
+ (confirmed rate) all in one sentence set, so the dashboard doesn't make
+ someone read four numbers to know if things got better or worse."""
+ parts = []
+
+ cd = comparison["changePercentageDelta"]
+ if cd is not None:
+ if abs(cd) < 0.01:
+ parts.append("Change area is essentially unchanged from the previous run.")
+ else:
+ direction = "up" if cd > 0 else "down"
+ parts.append(
+ f"Change area is {direction} {abs(cd):.2f} percentage points "
+ f"({previous['changePercentage']:.2f}% → {latest['changePercentage']:.2f}%)."
+ )
+
+ rd = comparison["regionsCountDelta"]
+ if rd != 0:
+ parts.append(f"{abs(rd)} {'more' if rd > 0 else 'fewer'} region(s) detected than last time.")
+ else:
+ parts.append("Same number of regions detected as last time.")
+
+ rate_delta = comparison["confirmedRateDelta"]
+ if rate_delta is not None:
+ if abs(rate_delta) < 0.1:
+ parts.append(f"Confirmed rate held steady at {latest['confirmedRate']:.1f}%.")
+ else:
+ direction = "improved" if rate_delta > 0 else "dropped"
+ parts.append(
+ f"Confirmed rate {direction} {abs(rate_delta):.1f} points "
+ f"({previous['confirmedRate']:.1f}% → {latest['confirmedRate']:.1f}%)."
+ )
+ elif latest["confirmedRate"] is not None:
+ parts.append(f"Confirmed rate is {latest['confirmedRate']:.1f}% (previous run not yet reviewed).")
+
+ fpd = comparison["falsePositiveDelta"]
+ if fpd != 0:
+ parts.append(f"{abs(fpd)} {'more' if fpd > 0 else 'fewer'} false positive(s) flagged.")
+
+ return " ".join(parts)
+
+
@router.get("/dashboard/recent-reports")
def dashboard_recent_reports(
limit: int = Query(8, ge=1, le=50),
+ date_from: Optional[str] = Query(None),
+ date_to: Optional[str] = Query(None),
db: Session = Depends(get_db),
user: User = Depends(current_dda_user),
):
_require_dda()
- rows = _load_dataset(db, user)
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
rows.sort(key=lambda r: r["run"].created_at, reverse=True)
return {"reports": [_run_summary(r) for r in rows[:limit]]}
+
+
+@router.get("/dashboard/library-stats")
+def dashboard_library_stats(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
+ """Image Library summary for the dashboard — total images, zone/village
+ breakdown, format mix, and how many still have no zone/village assigned.
+ Independent of detection history and not scoped per-user: the library
+ itself isn't user-scoped anywhere else in the app (matches
+ local_routes.py's image listing), so this reflects the whole library as
+ it stands right now, for every viewer.
+ """
+ _require_dda()
+ from sqlalchemy import func as sa_func
+
+ from .tree.models import ImageLibrary
+
+ total = db.query(ImageLibrary).count()
+ by_zone, by_village = _image_counts_by_location(db)
+ unassigned = db.query(ImageLibrary).filter(ImageLibrary.zone_id.is_(None)).count()
+
+ format_rows = (
+ db.query(ImageLibrary.format, sa_func.count(ImageLibrary.id))
+ .group_by(ImageLibrary.format)
+ .all()
+ )
+ by_format = sorted(
+ ({"format": fmt or "Unknown", "count": cnt} for fmt, cnt in format_rows),
+ key=lambda x: -x["count"],
+ )
+ zones_out = sorted(
+ ({"zone": z, "imageCount": c} for z, c in by_zone.items()),
+ key=lambda x: -x["imageCount"],
+ )
+ villages_out = sorted(
+ ({"zone": z, "village": v, "imageCount": c} for (z, v), c in by_village.items()),
+ key=lambda x: -x["imageCount"],
+ )
+
+ return {
+ "totalImages": total,
+ "assignedImages": total - unassigned,
+ "unassignedImages": unassigned,
+ "zonesCovered": len(by_zone),
+ "villagesCovered": len(by_village),
+ "byZone": zones_out,
+ "byVillage": villages_out,
+ "byFormat": by_format,
+ }
diff --git a/app/dda/detect_service.py b/app/dda/detect_service.py
index 7a13a8e7a04a50bf172a841d6b11c39a1d2ceaf1..3f3d3f16a861d37241dddfb3194c363e8f3d7552 100644
--- a/app/dda/detect_service.py
+++ b/app/dda/detect_service.py
@@ -27,6 +27,17 @@ THUMB_MAX_SIZE = 200
MAX_POLYGON_VERTICES = 100
+def clamp_detection_params(detection_sensitivity, min_region_area):
+ """Single source of truth for valid ranges, shared by every entry point
+ that accepts these as Form params (app/main.py's /api/detect and this
+ module's run_detection_and_save, used by /api/dda/detect/from-library) —
+ previously each clamped independently and could drift apart."""
+ sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
+ if min_region_area is not None:
+ min_region_area = int(max(50, min(10000, min_region_area)))
+ return sensitivity, min_region_area
+
+
def _serialize_polygon(raw) -> Optional[list]:
"""Normalize an engine polygon to ``[[x, y], ...]`` ints, or None.
@@ -225,9 +236,7 @@ def run_detection_and_save(
else:
user = get_or_create_guest_user(db)
db.commit()
- detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
- if min_region_area is not None:
- min_region_area = int(max(50, min(10000, min_region_area)))
+ detection_sensitivity, min_region_area = clamp_detection_params(detection_sensitivity, min_region_area)
def _on_engine_progress(engine_pct: int, stage: str) -> None:
# Map engine 0–100% into job 15–78%
@@ -414,6 +423,8 @@ def run_detection_and_save(
after_thumb_path=relative_after_thumb,
after_full_path=relative_after_full,
regions_json=json.dumps(regions_serializable),
+ detection_sensitivity=detection_sensitivity,
+ min_region_area=min_region_area,
)
db.add(run)
db.commit()
@@ -468,6 +479,8 @@ def run_detection_and_save(
"changedPixels": changed_px,
"unchangedPixels": int(stats["unchanged_pixels"]),
"changePercentage": change_pct,
+ "detectionSensitivity": detection_sensitivity,
+ "minRegionArea": min_region_area,
"thresholdDebug": stats.get("threshold_debug", {}),
"params": stats.get("params", {}),
"alignmentWarning": stats.get("alignment_warning"),
diff --git a/app/dda/exception_routes.py b/app/dda/exception_routes.py
index 3c335e661332d29ab7d56b76f844739f8a5f36f0..65e12545bcb43d758298258615826b073ae4cbd7 100644
--- a/app/dda/exception_routes.py
+++ b/app/dda/exception_routes.py
@@ -34,6 +34,7 @@ def _entry_dict(entry: ExceptionLog, *, with_traceback: bool = False) -> dict:
"statusCode": entry.status_code,
"handled": bool(entry.handled),
"userEmail": entry.user_email or "",
+ "userRole": getattr(entry, "user_role", None) or "",
}
if with_traceback:
d["traceback"] = entry.traceback or ""
diff --git a/app/dda/job_runner.py b/app/dda/job_runner.py
index 5fd46508a40201ad637a0a1d2356438fe9dc72e5..11e46cf669dca044c609363ea8d5365288fb023f 100644
--- a/app/dda/job_runner.py
+++ b/app/dda/job_runner.py
@@ -126,14 +126,21 @@ def _run_job_sync(job_id: int) -> None:
"Job %d detection max_size=%d (loaded %s)",
job_id, detect_max, before_pil.size,
)
+ zone = (params.get("zone") or "").strip()
+ village = (params.get("village") or "").strip()
+ if not zone or not village:
+ from .tree.image_service import resolve_run_location
+ auto_zone, auto_village = resolve_run_location(db, base_path, comparison_path)
+ zone = zone or auto_zone
+ village = village or auto_village
result = run_detection_and_save(
db,
before_pil,
after_pil,
method=method,
title=title,
- zone=params.get("zone", ""),
- village=params.get("village", ""),
+ zone=zone,
+ village=village,
enable_registration=bool(params.get("enable_registration", True)),
enable_normalization=bool(params.get("enable_normalization", True)),
detection_sensitivity=float(params.get("detection_sensitivity", 0.45)),
diff --git a/app/dda/local_routes.py b/app/dda/local_routes.py
index 38bf855c4ad1bf06caf8ec4c3c18fa0a85625f6d..7145bcd08e642456f97b9404f42b94ea732473ac 100644
--- a/app/dda/local_routes.py
+++ b/app/dda/local_routes.py
@@ -112,7 +112,14 @@ def local_library_config(user: User = Depends(current_dda_user)):
def local_images(
node_id: Optional[int] = Query(None),
q: Optional[str] = Query(None),
- limit: Optional[int] = Query(None, ge=1, le=500),
+ year: Optional[int] = Query(None),
+ image_type: Optional[str] = Query(None),
+ zone_id: Optional[int] = Query(None),
+ village_id: Optional[int] = Query(None),
+ capture_date_from: Optional[str] = Query(None),
+ capture_date_to: Optional[str] = Query(None),
+ source: Optional[str] = Query(None),
+ limit: Optional[int] = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
user: User = Depends(current_dda_user),
@@ -122,9 +129,16 @@ def local_images(
db,
node_id=node_id,
query=q,
+ year=year,
+ image_type=image_type,
+ zone_id=zone_id,
+ village_id=village_id,
+ capture_date_from=capture_date_from,
+ capture_date_to=capture_date_to,
+ source=source,
include_bounds=False,
slim=True,
- limit=limit,
+ limit=limit if limit is not None else 50,
offset=offset,
)
@@ -132,19 +146,22 @@ def local_images(
@router.get("/local/area-groups")
def local_area_groups(
node_id: Optional[int] = Query(None),
+ include_unpaired: int = Query(0, ge=0, le=1),
db: Session = Depends(get_db),
user: User = Depends(current_dda_user),
):
"""Images covering the same place, with oldest → Before and newest → After."""
_require_dda()
- from .tree.area_groups import filter_area_groups_for_node, load_cached_area_groups
+ from .tree.area_groups import load_cached_area_groups
from .tree.tree_service import get_node_or_404
- payload = load_cached_area_groups(db)
if node_id:
- node = get_node_or_404(db, node_id)
- return filter_area_groups_for_node(payload, node)
- return payload
+ get_node_or_404(db, node_id)
+ return load_cached_area_groups(
+ db,
+ node_id=node_id,
+ include_unpaired=bool(include_unpaired),
+ )
@router.get("/local/thumb")
@@ -377,7 +394,7 @@ async def detect_from_library(
enable_registration: bool = Form(True),
enable_normalization: bool = Form(True),
detection_sensitivity: float = Form(0.5),
- min_region_area: Optional[int] = Form(150),
+ min_region_area: Optional[int] = Form(None),
notify_email: Optional[str] = Form(None),
roi: Optional[str] = Form(None),
shape_mode: str = Form("polygon"),
@@ -449,6 +466,12 @@ async def detect_from_library(
if title == "Untitled run":
title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
+ if not zone.strip() or not village.strip():
+ from .tree.image_service import resolve_run_location
+ auto_zone, auto_village = resolve_run_location(db, base_norm, comp_norm)
+ zone = zone.strip() or auto_zone
+ village = village.strip() or auto_village
+
try:
# Use fullres load cap (up to DETECTION_FULLRES_MAX_SIDE), not the
# legacy 4096 downscaled cap — otherwise max-res config is ignored.
diff --git a/app/dda/masters_routes.py b/app/dda/masters_routes.py
index bb09844abc5e016b623af02ed7866547f1026900..9d2b60d3772bdb20865fb2a98cd6a6daad369687 100644
--- a/app/dda/masters_routes.py
+++ b/app/dda/masters_routes.py
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User
from .models import DdaVillage, DdaZone, ImageAsset
+from .tree.models import ImageLibrary
from .rbac.permissions import require_module_permission
from .seed import seed_delhi_hierarchy
@@ -67,6 +68,28 @@ def _village_dict(village: DdaVillage, zone: Optional[DdaZone] = None) -> dict:
}
+@router.get("/masters/options")
+def masters_options(
+ db: Session = Depends(get_db),
+ user: User = Depends(require_module_permission("library", "view")),
+):
+ """Zone + village lists for pickers (upload form).
+
+ Separate from /masters/zones because choosing a location while uploading
+ only needs to read the masters, not manage them.
+ """
+ _require_dda()
+ seed_delhi_hierarchy(db)
+ zones = db.query(DdaZone).order_by(DdaZone.name).all()
+ villages = db.query(DdaVillage).order_by(DdaVillage.name).all()
+ return {
+ "zones": [{"id": z.id, "name": z.name} for z in zones],
+ "villages": [
+ {"id": v.id, "name": v.name, "zoneId": v.zone_id} for v in villages
+ ],
+ }
+
+
@router.get("/masters/zones")
def list_zones(
db: Session = Depends(get_db),
@@ -132,7 +155,10 @@ def delete_zone(
zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Zone not found")
- in_use = db.query(ImageAsset).filter(ImageAsset.zone_id == zone_id).count()
+ in_use = (
+ db.query(ImageAsset).filter(ImageAsset.zone_id == zone_id).count()
+ + db.query(ImageLibrary).filter(ImageLibrary.zone_id == zone_id).count()
+ )
if in_use:
raise HTTPException(status_code=409, detail="This zone is used by library images and cannot be deleted.")
zone_name = zone.name
@@ -231,7 +257,10 @@ def delete_village(
village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
if not village:
raise HTTPException(status_code=404, detail="Location not found")
- in_use = db.query(ImageAsset).filter(ImageAsset.village_id == village_id).count()
+ in_use = (
+ db.query(ImageAsset).filter(ImageAsset.village_id == village_id).count()
+ + db.query(ImageLibrary).filter(ImageLibrary.village_id == village_id).count()
+ )
if in_use:
raise HTTPException(status_code=409, detail="This location is used by library images and cannot be deleted.")
village_name = village.name
diff --git a/app/dda/rbac/permissions.py b/app/dda/rbac/permissions.py
index 7c9e0fa3ce865c7af6db7c792f0d5c4dea83989d..cc4392186e3baed1469d1c2aa981320ba9d6de77 100644
--- a/app/dda/rbac/permissions.py
+++ b/app/dda/rbac/permissions.py
@@ -2,7 +2,7 @@
routes/pages, and by the navbar to filter which menu items a user sees."""
from __future__ import annotations
-from typing import List, Optional
+from typing import Dict, List, Optional
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
@@ -32,6 +32,32 @@ def get_role_rank(user: User, db: Session) -> Optional[int]:
return role.rank if role else None
+def _role_permission_rows(role: Optional[Role], db: Session) -> Dict[int, RolePermission]:
+ if not role:
+ return {}
+ return {
+ p.module_id: p
+ for p in db.query(RolePermission).filter(RolePermission.role_id == role.id).all()
+ }
+
+
+def user_permissions_map(user: User, db: Session) -> dict:
+ """One role + one permissions query for every module (no per-action N+1)."""
+ role = get_user_role_row(user, db)
+ modules = db.query(Module).all()
+ perms = _role_permission_rows(role, db)
+ out = {}
+ for m in modules:
+ p = perms.get(m.id)
+ out[m.key] = {
+ "view": bool(p and p.can_view),
+ "create": bool(p and p.can_create),
+ "edit": bool(p and p.can_edit),
+ "delete": bool(p and p.can_delete),
+ }
+ return out
+
+
def user_can(user: User, db: Session, module_key: str, action: str = "view") -> bool:
"""action: 'view' | 'create' | 'edit' | 'delete'."""
role = get_user_role_row(user, db)
@@ -77,28 +103,31 @@ def resolve_user_menu(user: User, db: Session) -> List[MenuItem]:
a group item ("Configuration") which is only shown once it has at least
one visible child — otherwise a role with no admin access would see an
empty, dead group header."""
+ role = get_user_role_row(user, db)
+ can_view_module_ids = {
+ mid
+ for mid, p in _role_permission_rows(role, db).items()
+ if p.can_view
+ }
def _visible(item: MenuItem) -> bool:
if item.module_id is None:
return True
- module = db.query(Module).filter(Module.id == item.module_id).first()
- return bool(module and user_can(user, db, module.key, "view"))
+ return item.module_id in can_view_module_ids
- top_level = (
+ items = (
db.query(MenuItem)
- .filter(MenuItem.is_active.is_(True), MenuItem.parent_id.is_(None))
+ .filter(MenuItem.is_active.is_(True))
.order_by(MenuItem.sort_order)
.all()
)
+ children_by_parent: dict[Optional[int], list] = {}
+ for item in items:
+ children_by_parent.setdefault(item.parent_id, []).append(item)
visible = []
- for item in top_level:
- children = (
- db.query(MenuItem)
- .filter(MenuItem.is_active.is_(True), MenuItem.parent_id == item.id)
- .order_by(MenuItem.sort_order)
- .all()
- )
+ for item in children_by_parent.get(None, []):
+ children = children_by_parent.get(item.id, [])
item.visible_children = [c for c in children if _visible(c)]
is_group = bool(children)
if (item.visible_children if is_group else _visible(item)):
diff --git a/app/dda/rbac/routes.py b/app/dda/rbac/routes.py
index f2cf17bbf80dd5b9e1c7b3b62565de84a264148c..249e62c1a1987c8b32ef371ef22430154a7ff995 100644
--- a/app/dda/rbac/routes.py
+++ b/app/dda/rbac/routes.py
@@ -9,12 +9,12 @@ from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
-from ...auth import get_password_hash, get_user_by_email, get_user_by_phone, normalize_phone
+from ...auth import get_password_hash, get_user_by_email, get_user_by_phone, normalize_phone, revoke_user_sessions
from ...database import get_db
from ...models import User
from ..dda_auth import current_dda_user
from .models import MenuItem, Module, Role, RolePermission
-from .permissions import require_module_permission, user_can
+from .permissions import require_module_permission, user_can, user_permissions_map
router = APIRouter()
@@ -91,16 +91,7 @@ def my_permissions(db: Session = Depends(get_db), user: User = Depends(current_d
show/hide/disable in the UI (Upload, Run detection, Delete, etc.) so
the frontend always matches whatever's set in the Roles & Users
permission matrix, without hardcoding any role name."""
- modules = db.query(Module).all()
- return {
- m.key: {
- "view": user_can(user, db, m.key, "view"),
- "create": user_can(user, db, m.key, "create"),
- "edit": user_can(user, db, m.key, "edit"),
- "delete": user_can(user, db, m.key, "delete"),
- }
- for m in modules
- }
+ return user_permissions_map(user, db)
# ---------------------------------------------------------------- Roles ----
@@ -476,6 +467,7 @@ def update_user(
target.role = role.name
if body.password:
target.hashed_password = get_password_hash(body.password)
+ revoke_user_sessions(target)
db.commit()
_log_admin_action(db, "update_user", _user, {"user_id": target.id, "email": target.email, "role": role.name})
return _user_dict(target, role.name, role.id)
diff --git a/app/dda/rbac/seed.py b/app/dda/rbac/seed.py
index bf47e2e4818a18b864dce46dee2f14a3ecf171ff..3bfb173b1b25c75e957816c572915699bf279acc 100644
--- a/app/dda/rbac/seed.py
+++ b/app/dda/rbac/seed.py
@@ -24,7 +24,7 @@ DEFAULT_ROLES = [
# (key, name, description)
DEFAULT_MODULES = [
("home", "Dashboard", "System dashboard — detection overview, regions, reports"),
- ("library", "Master Library", "Browse and upload satellite / drone imagery"),
+ ("library", "Image Library", "Browse and upload satellite / drone imagery"),
("masters", "Masters", "Zone / district and village / location master data"),
("detect", "Change Detection", "Compare imagery and run detections"),
("reports", "Reports", "Detection history, exports, and PDF reports"),
@@ -42,7 +42,7 @@ DEFAULT_MODULES = [
DEFAULT_MENU_ITEMS = [
("Dashboard", "/", "home", 0, None),
("Masters", "#masters", None, 1, None),
- ("Master Library", "/library", "library", 0, "Masters"),
+ ("Image Library", "/library", "library", 0, "Masters"),
("Zone / District", "/masters/zones", "masters", 1, "Masters"),
("Village / Location", "/masters/villages", "masters", 2, "Masters"),
("Change Detection", "/detect", "detect", 2, None),
diff --git a/app/dda/report_pdf.py b/app/dda/report_pdf.py
index d59f1ac09119cd808bb5d1e51f17c30614b63333..40be94ba9c9a38fe1468e43692a54410821b0f50 100644
--- a/app/dda/report_pdf.py
+++ b/app/dda/report_pdf.py
@@ -11,6 +11,16 @@ from ..models import DetectionRun
from .detect_service import _isoformat_ist
+def _detection_max_side() -> Optional[int]:
+ """Detection resolution used across the app (see get_load_max_side) — a
+ static config value, not per-run, but useful context on a report."""
+ try:
+ from ..detection_config import get_load_max_side
+ return get_load_max_side()
+ except Exception:
+ return None
+
+
def _safe_filename(title: str, run_id: int) -> str:
base = "".join(c if c.isalnum() or c in " -_" else "_" for c in (title or "report"))
base = base.strip().replace(" ", "_")[:60] or "report"
@@ -31,6 +41,9 @@ def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False, r
"changedPixels": run.changed_pixels,
"unchangedPixels": run.total_pixels - run.changed_pixels,
"changePercentage": run.change_percentage,
+ "detectionSensitivity": run.detection_sensitivity,
+ "minRegionArea": run.min_region_area,
+ "detectionMaxSide": _detection_max_side(),
},
"regions": regions,
"regionsCount": run.regions_count,
@@ -80,8 +93,12 @@ def generate_report_pdf(run: DetectionRun, *, regions: Optional[List[dict]] = No
Paragraph(f"Location: {location}", body),
Paragraph(f"Change: {run.change_percentage:.2f}% ({run.changed_pixels:,} / {run.total_pixels:,} px)", body),
Paragraph(f"Regions detected: {run.regions_count}", body),
- Spacer(1, 10),
]
+ if run.detection_sensitivity is not None or run.min_region_area is not None:
+ sens_txt = f"{run.detection_sensitivity:.2f}" if run.detection_sensitivity is not None else "—"
+ area_txt = f"{run.min_region_area:,} px" if run.min_region_area is not None else "—"
+ story.append(Paragraph(f"Detection settings: Sensitivity {sens_txt} · Min region area {area_txt}", body))
+ story.append(Spacer(1, 10))
overlay_path = run.overlay_path
if overlay_path:
diff --git a/app/dda/reports_routes.py b/app/dda/reports_routes.py
index 78f40b71135ff9d12082cffd20d2a20d7b74c6d3..37cb417fb9f4b50cdd1f00017579b006923a86c6 100644
--- a/app/dda/reports_routes.py
+++ b/app/dda/reports_routes.py
@@ -175,3 +175,47 @@ def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get
if not sent:
raise HTTPException(status_code=400, detail=error or "Failed to send report email")
return {"ok": True, "message": f"Report link sent to {body.email.strip()}.", "reportUrl": report_url}
+
+
+class ReportTitleBody(BaseModel):
+ title: str
+
+
+@router.patch("/reports/{run_id}/title")
+def update_report_title(
+ run_id: int,
+ body: ReportTitleBody,
+ db: Session = Depends(get_db),
+ user: User = Depends(require_module_permission("reports", "edit")),
+):
+ """Rename a report. The auto-generated 'before.tif vs after.tif' title
+ is rarely the name someone wants on a shared report, so this lets it be
+ edited after the fact without touching the underlying run data.
+
+ The Reports list (reports.js) shows a job's own cached title ahead of
+ the run's, for any run that still has a matching DetectionJob row — so
+ that job's params_json is updated too, or the list would keep showing
+ the old name even though the run and the report page are both current."""
+ _require_dda()
+ run = _get_user_run(db, run_id, user.id)
+ title = body.title.strip()
+ if not title:
+ raise HTTPException(status_code=400, detail="Title cannot be empty.")
+ if len(title) > 255:
+ raise HTTPException(status_code=400, detail="Title must be 255 characters or fewer.")
+ run.title = title
+
+ import json as _json
+
+ from .models import DetectionJob
+ job = db.query(DetectionJob).filter(DetectionJob.run_id == run.id).first()
+ if job:
+ try:
+ params = _json.loads(job.params_json or "{}")
+ except _json.JSONDecodeError:
+ params = {}
+ params["title"] = title
+ job.params_json = _json.dumps(params)
+
+ db.commit()
+ return {"ok": True, "title": run.title}
diff --git a/app/dda/review_service.py b/app/dda/review_service.py
index 0275e73283b6647b2bc4f7fdc5df6b5e38354ab8..6e4d87a9be508831fbc0659eaab40fdba9bd93ef 100644
--- a/app/dda/review_service.py
+++ b/app/dda/review_service.py
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import func
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..models import DetectionRun
@@ -62,7 +63,24 @@ def get_or_create_review(db: Session, run_id: int, region_id: int) -> RegionRevi
return row
row = RegionReview(run_id=run_id, region_id=region_id, status="pending")
db.add(row)
- db.commit()
+ try:
+ db.commit()
+ except IntegrityError:
+ # Two overlapping requests for the same region both found no existing
+ # row and raced to create one — the (run_id, region_id) unique index
+ # lets exactly one insert win; fall back to the row it created
+ # instead of duplicating (previously unconstrained, this silently
+ # produced duplicate RegionReview rows and inflated classification
+ # counts past regions_count).
+ db.rollback()
+ row = (
+ db.query(RegionReview)
+ .filter(RegionReview.run_id == run_id, RegionReview.region_id == region_id)
+ .first()
+ )
+ if row:
+ return row
+ raise
db.refresh(row)
return row
diff --git a/app/dda/tree/area_groups.py b/app/dda/tree/area_groups.py
index 3b525b0f2451f1bf98e71491946f5299ba54754c..f0b353369b777eed64638753c7f424067eba6974 100644
--- a/app/dda/tree/area_groups.py
+++ b/app/dda/tree/area_groups.py
@@ -1,15 +1,29 @@
-"""Group library images that cover the same place (oldest → Before, newest → After)."""
+"""Pair library images that cover the same place.
+
+Two images are compared on every piece of metadata the library actually holds:
+administrative location (zone / village), folder path, capture date, filename,
+and geographic overlap. Each signal contributes to a score, and a few of them
+act as hard gates that rule a pairing out entirely.
+
+Automatic pairing then picks the best-scoring pairs greedily, so a pair always
+holds exactly two images and no image is used by more than one pair. The full
+same-place cluster is still reported as ``candidates`` so an operator can swap
+in a different image by hand.
+"""
from __future__ import annotations
+import logging
import math
import re
from collections import defaultdict
from datetime import datetime
from pathlib import Path
-from typing import Any, Optional
+from typing import Any, NamedTuple, Optional
from ..geo_regions import parse_bounds
+logger = logging.getLogger(__name__)
+
YEAR_RE = re.compile(r"^(19|20)\d{2}$")
GRID_RE = re.compile(r"grid\s*(?:no\.?\s*)?(\d+)", re.I)
SHEET_RE = re.compile(r"\b([a-z]\d{2}[a-z]\d[a-z]\d+)\b", re.I)
@@ -22,6 +36,19 @@ NOISE_RE = re.compile(
re.I,
)
+# Candidate generation is blocked so a large library stays near-linear: only
+# images that already share a filename key, a village, or a map cell are ever
+# compared. These caps bound the work inside one oversized block.
+_SPATIAL_CELL = 0.02 # ~2 km at Delhi's latitude
+_SPATIAL_SUBCELL = 0.002 # ~200 m, used to split a crowded cell
+_BLOCK_FULL_LIMIT = 64 # compare every combination up to this block size
+_BLOCK_WINDOW = 12 # past it, only compare near-in-time neighbours
+_MAX_CANDIDATES = 500_000
+
+# A pair needs both a "same place" signal (see _score_pair) and enough total
+# agreement to be worth proposing.
+_MIN_PAIR_SCORE = 24
+
def _bbox(bounds: Any) -> Optional[tuple[float, float, float, float]]:
parsed = parse_bounds(bounds)
@@ -47,21 +74,35 @@ def _intersection(a: tuple[float, float, float, float], b: tuple[float, float, f
return (west, south, east, north)
-def bounds_same_area(a: Any, b: Any) -> bool:
- """True when two WGS84 boxes cover essentially the same place (not a tiny crop in a huge ORI)."""
+def overlap_stats(a: Any, b: Any) -> Optional[dict]:
+ """IoU plus how much of the smaller box is covered, or None if disjoint.
+
+ ``small`` and ``ratio`` matter because a wide orthomosaic legitimately
+ contains a smaller tile of the same place: IoU alone would call that a
+ mismatch.
+ """
ba, bb = _bbox(a), _bbox(b)
if not ba or not bb:
- return False
+ return None
inter = _intersection(ba, bb)
if not inter:
- return False
+ return None
aa, ab, ai = _area(ba), _area(bb), _area(inter)
if min(aa, ab) <= 0:
+ return None
+ return {
+ "iou": ai / max(aa + ab - ai, 1e-12),
+ "small": ai / min(aa, ab),
+ "ratio": min(aa, ab) / max(aa, ab),
+ }
+
+
+def bounds_same_area(a: Any, b: Any) -> bool:
+ """True when two WGS84 boxes cover essentially the same place (not a tiny crop in a huge ORI)."""
+ stats = overlap_stats(a, b)
+ if not stats:
return False
- iou = ai / max(aa + ab - ai, 1e-12)
- overlap_small = ai / min(aa, ab)
- ratio = min(aa, ab) / max(aa, ab)
- return iou >= 0.25 or (overlap_small >= 0.55 and ratio >= 0.15)
+ return stats["iou"] >= 0.25 or (stats["small"] >= 0.55 and stats["ratio"] >= 0.15)
def distinctive_keys(filename: str) -> set[str]:
@@ -153,33 +194,20 @@ def _name_rank(filename: str) -> int:
def _sort_images(images: list[dict]) -> list[dict]:
+ """Oldest first. Within one day the before/after in the name decides,
+ because a same-day upload order says nothing about which came first."""
return sorted(
images,
key=lambda img: (
- sort_datetime(img),
+ sort_datetime(img).date(),
_name_rank(img.get("filename") or ""),
+ sort_datetime(img),
(img.get("filename") or "").lower(),
img.get("id") or 0,
),
)
-class _UnionFind:
- def __init__(self, n: int):
- self.parent = list(range(n))
-
- def find(self, i: int) -> int:
- while self.parent[i] != i:
- self.parent[i] = self.parent[self.parent[i]]
- i = self.parent[i]
- return i
-
- def union(self, a: int, b: int) -> None:
- ra, rb = self.find(a), self.find(b)
- if ra != rb:
- self.parent[rb] = ra
-
-
_HARD_KEY_PREFIXES = ("seq:", "grid:", "sheet:")
@@ -190,14 +218,51 @@ def _hard_keys(keys: set[str]) -> set[str]:
def distinct_places(keys_a: set[str], keys_b: set[str]) -> bool:
"""True when both images are already identified as different sites.
- Bounds overlap is then ignored so a large ORI cannot swallow neighbouring
- pairs (union-find is transitive). Images with no hard key can still join
- via overlap or a shared folder.
+ They may still be the same place under two naming schemes (a grid number
+ and a survey sheet id), so this only raises the bar: such a pairing needs
+ strong geographic proof rather than being ruled out outright.
"""
ha, hb = _hard_keys(keys_a), _hard_keys(keys_b)
return bool(ha and hb and ha.isdisjoint(hb))
+def conflicting_keys(keys_a: set[str], keys_b: set[str]) -> bool:
+ """True when both names use the same vocabulary but disagree within it.
+
+ ``before3`` vs ``before7`` are two different before/after sets, and
+ ``grid 54`` vs ``grid 60`` are two different grids — no amount of map
+ overlap makes those one pair. A grid number against a sheet id is not a
+ conflict, because neither vocabulary can contradict the other.
+ """
+ for prefix in _HARD_KEY_PREFIXES:
+ a = {k for k in keys_a if k.startswith(prefix)}
+ b = {k for k in keys_b if k.startswith(prefix)}
+ if a and b and a.isdisjoint(b):
+ return True
+ return False
+
+
+def _key_text(key: str) -> str:
+ kind, _, value = key.partition(":")
+ if kind == "grid":
+ return f"grid {value}"
+ if kind == "sheet":
+ return f"sheet {value.upper()}"
+ if kind == "seq":
+ return f"before/after set {value}"
+ return "filename"
+
+
+def _gap_text(days: int) -> str:
+ if days < 45:
+ return f"{days} day{'' if days == 1 else 's'} apart"
+ if days < 730:
+ months = max(1, round(days / 30.4))
+ return f"{months} month{'' if months == 1 else 's'} apart"
+ years = round(days / 365.25, 1)
+ return f"{years:g} years apart"
+
+
def _group_label(images: list[dict], keys: set[str]) -> str:
base = ""
for key in sorted(keys):
@@ -235,98 +300,447 @@ def _group_label(images: list[dict], keys: set[str]) -> str:
return base
-def _match_reason(images: list[dict]) -> str:
- boxes = [_bbox(img.get("bounds")) for img in images]
- if sum(1 for b in boxes if b) >= 2:
- return "bounds"
- return "filename"
-
-
-def _spatial_cells(b: tuple[float, float, float, float], cell: float = 0.05):
+def _spatial_cells(b: tuple[float, float, float, float], cell: float = _SPATIAL_CELL):
+ """Every grid cell the box touches, so any two overlapping boxes meet in one."""
west, south, east, north = b
x0 = math.floor(west / cell)
x1 = math.floor((east - 1e-12) / cell)
y0 = math.floor(south / cell)
y1 = math.floor((north - 1e-12) / cell)
+ # A city-wide orthomosaic can span thousands of cells; indexing it in all
+ # of them would cost far more than the recall it buys.
+ if (x1 - x0 + 1) * (y1 - y0 + 1) > 256:
+ yield (x0, y0)
+ yield (x1, y1)
+ return
for x in range(x0, x1 + 1):
for y in range(y0, y1 + 1):
yield (x, y)
-def _public_image(img: dict) -> dict:
- """Drop heavy fields after pairing so the HTTP payload stays small."""
- out = dict(img)
- out.pop("bounds", None)
- return out
+def _clip_to_cell(b: tuple[float, float, float, float], cell: tuple[int, int], size: float):
+ """Box trimmed to one coarse cell, so a re-hash inside it stays bounded."""
+ west = max(b[0], cell[0] * size)
+ south = max(b[1], cell[1] * size)
+ east = min(b[2], (cell[0] + 1) * size)
+ north = min(b[3], (cell[1] + 1) * size)
+ if east <= west or north <= south:
+ return None
+ return (west, south, east, north)
-def build_area_groups(images: list[dict]) -> dict:
- """Cluster images that cover the same area; suggest oldest/newest as Before/After."""
- items = list(images or [])
- n = len(items)
- uf = _UnionFind(n)
- keys = [distinctive_keys(img.get("filename") or img.get("path") or "") for img in items]
+def _public_image(img: dict, item: Optional["_Item"] = None) -> dict:
+ """Keep only fields the pairs UI needs (id/path/dates/location)."""
+ zone_id = item.zone if item is not None else img.get("zoneId")
+ village_id = item.village if item is not None else img.get("villageId")
+ return {
+ "id": img.get("id"),
+ "path": img.get("path"),
+ "filename": img.get("filename") or img.get("imageName"),
+ "imageName": img.get("imageName") or img.get("filename"),
+ "captureDate": img.get("captureDate"),
+ "nodeId": img.get("nodeId"),
+ "nodePath": img.get("nodePath") or "",
+ "zoneId": zone_id,
+ "villageId": village_id,
+ "zoneName": (item.zone_name if item is not None else None) or img.get("zoneName") or "",
+ "villageName": (item.village_name if item is not None else None) or img.get("villageName") or "",
+ }
+
+
+class ResolvedLocation(NamedTuple):
+ zone_id: Optional[int]
+ village_id: Optional[int]
+ zone_name: str
+ village_name: str
+ # True only when a person chose this on upload. A location read off the
+ # folder tree is a hint, so it may suggest a pairing but never veto one.
+ explicit: bool
+
+
+# Folder names that stand for "no location yet" — matching them against the
+# masters would invent a location the image does not really have.
+_PLACEHOLDER_LOCATIONS = frozenset({
+ "unassigned", "_unassigned", "unknown", "uncategorized",
+ "legacy", "misc", "other", "root", "images", "temp", "tmp",
+})
+
+
+class LocationIndex:
+ """Resolves an image's zone / village, falling back to its folder names.
+
+ Uploads carry an explicit choice, but the folder tree has always spelled
+ the same thing out ("Central Delhi/Daryaganj"), so images indexed from
+ disk still get a location to compare on.
+ """
+
+ def __init__(self, zones=(), villages=()):
+ self.zone_by_name: dict[str, int] = {}
+ self.zone_names: dict[int, str] = {}
+ for zone_id, name in zones:
+ clean = (name or "").strip()
+ if clean:
+ self.zone_by_name[clean.lower()] = zone_id
+ self.zone_names[zone_id] = clean
+ # One village name can exist in several zones, so keep every option.
+ self.villages_by_name: dict[str, list] = defaultdict(list)
+ self.village_names: dict[int, str] = {}
+ for village_id, name, zone_id in villages:
+ clean = (name or "").strip()
+ if clean:
+ self.villages_by_name[clean.lower()].append((zone_id, village_id))
+ self.village_names[village_id] = clean
+
+ def __bool__(self) -> bool:
+ return bool(self.zone_by_name or self.villages_by_name)
+
+ def resolve(self, img: dict) -> ResolvedLocation:
+ zone_id = img.get("zoneId") or None
+ village_id = img.get("villageId") or None
+ explicit = bool(zone_id or village_id)
+ if not (zone_id and village_id) and self:
+ segments = [
+ s.strip().lower()
+ for s in str(img.get("nodePath") or "").replace("\\", "/").split("/")
+ if s.strip() and s.strip().lower() not in _PLACEHOLDER_LOCATIONS
+ ]
+ if not zone_id:
+ for segment in segments:
+ if segment in self.zone_by_name:
+ zone_id = self.zone_by_name[segment]
+ break
+ if not village_id:
+ for segment in segments:
+ options = self.villages_by_name.get(segment) or []
+ if zone_id:
+ options = [o for o in options if o[0] == zone_id] or options
+ # An ambiguous name is left unresolved rather than guessed.
+ if len(options) == 1:
+ zone_id = zone_id or options[0][0]
+ village_id = options[0][1]
+ break
+ return ResolvedLocation(
+ zone_id,
+ village_id,
+ img.get("zoneName") or self.zone_names.get(zone_id, ""),
+ img.get("villageName") or self.village_names.get(village_id, ""),
+ explicit,
+ )
+
+
+def build_location_index(db) -> LocationIndex:
+ from ..models import DdaVillage, DdaZone
+
+ return LocationIndex(
+ db.query(DdaZone.id, DdaZone.name).all(),
+ db.query(DdaVillage.id, DdaVillage.name, DdaVillage.zone_id).all(),
+ )
+
+
+class _Item:
+ """Everything the scorer needs about one image, computed once."""
+
+ __slots__ = (
+ "idx", "img", "path", "filename", "keys", "hard", "bbox", "when",
+ "zone", "village", "zone_name", "village_name", "pinned_location",
+ "node_id", "folder", "rank",
+ )
+
+ def __init__(self, idx: int, img: dict, locations: Optional[LocationIndex] = None):
+ self.idx = idx
+ self.img = img
+ self.path = (img.get("path") or "").replace("\\", "/")
+ self.filename = img.get("filename") or img.get("imageName") or self.path
+ self.keys = distinctive_keys(self.filename or self.path)
+ self.hard = _hard_keys(self.keys)
+ self.bbox = _bbox(img.get("bounds"))
+ self.when = infer_capture_datetime(img)
+ place = (locations or LocationIndex()).resolve(img)
+ self.zone = place.zone_id
+ self.village = place.village_id
+ self.zone_name = place.zone_name
+ self.village_name = place.village_name
+ self.pinned_location = place.explicit
+ self.node_id = img.get("nodeId") or None
+ folder = (img.get("nodePath") or "").strip("/")
+ if not folder:
+ folder = self.path.rsplit("/", 1)[0] if "/" in self.path else ""
+ self.folder = folder.lower()
+ self.rank = _name_rank(self.filename)
+
+
+def _folder_score(a: _Item, b: _Item) -> tuple[float, str]:
+ if a.node_id and b.node_id and a.node_id == b.node_id:
+ return 10.0, "same folder"
+ if a.folder and a.folder == b.folder:
+ return 10.0, "same folder"
+ if not a.folder or not b.folder:
+ return 0.0, ""
+ seg_a, seg_b = a.folder.split("/"), b.folder.split("/")
+ shared = 0
+ for x, y in zip(seg_a, seg_b):
+ if x != y:
+ break
+ shared += 1
+ if shared:
+ return min(6.0, 2.0 * shared), "same folder branch"
+ return 0.0, ""
+
+
+def _date_score(a: _Item, b: _Item) -> tuple[float, str]:
+ if not a.when or not b.when:
+ return 0.0, ""
+ gap = abs((a.when - b.when).days)
+ if gap == 0:
+ # Change detection needs two moments in time; a same-day pairing only
+ # survives when the filenames explicitly say before/after.
+ return -4.0, "same capture date"
+ if gap >= 180:
+ return 14.0, _gap_text(gap)
+ if gap >= 30:
+ return 12.0, _gap_text(gap)
+ return 8.0, _gap_text(gap)
+
+
+def _score_pair(a: _Item, b: _Item) -> Optional[dict]:
+ """Score one candidate pairing, or None when the metadata rules it out."""
+ if a.path and a.path == b.path:
+ return None
+ if conflicting_keys(a.hard, b.hard):
+ return None
+ # Only a location someone actually picked can veto a pairing; one read off
+ # the folder tree just describes how the files happen to be filed.
+ if a.pinned_location and b.pinned_location:
+ if a.zone and b.zone and a.zone != b.zone:
+ return None
+ if a.village and b.village and a.village != b.village:
+ return None
+
+ stats = overlap_stats(a.bbox, b.bbox) if (a.bbox and b.bbox) else None
+ if a.bbox and b.bbox and stats is None:
+ # Both are georeferenced and their footprints do not touch.
+ return None
+ strong_geo = bool(stats) and (stats["small"] >= 0.85 and stats["ratio"] >= 0.30)
+ if distinct_places(a.hard, b.hard) and not strong_geo:
+ return None
+
+ score = 0.0
+ reasons: list[str] = []
+ match = "filename"
+
+ geo_ok = False
+ if stats:
+ geo_ok = stats["iou"] >= 0.25 or (stats["small"] >= 0.55 and stats["ratio"] >= 0.15)
+ score += 40.0 * min(1.0, stats["iou"])
+ if stats["small"] >= 0.8:
+ score += 10.0
+ if geo_ok:
+ match = "bounds"
+ reasons.append(f"{round(stats['iou'] * 100)}% map overlap")
+
+ shared_hard = a.hard & b.hard
+ shared_soft = (a.keys & b.keys) - shared_hard
+ if shared_hard:
+ score += 26.0
+ reasons.append(f"matching {_key_text(sorted(shared_hard)[0])}")
+ elif shared_soft:
+ score += 16.0
+ reasons.append("matching filename")
+
+ same_village = bool(a.village) and a.village == b.village
+ if same_village:
+ score += 20.0
+ reasons.append(f"same location ({a.village_name})" if a.village_name else "same location")
+ if match == "filename" and not shared_hard and not shared_soft:
+ match = "location"
+ elif a.zone and a.zone == b.zone:
+ score += 12.0
+ reasons.append(f"same zone ({a.zone_name})" if a.zone_name else "same zone")
+
+ folder_points, folder_text = _folder_score(a, b)
+ score += folder_points
+ if folder_text:
+ reasons.append(folder_text)
+
+ date_points, date_text = _date_score(a, b)
+ score += date_points
+ if date_text:
+ reasons.append(date_text)
+
+ if {a.rank, b.rank} == {0, 2}:
+ score += 12.0
+ reasons.append("named before / after")
+
+ # Folder, zone and capture date only say "could be related". When both
+ # footprints are known the map is authoritative — a shared name key can
+ # still vouch for a partial overlap, but a folder or village cannot.
+ if a.bbox and b.bbox:
+ same_place = geo_ok or bool(shared_hard)
+ else:
+ same_place = geo_ok or bool(shared_hard) or bool(shared_soft) or same_village
+ if not same_place:
+ return None
+ if score < _MIN_PAIR_SCORE:
+ return None
+ return {"score": score, "reasons": reasons, "match": match}
+
+
+def _emit_block(members: list[int], items: list[_Item], add) -> None:
+ members = list(dict.fromkeys(members))
+ if len(members) < 2:
+ return
+ if len(members) <= _BLOCK_FULL_LIMIT:
+ for a in range(len(members)):
+ for b in range(a + 1, len(members)):
+ add(members[a], members[b])
+ return
+ # Too crowded to compare exhaustively: order by capture time and only look
+ # at each image's nearest neighbours, since a pair needs two dates anyway.
+ members.sort(key=lambda i: (items[i].when or datetime.min, items[i].path))
+ for a in range(len(members)):
+ for b in range(a + 1, min(len(members), a + _BLOCK_WINDOW)):
+ add(members[a], members[b])
+
+
+def _candidate_pairs(items: list[_Item]) -> set[tuple[int, int]]:
+ """Index pairings worth scoring: shared filename key, map cell, or village.
+
+ Folder and zone are deliberately not blocking keys. Neither can establish
+ on its own that two images show the same place, so blocking on them would
+ only add candidates the scorer then throws away.
+ """
+ pairs: set[tuple[int, int]] = set()
+ truncated = False
+
+ def add(i: int, j: int) -> None:
+ nonlocal truncated
+ if len(pairs) >= _MAX_CANDIDATES:
+ truncated = True
+ return
+ pairs.add((i, j) if i < j else (j, i))
by_key: dict[str, list[int]] = defaultdict(list)
- for i, ks in enumerate(keys):
- for k in ks:
- by_key[k].append(i)
- for idxs in by_key.values():
- root = idxs[0]
- for j in idxs[1:]:
- uf.union(root, j)
-
- cell_map: dict[tuple[int, int], list[int]] = defaultdict(list)
- for i, img in enumerate(items):
- box = _bbox(img.get("bounds"))
- if not box:
+ for item in items:
+ for key in item.keys:
+ by_key[key].append(item.idx)
+ for members in by_key.values():
+ _emit_block(members, items, add)
+
+ by_cell: dict[tuple[int, int], list[int]] = defaultdict(list)
+ for item in items:
+ if item.bbox:
+ for cell in _spatial_cells(item.bbox):
+ by_cell[cell].append(item.idx)
+ for cell, members in by_cell.items():
+ if len(members) <= _BLOCK_FULL_LIMIT:
+ _emit_block(members, items, add)
continue
- for cell in _spatial_cells(box):
- cell_map[cell].append(i)
-
- seen: set[tuple[int, int]] = set()
- for idxs in cell_map.values():
- uniq = list(dict.fromkeys(idxs))
- for a in range(len(uniq)):
- for b in range(a + 1, len(uniq)):
- i, j = uniq[a], uniq[b]
- pair = (i, j) if i < j else (j, i)
- if pair in seen:
- continue
- seen.add(pair)
- if uf.find(i) == uf.find(j):
- continue
- if distinct_places(keys[i], keys[j]):
- continue
- if bounds_same_area(items[i].get("bounds"), items[j].get("bounds")):
- uf.union(i, j)
-
- buckets: dict[int, list[int]] = {}
- for i in range(n):
- buckets.setdefault(uf.find(i), []).append(i)
+ # Dense city imagery keeps its geographic precision by re-hashing the
+ # crowded cell at a finer resolution instead of giving up on bounds.
+ sub: dict[tuple[int, int], list[int]] = defaultdict(list)
+ for idx in members:
+ clipped = _clip_to_cell(items[idx].bbox, cell, _SPATIAL_CELL)
+ if not clipped:
+ continue
+ for fine in _spatial_cells(clipped, _SPATIAL_SUBCELL):
+ sub[fine].append(idx)
+ for group in sub.values():
+ _emit_block(group, items, add)
+
+ # Village is the only bridge for an image with no map extent; images that
+ # do have one are already indexed by cell, so skip those combinations.
+ by_village: dict[Any, list[int]] = defaultdict(list)
+ for item in items:
+ if item.village:
+ by_village[item.village].append(item.idx)
+ for members in by_village.values():
+ floating = [i for i in members if not items[i].bbox]
+ if not floating:
+ continue
+ _emit_block(floating, items, add)
+ located = [i for i in members if items[i].bbox]
+ if len(located) > _BLOCK_WINDOW:
+ located.sort(key=lambda i: (items[i].when or datetime.min, items[i].path))
+ located = located[:_BLOCK_WINDOW]
+ for i in floating:
+ for j in located:
+ add(i, j)
+
+ if truncated:
+ logger.warning(
+ "Same-area pairing hit the %d candidate cap; some weak pairings were skipped.",
+ _MAX_CANDIDATES,
+ )
+ return pairs
+
+
+def build_area_groups(
+ images: list[dict],
+ locations: Optional[LocationIndex] = None,
+) -> dict:
+ """Pair images that cover the same place: two per pair, never reused."""
+ items = [_Item(i, img, locations) for i, img in enumerate(images or [])]
+ n = len(items)
+ candidates = _candidate_pairs(items)
+ scored = []
+ for i, j in candidates:
+ result = _score_pair(items[i], items[j])
+ if result:
+ scored.append((result["score"], i, j, result))
+
+ # Every image that shares a viable pairing with another belongs to the same
+ # cluster; that is what the UI offers as manual alternatives.
+ cluster_of: dict[int, int] = {}
+ clusters: dict[int, list[int]] = {}
+ for _, i, j, _r in scored:
+ ci, cj = cluster_of.get(i), cluster_of.get(j)
+ if ci is None and cj is None:
+ root = min(i, j)
+ clusters[root] = [i, j]
+ cluster_of[i] = cluster_of[j] = root
+ elif ci is None:
+ clusters[cj].append(i)
+ cluster_of[i] = cj
+ elif cj is None:
+ clusters[ci].append(j)
+ cluster_of[j] = ci
+ elif ci != cj:
+ clusters[ci].extend(clusters.pop(cj))
+ for idx in clusters[ci]:
+ cluster_of[idx] = ci
+
+ # Greedy best-first matching: strongest evidence wins, and an image that is
+ # already in a pair is skipped, so no pair repeats an image.
+ scored.sort(key=lambda row: (-row[0], items[row[1]].path, items[row[2]].path))
+ item_of = {id(it.img): it for it in items}
+
+ def public(img: dict) -> dict:
+ return _public_image(img, item_of.get(id(img)))
+
+ used: set[int] = set()
groups = []
- unpaired = []
- for idxs in buckets.values():
- members = _sort_images([items[i] for i in idxs])
- if len(members) < 2:
- unpaired.extend(members)
+ for score, i, j, result in scored:
+ if i in used or j in used or i == j:
+ continue
+ if items[i].path and items[i].path == items[j].path:
continue
- shared: set[str] = set()
- for i in idxs:
- if not shared:
- shared = set(keys[i])
- else:
- shared &= keys[i]
- before, after = members[0], members[-1]
- public_members = [_public_image(m) for m in members]
+ used.add(i)
+ used.add(j)
+ before, after = _sort_images([items[i].img, items[j].img])
+ shared = items[i].keys & items[j].keys
+ cluster = clusters.get(cluster_of.get(i, i), [i, j])
+ candidate_imgs = _sort_images([items[k].img for k in dict.fromkeys(cluster)])
groups.append({
"id": f"area-{before.get('id')}-{after.get('id')}",
- "label": _group_label(members, shared),
- "match": _match_reason(members),
- "images": public_members,
- "suggestedBefore": _public_image(before),
- "suggestedAfter": _public_image(after),
+ "label": _group_label([before, after], shared),
+ "match": result["match"],
+ "reasons": result["reasons"],
+ "score": round(score, 1),
+ "images": [public(before), public(after)],
+ "candidates": [public(img) for img in candidate_imgs],
+ "suggestedBefore": public(before),
+ "suggestedAfter": public(after),
"beforePath": before.get("path"),
"afterPath": after.get("path"),
"beforeDate": sort_datetime(before).date().isoformat() if sort_datetime(before) != datetime.min else None,
@@ -334,31 +748,54 @@ def build_area_groups(images: list[dict]) -> dict:
})
groups.sort(key=lambda g: (g["label"].lower(), g["id"]))
- unpaired = [_public_image(img) for img in _sort_images(unpaired)]
+ unpaired = [
+ public(img)
+ for img in _sort_images([it.img for it in items if it.idx not in used])
+ ]
return {
"groups": groups,
"unpaired": unpaired,
+ "unpairedCount": len(unpaired),
"totalImages": n,
- "pairedImages": n - len(unpaired),
+ "pairedImages": len(used),
}
-def load_cached_area_groups(db) -> dict:
- """Build same-area groups once per library fingerprint, then reuse in-process."""
+def _area_groups_response(payload: dict, *, include_unpaired: bool) -> dict:
+ unpaired = payload.get("unpaired") or []
+ out = {
+ "groups": payload.get("groups") or [],
+ "unpairedCount": int(payload.get("unpairedCount", len(unpaired))),
+ "totalImages": payload.get("totalImages", 0),
+ "pairedImages": payload.get("pairedImages", 0),
+ "unpaired": unpaired if include_unpaired else [],
+ }
+ return out
+
+
+def load_cached_area_groups(
+ db,
+ node_id: Optional[int] = None,
+ *,
+ include_unpaired: bool = False,
+) -> dict:
+ """Build same-area groups for a folder (or whole library), cached by fingerprint+node."""
from .image_service import library_fingerprint, list_all_images
from .library_cache import get_area_groups_cache, set_area_groups_cache
fp = library_fingerprint(db)
- cached = get_area_groups_cache(fp)
+ cache_node = int(node_id or 0)
+ cached = get_area_groups_cache(fp, cache_node)
if cached is not None:
- return cached
- images = list_all_images(db, include_bounds=True, slim=True)
- payload = build_area_groups(images)
- set_area_groups_cache(fp, payload)
- return payload
+ return _area_groups_response(cached, include_unpaired=include_unpaired)
+ images = list_all_images(db, node_id=node_id, include_bounds=True, slim=True)
+ payload = build_area_groups(images, build_location_index(db))
+ set_area_groups_cache(fp, payload, cache_node)
+ return _area_groups_response(payload, include_unpaired=include_unpaired)
def filter_area_groups_for_node(payload: dict, node) -> dict:
+ """Legacy post-filter; prefer scoping via load_cached_area_groups(node_id=...)."""
prefix = (getattr(node, "node_path", None) or "").rstrip("/")
node_id = getattr(node, "id", None)
@@ -374,6 +811,7 @@ def filter_area_groups_for_node(payload: dict, node) -> dict:
return {
"groups": groups,
"unpaired": unpaired,
+ "unpairedCount": len(unpaired),
"totalImages": payload.get("totalImages", 0),
"pairedImages": sum(len(g.get("images") or []) for g in groups),
}
diff --git a/app/dda/tree/audit_service.py b/app/dda/tree/audit_service.py
index 7daea0b10c7bb04fd92b235fa1ec22bbdb8f8212..c0c084fc40a15bffa95f644daef1ae05fb88554d 100644
--- a/app/dda/tree/audit_service.py
+++ b/app/dda/tree/audit_service.py
@@ -54,6 +54,7 @@ def log_exception(
status_code: int = 500,
handled: bool = False,
user_email: Optional[str] = None,
+ user_role: Optional[str] = None,
) -> None:
entry = ExceptionLog(
exception_type=type(exc).__name__,
@@ -64,6 +65,7 @@ def log_exception(
handled=handled,
traceback="".join(traceback_module.format_exception(type(exc), exc, exc.__traceback__))[:8000],
user_email=user_email,
+ user_role=user_role,
)
db.add(entry)
db.commit()
diff --git a/app/dda/tree/bulk_upload_service.py b/app/dda/tree/bulk_upload_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef2e1c08bcc059273ea1c0984def9888fbf35d0b
--- /dev/null
+++ b/app/dda/tree/bulk_upload_service.py
@@ -0,0 +1,414 @@
+"""Bulk image upload: an Excel manifest + a batch of files, routed through the
+same upload_image() pipeline used by the single-image upload (validation,
+dedup, thumbnailing, geo-metadata) — see image_service.py."""
+from __future__ import annotations
+
+import io
+import logging
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from fastapi import HTTPException, UploadFile
+from openpyxl import Workbook, load_workbook
+from openpyxl.worksheet.datavalidation import DataValidation
+from sqlalchemy.orm import Session
+
+from ..config import get_storage_root
+from ..models import DdaVillage, DdaZone
+from .image_service import IMAGE_SOURCES, IMAGE_TYPES, upload_image
+from .models import ImageLibrary
+from .tree_service import get_or_create_node_by_path
+
+logger = logging.getLogger(__name__)
+
+# General Folder replaces Folder Path (still accepted when parsing old sheets).
+TEMPLATE_HEADERS = [
+ "File Name",
+ "Server Filename",
+ "General Folder",
+ "Zone",
+ "Village",
+ "Image Type",
+ "Year",
+ "Source",
+ "Capture Date",
+ "Manual Bounds",
+]
+TEMPLATE_EXAMPLE_ROW = [
+ "example_before.tif",
+ "central_delhi_2025_before.tif",
+ "Central Delhi/Sector 5/2025",
+ "Central Delhi",
+ "Sector 5",
+ "GeoTIFF",
+ "2025",
+ "satellite",
+ "2025-01-15",
+ "",
+]
+_REQUIRED_COLUMNS = ("File Name", "General Folder", "Zone", "Village", "Image Type")
+_SOURCE_CHOICES = sorted(s for s in IMAGE_SOURCES if s)
+
+
+def _basename(value: str) -> str:
+ """Strip quotes and take the last path segment so Explorer 'Copy as path'
+ pastes still match browser upload basenames."""
+ v = (value or "").strip()
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
+ v = v[1:-1].strip()
+ return Path(v.replace("\\", "/")).name
+
+
+def _normalize_general_folder(value: str) -> str:
+ """Relative slash-separated path under Main folder. Reject absolute paths."""
+ v = (value or "").strip()
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
+ v = v[1:-1].strip()
+ v = v.replace("\\", "/").strip()
+ if not v:
+ raise ValueError("General Folder is required")
+ if re.match(r"^[A-Za-z]:[/\\]", v) or v.startswith("//") or v.startswith("/"):
+ raise ValueError(
+ "General Folder must be relative to the server Main folder "
+ "(e.g. Central Delhi/Sector 5/2025), not an absolute path"
+ )
+ parts = [p.strip() for p in v.split("/") if p.strip() and p.strip() != "."]
+ if any(p == ".." for p in parts):
+ raise ValueError("General Folder cannot contain '..'")
+ if not parts:
+ raise ValueError("General Folder is required")
+ return "/".join(parts)
+
+
+def build_bulk_upload_template() -> bytes:
+ """Generate the standardized .xlsx template for bulk image upload."""
+ main = str(get_storage_root())
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Images"
+ ws.append(TEMPLATE_HEADERS)
+ ws.append(TEMPLATE_EXAMPLE_ROW)
+ widths = (28, 32, 40, 16, 16, 14, 10, 12, 14, 22)
+ for col_idx, width in enumerate(widths, start=1):
+ ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = width
+
+ dv_type = DataValidation(
+ type="list", formula1='"' + ",".join(sorted(IMAGE_TYPES)) + '"', allow_blank=False
+ )
+ ws.add_data_validation(dv_type)
+ dv_type.add("F2:F1000")
+
+ if _SOURCE_CHOICES:
+ dv_src = DataValidation(
+ type="list", formula1='"' + ",".join(_SOURCE_CHOICES) + '"', allow_blank=True
+ )
+ ws.add_data_validation(dv_src)
+ dv_src.add("H2:H1000")
+
+ instructions = wb.create_sheet("Instructions")
+ instructions.append(["Column", "Required", "Notes"])
+ instructions.append([
+ "Main folder (this server)",
+ "—",
+ f"Images are stored under: {main}. Set STORAGE_ROOT or LOCAL_LIBRARY_ROOT on each environment so relative General Folder paths resolve correctly.",
+ ])
+ instructions.append([
+ "File Name",
+ "Yes",
+ "Must match one of the files you upload with this sheet (basename). Pasting a full Explorer path is fine — only the filename is used for matching.",
+ ])
+ instructions.append([
+ "Server Filename",
+ "No",
+ "Name stored on the server. Blank = File Name basename.",
+ ])
+ instructions.append([
+ "General Folder",
+ "Yes",
+ "Relative path under Main folder / library tree, e.g. Central Delhi/Sector 5/2025. Absolute paths (C:\\… or /…) are rejected. Missing folders are created automatically.",
+ ])
+ instructions.append(["Zone", "Yes", "Must match an existing Zone name exactly (see Zone / District master)."])
+ instructions.append(["Village", "Yes", "Must match an existing Village name within that Zone."])
+ instructions.append(["Image Type", "Yes", "One of: " + ", ".join(sorted(IMAGE_TYPES))])
+ instructions.append(["Year", "No", "Four-digit year (1900–2100). Blank may be inferred from a year segment in General Folder."])
+ instructions.append([
+ "Source",
+ "No",
+ "One of: " + (", ".join(_SOURCE_CHOICES) if _SOURCE_CHOICES else "satellite or drone"),
+ ])
+ instructions.append(["Capture Date", "No", "YYYY-MM-DD"])
+ instructions.append(["Manual Bounds", "No", "West,South,East,North — only if the image has no embedded georeferencing."])
+ instructions.column_dimensions["A"].width = 28
+ instructions.column_dimensions["B"].width = 10
+ instructions.column_dimensions["C"].width = 100
+
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+@dataclass
+class ManifestRow:
+ row_number: int
+ file_name: str
+ folder_path: str
+ zone: str
+ village: str
+ image_type: str
+ capture_date: Optional[str]
+ manual_bounds: Optional[str]
+ server_filename: Optional[str] = None
+ year: Optional[str] = None
+ source: Optional[str] = None
+
+
+@dataclass
+class BulkDefaults:
+ """Optional modal defaults applied only where Excel cells are blank."""
+ year: Optional[str] = None
+ source: Optional[str] = None
+ image_type: Optional[str] = None
+ zone: Optional[str] = None
+ village: Optional[str] = None
+ filenames: Dict[str, str] = field(default_factory=dict)
+
+
+def parse_bulk_manifest(xlsx_bytes: bytes) -> tuple[List[ManifestRow], List[dict]]:
+ """Parse the uploaded manifest. Returns (valid_rows, row_errors)."""
+ try:
+ wb = load_workbook(io.BytesIO(xlsx_bytes), data_only=True, read_only=True)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Could not read the Excel file. Please use the provided template.")
+ ws = wb["Images"] if "Images" in wb.sheetnames else wb.worksheets[0]
+
+ rows_iter = ws.iter_rows(values_only=True)
+ try:
+ header = [str(c or "").strip() for c in next(rows_iter)]
+ except StopIteration:
+ raise HTTPException(status_code=400, detail="The Excel sheet is empty.")
+
+ idx: Dict[str, Optional[int]] = {}
+ for name in TEMPLATE_HEADERS:
+ idx[name] = header.index(name) if name in header else None
+ if idx.get("General Folder") is None and "Folder Path" in header:
+ idx["General Folder"] = header.index("Folder Path")
+
+ missing = [name for name in _REQUIRED_COLUMNS if idx.get(name) is None]
+ if missing:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Missing required column(s): {', '.join(missing)}. Please use the provided template.",
+ )
+
+ valid: List[ManifestRow] = []
+ errors: List[dict] = []
+ for row_number, raw in enumerate(rows_iter, start=2):
+ if raw is None or all(c is None or str(c).strip() == "" for c in raw):
+ continue
+
+ def cell(name: str, _raw=raw) -> str:
+ i = idx.get(name)
+ if i is None or i >= len(_raw) or _raw[i] is None:
+ return ""
+ return str(_raw[i]).strip()
+
+ file_name = _basename(cell("File Name"))
+ server_filename = cell("Server Filename") or None
+ if server_filename:
+ server_filename = _basename(server_filename)
+ zone = cell("Zone")
+ village = cell("Village")
+ image_type = cell("Image Type") or ""
+ year = cell("Year") or None
+ if year and year.endswith(".0"):
+ year = year[:-2]
+ source = (cell("Source") or "").strip().lower() or None
+ capture_date = cell("Capture Date") or None
+ manual_bounds = cell("Manual Bounds") or None
+
+ try:
+ folder_path = _normalize_general_folder(cell("General Folder"))
+ except ValueError as exc:
+ errors.append({"row": row_number, "file": file_name, "reason": str(exc)})
+ continue
+
+ if not file_name:
+ errors.append({"row": row_number, "file": file_name, "reason": "File Name is required"})
+ continue
+ # Zone/Village/Image Type may be filled from modal defaults later.
+ if image_type and image_type not in IMAGE_TYPES:
+ errors.append({
+ "row": row_number,
+ "file": file_name,
+ "reason": f"Image Type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
+ })
+ continue
+ if source and source not in IMAGE_SOURCES:
+ errors.append({
+ "row": row_number,
+ "file": file_name,
+ "reason": "Source must be satellite or drone",
+ })
+ continue
+ valid.append(ManifestRow(
+ row_number=row_number,
+ file_name=file_name,
+ folder_path=folder_path,
+ zone=zone,
+ village=village,
+ image_type=image_type,
+ capture_date=capture_date,
+ manual_bounds=manual_bounds,
+ server_filename=server_filename,
+ year=year,
+ source=source,
+ ))
+ return valid, errors
+
+
+def _resolve_zone_village_by_name(db: Session, zone_name: str, village_name: str) -> tuple[int, int]:
+ zone = db.query(DdaZone).filter(DdaZone.name.ilike(zone_name.strip())).first()
+ if not zone:
+ raise HTTPException(status_code=400, detail=f"Zone '{zone_name}' not found. Check the Zone / District master.")
+ village = (
+ db.query(DdaVillage)
+ .filter(DdaVillage.zone_id == zone.id, DdaVillage.name.ilike(village_name.strip()))
+ .first()
+ )
+ if not village:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Village '{village_name}' not found under zone '{zone_name}'. Check the Village / Location master.",
+ )
+ return zone.id, village.id
+
+
+def zone_village_names(db: Session, zone_id: Optional[int], village_id: Optional[int]) -> tuple[str, str]:
+ zone_name, village_name = "", ""
+ if zone_id:
+ z = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
+ zone_name = z.name if z else ""
+ if village_id:
+ v = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
+ village_name = v.name if v else ""
+ return zone_name, village_name
+
+
+def apply_bulk_defaults(rows: List[ManifestRow], defaults: BulkDefaults) -> None:
+ """Fill blank Excel fields from modal defaults (in place)."""
+ for row in rows:
+ key = row.file_name.strip().lower()
+ if not row.server_filename and key in defaults.filenames:
+ row.server_filename = defaults.filenames[key]
+ if not row.year and defaults.year:
+ row.year = defaults.year
+ if not row.source and defaults.source:
+ row.source = defaults.source
+ if not row.image_type and defaults.image_type:
+ row.image_type = defaults.image_type
+ if not row.zone and defaults.zone:
+ row.zone = defaults.zone
+ if not row.village and defaults.village:
+ row.village = defaults.village
+ if not row.image_type:
+ row.image_type = "GeoTIFF"
+
+
+@dataclass
+class BulkUploadResult:
+ created: List[dict] = field(default_factory=list)
+ duplicates: List[dict] = field(default_factory=list)
+ errors: List[dict] = field(default_factory=list)
+ unmatched_files: List[dict] = field(default_factory=list)
+
+
+async def bulk_upload_images(
+ db: Session,
+ rows: List[ManifestRow],
+ files: List[UploadFile],
+ *,
+ uploaded_by: str,
+ defaults: Optional[BulkDefaults] = None,
+) -> BulkUploadResult:
+ result = BulkUploadResult()
+ apply_bulk_defaults(rows, defaults or BulkDefaults())
+
+ files_by_name = {(f.filename or "").strip().lower(): f for f in files}
+ matched_names = set()
+
+ for row in rows:
+ key = row.file_name.strip().lower()
+ file = files_by_name.get(key)
+ if file is None:
+ result.errors.append({
+ "row": row.row_number,
+ "file": row.file_name,
+ "reason": "No uploaded file matches this File Name",
+ })
+ continue
+ matched_names.add(key)
+ stored_name = row.server_filename or row.file_name
+ if not row.zone or not row.village:
+ result.errors.append({
+ "row": row.row_number,
+ "file": row.file_name,
+ "reason": "Zone and Village are required (set in Excel or modal defaults)",
+ })
+ continue
+ if row.image_type not in IMAGE_TYPES:
+ result.errors.append({
+ "row": row.row_number,
+ "file": row.file_name,
+ "reason": f"Image Type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
+ })
+ continue
+ try:
+ zone_id, village_id = _resolve_zone_village_by_name(db, row.zone, row.village)
+ node = get_or_create_node_by_path(db, row.folder_path, created_by=uploaded_by)
+ already_existed = (
+ db.query(ImageLibrary)
+ .filter(
+ ImageLibrary.node_id == node.id,
+ ImageLibrary.image_name == Path(stored_name).name,
+ )
+ .count() > 0
+ )
+ img = await upload_image(
+ db, node.id, file,
+ image_type=row.image_type,
+ capture_date=row.capture_date,
+ uploaded_by=uploaded_by,
+ manual_bounds=row.manual_bounds,
+ zone_id=zone_id,
+ village_id=village_id,
+ year=row.year,
+ source=row.source,
+ server_filename=stored_name,
+ )
+ entry = {
+ "row": row.row_number,
+ "file": row.file_name,
+ "serverFilename": stored_name,
+ "folder": node.node_path,
+ "imageId": img.id,
+ }
+ (result.duplicates if already_existed else result.created).append(entry)
+ except HTTPException as exc:
+ db.rollback()
+ result.errors.append({"row": row.row_number, "file": row.file_name, "reason": exc.detail})
+ except Exception:
+ db.rollback()
+ logger.exception("Bulk upload failed for row %s (%s)", row.row_number, row.file_name)
+ result.errors.append({
+ "row": row.row_number,
+ "file": row.file_name,
+ "reason": "Unexpected error while uploading this file",
+ })
+
+ for name, file in files_by_name.items():
+ if name not in matched_names:
+ result.unmatched_files.append({"file": file.filename})
+
+ return result
diff --git a/app/dda/tree/image_service.py b/app/dda/tree/image_service.py
index cb87cf30232964df9a25f496ab09f317862eaa60..2f26a3ea7cfed3c68906e5b5172b1e5ea0903e36 100644
--- a/app/dda/tree/image_service.py
+++ b/app/dda/tree/image_service.py
@@ -25,8 +25,91 @@ from .tree_service import get_node_or_404
logger = logging.getLogger(__name__)
-IMAGE_TYPES = {"Satellite", "Drone", "Orthomosaic", "DEM", "GeoTIFF", "Raster", "PNG", "JPEG"}
+IMAGE_TYPES = {"GeoTIFF", "PNG", "JPEG"}
+# Type-first: selected image_type only accepts these extensions.
+IMAGE_TYPE_EXTENSIONS = {
+ "GeoTIFF": {".tif", ".tiff"},
+ "PNG": {".png"},
+ "JPEG": {".jpg", ".jpeg"},
+}
+
+IMAGE_SOURCES = {"satellite", "drone", ""}
+
+
+def extensions_for_image_type(image_type: str) -> set[str]:
+ return IMAGE_TYPE_EXTENSIONS.get((image_type or "").strip(), set())
+
+
+def format_allowed_extensions(exts: set[str]) -> str:
+ return ", ".join(sorted(exts))
+
+
+def validate_image_type_extension(image_type: str, filename: str) -> str:
+ """Return lowercased extension or raise 400 if type/extension mismatch."""
+ itype = (image_type or "").strip() or "GeoTIFF"
+ if itype not in IMAGE_TYPES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"image_type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
+ )
+ allowed = IMAGE_TYPE_EXTENSIONS[itype]
+ ext = Path(filename or "").suffix.lower()
+ if not ext or ext not in allowed:
+ got = ext or "(none)"
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"image_type {itype} only allows {format_allowed_extensions(allowed)} "
+ f"(got {got} for {Path(filename or 'upload').name})"
+ ),
+ )
+ if ext not in ALLOWED_EXTENSIONS:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Allowed extensions: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
+ )
+ return ext
+
+
+def _infer_year_from_node(node: TreeNode) -> Optional[int]:
+ for part in (node.node_path or "").split("/"):
+ part = part.strip()
+ if len(part) == 4 and part.isdigit() and part.startswith(("19", "20")):
+ return int(part)
+ if node.node_type == "Year":
+ name = (node.node_name or "").strip()
+ if len(name) == 4 and name.isdigit():
+ return int(name)
+ return None
+
+
+def _parse_year(raw: Optional[str]) -> Optional[int]:
+ if raw is None:
+ return None
+ text = str(raw).strip()
+ if not text:
+ return None
+ try:
+ year = int(text)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="year must be a number")
+ if year < 1900 or year > 2100:
+ raise HTTPException(status_code=400, detail="year out of range")
+ return year
+
+
+def _parse_source(raw: Optional[str], image_type: str) -> str:
+ text = (raw or "").strip().lower()
+ if text in ("satellite", "drone"):
+ return text
+ if text:
+ raise HTTPException(status_code=400, detail="source must be satellite or drone")
+ if image_type == "Satellite":
+ return "satellite"
+ if image_type == "Drone":
+ return "drone"
+ return ""
def _safe_basename(filename: str) -> str:
name = Path(filename or "upload").name
@@ -89,12 +172,84 @@ def _as_iso(value) -> Optional[str]:
return text
+def resolve_location(
+ db: Session,
+ zone_id: Optional[int],
+ village_id: Optional[int],
+) -> tuple[Optional[int], Optional[int]]:
+ """Validate a required zone/village choice; a village implies its zone.
+
+ Both are mandatory — every image must be tagged with exactly one zone
+ and one village at upload time. This used to be optional, which is how
+ thousands of images ended up "Unassigned" (bulk upload and filesystem
+ sync both created rows with no location); the database itself now also
+ rejects a NULL zone_id/village_id, so this check exists for a clear,
+ early error message rather than an opaque IntegrityError."""
+ from ..models import DdaVillage, DdaZone
+
+ zid = int(zone_id) if zone_id else None
+ vid = int(village_id) if village_id else None
+ if not vid:
+ raise HTTPException(status_code=400, detail="A village is required for every image upload.")
+ village = db.query(DdaVillage).filter(DdaVillage.id == vid).first()
+ if not village:
+ raise HTTPException(status_code=404, detail="Location (village) not found")
+ if zid and village.zone_id != zid:
+ raise HTTPException(
+ status_code=400,
+ detail="The selected location does not belong to the selected zone.",
+ )
+ if not zid and not village.zone_id:
+ raise HTTPException(status_code=400, detail="A zone is required for every image upload.")
+ return village.zone_id, village.id
+
+
+def location_names(db: Session) -> tuple[dict[int, str], dict[int, str]]:
+ """id → name lookups for zones and villages (both tables are small)."""
+ from ..models import DdaVillage, DdaZone
+
+ zones = {z.id: z.name for z in db.query(DdaZone.id, DdaZone.name).all()}
+ villages = {v.id: v.name for v in db.query(DdaVillage.id, DdaVillage.name).all()}
+ return zones, villages
+
+
+def resolve_run_location(db: Session, base_path: str, comparison_path: str) -> tuple[str, str]:
+ """Best-effort zone/village NAME for a detection run, derived from the
+ library images being compared (their own zone_id/village_id, set at
+ upload time) — so a new DetectionRun isn't bucketed under "Unassigned"
+ on the dashboard just because the caller didn't pass zone/village
+ explicitly. Prefers the after/comparison image's location, falling back
+ to the before/base image's. Returns ("", "") if neither image has one."""
+ def _norm(p: str) -> str:
+ return (p or "").replace("\\", "/").strip().lstrip("/")
+
+ paths = [p for p in (_norm(comparison_path), _norm(base_path)) if p]
+ if not paths:
+ return "", ""
+ by_path = {
+ img.file_path: img
+ for img in db.query(ImageLibrary).filter(ImageLibrary.file_path.in_(paths)).all()
+ }
+ zones, villages = location_names(db)
+ for p in paths:
+ img = by_path.get(p)
+ if not img or not (img.zone_id or img.village_id):
+ continue
+ zone_name = zones.get(img.zone_id, "") if img.zone_id else ""
+ village_name = villages.get(img.village_id, "") if img.village_id else ""
+ if zone_name or village_name:
+ return zone_name, village_name
+ return "", ""
+
+
def image_to_dict(
img: ImageLibrary,
node: Optional[TreeNode] = None,
*,
include_bounds: bool = True,
slim: bool = False,
+ zone_names: Optional[dict] = None,
+ village_names: Optional[dict] = None,
) -> dict:
rel = (img.file_path or "").replace("\\", "/")
encoded = quote(rel, safe="/")
@@ -120,7 +275,15 @@ def image_to_dict(
"hasGeoref": bool(img.has_georef),
"width": img.width,
"height": img.height,
+ "zoneId": img.zone_id,
+ "villageId": img.village_id,
+ "year": img.year,
+ "source": img.source or "",
}
+ if zone_names is not None:
+ payload["zoneName"] = zone_names.get(img.zone_id, "") if img.zone_id else ""
+ if village_names is not None:
+ payload["villageName"] = village_names.get(img.village_id, "") if img.village_id else ""
if include_bounds:
payload["bounds"] = bounds
if slim:
@@ -130,7 +293,6 @@ def image_to_dict(
"previewUrl": f"/api/dda/local/preview?path={encoded}",
"mapInfoUrl": f"/api/dda/local/map-info?path={encoded}",
"format": img.format,
- "source": "tree_library",
})
return payload
@@ -160,16 +322,21 @@ async def upload_image(
capture_date: Optional[str],
uploaded_by: str,
manual_bounds: Optional[str] = None,
+ zone_id: Optional[int] = None,
+ village_id: Optional[int] = None,
+ year: Optional[str] = None,
+ source: Optional[str] = None,
+ server_filename: Optional[str] = None,
) -> ImageLibrary:
node = get_node_or_404(db, node_id)
+ zone_id, village_id = resolve_location(db, zone_id, village_id)
itype = image_type.strip() or "GeoTIFF"
- if itype not in IMAGE_TYPES:
- raise HTTPException(status_code=400, detail=f"image_type must be one of: {', '.join(sorted(IMAGE_TYPES))}")
-
- original = _safe_basename(file.filename or "upload")
- ext = Path(original).suffix.lower()
- if ext not in ALLOWED_EXTENSIONS:
- raise HTTPException(status_code=400, detail=f"Allowed extensions: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
+ original = _safe_basename(server_filename or file.filename or "upload")
+ ext = validate_image_type_extension(itype, original)
+ year_val = _parse_year(year)
+ if year_val is None:
+ year_val = _infer_year_from_node(node)
+ source_val = _parse_source(source, itype)
existing = (
db.query(ImageLibrary)
@@ -177,6 +344,25 @@ async def upload_image(
.first()
)
if existing is not None:
+ # Re-uploading the same file is a no-op, but it is still the natural way
+ # to fill in a location that was left blank the first time.
+ backfilled = False
+ if zone_id and not existing.zone_id:
+ existing.zone_id = zone_id
+ backfilled = True
+ if village_id and not existing.village_id:
+ existing.village_id = village_id
+ backfilled = True
+ if year_val and not existing.year:
+ existing.year = year_val
+ backfilled = True
+ if source_val and not existing.source:
+ existing.source = source_val
+ backfilled = True
+ if backfilled:
+ db.commit()
+ db.refresh(existing)
+ invalidate_library_caches()
logger.info("Skipped duplicate upload %s -> node %s (already exists as image id %s)",
original, node.id, existing.id)
return existing
@@ -218,6 +404,10 @@ async def upload_image(
image_type=itype,
file_path=rel,
capture_date=cap,
+ zone_id=zone_id,
+ village_id=village_id,
+ year=year_val,
+ source=source_val,
uploaded_by=uploaded_by or "",
file_size_bytes=size,
thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
@@ -228,7 +418,14 @@ async def upload_image(
format=meta.format,
)
db.add(img)
- log_action(db, "upload", node_id=node.id, new_value={"file": rel, "type": itype}, action_by=uploaded_by, module="library")
+ log_action(
+ db, "upload", node_id=node.id,
+ new_value={
+ "file": rel, "type": itype, "zoneId": zone_id, "villageId": village_id,
+ "year": year_val, "source": source_val,
+ },
+ action_by=uploaded_by, module="library",
+ )
db.commit()
db.refresh(img)
invalidate_library_caches()
@@ -239,7 +436,8 @@ async def upload_image(
def list_images_for_node(db: Session, node_id: int) -> List[dict]:
node = get_node_or_404(db, node_id)
rows = db.query(ImageLibrary).filter(ImageLibrary.node_id == node_id).order_by(ImageLibrary.uploaded_on.desc()).all()
- return [image_to_dict(r, node) for r in rows]
+ zones, villages = location_names(db)
+ return [image_to_dict(r, node, zone_names=zones, village_names=villages) for r in rows]
def get_image_by_file_path(db: Session, relative_path: str) -> Optional[ImageLibrary]:
@@ -265,7 +463,19 @@ def _node_and_descendants_clause(node: TreeNode):
return or_(*clauses)
-def _images_query(db: Session, *, node_id: Optional[int] = None, query: Optional[str] = None):
+def _images_query(
+ db: Session,
+ *,
+ node_id: Optional[int] = None,
+ query: Optional[str] = None,
+ year: Optional[int] = None,
+ image_type: Optional[str] = None,
+ zone_id: Optional[int] = None,
+ village_id: Optional[int] = None,
+ capture_date_from: Optional[str] = None,
+ capture_date_to: Optional[str] = None,
+ source: Optional[str] = None,
+):
q = (
db.query(ImageLibrary, TreeNode)
.outerjoin(TreeNode, ImageLibrary.node_id == TreeNode.id)
@@ -283,6 +493,28 @@ def _images_query(db: Session, *, node_id: Optional[int] = None, query: Optional
func.lower(ImageLibrary.image_name).like(like),
func.lower(func.coalesce(TreeNode.node_path, "")).like(like),
))
+ if year is not None:
+ q = q.filter(ImageLibrary.year == year)
+ if image_type:
+ q = q.filter(ImageLibrary.image_type == image_type.strip())
+ if zone_id is not None:
+ q = q.filter(ImageLibrary.zone_id == zone_id)
+ if village_id is not None:
+ q = q.filter(ImageLibrary.village_id == village_id)
+ if source:
+ q = q.filter(func.lower(ImageLibrary.source) == source.strip().lower())
+ if capture_date_from:
+ try:
+ start = date.fromisoformat(capture_date_from.strip())
+ except ValueError:
+ raise HTTPException(status_code=400, detail="capture_date_from must be YYYY-MM-DD")
+ q = q.filter(ImageLibrary.capture_date >= datetime.combine(start, datetime.min.time()))
+ if capture_date_to:
+ try:
+ end = date.fromisoformat(capture_date_to.strip())
+ except ValueError:
+ raise HTTPException(status_code=400, detail="capture_date_to must be YYYY-MM-DD")
+ q = q.filter(ImageLibrary.capture_date <= datetime.combine(end, datetime.max.time()))
return q
@@ -304,28 +536,93 @@ def list_all_images(
*,
node_id: Optional[int] = None,
query: Optional[str] = None,
+ year: Optional[int] = None,
+ image_type: Optional[str] = None,
+ zone_id: Optional[int] = None,
+ village_id: Optional[int] = None,
+ capture_date_from: Optional[str] = None,
+ capture_date_to: Optional[str] = None,
+ source: Optional[str] = None,
include_bounds: bool = False,
slim: bool = True,
limit: Optional[int] = None,
offset: int = 0,
):
- q = _images_query(db, node_id=node_id, query=query)
+ q = _images_query(
+ db,
+ node_id=node_id,
+ query=query,
+ year=year,
+ image_type=image_type,
+ zone_id=zone_id,
+ village_id=village_id,
+ capture_date_from=capture_date_from,
+ capture_date_to=capture_date_to,
+ source=source,
+ )
ordered = q.order_by(ImageLibrary.uploaded_on.desc())
+ zones, villages = location_names(db)
if limit is not None:
total = q.count()
rows = ordered.offset(offset).limit(limit).all()
items = [
- image_to_dict(img, node, include_bounds=include_bounds, slim=slim)
+ image_to_dict(img, node, include_bounds=include_bounds, slim=slim,
+ zone_names=zones, village_names=villages)
for img, node in rows
]
return {"items": items, "total": total, "offset": offset, "limit": limit}
rows = ordered.all()
return [
- image_to_dict(img, node, include_bounds=include_bounds, slim=slim)
+ image_to_dict(img, node, include_bounds=include_bounds, slim=slim,
+ zone_names=zones, village_names=villages)
for img, node in rows
]
+def export_library_rows(
+ db: Session,
+ *,
+ node_id: Optional[int] = None,
+ query: Optional[str] = None,
+ year: Optional[int] = None,
+ image_type: Optional[str] = None,
+ zone_id: Optional[int] = None,
+ village_id: Optional[int] = None,
+ capture_date_from: Optional[str] = None,
+ capture_date_to: Optional[str] = None,
+ source: Optional[str] = None,
+) -> list[dict]:
+ rows = list_all_images(
+ db,
+ node_id=node_id,
+ query=query,
+ year=year,
+ image_type=image_type,
+ zone_id=zone_id,
+ village_id=village_id,
+ capture_date_from=capture_date_from,
+ capture_date_to=capture_date_to,
+ source=source,
+ include_bounds=False,
+ slim=False,
+ )
+ out = []
+ for img in rows:
+ out.append({
+ "Image ID": img.get("id"),
+ "Server filename": img.get("filename") or img.get("imageName") or "",
+ "Image type": img.get("imageType") or "",
+ "Source": img.get("source") or "",
+ "Format": img.get("format") or "",
+ "Capture date": (str(img.get("captureDate") or "")[:10]),
+ "Year": img.get("year") or "",
+ "Zone": img.get("zoneName") or "",
+ "Village": img.get("villageName") or "",
+ "File size": img.get("fileSizeBytes") or 0,
+ "Uploaded on": img.get("uploadedOn") or "",
+ })
+ return out
+
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
full = resolve_file(relative_path)
cache = _thumb_cache_path(relative_path)
diff --git a/app/dda/tree/library_cache.py b/app/dda/tree/library_cache.py
index 2a3e2652ad34e2b3576d38ab7540d0912c670b17..b3492ddee0859fcfeadb1f9a99a569d64c2f49fe 100644
--- a/app/dda/tree/library_cache.py
+++ b/app/dda/tree/library_cache.py
@@ -3,23 +3,26 @@ from __future__ import annotations
from typing import Any, Optional
+# fingerprint → { node_id_or_0: payload }
_area_fp: Optional[tuple] = None
-_area_payload: Optional[dict[str, Any]] = None
+_area_by_node: dict[int, dict[str, Any]] = {}
def invalidate_library_caches() -> None:
- global _area_fp, _area_payload
+ global _area_fp, _area_by_node
_area_fp = None
- _area_payload = None
+ _area_by_node = {}
-def get_area_groups_cache(fingerprint: tuple) -> Optional[dict[str, Any]]:
- if _area_fp == fingerprint:
- return _area_payload
- return None
+def get_area_groups_cache(fingerprint: tuple, node_id: int = 0) -> Optional[dict[str, Any]]:
+ if _area_fp != fingerprint:
+ return None
+ return _area_by_node.get(int(node_id or 0))
-def set_area_groups_cache(fingerprint: tuple, payload: dict[str, Any]) -> None:
- global _area_fp, _area_payload
- _area_fp = fingerprint
- _area_payload = payload
+def set_area_groups_cache(fingerprint: tuple, payload: dict[str, Any], node_id: int = 0) -> None:
+ global _area_fp, _area_by_node
+ if _area_fp != fingerprint:
+ _area_by_node = {}
+ _area_fp = fingerprint
+ _area_by_node[int(node_id or 0)] = payload
diff --git a/app/dda/tree/migration.py b/app/dda/tree/migration.py
index 1bcd44fe4b6ebc39b876f4d3a9b8bcb20e8cf5a6..54b2967f936754cf7a5c404158a1ca691d86a3fd 100644
--- a/app/dda/tree/migration.py
+++ b/app/dda/tree/migration.py
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from ..config import ALLOWED_EXTENSIONS, get_library_roots, get_storage_root
from ..seed_data import DELHI_ZONES
-from .models import ImageLibrary, TreeNode
+from .models import TreeNode
from .path_service import ensure_node_directory, storage_root
from .path_slugs import unique_slug
from .tree_service import create_node
@@ -85,16 +85,10 @@ def migrate_flat_year_folders(db: Session) -> dict:
if path.resolve() != target.resolve():
shutil.move(str(path), str(target))
rel = target.relative_to(root).as_posix()
- if not db.query(ImageLibrary).filter(ImageLibrary.file_path == rel).first():
- db.add(ImageLibrary(
- node_id=year_node.id,
- image_name=target.name,
- image_type="GeoTIFF",
- file_path=rel,
- uploaded_by="migration",
- file_size_bytes=target.stat().st_size,
- ))
- indexed += 1
+ # zone_id/village_id are required (NOT NULL); this legacy path has
+ # no location info to give a migrated image, so the file is moved
+ # into the tree but left un-indexed — same reasoning as the
+ # filesystem sync's own skip (see sync_service.py).
moved += 1
try:
if entry.is_dir() and not any(entry.iterdir()):
diff --git a/app/dda/tree/models.py b/app/dda/tree/models.py
index 1f4923d9916f84623ab9cc217f48a16f4233c218..2740d02320042feff46248730062f3aa60b56252 100644
--- a/app/dda/tree/models.py
+++ b/app/dda/tree/models.py
@@ -43,6 +43,19 @@ class ImageLibrary(Base):
# path. Works unchanged on SQLite/PostgreSQL too.
file_path = Column(String(500), nullable=False, unique=True) # relative to storage root
capture_date = Column(DateTime, nullable=True)
+ # Administrative location picked on upload. Independent of the folder tree:
+ # the same zone/village can appear under several folders, and pairing uses
+ # it as a strong "same place" signal. Required, not just conventionally —
+ # an "Unassigned" image was a recurring real problem (thousands of images
+ # landed with no location via bulk upload / filesystem sync and cluttered
+ # every zone/village breakdown), so this is enforced NOT NULL at the DB
+ # level too (see bootstrap.py's migration list) so no insert path —
+ # including ones outside this app's own upload endpoints — can create an
+ # unassigned row.
+ zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=False, index=True)
+ village_id = Column(Integer, ForeignKey("dda_villages.id"), nullable=False, index=True)
+ year = Column(Integer, nullable=True, index=True)
+ source = Column(String(32), default="") # satellite | drone | ""
uploaded_by = Column(String(100), default="")
uploaded_on = Column(DateTime, default=_utcnow)
# BigInteger, not Integer: this project's GeoTIFFs regularly exceed 2GB
@@ -86,4 +99,5 @@ class ExceptionLog(Base):
handled = Column(Boolean, default=False)
traceback = Column(Text, default="")
user_email = Column(String(100), nullable=True)
+ user_role = Column(String(64), nullable=True)
created_at = Column(DateTime, default=_utcnow, index=True)
diff --git a/app/dda/tree/routes.py b/app/dda/tree/routes.py
index 07f76e111996bf4935750d91049c5a02c422b249..3e68a465f1125efe857c57b3efa462afde55a750 100644
--- a/app/dda/tree/routes.py
+++ b/app/dda/tree/routes.py
@@ -1,23 +1,37 @@
"""FastAPI routes for unlimited-depth tree library."""
from __future__ import annotations
-from typing import Optional
+import csv
+import io
+import json
+from typing import List, Optional
-from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
+from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
+from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...database import get_db
from ...models import User
-from ..config import IS_DDA_MODE
+from ..config import IS_DDA_MODE, get_storage_root
from ..dda_auth import current_dda_user, get_user_role
from ..rbac.permissions import require_module_permission
+from .bulk_upload_service import (
+ BulkDefaults,
+ build_bulk_upload_template,
+ bulk_upload_images,
+ parse_bulk_manifest,
+ zone_village_names,
+)
from .image_service import (
+ IMAGE_TYPE_EXTENSIONS,
IMAGE_TYPES,
delete_library_image,
+ export_library_rows,
image_to_dict,
list_all_images,
list_images_for_node,
+ location_names,
upload_image,
)
from .tree_service import (
@@ -38,6 +52,17 @@ def _require_dda():
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
+def _optional_int(raw: str, field: str) -> Optional[int]:
+ """Multipart form fields arrive as strings; '' means 'not selected'."""
+ text = (raw or "").strip()
+ if not text:
+ return None
+ try:
+ return int(text)
+ except ValueError:
+ raise HTTPException(status_code=400, detail=f"{field} must be a number")
+
+
class NodeCreateBody(BaseModel):
parent_id: Optional[int] = None
node_name: str = Field(..., min_length=1, max_length=500)
@@ -66,7 +91,11 @@ def tree_me(user: User = Depends(current_dda_user), db: Session = Depends(get_db
@router.get("/tree")
def get_tree(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
_require_dda()
- return {"tree": build_tree(db), "imageTypes": sorted(IMAGE_TYPES)}
+ return {
+ "tree": build_tree(db),
+ "imageTypes": sorted(IMAGE_TYPES),
+ "imageTypeExtensions": {k: sorted(v) for k, v in IMAGE_TYPE_EXTENSIONS.items()},
+ }
@router.post("/tree/nodes")
@@ -184,11 +213,98 @@ def api_list_node_images(node_id: int, db: Session = Depends(get_db), user: User
def api_list_images(
node_id: Optional[int] = None,
q: Optional[str] = None,
+ year: Optional[int] = None,
+ image_type: Optional[str] = None,
+ zone_id: Optional[int] = None,
+ village_id: Optional[int] = None,
+ capture_date_from: Optional[str] = None,
+ capture_date_to: Optional[str] = None,
+ source: Optional[str] = None,
+ limit: Optional[int] = Query(50, ge=1, le=500),
+ offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
user: User = Depends(current_dda_user),
):
_require_dda()
- return list_all_images(db, node_id=node_id, query=q)
+ return list_all_images(
+ db,
+ node_id=node_id,
+ query=q,
+ year=year,
+ image_type=image_type,
+ zone_id=zone_id,
+ village_id=village_id,
+ capture_date_from=capture_date_from,
+ capture_date_to=capture_date_to,
+ source=source,
+ include_bounds=False,
+ slim=True,
+ limit=limit if limit is not None else 50,
+ offset=offset,
+ )
+
+
+@router.get("/tree/images/export")
+def api_export_images(
+ node_id: Optional[int] = None,
+ q: Optional[str] = None,
+ year: Optional[int] = None,
+ image_type: Optional[str] = None,
+ zone_id: Optional[int] = None,
+ village_id: Optional[int] = None,
+ capture_date_from: Optional[str] = None,
+ capture_date_to: Optional[str] = None,
+ source: Optional[str] = None,
+ format: str = Query("csv", pattern="^(csv|xlsx)$"),
+ db: Session = Depends(get_db),
+ user: User = Depends(require_module_permission("library", "view")),
+):
+ _require_dda()
+ rows = export_library_rows(
+ db,
+ node_id=node_id,
+ query=q,
+ year=year,
+ image_type=image_type,
+ zone_id=zone_id,
+ village_id=village_id,
+ capture_date_from=capture_date_from,
+ capture_date_to=capture_date_to,
+ source=source,
+ )
+ headers = [
+ "Image ID", "Server filename", "Image type", "Source", "Format",
+ "Capture date", "Year", "Zone", "Village", "File size", "Uploaded on",
+ ]
+ if format == "xlsx":
+ from openpyxl import Workbook
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Library"
+ ws.append(headers)
+ for row in rows:
+ ws.append([row.get(h, "") for h in headers])
+ buf = io.BytesIO()
+ wb.save(buf)
+ buf.seek(0)
+ return Response(
+ content=buf.getvalue(),
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": 'attachment; filename="library_export.xlsx"'},
+ )
+
+ buf = io.StringIO()
+ writer = csv.DictWriter(buf, fieldnames=headers, extrasaction="ignore")
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(row)
+ data = buf.getvalue().encode("utf-8-sig")
+ return Response(
+ content=data,
+ media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": 'attachment; filename="library_export.csv"'},
+ )
@router.post("/tree/nodes/{node_id}/images/upload")
@@ -198,6 +314,11 @@ async def api_upload_image(
image_type: str = Form("GeoTIFF"),
capture_date: str = Form(""),
manual_bounds: str = Form(""),
+ zone_id: str = Form(""),
+ village_id: str = Form(""),
+ year: str = Form(""),
+ source: str = Form(""),
+ server_filename: str = Form(""),
db: Session = Depends(get_db),
user: User = Depends(require_module_permission("library", "create")),
):
@@ -208,9 +329,160 @@ async def api_upload_image(
capture_date=capture_date or None,
uploaded_by=user.email or str(user.id),
manual_bounds=manual_bounds or None,
+ zone_id=_optional_int(zone_id, "zone_id"),
+ village_id=_optional_int(village_id, "village_id"),
+ year=year or None,
+ source=source or None,
+ server_filename=server_filename or None,
+ )
+ node = get_node_or_404(db, node_id)
+ zones, villages = location_names(db)
+ return {
+ "status": True,
+ "message": "Image Uploaded Successfully",
+ "image": image_to_dict(img, node, zone_names=zones, village_names=villages),
+ }
+
+
+@router.get("/tree/bulk-upload/template")
+def api_bulk_upload_template(
+ user: User = Depends(require_module_permission("library", "view")),
+):
+ _require_dda()
+ content = build_bulk_upload_template()
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": "attachment; filename=bulk_image_upload_template.xlsx"},
+ )
+
+
+@router.get("/tree/bulk-upload/config")
+def api_bulk_upload_config(
+ user: User = Depends(require_module_permission("library", "view")),
+):
+ """Main folder for this environment — Excel General Folder paths are relative to it."""
+ _require_dda()
+ root = get_storage_root()
+ return {
+ "mainFolder": str(root),
+ "imageTypes": sorted(IMAGE_TYPES),
+ "sources": ["satellite", "drone"],
+ }
+
+
+@router.post("/tree/bulk-upload")
+async def api_bulk_upload(
+ excel: UploadFile = File(...),
+ files: List[UploadFile] = File(...),
+ year: str = Form(""),
+ source: str = Form(""),
+ image_type: str = Form(""),
+ zone_id: str = Form(""),
+ village_id: str = Form(""),
+ filenames: Optional[str] = Form(None),
+ db: Session = Depends(get_db),
+ user: User = Depends(require_module_permission("library", "create")),
+):
+ _require_dda()
+ xlsx_bytes = await excel.read()
+ rows, row_errors = parse_bulk_manifest(xlsx_bytes)
+
+ zid = _optional_int(zone_id, "zone_id")
+ vid = _optional_int(village_id, "village_id")
+ zone_name, village_name = zone_village_names(db, zid, vid)
+
+ name_map = {}
+ if filenames:
+ try:
+ raw = json.loads(filenames)
+ if isinstance(raw, dict):
+ name_map = {str(k).strip().lower(): str(v).strip() for k, v in raw.items() if v}
+ except Exception:
+ raise HTTPException(status_code=400, detail="filenames must be a JSON object of originalName → serverFilename")
+
+ defaults = BulkDefaults(
+ year=(year or "").strip() or None,
+ source=(source or "").strip().lower() or None,
+ image_type=(image_type or "").strip() or None,
+ zone=zone_name or None,
+ village=village_name or None,
+ filenames=name_map,
)
+ # Always run defaults so empty Image Type gets GeoTIFF.
+ result = await bulk_upload_images(
+ db, rows, files,
+ uploaded_by=user.email or str(user.id),
+ defaults=defaults,
+ )
+ result.errors = row_errors + result.errors
+ return {
+ "status": True,
+ "created": result.created,
+ "duplicates": result.duplicates,
+ "errors": result.errors,
+ "unmatchedFiles": result.unmatched_files,
+ "summary": {
+ "created": len(result.created),
+ "duplicates": len(result.duplicates),
+ "errors": len(result.errors),
+ "unmatchedFiles": len(result.unmatched_files),
+ },
+ "mainFolder": str(get_storage_root()),
+ }
+
+
+@router.post("/tree/nodes/{node_id}/images/upload-bulk")
+async def api_upload_images_bulk(
+ node_id: int,
+ files: List[UploadFile] = File(...),
+ image_type: str = Form("GeoTIFF"),
+ capture_date: str = Form(""),
+ manual_bounds: str = Form(""),
+ zone_id: str = Form(""),
+ village_id: str = Form(""),
+ year: str = Form(""),
+ source: str = Form(""),
+ filenames: Optional[List[str]] = Form(None),
+ db: Session = Depends(get_db),
+ user: User = Depends(require_module_permission("library", "create")),
+):
+ """Upload many files in one request; loops the single-file upload path."""
+ _require_dda()
node = get_node_or_404(db, node_id)
- return {"status": True, "message": "Image Uploaded Successfully", "image": image_to_dict(img, node)}
+ zones, villages = location_names(db)
+ uploaded = []
+ failed = []
+ if isinstance(filenames, str):
+ name_list = [filenames]
+ else:
+ name_list = list(filenames or [])
+ for i, file in enumerate(files):
+ server_name = name_list[i] if i < len(name_list) else (file.filename or "upload")
+ try:
+ img = await upload_image(
+ db, node_id, file,
+ image_type=image_type,
+ capture_date=capture_date or None,
+ uploaded_by=user.email or str(user.id),
+ manual_bounds=manual_bounds or None,
+ zone_id=_optional_int(zone_id, "zone_id"),
+ village_id=_optional_int(village_id, "village_id"),
+ year=year or None,
+ source=source or None,
+ server_filename=server_name,
+ )
+ uploaded.append(image_to_dict(img, node, zone_names=zones, village_names=villages))
+ except HTTPException as exc:
+ failed.append({"filename": server_name, "error": str(exc.detail)})
+ except Exception as exc:
+ failed.append({"filename": server_name, "error": str(exc)})
+ return {
+ "status": True,
+ "message": f"Uploaded {len(uploaded)} image(s)" + (f", {len(failed)} failed" if failed else ""),
+ "uploaded": uploaded,
+ "failed": failed,
+ }
@router.delete("/tree/images/{image_id}")
diff --git a/app/dda/tree/sync_service.py b/app/dda/tree/sync_service.py
index 4a2a15faa64d4225b1df807f6c6a8c9563218ecc..0cb8bda0733a64075c33523e6b0e3fb5162745a8 100644
--- a/app/dda/tree/sync_service.py
+++ b/app/dda/tree/sync_service.py
@@ -22,6 +22,7 @@ _NODE_TYPES = ("Zone", "Area", "Year", "Folder")
_SKIP_DIRS = frozenset({".git", ".thumbs", "__pycache__", "cache", "thumbs"})
_RESERVED_DIR_NAMES = RESERVED
_SKIP_CACHE: Optional[dict] = None
+_COMMIT_BATCH_SIZE = 200
def _skip_cache_path() -> Path:
@@ -172,7 +173,7 @@ def _image_type_for(file_path: Path) -> str:
return "JPEG"
if ext == ".png":
return "PNG"
- return "Raster"
+ return "GeoTIFF"
def _index_image_file(
@@ -182,6 +183,9 @@ def _index_image_file(
Unreadable rasters (corrupt TIFF, etc.) are skipped and counted in
``stats['imagesSkipped']`` so one bad file cannot fail the whole rescan.
+
+ Does not commit — the caller batches commits across many files (see
+ ``_COMMIT_BATCH_SIZE``) so a large sync isn't one fsync per row.
"""
existing = db.query(ImageLibrary).filter(ImageLibrary.file_path == rel_file).first()
path_fixed = False
@@ -197,7 +201,7 @@ def _index_image_file(
skip_meta = skip.get(rel_file)
if skip_meta is not None and int(skip_meta.get("size") or 0) == stat.st_size:
if path_fixed:
- db.commit()
+ db.flush()
stats["imagesSkipped"] = stats.get("imagesSkipped", 0) + 1
return False
if existing and existing.file_size_bytes == stat.st_size and (existing.width or 0) > 0:
@@ -209,7 +213,7 @@ def _index_image_file(
changed = True
if changed:
existing.node_id = node.id
- db.commit()
+ db.flush()
stats["imagesUpdated"] += 1
return False
try:
@@ -249,35 +253,29 @@ def _index_image_file(
inferred = _infer_capture_date(file_path, node)
if inferred:
existing.capture_date = inferred
- db.commit()
+ db.flush()
stats["imagesUpdated"] += 1
elif existing.capture_date is None:
inferred = _infer_capture_date(file_path, node)
if inferred:
existing.capture_date = inferred
- db.commit()
+ db.flush()
stats["imagesUpdated"] += 1
return False
- img = ImageLibrary(
- node_id=node.id,
- image_name=file_path.name,
- image_type=_image_type_for(file_path),
- file_path=rel_file,
- capture_date=_infer_capture_date(file_path, node),
- uploaded_by="filesystem-sync",
- file_size_bytes=stat.st_size,
- thumb_cache_key=hashlib.sha256(rel_file.encode()).hexdigest()[:32],
- width=meta.width,
- height=meta.height,
- has_georef=meta.has_georef,
- bounds_json=bounds_to_json(meta.bounds_wgs84) or "",
- format=meta.format,
+ # zone_id/village_id are required (NOT NULL) and there's no reliable way
+ # to derive them from a bare file on disk — a file found by the sync
+ # (rather than uploaded through the app, which does collect a location)
+ # is left un-indexed rather than creating an "Unassigned" row. It stays
+ # on disk and will be picked up automatically once someone uploads it
+ # properly (with a zone/village) through the Image Library UI, which
+ # de-dupes by file_path.
+ logger.info(
+ "Not indexing %s: found on disk but the filesystem sync has no zone/village for it "
+ "(upload it through the Image Library to tag a location)", rel_file,
)
- db.add(img)
- db.commit()
- logger.info("Indexed image from disk: %s", rel_file)
- return True
+ stats["imagesNeedLocation"] = stats.get("imagesNeedLocation", 0) + 1
+ return False
def _node_path_for_images_folder(rel_path: str) -> Optional[str]:
@@ -312,12 +310,19 @@ def _sync_directory(db: Session, abs_dir: Path, rel_path: str, stats: dict) -> N
logger.warning("Skipping images under %s: %s", rel_path, exc)
stats["foldersSkipped"] = stats.get("foldersSkipped", 0) + 1
continue
+ since_commit = 0
for f in sorted(child.iterdir()):
if not f.is_file() or f.suffix.lower() not in ALLOWED_EXTENSIONS:
continue
rel_file = f.relative_to(storage_root()).as_posix()
if _index_image_file(db, node, f, rel_file, stats):
stats["imagesIndexed"] += 1
+ since_commit += 1
+ if since_commit >= _COMMIT_BATCH_SIZE:
+ db.commit()
+ since_commit = 0
+ if since_commit:
+ db.commit()
continue
if child.name.lower() in _RESERVED_DIR_NAMES:
@@ -349,6 +354,7 @@ def sync_from_filesystem(db: Session, *, check_orphans: bool = True) -> dict:
"imagesIndexed": 0,
"imagesUpdated": 0,
"imagesSkipped": 0,
+ "imagesNeedLocation": 0,
"orphansFlagged": 0,
"foldersSkipped": 0,
}
diff --git a/app/dda/tree/tree_service.py b/app/dda/tree/tree_service.py
index 5c6fe72c398706ab5b45f8ef33ada28023c91c19..57ccaef27bb6545abd79f3371c48f446fa8f0033 100644
--- a/app/dda/tree/tree_service.py
+++ b/app/dda/tree/tree_service.py
@@ -173,6 +173,29 @@ def get_or_create_node(
raise
+def get_or_create_node_by_path(
+ db: Session,
+ path: str,
+ *,
+ created_by: str,
+ node_type: str = "Folder",
+) -> TreeNode:
+ """Walk a "/"-separated path from the root, creating any missing folder
+ segment along the way (mirrors the frontend's ensureFolderPath, used for
+ webkitdirectory uploads, but server-side — see bulk_upload_service.py)."""
+ segments = [s.strip() for s in (path or "").split("/") if s.strip()]
+ if not segments:
+ raise HTTPException(status_code=400, detail="Folder path is required")
+ parent_id: Optional[int] = None
+ node: Optional[TreeNode] = None
+ for segment in segments:
+ node, _created = get_or_create_node(
+ db, parent_id=parent_id, node_name=segment, node_type=node_type, created_by=created_by,
+ )
+ parent_id = node.id
+ return node
+
+
def rename_node(
db: Session,
node_id: int,
diff --git a/app/main.py b/app/main.py
index a1f4304880bdbcd3a1c545b07b6d2d2b8dfaf329..0fd8d17048d3114791f7f745ff5d43659c07f60c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -16,7 +16,7 @@ except ImportError:
from jose import JWTError, jwt
from sqlalchemy import text as sa_text
from fastapi import FastAPI, Depends, File, Form, HTTPException, Query, Request, UploadFile
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.exception_handlers import http_exception_handler as starlette_http_exception_handler
@@ -51,6 +51,7 @@ from .auth import (
mask_email,
normalize_phone,
record_login_event,
+ revoke_user_sessions,
verify_login_otp,
verify_password,
)
@@ -116,21 +117,26 @@ app = FastAPI(title="AI Change Detection", version="2.4.0-dda" if IS_DDA_MODE el
setup_dda(app)
-def _exception_log_user_email(request: Request) -> str | None:
- """Best-effort user lookup for the exception log — never raises."""
+def _exception_log_user(request: Request) -> tuple[str | None, str | None]:
+ """Best-effort user email + role for the exception log — never raises."""
try:
from .database import SessionLocal
+ from .dda.dda_auth import get_user_role
token = request.cookies.get("access_token") or request.cookies.get(COOKIE_NAME)
if not token:
- return None
+ return None, None
db = SessionLocal()
try:
user = get_user_from_token(token, db)
- return user.email if user else None
+ if not user:
+ return None, None
+ email = user.email if user else None
+ role = get_user_role(db, user) if user else None
+ return email, role
finally:
db.close()
except Exception:
- return None
+ return None, None
@app.exception_handler(StarletteHTTPException)
@@ -142,10 +148,11 @@ async def _dda_http_exception_handler(request: Request, exc: StarletteHTTPExcept
from .dda.tree.audit_service import log_exception
db = SessionLocal()
try:
+ email, role = _exception_log_user(request)
log_exception(
db, exc=exc, path=request.url.path, method=request.method,
status_code=exc.status_code, handled=True,
- user_email=_exception_log_user_email(request),
+ user_email=email, user_role=role,
)
finally:
db.close()
@@ -164,10 +171,11 @@ async def _dda_unhandled_exception_handler(request: Request, exc: Exception):
from .dda.tree.audit_service import log_exception
db = SessionLocal()
try:
+ email, role = _exception_log_user(request)
log_exception(
db, exc=exc, path=request.url.path, method=request.method,
status_code=500, handled=False,
- user_email=_exception_log_user_email(request),
+ user_email=email, user_role=role,
)
finally:
db.close()
@@ -302,6 +310,18 @@ THUMB_MAX_SIZE = 200 # max width or height for history thumbnails
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
+@app.middleware("http")
+async def _static_cache_control(request: Request, call_next):
+ """Long-lived cache for versioned /static assets (?v= busting on script tags)."""
+ response = await call_next(request)
+ if request.url.path.startswith("/static/"):
+ response.headers.setdefault(
+ "Cache-Control",
+ "public, max-age=31536000, immutable",
+ )
+ return response
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@@ -467,7 +487,7 @@ def login(data: UserLogin, request: Request, db: Session = Depends(get_db)):
user_id=user.id, ip_address=ip, user_agent=ua,
)
expires = REMEMBER_ME_EXPIRE if data.remember_me else SESSION_TOKEN_EXPIRE
- token = create_access_token(data={"sub": str(user.id), "purpose": "session"}, expires_delta=expires)
+ token = create_access_token(data={"sub": str(user.id), "purpose": "session", "tv": int(user.token_version or 0)}, expires_delta=expires)
return _auth_response(token, user, remember=data.remember_me)
except HTTPException:
raise
@@ -508,7 +528,8 @@ def request_login_otp(data: OtpRequest, request: Request, db: Session = Depends(
@app.post("/api/auth/logout")
def logout(request: Request, db: Session = Depends(get_db)):
- """Record the logout, then clear the auth cookie so subsequent requests are unauthenticated."""
+ """Record the logout, revoke the session token (and any other copy of it —
+ another tab, another device, or a leaked token), then clear the cookie."""
token = request.cookies.get(COOKIE_NAME)
if token:
user = get_user_from_token(token, db)
@@ -517,6 +538,8 @@ def logout(request: Request, db: Session = Depends(get_db)):
db, event_type="logout", attempted_email=user.email, user_id=user.id,
ip_address=get_client_ip(request), user_agent=get_user_agent(request),
)
+ revoke_user_sessions(user)
+ db.commit()
response = JSONResponse(content={"ok": True})
response.delete_cookie(
COOKIE_NAME,
@@ -541,6 +564,7 @@ def reset_password(data: PasswordReset, db: Session = Depends(get_db)):
# Intentionally vague to prevent email enumeration
raise HTTPException(status_code=404, detail="No account found with that email")
user.hashed_password = get_password_hash(data.new_password)
+ revoke_user_sessions(user)
db.commit()
return {"ok": True, "message": "Password has been reset. You can now sign in."}
@@ -585,6 +609,7 @@ def reset_password_with_token(data: ResetPasswordWithToken, db: Session = Depend
if not user:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
user.hashed_password = get_password_hash(data.new_password)
+ revoke_user_sessions(user)
db.commit()
return {"ok": True, "message": "Password has been reset. You can now sign in."}
@@ -648,6 +673,7 @@ def change_password(data: ChangePasswordRequest, user: Optional[User] = Depends(
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
user.hashed_password = get_password_hash(data.new_password)
+ revoke_user_sessions(user)
db.commit()
return {"ok": True, "message": "Password changed."}
@@ -726,7 +752,7 @@ def verify_login_otp_route(data: OtpVerifyRequest, request: Request, db: Session
user_id=user.id, ip_address=ip, user_agent=ua,
)
expires = REMEMBER_ME_EXPIRE if remember else SESSION_TOKEN_EXPIRE
- token = create_access_token(data={"sub": str(user.id), "purpose": "session"}, expires_delta=expires)
+ token = create_access_token(data={"sub": str(user.id), "purpose": "session", "tv": int(user.token_version or 0)}, expires_delta=expires)
return _auth_response(token, user, remember=remember)
@@ -810,9 +836,8 @@ async def detect(
before_pil = _read_upload(before, "before")
after_pil = _read_upload(after, "after")
- detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
- if min_region_area is not None:
- min_region_area = int(max(50, min(10000, min_region_area)))
+ from .dda.detect_service import clamp_detection_params
+ detection_sensitivity, min_region_area = clamp_detection_params(detection_sensitivity, min_region_area)
from .detection_engine import run_detection
change_mask, result_image, stats, change_regions = run_detection(
@@ -897,6 +922,8 @@ async def detect(
before_thumb_path=relative_before_thumb,
after_thumb_path=relative_after_thumb,
regions_json=json.dumps(regions_serializable),
+ detection_sensitivity=detection_sensitivity,
+ min_region_area=min_region_area,
)
db.add(run)
db.commit()
@@ -966,7 +993,12 @@ def notify_test(
@app.get("/api/overlay/{path:path}")
-def serve_overlay(path: str, request: Request, db: Session = Depends(get_db)):
+def serve_overlay(
+ path: str,
+ request: Request,
+ db: Session = Depends(get_db),
+ max_side: int | None = Query(None, ge=64, le=4096),
+):
# Restrict to overlays directory
full = (OVERLAYS_DIR.parent / path).resolve()
base = OVERLAYS_DIR.parent.resolve()
@@ -1014,6 +1046,32 @@ def serve_overlay(path: str, request: Request, db: Session = Depends(get_db)):
if owner_id_str.isdigit() and int(owner_id_str) != user.id:
raise HTTPException(404)
+ # Browser viewers choke on ~50MB full overlays; downscale + cache when requested.
+ if max_side:
+ cache = full.with_name(f"{full.stem}_ms{max_side}{full.suffix}")
+ try:
+ if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
+ return FileResponse(cache, media_type="image/png")
+ from PIL import Image
+ with Image.open(full) as im:
+ if im.mode not in ("RGB", "RGBA"):
+ im = im.convert("RGBA")
+ w, h = im.size
+ longest = max(w, h)
+ if longest > max_side:
+ scale = max_side / float(longest)
+ im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.Resampling.LANCZOS)
+ buf = io.BytesIO()
+ im.save(buf, format="PNG", optimize=True)
+ payload = buf.getvalue()
+ try:
+ cache.write_bytes(payload)
+ except Exception:
+ pass
+ return Response(content=payload, media_type="image/png")
+ except Exception:
+ pass
+
return FileResponse(full, media_type="image/png")
@@ -1023,6 +1081,8 @@ def history(
request: Request,
db: Session = Depends(get_db),
limit: int = Query(100, ge=1, le=200),
+ offset: int = Query(0, ge=0),
+ paged: int = Query(0, ge=0, le=1),
):
if IS_DDA_MODE:
from .dda.dda_auth import get_dda_user
@@ -1042,11 +1102,13 @@ def history(
filt = DetectionRun.user_id == user.id
if auto_ids:
filt = or_(filt, DetectionRun.id.in_(auto_ids))
- runs = db.query(DetectionRun).filter(filt).order_by(DetectionRun.created_at.desc()).limit(limit).all()
+ query = db.query(DetectionRun).filter(filt)
else:
user = get_or_create_guest_user(db)
- runs = db.query(DetectionRun).filter(DetectionRun.user_id == user.id).order_by(DetectionRun.created_at.desc()).limit(limit).all()
- return [
+ query = db.query(DetectionRun).filter(DetectionRun.user_id == user.id)
+ total = query.count() if paged else None
+ runs = query.order_by(DetectionRun.created_at.desc()).offset(offset).limit(limit).all()
+ items = [
{
"id": r.id,
"title": r.title,
@@ -1064,6 +1126,9 @@ def history(
}
for r in runs
]
+ if paged:
+ return {"items": items, "total": int(total or 0), "offset": offset, "limit": limit}
+ return items
@app.get("/api/history/{run_id}")
@@ -1204,16 +1269,18 @@ def delete_run(
# --- Serve SPA ---
@app.get("/dda/reports/{run_id}", response_class=HTMLResponse)
def dda_report_page(run_id: int, request: Request, db: Session = Depends(get_db)):
- """Standalone browser report (FR-05)."""
+ """Standalone browser report (FR-05) with interactive viewer."""
if not IS_DDA_MODE:
raise HTTPException(status_code=404, detail="Not found")
_user, redirect = _dda_session_or_redirect(request, db)
if redirect:
return redirect
- report_file = TEMPLATES_DIR / "report_dda.html"
- if not report_file.exists():
- raise HTTPException(status_code=404, detail="Report template missing")
- return FileResponse(report_file)
+ return templates.TemplateResponse(
+ request,
+ "report_dda.html",
+ {"run_id": run_id, "active_page": "reports", "menu_items": []},
+ headers={"Cache-Control": "no-store"},
+ )
# no-store on every auth page below: FileResponse otherwise sends
diff --git a/app/models.py b/app/models.py
index e00170e52a1bd534309a510b8d9db36669ccae03..979fcffce349424758e51f5f93fac3e886063d1b 100644
--- a/app/models.py
+++ b/app/models.py
@@ -29,6 +29,11 @@ class User(Base):
# accounts can bind a number on first successful OTP verify.
phone = Column(String(20), unique=True, nullable=True, index=True)
created_at = Column(DateTime, default=_utcnow)
+ # Bumped on logout and on any password change/reset so previously-issued
+ # JWTs (a copy in another tab, or a leaked token) stop authenticating
+ # immediately instead of staying valid until natural expiry — see
+ # create_access_token()/get_user_from_token() in auth.py.
+ token_version = Column(Integer, default=0, nullable=False)
detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
role_obj = relationship("Role", foreign_keys=[role_id])
@@ -53,6 +58,11 @@ class DetectionRun(Base):
zone = Column(String(128), default="")
village = Column(String(128), default="")
regions_json = Column(Text, default="[]")
+ # The Sensitivity / Minimum Region Area values actually used for this run
+ # (post-clamp — see detect_service.clamp_detection_params) — nullable so
+ # runs created before this column existed just show as blank, not 0.
+ detection_sensitivity = Column(Float, nullable=True)
+ min_region_area = Column(Integer, nullable=True)
created_at = Column(DateTime, default=_utcnow)
user = relationship("User", back_populates="detections")
diff --git a/requirements.txt b/requirements.txt
index 59b5481d610a8a149ccd5e73406f1daf3390b769..b3287249a5a92076890a2a6aea38bc01e6399b4c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,3 +24,4 @@ reportlab>=4.0.0
email-validator>=2.0.0
pyotp>=2.9.0
qrcode>=7.4.0
+openpyxl>=3.1.0
diff --git a/scripts/test_area_groups.py b/scripts/test_area_groups.py
index 29b727dbc93253fc06e513c188fa7a7c22ebee8d..03f31e58bf3bb9f342fba69029dfabfe2ac092ae 100644
--- a/scripts/test_area_groups.py
+++ b/scripts/test_area_groups.py
@@ -1,4 +1,4 @@
-"""Same-area pairing: hard keys must not be merged by overlapping bounds."""
+"""Same-area pairing: metadata comparison, exactly two images, no reuse."""
import sys
from datetime import datetime
from pathlib import Path
@@ -6,11 +6,25 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.dda.tree.area_groups import (
+ LocationIndex,
build_area_groups,
+ conflicting_keys,
distinct_places,
parse_datetime,
)
+DELHI = LocationIndex(
+ zones=[(1, "Central Delhi"), (2, "North Delhi")],
+ villages=[(5, "Daryaganj", 1), (6, "Paharganj", 1), (7, "Model Town", 1), (8, "Model Town", 2)],
+)
+
+BOUNDS = {"west": 77.0, "south": 28.0, "east": 77.2, "north": 28.2}
+ELSEWHERE = {"west": 78.0, "south": 29.0, "east": 78.2, "north": 29.2}
+
+
+def _paths(group):
+ return [img["path"] for img in group["images"]]
+
def test_mysql_datetime_and_tiff_strings_parse():
assert parse_datetime("2025-03-01 14:30:00") == datetime(2025, 3, 1, 14, 30, 0)
@@ -19,12 +33,11 @@ def test_mysql_datetime_and_tiff_strings_parse():
def test_seq_pairs_stay_separate_despite_overlap():
- bounds = {"west": 77.0, "south": 28.0, "east": 77.2, "north": 28.2}
images = [
- {"id": 1, "filename": "before3.tif", "path": "a/before3.tif", "captureDate": "2025-07-24", "bounds": bounds},
- {"id": 2, "filename": "after3.tif", "path": "a/after3.tif", "captureDate": "2026-07-31", "bounds": bounds},
- {"id": 3, "filename": "before7.tif", "path": "a/before7.tif", "captureDate": "2026-07-31", "bounds": bounds},
- {"id": 4, "filename": "after7.tif", "path": "a/after7.tif", "captureDate": "2026-07-31", "bounds": bounds},
+ {"id": 1, "filename": "before3.tif", "path": "a/before3.tif", "captureDate": "2025-07-24", "bounds": BOUNDS},
+ {"id": 2, "filename": "after3.tif", "path": "a/after3.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
+ {"id": 3, "filename": "before7.tif", "path": "a/before7.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
+ {"id": 4, "filename": "after7.tif", "path": "a/after7.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
]
payload = build_area_groups(images)
labels = sorted(g["label"] for g in payload["groups"])
@@ -58,9 +71,212 @@ def test_distinct_places_grid_vs_sheet():
assert distinct_places({"seq:1"}, set()) is False
+def test_conflicting_keys_only_within_one_vocabulary():
+ # Same vocabulary, different value: never the same place.
+ assert conflicting_keys({"seq:3"}, {"seq:7"}) is True
+ assert conflicting_keys({"grid:54"}, {"grid:60"}) is True
+ # Different vocabularies cannot contradict each other.
+ assert conflicting_keys({"grid:54"}, {"sheet:h43x2e1"}) is False
+ assert conflicting_keys({"grid:54"}, set()) is False
+
+
+def test_strong_overlap_beats_mismatched_naming_schemes():
+ """A grid number and a survey sheet id can name the same place."""
+ images = [
+ {"id": 1, "filename": "Grid_54.tif", "path": "a/Grid_54.tif", "captureDate": "2025-06-11", "bounds": BOUNDS},
+ {"id": 2, "filename": "H43X2E1.tif", "path": "a/H43X2E1.tif", "captureDate": "2025-06-27", "bounds": BOUNDS},
+ ]
+ payload = build_area_groups(images)
+ assert len(payload["groups"]) == 1
+ assert payload["groups"][0]["match"] == "bounds"
+ assert payload["unpaired"] == []
+
+
+def test_georeferenced_images_far_apart_never_pair():
+ images = [
+ {"id": 1, "filename": "grid_9_before.tif", "path": "a/grid_9_before.tif", "captureDate": "2025-01-01", "bounds": BOUNDS},
+ {"id": 2, "filename": "grid_9_after.tif", "path": "a/grid_9_after.tif", "captureDate": "2026-01-01", "bounds": ELSEWHERE},
+ ]
+ payload = build_area_groups(images)
+ assert payload["groups"] == []
+ assert len(payload["unpaired"]) == 2
+
+
+def test_shared_location_cannot_rescue_a_weak_overlap():
+ """When both footprints are known, the map decides, not the folder."""
+ barely = {"west": 77.19, "south": 28.19, "east": 77.40, "north": 28.40}
+ images = [
+ {"id": 1, "filename": "B2_ORI.tif", "path": "a/B2_ORI.tif", "nodeId": 1,
+ "nodePath": "Central Delhi/Daryaganj", "captureDate": "2025-01-01", "bounds": BOUNDS},
+ {"id": 2, "filename": "B3_ORI_CLIP.tif", "path": "a/B3_ORI_CLIP.tif", "nodeId": 1,
+ "nodePath": "Central Delhi/Daryaganj", "captureDate": "2026-01-01", "bounds": barely},
+ ]
+ assert build_area_groups(images, DELHI)["groups"] == []
+
+
+def test_folder_location_cannot_veto_a_clear_overlap():
+ """A file simply parked under 'Unassigned' still pairs with its own place."""
+ images = [
+ {"id": 1, "filename": "H43X2E2.tif", "path": "a/H43X2E2.tif",
+ "nodePath": "Central Delhi/Daryaganj", "captureDate": "2025-01-01", "bounds": BOUNDS},
+ {"id": 2, "filename": "H43X2E2_ORI.tif", "path": "b/H43X2E2_ORI.tif",
+ "nodePath": "Unassigned/Legacy/2025", "captureDate": "2026-01-01", "bounds": BOUNDS},
+ ]
+ assert len(build_area_groups(images, DELHI)["groups"]) == 1
+
+
+def test_different_villages_never_pair():
+ images = [
+ {"id": 1, "filename": "site.tif", "path": "a/site.tif", "captureDate": "2025-01-01",
+ "bounds": BOUNDS, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
+ {"id": 2, "filename": "site_new.tif", "path": "a/site_new.tif", "captureDate": "2026-01-01",
+ "bounds": BOUNDS, "zoneId": 1, "villageId": 6, "villageName": "Paharganj"},
+ ]
+ payload = build_area_groups(images)
+ assert payload["groups"] == []
+
+
+def test_village_pairs_images_that_have_no_map_extent():
+ images = [
+ {"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
+ "bounds": None, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh", "zoneName": "Central Delhi"},
+ {"id": 2, "filename": "scan_b.png", "path": "a/scan_b.png", "captureDate": "2026-01-01",
+ "bounds": None, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh", "zoneName": "Central Delhi"},
+ ]
+ payload = build_area_groups(images)
+ assert len(payload["groups"]) == 1
+ g = payload["groups"][0]
+ assert g["match"] == "location"
+ assert any("Karol Bagh" in reason for reason in g["reasons"])
+
+
+def test_unrelated_images_in_one_folder_do_not_pair():
+ """Folder and capture date alone are not evidence of the same place."""
+ images = [
+ {"id": 1, "filename": "alpha.png", "path": "a/alpha.png", "nodeId": 1,
+ "nodePath": "Delhi", "captureDate": "2025-01-01", "bounds": None},
+ {"id": 2, "filename": "omega.png", "path": "a/omega.png", "nodeId": 1,
+ "nodePath": "Delhi", "captureDate": "2026-01-01", "bounds": None},
+ ]
+ payload = build_area_groups(images)
+ assert payload["groups"] == []
+ assert len(payload["unpaired"]) == 2
+
+
+def test_pairs_hold_two_images_and_never_reuse_one():
+ """Five dates of one place become two pairs plus one leftover."""
+ images = [
+ {"id": i, "filename": f"Grid_54_{2021 + i}.tif", "path": f"a/Grid_54_{2021 + i}.tif",
+ "nodeId": 1, "nodePath": "Delhi", "captureDate": f"{2021 + i}-04-01", "bounds": BOUNDS}
+ for i in range(5)
+ ]
+ payload = build_area_groups(images)
+ assert len(payload["groups"]) == 2
+ assert payload["pairedImages"] == 4
+ assert len(payload["unpaired"]) == 1
+
+ seen = set()
+ for group in payload["groups"]:
+ assert len(group["images"]) == 2
+ for path in _paths(group):
+ assert path not in seen, f"{path} was used by two pairs"
+ seen.add(path)
+ ids = [g["id"] for g in payload["groups"]]
+ assert len(ids) == len(set(ids))
+ # The whole cluster stays available for a manual swap.
+ assert len(payload["groups"][0]["candidates"]) == 5
+
+
+def test_folder_names_supply_the_location():
+ """Images indexed from disk get their zone/village from the folder tree."""
+ images = [
+ {"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
+ "bounds": None, "nodePath": "Central Delhi/Daryaganj"},
+ {"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
+ "bounds": None, "nodePath": "Central Delhi/Daryaganj"},
+ ]
+ payload = build_area_groups(images, DELHI)
+ assert len(payload["groups"]) == 1
+ g = payload["groups"][0]
+ assert any("Daryaganj" in reason for reason in g["reasons"])
+ assert g["images"][0]["villageName"] == "Daryaganj"
+ # Without the masters there is no location to compare, so nothing pairs.
+ assert build_area_groups(images)["groups"] == []
+
+
+def test_folder_location_never_crosses_zones():
+ images = [
+ {"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
+ "bounds": None, "nodePath": "Central Delhi/Daryaganj"},
+ {"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
+ "bounds": None, "nodePath": "North Delhi/Model Town"},
+ ]
+ assert build_area_groups(images, DELHI)["groups"] == []
+
+
+def test_ambiguous_village_name_is_left_unresolved():
+ """'Model Town' exists in two zones, so the folder alone proves nothing."""
+ images = [
+ {"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
+ "bounds": None, "nodePath": "Model Town"},
+ {"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
+ "bounds": None, "nodePath": "Model Town"},
+ ]
+ assert build_area_groups(images, DELHI)["groups"] == []
+
+
+def test_named_before_after_wins_over_same_day_timestamps():
+ """An 'after' uploaded a minute earlier must not become the Before image."""
+ images = [
+ {"id": 1, "filename": "after5.tif", "path": "a/after5.tif",
+ "captureDate": "2026-07-31T09:00:00", "bounds": BOUNDS},
+ {"id": 2, "filename": "before5.tif", "path": "a/before5.tif",
+ "captureDate": "2026-07-31T10:00:00", "bounds": BOUNDS},
+ ]
+ g = build_area_groups(images)["groups"][0]
+ assert g["suggestedBefore"]["filename"] == "before5.tif"
+ assert g["suggestedAfter"]["filename"] == "after5.tif"
+
+
+def test_reasons_name_the_metadata_that_matched():
+ images = [
+ {"id": 1, "filename": "Grid_54.tif", "path": "Delhi/Grid_54.tif", "nodeId": 1,
+ "nodePath": "Delhi", "captureDate": "2024-01-01", "bounds": BOUNDS,
+ "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
+ {"id": 2, "filename": "Grid_54_new.tif", "path": "Delhi/Grid_54_new.tif", "nodeId": 1,
+ "nodePath": "Delhi", "captureDate": "2026-01-01", "bounds": BOUNDS,
+ "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
+ ]
+ reasons = build_area_groups(images)["groups"][0]["reasons"]
+ joined = " | ".join(reasons)
+ assert "map overlap" in joined
+ assert "grid 54" in joined
+ assert "Karol Bagh" in joined
+ assert "same folder" in joined
+ assert "apart" in joined
+
+
+def test_three_images_become_one_pair_and_one_unpaired():
+ """Automatic pairing never emits a 3-image group."""
+ images = [
+ {"id": 1, "filename": "site_2024.png", "path": "a/site_2024.png",
+ "captureDate": "2024-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
+ {"id": 2, "filename": "site_2025.png", "path": "a/site_2025.png",
+ "captureDate": "2025-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
+ {"id": 3, "filename": "site_2026.png", "path": "a/site_2026.png",
+ "captureDate": "2026-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
+ ]
+ payload = build_area_groups(images)
+ assert len(payload["groups"]) == 1
+ assert len(payload["groups"][0]["images"]) == 2
+ assert payload["pairedImages"] == 2
+ assert len(payload["unpaired"]) == 1
+ assert len(set(_paths(payload["groups"][0]))) == 2
+
+
if __name__ == "__main__":
- test_mysql_datetime_and_tiff_strings_parse()
- test_seq_pairs_stay_separate_despite_overlap()
- test_filename_iso_date_orders_before_after()
- test_distinct_places_grid_vs_sheet()
- print("ok")
+ for name, fn in sorted(list(globals().items())):
+ if name.startswith("test_") and callable(fn):
+ fn()
+ print(f"ok {name}")
+ print("all ok")
diff --git a/static/css/dda-modernist-admin.css b/static/css/dda-modernist-admin.css
new file mode 100644
index 0000000000000000000000000000000000000000..f6ec73d9f0a45e5919e81b7487cfada0eae3c4e1
--- /dev/null
+++ b/static/css/dda-modernist-admin.css
@@ -0,0 +1,372 @@
+/* ============================================================
+ Modernist admin theme (Claude Design redesign) — applied to the
+ three RBAC configuration screens: Admin Menu, Admin Modules,
+ Roles & Users. Same visual language as the #tab-home dashboard
+ reskin in dda.css: flat surfaces, hairline dividers, sharp
+ corners, one accent color, Archivo for headings. Reuses the
+ shared --dm-* tokens declared at :root in dda.css — nothing is
+ redeclared here.
+
+ Scoping: every rule is qualified by one of the three page
+ container ids (#tab-admin-menu / #tab-admin-modules /
+ #tab-admin-roles-users) or, for the modal dialogs that live
+ outside those containers in the DOM, by the modal's own id
+ (each id below is unique to one of these three pages). Nothing
+ here touches the bare/global .card, .btn-primary, .table rules.
+ ============================================================ */
+
+/* ---------------------------------------------------------------
+ Page heading
+ --------------------------------------------------------------- */
+#tab-admin-menu .dda-hero h1,
+#tab-admin-modules .dda-hero h1,
+#tab-admin-roles-users .dda-hero h1 {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ color: var(--dm-ink);
+}
+
+/* ---------------------------------------------------------------
+ Flat surfaces — cards and modal panels lose the soft shadow /
+ rounded corners in favor of a hairline border, same treatment
+ as the dashboard cards.
+ --------------------------------------------------------------- */
+#tab-admin-menu .card,
+#tab-admin-modules .card,
+#tab-admin-roles-users .card,
+#menu-item-modal .dda-modal-panel,
+#module-modal .dda-modal-panel,
+#module-view-modal .dda-modal-panel,
+#role-modal .dda-modal-panel,
+#perm-modal .dda-modal-panel,
+#user-modal .dda-modal-panel {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+}
+
+#tab-admin-menu .card-header,
+#tab-admin-modules .card-header,
+#tab-admin-roles-users .card-header,
+#menu-item-modal .card-header,
+#module-modal .card-header,
+#module-view-modal .card-header,
+#role-modal .card-header,
+#perm-modal .card-header,
+#user-modal .card-header {
+ border-bottom: 1px solid var(--dm-line);
+}
+
+/* ---------------------------------------------------------------
+ Buttons — flat accent instead of the app-wide purple gradient,
+ sharp corners, Archivo weight. Scoped to these pages/modals only.
+ --------------------------------------------------------------- */
+#tab-admin-menu .btn,
+#tab-admin-modules .btn,
+#tab-admin-roles-users .btn,
+#menu-item-modal .btn,
+#module-modal .btn,
+#module-view-modal .btn,
+#role-modal .btn,
+#perm-modal .btn,
+#user-modal .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#tab-admin-menu .btn-primary,
+#tab-admin-modules .btn-primary,
+#tab-admin-roles-users .btn-primary,
+#menu-item-modal .btn-primary,
+#module-modal .btn-primary,
+#role-modal .btn-primary,
+#perm-modal .btn-primary,
+#user-modal .btn-primary {
+ background: var(--dm-accent);
+}
+#tab-admin-menu .btn-primary:hover,
+#tab-admin-modules .btn-primary:hover,
+#tab-admin-roles-users .btn-primary:hover,
+#menu-item-modal .btn-primary:hover,
+#module-modal .btn-primary:hover,
+#role-modal .btn-primary:hover,
+#perm-modal .btn-primary:hover,
+#user-modal .btn-primary:hover {
+ background: var(--dm-accent-hover);
+ filter: none;
+ box-shadow: none;
+}
+#tab-admin-menu .btn-secondary,
+#tab-admin-modules .btn-secondary,
+#tab-admin-roles-users .btn-secondary,
+#menu-item-modal .btn-secondary,
+#module-modal .btn-secondary,
+#module-view-modal .btn-secondary,
+#role-modal .btn-secondary,
+#perm-modal .btn-secondary,
+#user-modal .btn-secondary {
+ background: transparent;
+ border-color: var(--dm-line);
+ color: var(--dm-ink);
+}
+#tab-admin-menu .btn-secondary:hover,
+#tab-admin-modules .btn-secondary:hover,
+#tab-admin-roles-users .btn-secondary:hover,
+#menu-item-modal .btn-secondary:hover,
+#module-modal .btn-secondary:hover,
+#module-view-modal .btn-secondary:hover,
+#role-modal .btn-secondary:hover,
+#perm-modal .btn-secondary:hover,
+#user-modal .btn-secondary:hover {
+ background: var(--dm-bg);
+ border-color: var(--dm-line);
+}
+#tab-admin-menu .btn-danger,
+#tab-admin-modules .btn-danger,
+#tab-admin-roles-users .btn-danger {
+ border-radius: 0;
+}
+#tab-admin-menu .btn-ghost,
+#tab-admin-modules .btn-ghost,
+#tab-admin-roles-users .btn-ghost {
+ color: var(--dm-accent);
+}
+#tab-admin-menu .btn-ghost:hover,
+#tab-admin-modules .btn-ghost:hover,
+#tab-admin-roles-users .btn-ghost:hover {
+ background: var(--dm-accent-100);
+ color: var(--dm-accent-hover);
+}
+
+/* ---------------------------------------------------------------
+ Status / system badges — sharpen the pill corners and switch to
+ the accent palette instead of green/red.
+ --------------------------------------------------------------- */
+#tab-admin-menu .dda-status-badge,
+#tab-admin-modules .dda-status-badge,
+#tab-admin-roles-users .dda-status-badge {
+ border-radius: 2px;
+}
+#tab-admin-menu .dda-status-badge.is-active,
+#tab-admin-modules .dda-status-badge.is-active,
+#tab-admin-roles-users .dda-status-badge.is-active {
+ background: var(--dm-accent-100);
+ color: var(--dm-accent-hover);
+}
+#tab-admin-menu .dda-status-badge.is-inactive,
+#tab-admin-modules .dda-status-badge.is-inactive,
+#tab-admin-roles-users .dda-status-badge.is-inactive {
+ background: var(--dm-line);
+ color: var(--dm-ink-muted);
+}
+#tab-admin-roles-users .dda-badge-system {
+ border-radius: 2px;
+ border-color: var(--dm-line);
+ color: var(--dm-ink-muted);
+}
+
+/* ---------------------------------------------------------------
+ Search inputs / select toolbars — flat hairline fields.
+ --------------------------------------------------------------- */
+#tab-admin-modules .dda-search,
+#tab-admin-roles-users .dda-search,
+#tab-admin-modules .dda-table-toolbar select,
+#tab-admin-roles-users .dda-table-toolbar select {
+ border-radius: 0;
+ border: 1px solid var(--dm-line);
+ background: var(--dm-surface);
+ color: var(--dm-ink);
+}
+#tab-admin-modules .dda-table-toolbar,
+#tab-admin-roles-users .dda-table-toolbar {
+ color: var(--dm-ink-muted);
+}
+
+/* ---------------------------------------------------------------
+ Tables — uppercase hairline headers, same technique as the
+ dashboard's .dda-reports-table override (reused verbatim here
+ since Modules/Users render into the same table class).
+ --------------------------------------------------------------- */
+#tab-admin-modules .dda-reports-table th,
+#tab-admin-roles-users .dda-reports-table th {
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--dm-ink-muted);
+ border: none;
+ border-bottom: 2px solid var(--dm-line);
+ background: transparent;
+}
+#tab-admin-modules .dda-reports-table td,
+#tab-admin-roles-users .dda-reports-table td {
+ border: none;
+ border-bottom: 1px solid var(--dm-line);
+}
+#tab-admin-modules .dda-reports-table tbody tr:hover,
+#tab-admin-roles-users .dda-reports-table tbody tr:hover {
+ background: var(--dm-bg);
+}
+
+#perm-modal .dda-perm-matrix th {
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--dm-ink-muted);
+ border: none;
+ border-bottom: 2px solid var(--dm-line);
+ background: transparent;
+}
+#perm-modal .dda-perm-matrix td {
+ border: none;
+ border-bottom: 1px solid var(--dm-line);
+}
+#perm-modal .dda-perm-matrix input[type="checkbox"],
+#role-modal input[type="checkbox"],
+#menu-item-modal input[type="checkbox"],
+#user-modal input[type="checkbox"] {
+ accent-color: var(--dm-accent);
+}
+
+/* ---------------------------------------------------------------
+ Pagination — flat, sharp corners, accent active state instead
+ of the app-wide gradient pill.
+ --------------------------------------------------------------- */
+#tab-admin-modules .pagination button,
+#tab-admin-roles-users .pagination button {
+ border-radius: 0;
+ border-color: var(--dm-line);
+}
+#tab-admin-modules .pagination button:hover:not(:disabled),
+#tab-admin-roles-users .pagination button:hover:not(:disabled) {
+ background: var(--dm-accent-100);
+ border-color: var(--dm-accent);
+ color: var(--dm-ink);
+}
+#tab-admin-modules .pagination button.active,
+#tab-admin-roles-users .pagination button.active {
+ background: var(--dm-accent);
+ color: #fff;
+ border-color: var(--dm-accent);
+}
+
+/* ---------------------------------------------------------------
+ Admin Menu — menu item list rendered as a fused hairline list
+ (same "1px gap on a line-colored background" technique the
+ dashboard KPI grid uses) instead of individually bordered,
+ rounded, shadowed rows.
+ --------------------------------------------------------------- */
+#tab-admin-menu .dda-admin-2col {
+ grid-template-columns: 1.4fr 1fr;
+}
+#tab-admin-menu .dda-menu-list {
+ gap: 1px;
+ background: var(--dm-line);
+ border: 1px solid var(--dm-line);
+}
+#tab-admin-menu .dda-menu-item-row {
+ border: none;
+ border-radius: 0;
+ background: var(--dm-surface);
+}
+#tab-admin-menu .dda-menu-item-row.is-child {
+ background: var(--dm-bg);
+}
+#tab-admin-menu .dda-menu-item-order button {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ background: var(--dm-surface);
+}
+#tab-admin-menu .dda-menu-item-order button:hover:not(:disabled) {
+ color: var(--dm-accent);
+ border-color: var(--dm-accent);
+}
+#tab-admin-menu .dda-menu-item-meta code {
+ background: var(--dm-bg);
+ border-radius: 0;
+}
+
+/* Instructions panel */
+#tab-admin-menu .dda-admin-side-panel code {
+ background: var(--dm-bg);
+ border-radius: 0;
+ color: var(--dm-accent);
+}
+
+/* Module Keys — rendered as a row of flat chips instead of a
+ stacked list, matching the mockup's tag row. */
+#tab-admin-menu .dda-module-keys {
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+#tab-admin-menu .dda-module-keys code {
+ background: var(--dm-bg);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ color: var(--dm-ink);
+ align-self: auto;
+}
+
+/* ---------------------------------------------------------------
+ Roles & Users — role hierarchy as a fused hairline list, wider
+ ratio for the users column to match the mockup's proportions.
+ --------------------------------------------------------------- */
+#tab-admin-roles-users .dda-admin-2col-roles {
+ grid-template-columns: 1fr 1.4fr;
+}
+#tab-admin-roles-users .dda-role-list {
+ gap: 1px;
+ background: var(--dm-line);
+ border: 1px solid var(--dm-line);
+}
+#tab-admin-roles-users .dda-role-card {
+ border: none;
+ border-radius: 0;
+ background: var(--dm-surface);
+}
+#tab-admin-roles-users .dda-role-card.is-inactive {
+ background: var(--dm-bg);
+}
+
+/* ---------------------------------------------------------------
+ Form fields inside these pages' modals — flat hairline inputs
+ instead of the app-wide rounded/shadowed style.
+ --------------------------------------------------------------- */
+#menu-item-modal input[type="text"],
+#menu-item-modal select,
+#module-modal input[type="text"],
+#module-modal select,
+#role-modal input[type="text"],
+#role-modal input[type="number"],
+#user-modal input[type="text"],
+#user-modal input[type="email"],
+#user-modal input[type="password"],
+#user-modal input[type="tel"],
+#user-modal select {
+ border-radius: 0;
+ border-color: var(--dm-line);
+}
+#menu-item-modal input[type="text"]:focus,
+#menu-item-modal select:focus,
+#module-modal input[type="text"]:focus,
+#module-modal select:focus,
+#role-modal input[type="text"]:focus,
+#role-modal input[type="number"]:focus,
+#user-modal input[type="text"]:focus,
+#user-modal input[type="email"]:focus,
+#user-modal input[type="password"]:focus,
+#user-modal input[type="tel"]:focus,
+#user-modal select:focus {
+ border-color: var(--dm-accent);
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
+}
+#user-modal .dda-phone-input {
+ border-radius: 0;
+ border-color: var(--dm-line);
+}
+#user-modal .dda-phone-input:focus-within {
+ border-color: var(--dm-accent);
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
+}
diff --git a/static/css/dda-modernist-auth.css b/static/css/dda-modernist-auth.css
new file mode 100644
index 0000000000000000000000000000000000000000..060ee0ac8859360b1f413778a9cd9cd9c0f47c8b
--- /dev/null
+++ b/static/css/dda-modernist-auth.css
@@ -0,0 +1,195 @@
+/* ============================================================
+ Modernist auth pages theme (from the Claude Design redesign).
+ Scoped entirely to #page-login / #page-forgot-password /
+ #page-reset-password so no other page is affected. Reuses the
+ shared --dm-* tokens declared once at :root in dda.css: flat
+ surfaces, hairline dividers, sharp corners, one accent color,
+ Archivo for headings. Pure visual/layout restyle — no markup
+ behavior, ids or JS hooks are changed by this file.
+ ============================================================ */
+
+#page-login,
+#page-forgot-password,
+#page-reset-password {
+ background: var(--dm-bg);
+}
+
+/* --- Shell: form panel centered in the full viewport (no side hero) --- */
+#page-login .dm-auth-shell,
+#page-forgot-password .dm-auth-shell,
+#page-reset-password .dm-auth-shell {
+ min-height: 100vh;
+}
+
+/* --- Form panel --- */
+#page-login .auth-page,
+#page-forgot-password .auth-page,
+#page-reset-password .auth-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ background: var(--dm-bg);
+}
+
+/* Brand mark inside the card — the only branding on the page now that
+ there's no side hero panel. */
+#page-login .auth-brand,
+#page-forgot-password .auth-brand,
+#page-reset-password .auth-brand {
+ display: flex;
+ color: var(--dm-ink);
+}
+
+/* --- Flat card surface, sharp corners, no shadow --- */
+#page-login .card,
+#page-forgot-password .card,
+#page-reset-password .card {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+ padding: 2rem;
+}
+#page-login .card-header,
+#page-forgot-password .card-header,
+#page-reset-password .card-header {
+ border-bottom-color: var(--dm-line);
+}
+#page-login .card-header h3,
+#page-forgot-password .card-header h3,
+#page-reset-password .card-header h3 {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ font-size: 1.4rem;
+ letter-spacing: -0.01em;
+ color: var(--dm-ink);
+}
+
+#page-login .auth-sub,
+#page-forgot-password .auth-sub,
+#page-reset-password .auth-sub {
+ color: var(--dm-ink-muted);
+}
+
+/* --- Inputs: sharp corners, hairline border --- */
+#page-login input[type="email"],
+#page-login input[type="password"],
+#page-login input[type="text"],
+#page-login input[type="tel"],
+#page-login select,
+#page-forgot-password input[type="email"],
+#page-reset-password input[type="password"] {
+ background: var(--dm-bg);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ color: var(--dm-ink);
+}
+#page-login input:focus,
+#page-login select:focus,
+#page-forgot-password input:focus,
+#page-reset-password input:focus {
+ border-color: var(--dm-accent);
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
+}
+#page-login .form-group label,
+#page-forgot-password .form-group label,
+#page-reset-password .form-group label {
+ color: var(--dm-ink-muted);
+}
+#page-login .field-hint,
+#page-reset-password .field-hint {
+ color: var(--dm-ink-dim);
+}
+
+/* --- Phone input (country code + local number) --- */
+#page-login .dda-phone-input {
+ background: var(--dm-bg);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+}
+#page-login .dda-phone-input:focus-within {
+ border-color: var(--dm-accent);
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
+}
+#page-login .dda-phone-input .dda-country-select {
+ border-right-color: var(--dm-line);
+}
+
+/* --- Sign-in method tabs (Password / Email OTP) --- */
+#page-login .dda-auth-tabs {
+ background: var(--dm-bg);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+}
+#page-login .dda-auth-tab.is-active {
+ background: var(--dm-surface);
+ color: var(--dm-ink);
+ box-shadow: none;
+ border: 1px solid var(--dm-line);
+}
+
+/* --- OTP code input --- */
+#page-login .dda-otp-input {
+ background: var(--dm-bg);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ color: var(--dm-ink);
+}
+
+/* --- Checkbox accent --- */
+#page-login .checkbox-group input[type="checkbox"] {
+ accent-color: var(--dm-accent);
+}
+
+/* --- Buttons: flat accent instead of the app-wide purple gradient --- */
+#page-login .btn,
+#page-forgot-password .btn,
+#page-reset-password .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#page-login .btn-primary,
+#page-forgot-password .btn-primary,
+#page-reset-password .btn-primary {
+ background: var(--dm-accent);
+}
+#page-login .btn-primary:hover,
+#page-forgot-password .btn-primary:hover,
+#page-reset-password .btn-primary:hover {
+ background: var(--dm-accent-hover);
+ filter: none;
+ box-shadow: none;
+}
+#page-login .btn-secondary,
+#page-forgot-password .btn-secondary,
+#page-reset-password .btn-secondary {
+ background: transparent;
+ border: 1px solid var(--dm-line);
+ color: var(--dm-ink);
+ border-radius: 0;
+}
+#page-login .btn-secondary:hover,
+#page-forgot-password .btn-secondary:hover,
+#page-reset-password .btn-secondary:hover {
+ background: var(--dm-bg);
+ border-color: var(--dm-line);
+}
+
+/* --- Links --- */
+#page-login .auth-forgot-link,
+#page-login .auth-toggle a,
+#page-forgot-password .auth-toggle a,
+#page-reset-password .auth-toggle a {
+ color: var(--dm-accent);
+}
+
+/* --- Alerts: sharp corners --- */
+#page-login .alert,
+#page-forgot-password .alert,
+#page-reset-password .alert {
+ border-radius: 0;
+}
+
diff --git a/static/css/dda-modernist-detect.css b/static/css/dda-modernist-detect.css
new file mode 100644
index 0000000000000000000000000000000000000000..5e1e0104b48b26fbe2d2380265c0c5529af7cb65
--- /dev/null
+++ b/static/css/dda-modernist-detect.css
@@ -0,0 +1,197 @@
+/* ============================================================
+ Modernist Change Detection theme (from the Claude Design
+ redesign). Scoped entirely to #tab-detect so the rest of the
+ app keeps its existing look until/unless it gets redesigned
+ too: flat surfaces, hairline dividers, sharp corners, one
+ accent color, Archivo for headings. Tokens (--dm-*) are
+ shared, declared once at :root in dda.css — reused here, not
+ redeclared. Pure visual/layout reskin: no ids/classes that
+ compare.js/tree.js/pairs.js/result.js/map.js select on are
+ renamed, and no RBAC (`data-requires-permission`) attributes
+ are touched.
+ ============================================================ */
+
+/* --- Page header (mirrors the mockup's title + subtitle) --------------- */
+#tab-detect .dda-detect-hero { margin-bottom: 1.25rem; }
+#tab-detect .dda-detect-hero h1 { margin: 0 0 0.2rem; font-weight: 800; letter-spacing: -0.01em; }
+#tab-detect .dda-detect-hero .sub { margin: 0; color: var(--dm-ink-muted); }
+
+/* --- Typography -------------------------------------------------------- */
+#tab-detect h1,
+#tab-detect .card-title,
+#tab-detect .card-header h3,
+#tab-detect .dda-run-bar-title {
+ font-family: var(--dm-font);
+}
+#tab-detect .card-header h3 { font-weight: 800; letter-spacing: -0.005em; }
+
+/* --- Flat, hairline-bordered surfaces instead of soft shadow cards ----- */
+#tab-detect .card,
+#tab-detect .dda-modal-panel,
+#tab-detect .dda-slot,
+#tab-detect .dda-roi-controls,
+#tab-detect .dda-upload-progress .dda-progress-bar {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+}
+#tab-detect .dda-slot { border-style: dashed; }
+#tab-detect .dda-slot.filled { border-style: solid; border-color: var(--dm-accent); }
+#tab-detect .dda-slot.drag-over { border-color: var(--dm-accent); background: var(--dm-accent-100); }
+
+/* --- Buttons — flat accent instead of the app-wide purple gradient ----- */
+#tab-detect .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
+#tab-detect .btn-primary { background: var(--dm-accent); border-color: var(--dm-accent); }
+#tab-detect .btn-primary:hover:not(:disabled) { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
+#tab-detect .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
+#tab-detect .btn-secondary:hover:not(:disabled) { background: var(--dm-bg); border-color: var(--dm-line); }
+#tab-detect .btn-ghost { color: var(--dm-accent); }
+#tab-detect .btn-ghost:hover:not(:disabled) { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
+
+/* --- Layout breathing room (mirrors the mockup's 20px card gaps) ------- */
+#tab-detect.dda-panel { display: flex; flex-direction: column; gap: 0; }
+#tab-detect .dda-layout { gap: 1.25rem; }
+#tab-detect .dda-main { display: flex; flex-direction: column; gap: 1.25rem; }
+#tab-detect .card { padding: 1.25rem; }
+#tab-detect .dda-sidebar.card { padding: 1rem; }
+
+/* --- Folder tree sidebar ------------------------------------------------ */
+#tab-detect .dda-folder-path { color: var(--dm-ink-muted); }
+#tab-detect .dda-search {
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ background: var(--dm-bg);
+ color: var(--dm-ink);
+}
+#tab-detect .dda-search:focus { outline: none; border-color: var(--dm-accent); }
+#tab-detect .dda-tree-all,
+#tab-detect .dda-tree-node-btn,
+#tab-detect .dda-tree-year,
+#tab-detect .dda-tree-village {
+ border-radius: 0;
+ color: var(--dm-ink-muted);
+}
+#tab-detect .dda-tree-all:hover, #tab-detect .dda-tree-all.active,
+#tab-detect .dda-tree-node-btn:hover, #tab-detect .dda-tree-node-btn.active,
+#tab-detect .dda-tree-year:hover, #tab-detect .dda-tree-year.active,
+#tab-detect .dda-tree-village:hover, #tab-detect .dda-tree-village.active {
+ background: var(--dm-accent-100);
+ color: var(--dm-accent-hover);
+}
+#tab-detect .dda-tree-zone > button { border-radius: 0; color: var(--dm-ink); }
+#tab-detect .dda-tree-zone > button:hover { background: var(--dm-accent-100); }
+#tab-detect .dda-tree-children { border-left-color: var(--dm-line); }
+
+/* --- Detection options form -------------------------------------------- */
+#tab-detect .dda-detect-options select,
+#tab-detect .dda-auto-compare select,
+#tab-detect .dda-image-select {
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ background: var(--dm-surface);
+ color: var(--dm-ink);
+}
+#tab-detect .dda-detect-options select:focus,
+#tab-detect .dda-auto-compare select:focus,
+#tab-detect .dda-image-select:focus { outline: none; border-color: var(--dm-accent); box-shadow: none; }
+
+#tab-detect .dda-sensitivity-field em { color: var(--dm-accent); font-weight: 700; }
+#tab-detect .dda-sensitivity-field input[type="range"] { accent-color: var(--dm-accent); }
+
+#tab-detect .dda-stepper-wrap {
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ background: var(--dm-surface);
+}
+#tab-detect .dda-stepper-wrap:focus-within { border-color: var(--dm-accent); box-shadow: none; }
+#tab-detect .dda-stepper-btn:first-child { border-right-color: var(--dm-line); }
+#tab-detect .dda-stepper-btn:last-child { border-left-color: var(--dm-line); }
+#tab-detect .dda-stepper-btn:hover:not(:disabled) { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
+#tab-detect .dda-input-suffix { color: var(--dm-ink-dim); }
+#tab-detect .dda-input-suffix-wrap input[type="number"]:focus + .dda-input-suffix { color: var(--dm-accent); }
+
+#tab-detect .dda-check { color: var(--dm-ink-muted); }
+#tab-detect .dda-check input[type="checkbox"] { accent-color: var(--dm-accent); }
+#tab-detect .dda-notify-email {
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ background: var(--dm-surface);
+ color: var(--dm-ink);
+}
+#tab-detect .dda-field-hint { color: var(--dm-accent); }
+
+/* Region-shape segmented toggle — flat, dark-ink active state (matches
+ the mockup's "seg"/"seg-opt" control) instead of the app-wide blue. */
+#tab-detect .dda-shape-toggle,
+#tab-detect .dda-mode-toggle {
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ overflow: hidden;
+}
+#tab-detect .dda-shape-btn,
+#tab-detect .dda-mode-btn {
+ border-radius: 0;
+ color: var(--dm-ink);
+ font-family: var(--dm-font);
+ font-weight: 600;
+}
+#tab-detect .dda-shape-btn + .dda-shape-btn,
+#tab-detect .dda-mode-btn + .dda-mode-btn { border-left-color: var(--dm-line); }
+#tab-detect .dda-shape-btn:hover,
+#tab-detect .dda-mode-btn:hover { background: var(--dm-bg); }
+#tab-detect .dda-shape-btn.is-active,
+#tab-detect .dda-mode-btn.is-active { background: var(--dm-ink); color: #fff; }
+
+#tab-detect .dda-roi-controls { padding: 0.6rem 0.85rem; color: var(--dm-ink-muted); }
+
+#tab-detect .dda-progress-fill { background: var(--dm-accent); }
+
+/* --- Automatic-mode area picker + schedule ------------------------------ */
+#tab-detect .dda-auto-field input {
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ background: var(--dm-surface);
+ color: var(--dm-ink);
+}
+#tab-detect .dda-auto-field input:focus { outline: none; border-color: var(--dm-accent); }
+#tab-detect .dda-auto-schedule { color: var(--dm-ink-muted); }
+
+/* --- Before/After compare slots ----------------------------------------- */
+#tab-detect .dda-slot-label {
+ font-size: 0.72rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--dm-ink-muted);
+}
+#tab-detect .dda-slot-preview { border-radius: 0; background: var(--dm-bg); }
+#tab-detect .dda-slot-meta { color: var(--dm-ink-muted); }
+
+/* --- Same-area pairs + library grid -------------------------------------- */
+#tab-detect .dda-compare-card,
+#tab-detect .dda-picker-item {
+ border-radius: 0;
+ border-color: var(--dm-line);
+}
+#tab-detect .dda-compare-card.selected-t1 { border-color: var(--dm-accent); box-shadow: inset 0 0 0 1px var(--dm-accent); }
+#tab-detect .dda-compare-card.selected-t2 { border-color: var(--dm-ink); box-shadow: inset 0 0 0 1px var(--dm-ink); }
+#tab-detect .dda-compare-assign { border-top-color: var(--dm-line); }
+#tab-detect .dda-compare-assign .btn { border-radius: 0; }
+#tab-detect .pagination .btn,
+#tab-detect .pagination button { border-radius: 0; }
+
+/* --- Run bar — dark flat bar with the accent primary button ------------- */
+#tab-detect .dda-run-bar {
+ border-radius: 0;
+ background: var(--dm-ink);
+ color: #fff;
+ border: none;
+}
+#tab-detect .dda-run-bar .dim,
+#tab-detect .dda-run-bar #dda-run-bar-hint { color: rgba(255, 255, 255, 0.65); }
+
+/* --- Modals (preflight / image picker) ---------------------------------- */
+#tab-detect .dda-modal-panel { border-radius: 0; }
+#tab-detect .dda-picker-grid .dda-picker-item:hover { border-color: var(--dm-accent); }
+#tab-detect .dda-preflight-list li.fail { border-left: 3px solid var(--dm-accent); }
diff --git a/static/css/dda-modernist-library.css b/static/css/dda-modernist-library.css
new file mode 100644
index 0000000000000000000000000000000000000000..18e9298ae736afe3e75a23f656185c216ec70796
--- /dev/null
+++ b/static/css/dda-modernist-library.css
@@ -0,0 +1,230 @@
+/* ============================================================
+ Modernist Image Library theme (Claude Design redesign).
+ Scoped entirely to #tab-library so the rest of the app keeps its
+ existing look until/unless it gets redesigned too: flat surfaces,
+ hairline dividers, sharp corners, one accent color, Archivo for
+ headings. Tokens (--dm-*) are shared globals declared once at
+ :root in dda.css — reused here, not redeclared.
+
+ Note: the image-viewer / manage-folders / bulk-upload modals and
+ the folder right-click menu are rendered outside the #tab-library
+ DOM subtree (they're siblings appended at the end of
in
+ library_dda.html), but their ids (#dda-image-viewer-modal,
+ #dda-manage-modal, #dda-bulk-upload-modal, #dda-tree-menu) only
+ ever exist on this page's markup, so anchoring rules to those ids
+ is just as safe as nesting under #tab-library — no other page can
+ be affected.
+ ============================================================ */
+
+#tab-library h1,
+#tab-library .card-header h3,
+#tab-library #lib-grid-title,
+#dda-image-viewer-title,
+#dda-manage-title,
+#dda-bulk-upload-title {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ letter-spacing: -0.01em;
+}
+
+/* Flat, hairline-bordered surfaces instead of soft shadow cards */
+#tab-library .card,
+#tab-library .dda-storage-info,
+#dda-image-viewer-modal .dda-modal-panel,
+#dda-manage-modal .dda-modal-panel,
+#dda-bulk-upload-modal .dda-modal-panel {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+}
+#tab-library .card-header { border-bottom-color: var(--dm-line); }
+#dda-image-viewer-modal .card-header,
+#dda-manage-modal .card-header,
+#dda-bulk-upload-modal .card-header { border-bottom-color: var(--dm-line); }
+
+/* Buttons — flat accent instead of the app-wide purple gradient,
+ scoped to this page (+ its modals) only so other pages are
+ untouched. */
+#tab-library .btn,
+#dda-image-viewer-modal .btn,
+#dda-manage-modal .btn,
+#dda-bulk-upload-modal .btn,
+#dda-tree-menu button {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#tab-library .btn-primary,
+#dda-image-viewer-modal .btn-primary,
+#dda-manage-modal .btn-primary,
+#dda-bulk-upload-modal .btn-primary {
+ background: var(--dm-accent);
+}
+#tab-library .btn-primary:hover,
+#dda-image-viewer-modal .btn-primary:hover,
+#dda-manage-modal .btn-primary:hover,
+#dda-bulk-upload-modal .btn-primary:hover {
+ background: var(--dm-accent-hover);
+ filter: none;
+ box-shadow: none;
+}
+#tab-library .btn-secondary,
+#dda-image-viewer-modal .btn-secondary,
+#dda-manage-modal .btn-secondary,
+#dda-bulk-upload-modal .btn-secondary {
+ background: transparent;
+ border-color: var(--dm-line);
+ color: var(--dm-ink);
+}
+#tab-library .btn-secondary:hover,
+#dda-image-viewer-modal .btn-secondary:hover,
+#dda-manage-modal .btn-secondary:hover,
+#dda-bulk-upload-modal .btn-secondary:hover {
+ background: var(--dm-bg);
+ border-color: var(--dm-line);
+}
+#tab-library .btn-danger,
+#dda-image-viewer-modal .btn-danger,
+#dda-manage-modal .btn-danger {
+ box-shadow: none;
+}
+#tab-library .btn-danger:hover,
+#dda-image-viewer-modal .btn-danger:hover,
+#dda-manage-modal .btn-danger:hover {
+ box-shadow: none;
+}
+#tab-library .btn-ghost { color: var(--dm-accent); }
+#tab-library .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
+
+/* Inputs, selects, search fields — sharp corners + hairline border */
+#tab-library input[type="text"],
+#tab-library input[type="search"],
+#tab-library input[type="date"],
+#tab-library input[type="url"],
+#tab-library select,
+#tab-library .dda-search,
+#dda-image-viewer-modal select,
+#dda-image-viewer-modal input[type="url"],
+#dda-manage-modal input[type="text"],
+#dda-manage-modal select,
+#dda-bulk-upload-modal input[type="text"] {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ background: var(--dm-surface);
+}
+#tab-library input:focus,
+#tab-library select:focus,
+#dda-image-viewer-modal select:focus,
+#dda-manage-modal input:focus,
+#dda-manage-modal select:focus {
+ border-color: var(--dm-accent);
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
+}
+
+/* --- Folder tree sidebar --- */
+#tab-library .dda-folder-path,
+#tab-library .dda-tree-hint { color: var(--dm-ink-muted); }
+#tab-library .dda-tree-toolbar-btn { color: var(--dm-ink-muted); }
+#tab-library .dda-tree-toolbar-btn:hover { color: var(--dm-accent); }
+#tab-library .dda-tree-all,
+#tab-library .dda-tree-node-btn,
+#tab-library .dda-tree-year,
+#tab-library .dda-tree-zone > button,
+#tab-library .dda-tree-village {
+ border-radius: 0;
+ color: var(--dm-ink);
+}
+#tab-library .dda-tree-all:hover,
+#tab-library .dda-tree-node-btn:hover,
+#tab-library .dda-tree-year:hover,
+#tab-library .dda-tree-zone > button:hover,
+#tab-library .dda-tree-village:hover {
+ background: var(--dm-accent-100);
+ color: var(--dm-accent);
+}
+#tab-library .dda-tree-all.active,
+#tab-library .dda-tree-node-btn.active,
+#tab-library .dda-tree-year.active,
+#tab-library .dda-tree-village.active {
+ background: var(--dm-accent-100);
+ color: var(--dm-accent);
+ border-left: 2px solid var(--dm-accent);
+ padding-left: calc(0.5rem - 2px);
+}
+#tab-library .dda-tree-children { border-left-color: var(--dm-line); }
+#tab-library .dda-tree-hit { background: var(--dm-accent-100); border-radius: 0; }
+#tab-library .dda-tree-node-btn.is-drop-target {
+ outline-color: var(--dm-accent);
+ background: var(--dm-accent-100);
+ color: var(--dm-accent);
+}
+#dda-tree-menu {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ box-shadow: none;
+}
+#dda-tree-menu button:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
+
+/* --- Upload card --- */
+#tab-library .dda-dropzone {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ background: var(--dm-bg);
+}
+#tab-library .dda-dropzone.is-dragover {
+ border-color: var(--dm-accent);
+ background: var(--dm-accent-100);
+}
+#tab-library .dda-progress-bar { border-radius: 0; background: var(--dm-line); }
+#tab-library .dda-progress-fill { background: var(--dm-accent); }
+#tab-library .dda-storage-info summary { color: var(--dm-ink-muted); }
+#tab-library .dda-storage-info summary:hover { color: var(--dm-accent); }
+#tab-library .dda-path-code { border-radius: 0; border-color: var(--dm-line); background: var(--dm-bg); }
+
+/* --- Same-area pairs --- */
+#tab-library .dda-area-pair {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ background: var(--dm-surface);
+}
+
+/* --- Image grid --- */
+#tab-library .dda-grid { gap: 1px; background: var(--dm-line); border: 1px solid var(--dm-line); }
+#tab-library .dda-card-img {
+ border-radius: 0;
+ border: none;
+ background: var(--dm-surface);
+ transition: none;
+}
+#tab-library .dda-card-img:hover { border-color: transparent; outline: 2px solid var(--dm-accent); outline-offset: -2px; }
+#tab-library .dda-card-delete-btn { border-radius: 0; background: rgba(32, 30, 29, 0.68); }
+#tab-library .dda-card-delete-btn:hover { background: var(--dm-accent); }
+#tab-library .dda-card-img .meta { color: var(--dm-ink-muted); }
+
+/* --- Pagination --- */
+#tab-library .pagination button { border-radius: 0; }
+#tab-library .pagination button.active { background: var(--dm-accent); border-color: var(--dm-accent); }
+#tab-library .pagination button:hover:not(:disabled):not(.active) {
+ border-color: var(--dm-accent);
+ color: var(--dm-accent);
+}
+
+/* --- Modals (viewer / manage folders / bulk upload) --- */
+#dda-image-viewer-modal .dda-image-viewer-wrap { border-radius: 0; border-color: var(--dm-line); background: var(--dm-bg); }
+#dda-image-viewer-modal .dda-view-btn { border-radius: 0; }
+#dda-image-viewer-modal .dda-view-btn.active { background: var(--dm-accent-100); color: var(--dm-accent); }
+#dda-image-viewer-modal .dda-map-canvas { border-radius: 0; }
+#dda-manage-modal .dda-manage-section h4,
+#dda-bulk-upload-modal .dda-manage-section h4 { font-family: var(--dm-font); font-weight: 700; color: var(--dm-ink); }
+#dda-bulk-upload-modal .dda-logs-table th { color: var(--dm-ink-muted); }
+#dda-bulk-upload-modal .dda-logs-table td,
+#dda-bulk-upload-modal .dda-logs-table th { border-bottom-color: var(--dm-line); }
+
+@media (max-width: 900px) {
+ #tab-library .dda-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
+}
+
+/* Required-field marker on the upload form's Zone/Village labels — these
+ are now mandatory (see upload_image()'s validation). */
+#tab-library .dda-required-mark { color: var(--dm-accent); }
diff --git a/static/css/dda-modernist-logs.css b/static/css/dda-modernist-logs.css
new file mode 100644
index 0000000000000000000000000000000000000000..0170447cf6fe5373100b0b8cdfe4581faa87705c
--- /dev/null
+++ b/static/css/dda-modernist-logs.css
@@ -0,0 +1,269 @@
+/* ============================================================
+ Modernist Audit Log & Exception Log theme (Claude Design reskin).
+ Companion to the "Modernist dashboard theme" block in dda.css
+ (search that file for #tab-home) — same visual language, same
+ shared --dm-* tokens declared once at :root in dda.css. Purely
+ visual: no ids/classes added or removed, no JS behavior changed.
+
+ Scoped to this page's own containers: #tab-audit-log and
+ #tab-exception-log. The one exception is the exception-detail
+ modal (#exception-detail-modal), which the base template renders
+ as a sibling of .dda-main-col (outside #tab-exception-log) so it
+ can overlay the whole viewport — it is still scoped, just under
+ its own page-unique id instead of the tab container.
+ ============================================================ */
+
+/* --- Headings / hero --- */
+#tab-audit-log h1,
+#tab-exception-log h1,
+#tab-exception-log .card-header h3,
+#tab-audit-log .card-header h3 {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ color: var(--dm-ink);
+}
+#tab-audit-log .sub,
+#tab-exception-log .sub {
+ color: var(--dm-ink-muted);
+}
+#tab-audit-log .dda-filter-meta,
+#tab-exception-log .dda-filter-meta {
+ color: var(--dm-ink-dim);
+ font-family: var(--dm-font);
+}
+
+/* --- Flat surfaces instead of soft shadow cards --- */
+#tab-audit-log .card,
+#tab-exception-log .card,
+#exception-detail-modal .card {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+}
+#tab-audit-log .card-header,
+#tab-exception-log .card-header,
+#exception-detail-modal .card-header {
+ border-bottom: 2px solid var(--dm-line);
+}
+#tab-audit-log .dda-filter-bar,
+#tab-exception-log .dda-filter-bar {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+}
+#tab-audit-log .dda-filter-field,
+#tab-exception-log .dda-filter-field {
+ font-family: var(--dm-font);
+ color: var(--dm-ink-muted);
+}
+
+/* --- Buttons — flat accent instead of the app-wide purple gradient --- */
+#tab-audit-log .btn,
+#tab-exception-log .btn,
+#exception-detail-modal .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#tab-audit-log .btn-primary,
+#tab-exception-log .btn-primary {
+ background: var(--dm-accent);
+}
+#tab-audit-log .btn-primary:hover,
+#tab-exception-log .btn-primary:hover {
+ background: var(--dm-accent-hover);
+ filter: none;
+ box-shadow: none;
+}
+#tab-audit-log .btn-secondary,
+#tab-exception-log .btn-secondary,
+#exception-detail-modal .btn-secondary {
+ background: transparent;
+ border-color: var(--dm-line);
+ color: var(--dm-ink);
+}
+#tab-audit-log .btn-secondary:hover,
+#tab-exception-log .btn-secondary:hover,
+#exception-detail-modal .btn-secondary:hover {
+ background: var(--dm-bg);
+ border-color: var(--dm-line);
+}
+#tab-audit-log .btn-ghost,
+#tab-exception-log .btn-ghost {
+ color: var(--dm-accent);
+}
+#tab-audit-log .btn-ghost:hover,
+#tab-exception-log .btn-ghost:hover {
+ background: var(--dm-accent-100);
+ color: var(--dm-accent-hover);
+}
+
+/* --- Filter inputs: selects, search box, date trigger --- */
+#tab-audit-log .dda-filter-field select,
+#tab-audit-log .dda-filter-field .dda-search,
+#tab-audit-log input.dda-date-input:not(.dda-date-native),
+#tab-audit-log .dda-date-trigger,
+#tab-exception-log .dda-filter-field select,
+#tab-exception-log .dda-filter-field .dda-search,
+#tab-exception-log input.dda-date-input:not(.dda-date-native),
+#tab-exception-log .dda-date-trigger {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ background: var(--dm-surface);
+ color: var(--dm-ink);
+}
+#tab-exception-log .dda-check {
+ font-family: var(--dm-font);
+ color: var(--dm-ink-muted);
+}
+#tab-exception-log .dda-check input[type="checkbox"] {
+ accent-color: var(--dm-accent);
+}
+
+/* --- Tables — uppercase hairline headers, flat row dividers --- */
+#tab-audit-log .dda-logs-table th,
+#tab-exception-log .dda-logs-table th {
+ font-family: var(--dm-font);
+ font-size: 0.68rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--dm-ink-muted);
+ background: var(--dm-surface);
+ border-bottom: 2px solid var(--dm-line);
+}
+#tab-audit-log .dda-logs-table td,
+#tab-exception-log .dda-logs-table td {
+ border-bottom: 1px solid var(--dm-line);
+ color: var(--dm-ink);
+}
+#tab-audit-log .dda-logs-table tbody tr:hover,
+#tab-exception-log .dda-logs-table tbody tr:hover {
+ background: var(--dm-bg);
+}
+#tab-exception-log .dda-log-level {
+ border-radius: 2px;
+ font-family: var(--dm-font);
+}
+
+/* --- Badges / status tags — sharp corners instead of pills --- */
+#tab-audit-log .dda-badge,
+#tab-exception-log .dda-badge,
+#tab-audit-log .dda-action-badge,
+#tab-exception-log .dda-action-badge {
+ border-radius: 2px;
+ font-family: var(--dm-font);
+}
+
+/* --- Pagination --- */
+#tab-audit-log .pagination button,
+#tab-exception-log .pagination button {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ background: var(--dm-surface);
+ color: var(--dm-ink-muted);
+ font-family: var(--dm-font);
+}
+#tab-audit-log .pagination button:hover:not(:disabled),
+#tab-exception-log .pagination button:hover:not(:disabled) {
+ background: var(--dm-accent-100);
+ border-color: var(--dm-accent);
+ color: var(--dm-ink);
+}
+#tab-audit-log .pagination button.active,
+#tab-exception-log .pagination button.active {
+ background: var(--dm-accent);
+ border-color: var(--dm-accent);
+ color: #fff;
+ font-weight: 700;
+}
+#tab-audit-log .pagination .page-jump input,
+#tab-exception-log .pagination .page-jump input {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ background: var(--dm-surface);
+}
+
+/* --- Exception Log: Exceptions / Application Logs segmented switch --- */
+#tab-exception-log .dda-mode-toggle {
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ overflow: hidden;
+}
+#tab-exception-log .dda-mode-btn {
+ font-family: var(--dm-font);
+ font-weight: 700;
+ font-size: 0.78rem;
+ letter-spacing: 0.02em;
+ color: var(--dm-ink-muted);
+ padding: 0.55rem 1.1rem;
+}
+#tab-exception-log .dda-mode-btn + .dda-mode-btn {
+ border-left: 1px solid var(--dm-line);
+}
+#tab-exception-log .dda-mode-btn:hover {
+ background: var(--dm-bg);
+}
+#tab-exception-log .dda-mode-btn.is-active {
+ background: var(--dm-ink);
+ color: #fff;
+}
+
+/* --- Exception Log: stat cards — flat hairline grid, no icons,
+ big Archivo numbers (icons stay in the DOM, just visually hidden
+ to match the flat numeric-tile look; nothing is removed). --- */
+#tab-exception-log .dda-stat-cards {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1px;
+ background: var(--dm-line);
+ border: 1px solid var(--dm-line);
+ margin: 0 0 1.25rem;
+}
+#tab-exception-log .dda-stat-card {
+ background: var(--dm-surface);
+ border: none;
+ border-radius: 0;
+ padding: 1rem 1.1rem;
+ text-align: left;
+}
+#tab-exception-log .dda-stat-icon {
+ display: none;
+}
+#tab-exception-log .dda-stat-value {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ font-size: 1.65rem;
+ line-height: 1.1;
+ color: var(--dm-ink);
+}
+#tab-exception-log .dda-stat-card.is-danger .dda-stat-value {
+ color: var(--danger);
+}
+#tab-exception-log .dda-stat-card.is-warn .dda-stat-value {
+ color: #b45309;
+}
+#tab-exception-log .dda-stat-label {
+ margin-top: 0.35rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--dm-ink-muted);
+}
+@media (max-width: 720px) {
+ #tab-exception-log .dda-stat-cards { grid-template-columns: repeat(2, 1fr); }
+}
+@media (max-width: 420px) {
+ #tab-exception-log .dda-stat-cards { grid-template-columns: 1fr; }
+}
+
+/* --- Exception detail modal --- */
+#exception-detail-modal .dda-modal-panel {
+ border-radius: 0;
+}
+#exception-detail-modal .dda-log-msg {
+ color: var(--dm-ink);
+}
diff --git a/static/css/dda-modernist-masters-profile.css b/static/css/dda-modernist-masters-profile.css
new file mode 100644
index 0000000000000000000000000000000000000000..5cd6ce399e230d8bebbbbb988f9abc695a4cf0db
--- /dev/null
+++ b/static/css/dda-modernist-masters-profile.css
@@ -0,0 +1,252 @@
+/* ============================================================
+ Modernist theme for Masters (Zones / Villages) and Profile.
+ Same visual language as the #tab-home redesign in dda.css
+ (flat surfaces, hairline dividers, sharp corners, one accent
+ color, Archivo for headings) — reuses the shared --dm-* tokens
+ declared at :root in dda.css. Purely visual: every rule below
+ is scoped under #tab-masters-zones, #tab-masters-villages or
+ #tab-profile, and no element id/behavior is touched.
+ ============================================================ */
+
+/* ---------- Shared: Masters (Zones + Villages) ---------- */
+
+#tab-masters-zones h1,
+#tab-masters-villages h1,
+#tab-masters-zones .card-header h3,
+#tab-masters-villages .card-header h3,
+#tab-masters-zones .dda-reports-table,
+#tab-masters-villages .dda-reports-table {
+ font-family: var(--dm-font);
+}
+#tab-masters-zones h1,
+#tab-masters-villages h1 {
+ font-weight: 800;
+ letter-spacing: -0.01em;
+}
+
+/* Flat, hairline-bordered surfaces instead of soft shadow cards */
+#tab-masters-zones .card,
+#tab-masters-villages .card {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+}
+
+#tab-masters-zones .card-header,
+#tab-masters-villages .card-header {
+ border-bottom: 2px solid var(--dm-line);
+}
+#tab-masters-zones .card-header h3,
+#tab-masters-villages .card-header h3 {
+ font-weight: 700;
+ color: var(--dm-ink);
+}
+
+/* Title row: heading + primary action on one line */
+#tab-masters-zones .dda-hero.dda-hero-row,
+#tab-masters-villages .dda-hero.dda-hero-row {
+ align-items: center;
+}
+
+/* Compact standalone search bar, mirroring the mockup's search card */
+#tab-masters-zones .dda-masters-search-card,
+#tab-masters-villages .dda-masters-search-card {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ padding: 0.85rem 1.1rem;
+ margin-bottom: 1.25rem;
+}
+#tab-masters-zones .dda-masters-search-card .dda-search,
+#tab-masters-villages .dda-masters-search-card .dda-search {
+ max-width: 320px;
+ margin-bottom: 0;
+ border-radius: 0;
+ background: var(--dm-surface);
+ border-color: var(--dm-line);
+}
+#tab-masters-villages .dda-masters-search-card select#masters-zone-filter {
+ max-width: 220px;
+ border-radius: 0;
+ background: var(--dm-surface);
+ border-color: var(--dm-line);
+ padding: 0.45rem 0.65rem;
+}
+
+/* Buttons — flat accent instead of the app-wide purple gradient */
+#tab-masters-zones .btn,
+#tab-masters-villages .btn,
+#tab-masters-zones .dda-modal .btn,
+#tab-masters-villages .dda-modal .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#tab-masters-zones .btn-primary,
+#tab-masters-villages .btn-primary {
+ background: var(--dm-accent);
+}
+#tab-masters-zones .btn-primary:hover,
+#tab-masters-villages .btn-primary:hover {
+ background: var(--dm-accent-hover);
+ filter: none;
+ box-shadow: none;
+}
+#tab-masters-zones .btn-secondary,
+#tab-masters-villages .btn-secondary {
+ background: transparent;
+ border-color: var(--dm-line);
+ color: var(--dm-ink);
+}
+#tab-masters-zones .btn-secondary:hover,
+#tab-masters-villages .btn-secondary:hover {
+ background: var(--dm-bg);
+ border-color: var(--dm-line);
+}
+#tab-masters-zones .btn-danger,
+#tab-masters-villages .btn-danger {
+ box-shadow: none;
+}
+
+/* Table — uppercase hairline headers instead of boxed cells */
+#tab-masters-zones .dda-reports-table,
+#tab-masters-villages .dda-reports-table {
+ border-collapse: collapse;
+}
+#tab-masters-zones .dda-reports-table th,
+#tab-masters-villages .dda-reports-table th {
+ border: none;
+ border-bottom: 2px solid var(--dm-line);
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--dm-ink-muted);
+ font-weight: 700;
+ background: transparent;
+ padding: 0.55rem 0.6rem;
+}
+#tab-masters-zones .dda-reports-table td,
+#tab-masters-villages .dda-reports-table td {
+ border: none;
+ border-bottom: 1px solid var(--dm-line);
+ padding: 0.55rem 0.6rem;
+ color: var(--dm-ink);
+}
+#tab-masters-zones .dda-reports-table tbody tr:hover,
+#tab-masters-villages .dda-reports-table tbody tr:hover {
+ background: var(--dm-bg);
+}
+
+/* Modal form fields — flat inputs/selects to match the rest of the page.
+ #masters-modal sits outside #tab-masters-zones/#tab-masters-villages in
+ the DOM (it's appended at body level so it can overlay the whole page),
+ so it's scoped via the body[data-masters] attribute each template already
+ sets instead — equivalent scoping, since each template only ever renders
+ its own single modal. */
+body[data-masters="zones"] #masters-modal input[type="text"],
+body[data-masters="villages"] #masters-modal input[type="text"],
+body[data-masters="zones"] #masters-modal select,
+body[data-masters="villages"] #masters-modal select,
+body[data-masters="zones"] #masters-modal .card,
+body[data-masters="villages"] #masters-modal .card,
+body[data-masters="zones"] #masters-modal .dda-modal-panel,
+body[data-masters="villages"] #masters-modal .dda-modal-panel {
+ border-radius: 0;
+ background: var(--dm-surface);
+ border-color: var(--dm-line);
+ box-shadow: none;
+}
+body[data-masters="zones"] #masters-modal .btn,
+body[data-masters="villages"] #masters-modal .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+body[data-masters="zones"] #masters-modal .btn-primary,
+body[data-masters="villages"] #masters-modal .btn-primary {
+ background: var(--dm-accent);
+}
+body[data-masters="zones"] #masters-modal .btn-primary:hover,
+body[data-masters="villages"] #masters-modal .btn-primary:hover {
+ background: var(--dm-accent-hover);
+ filter: none;
+ box-shadow: none;
+}
+body[data-masters="zones"] #masters-modal .card-header,
+body[data-masters="villages"] #masters-modal .card-header {
+ border-bottom: 2px solid var(--dm-line);
+}
+
+/* ---------- Profile ---------- */
+
+#tab-profile h1,
+#tab-profile .card-header h3,
+#tab-profile #profile-name,
+#tab-profile .dda-profile-avatar {
+ font-family: var(--dm-font);
+}
+#tab-profile h1 { font-weight: 800; letter-spacing: -0.01em; }
+
+#tab-profile .card {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+}
+#tab-profile .card-header {
+ border-bottom: 2px solid var(--dm-line);
+}
+#tab-profile .card-header h3 {
+ font-weight: 700;
+ color: var(--dm-ink);
+}
+
+/* Narrower, fixed-width profile summary column like the mockup */
+#tab-profile .dda-admin-2col-roles {
+ grid-template-columns: 280px minmax(0, 1fr);
+}
+
+#tab-profile .dda-profile-avatar {
+ border-radius: 0;
+ background: var(--dm-ink);
+ font-weight: 800;
+}
+#tab-profile .dda-profile-badge {
+ border-radius: 0;
+ background: var(--dm-accent-100);
+ color: var(--dm-accent);
+ font-weight: 700;
+}
+#tab-profile .dda-profile-meta {
+ border-top: 1px solid var(--dm-line);
+}
+#tab-profile .dda-profile-meta-label { color: var(--dm-ink-muted); }
+#tab-profile .dda-profile-meta-value { color: var(--dm-ink); }
+
+/* Buttons — flat accent instead of the app-wide purple gradient */
+#tab-profile .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#tab-profile .btn-primary { background: var(--dm-accent); }
+#tab-profile .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
+
+/* Flat inputs/selects inside both forms */
+#tab-profile input[type="text"],
+#tab-profile input[type="email"],
+#tab-profile input[type="tel"],
+#tab-profile input[type="password"],
+#tab-profile select {
+ border-radius: 0;
+ background: var(--dm-surface);
+ border-color: var(--dm-line);
+}
+#tab-profile .dda-phone-input {
+ border-radius: 0;
+ border-color: var(--dm-line);
+}
+
+#tab-profile .alert { border-radius: 0; }
diff --git a/static/css/dda-modernist-reports.css b/static/css/dda-modernist-reports.css
new file mode 100644
index 0000000000000000000000000000000000000000..148c0dcca59007c2f00bfc00019cb387a28b6ac4
--- /dev/null
+++ b/static/css/dda-modernist-reports.css
@@ -0,0 +1,286 @@
+/* ============================================================
+ Modernist Reports theme (from the Claude Design redesign).
+ Covers two screens:
+ - Reports list -> scoped under #tab-reports
+ - Report Detail page -> scoped under #page-report-detail
+ Same visual language as the Dashboard theme already shipped in
+ dda.css (search "#tab-home" there): flat surfaces, hairline
+ dividers, sharp corners, one accent color, Archivo for headings
+ and numbers. Reuses the shared --dm-* tokens declared once at
+ :root in dda.css — nothing is redeclared here. Every selector
+ below is scoped to one of the two pages above, so no other
+ screen (including the shared result modal used elsewhere) is
+ affected by this file.
+ ============================================================ */
+
+/* ================= Reports list (#tab-reports) ================= */
+
+#tab-reports h1,
+#tab-reports .card-title,
+#tab-reports h3 {
+ font-family: var(--dm-font);
+}
+#tab-reports h1 {
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ margin: 0 0 0.35rem;
+ color: var(--dm-ink);
+}
+#tab-reports .dda-reports-hero .sub {
+ margin: 0;
+ color: var(--dm-ink-muted);
+}
+#tab-reports .dda-dash-hero-actions { align-items: center; }
+
+/* Flat, hairline-bordered surfaces instead of soft shadow cards */
+#tab-reports .card {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+}
+#tab-reports .dda-reports-filter-card { padding: 1rem 1.1rem; }
+#tab-reports .dda-filter-bar {
+ border: none;
+ padding: 0;
+ margin: 0;
+ background: transparent;
+}
+#tab-reports .dda-filter-field { color: var(--dm-ink-muted); }
+#tab-reports .dda-filter-meta { color: var(--dm-ink-muted); }
+
+/* Buttons — flat accent instead of the app-wide purple gradient,
+ scoped to this page only so other pages are untouched. */
+#tab-reports .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
+#tab-reports .btn-primary { background: var(--dm-accent); }
+#tab-reports .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
+#tab-reports .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
+#tab-reports .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
+#tab-reports .btn-ghost { color: var(--dm-accent); }
+#tab-reports .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
+
+/* Table — uppercase hairline header, row-hover, no per-cell boxes */
+#tab-reports .dda-reports-table {
+ font-family: var(--font-sans);
+}
+#tab-reports .dda-reports-table th {
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--dm-ink-muted);
+ font-family: var(--dm-font);
+ border: none;
+ border-bottom: 2px solid var(--dm-line);
+ background: transparent;
+}
+#tab-reports .dda-reports-table td {
+ border: none;
+ border-bottom: 1px solid var(--dm-line);
+}
+#tab-reports .dda-reports-table tbody tr:hover { background: var(--dm-bg); }
+
+/* Change-type / status colors are semantic (CHANGE_TYPE_COLORS) and are
+ left untouched here — only the pill shape follows the flat theme. */
+#tab-reports .dda-changetype-pill { border-radius: 2px; }
+
+/* Pagination */
+#tab-reports .pagination button {
+ border-radius: 0;
+ font-family: var(--dm-font);
+}
+#tab-reports .pagination button:hover:not(:disabled) {
+ background: var(--dm-accent-100);
+ border-color: var(--dm-line);
+ color: var(--dm-ink);
+}
+#tab-reports .pagination button.active {
+ background: var(--dm-accent);
+ border-color: var(--dm-accent);
+ color: #fff;
+}
+
+@media (max-width: 720px) {
+ #tab-reports .dda-dash-hero-actions { width: 100%; justify-content: space-between; }
+}
+
+/* ================= Report Detail (#page-report-detail) ================= */
+
+#page-report-detail {
+ background: var(--dm-bg);
+ font-family: var(--font-sans);
+}
+/* Interactive viewer needs the full viewport (static report cards used 1100px). */
+#page-report-detail .dda-report-page {
+ max-width: none !important;
+ padding: 0 !important;
+ width: 100%;
+}
+
+/* Header bar — flat surface, hairline bottom border, Archivo brand type */
+#page-report-detail .dda-header.dda-report-header {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-bottom: 2px solid var(--dm-line);
+ margin: 0;
+ padding: 1rem 1.25rem;
+}
+#page-report-detail .app-brand span {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ font-size: 0.82rem;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: var(--dm-ink);
+}
+#page-report-detail .dda-report-actions .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
+#page-report-detail .dda-report-actions .btn-primary { background: var(--dm-accent); }
+#page-report-detail .dda-report-actions .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
+#page-report-detail .dda-report-actions .btn-primary:disabled { background: var(--dm-line); color: var(--dm-ink-dim); }
+#page-report-detail .dda-report-actions .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
+#page-report-detail .dda-report-actions .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
+
+/* Cards — flat, hairline-bordered, sharp corners */
+#page-report-detail .card {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+ padding: 1.4rem 1.5rem;
+}
+#page-report-detail .card + .card { margin-top: 1.25rem; }
+#page-report-detail .card h1,
+#page-report-detail .card h3 {
+ font-family: var(--dm-font);
+ color: var(--dm-ink);
+}
+#page-report-detail #report-title { font-weight: 800; letter-spacing: -0.01em; font-size: 1.5rem; margin: 0 0 0.35rem; }
+#page-report-detail .card h3 { font-weight: 700; font-size: 0.95rem; margin: 0 0 0.9rem; }
+#page-report-detail #report-meta { color: var(--dm-ink-muted); }
+
+/* Summary stats — hairline grid of flat cells instead of a loose flex row */
+#page-report-detail .dda-report-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 1px;
+ background: var(--dm-line);
+ border: 1px solid var(--dm-line);
+ margin-top: 1rem;
+}
+#page-report-detail .dda-stat {
+ background: var(--dm-surface);
+ padding: 0.85rem 0.9rem;
+}
+#page-report-detail .dda-stat .label {
+ font-size: 0.68rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--dm-ink-muted);
+}
+#page-report-detail .dda-stat .value {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ font-size: 1.35rem;
+ color: var(--dm-ink);
+}
+
+#page-report-detail .dda-report-review-summary {
+ margin: 0.85rem 0 0;
+ font-size: 0.85rem;
+}
+#page-report-detail .dda-report-review-summary strong { color: var(--dm-ink); }
+#page-report-detail .dda-report-actions-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ margin-top: 0.85rem;
+}
+#page-report-detail .dda-report-actions-row .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
+#page-report-detail .dda-report-actions-row .btn-primary { background: var(--dm-accent); }
+#page-report-detail .dda-report-actions-row .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
+#page-report-detail .dda-report-actions-row .btn-primary:disabled { background: var(--dm-line); color: var(--dm-ink-dim); }
+#page-report-detail .dda-report-actions-row .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
+#page-report-detail .dda-report-actions-row .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
+#page-report-detail #report-submit-msg { margin-top: 0.5rem; font-size: 0.8rem; }
+
+/* Overlay image — flat bordered frame with a small labeled chip,
+ same treatment as the Before/After panel in the design mockup. */
+#page-report-detail .dda-report-overlay-frame {
+ position: relative;
+ display: inline-block;
+ max-width: 100%;
+}
+#page-report-detail .dda-report-overlay-chip {
+ position: absolute;
+ top: 0.65rem;
+ left: 0.65rem;
+ z-index: 1;
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ color: var(--dm-ink);
+ font-family: var(--dm-font);
+ font-weight: 700;
+ font-size: 0.68rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 0.25rem 0.6rem;
+}
+#page-report-detail .dda-report-overlay {
+ border-radius: 0;
+ border: 1px solid var(--dm-line);
+ background: var(--dm-bg);
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+/* Detected regions table */
+#page-report-detail .table-wrap { overflow-x: auto; }
+#page-report-detail .dda-report-regions {
+ width: 100%;
+ border-collapse: collapse;
+}
+#page-report-detail .dda-report-regions th {
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--dm-ink-muted);
+ font-family: var(--dm-font);
+ border: none;
+ border-bottom: 2px solid var(--dm-line);
+ background: transparent;
+ text-align: left;
+ padding: 0.5rem 0.6rem;
+}
+#page-report-detail .dda-report-regions td {
+ border: none;
+ border-bottom: 1px solid var(--dm-line);
+ padding: 0.5rem 0.6rem;
+}
+#page-report-detail .dda-report-regions tbody tr:hover { background: var(--dm-bg); }
+#page-report-detail .dda-report-regions .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
+#page-report-detail .dda-report-regions .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
+#page-report-detail .dda-report-regions .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
+
+/* Email this report */
+#page-report-detail .dda-report-email input[type="email"] {
+ border-radius: 0;
+ border: 1px solid var(--dm-line);
+ background: var(--dm-surface);
+}
+#page-report-detail .dda-report-email input[type="email"]:focus {
+ border-color: var(--dm-accent);
+ box-shadow: none;
+}
+#page-report-detail .dda-report-email .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
+#page-report-detail .dda-report-email .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
+#page-report-detail .dda-report-email .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
+#page-report-detail .dda-report-email .btn-secondary:disabled { color: var(--dm-ink-dim); border-color: var(--dm-line); }
+
+/* Loading / error states */
+#page-report-detail #report-loading { border-radius: 0; }
+#page-report-detail .alert-error { border-radius: 0; }
+
+@media (max-width: 720px) {
+ #page-report-detail .dda-report-header { flex-direction: column; align-items: stretch; }
+ #page-report-detail .dda-report-stats { grid-template-columns: repeat(2, 1fr); }
+}
diff --git a/static/css/dda-modernist-result-modal.css b/static/css/dda-modernist-result-modal.css
new file mode 100644
index 0000000000000000000000000000000000000000..146138776816ccb01cee07211b98a494abf7c4a4
--- /dev/null
+++ b/static/css/dda-modernist-result-modal.css
@@ -0,0 +1,282 @@
+/* ============================================================
+ Modernist theme for the interactive Result modal (#result-modal)
+ — shared by the Change Detection page (shown right after a run)
+ and the standalone report page. Scoped entirely to
+ #result-modal so nothing else is affected.
+ ============================================================ */
+
+#result-modal.result-modal-backdrop {
+ position: fixed !important;
+ inset: 0 !important;
+ z-index: 10000 !important;
+ /* Solid — never let the page underneath bleed through. */
+ background: var(--dm-bg, #f3f2f2) !important;
+ backdrop-filter: none !important;
+ -webkit-backdrop-filter: none !important;
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ overflow-y: auto;
+}
+#result-modal .result-modal {
+ background: var(--dm-bg, #f3f2f2);
+ border-radius: 0;
+ box-shadow: none;
+ min-height: 100vh;
+ min-height: 100dvh;
+ width: 100%;
+ max-width: 100%;
+}
+
+/* --- Header --- */
+#result-modal .result-modal-header h3 {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ font-size: 1.3rem;
+ letter-spacing: -0.01em;
+ color: var(--dm-ink);
+}
+#result-modal .result-modal-header-actions .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#result-modal .result-modal-header-actions .btn-secondary {
+ background: transparent;
+ border-color: var(--dm-line);
+ color: var(--dm-ink);
+}
+#result-modal .result-modal-header-actions .btn-secondary:hover { background: var(--dm-surface); }
+#result-modal .btn-close-modal:hover { background: var(--dm-accent-100); color: var(--dm-accent); border-radius: 0; }
+
+/* --- Stat grid --- */
+#result-modal .result-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
+ gap: 1px;
+ background: var(--dm-line);
+ border: 1px solid var(--dm-line);
+ margin-bottom: 0.75rem;
+}
+#result-modal .stat-box {
+ background: var(--dm-surface);
+ border: none;
+ border-radius: 0;
+ padding: 0.85rem 0.75rem;
+}
+#result-modal .stat-box:hover { border-color: transparent; }
+#result-modal .stat-box .value {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ background: none;
+ -webkit-background-clip: initial;
+ background-clip: initial;
+ -webkit-text-fill-color: var(--dm-ink);
+ color: var(--dm-ink);
+}
+#result-modal .stat-box .label {
+ color: var(--dm-ink-muted);
+ font-weight: 600;
+}
+
+/* --- View toolbar --- */
+#result-modal .dda-view-toolbar {
+ border: 1px solid var(--dm-line);
+ padding: 0.3rem;
+ display: inline-flex;
+ gap: 0;
+ width: auto;
+}
+#result-modal .dda-view-label { color: var(--dm-ink-muted); margin: 0 0.5rem 0 0.15rem; }
+#result-modal .dda-view-btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#result-modal .dda-view-btn:hover { background: var(--dm-bg); }
+#result-modal .dda-view-btn.active {
+ background: var(--dm-accent);
+ color: #fff;
+}
+#result-modal #dda-locate-btn.btn-secondary {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#result-modal .btn-zoom {
+ border-radius: 0;
+ font-family: var(--dm-font);
+}
+#result-modal .zoom-level { font-family: var(--dm-font); font-weight: 700; color: var(--dm-ink); }
+
+#result-modal .compare-slider { border: 1px solid var(--dm-line); border-radius: 0; }
+#result-modal .compare-label {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#result-modal .compare-label-left { background: var(--dm-ink); color: #fff; }
+#result-modal .compare-label-right { background: var(--dm-accent); color: #fff; }
+#result-modal .compare-handle-knob { border-radius: 0; background: var(--dm-ink); color: #fff; }
+
+#result-modal .dda-map-toolbar {
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+}
+#result-modal .dda-map-toolbar select,
+#result-modal .dda-map-xyz {
+ border-radius: 0;
+}
+#result-modal .dda-map-toolbar .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
+#result-modal .dda-map-canvas { border: 1px solid var(--dm-line); border-radius: 0; }
+
+#result-modal .result-modal-layout {
+ align-items: stretch;
+}
+#result-modal .result-modal-side {
+ border-left-color: var(--dm-line);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ max-height: calc(100vh - 12rem);
+ max-height: calc(100dvh - 12rem);
+}
+#result-modal .dda-review-summary { color: var(--dm-ink-muted); }
+#result-modal .dda-review-summary strong { color: var(--dm-ink); }
+#result-modal .dda-review-actions .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#result-modal .dda-review-actions .btn-secondary {
+ background: transparent;
+ border-color: var(--dm-line);
+ color: var(--dm-ink);
+}
+#result-modal .dda-review-actions .btn-secondary:hover { background: var(--dm-surface); }
+#result-modal .dda-review-actions .btn-primary { background: var(--dm-accent); }
+#result-modal .dda-review-actions .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
+
+#result-modal .regions-table {
+ border-collapse: separate;
+ border-spacing: 0;
+}
+#result-modal .regions-table th {
+ font-family: var(--dm-font);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-size: 0.68rem;
+ color: var(--dm-ink-muted);
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ background: var(--dm-surface, #fff);
+ box-shadow: 0 1px 0 var(--dm-line);
+}
+#result-modal .regions-table td {
+ border-color: var(--dm-line);
+ background: var(--dm-surface, #fff);
+}
+#result-modal .regions-table td:nth-child(2) { color: var(--dm-ink); }
+#result-modal .regions-table tr:hover td { background: var(--dm-bg); }
+#result-modal .regions-table tr.region-hover td { background: var(--dm-accent-100); }
+#result-modal .regions-table tr.region-selected td { background: var(--dm-accent-100); }
+#result-modal .severity-badge,
+#result-modal .review-badge {
+ border-radius: 2px;
+}
+#result-modal .dda-review-btns .btn { border-radius: 0; }
+#result-modal .pagination { font-family: var(--dm-font); }
+
+#result-modal .result-modal-side .regions-table-wrap {
+ flex: 1 1 auto;
+ min-height: 12rem;
+ max-height: none;
+ overflow: auto;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+}
+#result-modal .result-modal-side .regions-table {
+ min-width: 960px;
+}
+#result-modal .result-modal-side > .pagination {
+ flex: 0 0 auto;
+}
+
+/* ---- Standalone report page: never clip the viewport ----
+ The report page embeds the result UI in-flow (not a fixed overlay).
+ Cap-heights + body overflow:hidden were cutting the bottom of the screen. */
+#page-report-detail {
+ overflow: auto !important;
+ height: auto !important;
+ min-height: 100vh;
+}
+#page-report-detail .dda-report-page {
+ overflow: visible !important;
+ height: auto !important;
+}
+#page-report-detail #result-modal.result-modal-backdrop,
+.dda-report-page #result-modal.result-modal-backdrop {
+ position: relative !important;
+ inset: auto !important;
+ z-index: 1 !important;
+ display: block !important;
+ overflow: visible !important;
+ min-height: 0 !important;
+ height: auto !important;
+ background: var(--dm-bg, #f3f2f2) !important;
+ backdrop-filter: none !important;
+}
+#page-report-detail #result-modal .result-modal,
+.dda-report-page #result-modal .result-modal {
+ min-height: 0 !important;
+ height: auto !important;
+ overflow: visible !important;
+}
+#page-report-detail #result-modal .result-modal-layout,
+.dda-report-page #result-modal .result-modal-layout {
+ align-items: start;
+}
+#page-report-detail #result-modal .result-modal-side,
+.dda-report-page #result-modal .result-modal-side {
+ max-height: none !important;
+ height: auto !important;
+ overflow: visible !important;
+}
+#page-report-detail #result-modal .result-modal-side .regions-table-wrap,
+.dda-report-page #result-modal .result-modal-side .regions-table-wrap {
+ flex: none !important;
+ max-height: none !important;
+ min-height: 0 !important;
+ /* Only horizontal scroll for wide columns; vertical uses the page. */
+ overflow-x: auto !important;
+ overflow-y: visible !important;
+}
+#page-report-detail #result-modal .regions-table th,
+.dda-report-page #result-modal .regions-table th {
+ position: static;
+ box-shadow: none;
+}
+#page-report-detail .dda-header.dda-report-header {
+ margin: 0 !important;
+}
+
+/* --- Editable report title --- */
+#result-modal .result-title-edit-btn {
+ border-radius: 0;
+ color: var(--dm-ink-muted);
+}
+#result-modal .result-title-edit-btn:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
+#result-modal .result-title-input {
+ border-radius: 0;
+ border-color: var(--dm-line);
+ font-family: var(--dm-font);
+}
+#result-modal .result-modal-title-edit .btn {
+ border-radius: 0;
+ font-family: var(--dm-font);
+ font-weight: 700;
+}
+#result-modal .result-modal-title-edit .btn-primary { background: var(--dm-accent); }
+#result-modal .result-modal-title-edit .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
+#result-modal .result-title-msg { color: var(--dm-accent); }
diff --git a/static/css/dda-modernist-sidebar.css b/static/css/dda-modernist-sidebar.css
new file mode 100644
index 0000000000000000000000000000000000000000..4b66d4a253160106fffd8dccb0b61a1f070b84d9
--- /dev/null
+++ b/static/css/dda-modernist-sidebar.css
@@ -0,0 +1,76 @@
+/* ============================================================
+ Modernist theme for the main left sidebar (navbar_dda.html) —
+ shared, identical partial injected into every page, so this
+ reskin applies globally rather than being scoped to one page.
+ Reuses the shared --dm-* tokens declared once at :root in
+ dda.css: flat surfaces, hairline dividers, sharp corners, one
+ accent color, Archivo for the brand/nav. Pure visual restyle —
+ the DB-driven menu structure, RBAC, and every id/class app.js/
+ notifications.js select on are untouched.
+ ============================================================ */
+
+.dda-sidebar-nav {
+ background: var(--dm-bg);
+ border-right: 2px solid var(--dm-line);
+}
+
+/* --- Brand --- */
+.dda-sidebar-brand > svg { color: var(--dm-accent); }
+.dda-sidebar-brand-name {
+ font-family: var(--dm-font);
+ font-weight: 800;
+ font-size: 1rem;
+ letter-spacing: 0.02em;
+ background: none;
+ -webkit-background-clip: initial;
+ background-clip: initial;
+ color: var(--dm-ink);
+}
+.dda-sidebar-brand-sub { color: var(--dm-ink-muted); text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.68rem; }
+.dda-sidebar-toggle { border-color: var(--dm-line); border-radius: 0; color: var(--dm-ink-muted); }
+.dda-sidebar-toggle:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
+
+/* --- Nav tabs --- */
+.dda-tab {
+ border-radius: 0;
+ border-left: 2px solid transparent;
+ color: var(--dm-ink);
+ font-weight: 500;
+}
+.dda-tab:hover { background: var(--dm-bg); color: var(--dm-ink); }
+.dda-tab.active {
+ background: var(--dm-accent-100);
+ border-left-color: var(--dm-accent);
+ color: var(--dm-accent);
+ font-weight: 700;
+}
+.dda-subtab { color: var(--dm-ink); font-weight: 500; }
+.dda-subtab.active {
+ background: var(--dm-accent-100);
+ border-left-color: var(--dm-accent);
+ color: var(--dm-accent);
+ font-weight: 700;
+}
+.dda-sidebar-submenu { border-left-color: var(--dm-line); }
+.dda-sidebar-group > summary.dda-tab-group { border-radius: 0; }
+
+/* --- Footer: notifications + user menu --- */
+.dda-sidebar-footer { border-top-color: var(--dm-line); }
+.dda-notify-btn { border-color: var(--dm-line); border-radius: 0; color: var(--dm-ink); }
+.dda-notify-btn:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
+.dda-notify-badge { background: var(--dm-accent); border-radius: 0; }
+.dda-notify-panel { border-radius: 0; border-color: var(--dm-line); background: var(--dm-surface); }
+.dda-notify-item { border-radius: 0; }
+.dda-notify-item:hover { background: var(--dm-bg); }
+.dda-notify-item.unread { background: var(--dm-accent-100); }
+
+.dda-user-avatar {
+ border-radius: 0;
+ background: var(--dm-ink);
+}
+.dda-user-wrap #dda-user-email { color: var(--dm-ink); }
+.dda-user-menu-panel { border-top-color: var(--dm-line); }
+.dda-user-menu-link { border-radius: 0; color: var(--dm-ink); }
+.dda-user-menu-link:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
+.dda-user-menu-link.active { color: var(--dm-accent); font-weight: 700; }
+.dda-sidebar-nav.collapsed .dda-user-menu-panel { border-radius: 0; border-color: var(--dm-line); background: var(--dm-surface); box-shadow: none; }
diff --git a/static/css/dda.css b/static/css/dda.css
index fa6f14e3df6867f560bab0a19d16b67a5db4e902..836e11c5c69be5402795467ad0dd37d72dde54f6 100644
--- a/static/css/dda.css
+++ b/static/css/dda.css
@@ -1,4 +1,23 @@
/* Vedangsoft dev UI — extends style.css */
+@import url('https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&display=swap');
+
+/* Modernist design tokens (from the Claude Design redesign) — shared by
+ every screen that opts into the new look via its own scoped block below.
+ Global/additive: these names don't collide with anything style.css
+ already defines, so declaring them at :root can't affect pages that
+ don't reference them. */
+:root {
+ --dm-bg: #f3f2f2;
+ --dm-surface: #ffffff;
+ --dm-line: rgba(32, 30, 29, 0.14);
+ --dm-ink: #201e1d;
+ --dm-ink-muted: #7d7979;
+ --dm-ink-dim: #9b9797;
+ --dm-accent: #ec3013;
+ --dm-accent-hover: #dd2b0f;
+ --dm-accent-100: #fff2ef;
+ --dm-font: 'Archivo', system-ui, sans-serif;
+}
.dda-app {
max-width: 1600px;
@@ -287,7 +306,7 @@
help text collapses behind — together these keep the upload
card short so the page reads as three compact sections, not four tall
stacked cards. */
-.dda-upload-row { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
+.dda-upload-row { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
/* Detect page: manual-mode pairs + library grid split into their own cards
(same pattern as the Library page) instead of nesting inside the main
@@ -397,6 +416,19 @@
.dda-tree-node-summary { list-style: none; cursor: pointer; }
.dda-tree-node-summary .dda-tree-node-btn { display: inline; width: auto; }
.dda-tree-children { padding-left: 0.65rem; border-left: 1px solid var(--border); margin-left: 0.35rem; }
+.dda-tree-toolbar { display: flex; gap: 0.5rem; margin: -0.35rem 0 0.6rem; }
+.dda-tree-toolbar-btn {
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 0.76rem;
+ padding: 0;
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.dda-tree-toolbar-btn:hover { color: var(--grad-start); }
+.dda-tree-hit { background: rgba(255, 205, 60, 0.45); color: inherit; border-radius: 2px; }
.dda-tree-leaf { padding-left: 0.75rem; }
.dda-crumb { font-size: 0.78rem; line-height: 1.3; }
.dda-manage-panel { max-width: 640px; width: 100%; }
@@ -516,8 +548,9 @@
line-height: 1.3;
/* Fixed height regardless of path length — the variable-length crumb
text (folder path + filename) was the other source of uneven card
- heights, wrapping to 1-3 lines depending on the image. */
- height: 2.6em;
+ heights, wrapping to 1-4 lines depending on the image: crumb, optional
+ village/zone place, capture date/type/size/dims, uploaded date. */
+ height: 5.2em;
overflow: hidden;
}
.dda-card-img .meta .dda-crumb {
@@ -526,6 +559,12 @@
overflow: hidden;
text-overflow: ellipsis;
}
+.dda-card-img .meta .dda-card-uploaded {
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
.dda-compare-slots {
display: grid;
@@ -607,6 +646,89 @@
}
.dda-detect-options .form-group { margin: 0; min-width: 160px; }
.dda-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.88rem; color: var(--text-muted); }
+.dda-sensitivity-field label { display: flex; align-items: baseline; gap: 0.35rem; }
+.dda-sensitivity-field em { font-style: normal; color: var(--text); font-variant-numeric: tabular-nums; }
+.dda-sensitivity-field input[type="range"] { width: 100%; min-width: 140px; }
+.dda-field-hint { font-size: 0.78rem; margin: 0.3rem 0 0; color: var(--accent-dim); font-weight: 500; }
+
+/* Min region area: a unified [−] [140 px] [+] stepper control — one bordered
+ group instead of a plain number input, since the browser's own spinner
+ arrows collide with our "px" suffix and can't be styled consistently. */
+.dda-stepper-wrap {
+ display: flex;
+ align-items: stretch;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated);
+ overflow: hidden;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+.dda-stepper-wrap:focus-within {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 3px rgba(46, 51, 197, 0.18);
+}
+.dda-stepper-wrap .dda-input-suffix-wrap { flex: 1; min-width: 0; }
+.dda-stepper-wrap input[type="number"] {
+ border: none;
+ border-radius: 0;
+ background: transparent;
+}
+.dda-stepper-wrap input[type="number"]:focus { outline: none; box-shadow: none; }
+.dda-stepper-btn {
+ flex: 0 0 auto;
+ width: 2.25rem;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 1.15rem;
+ line-height: 1;
+ font-family: var(--font-sans);
+ cursor: pointer;
+ transition: background var(--transition), color var(--transition);
+}
+.dda-stepper-btn:first-child { border-right: 1px solid var(--border); }
+.dda-stepper-btn:last-child { border-left: 1px solid var(--border); }
+.dda-stepper-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--accent-dim); }
+.dda-stepper-btn:active:not(:disabled) { background: var(--accent-glow); }
+.dda-stepper-btn:disabled { opacity: 0.35; cursor: not-allowed; }
+
+.dda-input-suffix-wrap {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+.dda-input-suffix-wrap input[type="number"] {
+ padding-right: 2.75rem;
+ /* Hide the native spinner — it renders at a browser-chosen width that
+ collides with our own "px" suffix instead of sitting cleanly beside it;
+ our own stepper buttons replace its increment/decrement role. */
+ -moz-appearance: textfield;
+}
+.dda-input-suffix-wrap input[type="number"]::-webkit-outer-spin-button,
+.dda-input-suffix-wrap input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+.dda-input-suffix-wrap input[type="number"]:focus + .dda-input-suffix {
+ color: var(--accent-dim);
+}
+.dda-input-suffix {
+ position: absolute;
+ right: 1rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-dim);
+ font-size: 0.8rem;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ pointer-events: none;
+ transition: color var(--transition);
+}
.dda-modal {
position: fixed;
@@ -703,6 +825,9 @@
}
.dda-image-viewer-status { min-height: 1.2rem; margin: 0 0 0.5rem; }
.dda-image-viewer-status.dda-viewer-error { color: var(--danger, #dc2626); }
+.dda-image-viewer-meta { margin: 0 0 0.4rem; }
+.dda-viewer-meta-row { line-height: 1.5; }
+.dda-viewer-meta-row .dda-meta-sep { opacity: 0.5; margin: 0 0.15rem; }
.dda-viewer-mode-bar {
display: flex;
@@ -793,6 +918,19 @@
}
.dda-upload-form .location-row { margin-bottom: 0.5rem; }
+.dda-upload-form .form-group {
+ margin-bottom: 0;
+ min-width: 0;
+}
+.dda-upload-form .location-row > .form-group > label {
+ display: block;
+ align-items: unset;
+ gap: 0;
+}
+.dda-upload-form .dda-datewrap {
+ min-width: 0;
+ width: 100%;
+}
.dda-upload-progress { margin: 0.75rem 0 1rem; }
.dda-progress-bar {
@@ -919,8 +1057,112 @@
.dda-report-actions-cell { white-space: nowrap; }
.dda-report-actions-cell .btn, .dda-report-actions-cell a.btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
-/* Standalone report page has no sidebar nav — override the sidebar-shell flex layout. */
-.dda-report-page { display: block; max-width: 960px; margin: 0 auto; padding: 1rem; }
+/* Standalone report page has no sidebar nav — full viewport interactive viewer. */
+.dda-report-page.dda-app,
+#page-report-detail .dda-report-page {
+ display: block;
+ max-width: none;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+ height: auto;
+ overflow: visible;
+ background: var(--dm-bg, #f3f2f2);
+}
+.dda-report-page .dda-report-header {
+ padding: 0.75rem 1rem;
+ margin: 0;
+ border-bottom: 1px solid var(--border);
+}
+.dda-report-page #result-modal.result-modal-backdrop {
+ position: relative !important;
+ inset: auto !important;
+ z-index: 1 !important;
+ display: block !important;
+ background: var(--dm-bg, #f3f2f2) !important;
+ backdrop-filter: none !important;
+ overflow: visible !important;
+ min-height: 0;
+ height: auto;
+}
+.dda-report-page #result-modal .result-modal {
+ min-height: 0;
+ height: auto;
+ overflow: visible;
+ box-shadow: none;
+}
+.dda-report-page #result-modal-close {
+ display: none;
+}
+.dda-report-page #report-loading.hidden { display: none !important; }
+
+.table-wrap {
+ overflow-x: auto;
+ width: 100%;
+}
+.dda-upload-type-error {
+ color: var(--danger, #b91c1c);
+ font-size: 0.85rem;
+ margin: 0.5rem 0 0;
+}
+.dda-upload-type-error.hidden { display: none; }
+.dda-upload-file-list-wrap {
+ margin-top: 0.75rem;
+ overflow-x: auto;
+ max-height: 220px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+.dda-upload-file-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+.dda-upload-file-table th,
+.dda-upload-file-table td {
+ padding: 0.4rem 0.55rem;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ vertical-align: middle;
+}
+.dda-upload-file-table .upload-server-name {
+ width: 100%;
+ min-width: 140px;
+ font-size: 0.85rem;
+ padding: 0.25rem 0.4rem;
+}
+.dda-lib-grid-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ align-items: center;
+}
+.dda-card-img { position: relative; }
+.dda-card-select-wrap {
+ position: absolute;
+ top: 0.35rem;
+ left: 0.35rem;
+ z-index: 2;
+ background: rgba(255,255,255,0.9);
+ border-radius: 4px;
+ padding: 0.1rem 0.2rem;
+ line-height: 1;
+}
+.dda-card-filename {
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+.dda-card-capture {
+ font-size: 0.8rem;
+ color: var(--text, #222);
+}
+.dda-upload-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 0.65rem 0.75rem;
+ align-items: end;
+}
.dda-header {
display: flex;
flex-wrap: wrap;
@@ -1014,11 +1256,18 @@
.regions-table-wrap .regions-table {
min-width: 960px;
}
+/* Modal side panel owns its own scroll; don't force a second min-width fight. */
+.result-modal-side .regions-table-wrap .regions-table {
+ min-width: 960px;
+}
@media (max-width: 768px) {
.dda-compare-slots {
grid-template-columns: 1fr;
}
+ .dda-detect-options .form-group {
+ min-width: 100%;
+ }
}
/* ROI (region-of-interest) draw tool on the T2 preview */
@@ -1384,7 +1633,8 @@
}
.dda-filter-field .dda-search,
.dda-filter-field select,
-.dda-date-input {
+.dda-filter-field .dda-datewrap,
+input.dda-date-input:not(.dda-date-native) {
min-width: 9.5rem;
margin-bottom: 0;
padding: 0.4rem 0.55rem;
@@ -1493,7 +1743,8 @@ time.dda-date {
@media (max-width: 720px) {
.dda-filter-field .dda-search,
.dda-filter-field select,
- .dda-date-input,
+ .dda-filter-field .dda-datewrap,
+ input.dda-date-input:not(.dda-date-native),
.dda-datewrap { min-width: 100%; }
.dda-run-bar { position: static; }
}
@@ -1508,6 +1759,50 @@ time.dda-date {
}
.dda-dash-hero-actions { display: flex; align-items: center; gap: 0.75rem; }
.dda-dash-updated { font-size: 0.8rem; white-space: nowrap; }
+.dda-dash-filter-row { margin-top: 0.75rem; flex-wrap: wrap; }
+
+/* A distinct, bordered toolbar strip for the date filter — visually
+ grouped instead of loose inline controls, so it reads as one control
+ panel rather than stray fields sitting under the title. Label sits on
+ its own line above the controls row so it never fights the taller
+ labeled inputs for vertical alignment. */
+.dda-dash-filter-bar {
+ margin-top: 1rem;
+ padding: 0.85rem 1rem 1rem;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+.dda-dash-filter-bar .card-header { margin-bottom: 0.85rem; }
+.dda-dash-filter-bar .card-header svg { flex-shrink: 0; color: var(--text-muted); }
+.dda-dash-filter-bar-controls {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 0.85rem;
+}
+.dda-dash-filter-range { display: flex; align-items: flex-end; gap: 0.65rem; }
+.dda-dash-filter-arrow { padding-bottom: 0.55rem; color: var(--text-dim); font-size: 0.9rem; }
+.dda-dash-filter-actions { display: flex; align-items: flex-end; gap: 0.6rem; }
+
+.dda-dash-section + .dda-dash-section { margin-top: 2rem; }
+.dda-dash-section-title {
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-dim, var(--text-muted));
+ margin: 0 0 0.75rem;
+ padding-left: 0.15rem;
+}
+.dda-dash-overview-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
+ gap: 1.25rem;
+ align-items: stretch;
+}
+.dda-dash-overview-grid .card { margin-top: 0; }
.dda-dash-kpi-grid {
display: grid;
@@ -1518,9 +1813,20 @@ a.stat-box.dda-dash-kpi {
display: flex;
flex-direction: column;
text-decoration: none;
- transition: border-color 0.15s ease, transform 0.15s ease;
+ border-top: 3px solid var(--accent);
+ transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
+}
+a.stat-box.dda-dash-kpi:hover {
+ border-color: var(--accent);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
-a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translateY(-1px); }
+a.stat-box.dda-dash-kpi:nth-child(6n+1) { border-top-color: var(--grad-start, var(--accent)); }
+a.stat-box.dda-dash-kpi:nth-child(6n+2) { border-top-color: var(--accent); }
+a.stat-box.dda-dash-kpi:nth-child(6n+3) { border-top-color: var(--success, var(--accent)); }
+a.stat-box.dda-dash-kpi:nth-child(6n+4) { border-top-color: var(--grad-mid, var(--accent)); }
+a.stat-box.dda-dash-kpi:nth-child(6n+5) { border-top-color: var(--accent-dim, var(--accent)); }
+a.stat-box.dda-dash-kpi:nth-child(6n+6) { border-top-color: var(--grad-end, var(--accent)); }
.dda-dash-latest-row { display: flex; gap: 1.25rem; align-items: flex-start; flex-wrap: wrap; }
.dda-dash-latest-thumb {
@@ -1542,9 +1848,23 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
.dda-dash-chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
- gap: 1.5rem;
+ gap: 1.25rem;
+}
+.dda-dash-chart-box-wide { grid-column: 1 / -1; }
+.dda-dash-chart-box {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 1rem 1.1rem 0.6rem;
+}
+.dda-dash-chart-box h4 {
+ margin: 0 0 0.75rem;
+ font-size: 0.82rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text-muted);
}
-.dda-dash-chart-box h4 { margin: 0 0 0.6rem; font-size: 0.9rem; color: var(--text-muted); }
.dda-dash-compare-grid {
display: grid;
@@ -1561,31 +1881,319 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
.dda-dash-compare-col p { margin: 0 0 0.35rem; }
.dda-dash-delta { font-weight: 700; font-family: var(--font-mono); }
.dda-dash-history-title { margin: 0 0 0.6rem; font-size: 0.95rem; }
+.dda-dash-class-line { font-size: 0.82rem; }
+
+/* Classification mini-bar — a compact stacked-proportion visual standing in
+ for four separate table columns (confirmed/false positive/submitted/
+ unclassified), used across Region-wise, Village-wise, and Detection
+ History wherever a full breakdown would otherwise need its own charts. */
+.dda-class-bar {
+ display: flex;
+ width: 100%;
+ min-width: 90px;
+ height: 10px;
+ border-radius: 5px;
+ overflow: hidden;
+ background: var(--bg-elevated);
+}
+.dda-class-seg { display: block; height: 100%; }
+.dda-class-confirmed { background: #16a34a; }
+.dda-class-fp { background: #dc2626; }
+.dda-class-submitted { background: #2563eb; }
+.dda-class-unclassified { background: #ca8a04; }
+.dda-class-bar-caption {
+ display: block;
+ font-size: 0.7rem;
+ margin-top: 0.2rem;
+ font-family: var(--font-mono);
+}
+
+.dda-accuracy-good { color: var(--success, #16a34a); font-weight: 700; }
+.dda-accuracy-mid { color: #ca8a04; font-weight: 700; }
+.dda-accuracy-low { color: var(--danger, #dc2626); font-weight: 700; }
+
+.dda-dash-summary {
+ margin: 0 0 1.25rem;
+ padding: 0.75rem 1rem;
+ background: var(--accent-glow);
+ border-left: 3px solid var(--accent);
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+ font-size: 0.88rem;
+ line-height: 1.5;
+}
+.dda-dash-compare-chart { margin: 0 0 1.25rem; }
+
+/* Change-type pill — colors match CHANGE_TYPE_COLORS in home.js exactly,
+ one fixed meaning per color across every chart/table on the dashboard. */
+.dda-changetype-pill {
+ display: inline-block;
+ padding: 0.15rem 0.55rem;
+ border-radius: 999px;
+ font-size: 0.72rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+.dda-changetype-New-Construction { background: rgba(249, 115, 22, 0.15); color: #c2410c; }
+.dda-changetype-Demolition { background: rgba(220, 38, 38, 0.15); color: #b91c1c; }
+.dda-changetype-Extension { background: rgba(37, 99, 235, 0.15); color: #1d4ed8; }
+.dda-changetype-Vegetation-Change { background: rgba(22, 163, 74, 0.15); color: #15803d; }
+.dda-changetype-Other { background: rgba(100, 116, 139, 0.15); color: #475569; }
+.dda-changetype-pill-group { display: flex; flex-wrap: wrap; gap: 0.3rem; }
+.dda-dash-latest-changetypes { margin: 0 0 0.85rem; }
@media (max-width: 720px) {
.dda-dash-latest-row { flex-direction: column; }
.dda-dash-latest-thumb { width: 100%; height: 160px; }
}
+
+/* ============================================================
+ Modernist dashboard theme (from the Claude Design redesign).
+ Scoped entirely to #tab-home so the rest of the app keeps its
+ existing look until/unless it gets redesigned too: flat
+ surfaces, hairline dividers, sharp corners, one accent color,
+ Archivo for headings and numbers. Tokens (--dm-*) are shared,
+ declared once at :root above.
+ ============================================================ */
+
+#tab-home h1,
+#tab-home .dda-dash-section-title,
+#tab-home .card-title,
+#tab-home .dda-dash-chart-box h4,
+#tab-home .dda-dash-ranked-count,
+#tab-home .stat-box .value {
+ font-family: var(--dm-font);
+}
+#tab-home h1#dda-home-greeting { font-weight: 800; letter-spacing: -0.01em; }
+
+#tab-home .dda-dash-section-title {
+ font-size: 0.8rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ border-bottom: 2px solid var(--dm-line);
+ padding-bottom: 0.6rem;
+ color: var(--dm-ink);
+}
+
+/* Flat, hairline-bordered surfaces instead of soft shadow cards */
+#tab-home .card,
+#tab-home .dda-dash-chart-box,
+#tab-home .dda-dash-filter-bar,
+#tab-home .dda-dash-compare-grid {
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ box-shadow: none;
+}
+#tab-home .dda-dash-chart-box h4 { font-weight: 700; color: var(--dm-ink); }
+#tab-home .dda-dash-filter-bar .card-header svg { color: var(--dm-accent); }
+#tab-home .dda-dash-filter-arrow { color: var(--dm-ink-dim); }
+#tab-home .dda-dash-latest-thumb { border-radius: 0; }
+#tab-home .dda-dash-summary { background: var(--dm-accent-100); border-left-color: var(--dm-accent); border-radius: 0; }
+
+/* Buttons — flat accent instead of the app-wide purple gradient,
+ scoped to this page only so other pages are untouched. */
+#tab-home .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
+#tab-home .btn-primary { background: var(--dm-accent); }
+#tab-home .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
+#tab-home .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
+#tab-home .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
+#tab-home .btn-ghost { color: var(--dm-accent); }
+#tab-home .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
+
+/* Overview / Charts & Graphs / Breakdown / Image Library tab switch — a row
+ of small flat cards (icon + label + sub-label) instead of a plain button
+ toolbar, so each section reads as its own destination rather than one of
+ several equal-weight pill options. */
+#tab-home .dda-dash-tab-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+#tab-home .dda-dash-tab-card {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.9rem 1rem;
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-radius: 0;
+ cursor: pointer;
+ text-align: left;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+#tab-home .dda-dash-tab-card:hover { background: var(--dm-bg); }
+#tab-home .dda-dash-tab-card-icon {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--dm-ink-muted);
+ background: var(--dm-bg);
+ border: 1px solid var(--dm-line);
+}
+#tab-home .dda-dash-tab-card-text { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
+#tab-home .dda-dash-tab-card-label {
+ font-family: var(--dm-font);
+ font-weight: 700;
+ font-size: 0.92rem;
+ color: var(--dm-ink);
+}
+#tab-home .dda-dash-tab-card-sub {
+ font-size: 0.72rem;
+ color: var(--dm-ink-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+#tab-home .dda-dash-tab-card.is-active {
+ border-color: var(--dm-accent);
+ background: var(--dm-accent-100);
+}
+#tab-home .dda-dash-tab-card.is-active .dda-dash-tab-card-icon {
+ color: var(--dm-accent);
+ border-color: var(--dm-accent);
+ background: var(--dm-surface);
+}
+#tab-home .dda-dash-tab-card.is-active .dda-dash-tab-card-label { color: var(--dm-accent); }
+
+/* KPI grid — one shared hairline grid instead of individually
+ bordered/shadowed tiles, each cell a plain flat surface. */
+#tab-home .dda-dash-kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1px;
+ background: var(--dm-line);
+ border: 1px solid var(--dm-line);
+}
+#tab-home a.stat-box.dda-dash-kpi {
+ background: var(--dm-surface);
+ border: none !important;
+ border-radius: 0;
+ padding: 0.9rem 0.75rem;
+ text-align: left;
+ align-items: flex-start;
+ transform: none !important;
+ box-shadow: none !important;
+}
+#tab-home a.stat-box.dda-dash-kpi:hover { background: var(--dm-accent-100); }
+#tab-home .stat-box .value {
+ font-weight: 800;
+ font-size: 1.3rem;
+ background: none;
+ -webkit-background-clip: initial;
+ background-clip: initial;
+ -webkit-text-fill-color: var(--dm-ink);
+ color: var(--dm-ink);
+ white-space: normal;
+}
+#tab-home .stat-box .label { color: var(--dm-ink-muted); font-weight: 600; }
+@media (max-width: 900px) { #tab-home .dda-dash-kpi-grid { grid-template-columns: repeat(2, 1fr); } }
+@media (max-width: 480px) { #tab-home .dda-dash-kpi-grid { grid-template-columns: 1fr; } }
+
+/* Tables — uppercase hairline headers, no card wrapper needed per row */
+#tab-home .dda-reports-table th {
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--dm-ink-muted);
+ border-bottom: 2px solid var(--dm-line);
+}
+#tab-home .dda-reports-table td { border-bottom: 1px solid var(--dm-line); }
+#tab-home .dda-reports-table tbody tr:hover { background: var(--dm-bg); }
+#tab-home .dda-dash-row-active { background: var(--dm-accent-100) !important; }
+
+#tab-home .dda-changetype-pill { border-radius: 2px; }
+
+/* Live detection-job banner — real job data (zone/village/method/
+ progress) from GET /api/dda/jobs, not a mockup placeholder. */
+.dda-dash-live-banner {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+ padding: 0.85rem 1.1rem;
+ margin-bottom: 1.25rem;
+ background: var(--dm-surface);
+ border: 1px solid var(--dm-line);
+ border-left: 3px solid var(--dm-accent);
+}
+.dda-dash-live-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--dm-accent);
+ flex-shrink: 0;
+ animation: dda-dash-pulse 1.4s infinite;
+}
+@keyframes dda-dash-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
+.dda-dash-live-info { flex: 1; min-width: 220px; }
+.dda-dash-live-title { font-weight: 700; font-size: 0.9rem; color: var(--dm-ink); }
+.dda-dash-live-meta { font-size: 0.78rem; color: var(--dm-ink-muted); margin-top: 0.15rem; }
+.dda-dash-live-bar { width: 180px; height: 6px; background: var(--dm-line); flex-shrink: 0; }
+.dda-dash-live-bar-fill { height: 100%; background: var(--dm-accent); transition: width 0.6s ease; }
+.dda-dash-live-queued {
+ font-size: 0.68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border: 1px solid var(--dm-accent);
+ color: var(--dm-accent);
+ padding: 0.2rem 0.5rem;
+ white-space: nowrap;
+}
+@media (max-width: 640px) { .dda-dash-live-bar { width: 100%; } }
+
+/* Ranked change-types list — replaces a second duplicate chart with
+ the same totals the stacked "Change Types by Zone" bar already shows. */
+.dda-dash-ranked-list { display: flex; flex-direction: column; gap: 0.55rem; }
+.dda-dash-ranked-row { display: flex; align-items: center; gap: 0.65rem; font-size: 0.85rem; }
+.dda-dash-ranked-swatch { width: 10px; height: 10px; flex-shrink: 0; }
+.dda-dash-ranked-label { flex: 1; color: var(--dm-ink); }
+.dda-dash-ranked-count { font-weight: 700; }
+.dda-dash-ranked-hint { font-size: 0.72rem; color: var(--dm-ink-dim); margin: -0.4rem 0 0.65rem; }
+
/* Styled calendar date picker */
.dda-datewrap {
position: relative;
- min-width: 11.5rem;
+ min-width: 0;
+ width: 100%;
overflow: visible;
}
+.dda-filter-field .dda-datewrap { min-width: 9.5rem; }
+input.dda-date-input.dda-date-native,
.dda-date-native {
- position: absolute;
- width: 1px;
- height: 1px;
- opacity: 0;
+ position: absolute !important;
+ left: 0;
+ top: 0;
+ width: 1px !important;
+ min-width: 0 !important;
+ max-width: 1px !important;
+ height: 1px !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ border: 0 !important;
+ opacity: 0 !important;
pointer-events: none;
+ clip: rect(0, 0, 0, 0);
+ appearance: none;
+ -webkit-appearance: none;
+}
+.dda-date-native:focus {
+ outline: none !important;
+ box-shadow: none !important;
}
.dda-date-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
+ min-width: 0;
min-height: 2.35rem;
padding: 0.4rem 0.7rem;
+ overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
@@ -1602,6 +2210,12 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
box-shadow: 0 0 0 3px var(--accent-glow);
}
.dda-date-placeholder { color: var(--text-dim); font-weight: 450; }
+.dda-date-trigger > span:not(.dda-date-clear) {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.dda-date-clear {
margin-left: auto;
color: var(--text-dim);
diff --git a/static/css/style.css b/static/css/style.css
index 2bec5b38b3d690dd927215aa4b93d810975216c8..8c061f5046d6065b0301ec4b61e726171273a866 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -238,6 +238,7 @@ body::before {
input[type="email"],
input[type="password"],
input[type="text"],
+input[type="number"],
select,
textarea {
width: 100%;
@@ -1087,6 +1088,41 @@ input:focus, select:focus, textarea:focus {
font-weight: 600;
margin: 0;
}
+.result-modal-title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-width: 0;
+}
+.result-title-edit-btn {
+ flex-shrink: 0;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.result-modal-title-edit {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
+}
+.result-title-input {
+ flex: 1 1 320px;
+ min-width: 200px;
+ max-width: 480px;
+ padding: 0.45rem 0.65rem;
+ font-family: var(--font-sans);
+ font-size: 0.95rem;
+ color: var(--text);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+.result-title-msg { font-size: 0.8rem; }
.result-modal-header-actions {
display: flex;
align-items: center;
@@ -1096,7 +1132,7 @@ input:focus, select:focus, textarea:focus {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
gap: 1rem;
- align-items: start;
+ align-items: stretch;
transition: grid-template-columns var(--transition);
}
.result-modal-layout.report-hidden {
@@ -1109,18 +1145,41 @@ input:focus, select:focus, textarea:focus {
min-width: 0;
border-left: 1px solid var(--border);
padding-left: 1rem;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ max-height: calc(100vh - 12rem);
}
.result-modal-side .dda-review-bar {
margin-top: 0;
padding-top: 0;
border-top: 0;
+ flex: 0 0 auto;
}
.result-modal-side .regions-table-wrap {
- max-height: 68vh;
+ flex: 1 1 auto;
+ min-height: 0;
+ max-height: none;
overflow: auto;
+ overscroll-behavior: contain;
+ margin-top: 0.5rem;
}
.result-modal-side .regions-table {
- min-width: 860px;
+ min-width: 960px;
+ /* sticky thead + collapse clips mid-row in Chromium */
+ border-collapse: separate;
+ border-spacing: 0;
+}
+.result-modal-side .regions-table th {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ background: var(--bg-elevated, #f5f5f5);
+ box-shadow: 0 1px 0 var(--border);
+}
+.result-modal-side > .pagination {
+ flex: 0 0 auto;
+ padding-top: 0.5rem;
}
.btn-close-modal {
background: none;
@@ -1154,9 +1213,10 @@ input:focus, select:focus, textarea:focus {
border-top: 1px solid var(--border);
padding-left: 0;
padding-top: 0.75rem;
+ max-height: none;
}
.result-modal-side .regions-table-wrap {
- max-height: none;
+ max-height: min(50vh, 420px);
}
}
diff --git a/static/js/dda/app.js b/static/js/dda/app.js
index 1bd1af00ffe9a44f0452413b01c856ca386d63f4..594bb2584f41a7d0ff9df4b471fccfb0ceaf29b3 100644
--- a/static/js/dda/app.js
+++ b/static/js/dda/app.js
@@ -35,7 +35,7 @@ async function initDda() {
if (!needsLibrary && !needsDetect) return;
try {
const [me, cfg, localCfg] = await Promise.all([
- ddaApi('GET', '/api/dda/me').catch(() => null),
+ (typeof ensureDdaMe === 'function' ? ensureDdaMe() : ddaApi('GET', '/api/me')).catch(() => null),
ddaApi('GET', '/api/dda/config'),
ddaApi('GET', '/api/dda/local/config'),
]);
@@ -55,9 +55,7 @@ async function initDda() {
const folderPath = document.getElementById('lib-folder-path');
if (folderPath) {
- folderPath.textContent = localCfg.isHosted
- ? 'Hugging Face — tree library'
- : `Storage: ${localCfg.storageRoot || ''}`;
+ folderPath.textContent = `Storage: ${localCfg.storageRoot || ''}`;
}
const instr = document.getElementById('lib-instructions');
@@ -74,10 +72,16 @@ async function initDda() {
}
if (needsLibrary) {
- await Promise.all([
- typeof loadTree === 'function' ? loadTree() : Promise.resolve(),
- loadLibraryImages({ resetPage: true }),
- ]);
+ // Grid first — tree must not block first paint of images.
+ loadLibraryImages({ resetPage: true });
+ if (typeof loadTree === 'function') {
+ loadTree().catch(() => {
+ const treeEl = document.getElementById('lib-tree');
+ if (treeEl && !treeEl.querySelector('.dda-tree-node, .dda-tree-item')) {
+ treeEl.innerHTML = 'Could not load folders.
';
+ }
+ });
+ }
}
} catch (err) {
showDdaError(err.message || 'Failed to load library');
@@ -109,19 +113,35 @@ function renderLibGrid() {
grid.innerHTML = items.map((img) => {
const thumb = img.thumbUrl || '';
const crumb = img.breadcrumb || img.nodePath || img.filename;
+ const place = [img.villageName, img.zoneName].filter(Boolean).join(', ');
const encPath = encodeURIComponent(img.path);
+ const capture = img.captureDate ? String(img.captureDate).slice(0, 10) : '';
+ const year = img.year != null ? String(img.year) : '';
return `
+
+
+
×
${thumb ? `
` : '
No preview
'}
+ ${escapeHtml(img.filename || img.imageName || '')}
${escapeHtml(crumb)}
- ${img.captureDate ? String(img.captureDate).slice(0, 10) + ' · ' : ''}${img.imageType || ''} · ${formatBytes(img.fileSizeBytes)}
+ ${place ? `${escapeHtml(place)} ` : ''}
+ ${capture ? `Capture: ${escapeHtml(capture)} ` : ''}
+ ${year ? escapeHtml(year) + ' · ' : ''}${escapeHtml(img.imageType || '')}${img.source ? ' · ' + escapeHtml(img.source) : ''} · ${formatBytes(img.fileSizeBytes)}${img.width && img.height ? ` · ${img.width}×${img.height}` : ''}
+ Uploaded ${typeof formatDateIst === 'function' ? formatDateIst(img.uploadedOn) : (img.uploadedOn || '—')}
`;
}).join('');
if (typeof bindLibraryGridCards === 'function') bindLibraryGridCards(grid);
if (typeof applyPermissionGating === 'function') applyPermissionGating(window.ddaPermissions || {}, grid);
+ grid.querySelectorAll('.dda-card-select').forEach((cb) => {
+ cb.addEventListener('change', () => {
+ if (typeof updateLibDeleteSelectedBtn === 'function') updateLibDeleteSelectedBtn();
+ });
+ });
+ if (typeof updateLibDeleteSelectedBtn === 'function') updateLibDeleteSelectedBtn();
if (typeof renderPaginationControls === 'function') {
renderPaginationControls(document.getElementById('lib-grid-pagination'), libGridPage, totalPages, (p) => {
@@ -137,7 +157,7 @@ async function loadLibraryImages(opts) {
if (!grid) return;
if (opts && opts.resetPage) libGridPage = 0;
const q = document.getElementById('lib-filter')?.value?.trim() || '';
- const params = new URLSearchParams();
+ const params = typeof libFilterParams === 'function' ? libFilterParams() : new URLSearchParams();
if (selectedNode.id) params.set('node_id', String(selectedNode.id));
if (q) params.set('q', q);
params.set('limit', String(LIB_GRID_PER_PAGE));
@@ -155,11 +175,6 @@ async function loadLibraryImages(opts) {
return loadLibraryImages();
}
renderLibGrid();
- if (typeof loadAreaPairs === 'function') {
- const run = () => loadAreaPairs({ nodeId: selectedNode.id || null });
- if (typeof deferIdle === 'function') deferIdle(run);
- else setTimeout(run, 0);
- }
} catch (err) {
grid.innerHTML = `Could not load images: ${err.message}
`;
}
@@ -192,7 +207,7 @@ document.getElementById('btn-refresh-lib')?.addEventListener('click', async () =
const emailEl = document.getElementById('dda-user-email');
if (!emailEl) return;
try {
- const me = await ddaApi('GET', '/api/me');
+ const me = typeof ensureDdaMe === 'function' ? await ensureDdaMe() : await ddaApi('GET', '/api/me');
emailEl.textContent = me.email || '';
const avatarEl = document.getElementById('dda-user-avatar');
if (avatarEl) avatarEl.textContent = (me.full_name || me.email || '?').trim().charAt(0).toUpperCase();
diff --git a/static/js/dda/audit_log.js b/static/js/dda/audit_log.js
index d042d96cfc402983a43442c385bc6c752cf1e83b..4f7fadf3969af71aa1234e740c2e2b0b0fb9a188 100644
--- a/static/js/dda/audit_log.js
+++ b/static/js/dda/audit_log.js
@@ -9,6 +9,13 @@
let auditPage = 0;
+ function actionBadgeClass(action) {
+ const a = (action || '').toLowerCase();
+ if (a.includes('delete') || a.includes('remove') || a.includes('clear')) return 'is-danger';
+ if (a.includes('create') || a.includes('add') || a.includes('upload') || a.includes('login')) return 'is-ok';
+ return 'is-neutral';
+ }
+
function fillSelect(select, values, current) {
if (!select) return;
const placeholder = select.options[0];
@@ -70,7 +77,7 @@
${typeof formatDateIst === 'function' ? formatDateIst(r.timestamp) : escapeHtml(r.timestamp || '')}
${escapeHtml(r.user || '—')}
${escapeHtml(r.role || '—')}
- ${escapeHtml(r.action)}
+ ${escapeHtml(r.action)}
${escapeHtml(r.module || '—')}
${escapeHtml(r.newValue || r.oldValue || '')}
`).join('')}
diff --git a/static/js/dda/compare.js b/static/js/dda/compare.js
index eb1603446ce3e96fd4b0b315259866e01faa18cc..8cdc42834003611c0f74516ce477f6a84c76e2b5 100644
--- a/static/js/dda/compare.js
+++ b/static/js/dda/compare.js
@@ -29,6 +29,72 @@ function initShapeToggle() {
apply();
}
+// --- Sensitivity slider + Minimum Region validation --------------------
+function clampSensitivity(raw) {
+ const n = Number(raw);
+ return Number.isNaN(n) ? 0.45 : Math.max(0, Math.min(1, n));
+}
+function clampMinRegionArea(raw) {
+ const n = Math.round(Number(raw));
+ return Number.isNaN(n) ? 50 : Math.max(50, Math.min(10000, n));
+}
+
+function initDetectSettingsControls() {
+ const sens = document.getElementById('dda-detect-sensitivity');
+ const sensVal = document.getElementById('dda-detect-sensitivity-val');
+ if (sens && !sens.dataset.bound) {
+ sens.dataset.bound = '1';
+ const syncSensLabel = () => { if (sensVal) sensVal.textContent = clampSensitivity(sens.value).toFixed(2); };
+ sens.addEventListener('input', syncSensLabel);
+ syncSensLabel();
+ }
+
+ const minArea = document.getElementById('dda-detect-min-area');
+ const minAreaHint = document.getElementById('dda-detect-min-area-hint');
+ const decBtn = document.getElementById('dda-detect-min-area-dec');
+ const incBtn = document.getElementById('dda-detect-min-area-inc');
+ const MIN_AREA_STEP = 10;
+
+ function updateMinAreaStepperState() {
+ if (!minArea) return;
+ const v = clampMinRegionArea(minArea.value);
+ if (decBtn) decBtn.disabled = v <= 50;
+ if (incBtn) incBtn.disabled = v >= 10000;
+ }
+
+ function applyMinAreaValue(rawValue, { wasBlank = false } = {}) {
+ if (!minArea) return;
+ const before = wasBlank ? NaN : Number(rawValue);
+ const clamped = clampMinRegionArea(rawValue);
+ const changed = wasBlank || Number.isNaN(before) || before !== clamped;
+ minArea.value = String(clamped);
+ if (minAreaHint) {
+ if (changed) {
+ const reason = clamped === 50 ? 'minimum allowed' : clamped === 10000 ? 'maximum allowed' : 'valid range';
+ minAreaHint.textContent = `Adjusted to ${clamped} (${reason}).`;
+ minAreaHint.classList.remove('hidden');
+ } else {
+ minAreaHint.classList.add('hidden');
+ }
+ }
+ updateMinAreaStepperState();
+ }
+
+ if (minArea && !minArea.dataset.bound) {
+ minArea.dataset.bound = '1';
+ minArea.addEventListener('blur', () => {
+ applyMinAreaValue(minArea.value, { wasBlank: minArea.value.trim() === '' });
+ });
+ decBtn?.addEventListener('click', () => {
+ applyMinAreaValue(clampMinRegionArea(minArea.value) - MIN_AREA_STEP);
+ });
+ incBtn?.addEventListener('click', () => {
+ applyMinAreaValue(clampMinRegionArea(minArea.value) + MIN_AREA_STEP);
+ });
+ updateMinAreaStepperState();
+ }
+}
+
function compareFormatBytes(n) {
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
@@ -117,12 +183,25 @@ function setDetectMode(mode, { applyPair } = { applyPair: true }) {
? `Automatic: the system selects the oldest image as Before and the newest as After for the chosen area. Set the interval and time, then click Start schedule — it does not run when the app launches. Use Run automatic detection for a one-off run.`
: 'Manual: pick the Before and After images yourself (dropdowns, library cards, or drag-and-drop), then run change detection. Manual runs are not scheduled.';
}
- if (compareState.mode === 'automatic' && applyPair !== false && typeof applySelectedAutoPair === 'function') {
- applySelectedAutoPair();
+ if (compareState.mode === 'automatic') {
+ ensureDetectPairsLoaded({ applyPair: applyPair !== false });
+ } else if (applyPair !== false && typeof applySelectedAutoPair === 'function') {
+ // no-op for manual
}
updateRunButton();
}
+async function ensureDetectPairsLoaded({ applyPair } = {}) {
+ if (typeof loadAreaPairs !== 'function') return;
+ const nodeId = compareState.selectedNode?.id || null;
+ await loadAreaPairs({
+ nodeId,
+ includeUnpaired: true,
+ autoFillCompare: !!applyPair,
+ });
+ if (applyPair && typeof applySelectedAutoPair === 'function') applySelectedAutoPair();
+}
+
function initDetectMode() {
const wrap = document.getElementById('dda-detect-mode');
if (!wrap || wrap.dataset.bound) {
@@ -157,9 +236,10 @@ function renderSlotPreview(slotKey, selection) {
slot.classList.add('filled');
const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
+ const metaHtml = typeof buildImageMetaHtml === 'function' ? buildImageMetaHtml(selection) : '';
wrap.innerHTML = `
- ${escapeHtml(selection.label || selection.filename)}${selection.captureDate ? `${escapeHtml(String(selection.captureDate).slice(0, 10))} ` : ''}
`;
+ ${metaHtml || escapeHtml(selection.label || selection.filename)}
`;
if (slotKey === 't2') setupRoiDraw();
}
@@ -339,6 +419,18 @@ function setSlot(slotKey, img, options) {
filename: img.filename,
thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
captureDate: img.captureDate || null,
+ breadcrumb: img.breadcrumb || null,
+ nodePath: img.nodePath || null,
+ imageType: img.imageType || null,
+ format: img.format || null,
+ fileSizeBytes: img.fileSizeBytes || 0,
+ width: img.width || 0,
+ height: img.height || 0,
+ hasGeoref: !!img.hasGeoref,
+ uploadedBy: img.uploadedBy || null,
+ uploadedOn: img.uploadedOn || null,
+ villageName: img.villageName || null,
+ zoneName: img.zoneName || null,
};
if (slotKey === 't1') {
compareState.t1 = item;
@@ -509,14 +601,6 @@ async function loadCompareLibraryGrid(opts) {
ensureDdaState().libraryItems = page.items;
populateSelects(selectSourceItems(page.items));
renderCompareGrid(page.items, page.total);
- if (typeof loadAreaPairs === 'function' && !opts?.keepPage) {
- const run = () => loadAreaPairs({
- nodeId: compareState.selectedNode?.id || null,
- autoFillCompare: true,
- });
- if (typeof deferIdle === 'function') deferIdle(run);
- else setTimeout(run, 0);
- }
} catch (err) {
grid.innerHTML = `Could not load images: ${err.message}
`;
if (typeof showDdaError === 'function') showDdaError(err.message);
@@ -862,6 +946,42 @@ async function pollJobUntilDone(jobId) {
}
}
+/** If this user already has a job running/queued (e.g. they navigated away
+ * from /detect and came back, or reloaded the page) resume showing its live
+ * progress instead of silently presenting the idle "Ready to detect?" form
+ * — that blank state is exactly what makes a still-running job look dead
+ * and tempts a server restart, which actually does kill it. */
+async function resumeActiveJobIfAny() {
+ let jobs;
+ try {
+ jobs = typeof getDdaNotifyJobs === 'function'
+ ? await getDdaNotifyJobs()
+ : ((await ddaApi('GET', '/api/dda/jobs?limit=20')).jobs || []);
+ } catch (_) {
+ return;
+ }
+ const active = jobs.find((j) => j.status === 'running' || j.status === 'queued');
+ if (!active) return;
+
+ if (typeof showDdaSuccess === 'function') {
+ showDdaSuccess('Resuming progress for a detection already in progress…');
+ }
+ forEachRunButton((btn) => { btn.disabled = true; });
+ showDetectProgress();
+ try {
+ const job = await pollJobUntilDone(active.id);
+ if (job.jobId && typeof window.markDdaJobSeen === 'function') window.markDdaJobSeen(job.jobId);
+ showDetectResult(job.result);
+ if (typeof showDdaSuccess === 'function') showDdaSuccess('Detection complete.');
+ if (typeof loadReportsList === 'function') loadReportsList();
+ } catch (err) {
+ if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
+ } finally {
+ updateRunButton();
+ hideDetectProgress(2000);
+ }
+}
+
async function runLibraryDetection() {
if (!compareState.t1 || !compareState.t2) {
if (typeof showDdaError === 'function') showDdaError(
@@ -908,9 +1028,8 @@ async function runLibraryDetection() {
form.append('method', document.getElementById('dda-detect-method')?.value || 'AI-Based Deep Learning');
form.append('enable_registration', String(document.getElementById('dda-detect-registration')?.checked !== false));
form.append('enable_normalization', String(document.getElementById('dda-detect-normalization')?.checked !== false));
- form.append('detection_sensitivity', String(Math.max(0, Math.min(1, Number(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)))));
- const minArea = Number(document.getElementById('dda-detect-min-area')?.value ?? 150);
- if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
+ form.append('detection_sensitivity', String(clampSensitivity(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)));
+ form.append('min_region_area', String(clampMinRegionArea(document.getElementById('dda-detect-min-area')?.value ?? 150)));
const notifyCb = document.getElementById('dda-detect-notify');
const notifyEmail = document.getElementById('dda-detect-notify-email');
if (notifyCb?.checked && notifyEmail?.value?.trim()) {
@@ -944,6 +1063,7 @@ let compareInitialized = false;
function initCompareTab() {
initShapeToggle();
initDetectMode();
+ initDetectSettingsControls();
if (!compareInitialized) {
if (typeof registerCompareTreeSidebar === 'function') {
registerCompareTreeSidebar({
@@ -967,6 +1087,11 @@ function initCompareTab() {
updateRunButton();
loadCompareLibraryGrid();
if (typeof loadTree === 'function') loadTree().catch(() => {});
+ resumeActiveJobIfAny();
+ // Automatic mode loads pairs from setDetectMode; ?pair= on Manual still needs a fetch.
+ if (new URLSearchParams(window.location.search).get('pair') && compareState.mode !== 'automatic') {
+ ensureDetectPairsLoaded({ applyPair: false });
+ }
}
window.loadCompareLibraryGrid = loadCompareLibraryGrid;
diff --git a/static/js/dda/exception_log.js b/static/js/dda/exception_log.js
index d5e4e80c0114eb241ebed6ac01c112dcb6da48cc..22290217385874162b98d66ec4b22fa028cf419f 100644
--- a/static/js/dda/exception_log.js
+++ b/static/js/dda/exception_log.js
@@ -7,8 +7,8 @@
// ------------------------------------------------------------- Tabs ----
const TITLES = {
- exceptions: ['💥 Exception Log', 'System errors captured by the global exception handler'],
- applogs: ['📝 Application Logs', 'Live view of recent server events. Filter, download, or clear the buffer.'],
+ exceptions: ['Exception Log', 'System errors captured by the global exception handler'],
+ applogs: ['Application Logs', 'Live view of recent server events. Filter, download, or clear the buffer.'],
};
function activateTab(tab) {
@@ -106,7 +106,7 @@
wrap.innerHTML = `
- Timestamp (IST) Type Message Path Code Handled Details
+ Timestamp (IST) Type Message Path Code User Role Handled Details
${rows.map((r) => `
@@ -116,6 +116,8 @@
${escapeHtml(r.message || '')}
${escapeHtml(r.method || '')} ${escapeHtml(r.path || '')}
${escapeHtml(String(r.statusCode ?? ''))}
+ ${escapeHtml(r.userEmail || '—')}
+ ${escapeHtml(r.userRole || '—')}
${r.handled ? '✓ Yes' : '✗ No'}
View
`).join('')}
@@ -141,6 +143,7 @@
`${entry.type} — ${entry.statusCode} (${entry.handled ? 'handled' : 'unhandled'})\n` +
`${entry.method} ${entry.path}\n` +
`User: ${entry.userEmail || '—'}\n` +
+ `Role: ${entry.userRole || '—'}\n` +
`Time: ${entry.timestamp}\n\n${entry.message}\n\n${entry.traceback || ''}`;
} catch (err) {
content.textContent = err.message || 'Could not load exception details.';
diff --git a/static/js/dda/home.js b/static/js/dda/home.js
index eef7087768e1fbd7b1e05e64804496eb8dad4b1f..7621e7053ffa4130f7e21fe285e6a3d948df99dd 100644
--- a/static/js/dda/home.js
+++ b/static/js/dda/home.js
@@ -2,6 +2,35 @@
* change comparison, reports feed, and charts. Replaces the old
* greeting+3-cards Home page in place. */
+// ---------------------------------------------------------------- Tabs ----
+// Overview / Charts & Graphs / Breakdown / Image Library, one visible at a
+// time via small card-style tabs, so the dashboard doesn't force one long
+// scroll through every section and sub-section.
+const DASH_TABS = ['overview', 'charts', 'breakdown', 'library'];
+function activateDashTab(tab) {
+ DASH_TABS.forEach((t) => {
+ const panel = document.getElementById(`dash-tab-${t}`);
+ if (panel) panel.hidden = t !== tab;
+ });
+ document.querySelectorAll('#dda-dash-tab-switch .dda-dash-tab-card').forEach((card) => {
+ const active = card.dataset.tab === tab;
+ card.classList.toggle('is-active', active);
+ card.setAttribute('aria-selected', String(active));
+ });
+ // Chart.js lays a canvas out at 0x0 if it was created while its container
+ // had `hidden` set; force every existing chart to recompute now that its
+ // panel is actually visible. Covers both the Charts & Graphs canvases and
+ // the Image Library's format doughnut.
+ if (tab === 'charts' || tab === 'library') {
+ Object.values(dashCharts).forEach((chart) => {
+ try { chart.resize(); } catch (_) { /* chart may have been destroyed/replaced */ }
+ });
+ }
+}
+document.querySelectorAll('#dda-dash-tab-switch .dda-dash-tab-card').forEach((card) => {
+ card.addEventListener('click', () => activateDashTab(card.dataset.tab));
+});
+
function formatHomeDate(iso) {
return typeof formatDateIst === 'function' ? formatDateIst(iso) : (iso || '—');
}
@@ -16,22 +45,173 @@ function dashDelta(v, suffix) {
return `${sign}${typeof v === 'number' && suffix === '%' ? v.toFixed(2) : v}${suffix}`;
}
+// Fixed taxonomy every region is already classified into (see
+// app/dda/change_type_map.py) — same colors/order used everywhere a change
+// type shows up, so a color always means the same thing across the page.
+const CHANGE_TYPE_ORDER = ['New Construction', 'Demolition', 'Extension', 'Vegetation Change', 'Other'];
+const CHANGE_TYPE_COLORS = {
+ 'New Construction': '#f97316',
+ 'Demolition': '#dc2626',
+ 'Extension': '#2563eb',
+ 'Vegetation Change': '#16a34a',
+ 'Other': '#64748b',
+};
+function changeTypeSlug(t) {
+ return String(t || 'Other').replace(/\s+/g, '-');
+}
+/** Small colored pill for the single most common change type in a
+ * confirmed set of counts — a lightweight way to surface "what kind of
+ * change" without adding a full breakdown column to already-dense tables. */
+function topChangeTypePill(changeTypes) {
+ const entries = Object.entries(changeTypes || {});
+ if (!entries.length) return '— ';
+ entries.sort((a, b) => b[1] - a[1]);
+ const [type, count] = entries[0];
+ const total = entries.reduce((sum, [, c]) => sum + c, 0);
+ return `${escapeHtml(type)}${entries.length > 1 ? ` +${entries.length - 1}` : ''} `;
+}
+
+/** Every change type present, each its own pill with a count — used for a
+ * single report (Detection History), where "per report" detail matters more
+ * than the compact single-pill summary that's appropriate when a row is
+ * already an aggregate across several reports (Region-wise/Village-wise). */
+function allChangeTypePills(changeTypes) {
+ const entries = Object.entries(changeTypes || {});
+ if (!entries.length) return 'No regions classified ';
+ entries.sort((a, b) => b[1] - a[1]);
+ return `${entries.map(([type, count]) =>
+ `${escapeHtml(type)}: ${count} `
+ ).join('')}
`;
+}
+
+/** Stacked bar — one bar per region, one colored segment per change type —
+ * the only change-type chart on the dashboard: a separate system-wide
+ * doughnut was dropped since its totals are just these bars summed, and
+ * showing both read as the same information twice. */
+function renderChangeTypesByRegionChart(regions) {
+ const top = regions.slice(0, 10);
+ renderDashChart('dda-chart-change-types-region', {
+ type: 'bar',
+ data: {
+ labels: top.map((r) => r.zone),
+ datasets: CHANGE_TYPE_ORDER
+ .filter((t) => top.some((r) => (r.changeTypes || {})[t]))
+ .map((t) => ({
+ label: t,
+ data: top.map((r) => (r.changeTypes || {})[t] || 0),
+ backgroundColor: CHANGE_TYPE_COLORS[t],
+ })),
+ },
+ options: {
+ responsive: true,
+ plugins: { legend: { position: 'bottom' } },
+ scales: {
+ x: { stacked: true },
+ y: { stacked: true, beginAtZero: true, ticks: { precision: 0 } },
+ },
+ },
+ });
+}
+
const dashCharts = {};
+/** Current From/To date filter, as a URLSearchParams-ready query string
+ * fragment (without leading '?') — shared by every dashboard fetch except
+ * the trend chart, which has its own fixed month window. */
+function dashDateParams() {
+ const from = document.getElementById('dash-filter-from')?.value || '';
+ const to = document.getElementById('dash-filter-to')?.value || '';
+ const params = new URLSearchParams();
+ if (from) params.set('date_from', from);
+ if (to) params.set('date_to', to);
+ return params.toString();
+}
+
+function dashUrl(path) {
+ const qs = dashDateParams();
+ if (!qs) return path;
+ return path + (path.includes('?') ? '&' : '?') + qs;
+}
+
+/** Every dashboard chart goes through here so animation and hover behavior
+ * stay consistent without repeating options at each call site. Pass
+ * `onClick(index)` in config to make chart segments clickable — the cursor
+ * switches to a pointer only where a click actually does something. */
function renderDashChart(canvasId, config) {
const canvas = document.getElementById(canvasId);
if (!canvas || typeof Chart === 'undefined') return;
if (dashCharts[canvasId]) {
dashCharts[canvasId].destroy();
}
+ const onClick = config.onClick;
+ delete config.onClick;
+
+ // Rounded, hover-highlighted bars and a pop-out hover on doughnut slices —
+ // applied to every dataset that doesn't already set these, so each chart
+ // call site doesn't have to repeat the same styling.
+ (config.data?.datasets || []).forEach((ds) => {
+ if (config.type === 'bar' && ds.borderRadius === undefined) {
+ ds.borderRadius = 6;
+ ds.borderSkipped = false;
+ ds.hoverBackgroundColor = ds.hoverBackgroundColor || _dashHoverShade(ds.backgroundColor);
+ }
+ if (config.type === 'doughnut' && ds.hoverOffset === undefined) {
+ ds.hoverOffset = 14;
+ }
+ if (config.type === 'line' && ds.pointHoverRadius === undefined) {
+ ds.pointRadius = ds.pointRadius ?? 3;
+ ds.pointHoverRadius = 7;
+ }
+ });
+
+ const multiDataset = (config.data?.datasets || []).length > 1;
+ const incomingOptions = config.options || {};
+ const incomingPlugins = incomingOptions.plugins || {};
+ config.options = {
+ animation: { duration: 700, easing: 'easeOutQuart' },
+ interaction: { intersect: true, mode: 'nearest' },
+ ...incomingOptions,
+ plugins: {
+ ...incomingPlugins,
+ tooltip: {
+ padding: 10,
+ cornerRadius: 8,
+ titleFont: { weight: '600' },
+ displayColors: config.type === 'doughnut' || multiDataset,
+ ...incomingPlugins.tooltip,
+ },
+ },
+ };
+
+ if (onClick) {
+ config.options.onClick = (evt, elements) => {
+ if (elements.length) onClick(elements[0].index);
+ };
+ config.options.onHover = (evt, elements) => {
+ evt.native.target.style.cursor = elements.length ? 'pointer' : 'default';
+ };
+ }
dashCharts[canvasId] = new Chart(canvas, config);
}
+/** Darken/lighten a hex color a touch for a bar's hover state — cheap
+ * "interactive" affordance without needing a second color per dataset. */
+function _dashHoverShade(hex) {
+ if (typeof hex !== 'string' || !hex.startsWith('#') || hex.length !== 7) return hex;
+ const amt = -24;
+ const num = parseInt(hex.slice(1), 16);
+ const clamp = (v) => Math.max(0, Math.min(255, v));
+ const r = clamp(((num >> 16) & 0xff) + amt);
+ const g = clamp(((num >> 8) & 0xff) + amt);
+ const b = clamp((num & 0xff) + amt);
+ return `rgb(${r}, ${g}, ${b})`;
+}
+
async function loadHomeGreeting() {
const el = document.getElementById('dda-home-greeting');
if (!el) return;
try {
- const me = await ddaApi('GET', '/api/me');
+ const me = typeof ensureDdaMe === 'function' ? await ensureDdaMe() : await ddaApi('GET', '/api/me');
el.textContent = me.full_name ? `Welcome, ${me.full_name}` : 'Welcome';
} catch (_) {
// Not authenticated — the page-load redirect to /login should already
@@ -45,12 +225,14 @@ function renderDashKpis(summary) {
const el = document.getElementById('dda-dash-kpis');
if (!el) return;
const tiles = [
- ['Total Detection Runs', summary.totalRuns],
- ['Total Detected Changes', summary.totalDetectedChanges],
- ['Total Unclassified', summary.totalUnclassified],
+ ['Detection Runs', summary.totalRuns],
+ ['Regions Detected', summary.totalDetectedChanges],
+ ['Confirmed', summary.totalConfirmed],
+ ['False Positive', summary.totalFalsePositive],
+ ['Submitted', summary.totalSubmitted],
['Avg Change %', dashPct(summary.avgChangePercentage)],
- ['Regions Covered', summary.totalRegions],
- ['Villages Covered', summary.totalVillages],
+ ['Zones Covered', `${summary.totalRegions}${summary.zonesTotal ? `/${summary.zonesTotal}` : ''}`],
+ ['Villages Covered', `${summary.totalVillages}${summary.villagesTotal ? `/${summary.villagesTotal}` : ''}`],
];
el.innerHTML = tiles.map(([label, value]) => `
@@ -59,6 +241,26 @@ function renderDashKpis(summary) {
`).join('');
}
+/** Ranked list of system-wide change-type totals — same numbers as the
+ * "Change Types by Zone" stacked chart, just ranked instead of charted a
+ * second time (a duplicate doughnut used to sit here and was dropped). */
+function renderRankedChangeTypes(changeTypes) {
+ const el = document.getElementById('dda-dash-ranked-changetypes');
+ if (!el) return;
+ const entries = Object.entries(changeTypes || {});
+ if (!entries.length) {
+ el.innerHTML = 'No classified changes yet.
';
+ return;
+ }
+ entries.sort((a, b) => b[1] - a[1]);
+ el.innerHTML = `${entries.map(([type, count]) => `
+
+
+ ${escapeHtml(type)}
+ ${count}
+
`).join('')}
`;
+}
+
function renderDashLatest(run) {
const el = document.getElementById('dda-dash-latest');
if (!el) return;
@@ -78,6 +280,7 @@ function renderDashLatest(run) {
${run.regionsCount ?? '—'} regions
${run.unclassified ?? '—'} unclassified
+ ${allChangeTypePills(run.changeTypes)}
View Report
`;
@@ -85,37 +288,69 @@ function renderDashLatest(run) {
async function loadDashboardSummary() {
try {
- const summary = await ddaApi('GET', '/api/dda/dashboard/summary');
- dashSummaryCache = summary;
- const empty = document.getElementById('dda-dash-empty');
- const content = document.getElementById('dda-dash-content');
- if (!summary.totalRuns) {
- empty?.classList.remove('hidden');
- content?.classList.add('hidden');
- return summary;
- }
- empty?.classList.add('hidden');
- content?.classList.remove('hidden');
- renderDashKpis(summary);
- renderDashLatest(summary.latestRun);
- renderDashChart('dda-chart-status', {
- type: 'doughnut',
- data: {
- labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
- datasets: [{
- data: [summary.totalConfirmed, summary.totalFalsePositive, summary.totalSubmitted, summary.totalUnclassified],
- backgroundColor: ['#16a34a', '#dc2626', '#6358d4', '#ca8a04'],
- }],
- },
- options: { responsive: true, plugins: { legend: { position: 'bottom' } } },
- });
- return summary;
+ const summary = await ddaApi('GET', dashUrl('/api/dda/dashboard/summary'));
+ return applyDashboardSummary(summary);
} catch (err) {
document.getElementById('dda-dash-kpis').innerHTML = `Could not load overview: ${escapeHtml(err.message || 'error')}
`;
return null;
}
}
+function applyDashboardSummary(summary) {
+ dashSummaryCache = summary;
+ const empty = document.getElementById('dda-dash-empty');
+ // Only the Overview tab's KPI/Latest-result cards hide behind the empty
+ // state — Charts & Graphs, Breakdown, and Image Library stay reachable.
+ const overviewGrid = document.getElementById('dda-dash-overview-grid');
+ if (!summary.totalRuns) {
+ const filtered = !!dashDateParams();
+ const heading = empty?.querySelector('h3');
+ const body = empty?.querySelector('p');
+ const cta = empty?.querySelector('a');
+ if (heading) heading.textContent = filtered ? 'No runs in this date range' : 'No detections yet';
+ if (body) {
+ body.textContent = filtered
+ ? 'Try clearing the From/To filter or picking a wider range.'
+ : 'Run your first comparison to start populating the dashboard with detection statistics, region/village breakdowns, and reports.';
+ }
+ if (cta) cta.classList.toggle('hidden', filtered);
+ empty?.classList.remove('hidden');
+ overviewGrid?.classList.add('hidden');
+ return summary;
+ }
+ empty?.classList.add('hidden');
+ overviewGrid?.classList.remove('hidden');
+ renderDashKpis(summary);
+ renderDashLatest(summary.latestRun);
+ renderRankedChangeTypes(summary.changeTypes);
+ renderDashChart('dda-chart-status', {
+ type: 'doughnut',
+ data: {
+ labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
+ datasets: [{
+ data: [summary.totalConfirmed, summary.totalFalsePositive, summary.totalSubmitted, summary.totalUnclassified],
+ backgroundColor: ['#16a34a', '#dc2626', '#2563eb', '#ca8a04'],
+ }],
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: { position: 'bottom' },
+ tooltip: {
+ callbacks: {
+ label: (ctx) => {
+ const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
+ const pct = total ? Math.round((ctx.parsed / total) * 100) : 0;
+ return ` ${ctx.label}: ${ctx.parsed} (${pct}%)`;
+ },
+ },
+ },
+ },
+ },
+ });
+ return summary;
+}
+
function roleLabel(role) {
const r = String(role || '').trim();
return r ? r.charAt(0).toUpperCase() + r.slice(1) : 'Unknown';
@@ -159,25 +394,56 @@ function renderDashRolesTable(roles) {
async function loadDashboardRoles() {
const el = document.getElementById('dda-dash-roles');
try {
- const data = await ddaApi('GET', '/api/dda/dashboard/roles');
- const roles = data.roles || [];
- renderDashRolesTable(roles);
- renderDashChart('dda-chart-roles', {
- type: 'bar',
- data: {
- labels: roles.map((r) => roleLabel(r.role)),
- datasets: [{ label: 'Detections', data: roles.map((r) => r.runsCount), backgroundColor: '#16a34a' }],
- },
- options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
- });
+ const data = await ddaApi('GET', dashUrl('/api/dda/dashboard/roles'));
+ applyDashboardRoles(data.roles || []);
} catch (err) {
if (el) el.innerHTML = `Could not load role breakdown: ${escapeHtml(err.message || 'error')}
`;
}
}
+function applyDashboardRoles(roles) {
+ const el = document.getElementById('dda-dash-roles');
+ renderDashRolesTable(roles);
+ renderDashChart('dda-chart-roles', {
+ type: 'bar',
+ data: {
+ labels: roles.map((r) => roleLabel(r.role)),
+ datasets: [{ label: 'Detections', data: roles.map((r) => r.runsCount), backgroundColor: '#2d2b2b' }],
+ },
+ options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
+ });
+}
+
let dashRegionsCache = [];
let dashSelectedZone = '';
+/** Small horizontal stacked bar visualizing Confirmed/False Positive/
+ * Submitted/Unclassified as proportions of total regions — used wherever a
+ * classification breakdown would otherwise be four more table columns. */
+function classificationBar(r) {
+ const confirmed = r.confirmed || 0;
+ const falsePositive = r.falsePositive || 0;
+ const submitted = r.submitted || 0;
+ const unclassified = r.unclassified || 0;
+ const total = confirmed + falsePositive + submitted + unclassified;
+ if (!total) return '— ';
+ const seg = (val, cls) => (val > 0
+ ? ` `
+ : '');
+ const title = `${confirmed} confirmed · ${falsePositive} false positive · ${submitted} submitted · ${unclassified} unclassified`;
+ return `
+
+ ${seg(confirmed, 'confirmed')}${seg(falsePositive, 'fp')}${seg(submitted, 'submitted')}${seg(unclassified, 'unclassified')}
+
+ ${confirmed}/${falsePositive}/${submitted}/${unclassified} `;
+}
+
+function accuracyCell(rate) {
+ if (rate == null) return 'Not yet reviewed ';
+ const cls = rate >= 66 ? 'dda-accuracy-good' : rate >= 33 ? 'dda-accuracy-mid' : 'dda-accuracy-low';
+ return `${rate.toFixed(1)}% `;
+}
+
function renderDashRegionsTable(regions) {
const el = document.getElementById('dda-dash-regions');
if (!el) return;
@@ -189,10 +455,13 @@ function renderDashRegionsTable(regions) {
- Region
+ Zone
Runs
+ Images
Detected Changes
- Unclassified
+ Top Change Type
+ Classification (confirmed/FP/submitted/unclassified)
+ Accuracy
Villages
Avg Change %
Last Detection
@@ -203,8 +472,11 @@ function renderDashRegionsTable(regions) {
${escapeHtml(r.zone)}
${r.runsCount}
+ ${r.imageCount ?? 0}
${r.detectedChanges}
- ${r.unclassified}
+ ${topChangeTypePill(r.changeTypes)}
+ ${classificationBar(r)}
+ ${accuracyCell(r.confirmedRate)}
${r.villagesCount}
${dashPct(r.avgChangePercentage)}
${formatHomeDate(r.lastDetectionAt)}
@@ -218,12 +490,13 @@ function renderDashRegionsTable(regions) {
function selectDashRegion(zone) {
dashSelectedZone = dashSelectedZone === zone ? '' : zone;
+ dashSelectedVillageKey = '';
renderDashRegionsTable(dashRegionsCache);
loadDashboardVillages(dashSelectedZone);
const label = document.getElementById('dda-dash-village-filter-label');
const clearBtn = document.getElementById('btn-dash-village-clear');
if (dashSelectedZone) {
- label.textContent = `Filtered to region: ${dashSelectedZone}`;
+ label.textContent = `Filtered to zone: ${dashSelectedZone}`;
label.classList.remove('hidden');
clearBtn.classList.remove('hidden');
} else {
@@ -234,22 +507,30 @@ function selectDashRegion(zone) {
async function loadDashboardRegions() {
try {
- const data = await ddaApi('GET', '/api/dda/dashboard/regions');
- dashRegionsCache = data.regions || [];
- renderDashRegionsTable(dashRegionsCache);
- renderDashChart('dda-chart-regions', {
- type: 'bar',
- data: {
- labels: dashRegionsCache.map((r) => r.zone),
- datasets: [{ label: 'Detections', data: dashRegionsCache.map((r) => r.runsCount), backgroundColor: '#7a6aed' }],
- },
- options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
- });
+ const data = await ddaApi('GET', dashUrl('/api/dda/dashboard/regions'));
+ applyDashboardRegions(data.regions || []);
} catch (err) {
document.getElementById('dda-dash-regions').innerHTML = `Could not load regions: ${escapeHtml(err.message || 'error')}
`;
}
}
+function applyDashboardRegions(regions) {
+ dashRegionsCache = regions;
+ renderDashRegionsTable(dashRegionsCache);
+ renderDashChart('dda-chart-regions', {
+ type: 'bar',
+ data: {
+ labels: dashRegionsCache.map((r) => r.zone),
+ datasets: [{ label: 'Detections', data: dashRegionsCache.map((r) => r.runsCount), backgroundColor: '#ec3013' }],
+ },
+ options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
+ onClick: (i) => selectDashRegion(dashRegionsCache[i]?.zone),
+ });
+ renderChangeTypesByRegionChart(dashRegionsCache);
+}
+
+let dashSelectedVillageKey = '';
+
function renderDashVillagesTable(villages) {
const el = document.getElementById('dda-dash-villages');
if (!el) return;
@@ -261,30 +542,42 @@ function renderDashVillagesTable(villages) {
- Region
+ Zone
Village
Runs
+ Images
Detected Changes
- Unclassified
+ Top Change Type
+ Classification (confirmed/FP/submitted/unclassified)
+ Accuracy
Avg Change %
Last Detection
- ${villages.map((v) => `
-
+ ${villages.map((v) => {
+ const key = `${v.zone} ${v.village}`;
+ return `
+
${escapeHtml(v.zone)}
${escapeHtml(v.village)}
${v.runsCount}
+ ${v.imageCount ?? 0}
${v.detectedChanges}
- ${v.unclassified}
+ ${topChangeTypePill(v.changeTypes)}
+ ${classificationBar(v)}
+ ${accuracyCell(v.confirmedRate)}
${dashPct(v.avgChangePercentage)}
${formatHomeDate(v.lastDetectionAt)}
- `).join('')}
+ `;
+ }).join('')}
`;
el.querySelectorAll('[data-village]').forEach((row) => {
row.addEventListener('click', () => {
+ dashSelectedVillageKey = `${row.dataset.zone} ${row.dataset.village}`;
+ renderDashVillagesTable(villages);
loadDashboardLocation(row.dataset.zone, row.dataset.village);
document.getElementById('dda-dash-location-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
@@ -295,29 +588,51 @@ async function loadDashboardVillages(zone) {
const el = document.getElementById('dda-dash-villages');
if (el) el.innerHTML = 'Loading villages…
';
try {
- const qs = zone ? `?zone=${encodeURIComponent(zone)}` : '';
- const data = await ddaApi('GET', `/api/dda/dashboard/villages${qs}`);
- const villages = data.villages || [];
- renderDashVillagesTable(villages);
- const top = villages.slice(0, 15);
- renderDashChart('dda-chart-villages', {
- type: 'bar',
- data: {
- labels: top.map((v) => v.village),
- datasets: [{ label: 'Detections', data: top.map((v) => v.runsCount), backgroundColor: '#6358d4' }],
- },
- options: {
- indexAxis: 'y',
- responsive: true,
- plugins: { legend: { display: false } },
- scales: { x: { beginAtZero: true, ticks: { precision: 0 } } },
- },
- });
+ const params = new URLSearchParams(dashDateParams());
+ if (zone) params.set('zone', zone);
+ const qs = params.toString();
+ const data = await ddaApi('GET', `/api/dda/dashboard/villages${qs ? `?${qs}` : ''}`);
+ applyDashboardVillages(data.villages || []);
} catch (err) {
if (el) el.innerHTML = `Could not load villages: ${escapeHtml(err.message || 'error')}
`;
}
}
+function applyDashboardVillages(villages) {
+ renderDashVillagesTable(villages);
+ if (villages.length && !dashSelectedVillageKey) {
+ dashSelectedVillageKey = `${villages[0].zone} ${villages[0].village}`;
+ renderDashVillagesTable(villages);
+ loadDashboardLocation(villages[0].zone, villages[0].village);
+ }
+ const top = villages.slice(0, 15);
+ renderDashChart('dda-chart-villages', {
+ type: 'bar',
+ data: {
+ labels: top.map((v) => v.village),
+ datasets: [{ label: 'Detections', data: top.map((v) => v.runsCount), backgroundColor: '#e15b47' }],
+ },
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ plugins: { legend: { display: false } },
+ scales: { x: { beginAtZero: true, ticks: { precision: 0 } } },
+ },
+ onClick: (i) => {
+ const v = top[i];
+ if (!v) return;
+ dashSelectedVillageKey = `${v.zone} ${v.village}`;
+ renderDashVillagesTable(villages);
+ loadDashboardLocation(v.zone, v.village);
+ document.getElementById('dda-dash-location-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ },
+ });
+}
+
+function dashClassRow(label, r) {
+ return `${label} ${r.confirmed}/${r.falsePositive}/${r.submitted}/${r.unclassified} · ${accuracyCell(r.confirmedRate)}
`;
+}
+
function renderDashLocation(data) {
const el = document.getElementById('dda-dash-location');
if (!el) return;
@@ -331,32 +646,38 @@ function renderDashLocation(data) {
${data.runsCount} detection run${data.runsCount === 1 ? '' : 's'} recorded for this location.
-
Current
+
Recent Change Detection
${escapeHtml(data.latest.title)}
-
${dashPct(data.latest.changePercentage)} change · ${data.latest.regionsCount} regions
+
${dashPct(data.latest.changePercentage)} change · ${data.latest.regionsCount} regions
+ ${dashClassRow('Confirmed/FP/Submitted/Unclassified', data.latest)}
${formatHomeDate(data.latest.createdAt)}
View Report
-
Previous
+
Previous Change Detection
${data.previous ? `
${escapeHtml(data.previous.title)}
-
${dashPct(data.previous.changePercentage)} change · ${data.previous.regionsCount} regions
+
${dashPct(data.previous.changePercentage)} change · ${data.previous.regionsCount} regions
+ ${dashClassRow('Confirmed/FP/Submitted/Unclassified', data.previous)}
${formatHomeDate(data.previous.createdAt)}
View Report
` : '
No earlier run to compare against.
'}
-
Change vs previous
+
Recent vs Previous
${cmp ? `
${dashDelta(cmp.changePercentageDelta, '%')} change %
${dashDelta(cmp.regionsCountDelta, '')} regions
- ` : '
—
'}
+
${dashDelta(cmp.confirmedRateDelta, '%')} accuracy
+
${dashDelta(cmp.falsePositiveDelta, '')} false positives
+ ` : '
Run a second detection here to see a comparison.
'}
+ ${data.summary ? `${escapeHtml(data.summary)}
` : ''}
+ ${data.previous ? '
' : ''}
Detection History
- Date Title Change % Regions Unclassified
+ Date Title Change % Regions Change Types (this report) Classification (confirmed/FP/submitted/unclassified) Accuracy
${data.history.map((r) => `
@@ -364,11 +685,40 @@ function renderDashLocation(data) {
${escapeHtml(r.title)}
${dashPct(r.changePercentage)}
${r.regionsCount}
- ${r.unclassified}
+ ${allChangeTypePills(r.changeTypes)}
+ ${classificationBar(r)}
+ ${accuracyCell(r.confirmedRate)}
Report
`).join('')}
`;
+
+ if (data.previous) {
+ renderDashChart('dda-chart-location-compare', {
+ type: 'bar',
+ data: {
+ labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
+ datasets: [
+ {
+ label: 'Previous',
+ data: [data.previous.confirmed, data.previous.falsePositive, data.previous.submitted, data.previous.unclassified],
+ backgroundColor: '#d7d3d3',
+ },
+ {
+ label: 'Recent',
+ data: [data.latest.confirmed, data.latest.falsePositive, data.latest.submitted, data.latest.unclassified],
+ backgroundColor: '#ec3013',
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ animation: { duration: 500, easing: 'easeOutQuart' },
+ plugins: { legend: { position: 'bottom' } },
+ scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
+ },
+ });
+ }
}
async function loadDashboardLocation(zone, village) {
@@ -385,67 +735,99 @@ async function loadDashboardLocation(zone, village) {
async function loadDashboardTrend() {
try {
const data = await ddaApi('GET', '/api/dda/dashboard/trend?months=6');
- const buckets = data.buckets || [];
- renderDashChart('dda-chart-trend', {
- type: 'line',
- data: {
- labels: buckets.map((b) => b.label),
- datasets: [{
- label: 'Detection runs',
- data: buckets.map((b) => b.runsCount),
- borderColor: '#7a6aed',
- backgroundColor: 'rgba(122,106,237,0.15)',
- tension: 0.3,
- fill: true,
- }],
- },
- options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
- });
+ applyDashboardTrend(data.buckets || []);
} catch (_) {
// Chart stays absent — non-critical widget, other sections still work.
}
}
-async function loadDashboardReports() {
- const el = document.getElementById('dda-dash-reports');
+function applyDashboardTrend(buckets) {
+ renderDashChart('dda-chart-trend', {
+ type: 'line',
+ data: {
+ labels: buckets.map((b) => b.label),
+ datasets: [{
+ label: 'Detection runs',
+ data: buckets.map((b) => b.runsCount),
+ borderColor: '#ec3013',
+ backgroundColor: 'rgba(236,48,19,0.12)',
+ tension: 0.3,
+ fill: true,
+ }],
+ },
+ options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
+ onClick: (i) => {
+ const period = buckets[i]?.period; // "YYYY-MM"
+ if (!period) return;
+ const [y, m] = period.split('-').map(Number);
+ const lastDay = new Date(y, m, 0).getDate();
+ const from = document.getElementById('dash-filter-from');
+ const to = document.getElementById('dash-filter-to');
+ if (from) from.value = `${period}-01`;
+ if (to) to.value = `${period}-${String(lastDay).padStart(2, '0')}`;
+ loadDashboard();
+ },
+ });
+}
+
+let dashLiveJobTimer = null;
+
+function dashElapsedLabel(startedAtIso) {
+ if (!startedAtIso) return '';
+ const started = new Date(startedAtIso).getTime();
+ if (Number.isNaN(started)) return '';
+ const secs = Math.max(0, Math.floor((Date.now() - started) / 1000));
+ const m = Math.floor(secs / 60);
+ const s = secs % 60;
+ return `started ${m}m ${s}s ago`;
+}
+
+/** Banner for a detection job that's actually running right now — real
+ * progress/stage/elapsed time from GET /api/dda/jobs, not a placeholder.
+ * Polls every 15s while a job is live so it doesn't look stuck, and stops
+ * polling itself once nothing is running. */
+function renderDashLiveBanner(jobs) {
+ const el = document.getElementById('dda-dash-live-banner');
if (!el) return;
- try {
- const data = await ddaApi('GET', '/api/dda/dashboard/recent-reports?limit=8');
- const rows = data.reports || [];
- if (!rows.length) {
- el.innerHTML = 'No detection reports yet. Run a comparison on the Change Detection page.
';
- return;
+ const running = jobs.filter((j) => j.status === 'running');
+ const queued = jobs.filter((j) => j.status === 'queued');
+ if (!running.length) {
+ el.classList.add('hidden');
+ el.innerHTML = '';
+ if (dashLiveJobTimer) {
+ clearInterval(dashLiveJobTimer);
+ dashLiveJobTimer = null;
}
- el.innerHTML = `
-
-
-
- Date (IST)
- Title
- Area
- Change %
- Regions
- Confirmed
- Unclassified
-
-
-
-
- ${rows.map((r) => `
-
- ${formatHomeDate(r.createdAt)}
- ${escapeHtml(r.title)}
- ${escapeHtml(r.zone)} › ${escapeHtml(r.village)}
- ${dashPct(r.changePercentage)}
- ${r.regionsCount}
- ${r.confirmed}
- ${r.unclassified}
- Report
- `).join('')}
-
-
`;
- } catch (err) {
- el.innerHTML = `Could not load recent reports: ${escapeHtml(err.message || 'error')}
`;
+ return;
+ }
+ const job = running[0];
+ const loc = [job.zone, job.village].filter(Boolean).join(' · ');
+ const pct = Math.max(0, Math.min(100, Math.round(job.progressPct || 0)));
+ const meta = [job.method, job.progressStage, dashElapsedLabel(job.startedAt)].filter(Boolean).join(' · ');
+ el.classList.remove('hidden');
+ el.innerHTML = `
+
+
+
Detection job running${loc ? ` — ${escapeHtml(loc)}` : ''}
+
${escapeHtml(meta)}
+
+
+ ${queued.length ? `${queued.length} queued ` : ''}
+ View progress `;
+
+ if (!dashLiveJobTimer) {
+ dashLiveJobTimer = setInterval(loadDashboardLiveJob, 15000);
+ }
+}
+
+async function loadDashboardLiveJob() {
+ try {
+ const jobs = typeof getDdaNotifyJobs === 'function'
+ ? await getDdaNotifyJobs()
+ : ((await ddaApi('GET', '/api/dda/jobs?limit=20')).jobs || []);
+ renderDashLiveBanner((jobs || []).filter((j) => j.status === 'running' || j.status === 'queued'));
+ } catch (_) {
+ // Non-critical widget — leave banner as it was on a transient failure.
}
}
@@ -455,31 +837,118 @@ function markDashUpdated() {
el.textContent = `Updated ${formatHomeDate(new Date().toISOString())}`;
}
+async function loadDashboardLibraryStats() {
+ const kpiEl = document.getElementById('dda-dash-lib-kpis');
+ const locEl = document.getElementById('dda-dash-lib-locations');
+ try {
+ const lib = await ddaApi('GET', '/api/dda/dashboard/library-stats');
+
+ const tiles = [
+ ['Total Images', lib.totalImages],
+ ['Assigned to a Location', lib.assignedImages],
+ ['Unassigned', lib.unassignedImages],
+ ['Zones Covered', lib.zonesCovered],
+ ['Villages Covered', lib.villagesCovered],
+ ];
+ if (kpiEl) {
+ kpiEl.innerHTML = tiles.map(([label, value]) => `
+
+ ${escapeHtml(String(value ?? '—'))}
+ ${escapeHtml(label)}
+ `).join('');
+ }
+
+ renderDashChart('dda-chart-lib-format', {
+ type: 'doughnut',
+ data: {
+ labels: lib.byFormat.map((f) => f.format),
+ datasets: [{
+ data: lib.byFormat.map((f) => f.count),
+ backgroundColor: ['#2d2b2b', '#ec3013', '#d7d3d3', '#2563eb', '#16a34a', '#64748b'],
+ }],
+ },
+ options: { responsive: true, plugins: { legend: { position: 'bottom' } } },
+ });
+
+ if (locEl) {
+ const zoneRows = lib.byZone.length
+ ? lib.byZone.map((z) => ` ${escapeHtml(z.zone)} ${z.imageCount} `).join('')
+ : 'No images assigned to a zone yet. ';
+ const villageRows = lib.byVillage.length
+ ? lib.byVillage.slice(0, 20).map((v) => `${escapeHtml(v.zone)} ${escapeHtml(v.village)} ${v.imageCount} `).join('')
+ : 'No images assigned to a village yet. ';
+ locEl.innerHTML = `
+
+
+ Zone Images
+ ${zoneRows}
+
+
+ Zone Village Images
+ ${villageRows}
+
+
+ ${lib.unassignedImages ? `${lib.unassignedImages} image(s) have no zone/village tag yet — assign one from the Image Library upload form or an image's detail view.
` : ''}`;
+ }
+ } catch (err) {
+ if (kpiEl) kpiEl.innerHTML = `Could not load library stats: ${escapeHtml(err.message || 'error')}
`;
+ if (locEl) locEl.innerHTML = '';
+ }
+}
+
async function loadDashboard() {
dashSelectedZone = '';
+ dashSelectedVillageKey = '';
document.getElementById('dda-dash-village-filter-label')?.classList.add('hidden');
document.getElementById('btn-dash-village-clear')?.classList.add('hidden');
document.getElementById('dda-dash-location').innerHTML =
'Select a village above to compare its latest and previous detection results.
';
- const summary = await loadDashboardSummary();
- if (!summary || !summary.totalRuns) {
+ // Independent of detection history — the library can (and usually does)
+ // have images even with zero runs yet, so this isn't gated behind
+ // summary.totalRuns like the sections below it.
+ loadDashboardLibraryStats();
+ loadDashboardLiveJob();
+
+ try {
+ const params = new URLSearchParams(dashDateParams());
+ params.set('months', '6');
+ const qs = params.toString();
+ const data = await ddaApi('GET', `/api/dda/dashboard/bootstrap${qs ? `?${qs}` : ''}`);
+ const summary = applyDashboardSummary(data.summary || {});
+ if (!summary || !summary.totalRuns) {
+ // Charts & Graphs / Breakdown stay reachable via tabs — clear placeholders.
+ renderDashRolesTable([]);
+ dashRegionsCache = [];
+ renderDashRegionsTable([]);
+ renderDashVillagesTable([]);
+ markDashUpdated();
+ return;
+ }
+ applyDashboardRoles(data.roles || []);
+ applyDashboardRegions(data.regions || []);
+ applyDashboardVillages(data.villages || []);
+ applyDashboardTrend((data.trend && data.trend.buckets) || []);
markDashUpdated();
- return;
+ } catch (err) {
+ document.getElementById('dda-dash-kpis').innerHTML = `Could not load overview: ${escapeHtml(err.message || 'error')}
`;
}
- await Promise.all([
- loadDashboardRoles(),
- loadDashboardRegions(),
- loadDashboardVillages(''),
- loadDashboardTrend(),
- loadDashboardReports(),
- ]);
- markDashUpdated();
}
document.getElementById('btn-dash-refresh')?.addEventListener('click', loadDashboard);
document.getElementById('btn-dash-village-clear')?.addEventListener('click', () => selectDashRegion(''));
+// Date filter applies on an explicit Search click, not on every date pick —
+// picking From then To would otherwise fire two reloads back to back.
+document.getElementById('btn-dash-filter-apply')?.addEventListener('click', loadDashboard);
+document.getElementById('btn-dash-filter-clear')?.addEventListener('click', () => {
+ const from = document.getElementById('dash-filter-from');
+ const to = document.getElementById('dash-filter-to');
+ if (from) from.value = '';
+ if (to) to.value = '';
+ loadDashboard();
+});
+
document.addEventListener('DOMContentLoaded', () => {
loadHomeGreeting();
loadDashboard();
diff --git a/static/js/dda/library.js b/static/js/dda/library.js
index 6d0d3cbd75bb83cf4b88742ffc3901249fef9576..8bbf459a83b69d02515cc6192734ef405993877c 100644
--- a/static/js/dda/library.js
+++ b/static/js/dda/library.js
@@ -115,14 +115,17 @@ function openLibraryImageViewer(img) {
revokeViewerObjectUrl();
libraryViewerState.image = img;
title.textContent = img.filename || img.imageName || 'Image';
- const parts = [
- img.breadcrumb || img.nodePath || '',
- img.imageType || '',
- typeof formatBytes === 'function' ? formatBytes(img.fileSizeBytes) : '',
- img.width && img.height ? `${img.width}×${img.height}` : '',
- img.hasGeoref ? 'georeferenced' : '',
- ].filter(Boolean);
- meta.textContent = parts.join(' · ');
+ meta.innerHTML = typeof buildImageMetaHtml === 'function'
+ ? buildImageMetaHtml(img)
+ : [
+ img.breadcrumb || img.nodePath || '',
+ [img.villageName, img.zoneName].filter(Boolean).join(', '),
+ img.captureDate ? `Capture ${String(img.captureDate).slice(0, 10)}` : '',
+ img.year != null ? `Year ${img.year}` : '',
+ img.imageType || '',
+ img.source || '',
+ typeof formatBytes === 'function' ? formatBytes(img.fileSizeBytes) : '',
+ ].filter(Boolean).join(' · ');
imageEl.alt = img.filename || 'Library image';
imageEl.removeAttribute('src');
setViewerStatus('Loading preview…', false);
@@ -274,22 +277,116 @@ function uploadWithProgress(url, formData, onProgress) {
});
}
-const LIBRARY_IMAGE_EXTS = ['.tif', '.tiff', '.png', '.jpg', '.jpeg'];
+const IMAGE_TYPE_EXTENSIONS = {
+ GeoTIFF: ['.tif', '.tiff'],
+ PNG: ['.png'],
+ JPEG: ['.jpg', '.jpeg'],
+};
+
let pendingUploadFiles = [];
+/** @type {{file: File, serverName: string, selected: boolean}[]} */
+let pendingUploadEntries = [];
+
+function currentUploadImageType() {
+ return document.getElementById('upload-image-type')?.value || 'GeoTIFF';
+}
+
+function allowedExtsForType(imageType) {
+ return IMAGE_TYPE_EXTENSIONS[imageType] || IMAGE_TYPE_EXTENSIONS.GeoTIFF;
+}
+
+function fileExtension(name) {
+ const n = String(name || '');
+ const i = n.lastIndexOf('.');
+ if (i < 0) return '';
+ return n.slice(i).toLowerCase();
+}
+
+function formatAllowedExts(exts) {
+ if (exts.length === 1) return exts[0];
+ if (exts.length === 2) return `${exts[0]} or ${exts[1]}`;
+ return `${exts.slice(0, -1).join(', ')} or ${exts[exts.length - 1]}`;
+}
+
+function formatRejectedNames(names) {
+ const list = names.slice(0, 3);
+ const more = names.length - list.length;
+ const joined = list.map((n) => `"${n}"`).join(', ');
+ return more > 0 ? `${joined}, and ${more} more` : joined;
+}
+
+function setUploadTypeError(msg) {
+ const el = document.getElementById('upload-type-error');
+ if (el) {
+ el.textContent = msg || '';
+ el.classList.toggle('hidden', !msg);
+ }
+ if (msg) showDdaError?.(msg);
+ else hideDdaError?.();
+}
+
+function clearUploadTypeError() {
+ setUploadTypeError('');
+}
+
+function splitFilesForImageType(files, imageType) {
+ const allowed = allowedExtsForType(imageType);
+ const accepted = [];
+ const rejected = [];
+ Array.from(files || []).forEach((file) => {
+ const name = file.name || '';
+ if (!name || name.startsWith('.') || name.toLowerCase() === '.ds_store') {
+ rejected.push(file);
+ return;
+ }
+ if ((file._relativePath || file.webkitRelativePath || '').toLowerCase().includes('__macosx')) {
+ rejected.push(file);
+ return;
+ }
+ const ext = fileExtension(name);
+ if (!ext || !allowed.includes(ext)) rejected.push(file);
+ else accepted.push(file);
+ });
+ return { accepted, rejected };
+}
+
+function rejectMessage(imageType, rejected, { typeChanged = false } = {}) {
+ const allowed = allowedExtsForType(imageType);
+ const names = rejected.map((f) => f.name || 'file');
+ const named = formatRejectedNames(names);
+ const allow = formatAllowedExts(allowed);
+ if (typeChanged) {
+ return `Image type changed to ${imageType}. ${rejected.length} selected file${rejected.length === 1 ? ' is' : 's are'} not ${allow} and ${rejected.length === 1 ? 'was' : 'were'} removed: ${named}.`;
+ }
+ if (rejected.length === 1) {
+ const ext = fileExtension(rejected[0].name);
+ if (!ext) {
+ return `Image type ${imageType} only allows ${allow}. "${rejected[0].name}" has no supported extension and was not added.`;
+ }
+ return `Image type ${imageType} only allows ${allow}. "${rejected[0].name}" was not added.`;
+ }
+ return `Image type ${imageType} only allows ${allow}. ${rejected.length} files were not added: ${named}.`;
+}
+
+function syncUploadAcceptAttribute() {
+ const type = currentUploadImageType();
+ const accept = allowedExtsForType(type).join(',');
+ const fileInput = document.getElementById('upload-file');
+ const folderInput = document.getElementById('upload-folder');
+ if (fileInput) fileInput.accept = accept;
+ if (folderInput) folderInput.accept = accept;
+ const hint = document.getElementById('upload-accept-hint');
+ if (hint) {
+ hint.textContent = `${type} only: ${formatAllowedExts(allowedExtsForType(type))}. Multiple files and nested folders are supported.`;
+ }
+}
function isLibraryImageFile(file) {
const name = (file.name || '').toLowerCase();
if (!name || name.startsWith('.') || name === '.ds_store') return false;
if ((file._relativePath || file.webkitRelativePath || '').includes('__macosx')) return false;
- return LIBRARY_IMAGE_EXTS.some((ext) => name.endsWith(ext));
-}
-
-function guessImageType(file) {
- const ext = (file.name.split('.').pop() || '').toLowerCase();
- if (ext === 'tif' || ext === 'tiff') return 'GeoTIFF';
- if (ext === 'png') return 'PNG';
- if (ext === 'jpg' || ext === 'jpeg') return 'JPEG';
- return document.getElementById('upload-image-type')?.value || 'GeoTIFF';
+ const allowed = allowedExtsForType(currentUploadImageType());
+ return allowed.includes(fileExtension(name));
}
function relativeUploadPath(file) {
@@ -312,24 +409,237 @@ function fileTooLarge(file) {
function updateUploadSummary() {
const el = document.getElementById('upload-file-summary');
if (!el) return;
- const images = pendingUploadFiles.filter(isLibraryImageFile);
- const skipped = pendingUploadFiles.length - images.length;
- if (!pendingUploadFiles.length) {
+ const n = pendingUploadEntries.length;
+ if (!n) {
el.textContent = 'No files selected.';
return;
}
- el.textContent = `${images.length} image${images.length === 1 ? '' : 's'} ready` +
- (skipped ? ` · ${skipped} skipped` : '');
+ el.textContent = `${n} image${n === 1 ? '' : 's'} ready for ${currentUploadImageType()}.`;
}
-function setPendingUploadFiles(files) {
- pendingUploadFiles = Array.from(files || []);
+function renderUploadFileList() {
+ const wrap = document.getElementById('upload-file-list-wrap');
+ const tbody = document.getElementById('upload-file-tbody');
+ if (!wrap || !tbody) return;
+ if (!pendingUploadEntries.length) {
+ wrap.classList.add('hidden');
+ tbody.innerHTML = '';
+ return;
+ }
+ wrap.classList.remove('hidden');
+ const type = currentUploadImageType();
+ tbody.innerHTML = pendingUploadEntries.map((entry, idx) => `
+
+
+ ${escapeHtml(entry.file.name)}
+
+ ${escapeHtml(type)}
+ ${typeof formatBytes === 'function' ? formatBytes(entry.file.size) : entry.file.size}
+ `).join('');
+
+ tbody.querySelectorAll('.upload-file-check').forEach((cb) => {
+ cb.addEventListener('change', () => {
+ const idx = parseInt(cb.closest('tr').dataset.idx, 10);
+ if (pendingUploadEntries[idx]) pendingUploadEntries[idx].selected = cb.checked;
+ });
+ });
+ tbody.querySelectorAll('.upload-server-name').forEach((input) => {
+ input.addEventListener('change', () => {
+ const idx = parseInt(input.closest('tr').dataset.idx, 10);
+ if (pendingUploadEntries[idx]) {
+ const name = (input.value || '').trim() || pendingUploadEntries[idx].file.name;
+ pendingUploadEntries[idx].serverName = name.split(/[/\\]/).pop();
+ input.value = pendingUploadEntries[idx].serverName;
+ }
+ });
+ });
+}
+
+function setPendingUploadFiles(files, { append = false } = {}) {
+ const type = currentUploadImageType();
+ const { accepted, rejected } = splitFilesForImageType(files, type);
+ if (rejected.length) setUploadTypeError(rejectMessage(type, rejected));
+ else clearUploadTypeError();
+
+ const entries = accepted.map((file) => ({
+ file,
+ serverName: (file.name || 'upload').split(/[/\\]/).pop(),
+ selected: true,
+ }));
+ pendingUploadEntries = append ? pendingUploadEntries.concat(entries) : entries;
+ pendingUploadFiles = pendingUploadEntries.map((e) => e.file);
updateUploadSummary();
+ renderUploadFileList();
+}
+
+function revalidatePendingForImageType({ typeChanged = false } = {}) {
+ const type = currentUploadImageType();
+ syncUploadAcceptAttribute();
+ if (!pendingUploadEntries.length) {
+ clearUploadTypeError();
+ updateUploadSummary();
+ renderUploadFileList();
+ suggestSourceFromType();
+ return;
+ }
+ const files = pendingUploadEntries.map((e) => e.file);
+ const { accepted, rejected } = splitFilesForImageType(files, type);
+ if (rejected.length) {
+ const keepNames = new Set(accepted.map((f) => f));
+ pendingUploadEntries = pendingUploadEntries.filter((e) => keepNames.has(e.file));
+ pendingUploadFiles = pendingUploadEntries.map((e) => e.file);
+ setUploadTypeError(rejectMessage(type, rejected, { typeChanged: true }));
+ } else {
+ clearUploadTypeError();
+ }
+ updateUploadSummary();
+ renderUploadFileList();
+ suggestSourceFromType();
+}
+
+function suggestSourceFromType() {
+ const sourceSel = document.getElementById('upload-source');
+ if (!sourceSel || sourceSel.dataset.userPicked === '1') return;
+ const type = currentUploadImageType();
+ if (type === 'Satellite') sourceSel.value = 'satellite';
+ else if (type === 'Drone') sourceSel.value = 'drone';
+}
+
+function populateUploadYearOptions() {
+ const sel = document.getElementById('upload-year');
+ const filterSel = document.getElementById('lib-filter-year');
+ if (!sel) return;
+ const current = new Date().getFullYear();
+ const years = [];
+ for (let y = current; y >= current - 15; y--) years.push(y);
+ const prev = sel.value;
+ sel.innerHTML = '— Select year — ' +
+ years.map((y) => `${y} `).join('');
+ if (prev) sel.value = prev;
+ if (filterSel) {
+ const fprev = filterSel.value;
+ filterSel.innerHTML = 'All ' +
+ years.map((y) => `${y} `).join('');
+ if (fprev) filterSel.value = fprev;
+ }
+}
+
+const uploadLocations = { zones: [], villages: [] };
+
+function renderUploadVillageOptions() {
+ const villageSel = document.getElementById('upload-village');
+ if (!villageSel) return;
+ const zoneId = document.getElementById('upload-zone')?.value || '';
+ const previous = villageSel.value;
+ const list = zoneId
+ ? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
+ : uploadLocations.villages;
+ const empty = zoneId ? '— Select village —' : '— Select village (or pick a zone first) —';
+ villageSel.innerHTML = `${empty} ` + list.map((v) => {
+ const label = zoneId ? v.name : `${v.name} · ${v.zoneName || ''}`;
+ return `${escapeHtml(label)} `;
+ }).join('');
+ if (previous && list.some((v) => String(v.id) === String(previous))) villageSel.value = previous;
+}
+
+function renderLibFilterLocationOptions() {
+ const zoneSel = document.getElementById('lib-filter-zone');
+ const villageSel = document.getElementById('lib-filter-village');
+ if (zoneSel) {
+ const prev = zoneSel.value;
+ zoneSel.innerHTML = 'All ' + uploadLocations.zones
+ .map((z) => `${escapeHtml(z.name)} `).join('');
+ if (prev) zoneSel.value = prev;
+ }
+ if (villageSel) {
+ const zoneId = zoneSel?.value || '';
+ const prev = villageSel.value;
+ const list = zoneId
+ ? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
+ : uploadLocations.villages;
+ villageSel.innerHTML = 'All ' + list
+ .map((v) => `${escapeHtml(v.name)} `).join('');
+ if (prev) villageSel.value = prev;
+ }
+}
+
+function markUploadLocationPicked() {
+ const zoneSel = document.getElementById('upload-zone');
+ if (zoneSel) zoneSel.dataset.userPicked = '1';
+}
+
+function suggestUploadLocationFromFolder() {
+ const zoneSel = document.getElementById('upload-zone');
+ const villageSel = document.getElementById('upload-village');
+ if (!zoneSel || zoneSel.dataset.userPicked === '1') return;
+ const path = document.getElementById('upload-node')?.selectedOptions?.[0]?.textContent || '';
+ const segments = path.split(/[/\\]/).map((s) => s.trim().toLowerCase()).filter(Boolean);
+ if (!segments.length) return;
+ const zone = uploadLocations.zones.find((z) => segments.includes(String(z.name || '').toLowerCase()));
+ if (zone) zoneSel.value = String(zone.id);
+ renderUploadVillageOptions();
+ const matches = uploadLocations.villages.filter((v) => {
+ if (!segments.includes(String(v.name || '').toLowerCase())) return false;
+ if (zone && String(v.zoneId) !== String(zone.id)) return false;
+ return true;
+ });
+ if (matches.length === 1 && villageSel) {
+ if (!zoneSel.value) zoneSel.value = String(matches[0].zoneId);
+ renderUploadVillageOptions();
+ villageSel.value = String(matches[0].id);
+ }
+ // Suggest year from folder path segments
+ const yearSel = document.getElementById('upload-year');
+ if (yearSel && !yearSel.value) {
+ const yearSeg = segments.find((s) => /^\d{4}$/.test(s) && (s.startsWith('19') || s.startsWith('20')));
+ if (yearSeg) yearSel.value = yearSeg;
+ }
+}
+
+async function loadUploadLocations() {
+ const zoneSel = document.getElementById('upload-zone');
+ if (!zoneSel) return;
+ try {
+ const data = await ddaApi('GET', '/api/dda/masters/options');
+ const zoneNames = new Map((data.zones || []).map((z) => [z.id, z.name]));
+ uploadLocations.zones = data.zones || [];
+ uploadLocations.villages = (data.villages || []).map((v) => ({
+ ...v, zoneName: zoneNames.get(v.zoneId) || '',
+ }));
+ } catch (_) {
+ return;
+ }
+ const previousZone = zoneSel.value;
+ zoneSel.innerHTML = '— Select zone — ' + uploadLocations.zones
+ .map((z) => `${escapeHtml(z.name)} `).join('');
+ if (previousZone) zoneSel.value = previousZone;
+ renderUploadVillageOptions();
+ renderLibFilterLocationOptions();
+ fillBulkZoneOptions();
+
+ if (!zoneSel.dataset.bound) {
+ zoneSel.dataset.bound = '1';
+ zoneSel.addEventListener('change', () => {
+ markUploadLocationPicked();
+ renderUploadVillageOptions();
+ });
+ document.getElementById('upload-village')?.addEventListener('change', (e) => {
+ markUploadLocationPicked();
+ const village = uploadLocations.villages.find((v) => String(v.id) === String(e.target.value));
+ if (village) zoneSel.value = String(village.zoneId);
+ renderUploadVillageOptions();
+ if (village) document.getElementById('upload-village').value = String(village.id);
+ });
+ document.getElementById('upload-node')?.addEventListener('change', suggestUploadLocationFromFolder);
+ }
}
function setUploadTargetNode(nodeId) {
const sel = document.getElementById('upload-node');
- if (sel && nodeId) sel.value = String(nodeId);
+ if (sel && nodeId) {
+ sel.value = String(nodeId);
+ sel.dispatchEvent(new Event('change'));
+ }
document.getElementById('upload-card')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
@@ -346,24 +656,80 @@ async function ensureFolderPath(parentId, segments) {
return current;
}
-async function uploadOneLibraryFile(nodeId, file, { onProgress } = {}) {
- const oversized = fileTooLarge(file);
- if (oversized) throw new Error(oversized);
- const form = new FormData();
- form.append('file', file);
- form.append('image_type', document.getElementById('upload-image-type')?.value || guessImageType(file));
+function appendSharedUploadFields(form) {
+ form.append('image_type', currentUploadImageType());
form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
const manualBounds = document.getElementById('upload-manual-bounds')?.value?.trim();
if (manualBounds) form.append('manual_bounds', manualBounds);
+ const zoneId = document.getElementById('upload-zone')?.value || '';
+ const villageId = document.getElementById('upload-village')?.value || '';
+ if (zoneId) form.append('zone_id', zoneId);
+ if (villageId) form.append('village_id', villageId);
+ const year = document.getElementById('upload-year')?.value || '';
+ if (year) form.append('year', year);
+ const source = document.getElementById('upload-source')?.value || '';
+ if (source) form.append('source', source);
+}
+
+async function uploadOneLibraryFile(nodeId, file, serverName, { onProgress } = {}) {
+ const oversized = fileTooLarge(file);
+ if (oversized) throw new Error(oversized);
+ const form = new FormData();
+ form.append('file', file, serverName || file.name);
+ form.append('server_filename', serverName || file.name);
+ appendSharedUploadFields(form);
return uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload`, form, onProgress);
}
-async function uploadLibraryFiles(files, { nodeId, preserveFolders = true } = {}) {
+async function uploadBulkLibraryFiles(nodeId, entries, { onProgress } = {}) {
+ const form = new FormData();
+ entries.forEach((entry) => {
+ form.append('files', entry.file, entry.serverName || entry.file.name);
+ form.append('filenames', entry.serverName || entry.file.name);
+ });
+ appendSharedUploadFields(form);
+ return uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload-bulk`, form, onProgress);
+}
+
+async function uploadLibraryFiles(files, { nodeId, preserveFolders = true, entries = null } = {}) {
if (uploadLibraryFiles._busy) return 0;
- const list = Array.from(files || []).filter(isLibraryImageFile);
- if (!list.length) throw new Error('No GeoTIFF, PNG, or JPEG files to upload.');
+ // Every upload path (drag-drop, file picker, form submit) funnels through
+ // here, so this is the one place to enforce it: a zone and village are
+ // required, full stop — no more images landing as "Unassigned".
+ const zoneId = document.getElementById('upload-zone')?.value || '';
+ const villageId = document.getElementById('upload-village')?.value || '';
+ if (!zoneId || !villageId) {
+ const msg = 'Select a zone and village before uploading — every image needs a location.';
+ showDdaError?.(msg);
+ throw new Error(msg);
+ }
+ const type = currentUploadImageType();
+ let workEntries = entries;
+ if (!workEntries) {
+ const { accepted, rejected } = splitFilesForImageType(files, type);
+ if (rejected.length) {
+ setUploadTypeError(rejectMessage(type, rejected));
+ if (!accepted.length) throw new Error(rejectMessage(type, rejected));
+ }
+ workEntries = accepted.map((file) => ({
+ file,
+ serverName: (file.name || 'upload').split(/[/\\]/).pop(),
+ selected: true,
+ }));
+ }
+ workEntries = workEntries.filter((e) => e.selected !== false);
+ if (!workEntries.length) throw new Error(`No ${type} files to upload.`);
+
+ // Final gate before POST
+ const recheck = splitFilesForImageType(workEntries.map((e) => e.file), type);
+ if (recheck.rejected.length) {
+ const msg = `Cannot upload. Image type ${type} only allows ${formatAllowedExts(allowedExtsForType(type))}.`;
+ setUploadTypeError(msg);
+ throw new Error(msg);
+ }
+
const dest = nodeId || document.getElementById('upload-node')?.value;
- const needsFolders = preserveFolders && list.some((f) => relativeUploadPath(f).includes('/'));
+ const needsFolders = preserveFolders && workEntries.some((e) => relativeUploadPath(e.file).includes('/'));
const canCreateFolders = typeof hasPermission === 'function'
? hasPermission(window.ddaPermissions, 'library', 'create')
: window.ddaState?.userRole === 'admin';
@@ -383,10 +749,12 @@ async function uploadLibraryFiles(files, { nodeId, preserveFolders = true } = {}
const folderCache = new Map();
let ok = 0;
const errors = [];
+ const storedNames = [];
try {
- for (let i = 0; i < list.length; i++) {
- const file = list[i];
- const rel = relativeUploadPath(file);
+ // Group by destination folder for bulk when no nested folders, or multiple files same dest
+ const byTarget = new Map();
+ for (const entry of workEntries) {
+ const rel = relativeUploadPath(entry.file);
const parts = rel.split('/').filter(Boolean);
const dirParts = parts.length > 1 ? parts.slice(0, -1) : [];
let targetId = dest ? Number(dest) : null;
@@ -398,33 +766,73 @@ async function uploadLibraryFiles(files, { nodeId, preserveFolders = true } = {}
targetId = folderCache.get(key);
}
if (!targetId) throw new Error('Select a destination folder.');
- if (progLabel) {
- progLabel.textContent = `Uploading ${i + 1} of ${list.length}: ${file.name}`;
- }
- if (progFill) progFill.style.width = `${Math.round((i / list.length) * 100)}%`;
- try {
- await uploadOneLibraryFile(targetId, file, {
- onProgress: (loaded, total) => {
- const filePct = total ? loaded / total : 1;
- const overall = ((i + filePct) / list.length) * 100;
- if (progFill) progFill.style.width = `${Math.round(overall)}%`;
- },
- });
- ok += 1;
- } catch (err) {
- errors.push(`${file.name}: ${err.message || 'failed'}`);
+ if (!byTarget.has(targetId)) byTarget.set(targetId, []);
+ byTarget.get(targetId).push(entry);
+ }
+
+ let done = 0;
+ const total = workEntries.length;
+ for (const [targetId, group] of byTarget.entries()) {
+ if (group.length === 1) {
+ const entry = group[0];
+ if (progLabel) progLabel.textContent = `Uploading ${done + 1} of ${total}: ${entry.serverName}`;
+ try {
+ const res = await uploadOneLibraryFile(targetId, entry.file, entry.serverName, {
+ onProgress: (loaded, tot) => {
+ const filePct = tot ? loaded / tot : 1;
+ if (progFill) progFill.style.width = `${Math.round(((done + filePct) / total) * 100)}%`;
+ },
+ });
+ ok += 1;
+ if (res?.image?.filename) storedNames.push(res.image.filename);
+ } catch (err) {
+ errors.push(`${entry.serverName}: ${err.message || 'failed'}`);
+ }
+ done += 1;
+ } else {
+ if (progLabel) progLabel.textContent = `Uploading ${group.length} files…`;
+ try {
+ const res = await uploadBulkLibraryFiles(targetId, group, {
+ onProgress: (loaded, tot) => {
+ const filePct = tot ? loaded / tot : 1;
+ if (progFill) progFill.style.width = `${Math.round(((done + filePct * group.length) / total) * 100)}%`;
+ },
+ });
+ ok += (res.uploaded || []).length;
+ (res.uploaded || []).forEach((img) => {
+ if (img.filename) storedNames.push(img.filename);
+ });
+ (res.failed || []).forEach((f) => errors.push(`${f.filename}: ${f.error}`));
+ } catch (err) {
+ errors.push(err.message || 'Bulk upload failed');
+ }
+ done += group.length;
}
}
if (progFill) progFill.style.width = '100%';
- if (ok && typeof window.ddaState?.rescan === 'function') await window.ddaState.rescan();
if (errors.length && ok) {
showDdaError?.(`${ok} uploaded, ${errors.length} failed. ${errors[0]}`);
} else if (errors.length) {
throw new Error(errors[0]);
} else {
- showDdaSuccess?.(`Uploaded ${ok} image${ok === 1 ? '' : 's'}.`);
+ const nameHint = storedNames.length === 1 ? ` Saved as ${storedNames[0]}.` : '';
+ showDdaSuccess?.(`Uploaded ${ok} image${ok === 1 ? '' : 's'}.${nameHint}`);
+ clearUploadTypeError();
+ }
+ // Refresh after toast so a tree/grid failure cannot swallow upload success.
+ if (ok) {
+ try {
+ if (typeof loadTree === 'function') await loadTree();
+ if (typeof loadLibraryImages === 'function') await loadLibraryImages({ resetPage: true });
+ else if (typeof window.ddaState?.refreshImages === 'function') await window.ddaState.refreshImages();
+ } catch (_) {
+ /* upload already succeeded — leave the success toast visible */
+ }
}
- setPendingUploadFiles([]);
+ pendingUploadEntries = [];
+ pendingUploadFiles = [];
+ updateUploadSummary();
+ renderUploadFileList();
const fileInput = document.getElementById('upload-file');
const folderInput = document.getElementById('upload-folder');
if (fileInput) fileInput.value = '';
@@ -504,9 +912,14 @@ function initLibraryDropzone() {
const files = await readDroppedEntries(e.dataTransfer);
if (!files.length) return;
setPendingUploadFiles(files);
+ if (!pendingUploadEntries.length) return;
const dest = document.getElementById('upload-node')?.value;
try {
- await uploadLibraryFiles(files, { nodeId: dest, preserveFolders: true });
+ await uploadLibraryFiles(null, {
+ nodeId: dest,
+ preserveFolders: true,
+ entries: pendingUploadEntries.slice(),
+ });
} catch (err) {
showDdaError?.(err.message || 'Upload failed.');
}
@@ -523,24 +936,343 @@ function initLibraryDropzone() {
document.getElementById('form-tree-upload')?.addEventListener('submit', async (e) => {
e.preventDefault();
hideDdaError?.();
- const files = pendingUploadFiles.length
- ? pendingUploadFiles
- : [
- ...Array.from(document.getElementById('upload-file')?.files || []),
- ...Array.from(document.getElementById('upload-folder')?.files || []),
- ];
try {
- await uploadLibraryFiles(files, {
+ await uploadLibraryFiles(null, {
nodeId: document.getElementById('upload-node')?.value,
preserveFolders: true,
+ entries: pendingUploadEntries.length ? pendingUploadEntries.slice() : null,
});
} catch (err) {
showDdaError?.(err.message || 'Upload failed.');
}
});
+document.getElementById('upload-image-type')?.addEventListener('change', () => {
+ revalidatePendingForImageType({ typeChanged: true });
+});
+document.getElementById('upload-source')?.addEventListener('change', (e) => {
+ e.target.dataset.userPicked = '1';
+});
+
+function libFilterParams() {
+ const params = new URLSearchParams();
+ const year = document.getElementById('lib-filter-year')?.value || '';
+ const type = document.getElementById('lib-filter-type')?.value || '';
+ const zone = document.getElementById('lib-filter-zone')?.value || '';
+ const village = document.getElementById('lib-filter-village')?.value || '';
+ const from = document.getElementById('lib-filter-from')?.value || '';
+ const to = document.getElementById('lib-filter-to')?.value || '';
+ if (year) params.set('year', year);
+ if (type) params.set('image_type', type);
+ if (zone) params.set('zone_id', zone);
+ if (village) params.set('village_id', village);
+ if (from) params.set('capture_date_from', from);
+ if (to) params.set('capture_date_to', to);
+ return params;
+}
+
+function exportLibrary(format) {
+ const params = libFilterParams();
+ const q = document.getElementById('lib-filter')?.value?.trim() || '';
+ if (typeof selectedNode !== 'undefined' && selectedNode?.id) {
+ params.set('node_id', String(selectedNode.id));
+ }
+ if (q) params.set('q', q);
+ params.set('format', format);
+ window.location.href = `/api/dda/tree/images/export?${params.toString()}`;
+}
+
+document.getElementById('btn-lib-export-csv')?.addEventListener('click', () => exportLibrary('csv'));
+document.getElementById('btn-lib-export-xlsx')?.addEventListener('click', () => exportLibrary('xlsx'));
+
+document.getElementById('btn-lib-filter-clear')?.addEventListener('click', () => {
+ ['lib-filter-year', 'lib-filter-type', 'lib-filter-zone', 'lib-filter-village', 'lib-filter-from', 'lib-filter-to'].forEach((id) => {
+ const el = document.getElementById(id);
+ if (el) el.value = '';
+ });
+ if (typeof loadLibraryImages === 'function') loadLibraryImages({ resetPage: true });
+});
+
+['lib-filter-year', 'lib-filter-type', 'lib-filter-zone', 'lib-filter-village', 'lib-filter-from', 'lib-filter-to'].forEach((id) => {
+ document.getElementById(id)?.addEventListener('change', () => {
+ if (id === 'lib-filter-zone') renderLibFilterLocationOptions();
+ if (typeof loadLibraryImages === 'function') loadLibraryImages({ resetPage: true });
+ });
+});
+
+document.getElementById('btn-lib-select-all')?.addEventListener('click', () => {
+ const checks = document.querySelectorAll('.dda-card-select');
+ const allOn = [...checks].every((c) => c.checked);
+ checks.forEach((c) => { c.checked = !allOn; });
+ updateLibDeleteSelectedBtn();
+});
+
+function updateLibDeleteSelectedBtn() {
+ const btn = document.getElementById('btn-lib-delete-selected');
+ if (!btn) return;
+ const n = document.querySelectorAll('.dda-card-select:checked').length;
+ btn.disabled = n === 0;
+ btn.textContent = n ? `Delete selected (${n})` : 'Delete selected';
+}
+
+document.getElementById('btn-lib-delete-selected')?.addEventListener('click', async () => {
+ const ids = [...document.querySelectorAll('.dda-card-select:checked')].map((c) => c.dataset.imageId).filter(Boolean);
+ if (!ids.length) return;
+ if (!confirm(`Delete ${ids.length} selected image${ids.length === 1 ? '' : 's'}?`)) return;
+ let ok = 0;
+ const errors = [];
+ for (const id of ids) {
+ try {
+ await ddaApi('DELETE', `/api/dda/tree/images/${id}`);
+ ok += 1;
+ } catch (err) {
+ errors.push(err.message || 'failed');
+ }
+ }
+ if (ok && typeof loadLibraryImages === 'function') await loadLibraryImages();
+ if (errors.length) showDdaError?.(`${ok} deleted, ${errors.length} failed. ${errors[0]}`);
+ else showDdaSuccess?.(`Deleted ${ok} image${ok === 1 ? '' : 's'}.`);
+});
+
+populateUploadYearOptions();
+syncUploadAcceptAttribute();
+suggestSourceFromType();
initLibraryDropzone();
+loadUploadLocations();
+window.loadUploadLocations = loadUploadLocations;
window.uploadLibraryFiles = uploadLibraryFiles;
window.setUploadTargetNode = setUploadTargetNode;
window.setPendingUploadFiles = setPendingUploadFiles;
window.readDroppedEntries = readDroppedEntries;
+window.splitFilesForImageType = splitFilesForImageType;
+window.libFilterParams = libFilterParams;
+window.updateLibDeleteSelectedBtn = updateLibDeleteSelectedBtn;
+
+// --- Bulk upload (Excel manifest + files) ---------------------------------
+
+let bulkPendingEntries = [];
+
+function populateBulkYearOptions() {
+ const sel = document.getElementById('bulk-year');
+ if (!sel || sel.options.length > 1) return;
+ const now = new Date().getFullYear();
+ for (let y = now + 1; y >= 1990; y--) {
+ const opt = document.createElement('option');
+ opt.value = String(y);
+ opt.textContent = String(y);
+ sel.appendChild(opt);
+ }
+}
+
+function renderBulkVillageOptions() {
+ const villageSel = document.getElementById('bulk-village');
+ if (!villageSel) return;
+ const zoneId = document.getElementById('bulk-zone')?.value || '';
+ const previous = villageSel.value;
+ const list = zoneId
+ ? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
+ : uploadLocations.villages;
+ villageSel.innerHTML = '— From Excel — ' + list.map((v) => {
+ const label = zoneId ? v.name : `${v.name} · ${v.zoneName || ''}`;
+ return `${escapeHtml(label)} `;
+ }).join('');
+ if (previous && list.some((v) => String(v.id) === String(previous))) villageSel.value = previous;
+}
+
+function fillBulkZoneOptions() {
+ const zoneSel = document.getElementById('bulk-zone');
+ if (!zoneSel) return;
+ const previous = zoneSel.value;
+ zoneSel.innerHTML = '— From Excel — ' + uploadLocations.zones
+ .map((z) => `${escapeHtml(z.name)} `).join('');
+ if (previous) zoneSel.value = previous;
+ renderBulkVillageOptions();
+}
+
+async function loadBulkUploadConfig() {
+ const el = document.getElementById('bulk-main-folder');
+ try {
+ const data = await ddaApi('GET', '/api/dda/tree/bulk-upload/config');
+ if (el) el.textContent = data.mainFolder || '(not set)';
+ } catch (_) {
+ if (el) el.textContent = '(could not load Main folder)';
+ }
+}
+
+function renderBulkFileList() {
+ const wrap = document.getElementById('bulk-upload-file-list-wrap');
+ const tbody = document.getElementById('bulk-upload-file-tbody');
+ const nameEl = document.getElementById('bulk-upload-files-name');
+ if (!wrap || !tbody) return;
+ const n = bulkPendingEntries.length;
+ if (nameEl) {
+ nameEl.textContent = n ? `${n} file${n === 1 ? '' : 's'} selected.` : 'No files selected.';
+ }
+ if (!n) {
+ wrap.classList.add('hidden');
+ tbody.innerHTML = '';
+ return;
+ }
+ wrap.classList.remove('hidden');
+ tbody.innerHTML = bulkPendingEntries.map((entry, idx) => `
+
+ ${escapeHtml(entry.file.name)}
+
+ ${typeof formatBytes === 'function' ? formatBytes(entry.file.size) : entry.file.size}
+ `).join('');
+ tbody.querySelectorAll('.bulk-server-name').forEach((input) => {
+ input.addEventListener('change', () => {
+ const idx = parseInt(input.closest('tr').dataset.idx, 10);
+ if (bulkPendingEntries[idx]) {
+ bulkPendingEntries[idx].serverName = (input.value || '').trim() || bulkPendingEntries[idx].file.name;
+ }
+ });
+ });
+}
+
+function setBulkPendingFiles(files) {
+ const type = document.getElementById('bulk-image-type')?.value || '';
+ const errEl = document.getElementById('bulk-upload-type-error');
+ let list = Array.from(files || []);
+ if (type && typeof splitFilesForImageType === 'function') {
+ const { accepted, rejected } = splitFilesForImageType(list, type);
+ list = accepted;
+ if (errEl) {
+ if (rejected.length) {
+ errEl.textContent = `Skipped ${rejected.length} file(s) that do not match ${type}: ${rejected.map((f) => f.name).slice(0, 5).join(', ')}${rejected.length > 5 ? '…' : ''}`;
+ errEl.classList.remove('hidden');
+ } else {
+ errEl.classList.add('hidden');
+ errEl.textContent = '';
+ }
+ }
+ } else if (errEl) {
+ errEl.classList.add('hidden');
+ }
+ bulkPendingEntries = list.map((file) => ({
+ file,
+ serverName: file.name,
+ }));
+ renderBulkFileList();
+}
+
+function openBulkUploadModal() {
+ document.getElementById('bulk-upload-result')?.classList.add('hidden');
+ document.getElementById('dda-bulk-upload-modal')?.classList.remove('hidden');
+ populateBulkYearOptions();
+ fillBulkZoneOptions();
+ loadBulkUploadConfig();
+}
+function closeBulkUploadModal() {
+ document.getElementById('dda-bulk-upload-modal')?.classList.add('hidden');
+}
+
+document.getElementById('btn-open-bulk-upload')?.addEventListener('click', openBulkUploadModal);
+document.getElementById('dda-bulk-upload-close')?.addEventListener('click', closeBulkUploadModal);
+document.getElementById('dda-bulk-upload-modal')?.addEventListener('click', (e) => {
+ if (e.target.id === 'dda-bulk-upload-modal') closeBulkUploadModal();
+});
+
+document.getElementById('bulk-zone')?.addEventListener('change', renderBulkVillageOptions);
+document.getElementById('bulk-image-type')?.addEventListener('change', () => {
+ const input = document.getElementById('bulk-upload-files');
+ if (input?.files?.length) setBulkPendingFiles(input.files);
+});
+
+document.getElementById('bulk-upload-excel')?.addEventListener('change', (e) => {
+ const f = e.target.files?.[0];
+ document.getElementById('bulk-upload-excel-name').textContent = f ? f.name : 'No file selected.';
+});
+document.getElementById('bulk-upload-files')?.addEventListener('change', (e) => {
+ setBulkPendingFiles(e.target.files);
+});
+
+function renderBulkUploadResult(data) {
+ const wrap = document.getElementById('bulk-upload-result');
+ const summaryEl = document.getElementById('bulk-upload-summary');
+ const errorsEl = document.getElementById('bulk-upload-errors');
+ if (!wrap || !summaryEl || !errorsEl) return;
+ const s = data.summary || {};
+ summaryEl.innerHTML = `
+ ${s.created || 0} created,
+ ${s.duplicates || 0} already existed (skipped),
+ ${s.errors || 0} error${(s.errors || 0) === 1 ? '' : 's'},
+ ${s.unmatchedFiles || 0} file${(s.unmatchedFiles || 0) === 1 ? '' : 's'} not listed in the sheet.
`;
+ const rows = [
+ ...(data.errors || []).map((e) => ({ row: e.row, file: e.file, reason: e.reason })),
+ ...(data.unmatchedFiles || []).map((u) => ({ row: '—', file: u.file, reason: 'No matching row in the Excel sheet' })),
+ ];
+ errorsEl.innerHTML = rows.length
+ ? `Row File Reason
+ ${rows.map((r) => `${escapeHtml(String(r.row))} ${escapeHtml(r.file || '')} ${escapeHtml(r.reason || '')} `).join('')}
+
`
+ : '';
+ wrap.classList.remove('hidden');
+}
+
+document.getElementById('btn-bulk-upload-submit')?.addEventListener('click', async () => {
+ const excelInput = document.getElementById('bulk-upload-excel');
+ const excelFile = excelInput?.files?.[0];
+ const imageFiles = bulkPendingEntries.map((e) => e.file);
+ if (!excelFile) { showDdaError?.('Choose the filled-in Excel sheet first.'); return; }
+ if (!imageFiles.length) { showDdaError?.('Choose the image files listed in the sheet.'); return; }
+
+ const btn = document.getElementById('btn-bulk-upload-submit');
+ const progWrap = document.getElementById('bulk-upload-progress');
+ const progFill = document.getElementById('bulk-upload-progress-fill');
+ const progLabel = document.getElementById('bulk-upload-progress-label');
+ if (btn) btn.disabled = true;
+ progWrap?.classList.remove('hidden');
+ if (progLabel) progLabel.textContent = `Uploading ${imageFiles.length} file${imageFiles.length === 1 ? '' : 's'}…`;
+ if (progFill) progFill.style.width = '0%';
+
+ const filenames = {};
+ bulkPendingEntries.forEach((e) => {
+ if (e.serverName && e.serverName !== e.file.name) {
+ filenames[e.file.name] = e.serverName;
+ } else if (e.serverName) {
+ filenames[e.file.name] = e.serverName;
+ }
+ });
+
+ const form = new FormData();
+ form.append('excel', excelFile);
+ imageFiles.forEach((f) => form.append('files', f));
+ const year = document.getElementById('bulk-year')?.value || '';
+ const source = document.getElementById('bulk-source')?.value || '';
+ const imageType = document.getElementById('bulk-image-type')?.value || '';
+ const zoneId = document.getElementById('bulk-zone')?.value || '';
+ const villageId = document.getElementById('bulk-village')?.value || '';
+ if (year) form.append('year', year);
+ if (source) form.append('source', source);
+ if (imageType) form.append('image_type', imageType);
+ if (zoneId) form.append('zone_id', zoneId);
+ if (villageId) form.append('village_id', villageId);
+ if (Object.keys(filenames).length) form.append('filenames', JSON.stringify(filenames));
+
+ try {
+ const data = await uploadWithProgress('/api/dda/tree/bulk-upload', form, (loaded, total) => {
+ if (progFill && total) progFill.style.width = `${Math.round((loaded / total) * 100)}%`;
+ });
+ if (progFill) progFill.style.width = '100%';
+ renderBulkUploadResult(data);
+ const created = data.summary?.created || 0;
+ if (created) {
+ showDdaSuccess?.(`Bulk upload complete — ${created} image${created === 1 ? '' : 's'} added.`);
+ try {
+ if (typeof loadTree === 'function') await loadTree();
+ if (typeof loadLibraryImages === 'function') await loadLibraryImages({ resetPage: true });
+ else if (typeof window.ddaState?.refreshImages === 'function') await window.ddaState.refreshImages();
+ } catch (_) {
+ /* bulk upload already succeeded — leave the success toast visible */
+ }
+ } else {
+ showDdaError?.('No images were created — see the results below.');
+ }
+ } catch (err) {
+ showDdaError?.(err.message || 'Bulk upload failed.');
+ } finally {
+ if (btn) btn.disabled = false;
+ setTimeout(() => progWrap?.classList.add('hidden'), 1500);
+ }
+});
diff --git a/static/js/dda/notifications.js b/static/js/dda/notifications.js
index e61c6435db1cbb47b2adf87038d87af24671c903..6e54b5d47a85e3e4ec78ca3a1e18c18d2ef41f7e 100644
--- a/static/js/dda/notifications.js
+++ b/static/js/dda/notifications.js
@@ -36,12 +36,23 @@ function formatNotifyTime(iso) {
async function fetchNotifyJobs() {
try {
const data = await ddaApi('GET', '/api/dda/jobs?limit=25');
- return data.jobs || [];
+ const jobs = data.jobs || [];
+ window.ddaNotifyJobs = jobs;
+ window._ddaNotifyJobsLoaded = true;
+ return jobs;
} catch (_) {
- return [];
+ return window.ddaNotifyJobs || [];
}
}
+/** Shared jobs list for Detect resume / home live banner — avoids a second fetch. */
+async function getDdaNotifyJobs({ force } = {}) {
+ if (!force && window._ddaNotifyJobsLoaded) {
+ return window.ddaNotifyJobs || [];
+ }
+ return fetchNotifyJobs();
+}
+
function updateNotifyBadge(jobs) {
const badge = document.getElementById('dda-notify-badge');
if (!badge) return;
@@ -179,3 +190,4 @@ function initNotifications() {
document.addEventListener('DOMContentLoaded', initNotifications);
window.markDdaJobSeen = markJobSeen;
window.refreshDdaNotifications = refreshNotifications;
+window.getDdaNotifyJobs = getDdaNotifyJobs;
diff --git a/static/js/dda/pairs.js b/static/js/dda/pairs.js
index 0aef67da8d4e9896bd47e38965d34cc08fc0211e..fa7027baefb120351fed57298f675adb293ba0ec 100644
--- a/static/js/dda/pairs.js
+++ b/static/js/dda/pairs.js
@@ -1,6 +1,9 @@
/** Same-area pairing: oldest = Before, newest = After, with manual override. */
-const areaPairsState = { groups: [], unpaired: [], overrides: {}, autoApplied: false };
+const areaPairsState = { groups: [], unpaired: [], unpairedCount: 0, overrides: {}, autoApplied: false, loadedKey: null };
+const PAIRS_PER_PAGE = 20;
+let areaPairsPage = 0;
+let comparePairsPage = 0;
function _pairDateLabel(img) {
if (!img) return '';
@@ -9,21 +12,62 @@ function _pairDateLabel(img) {
}
function _pairOptionLabel(img) {
- const date = _pairDateLabel(img);
const name = img.filename || img.imageName || img.path || '';
- return date ? `${date} · ${name}` : name;
+ const parts = [_pairDateLabel(img), name, img.villageName || img.zoneName || ''];
+ return parts.filter(Boolean).join(' · ');
+}
+
+function _assignedPaths(exceptGroupId) {
+ const used = new Set();
+ for (const group of areaPairsState.groups || []) {
+ if (group.id === exceptGroupId) continue;
+ const ov = areaPairsState.overrides[group.id];
+ const before = ov?.beforePath || group.beforePath;
+ const after = ov?.afterPath || group.afterPath;
+ if (before) used.add(before);
+ if (after) used.add(after);
+ }
+ return used;
+}
+
+// Automatic pairing is always two images. The dropdown may offer unused
+// images from the same place, but never an image already assigned elsewhere.
+function _pairOptions(group, { excludePath } = {}) {
+ const used = _assignedPaths(group.id);
+ const pool = [];
+ const seen = new Set();
+ const add = (img) => {
+ if (!img?.path || seen.has(img.path)) return;
+ if (excludePath && img.path === excludePath) return;
+ seen.add(img.path);
+ pool.push(img);
+ };
+ (group.images || []).forEach(add);
+ (group.candidates || []).forEach((img) => {
+ if (used.has(img.path)) return;
+ add(img);
+ });
+ return pool;
}
function _pairChoice(group) {
const ov = areaPairsState.overrides[group.id];
- const images = group.images || [];
const beforePath = ov?.beforePath || group.beforePath || group.suggestedBefore?.path;
const afterPath = ov?.afterPath || group.afterPath || group.suggestedAfter?.path;
+ const images = _pairOptions(group);
const before = images.find((i) => i.path === beforePath) || group.suggestedBefore;
const after = images.find((i) => i.path === afterPath) || group.suggestedAfter;
+ if (before?.path && after?.path && before.path === after.path) {
+ return { before: group.suggestedBefore, after: group.suggestedAfter, overridden: false };
+ }
return { before, after, overridden: !!ov };
}
+function _pairReasonText(group) {
+ if (group.reasons?.length) return group.reasons.join(' · ');
+ return group.match === 'bounds' ? 'overlapping map extent' : 'matching name / grid';
+}
+
function renderAreaPairRows(container, groups, { compact } = {}) {
if (!container) return;
if (!groups.length) {
@@ -32,23 +76,22 @@ function renderAreaPairRows(container, groups, { compact } = {}) {
}
container.innerHTML = groups.map((g) => {
const { before, after, overridden } = _pairChoice(g);
- const opts = (g.images || []).map((img) => {
+ const optionHtml = (excludePath) => _pairOptions(g, { excludePath }).map((img) => {
const enc = encodeURIComponent(img.path);
return `${escapeHtml(_pairOptionLabel(img))} `;
}).join('');
- const match = g.match === 'bounds' ? 'overlapping map extent' : 'matching name / grid';
- const n = (g.images || []).length;
+ const extra = Math.max(0, _pairOptions(g).length - 2);
return `
${escapeHtml(g.label)}
-
${n} image${n === 1 ? '' : 's'} · ${escapeHtml(match)}${overridden ? ' · overridden ' : ''}
+
${escapeHtml(_pairReasonText(g))}${extra ? ` · ${extra} other image${extra === 1 ? '' : 's'} here` : ''}${overridden ? ' · overridden ' : ''}
Before (oldest)
- ${opts}
+ ${optionHtml(after?.path)}
After (newest)
- ${opts}
+ ${optionHtml(before?.path)}
Compare
@@ -66,15 +109,19 @@ function renderAreaPairRows(container, groups, { compact } = {}) {
if (afterSel && after) afterSel.value = encodeURIComponent(after.path);
const saveOverride = () => {
- areaPairsState.overrides[group.id] = {
- beforePath: decodeURIComponent(beforeSel.value || ''),
- afterPath: decodeURIComponent(afterSel.value || ''),
- };
- renderAreaPairRows(document.getElementById('dda-area-pairs'), areaPairsState.groups);
- const cmp = document.getElementById('dda-compare-pairs');
- if (cmp && areaPairsState.groups.length) {
- renderAreaPairRows(cmp, areaPairsState.groups, { compact: true });
+ const beforePath = decodeURIComponent(beforeSel.value || '');
+ const afterPath = decodeURIComponent(afterSel.value || '');
+ if (!beforePath || !afterPath || beforePath === afterPath) {
+ if (typeof showDdaError === 'function') {
+ showDdaError('A pair must be two different images. The same file cannot be Before and After.');
+ }
+ renderLibAreaPairs();
+ renderCompareAreaPairs();
+ return;
}
+ areaPairsState.overrides[group.id] = { beforePath, afterPath };
+ renderLibAreaPairs();
+ renderCompareAreaPairs();
};
beforeSel?.addEventListener('change', saveOverride);
afterSel?.addEventListener('change', saveOverride);
@@ -140,8 +187,8 @@ function populateAutoAreaSelect() {
return;
}
sel.innerHTML = groups.map((g) => {
- const n = (g.images || []).length;
- return `
${escapeHtml(g.label)} · ${n} image${n === 1 ? '' : 's'} `;
+ const span = [g.beforeDate, g.afterDate].filter(Boolean).join(' → ');
+ return `
${escapeHtml(g.label)}${span ? ` · ${escapeHtml(span)}` : ''} `;
}).join('');
if (prev && groups.some((g) => g.id === prev)) sel.value = prev;
else sel.value = groups[0].id;
@@ -174,30 +221,96 @@ function applyPairFromUrl() {
}
}
-async function loadAreaPairs({ nodeId, autoFillCompare } = {}) {
+function renderLibAreaPairs() {
+ const libBox = document.getElementById('dda-area-pairs');
+ const pag = document.getElementById('dda-area-pairs-pagination');
+ if (!libBox) return;
+ const groups = areaPairsState.groups || [];
+ if (!groups.length) {
+ renderAreaPairRows(libBox, []);
+ if (pag) pag.innerHTML = '';
+ return;
+ }
+ const totalPages = Math.max(1, Math.ceil(groups.length / PAIRS_PER_PAGE));
+ if (areaPairsPage > totalPages - 1) areaPairsPage = Math.max(0, totalPages - 1);
+ const start = areaPairsPage * PAIRS_PER_PAGE;
+ renderAreaPairRows(libBox, groups.slice(start, start + PAIRS_PER_PAGE));
+ if (typeof renderPaginationControls === 'function' && pag) {
+ renderPaginationControls(pag, areaPairsPage, totalPages, (p) => {
+ areaPairsPage = p;
+ renderLibAreaPairs();
+ });
+ } else if (pag) {
+ pag.innerHTML = '';
+ }
+}
+
+function renderCompareAreaPairs() {
+ const cmpBox = document.getElementById('dda-compare-pairs');
+ const pag = document.getElementById('dda-compare-pairs-pagination');
+ if (!cmpBox) return;
+ const groups = areaPairsState.groups || [];
+ if (!groups.length) {
+ cmpBox.innerHTML = '
No same-area pairs loaded yet. Click Load pairs or switch to Automatic.
';
+ if (pag) pag.innerHTML = '';
+ return;
+ }
+ const totalPages = Math.max(1, Math.ceil(groups.length / PAIRS_PER_PAGE));
+ if (comparePairsPage > totalPages - 1) comparePairsPage = Math.max(0, totalPages - 1);
+ const start = comparePairsPage * PAIRS_PER_PAGE;
+ renderAreaPairRows(cmpBox, groups.slice(start, start + PAIRS_PER_PAGE), { compact: true });
+ if (typeof renderPaginationControls === 'function' && pag) {
+ renderPaginationControls(pag, comparePairsPage, totalPages, (p) => {
+ comparePairsPage = p;
+ renderCompareAreaPairs();
+ });
+ } else if (pag) {
+ pag.innerHTML = '';
+ }
+}
+
+async function loadAreaPairs({ nodeId, autoFillCompare, includeUnpaired, force } = {}) {
const libBox = document.getElementById('dda-area-pairs');
const hint = document.getElementById('dda-area-pairs-hint');
const cmpBox = document.getElementById('dda-compare-pairs');
+ const cacheKey = String(nodeId || 0);
+ if (!force && areaPairsState.loadedKey === cacheKey && (areaPairsState.groups || []).length) {
+ renderLibAreaPairs();
+ renderCompareAreaPairs();
+ populateAutoAreaSelect();
+ applyPairFromUrl();
+ return areaPairsState;
+ }
+ // Detect Automatic needs unpaired for dropdowns; Library Load pairs does not.
+ const wantUnpaired = includeUnpaired != null
+ ? !!includeUnpaired
+ : !!(document.getElementById('slot-t1') && typeof getDetectMode === 'function' && getDetectMode() === 'automatic');
const params = new URLSearchParams();
if (nodeId) params.set('node_id', String(nodeId));
+ if (wantUnpaired) params.set('include_unpaired', '1');
+ if (libBox) libBox.innerHTML = '
Loading pairs…
';
+ if (cmpBox) cmpBox.innerHTML = '
Loading pairs…
';
try {
const data = await ddaApi('GET', '/api/dda/local/area-groups' + (params.toString() ? `?${params}` : ''));
areaPairsState.groups = data.groups || [];
areaPairsState.unpaired = data.unpaired || [];
+ areaPairsState.unpairedCount = data.unpairedCount != null
+ ? data.unpairedCount
+ : areaPairsState.unpaired.length;
+ areaPairsState.loadedKey = cacheKey;
+ areaPairsPage = 0;
+ comparePairsPage = 0;
window.ddaState = window.ddaState || {};
window.ddaState.areaGroups = areaPairsState.groups;
const n = areaPairsState.groups.length;
- const unpaired = areaPairsState.unpaired.length;
+ const unpaired = areaPairsState.unpairedCount;
if (hint) {
hint.textContent = n
? `${n} pair${n === 1 ? '' : 's'} · ${unpaired} unpaired`
- : (unpaired ? `${unpaired} image(s) not yet paired` : '');
- }
- renderAreaPairRows(libBox, areaPairsState.groups);
- if (cmpBox) {
- if (!areaPairsState.groups.length) cmpBox.innerHTML = '';
- else renderAreaPairRows(cmpBox, areaPairsState.groups, { compact: true });
+ : (unpaired ? `${unpaired} image(s) not yet paired` : 'No pairs found');
}
+ renderLibAreaPairs();
+ renderCompareAreaPairs();
populateAutoAreaSelect();
applyPairFromUrl();
if (typeof populateSelects === 'function' && typeof selectSourceItems === 'function') {
@@ -214,11 +327,40 @@ async function loadAreaPairs({ nodeId, autoFillCompare } = {}) {
}
areaPairsState.autoApplied = true;
}
+ const loadBtn = document.getElementById('btn-load-area-pairs');
+ if (loadBtn) loadBtn.textContent = 'Refresh';
} catch (err) {
- if (libBox) libBox.innerHTML = `
Could not load same-area pairs: ${escapeHtml(err.message || 'error')}
`;
+ const msg = `
Could not load same-area pairs: ${escapeHtml(err.message || 'error')}
`;
+ if (libBox) libBox.innerHTML = msg;
+ if (cmpBox) cmpBox.innerHTML = msg;
}
}
+function bindAreaPairsLoadButton() {
+ document.querySelectorAll('#btn-load-area-pairs').forEach((btn) => {
+ if (btn.dataset.bound) return;
+ btn.dataset.bound = '1';
+ btn.addEventListener('click', async () => {
+ btn.disabled = true;
+ try {
+ const nodeId = window.ddaState?.selectedNode?.id
+ || (typeof compareState !== 'undefined' ? compareState.selectedNode?.id : null)
+ || null;
+ await loadAreaPairs({
+ nodeId,
+ force: true,
+ includeUnpaired: !!document.getElementById('slot-t1'),
+ autoFillCompare: !!document.getElementById('slot-t1'),
+ });
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ });
+}
+
+bindAreaPairsLoadButton();
+
window.loadAreaPairs = loadAreaPairs;
window.areaPairsState = areaPairsState;
window.applySelectedAutoPair = applySelectedAutoPair;
diff --git a/static/js/dda/profile.js b/static/js/dda/profile.js
index 36db0b26584727ed6f9787d24eafd3678c81288f..73e2d9f73a4c2086d4ff978a0dc4b3b4f4aeb41b 100644
--- a/static/js/dda/profile.js
+++ b/static/js/dda/profile.js
@@ -86,8 +86,12 @@
}
try {
await ddaApi('POST', '/api/auth/change-password', { body: JSON.stringify({ current_password, new_password }) });
- showMsg('password-success', 'Password changed.', false);
document.getElementById('password-form').reset();
+ // Changing the password invalidates the current session too (server-side,
+ // so a leaked old token can't keep working) — send the user back to sign
+ // in with the new password instead of leaving a now-dead session active.
+ showMsg('password-success', 'Password changed. Signing you out for security — please sign in again.', false);
+ setTimeout(() => { window.location.href = '/login'; }, 2000);
} catch (err) {
showMsg('password-error', err.message || 'Could not change password.', true);
}
diff --git a/static/js/dda/report_page.js b/static/js/dda/report_page.js
index cf5cac6f943cb7c44a253af0f3df1df517a76fb7..61ba2c2169599f7400c8db10741bb84076c74b0f 100644
--- a/static/js/dda/report_page.js
+++ b/static/js/dda/report_page.js
@@ -1,4 +1,4 @@
-/** Standalone report page at /dda/reports/{runId} (FR-05). */
+/** Standalone report page at /dda/reports/{runId} — interactive viewer in-window. */
function parseReportRunId() {
const m = window.location.pathname.match(/\/dda\/reports\/(\d+)/);
@@ -12,79 +12,21 @@ function showReportError(msg) {
el.classList.remove('hidden');
}
-function formatCoord(v) {
- if (v == null || v === '') return '—';
- const n = Number(v);
- return Number.isFinite(n) ? n.toFixed(5) : '—';
-}
-
-function regionLatLng(r) {
- const ll = r.latLng || {};
- const lat = r.latitude ?? ll.lat;
- const lng = r.longitude ?? ll.lng;
- return { lat, lng };
-}
-
async function loadReportPage() {
const runId = parseReportRunId();
const loading = document.getElementById('report-loading');
- const content = document.getElementById('report-content');
if (!runId) {
if (loading) loading.innerHTML = '
Invalid report URL.
';
return;
}
try {
- const data = await ddaApi('GET', `/api/dda/reports/${runId}`);
- if (loading) loading.classList.add('hidden');
- if (content) content.classList.remove('hidden');
-
- document.getElementById('report-title').textContent = data.title || `Run #${runId}`;
- const loc = [data.village, data.zone].filter(Boolean).join(', ') || '—';
- document.getElementById('report-meta').textContent =
- `${data.method || '—'} · ${loc} · ${data.createdAt || ''}`;
-
- const stats = data.statistics || {};
- document.getElementById('report-stats').innerHTML = `
-
Change ${(stats.changePercentage ?? 0).toFixed(2)}%
-
Changed px ${(stats.changedPixels ?? 0).toLocaleString()}
-
Regions ${data.regionsCount ?? (data.regions || []).length}
`;
-
- const overlay = document.getElementById('report-overlay');
- if (overlay) {
- if (data.overlayBase64Png) {
- overlay.src = `data:image/png;base64,${data.overlayBase64Png}`;
- } else if (data.overlayUrl) {
- overlay.src = data.overlayUrl;
- } else {
- overlay.alt = 'No overlay available';
- }
- }
-
- const tbody = document.getElementById('report-regions-body');
- const regions = data.regions || [];
- if (tbody) {
- tbody.innerHTML = regions.length
- ? regions.map((r) => {
- const { lat, lng } = regionLatLng(r);
- const hasCoords = Number.isFinite(Number(lat)) && Number.isFinite(Number(lng));
- const mapsUrl = hasCoords
- ? `https://www.google.com/maps?q=${lat},${lng}&ll=${lat},${lng}&z=19`
- : null;
- return `
-
- ${r.id ?? ''}
- ${r.ddaChangeType || r.objectType || '—'}
- ${r.internalObjectType || r.objectType || '—'}
- ${((r.confidence ?? 0) * 100).toFixed(0)}%
- ${(r.area ?? 0).toLocaleString()}
- ${formatCoord(lat)}
- ${formatCoord(lng)}
- ${mapsUrl ? `Map ` : '—'}
- `;
- }).join('')
- : '
No regions detected. ';
+ if (typeof loadMyPermissions === 'function') {
+ window.ddaPermissions = await loadMyPermissions();
+ if (typeof applyPermissionGating === 'function') applyPermissionGating(window.ddaPermissions);
}
+ const data = await ddaApi('GET', `/api/history/${runId}`);
+ if (loading) loading.classList.add('hidden');
const pdfBtn = document.getElementById('report-pdf-btn');
if (pdfBtn) {
@@ -92,39 +34,28 @@ async function loadReportPage() {
pdfBtn.onclick = () => { window.location.href = `/api/dda/reports/${runId}/pdf`; };
}
- const viewBtn = document.getElementById('report-view-btn');
- if (viewBtn) {
- viewBtn.disabled = false;
- viewBtn.onclick = () => {
- try { sessionStorage.setItem('dda_open_run', String(runId)); } catch (_) {}
- window.location.href = '/reports';
- };
+ if (typeof showDdaResult === 'function') {
+ showDdaResult(data);
+ const beforeImg = document.getElementById('compare-before-img');
+ const afterImg = document.getElementById('compare-after-img');
+ const waitLoad = (img) => new Promise((resolve) => {
+ if (!img) return resolve();
+ if (img.complete && img.naturalWidth) return resolve();
+ img.addEventListener('load', () => resolve(), { once: true });
+ img.addEventListener('error', () => resolve(), { once: true });
+ setTimeout(resolve, 8000);
+ });
+ await Promise.all([waitLoad(beforeImg), waitLoad(afterImg)]);
+ requestAnimationFrame(() => {
+ if (typeof applyDdaZoom === 'function') applyDdaZoom();
+ });
+ } else {
+ showReportError('Interactive viewer failed to load.');
}
-
- window._reportPageData = data;
} catch (err) {
if (loading) loading.classList.add('hidden');
showReportError(err.message || 'Could not load report.');
}
}
-document.getElementById('report-email-btn')?.addEventListener('click', async () => {
- const runId = parseReportRunId();
- const input = document.getElementById('report-email');
- const msg = document.getElementById('report-email-msg');
- const email = (input?.value || '').trim();
- if (!email) {
- if (msg) msg.textContent = 'Enter an email address.';
- return;
- }
- try {
- const res = await ddaApi('POST', `/api/dda/reports/${runId}/notify`, {
- body: JSON.stringify({ email }),
- });
- if (msg) msg.textContent = res.message || 'Sent.';
- } catch (err) {
- if (msg) msg.textContent = err.message || 'Send failed.';
- }
-});
-
loadReportPage();
diff --git a/static/js/dda/reports.js b/static/js/dda/reports.js
index 56e8f1f470b6bbe31079f6968c5ccd2757e175e6..50b64250664fd1a4bc4da6338d0365e44b4293ee 100644
--- a/static/js/dda/reports.js
+++ b/static/js/dda/reports.js
@@ -6,7 +6,9 @@ function formatReportDate(iso) {
let allReportRows = [];
let reportsPage = 0;
-const REPORTS_PER_PAGE = 10;
+const REPORTS_PER_PAGE = 25;
+let reportsTotal = 0;
+let reportsServerPaged = true;
function reportFilterState() {
return {
@@ -71,23 +73,22 @@ function updateReportsMeta(shown, total) {
function renderReportsTable(rows) {
const el = document.getElementById('reports-list');
if (!el) return;
- updateReportsMeta(rows.length, allReportRows.length);
+ const totalForMeta = reportsServerPaged ? reportsTotal : allReportRows.length;
+ updateReportsMeta(rows.length, totalForMeta || rows.length);
- if (!allReportRows.length) {
+ if (!allReportRows.length && !reportsTotal) {
el.innerHTML = '
No detection reports yet. Run a comparison on the Change Detection page.
';
document.getElementById('reports-pagination')?.replaceChildren();
return;
}
if (!rows.length) {
- el.innerHTML = '
No reports match these filters. Clear filters to see everything.
';
+ el.innerHTML = '
No reports match these filters on this page. Clear filters or try another page.
';
document.getElementById('reports-pagination')?.replaceChildren();
return;
}
- const totalPages = Math.max(1, Math.ceil(rows.length / REPORTS_PER_PAGE));
- reportsPage = Math.max(0, Math.min(reportsPage, totalPages - 1));
- const start = reportsPage * REPORTS_PER_PAGE;
- const pageRows = rows.slice(start, start + REPORTS_PER_PAGE);
+ // Server already returned one page; client filters apply within that page.
+ const pageRows = rows;
el.innerHTML = `
@@ -113,7 +114,7 @@ function renderReportsTable(rows) {
${r.regions ?? '—'}
${r.status === 'completed' && r.runId
- ? `View
+ ? `View
Report
PDF `
: (r.error ? `Error ` : '—')}
@@ -122,22 +123,11 @@ function renderReportsTable(rows) {
`;
- el.querySelectorAll('[data-view-run]').forEach((btn) => {
- btn.addEventListener('click', async () => {
- const runId = btn.dataset.viewRun;
- try {
- const data = await ddaApi('GET', `/api/history/${runId}`);
- if (typeof showDdaResult === 'function') showDdaResult(data);
- } catch (err) {
- if (typeof showDdaError === 'function') showDdaError(err.message);
- }
- });
- });
-
+ const totalPages = Math.max(1, Math.ceil((reportsTotal || rows.length) / REPORTS_PER_PAGE));
if (typeof renderPaginationControls === 'function') {
renderPaginationControls(document.getElementById('reports-pagination'), reportsPage, totalPages, (p) => {
reportsPage = p;
- renderReportsTable(rows);
+ loadReportsList({ keepFilters: true });
});
}
}
@@ -164,59 +154,71 @@ function clearReportsFilters() {
applyReportsFilter();
}
-async function loadReportsList() {
+function _historyToRows(history) {
+ return (history || []).map((r) => ({
+ kind: 'run',
+ id: r.id,
+ runId: r.id,
+ title: r.title,
+ status: 'completed',
+ autoScheduled: String(r.title || '').startsWith('[Auto]'),
+ zone: r.zone || '',
+ village: r.village || '',
+ changePct: r.changePercentage,
+ regions: r.regionsCount,
+ createdAt: r.createdAt,
+ }));
+}
+
+function _jobsToRows(jobs) {
+ return (jobs || []).map((j) => ({
+ kind: 'job',
+ id: j.id,
+ runId: j.runId,
+ title: j.title || `${j.basePath} vs ${j.comparisonPath}`,
+ status: j.status,
+ autoScheduled: !!j.autoScheduled,
+ zone: j.zone || j.report?.zone || '',
+ village: j.village || j.report?.village || '',
+ changePct: j.report?.changePercentage,
+ regions: j.report?.regionsCount,
+ createdAt: j.createdAt,
+ error: j.errorMessage,
+ }));
+}
+
+async function loadReportsList(opts = {}) {
const el = document.getElementById('reports-list');
if (!el) return;
+ if (!opts.keepFilters) reportsPage = 0;
el.innerHTML = '
Loading reports…
';
try {
- let jobs = [];
- try {
- const jobsData = await ddaApi('GET', '/api/dda/jobs?limit=100');
- jobs = jobsData.jobs || [];
- } catch (_) {
- /* jobs API optional — history still works */
- }
- const history = await ddaApi('GET', '/api/history');
- const rows = [];
-
- jobs.forEach((j) => {
- rows.push({
- kind: 'job',
- id: j.id,
- runId: j.runId,
- title: j.title || `${j.basePath} vs ${j.comparisonPath}`,
- status: j.status,
- autoScheduled: !!j.autoScheduled,
- zone: j.zone || j.report?.zone || '',
- village: j.village || j.report?.village || '',
- changePct: j.report?.changePercentage,
- regions: j.report?.regionsCount,
- createdAt: j.createdAt,
- error: j.errorMessage,
- });
- });
+ const offset = reportsPage * REPORTS_PER_PAGE;
+ const [jobsData, historyPage] = await Promise.all([
+ ddaApi('GET', '/api/dda/jobs?limit=25').catch(() => ({ jobs: [] })),
+ ddaApi('GET', `/api/history?limit=${REPORTS_PER_PAGE}&offset=${offset}&paged=1`),
+ ]);
+ const jobs = jobsData.jobs || [];
+ const history = historyPage.items || [];
+ reportsTotal = historyPage.total != null ? historyPage.total : history.length;
+ reportsServerPaged = true;
- (history || []).forEach((r) => {
- if (rows.some((x) => x.runId === r.id)) return;
- rows.push({
- kind: 'run',
- id: r.id,
- runId: r.id,
- title: r.title,
- status: 'completed',
- autoScheduled: String(r.title || '').startsWith('[Auto]'),
- zone: r.zone || '',
- village: r.village || '',
- changePct: r.changePercentage,
- regions: r.regionsCount,
- createdAt: r.createdAt,
+ const rows = [];
+ // Active/recent jobs only on first page so they stay visible without a huge merge.
+ if (reportsPage === 0) {
+ _jobsToRows(jobs).forEach((row) => {
+ if (row.status === 'completed' && row.runId) return;
+ rows.push(row);
});
+ }
+ _historyToRows(history).forEach((row) => {
+ if (rows.some((x) => x.runId === row.runId)) return;
+ rows.push(row);
});
- rows.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
allReportRows = rows;
syncReportZoneOptions();
- applyReportsFilter();
+ renderReportsTable(filteredReportRows());
} catch (err) {
el.innerHTML = `
Could not load reports: ${err.message}
`;
}
@@ -225,30 +227,16 @@ async function loadReportsList() {
window.loadReportsList = loadReportsList;
window.applyReportsFilter = applyReportsFilter;
-document.getElementById('btn-reports-refresh')?.addEventListener('click', loadReportsList);
+document.getElementById('btn-reports-refresh')?.addEventListener('click', () => loadReportsList());
document.getElementById('btn-reports-clear')?.addEventListener('click', clearReportsFilters);
['reports-filter', 'reports-filter-status', 'reports-filter-source', 'reports-filter-zone',
'reports-filter-from', 'reports-filter-to', 'reports-filter-sort'].forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
- el.addEventListener(el.tagName === 'INPUT' && el.type === 'search' ? 'input' : 'change', applyReportsFilter);
+ el.addEventListener('input', () => applyReportsFilter());
+ el.addEventListener('change', () => applyReportsFilter());
});
-document.addEventListener('DOMContentLoaded', () => {
- try {
- const openRun = sessionStorage.getItem('dda_open_run');
- if (openRun) {
- sessionStorage.removeItem('dda_open_run');
- setTimeout(async () => {
- try {
- const data = await ddaApi('GET', `/api/history/${openRun}`);
- if (typeof showDdaResult === 'function') showDdaResult(data);
- } catch (_) {}
- }, 300);
- }
- } catch (_) {}
-
- if (document.getElementById('tab-reports')?.classList.contains('active')) {
- loadReportsList();
- }
-});
+if (document.getElementById('reports-list')) {
+ loadReportsList();
+}
diff --git a/static/js/dda/result.js b/static/js/dda/result.js
index aa40e657128dc69d4d7927188c605143477e1b88..08289517a36448ef2172bbf3f53cbf9d53c45b5c 100644
--- a/static/js/dda/result.js
+++ b/static/js/dda/result.js
@@ -6,7 +6,7 @@ let ddaRegionList = [];
let ddaRegionPage = 0;
let ddaSelectedRegionId = null;
let ddaViewMode = 'slider';
-let ddaViewUrls = { before: '', after: '', overlay: '' };
+let ddaViewUrls = { before: '', after: '', overlay: '', beforeFallback: '', afterFallback: '' };
const DDA_REGIONS_PER_PAGE = 10;
let ddaZoom = 1;
const DDA_ZOOM_MIN = 0.5;
@@ -25,6 +25,27 @@ function formatCompact(n) {
return n.toLocaleString();
}
+/** Prefer small thumbs so the viewer paints immediately; full URLs are downscaled. */
+function ddaViewerSrc(url) {
+ if (!url || String(url).startsWith('data:')) return url || '';
+ const sep = String(url).includes('?') ? '&' : '?';
+ return `${url}${sep}max_side=1600`;
+}
+
+function ddaSetImgSrc(img, primary, fallback) {
+ if (!img) return;
+ const next = primary || fallback || '';
+ img.onerror = null;
+ if (primary && fallback && primary !== fallback) {
+ img.onerror = () => {
+ img.onerror = null;
+ img.src = fallback;
+ };
+ }
+ if (next) img.src = next;
+ else img.removeAttribute('src');
+}
+
function reviewBadgeClass(status) {
const s = (status || 'pending').toLowerCase();
return `review-badge review-${s.replace('_', '-')}`;
@@ -61,23 +82,23 @@ function setDdaViewMode(mode) {
const afterSrc = ddaViewUrls.after || overlaySrc;
if (mode === 'slider') {
- beforeImg.src = beforeSrc;
- afterImg.src = overlaySrc;
+ ddaSetImgSrc(beforeImg, beforeSrc, ddaViewUrls.beforeFallback);
+ ddaSetImgSrc(afterImg, overlaySrc, ddaViewUrls.afterFallback || overlaySrc);
if (afterClip) afterClip.style.clipPath = 'inset(0 0 0 50%)';
if (handle) { handle.style.display = ''; handle.style.left = '50%'; }
} else if (mode === 't1') {
- beforeImg.src = beforeSrc;
- afterImg.src = overlaySrc;
+ ddaSetImgSrc(beforeImg, beforeSrc, ddaViewUrls.beforeFallback);
+ ddaSetImgSrc(afterImg, overlaySrc, ddaViewUrls.afterFallback || overlaySrc);
if (afterClip) afterClip.style.clipPath = 'inset(0 0 0 100%)';
if (handle) handle.style.display = 'none';
} else if (mode === 't2') {
- beforeImg.src = beforeSrc;
- afterImg.src = afterSrc;
+ ddaSetImgSrc(beforeImg, beforeSrc, ddaViewUrls.beforeFallback);
+ ddaSetImgSrc(afterImg, afterSrc, ddaViewUrls.afterFallback || afterSrc);
if (afterClip) afterClip.style.clipPath = 'inset(0 0 0 0%)';
if (handle) handle.style.display = 'none';
} else if (mode === 'overlay') {
- beforeImg.src = beforeSrc;
- afterImg.src = overlaySrc;
+ ddaSetImgSrc(beforeImg, beforeSrc, ddaViewUrls.beforeFallback);
+ ddaSetImgSrc(afterImg, overlaySrc, ddaViewUrls.afterFallback || overlaySrc);
if (afterClip) afterClip.style.clipPath = 'inset(0 0 0 0%)';
if (handle) handle.style.display = 'none';
}
@@ -164,6 +185,7 @@ function setupTrainingPackButton(runId, geo) {
async function patchRegionReview(runId, regionId, reviewStatus) {
const res = await ddaApi('PATCH', `/api/dda/reports/${runId}/regions/${regionId}`, {
body: JSON.stringify({ reviewStatus }),
+ timeoutMs: 15000,
});
return res.region;
}
@@ -271,6 +293,17 @@ function showDdaResult(data) {
}
if (titleEl) titleEl.textContent = data.title || 'Detection Result';
+ closeResultTitleEdit();
+ const titleEditBtn = document.getElementById('result-title-edit-btn');
+ if (titleEditBtn) {
+ // Only a saved run (has an id) can be renamed. toggle() first, then
+ // re-run permission gating scoped to this button so a user without
+ // reports:edit still never sees it, regardless of id state.
+ titleEditBtn.classList.toggle('hidden', !data.id);
+ if (data.id && typeof applyPermissionGating === 'function') {
+ applyPermissionGating(window.ddaPermissions || {}, titleEditBtn.parentElement);
+ }
+ }
const stats = data.statistics || {};
const pct = (stats.changePercentage ?? 0).toFixed(2);
@@ -314,6 +347,9 @@ function showDdaResult(data) {
const shadowHint = shadowPx > 0
? `
${formatCompact(shadowPx)}
Shadow px
`
: '';
+ const settingsHint = stats.detectionSensitivity != null
+ ? `
${Number(stats.detectionSensitivity).toFixed(2)} / ${(stats.minRegionArea ?? 0).toLocaleString()}px
Sensitivity / Min region
`
+ : '';
statsEl.innerHTML = warnHtml + `
@@ -328,13 +364,23 @@ function showDdaResult(data) {
${tileSkipHtml}
`;
- const overlaySrc = data.overlayBase64Png
- ? 'data:image/png;base64,' + data.overlayBase64Png
- : (data.overlayUrl || '');
- const beforeSrc = data.beforeFullUrl || data.beforeThumbUrl || '';
- const afterSrc = data.afterFullUrl || data.afterThumbUrl || beforeSrc;
+ const beforeThumb = data.beforeThumbUrl || '';
+ const afterThumb = data.afterThumbUrl || '';
+ // Thumb first (instant); downscaled full as upgrade path / overlay source.
+ const beforeSrc = beforeThumb || ddaViewerSrc(data.beforeFullUrl) || '';
+ const afterSrc = afterThumb || ddaViewerSrc(data.afterFullUrl) || beforeSrc;
+ const overlaySrc = ddaViewerSrc(data.overlayUrl)
+ || (data.overlayBase64Png ? 'data:image/png;base64,' + data.overlayBase64Png : '')
+ || afterSrc;
- ddaViewUrls = { before: beforeSrc, after: afterSrc, overlay: overlaySrc };
+ openDdaResultModal();
+ ddaViewUrls = {
+ before: beforeSrc,
+ after: afterSrc,
+ overlay: overlaySrc,
+ beforeFallback: ddaViewerSrc(data.beforeFullUrl) || beforeThumb,
+ afterFallback: afterThumb || beforeThumb,
+ };
setDdaViewMode('slider');
const beforeImg = document.getElementById('compare-before-img');
@@ -378,7 +424,6 @@ function showDdaResult(data) {
window._ddaPolyResizeBound = true;
window.addEventListener('resize', () => renderRegionPolygons(ddaRegionList));
}
- openDdaResultModal();
}
function renderDdaRegionPage() {
@@ -412,28 +457,49 @@ function setupDdaReviewButtons(tbody, regions) {
if (!runId) return;
tbody.querySelectorAll('tr[data-region-id]').forEach((tr) => {
- tr.querySelector('.btn-review-ok')?.addEventListener('click', async (e) => {
+ const okBtn = tr.querySelector('.btn-review-ok');
+ const fpBtn = tr.querySelector('.btn-review-fp');
+ // Guards against a double-click (or a slow/retried request) firing two
+ // overlapping PATCHes for the same region — previously each could create
+ // its own RegionReview row and inflate the confirmed/false-positive
+ // counts past the actual region count.
+ let inFlight = false;
+ const setRowBusy = (busy) => {
+ inFlight = busy;
+ if (okBtn) okBtn.disabled = busy;
+ if (fpBtn) fpBtn.disabled = busy;
+ };
+
+ okBtn?.addEventListener('click', async (e) => {
e.stopPropagation();
+ if (inFlight) return;
const id = parseInt(tr.dataset.regionId, 10);
const prev = applyRegionReviewLocally(id, 'confirmed');
+ setRowBusy(true);
try {
const updated = await patchRegionReview(runId, id, 'confirmed');
applyRegionReviewLocally(id, updated?.reviewStatus || 'confirmed', updated || {});
} catch (err) {
if (prev) applyRegionReviewLocally(id, prev.reviewStatus || 'pending', prev);
if (typeof showDdaError === 'function') showDdaError(err.message);
+ } finally {
+ setRowBusy(false);
}
});
- tr.querySelector('.btn-review-fp')?.addEventListener('click', async (e) => {
+ fpBtn?.addEventListener('click', async (e) => {
e.stopPropagation();
+ if (inFlight) return;
const id = parseInt(tr.dataset.regionId, 10);
const prev = applyRegionReviewLocally(id, 'false_positive');
+ setRowBusy(true);
try {
const updated = await patchRegionReview(runId, id, 'false_positive');
applyRegionReviewLocally(id, updated?.reviewStatus || 'false_positive', updated || {});
} catch (err) {
if (prev) applyRegionReviewLocally(id, prev.reviewStatus || 'pending', prev);
if (typeof showDdaError === 'function') showDdaError(err.message);
+ } finally {
+ setRowBusy(false);
}
});
tr.querySelector('.btn-review-locate')?.addEventListener('click', (e) => {
@@ -615,18 +681,28 @@ function locateRegionOnViewer(r) {
if (!beforeImg || !wrapper) return;
const runLocate = () => {
- const m0 = getCompareImageMetrics();
- if (!m0) return;
- // Size region to ~40% of the shorter viewport edge (natural→display at zoom 1).
- const basePx = Math.max(r.bbox.w, r.bbox.h) * (wrapper.clientWidth / m0.imgW);
- const desired = Math.min(wrapper.clientWidth, wrapper.clientHeight) * 0.4;
- const nextZoom = desired / Math.max(basePx, 1);
- ddaZoom = Math.min(DDA_ZOOM_MAX, Math.max(1.25, nextZoom));
- applyDdaZoom();
- requestAnimationFrame(() => {
- placeRegionHighlight(r, { pulse: true });
- scrollViewerToRegion(r);
- });
+ // Wait one layout frame so new-tab / fresh modal has real wrapper size.
+ let tries = 0;
+ const attempt = () => {
+ const m0 = getCompareImageMetrics();
+ if ((!m0 || !wrapper.clientWidth) && tries < 20) {
+ tries += 1;
+ requestAnimationFrame(attempt);
+ return;
+ }
+ if (!m0) return;
+ // Size region to ~40% of the shorter viewport edge (natural→display at zoom 1).
+ const basePx = Math.max(r.bbox.w, r.bbox.h) * (wrapper.clientWidth / m0.imgW);
+ const desired = Math.min(wrapper.clientWidth, wrapper.clientHeight) * 0.4;
+ const nextZoom = desired / Math.max(basePx, 1);
+ ddaZoom = Math.min(DDA_ZOOM_MAX, Math.max(1.25, nextZoom));
+ applyDdaZoom();
+ requestAnimationFrame(() => {
+ placeRegionHighlight(r, { pulse: true });
+ scrollViewerToRegion(r);
+ });
+ };
+ requestAnimationFrame(attempt);
};
if (!beforeImg.naturalWidth) {
@@ -667,8 +743,20 @@ function setupDdaRegionHover(tbody, regions) {
function openDdaResultModal() {
const modal = document.getElementById('result-modal');
if (!modal) return;
+ const onReportPage = document.body.id === 'page-report-detail';
+ // On Detect/Reports-list, pin modal to so ancestors cannot clip it.
+ if (!onReportPage && modal.parentElement !== document.body) {
+ document.body.appendChild(modal);
+ }
modal.classList.remove('hidden');
- document.body.style.overflow = 'hidden';
+ // Never freeze scroll on the standalone report page — that cuts the screen.
+ if (!onReportPage) {
+ document.documentElement.style.overflow = 'hidden';
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.documentElement.style.overflow = '';
+ document.body.style.overflow = '';
+ }
}
function resultMapPaths(data) {
@@ -783,9 +871,11 @@ async function loadDdaResultMap() {
function closeDdaResultModal() {
const modal = document.getElementById('result-modal');
if (!modal) return;
+ if (document.body.id === 'page-report-detail') return;
modal.classList.add('hidden');
const picker = document.getElementById('dda-picker-modal');
if (!picker || picker.classList.contains('hidden')) {
+ document.documentElement.style.overflow = '';
document.body.style.overflow = '';
}
if (ddaResultMap) {
@@ -952,6 +1042,60 @@ document.getElementById('dda-toggle-report-btn')?.addEventListener('click', (e)
btn.setAttribute('aria-expanded', String(!nowHidden));
});
+function closeResultTitleEdit() {
+ document.getElementById('result-modal-title-wrap')?.classList.remove('hidden');
+ document.getElementById('result-modal-title-edit')?.classList.add('hidden');
+}
+
+function openResultTitleEdit() {
+ const titleWrap = document.getElementById('result-modal-title-wrap');
+ const editRow = document.getElementById('result-modal-title-edit');
+ const input = document.getElementById('result-title-input');
+ const msg = document.getElementById('result-title-msg');
+ if (!titleWrap || !editRow || !input) return;
+ input.value = ddaCurrentResult?.title || '';
+ if (msg) msg.textContent = '';
+ titleWrap.classList.add('hidden');
+ editRow.classList.remove('hidden');
+ input.focus();
+ input.select();
+}
+
+async function saveResultTitle() {
+ const input = document.getElementById('result-title-input');
+ const msg = document.getElementById('result-title-msg');
+ const saveBtn = document.getElementById('result-title-save-btn');
+ const runId = ddaCurrentResult?.id;
+ if (!input || !runId) return;
+ const title = input.value.trim();
+ if (!title) {
+ if (msg) msg.textContent = 'Title cannot be empty.';
+ return;
+ }
+ if (saveBtn) saveBtn.disabled = true;
+ try {
+ const res = await ddaApi('PATCH', `/api/dda/reports/${runId}/title`, {
+ body: JSON.stringify({ title }),
+ });
+ ddaCurrentResult.title = res.title;
+ const titleEl = document.getElementById('result-modal-title');
+ if (titleEl) titleEl.textContent = res.title;
+ closeResultTitleEdit();
+ } catch (err) {
+ if (msg) msg.textContent = err.message || 'Could not save title.';
+ } finally {
+ if (saveBtn) saveBtn.disabled = false;
+ }
+}
+
+document.getElementById('result-title-edit-btn')?.addEventListener('click', openResultTitleEdit);
+document.getElementById('result-title-cancel-btn')?.addEventListener('click', closeResultTitleEdit);
+document.getElementById('result-title-save-btn')?.addEventListener('click', saveResultTitle);
+document.getElementById('result-title-input')?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.preventDefault(); saveResultTitle(); }
+ if (e.key === 'Escape') { e.preventDefault(); closeResultTitleEdit(); }
+});
+
document.getElementById('result-modal-close')?.addEventListener('click', closeDdaResultModal);
document.getElementById('result-modal')?.addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeDdaResultModal();
diff --git a/static/js/dda/shared.js b/static/js/dda/shared.js
index 69f22d91bebbea34b37c2ca471257db619d87f65..e1e8f09d4449cbf3a432fc2a1debcb62d935dbc2 100644
--- a/static/js/dda/shared.js
+++ b/static/js/dda/shared.js
@@ -21,12 +21,37 @@ function formatApiError(detail) {
return String(detail);
}
+/** `options.timeoutMs` is opt-in (default: none, i.e. today's unlimited-wait
+ * behavior) — only pass it for calls that should normally be fast (a single
+ * PATCH, a small GET), never for long-running work like running a detection,
+ * which can legitimately take minutes. Without it, a stalled network/DB
+ * connection leaves the caller's await pending forever, which is what made
+ * the region-review buttons look like a frozen page instead of a clear
+ * "request failed" error. */
async function ddaApi(method, path, options = {}) {
- const headers = { ...options.headers };
- if (options.body && !(options.body instanceof FormData)) {
+ const { timeoutMs, ...rest } = options;
+ const headers = { ...rest.headers };
+ if (rest.body && !(rest.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
- const res = await fetch(API + path, { method, headers, credentials: 'include', ...options });
+ let signal = rest.signal;
+ let timer = null;
+ if (timeoutMs) {
+ const controller = new AbortController();
+ signal = controller.signal;
+ timer = setTimeout(() => controller.abort(), timeoutMs);
+ }
+ let res;
+ try {
+ res = await fetch(API + path, { method, headers, credentials: 'include', ...rest, signal });
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ throw new Error('Request timed out — the server took too long to respond. Please try again.');
+ }
+ throw err;
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) {}
@@ -79,6 +104,64 @@ function formatBytes(n) {
return (n / 1024).toFixed(0) + ' KB';
}
+/** Structured metadata rows (path, type/format/size/dims/georef, capture/upload
+ * info) for a library image dict (see app/dda/tree/image_service.py::image_to_dict).
+ * Shared by the Image Library viewer and the Change Detection Before/After slots. */
+function buildImageMetaHtml(img) {
+ const esc = (s) => escapeHtml(String(s));
+ const row = (parts) => {
+ const filled = parts.filter(Boolean);
+ return filled.length ? `
${filled.join(' · ')}
` : '';
+ };
+ const path = img.breadcrumb || img.nodePath || '';
+ const place = [img.villageName, img.zoneName].filter(Boolean).join(', ');
+ const size = typeof formatBytes === 'function' ? formatBytes(img.fileSizeBytes) : '';
+ const dims = img.width && img.height ? `${img.width}×${img.height}px` : '';
+ const captureDate = img.captureDate && typeof formatDateIst === 'function'
+ ? formatDateIst(img.captureDate, { withTime: false }) : '';
+ const uploadedOn = img.uploadedOn && typeof formatDateIst === 'function'
+ ? formatDateIst(img.uploadedOn) : '';
+ const uploadedBy = img.uploadedBy ? esc(img.uploadedBy) : '';
+
+ return [
+ row([path ? esc(path) : '']),
+ row([place ? esc(place) : '']),
+ row([
+ img.imageType ? esc(img.imageType) : '',
+ img.source ? esc(img.source) : '',
+ img.year != null ? `Year ${esc(img.year)}` : '',
+ img.format ? esc(img.format) : '',
+ size,
+ dims,
+ img.hasGeoref ? 'Georeferenced' : 'Not georeferenced',
+ ]),
+ row([
+ captureDate ? `Captured ${captureDate}` : '',
+ uploadedOn ? `Uploaded ${uploadedOn}${uploadedBy ? ` by ${uploadedBy}` : ''}` : '',
+ ]),
+ ].join('');
+}
+
+// --- Current user (shared across chip / initDda / home greeting) ---
+
+let _ddaMePromise = null;
+
+/** Fetch `/api/me` once per page load and cache on `window.ddaMe`. */
+function ensureDdaMe() {
+ if (window.ddaMe) return Promise.resolve(window.ddaMe);
+ if (_ddaMePromise) return _ddaMePromise;
+ _ddaMePromise = ddaApi('GET', '/api/me')
+ .then((me) => {
+ window.ddaMe = me;
+ return me;
+ })
+ .catch((err) => {
+ _ddaMePromise = null;
+ throw err;
+ });
+ return _ddaMePromise;
+}
+
// --- Dynamic role permissions (drives show/hide/disable in the UI so it
// always matches whatever's set in the Roles & Users permission matrix,
// without hardcoding any role name in a template or JS file). ---
diff --git a/static/js/dda/tree.js b/static/js/dda/tree.js
index 311729850ca34af62ae28b5739f03afcaa8fa2b1..cf23c834382ad39f80866011c083af00c7686deb 100644
--- a/static/js/dda/tree.js
+++ b/static/js/dda/tree.js
@@ -3,6 +3,22 @@
let treeData = null;
let imageTypes = [];
+// Folders a user has explicitly collapsed, persisted across re-renders
+// (uploads, rescans, node selection) so closing a folder actually sticks
+// instead of snapping back open on the next redraw.
+const collapsedTreeNodeIds = new Set();
+
+function highlightMatch(name, filter) {
+ const escaped = escapeHtml(name);
+ if (!filter) return escaped;
+ const idx = name.toLowerCase().indexOf(filter);
+ if (idx === -1) return escaped;
+ const before = escapeHtml(name.slice(0, idx));
+ const hit = escapeHtml(name.slice(idx, idx + filter.length));
+ const after = escapeHtml(name.slice(idx + filter.length));
+ return `${before}
${hit} ${after}`;
+}
+
const librarySidebar = {
containerId: 'lib-tree',
searchId: 'lib-tree-search',
@@ -54,20 +70,24 @@ function renderTreeNodes(nodes, sidebar, depth = 0) {
const hasKids = children.length > 0;
const count = node.imageCount ? `
(${node.imageCount}) ` : '';
const indent = depth > 0 ? ` style="padding-left:${depth * 0.5}rem"` : '';
+ const label = highlightMatch(name, filter);
if (hasKids) {
+ // While filtering, force every branch open so matches stay visible;
+ // otherwise honor the user's own collapse/expand choice.
+ const isOpen = filter ? true : !collapsedTreeNodeIds.has(node.id);
return `
-
+
${escapeHtml(name)}${count}
+ data-node-path="${escapeHtml(node.nodePath || name)}">${label}${count}
${renderTreeNodes(children, sidebar, depth + 1)}
`;
}
return `
${escapeHtml(name)}${count} `;
+ data-node-path="${escapeHtml(node.nodePath || name)}"${indent}>${label}${count}`;
}).join('');
}
@@ -80,10 +100,14 @@ function renderTreeSidebar(sidebar, tree, types) {
const sel = sidebar.getSelectedNode?.();
const allActive = !sel?.id;
const allBtnId = sidebar.allBtnId || `btn-tree-all-${sidebar.containerId}`;
+ const filterVal = (document.getElementById(sidebar.searchId)?.value || '').trim();
let html = `All images `;
- html += renderTreeNodes(nodes, sidebar);
+ const nodesHtml = renderTreeNodes(nodes, sidebar);
+ html += nodesHtml;
if (!nodes.length) {
html += 'No folders yet. Use Folders master to add zones and areas, or drop a folder onto Upload.
';
+ } else if (filterVal && !nodesHtml.trim()) {
+ html += `No folders match "${escapeHtml(filterVal)}".
`;
}
el.innerHTML = html;
@@ -92,8 +116,22 @@ function renderTreeSidebar(sidebar, tree, types) {
renderAllTrees(treeData, imageTypes);
});
+ el.querySelectorAll('details.dda-tree-node-wrap').forEach((det) => {
+ det.addEventListener('toggle', () => {
+ const id = parseInt(det.dataset.nodeId, 10);
+ if (det.open) collapsedTreeNodeIds.delete(id);
+ else collapsedTreeNodeIds.add(id);
+ });
+ });
+
el.querySelectorAll('.dda-tree-node-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
+ // Without this, clicking the folder name (inside ) also
+ // triggers the browser's native disclosure toggle on the parent
+ // — harmless before collapse state was persisted (every
+ // redraw forced it back open), but a real bug now: selecting a
+ // folder would silently collapse it too.
+ e.preventDefault();
e.stopPropagation();
sidebar.onNodeSelect?.({
id: parseInt(btn.dataset.nodeId, 10),
@@ -123,6 +161,17 @@ document.getElementById('lib-tree-search')?.addEventListener('input', () => {
if (treeData) renderAllTrees(treeData, imageTypes);
});
+document.getElementById('lib-tree-expand-all')?.addEventListener('click', () => {
+ collapsedTreeNodeIds.clear();
+ if (treeData) renderAllTrees(treeData, imageTypes);
+});
+document.getElementById('lib-tree-collapse-all')?.addEventListener('click', () => {
+ flattenNodes(treeData?.tree || treeData || []).forEach((n) => {
+ if ((n.children || []).length) collapsedTreeNodeIds.add(n.id);
+ });
+ if (treeData) renderAllTrees(treeData, imageTypes);
+});
+
document.getElementById('compare-tree-search')?.addEventListener('input', () => {
if (treeData) renderAllTrees(treeData, imageTypes);
});
@@ -171,7 +220,10 @@ function populateNodeSelects(tree) {
function syncUploadNodeSelect() {
const sel = document.getElementById('upload-node');
const node = window.ddaState?.selectedNode;
- if (sel && node?.id) sel.value = String(node.id);
+ if (sel && node?.id) {
+ sel.value = String(node.id);
+ sel.dispatchEvent(new Event('change'));
+ }
}
/* Manage modal */
@@ -306,6 +358,7 @@ function bindLibraryTreeExtras(el) {
? await window.readDroppedEntries(e.dataTransfer)
: Array.from(e.dataTransfer.files || []);
if (!files.length) return;
+ if (typeof setPendingUploadFiles === 'function') setPendingUploadFiles(files);
if (typeof uploadLibraryFiles === 'function') {
await uploadLibraryFiles(files, { nodeId, preserveFolders: true });
}
diff --git a/templates/admin_menu_dda.html b/templates/admin_menu_dda.html
index abc444500f28c50825c557d45eec9410184a2c0e..b6890f4c7ff26bca17325d99ca92d1ea1b3c0b99 100644
--- a/templates/admin_menu_dda.html
+++ b/templates/admin_menu_dda.html
@@ -5,7 +5,9 @@
Menu Management — Vedangsoft Change Detection
-
+
+
+
@@ -87,8 +89,8 @@
-
-
+
+
diff --git a/templates/admin_modules_dda.html b/templates/admin_modules_dda.html
index c5ef82ddb1acea50ebe498d47bb681bcd371e9bc..9d3baf8761cf94f39c7931fb9a12456c3de659d8 100644
--- a/templates/admin_modules_dda.html
+++ b/templates/admin_modules_dda.html
@@ -5,7 +5,9 @@
App Modules — Vedangsoft Change Detection
-
+
+
+
@@ -86,8 +88,8 @@
-
-
+
+
diff --git a/templates/admin_roles_users_dda.html b/templates/admin_roles_users_dda.html
index 3f8128c202d7b63f5b5e9a723d60595dcb3d55f2..33686b984a29440ce417cc208dd5bb2fc52c879e 100644
--- a/templates/admin_roles_users_dda.html
+++ b/templates/admin_roles_users_dda.html
@@ -5,7 +5,9 @@
Roles & Users — Vedangsoft Change Detection
-
+
+
+
@@ -124,9 +126,9 @@
-
+
-
+
diff --git a/templates/audit_log_dda.html b/templates/audit_log_dda.html
index 3f7f3810b68f25e3b552d3017ee5cbcb92661246..5c8d6e773f4b5eb90f9fca525ddfcd3f89a025d9 100644
--- a/templates/audit_log_dda.html
+++ b/templates/audit_log_dda.html
@@ -5,7 +5,9 @@
Audit Log — Vedangsoft Change Detection
-
+
+
+
@@ -14,9 +16,9 @@
{% include "partials/alerts_dda.html" %}
-
+
-
📋 Audit Log
+
Audit Log
User activity audit trail
@@ -55,8 +57,8 @@
-
+
-
+