Spaces:
Sleeping
Sleeping
File size: 12,933 Bytes
d70361b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 | """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
@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)
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,
}
@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)
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}
@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)
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}
@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)
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}
@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)
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}
@router.get("/dashboard/location")
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)),
}
@router.get("/dashboard/recent-reports")
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]]}
|