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 @@ ${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 = ` - + ${rows.map((r) => ` @@ -116,6 +116,8 @@ + + `).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) {
Timestamp (IST)TypeMessagePathCodeHandledDetails
Timestamp (IST)TypeMessagePathCodeUserRoleHandledDetails
${escapeHtml(r.message || '')} ${escapeHtml(r.method || '')} ${escapeHtml(r.path || '')} ${escapeHtml(String(r.statusCode ?? ''))}${escapeHtml(r.userEmail || '—')}${escapeHtml(r.userRole || '—')} ${r.handled ? '✓ Yes' : '✗ No'}
- + + - + + + @@ -203,8 +472,11 @@ function renderDashRegionsTable(regions) { + - + + + @@ -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) {
RegionZone RunsImages Detected ChangesUnclassifiedTop Change TypeClassification (confirmed/FP/submitted/unclassified)Accuracy Villages Avg Change % Last Detection
${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)}
- + + - + + + - ${villages.map((v) => ` - + ${villages.map((v) => { + const key = `${v.zone} ${v.village}`; + return ` + + - + + + - `).join('')} + `; + }).join('')}
RegionZone Village RunsImages Detected ChangesUnclassifiedTop Change TypeClassification (confirmed/FP/submitted/unclassified)Accuracy Avg Change % Last Detection
${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)}
`; 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)}
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)}
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

- + ${data.history.map((r) => ` @@ -364,11 +685,40 @@ function renderDashLocation(data) { - + + + `).join('')}
DateTitleChange %RegionsUnclassified
DateTitleChange %RegionsChange Types (this report)Classification (confirmed/FP/submitted/unclassified)Accuracy
${escapeHtml(r.title)} ${dashPct(r.changePercentage)} ${r.regionsCount}${r.unclassified}${allChangeTypePills(r.changeTypes)}${classificationBar(r)}${accuracyCell(r.confirmedRate)} Report
`; + + 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 = ` - - - - - - - - - - - - - - - ${rows.map((r) => ` - - - - - - - - - - `).join('')} - -
Date (IST)TitleAreaChange %RegionsConfirmedUnclassified
${escapeHtml(r.title)}${escapeHtml(r.zone)} › ${escapeHtml(r.village)}${dashPct(r.changePercentage)}${r.regionsCount}${r.confirmed}${r.unclassified}Report
`; - } 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 = ` +
+ + + ${zoneRows} +
ZoneImages
+ + + ${villageRows} +
ZoneVillageImages
+
+ ${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 = '' + + years.map((y) => ``).join(''); + if (prev) sel.value = prev; + if (filterSel) { + const fprev = filterSel.value; + filterSel.innerHTML = '' + + years.map((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 = `` + list.map((v) => { + const label = zoneId ? v.name : `${v.name} · ${v.zoneName || ''}`; + return ``; + }).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 = '' + uploadLocations.zones + .map((z) => ``).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 = '' + list + .map((v) => ``).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 = '' + uploadLocations.zones + .map((z) => ``).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 = '' + list.map((v) => { + const label = zoneId ? v.name : `${v.name} · ${v.zoneName || ''}`; + return ``; + }).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 = '' + uploadLocations.zones + .map((z) => ``).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 + ? ` + ${rows.map((r) => ``).join('')} +
RowFileReason
${escapeHtml(String(r.row))}${escapeHtml(r.file || '')}${escapeHtml(r.reason || '')}
` + : ''; + 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 ``; }).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' : ''}

@@ -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 ``; + const span = [g.beforeDate, g.afterDate].filter(Boolean).join(' → '); + return ``; }).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 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 + `
${pct}%
Changed
@@ -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 ` -
+
+ data-node-path="${escapeHtml(node.nodePath || name)}">${label}${count}
${renderTreeNodes(children, sidebar, depth + 1)}
`; } return ` `; + 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 = ``; - 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 @@
- + - + diff --git a/templates/detect_dda.html b/templates/detect_dda.html index b7bb50a76fdf30f1f79ecadd5d8eef4bbc5bf29d..f2259487482703a3ee52296c95fcf1011c8c8252 100644 --- a/templates/detect_dda.html +++ b/templates/detect_dda.html @@ -5,8 +5,11 @@ Change Detection — Vedangsoft Change Detection - - + + + + +
@@ -16,6 +19,10 @@ {% include "partials/alerts_dda.html" %}
+
+

Change Detection

+

Compare a before/after image pair and detect changes.

+
-
-
-