Spaces:
Sleeping
Sleeping
Download app/dda/dashboard_routes.py from coderuday21/satdetect-dev: direct link, hf CLI and curl.
- Browser
- Download file 12.9 kB
-
https://huggingface.co/spaces/coderuday21/satdetect-dev/resolve/main/app/dda/dashboard_routes.py
- Command line
-
hf download hf://spaces/coderuday21/satdetect-dev/app/dda/dashboard_routes.py
-
curl -L -o dashboard_routes.py https://huggingface.co/spaces/coderuday21/satdetect-dev/resolve/main/app/dda/dashboard_routes.py
12.9 kB
| """Dashboard aggregation API. | |
| Backs the app's home dashboard (Change Detection Overview, region/village | |
| breakdowns, location comparisons, trend, recent reports). Scoped to the | |
| requesting user the same way /api/history and reports.js are — own runs | |
| plus any auto-scheduled runs everyone can see — so two different users see | |
| different dashboards, exactly like they already see different Reports lists. | |
| """ | |
| from __future__ import annotations | |
| from collections import defaultdict | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Optional | |
| from fastapi import APIRouter, Depends, HTTPException, Query | |
| from sqlalchemy import or_ | |
| from sqlalchemy.orm import Session | |
| from ..database import get_db | |
| from ..models import DetectionRun, User | |
| from .auto_detect import is_auto_schedule_job | |
| from .dda_auth import current_dda_user | |
| from .models import DetectionJob | |
| from .review_service import bulk_classification_counts | |
| router = APIRouter() | |
| UNASSIGNED = "Unassigned" | |
| DEFAULT_ROLE = "analyst" | |
| # 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"] | |
| MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] | |
| _IST = timezone(timedelta(hours=5, minutes=30)) | |
| def _isoformat_ist(dt): | |
| """Same conversion as detect_service._isoformat_ist, duplicated locally | |
| to avoid importing detect_service (which pulls in geo_regions) just for | |
| date formatting.""" | |
| if dt is None: | |
| return None | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| return dt.astimezone(_IST).isoformat() | |
| def _require_dda(): | |
| from .config import IS_DDA_MODE | |
| if not IS_DDA_MODE: | |
| raise HTTPException(status_code=404, detail="DDA mode is not enabled") | |
| def _run_thumbs(run: DetectionRun) -> dict: | |
| return { | |
| "overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None, | |
| "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if (run.before_thumb_path or "").strip() else None, | |
| "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if (run.after_thumb_path or "").strip() else None, | |
| } | |
| def _run_summary(row: dict) -> dict: | |
| run = row["run"] | |
| return { | |
| "id": run.id, | |
| "title": run.title, | |
| "zone": row["zone"], | |
| "village": row["village"], | |
| "role": row["role"], | |
| "method": run.method, | |
| "changePercentage": run.change_percentage, | |
| "regionsCount": run.regions_count or 0, | |
| "unclassified": row["unclassified"], | |
| "confirmed": row["confirmed"], | |
| "falsePositive": row["falsePositive"], | |
| "createdAt": _isoformat_ist(run.created_at), | |
| **_run_thumbs(run), | |
| } | |
| 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 | |
| to everyone, not just whoever happened to be logged in when it fired).""" | |
| auto_ids = [ | |
| j.run_id for j in ( | |
| db.query(DetectionJob) | |
| .filter(DetectionJob.run_id.isnot(None)) | |
| .order_by(DetectionJob.id.desc()) | |
| .limit(200) | |
| .all() | |
| ) | |
| if is_auto_schedule_job(j) | |
| ] | |
| filt = DetectionRun.user_id == user.id | |
| if auto_ids: | |
| filt = or_(filt, DetectionRun.id.in_(auto_ids)) | |
| return filt | |
| def _load_dataset(db: Session, user: User) -> 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. | |
| """ | |
| runs = ( | |
| db.query(DetectionRun) | |
| .filter(_accessible_run_filter(db, user)) | |
| .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()} | |
| rows = [] | |
| for run in runs: | |
| 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, | |
| "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, | |
| }) | |
| return rows | |
| def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(current_dda_user)): | |
| _require_dda() | |
| rows = _load_dataset(db, user) | |
| 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} | |
| return { | |
| "totalRuns": len(rows), | |
| "totalDetectedChanges": total_detected, | |
| "totalUnclassified": sum(r["unclassified"] for r in rows), | |
| "totalConfirmed": sum(r["confirmed"] for r in rows), | |
| "totalFalsePositive": sum(r["falsePositive"] for r in rows), | |
| "totalSubmitted": sum(r["submitted"] for r in rows), | |
| "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None, | |
| "totalRegions": len(zones), | |
| "totalVillages": len(villages), | |
| "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, | |
| } | |
| def dashboard_regions(db: Session = Depends(get_db), user: User = Depends(current_dda_user)): | |
| _require_dda() | |
| rows = _load_dataset(db, user) | |
| by_zone: dict[str, list] = defaultdict(list) | |
| for r in rows: | |
| by_zone[r["zone"]].append(r) | |
| 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] | |
| 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), | |
| "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None, | |
| "villagesCount": len({i["village"] for i in 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} | |
| 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) | |
| by_role: dict[str, list] = defaultdict(list) | |
| for r in rows: | |
| by_role[r["role"]].append(r) | |
| def _rank(role: str) -> int: | |
| return ROLE_ORDER.index(role) if role in ROLE_ORDER else len(ROLE_ORDER) | |
| out = [] | |
| for role, items in by_role.items(): | |
| pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None] | |
| out.append({ | |
| "role": role, | |
| "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), | |
| "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None, | |
| "usersCount": len({i["run"].user_id for i in items}), | |
| "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} | |
| 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) | |
| 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) | |
| 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] | |
| 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), | |
| "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None, | |
| "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)), | |
| }) | |
| out.sort(key=lambda x: x["runsCount"], reverse=True) | |
| return {"villages": out} | |
| 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) | |
| now = datetime.now(timezone.utc) | |
| keys = [] | |
| y, m = now.year, now.month | |
| for _ in range(months): | |
| keys.append((y, m)) | |
| m -= 1 | |
| if m == 0: | |
| m, y = 12, y - 1 | |
| keys.reverse() | |
| counts = {k: {"runsCount": 0, "detectedChanges": 0} for k in keys} | |
| for r in rows: | |
| dt = r["run"].created_at | |
| if dt is None: | |
| continue | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| key = (dt.year, dt.month) | |
| if key in counts: | |
| counts[key]["runsCount"] += 1 | |
| counts[key]["detectedChanges"] += (r["run"].regions_count or 0) | |
| buckets = [ | |
| { | |
| "period": f"{y}-{m:02d}", | |
| "label": f"{MONTH_NAMES[m]} {y}", | |
| "runsCount": counts[(y, m)]["runsCount"], | |
| "detectedChanges": counts[(y, m)]["detectedChanges"], | |
| } | |
| for (y, m) in keys | |
| ] | |
| return {"buckets": buckets} | |
| def dashboard_location( | |
| zone: str = Query(...), | |
| village: str = Query(...), | |
| db: Session = Depends(get_db), | |
| user: User = Depends(current_dda_user), | |
| ): | |
| _require_dda() | |
| rows = _load_dataset(db, user) | |
| items = [r for r in rows if r["zone"] == zone and r["village"] == village] | |
| items.sort(key=lambda r: r["run"].created_at) | |
| history = [_run_summary(i) for i in items] | |
| latest = history[-1] if history else None | |
| previous = history[-2] if len(history) > 1 else None | |
| comparison = None | |
| if latest and previous: | |
| 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"], | |
| } | |
| return { | |
| "zone": zone, | |
| "village": village, | |
| "runsCount": len(items), | |
| "latest": latest, | |
| "previous": previous, | |
| "comparison": comparison, | |
| "history": list(reversed(history)), | |
| } | |
| def dashboard_recent_reports( | |
| limit: int = Query(8, ge=1, le=50), | |
| db: Session = Depends(get_db), | |
| user: User = Depends(current_dda_user), | |
| ): | |
| _require_dda() | |
| rows = _load_dataset(db, user) | |
| rows.sort(key=lambda r: r["run"].created_at, reverse=True) | |
| return {"reports": [_run_summary(r) for r in rows[:limit]]} | |