coderuday21 Cursor commited on
Commit
bec7397
·
1 Parent(s): 669f7f8

Fix DDA dev bugs and polish from full review pass.

Browse files

Job poll/history fallback, stale job reconciliation, after_full for T2 view, lat/lng on reports/PDF, review submit in file mode, and UI hardening.

Co-authored-by: Cursor <cursoragent@cursor.com>

Dockerfile CHANGED
@@ -21,7 +21,7 @@ WORKDIR /app
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
- ARG APP_BUILD=35
25
  ENV MAX_GEOTIFF_MB=5120
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
 
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
+ ARG APP_BUILD=36
25
  ENV MAX_GEOTIFF_MB=5120
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
app/dda/bootstrap.py CHANGED
@@ -23,18 +23,32 @@ def init_dda_database():
23
  ensure_local_year_folders()
24
  try:
25
  with engine.connect() as conn:
 
 
 
 
 
 
 
 
 
26
  try:
27
- conn.execute(sa_text("ALTER TABLE users ADD COLUMN role VARCHAR(32) DEFAULT 'analyst'"))
 
 
 
28
  conn.commit()
29
  except Exception:
30
  conn.rollback()
31
  except Exception as exc:
32
- logger.warning("DDA user role migration skipped: %s", exc)
33
 
34
  from ..database import SessionLocal
35
  db = SessionLocal()
36
  try:
37
  seed_delhi_hierarchy(db)
 
 
38
  finally:
39
  db.close()
40
 
 
23
  ensure_local_year_folders()
24
  try:
25
  with engine.connect() as conn:
26
+ for stmt in (
27
+ "ALTER TABLE users ADD COLUMN role VARCHAR(32) DEFAULT 'analyst'",
28
+ "ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
29
+ ):
30
+ try:
31
+ conn.execute(sa_text(stmt))
32
+ conn.commit()
33
+ except Exception:
34
+ conn.rollback()
35
  try:
36
+ conn.execute(sa_text(
37
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_dda_region_reviews_run_region "
38
+ "ON dda_region_reviews (run_id, region_id)"
39
+ ))
40
  conn.commit()
41
  except Exception:
42
  conn.rollback()
43
  except Exception as exc:
44
+ logger.warning("DDA schema migration skipped: %s", exc)
45
 
46
  from ..database import SessionLocal
47
  db = SessionLocal()
48
  try:
49
  seed_delhi_hierarchy(db)
50
+ from .job_runner import reconcile_stale_jobs
51
+ reconcile_stale_jobs(db)
52
  finally:
53
  db.close()
54
 
app/dda/dept_export.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
 
4
  import csv
5
  import io
6
- import json
7
  import logging
8
  import os
9
  from dataclasses import dataclass
@@ -12,6 +11,7 @@ from typing import Any, Dict, List, Optional
12
  import requests
13
 
14
  from ..models import DetectionRun
 
15
 
16
  logger = logging.getLogger(__name__)
17
 
@@ -39,8 +39,7 @@ def regions_to_csv_rows(run: DetectionRun, regions: List[dict]) -> str:
39
  "latitude", "longitude", "area_sq_m", "review_status", "notes",
40
  ])
41
  for r in regions:
42
- lat = r.get("latitude") or (r.get("latLng") or {}).get("lat")
43
- lng = r.get("longitude") or (r.get("latLng") or {}).get("lng")
44
  writer.writerow([
45
  run.id,
46
  run.title,
 
3
 
4
  import csv
5
  import io
 
6
  import logging
7
  import os
8
  from dataclasses import dataclass
 
11
  import requests
12
 
13
  from ..models import DetectionRun
14
+ from .geo_regions import region_lat_lng
15
 
16
  logger = logging.getLogger(__name__)
17
 
 
39
  "latitude", "longitude", "area_sq_m", "review_status", "notes",
40
  ])
41
  for r in regions:
42
+ lat, lng = region_lat_lng(r)
 
43
  writer.writerow([
44
  run.id,
45
  run.title,
app/dda/detect_service.py CHANGED
@@ -115,10 +115,17 @@ def run_detection_and_save(
115
  relative_before_full = ""
116
  relative_before_thumb = ""
117
  relative_after_thumb = ""
 
118
  try:
 
 
 
119
  before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
120
  before_for_slider.save(before_full_file)
121
  relative_before_full = f"overlays/{base_name}_before.png"
 
 
 
122
  before_thumb_pil = before_pil.copy()
123
  before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
124
  before_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_before_thumb.png")
@@ -157,6 +164,7 @@ def run_detection_and_save(
157
  before_full_path=relative_before_full,
158
  before_thumb_path=relative_before_thumb,
159
  after_thumb_path=relative_after_thumb,
 
160
  regions_json=json.dumps(regions_serializable),
161
  )
162
  db.add(run)
@@ -204,6 +212,7 @@ def run_detection_and_save(
204
  "beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None,
205
  "beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None,
206
  "afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None,
 
207
  "notificationSent": notification_sent,
208
  "notificationError": notification_error,
209
  "createdAt": _isoformat_ist(run.created_at),
 
115
  relative_before_full = ""
116
  relative_before_thumb = ""
117
  relative_after_thumb = ""
118
+ relative_after_full = ""
119
  try:
120
+ after_for_slider = Image.fromarray(
121
+ preprocess_image(after_pil, max_size=max_size or get_detection_max_size())
122
+ )
123
  before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
124
  before_for_slider.save(before_full_file)
125
  relative_before_full = f"overlays/{base_name}_before.png"
126
+ after_full_file = OVERLAYS_DIR / f"{base_name}_after.png"
127
+ after_for_slider.save(after_full_file)
128
+ relative_after_full = f"overlays/{base_name}_after.png"
129
  before_thumb_pil = before_pil.copy()
130
  before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
131
  before_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_before_thumb.png")
 
164
  before_full_path=relative_before_full,
165
  before_thumb_path=relative_before_thumb,
166
  after_thumb_path=relative_after_thumb,
167
+ after_full_path=relative_after_full,
168
  regions_json=json.dumps(regions_serializable),
169
  )
170
  db.add(run)
 
212
  "beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None,
213
  "beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None,
214
  "afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None,
215
+ "afterFullUrl": f"/api/overlay/{relative_after_full}" if relative_after_full else None,
216
  "notificationSent": notification_sent,
217
  "notificationError": notification_error,
218
  "createdAt": _isoformat_ist(run.created_at),
app/dda/geo_regions.py CHANGED
@@ -112,3 +112,17 @@ def enrich_regions_geo(
112
  enriched["latLng"] = None
113
  out.append(enriched)
114
  return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  enriched["latLng"] = None
113
  out.append(enriched)
114
  return out
115
+
116
+
117
+ def region_lat_lng(region: dict) -> tuple[Optional[float], Optional[float]]:
118
+ """Read lat/lng from region dict (supports latLng object or flat keys)."""
119
+ ll = region.get("latLng") or {}
120
+ lat = region.get("latitude", ll.get("lat") if isinstance(ll, dict) else None)
121
+ lng = region.get("longitude", ll.get("lng") if isinstance(ll, dict) else None)
122
+ if lat is None or lng is None:
123
+ return None, None
124
+ try:
125
+ return float(lat), float(lng)
126
+ except (TypeError, ValueError):
127
+ return None, None
128
+
app/dda/job_runner.py CHANGED
@@ -138,6 +138,33 @@ def is_job_runner_busy() -> bool:
138
  return _active_job_id is not None
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  def create_local_folder_job(
142
  db: Session,
143
  *,
 
138
  return _active_job_id is not None
139
 
140
 
141
+ def reconcile_stale_jobs(db: Session) -> int:
142
+ """Mark orphaned running jobs failed after server restart; re-queue oldest queued job."""
143
+ if is_job_runner_busy():
144
+ return 0
145
+ fixed = 0
146
+ running = db.query(DetectionJob).filter(DetectionJob.status == "running").all()
147
+ for job in running:
148
+ job.status = "failed"
149
+ job.error_message = "Job interrupted (server restarted). Please run detection again."
150
+ job.completed_at = _utcnow()
151
+ fixed += 1
152
+ if fixed:
153
+ db.commit()
154
+ logger.info("Reconciled %d stale running job(s)", fixed)
155
+
156
+ if not is_job_runner_busy():
157
+ next_queued = (
158
+ db.query(DetectionJob)
159
+ .filter(DetectionJob.status == "queued")
160
+ .order_by(DetectionJob.created_at.asc())
161
+ .first()
162
+ )
163
+ if next_queued:
164
+ enqueue_detection_job(next_queued.id)
165
+ return fixed
166
+
167
+
168
  def create_local_folder_job(
169
  db: Session,
170
  *,
app/dda/jobs_routes.py CHANGED
@@ -120,6 +120,7 @@ def get_job(job_id: int, db: Session = Depends(get_db)):
120
  raise
121
  except Exception as exc:
122
  logger.warning("Could not load full run for job %s: %s", job_id, exc)
 
123
  return data
124
 
125
 
@@ -140,6 +141,7 @@ def _run_detail(db: Session, run: DetectionRun, user_id: int) -> dict:
140
  if overlay_file.exists():
141
  overlay_b64 = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
142
 
 
143
  from .detect_service import _isoformat_ist
144
 
145
  return {
@@ -160,7 +162,9 @@ def _run_detail(db: Session, run: DetectionRun, user_id: int) -> dict:
160
  "beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
161
  "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
162
  "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
 
163
  "createdAt": _isoformat_ist(run.created_at),
 
164
  }
165
 
166
 
@@ -172,6 +176,8 @@ def list_jobs(
172
  ):
173
  """Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
174
  _require_dda()
 
 
175
  user = get_or_create_guest_user(db)
176
  q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
177
  if status:
 
120
  raise
121
  except Exception as exc:
122
  logger.warning("Could not load full run for job %s: %s", job_id, exc)
123
+ data["resultError"] = str(exc)[:500]
124
  return data
125
 
126
 
 
141
  if overlay_file.exists():
142
  overlay_b64 = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
143
 
144
+ from .config import get_detection_max_side
145
  from .detect_service import _isoformat_ist
146
 
147
  return {
 
162
  "beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
163
  "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
164
  "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
165
+ "afterFullUrl": f"/api/overlay/{run.after_full_path}" if getattr(run, "after_full_path", None) else None,
166
  "createdAt": _isoformat_ist(run.created_at),
167
+ "detectionMaxSide": get_detection_max_side(),
168
  }
169
 
170
 
 
176
  ):
177
  """Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
178
  _require_dda()
179
+ from .job_runner import reconcile_stale_jobs
180
+ reconcile_stale_jobs(db)
181
  user = get_or_create_guest_user(db)
182
  q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
183
  if status:
app/dda/library_routes.py CHANGED
@@ -69,6 +69,7 @@ def dda_config():
69
  max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
70
  return {
71
  "mode": "dda",
 
72
  "maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
73
  "maxUploadGb": round(max_gb, 2),
74
  "maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
 
69
  max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
70
  return {
71
  "mode": "dda",
72
+ "appMode": "dda",
73
  "maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
74
  "maxUploadGb": round(max_gb, 2),
75
  "maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
app/dda/report_pdf.py CHANGED
@@ -38,6 +38,7 @@ def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False, r
38
  "beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
39
  "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
40
  "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
 
41
  "createdAt": _isoformat_ist(run.created_at),
42
  "pdfUrl": f"/api/dda/reports/{run.id}/pdf",
43
  }
@@ -49,7 +50,7 @@ def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False, r
49
  return payload
50
 
51
 
52
- def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
53
  """Build PDF bytes and suggested download filename."""
54
  from reportlab.lib import colors
55
  from reportlab.lib.pagesizes import A4
@@ -58,7 +59,10 @@ def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
58
  from reportlab.platypus import Image as RLImage
59
  from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
60
 
61
- regions: List[dict] = json.loads(run.regions_json or "[]")
 
 
 
62
  buf = BytesIO()
63
  doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm)
64
  styles = getSampleStyleSheet()
@@ -102,10 +106,9 @@ def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
102
 
103
  story.append(Paragraph("Detected regions", styles["Heading3"]))
104
  if regions:
105
- table_data = [["#", "DDA type", "Internal type", "Conf.", "Area (px)", "Lat", "Lng"]]
106
  for r in regions[:50]:
107
- lat = r.get("latitude") or r.get("lat")
108
- lng = r.get("longitude") or r.get("lng")
109
  table_data.append([
110
  str(r.get("id", "")),
111
  r.get("ddaChangeType") or r.get("objectType") or "—",
@@ -114,6 +117,7 @@ def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
114
  f'{r.get("area", 0):,}',
115
  f"{lat:.5f}" if lat is not None else "—",
116
  f"{lng:.5f}" if lng is not None else "—",
 
117
  ])
118
  tbl = Table(table_data, repeatRows=1, colWidths=[22, 72, 72, 36, 52, 48, 48])
119
  tbl.setStyle(TableStyle([
 
38
  "beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
39
  "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
40
  "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
41
+ "afterFullUrl": f"/api/overlay/{run.after_full_path}" if getattr(run, "after_full_path", None) else None,
42
  "createdAt": _isoformat_ist(run.created_at),
43
  "pdfUrl": f"/api/dda/reports/{run.id}/pdf",
44
  }
 
50
  return payload
51
 
52
 
53
+ def generate_report_pdf(run: DetectionRun, *, regions: Optional[List[dict]] = None) -> tuple[bytes, str]:
54
  """Build PDF bytes and suggested download filename."""
55
  from reportlab.lib import colors
56
  from reportlab.lib.pagesizes import A4
 
59
  from reportlab.platypus import Image as RLImage
60
  from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
61
 
62
+ from .geo_regions import region_lat_lng
63
+
64
+ if regions is None:
65
+ regions = json.loads(run.regions_json or "[]")
66
  buf = BytesIO()
67
  doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm)
68
  styles = getSampleStyleSheet()
 
106
 
107
  story.append(Paragraph("Detected regions", styles["Heading3"]))
108
  if regions:
109
+ table_data = [["#", "DDA type", "Internal type", "Conf.", "Area (px)", "Lat", "Lng", "Review"]]
110
  for r in regions[:50]:
111
+ lat, lng = region_lat_lng(r)
 
112
  table_data.append([
113
  str(r.get("id", "")),
114
  r.get("ddaChangeType") or r.get("objectType") or "—",
 
117
  f'{r.get("area", 0):,}',
118
  f"{lat:.5f}" if lat is not None else "—",
119
  f"{lng:.5f}" if lng is not None else "—",
120
+ r.get("reviewStatus") or "pending",
121
  ])
122
  tbl = Table(table_data, repeatRows=1, colWidths=[22, 72, 72, 36, 52, 48, 48])
123
  tbl.setStyle(TableStyle([
app/dda/reports_routes.py CHANGED
@@ -55,8 +55,9 @@ def download_report_pdf(run_id: int, db: Session = Depends(get_db)):
55
  _require_dda()
56
  user = get_or_create_guest_user(db)
57
  run = _get_user_run(db, run_id, user.id)
 
58
  try:
59
- pdf_bytes, filename = generate_report_pdf(run)
60
  except ImportError as exc:
61
  raise HTTPException(status_code=503, detail="PDF export is not available (reportlab missing)") from exc
62
  except Exception as exc:
@@ -79,8 +80,7 @@ def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get
79
  _require_dda()
80
  user = get_or_create_guest_user(db)
81
  run = _get_user_run(db, run_id, user.id)
82
- import json
83
- regions = json.loads(run.regions_json or "[]")
84
  report_url = f"{get_public_base_url()}/dda/reports/{run.id}"
85
  sent, error = send_notification(
86
  recipient=body.email.strip(),
 
55
  _require_dda()
56
  user = get_or_create_guest_user(db)
57
  run = _get_user_run(db, run_id, user.id)
58
+ regions = merge_reviews(db, run.id, load_regions(run))
59
  try:
60
+ pdf_bytes, filename = generate_report_pdf(run, regions=regions)
61
  except ImportError as exc:
62
  raise HTTPException(status_code=503, detail="PDF export is not available (reportlab missing)") from exc
63
  except Exception as exc:
 
80
  _require_dda()
81
  user = get_or_create_guest_user(db)
82
  run = _get_user_run(db, run_id, user.id)
83
+ regions = merge_reviews(db, run.id, load_regions(run))
 
84
  report_url = f"{get_public_base_url()}/dda/reports/{run.id}"
85
  sent, error = send_notification(
86
  recipient=body.email.strip(),
app/dda/review_routes.py CHANGED
@@ -124,10 +124,7 @@ def submit_confirmed(run_id: int, db: Session = Depends(get_db)):
124
  raise HTTPException(status_code=502, detail=result.message)
125
 
126
  submitted_ids = [int(r["id"]) for r in confirmed if r.get("id") is not None]
127
- if result.mode == "api":
128
- mark_confirmed_submitted(db, run.id, submitted_ids)
129
- for r in confirmed:
130
- r["reviewStatus"] = "submitted"
131
 
132
  return {
133
  "ok": True,
 
124
  raise HTTPException(status_code=502, detail=result.message)
125
 
126
  submitted_ids = [int(r["id"]) for r in confirmed if r.get("id") is not None]
127
+ mark_confirmed_submitted(db, run.id, submitted_ids)
 
 
 
128
 
129
  return {
130
  "ok": True,
app/main.py CHANGED
@@ -485,7 +485,11 @@ def get_run(
485
  regions = _load_regions_json(run.regions_json)
486
  if IS_DDA_MODE:
487
  from .dda.review_service import merge_reviews
 
488
  regions = merge_reviews(db, run.id, regions)
 
 
 
489
  return {
490
  "id": run.id,
491
  "title": run.title,
@@ -503,7 +507,9 @@ def get_run(
503
  "beforeFullUrl": f"/api/overlay/{run.before_full_path}" if (getattr(run, "before_full_path", None) or "").strip() else None,
504
  "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if (getattr(run, "before_thumb_path", None) or "").strip() else None,
505
  "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if (getattr(run, "after_thumb_path", None) or "").strip() else None,
 
506
  "createdAt": _isoformat_ist(run.created_at),
 
507
  }
508
 
509
 
@@ -546,12 +552,17 @@ def delete_run(
546
  if not run:
547
  raise HTTPException(status_code=404, detail="Run not found")
548
  # Delete overlay and thumbnail files if they exist
549
- for path_attr in ("overlay_path", "before_full_path", "before_thumb_path", "after_thumb_path"):
550
  path_val = getattr(run, path_attr, None)
551
  if path_val:
552
  f = OVERLAYS_DIR.parent / path_val
553
  if f.exists():
554
  f.unlink(missing_ok=True)
 
 
 
 
 
555
  db.delete(run)
556
  db.commit()
557
  return {"ok": True, "deleted_id": run_id}
 
485
  regions = _load_regions_json(run.regions_json)
486
  if IS_DDA_MODE:
487
  from .dda.review_service import merge_reviews
488
+ from .dda.config import get_detection_max_side
489
  regions = merge_reviews(db, run.id, regions)
490
+ detection_max_side = get_detection_max_side()
491
+ else:
492
+ detection_max_side = None
493
  return {
494
  "id": run.id,
495
  "title": run.title,
 
507
  "beforeFullUrl": f"/api/overlay/{run.before_full_path}" if (getattr(run, "before_full_path", None) or "").strip() else None,
508
  "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if (getattr(run, "before_thumb_path", None) or "").strip() else None,
509
  "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if (getattr(run, "after_thumb_path", None) or "").strip() else None,
510
+ "afterFullUrl": f"/api/overlay/{run.after_full_path}" if (getattr(run, "after_full_path", None) or "").strip() else None,
511
  "createdAt": _isoformat_ist(run.created_at),
512
+ "detectionMaxSide": detection_max_side,
513
  }
514
 
515
 
 
552
  if not run:
553
  raise HTTPException(status_code=404, detail="Run not found")
554
  # Delete overlay and thumbnail files if they exist
555
+ for path_attr in ("overlay_path", "before_full_path", "before_thumb_path", "after_thumb_path", "after_full_path"):
556
  path_val = getattr(run, path_attr, None)
557
  if path_val:
558
  f = OVERLAYS_DIR.parent / path_val
559
  if f.exists():
560
  f.unlink(missing_ok=True)
561
+ if IS_DDA_MODE:
562
+ from .dda.models import DetectionJob, RegionReview
563
+ db.query(RegionReview).filter(RegionReview.run_id == run_id).delete()
564
+ for job in db.query(DetectionJob).filter(DetectionJob.run_id == run_id).all():
565
+ job.run_id = None
566
  db.delete(run)
567
  db.commit()
568
  return {"ok": True, "deleted_id": run_id}
app/models.py CHANGED
@@ -36,6 +36,7 @@ class DetectionRun(Base):
36
  before_full_path = Column(String(512), default="")
37
  before_thumb_path = Column(String(512), default="")
38
  after_thumb_path = Column(String(512), default="")
 
39
  zone = Column(String(128), default="")
40
  village = Column(String(128), default="")
41
  regions_json = Column(Text, default="[]")
 
36
  before_full_path = Column(String(512), default="")
37
  before_thumb_path = Column(String(512), default="")
38
  after_thumb_path = Column(String(512), default="")
39
+ after_full_path = Column(String(512), default="")
40
  zone = Column(String(128), default="")
41
  village = Column(String(128), default="")
42
  regions_json = Column(Text, default="[]")
library_sources/2026/.gitkeep CHANGED
@@ -0,0 +1 @@
 
 
1
+
static/css/dda.css CHANGED
@@ -478,3 +478,17 @@
478
  0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
479
  50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
480
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
479
  50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
480
  }
481
+
482
+ .regions-table-wrap {
483
+ overflow-x: auto;
484
+ max-width: 100%;
485
+ }
486
+ .regions-table-wrap .regions-table {
487
+ min-width: 960px;
488
+ }
489
+
490
+ @media (max-width: 768px) {
491
+ .dda-compare-slots {
492
+ grid-template-columns: 1fr;
493
+ }
494
+ }
static/js/dda/app.js CHANGED
@@ -1,5 +1,23 @@
1
  const API = '';
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  async function ddaApi(method, path, options = {}) {
4
  const headers = { ...options.headers };
5
  if (options.body && !(options.body instanceof FormData)) {
@@ -9,7 +27,7 @@ async function ddaApi(method, path, options = {}) {
9
  const text = await res.text();
10
  let data = null;
11
  try { data = text ? JSON.parse(text) : null; } catch (_) {}
12
- if (!res.ok) throw new Error(data?.detail || res.statusText || 'Request failed');
13
  return data;
14
  }
15
 
@@ -113,10 +131,16 @@ async function initDda() {
113
  uploadLimit.textContent = `Files on your computer are not on the server. Upload .tif images here (up to ${localCfg.maxUploadGb} GB each).`;
114
  }
115
 
116
- if (!localCfg.isHosted && ddaConfig.appMode !== 'dda') {
 
117
  showDdaError('DDA mode is off. Run locally with: python run.py');
118
  }
119
 
 
 
 
 
 
120
  const yearsData = await ddaApi('GET', '/api/dda/local/years');
121
  localYears = yearsData.years || [];
122
  if (typeof renderYearTree === 'function') renderYearTree(localYears);
@@ -137,7 +161,8 @@ async function loadHierarchyTree() {
137
  el.innerHTML = '<p class="dim">No zones seeded.</p>';
138
  return;
139
  }
140
- el.innerHTML = zones.map((z) => {
 
141
  const villages = z.villages || [];
142
  const zoneCount = villages.reduce((s, v) => s + (v.imageCount || 0), 0);
143
  const villageItems = villages.map((v) =>
@@ -180,11 +205,11 @@ async function loadLibraryImages() {
180
  grid.innerHTML = items.map((img) => {
181
  const thumb = img.thumbUrl ? img.thumbUrl.replace(/path=[^&]+/, 'path=' + encodeURIComponent(img.path)) : '';
182
  return `
183
- <div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '&quot;')}" title="${img.filename}">
184
  ${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
185
  <div class="meta">
186
- <strong>${img.year}</strong><br/>
187
- ${img.filename}<br/>
188
  <span class="dim">${formatBytes(img.fileSizeBytes)}</span>
189
  </div>
190
  </div>`;
 
1
  const API = '';
2
 
3
+ function escapeHtml(text) {
4
+ if (text == null) return '';
5
+ return String(text)
6
+ .replace(/&/g, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;');
10
+ }
11
+
12
+ function formatApiError(detail) {
13
+ if (!detail) return null;
14
+ if (typeof detail === 'string') return detail;
15
+ if (Array.isArray(detail)) {
16
+ return detail.map((d) => (d && d.msg) || JSON.stringify(d)).join('; ');
17
+ }
18
+ return String(detail);
19
+ }
20
+
21
  async function ddaApi(method, path, options = {}) {
22
  const headers = { ...options.headers };
23
  if (options.body && !(options.body instanceof FormData)) {
 
27
  const text = await res.text();
28
  let data = null;
29
  try { data = text ? JSON.parse(text) : null; } catch (_) {}
30
+ if (!res.ok) throw new Error(formatApiError(data?.detail) || res.statusText || 'Request failed');
31
  return data;
32
  }
33
 
 
131
  uploadLimit.textContent = `Files on your computer are not on the server. Upload .tif images here (up to ${localCfg.maxUploadGb} GB each).`;
132
  }
133
 
134
+ const appMode = ddaConfig.appMode || ddaConfig.mode || localCfg.appMode || 'dda';
135
+ if (!localCfg.isHosted && appMode !== 'dda') {
136
  showDdaError('DDA mode is off. Run locally with: python run.py');
137
  }
138
 
139
+ const urlTab = new URLSearchParams(window.location.search).get('tab');
140
+ if (urlTab) {
141
+ document.querySelector(`.dda-tab[data-tab="${urlTab}"]`)?.click();
142
+ }
143
+
144
  const yearsData = await ddaApi('GET', '/api/dda/local/years');
145
  localYears = yearsData.years || [];
146
  if (typeof renderYearTree === 'function') renderYearTree(localYears);
 
161
  el.innerHTML = '<p class="dim">No zones seeded.</p>';
162
  return;
163
  }
164
+ el.innerHTML = `<p class="dim" style="margin-bottom:0.5rem">DB hierarchy (upload API). Local library uses year folders.</p>` +
165
+ zones.map((z) => {
166
  const villages = z.villages || [];
167
  const zoneCount = villages.reduce((s, v) => s + (v.imageCount || 0), 0);
168
  const villageItems = villages.map((v) =>
 
205
  grid.innerHTML = items.map((img) => {
206
  const thumb = img.thumbUrl ? img.thumbUrl.replace(/path=[^&]+/, 'path=' + encodeURIComponent(img.path)) : '';
207
  return `
208
+ <div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '&quot;')}" title="${escapeHtml(img.filename)}">
209
  ${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
210
  <div class="meta">
211
+ <strong>${escapeHtml(String(img.year))}</strong><br/>
212
+ ${escapeHtml(img.filename)}<br/>
213
  <span class="dim">${formatBytes(img.fileSizeBytes)}</span>
214
  </div>
215
  </div>`;
static/js/dda/compare.js CHANGED
@@ -306,7 +306,9 @@ async function runDetectionWithFallback(form, loadingEl) {
306
  return await pollJobUntilDone(queued.jobId, loadingEl);
307
  } catch (err) {
308
  const msg = String(err.message || '');
309
- if (!msg.includes('Not Found') && !msg.includes('404')) throw err;
 
 
310
  loadingEl.textContent = 'Running detection (sync fallback)…';
311
  }
312
  }
@@ -321,9 +323,17 @@ async function pollJobUntilDone(jobId, loadingEl) {
321
  if (loadingEl) {
322
  loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
323
  }
324
- if (status === 'completed' && job.result) {
325
- if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications();
326
- return { result: job.result, jobId };
 
 
 
 
 
 
 
 
327
  }
328
  if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
329
  await new Promise((r) => setTimeout(r, 2000));
 
306
  return await pollJobUntilDone(queued.jobId, loadingEl);
307
  } catch (err) {
308
  const msg = String(err.message || '');
309
+ const useSync = msg.includes('Not Found') || msg.includes('404')
310
+ || msg.includes('503') || msg.includes('409') || msg.includes('busy');
311
+ if (!useSync) throw err;
312
  loadingEl.textContent = 'Running detection (sync fallback)…';
313
  }
314
  }
 
323
  if (loadingEl) {
324
  loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
325
  }
326
+ if (status === 'completed') {
327
+ if (job.result) {
328
+ if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications();
329
+ return { result: job.result, jobId };
330
+ }
331
+ if (job.runId) {
332
+ const data = await ddaApi('GET', `/api/history/${job.runId}`);
333
+ if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications();
334
+ return { result: data, jobId };
335
+ }
336
+ if (job.resultError) throw new Error(job.resultError);
337
  }
338
  if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
339
  await new Promise((r) => setTimeout(r, 2000));
static/js/dda/report_page.js CHANGED
@@ -18,6 +18,13 @@ function formatCoord(v) {
18
  return Number.isFinite(n) ? n.toFixed(5) : '—';
19
  }
20
 
 
 
 
 
 
 
 
21
  async function loadReportPage() {
22
  const runId = parseReportRunId();
23
  const loading = document.getElementById('report-loading');
@@ -58,16 +65,19 @@ async function loadReportPage() {
58
  const regions = data.regions || [];
59
  if (tbody) {
60
  tbody.innerHTML = regions.length
61
- ? regions.map((r) => `
 
 
62
  <tr>
63
  <td>${r.id ?? ''}</td>
64
  <td>${r.ddaChangeType || r.objectType || '—'}</td>
65
  <td>${r.internalObjectType || r.objectType || '—'}</td>
66
  <td>${((r.confidence ?? 0) * 100).toFixed(0)}%</td>
67
  <td>${(r.area ?? 0).toLocaleString()}</td>
68
- <td>${formatCoord(r.latitude ?? r.lat)}</td>
69
- <td>${formatCoord(r.longitude ?? r.lng)}</td>
70
- </tr>`).join('')
 
71
  : '<tr><td colspan="7" class="dim">No regions detected.</td></tr>';
72
  }
73
 
@@ -81,8 +91,8 @@ async function loadReportPage() {
81
  if (viewBtn) {
82
  viewBtn.disabled = false;
83
  viewBtn.onclick = () => {
84
- window.location.href = '/?tab=reports';
85
  try { sessionStorage.setItem('dda_open_run', String(runId)); } catch (_) {}
 
86
  };
87
  }
88
 
 
18
  return Number.isFinite(n) ? n.toFixed(5) : '—';
19
  }
20
 
21
+ function regionLatLng(r) {
22
+ const ll = r.latLng || {};
23
+ const lat = r.latitude ?? ll.lat;
24
+ const lng = r.longitude ?? ll.lng;
25
+ return { lat, lng };
26
+ }
27
+
28
  async function loadReportPage() {
29
  const runId = parseReportRunId();
30
  const loading = document.getElementById('report-loading');
 
65
  const regions = data.regions || [];
66
  if (tbody) {
67
  tbody.innerHTML = regions.length
68
+ ? regions.map((r) => {
69
+ const { lat, lng } = regionLatLng(r);
70
+ return `
71
  <tr>
72
  <td>${r.id ?? ''}</td>
73
  <td>${r.ddaChangeType || r.objectType || '—'}</td>
74
  <td>${r.internalObjectType || r.objectType || '—'}</td>
75
  <td>${((r.confidence ?? 0) * 100).toFixed(0)}%</td>
76
  <td>${(r.area ?? 0).toLocaleString()}</td>
77
+ <td>${formatCoord(lat)}</td>
78
+ <td>${formatCoord(lng)}</td>
79
+ </tr>`;
80
+ }).join('')
81
  : '<tr><td colspan="7" class="dim">No regions detected.</td></tr>';
82
  }
83
 
 
91
  if (viewBtn) {
92
  viewBtn.disabled = false;
93
  viewBtn.onclick = () => {
 
94
  try { sessionStorage.setItem('dda_open_run', String(runId)); } catch (_) {}
95
+ window.location.href = '/?tab=reports';
96
  };
97
  }
98
 
static/js/dda/reports.js CHANGED
@@ -75,7 +75,7 @@ async function loadReportsList() {
75
  ${rows.map((r) => `
76
  <tr>
77
  <td>${formatReportDate(r.createdAt)}</td>
78
- <td>${r.title}</td>
79
  <td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
80
  <td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
81
  <td>${r.regions ?? '—'}</td>
 
75
  ${rows.map((r) => `
76
  <tr>
77
  <td>${formatReportDate(r.createdAt)}</td>
78
+ <td>${escapeHtml(r.title)}</td>
79
  <td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
80
  <td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
81
  <td>${r.regions ?? '—'}</td>
static/js/dda/result.js CHANGED
@@ -74,7 +74,7 @@ function updateDdaReviewSummary(regions) {
74
  const s = r.reviewStatus || 'pending';
75
  counts[s] = (counts[s] || 0) + 1;
76
  });
77
- el.textContent = `Review: ${counts.confirmed} confirmed · ${counts.false_positive} false positive · ${counts.pending} pending`;
78
  }
79
 
80
  function setupDdaReviewBar(runId, regions) {
@@ -156,7 +156,7 @@ function showDdaResult(data) {
156
  ? 'data:image/png;base64,' + data.overlayBase64Png
157
  : (data.overlayUrl || '');
158
  const beforeSrc = data.beforeFullUrl || data.beforeThumbUrl || '';
159
- const afterSrc = data.afterThumbUrl || data.afterFullUrl || beforeSrc;
160
 
161
  ddaViewUrls = { before: beforeSrc, after: afterSrc, overlay: overlaySrc };
162
  setDdaViewMode('slider');
@@ -176,7 +176,7 @@ function showDdaResult(data) {
176
  beforeImg.onload = onReady;
177
  setTimeout(() => { resetDdaCompareSlider(); resetDdaZoom(); }, 500);
178
 
179
- const regions = (data.regions || []).slice(0, 60);
180
  ddaRegionList = regions;
181
  ddaRegionRows = regions.map((r) => {
182
  const tr = document.createElement('tr');
 
74
  const s = r.reviewStatus || 'pending';
75
  counts[s] = (counts[s] || 0) + 1;
76
  });
77
+ el.textContent = `Review: ${counts.confirmed} confirmed · ${counts.false_positive} false positive · ${counts.submitted || 0} submitted · ${counts.pending} pending`;
78
  }
79
 
80
  function setupDdaReviewBar(runId, regions) {
 
156
  ? 'data:image/png;base64,' + data.overlayBase64Png
157
  : (data.overlayUrl || '');
158
  const beforeSrc = data.beforeFullUrl || data.beforeThumbUrl || '';
159
+ const afterSrc = data.afterFullUrl || data.afterThumbUrl || beforeSrc;
160
 
161
  ddaViewUrls = { before: beforeSrc, after: afterSrc, overlay: overlaySrc };
162
  setDdaViewMode('slider');
 
176
  beforeImg.onload = onReady;
177
  setTimeout(() => { resetDdaCompareSlider(); resetDdaZoom(); }, 500);
178
 
179
+ const regions = data.regions || [];
180
  ddaRegionList = regions;
181
  ddaRegionRows = regions.map((r) => {
182
  const tr = document.createElement('tr');
templates/index_dda.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Change Detection</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
- <link rel="stylesheet" href="/static/css/dda.css?v=10" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
@@ -259,11 +259,11 @@
259
  </div>
260
  </div>
261
 
262
- <script src="/static/js/dda/app.js?v=11"></script>
263
  <script src="/static/js/dda/library.js?v=6"></script>
264
- <script src="/static/js/dda/result.js?v=5"></script>
265
- <script src="/static/js/dda/compare.js?v=8"></script>
266
- <script src="/static/js/dda/reports.js?v=3"></script>
267
  <script src="/static/js/dda/notifications.js?v=1"></script>
268
  </body>
269
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Change Detection</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
+ <link rel="stylesheet" href="/static/css/dda.css?v=11" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
 
259
  </div>
260
  </div>
261
 
262
+ <script src="/static/js/dda/app.js?v=12"></script>
263
  <script src="/static/js/dda/library.js?v=6"></script>
264
+ <script src="/static/js/dda/result.js?v=6"></script>
265
+ <script src="/static/js/dda/compare.js?v=9"></script>
266
+ <script src="/static/js/dda/reports.js?v=4"></script>
267
  <script src="/static/js/dda/notifications.js?v=1"></script>
268
  </body>
269
  </html>
templates/report_dda.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Detection Report</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
- <link rel="stylesheet" href="/static/css/dda.css?v=9" />
9
  </head>
10
  <body>
11
  <div class="app dda-app dda-report-page">
@@ -62,6 +62,6 @@
62
  </div>
63
  </div>
64
  <script src="/static/js/dda/app.js?v=11"></script>
65
- <script src="/static/js/dda/report_page.js?v=1"></script>
66
  </body>
67
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Detection Report</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
+ <link rel="stylesheet" href="/static/css/dda.css?v=11" />
9
  </head>
10
  <body>
11
  <div class="app dda-app dda-report-page">
 
62
  </div>
63
  </div>
64
  <script src="/static/js/dda/app.js?v=11"></script>
65
+ <script src="/static/js/dda/report_page.js?v=2"></script>
66
  </body>
67
  </html>