Spaces:
Sleeping
Sleeping
Commit ·
c682b53
1
Parent(s): 45a6b1d
Deploy latest uday app code to Hugging Face (skip research binaries).
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +0 -3
- Dockerfile +1 -1
- app/auth.py +16 -1
- app/database.py +14 -1
- app/dda/auto_detect.py +16 -6
- app/dda/bootstrap.py +40 -0
- app/dda/dashboard_routes.py +389 -57
- app/dda/detect_service.py +16 -3
- app/dda/exception_routes.py +1 -0
- app/dda/job_runner.py +9 -2
- app/dda/local_routes.py +31 -8
- app/dda/masters_routes.py +31 -2
- app/dda/rbac/permissions.py +41 -12
- app/dda/rbac/routes.py +4 -12
- app/dda/rbac/seed.py +2 -2
- app/dda/report_pdf.py +18 -1
- app/dda/reports_routes.py +44 -0
- app/dda/review_service.py +19 -1
- app/dda/tree/area_groups.py +550 -112
- app/dda/tree/audit_service.py +2 -0
- app/dda/tree/bulk_upload_service.py +414 -0
- app/dda/tree/image_service.py +312 -15
- app/dda/tree/library_cache.py +14 -11
- app/dda/tree/migration.py +5 -11
- app/dda/tree/models.py +14 -0
- app/dda/tree/routes.py +278 -6
- app/dda/tree/sync_service.py +29 -23
- app/dda/tree/tree_service.py +23 -0
- app/main.py +90 -23
- app/models.py +10 -0
- requirements.txt +1 -0
- scripts/test_area_groups.py +227 -11
- static/css/dda-modernist-admin.css +372 -0
- static/css/dda-modernist-auth.css +195 -0
- static/css/dda-modernist-detect.css +197 -0
- static/css/dda-modernist-library.css +230 -0
- static/css/dda-modernist-logs.css +269 -0
- static/css/dda-modernist-masters-profile.css +252 -0
- static/css/dda-modernist-reports.css +286 -0
- static/css/dda-modernist-result-modal.css +282 -0
- static/css/dda-modernist-sidebar.css +76 -0
- static/css/dda.css +630 -16
- static/css/style.css +64 -4
- static/js/dda/app.js +31 -16
- static/js/dda/audit_log.js +8 -1
- static/js/dda/compare.js +139 -14
- static/js/dda/exception_log.js +6 -3
- static/js/dda/home.js +623 -154
- static/js/dda/library.js +797 -65
- static/js/dda/notifications.js +14 -2
.gitignore
CHANGED
|
@@ -14,9 +14,6 @@ data/library_sources/**/*.tif
|
|
| 14 |
data/library_sources/**/*.tiff
|
| 15 |
data/library_sources/**/*.TIF
|
| 16 |
data/library_sources/**/*.TIFF
|
| 17 |
-
data/library_sources/**/*.png
|
| 18 |
-
data/library_sources/**/*.jpg
|
| 19 |
-
data/library_sources/**/*.jpeg
|
| 20 |
!library_sources/**/.gitkeep
|
| 21 |
.env
|
| 22 |
data/.secret_key
|
|
|
|
| 14 |
data/library_sources/**/*.tiff
|
| 15 |
data/library_sources/**/*.TIF
|
| 16 |
data/library_sources/**/*.TIFF
|
|
|
|
|
|
|
|
|
|
| 17 |
!library_sources/**/.gitkeep
|
| 18 |
.env
|
| 19 |
data/.secret_key
|
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=
|
| 25 |
ENV MAX_GEOTIFF_MB=15360
|
| 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=37
|
| 25 |
ENV MAX_GEOTIFF_MB=15360
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
app/auth.py
CHANGED
|
@@ -201,6 +201,14 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
|
| 201 |
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 30
|
| 205 |
|
| 206 |
|
|
@@ -371,7 +379,14 @@ def get_user_from_token(token: str, db: Session, *, allow_pending_otp: bool = Fa
|
|
| 371 |
return None
|
| 372 |
except JWTError:
|
| 373 |
return None
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
|
| 377 |
LOGIN_OTP_EXPIRE_MINUTES = 10
|
|
|
|
| 201 |
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 202 |
|
| 203 |
|
| 204 |
+
def revoke_user_sessions(user: User) -> None:
|
| 205 |
+
"""Invalidate every previously-issued session token for `user` (any tab,
|
| 206 |
+
device, or leaked copy) by bumping token_version — get_user_from_token()
|
| 207 |
+
rejects any token whose embedded 'tv' no longer matches. Caller commits.
|
| 208 |
+
"""
|
| 209 |
+
user.token_version = int(user.token_version or 0) + 1
|
| 210 |
+
|
| 211 |
+
|
| 212 |
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 30
|
| 213 |
|
| 214 |
|
|
|
|
| 379 |
return None
|
| 380 |
except JWTError:
|
| 381 |
return None
|
| 382 |
+
user = get_user_by_id(db, user_id)
|
| 383 |
+
if user is None:
|
| 384 |
+
return None
|
| 385 |
+
if purpose == "session" and int(payload.get("tv", 0)) != int(user.token_version or 0):
|
| 386 |
+
# Token was issued before a logout / password change bumped
|
| 387 |
+
# token_version — reject it even though it hasn't expired yet.
|
| 388 |
+
return None
|
| 389 |
+
return user
|
| 390 |
|
| 391 |
|
| 392 |
LOGIN_OTP_EXPIRE_MINUTES = 10
|
app/database.py
CHANGED
|
@@ -28,15 +28,28 @@ if DATABASE_URL.startswith("sqlite"):
|
|
| 28 |
# timeout = busy wait while another connection holds the write lock
|
| 29 |
# (progress updates vs the long-running detection worker).
|
| 30 |
connect_args = {"check_same_thread": False, "timeout": 60.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# pool_pre_ping: checks each connection before use and transparently reconnects
|
| 33 |
# if it's gone stale — matters for a remote DB (MySQL/Postgres) reached over
|
| 34 |
# the network, where idle connections can be dropped by the server or a
|
| 35 |
# firewall; irrelevant (and skipped) for local SQLite.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
engine = create_engine(
|
| 37 |
DATABASE_URL,
|
| 38 |
connect_args=connect_args,
|
| 39 |
-
pool_pre_ping=not
|
|
|
|
| 40 |
)
|
| 41 |
|
| 42 |
|
|
|
|
| 28 |
# timeout = busy wait while another connection holds the write lock
|
| 29 |
# (progress updates vs the long-running detection worker).
|
| 30 |
connect_args = {"check_same_thread": False, "timeout": 60.0}
|
| 31 |
+
elif DATABASE_URL.startswith("mysql"):
|
| 32 |
+
# connect_timeout: fail a broken/unreachable connection attempt in 10s
|
| 33 |
+
# instead of hanging the request (and the page) indefinitely — matters
|
| 34 |
+
# for a remote MySQL host reached over the network.
|
| 35 |
+
connect_args = {"connect_timeout": 10}
|
| 36 |
+
|
| 37 |
+
_is_sqlite = DATABASE_URL.startswith("sqlite")
|
| 38 |
|
| 39 |
# pool_pre_ping: checks each connection before use and transparently reconnects
|
| 40 |
# if it's gone stale — matters for a remote DB (MySQL/Postgres) reached over
|
| 41 |
# the network, where idle connections can be dropped by the server or a
|
| 42 |
# firewall; irrelevant (and skipped) for local SQLite.
|
| 43 |
+
# pool_recycle: proactively discard and replace any pooled connection older
|
| 44 |
+
# than 280s — shorter than the "wait_timeout" most managed MySQL hosts use to
|
| 45 |
+
# silently drop idle connections server-side, so the pool never hands out a
|
| 46 |
+
# connection the server has already closed (seen in production as
|
| 47 |
+
# "OperationalError 2013: Lost connection to MySQL server during query").
|
| 48 |
engine = create_engine(
|
| 49 |
DATABASE_URL,
|
| 50 |
connect_args=connect_args,
|
| 51 |
+
pool_pre_ping=not _is_sqlite,
|
| 52 |
+
pool_recycle=280 if not _is_sqlite else -1,
|
| 53 |
)
|
| 54 |
|
| 55 |
|
app/dda/auto_detect.py
CHANGED
|
@@ -17,6 +17,7 @@ from datetime import datetime, timedelta, timezone
|
|
| 17 |
from pathlib import Path
|
| 18 |
from typing import Any, Optional
|
| 19 |
|
|
|
|
| 20 |
from sqlalchemy.orm import Session
|
| 21 |
|
| 22 |
from ..database import SessionLocal
|
|
@@ -257,13 +258,18 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
|
|
| 257 |
live_keys = set()
|
| 258 |
now = _utcnow()
|
| 259 |
for pair in pairs:
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
live_keys.add(key)
|
| 262 |
row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
|
| 263 |
if row:
|
| 264 |
row.group_label = pair["label"]
|
| 265 |
-
row.before_path =
|
| 266 |
-
row.after_path =
|
| 267 |
row.interval_days = interval
|
| 268 |
row.enabled = True
|
| 269 |
row.updated_at = now
|
|
@@ -271,8 +277,8 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
|
|
| 271 |
db.add(AutoPairSchedule(
|
| 272 |
pair_key=key,
|
| 273 |
group_label=pair["label"],
|
| 274 |
-
before_path=
|
| 275 |
-
after_path=
|
| 276 |
interval_days=interval,
|
| 277 |
enabled=True,
|
| 278 |
))
|
|
@@ -285,7 +291,11 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
|
|
| 285 |
if row.pair_key not in live_keys:
|
| 286 |
row.enabled = False
|
| 287 |
row.updated_at = now
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
|
| 290 |
|
| 291 |
def _pending_job_for_paths(db: Session, before: str, after: str) -> Optional[DetectionJob]:
|
|
|
|
| 17 |
from pathlib import Path
|
| 18 |
from typing import Any, Optional
|
| 19 |
|
| 20 |
+
from sqlalchemy.exc import IntegrityError
|
| 21 |
from sqlalchemy.orm import Session
|
| 22 |
|
| 23 |
from ..database import SessionLocal
|
|
|
|
| 258 |
live_keys = set()
|
| 259 |
now = _utcnow()
|
| 260 |
for pair in pairs:
|
| 261 |
+
before, after = pair["before_path"], pair["after_path"]
|
| 262 |
+
if not before or not after or before == after:
|
| 263 |
+
continue
|
| 264 |
+
key = pair_key_for(before, after)
|
| 265 |
+
if key in live_keys:
|
| 266 |
+
continue
|
| 267 |
live_keys.add(key)
|
| 268 |
row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
|
| 269 |
if row:
|
| 270 |
row.group_label = pair["label"]
|
| 271 |
+
row.before_path = before
|
| 272 |
+
row.after_path = after
|
| 273 |
row.interval_days = interval
|
| 274 |
row.enabled = True
|
| 275 |
row.updated_at = now
|
|
|
|
| 277 |
db.add(AutoPairSchedule(
|
| 278 |
pair_key=key,
|
| 279 |
group_label=pair["label"],
|
| 280 |
+
before_path=before,
|
| 281 |
+
after_path=after,
|
| 282 |
interval_days=interval,
|
| 283 |
enabled=True,
|
| 284 |
))
|
|
|
|
| 291 |
if row.pair_key not in live_keys:
|
| 292 |
row.enabled = False
|
| 293 |
row.updated_at = now
|
| 294 |
+
try:
|
| 295 |
+
db.commit()
|
| 296 |
+
except IntegrityError:
|
| 297 |
+
db.rollback()
|
| 298 |
+
logger.warning("Auto-pair schedule sync hit a duplicate pair_key; it will retry on the next tick.")
|
| 299 |
|
| 300 |
|
| 301 |
def _pending_job_for_paths(db: Session, before: str, after: str) -> Optional[DetectionJob]:
|
app/dda/bootstrap.py
CHANGED
|
@@ -103,10 +103,37 @@ def init_dda_database():
|
|
| 103 |
"ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0",
|
| 104 |
"ALTER TABLE users ADD COLUMN totp_confirmed_at DATETIME DEFAULT NULL",
|
| 105 |
"ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL",
|
|
|
|
| 106 |
"ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
|
|
|
|
|
|
|
| 107 |
"ALTER TABLE dda_roles ADD COLUMN is_active BOOLEAN DEFAULT 1",
|
| 108 |
"ALTER TABLE dda_audit_logs ADD COLUMN role VARCHAR(64) DEFAULT ''",
|
| 109 |
"ALTER TABLE dda_audit_logs ADD COLUMN module VARCHAR(64) DEFAULT ''",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
):
|
| 111 |
try:
|
| 112 |
conn.execute(sa_text(stmt))
|
|
@@ -128,6 +155,19 @@ def init_dda_database():
|
|
| 128 |
conn.commit()
|
| 129 |
except Exception:
|
| 130 |
conn.rollback()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
_migrate_detection_jobs_nullable()
|
| 132 |
except Exception as exc:
|
| 133 |
logger.warning("DDA schema migration skipped: %s", exc)
|
|
|
|
| 103 |
"ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0",
|
| 104 |
"ALTER TABLE users ADD COLUMN totp_confirmed_at DATETIME DEFAULT NULL",
|
| 105 |
"ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL",
|
| 106 |
+
"ALTER TABLE users ADD COLUMN token_version INTEGER DEFAULT 0",
|
| 107 |
"ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
|
| 108 |
+
"ALTER TABLE detection_runs ADD COLUMN detection_sensitivity FLOAT DEFAULT NULL",
|
| 109 |
+
"ALTER TABLE detection_runs ADD COLUMN min_region_area INTEGER DEFAULT NULL",
|
| 110 |
"ALTER TABLE dda_roles ADD COLUMN is_active BOOLEAN DEFAULT 1",
|
| 111 |
"ALTER TABLE dda_audit_logs ADD COLUMN role VARCHAR(64) DEFAULT ''",
|
| 112 |
"ALTER TABLE dda_audit_logs ADD COLUMN module VARCHAR(64) DEFAULT ''",
|
| 113 |
+
"ALTER TABLE dda_image_library ADD COLUMN zone_id INTEGER DEFAULT NULL",
|
| 114 |
+
"ALTER TABLE dda_image_library ADD COLUMN village_id INTEGER DEFAULT NULL",
|
| 115 |
+
"ALTER TABLE dda_image_library ADD COLUMN year INTEGER DEFAULT NULL",
|
| 116 |
+
"ALTER TABLE dda_image_library ADD COLUMN source VARCHAR(32) DEFAULT ''",
|
| 117 |
+
"ALTER TABLE dda_exception_logs ADD COLUMN user_role VARCHAR(64) DEFAULT NULL",
|
| 118 |
+
):
|
| 119 |
+
try:
|
| 120 |
+
conn.execute(sa_text(stmt))
|
| 121 |
+
conn.commit()
|
| 122 |
+
except Exception:
|
| 123 |
+
conn.rollback()
|
| 124 |
+
# Enforce "every image has a zone and village" at the database
|
| 125 |
+
# level, not just in application code — this used to be
|
| 126 |
+
# optional, which is how thousands of images ended up
|
| 127 |
+
# "Unassigned" (bulk upload with no location columns, and the
|
| 128 |
+
# filesystem sync auto-indexing files with none to give).
|
| 129 |
+
# MySQL-only (SQLite can't ALTER a column's nullability); this
|
| 130 |
+
# fails harmlessly and keeps retrying on every startup until any
|
| 131 |
+
# pre-existing NULL rows are cleaned up, at which point it
|
| 132 |
+
# sticks — same best-effort pattern as every other statement
|
| 133 |
+
# above.
|
| 134 |
+
for stmt in (
|
| 135 |
+
"ALTER TABLE dda_image_library MODIFY COLUMN zone_id INTEGER NOT NULL",
|
| 136 |
+
"ALTER TABLE dda_image_library MODIFY COLUMN village_id INTEGER NOT NULL",
|
| 137 |
):
|
| 138 |
try:
|
| 139 |
conn.execute(sa_text(stmt))
|
|
|
|
| 155 |
conn.commit()
|
| 156 |
except Exception:
|
| 157 |
conn.rollback()
|
| 158 |
+
for stmt in (
|
| 159 |
+
"CREATE INDEX IF NOT EXISTS ix_dda_image_library_zone_id "
|
| 160 |
+
"ON dda_image_library (zone_id)",
|
| 161 |
+
"CREATE INDEX IF NOT EXISTS ix_dda_image_library_village_id "
|
| 162 |
+
"ON dda_image_library (village_id)",
|
| 163 |
+
"CREATE INDEX IF NOT EXISTS ix_dda_image_library_year "
|
| 164 |
+
"ON dda_image_library (year)",
|
| 165 |
+
):
|
| 166 |
+
try:
|
| 167 |
+
conn.execute(sa_text(stmt))
|
| 168 |
+
conn.commit()
|
| 169 |
+
except Exception:
|
| 170 |
+
conn.rollback()
|
| 171 |
_migrate_detection_jobs_nullable()
|
| 172 |
except Exception as exc:
|
| 173 |
logger.warning("DDA schema migration skipped: %s", exc)
|
app/dda/dashboard_routes.py
CHANGED
|
@@ -8,6 +8,7 @@ different dashboards, exactly like they already see different Reports lists.
|
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 11 |
from collections import defaultdict
|
| 12 |
from datetime import datetime, timedelta, timezone
|
| 13 |
from typing import Optional
|
|
@@ -28,6 +29,12 @@ router = APIRouter()
|
|
| 28 |
UNASSIGNED = "Unassigned"
|
| 29 |
DEFAULT_ROLE = "analyst"
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# Matches DEFAULT_ROLES rank order in app/dda/rbac/seed.py — keeps the role
|
| 32 |
# breakdown in a stable, meaningful order instead of alphabetical/count order.
|
| 33 |
ROLE_ORDER = ["viewer", "uploader", "analyst", "admin"]
|
|
@@ -62,6 +69,17 @@ def _run_thumbs(run: DetectionRun) -> dict:
|
|
| 62 |
}
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def _run_summary(row: dict) -> dict:
|
| 66 |
run = row["run"]
|
| 67 |
return {
|
|
@@ -76,11 +94,49 @@ def _run_summary(row: dict) -> dict:
|
|
| 76 |
"unclassified": row["unclassified"],
|
| 77 |
"confirmed": row["confirmed"],
|
| 78 |
"falsePositive": row["falsePositive"],
|
|
|
|
|
|
|
|
|
|
| 79 |
"createdAt": _isoformat_ist(run.created_at),
|
| 80 |
**_run_thumbs(run),
|
| 81 |
}
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
def _accessible_run_filter(db: Session, user: User):
|
| 85 |
"""Same run-visibility rule as GET /api/history in app/main.py: the
|
| 86 |
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):
|
|
| 101 |
return filt
|
| 102 |
|
| 103 |
|
| 104 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
"""One query for every accessible run + one grouped query for
|
| 106 |
classification counts; every endpoint below aggregates from this in
|
| 107 |
Python — mirrors the simple query style already used by
|
| 108 |
admin_routes.admin_status(). Runs with a blank zone/village are bucketed
|
| 109 |
-
under "Unassigned" so totals reconcile
|
|
|
|
|
|
|
|
|
|
| 110 |
"""
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
| 117 |
counts = bulk_classification_counts(db)
|
| 118 |
# Same role resolution as dda_auth.get_user_role (raw User.role string,
|
| 119 |
# falling back to "analyst") but bulk-loaded to avoid a query per run.
|
| 120 |
roles_by_user = {uid: (role or DEFAULT_ROLE) for uid, role in db.query(User.id, User.role).all()}
|
|
|
|
|
|
|
| 121 |
rows = []
|
| 122 |
for run in runs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
c = counts.get(run.id, {})
|
| 124 |
classified = c.get("confirmed", 0) + c.get("false_positive", 0) + c.get("submitted", 0)
|
| 125 |
unclassified = max(0, (run.regions_count or 0) - classified)
|
| 126 |
rows.append({
|
| 127 |
"run": run,
|
| 128 |
-
"zone":
|
| 129 |
-
"village":
|
| 130 |
"role": roles_by_user.get(run.user_id, DEFAULT_ROLE),
|
| 131 |
"confirmed": c.get("confirmed", 0),
|
| 132 |
"falsePositive": c.get("false_positive", 0),
|
| 133 |
"submitted": c.get("submitted", 0),
|
| 134 |
"unclassified": unclassified,
|
|
|
|
| 135 |
})
|
| 136 |
return rows
|
| 137 |
|
| 138 |
|
| 139 |
-
|
| 140 |
-
def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 141 |
-
_require_dda()
|
| 142 |
-
rows = _load_dataset(db, user)
|
| 143 |
-
|
| 144 |
total_detected = sum((r["run"].regions_count or 0) for r in rows)
|
| 145 |
pct_values = [r["run"].change_percentage for r in rows if r["run"].change_percentage is not None]
|
| 146 |
zones = {r["zone"] for r in rows}
|
| 147 |
villages = {(r["zone"], r["village"]) for r in rows}
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
return {
|
| 150 |
"totalRuns": len(rows),
|
| 151 |
"totalDetectedChanges": total_detected,
|
|
@@ -156,43 +305,46 @@ def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(curren
|
|
| 156 |
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 157 |
"totalRegions": len(zones),
|
| 158 |
"totalVillages": len(villages),
|
|
|
|
|
|
|
|
|
|
| 159 |
"jobsQueued": db.query(DetectionJob).filter(DetectionJob.status == "queued", DetectionJob.created_by == user.id).count(),
|
| 160 |
"jobsRunning": db.query(DetectionJob).filter(DetectionJob.status == "running", DetectionJob.created_by == user.id).count(),
|
| 161 |
"latestRun": _run_summary(rows[-1]) if rows else None,
|
| 162 |
}
|
| 163 |
|
| 164 |
|
| 165 |
-
|
| 166 |
-
def dashboard_regions(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 167 |
-
_require_dda()
|
| 168 |
-
rows = _load_dataset(db, user)
|
| 169 |
by_zone: dict[str, list] = defaultdict(list)
|
| 170 |
for r in rows:
|
| 171 |
by_zone[r["zone"]].append(r)
|
|
|
|
| 172 |
|
| 173 |
out = []
|
| 174 |
for zone, items in by_zone.items():
|
| 175 |
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
|
|
|
|
|
|
|
| 176 |
out.append({
|
| 177 |
"zone": zone,
|
| 178 |
"runsCount": len(items),
|
| 179 |
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
|
| 180 |
"unclassified": sum(i["unclassified"] for i in items),
|
| 181 |
-
"confirmed":
|
| 182 |
-
"falsePositive":
|
|
|
|
|
|
|
| 183 |
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 184 |
"villagesCount": len({i["village"] for i in items}),
|
|
|
|
|
|
|
| 185 |
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 186 |
})
|
| 187 |
out.sort(key=lambda x: x["runsCount"], reverse=True)
|
| 188 |
-
return
|
| 189 |
|
| 190 |
|
| 191 |
-
|
| 192 |
-
def dashboard_roles(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 193 |
-
"""Detection activity grouped by the role of the user who ran it."""
|
| 194 |
-
_require_dda()
|
| 195 |
-
rows = _load_dataset(db, user)
|
| 196 |
by_role: dict[str, list] = defaultdict(list)
|
| 197 |
for r in rows:
|
| 198 |
by_role[r["role"]].append(r)
|
|
@@ -215,50 +367,42 @@ def dashboard_roles(db: Session = Depends(get_db), user: User = Depends(current_
|
|
| 215 |
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 216 |
})
|
| 217 |
out.sort(key=lambda x: (_rank(x["role"]), x["role"]))
|
| 218 |
-
return
|
| 219 |
|
| 220 |
|
| 221 |
-
|
| 222 |
-
def dashboard_villages(
|
| 223 |
-
zone: Optional[str] = Query(None),
|
| 224 |
-
db: Session = Depends(get_db),
|
| 225 |
-
user: User = Depends(current_dda_user),
|
| 226 |
-
):
|
| 227 |
-
_require_dda()
|
| 228 |
-
rows = _load_dataset(db, user)
|
| 229 |
if zone:
|
| 230 |
rows = [r for r in rows if r["zone"] == zone]
|
| 231 |
by_village: dict[tuple, list] = defaultdict(list)
|
| 232 |
for r in rows:
|
| 233 |
by_village[(r["zone"], r["village"])].append(r)
|
|
|
|
| 234 |
|
| 235 |
out = []
|
| 236 |
for (z, v), items in by_village.items():
|
| 237 |
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
|
|
|
|
|
|
|
| 238 |
out.append({
|
| 239 |
"zone": z,
|
| 240 |
"village": v,
|
| 241 |
"runsCount": len(items),
|
| 242 |
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
|
| 243 |
"unclassified": sum(i["unclassified"] for i in items),
|
| 244 |
-
"confirmed":
|
| 245 |
-
"falsePositive":
|
|
|
|
|
|
|
| 246 |
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
|
|
|
|
|
|
| 247 |
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 248 |
})
|
| 249 |
out.sort(key=lambda x: x["runsCount"], reverse=True)
|
| 250 |
-
return
|
| 251 |
|
| 252 |
|
| 253 |
-
|
| 254 |
-
def dashboard_trend(
|
| 255 |
-
months: int = Query(6, ge=1, le=24),
|
| 256 |
-
db: Session = Depends(get_db),
|
| 257 |
-
user: User = Depends(current_dda_user),
|
| 258 |
-
):
|
| 259 |
-
_require_dda()
|
| 260 |
-
rows = _load_dataset(db, user)
|
| 261 |
-
|
| 262 |
now = datetime.now(timezone.utc)
|
| 263 |
keys = []
|
| 264 |
y, m = now.year, now.month
|
|
@@ -281,7 +425,7 @@ def dashboard_trend(
|
|
| 281 |
counts[key]["runsCount"] += 1
|
| 282 |
counts[key]["detectedChanges"] += (r["run"].regions_count or 0)
|
| 283 |
|
| 284 |
-
|
| 285 |
{
|
| 286 |
"period": f"{y}-{m:02d}",
|
| 287 |
"label": f"{MONTH_NAMES[m]} {y}",
|
|
@@ -290,7 +434,87 @@ def dashboard_trend(
|
|
| 290 |
}
|
| 291 |
for (y, m) in keys
|
| 292 |
]
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
|
| 296 |
@router.get("/dashboard/location")
|
|
@@ -310,15 +534,28 @@ def dashboard_location(
|
|
| 310 |
previous = history[-2] if len(history) > 1 else None
|
| 311 |
|
| 312 |
comparison = None
|
|
|
|
| 313 |
if latest and previous:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
comparison = {
|
| 315 |
-
"changePercentageDelta":
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
"
|
| 321 |
}
|
|
|
|
| 322 |
|
| 323 |
return {
|
| 324 |
"zone": zone,
|
|
@@ -327,17 +564,112 @@ def dashboard_location(
|
|
| 327 |
"latest": latest,
|
| 328 |
"previous": previous,
|
| 329 |
"comparison": comparison,
|
|
|
|
| 330 |
"history": list(reversed(history)),
|
| 331 |
}
|
| 332 |
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
@router.get("/dashboard/recent-reports")
|
| 335 |
def dashboard_recent_reports(
|
| 336 |
limit: int = Query(8, ge=1, le=50),
|
|
|
|
|
|
|
| 337 |
db: Session = Depends(get_db),
|
| 338 |
user: User = Depends(current_dda_user),
|
| 339 |
):
|
| 340 |
_require_dda()
|
| 341 |
-
rows = _load_dataset(db, user)
|
| 342 |
rows.sort(key=lambda r: r["run"].created_at, reverse=True)
|
| 343 |
return {"reports": [_run_summary(r) for r in rows[:limit]]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import json
|
| 12 |
from collections import defaultdict
|
| 13 |
from datetime import datetime, timedelta, timezone
|
| 14 |
from typing import Optional
|
|
|
|
| 29 |
UNASSIGNED = "Unassigned"
|
| 30 |
DEFAULT_ROLE = "analyst"
|
| 31 |
|
| 32 |
+
# Fixed taxonomy every region is already classified into — see
|
| 33 |
+
# app/dda/change_type_map.py::map_to_dda_change_type. Kept here (not
|
| 34 |
+
# imported) purely to pin a stable display order across every chart/table
|
| 35 |
+
# that breaks a count down by change type.
|
| 36 |
+
CHANGE_TYPE_ORDER = ["New Construction", "Demolition", "Extension", "Vegetation Change", "Other"]
|
| 37 |
+
|
| 38 |
# Matches DEFAULT_ROLES rank order in app/dda/rbac/seed.py — keeps the role
|
| 39 |
# breakdown in a stable, meaningful order instead of alphabetical/count order.
|
| 40 |
ROLE_ORDER = ["viewer", "uploader", "analyst", "admin"]
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
|
| 72 |
+
def _confirmed_rate(confirmed: int, false_positive: int) -> Optional[float]:
|
| 73 |
+
"""'Accuracy' for a detection: of the regions a human has actually
|
| 74 |
+
adjudicated as real-or-not, what share were real? None (not 0%) when
|
| 75 |
+
nothing's been reviewed yet, so the UI can show "not yet reviewed"
|
| 76 |
+
instead of a misleading 0%."""
|
| 77 |
+
denom = confirmed + false_positive
|
| 78 |
+
if denom == 0:
|
| 79 |
+
return None
|
| 80 |
+
return round(100 * confirmed / denom, 1)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
def _run_summary(row: dict) -> dict:
|
| 84 |
run = row["run"]
|
| 85 |
return {
|
|
|
|
| 94 |
"unclassified": row["unclassified"],
|
| 95 |
"confirmed": row["confirmed"],
|
| 96 |
"falsePositive": row["falsePositive"],
|
| 97 |
+
"submitted": row["submitted"],
|
| 98 |
+
"confirmedRate": _confirmed_rate(row["confirmed"], row["falsePositive"]),
|
| 99 |
+
"changeTypes": row["changeTypes"],
|
| 100 |
"createdAt": _isoformat_ist(run.created_at),
|
| 101 |
**_run_thumbs(run),
|
| 102 |
}
|
| 103 |
|
| 104 |
|
| 105 |
+
def _image_counts_by_location(db: Session) -> tuple[dict[str, int], dict[tuple[str, str], int]]:
|
| 106 |
+
"""Image Library counts grouped by zone name and by (zone, village) name
|
| 107 |
+
— joined through the same DdaZone/DdaVillage masters the dashboard's
|
| 108 |
+
zone/village strings already come from, so the names line up exactly
|
| 109 |
+
with what dashboard_regions()/dashboard_villages() group by."""
|
| 110 |
+
from sqlalchemy import func as sa_func
|
| 111 |
+
|
| 112 |
+
from .models import DdaVillage, DdaZone
|
| 113 |
+
from .tree.models import ImageLibrary
|
| 114 |
+
|
| 115 |
+
by_zone: dict[str, int] = defaultdict(int)
|
| 116 |
+
by_village: dict[tuple[str, str], int] = defaultdict(int)
|
| 117 |
+
|
| 118 |
+
zone_rows = (
|
| 119 |
+
db.query(DdaZone.name, sa_func.count(ImageLibrary.id))
|
| 120 |
+
.join(ImageLibrary, ImageLibrary.zone_id == DdaZone.id)
|
| 121 |
+
.group_by(DdaZone.name)
|
| 122 |
+
.all()
|
| 123 |
+
)
|
| 124 |
+
for name, cnt in zone_rows:
|
| 125 |
+
by_zone[name] = cnt
|
| 126 |
+
|
| 127 |
+
village_rows = (
|
| 128 |
+
db.query(DdaZone.name, DdaVillage.name, sa_func.count(ImageLibrary.id))
|
| 129 |
+
.join(DdaVillage, DdaVillage.zone_id == DdaZone.id)
|
| 130 |
+
.join(ImageLibrary, ImageLibrary.village_id == DdaVillage.id)
|
| 131 |
+
.group_by(DdaZone.name, DdaVillage.name)
|
| 132 |
+
.all()
|
| 133 |
+
)
|
| 134 |
+
for zone_name, village_name, cnt in village_rows:
|
| 135 |
+
by_village[(zone_name, village_name)] = cnt
|
| 136 |
+
|
| 137 |
+
return by_zone, by_village
|
| 138 |
+
|
| 139 |
+
|
| 140 |
def _accessible_run_filter(db: Session, user: User):
|
| 141 |
"""Same run-visibility rule as GET /api/history in app/main.py: the
|
| 142 |
user's own runs, plus any run that came from the auto-schedule (visible
|
|
|
|
| 157 |
return filt
|
| 158 |
|
| 159 |
|
| 160 |
+
def _parse_date_bound(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
|
| 161 |
+
"""Parse a 'YYYY-MM-DD' filter bound (as sent by an <input type=date>) as
|
| 162 |
+
an IST calendar day — every timestamp in this app is shown in IST, so a
|
| 163 |
+
user picking "7 Sep" means the whole IST day, not UTC midnight — then
|
| 164 |
+
convert to UTC since DetectionRun.created_at is stored in UTC."""
|
| 165 |
+
if not value or not value.strip():
|
| 166 |
+
return None
|
| 167 |
+
try:
|
| 168 |
+
d = datetime.strptime(value.strip(), "%Y-%m-%d")
|
| 169 |
+
except ValueError:
|
| 170 |
+
raise HTTPException(status_code=400, detail=f"Invalid date '{value}', expected YYYY-MM-DD")
|
| 171 |
+
if end_of_day:
|
| 172 |
+
d = d + timedelta(days=1, microseconds=-1)
|
| 173 |
+
return d.replace(tzinfo=_IST).astimezone(timezone.utc)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _live_locations_for(db: Session, run_ids: list[int]) -> dict[int, tuple[str, str]]:
|
| 177 |
+
"""Zone/village for runs that still have a blank one, re-derived on every
|
| 178 |
+
call from the run's original before/after image paths (kept on its
|
| 179 |
+
DetectionJob) and those images' *current* zone/village. This is what
|
| 180 |
+
makes assigning a location to an image in the Image Library immediately
|
| 181 |
+
reflected on the dashboard for every past run that used it — no separate
|
| 182 |
+
backfill step, no stale copy to re-sync."""
|
| 183 |
+
if not run_ids:
|
| 184 |
+
return {}
|
| 185 |
+
from .tree.image_service import resolve_run_location
|
| 186 |
+
|
| 187 |
+
jobs = db.query(DetectionJob).filter(DetectionJob.run_id.in_(run_ids)).all()
|
| 188 |
+
out: dict[int, tuple[str, str]] = {}
|
| 189 |
+
for job in jobs:
|
| 190 |
+
try:
|
| 191 |
+
params = json.loads(job.params_json or "{}")
|
| 192 |
+
except json.JSONDecodeError:
|
| 193 |
+
continue
|
| 194 |
+
base_path = params.get("base_path", "")
|
| 195 |
+
comparison_path = params.get("comparison_path", "")
|
| 196 |
+
if not base_path and not comparison_path:
|
| 197 |
+
continue
|
| 198 |
+
out[job.run_id] = resolve_run_location(db, base_path, comparison_path)
|
| 199 |
+
return out
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _run_change_type_counts(run: DetectionRun) -> dict[str, int]:
|
| 203 |
+
"""How many of this run's regions fall into each fixed change-type
|
| 204 |
+
category (New Construction/Demolition/Extension/Vegetation Change/
|
| 205 |
+
Other) — already computed per region at detection time (see
|
| 206 |
+
change_type_map.py) and stored in regions_json; this just tallies it."""
|
| 207 |
+
try:
|
| 208 |
+
regions = json.loads(run.regions_json or "[]")
|
| 209 |
+
except json.JSONDecodeError:
|
| 210 |
+
return {}
|
| 211 |
+
counts: dict[str, int] = defaultdict(int)
|
| 212 |
+
for region in regions:
|
| 213 |
+
if region.get("suppressed"):
|
| 214 |
+
continue
|
| 215 |
+
ctype = region.get("ddaChangeType") or "Other"
|
| 216 |
+
counts[ctype] += 1
|
| 217 |
+
return dict(counts)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _merge_change_type_counts(items: list[dict]) -> dict[str, int]:
|
| 221 |
+
"""Sum per-run changeTypes dicts into one, ordered per CHANGE_TYPE_ORDER
|
| 222 |
+
with any unrecognized label appended at the end rather than dropped."""
|
| 223 |
+
total: dict[str, int] = defaultdict(int)
|
| 224 |
+
for i in items:
|
| 225 |
+
for ctype, cnt in i["changeTypes"].items():
|
| 226 |
+
total[ctype] += cnt
|
| 227 |
+
ordered = {k: total[k] for k in CHANGE_TYPE_ORDER if k in total}
|
| 228 |
+
ordered.update({k: v for k, v in total.items() if k not in ordered})
|
| 229 |
+
return ordered
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _load_dataset(
|
| 233 |
+
db: Session,
|
| 234 |
+
user: User,
|
| 235 |
+
*,
|
| 236 |
+
date_from: Optional[str] = None,
|
| 237 |
+
date_to: Optional[str] = None,
|
| 238 |
+
) -> list[dict]:
|
| 239 |
"""One query for every accessible run + one grouped query for
|
| 240 |
classification counts; every endpoint below aggregates from this in
|
| 241 |
Python — mirrors the simple query style already used by
|
| 242 |
admin_routes.admin_status(). Runs with a blank zone/village are bucketed
|
| 243 |
+
under "Unassigned" so totals reconcile, after first trying to live-derive
|
| 244 |
+
one from the source images (see _live_locations_for). ``date_from``/
|
| 245 |
+
``date_to`` are optional 'YYYY-MM-DD' bounds on created_at (inclusive on
|
| 246 |
+
both ends).
|
| 247 |
"""
|
| 248 |
+
from_dt = _parse_date_bound(date_from)
|
| 249 |
+
to_dt = _parse_date_bound(date_to, end_of_day=True)
|
| 250 |
+
|
| 251 |
+
query = db.query(DetectionRun).filter(_accessible_run_filter(db, user))
|
| 252 |
+
if from_dt:
|
| 253 |
+
query = query.filter(DetectionRun.created_at >= from_dt)
|
| 254 |
+
if to_dt:
|
| 255 |
+
query = query.filter(DetectionRun.created_at <= to_dt)
|
| 256 |
+
runs = query.order_by(DetectionRun.created_at.asc()).all()
|
| 257 |
counts = bulk_classification_counts(db)
|
| 258 |
# Same role resolution as dda_auth.get_user_role (raw User.role string,
|
| 259 |
# falling back to "analyst") but bulk-loaded to avoid a query per run.
|
| 260 |
roles_by_user = {uid: (role or DEFAULT_ROLE) for uid, role in db.query(User.id, User.role).all()}
|
| 261 |
+
blank_ids = [r.id for r in runs if not (r.zone or "").strip() or not (r.village or "").strip()]
|
| 262 |
+
live = _live_locations_for(db, blank_ids)
|
| 263 |
rows = []
|
| 264 |
for run in runs:
|
| 265 |
+
zone = (run.zone or "").strip()
|
| 266 |
+
village = (run.village or "").strip()
|
| 267 |
+
if not zone or not village:
|
| 268 |
+
auto_zone, auto_village = live.get(run.id, ("", ""))
|
| 269 |
+
zone = zone or auto_zone
|
| 270 |
+
village = village or auto_village
|
| 271 |
c = counts.get(run.id, {})
|
| 272 |
classified = c.get("confirmed", 0) + c.get("false_positive", 0) + c.get("submitted", 0)
|
| 273 |
unclassified = max(0, (run.regions_count or 0) - classified)
|
| 274 |
rows.append({
|
| 275 |
"run": run,
|
| 276 |
+
"zone": zone or UNASSIGNED,
|
| 277 |
+
"village": village or UNASSIGNED,
|
| 278 |
"role": roles_by_user.get(run.user_id, DEFAULT_ROLE),
|
| 279 |
"confirmed": c.get("confirmed", 0),
|
| 280 |
"falsePositive": c.get("false_positive", 0),
|
| 281 |
"submitted": c.get("submitted", 0),
|
| 282 |
"unclassified": unclassified,
|
| 283 |
+
"changeTypes": _run_change_type_counts(run),
|
| 284 |
})
|
| 285 |
return rows
|
| 286 |
|
| 287 |
|
| 288 |
+
def _summary_from_rows(rows: list[dict], db: Session, user: User) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
total_detected = sum((r["run"].regions_count or 0) for r in rows)
|
| 290 |
pct_values = [r["run"].change_percentage for r in rows if r["run"].change_percentage is not None]
|
| 291 |
zones = {r["zone"] for r in rows}
|
| 292 |
villages = {(r["zone"], r["village"]) for r in rows}
|
| 293 |
|
| 294 |
+
from .models import DdaVillage, DdaZone
|
| 295 |
+
zones_total = db.query(DdaZone).count()
|
| 296 |
+
villages_total = db.query(DdaVillage).count()
|
| 297 |
+
|
| 298 |
return {
|
| 299 |
"totalRuns": len(rows),
|
| 300 |
"totalDetectedChanges": total_detected,
|
|
|
|
| 305 |
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 306 |
"totalRegions": len(zones),
|
| 307 |
"totalVillages": len(villages),
|
| 308 |
+
"zonesTotal": zones_total,
|
| 309 |
+
"villagesTotal": villages_total,
|
| 310 |
+
"changeTypes": _merge_change_type_counts(rows),
|
| 311 |
"jobsQueued": db.query(DetectionJob).filter(DetectionJob.status == "queued", DetectionJob.created_by == user.id).count(),
|
| 312 |
"jobsRunning": db.query(DetectionJob).filter(DetectionJob.status == "running", DetectionJob.created_by == user.id).count(),
|
| 313 |
"latestRun": _run_summary(rows[-1]) if rows else None,
|
| 314 |
}
|
| 315 |
|
| 316 |
|
| 317 |
+
def _regions_from_rows(rows: list[dict], db: Session) -> list[dict]:
|
|
|
|
|
|
|
|
|
|
| 318 |
by_zone: dict[str, list] = defaultdict(list)
|
| 319 |
for r in rows:
|
| 320 |
by_zone[r["zone"]].append(r)
|
| 321 |
+
image_counts_by_zone, _ = _image_counts_by_location(db)
|
| 322 |
|
| 323 |
out = []
|
| 324 |
for zone, items in by_zone.items():
|
| 325 |
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
|
| 326 |
+
confirmed = sum(i["confirmed"] for i in items)
|
| 327 |
+
false_positive = sum(i["falsePositive"] for i in items)
|
| 328 |
out.append({
|
| 329 |
"zone": zone,
|
| 330 |
"runsCount": len(items),
|
| 331 |
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
|
| 332 |
"unclassified": sum(i["unclassified"] for i in items),
|
| 333 |
+
"confirmed": confirmed,
|
| 334 |
+
"falsePositive": false_positive,
|
| 335 |
+
"submitted": sum(i["submitted"] for i in items),
|
| 336 |
+
"confirmedRate": _confirmed_rate(confirmed, false_positive),
|
| 337 |
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 338 |
"villagesCount": len({i["village"] for i in items}),
|
| 339 |
+
"imageCount": image_counts_by_zone.get(zone, 0),
|
| 340 |
+
"changeTypes": _merge_change_type_counts(items),
|
| 341 |
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 342 |
})
|
| 343 |
out.sort(key=lambda x: x["runsCount"], reverse=True)
|
| 344 |
+
return out
|
| 345 |
|
| 346 |
|
| 347 |
+
def _roles_from_rows(rows: list[dict]) -> list[dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
by_role: dict[str, list] = defaultdict(list)
|
| 349 |
for r in rows:
|
| 350 |
by_role[r["role"]].append(r)
|
|
|
|
| 367 |
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 368 |
})
|
| 369 |
out.sort(key=lambda x: (_rank(x["role"]), x["role"]))
|
| 370 |
+
return out
|
| 371 |
|
| 372 |
|
| 373 |
+
def _villages_from_rows(rows: list[dict], db: Session, zone: Optional[str] = None) -> list[dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
if zone:
|
| 375 |
rows = [r for r in rows if r["zone"] == zone]
|
| 376 |
by_village: dict[tuple, list] = defaultdict(list)
|
| 377 |
for r in rows:
|
| 378 |
by_village[(r["zone"], r["village"])].append(r)
|
| 379 |
+
_, image_counts_by_village = _image_counts_by_location(db)
|
| 380 |
|
| 381 |
out = []
|
| 382 |
for (z, v), items in by_village.items():
|
| 383 |
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
|
| 384 |
+
confirmed = sum(i["confirmed"] for i in items)
|
| 385 |
+
false_positive = sum(i["falsePositive"] for i in items)
|
| 386 |
out.append({
|
| 387 |
"zone": z,
|
| 388 |
"village": v,
|
| 389 |
"runsCount": len(items),
|
| 390 |
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
|
| 391 |
"unclassified": sum(i["unclassified"] for i in items),
|
| 392 |
+
"confirmed": confirmed,
|
| 393 |
+
"falsePositive": false_positive,
|
| 394 |
+
"submitted": sum(i["submitted"] for i in items),
|
| 395 |
+
"confirmedRate": _confirmed_rate(confirmed, false_positive),
|
| 396 |
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 397 |
+
"imageCount": image_counts_by_village.get((z, v), 0),
|
| 398 |
+
"changeTypes": _merge_change_type_counts(items),
|
| 399 |
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 400 |
})
|
| 401 |
out.sort(key=lambda x: x["runsCount"], reverse=True)
|
| 402 |
+
return out
|
| 403 |
|
| 404 |
|
| 405 |
+
def _trend_from_rows(rows: list[dict], months: int = 6) -> list[dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
now = datetime.now(timezone.utc)
|
| 407 |
keys = []
|
| 408 |
y, m = now.year, now.month
|
|
|
|
| 425 |
counts[key]["runsCount"] += 1
|
| 426 |
counts[key]["detectedChanges"] += (r["run"].regions_count or 0)
|
| 427 |
|
| 428 |
+
return [
|
| 429 |
{
|
| 430 |
"period": f"{y}-{m:02d}",
|
| 431 |
"label": f"{MONTH_NAMES[m]} {y}",
|
|
|
|
| 434 |
}
|
| 435 |
for (y, m) in keys
|
| 436 |
]
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
@router.get("/dashboard/bootstrap")
|
| 440 |
+
def dashboard_bootstrap(
|
| 441 |
+
date_from: Optional[str] = Query(None),
|
| 442 |
+
date_to: Optional[str] = Query(None),
|
| 443 |
+
months: int = Query(6, ge=1, le=24),
|
| 444 |
+
db: Session = Depends(get_db),
|
| 445 |
+
user: User = Depends(current_dda_user),
|
| 446 |
+
):
|
| 447 |
+
"""Single payload for home dashboard — one `_load_dataset` pass."""
|
| 448 |
+
_require_dda()
|
| 449 |
+
rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
|
| 450 |
+
return {
|
| 451 |
+
"summary": _summary_from_rows(rows, db, user),
|
| 452 |
+
"roles": _roles_from_rows(rows),
|
| 453 |
+
"regions": _regions_from_rows(rows, db),
|
| 454 |
+
"villages": _villages_from_rows(rows, db),
|
| 455 |
+
"trend": {"buckets": _trend_from_rows(rows, months=months)},
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
@router.get("/dashboard/summary")
|
| 460 |
+
def dashboard_summary(
|
| 461 |
+
date_from: Optional[str] = Query(None),
|
| 462 |
+
date_to: Optional[str] = Query(None),
|
| 463 |
+
db: Session = Depends(get_db),
|
| 464 |
+
user: User = Depends(current_dda_user),
|
| 465 |
+
):
|
| 466 |
+
_require_dda()
|
| 467 |
+
rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
|
| 468 |
+
return _summary_from_rows(rows, db, user)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
@router.get("/dashboard/regions")
|
| 472 |
+
def dashboard_regions(
|
| 473 |
+
date_from: Optional[str] = Query(None),
|
| 474 |
+
date_to: Optional[str] = Query(None),
|
| 475 |
+
db: Session = Depends(get_db),
|
| 476 |
+
user: User = Depends(current_dda_user),
|
| 477 |
+
):
|
| 478 |
+
_require_dda()
|
| 479 |
+
rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
|
| 480 |
+
return {"regions": _regions_from_rows(rows, db)}
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
@router.get("/dashboard/roles")
|
| 484 |
+
def dashboard_roles(
|
| 485 |
+
date_from: Optional[str] = Query(None),
|
| 486 |
+
date_to: Optional[str] = Query(None),
|
| 487 |
+
db: Session = Depends(get_db),
|
| 488 |
+
user: User = Depends(current_dda_user),
|
| 489 |
+
):
|
| 490 |
+
"""Detection activity grouped by the role of the user who ran it."""
|
| 491 |
+
_require_dda()
|
| 492 |
+
rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
|
| 493 |
+
return {"roles": _roles_from_rows(rows)}
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
@router.get("/dashboard/villages")
|
| 497 |
+
def dashboard_villages(
|
| 498 |
+
zone: Optional[str] = Query(None),
|
| 499 |
+
date_from: Optional[str] = Query(None),
|
| 500 |
+
date_to: Optional[str] = Query(None),
|
| 501 |
+
db: Session = Depends(get_db),
|
| 502 |
+
user: User = Depends(current_dda_user),
|
| 503 |
+
):
|
| 504 |
+
_require_dda()
|
| 505 |
+
rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
|
| 506 |
+
return {"villages": _villages_from_rows(rows, db, zone=zone)}
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
@router.get("/dashboard/trend")
|
| 510 |
+
def dashboard_trend(
|
| 511 |
+
months: int = Query(6, ge=1, le=24),
|
| 512 |
+
db: Session = Depends(get_db),
|
| 513 |
+
user: User = Depends(current_dda_user),
|
| 514 |
+
):
|
| 515 |
+
_require_dda()
|
| 516 |
+
rows = _load_dataset(db, user)
|
| 517 |
+
return {"buckets": _trend_from_rows(rows, months=months)}
|
| 518 |
|
| 519 |
|
| 520 |
@router.get("/dashboard/location")
|
|
|
|
| 534 |
previous = history[-2] if len(history) > 1 else None
|
| 535 |
|
| 536 |
comparison = None
|
| 537 |
+
summary = None
|
| 538 |
if latest and previous:
|
| 539 |
+
change_delta = (
|
| 540 |
+
round(latest["changePercentage"] - previous["changePercentage"], 2)
|
| 541 |
+
if latest["changePercentage"] is not None and previous["changePercentage"] is not None
|
| 542 |
+
else None
|
| 543 |
+
)
|
| 544 |
+
regions_delta = latest["regionsCount"] - previous["regionsCount"]
|
| 545 |
+
rate_delta = (
|
| 546 |
+
round(latest["confirmedRate"] - previous["confirmedRate"], 1)
|
| 547 |
+
if latest["confirmedRate"] is not None and previous["confirmedRate"] is not None
|
| 548 |
+
else None
|
| 549 |
+
)
|
| 550 |
comparison = {
|
| 551 |
+
"changePercentageDelta": change_delta,
|
| 552 |
+
"regionsCountDelta": regions_delta,
|
| 553 |
+
"confirmedDelta": latest["confirmed"] - previous["confirmed"],
|
| 554 |
+
"falsePositiveDelta": latest["falsePositive"] - previous["falsePositive"],
|
| 555 |
+
"unclassifiedDelta": latest["unclassified"] - previous["unclassified"],
|
| 556 |
+
"confirmedRateDelta": rate_delta,
|
| 557 |
}
|
| 558 |
+
summary = _build_comparison_summary(latest, previous, comparison)
|
| 559 |
|
| 560 |
return {
|
| 561 |
"zone": zone,
|
|
|
|
| 564 |
"latest": latest,
|
| 565 |
"previous": previous,
|
| 566 |
"comparison": comparison,
|
| 567 |
+
"summary": summary,
|
| 568 |
"history": list(reversed(history)),
|
| 569 |
}
|
| 570 |
|
| 571 |
|
| 572 |
+
def _build_comparison_summary(latest: dict, previous: dict, comparison: dict) -> str:
|
| 573 |
+
"""Plain-English recap of how the latest run compares to the previous
|
| 574 |
+
one at this location — change area, region count, and review accuracy
|
| 575 |
+
(confirmed rate) all in one sentence set, so the dashboard doesn't make
|
| 576 |
+
someone read four numbers to know if things got better or worse."""
|
| 577 |
+
parts = []
|
| 578 |
+
|
| 579 |
+
cd = comparison["changePercentageDelta"]
|
| 580 |
+
if cd is not None:
|
| 581 |
+
if abs(cd) < 0.01:
|
| 582 |
+
parts.append("Change area is essentially unchanged from the previous run.")
|
| 583 |
+
else:
|
| 584 |
+
direction = "up" if cd > 0 else "down"
|
| 585 |
+
parts.append(
|
| 586 |
+
f"Change area is {direction} {abs(cd):.2f} percentage points "
|
| 587 |
+
f"({previous['changePercentage']:.2f}% → {latest['changePercentage']:.2f}%)."
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
rd = comparison["regionsCountDelta"]
|
| 591 |
+
if rd != 0:
|
| 592 |
+
parts.append(f"{abs(rd)} {'more' if rd > 0 else 'fewer'} region(s) detected than last time.")
|
| 593 |
+
else:
|
| 594 |
+
parts.append("Same number of regions detected as last time.")
|
| 595 |
+
|
| 596 |
+
rate_delta = comparison["confirmedRateDelta"]
|
| 597 |
+
if rate_delta is not None:
|
| 598 |
+
if abs(rate_delta) < 0.1:
|
| 599 |
+
parts.append(f"Confirmed rate held steady at {latest['confirmedRate']:.1f}%.")
|
| 600 |
+
else:
|
| 601 |
+
direction = "improved" if rate_delta > 0 else "dropped"
|
| 602 |
+
parts.append(
|
| 603 |
+
f"Confirmed rate {direction} {abs(rate_delta):.1f} points "
|
| 604 |
+
f"({previous['confirmedRate']:.1f}% → {latest['confirmedRate']:.1f}%)."
|
| 605 |
+
)
|
| 606 |
+
elif latest["confirmedRate"] is not None:
|
| 607 |
+
parts.append(f"Confirmed rate is {latest['confirmedRate']:.1f}% (previous run not yet reviewed).")
|
| 608 |
+
|
| 609 |
+
fpd = comparison["falsePositiveDelta"]
|
| 610 |
+
if fpd != 0:
|
| 611 |
+
parts.append(f"{abs(fpd)} {'more' if fpd > 0 else 'fewer'} false positive(s) flagged.")
|
| 612 |
+
|
| 613 |
+
return " ".join(parts)
|
| 614 |
+
|
| 615 |
+
|
| 616 |
@router.get("/dashboard/recent-reports")
|
| 617 |
def dashboard_recent_reports(
|
| 618 |
limit: int = Query(8, ge=1, le=50),
|
| 619 |
+
date_from: Optional[str] = Query(None),
|
| 620 |
+
date_to: Optional[str] = Query(None),
|
| 621 |
db: Session = Depends(get_db),
|
| 622 |
user: User = Depends(current_dda_user),
|
| 623 |
):
|
| 624 |
_require_dda()
|
| 625 |
+
rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
|
| 626 |
rows.sort(key=lambda r: r["run"].created_at, reverse=True)
|
| 627 |
return {"reports": [_run_summary(r) for r in rows[:limit]]}
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
@router.get("/dashboard/library-stats")
|
| 631 |
+
def dashboard_library_stats(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 632 |
+
"""Image Library summary for the dashboard — total images, zone/village
|
| 633 |
+
breakdown, format mix, and how many still have no zone/village assigned.
|
| 634 |
+
Independent of detection history and not scoped per-user: the library
|
| 635 |
+
itself isn't user-scoped anywhere else in the app (matches
|
| 636 |
+
local_routes.py's image listing), so this reflects the whole library as
|
| 637 |
+
it stands right now, for every viewer.
|
| 638 |
+
"""
|
| 639 |
+
_require_dda()
|
| 640 |
+
from sqlalchemy import func as sa_func
|
| 641 |
+
|
| 642 |
+
from .tree.models import ImageLibrary
|
| 643 |
+
|
| 644 |
+
total = db.query(ImageLibrary).count()
|
| 645 |
+
by_zone, by_village = _image_counts_by_location(db)
|
| 646 |
+
unassigned = db.query(ImageLibrary).filter(ImageLibrary.zone_id.is_(None)).count()
|
| 647 |
+
|
| 648 |
+
format_rows = (
|
| 649 |
+
db.query(ImageLibrary.format, sa_func.count(ImageLibrary.id))
|
| 650 |
+
.group_by(ImageLibrary.format)
|
| 651 |
+
.all()
|
| 652 |
+
)
|
| 653 |
+
by_format = sorted(
|
| 654 |
+
({"format": fmt or "Unknown", "count": cnt} for fmt, cnt in format_rows),
|
| 655 |
+
key=lambda x: -x["count"],
|
| 656 |
+
)
|
| 657 |
+
zones_out = sorted(
|
| 658 |
+
({"zone": z, "imageCount": c} for z, c in by_zone.items()),
|
| 659 |
+
key=lambda x: -x["imageCount"],
|
| 660 |
+
)
|
| 661 |
+
villages_out = sorted(
|
| 662 |
+
({"zone": z, "village": v, "imageCount": c} for (z, v), c in by_village.items()),
|
| 663 |
+
key=lambda x: -x["imageCount"],
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
return {
|
| 667 |
+
"totalImages": total,
|
| 668 |
+
"assignedImages": total - unassigned,
|
| 669 |
+
"unassignedImages": unassigned,
|
| 670 |
+
"zonesCovered": len(by_zone),
|
| 671 |
+
"villagesCovered": len(by_village),
|
| 672 |
+
"byZone": zones_out,
|
| 673 |
+
"byVillage": villages_out,
|
| 674 |
+
"byFormat": by_format,
|
| 675 |
+
}
|
app/dda/detect_service.py
CHANGED
|
@@ -27,6 +27,17 @@ THUMB_MAX_SIZE = 200
|
|
| 27 |
MAX_POLYGON_VERTICES = 100
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def _serialize_polygon(raw) -> Optional[list]:
|
| 31 |
"""Normalize an engine polygon to ``[[x, y], ...]`` ints, or None.
|
| 32 |
|
|
@@ -225,9 +236,7 @@ def run_detection_and_save(
|
|
| 225 |
else:
|
| 226 |
user = get_or_create_guest_user(db)
|
| 227 |
db.commit()
|
| 228 |
-
detection_sensitivity =
|
| 229 |
-
if min_region_area is not None:
|
| 230 |
-
min_region_area = int(max(50, min(10000, min_region_area)))
|
| 231 |
|
| 232 |
def _on_engine_progress(engine_pct: int, stage: str) -> None:
|
| 233 |
# Map engine 0–100% into job 15–78%
|
|
@@ -414,6 +423,8 @@ def run_detection_and_save(
|
|
| 414 |
after_thumb_path=relative_after_thumb,
|
| 415 |
after_full_path=relative_after_full,
|
| 416 |
regions_json=json.dumps(regions_serializable),
|
|
|
|
|
|
|
| 417 |
)
|
| 418 |
db.add(run)
|
| 419 |
db.commit()
|
|
@@ -468,6 +479,8 @@ def run_detection_and_save(
|
|
| 468 |
"changedPixels": changed_px,
|
| 469 |
"unchangedPixels": int(stats["unchanged_pixels"]),
|
| 470 |
"changePercentage": change_pct,
|
|
|
|
|
|
|
| 471 |
"thresholdDebug": stats.get("threshold_debug", {}),
|
| 472 |
"params": stats.get("params", {}),
|
| 473 |
"alignmentWarning": stats.get("alignment_warning"),
|
|
|
|
| 27 |
MAX_POLYGON_VERTICES = 100
|
| 28 |
|
| 29 |
|
| 30 |
+
def clamp_detection_params(detection_sensitivity, min_region_area):
|
| 31 |
+
"""Single source of truth for valid ranges, shared by every entry point
|
| 32 |
+
that accepts these as Form params (app/main.py's /api/detect and this
|
| 33 |
+
module's run_detection_and_save, used by /api/dda/detect/from-library) —
|
| 34 |
+
previously each clamped independently and could drift apart."""
|
| 35 |
+
sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
|
| 36 |
+
if min_region_area is not None:
|
| 37 |
+
min_region_area = int(max(50, min(10000, min_region_area)))
|
| 38 |
+
return sensitivity, min_region_area
|
| 39 |
+
|
| 40 |
+
|
| 41 |
def _serialize_polygon(raw) -> Optional[list]:
|
| 42 |
"""Normalize an engine polygon to ``[[x, y], ...]`` ints, or None.
|
| 43 |
|
|
|
|
| 236 |
else:
|
| 237 |
user = get_or_create_guest_user(db)
|
| 238 |
db.commit()
|
| 239 |
+
detection_sensitivity, min_region_area = clamp_detection_params(detection_sensitivity, min_region_area)
|
|
|
|
|
|
|
| 240 |
|
| 241 |
def _on_engine_progress(engine_pct: int, stage: str) -> None:
|
| 242 |
# Map engine 0–100% into job 15–78%
|
|
|
|
| 423 |
after_thumb_path=relative_after_thumb,
|
| 424 |
after_full_path=relative_after_full,
|
| 425 |
regions_json=json.dumps(regions_serializable),
|
| 426 |
+
detection_sensitivity=detection_sensitivity,
|
| 427 |
+
min_region_area=min_region_area,
|
| 428 |
)
|
| 429 |
db.add(run)
|
| 430 |
db.commit()
|
|
|
|
| 479 |
"changedPixels": changed_px,
|
| 480 |
"unchangedPixels": int(stats["unchanged_pixels"]),
|
| 481 |
"changePercentage": change_pct,
|
| 482 |
+
"detectionSensitivity": detection_sensitivity,
|
| 483 |
+
"minRegionArea": min_region_area,
|
| 484 |
"thresholdDebug": stats.get("threshold_debug", {}),
|
| 485 |
"params": stats.get("params", {}),
|
| 486 |
"alignmentWarning": stats.get("alignment_warning"),
|
app/dda/exception_routes.py
CHANGED
|
@@ -34,6 +34,7 @@ def _entry_dict(entry: ExceptionLog, *, with_traceback: bool = False) -> dict:
|
|
| 34 |
"statusCode": entry.status_code,
|
| 35 |
"handled": bool(entry.handled),
|
| 36 |
"userEmail": entry.user_email or "",
|
|
|
|
| 37 |
}
|
| 38 |
if with_traceback:
|
| 39 |
d["traceback"] = entry.traceback or ""
|
|
|
|
| 34 |
"statusCode": entry.status_code,
|
| 35 |
"handled": bool(entry.handled),
|
| 36 |
"userEmail": entry.user_email or "",
|
| 37 |
+
"userRole": getattr(entry, "user_role", None) or "",
|
| 38 |
}
|
| 39 |
if with_traceback:
|
| 40 |
d["traceback"] = entry.traceback or ""
|
app/dda/job_runner.py
CHANGED
|
@@ -126,14 +126,21 @@ def _run_job_sync(job_id: int) -> None:
|
|
| 126 |
"Job %d detection max_size=%d (loaded %s)",
|
| 127 |
job_id, detect_max, before_pil.size,
|
| 128 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
result = run_detection_and_save(
|
| 130 |
db,
|
| 131 |
before_pil,
|
| 132 |
after_pil,
|
| 133 |
method=method,
|
| 134 |
title=title,
|
| 135 |
-
zone=
|
| 136 |
-
village=
|
| 137 |
enable_registration=bool(params.get("enable_registration", True)),
|
| 138 |
enable_normalization=bool(params.get("enable_normalization", True)),
|
| 139 |
detection_sensitivity=float(params.get("detection_sensitivity", 0.45)),
|
|
|
|
| 126 |
"Job %d detection max_size=%d (loaded %s)",
|
| 127 |
job_id, detect_max, before_pil.size,
|
| 128 |
)
|
| 129 |
+
zone = (params.get("zone") or "").strip()
|
| 130 |
+
village = (params.get("village") or "").strip()
|
| 131 |
+
if not zone or not village:
|
| 132 |
+
from .tree.image_service import resolve_run_location
|
| 133 |
+
auto_zone, auto_village = resolve_run_location(db, base_path, comparison_path)
|
| 134 |
+
zone = zone or auto_zone
|
| 135 |
+
village = village or auto_village
|
| 136 |
result = run_detection_and_save(
|
| 137 |
db,
|
| 138 |
before_pil,
|
| 139 |
after_pil,
|
| 140 |
method=method,
|
| 141 |
title=title,
|
| 142 |
+
zone=zone,
|
| 143 |
+
village=village,
|
| 144 |
enable_registration=bool(params.get("enable_registration", True)),
|
| 145 |
enable_normalization=bool(params.get("enable_normalization", True)),
|
| 146 |
detection_sensitivity=float(params.get("detection_sensitivity", 0.45)),
|
app/dda/local_routes.py
CHANGED
|
@@ -112,7 +112,14 @@ def local_library_config(user: User = Depends(current_dda_user)):
|
|
| 112 |
def local_images(
|
| 113 |
node_id: Optional[int] = Query(None),
|
| 114 |
q: Optional[str] = Query(None),
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
offset: int = Query(0, ge=0),
|
| 117 |
db: Session = Depends(get_db),
|
| 118 |
user: User = Depends(current_dda_user),
|
|
@@ -122,9 +129,16 @@ def local_images(
|
|
| 122 |
db,
|
| 123 |
node_id=node_id,
|
| 124 |
query=q,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
include_bounds=False,
|
| 126 |
slim=True,
|
| 127 |
-
limit=limit,
|
| 128 |
offset=offset,
|
| 129 |
)
|
| 130 |
|
|
@@ -132,19 +146,22 @@ def local_images(
|
|
| 132 |
@router.get("/local/area-groups")
|
| 133 |
def local_area_groups(
|
| 134 |
node_id: Optional[int] = Query(None),
|
|
|
|
| 135 |
db: Session = Depends(get_db),
|
| 136 |
user: User = Depends(current_dda_user),
|
| 137 |
):
|
| 138 |
"""Images covering the same place, with oldest → Before and newest → After."""
|
| 139 |
_require_dda()
|
| 140 |
-
from .tree.area_groups import
|
| 141 |
from .tree.tree_service import get_node_or_404
|
| 142 |
|
| 143 |
-
payload = load_cached_area_groups(db)
|
| 144 |
if node_id:
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
@router.get("/local/thumb")
|
|
@@ -377,7 +394,7 @@ async def detect_from_library(
|
|
| 377 |
enable_registration: bool = Form(True),
|
| 378 |
enable_normalization: bool = Form(True),
|
| 379 |
detection_sensitivity: float = Form(0.5),
|
| 380 |
-
min_region_area: Optional[int] = Form(
|
| 381 |
notify_email: Optional[str] = Form(None),
|
| 382 |
roi: Optional[str] = Form(None),
|
| 383 |
shape_mode: str = Form("polygon"),
|
|
@@ -449,6 +466,12 @@ async def detect_from_library(
|
|
| 449 |
if title == "Untitled run":
|
| 450 |
title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
|
| 451 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
try:
|
| 453 |
# Use fullres load cap (up to DETECTION_FULLRES_MAX_SIDE), not the
|
| 454 |
# legacy 4096 downscaled cap — otherwise max-res config is ignored.
|
|
|
|
| 112 |
def local_images(
|
| 113 |
node_id: Optional[int] = Query(None),
|
| 114 |
q: Optional[str] = Query(None),
|
| 115 |
+
year: Optional[int] = Query(None),
|
| 116 |
+
image_type: Optional[str] = Query(None),
|
| 117 |
+
zone_id: Optional[int] = Query(None),
|
| 118 |
+
village_id: Optional[int] = Query(None),
|
| 119 |
+
capture_date_from: Optional[str] = Query(None),
|
| 120 |
+
capture_date_to: Optional[str] = Query(None),
|
| 121 |
+
source: Optional[str] = Query(None),
|
| 122 |
+
limit: Optional[int] = Query(50, ge=1, le=500),
|
| 123 |
offset: int = Query(0, ge=0),
|
| 124 |
db: Session = Depends(get_db),
|
| 125 |
user: User = Depends(current_dda_user),
|
|
|
|
| 129 |
db,
|
| 130 |
node_id=node_id,
|
| 131 |
query=q,
|
| 132 |
+
year=year,
|
| 133 |
+
image_type=image_type,
|
| 134 |
+
zone_id=zone_id,
|
| 135 |
+
village_id=village_id,
|
| 136 |
+
capture_date_from=capture_date_from,
|
| 137 |
+
capture_date_to=capture_date_to,
|
| 138 |
+
source=source,
|
| 139 |
include_bounds=False,
|
| 140 |
slim=True,
|
| 141 |
+
limit=limit if limit is not None else 50,
|
| 142 |
offset=offset,
|
| 143 |
)
|
| 144 |
|
|
|
|
| 146 |
@router.get("/local/area-groups")
|
| 147 |
def local_area_groups(
|
| 148 |
node_id: Optional[int] = Query(None),
|
| 149 |
+
include_unpaired: int = Query(0, ge=0, le=1),
|
| 150 |
db: Session = Depends(get_db),
|
| 151 |
user: User = Depends(current_dda_user),
|
| 152 |
):
|
| 153 |
"""Images covering the same place, with oldest → Before and newest → After."""
|
| 154 |
_require_dda()
|
| 155 |
+
from .tree.area_groups import load_cached_area_groups
|
| 156 |
from .tree.tree_service import get_node_or_404
|
| 157 |
|
|
|
|
| 158 |
if node_id:
|
| 159 |
+
get_node_or_404(db, node_id)
|
| 160 |
+
return load_cached_area_groups(
|
| 161 |
+
db,
|
| 162 |
+
node_id=node_id,
|
| 163 |
+
include_unpaired=bool(include_unpaired),
|
| 164 |
+
)
|
| 165 |
|
| 166 |
|
| 167 |
@router.get("/local/thumb")
|
|
|
|
| 394 |
enable_registration: bool = Form(True),
|
| 395 |
enable_normalization: bool = Form(True),
|
| 396 |
detection_sensitivity: float = Form(0.5),
|
| 397 |
+
min_region_area: Optional[int] = Form(None),
|
| 398 |
notify_email: Optional[str] = Form(None),
|
| 399 |
roi: Optional[str] = Form(None),
|
| 400 |
shape_mode: str = Form("polygon"),
|
|
|
|
| 466 |
if title == "Untitled run":
|
| 467 |
title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
|
| 468 |
|
| 469 |
+
if not zone.strip() or not village.strip():
|
| 470 |
+
from .tree.image_service import resolve_run_location
|
| 471 |
+
auto_zone, auto_village = resolve_run_location(db, base_norm, comp_norm)
|
| 472 |
+
zone = zone.strip() or auto_zone
|
| 473 |
+
village = village.strip() or auto_village
|
| 474 |
+
|
| 475 |
try:
|
| 476 |
# Use fullres load cap (up to DETECTION_FULLRES_MAX_SIDE), not the
|
| 477 |
# legacy 4096 downscaled cap — otherwise max-res config is ignored.
|
app/dda/masters_routes.py
CHANGED
|
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|
| 10 |
from ..database import get_db
|
| 11 |
from ..models import User
|
| 12 |
from .models import DdaVillage, DdaZone, ImageAsset
|
|
|
|
| 13 |
from .rbac.permissions import require_module_permission
|
| 14 |
from .seed import seed_delhi_hierarchy
|
| 15 |
|
|
@@ -67,6 +68,28 @@ def _village_dict(village: DdaVillage, zone: Optional[DdaZone] = None) -> dict:
|
|
| 67 |
}
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
@router.get("/masters/zones")
|
| 71 |
def list_zones(
|
| 72 |
db: Session = Depends(get_db),
|
|
@@ -132,7 +155,10 @@ def delete_zone(
|
|
| 132 |
zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
|
| 133 |
if not zone:
|
| 134 |
raise HTTPException(status_code=404, detail="Zone not found")
|
| 135 |
-
in_use =
|
|
|
|
|
|
|
|
|
|
| 136 |
if in_use:
|
| 137 |
raise HTTPException(status_code=409, detail="This zone is used by library images and cannot be deleted.")
|
| 138 |
zone_name = zone.name
|
|
@@ -231,7 +257,10 @@ def delete_village(
|
|
| 231 |
village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
|
| 232 |
if not village:
|
| 233 |
raise HTTPException(status_code=404, detail="Location not found")
|
| 234 |
-
in_use =
|
|
|
|
|
|
|
|
|
|
| 235 |
if in_use:
|
| 236 |
raise HTTPException(status_code=409, detail="This location is used by library images and cannot be deleted.")
|
| 237 |
village_name = village.name
|
|
|
|
| 10 |
from ..database import get_db
|
| 11 |
from ..models import User
|
| 12 |
from .models import DdaVillage, DdaZone, ImageAsset
|
| 13 |
+
from .tree.models import ImageLibrary
|
| 14 |
from .rbac.permissions import require_module_permission
|
| 15 |
from .seed import seed_delhi_hierarchy
|
| 16 |
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
|
| 71 |
+
@router.get("/masters/options")
|
| 72 |
+
def masters_options(
|
| 73 |
+
db: Session = Depends(get_db),
|
| 74 |
+
user: User = Depends(require_module_permission("library", "view")),
|
| 75 |
+
):
|
| 76 |
+
"""Zone + village lists for pickers (upload form).
|
| 77 |
+
|
| 78 |
+
Separate from /masters/zones because choosing a location while uploading
|
| 79 |
+
only needs to read the masters, not manage them.
|
| 80 |
+
"""
|
| 81 |
+
_require_dda()
|
| 82 |
+
seed_delhi_hierarchy(db)
|
| 83 |
+
zones = db.query(DdaZone).order_by(DdaZone.name).all()
|
| 84 |
+
villages = db.query(DdaVillage).order_by(DdaVillage.name).all()
|
| 85 |
+
return {
|
| 86 |
+
"zones": [{"id": z.id, "name": z.name} for z in zones],
|
| 87 |
+
"villages": [
|
| 88 |
+
{"id": v.id, "name": v.name, "zoneId": v.zone_id} for v in villages
|
| 89 |
+
],
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
@router.get("/masters/zones")
|
| 94 |
def list_zones(
|
| 95 |
db: Session = Depends(get_db),
|
|
|
|
| 155 |
zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
|
| 156 |
if not zone:
|
| 157 |
raise HTTPException(status_code=404, detail="Zone not found")
|
| 158 |
+
in_use = (
|
| 159 |
+
db.query(ImageAsset).filter(ImageAsset.zone_id == zone_id).count()
|
| 160 |
+
+ db.query(ImageLibrary).filter(ImageLibrary.zone_id == zone_id).count()
|
| 161 |
+
)
|
| 162 |
if in_use:
|
| 163 |
raise HTTPException(status_code=409, detail="This zone is used by library images and cannot be deleted.")
|
| 164 |
zone_name = zone.name
|
|
|
|
| 257 |
village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
|
| 258 |
if not village:
|
| 259 |
raise HTTPException(status_code=404, detail="Location not found")
|
| 260 |
+
in_use = (
|
| 261 |
+
db.query(ImageAsset).filter(ImageAsset.village_id == village_id).count()
|
| 262 |
+
+ db.query(ImageLibrary).filter(ImageLibrary.village_id == village_id).count()
|
| 263 |
+
)
|
| 264 |
if in_use:
|
| 265 |
raise HTTPException(status_code=409, detail="This location is used by library images and cannot be deleted.")
|
| 266 |
village_name = village.name
|
app/dda/rbac/permissions.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
routes/pages, and by the navbar to filter which menu items a user sees."""
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
from typing import List, Optional
|
| 6 |
|
| 7 |
from fastapi import Depends, HTTPException
|
| 8 |
from sqlalchemy.orm import Session
|
|
@@ -32,6 +32,32 @@ def get_role_rank(user: User, db: Session) -> Optional[int]:
|
|
| 32 |
return role.rank if role else None
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def user_can(user: User, db: Session, module_key: str, action: str = "view") -> bool:
|
| 36 |
"""action: 'view' | 'create' | 'edit' | 'delete'."""
|
| 37 |
role = get_user_role_row(user, db)
|
|
@@ -77,28 +103,31 @@ def resolve_user_menu(user: User, db: Session) -> List[MenuItem]:
|
|
| 77 |
a group item ("Configuration") which is only shown once it has at least
|
| 78 |
one visible child — otherwise a role with no admin access would see an
|
| 79 |
empty, dead group header."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
def _visible(item: MenuItem) -> bool:
|
| 82 |
if item.module_id is None:
|
| 83 |
return True
|
| 84 |
-
|
| 85 |
-
return bool(module and user_can(user, db, module.key, "view"))
|
| 86 |
|
| 87 |
-
|
| 88 |
db.query(MenuItem)
|
| 89 |
-
.filter(MenuItem.is_active.is_(True)
|
| 90 |
.order_by(MenuItem.sort_order)
|
| 91 |
.all()
|
| 92 |
)
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
visible = []
|
| 95 |
-
for item in
|
| 96 |
-
children = (
|
| 97 |
-
db.query(MenuItem)
|
| 98 |
-
.filter(MenuItem.is_active.is_(True), MenuItem.parent_id == item.id)
|
| 99 |
-
.order_by(MenuItem.sort_order)
|
| 100 |
-
.all()
|
| 101 |
-
)
|
| 102 |
item.visible_children = [c for c in children if _visible(c)]
|
| 103 |
is_group = bool(children)
|
| 104 |
if (item.visible_children if is_group else _visible(item)):
|
|
|
|
| 2 |
routes/pages, and by the navbar to filter which menu items a user sees."""
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from typing import Dict, List, Optional
|
| 6 |
|
| 7 |
from fastapi import Depends, HTTPException
|
| 8 |
from sqlalchemy.orm import Session
|
|
|
|
| 32 |
return role.rank if role else None
|
| 33 |
|
| 34 |
|
| 35 |
+
def _role_permission_rows(role: Optional[Role], db: Session) -> Dict[int, RolePermission]:
|
| 36 |
+
if not role:
|
| 37 |
+
return {}
|
| 38 |
+
return {
|
| 39 |
+
p.module_id: p
|
| 40 |
+
for p in db.query(RolePermission).filter(RolePermission.role_id == role.id).all()
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def user_permissions_map(user: User, db: Session) -> dict:
|
| 45 |
+
"""One role + one permissions query for every module (no per-action N+1)."""
|
| 46 |
+
role = get_user_role_row(user, db)
|
| 47 |
+
modules = db.query(Module).all()
|
| 48 |
+
perms = _role_permission_rows(role, db)
|
| 49 |
+
out = {}
|
| 50 |
+
for m in modules:
|
| 51 |
+
p = perms.get(m.id)
|
| 52 |
+
out[m.key] = {
|
| 53 |
+
"view": bool(p and p.can_view),
|
| 54 |
+
"create": bool(p and p.can_create),
|
| 55 |
+
"edit": bool(p and p.can_edit),
|
| 56 |
+
"delete": bool(p and p.can_delete),
|
| 57 |
+
}
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def user_can(user: User, db: Session, module_key: str, action: str = "view") -> bool:
|
| 62 |
"""action: 'view' | 'create' | 'edit' | 'delete'."""
|
| 63 |
role = get_user_role_row(user, db)
|
|
|
|
| 103 |
a group item ("Configuration") which is only shown once it has at least
|
| 104 |
one visible child — otherwise a role with no admin access would see an
|
| 105 |
empty, dead group header."""
|
| 106 |
+
role = get_user_role_row(user, db)
|
| 107 |
+
can_view_module_ids = {
|
| 108 |
+
mid
|
| 109 |
+
for mid, p in _role_permission_rows(role, db).items()
|
| 110 |
+
if p.can_view
|
| 111 |
+
}
|
| 112 |
|
| 113 |
def _visible(item: MenuItem) -> bool:
|
| 114 |
if item.module_id is None:
|
| 115 |
return True
|
| 116 |
+
return item.module_id in can_view_module_ids
|
|
|
|
| 117 |
|
| 118 |
+
items = (
|
| 119 |
db.query(MenuItem)
|
| 120 |
+
.filter(MenuItem.is_active.is_(True))
|
| 121 |
.order_by(MenuItem.sort_order)
|
| 122 |
.all()
|
| 123 |
)
|
| 124 |
+
children_by_parent: dict[Optional[int], list] = {}
|
| 125 |
+
for item in items:
|
| 126 |
+
children_by_parent.setdefault(item.parent_id, []).append(item)
|
| 127 |
|
| 128 |
visible = []
|
| 129 |
+
for item in children_by_parent.get(None, []):
|
| 130 |
+
children = children_by_parent.get(item.id, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
item.visible_children = [c for c in children if _visible(c)]
|
| 132 |
is_group = bool(children)
|
| 133 |
if (item.visible_children if is_group else _visible(item)):
|
app/dda/rbac/routes.py
CHANGED
|
@@ -9,12 +9,12 @@ from pydantic import BaseModel, EmailStr, Field
|
|
| 9 |
from sqlalchemy.exc import IntegrityError
|
| 10 |
from sqlalchemy.orm import Session
|
| 11 |
|
| 12 |
-
from ...auth import get_password_hash, get_user_by_email, get_user_by_phone, normalize_phone
|
| 13 |
from ...database import get_db
|
| 14 |
from ...models import User
|
| 15 |
from ..dda_auth import current_dda_user
|
| 16 |
from .models import MenuItem, Module, Role, RolePermission
|
| 17 |
-
from .permissions import require_module_permission, user_can
|
| 18 |
|
| 19 |
router = APIRouter()
|
| 20 |
|
|
@@ -91,16 +91,7 @@ def my_permissions(db: Session = Depends(get_db), user: User = Depends(current_d
|
|
| 91 |
show/hide/disable in the UI (Upload, Run detection, Delete, etc.) so
|
| 92 |
the frontend always matches whatever's set in the Roles & Users
|
| 93 |
permission matrix, without hardcoding any role name."""
|
| 94 |
-
|
| 95 |
-
return {
|
| 96 |
-
m.key: {
|
| 97 |
-
"view": user_can(user, db, m.key, "view"),
|
| 98 |
-
"create": user_can(user, db, m.key, "create"),
|
| 99 |
-
"edit": user_can(user, db, m.key, "edit"),
|
| 100 |
-
"delete": user_can(user, db, m.key, "delete"),
|
| 101 |
-
}
|
| 102 |
-
for m in modules
|
| 103 |
-
}
|
| 104 |
|
| 105 |
|
| 106 |
# ---------------------------------------------------------------- Roles ----
|
|
@@ -476,6 +467,7 @@ def update_user(
|
|
| 476 |
target.role = role.name
|
| 477 |
if body.password:
|
| 478 |
target.hashed_password = get_password_hash(body.password)
|
|
|
|
| 479 |
db.commit()
|
| 480 |
_log_admin_action(db, "update_user", _user, {"user_id": target.id, "email": target.email, "role": role.name})
|
| 481 |
return _user_dict(target, role.name, role.id)
|
|
|
|
| 9 |
from sqlalchemy.exc import IntegrityError
|
| 10 |
from sqlalchemy.orm import Session
|
| 11 |
|
| 12 |
+
from ...auth import get_password_hash, get_user_by_email, get_user_by_phone, normalize_phone, revoke_user_sessions
|
| 13 |
from ...database import get_db
|
| 14 |
from ...models import User
|
| 15 |
from ..dda_auth import current_dda_user
|
| 16 |
from .models import MenuItem, Module, Role, RolePermission
|
| 17 |
+
from .permissions import require_module_permission, user_can, user_permissions_map
|
| 18 |
|
| 19 |
router = APIRouter()
|
| 20 |
|
|
|
|
| 91 |
show/hide/disable in the UI (Upload, Run detection, Delete, etc.) so
|
| 92 |
the frontend always matches whatever's set in the Roles & Users
|
| 93 |
permission matrix, without hardcoding any role name."""
|
| 94 |
+
return user_permissions_map(user, db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
# ---------------------------------------------------------------- Roles ----
|
|
|
|
| 467 |
target.role = role.name
|
| 468 |
if body.password:
|
| 469 |
target.hashed_password = get_password_hash(body.password)
|
| 470 |
+
revoke_user_sessions(target)
|
| 471 |
db.commit()
|
| 472 |
_log_admin_action(db, "update_user", _user, {"user_id": target.id, "email": target.email, "role": role.name})
|
| 473 |
return _user_dict(target, role.name, role.id)
|
app/dda/rbac/seed.py
CHANGED
|
@@ -24,7 +24,7 @@ DEFAULT_ROLES = [
|
|
| 24 |
# (key, name, description)
|
| 25 |
DEFAULT_MODULES = [
|
| 26 |
("home", "Dashboard", "System dashboard — detection overview, regions, reports"),
|
| 27 |
-
("library", "
|
| 28 |
("masters", "Masters", "Zone / district and village / location master data"),
|
| 29 |
("detect", "Change Detection", "Compare imagery and run detections"),
|
| 30 |
("reports", "Reports", "Detection history, exports, and PDF reports"),
|
|
@@ -42,7 +42,7 @@ DEFAULT_MODULES = [
|
|
| 42 |
DEFAULT_MENU_ITEMS = [
|
| 43 |
("Dashboard", "/", "home", 0, None),
|
| 44 |
("Masters", "#masters", None, 1, None),
|
| 45 |
-
("
|
| 46 |
("Zone / District", "/masters/zones", "masters", 1, "Masters"),
|
| 47 |
("Village / Location", "/masters/villages", "masters", 2, "Masters"),
|
| 48 |
("Change Detection", "/detect", "detect", 2, None),
|
|
|
|
| 24 |
# (key, name, description)
|
| 25 |
DEFAULT_MODULES = [
|
| 26 |
("home", "Dashboard", "System dashboard — detection overview, regions, reports"),
|
| 27 |
+
("library", "Image Library", "Browse and upload satellite / drone imagery"),
|
| 28 |
("masters", "Masters", "Zone / district and village / location master data"),
|
| 29 |
("detect", "Change Detection", "Compare imagery and run detections"),
|
| 30 |
("reports", "Reports", "Detection history, exports, and PDF reports"),
|
|
|
|
| 42 |
DEFAULT_MENU_ITEMS = [
|
| 43 |
("Dashboard", "/", "home", 0, None),
|
| 44 |
("Masters", "#masters", None, 1, None),
|
| 45 |
+
("Image Library", "/library", "library", 0, "Masters"),
|
| 46 |
("Zone / District", "/masters/zones", "masters", 1, "Masters"),
|
| 47 |
("Village / Location", "/masters/villages", "masters", 2, "Masters"),
|
| 48 |
("Change Detection", "/detect", "detect", 2, None),
|
app/dda/report_pdf.py
CHANGED
|
@@ -11,6 +11,16 @@ from ..models import DetectionRun
|
|
| 11 |
from .detect_service import _isoformat_ist
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def _safe_filename(title: str, run_id: int) -> str:
|
| 15 |
base = "".join(c if c.isalnum() or c in " -_" else "_" for c in (title or "report"))
|
| 16 |
base = base.strip().replace(" ", "_")[:60] or "report"
|
|
@@ -31,6 +41,9 @@ def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False, r
|
|
| 31 |
"changedPixels": run.changed_pixels,
|
| 32 |
"unchangedPixels": run.total_pixels - run.changed_pixels,
|
| 33 |
"changePercentage": run.change_percentage,
|
|
|
|
|
|
|
|
|
|
| 34 |
},
|
| 35 |
"regions": regions,
|
| 36 |
"regionsCount": run.regions_count,
|
|
@@ -80,8 +93,12 @@ def generate_report_pdf(run: DetectionRun, *, regions: Optional[List[dict]] = No
|
|
| 80 |
Paragraph(f"<b>Location:</b> {location}", body),
|
| 81 |
Paragraph(f"<b>Change:</b> {run.change_percentage:.2f}% ({run.changed_pixels:,} / {run.total_pixels:,} px)", body),
|
| 82 |
Paragraph(f"<b>Regions detected:</b> {run.regions_count}", body),
|
| 83 |
-
Spacer(1, 10),
|
| 84 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
overlay_path = run.overlay_path
|
| 87 |
if overlay_path:
|
|
|
|
| 11 |
from .detect_service import _isoformat_ist
|
| 12 |
|
| 13 |
|
| 14 |
+
def _detection_max_side() -> Optional[int]:
|
| 15 |
+
"""Detection resolution used across the app (see get_load_max_side) — a
|
| 16 |
+
static config value, not per-run, but useful context on a report."""
|
| 17 |
+
try:
|
| 18 |
+
from ..detection_config import get_load_max_side
|
| 19 |
+
return get_load_max_side()
|
| 20 |
+
except Exception:
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
def _safe_filename(title: str, run_id: int) -> str:
|
| 25 |
base = "".join(c if c.isalnum() or c in " -_" else "_" for c in (title or "report"))
|
| 26 |
base = base.strip().replace(" ", "_")[:60] or "report"
|
|
|
|
| 41 |
"changedPixels": run.changed_pixels,
|
| 42 |
"unchangedPixels": run.total_pixels - run.changed_pixels,
|
| 43 |
"changePercentage": run.change_percentage,
|
| 44 |
+
"detectionSensitivity": run.detection_sensitivity,
|
| 45 |
+
"minRegionArea": run.min_region_area,
|
| 46 |
+
"detectionMaxSide": _detection_max_side(),
|
| 47 |
},
|
| 48 |
"regions": regions,
|
| 49 |
"regionsCount": run.regions_count,
|
|
|
|
| 93 |
Paragraph(f"<b>Location:</b> {location}", body),
|
| 94 |
Paragraph(f"<b>Change:</b> {run.change_percentage:.2f}% ({run.changed_pixels:,} / {run.total_pixels:,} px)", body),
|
| 95 |
Paragraph(f"<b>Regions detected:</b> {run.regions_count}", body),
|
|
|
|
| 96 |
]
|
| 97 |
+
if run.detection_sensitivity is not None or run.min_region_area is not None:
|
| 98 |
+
sens_txt = f"{run.detection_sensitivity:.2f}" if run.detection_sensitivity is not None else "—"
|
| 99 |
+
area_txt = f"{run.min_region_area:,} px" if run.min_region_area is not None else "—"
|
| 100 |
+
story.append(Paragraph(f"<b>Detection settings:</b> Sensitivity {sens_txt} · Min region area {area_txt}", body))
|
| 101 |
+
story.append(Spacer(1, 10))
|
| 102 |
|
| 103 |
overlay_path = run.overlay_path
|
| 104 |
if overlay_path:
|
app/dda/reports_routes.py
CHANGED
|
@@ -175,3 +175,47 @@ def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get
|
|
| 175 |
if not sent:
|
| 176 |
raise HTTPException(status_code=400, detail=error or "Failed to send report email")
|
| 177 |
return {"ok": True, "message": f"Report link sent to {body.email.strip()}.", "reportUrl": report_url}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if not sent:
|
| 176 |
raise HTTPException(status_code=400, detail=error or "Failed to send report email")
|
| 177 |
return {"ok": True, "message": f"Report link sent to {body.email.strip()}.", "reportUrl": report_url}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class ReportTitleBody(BaseModel):
|
| 181 |
+
title: str
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@router.patch("/reports/{run_id}/title")
|
| 185 |
+
def update_report_title(
|
| 186 |
+
run_id: int,
|
| 187 |
+
body: ReportTitleBody,
|
| 188 |
+
db: Session = Depends(get_db),
|
| 189 |
+
user: User = Depends(require_module_permission("reports", "edit")),
|
| 190 |
+
):
|
| 191 |
+
"""Rename a report. The auto-generated 'before.tif vs after.tif' title
|
| 192 |
+
is rarely the name someone wants on a shared report, so this lets it be
|
| 193 |
+
edited after the fact without touching the underlying run data.
|
| 194 |
+
|
| 195 |
+
The Reports list (reports.js) shows a job's own cached title ahead of
|
| 196 |
+
the run's, for any run that still has a matching DetectionJob row — so
|
| 197 |
+
that job's params_json is updated too, or the list would keep showing
|
| 198 |
+
the old name even though the run and the report page are both current."""
|
| 199 |
+
_require_dda()
|
| 200 |
+
run = _get_user_run(db, run_id, user.id)
|
| 201 |
+
title = body.title.strip()
|
| 202 |
+
if not title:
|
| 203 |
+
raise HTTPException(status_code=400, detail="Title cannot be empty.")
|
| 204 |
+
if len(title) > 255:
|
| 205 |
+
raise HTTPException(status_code=400, detail="Title must be 255 characters or fewer.")
|
| 206 |
+
run.title = title
|
| 207 |
+
|
| 208 |
+
import json as _json
|
| 209 |
+
|
| 210 |
+
from .models import DetectionJob
|
| 211 |
+
job = db.query(DetectionJob).filter(DetectionJob.run_id == run.id).first()
|
| 212 |
+
if job:
|
| 213 |
+
try:
|
| 214 |
+
params = _json.loads(job.params_json or "{}")
|
| 215 |
+
except _json.JSONDecodeError:
|
| 216 |
+
params = {}
|
| 217 |
+
params["title"] = title
|
| 218 |
+
job.params_json = _json.dumps(params)
|
| 219 |
+
|
| 220 |
+
db.commit()
|
| 221 |
+
return {"ok": True, "title": run.title}
|
app/dda/review_service.py
CHANGED
|
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
| 8 |
from sqlalchemy import func
|
|
|
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from ..models import DetectionRun
|
|
@@ -62,7 +63,24 @@ def get_or_create_review(db: Session, run_id: int, region_id: int) -> RegionRevi
|
|
| 62 |
return row
|
| 63 |
row = RegionReview(run_id=run_id, region_id=region_id, status="pending")
|
| 64 |
db.add(row)
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
db.refresh(row)
|
| 67 |
return row
|
| 68 |
|
|
|
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
| 8 |
from sqlalchemy import func
|
| 9 |
+
from sqlalchemy.exc import IntegrityError
|
| 10 |
from sqlalchemy.orm import Session
|
| 11 |
|
| 12 |
from ..models import DetectionRun
|
|
|
|
| 63 |
return row
|
| 64 |
row = RegionReview(run_id=run_id, region_id=region_id, status="pending")
|
| 65 |
db.add(row)
|
| 66 |
+
try:
|
| 67 |
+
db.commit()
|
| 68 |
+
except IntegrityError:
|
| 69 |
+
# Two overlapping requests for the same region both found no existing
|
| 70 |
+
# row and raced to create one — the (run_id, region_id) unique index
|
| 71 |
+
# lets exactly one insert win; fall back to the row it created
|
| 72 |
+
# instead of duplicating (previously unconstrained, this silently
|
| 73 |
+
# produced duplicate RegionReview rows and inflated classification
|
| 74 |
+
# counts past regions_count).
|
| 75 |
+
db.rollback()
|
| 76 |
+
row = (
|
| 77 |
+
db.query(RegionReview)
|
| 78 |
+
.filter(RegionReview.run_id == run_id, RegionReview.region_id == region_id)
|
| 79 |
+
.first()
|
| 80 |
+
)
|
| 81 |
+
if row:
|
| 82 |
+
return row
|
| 83 |
+
raise
|
| 84 |
db.refresh(row)
|
| 85 |
return row
|
| 86 |
|
app/dda/tree/area_groups.py
CHANGED
|
@@ -1,15 +1,29 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
| 4 |
import math
|
| 5 |
import re
|
| 6 |
from collections import defaultdict
|
| 7 |
from datetime import datetime
|
| 8 |
from pathlib import Path
|
| 9 |
-
from typing import Any, Optional
|
| 10 |
|
| 11 |
from ..geo_regions import parse_bounds
|
| 12 |
|
|
|
|
|
|
|
| 13 |
YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
| 14 |
GRID_RE = re.compile(r"grid\s*(?:no\.?\s*)?(\d+)", re.I)
|
| 15 |
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(
|
|
| 22 |
re.I,
|
| 23 |
)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def _bbox(bounds: Any) -> Optional[tuple[float, float, float, float]]:
|
| 27 |
parsed = parse_bounds(bounds)
|
|
@@ -47,21 +74,35 @@ def _intersection(a: tuple[float, float, float, float], b: tuple[float, float, f
|
|
| 47 |
return (west, south, east, north)
|
| 48 |
|
| 49 |
|
| 50 |
-
def
|
| 51 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
ba, bb = _bbox(a), _bbox(b)
|
| 53 |
if not ba or not bb:
|
| 54 |
-
return
|
| 55 |
inter = _intersection(ba, bb)
|
| 56 |
if not inter:
|
| 57 |
-
return
|
| 58 |
aa, ab, ai = _area(ba), _area(bb), _area(inter)
|
| 59 |
if min(aa, ab) <= 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
return False
|
| 61 |
-
iou =
|
| 62 |
-
overlap_small = ai / min(aa, ab)
|
| 63 |
-
ratio = min(aa, ab) / max(aa, ab)
|
| 64 |
-
return iou >= 0.25 or (overlap_small >= 0.55 and ratio >= 0.15)
|
| 65 |
|
| 66 |
|
| 67 |
def distinctive_keys(filename: str) -> set[str]:
|
|
@@ -153,33 +194,20 @@ def _name_rank(filename: str) -> int:
|
|
| 153 |
|
| 154 |
|
| 155 |
def _sort_images(images: list[dict]) -> list[dict]:
|
|
|
|
|
|
|
| 156 |
return sorted(
|
| 157 |
images,
|
| 158 |
key=lambda img: (
|
| 159 |
-
sort_datetime(img),
|
| 160 |
_name_rank(img.get("filename") or ""),
|
|
|
|
| 161 |
(img.get("filename") or "").lower(),
|
| 162 |
img.get("id") or 0,
|
| 163 |
),
|
| 164 |
)
|
| 165 |
|
| 166 |
|
| 167 |
-
class _UnionFind:
|
| 168 |
-
def __init__(self, n: int):
|
| 169 |
-
self.parent = list(range(n))
|
| 170 |
-
|
| 171 |
-
def find(self, i: int) -> int:
|
| 172 |
-
while self.parent[i] != i:
|
| 173 |
-
self.parent[i] = self.parent[self.parent[i]]
|
| 174 |
-
i = self.parent[i]
|
| 175 |
-
return i
|
| 176 |
-
|
| 177 |
-
def union(self, a: int, b: int) -> None:
|
| 178 |
-
ra, rb = self.find(a), self.find(b)
|
| 179 |
-
if ra != rb:
|
| 180 |
-
self.parent[rb] = ra
|
| 181 |
-
|
| 182 |
-
|
| 183 |
_HARD_KEY_PREFIXES = ("seq:", "grid:", "sheet:")
|
| 184 |
|
| 185 |
|
|
@@ -190,14 +218,51 @@ def _hard_keys(keys: set[str]) -> set[str]:
|
|
| 190 |
def distinct_places(keys_a: set[str], keys_b: set[str]) -> bool:
|
| 191 |
"""True when both images are already identified as different sites.
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
"""
|
| 197 |
ha, hb = _hard_keys(keys_a), _hard_keys(keys_b)
|
| 198 |
return bool(ha and hb and ha.isdisjoint(hb))
|
| 199 |
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
def _group_label(images: list[dict], keys: set[str]) -> str:
|
| 202 |
base = ""
|
| 203 |
for key in sorted(keys):
|
|
@@ -235,98 +300,447 @@ def _group_label(images: list[dict], keys: set[str]) -> str:
|
|
| 235 |
return base
|
| 236 |
|
| 237 |
|
| 238 |
-
def
|
| 239 |
-
|
| 240 |
-
if sum(1 for b in boxes if b) >= 2:
|
| 241 |
-
return "bounds"
|
| 242 |
-
return "filename"
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
def _spatial_cells(b: tuple[float, float, float, float], cell: float = 0.05):
|
| 246 |
west, south, east, north = b
|
| 247 |
x0 = math.floor(west / cell)
|
| 248 |
x1 = math.floor((east - 1e-12) / cell)
|
| 249 |
y0 = math.floor(south / cell)
|
| 250 |
y1 = math.floor((north - 1e-12) / cell)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
for x in range(x0, x1 + 1):
|
| 252 |
for y in range(y0, y1 + 1):
|
| 253 |
yield (x, y)
|
| 254 |
|
| 255 |
|
| 256 |
-
def
|
| 257 |
-
"""
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
|
| 263 |
-
def
|
| 264 |
-
"""
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
by_key: dict[str, list[int]] = defaultdict(list)
|
| 271 |
-
for
|
| 272 |
-
for
|
| 273 |
-
by_key[
|
| 274 |
-
for
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
| 283 |
continue
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
for
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
groups = []
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
if
|
| 313 |
-
unpaired.extend(members)
|
| 314 |
continue
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
before, after = members[0], members[-1]
|
| 322 |
-
public_members = [_public_image(m) for m in members]
|
| 323 |
groups.append({
|
| 324 |
"id": f"area-{before.get('id')}-{after.get('id')}",
|
| 325 |
-
"label": _group_label(
|
| 326 |
-
"match":
|
| 327 |
-
"
|
| 328 |
-
"
|
| 329 |
-
"
|
|
|
|
|
|
|
|
|
|
| 330 |
"beforePath": before.get("path"),
|
| 331 |
"afterPath": after.get("path"),
|
| 332 |
"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:
|
|
| 334 |
})
|
| 335 |
|
| 336 |
groups.sort(key=lambda g: (g["label"].lower(), g["id"]))
|
| 337 |
-
unpaired = [
|
|
|
|
|
|
|
|
|
|
| 338 |
return {
|
| 339 |
"groups": groups,
|
| 340 |
"unpaired": unpaired,
|
|
|
|
| 341 |
"totalImages": n,
|
| 342 |
-
"pairedImages":
|
| 343 |
}
|
| 344 |
|
| 345 |
|
| 346 |
-
def
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
from .image_service import library_fingerprint, list_all_images
|
| 349 |
from .library_cache import get_area_groups_cache, set_area_groups_cache
|
| 350 |
|
| 351 |
fp = library_fingerprint(db)
|
| 352 |
-
|
|
|
|
| 353 |
if cached is not None:
|
| 354 |
-
return cached
|
| 355 |
-
images = list_all_images(db, include_bounds=True, slim=True)
|
| 356 |
-
payload = build_area_groups(images)
|
| 357 |
-
set_area_groups_cache(fp, payload)
|
| 358 |
-
return payload
|
| 359 |
|
| 360 |
|
| 361 |
def filter_area_groups_for_node(payload: dict, node) -> dict:
|
|
|
|
| 362 |
prefix = (getattr(node, "node_path", None) or "").rstrip("/")
|
| 363 |
node_id = getattr(node, "id", None)
|
| 364 |
|
|
@@ -374,6 +811,7 @@ def filter_area_groups_for_node(payload: dict, node) -> dict:
|
|
| 374 |
return {
|
| 375 |
"groups": groups,
|
| 376 |
"unpaired": unpaired,
|
|
|
|
| 377 |
"totalImages": payload.get("totalImages", 0),
|
| 378 |
"pairedImages": sum(len(g.get("images") or []) for g in groups),
|
| 379 |
}
|
|
|
|
| 1 |
+
"""Pair library images that cover the same place.
|
| 2 |
+
|
| 3 |
+
Two images are compared on every piece of metadata the library actually holds:
|
| 4 |
+
administrative location (zone / village), folder path, capture date, filename,
|
| 5 |
+
and geographic overlap. Each signal contributes to a score, and a few of them
|
| 6 |
+
act as hard gates that rule a pairing out entirely.
|
| 7 |
+
|
| 8 |
+
Automatic pairing then picks the best-scoring pairs greedily, so a pair always
|
| 9 |
+
holds exactly two images and no image is used by more than one pair. The full
|
| 10 |
+
same-place cluster is still reported as ``candidates`` so an operator can swap
|
| 11 |
+
in a different image by hand.
|
| 12 |
+
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
import logging
|
| 16 |
import math
|
| 17 |
import re
|
| 18 |
from collections import defaultdict
|
| 19 |
from datetime import datetime
|
| 20 |
from pathlib import Path
|
| 21 |
+
from typing import Any, NamedTuple, Optional
|
| 22 |
|
| 23 |
from ..geo_regions import parse_bounds
|
| 24 |
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
| 28 |
GRID_RE = re.compile(r"grid\s*(?:no\.?\s*)?(\d+)", re.I)
|
| 29 |
SHEET_RE = re.compile(r"\b([a-z]\d{2}[a-z]\d[a-z]\d+)\b", re.I)
|
|
|
|
| 36 |
re.I,
|
| 37 |
)
|
| 38 |
|
| 39 |
+
# Candidate generation is blocked so a large library stays near-linear: only
|
| 40 |
+
# images that already share a filename key, a village, or a map cell are ever
|
| 41 |
+
# compared. These caps bound the work inside one oversized block.
|
| 42 |
+
_SPATIAL_CELL = 0.02 # ~2 km at Delhi's latitude
|
| 43 |
+
_SPATIAL_SUBCELL = 0.002 # ~200 m, used to split a crowded cell
|
| 44 |
+
_BLOCK_FULL_LIMIT = 64 # compare every combination up to this block size
|
| 45 |
+
_BLOCK_WINDOW = 12 # past it, only compare near-in-time neighbours
|
| 46 |
+
_MAX_CANDIDATES = 500_000
|
| 47 |
+
|
| 48 |
+
# A pair needs both a "same place" signal (see _score_pair) and enough total
|
| 49 |
+
# agreement to be worth proposing.
|
| 50 |
+
_MIN_PAIR_SCORE = 24
|
| 51 |
+
|
| 52 |
|
| 53 |
def _bbox(bounds: Any) -> Optional[tuple[float, float, float, float]]:
|
| 54 |
parsed = parse_bounds(bounds)
|
|
|
|
| 74 |
return (west, south, east, north)
|
| 75 |
|
| 76 |
|
| 77 |
+
def overlap_stats(a: Any, b: Any) -> Optional[dict]:
|
| 78 |
+
"""IoU plus how much of the smaller box is covered, or None if disjoint.
|
| 79 |
+
|
| 80 |
+
``small`` and ``ratio`` matter because a wide orthomosaic legitimately
|
| 81 |
+
contains a smaller tile of the same place: IoU alone would call that a
|
| 82 |
+
mismatch.
|
| 83 |
+
"""
|
| 84 |
ba, bb = _bbox(a), _bbox(b)
|
| 85 |
if not ba or not bb:
|
| 86 |
+
return None
|
| 87 |
inter = _intersection(ba, bb)
|
| 88 |
if not inter:
|
| 89 |
+
return None
|
| 90 |
aa, ab, ai = _area(ba), _area(bb), _area(inter)
|
| 91 |
if min(aa, ab) <= 0:
|
| 92 |
+
return None
|
| 93 |
+
return {
|
| 94 |
+
"iou": ai / max(aa + ab - ai, 1e-12),
|
| 95 |
+
"small": ai / min(aa, ab),
|
| 96 |
+
"ratio": min(aa, ab) / max(aa, ab),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def bounds_same_area(a: Any, b: Any) -> bool:
|
| 101 |
+
"""True when two WGS84 boxes cover essentially the same place (not a tiny crop in a huge ORI)."""
|
| 102 |
+
stats = overlap_stats(a, b)
|
| 103 |
+
if not stats:
|
| 104 |
return False
|
| 105 |
+
return stats["iou"] >= 0.25 or (stats["small"] >= 0.55 and stats["ratio"] >= 0.15)
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
|
| 108 |
def distinctive_keys(filename: str) -> set[str]:
|
|
|
|
| 194 |
|
| 195 |
|
| 196 |
def _sort_images(images: list[dict]) -> list[dict]:
|
| 197 |
+
"""Oldest first. Within one day the before/after in the name decides,
|
| 198 |
+
because a same-day upload order says nothing about which came first."""
|
| 199 |
return sorted(
|
| 200 |
images,
|
| 201 |
key=lambda img: (
|
| 202 |
+
sort_datetime(img).date(),
|
| 203 |
_name_rank(img.get("filename") or ""),
|
| 204 |
+
sort_datetime(img),
|
| 205 |
(img.get("filename") or "").lower(),
|
| 206 |
img.get("id") or 0,
|
| 207 |
),
|
| 208 |
)
|
| 209 |
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
_HARD_KEY_PREFIXES = ("seq:", "grid:", "sheet:")
|
| 212 |
|
| 213 |
|
|
|
|
| 218 |
def distinct_places(keys_a: set[str], keys_b: set[str]) -> bool:
|
| 219 |
"""True when both images are already identified as different sites.
|
| 220 |
|
| 221 |
+
They may still be the same place under two naming schemes (a grid number
|
| 222 |
+
and a survey sheet id), so this only raises the bar: such a pairing needs
|
| 223 |
+
strong geographic proof rather than being ruled out outright.
|
| 224 |
"""
|
| 225 |
ha, hb = _hard_keys(keys_a), _hard_keys(keys_b)
|
| 226 |
return bool(ha and hb and ha.isdisjoint(hb))
|
| 227 |
|
| 228 |
|
| 229 |
+
def conflicting_keys(keys_a: set[str], keys_b: set[str]) -> bool:
|
| 230 |
+
"""True when both names use the same vocabulary but disagree within it.
|
| 231 |
+
|
| 232 |
+
``before3`` vs ``before7`` are two different before/after sets, and
|
| 233 |
+
``grid 54`` vs ``grid 60`` are two different grids — no amount of map
|
| 234 |
+
overlap makes those one pair. A grid number against a sheet id is not a
|
| 235 |
+
conflict, because neither vocabulary can contradict the other.
|
| 236 |
+
"""
|
| 237 |
+
for prefix in _HARD_KEY_PREFIXES:
|
| 238 |
+
a = {k for k in keys_a if k.startswith(prefix)}
|
| 239 |
+
b = {k for k in keys_b if k.startswith(prefix)}
|
| 240 |
+
if a and b and a.isdisjoint(b):
|
| 241 |
+
return True
|
| 242 |
+
return False
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _key_text(key: str) -> str:
|
| 246 |
+
kind, _, value = key.partition(":")
|
| 247 |
+
if kind == "grid":
|
| 248 |
+
return f"grid {value}"
|
| 249 |
+
if kind == "sheet":
|
| 250 |
+
return f"sheet {value.upper()}"
|
| 251 |
+
if kind == "seq":
|
| 252 |
+
return f"before/after set {value}"
|
| 253 |
+
return "filename"
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _gap_text(days: int) -> str:
|
| 257 |
+
if days < 45:
|
| 258 |
+
return f"{days} day{'' if days == 1 else 's'} apart"
|
| 259 |
+
if days < 730:
|
| 260 |
+
months = max(1, round(days / 30.4))
|
| 261 |
+
return f"{months} month{'' if months == 1 else 's'} apart"
|
| 262 |
+
years = round(days / 365.25, 1)
|
| 263 |
+
return f"{years:g} years apart"
|
| 264 |
+
|
| 265 |
+
|
| 266 |
def _group_label(images: list[dict], keys: set[str]) -> str:
|
| 267 |
base = ""
|
| 268 |
for key in sorted(keys):
|
|
|
|
| 300 |
return base
|
| 301 |
|
| 302 |
|
| 303 |
+
def _spatial_cells(b: tuple[float, float, float, float], cell: float = _SPATIAL_CELL):
|
| 304 |
+
"""Every grid cell the box touches, so any two overlapping boxes meet in one."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
west, south, east, north = b
|
| 306 |
x0 = math.floor(west / cell)
|
| 307 |
x1 = math.floor((east - 1e-12) / cell)
|
| 308 |
y0 = math.floor(south / cell)
|
| 309 |
y1 = math.floor((north - 1e-12) / cell)
|
| 310 |
+
# A city-wide orthomosaic can span thousands of cells; indexing it in all
|
| 311 |
+
# of them would cost far more than the recall it buys.
|
| 312 |
+
if (x1 - x0 + 1) * (y1 - y0 + 1) > 256:
|
| 313 |
+
yield (x0, y0)
|
| 314 |
+
yield (x1, y1)
|
| 315 |
+
return
|
| 316 |
for x in range(x0, x1 + 1):
|
| 317 |
for y in range(y0, y1 + 1):
|
| 318 |
yield (x, y)
|
| 319 |
|
| 320 |
|
| 321 |
+
def _clip_to_cell(b: tuple[float, float, float, float], cell: tuple[int, int], size: float):
|
| 322 |
+
"""Box trimmed to one coarse cell, so a re-hash inside it stays bounded."""
|
| 323 |
+
west = max(b[0], cell[0] * size)
|
| 324 |
+
south = max(b[1], cell[1] * size)
|
| 325 |
+
east = min(b[2], (cell[0] + 1) * size)
|
| 326 |
+
north = min(b[3], (cell[1] + 1) * size)
|
| 327 |
+
if east <= west or north <= south:
|
| 328 |
+
return None
|
| 329 |
+
return (west, south, east, north)
|
| 330 |
|
| 331 |
|
| 332 |
+
def _public_image(img: dict, item: Optional["_Item"] = None) -> dict:
|
| 333 |
+
"""Keep only fields the pairs UI needs (id/path/dates/location)."""
|
| 334 |
+
zone_id = item.zone if item is not None else img.get("zoneId")
|
| 335 |
+
village_id = item.village if item is not None else img.get("villageId")
|
| 336 |
+
return {
|
| 337 |
+
"id": img.get("id"),
|
| 338 |
+
"path": img.get("path"),
|
| 339 |
+
"filename": img.get("filename") or img.get("imageName"),
|
| 340 |
+
"imageName": img.get("imageName") or img.get("filename"),
|
| 341 |
+
"captureDate": img.get("captureDate"),
|
| 342 |
+
"nodeId": img.get("nodeId"),
|
| 343 |
+
"nodePath": img.get("nodePath") or "",
|
| 344 |
+
"zoneId": zone_id,
|
| 345 |
+
"villageId": village_id,
|
| 346 |
+
"zoneName": (item.zone_name if item is not None else None) or img.get("zoneName") or "",
|
| 347 |
+
"villageName": (item.village_name if item is not None else None) or img.get("villageName") or "",
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
class ResolvedLocation(NamedTuple):
|
| 352 |
+
zone_id: Optional[int]
|
| 353 |
+
village_id: Optional[int]
|
| 354 |
+
zone_name: str
|
| 355 |
+
village_name: str
|
| 356 |
+
# True only when a person chose this on upload. A location read off the
|
| 357 |
+
# folder tree is a hint, so it may suggest a pairing but never veto one.
|
| 358 |
+
explicit: bool
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
# Folder names that stand for "no location yet" — matching them against the
|
| 362 |
+
# masters would invent a location the image does not really have.
|
| 363 |
+
_PLACEHOLDER_LOCATIONS = frozenset({
|
| 364 |
+
"unassigned", "_unassigned", "unknown", "uncategorized",
|
| 365 |
+
"legacy", "misc", "other", "root", "images", "temp", "tmp",
|
| 366 |
+
})
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
class LocationIndex:
|
| 370 |
+
"""Resolves an image's zone / village, falling back to its folder names.
|
| 371 |
+
|
| 372 |
+
Uploads carry an explicit choice, but the folder tree has always spelled
|
| 373 |
+
the same thing out ("Central Delhi/Daryaganj"), so images indexed from
|
| 374 |
+
disk still get a location to compare on.
|
| 375 |
+
"""
|
| 376 |
+
|
| 377 |
+
def __init__(self, zones=(), villages=()):
|
| 378 |
+
self.zone_by_name: dict[str, int] = {}
|
| 379 |
+
self.zone_names: dict[int, str] = {}
|
| 380 |
+
for zone_id, name in zones:
|
| 381 |
+
clean = (name or "").strip()
|
| 382 |
+
if clean:
|
| 383 |
+
self.zone_by_name[clean.lower()] = zone_id
|
| 384 |
+
self.zone_names[zone_id] = clean
|
| 385 |
+
# One village name can exist in several zones, so keep every option.
|
| 386 |
+
self.villages_by_name: dict[str, list] = defaultdict(list)
|
| 387 |
+
self.village_names: dict[int, str] = {}
|
| 388 |
+
for village_id, name, zone_id in villages:
|
| 389 |
+
clean = (name or "").strip()
|
| 390 |
+
if clean:
|
| 391 |
+
self.villages_by_name[clean.lower()].append((zone_id, village_id))
|
| 392 |
+
self.village_names[village_id] = clean
|
| 393 |
+
|
| 394 |
+
def __bool__(self) -> bool:
|
| 395 |
+
return bool(self.zone_by_name or self.villages_by_name)
|
| 396 |
+
|
| 397 |
+
def resolve(self, img: dict) -> ResolvedLocation:
|
| 398 |
+
zone_id = img.get("zoneId") or None
|
| 399 |
+
village_id = img.get("villageId") or None
|
| 400 |
+
explicit = bool(zone_id or village_id)
|
| 401 |
+
if not (zone_id and village_id) and self:
|
| 402 |
+
segments = [
|
| 403 |
+
s.strip().lower()
|
| 404 |
+
for s in str(img.get("nodePath") or "").replace("\\", "/").split("/")
|
| 405 |
+
if s.strip() and s.strip().lower() not in _PLACEHOLDER_LOCATIONS
|
| 406 |
+
]
|
| 407 |
+
if not zone_id:
|
| 408 |
+
for segment in segments:
|
| 409 |
+
if segment in self.zone_by_name:
|
| 410 |
+
zone_id = self.zone_by_name[segment]
|
| 411 |
+
break
|
| 412 |
+
if not village_id:
|
| 413 |
+
for segment in segments:
|
| 414 |
+
options = self.villages_by_name.get(segment) or []
|
| 415 |
+
if zone_id:
|
| 416 |
+
options = [o for o in options if o[0] == zone_id] or options
|
| 417 |
+
# An ambiguous name is left unresolved rather than guessed.
|
| 418 |
+
if len(options) == 1:
|
| 419 |
+
zone_id = zone_id or options[0][0]
|
| 420 |
+
village_id = options[0][1]
|
| 421 |
+
break
|
| 422 |
+
return ResolvedLocation(
|
| 423 |
+
zone_id,
|
| 424 |
+
village_id,
|
| 425 |
+
img.get("zoneName") or self.zone_names.get(zone_id, ""),
|
| 426 |
+
img.get("villageName") or self.village_names.get(village_id, ""),
|
| 427 |
+
explicit,
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def build_location_index(db) -> LocationIndex:
|
| 432 |
+
from ..models import DdaVillage, DdaZone
|
| 433 |
+
|
| 434 |
+
return LocationIndex(
|
| 435 |
+
db.query(DdaZone.id, DdaZone.name).all(),
|
| 436 |
+
db.query(DdaVillage.id, DdaVillage.name, DdaVillage.zone_id).all(),
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
class _Item:
|
| 441 |
+
"""Everything the scorer needs about one image, computed once."""
|
| 442 |
+
|
| 443 |
+
__slots__ = (
|
| 444 |
+
"idx", "img", "path", "filename", "keys", "hard", "bbox", "when",
|
| 445 |
+
"zone", "village", "zone_name", "village_name", "pinned_location",
|
| 446 |
+
"node_id", "folder", "rank",
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
def __init__(self, idx: int, img: dict, locations: Optional[LocationIndex] = None):
|
| 450 |
+
self.idx = idx
|
| 451 |
+
self.img = img
|
| 452 |
+
self.path = (img.get("path") or "").replace("\\", "/")
|
| 453 |
+
self.filename = img.get("filename") or img.get("imageName") or self.path
|
| 454 |
+
self.keys = distinctive_keys(self.filename or self.path)
|
| 455 |
+
self.hard = _hard_keys(self.keys)
|
| 456 |
+
self.bbox = _bbox(img.get("bounds"))
|
| 457 |
+
self.when = infer_capture_datetime(img)
|
| 458 |
+
place = (locations or LocationIndex()).resolve(img)
|
| 459 |
+
self.zone = place.zone_id
|
| 460 |
+
self.village = place.village_id
|
| 461 |
+
self.zone_name = place.zone_name
|
| 462 |
+
self.village_name = place.village_name
|
| 463 |
+
self.pinned_location = place.explicit
|
| 464 |
+
self.node_id = img.get("nodeId") or None
|
| 465 |
+
folder = (img.get("nodePath") or "").strip("/")
|
| 466 |
+
if not folder:
|
| 467 |
+
folder = self.path.rsplit("/", 1)[0] if "/" in self.path else ""
|
| 468 |
+
self.folder = folder.lower()
|
| 469 |
+
self.rank = _name_rank(self.filename)
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def _folder_score(a: _Item, b: _Item) -> tuple[float, str]:
|
| 473 |
+
if a.node_id and b.node_id and a.node_id == b.node_id:
|
| 474 |
+
return 10.0, "same folder"
|
| 475 |
+
if a.folder and a.folder == b.folder:
|
| 476 |
+
return 10.0, "same folder"
|
| 477 |
+
if not a.folder or not b.folder:
|
| 478 |
+
return 0.0, ""
|
| 479 |
+
seg_a, seg_b = a.folder.split("/"), b.folder.split("/")
|
| 480 |
+
shared = 0
|
| 481 |
+
for x, y in zip(seg_a, seg_b):
|
| 482 |
+
if x != y:
|
| 483 |
+
break
|
| 484 |
+
shared += 1
|
| 485 |
+
if shared:
|
| 486 |
+
return min(6.0, 2.0 * shared), "same folder branch"
|
| 487 |
+
return 0.0, ""
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _date_score(a: _Item, b: _Item) -> tuple[float, str]:
|
| 491 |
+
if not a.when or not b.when:
|
| 492 |
+
return 0.0, ""
|
| 493 |
+
gap = abs((a.when - b.when).days)
|
| 494 |
+
if gap == 0:
|
| 495 |
+
# Change detection needs two moments in time; a same-day pairing only
|
| 496 |
+
# survives when the filenames explicitly say before/after.
|
| 497 |
+
return -4.0, "same capture date"
|
| 498 |
+
if gap >= 180:
|
| 499 |
+
return 14.0, _gap_text(gap)
|
| 500 |
+
if gap >= 30:
|
| 501 |
+
return 12.0, _gap_text(gap)
|
| 502 |
+
return 8.0, _gap_text(gap)
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def _score_pair(a: _Item, b: _Item) -> Optional[dict]:
|
| 506 |
+
"""Score one candidate pairing, or None when the metadata rules it out."""
|
| 507 |
+
if a.path and a.path == b.path:
|
| 508 |
+
return None
|
| 509 |
+
if conflicting_keys(a.hard, b.hard):
|
| 510 |
+
return None
|
| 511 |
+
# Only a location someone actually picked can veto a pairing; one read off
|
| 512 |
+
# the folder tree just describes how the files happen to be filed.
|
| 513 |
+
if a.pinned_location and b.pinned_location:
|
| 514 |
+
if a.zone and b.zone and a.zone != b.zone:
|
| 515 |
+
return None
|
| 516 |
+
if a.village and b.village and a.village != b.village:
|
| 517 |
+
return None
|
| 518 |
+
|
| 519 |
+
stats = overlap_stats(a.bbox, b.bbox) if (a.bbox and b.bbox) else None
|
| 520 |
+
if a.bbox and b.bbox and stats is None:
|
| 521 |
+
# Both are georeferenced and their footprints do not touch.
|
| 522 |
+
return None
|
| 523 |
+
strong_geo = bool(stats) and (stats["small"] >= 0.85 and stats["ratio"] >= 0.30)
|
| 524 |
+
if distinct_places(a.hard, b.hard) and not strong_geo:
|
| 525 |
+
return None
|
| 526 |
+
|
| 527 |
+
score = 0.0
|
| 528 |
+
reasons: list[str] = []
|
| 529 |
+
match = "filename"
|
| 530 |
+
|
| 531 |
+
geo_ok = False
|
| 532 |
+
if stats:
|
| 533 |
+
geo_ok = stats["iou"] >= 0.25 or (stats["small"] >= 0.55 and stats["ratio"] >= 0.15)
|
| 534 |
+
score += 40.0 * min(1.0, stats["iou"])
|
| 535 |
+
if stats["small"] >= 0.8:
|
| 536 |
+
score += 10.0
|
| 537 |
+
if geo_ok:
|
| 538 |
+
match = "bounds"
|
| 539 |
+
reasons.append(f"{round(stats['iou'] * 100)}% map overlap")
|
| 540 |
+
|
| 541 |
+
shared_hard = a.hard & b.hard
|
| 542 |
+
shared_soft = (a.keys & b.keys) - shared_hard
|
| 543 |
+
if shared_hard:
|
| 544 |
+
score += 26.0
|
| 545 |
+
reasons.append(f"matching {_key_text(sorted(shared_hard)[0])}")
|
| 546 |
+
elif shared_soft:
|
| 547 |
+
score += 16.0
|
| 548 |
+
reasons.append("matching filename")
|
| 549 |
+
|
| 550 |
+
same_village = bool(a.village) and a.village == b.village
|
| 551 |
+
if same_village:
|
| 552 |
+
score += 20.0
|
| 553 |
+
reasons.append(f"same location ({a.village_name})" if a.village_name else "same location")
|
| 554 |
+
if match == "filename" and not shared_hard and not shared_soft:
|
| 555 |
+
match = "location"
|
| 556 |
+
elif a.zone and a.zone == b.zone:
|
| 557 |
+
score += 12.0
|
| 558 |
+
reasons.append(f"same zone ({a.zone_name})" if a.zone_name else "same zone")
|
| 559 |
+
|
| 560 |
+
folder_points, folder_text = _folder_score(a, b)
|
| 561 |
+
score += folder_points
|
| 562 |
+
if folder_text:
|
| 563 |
+
reasons.append(folder_text)
|
| 564 |
+
|
| 565 |
+
date_points, date_text = _date_score(a, b)
|
| 566 |
+
score += date_points
|
| 567 |
+
if date_text:
|
| 568 |
+
reasons.append(date_text)
|
| 569 |
+
|
| 570 |
+
if {a.rank, b.rank} == {0, 2}:
|
| 571 |
+
score += 12.0
|
| 572 |
+
reasons.append("named before / after")
|
| 573 |
+
|
| 574 |
+
# Folder, zone and capture date only say "could be related". When both
|
| 575 |
+
# footprints are known the map is authoritative — a shared name key can
|
| 576 |
+
# still vouch for a partial overlap, but a folder or village cannot.
|
| 577 |
+
if a.bbox and b.bbox:
|
| 578 |
+
same_place = geo_ok or bool(shared_hard)
|
| 579 |
+
else:
|
| 580 |
+
same_place = geo_ok or bool(shared_hard) or bool(shared_soft) or same_village
|
| 581 |
+
if not same_place:
|
| 582 |
+
return None
|
| 583 |
+
if score < _MIN_PAIR_SCORE:
|
| 584 |
+
return None
|
| 585 |
+
return {"score": score, "reasons": reasons, "match": match}
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
def _emit_block(members: list[int], items: list[_Item], add) -> None:
|
| 589 |
+
members = list(dict.fromkeys(members))
|
| 590 |
+
if len(members) < 2:
|
| 591 |
+
return
|
| 592 |
+
if len(members) <= _BLOCK_FULL_LIMIT:
|
| 593 |
+
for a in range(len(members)):
|
| 594 |
+
for b in range(a + 1, len(members)):
|
| 595 |
+
add(members[a], members[b])
|
| 596 |
+
return
|
| 597 |
+
# Too crowded to compare exhaustively: order by capture time and only look
|
| 598 |
+
# at each image's nearest neighbours, since a pair needs two dates anyway.
|
| 599 |
+
members.sort(key=lambda i: (items[i].when or datetime.min, items[i].path))
|
| 600 |
+
for a in range(len(members)):
|
| 601 |
+
for b in range(a + 1, min(len(members), a + _BLOCK_WINDOW)):
|
| 602 |
+
add(members[a], members[b])
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def _candidate_pairs(items: list[_Item]) -> set[tuple[int, int]]:
|
| 606 |
+
"""Index pairings worth scoring: shared filename key, map cell, or village.
|
| 607 |
+
|
| 608 |
+
Folder and zone are deliberately not blocking keys. Neither can establish
|
| 609 |
+
on its own that two images show the same place, so blocking on them would
|
| 610 |
+
only add candidates the scorer then throws away.
|
| 611 |
+
"""
|
| 612 |
+
pairs: set[tuple[int, int]] = set()
|
| 613 |
+
truncated = False
|
| 614 |
+
|
| 615 |
+
def add(i: int, j: int) -> None:
|
| 616 |
+
nonlocal truncated
|
| 617 |
+
if len(pairs) >= _MAX_CANDIDATES:
|
| 618 |
+
truncated = True
|
| 619 |
+
return
|
| 620 |
+
pairs.add((i, j) if i < j else (j, i))
|
| 621 |
|
| 622 |
by_key: dict[str, list[int]] = defaultdict(list)
|
| 623 |
+
for item in items:
|
| 624 |
+
for key in item.keys:
|
| 625 |
+
by_key[key].append(item.idx)
|
| 626 |
+
for members in by_key.values():
|
| 627 |
+
_emit_block(members, items, add)
|
| 628 |
+
|
| 629 |
+
by_cell: dict[tuple[int, int], list[int]] = defaultdict(list)
|
| 630 |
+
for item in items:
|
| 631 |
+
if item.bbox:
|
| 632 |
+
for cell in _spatial_cells(item.bbox):
|
| 633 |
+
by_cell[cell].append(item.idx)
|
| 634 |
+
for cell, members in by_cell.items():
|
| 635 |
+
if len(members) <= _BLOCK_FULL_LIMIT:
|
| 636 |
+
_emit_block(members, items, add)
|
| 637 |
continue
|
| 638 |
+
# Dense city imagery keeps its geographic precision by re-hashing the
|
| 639 |
+
# crowded cell at a finer resolution instead of giving up on bounds.
|
| 640 |
+
sub: dict[tuple[int, int], list[int]] = defaultdict(list)
|
| 641 |
+
for idx in members:
|
| 642 |
+
clipped = _clip_to_cell(items[idx].bbox, cell, _SPATIAL_CELL)
|
| 643 |
+
if not clipped:
|
| 644 |
+
continue
|
| 645 |
+
for fine in _spatial_cells(clipped, _SPATIAL_SUBCELL):
|
| 646 |
+
sub[fine].append(idx)
|
| 647 |
+
for group in sub.values():
|
| 648 |
+
_emit_block(group, items, add)
|
| 649 |
+
|
| 650 |
+
# Village is the only bridge for an image with no map extent; images that
|
| 651 |
+
# do have one are already indexed by cell, so skip those combinations.
|
| 652 |
+
by_village: dict[Any, list[int]] = defaultdict(list)
|
| 653 |
+
for item in items:
|
| 654 |
+
if item.village:
|
| 655 |
+
by_village[item.village].append(item.idx)
|
| 656 |
+
for members in by_village.values():
|
| 657 |
+
floating = [i for i in members if not items[i].bbox]
|
| 658 |
+
if not floating:
|
| 659 |
+
continue
|
| 660 |
+
_emit_block(floating, items, add)
|
| 661 |
+
located = [i for i in members if items[i].bbox]
|
| 662 |
+
if len(located) > _BLOCK_WINDOW:
|
| 663 |
+
located.sort(key=lambda i: (items[i].when or datetime.min, items[i].path))
|
| 664 |
+
located = located[:_BLOCK_WINDOW]
|
| 665 |
+
for i in floating:
|
| 666 |
+
for j in located:
|
| 667 |
+
add(i, j)
|
| 668 |
+
|
| 669 |
+
if truncated:
|
| 670 |
+
logger.warning(
|
| 671 |
+
"Same-area pairing hit the %d candidate cap; some weak pairings were skipped.",
|
| 672 |
+
_MAX_CANDIDATES,
|
| 673 |
+
)
|
| 674 |
+
return pairs
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def build_area_groups(
|
| 678 |
+
images: list[dict],
|
| 679 |
+
locations: Optional[LocationIndex] = None,
|
| 680 |
+
) -> dict:
|
| 681 |
+
"""Pair images that cover the same place: two per pair, never reused."""
|
| 682 |
+
items = [_Item(i, img, locations) for i, img in enumerate(images or [])]
|
| 683 |
+
n = len(items)
|
| 684 |
|
| 685 |
+
candidates = _candidate_pairs(items)
|
| 686 |
+
scored = []
|
| 687 |
+
for i, j in candidates:
|
| 688 |
+
result = _score_pair(items[i], items[j])
|
| 689 |
+
if result:
|
| 690 |
+
scored.append((result["score"], i, j, result))
|
| 691 |
+
|
| 692 |
+
# Every image that shares a viable pairing with another belongs to the same
|
| 693 |
+
# cluster; that is what the UI offers as manual alternatives.
|
| 694 |
+
cluster_of: dict[int, int] = {}
|
| 695 |
+
clusters: dict[int, list[int]] = {}
|
| 696 |
+
for _, i, j, _r in scored:
|
| 697 |
+
ci, cj = cluster_of.get(i), cluster_of.get(j)
|
| 698 |
+
if ci is None and cj is None:
|
| 699 |
+
root = min(i, j)
|
| 700 |
+
clusters[root] = [i, j]
|
| 701 |
+
cluster_of[i] = cluster_of[j] = root
|
| 702 |
+
elif ci is None:
|
| 703 |
+
clusters[cj].append(i)
|
| 704 |
+
cluster_of[i] = cj
|
| 705 |
+
elif cj is None:
|
| 706 |
+
clusters[ci].append(j)
|
| 707 |
+
cluster_of[j] = ci
|
| 708 |
+
elif ci != cj:
|
| 709 |
+
clusters[ci].extend(clusters.pop(cj))
|
| 710 |
+
for idx in clusters[ci]:
|
| 711 |
+
cluster_of[idx] = ci
|
| 712 |
+
|
| 713 |
+
# Greedy best-first matching: strongest evidence wins, and an image that is
|
| 714 |
+
# already in a pair is skipped, so no pair repeats an image.
|
| 715 |
+
scored.sort(key=lambda row: (-row[0], items[row[1]].path, items[row[2]].path))
|
| 716 |
+
item_of = {id(it.img): it for it in items}
|
| 717 |
+
|
| 718 |
+
def public(img: dict) -> dict:
|
| 719 |
+
return _public_image(img, item_of.get(id(img)))
|
| 720 |
+
|
| 721 |
+
used: set[int] = set()
|
| 722 |
groups = []
|
| 723 |
+
for score, i, j, result in scored:
|
| 724 |
+
if i in used or j in used or i == j:
|
| 725 |
+
continue
|
| 726 |
+
if items[i].path and items[i].path == items[j].path:
|
|
|
|
| 727 |
continue
|
| 728 |
+
used.add(i)
|
| 729 |
+
used.add(j)
|
| 730 |
+
before, after = _sort_images([items[i].img, items[j].img])
|
| 731 |
+
shared = items[i].keys & items[j].keys
|
| 732 |
+
cluster = clusters.get(cluster_of.get(i, i), [i, j])
|
| 733 |
+
candidate_imgs = _sort_images([items[k].img for k in dict.fromkeys(cluster)])
|
|
|
|
|
|
|
| 734 |
groups.append({
|
| 735 |
"id": f"area-{before.get('id')}-{after.get('id')}",
|
| 736 |
+
"label": _group_label([before, after], shared),
|
| 737 |
+
"match": result["match"],
|
| 738 |
+
"reasons": result["reasons"],
|
| 739 |
+
"score": round(score, 1),
|
| 740 |
+
"images": [public(before), public(after)],
|
| 741 |
+
"candidates": [public(img) for img in candidate_imgs],
|
| 742 |
+
"suggestedBefore": public(before),
|
| 743 |
+
"suggestedAfter": public(after),
|
| 744 |
"beforePath": before.get("path"),
|
| 745 |
"afterPath": after.get("path"),
|
| 746 |
"beforeDate": sort_datetime(before).date().isoformat() if sort_datetime(before) != datetime.min else None,
|
|
|
|
| 748 |
})
|
| 749 |
|
| 750 |
groups.sort(key=lambda g: (g["label"].lower(), g["id"]))
|
| 751 |
+
unpaired = [
|
| 752 |
+
public(img)
|
| 753 |
+
for img in _sort_images([it.img for it in items if it.idx not in used])
|
| 754 |
+
]
|
| 755 |
return {
|
| 756 |
"groups": groups,
|
| 757 |
"unpaired": unpaired,
|
| 758 |
+
"unpairedCount": len(unpaired),
|
| 759 |
"totalImages": n,
|
| 760 |
+
"pairedImages": len(used),
|
| 761 |
}
|
| 762 |
|
| 763 |
|
| 764 |
+
def _area_groups_response(payload: dict, *, include_unpaired: bool) -> dict:
|
| 765 |
+
unpaired = payload.get("unpaired") or []
|
| 766 |
+
out = {
|
| 767 |
+
"groups": payload.get("groups") or [],
|
| 768 |
+
"unpairedCount": int(payload.get("unpairedCount", len(unpaired))),
|
| 769 |
+
"totalImages": payload.get("totalImages", 0),
|
| 770 |
+
"pairedImages": payload.get("pairedImages", 0),
|
| 771 |
+
"unpaired": unpaired if include_unpaired else [],
|
| 772 |
+
}
|
| 773 |
+
return out
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def load_cached_area_groups(
|
| 777 |
+
db,
|
| 778 |
+
node_id: Optional[int] = None,
|
| 779 |
+
*,
|
| 780 |
+
include_unpaired: bool = False,
|
| 781 |
+
) -> dict:
|
| 782 |
+
"""Build same-area groups for a folder (or whole library), cached by fingerprint+node."""
|
| 783 |
from .image_service import library_fingerprint, list_all_images
|
| 784 |
from .library_cache import get_area_groups_cache, set_area_groups_cache
|
| 785 |
|
| 786 |
fp = library_fingerprint(db)
|
| 787 |
+
cache_node = int(node_id or 0)
|
| 788 |
+
cached = get_area_groups_cache(fp, cache_node)
|
| 789 |
if cached is not None:
|
| 790 |
+
return _area_groups_response(cached, include_unpaired=include_unpaired)
|
| 791 |
+
images = list_all_images(db, node_id=node_id, include_bounds=True, slim=True)
|
| 792 |
+
payload = build_area_groups(images, build_location_index(db))
|
| 793 |
+
set_area_groups_cache(fp, payload, cache_node)
|
| 794 |
+
return _area_groups_response(payload, include_unpaired=include_unpaired)
|
| 795 |
|
| 796 |
|
| 797 |
def filter_area_groups_for_node(payload: dict, node) -> dict:
|
| 798 |
+
"""Legacy post-filter; prefer scoping via load_cached_area_groups(node_id=...)."""
|
| 799 |
prefix = (getattr(node, "node_path", None) or "").rstrip("/")
|
| 800 |
node_id = getattr(node, "id", None)
|
| 801 |
|
|
|
|
| 811 |
return {
|
| 812 |
"groups": groups,
|
| 813 |
"unpaired": unpaired,
|
| 814 |
+
"unpairedCount": len(unpaired),
|
| 815 |
"totalImages": payload.get("totalImages", 0),
|
| 816 |
"pairedImages": sum(len(g.get("images") or []) for g in groups),
|
| 817 |
}
|
app/dda/tree/audit_service.py
CHANGED
|
@@ -54,6 +54,7 @@ def log_exception(
|
|
| 54 |
status_code: int = 500,
|
| 55 |
handled: bool = False,
|
| 56 |
user_email: Optional[str] = None,
|
|
|
|
| 57 |
) -> None:
|
| 58 |
entry = ExceptionLog(
|
| 59 |
exception_type=type(exc).__name__,
|
|
@@ -64,6 +65,7 @@ def log_exception(
|
|
| 64 |
handled=handled,
|
| 65 |
traceback="".join(traceback_module.format_exception(type(exc), exc, exc.__traceback__))[:8000],
|
| 66 |
user_email=user_email,
|
|
|
|
| 67 |
)
|
| 68 |
db.add(entry)
|
| 69 |
db.commit()
|
|
|
|
| 54 |
status_code: int = 500,
|
| 55 |
handled: bool = False,
|
| 56 |
user_email: Optional[str] = None,
|
| 57 |
+
user_role: Optional[str] = None,
|
| 58 |
) -> None:
|
| 59 |
entry = ExceptionLog(
|
| 60 |
exception_type=type(exc).__name__,
|
|
|
|
| 65 |
handled=handled,
|
| 66 |
traceback="".join(traceback_module.format_exception(type(exc), exc, exc.__traceback__))[:8000],
|
| 67 |
user_email=user_email,
|
| 68 |
+
user_role=user_role,
|
| 69 |
)
|
| 70 |
db.add(entry)
|
| 71 |
db.commit()
|
app/dda/tree/bulk_upload_service.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bulk image upload: an Excel manifest + a batch of files, routed through the
|
| 2 |
+
same upload_image() pipeline used by the single-image upload (validation,
|
| 3 |
+
dedup, thumbnailing, geo-metadata) — see image_service.py."""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
import logging
|
| 8 |
+
import re
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from fastapi import HTTPException, UploadFile
|
| 14 |
+
from openpyxl import Workbook, load_workbook
|
| 15 |
+
from openpyxl.worksheet.datavalidation import DataValidation
|
| 16 |
+
from sqlalchemy.orm import Session
|
| 17 |
+
|
| 18 |
+
from ..config import get_storage_root
|
| 19 |
+
from ..models import DdaVillage, DdaZone
|
| 20 |
+
from .image_service import IMAGE_SOURCES, IMAGE_TYPES, upload_image
|
| 21 |
+
from .models import ImageLibrary
|
| 22 |
+
from .tree_service import get_or_create_node_by_path
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# General Folder replaces Folder Path (still accepted when parsing old sheets).
|
| 27 |
+
TEMPLATE_HEADERS = [
|
| 28 |
+
"File Name",
|
| 29 |
+
"Server Filename",
|
| 30 |
+
"General Folder",
|
| 31 |
+
"Zone",
|
| 32 |
+
"Village",
|
| 33 |
+
"Image Type",
|
| 34 |
+
"Year",
|
| 35 |
+
"Source",
|
| 36 |
+
"Capture Date",
|
| 37 |
+
"Manual Bounds",
|
| 38 |
+
]
|
| 39 |
+
TEMPLATE_EXAMPLE_ROW = [
|
| 40 |
+
"example_before.tif",
|
| 41 |
+
"central_delhi_2025_before.tif",
|
| 42 |
+
"Central Delhi/Sector 5/2025",
|
| 43 |
+
"Central Delhi",
|
| 44 |
+
"Sector 5",
|
| 45 |
+
"GeoTIFF",
|
| 46 |
+
"2025",
|
| 47 |
+
"satellite",
|
| 48 |
+
"2025-01-15",
|
| 49 |
+
"",
|
| 50 |
+
]
|
| 51 |
+
_REQUIRED_COLUMNS = ("File Name", "General Folder", "Zone", "Village", "Image Type")
|
| 52 |
+
_SOURCE_CHOICES = sorted(s for s in IMAGE_SOURCES if s)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _basename(value: str) -> str:
|
| 56 |
+
"""Strip quotes and take the last path segment so Explorer 'Copy as path'
|
| 57 |
+
pastes still match browser upload basenames."""
|
| 58 |
+
v = (value or "").strip()
|
| 59 |
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
|
| 60 |
+
v = v[1:-1].strip()
|
| 61 |
+
return Path(v.replace("\\", "/")).name
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _normalize_general_folder(value: str) -> str:
|
| 65 |
+
"""Relative slash-separated path under Main folder. Reject absolute paths."""
|
| 66 |
+
v = (value or "").strip()
|
| 67 |
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
|
| 68 |
+
v = v[1:-1].strip()
|
| 69 |
+
v = v.replace("\\", "/").strip()
|
| 70 |
+
if not v:
|
| 71 |
+
raise ValueError("General Folder is required")
|
| 72 |
+
if re.match(r"^[A-Za-z]:[/\\]", v) or v.startswith("//") or v.startswith("/"):
|
| 73 |
+
raise ValueError(
|
| 74 |
+
"General Folder must be relative to the server Main folder "
|
| 75 |
+
"(e.g. Central Delhi/Sector 5/2025), not an absolute path"
|
| 76 |
+
)
|
| 77 |
+
parts = [p.strip() for p in v.split("/") if p.strip() and p.strip() != "."]
|
| 78 |
+
if any(p == ".." for p in parts):
|
| 79 |
+
raise ValueError("General Folder cannot contain '..'")
|
| 80 |
+
if not parts:
|
| 81 |
+
raise ValueError("General Folder is required")
|
| 82 |
+
return "/".join(parts)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build_bulk_upload_template() -> bytes:
|
| 86 |
+
"""Generate the standardized .xlsx template for bulk image upload."""
|
| 87 |
+
main = str(get_storage_root())
|
| 88 |
+
wb = Workbook()
|
| 89 |
+
ws = wb.active
|
| 90 |
+
ws.title = "Images"
|
| 91 |
+
ws.append(TEMPLATE_HEADERS)
|
| 92 |
+
ws.append(TEMPLATE_EXAMPLE_ROW)
|
| 93 |
+
widths = (28, 32, 40, 16, 16, 14, 10, 12, 14, 22)
|
| 94 |
+
for col_idx, width in enumerate(widths, start=1):
|
| 95 |
+
ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = width
|
| 96 |
+
|
| 97 |
+
dv_type = DataValidation(
|
| 98 |
+
type="list", formula1='"' + ",".join(sorted(IMAGE_TYPES)) + '"', allow_blank=False
|
| 99 |
+
)
|
| 100 |
+
ws.add_data_validation(dv_type)
|
| 101 |
+
dv_type.add("F2:F1000")
|
| 102 |
+
|
| 103 |
+
if _SOURCE_CHOICES:
|
| 104 |
+
dv_src = DataValidation(
|
| 105 |
+
type="list", formula1='"' + ",".join(_SOURCE_CHOICES) + '"', allow_blank=True
|
| 106 |
+
)
|
| 107 |
+
ws.add_data_validation(dv_src)
|
| 108 |
+
dv_src.add("H2:H1000")
|
| 109 |
+
|
| 110 |
+
instructions = wb.create_sheet("Instructions")
|
| 111 |
+
instructions.append(["Column", "Required", "Notes"])
|
| 112 |
+
instructions.append([
|
| 113 |
+
"Main folder (this server)",
|
| 114 |
+
"—",
|
| 115 |
+
f"Images are stored under: {main}. Set STORAGE_ROOT or LOCAL_LIBRARY_ROOT on each environment so relative General Folder paths resolve correctly.",
|
| 116 |
+
])
|
| 117 |
+
instructions.append([
|
| 118 |
+
"File Name",
|
| 119 |
+
"Yes",
|
| 120 |
+
"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.",
|
| 121 |
+
])
|
| 122 |
+
instructions.append([
|
| 123 |
+
"Server Filename",
|
| 124 |
+
"No",
|
| 125 |
+
"Name stored on the server. Blank = File Name basename.",
|
| 126 |
+
])
|
| 127 |
+
instructions.append([
|
| 128 |
+
"General Folder",
|
| 129 |
+
"Yes",
|
| 130 |
+
"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.",
|
| 131 |
+
])
|
| 132 |
+
instructions.append(["Zone", "Yes", "Must match an existing Zone name exactly (see Zone / District master)."])
|
| 133 |
+
instructions.append(["Village", "Yes", "Must match an existing Village name within that Zone."])
|
| 134 |
+
instructions.append(["Image Type", "Yes", "One of: " + ", ".join(sorted(IMAGE_TYPES))])
|
| 135 |
+
instructions.append(["Year", "No", "Four-digit year (1900–2100). Blank may be inferred from a year segment in General Folder."])
|
| 136 |
+
instructions.append([
|
| 137 |
+
"Source",
|
| 138 |
+
"No",
|
| 139 |
+
"One of: " + (", ".join(_SOURCE_CHOICES) if _SOURCE_CHOICES else "satellite or drone"),
|
| 140 |
+
])
|
| 141 |
+
instructions.append(["Capture Date", "No", "YYYY-MM-DD"])
|
| 142 |
+
instructions.append(["Manual Bounds", "No", "West,South,East,North — only if the image has no embedded georeferencing."])
|
| 143 |
+
instructions.column_dimensions["A"].width = 28
|
| 144 |
+
instructions.column_dimensions["B"].width = 10
|
| 145 |
+
instructions.column_dimensions["C"].width = 100
|
| 146 |
+
|
| 147 |
+
buf = io.BytesIO()
|
| 148 |
+
wb.save(buf)
|
| 149 |
+
return buf.getvalue()
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@dataclass
|
| 153 |
+
class ManifestRow:
|
| 154 |
+
row_number: int
|
| 155 |
+
file_name: str
|
| 156 |
+
folder_path: str
|
| 157 |
+
zone: str
|
| 158 |
+
village: str
|
| 159 |
+
image_type: str
|
| 160 |
+
capture_date: Optional[str]
|
| 161 |
+
manual_bounds: Optional[str]
|
| 162 |
+
server_filename: Optional[str] = None
|
| 163 |
+
year: Optional[str] = None
|
| 164 |
+
source: Optional[str] = None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@dataclass
|
| 168 |
+
class BulkDefaults:
|
| 169 |
+
"""Optional modal defaults applied only where Excel cells are blank."""
|
| 170 |
+
year: Optional[str] = None
|
| 171 |
+
source: Optional[str] = None
|
| 172 |
+
image_type: Optional[str] = None
|
| 173 |
+
zone: Optional[str] = None
|
| 174 |
+
village: Optional[str] = None
|
| 175 |
+
filenames: Dict[str, str] = field(default_factory=dict)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def parse_bulk_manifest(xlsx_bytes: bytes) -> tuple[List[ManifestRow], List[dict]]:
|
| 179 |
+
"""Parse the uploaded manifest. Returns (valid_rows, row_errors)."""
|
| 180 |
+
try:
|
| 181 |
+
wb = load_workbook(io.BytesIO(xlsx_bytes), data_only=True, read_only=True)
|
| 182 |
+
except Exception:
|
| 183 |
+
raise HTTPException(status_code=400, detail="Could not read the Excel file. Please use the provided template.")
|
| 184 |
+
ws = wb["Images"] if "Images" in wb.sheetnames else wb.worksheets[0]
|
| 185 |
+
|
| 186 |
+
rows_iter = ws.iter_rows(values_only=True)
|
| 187 |
+
try:
|
| 188 |
+
header = [str(c or "").strip() for c in next(rows_iter)]
|
| 189 |
+
except StopIteration:
|
| 190 |
+
raise HTTPException(status_code=400, detail="The Excel sheet is empty.")
|
| 191 |
+
|
| 192 |
+
idx: Dict[str, Optional[int]] = {}
|
| 193 |
+
for name in TEMPLATE_HEADERS:
|
| 194 |
+
idx[name] = header.index(name) if name in header else None
|
| 195 |
+
if idx.get("General Folder") is None and "Folder Path" in header:
|
| 196 |
+
idx["General Folder"] = header.index("Folder Path")
|
| 197 |
+
|
| 198 |
+
missing = [name for name in _REQUIRED_COLUMNS if idx.get(name) is None]
|
| 199 |
+
if missing:
|
| 200 |
+
raise HTTPException(
|
| 201 |
+
status_code=400,
|
| 202 |
+
detail=f"Missing required column(s): {', '.join(missing)}. Please use the provided template.",
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
valid: List[ManifestRow] = []
|
| 206 |
+
errors: List[dict] = []
|
| 207 |
+
for row_number, raw in enumerate(rows_iter, start=2):
|
| 208 |
+
if raw is None or all(c is None or str(c).strip() == "" for c in raw):
|
| 209 |
+
continue
|
| 210 |
+
|
| 211 |
+
def cell(name: str, _raw=raw) -> str:
|
| 212 |
+
i = idx.get(name)
|
| 213 |
+
if i is None or i >= len(_raw) or _raw[i] is None:
|
| 214 |
+
return ""
|
| 215 |
+
return str(_raw[i]).strip()
|
| 216 |
+
|
| 217 |
+
file_name = _basename(cell("File Name"))
|
| 218 |
+
server_filename = cell("Server Filename") or None
|
| 219 |
+
if server_filename:
|
| 220 |
+
server_filename = _basename(server_filename)
|
| 221 |
+
zone = cell("Zone")
|
| 222 |
+
village = cell("Village")
|
| 223 |
+
image_type = cell("Image Type") or ""
|
| 224 |
+
year = cell("Year") or None
|
| 225 |
+
if year and year.endswith(".0"):
|
| 226 |
+
year = year[:-2]
|
| 227 |
+
source = (cell("Source") or "").strip().lower() or None
|
| 228 |
+
capture_date = cell("Capture Date") or None
|
| 229 |
+
manual_bounds = cell("Manual Bounds") or None
|
| 230 |
+
|
| 231 |
+
try:
|
| 232 |
+
folder_path = _normalize_general_folder(cell("General Folder"))
|
| 233 |
+
except ValueError as exc:
|
| 234 |
+
errors.append({"row": row_number, "file": file_name, "reason": str(exc)})
|
| 235 |
+
continue
|
| 236 |
+
|
| 237 |
+
if not file_name:
|
| 238 |
+
errors.append({"row": row_number, "file": file_name, "reason": "File Name is required"})
|
| 239 |
+
continue
|
| 240 |
+
# Zone/Village/Image Type may be filled from modal defaults later.
|
| 241 |
+
if image_type and image_type not in IMAGE_TYPES:
|
| 242 |
+
errors.append({
|
| 243 |
+
"row": row_number,
|
| 244 |
+
"file": file_name,
|
| 245 |
+
"reason": f"Image Type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
|
| 246 |
+
})
|
| 247 |
+
continue
|
| 248 |
+
if source and source not in IMAGE_SOURCES:
|
| 249 |
+
errors.append({
|
| 250 |
+
"row": row_number,
|
| 251 |
+
"file": file_name,
|
| 252 |
+
"reason": "Source must be satellite or drone",
|
| 253 |
+
})
|
| 254 |
+
continue
|
| 255 |
+
valid.append(ManifestRow(
|
| 256 |
+
row_number=row_number,
|
| 257 |
+
file_name=file_name,
|
| 258 |
+
folder_path=folder_path,
|
| 259 |
+
zone=zone,
|
| 260 |
+
village=village,
|
| 261 |
+
image_type=image_type,
|
| 262 |
+
capture_date=capture_date,
|
| 263 |
+
manual_bounds=manual_bounds,
|
| 264 |
+
server_filename=server_filename,
|
| 265 |
+
year=year,
|
| 266 |
+
source=source,
|
| 267 |
+
))
|
| 268 |
+
return valid, errors
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _resolve_zone_village_by_name(db: Session, zone_name: str, village_name: str) -> tuple[int, int]:
|
| 272 |
+
zone = db.query(DdaZone).filter(DdaZone.name.ilike(zone_name.strip())).first()
|
| 273 |
+
if not zone:
|
| 274 |
+
raise HTTPException(status_code=400, detail=f"Zone '{zone_name}' not found. Check the Zone / District master.")
|
| 275 |
+
village = (
|
| 276 |
+
db.query(DdaVillage)
|
| 277 |
+
.filter(DdaVillage.zone_id == zone.id, DdaVillage.name.ilike(village_name.strip()))
|
| 278 |
+
.first()
|
| 279 |
+
)
|
| 280 |
+
if not village:
|
| 281 |
+
raise HTTPException(
|
| 282 |
+
status_code=400,
|
| 283 |
+
detail=f"Village '{village_name}' not found under zone '{zone_name}'. Check the Village / Location master.",
|
| 284 |
+
)
|
| 285 |
+
return zone.id, village.id
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def zone_village_names(db: Session, zone_id: Optional[int], village_id: Optional[int]) -> tuple[str, str]:
|
| 289 |
+
zone_name, village_name = "", ""
|
| 290 |
+
if zone_id:
|
| 291 |
+
z = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
|
| 292 |
+
zone_name = z.name if z else ""
|
| 293 |
+
if village_id:
|
| 294 |
+
v = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
|
| 295 |
+
village_name = v.name if v else ""
|
| 296 |
+
return zone_name, village_name
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def apply_bulk_defaults(rows: List[ManifestRow], defaults: BulkDefaults) -> None:
|
| 300 |
+
"""Fill blank Excel fields from modal defaults (in place)."""
|
| 301 |
+
for row in rows:
|
| 302 |
+
key = row.file_name.strip().lower()
|
| 303 |
+
if not row.server_filename and key in defaults.filenames:
|
| 304 |
+
row.server_filename = defaults.filenames[key]
|
| 305 |
+
if not row.year and defaults.year:
|
| 306 |
+
row.year = defaults.year
|
| 307 |
+
if not row.source and defaults.source:
|
| 308 |
+
row.source = defaults.source
|
| 309 |
+
if not row.image_type and defaults.image_type:
|
| 310 |
+
row.image_type = defaults.image_type
|
| 311 |
+
if not row.zone and defaults.zone:
|
| 312 |
+
row.zone = defaults.zone
|
| 313 |
+
if not row.village and defaults.village:
|
| 314 |
+
row.village = defaults.village
|
| 315 |
+
if not row.image_type:
|
| 316 |
+
row.image_type = "GeoTIFF"
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@dataclass
|
| 320 |
+
class BulkUploadResult:
|
| 321 |
+
created: List[dict] = field(default_factory=list)
|
| 322 |
+
duplicates: List[dict] = field(default_factory=list)
|
| 323 |
+
errors: List[dict] = field(default_factory=list)
|
| 324 |
+
unmatched_files: List[dict] = field(default_factory=list)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
async def bulk_upload_images(
|
| 328 |
+
db: Session,
|
| 329 |
+
rows: List[ManifestRow],
|
| 330 |
+
files: List[UploadFile],
|
| 331 |
+
*,
|
| 332 |
+
uploaded_by: str,
|
| 333 |
+
defaults: Optional[BulkDefaults] = None,
|
| 334 |
+
) -> BulkUploadResult:
|
| 335 |
+
result = BulkUploadResult()
|
| 336 |
+
apply_bulk_defaults(rows, defaults or BulkDefaults())
|
| 337 |
+
|
| 338 |
+
files_by_name = {(f.filename or "").strip().lower(): f for f in files}
|
| 339 |
+
matched_names = set()
|
| 340 |
+
|
| 341 |
+
for row in rows:
|
| 342 |
+
key = row.file_name.strip().lower()
|
| 343 |
+
file = files_by_name.get(key)
|
| 344 |
+
if file is None:
|
| 345 |
+
result.errors.append({
|
| 346 |
+
"row": row.row_number,
|
| 347 |
+
"file": row.file_name,
|
| 348 |
+
"reason": "No uploaded file matches this File Name",
|
| 349 |
+
})
|
| 350 |
+
continue
|
| 351 |
+
matched_names.add(key)
|
| 352 |
+
stored_name = row.server_filename or row.file_name
|
| 353 |
+
if not row.zone or not row.village:
|
| 354 |
+
result.errors.append({
|
| 355 |
+
"row": row.row_number,
|
| 356 |
+
"file": row.file_name,
|
| 357 |
+
"reason": "Zone and Village are required (set in Excel or modal defaults)",
|
| 358 |
+
})
|
| 359 |
+
continue
|
| 360 |
+
if row.image_type not in IMAGE_TYPES:
|
| 361 |
+
result.errors.append({
|
| 362 |
+
"row": row.row_number,
|
| 363 |
+
"file": row.file_name,
|
| 364 |
+
"reason": f"Image Type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
|
| 365 |
+
})
|
| 366 |
+
continue
|
| 367 |
+
try:
|
| 368 |
+
zone_id, village_id = _resolve_zone_village_by_name(db, row.zone, row.village)
|
| 369 |
+
node = get_or_create_node_by_path(db, row.folder_path, created_by=uploaded_by)
|
| 370 |
+
already_existed = (
|
| 371 |
+
db.query(ImageLibrary)
|
| 372 |
+
.filter(
|
| 373 |
+
ImageLibrary.node_id == node.id,
|
| 374 |
+
ImageLibrary.image_name == Path(stored_name).name,
|
| 375 |
+
)
|
| 376 |
+
.count() > 0
|
| 377 |
+
)
|
| 378 |
+
img = await upload_image(
|
| 379 |
+
db, node.id, file,
|
| 380 |
+
image_type=row.image_type,
|
| 381 |
+
capture_date=row.capture_date,
|
| 382 |
+
uploaded_by=uploaded_by,
|
| 383 |
+
manual_bounds=row.manual_bounds,
|
| 384 |
+
zone_id=zone_id,
|
| 385 |
+
village_id=village_id,
|
| 386 |
+
year=row.year,
|
| 387 |
+
source=row.source,
|
| 388 |
+
server_filename=stored_name,
|
| 389 |
+
)
|
| 390 |
+
entry = {
|
| 391 |
+
"row": row.row_number,
|
| 392 |
+
"file": row.file_name,
|
| 393 |
+
"serverFilename": stored_name,
|
| 394 |
+
"folder": node.node_path,
|
| 395 |
+
"imageId": img.id,
|
| 396 |
+
}
|
| 397 |
+
(result.duplicates if already_existed else result.created).append(entry)
|
| 398 |
+
except HTTPException as exc:
|
| 399 |
+
db.rollback()
|
| 400 |
+
result.errors.append({"row": row.row_number, "file": row.file_name, "reason": exc.detail})
|
| 401 |
+
except Exception:
|
| 402 |
+
db.rollback()
|
| 403 |
+
logger.exception("Bulk upload failed for row %s (%s)", row.row_number, row.file_name)
|
| 404 |
+
result.errors.append({
|
| 405 |
+
"row": row.row_number,
|
| 406 |
+
"file": row.file_name,
|
| 407 |
+
"reason": "Unexpected error while uploading this file",
|
| 408 |
+
})
|
| 409 |
+
|
| 410 |
+
for name, file in files_by_name.items():
|
| 411 |
+
if name not in matched_names:
|
| 412 |
+
result.unmatched_files.append({"file": file.filename})
|
| 413 |
+
|
| 414 |
+
return result
|
app/dda/tree/image_service.py
CHANGED
|
@@ -25,8 +25,91 @@ from .tree_service import get_node_or_404
|
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
| 28 |
-
IMAGE_TYPES = {"
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
def _safe_basename(filename: str) -> str:
|
| 32 |
name = Path(filename or "upload").name
|
|
@@ -89,12 +172,84 @@ def _as_iso(value) -> Optional[str]:
|
|
| 89 |
return text
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def image_to_dict(
|
| 93 |
img: ImageLibrary,
|
| 94 |
node: Optional[TreeNode] = None,
|
| 95 |
*,
|
| 96 |
include_bounds: bool = True,
|
| 97 |
slim: bool = False,
|
|
|
|
|
|
|
| 98 |
) -> dict:
|
| 99 |
rel = (img.file_path or "").replace("\\", "/")
|
| 100 |
encoded = quote(rel, safe="/")
|
|
@@ -120,7 +275,15 @@ def image_to_dict(
|
|
| 120 |
"hasGeoref": bool(img.has_georef),
|
| 121 |
"width": img.width,
|
| 122 |
"height": img.height,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
if include_bounds:
|
| 125 |
payload["bounds"] = bounds
|
| 126 |
if slim:
|
|
@@ -130,7 +293,6 @@ def image_to_dict(
|
|
| 130 |
"previewUrl": f"/api/dda/local/preview?path={encoded}",
|
| 131 |
"mapInfoUrl": f"/api/dda/local/map-info?path={encoded}",
|
| 132 |
"format": img.format,
|
| 133 |
-
"source": "tree_library",
|
| 134 |
})
|
| 135 |
return payload
|
| 136 |
|
|
@@ -160,16 +322,21 @@ async def upload_image(
|
|
| 160 |
capture_date: Optional[str],
|
| 161 |
uploaded_by: str,
|
| 162 |
manual_bounds: Optional[str] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
) -> ImageLibrary:
|
| 164 |
node = get_node_or_404(db, node_id)
|
|
|
|
| 165 |
itype = image_type.strip() or "GeoTIFF"
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
raise HTTPException(status_code=400, detail=f"Allowed extensions: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
|
| 173 |
|
| 174 |
existing = (
|
| 175 |
db.query(ImageLibrary)
|
|
@@ -177,6 +344,25 @@ async def upload_image(
|
|
| 177 |
.first()
|
| 178 |
)
|
| 179 |
if existing is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
logger.info("Skipped duplicate upload %s -> node %s (already exists as image id %s)",
|
| 181 |
original, node.id, existing.id)
|
| 182 |
return existing
|
|
@@ -218,6 +404,10 @@ async def upload_image(
|
|
| 218 |
image_type=itype,
|
| 219 |
file_path=rel,
|
| 220 |
capture_date=cap,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
uploaded_by=uploaded_by or "",
|
| 222 |
file_size_bytes=size,
|
| 223 |
thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
|
|
@@ -228,7 +418,14 @@ async def upload_image(
|
|
| 228 |
format=meta.format,
|
| 229 |
)
|
| 230 |
db.add(img)
|
| 231 |
-
log_action(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
db.commit()
|
| 233 |
db.refresh(img)
|
| 234 |
invalidate_library_caches()
|
|
@@ -239,7 +436,8 @@ async def upload_image(
|
|
| 239 |
def list_images_for_node(db: Session, node_id: int) -> List[dict]:
|
| 240 |
node = get_node_or_404(db, node_id)
|
| 241 |
rows = db.query(ImageLibrary).filter(ImageLibrary.node_id == node_id).order_by(ImageLibrary.uploaded_on.desc()).all()
|
| 242 |
-
|
|
|
|
| 243 |
|
| 244 |
|
| 245 |
def get_image_by_file_path(db: Session, relative_path: str) -> Optional[ImageLibrary]:
|
|
@@ -265,7 +463,19 @@ def _node_and_descendants_clause(node: TreeNode):
|
|
| 265 |
return or_(*clauses)
|
| 266 |
|
| 267 |
|
| 268 |
-
def _images_query(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
q = (
|
| 270 |
db.query(ImageLibrary, TreeNode)
|
| 271 |
.outerjoin(TreeNode, ImageLibrary.node_id == TreeNode.id)
|
|
@@ -283,6 +493,28 @@ def _images_query(db: Session, *, node_id: Optional[int] = None, query: Optional
|
|
| 283 |
func.lower(ImageLibrary.image_name).like(like),
|
| 284 |
func.lower(func.coalesce(TreeNode.node_path, "")).like(like),
|
| 285 |
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
return q
|
| 287 |
|
| 288 |
|
|
@@ -304,28 +536,93 @@ def list_all_images(
|
|
| 304 |
*,
|
| 305 |
node_id: Optional[int] = None,
|
| 306 |
query: Optional[str] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
include_bounds: bool = False,
|
| 308 |
slim: bool = True,
|
| 309 |
limit: Optional[int] = None,
|
| 310 |
offset: int = 0,
|
| 311 |
):
|
| 312 |
-
q = _images_query(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
ordered = q.order_by(ImageLibrary.uploaded_on.desc())
|
|
|
|
| 314 |
if limit is not None:
|
| 315 |
total = q.count()
|
| 316 |
rows = ordered.offset(offset).limit(limit).all()
|
| 317 |
items = [
|
| 318 |
-
image_to_dict(img, node, include_bounds=include_bounds, slim=slim
|
|
|
|
| 319 |
for img, node in rows
|
| 320 |
]
|
| 321 |
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
| 322 |
rows = ordered.all()
|
| 323 |
return [
|
| 324 |
-
image_to_dict(img, node, include_bounds=include_bounds, slim=slim
|
|
|
|
| 325 |
for img, node in rows
|
| 326 |
]
|
| 327 |
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
| 330 |
full = resolve_file(relative_path)
|
| 331 |
cache = _thumb_cache_path(relative_path)
|
|
|
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
| 28 |
+
IMAGE_TYPES = {"GeoTIFF", "PNG", "JPEG"}
|
| 29 |
|
| 30 |
+
# Type-first: selected image_type only accepts these extensions.
|
| 31 |
+
IMAGE_TYPE_EXTENSIONS = {
|
| 32 |
+
"GeoTIFF": {".tif", ".tiff"},
|
| 33 |
+
"PNG": {".png"},
|
| 34 |
+
"JPEG": {".jpg", ".jpeg"},
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
IMAGE_SOURCES = {"satellite", "drone", ""}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def extensions_for_image_type(image_type: str) -> set[str]:
|
| 41 |
+
return IMAGE_TYPE_EXTENSIONS.get((image_type or "").strip(), set())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def format_allowed_extensions(exts: set[str]) -> str:
|
| 45 |
+
return ", ".join(sorted(exts))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def validate_image_type_extension(image_type: str, filename: str) -> str:
|
| 49 |
+
"""Return lowercased extension or raise 400 if type/extension mismatch."""
|
| 50 |
+
itype = (image_type or "").strip() or "GeoTIFF"
|
| 51 |
+
if itype not in IMAGE_TYPES:
|
| 52 |
+
raise HTTPException(
|
| 53 |
+
status_code=400,
|
| 54 |
+
detail=f"image_type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
|
| 55 |
+
)
|
| 56 |
+
allowed = IMAGE_TYPE_EXTENSIONS[itype]
|
| 57 |
+
ext = Path(filename or "").suffix.lower()
|
| 58 |
+
if not ext or ext not in allowed:
|
| 59 |
+
got = ext or "(none)"
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=400,
|
| 62 |
+
detail=(
|
| 63 |
+
f"image_type {itype} only allows {format_allowed_extensions(allowed)} "
|
| 64 |
+
f"(got {got} for {Path(filename or 'upload').name})"
|
| 65 |
+
),
|
| 66 |
+
)
|
| 67 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
status_code=400,
|
| 70 |
+
detail=f"Allowed extensions: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
| 71 |
+
)
|
| 72 |
+
return ext
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _infer_year_from_node(node: TreeNode) -> Optional[int]:
|
| 76 |
+
for part in (node.node_path or "").split("/"):
|
| 77 |
+
part = part.strip()
|
| 78 |
+
if len(part) == 4 and part.isdigit() and part.startswith(("19", "20")):
|
| 79 |
+
return int(part)
|
| 80 |
+
if node.node_type == "Year":
|
| 81 |
+
name = (node.node_name or "").strip()
|
| 82 |
+
if len(name) == 4 and name.isdigit():
|
| 83 |
+
return int(name)
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _parse_year(raw: Optional[str]) -> Optional[int]:
|
| 88 |
+
if raw is None:
|
| 89 |
+
return None
|
| 90 |
+
text = str(raw).strip()
|
| 91 |
+
if not text:
|
| 92 |
+
return None
|
| 93 |
+
try:
|
| 94 |
+
year = int(text)
|
| 95 |
+
except ValueError:
|
| 96 |
+
raise HTTPException(status_code=400, detail="year must be a number")
|
| 97 |
+
if year < 1900 or year > 2100:
|
| 98 |
+
raise HTTPException(status_code=400, detail="year out of range")
|
| 99 |
+
return year
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _parse_source(raw: Optional[str], image_type: str) -> str:
|
| 103 |
+
text = (raw or "").strip().lower()
|
| 104 |
+
if text in ("satellite", "drone"):
|
| 105 |
+
return text
|
| 106 |
+
if text:
|
| 107 |
+
raise HTTPException(status_code=400, detail="source must be satellite or drone")
|
| 108 |
+
if image_type == "Satellite":
|
| 109 |
+
return "satellite"
|
| 110 |
+
if image_type == "Drone":
|
| 111 |
+
return "drone"
|
| 112 |
+
return ""
|
| 113 |
|
| 114 |
def _safe_basename(filename: str) -> str:
|
| 115 |
name = Path(filename or "upload").name
|
|
|
|
| 172 |
return text
|
| 173 |
|
| 174 |
|
| 175 |
+
def resolve_location(
|
| 176 |
+
db: Session,
|
| 177 |
+
zone_id: Optional[int],
|
| 178 |
+
village_id: Optional[int],
|
| 179 |
+
) -> tuple[Optional[int], Optional[int]]:
|
| 180 |
+
"""Validate a required zone/village choice; a village implies its zone.
|
| 181 |
+
|
| 182 |
+
Both are mandatory — every image must be tagged with exactly one zone
|
| 183 |
+
and one village at upload time. This used to be optional, which is how
|
| 184 |
+
thousands of images ended up "Unassigned" (bulk upload and filesystem
|
| 185 |
+
sync both created rows with no location); the database itself now also
|
| 186 |
+
rejects a NULL zone_id/village_id, so this check exists for a clear,
|
| 187 |
+
early error message rather than an opaque IntegrityError."""
|
| 188 |
+
from ..models import DdaVillage, DdaZone
|
| 189 |
+
|
| 190 |
+
zid = int(zone_id) if zone_id else None
|
| 191 |
+
vid = int(village_id) if village_id else None
|
| 192 |
+
if not vid:
|
| 193 |
+
raise HTTPException(status_code=400, detail="A village is required for every image upload.")
|
| 194 |
+
village = db.query(DdaVillage).filter(DdaVillage.id == vid).first()
|
| 195 |
+
if not village:
|
| 196 |
+
raise HTTPException(status_code=404, detail="Location (village) not found")
|
| 197 |
+
if zid and village.zone_id != zid:
|
| 198 |
+
raise HTTPException(
|
| 199 |
+
status_code=400,
|
| 200 |
+
detail="The selected location does not belong to the selected zone.",
|
| 201 |
+
)
|
| 202 |
+
if not zid and not village.zone_id:
|
| 203 |
+
raise HTTPException(status_code=400, detail="A zone is required for every image upload.")
|
| 204 |
+
return village.zone_id, village.id
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def location_names(db: Session) -> tuple[dict[int, str], dict[int, str]]:
|
| 208 |
+
"""id → name lookups for zones and villages (both tables are small)."""
|
| 209 |
+
from ..models import DdaVillage, DdaZone
|
| 210 |
+
|
| 211 |
+
zones = {z.id: z.name for z in db.query(DdaZone.id, DdaZone.name).all()}
|
| 212 |
+
villages = {v.id: v.name for v in db.query(DdaVillage.id, DdaVillage.name).all()}
|
| 213 |
+
return zones, villages
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def resolve_run_location(db: Session, base_path: str, comparison_path: str) -> tuple[str, str]:
|
| 217 |
+
"""Best-effort zone/village NAME for a detection run, derived from the
|
| 218 |
+
library images being compared (their own zone_id/village_id, set at
|
| 219 |
+
upload time) — so a new DetectionRun isn't bucketed under "Unassigned"
|
| 220 |
+
on the dashboard just because the caller didn't pass zone/village
|
| 221 |
+
explicitly. Prefers the after/comparison image's location, falling back
|
| 222 |
+
to the before/base image's. Returns ("", "") if neither image has one."""
|
| 223 |
+
def _norm(p: str) -> str:
|
| 224 |
+
return (p or "").replace("\\", "/").strip().lstrip("/")
|
| 225 |
+
|
| 226 |
+
paths = [p for p in (_norm(comparison_path), _norm(base_path)) if p]
|
| 227 |
+
if not paths:
|
| 228 |
+
return "", ""
|
| 229 |
+
by_path = {
|
| 230 |
+
img.file_path: img
|
| 231 |
+
for img in db.query(ImageLibrary).filter(ImageLibrary.file_path.in_(paths)).all()
|
| 232 |
+
}
|
| 233 |
+
zones, villages = location_names(db)
|
| 234 |
+
for p in paths:
|
| 235 |
+
img = by_path.get(p)
|
| 236 |
+
if not img or not (img.zone_id or img.village_id):
|
| 237 |
+
continue
|
| 238 |
+
zone_name = zones.get(img.zone_id, "") if img.zone_id else ""
|
| 239 |
+
village_name = villages.get(img.village_id, "") if img.village_id else ""
|
| 240 |
+
if zone_name or village_name:
|
| 241 |
+
return zone_name, village_name
|
| 242 |
+
return "", ""
|
| 243 |
+
|
| 244 |
+
|
| 245 |
def image_to_dict(
|
| 246 |
img: ImageLibrary,
|
| 247 |
node: Optional[TreeNode] = None,
|
| 248 |
*,
|
| 249 |
include_bounds: bool = True,
|
| 250 |
slim: bool = False,
|
| 251 |
+
zone_names: Optional[dict] = None,
|
| 252 |
+
village_names: Optional[dict] = None,
|
| 253 |
) -> dict:
|
| 254 |
rel = (img.file_path or "").replace("\\", "/")
|
| 255 |
encoded = quote(rel, safe="/")
|
|
|
|
| 275 |
"hasGeoref": bool(img.has_georef),
|
| 276 |
"width": img.width,
|
| 277 |
"height": img.height,
|
| 278 |
+
"zoneId": img.zone_id,
|
| 279 |
+
"villageId": img.village_id,
|
| 280 |
+
"year": img.year,
|
| 281 |
+
"source": img.source or "",
|
| 282 |
}
|
| 283 |
+
if zone_names is not None:
|
| 284 |
+
payload["zoneName"] = zone_names.get(img.zone_id, "") if img.zone_id else ""
|
| 285 |
+
if village_names is not None:
|
| 286 |
+
payload["villageName"] = village_names.get(img.village_id, "") if img.village_id else ""
|
| 287 |
if include_bounds:
|
| 288 |
payload["bounds"] = bounds
|
| 289 |
if slim:
|
|
|
|
| 293 |
"previewUrl": f"/api/dda/local/preview?path={encoded}",
|
| 294 |
"mapInfoUrl": f"/api/dda/local/map-info?path={encoded}",
|
| 295 |
"format": img.format,
|
|
|
|
| 296 |
})
|
| 297 |
return payload
|
| 298 |
|
|
|
|
| 322 |
capture_date: Optional[str],
|
| 323 |
uploaded_by: str,
|
| 324 |
manual_bounds: Optional[str] = None,
|
| 325 |
+
zone_id: Optional[int] = None,
|
| 326 |
+
village_id: Optional[int] = None,
|
| 327 |
+
year: Optional[str] = None,
|
| 328 |
+
source: Optional[str] = None,
|
| 329 |
+
server_filename: Optional[str] = None,
|
| 330 |
) -> ImageLibrary:
|
| 331 |
node = get_node_or_404(db, node_id)
|
| 332 |
+
zone_id, village_id = resolve_location(db, zone_id, village_id)
|
| 333 |
itype = image_type.strip() or "GeoTIFF"
|
| 334 |
+
original = _safe_basename(server_filename or file.filename or "upload")
|
| 335 |
+
ext = validate_image_type_extension(itype, original)
|
| 336 |
+
year_val = _parse_year(year)
|
| 337 |
+
if year_val is None:
|
| 338 |
+
year_val = _infer_year_from_node(node)
|
| 339 |
+
source_val = _parse_source(source, itype)
|
|
|
|
| 340 |
|
| 341 |
existing = (
|
| 342 |
db.query(ImageLibrary)
|
|
|
|
| 344 |
.first()
|
| 345 |
)
|
| 346 |
if existing is not None:
|
| 347 |
+
# Re-uploading the same file is a no-op, but it is still the natural way
|
| 348 |
+
# to fill in a location that was left blank the first time.
|
| 349 |
+
backfilled = False
|
| 350 |
+
if zone_id and not existing.zone_id:
|
| 351 |
+
existing.zone_id = zone_id
|
| 352 |
+
backfilled = True
|
| 353 |
+
if village_id and not existing.village_id:
|
| 354 |
+
existing.village_id = village_id
|
| 355 |
+
backfilled = True
|
| 356 |
+
if year_val and not existing.year:
|
| 357 |
+
existing.year = year_val
|
| 358 |
+
backfilled = True
|
| 359 |
+
if source_val and not existing.source:
|
| 360 |
+
existing.source = source_val
|
| 361 |
+
backfilled = True
|
| 362 |
+
if backfilled:
|
| 363 |
+
db.commit()
|
| 364 |
+
db.refresh(existing)
|
| 365 |
+
invalidate_library_caches()
|
| 366 |
logger.info("Skipped duplicate upload %s -> node %s (already exists as image id %s)",
|
| 367 |
original, node.id, existing.id)
|
| 368 |
return existing
|
|
|
|
| 404 |
image_type=itype,
|
| 405 |
file_path=rel,
|
| 406 |
capture_date=cap,
|
| 407 |
+
zone_id=zone_id,
|
| 408 |
+
village_id=village_id,
|
| 409 |
+
year=year_val,
|
| 410 |
+
source=source_val,
|
| 411 |
uploaded_by=uploaded_by or "",
|
| 412 |
file_size_bytes=size,
|
| 413 |
thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
|
|
|
|
| 418 |
format=meta.format,
|
| 419 |
)
|
| 420 |
db.add(img)
|
| 421 |
+
log_action(
|
| 422 |
+
db, "upload", node_id=node.id,
|
| 423 |
+
new_value={
|
| 424 |
+
"file": rel, "type": itype, "zoneId": zone_id, "villageId": village_id,
|
| 425 |
+
"year": year_val, "source": source_val,
|
| 426 |
+
},
|
| 427 |
+
action_by=uploaded_by, module="library",
|
| 428 |
+
)
|
| 429 |
db.commit()
|
| 430 |
db.refresh(img)
|
| 431 |
invalidate_library_caches()
|
|
|
|
| 436 |
def list_images_for_node(db: Session, node_id: int) -> List[dict]:
|
| 437 |
node = get_node_or_404(db, node_id)
|
| 438 |
rows = db.query(ImageLibrary).filter(ImageLibrary.node_id == node_id).order_by(ImageLibrary.uploaded_on.desc()).all()
|
| 439 |
+
zones, villages = location_names(db)
|
| 440 |
+
return [image_to_dict(r, node, zone_names=zones, village_names=villages) for r in rows]
|
| 441 |
|
| 442 |
|
| 443 |
def get_image_by_file_path(db: Session, relative_path: str) -> Optional[ImageLibrary]:
|
|
|
|
| 463 |
return or_(*clauses)
|
| 464 |
|
| 465 |
|
| 466 |
+
def _images_query(
|
| 467 |
+
db: Session,
|
| 468 |
+
*,
|
| 469 |
+
node_id: Optional[int] = None,
|
| 470 |
+
query: Optional[str] = None,
|
| 471 |
+
year: Optional[int] = None,
|
| 472 |
+
image_type: Optional[str] = None,
|
| 473 |
+
zone_id: Optional[int] = None,
|
| 474 |
+
village_id: Optional[int] = None,
|
| 475 |
+
capture_date_from: Optional[str] = None,
|
| 476 |
+
capture_date_to: Optional[str] = None,
|
| 477 |
+
source: Optional[str] = None,
|
| 478 |
+
):
|
| 479 |
q = (
|
| 480 |
db.query(ImageLibrary, TreeNode)
|
| 481 |
.outerjoin(TreeNode, ImageLibrary.node_id == TreeNode.id)
|
|
|
|
| 493 |
func.lower(ImageLibrary.image_name).like(like),
|
| 494 |
func.lower(func.coalesce(TreeNode.node_path, "")).like(like),
|
| 495 |
))
|
| 496 |
+
if year is not None:
|
| 497 |
+
q = q.filter(ImageLibrary.year == year)
|
| 498 |
+
if image_type:
|
| 499 |
+
q = q.filter(ImageLibrary.image_type == image_type.strip())
|
| 500 |
+
if zone_id is not None:
|
| 501 |
+
q = q.filter(ImageLibrary.zone_id == zone_id)
|
| 502 |
+
if village_id is not None:
|
| 503 |
+
q = q.filter(ImageLibrary.village_id == village_id)
|
| 504 |
+
if source:
|
| 505 |
+
q = q.filter(func.lower(ImageLibrary.source) == source.strip().lower())
|
| 506 |
+
if capture_date_from:
|
| 507 |
+
try:
|
| 508 |
+
start = date.fromisoformat(capture_date_from.strip())
|
| 509 |
+
except ValueError:
|
| 510 |
+
raise HTTPException(status_code=400, detail="capture_date_from must be YYYY-MM-DD")
|
| 511 |
+
q = q.filter(ImageLibrary.capture_date >= datetime.combine(start, datetime.min.time()))
|
| 512 |
+
if capture_date_to:
|
| 513 |
+
try:
|
| 514 |
+
end = date.fromisoformat(capture_date_to.strip())
|
| 515 |
+
except ValueError:
|
| 516 |
+
raise HTTPException(status_code=400, detail="capture_date_to must be YYYY-MM-DD")
|
| 517 |
+
q = q.filter(ImageLibrary.capture_date <= datetime.combine(end, datetime.max.time()))
|
| 518 |
return q
|
| 519 |
|
| 520 |
|
|
|
|
| 536 |
*,
|
| 537 |
node_id: Optional[int] = None,
|
| 538 |
query: Optional[str] = None,
|
| 539 |
+
year: Optional[int] = None,
|
| 540 |
+
image_type: Optional[str] = None,
|
| 541 |
+
zone_id: Optional[int] = None,
|
| 542 |
+
village_id: Optional[int] = None,
|
| 543 |
+
capture_date_from: Optional[str] = None,
|
| 544 |
+
capture_date_to: Optional[str] = None,
|
| 545 |
+
source: Optional[str] = None,
|
| 546 |
include_bounds: bool = False,
|
| 547 |
slim: bool = True,
|
| 548 |
limit: Optional[int] = None,
|
| 549 |
offset: int = 0,
|
| 550 |
):
|
| 551 |
+
q = _images_query(
|
| 552 |
+
db,
|
| 553 |
+
node_id=node_id,
|
| 554 |
+
query=query,
|
| 555 |
+
year=year,
|
| 556 |
+
image_type=image_type,
|
| 557 |
+
zone_id=zone_id,
|
| 558 |
+
village_id=village_id,
|
| 559 |
+
capture_date_from=capture_date_from,
|
| 560 |
+
capture_date_to=capture_date_to,
|
| 561 |
+
source=source,
|
| 562 |
+
)
|
| 563 |
ordered = q.order_by(ImageLibrary.uploaded_on.desc())
|
| 564 |
+
zones, villages = location_names(db)
|
| 565 |
if limit is not None:
|
| 566 |
total = q.count()
|
| 567 |
rows = ordered.offset(offset).limit(limit).all()
|
| 568 |
items = [
|
| 569 |
+
image_to_dict(img, node, include_bounds=include_bounds, slim=slim,
|
| 570 |
+
zone_names=zones, village_names=villages)
|
| 571 |
for img, node in rows
|
| 572 |
]
|
| 573 |
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
| 574 |
rows = ordered.all()
|
| 575 |
return [
|
| 576 |
+
image_to_dict(img, node, include_bounds=include_bounds, slim=slim,
|
| 577 |
+
zone_names=zones, village_names=villages)
|
| 578 |
for img, node in rows
|
| 579 |
]
|
| 580 |
|
| 581 |
|
| 582 |
+
def export_library_rows(
|
| 583 |
+
db: Session,
|
| 584 |
+
*,
|
| 585 |
+
node_id: Optional[int] = None,
|
| 586 |
+
query: Optional[str] = None,
|
| 587 |
+
year: Optional[int] = None,
|
| 588 |
+
image_type: Optional[str] = None,
|
| 589 |
+
zone_id: Optional[int] = None,
|
| 590 |
+
village_id: Optional[int] = None,
|
| 591 |
+
capture_date_from: Optional[str] = None,
|
| 592 |
+
capture_date_to: Optional[str] = None,
|
| 593 |
+
source: Optional[str] = None,
|
| 594 |
+
) -> list[dict]:
|
| 595 |
+
rows = list_all_images(
|
| 596 |
+
db,
|
| 597 |
+
node_id=node_id,
|
| 598 |
+
query=query,
|
| 599 |
+
year=year,
|
| 600 |
+
image_type=image_type,
|
| 601 |
+
zone_id=zone_id,
|
| 602 |
+
village_id=village_id,
|
| 603 |
+
capture_date_from=capture_date_from,
|
| 604 |
+
capture_date_to=capture_date_to,
|
| 605 |
+
source=source,
|
| 606 |
+
include_bounds=False,
|
| 607 |
+
slim=False,
|
| 608 |
+
)
|
| 609 |
+
out = []
|
| 610 |
+
for img in rows:
|
| 611 |
+
out.append({
|
| 612 |
+
"Image ID": img.get("id"),
|
| 613 |
+
"Server filename": img.get("filename") or img.get("imageName") or "",
|
| 614 |
+
"Image type": img.get("imageType") or "",
|
| 615 |
+
"Source": img.get("source") or "",
|
| 616 |
+
"Format": img.get("format") or "",
|
| 617 |
+
"Capture date": (str(img.get("captureDate") or "")[:10]),
|
| 618 |
+
"Year": img.get("year") or "",
|
| 619 |
+
"Zone": img.get("zoneName") or "",
|
| 620 |
+
"Village": img.get("villageName") or "",
|
| 621 |
+
"File size": img.get("fileSizeBytes") or 0,
|
| 622 |
+
"Uploaded on": img.get("uploadedOn") or "",
|
| 623 |
+
})
|
| 624 |
+
return out
|
| 625 |
+
|
| 626 |
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
| 627 |
full = resolve_file(relative_path)
|
| 628 |
cache = _thumb_cache_path(relative_path)
|
app/dda/tree/library_cache.py
CHANGED
|
@@ -3,23 +3,26 @@ from __future__ import annotations
|
|
| 3 |
|
| 4 |
from typing import Any, Optional
|
| 5 |
|
|
|
|
| 6 |
_area_fp: Optional[tuple] = None
|
| 7 |
-
|
| 8 |
|
| 9 |
|
| 10 |
def invalidate_library_caches() -> None:
|
| 11 |
-
global _area_fp,
|
| 12 |
_area_fp = None
|
| 13 |
-
|
| 14 |
|
| 15 |
|
| 16 |
-
def get_area_groups_cache(fingerprint: tuple) -> Optional[dict[str, Any]]:
|
| 17 |
-
if _area_fp =
|
| 18 |
-
return
|
| 19 |
-
return
|
| 20 |
|
| 21 |
|
| 22 |
-
def set_area_groups_cache(fingerprint: tuple, payload: dict[str, Any]) -> None:
|
| 23 |
-
global _area_fp,
|
| 24 |
-
_area_fp = fingerprint
|
| 25 |
-
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from typing import Any, Optional
|
| 5 |
|
| 6 |
+
# fingerprint → { node_id_or_0: payload }
|
| 7 |
_area_fp: Optional[tuple] = None
|
| 8 |
+
_area_by_node: dict[int, dict[str, Any]] = {}
|
| 9 |
|
| 10 |
|
| 11 |
def invalidate_library_caches() -> None:
|
| 12 |
+
global _area_fp, _area_by_node
|
| 13 |
_area_fp = None
|
| 14 |
+
_area_by_node = {}
|
| 15 |
|
| 16 |
|
| 17 |
+
def get_area_groups_cache(fingerprint: tuple, node_id: int = 0) -> Optional[dict[str, Any]]:
|
| 18 |
+
if _area_fp != fingerprint:
|
| 19 |
+
return None
|
| 20 |
+
return _area_by_node.get(int(node_id or 0))
|
| 21 |
|
| 22 |
|
| 23 |
+
def set_area_groups_cache(fingerprint: tuple, payload: dict[str, Any], node_id: int = 0) -> None:
|
| 24 |
+
global _area_fp, _area_by_node
|
| 25 |
+
if _area_fp != fingerprint:
|
| 26 |
+
_area_by_node = {}
|
| 27 |
+
_area_fp = fingerprint
|
| 28 |
+
_area_by_node[int(node_id or 0)] = payload
|
app/dda/tree/migration.py
CHANGED
|
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
|
| 10 |
|
| 11 |
from ..config import ALLOWED_EXTENSIONS, get_library_roots, get_storage_root
|
| 12 |
from ..seed_data import DELHI_ZONES
|
| 13 |
-
from .models import
|
| 14 |
from .path_service import ensure_node_directory, storage_root
|
| 15 |
from .path_slugs import unique_slug
|
| 16 |
from .tree_service import create_node
|
|
@@ -85,16 +85,10 @@ def migrate_flat_year_folders(db: Session) -> dict:
|
|
| 85 |
if path.resolve() != target.resolve():
|
| 86 |
shutil.move(str(path), str(target))
|
| 87 |
rel = target.relative_to(root).as_posix()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
image_type="GeoTIFF",
|
| 93 |
-
file_path=rel,
|
| 94 |
-
uploaded_by="migration",
|
| 95 |
-
file_size_bytes=target.stat().st_size,
|
| 96 |
-
))
|
| 97 |
-
indexed += 1
|
| 98 |
moved += 1
|
| 99 |
try:
|
| 100 |
if entry.is_dir() and not any(entry.iterdir()):
|
|
|
|
| 10 |
|
| 11 |
from ..config import ALLOWED_EXTENSIONS, get_library_roots, get_storage_root
|
| 12 |
from ..seed_data import DELHI_ZONES
|
| 13 |
+
from .models import TreeNode
|
| 14 |
from .path_service import ensure_node_directory, storage_root
|
| 15 |
from .path_slugs import unique_slug
|
| 16 |
from .tree_service import create_node
|
|
|
|
| 85 |
if path.resolve() != target.resolve():
|
| 86 |
shutil.move(str(path), str(target))
|
| 87 |
rel = target.relative_to(root).as_posix()
|
| 88 |
+
# zone_id/village_id are required (NOT NULL); this legacy path has
|
| 89 |
+
# no location info to give a migrated image, so the file is moved
|
| 90 |
+
# into the tree but left un-indexed — same reasoning as the
|
| 91 |
+
# filesystem sync's own skip (see sync_service.py).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
moved += 1
|
| 93 |
try:
|
| 94 |
if entry.is_dir() and not any(entry.iterdir()):
|
app/dda/tree/models.py
CHANGED
|
@@ -43,6 +43,19 @@ class ImageLibrary(Base):
|
|
| 43 |
# path. Works unchanged on SQLite/PostgreSQL too.
|
| 44 |
file_path = Column(String(500), nullable=False, unique=True) # relative to storage root
|
| 45 |
capture_date = Column(DateTime, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
uploaded_by = Column(String(100), default="")
|
| 47 |
uploaded_on = Column(DateTime, default=_utcnow)
|
| 48 |
# BigInteger, not Integer: this project's GeoTIFFs regularly exceed 2GB
|
|
@@ -86,4 +99,5 @@ class ExceptionLog(Base):
|
|
| 86 |
handled = Column(Boolean, default=False)
|
| 87 |
traceback = Column(Text, default="")
|
| 88 |
user_email = Column(String(100), nullable=True)
|
|
|
|
| 89 |
created_at = Column(DateTime, default=_utcnow, index=True)
|
|
|
|
| 43 |
# path. Works unchanged on SQLite/PostgreSQL too.
|
| 44 |
file_path = Column(String(500), nullable=False, unique=True) # relative to storage root
|
| 45 |
capture_date = Column(DateTime, nullable=True)
|
| 46 |
+
# Administrative location picked on upload. Independent of the folder tree:
|
| 47 |
+
# the same zone/village can appear under several folders, and pairing uses
|
| 48 |
+
# it as a strong "same place" signal. Required, not just conventionally —
|
| 49 |
+
# an "Unassigned" image was a recurring real problem (thousands of images
|
| 50 |
+
# landed with no location via bulk upload / filesystem sync and cluttered
|
| 51 |
+
# every zone/village breakdown), so this is enforced NOT NULL at the DB
|
| 52 |
+
# level too (see bootstrap.py's migration list) so no insert path —
|
| 53 |
+
# including ones outside this app's own upload endpoints — can create an
|
| 54 |
+
# unassigned row.
|
| 55 |
+
zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=False, index=True)
|
| 56 |
+
village_id = Column(Integer, ForeignKey("dda_villages.id"), nullable=False, index=True)
|
| 57 |
+
year = Column(Integer, nullable=True, index=True)
|
| 58 |
+
source = Column(String(32), default="") # satellite | drone | ""
|
| 59 |
uploaded_by = Column(String(100), default="")
|
| 60 |
uploaded_on = Column(DateTime, default=_utcnow)
|
| 61 |
# BigInteger, not Integer: this project's GeoTIFFs regularly exceed 2GB
|
|
|
|
| 99 |
handled = Column(Boolean, default=False)
|
| 100 |
traceback = Column(Text, default="")
|
| 101 |
user_email = Column(String(100), nullable=True)
|
| 102 |
+
user_role = Column(String(64), nullable=True)
|
| 103 |
created_at = Column(DateTime, default=_utcnow, index=True)
|
app/dda/tree/routes.py
CHANGED
|
@@ -1,23 +1,37 @@
|
|
| 1 |
"""FastAPI routes for unlimited-depth tree library."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
|
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
from sqlalchemy.orm import Session
|
| 9 |
|
| 10 |
from ...database import get_db
|
| 11 |
from ...models import User
|
| 12 |
-
from ..config import IS_DDA_MODE
|
| 13 |
from ..dda_auth import current_dda_user, get_user_role
|
| 14 |
from ..rbac.permissions import require_module_permission
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from .image_service import (
|
|
|
|
| 16 |
IMAGE_TYPES,
|
| 17 |
delete_library_image,
|
|
|
|
| 18 |
image_to_dict,
|
| 19 |
list_all_images,
|
| 20 |
list_images_for_node,
|
|
|
|
| 21 |
upload_image,
|
| 22 |
)
|
| 23 |
from .tree_service import (
|
|
@@ -38,6 +52,17 @@ def _require_dda():
|
|
| 38 |
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
class NodeCreateBody(BaseModel):
|
| 42 |
parent_id: Optional[int] = None
|
| 43 |
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
|
|
| 66 |
@router.get("/tree")
|
| 67 |
def get_tree(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 68 |
_require_dda()
|
| 69 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
@router.post("/tree/nodes")
|
|
@@ -184,11 +213,98 @@ def api_list_node_images(node_id: int, db: Session = Depends(get_db), user: User
|
|
| 184 |
def api_list_images(
|
| 185 |
node_id: Optional[int] = None,
|
| 186 |
q: Optional[str] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
db: Session = Depends(get_db),
|
| 188 |
user: User = Depends(current_dda_user),
|
| 189 |
):
|
| 190 |
_require_dda()
|
| 191 |
-
return list_all_images(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
|
| 194 |
@router.post("/tree/nodes/{node_id}/images/upload")
|
|
@@ -198,6 +314,11 @@ async def api_upload_image(
|
|
| 198 |
image_type: str = Form("GeoTIFF"),
|
| 199 |
capture_date: str = Form(""),
|
| 200 |
manual_bounds: str = Form(""),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
db: Session = Depends(get_db),
|
| 202 |
user: User = Depends(require_module_permission("library", "create")),
|
| 203 |
):
|
|
@@ -208,9 +329,160 @@ async def api_upload_image(
|
|
| 208 |
capture_date=capture_date or None,
|
| 209 |
uploaded_by=user.email or str(user.id),
|
| 210 |
manual_bounds=manual_bounds or None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
node = get_node_or_404(db, node_id)
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
|
| 216 |
@router.delete("/tree/images/{image_id}")
|
|
|
|
| 1 |
"""FastAPI routes for unlimited-depth tree library."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import csv
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
from typing import List, Optional
|
| 8 |
|
| 9 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
| 10 |
+
from fastapi.responses import Response
|
| 11 |
from pydantic import BaseModel, Field
|
| 12 |
from sqlalchemy.orm import Session
|
| 13 |
|
| 14 |
from ...database import get_db
|
| 15 |
from ...models import User
|
| 16 |
+
from ..config import IS_DDA_MODE, get_storage_root
|
| 17 |
from ..dda_auth import current_dda_user, get_user_role
|
| 18 |
from ..rbac.permissions import require_module_permission
|
| 19 |
+
from .bulk_upload_service import (
|
| 20 |
+
BulkDefaults,
|
| 21 |
+
build_bulk_upload_template,
|
| 22 |
+
bulk_upload_images,
|
| 23 |
+
parse_bulk_manifest,
|
| 24 |
+
zone_village_names,
|
| 25 |
+
)
|
| 26 |
from .image_service import (
|
| 27 |
+
IMAGE_TYPE_EXTENSIONS,
|
| 28 |
IMAGE_TYPES,
|
| 29 |
delete_library_image,
|
| 30 |
+
export_library_rows,
|
| 31 |
image_to_dict,
|
| 32 |
list_all_images,
|
| 33 |
list_images_for_node,
|
| 34 |
+
location_names,
|
| 35 |
upload_image,
|
| 36 |
)
|
| 37 |
from .tree_service import (
|
|
|
|
| 52 |
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 53 |
|
| 54 |
|
| 55 |
+
def _optional_int(raw: str, field: str) -> Optional[int]:
|
| 56 |
+
"""Multipart form fields arrive as strings; '' means 'not selected'."""
|
| 57 |
+
text = (raw or "").strip()
|
| 58 |
+
if not text:
|
| 59 |
+
return None
|
| 60 |
+
try:
|
| 61 |
+
return int(text)
|
| 62 |
+
except ValueError:
|
| 63 |
+
raise HTTPException(status_code=400, detail=f"{field} must be a number")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
class NodeCreateBody(BaseModel):
|
| 67 |
parent_id: Optional[int] = None
|
| 68 |
node_name: str = Field(..., min_length=1, max_length=500)
|
|
|
|
| 91 |
@router.get("/tree")
|
| 92 |
def get_tree(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 93 |
_require_dda()
|
| 94 |
+
return {
|
| 95 |
+
"tree": build_tree(db),
|
| 96 |
+
"imageTypes": sorted(IMAGE_TYPES),
|
| 97 |
+
"imageTypeExtensions": {k: sorted(v) for k, v in IMAGE_TYPE_EXTENSIONS.items()},
|
| 98 |
+
}
|
| 99 |
|
| 100 |
|
| 101 |
@router.post("/tree/nodes")
|
|
|
|
| 213 |
def api_list_images(
|
| 214 |
node_id: Optional[int] = None,
|
| 215 |
q: Optional[str] = None,
|
| 216 |
+
year: Optional[int] = None,
|
| 217 |
+
image_type: Optional[str] = None,
|
| 218 |
+
zone_id: Optional[int] = None,
|
| 219 |
+
village_id: Optional[int] = None,
|
| 220 |
+
capture_date_from: Optional[str] = None,
|
| 221 |
+
capture_date_to: Optional[str] = None,
|
| 222 |
+
source: Optional[str] = None,
|
| 223 |
+
limit: Optional[int] = Query(50, ge=1, le=500),
|
| 224 |
+
offset: int = Query(0, ge=0),
|
| 225 |
db: Session = Depends(get_db),
|
| 226 |
user: User = Depends(current_dda_user),
|
| 227 |
):
|
| 228 |
_require_dda()
|
| 229 |
+
return list_all_images(
|
| 230 |
+
db,
|
| 231 |
+
node_id=node_id,
|
| 232 |
+
query=q,
|
| 233 |
+
year=year,
|
| 234 |
+
image_type=image_type,
|
| 235 |
+
zone_id=zone_id,
|
| 236 |
+
village_id=village_id,
|
| 237 |
+
capture_date_from=capture_date_from,
|
| 238 |
+
capture_date_to=capture_date_to,
|
| 239 |
+
source=source,
|
| 240 |
+
include_bounds=False,
|
| 241 |
+
slim=True,
|
| 242 |
+
limit=limit if limit is not None else 50,
|
| 243 |
+
offset=offset,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@router.get("/tree/images/export")
|
| 248 |
+
def api_export_images(
|
| 249 |
+
node_id: Optional[int] = None,
|
| 250 |
+
q: Optional[str] = None,
|
| 251 |
+
year: Optional[int] = None,
|
| 252 |
+
image_type: Optional[str] = None,
|
| 253 |
+
zone_id: Optional[int] = None,
|
| 254 |
+
village_id: Optional[int] = None,
|
| 255 |
+
capture_date_from: Optional[str] = None,
|
| 256 |
+
capture_date_to: Optional[str] = None,
|
| 257 |
+
source: Optional[str] = None,
|
| 258 |
+
format: str = Query("csv", pattern="^(csv|xlsx)$"),
|
| 259 |
+
db: Session = Depends(get_db),
|
| 260 |
+
user: User = Depends(require_module_permission("library", "view")),
|
| 261 |
+
):
|
| 262 |
+
_require_dda()
|
| 263 |
+
rows = export_library_rows(
|
| 264 |
+
db,
|
| 265 |
+
node_id=node_id,
|
| 266 |
+
query=q,
|
| 267 |
+
year=year,
|
| 268 |
+
image_type=image_type,
|
| 269 |
+
zone_id=zone_id,
|
| 270 |
+
village_id=village_id,
|
| 271 |
+
capture_date_from=capture_date_from,
|
| 272 |
+
capture_date_to=capture_date_to,
|
| 273 |
+
source=source,
|
| 274 |
+
)
|
| 275 |
+
headers = [
|
| 276 |
+
"Image ID", "Server filename", "Image type", "Source", "Format",
|
| 277 |
+
"Capture date", "Year", "Zone", "Village", "File size", "Uploaded on",
|
| 278 |
+
]
|
| 279 |
+
if format == "xlsx":
|
| 280 |
+
from openpyxl import Workbook
|
| 281 |
+
|
| 282 |
+
wb = Workbook()
|
| 283 |
+
ws = wb.active
|
| 284 |
+
ws.title = "Library"
|
| 285 |
+
ws.append(headers)
|
| 286 |
+
for row in rows:
|
| 287 |
+
ws.append([row.get(h, "") for h in headers])
|
| 288 |
+
buf = io.BytesIO()
|
| 289 |
+
wb.save(buf)
|
| 290 |
+
buf.seek(0)
|
| 291 |
+
return Response(
|
| 292 |
+
content=buf.getvalue(),
|
| 293 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 294 |
+
headers={"Content-Disposition": 'attachment; filename="library_export.xlsx"'},
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
buf = io.StringIO()
|
| 298 |
+
writer = csv.DictWriter(buf, fieldnames=headers, extrasaction="ignore")
|
| 299 |
+
writer.writeheader()
|
| 300 |
+
for row in rows:
|
| 301 |
+
writer.writerow(row)
|
| 302 |
+
data = buf.getvalue().encode("utf-8-sig")
|
| 303 |
+
return Response(
|
| 304 |
+
content=data,
|
| 305 |
+
media_type="text/csv; charset=utf-8",
|
| 306 |
+
headers={"Content-Disposition": 'attachment; filename="library_export.csv"'},
|
| 307 |
+
)
|
| 308 |
|
| 309 |
|
| 310 |
@router.post("/tree/nodes/{node_id}/images/upload")
|
|
|
|
| 314 |
image_type: str = Form("GeoTIFF"),
|
| 315 |
capture_date: str = Form(""),
|
| 316 |
manual_bounds: str = Form(""),
|
| 317 |
+
zone_id: str = Form(""),
|
| 318 |
+
village_id: str = Form(""),
|
| 319 |
+
year: str = Form(""),
|
| 320 |
+
source: str = Form(""),
|
| 321 |
+
server_filename: str = Form(""),
|
| 322 |
db: Session = Depends(get_db),
|
| 323 |
user: User = Depends(require_module_permission("library", "create")),
|
| 324 |
):
|
|
|
|
| 329 |
capture_date=capture_date or None,
|
| 330 |
uploaded_by=user.email or str(user.id),
|
| 331 |
manual_bounds=manual_bounds or None,
|
| 332 |
+
zone_id=_optional_int(zone_id, "zone_id"),
|
| 333 |
+
village_id=_optional_int(village_id, "village_id"),
|
| 334 |
+
year=year or None,
|
| 335 |
+
source=source or None,
|
| 336 |
+
server_filename=server_filename or None,
|
| 337 |
+
)
|
| 338 |
+
node = get_node_or_404(db, node_id)
|
| 339 |
+
zones, villages = location_names(db)
|
| 340 |
+
return {
|
| 341 |
+
"status": True,
|
| 342 |
+
"message": "Image Uploaded Successfully",
|
| 343 |
+
"image": image_to_dict(img, node, zone_names=zones, village_names=villages),
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
@router.get("/tree/bulk-upload/template")
|
| 348 |
+
def api_bulk_upload_template(
|
| 349 |
+
user: User = Depends(require_module_permission("library", "view")),
|
| 350 |
+
):
|
| 351 |
+
_require_dda()
|
| 352 |
+
content = build_bulk_upload_template()
|
| 353 |
+
return Response(
|
| 354 |
+
content=content,
|
| 355 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 356 |
+
headers={"Content-Disposition": "attachment; filename=bulk_image_upload_template.xlsx"},
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
@router.get("/tree/bulk-upload/config")
|
| 361 |
+
def api_bulk_upload_config(
|
| 362 |
+
user: User = Depends(require_module_permission("library", "view")),
|
| 363 |
+
):
|
| 364 |
+
"""Main folder for this environment — Excel General Folder paths are relative to it."""
|
| 365 |
+
_require_dda()
|
| 366 |
+
root = get_storage_root()
|
| 367 |
+
return {
|
| 368 |
+
"mainFolder": str(root),
|
| 369 |
+
"imageTypes": sorted(IMAGE_TYPES),
|
| 370 |
+
"sources": ["satellite", "drone"],
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
@router.post("/tree/bulk-upload")
|
| 375 |
+
async def api_bulk_upload(
|
| 376 |
+
excel: UploadFile = File(...),
|
| 377 |
+
files: List[UploadFile] = File(...),
|
| 378 |
+
year: str = Form(""),
|
| 379 |
+
source: str = Form(""),
|
| 380 |
+
image_type: str = Form(""),
|
| 381 |
+
zone_id: str = Form(""),
|
| 382 |
+
village_id: str = Form(""),
|
| 383 |
+
filenames: Optional[str] = Form(None),
|
| 384 |
+
db: Session = Depends(get_db),
|
| 385 |
+
user: User = Depends(require_module_permission("library", "create")),
|
| 386 |
+
):
|
| 387 |
+
_require_dda()
|
| 388 |
+
xlsx_bytes = await excel.read()
|
| 389 |
+
rows, row_errors = parse_bulk_manifest(xlsx_bytes)
|
| 390 |
+
|
| 391 |
+
zid = _optional_int(zone_id, "zone_id")
|
| 392 |
+
vid = _optional_int(village_id, "village_id")
|
| 393 |
+
zone_name, village_name = zone_village_names(db, zid, vid)
|
| 394 |
+
|
| 395 |
+
name_map = {}
|
| 396 |
+
if filenames:
|
| 397 |
+
try:
|
| 398 |
+
raw = json.loads(filenames)
|
| 399 |
+
if isinstance(raw, dict):
|
| 400 |
+
name_map = {str(k).strip().lower(): str(v).strip() for k, v in raw.items() if v}
|
| 401 |
+
except Exception:
|
| 402 |
+
raise HTTPException(status_code=400, detail="filenames must be a JSON object of originalName → serverFilename")
|
| 403 |
+
|
| 404 |
+
defaults = BulkDefaults(
|
| 405 |
+
year=(year or "").strip() or None,
|
| 406 |
+
source=(source or "").strip().lower() or None,
|
| 407 |
+
image_type=(image_type or "").strip() or None,
|
| 408 |
+
zone=zone_name or None,
|
| 409 |
+
village=village_name or None,
|
| 410 |
+
filenames=name_map,
|
| 411 |
)
|
| 412 |
+
# Always run defaults so empty Image Type gets GeoTIFF.
|
| 413 |
+
result = await bulk_upload_images(
|
| 414 |
+
db, rows, files,
|
| 415 |
+
uploaded_by=user.email or str(user.id),
|
| 416 |
+
defaults=defaults,
|
| 417 |
+
)
|
| 418 |
+
result.errors = row_errors + result.errors
|
| 419 |
+
return {
|
| 420 |
+
"status": True,
|
| 421 |
+
"created": result.created,
|
| 422 |
+
"duplicates": result.duplicates,
|
| 423 |
+
"errors": result.errors,
|
| 424 |
+
"unmatchedFiles": result.unmatched_files,
|
| 425 |
+
"summary": {
|
| 426 |
+
"created": len(result.created),
|
| 427 |
+
"duplicates": len(result.duplicates),
|
| 428 |
+
"errors": len(result.errors),
|
| 429 |
+
"unmatchedFiles": len(result.unmatched_files),
|
| 430 |
+
},
|
| 431 |
+
"mainFolder": str(get_storage_root()),
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@router.post("/tree/nodes/{node_id}/images/upload-bulk")
|
| 436 |
+
async def api_upload_images_bulk(
|
| 437 |
+
node_id: int,
|
| 438 |
+
files: List[UploadFile] = File(...),
|
| 439 |
+
image_type: str = Form("GeoTIFF"),
|
| 440 |
+
capture_date: str = Form(""),
|
| 441 |
+
manual_bounds: str = Form(""),
|
| 442 |
+
zone_id: str = Form(""),
|
| 443 |
+
village_id: str = Form(""),
|
| 444 |
+
year: str = Form(""),
|
| 445 |
+
source: str = Form(""),
|
| 446 |
+
filenames: Optional[List[str]] = Form(None),
|
| 447 |
+
db: Session = Depends(get_db),
|
| 448 |
+
user: User = Depends(require_module_permission("library", "create")),
|
| 449 |
+
):
|
| 450 |
+
"""Upload many files in one request; loops the single-file upload path."""
|
| 451 |
+
_require_dda()
|
| 452 |
node = get_node_or_404(db, node_id)
|
| 453 |
+
zones, villages = location_names(db)
|
| 454 |
+
uploaded = []
|
| 455 |
+
failed = []
|
| 456 |
+
if isinstance(filenames, str):
|
| 457 |
+
name_list = [filenames]
|
| 458 |
+
else:
|
| 459 |
+
name_list = list(filenames or [])
|
| 460 |
+
for i, file in enumerate(files):
|
| 461 |
+
server_name = name_list[i] if i < len(name_list) else (file.filename or "upload")
|
| 462 |
+
try:
|
| 463 |
+
img = await upload_image(
|
| 464 |
+
db, node_id, file,
|
| 465 |
+
image_type=image_type,
|
| 466 |
+
capture_date=capture_date or None,
|
| 467 |
+
uploaded_by=user.email or str(user.id),
|
| 468 |
+
manual_bounds=manual_bounds or None,
|
| 469 |
+
zone_id=_optional_int(zone_id, "zone_id"),
|
| 470 |
+
village_id=_optional_int(village_id, "village_id"),
|
| 471 |
+
year=year or None,
|
| 472 |
+
source=source or None,
|
| 473 |
+
server_filename=server_name,
|
| 474 |
+
)
|
| 475 |
+
uploaded.append(image_to_dict(img, node, zone_names=zones, village_names=villages))
|
| 476 |
+
except HTTPException as exc:
|
| 477 |
+
failed.append({"filename": server_name, "error": str(exc.detail)})
|
| 478 |
+
except Exception as exc:
|
| 479 |
+
failed.append({"filename": server_name, "error": str(exc)})
|
| 480 |
+
return {
|
| 481 |
+
"status": True,
|
| 482 |
+
"message": f"Uploaded {len(uploaded)} image(s)" + (f", {len(failed)} failed" if failed else ""),
|
| 483 |
+
"uploaded": uploaded,
|
| 484 |
+
"failed": failed,
|
| 485 |
+
}
|
| 486 |
|
| 487 |
|
| 488 |
@router.delete("/tree/images/{image_id}")
|
app/dda/tree/sync_service.py
CHANGED
|
@@ -22,6 +22,7 @@ _NODE_TYPES = ("Zone", "Area", "Year", "Folder")
|
|
| 22 |
_SKIP_DIRS = frozenset({".git", ".thumbs", "__pycache__", "cache", "thumbs"})
|
| 23 |
_RESERVED_DIR_NAMES = RESERVED
|
| 24 |
_SKIP_CACHE: Optional[dict] = None
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def _skip_cache_path() -> Path:
|
|
@@ -172,7 +173,7 @@ def _image_type_for(file_path: Path) -> str:
|
|
| 172 |
return "JPEG"
|
| 173 |
if ext == ".png":
|
| 174 |
return "PNG"
|
| 175 |
-
return "
|
| 176 |
|
| 177 |
|
| 178 |
def _index_image_file(
|
|
@@ -182,6 +183,9 @@ def _index_image_file(
|
|
| 182 |
|
| 183 |
Unreadable rasters (corrupt TIFF, etc.) are skipped and counted in
|
| 184 |
``stats['imagesSkipped']`` so one bad file cannot fail the whole rescan.
|
|
|
|
|
|
|
|
|
|
| 185 |
"""
|
| 186 |
existing = db.query(ImageLibrary).filter(ImageLibrary.file_path == rel_file).first()
|
| 187 |
path_fixed = False
|
|
@@ -197,7 +201,7 @@ def _index_image_file(
|
|
| 197 |
skip_meta = skip.get(rel_file)
|
| 198 |
if skip_meta is not None and int(skip_meta.get("size") or 0) == stat.st_size:
|
| 199 |
if path_fixed:
|
| 200 |
-
db.
|
| 201 |
stats["imagesSkipped"] = stats.get("imagesSkipped", 0) + 1
|
| 202 |
return False
|
| 203 |
if existing and existing.file_size_bytes == stat.st_size and (existing.width or 0) > 0:
|
|
@@ -209,7 +213,7 @@ def _index_image_file(
|
|
| 209 |
changed = True
|
| 210 |
if changed:
|
| 211 |
existing.node_id = node.id
|
| 212 |
-
db.
|
| 213 |
stats["imagesUpdated"] += 1
|
| 214 |
return False
|
| 215 |
try:
|
|
@@ -249,35 +253,29 @@ def _index_image_file(
|
|
| 249 |
inferred = _infer_capture_date(file_path, node)
|
| 250 |
if inferred:
|
| 251 |
existing.capture_date = inferred
|
| 252 |
-
db.
|
| 253 |
stats["imagesUpdated"] += 1
|
| 254 |
elif existing.capture_date is None:
|
| 255 |
inferred = _infer_capture_date(file_path, node)
|
| 256 |
if inferred:
|
| 257 |
existing.capture_date = inferred
|
| 258 |
-
db.
|
| 259 |
stats["imagesUpdated"] += 1
|
| 260 |
return False
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
height=meta.height,
|
| 273 |
-
has_georef=meta.has_georef,
|
| 274 |
-
bounds_json=bounds_to_json(meta.bounds_wgs84) or "",
|
| 275 |
-
format=meta.format,
|
| 276 |
)
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
logger.info("Indexed image from disk: %s", rel_file)
|
| 280 |
-
return True
|
| 281 |
|
| 282 |
|
| 283 |
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
|
|
| 312 |
logger.warning("Skipping images under %s: %s", rel_path, exc)
|
| 313 |
stats["foldersSkipped"] = stats.get("foldersSkipped", 0) + 1
|
| 314 |
continue
|
|
|
|
| 315 |
for f in sorted(child.iterdir()):
|
| 316 |
if not f.is_file() or f.suffix.lower() not in ALLOWED_EXTENSIONS:
|
| 317 |
continue
|
| 318 |
rel_file = f.relative_to(storage_root()).as_posix()
|
| 319 |
if _index_image_file(db, node, f, rel_file, stats):
|
| 320 |
stats["imagesIndexed"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
continue
|
| 322 |
|
| 323 |
if child.name.lower() in _RESERVED_DIR_NAMES:
|
|
@@ -349,6 +354,7 @@ def sync_from_filesystem(db: Session, *, check_orphans: bool = True) -> dict:
|
|
| 349 |
"imagesIndexed": 0,
|
| 350 |
"imagesUpdated": 0,
|
| 351 |
"imagesSkipped": 0,
|
|
|
|
| 352 |
"orphansFlagged": 0,
|
| 353 |
"foldersSkipped": 0,
|
| 354 |
}
|
|
|
|
| 22 |
_SKIP_DIRS = frozenset({".git", ".thumbs", "__pycache__", "cache", "thumbs"})
|
| 23 |
_RESERVED_DIR_NAMES = RESERVED
|
| 24 |
_SKIP_CACHE: Optional[dict] = None
|
| 25 |
+
_COMMIT_BATCH_SIZE = 200
|
| 26 |
|
| 27 |
|
| 28 |
def _skip_cache_path() -> Path:
|
|
|
|
| 173 |
return "JPEG"
|
| 174 |
if ext == ".png":
|
| 175 |
return "PNG"
|
| 176 |
+
return "GeoTIFF"
|
| 177 |
|
| 178 |
|
| 179 |
def _index_image_file(
|
|
|
|
| 183 |
|
| 184 |
Unreadable rasters (corrupt TIFF, etc.) are skipped and counted in
|
| 185 |
``stats['imagesSkipped']`` so one bad file cannot fail the whole rescan.
|
| 186 |
+
|
| 187 |
+
Does not commit — the caller batches commits across many files (see
|
| 188 |
+
``_COMMIT_BATCH_SIZE``) so a large sync isn't one fsync per row.
|
| 189 |
"""
|
| 190 |
existing = db.query(ImageLibrary).filter(ImageLibrary.file_path == rel_file).first()
|
| 191 |
path_fixed = False
|
|
|
|
| 201 |
skip_meta = skip.get(rel_file)
|
| 202 |
if skip_meta is not None and int(skip_meta.get("size") or 0) == stat.st_size:
|
| 203 |
if path_fixed:
|
| 204 |
+
db.flush()
|
| 205 |
stats["imagesSkipped"] = stats.get("imagesSkipped", 0) + 1
|
| 206 |
return False
|
| 207 |
if existing and existing.file_size_bytes == stat.st_size and (existing.width or 0) > 0:
|
|
|
|
| 213 |
changed = True
|
| 214 |
if changed:
|
| 215 |
existing.node_id = node.id
|
| 216 |
+
db.flush()
|
| 217 |
stats["imagesUpdated"] += 1
|
| 218 |
return False
|
| 219 |
try:
|
|
|
|
| 253 |
inferred = _infer_capture_date(file_path, node)
|
| 254 |
if inferred:
|
| 255 |
existing.capture_date = inferred
|
| 256 |
+
db.flush()
|
| 257 |
stats["imagesUpdated"] += 1
|
| 258 |
elif existing.capture_date is None:
|
| 259 |
inferred = _infer_capture_date(file_path, node)
|
| 260 |
if inferred:
|
| 261 |
existing.capture_date = inferred
|
| 262 |
+
db.flush()
|
| 263 |
stats["imagesUpdated"] += 1
|
| 264 |
return False
|
| 265 |
|
| 266 |
+
# zone_id/village_id are required (NOT NULL) and there's no reliable way
|
| 267 |
+
# to derive them from a bare file on disk — a file found by the sync
|
| 268 |
+
# (rather than uploaded through the app, which does collect a location)
|
| 269 |
+
# is left un-indexed rather than creating an "Unassigned" row. It stays
|
| 270 |
+
# on disk and will be picked up automatically once someone uploads it
|
| 271 |
+
# properly (with a zone/village) through the Image Library UI, which
|
| 272 |
+
# de-dupes by file_path.
|
| 273 |
+
logger.info(
|
| 274 |
+
"Not indexing %s: found on disk but the filesystem sync has no zone/village for it "
|
| 275 |
+
"(upload it through the Image Library to tag a location)", rel_file,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
)
|
| 277 |
+
stats["imagesNeedLocation"] = stats.get("imagesNeedLocation", 0) + 1
|
| 278 |
+
return False
|
|
|
|
|
|
|
| 279 |
|
| 280 |
|
| 281 |
def _node_path_for_images_folder(rel_path: str) -> Optional[str]:
|
|
|
|
| 310 |
logger.warning("Skipping images under %s: %s", rel_path, exc)
|
| 311 |
stats["foldersSkipped"] = stats.get("foldersSkipped", 0) + 1
|
| 312 |
continue
|
| 313 |
+
since_commit = 0
|
| 314 |
for f in sorted(child.iterdir()):
|
| 315 |
if not f.is_file() or f.suffix.lower() not in ALLOWED_EXTENSIONS:
|
| 316 |
continue
|
| 317 |
rel_file = f.relative_to(storage_root()).as_posix()
|
| 318 |
if _index_image_file(db, node, f, rel_file, stats):
|
| 319 |
stats["imagesIndexed"] += 1
|
| 320 |
+
since_commit += 1
|
| 321 |
+
if since_commit >= _COMMIT_BATCH_SIZE:
|
| 322 |
+
db.commit()
|
| 323 |
+
since_commit = 0
|
| 324 |
+
if since_commit:
|
| 325 |
+
db.commit()
|
| 326 |
continue
|
| 327 |
|
| 328 |
if child.name.lower() in _RESERVED_DIR_NAMES:
|
|
|
|
| 354 |
"imagesIndexed": 0,
|
| 355 |
"imagesUpdated": 0,
|
| 356 |
"imagesSkipped": 0,
|
| 357 |
+
"imagesNeedLocation": 0,
|
| 358 |
"orphansFlagged": 0,
|
| 359 |
"foldersSkipped": 0,
|
| 360 |
}
|
app/dda/tree/tree_service.py
CHANGED
|
@@ -173,6 +173,29 @@ def get_or_create_node(
|
|
| 173 |
raise
|
| 174 |
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
def rename_node(
|
| 177 |
db: Session,
|
| 178 |
node_id: int,
|
|
|
|
| 173 |
raise
|
| 174 |
|
| 175 |
|
| 176 |
+
def get_or_create_node_by_path(
|
| 177 |
+
db: Session,
|
| 178 |
+
path: str,
|
| 179 |
+
*,
|
| 180 |
+
created_by: str,
|
| 181 |
+
node_type: str = "Folder",
|
| 182 |
+
) -> TreeNode:
|
| 183 |
+
"""Walk a "/"-separated path from the root, creating any missing folder
|
| 184 |
+
segment along the way (mirrors the frontend's ensureFolderPath, used for
|
| 185 |
+
webkitdirectory uploads, but server-side — see bulk_upload_service.py)."""
|
| 186 |
+
segments = [s.strip() for s in (path or "").split("/") if s.strip()]
|
| 187 |
+
if not segments:
|
| 188 |
+
raise HTTPException(status_code=400, detail="Folder path is required")
|
| 189 |
+
parent_id: Optional[int] = None
|
| 190 |
+
node: Optional[TreeNode] = None
|
| 191 |
+
for segment in segments:
|
| 192 |
+
node, _created = get_or_create_node(
|
| 193 |
+
db, parent_id=parent_id, node_name=segment, node_type=node_type, created_by=created_by,
|
| 194 |
+
)
|
| 195 |
+
parent_id = node.id
|
| 196 |
+
return node
|
| 197 |
+
|
| 198 |
+
|
| 199 |
def rename_node(
|
| 200 |
db: Session,
|
| 201 |
node_id: int,
|
app/main.py
CHANGED
|
@@ -16,7 +16,7 @@ except ImportError:
|
|
| 16 |
from jose import JWTError, jwt
|
| 17 |
from sqlalchemy import text as sa_text
|
| 18 |
from fastapi import FastAPI, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
| 19 |
-
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
| 20 |
from fastapi.staticfiles import StaticFiles
|
| 21 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 22 |
from fastapi.exception_handlers import http_exception_handler as starlette_http_exception_handler
|
|
@@ -51,6 +51,7 @@ from .auth import (
|
|
| 51 |
mask_email,
|
| 52 |
normalize_phone,
|
| 53 |
record_login_event,
|
|
|
|
| 54 |
verify_login_otp,
|
| 55 |
verify_password,
|
| 56 |
)
|
|
@@ -116,21 +117,26 @@ app = FastAPI(title="AI Change Detection", version="2.4.0-dda" if IS_DDA_MODE el
|
|
| 116 |
setup_dda(app)
|
| 117 |
|
| 118 |
|
| 119 |
-
def
|
| 120 |
-
"""Best-effort user
|
| 121 |
try:
|
| 122 |
from .database import SessionLocal
|
|
|
|
| 123 |
token = request.cookies.get("access_token") or request.cookies.get(COOKIE_NAME)
|
| 124 |
if not token:
|
| 125 |
-
return None
|
| 126 |
db = SessionLocal()
|
| 127 |
try:
|
| 128 |
user = get_user_from_token(token, db)
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
finally:
|
| 131 |
db.close()
|
| 132 |
except Exception:
|
| 133 |
-
return None
|
| 134 |
|
| 135 |
|
| 136 |
@app.exception_handler(StarletteHTTPException)
|
|
@@ -142,10 +148,11 @@ async def _dda_http_exception_handler(request: Request, exc: StarletteHTTPExcept
|
|
| 142 |
from .dda.tree.audit_service import log_exception
|
| 143 |
db = SessionLocal()
|
| 144 |
try:
|
|
|
|
| 145 |
log_exception(
|
| 146 |
db, exc=exc, path=request.url.path, method=request.method,
|
| 147 |
status_code=exc.status_code, handled=True,
|
| 148 |
-
user_email=
|
| 149 |
)
|
| 150 |
finally:
|
| 151 |
db.close()
|
|
@@ -164,10 +171,11 @@ async def _dda_unhandled_exception_handler(request: Request, exc: Exception):
|
|
| 164 |
from .dda.tree.audit_service import log_exception
|
| 165 |
db = SessionLocal()
|
| 166 |
try:
|
|
|
|
| 167 |
log_exception(
|
| 168 |
db, exc=exc, path=request.url.path, method=request.method,
|
| 169 |
status_code=500, handled=False,
|
| 170 |
-
user_email=
|
| 171 |
)
|
| 172 |
finally:
|
| 173 |
db.close()
|
|
@@ -302,6 +310,18 @@ THUMB_MAX_SIZE = 200 # max width or height for history thumbnails
|
|
| 302 |
if STATIC_DIR.exists():
|
| 303 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 306 |
|
| 307 |
|
|
@@ -467,7 +487,7 @@ def login(data: UserLogin, request: Request, db: Session = Depends(get_db)):
|
|
| 467 |
user_id=user.id, ip_address=ip, user_agent=ua,
|
| 468 |
)
|
| 469 |
expires = REMEMBER_ME_EXPIRE if data.remember_me else SESSION_TOKEN_EXPIRE
|
| 470 |
-
token = create_access_token(data={"sub": str(user.id), "purpose": "session"}, expires_delta=expires)
|
| 471 |
return _auth_response(token, user, remember=data.remember_me)
|
| 472 |
except HTTPException:
|
| 473 |
raise
|
|
@@ -508,7 +528,8 @@ def request_login_otp(data: OtpRequest, request: Request, db: Session = Depends(
|
|
| 508 |
|
| 509 |
@app.post("/api/auth/logout")
|
| 510 |
def logout(request: Request, db: Session = Depends(get_db)):
|
| 511 |
-
"""Record the logout,
|
|
|
|
| 512 |
token = request.cookies.get(COOKIE_NAME)
|
| 513 |
if token:
|
| 514 |
user = get_user_from_token(token, db)
|
|
@@ -517,6 +538,8 @@ def logout(request: Request, db: Session = Depends(get_db)):
|
|
| 517 |
db, event_type="logout", attempted_email=user.email, user_id=user.id,
|
| 518 |
ip_address=get_client_ip(request), user_agent=get_user_agent(request),
|
| 519 |
)
|
|
|
|
|
|
|
| 520 |
response = JSONResponse(content={"ok": True})
|
| 521 |
response.delete_cookie(
|
| 522 |
COOKIE_NAME,
|
|
@@ -541,6 +564,7 @@ def reset_password(data: PasswordReset, db: Session = Depends(get_db)):
|
|
| 541 |
# Intentionally vague to prevent email enumeration
|
| 542 |
raise HTTPException(status_code=404, detail="No account found with that email")
|
| 543 |
user.hashed_password = get_password_hash(data.new_password)
|
|
|
|
| 544 |
db.commit()
|
| 545 |
return {"ok": True, "message": "Password has been reset. You can now sign in."}
|
| 546 |
|
|
@@ -585,6 +609,7 @@ def reset_password_with_token(data: ResetPasswordWithToken, db: Session = Depend
|
|
| 585 |
if not user:
|
| 586 |
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
| 587 |
user.hashed_password = get_password_hash(data.new_password)
|
|
|
|
| 588 |
db.commit()
|
| 589 |
return {"ok": True, "message": "Password has been reset. You can now sign in."}
|
| 590 |
|
|
@@ -648,6 +673,7 @@ def change_password(data: ChangePasswordRequest, user: Optional[User] = Depends(
|
|
| 648 |
if len(data.new_password) < 8:
|
| 649 |
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
| 650 |
user.hashed_password = get_password_hash(data.new_password)
|
|
|
|
| 651 |
db.commit()
|
| 652 |
return {"ok": True, "message": "Password changed."}
|
| 653 |
|
|
@@ -726,7 +752,7 @@ def verify_login_otp_route(data: OtpVerifyRequest, request: Request, db: Session
|
|
| 726 |
user_id=user.id, ip_address=ip, user_agent=ua,
|
| 727 |
)
|
| 728 |
expires = REMEMBER_ME_EXPIRE if remember else SESSION_TOKEN_EXPIRE
|
| 729 |
-
token = create_access_token(data={"sub": str(user.id), "purpose": "session"}, expires_delta=expires)
|
| 730 |
return _auth_response(token, user, remember=remember)
|
| 731 |
|
| 732 |
|
|
@@ -810,9 +836,8 @@ async def detect(
|
|
| 810 |
|
| 811 |
before_pil = _read_upload(before, "before")
|
| 812 |
after_pil = _read_upload(after, "after")
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
min_region_area = int(max(50, min(10000, min_region_area)))
|
| 816 |
|
| 817 |
from .detection_engine import run_detection
|
| 818 |
change_mask, result_image, stats, change_regions = run_detection(
|
|
@@ -897,6 +922,8 @@ async def detect(
|
|
| 897 |
before_thumb_path=relative_before_thumb,
|
| 898 |
after_thumb_path=relative_after_thumb,
|
| 899 |
regions_json=json.dumps(regions_serializable),
|
|
|
|
|
|
|
| 900 |
)
|
| 901 |
db.add(run)
|
| 902 |
db.commit()
|
|
@@ -966,7 +993,12 @@ def notify_test(
|
|
| 966 |
|
| 967 |
|
| 968 |
@app.get("/api/overlay/{path:path}")
|
| 969 |
-
def serve_overlay(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
# Restrict to overlays directory
|
| 971 |
full = (OVERLAYS_DIR.parent / path).resolve()
|
| 972 |
base = OVERLAYS_DIR.parent.resolve()
|
|
@@ -1014,6 +1046,32 @@ def serve_overlay(path: str, request: Request, db: Session = Depends(get_db)):
|
|
| 1014 |
if owner_id_str.isdigit() and int(owner_id_str) != user.id:
|
| 1015 |
raise HTTPException(404)
|
| 1016 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1017 |
return FileResponse(full, media_type="image/png")
|
| 1018 |
|
| 1019 |
|
|
@@ -1023,6 +1081,8 @@ def history(
|
|
| 1023 |
request: Request,
|
| 1024 |
db: Session = Depends(get_db),
|
| 1025 |
limit: int = Query(100, ge=1, le=200),
|
|
|
|
|
|
|
| 1026 |
):
|
| 1027 |
if IS_DDA_MODE:
|
| 1028 |
from .dda.dda_auth import get_dda_user
|
|
@@ -1042,11 +1102,13 @@ def history(
|
|
| 1042 |
filt = DetectionRun.user_id == user.id
|
| 1043 |
if auto_ids:
|
| 1044 |
filt = or_(filt, DetectionRun.id.in_(auto_ids))
|
| 1045 |
-
|
| 1046 |
else:
|
| 1047 |
user = get_or_create_guest_user(db)
|
| 1048 |
-
|
| 1049 |
-
|
|
|
|
|
|
|
| 1050 |
{
|
| 1051 |
"id": r.id,
|
| 1052 |
"title": r.title,
|
|
@@ -1064,6 +1126,9 @@ def history(
|
|
| 1064 |
}
|
| 1065 |
for r in runs
|
| 1066 |
]
|
|
|
|
|
|
|
|
|
|
| 1067 |
|
| 1068 |
|
| 1069 |
@app.get("/api/history/{run_id}")
|
|
@@ -1204,16 +1269,18 @@ def delete_run(
|
|
| 1204 |
# --- Serve SPA ---
|
| 1205 |
@app.get("/dda/reports/{run_id}", response_class=HTMLResponse)
|
| 1206 |
def dda_report_page(run_id: int, request: Request, db: Session = Depends(get_db)):
|
| 1207 |
-
"""Standalone browser report (FR-05)."""
|
| 1208 |
if not IS_DDA_MODE:
|
| 1209 |
raise HTTPException(status_code=404, detail="Not found")
|
| 1210 |
_user, redirect = _dda_session_or_redirect(request, db)
|
| 1211 |
if redirect:
|
| 1212 |
return redirect
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
|
|
|
|
|
|
| 1217 |
|
| 1218 |
|
| 1219 |
# no-store on every auth page below: FileResponse otherwise sends
|
|
|
|
| 16 |
from jose import JWTError, jwt
|
| 17 |
from sqlalchemy import text as sa_text
|
| 18 |
from fastapi import FastAPI, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
| 19 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
| 20 |
from fastapi.staticfiles import StaticFiles
|
| 21 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 22 |
from fastapi.exception_handlers import http_exception_handler as starlette_http_exception_handler
|
|
|
|
| 51 |
mask_email,
|
| 52 |
normalize_phone,
|
| 53 |
record_login_event,
|
| 54 |
+
revoke_user_sessions,
|
| 55 |
verify_login_otp,
|
| 56 |
verify_password,
|
| 57 |
)
|
|
|
|
| 117 |
setup_dda(app)
|
| 118 |
|
| 119 |
|
| 120 |
+
def _exception_log_user(request: Request) -> tuple[str | None, str | None]:
|
| 121 |
+
"""Best-effort user email + role for the exception log — never raises."""
|
| 122 |
try:
|
| 123 |
from .database import SessionLocal
|
| 124 |
+
from .dda.dda_auth import get_user_role
|
| 125 |
token = request.cookies.get("access_token") or request.cookies.get(COOKIE_NAME)
|
| 126 |
if not token:
|
| 127 |
+
return None, None
|
| 128 |
db = SessionLocal()
|
| 129 |
try:
|
| 130 |
user = get_user_from_token(token, db)
|
| 131 |
+
if not user:
|
| 132 |
+
return None, None
|
| 133 |
+
email = user.email if user else None
|
| 134 |
+
role = get_user_role(db, user) if user else None
|
| 135 |
+
return email, role
|
| 136 |
finally:
|
| 137 |
db.close()
|
| 138 |
except Exception:
|
| 139 |
+
return None, None
|
| 140 |
|
| 141 |
|
| 142 |
@app.exception_handler(StarletteHTTPException)
|
|
|
|
| 148 |
from .dda.tree.audit_service import log_exception
|
| 149 |
db = SessionLocal()
|
| 150 |
try:
|
| 151 |
+
email, role = _exception_log_user(request)
|
| 152 |
log_exception(
|
| 153 |
db, exc=exc, path=request.url.path, method=request.method,
|
| 154 |
status_code=exc.status_code, handled=True,
|
| 155 |
+
user_email=email, user_role=role,
|
| 156 |
)
|
| 157 |
finally:
|
| 158 |
db.close()
|
|
|
|
| 171 |
from .dda.tree.audit_service import log_exception
|
| 172 |
db = SessionLocal()
|
| 173 |
try:
|
| 174 |
+
email, role = _exception_log_user(request)
|
| 175 |
log_exception(
|
| 176 |
db, exc=exc, path=request.url.path, method=request.method,
|
| 177 |
status_code=500, handled=False,
|
| 178 |
+
user_email=email, user_role=role,
|
| 179 |
)
|
| 180 |
finally:
|
| 181 |
db.close()
|
|
|
|
| 310 |
if STATIC_DIR.exists():
|
| 311 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 312 |
|
| 313 |
+
|
| 314 |
+
@app.middleware("http")
|
| 315 |
+
async def _static_cache_control(request: Request, call_next):
|
| 316 |
+
"""Long-lived cache for versioned /static assets (?v= busting on script tags)."""
|
| 317 |
+
response = await call_next(request)
|
| 318 |
+
if request.url.path.startswith("/static/"):
|
| 319 |
+
response.headers.setdefault(
|
| 320 |
+
"Cache-Control",
|
| 321 |
+
"public, max-age=31536000, immutable",
|
| 322 |
+
)
|
| 323 |
+
return response
|
| 324 |
+
|
| 325 |
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 326 |
|
| 327 |
|
|
|
|
| 487 |
user_id=user.id, ip_address=ip, user_agent=ua,
|
| 488 |
)
|
| 489 |
expires = REMEMBER_ME_EXPIRE if data.remember_me else SESSION_TOKEN_EXPIRE
|
| 490 |
+
token = create_access_token(data={"sub": str(user.id), "purpose": "session", "tv": int(user.token_version or 0)}, expires_delta=expires)
|
| 491 |
return _auth_response(token, user, remember=data.remember_me)
|
| 492 |
except HTTPException:
|
| 493 |
raise
|
|
|
|
| 528 |
|
| 529 |
@app.post("/api/auth/logout")
|
| 530 |
def logout(request: Request, db: Session = Depends(get_db)):
|
| 531 |
+
"""Record the logout, revoke the session token (and any other copy of it —
|
| 532 |
+
another tab, another device, or a leaked token), then clear the cookie."""
|
| 533 |
token = request.cookies.get(COOKIE_NAME)
|
| 534 |
if token:
|
| 535 |
user = get_user_from_token(token, db)
|
|
|
|
| 538 |
db, event_type="logout", attempted_email=user.email, user_id=user.id,
|
| 539 |
ip_address=get_client_ip(request), user_agent=get_user_agent(request),
|
| 540 |
)
|
| 541 |
+
revoke_user_sessions(user)
|
| 542 |
+
db.commit()
|
| 543 |
response = JSONResponse(content={"ok": True})
|
| 544 |
response.delete_cookie(
|
| 545 |
COOKIE_NAME,
|
|
|
|
| 564 |
# Intentionally vague to prevent email enumeration
|
| 565 |
raise HTTPException(status_code=404, detail="No account found with that email")
|
| 566 |
user.hashed_password = get_password_hash(data.new_password)
|
| 567 |
+
revoke_user_sessions(user)
|
| 568 |
db.commit()
|
| 569 |
return {"ok": True, "message": "Password has been reset. You can now sign in."}
|
| 570 |
|
|
|
|
| 609 |
if not user:
|
| 610 |
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
| 611 |
user.hashed_password = get_password_hash(data.new_password)
|
| 612 |
+
revoke_user_sessions(user)
|
| 613 |
db.commit()
|
| 614 |
return {"ok": True, "message": "Password has been reset. You can now sign in."}
|
| 615 |
|
|
|
|
| 673 |
if len(data.new_password) < 8:
|
| 674 |
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
| 675 |
user.hashed_password = get_password_hash(data.new_password)
|
| 676 |
+
revoke_user_sessions(user)
|
| 677 |
db.commit()
|
| 678 |
return {"ok": True, "message": "Password changed."}
|
| 679 |
|
|
|
|
| 752 |
user_id=user.id, ip_address=ip, user_agent=ua,
|
| 753 |
)
|
| 754 |
expires = REMEMBER_ME_EXPIRE if remember else SESSION_TOKEN_EXPIRE
|
| 755 |
+
token = create_access_token(data={"sub": str(user.id), "purpose": "session", "tv": int(user.token_version or 0)}, expires_delta=expires)
|
| 756 |
return _auth_response(token, user, remember=remember)
|
| 757 |
|
| 758 |
|
|
|
|
| 836 |
|
| 837 |
before_pil = _read_upload(before, "before")
|
| 838 |
after_pil = _read_upload(after, "after")
|
| 839 |
+
from .dda.detect_service import clamp_detection_params
|
| 840 |
+
detection_sensitivity, min_region_area = clamp_detection_params(detection_sensitivity, min_region_area)
|
|
|
|
| 841 |
|
| 842 |
from .detection_engine import run_detection
|
| 843 |
change_mask, result_image, stats, change_regions = run_detection(
|
|
|
|
| 922 |
before_thumb_path=relative_before_thumb,
|
| 923 |
after_thumb_path=relative_after_thumb,
|
| 924 |
regions_json=json.dumps(regions_serializable),
|
| 925 |
+
detection_sensitivity=detection_sensitivity,
|
| 926 |
+
min_region_area=min_region_area,
|
| 927 |
)
|
| 928 |
db.add(run)
|
| 929 |
db.commit()
|
|
|
|
| 993 |
|
| 994 |
|
| 995 |
@app.get("/api/overlay/{path:path}")
|
| 996 |
+
def serve_overlay(
|
| 997 |
+
path: str,
|
| 998 |
+
request: Request,
|
| 999 |
+
db: Session = Depends(get_db),
|
| 1000 |
+
max_side: int | None = Query(None, ge=64, le=4096),
|
| 1001 |
+
):
|
| 1002 |
# Restrict to overlays directory
|
| 1003 |
full = (OVERLAYS_DIR.parent / path).resolve()
|
| 1004 |
base = OVERLAYS_DIR.parent.resolve()
|
|
|
|
| 1046 |
if owner_id_str.isdigit() and int(owner_id_str) != user.id:
|
| 1047 |
raise HTTPException(404)
|
| 1048 |
|
| 1049 |
+
# Browser viewers choke on ~50MB full overlays; downscale + cache when requested.
|
| 1050 |
+
if max_side:
|
| 1051 |
+
cache = full.with_name(f"{full.stem}_ms{max_side}{full.suffix}")
|
| 1052 |
+
try:
|
| 1053 |
+
if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
|
| 1054 |
+
return FileResponse(cache, media_type="image/png")
|
| 1055 |
+
from PIL import Image
|
| 1056 |
+
with Image.open(full) as im:
|
| 1057 |
+
if im.mode not in ("RGB", "RGBA"):
|
| 1058 |
+
im = im.convert("RGBA")
|
| 1059 |
+
w, h = im.size
|
| 1060 |
+
longest = max(w, h)
|
| 1061 |
+
if longest > max_side:
|
| 1062 |
+
scale = max_side / float(longest)
|
| 1063 |
+
im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.Resampling.LANCZOS)
|
| 1064 |
+
buf = io.BytesIO()
|
| 1065 |
+
im.save(buf, format="PNG", optimize=True)
|
| 1066 |
+
payload = buf.getvalue()
|
| 1067 |
+
try:
|
| 1068 |
+
cache.write_bytes(payload)
|
| 1069 |
+
except Exception:
|
| 1070 |
+
pass
|
| 1071 |
+
return Response(content=payload, media_type="image/png")
|
| 1072 |
+
except Exception:
|
| 1073 |
+
pass
|
| 1074 |
+
|
| 1075 |
return FileResponse(full, media_type="image/png")
|
| 1076 |
|
| 1077 |
|
|
|
|
| 1081 |
request: Request,
|
| 1082 |
db: Session = Depends(get_db),
|
| 1083 |
limit: int = Query(100, ge=1, le=200),
|
| 1084 |
+
offset: int = Query(0, ge=0),
|
| 1085 |
+
paged: int = Query(0, ge=0, le=1),
|
| 1086 |
):
|
| 1087 |
if IS_DDA_MODE:
|
| 1088 |
from .dda.dda_auth import get_dda_user
|
|
|
|
| 1102 |
filt = DetectionRun.user_id == user.id
|
| 1103 |
if auto_ids:
|
| 1104 |
filt = or_(filt, DetectionRun.id.in_(auto_ids))
|
| 1105 |
+
query = db.query(DetectionRun).filter(filt)
|
| 1106 |
else:
|
| 1107 |
user = get_or_create_guest_user(db)
|
| 1108 |
+
query = db.query(DetectionRun).filter(DetectionRun.user_id == user.id)
|
| 1109 |
+
total = query.count() if paged else None
|
| 1110 |
+
runs = query.order_by(DetectionRun.created_at.desc()).offset(offset).limit(limit).all()
|
| 1111 |
+
items = [
|
| 1112 |
{
|
| 1113 |
"id": r.id,
|
| 1114 |
"title": r.title,
|
|
|
|
| 1126 |
}
|
| 1127 |
for r in runs
|
| 1128 |
]
|
| 1129 |
+
if paged:
|
| 1130 |
+
return {"items": items, "total": int(total or 0), "offset": offset, "limit": limit}
|
| 1131 |
+
return items
|
| 1132 |
|
| 1133 |
|
| 1134 |
@app.get("/api/history/{run_id}")
|
|
|
|
| 1269 |
# --- Serve SPA ---
|
| 1270 |
@app.get("/dda/reports/{run_id}", response_class=HTMLResponse)
|
| 1271 |
def dda_report_page(run_id: int, request: Request, db: Session = Depends(get_db)):
|
| 1272 |
+
"""Standalone browser report (FR-05) with interactive viewer."""
|
| 1273 |
if not IS_DDA_MODE:
|
| 1274 |
raise HTTPException(status_code=404, detail="Not found")
|
| 1275 |
_user, redirect = _dda_session_or_redirect(request, db)
|
| 1276 |
if redirect:
|
| 1277 |
return redirect
|
| 1278 |
+
return templates.TemplateResponse(
|
| 1279 |
+
request,
|
| 1280 |
+
"report_dda.html",
|
| 1281 |
+
{"run_id": run_id, "active_page": "reports", "menu_items": []},
|
| 1282 |
+
headers={"Cache-Control": "no-store"},
|
| 1283 |
+
)
|
| 1284 |
|
| 1285 |
|
| 1286 |
# no-store on every auth page below: FileResponse otherwise sends
|
app/models.py
CHANGED
|
@@ -29,6 +29,11 @@ class User(Base):
|
|
| 29 |
# accounts can bind a number on first successful OTP verify.
|
| 30 |
phone = Column(String(20), unique=True, nullable=True, index=True)
|
| 31 |
created_at = Column(DateTime, default=_utcnow)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
|
| 34 |
role_obj = relationship("Role", foreign_keys=[role_id])
|
|
@@ -53,6 +58,11 @@ class DetectionRun(Base):
|
|
| 53 |
zone = Column(String(128), default="")
|
| 54 |
village = Column(String(128), default="")
|
| 55 |
regions_json = Column(Text, default="[]")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
created_at = Column(DateTime, default=_utcnow)
|
| 57 |
|
| 58 |
user = relationship("User", back_populates="detections")
|
|
|
|
| 29 |
# accounts can bind a number on first successful OTP verify.
|
| 30 |
phone = Column(String(20), unique=True, nullable=True, index=True)
|
| 31 |
created_at = Column(DateTime, default=_utcnow)
|
| 32 |
+
# Bumped on logout and on any password change/reset so previously-issued
|
| 33 |
+
# JWTs (a copy in another tab, or a leaked token) stop authenticating
|
| 34 |
+
# immediately instead of staying valid until natural expiry — see
|
| 35 |
+
# create_access_token()/get_user_from_token() in auth.py.
|
| 36 |
+
token_version = Column(Integer, default=0, nullable=False)
|
| 37 |
|
| 38 |
detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
|
| 39 |
role_obj = relationship("Role", foreign_keys=[role_id])
|
|
|
|
| 58 |
zone = Column(String(128), default="")
|
| 59 |
village = Column(String(128), default="")
|
| 60 |
regions_json = Column(Text, default="[]")
|
| 61 |
+
# The Sensitivity / Minimum Region Area values actually used for this run
|
| 62 |
+
# (post-clamp — see detect_service.clamp_detection_params) — nullable so
|
| 63 |
+
# runs created before this column existed just show as blank, not 0.
|
| 64 |
+
detection_sensitivity = Column(Float, nullable=True)
|
| 65 |
+
min_region_area = Column(Integer, nullable=True)
|
| 66 |
created_at = Column(DateTime, default=_utcnow)
|
| 67 |
|
| 68 |
user = relationship("User", back_populates="detections")
|
requirements.txt
CHANGED
|
@@ -24,3 +24,4 @@ reportlab>=4.0.0
|
|
| 24 |
email-validator>=2.0.0
|
| 25 |
pyotp>=2.9.0
|
| 26 |
qrcode>=7.4.0
|
|
|
|
|
|
| 24 |
email-validator>=2.0.0
|
| 25 |
pyotp>=2.9.0
|
| 26 |
qrcode>=7.4.0
|
| 27 |
+
openpyxl>=3.1.0
|
scripts/test_area_groups.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Same-area pairing:
|
| 2 |
import sys
|
| 3 |
from datetime import datetime
|
| 4 |
from pathlib import Path
|
|
@@ -6,11 +6,25 @@ from pathlib import Path
|
|
| 6 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 7 |
|
| 8 |
from app.dda.tree.area_groups import (
|
|
|
|
| 9 |
build_area_groups,
|
|
|
|
| 10 |
distinct_places,
|
| 11 |
parse_datetime,
|
| 12 |
)
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
def test_mysql_datetime_and_tiff_strings_parse():
|
| 16 |
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():
|
|
| 19 |
|
| 20 |
|
| 21 |
def test_seq_pairs_stay_separate_despite_overlap():
|
| 22 |
-
bounds = {"west": 77.0, "south": 28.0, "east": 77.2, "north": 28.2}
|
| 23 |
images = [
|
| 24 |
-
{"id": 1, "filename": "before3.tif", "path": "a/before3.tif", "captureDate": "2025-07-24", "bounds":
|
| 25 |
-
{"id": 2, "filename": "after3.tif", "path": "a/after3.tif", "captureDate": "2026-07-31", "bounds":
|
| 26 |
-
{"id": 3, "filename": "before7.tif", "path": "a/before7.tif", "captureDate": "2026-07-31", "bounds":
|
| 27 |
-
{"id": 4, "filename": "after7.tif", "path": "a/after7.tif", "captureDate": "2026-07-31", "bounds":
|
| 28 |
]
|
| 29 |
payload = build_area_groups(images)
|
| 30 |
labels = sorted(g["label"] for g in payload["groups"])
|
|
@@ -58,9 +71,212 @@ def test_distinct_places_grid_vs_sheet():
|
|
| 58 |
assert distinct_places({"seq:1"}, set()) is False
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
if __name__ == "__main__":
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
print("ok")
|
|
|
|
| 1 |
+
"""Same-area pairing: metadata comparison, exactly two images, no reuse."""
|
| 2 |
import sys
|
| 3 |
from datetime import datetime
|
| 4 |
from pathlib import Path
|
|
|
|
| 6 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 7 |
|
| 8 |
from app.dda.tree.area_groups import (
|
| 9 |
+
LocationIndex,
|
| 10 |
build_area_groups,
|
| 11 |
+
conflicting_keys,
|
| 12 |
distinct_places,
|
| 13 |
parse_datetime,
|
| 14 |
)
|
| 15 |
|
| 16 |
+
DELHI = LocationIndex(
|
| 17 |
+
zones=[(1, "Central Delhi"), (2, "North Delhi")],
|
| 18 |
+
villages=[(5, "Daryaganj", 1), (6, "Paharganj", 1), (7, "Model Town", 1), (8, "Model Town", 2)],
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
BOUNDS = {"west": 77.0, "south": 28.0, "east": 77.2, "north": 28.2}
|
| 22 |
+
ELSEWHERE = {"west": 78.0, "south": 29.0, "east": 78.2, "north": 29.2}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _paths(group):
|
| 26 |
+
return [img["path"] for img in group["images"]]
|
| 27 |
+
|
| 28 |
|
| 29 |
def test_mysql_datetime_and_tiff_strings_parse():
|
| 30 |
assert parse_datetime("2025-03-01 14:30:00") == datetime(2025, 3, 1, 14, 30, 0)
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def test_seq_pairs_stay_separate_despite_overlap():
|
|
|
|
| 36 |
images = [
|
| 37 |
+
{"id": 1, "filename": "before3.tif", "path": "a/before3.tif", "captureDate": "2025-07-24", "bounds": BOUNDS},
|
| 38 |
+
{"id": 2, "filename": "after3.tif", "path": "a/after3.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
|
| 39 |
+
{"id": 3, "filename": "before7.tif", "path": "a/before7.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
|
| 40 |
+
{"id": 4, "filename": "after7.tif", "path": "a/after7.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
|
| 41 |
]
|
| 42 |
payload = build_area_groups(images)
|
| 43 |
labels = sorted(g["label"] for g in payload["groups"])
|
|
|
|
| 71 |
assert distinct_places({"seq:1"}, set()) is False
|
| 72 |
|
| 73 |
|
| 74 |
+
def test_conflicting_keys_only_within_one_vocabulary():
|
| 75 |
+
# Same vocabulary, different value: never the same place.
|
| 76 |
+
assert conflicting_keys({"seq:3"}, {"seq:7"}) is True
|
| 77 |
+
assert conflicting_keys({"grid:54"}, {"grid:60"}) is True
|
| 78 |
+
# Different vocabularies cannot contradict each other.
|
| 79 |
+
assert conflicting_keys({"grid:54"}, {"sheet:h43x2e1"}) is False
|
| 80 |
+
assert conflicting_keys({"grid:54"}, set()) is False
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_strong_overlap_beats_mismatched_naming_schemes():
|
| 84 |
+
"""A grid number and a survey sheet id can name the same place."""
|
| 85 |
+
images = [
|
| 86 |
+
{"id": 1, "filename": "Grid_54.tif", "path": "a/Grid_54.tif", "captureDate": "2025-06-11", "bounds": BOUNDS},
|
| 87 |
+
{"id": 2, "filename": "H43X2E1.tif", "path": "a/H43X2E1.tif", "captureDate": "2025-06-27", "bounds": BOUNDS},
|
| 88 |
+
]
|
| 89 |
+
payload = build_area_groups(images)
|
| 90 |
+
assert len(payload["groups"]) == 1
|
| 91 |
+
assert payload["groups"][0]["match"] == "bounds"
|
| 92 |
+
assert payload["unpaired"] == []
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_georeferenced_images_far_apart_never_pair():
|
| 96 |
+
images = [
|
| 97 |
+
{"id": 1, "filename": "grid_9_before.tif", "path": "a/grid_9_before.tif", "captureDate": "2025-01-01", "bounds": BOUNDS},
|
| 98 |
+
{"id": 2, "filename": "grid_9_after.tif", "path": "a/grid_9_after.tif", "captureDate": "2026-01-01", "bounds": ELSEWHERE},
|
| 99 |
+
]
|
| 100 |
+
payload = build_area_groups(images)
|
| 101 |
+
assert payload["groups"] == []
|
| 102 |
+
assert len(payload["unpaired"]) == 2
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_shared_location_cannot_rescue_a_weak_overlap():
|
| 106 |
+
"""When both footprints are known, the map decides, not the folder."""
|
| 107 |
+
barely = {"west": 77.19, "south": 28.19, "east": 77.40, "north": 28.40}
|
| 108 |
+
images = [
|
| 109 |
+
{"id": 1, "filename": "B2_ORI.tif", "path": "a/B2_ORI.tif", "nodeId": 1,
|
| 110 |
+
"nodePath": "Central Delhi/Daryaganj", "captureDate": "2025-01-01", "bounds": BOUNDS},
|
| 111 |
+
{"id": 2, "filename": "B3_ORI_CLIP.tif", "path": "a/B3_ORI_CLIP.tif", "nodeId": 1,
|
| 112 |
+
"nodePath": "Central Delhi/Daryaganj", "captureDate": "2026-01-01", "bounds": barely},
|
| 113 |
+
]
|
| 114 |
+
assert build_area_groups(images, DELHI)["groups"] == []
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_folder_location_cannot_veto_a_clear_overlap():
|
| 118 |
+
"""A file simply parked under 'Unassigned' still pairs with its own place."""
|
| 119 |
+
images = [
|
| 120 |
+
{"id": 1, "filename": "H43X2E2.tif", "path": "a/H43X2E2.tif",
|
| 121 |
+
"nodePath": "Central Delhi/Daryaganj", "captureDate": "2025-01-01", "bounds": BOUNDS},
|
| 122 |
+
{"id": 2, "filename": "H43X2E2_ORI.tif", "path": "b/H43X2E2_ORI.tif",
|
| 123 |
+
"nodePath": "Unassigned/Legacy/2025", "captureDate": "2026-01-01", "bounds": BOUNDS},
|
| 124 |
+
]
|
| 125 |
+
assert len(build_area_groups(images, DELHI)["groups"]) == 1
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_different_villages_never_pair():
|
| 129 |
+
images = [
|
| 130 |
+
{"id": 1, "filename": "site.tif", "path": "a/site.tif", "captureDate": "2025-01-01",
|
| 131 |
+
"bounds": BOUNDS, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
|
| 132 |
+
{"id": 2, "filename": "site_new.tif", "path": "a/site_new.tif", "captureDate": "2026-01-01",
|
| 133 |
+
"bounds": BOUNDS, "zoneId": 1, "villageId": 6, "villageName": "Paharganj"},
|
| 134 |
+
]
|
| 135 |
+
payload = build_area_groups(images)
|
| 136 |
+
assert payload["groups"] == []
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_village_pairs_images_that_have_no_map_extent():
|
| 140 |
+
images = [
|
| 141 |
+
{"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
|
| 142 |
+
"bounds": None, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh", "zoneName": "Central Delhi"},
|
| 143 |
+
{"id": 2, "filename": "scan_b.png", "path": "a/scan_b.png", "captureDate": "2026-01-01",
|
| 144 |
+
"bounds": None, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh", "zoneName": "Central Delhi"},
|
| 145 |
+
]
|
| 146 |
+
payload = build_area_groups(images)
|
| 147 |
+
assert len(payload["groups"]) == 1
|
| 148 |
+
g = payload["groups"][0]
|
| 149 |
+
assert g["match"] == "location"
|
| 150 |
+
assert any("Karol Bagh" in reason for reason in g["reasons"])
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_unrelated_images_in_one_folder_do_not_pair():
|
| 154 |
+
"""Folder and capture date alone are not evidence of the same place."""
|
| 155 |
+
images = [
|
| 156 |
+
{"id": 1, "filename": "alpha.png", "path": "a/alpha.png", "nodeId": 1,
|
| 157 |
+
"nodePath": "Delhi", "captureDate": "2025-01-01", "bounds": None},
|
| 158 |
+
{"id": 2, "filename": "omega.png", "path": "a/omega.png", "nodeId": 1,
|
| 159 |
+
"nodePath": "Delhi", "captureDate": "2026-01-01", "bounds": None},
|
| 160 |
+
]
|
| 161 |
+
payload = build_area_groups(images)
|
| 162 |
+
assert payload["groups"] == []
|
| 163 |
+
assert len(payload["unpaired"]) == 2
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def test_pairs_hold_two_images_and_never_reuse_one():
|
| 167 |
+
"""Five dates of one place become two pairs plus one leftover."""
|
| 168 |
+
images = [
|
| 169 |
+
{"id": i, "filename": f"Grid_54_{2021 + i}.tif", "path": f"a/Grid_54_{2021 + i}.tif",
|
| 170 |
+
"nodeId": 1, "nodePath": "Delhi", "captureDate": f"{2021 + i}-04-01", "bounds": BOUNDS}
|
| 171 |
+
for i in range(5)
|
| 172 |
+
]
|
| 173 |
+
payload = build_area_groups(images)
|
| 174 |
+
assert len(payload["groups"]) == 2
|
| 175 |
+
assert payload["pairedImages"] == 4
|
| 176 |
+
assert len(payload["unpaired"]) == 1
|
| 177 |
+
|
| 178 |
+
seen = set()
|
| 179 |
+
for group in payload["groups"]:
|
| 180 |
+
assert len(group["images"]) == 2
|
| 181 |
+
for path in _paths(group):
|
| 182 |
+
assert path not in seen, f"{path} was used by two pairs"
|
| 183 |
+
seen.add(path)
|
| 184 |
+
ids = [g["id"] for g in payload["groups"]]
|
| 185 |
+
assert len(ids) == len(set(ids))
|
| 186 |
+
# The whole cluster stays available for a manual swap.
|
| 187 |
+
assert len(payload["groups"][0]["candidates"]) == 5
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_folder_names_supply_the_location():
|
| 191 |
+
"""Images indexed from disk get their zone/village from the folder tree."""
|
| 192 |
+
images = [
|
| 193 |
+
{"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
|
| 194 |
+
"bounds": None, "nodePath": "Central Delhi/Daryaganj"},
|
| 195 |
+
{"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
|
| 196 |
+
"bounds": None, "nodePath": "Central Delhi/Daryaganj"},
|
| 197 |
+
]
|
| 198 |
+
payload = build_area_groups(images, DELHI)
|
| 199 |
+
assert len(payload["groups"]) == 1
|
| 200 |
+
g = payload["groups"][0]
|
| 201 |
+
assert any("Daryaganj" in reason for reason in g["reasons"])
|
| 202 |
+
assert g["images"][0]["villageName"] == "Daryaganj"
|
| 203 |
+
# Without the masters there is no location to compare, so nothing pairs.
|
| 204 |
+
assert build_area_groups(images)["groups"] == []
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_folder_location_never_crosses_zones():
|
| 208 |
+
images = [
|
| 209 |
+
{"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
|
| 210 |
+
"bounds": None, "nodePath": "Central Delhi/Daryaganj"},
|
| 211 |
+
{"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
|
| 212 |
+
"bounds": None, "nodePath": "North Delhi/Model Town"},
|
| 213 |
+
]
|
| 214 |
+
assert build_area_groups(images, DELHI)["groups"] == []
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_ambiguous_village_name_is_left_unresolved():
|
| 218 |
+
"""'Model Town' exists in two zones, so the folder alone proves nothing."""
|
| 219 |
+
images = [
|
| 220 |
+
{"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
|
| 221 |
+
"bounds": None, "nodePath": "Model Town"},
|
| 222 |
+
{"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
|
| 223 |
+
"bounds": None, "nodePath": "Model Town"},
|
| 224 |
+
]
|
| 225 |
+
assert build_area_groups(images, DELHI)["groups"] == []
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_named_before_after_wins_over_same_day_timestamps():
|
| 229 |
+
"""An 'after' uploaded a minute earlier must not become the Before image."""
|
| 230 |
+
images = [
|
| 231 |
+
{"id": 1, "filename": "after5.tif", "path": "a/after5.tif",
|
| 232 |
+
"captureDate": "2026-07-31T09:00:00", "bounds": BOUNDS},
|
| 233 |
+
{"id": 2, "filename": "before5.tif", "path": "a/before5.tif",
|
| 234 |
+
"captureDate": "2026-07-31T10:00:00", "bounds": BOUNDS},
|
| 235 |
+
]
|
| 236 |
+
g = build_area_groups(images)["groups"][0]
|
| 237 |
+
assert g["suggestedBefore"]["filename"] == "before5.tif"
|
| 238 |
+
assert g["suggestedAfter"]["filename"] == "after5.tif"
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_reasons_name_the_metadata_that_matched():
|
| 242 |
+
images = [
|
| 243 |
+
{"id": 1, "filename": "Grid_54.tif", "path": "Delhi/Grid_54.tif", "nodeId": 1,
|
| 244 |
+
"nodePath": "Delhi", "captureDate": "2024-01-01", "bounds": BOUNDS,
|
| 245 |
+
"zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
|
| 246 |
+
{"id": 2, "filename": "Grid_54_new.tif", "path": "Delhi/Grid_54_new.tif", "nodeId": 1,
|
| 247 |
+
"nodePath": "Delhi", "captureDate": "2026-01-01", "bounds": BOUNDS,
|
| 248 |
+
"zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
|
| 249 |
+
]
|
| 250 |
+
reasons = build_area_groups(images)["groups"][0]["reasons"]
|
| 251 |
+
joined = " | ".join(reasons)
|
| 252 |
+
assert "map overlap" in joined
|
| 253 |
+
assert "grid 54" in joined
|
| 254 |
+
assert "Karol Bagh" in joined
|
| 255 |
+
assert "same folder" in joined
|
| 256 |
+
assert "apart" in joined
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def test_three_images_become_one_pair_and_one_unpaired():
|
| 260 |
+
"""Automatic pairing never emits a 3-image group."""
|
| 261 |
+
images = [
|
| 262 |
+
{"id": 1, "filename": "site_2024.png", "path": "a/site_2024.png",
|
| 263 |
+
"captureDate": "2024-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
|
| 264 |
+
{"id": 2, "filename": "site_2025.png", "path": "a/site_2025.png",
|
| 265 |
+
"captureDate": "2025-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
|
| 266 |
+
{"id": 3, "filename": "site_2026.png", "path": "a/site_2026.png",
|
| 267 |
+
"captureDate": "2026-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
|
| 268 |
+
]
|
| 269 |
+
payload = build_area_groups(images)
|
| 270 |
+
assert len(payload["groups"]) == 1
|
| 271 |
+
assert len(payload["groups"][0]["images"]) == 2
|
| 272 |
+
assert payload["pairedImages"] == 2
|
| 273 |
+
assert len(payload["unpaired"]) == 1
|
| 274 |
+
assert len(set(_paths(payload["groups"][0]))) == 2
|
| 275 |
+
|
| 276 |
+
|
| 277 |
if __name__ == "__main__":
|
| 278 |
+
for name, fn in sorted(list(globals().items())):
|
| 279 |
+
if name.startswith("test_") and callable(fn):
|
| 280 |
+
fn()
|
| 281 |
+
print(f"ok {name}")
|
| 282 |
+
print("all ok")
|
static/css/dda-modernist-admin.css
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist admin theme (Claude Design redesign) — applied to the
|
| 3 |
+
three RBAC configuration screens: Admin Menu, Admin Modules,
|
| 4 |
+
Roles & Users. Same visual language as the #tab-home dashboard
|
| 5 |
+
reskin in dda.css: flat surfaces, hairline dividers, sharp
|
| 6 |
+
corners, one accent color, Archivo for headings. Reuses the
|
| 7 |
+
shared --dm-* tokens declared at :root in dda.css — nothing is
|
| 8 |
+
redeclared here.
|
| 9 |
+
|
| 10 |
+
Scoping: every rule is qualified by one of the three page
|
| 11 |
+
container ids (#tab-admin-menu / #tab-admin-modules /
|
| 12 |
+
#tab-admin-roles-users) or, for the modal dialogs that live
|
| 13 |
+
outside those containers in the DOM, by the modal's own id
|
| 14 |
+
(each id below is unique to one of these three pages). Nothing
|
| 15 |
+
here touches the bare/global .card, .btn-primary, .table rules.
|
| 16 |
+
============================================================ */
|
| 17 |
+
|
| 18 |
+
/* ---------------------------------------------------------------
|
| 19 |
+
Page heading
|
| 20 |
+
--------------------------------------------------------------- */
|
| 21 |
+
#tab-admin-menu .dda-hero h1,
|
| 22 |
+
#tab-admin-modules .dda-hero h1,
|
| 23 |
+
#tab-admin-roles-users .dda-hero h1 {
|
| 24 |
+
font-family: var(--dm-font);
|
| 25 |
+
font-weight: 800;
|
| 26 |
+
letter-spacing: -0.01em;
|
| 27 |
+
color: var(--dm-ink);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* ---------------------------------------------------------------
|
| 31 |
+
Flat surfaces — cards and modal panels lose the soft shadow /
|
| 32 |
+
rounded corners in favor of a hairline border, same treatment
|
| 33 |
+
as the dashboard cards.
|
| 34 |
+
--------------------------------------------------------------- */
|
| 35 |
+
#tab-admin-menu .card,
|
| 36 |
+
#tab-admin-modules .card,
|
| 37 |
+
#tab-admin-roles-users .card,
|
| 38 |
+
#menu-item-modal .dda-modal-panel,
|
| 39 |
+
#module-modal .dda-modal-panel,
|
| 40 |
+
#module-view-modal .dda-modal-panel,
|
| 41 |
+
#role-modal .dda-modal-panel,
|
| 42 |
+
#perm-modal .dda-modal-panel,
|
| 43 |
+
#user-modal .dda-modal-panel {
|
| 44 |
+
background: var(--dm-surface);
|
| 45 |
+
border: 1px solid var(--dm-line);
|
| 46 |
+
border-radius: 0;
|
| 47 |
+
box-shadow: none;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
#tab-admin-menu .card-header,
|
| 51 |
+
#tab-admin-modules .card-header,
|
| 52 |
+
#tab-admin-roles-users .card-header,
|
| 53 |
+
#menu-item-modal .card-header,
|
| 54 |
+
#module-modal .card-header,
|
| 55 |
+
#module-view-modal .card-header,
|
| 56 |
+
#role-modal .card-header,
|
| 57 |
+
#perm-modal .card-header,
|
| 58 |
+
#user-modal .card-header {
|
| 59 |
+
border-bottom: 1px solid var(--dm-line);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* ---------------------------------------------------------------
|
| 63 |
+
Buttons — flat accent instead of the app-wide purple gradient,
|
| 64 |
+
sharp corners, Archivo weight. Scoped to these pages/modals only.
|
| 65 |
+
--------------------------------------------------------------- */
|
| 66 |
+
#tab-admin-menu .btn,
|
| 67 |
+
#tab-admin-modules .btn,
|
| 68 |
+
#tab-admin-roles-users .btn,
|
| 69 |
+
#menu-item-modal .btn,
|
| 70 |
+
#module-modal .btn,
|
| 71 |
+
#module-view-modal .btn,
|
| 72 |
+
#role-modal .btn,
|
| 73 |
+
#perm-modal .btn,
|
| 74 |
+
#user-modal .btn {
|
| 75 |
+
border-radius: 0;
|
| 76 |
+
font-family: var(--dm-font);
|
| 77 |
+
font-weight: 700;
|
| 78 |
+
}
|
| 79 |
+
#tab-admin-menu .btn-primary,
|
| 80 |
+
#tab-admin-modules .btn-primary,
|
| 81 |
+
#tab-admin-roles-users .btn-primary,
|
| 82 |
+
#menu-item-modal .btn-primary,
|
| 83 |
+
#module-modal .btn-primary,
|
| 84 |
+
#role-modal .btn-primary,
|
| 85 |
+
#perm-modal .btn-primary,
|
| 86 |
+
#user-modal .btn-primary {
|
| 87 |
+
background: var(--dm-accent);
|
| 88 |
+
}
|
| 89 |
+
#tab-admin-menu .btn-primary:hover,
|
| 90 |
+
#tab-admin-modules .btn-primary:hover,
|
| 91 |
+
#tab-admin-roles-users .btn-primary:hover,
|
| 92 |
+
#menu-item-modal .btn-primary:hover,
|
| 93 |
+
#module-modal .btn-primary:hover,
|
| 94 |
+
#role-modal .btn-primary:hover,
|
| 95 |
+
#perm-modal .btn-primary:hover,
|
| 96 |
+
#user-modal .btn-primary:hover {
|
| 97 |
+
background: var(--dm-accent-hover);
|
| 98 |
+
filter: none;
|
| 99 |
+
box-shadow: none;
|
| 100 |
+
}
|
| 101 |
+
#tab-admin-menu .btn-secondary,
|
| 102 |
+
#tab-admin-modules .btn-secondary,
|
| 103 |
+
#tab-admin-roles-users .btn-secondary,
|
| 104 |
+
#menu-item-modal .btn-secondary,
|
| 105 |
+
#module-modal .btn-secondary,
|
| 106 |
+
#module-view-modal .btn-secondary,
|
| 107 |
+
#role-modal .btn-secondary,
|
| 108 |
+
#perm-modal .btn-secondary,
|
| 109 |
+
#user-modal .btn-secondary {
|
| 110 |
+
background: transparent;
|
| 111 |
+
border-color: var(--dm-line);
|
| 112 |
+
color: var(--dm-ink);
|
| 113 |
+
}
|
| 114 |
+
#tab-admin-menu .btn-secondary:hover,
|
| 115 |
+
#tab-admin-modules .btn-secondary:hover,
|
| 116 |
+
#tab-admin-roles-users .btn-secondary:hover,
|
| 117 |
+
#menu-item-modal .btn-secondary:hover,
|
| 118 |
+
#module-modal .btn-secondary:hover,
|
| 119 |
+
#module-view-modal .btn-secondary:hover,
|
| 120 |
+
#role-modal .btn-secondary:hover,
|
| 121 |
+
#perm-modal .btn-secondary:hover,
|
| 122 |
+
#user-modal .btn-secondary:hover {
|
| 123 |
+
background: var(--dm-bg);
|
| 124 |
+
border-color: var(--dm-line);
|
| 125 |
+
}
|
| 126 |
+
#tab-admin-menu .btn-danger,
|
| 127 |
+
#tab-admin-modules .btn-danger,
|
| 128 |
+
#tab-admin-roles-users .btn-danger {
|
| 129 |
+
border-radius: 0;
|
| 130 |
+
}
|
| 131 |
+
#tab-admin-menu .btn-ghost,
|
| 132 |
+
#tab-admin-modules .btn-ghost,
|
| 133 |
+
#tab-admin-roles-users .btn-ghost {
|
| 134 |
+
color: var(--dm-accent);
|
| 135 |
+
}
|
| 136 |
+
#tab-admin-menu .btn-ghost:hover,
|
| 137 |
+
#tab-admin-modules .btn-ghost:hover,
|
| 138 |
+
#tab-admin-roles-users .btn-ghost:hover {
|
| 139 |
+
background: var(--dm-accent-100);
|
| 140 |
+
color: var(--dm-accent-hover);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* ---------------------------------------------------------------
|
| 144 |
+
Status / system badges — sharpen the pill corners and switch to
|
| 145 |
+
the accent palette instead of green/red.
|
| 146 |
+
--------------------------------------------------------------- */
|
| 147 |
+
#tab-admin-menu .dda-status-badge,
|
| 148 |
+
#tab-admin-modules .dda-status-badge,
|
| 149 |
+
#tab-admin-roles-users .dda-status-badge {
|
| 150 |
+
border-radius: 2px;
|
| 151 |
+
}
|
| 152 |
+
#tab-admin-menu .dda-status-badge.is-active,
|
| 153 |
+
#tab-admin-modules .dda-status-badge.is-active,
|
| 154 |
+
#tab-admin-roles-users .dda-status-badge.is-active {
|
| 155 |
+
background: var(--dm-accent-100);
|
| 156 |
+
color: var(--dm-accent-hover);
|
| 157 |
+
}
|
| 158 |
+
#tab-admin-menu .dda-status-badge.is-inactive,
|
| 159 |
+
#tab-admin-modules .dda-status-badge.is-inactive,
|
| 160 |
+
#tab-admin-roles-users .dda-status-badge.is-inactive {
|
| 161 |
+
background: var(--dm-line);
|
| 162 |
+
color: var(--dm-ink-muted);
|
| 163 |
+
}
|
| 164 |
+
#tab-admin-roles-users .dda-badge-system {
|
| 165 |
+
border-radius: 2px;
|
| 166 |
+
border-color: var(--dm-line);
|
| 167 |
+
color: var(--dm-ink-muted);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* ---------------------------------------------------------------
|
| 171 |
+
Search inputs / select toolbars — flat hairline fields.
|
| 172 |
+
--------------------------------------------------------------- */
|
| 173 |
+
#tab-admin-modules .dda-search,
|
| 174 |
+
#tab-admin-roles-users .dda-search,
|
| 175 |
+
#tab-admin-modules .dda-table-toolbar select,
|
| 176 |
+
#tab-admin-roles-users .dda-table-toolbar select {
|
| 177 |
+
border-radius: 0;
|
| 178 |
+
border: 1px solid var(--dm-line);
|
| 179 |
+
background: var(--dm-surface);
|
| 180 |
+
color: var(--dm-ink);
|
| 181 |
+
}
|
| 182 |
+
#tab-admin-modules .dda-table-toolbar,
|
| 183 |
+
#tab-admin-roles-users .dda-table-toolbar {
|
| 184 |
+
color: var(--dm-ink-muted);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/* ---------------------------------------------------------------
|
| 188 |
+
Tables — uppercase hairline headers, same technique as the
|
| 189 |
+
dashboard's .dda-reports-table override (reused verbatim here
|
| 190 |
+
since Modules/Users render into the same table class).
|
| 191 |
+
--------------------------------------------------------------- */
|
| 192 |
+
#tab-admin-modules .dda-reports-table th,
|
| 193 |
+
#tab-admin-roles-users .dda-reports-table th {
|
| 194 |
+
font-size: 0.68rem;
|
| 195 |
+
letter-spacing: 0.06em;
|
| 196 |
+
text-transform: uppercase;
|
| 197 |
+
color: var(--dm-ink-muted);
|
| 198 |
+
border: none;
|
| 199 |
+
border-bottom: 2px solid var(--dm-line);
|
| 200 |
+
background: transparent;
|
| 201 |
+
}
|
| 202 |
+
#tab-admin-modules .dda-reports-table td,
|
| 203 |
+
#tab-admin-roles-users .dda-reports-table td {
|
| 204 |
+
border: none;
|
| 205 |
+
border-bottom: 1px solid var(--dm-line);
|
| 206 |
+
}
|
| 207 |
+
#tab-admin-modules .dda-reports-table tbody tr:hover,
|
| 208 |
+
#tab-admin-roles-users .dda-reports-table tbody tr:hover {
|
| 209 |
+
background: var(--dm-bg);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
#perm-modal .dda-perm-matrix th {
|
| 213 |
+
font-size: 0.68rem;
|
| 214 |
+
letter-spacing: 0.06em;
|
| 215 |
+
text-transform: uppercase;
|
| 216 |
+
color: var(--dm-ink-muted);
|
| 217 |
+
border: none;
|
| 218 |
+
border-bottom: 2px solid var(--dm-line);
|
| 219 |
+
background: transparent;
|
| 220 |
+
}
|
| 221 |
+
#perm-modal .dda-perm-matrix td {
|
| 222 |
+
border: none;
|
| 223 |
+
border-bottom: 1px solid var(--dm-line);
|
| 224 |
+
}
|
| 225 |
+
#perm-modal .dda-perm-matrix input[type="checkbox"],
|
| 226 |
+
#role-modal input[type="checkbox"],
|
| 227 |
+
#menu-item-modal input[type="checkbox"],
|
| 228 |
+
#user-modal input[type="checkbox"] {
|
| 229 |
+
accent-color: var(--dm-accent);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/* ---------------------------------------------------------------
|
| 233 |
+
Pagination — flat, sharp corners, accent active state instead
|
| 234 |
+
of the app-wide gradient pill.
|
| 235 |
+
--------------------------------------------------------------- */
|
| 236 |
+
#tab-admin-modules .pagination button,
|
| 237 |
+
#tab-admin-roles-users .pagination button {
|
| 238 |
+
border-radius: 0;
|
| 239 |
+
border-color: var(--dm-line);
|
| 240 |
+
}
|
| 241 |
+
#tab-admin-modules .pagination button:hover:not(:disabled),
|
| 242 |
+
#tab-admin-roles-users .pagination button:hover:not(:disabled) {
|
| 243 |
+
background: var(--dm-accent-100);
|
| 244 |
+
border-color: var(--dm-accent);
|
| 245 |
+
color: var(--dm-ink);
|
| 246 |
+
}
|
| 247 |
+
#tab-admin-modules .pagination button.active,
|
| 248 |
+
#tab-admin-roles-users .pagination button.active {
|
| 249 |
+
background: var(--dm-accent);
|
| 250 |
+
color: #fff;
|
| 251 |
+
border-color: var(--dm-accent);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
/* ---------------------------------------------------------------
|
| 255 |
+
Admin Menu — menu item list rendered as a fused hairline list
|
| 256 |
+
(same "1px gap on a line-colored background" technique the
|
| 257 |
+
dashboard KPI grid uses) instead of individually bordered,
|
| 258 |
+
rounded, shadowed rows.
|
| 259 |
+
--------------------------------------------------------------- */
|
| 260 |
+
#tab-admin-menu .dda-admin-2col {
|
| 261 |
+
grid-template-columns: 1.4fr 1fr;
|
| 262 |
+
}
|
| 263 |
+
#tab-admin-menu .dda-menu-list {
|
| 264 |
+
gap: 1px;
|
| 265 |
+
background: var(--dm-line);
|
| 266 |
+
border: 1px solid var(--dm-line);
|
| 267 |
+
}
|
| 268 |
+
#tab-admin-menu .dda-menu-item-row {
|
| 269 |
+
border: none;
|
| 270 |
+
border-radius: 0;
|
| 271 |
+
background: var(--dm-surface);
|
| 272 |
+
}
|
| 273 |
+
#tab-admin-menu .dda-menu-item-row.is-child {
|
| 274 |
+
background: var(--dm-bg);
|
| 275 |
+
}
|
| 276 |
+
#tab-admin-menu .dda-menu-item-order button {
|
| 277 |
+
border-radius: 0;
|
| 278 |
+
border-color: var(--dm-line);
|
| 279 |
+
background: var(--dm-surface);
|
| 280 |
+
}
|
| 281 |
+
#tab-admin-menu .dda-menu-item-order button:hover:not(:disabled) {
|
| 282 |
+
color: var(--dm-accent);
|
| 283 |
+
border-color: var(--dm-accent);
|
| 284 |
+
}
|
| 285 |
+
#tab-admin-menu .dda-menu-item-meta code {
|
| 286 |
+
background: var(--dm-bg);
|
| 287 |
+
border-radius: 0;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/* Instructions panel */
|
| 291 |
+
#tab-admin-menu .dda-admin-side-panel code {
|
| 292 |
+
background: var(--dm-bg);
|
| 293 |
+
border-radius: 0;
|
| 294 |
+
color: var(--dm-accent);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
/* Module Keys — rendered as a row of flat chips instead of a
|
| 298 |
+
stacked list, matching the mockup's tag row. */
|
| 299 |
+
#tab-admin-menu .dda-module-keys {
|
| 300 |
+
flex-direction: row;
|
| 301 |
+
flex-wrap: wrap;
|
| 302 |
+
gap: 0.4rem;
|
| 303 |
+
}
|
| 304 |
+
#tab-admin-menu .dda-module-keys code {
|
| 305 |
+
background: var(--dm-bg);
|
| 306 |
+
border: 1px solid var(--dm-line);
|
| 307 |
+
border-radius: 0;
|
| 308 |
+
color: var(--dm-ink);
|
| 309 |
+
align-self: auto;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/* ---------------------------------------------------------------
|
| 313 |
+
Roles & Users — role hierarchy as a fused hairline list, wider
|
| 314 |
+
ratio for the users column to match the mockup's proportions.
|
| 315 |
+
--------------------------------------------------------------- */
|
| 316 |
+
#tab-admin-roles-users .dda-admin-2col-roles {
|
| 317 |
+
grid-template-columns: 1fr 1.4fr;
|
| 318 |
+
}
|
| 319 |
+
#tab-admin-roles-users .dda-role-list {
|
| 320 |
+
gap: 1px;
|
| 321 |
+
background: var(--dm-line);
|
| 322 |
+
border: 1px solid var(--dm-line);
|
| 323 |
+
}
|
| 324 |
+
#tab-admin-roles-users .dda-role-card {
|
| 325 |
+
border: none;
|
| 326 |
+
border-radius: 0;
|
| 327 |
+
background: var(--dm-surface);
|
| 328 |
+
}
|
| 329 |
+
#tab-admin-roles-users .dda-role-card.is-inactive {
|
| 330 |
+
background: var(--dm-bg);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* ---------------------------------------------------------------
|
| 334 |
+
Form fields inside these pages' modals — flat hairline inputs
|
| 335 |
+
instead of the app-wide rounded/shadowed style.
|
| 336 |
+
--------------------------------------------------------------- */
|
| 337 |
+
#menu-item-modal input[type="text"],
|
| 338 |
+
#menu-item-modal select,
|
| 339 |
+
#module-modal input[type="text"],
|
| 340 |
+
#module-modal select,
|
| 341 |
+
#role-modal input[type="text"],
|
| 342 |
+
#role-modal input[type="number"],
|
| 343 |
+
#user-modal input[type="text"],
|
| 344 |
+
#user-modal input[type="email"],
|
| 345 |
+
#user-modal input[type="password"],
|
| 346 |
+
#user-modal input[type="tel"],
|
| 347 |
+
#user-modal select {
|
| 348 |
+
border-radius: 0;
|
| 349 |
+
border-color: var(--dm-line);
|
| 350 |
+
}
|
| 351 |
+
#menu-item-modal input[type="text"]:focus,
|
| 352 |
+
#menu-item-modal select:focus,
|
| 353 |
+
#module-modal input[type="text"]:focus,
|
| 354 |
+
#module-modal select:focus,
|
| 355 |
+
#role-modal input[type="text"]:focus,
|
| 356 |
+
#role-modal input[type="number"]:focus,
|
| 357 |
+
#user-modal input[type="text"]:focus,
|
| 358 |
+
#user-modal input[type="email"]:focus,
|
| 359 |
+
#user-modal input[type="password"]:focus,
|
| 360 |
+
#user-modal input[type="tel"]:focus,
|
| 361 |
+
#user-modal select:focus {
|
| 362 |
+
border-color: var(--dm-accent);
|
| 363 |
+
box-shadow: 0 0 0 3px var(--dm-accent-100);
|
| 364 |
+
}
|
| 365 |
+
#user-modal .dda-phone-input {
|
| 366 |
+
border-radius: 0;
|
| 367 |
+
border-color: var(--dm-line);
|
| 368 |
+
}
|
| 369 |
+
#user-modal .dda-phone-input:focus-within {
|
| 370 |
+
border-color: var(--dm-accent);
|
| 371 |
+
box-shadow: 0 0 0 3px var(--dm-accent-100);
|
| 372 |
+
}
|
static/css/dda-modernist-auth.css
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist auth pages theme (from the Claude Design redesign).
|
| 3 |
+
Scoped entirely to #page-login / #page-forgot-password /
|
| 4 |
+
#page-reset-password so no other page is affected. Reuses the
|
| 5 |
+
shared --dm-* tokens declared once at :root in dda.css: flat
|
| 6 |
+
surfaces, hairline dividers, sharp corners, one accent color,
|
| 7 |
+
Archivo for headings. Pure visual/layout restyle — no markup
|
| 8 |
+
behavior, ids or JS hooks are changed by this file.
|
| 9 |
+
============================================================ */
|
| 10 |
+
|
| 11 |
+
#page-login,
|
| 12 |
+
#page-forgot-password,
|
| 13 |
+
#page-reset-password {
|
| 14 |
+
background: var(--dm-bg);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/* --- Shell: form panel centered in the full viewport (no side hero) --- */
|
| 18 |
+
#page-login .dm-auth-shell,
|
| 19 |
+
#page-forgot-password .dm-auth-shell,
|
| 20 |
+
#page-reset-password .dm-auth-shell {
|
| 21 |
+
min-height: 100vh;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/* --- Form panel --- */
|
| 25 |
+
#page-login .auth-page,
|
| 26 |
+
#page-forgot-password .auth-page,
|
| 27 |
+
#page-reset-password .auth-page {
|
| 28 |
+
min-height: 100vh;
|
| 29 |
+
display: flex;
|
| 30 |
+
align-items: center;
|
| 31 |
+
justify-content: center;
|
| 32 |
+
padding: 2rem;
|
| 33 |
+
background: var(--dm-bg);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Brand mark inside the card — the only branding on the page now that
|
| 37 |
+
there's no side hero panel. */
|
| 38 |
+
#page-login .auth-brand,
|
| 39 |
+
#page-forgot-password .auth-brand,
|
| 40 |
+
#page-reset-password .auth-brand {
|
| 41 |
+
display: flex;
|
| 42 |
+
color: var(--dm-ink);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* --- Flat card surface, sharp corners, no shadow --- */
|
| 46 |
+
#page-login .card,
|
| 47 |
+
#page-forgot-password .card,
|
| 48 |
+
#page-reset-password .card {
|
| 49 |
+
background: var(--dm-surface);
|
| 50 |
+
border: 1px solid var(--dm-line);
|
| 51 |
+
border-radius: 0;
|
| 52 |
+
box-shadow: none;
|
| 53 |
+
padding: 2rem;
|
| 54 |
+
}
|
| 55 |
+
#page-login .card-header,
|
| 56 |
+
#page-forgot-password .card-header,
|
| 57 |
+
#page-reset-password .card-header {
|
| 58 |
+
border-bottom-color: var(--dm-line);
|
| 59 |
+
}
|
| 60 |
+
#page-login .card-header h3,
|
| 61 |
+
#page-forgot-password .card-header h3,
|
| 62 |
+
#page-reset-password .card-header h3 {
|
| 63 |
+
font-family: var(--dm-font);
|
| 64 |
+
font-weight: 800;
|
| 65 |
+
font-size: 1.4rem;
|
| 66 |
+
letter-spacing: -0.01em;
|
| 67 |
+
color: var(--dm-ink);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
#page-login .auth-sub,
|
| 71 |
+
#page-forgot-password .auth-sub,
|
| 72 |
+
#page-reset-password .auth-sub {
|
| 73 |
+
color: var(--dm-ink-muted);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/* --- Inputs: sharp corners, hairline border --- */
|
| 77 |
+
#page-login input[type="email"],
|
| 78 |
+
#page-login input[type="password"],
|
| 79 |
+
#page-login input[type="text"],
|
| 80 |
+
#page-login input[type="tel"],
|
| 81 |
+
#page-login select,
|
| 82 |
+
#page-forgot-password input[type="email"],
|
| 83 |
+
#page-reset-password input[type="password"] {
|
| 84 |
+
background: var(--dm-bg);
|
| 85 |
+
border: 1px solid var(--dm-line);
|
| 86 |
+
border-radius: 0;
|
| 87 |
+
color: var(--dm-ink);
|
| 88 |
+
}
|
| 89 |
+
#page-login input:focus,
|
| 90 |
+
#page-login select:focus,
|
| 91 |
+
#page-forgot-password input:focus,
|
| 92 |
+
#page-reset-password input:focus {
|
| 93 |
+
border-color: var(--dm-accent);
|
| 94 |
+
box-shadow: 0 0 0 3px var(--dm-accent-100);
|
| 95 |
+
}
|
| 96 |
+
#page-login .form-group label,
|
| 97 |
+
#page-forgot-password .form-group label,
|
| 98 |
+
#page-reset-password .form-group label {
|
| 99 |
+
color: var(--dm-ink-muted);
|
| 100 |
+
}
|
| 101 |
+
#page-login .field-hint,
|
| 102 |
+
#page-reset-password .field-hint {
|
| 103 |
+
color: var(--dm-ink-dim);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* --- Phone input (country code + local number) --- */
|
| 107 |
+
#page-login .dda-phone-input {
|
| 108 |
+
background: var(--dm-bg);
|
| 109 |
+
border: 1px solid var(--dm-line);
|
| 110 |
+
border-radius: 0;
|
| 111 |
+
}
|
| 112 |
+
#page-login .dda-phone-input:focus-within {
|
| 113 |
+
border-color: var(--dm-accent);
|
| 114 |
+
box-shadow: 0 0 0 3px var(--dm-accent-100);
|
| 115 |
+
}
|
| 116 |
+
#page-login .dda-phone-input .dda-country-select {
|
| 117 |
+
border-right-color: var(--dm-line);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* --- Sign-in method tabs (Password / Email OTP) --- */
|
| 121 |
+
#page-login .dda-auth-tabs {
|
| 122 |
+
background: var(--dm-bg);
|
| 123 |
+
border: 1px solid var(--dm-line);
|
| 124 |
+
border-radius: 0;
|
| 125 |
+
}
|
| 126 |
+
#page-login .dda-auth-tab.is-active {
|
| 127 |
+
background: var(--dm-surface);
|
| 128 |
+
color: var(--dm-ink);
|
| 129 |
+
box-shadow: none;
|
| 130 |
+
border: 1px solid var(--dm-line);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* --- OTP code input --- */
|
| 134 |
+
#page-login .dda-otp-input {
|
| 135 |
+
background: var(--dm-bg);
|
| 136 |
+
border: 1px solid var(--dm-line);
|
| 137 |
+
border-radius: 0;
|
| 138 |
+
color: var(--dm-ink);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* --- Checkbox accent --- */
|
| 142 |
+
#page-login .checkbox-group input[type="checkbox"] {
|
| 143 |
+
accent-color: var(--dm-accent);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* --- Buttons: flat accent instead of the app-wide purple gradient --- */
|
| 147 |
+
#page-login .btn,
|
| 148 |
+
#page-forgot-password .btn,
|
| 149 |
+
#page-reset-password .btn {
|
| 150 |
+
border-radius: 0;
|
| 151 |
+
font-family: var(--dm-font);
|
| 152 |
+
font-weight: 700;
|
| 153 |
+
}
|
| 154 |
+
#page-login .btn-primary,
|
| 155 |
+
#page-forgot-password .btn-primary,
|
| 156 |
+
#page-reset-password .btn-primary {
|
| 157 |
+
background: var(--dm-accent);
|
| 158 |
+
}
|
| 159 |
+
#page-login .btn-primary:hover,
|
| 160 |
+
#page-forgot-password .btn-primary:hover,
|
| 161 |
+
#page-reset-password .btn-primary:hover {
|
| 162 |
+
background: var(--dm-accent-hover);
|
| 163 |
+
filter: none;
|
| 164 |
+
box-shadow: none;
|
| 165 |
+
}
|
| 166 |
+
#page-login .btn-secondary,
|
| 167 |
+
#page-forgot-password .btn-secondary,
|
| 168 |
+
#page-reset-password .btn-secondary {
|
| 169 |
+
background: transparent;
|
| 170 |
+
border: 1px solid var(--dm-line);
|
| 171 |
+
color: var(--dm-ink);
|
| 172 |
+
border-radius: 0;
|
| 173 |
+
}
|
| 174 |
+
#page-login .btn-secondary:hover,
|
| 175 |
+
#page-forgot-password .btn-secondary:hover,
|
| 176 |
+
#page-reset-password .btn-secondary:hover {
|
| 177 |
+
background: var(--dm-bg);
|
| 178 |
+
border-color: var(--dm-line);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/* --- Links --- */
|
| 182 |
+
#page-login .auth-forgot-link,
|
| 183 |
+
#page-login .auth-toggle a,
|
| 184 |
+
#page-forgot-password .auth-toggle a,
|
| 185 |
+
#page-reset-password .auth-toggle a {
|
| 186 |
+
color: var(--dm-accent);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/* --- Alerts: sharp corners --- */
|
| 190 |
+
#page-login .alert,
|
| 191 |
+
#page-forgot-password .alert,
|
| 192 |
+
#page-reset-password .alert {
|
| 193 |
+
border-radius: 0;
|
| 194 |
+
}
|
| 195 |
+
|
static/css/dda-modernist-detect.css
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist Change Detection theme (from the Claude Design
|
| 3 |
+
redesign). Scoped entirely to #tab-detect so the rest of the
|
| 4 |
+
app keeps its existing look until/unless it gets redesigned
|
| 5 |
+
too: flat surfaces, hairline dividers, sharp corners, one
|
| 6 |
+
accent color, Archivo for headings. Tokens (--dm-*) are
|
| 7 |
+
shared, declared once at :root in dda.css — reused here, not
|
| 8 |
+
redeclared. Pure visual/layout reskin: no ids/classes that
|
| 9 |
+
compare.js/tree.js/pairs.js/result.js/map.js select on are
|
| 10 |
+
renamed, and no RBAC (`data-requires-permission`) attributes
|
| 11 |
+
are touched.
|
| 12 |
+
============================================================ */
|
| 13 |
+
|
| 14 |
+
/* --- Page header (mirrors the mockup's title + subtitle) --------------- */
|
| 15 |
+
#tab-detect .dda-detect-hero { margin-bottom: 1.25rem; }
|
| 16 |
+
#tab-detect .dda-detect-hero h1 { margin: 0 0 0.2rem; font-weight: 800; letter-spacing: -0.01em; }
|
| 17 |
+
#tab-detect .dda-detect-hero .sub { margin: 0; color: var(--dm-ink-muted); }
|
| 18 |
+
|
| 19 |
+
/* --- Typography -------------------------------------------------------- */
|
| 20 |
+
#tab-detect h1,
|
| 21 |
+
#tab-detect .card-title,
|
| 22 |
+
#tab-detect .card-header h3,
|
| 23 |
+
#tab-detect .dda-run-bar-title {
|
| 24 |
+
font-family: var(--dm-font);
|
| 25 |
+
}
|
| 26 |
+
#tab-detect .card-header h3 { font-weight: 800; letter-spacing: -0.005em; }
|
| 27 |
+
|
| 28 |
+
/* --- Flat, hairline-bordered surfaces instead of soft shadow cards ----- */
|
| 29 |
+
#tab-detect .card,
|
| 30 |
+
#tab-detect .dda-modal-panel,
|
| 31 |
+
#tab-detect .dda-slot,
|
| 32 |
+
#tab-detect .dda-roi-controls,
|
| 33 |
+
#tab-detect .dda-upload-progress .dda-progress-bar {
|
| 34 |
+
background: var(--dm-surface);
|
| 35 |
+
border: 1px solid var(--dm-line);
|
| 36 |
+
border-radius: 0;
|
| 37 |
+
box-shadow: none;
|
| 38 |
+
}
|
| 39 |
+
#tab-detect .dda-slot { border-style: dashed; }
|
| 40 |
+
#tab-detect .dda-slot.filled { border-style: solid; border-color: var(--dm-accent); }
|
| 41 |
+
#tab-detect .dda-slot.drag-over { border-color: var(--dm-accent); background: var(--dm-accent-100); }
|
| 42 |
+
|
| 43 |
+
/* --- Buttons — flat accent instead of the app-wide purple gradient ----- */
|
| 44 |
+
#tab-detect .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
|
| 45 |
+
#tab-detect .btn-primary { background: var(--dm-accent); border-color: var(--dm-accent); }
|
| 46 |
+
#tab-detect .btn-primary:hover:not(:disabled) { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
|
| 47 |
+
#tab-detect .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
|
| 48 |
+
#tab-detect .btn-secondary:hover:not(:disabled) { background: var(--dm-bg); border-color: var(--dm-line); }
|
| 49 |
+
#tab-detect .btn-ghost { color: var(--dm-accent); }
|
| 50 |
+
#tab-detect .btn-ghost:hover:not(:disabled) { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
|
| 51 |
+
|
| 52 |
+
/* --- Layout breathing room (mirrors the mockup's 20px card gaps) ------- */
|
| 53 |
+
#tab-detect.dda-panel { display: flex; flex-direction: column; gap: 0; }
|
| 54 |
+
#tab-detect .dda-layout { gap: 1.25rem; }
|
| 55 |
+
#tab-detect .dda-main { display: flex; flex-direction: column; gap: 1.25rem; }
|
| 56 |
+
#tab-detect .card { padding: 1.25rem; }
|
| 57 |
+
#tab-detect .dda-sidebar.card { padding: 1rem; }
|
| 58 |
+
|
| 59 |
+
/* --- Folder tree sidebar ------------------------------------------------ */
|
| 60 |
+
#tab-detect .dda-folder-path { color: var(--dm-ink-muted); }
|
| 61 |
+
#tab-detect .dda-search {
|
| 62 |
+
border: 1px solid var(--dm-line);
|
| 63 |
+
border-radius: 0;
|
| 64 |
+
background: var(--dm-bg);
|
| 65 |
+
color: var(--dm-ink);
|
| 66 |
+
}
|
| 67 |
+
#tab-detect .dda-search:focus { outline: none; border-color: var(--dm-accent); }
|
| 68 |
+
#tab-detect .dda-tree-all,
|
| 69 |
+
#tab-detect .dda-tree-node-btn,
|
| 70 |
+
#tab-detect .dda-tree-year,
|
| 71 |
+
#tab-detect .dda-tree-village {
|
| 72 |
+
border-radius: 0;
|
| 73 |
+
color: var(--dm-ink-muted);
|
| 74 |
+
}
|
| 75 |
+
#tab-detect .dda-tree-all:hover, #tab-detect .dda-tree-all.active,
|
| 76 |
+
#tab-detect .dda-tree-node-btn:hover, #tab-detect .dda-tree-node-btn.active,
|
| 77 |
+
#tab-detect .dda-tree-year:hover, #tab-detect .dda-tree-year.active,
|
| 78 |
+
#tab-detect .dda-tree-village:hover, #tab-detect .dda-tree-village.active {
|
| 79 |
+
background: var(--dm-accent-100);
|
| 80 |
+
color: var(--dm-accent-hover);
|
| 81 |
+
}
|
| 82 |
+
#tab-detect .dda-tree-zone > button { border-radius: 0; color: var(--dm-ink); }
|
| 83 |
+
#tab-detect .dda-tree-zone > button:hover { background: var(--dm-accent-100); }
|
| 84 |
+
#tab-detect .dda-tree-children { border-left-color: var(--dm-line); }
|
| 85 |
+
|
| 86 |
+
/* --- Detection options form -------------------------------------------- */
|
| 87 |
+
#tab-detect .dda-detect-options select,
|
| 88 |
+
#tab-detect .dda-auto-compare select,
|
| 89 |
+
#tab-detect .dda-image-select {
|
| 90 |
+
border: 1px solid var(--dm-line);
|
| 91 |
+
border-radius: 0;
|
| 92 |
+
background: var(--dm-surface);
|
| 93 |
+
color: var(--dm-ink);
|
| 94 |
+
}
|
| 95 |
+
#tab-detect .dda-detect-options select:focus,
|
| 96 |
+
#tab-detect .dda-auto-compare select:focus,
|
| 97 |
+
#tab-detect .dda-image-select:focus { outline: none; border-color: var(--dm-accent); box-shadow: none; }
|
| 98 |
+
|
| 99 |
+
#tab-detect .dda-sensitivity-field em { color: var(--dm-accent); font-weight: 700; }
|
| 100 |
+
#tab-detect .dda-sensitivity-field input[type="range"] { accent-color: var(--dm-accent); }
|
| 101 |
+
|
| 102 |
+
#tab-detect .dda-stepper-wrap {
|
| 103 |
+
border: 1px solid var(--dm-line);
|
| 104 |
+
border-radius: 0;
|
| 105 |
+
background: var(--dm-surface);
|
| 106 |
+
}
|
| 107 |
+
#tab-detect .dda-stepper-wrap:focus-within { border-color: var(--dm-accent); box-shadow: none; }
|
| 108 |
+
#tab-detect .dda-stepper-btn:first-child { border-right-color: var(--dm-line); }
|
| 109 |
+
#tab-detect .dda-stepper-btn:last-child { border-left-color: var(--dm-line); }
|
| 110 |
+
#tab-detect .dda-stepper-btn:hover:not(:disabled) { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
|
| 111 |
+
#tab-detect .dda-input-suffix { color: var(--dm-ink-dim); }
|
| 112 |
+
#tab-detect .dda-input-suffix-wrap input[type="number"]:focus + .dda-input-suffix { color: var(--dm-accent); }
|
| 113 |
+
|
| 114 |
+
#tab-detect .dda-check { color: var(--dm-ink-muted); }
|
| 115 |
+
#tab-detect .dda-check input[type="checkbox"] { accent-color: var(--dm-accent); }
|
| 116 |
+
#tab-detect .dda-notify-email {
|
| 117 |
+
border: 1px solid var(--dm-line);
|
| 118 |
+
border-radius: 0;
|
| 119 |
+
background: var(--dm-surface);
|
| 120 |
+
color: var(--dm-ink);
|
| 121 |
+
}
|
| 122 |
+
#tab-detect .dda-field-hint { color: var(--dm-accent); }
|
| 123 |
+
|
| 124 |
+
/* Region-shape segmented toggle — flat, dark-ink active state (matches
|
| 125 |
+
the mockup's "seg"/"seg-opt" control) instead of the app-wide blue. */
|
| 126 |
+
#tab-detect .dda-shape-toggle,
|
| 127 |
+
#tab-detect .dda-mode-toggle {
|
| 128 |
+
border: 1px solid var(--dm-line);
|
| 129 |
+
border-radius: 0;
|
| 130 |
+
overflow: hidden;
|
| 131 |
+
}
|
| 132 |
+
#tab-detect .dda-shape-btn,
|
| 133 |
+
#tab-detect .dda-mode-btn {
|
| 134 |
+
border-radius: 0;
|
| 135 |
+
color: var(--dm-ink);
|
| 136 |
+
font-family: var(--dm-font);
|
| 137 |
+
font-weight: 600;
|
| 138 |
+
}
|
| 139 |
+
#tab-detect .dda-shape-btn + .dda-shape-btn,
|
| 140 |
+
#tab-detect .dda-mode-btn + .dda-mode-btn { border-left-color: var(--dm-line); }
|
| 141 |
+
#tab-detect .dda-shape-btn:hover,
|
| 142 |
+
#tab-detect .dda-mode-btn:hover { background: var(--dm-bg); }
|
| 143 |
+
#tab-detect .dda-shape-btn.is-active,
|
| 144 |
+
#tab-detect .dda-mode-btn.is-active { background: var(--dm-ink); color: #fff; }
|
| 145 |
+
|
| 146 |
+
#tab-detect .dda-roi-controls { padding: 0.6rem 0.85rem; color: var(--dm-ink-muted); }
|
| 147 |
+
|
| 148 |
+
#tab-detect .dda-progress-fill { background: var(--dm-accent); }
|
| 149 |
+
|
| 150 |
+
/* --- Automatic-mode area picker + schedule ------------------------------ */
|
| 151 |
+
#tab-detect .dda-auto-field input {
|
| 152 |
+
border: 1px solid var(--dm-line);
|
| 153 |
+
border-radius: 0;
|
| 154 |
+
background: var(--dm-surface);
|
| 155 |
+
color: var(--dm-ink);
|
| 156 |
+
}
|
| 157 |
+
#tab-detect .dda-auto-field input:focus { outline: none; border-color: var(--dm-accent); }
|
| 158 |
+
#tab-detect .dda-auto-schedule { color: var(--dm-ink-muted); }
|
| 159 |
+
|
| 160 |
+
/* --- Before/After compare slots ----------------------------------------- */
|
| 161 |
+
#tab-detect .dda-slot-label {
|
| 162 |
+
font-size: 0.72rem;
|
| 163 |
+
font-weight: 800;
|
| 164 |
+
text-transform: uppercase;
|
| 165 |
+
letter-spacing: 0.05em;
|
| 166 |
+
color: var(--dm-ink-muted);
|
| 167 |
+
}
|
| 168 |
+
#tab-detect .dda-slot-preview { border-radius: 0; background: var(--dm-bg); }
|
| 169 |
+
#tab-detect .dda-slot-meta { color: var(--dm-ink-muted); }
|
| 170 |
+
|
| 171 |
+
/* --- Same-area pairs + library grid -------------------------------------- */
|
| 172 |
+
#tab-detect .dda-compare-card,
|
| 173 |
+
#tab-detect .dda-picker-item {
|
| 174 |
+
border-radius: 0;
|
| 175 |
+
border-color: var(--dm-line);
|
| 176 |
+
}
|
| 177 |
+
#tab-detect .dda-compare-card.selected-t1 { border-color: var(--dm-accent); box-shadow: inset 0 0 0 1px var(--dm-accent); }
|
| 178 |
+
#tab-detect .dda-compare-card.selected-t2 { border-color: var(--dm-ink); box-shadow: inset 0 0 0 1px var(--dm-ink); }
|
| 179 |
+
#tab-detect .dda-compare-assign { border-top-color: var(--dm-line); }
|
| 180 |
+
#tab-detect .dda-compare-assign .btn { border-radius: 0; }
|
| 181 |
+
#tab-detect .pagination .btn,
|
| 182 |
+
#tab-detect .pagination button { border-radius: 0; }
|
| 183 |
+
|
| 184 |
+
/* --- Run bar — dark flat bar with the accent primary button ------------- */
|
| 185 |
+
#tab-detect .dda-run-bar {
|
| 186 |
+
border-radius: 0;
|
| 187 |
+
background: var(--dm-ink);
|
| 188 |
+
color: #fff;
|
| 189 |
+
border: none;
|
| 190 |
+
}
|
| 191 |
+
#tab-detect .dda-run-bar .dim,
|
| 192 |
+
#tab-detect .dda-run-bar #dda-run-bar-hint { color: rgba(255, 255, 255, 0.65); }
|
| 193 |
+
|
| 194 |
+
/* --- Modals (preflight / image picker) ---------------------------------- */
|
| 195 |
+
#tab-detect .dda-modal-panel { border-radius: 0; }
|
| 196 |
+
#tab-detect .dda-picker-grid .dda-picker-item:hover { border-color: var(--dm-accent); }
|
| 197 |
+
#tab-detect .dda-preflight-list li.fail { border-left: 3px solid var(--dm-accent); }
|
static/css/dda-modernist-library.css
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist Image Library theme (Claude Design redesign).
|
| 3 |
+
Scoped entirely to #tab-library so the rest of the app keeps its
|
| 4 |
+
existing look until/unless it gets redesigned too: flat surfaces,
|
| 5 |
+
hairline dividers, sharp corners, one accent color, Archivo for
|
| 6 |
+
headings. Tokens (--dm-*) are shared globals declared once at
|
| 7 |
+
:root in dda.css — reused here, not redeclared.
|
| 8 |
+
|
| 9 |
+
Note: the image-viewer / manage-folders / bulk-upload modals and
|
| 10 |
+
the folder right-click menu are rendered outside the #tab-library
|
| 11 |
+
DOM subtree (they're siblings appended at the end of <body> in
|
| 12 |
+
library_dda.html), but their ids (#dda-image-viewer-modal,
|
| 13 |
+
#dda-manage-modal, #dda-bulk-upload-modal, #dda-tree-menu) only
|
| 14 |
+
ever exist on this page's markup, so anchoring rules to those ids
|
| 15 |
+
is just as safe as nesting under #tab-library — no other page can
|
| 16 |
+
be affected.
|
| 17 |
+
============================================================ */
|
| 18 |
+
|
| 19 |
+
#tab-library h1,
|
| 20 |
+
#tab-library .card-header h3,
|
| 21 |
+
#tab-library #lib-grid-title,
|
| 22 |
+
#dda-image-viewer-title,
|
| 23 |
+
#dda-manage-title,
|
| 24 |
+
#dda-bulk-upload-title {
|
| 25 |
+
font-family: var(--dm-font);
|
| 26 |
+
font-weight: 800;
|
| 27 |
+
letter-spacing: -0.01em;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* Flat, hairline-bordered surfaces instead of soft shadow cards */
|
| 31 |
+
#tab-library .card,
|
| 32 |
+
#tab-library .dda-storage-info,
|
| 33 |
+
#dda-image-viewer-modal .dda-modal-panel,
|
| 34 |
+
#dda-manage-modal .dda-modal-panel,
|
| 35 |
+
#dda-bulk-upload-modal .dda-modal-panel {
|
| 36 |
+
background: var(--dm-surface);
|
| 37 |
+
border: 1px solid var(--dm-line);
|
| 38 |
+
border-radius: 0;
|
| 39 |
+
box-shadow: none;
|
| 40 |
+
}
|
| 41 |
+
#tab-library .card-header { border-bottom-color: var(--dm-line); }
|
| 42 |
+
#dda-image-viewer-modal .card-header,
|
| 43 |
+
#dda-manage-modal .card-header,
|
| 44 |
+
#dda-bulk-upload-modal .card-header { border-bottom-color: var(--dm-line); }
|
| 45 |
+
|
| 46 |
+
/* Buttons — flat accent instead of the app-wide purple gradient,
|
| 47 |
+
scoped to this page (+ its modals) only so other pages are
|
| 48 |
+
untouched. */
|
| 49 |
+
#tab-library .btn,
|
| 50 |
+
#dda-image-viewer-modal .btn,
|
| 51 |
+
#dda-manage-modal .btn,
|
| 52 |
+
#dda-bulk-upload-modal .btn,
|
| 53 |
+
#dda-tree-menu button {
|
| 54 |
+
border-radius: 0;
|
| 55 |
+
font-family: var(--dm-font);
|
| 56 |
+
font-weight: 700;
|
| 57 |
+
}
|
| 58 |
+
#tab-library .btn-primary,
|
| 59 |
+
#dda-image-viewer-modal .btn-primary,
|
| 60 |
+
#dda-manage-modal .btn-primary,
|
| 61 |
+
#dda-bulk-upload-modal .btn-primary {
|
| 62 |
+
background: var(--dm-accent);
|
| 63 |
+
}
|
| 64 |
+
#tab-library .btn-primary:hover,
|
| 65 |
+
#dda-image-viewer-modal .btn-primary:hover,
|
| 66 |
+
#dda-manage-modal .btn-primary:hover,
|
| 67 |
+
#dda-bulk-upload-modal .btn-primary:hover {
|
| 68 |
+
background: var(--dm-accent-hover);
|
| 69 |
+
filter: none;
|
| 70 |
+
box-shadow: none;
|
| 71 |
+
}
|
| 72 |
+
#tab-library .btn-secondary,
|
| 73 |
+
#dda-image-viewer-modal .btn-secondary,
|
| 74 |
+
#dda-manage-modal .btn-secondary,
|
| 75 |
+
#dda-bulk-upload-modal .btn-secondary {
|
| 76 |
+
background: transparent;
|
| 77 |
+
border-color: var(--dm-line);
|
| 78 |
+
color: var(--dm-ink);
|
| 79 |
+
}
|
| 80 |
+
#tab-library .btn-secondary:hover,
|
| 81 |
+
#dda-image-viewer-modal .btn-secondary:hover,
|
| 82 |
+
#dda-manage-modal .btn-secondary:hover,
|
| 83 |
+
#dda-bulk-upload-modal .btn-secondary:hover {
|
| 84 |
+
background: var(--dm-bg);
|
| 85 |
+
border-color: var(--dm-line);
|
| 86 |
+
}
|
| 87 |
+
#tab-library .btn-danger,
|
| 88 |
+
#dda-image-viewer-modal .btn-danger,
|
| 89 |
+
#dda-manage-modal .btn-danger {
|
| 90 |
+
box-shadow: none;
|
| 91 |
+
}
|
| 92 |
+
#tab-library .btn-danger:hover,
|
| 93 |
+
#dda-image-viewer-modal .btn-danger:hover,
|
| 94 |
+
#dda-manage-modal .btn-danger:hover {
|
| 95 |
+
box-shadow: none;
|
| 96 |
+
}
|
| 97 |
+
#tab-library .btn-ghost { color: var(--dm-accent); }
|
| 98 |
+
#tab-library .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
|
| 99 |
+
|
| 100 |
+
/* Inputs, selects, search fields — sharp corners + hairline border */
|
| 101 |
+
#tab-library input[type="text"],
|
| 102 |
+
#tab-library input[type="search"],
|
| 103 |
+
#tab-library input[type="date"],
|
| 104 |
+
#tab-library input[type="url"],
|
| 105 |
+
#tab-library select,
|
| 106 |
+
#tab-library .dda-search,
|
| 107 |
+
#dda-image-viewer-modal select,
|
| 108 |
+
#dda-image-viewer-modal input[type="url"],
|
| 109 |
+
#dda-manage-modal input[type="text"],
|
| 110 |
+
#dda-manage-modal select,
|
| 111 |
+
#dda-bulk-upload-modal input[type="text"] {
|
| 112 |
+
border-radius: 0;
|
| 113 |
+
border-color: var(--dm-line);
|
| 114 |
+
background: var(--dm-surface);
|
| 115 |
+
}
|
| 116 |
+
#tab-library input:focus,
|
| 117 |
+
#tab-library select:focus,
|
| 118 |
+
#dda-image-viewer-modal select:focus,
|
| 119 |
+
#dda-manage-modal input:focus,
|
| 120 |
+
#dda-manage-modal select:focus {
|
| 121 |
+
border-color: var(--dm-accent);
|
| 122 |
+
box-shadow: 0 0 0 3px var(--dm-accent-100);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* --- Folder tree sidebar --- */
|
| 126 |
+
#tab-library .dda-folder-path,
|
| 127 |
+
#tab-library .dda-tree-hint { color: var(--dm-ink-muted); }
|
| 128 |
+
#tab-library .dda-tree-toolbar-btn { color: var(--dm-ink-muted); }
|
| 129 |
+
#tab-library .dda-tree-toolbar-btn:hover { color: var(--dm-accent); }
|
| 130 |
+
#tab-library .dda-tree-all,
|
| 131 |
+
#tab-library .dda-tree-node-btn,
|
| 132 |
+
#tab-library .dda-tree-year,
|
| 133 |
+
#tab-library .dda-tree-zone > button,
|
| 134 |
+
#tab-library .dda-tree-village {
|
| 135 |
+
border-radius: 0;
|
| 136 |
+
color: var(--dm-ink);
|
| 137 |
+
}
|
| 138 |
+
#tab-library .dda-tree-all:hover,
|
| 139 |
+
#tab-library .dda-tree-node-btn:hover,
|
| 140 |
+
#tab-library .dda-tree-year:hover,
|
| 141 |
+
#tab-library .dda-tree-zone > button:hover,
|
| 142 |
+
#tab-library .dda-tree-village:hover {
|
| 143 |
+
background: var(--dm-accent-100);
|
| 144 |
+
color: var(--dm-accent);
|
| 145 |
+
}
|
| 146 |
+
#tab-library .dda-tree-all.active,
|
| 147 |
+
#tab-library .dda-tree-node-btn.active,
|
| 148 |
+
#tab-library .dda-tree-year.active,
|
| 149 |
+
#tab-library .dda-tree-village.active {
|
| 150 |
+
background: var(--dm-accent-100);
|
| 151 |
+
color: var(--dm-accent);
|
| 152 |
+
border-left: 2px solid var(--dm-accent);
|
| 153 |
+
padding-left: calc(0.5rem - 2px);
|
| 154 |
+
}
|
| 155 |
+
#tab-library .dda-tree-children { border-left-color: var(--dm-line); }
|
| 156 |
+
#tab-library .dda-tree-hit { background: var(--dm-accent-100); border-radius: 0; }
|
| 157 |
+
#tab-library .dda-tree-node-btn.is-drop-target {
|
| 158 |
+
outline-color: var(--dm-accent);
|
| 159 |
+
background: var(--dm-accent-100);
|
| 160 |
+
color: var(--dm-accent);
|
| 161 |
+
}
|
| 162 |
+
#dda-tree-menu {
|
| 163 |
+
border-radius: 0;
|
| 164 |
+
border-color: var(--dm-line);
|
| 165 |
+
box-shadow: none;
|
| 166 |
+
}
|
| 167 |
+
#dda-tree-menu button:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
|
| 168 |
+
|
| 169 |
+
/* --- Upload card --- */
|
| 170 |
+
#tab-library .dda-dropzone {
|
| 171 |
+
border-radius: 0;
|
| 172 |
+
border-color: var(--dm-line);
|
| 173 |
+
background: var(--dm-bg);
|
| 174 |
+
}
|
| 175 |
+
#tab-library .dda-dropzone.is-dragover {
|
| 176 |
+
border-color: var(--dm-accent);
|
| 177 |
+
background: var(--dm-accent-100);
|
| 178 |
+
}
|
| 179 |
+
#tab-library .dda-progress-bar { border-radius: 0; background: var(--dm-line); }
|
| 180 |
+
#tab-library .dda-progress-fill { background: var(--dm-accent); }
|
| 181 |
+
#tab-library .dda-storage-info summary { color: var(--dm-ink-muted); }
|
| 182 |
+
#tab-library .dda-storage-info summary:hover { color: var(--dm-accent); }
|
| 183 |
+
#tab-library .dda-path-code { border-radius: 0; border-color: var(--dm-line); background: var(--dm-bg); }
|
| 184 |
+
|
| 185 |
+
/* --- Same-area pairs --- */
|
| 186 |
+
#tab-library .dda-area-pair {
|
| 187 |
+
border-radius: 0;
|
| 188 |
+
border-color: var(--dm-line);
|
| 189 |
+
background: var(--dm-surface);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/* --- Image grid --- */
|
| 193 |
+
#tab-library .dda-grid { gap: 1px; background: var(--dm-line); border: 1px solid var(--dm-line); }
|
| 194 |
+
#tab-library .dda-card-img {
|
| 195 |
+
border-radius: 0;
|
| 196 |
+
border: none;
|
| 197 |
+
background: var(--dm-surface);
|
| 198 |
+
transition: none;
|
| 199 |
+
}
|
| 200 |
+
#tab-library .dda-card-img:hover { border-color: transparent; outline: 2px solid var(--dm-accent); outline-offset: -2px; }
|
| 201 |
+
#tab-library .dda-card-delete-btn { border-radius: 0; background: rgba(32, 30, 29, 0.68); }
|
| 202 |
+
#tab-library .dda-card-delete-btn:hover { background: var(--dm-accent); }
|
| 203 |
+
#tab-library .dda-card-img .meta { color: var(--dm-ink-muted); }
|
| 204 |
+
|
| 205 |
+
/* --- Pagination --- */
|
| 206 |
+
#tab-library .pagination button { border-radius: 0; }
|
| 207 |
+
#tab-library .pagination button.active { background: var(--dm-accent); border-color: var(--dm-accent); }
|
| 208 |
+
#tab-library .pagination button:hover:not(:disabled):not(.active) {
|
| 209 |
+
border-color: var(--dm-accent);
|
| 210 |
+
color: var(--dm-accent);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/* --- Modals (viewer / manage folders / bulk upload) --- */
|
| 214 |
+
#dda-image-viewer-modal .dda-image-viewer-wrap { border-radius: 0; border-color: var(--dm-line); background: var(--dm-bg); }
|
| 215 |
+
#dda-image-viewer-modal .dda-view-btn { border-radius: 0; }
|
| 216 |
+
#dda-image-viewer-modal .dda-view-btn.active { background: var(--dm-accent-100); color: var(--dm-accent); }
|
| 217 |
+
#dda-image-viewer-modal .dda-map-canvas { border-radius: 0; }
|
| 218 |
+
#dda-manage-modal .dda-manage-section h4,
|
| 219 |
+
#dda-bulk-upload-modal .dda-manage-section h4 { font-family: var(--dm-font); font-weight: 700; color: var(--dm-ink); }
|
| 220 |
+
#dda-bulk-upload-modal .dda-logs-table th { color: var(--dm-ink-muted); }
|
| 221 |
+
#dda-bulk-upload-modal .dda-logs-table td,
|
| 222 |
+
#dda-bulk-upload-modal .dda-logs-table th { border-bottom-color: var(--dm-line); }
|
| 223 |
+
|
| 224 |
+
@media (max-width: 900px) {
|
| 225 |
+
#tab-library .dda-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/* Required-field marker on the upload form's Zone/Village labels — these
|
| 229 |
+
are now mandatory (see upload_image()'s validation). */
|
| 230 |
+
#tab-library .dda-required-mark { color: var(--dm-accent); }
|
static/css/dda-modernist-logs.css
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist Audit Log & Exception Log theme (Claude Design reskin).
|
| 3 |
+
Companion to the "Modernist dashboard theme" block in dda.css
|
| 4 |
+
(search that file for #tab-home) — same visual language, same
|
| 5 |
+
shared --dm-* tokens declared once at :root in dda.css. Purely
|
| 6 |
+
visual: no ids/classes added or removed, no JS behavior changed.
|
| 7 |
+
|
| 8 |
+
Scoped to this page's own containers: #tab-audit-log and
|
| 9 |
+
#tab-exception-log. The one exception is the exception-detail
|
| 10 |
+
modal (#exception-detail-modal), which the base template renders
|
| 11 |
+
as a sibling of .dda-main-col (outside #tab-exception-log) so it
|
| 12 |
+
can overlay the whole viewport — it is still scoped, just under
|
| 13 |
+
its own page-unique id instead of the tab container.
|
| 14 |
+
============================================================ */
|
| 15 |
+
|
| 16 |
+
/* --- Headings / hero --- */
|
| 17 |
+
#tab-audit-log h1,
|
| 18 |
+
#tab-exception-log h1,
|
| 19 |
+
#tab-exception-log .card-header h3,
|
| 20 |
+
#tab-audit-log .card-header h3 {
|
| 21 |
+
font-family: var(--dm-font);
|
| 22 |
+
font-weight: 800;
|
| 23 |
+
letter-spacing: -0.01em;
|
| 24 |
+
color: var(--dm-ink);
|
| 25 |
+
}
|
| 26 |
+
#tab-audit-log .sub,
|
| 27 |
+
#tab-exception-log .sub {
|
| 28 |
+
color: var(--dm-ink-muted);
|
| 29 |
+
}
|
| 30 |
+
#tab-audit-log .dda-filter-meta,
|
| 31 |
+
#tab-exception-log .dda-filter-meta {
|
| 32 |
+
color: var(--dm-ink-dim);
|
| 33 |
+
font-family: var(--dm-font);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* --- Flat surfaces instead of soft shadow cards --- */
|
| 37 |
+
#tab-audit-log .card,
|
| 38 |
+
#tab-exception-log .card,
|
| 39 |
+
#exception-detail-modal .card {
|
| 40 |
+
background: var(--dm-surface);
|
| 41 |
+
border: 1px solid var(--dm-line);
|
| 42 |
+
border-radius: 0;
|
| 43 |
+
box-shadow: none;
|
| 44 |
+
}
|
| 45 |
+
#tab-audit-log .card-header,
|
| 46 |
+
#tab-exception-log .card-header,
|
| 47 |
+
#exception-detail-modal .card-header {
|
| 48 |
+
border-bottom: 2px solid var(--dm-line);
|
| 49 |
+
}
|
| 50 |
+
#tab-audit-log .dda-filter-bar,
|
| 51 |
+
#tab-exception-log .dda-filter-bar {
|
| 52 |
+
background: var(--dm-surface);
|
| 53 |
+
border: 1px solid var(--dm-line);
|
| 54 |
+
border-radius: 0;
|
| 55 |
+
}
|
| 56 |
+
#tab-audit-log .dda-filter-field,
|
| 57 |
+
#tab-exception-log .dda-filter-field {
|
| 58 |
+
font-family: var(--dm-font);
|
| 59 |
+
color: var(--dm-ink-muted);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* --- Buttons — flat accent instead of the app-wide purple gradient --- */
|
| 63 |
+
#tab-audit-log .btn,
|
| 64 |
+
#tab-exception-log .btn,
|
| 65 |
+
#exception-detail-modal .btn {
|
| 66 |
+
border-radius: 0;
|
| 67 |
+
font-family: var(--dm-font);
|
| 68 |
+
font-weight: 700;
|
| 69 |
+
}
|
| 70 |
+
#tab-audit-log .btn-primary,
|
| 71 |
+
#tab-exception-log .btn-primary {
|
| 72 |
+
background: var(--dm-accent);
|
| 73 |
+
}
|
| 74 |
+
#tab-audit-log .btn-primary:hover,
|
| 75 |
+
#tab-exception-log .btn-primary:hover {
|
| 76 |
+
background: var(--dm-accent-hover);
|
| 77 |
+
filter: none;
|
| 78 |
+
box-shadow: none;
|
| 79 |
+
}
|
| 80 |
+
#tab-audit-log .btn-secondary,
|
| 81 |
+
#tab-exception-log .btn-secondary,
|
| 82 |
+
#exception-detail-modal .btn-secondary {
|
| 83 |
+
background: transparent;
|
| 84 |
+
border-color: var(--dm-line);
|
| 85 |
+
color: var(--dm-ink);
|
| 86 |
+
}
|
| 87 |
+
#tab-audit-log .btn-secondary:hover,
|
| 88 |
+
#tab-exception-log .btn-secondary:hover,
|
| 89 |
+
#exception-detail-modal .btn-secondary:hover {
|
| 90 |
+
background: var(--dm-bg);
|
| 91 |
+
border-color: var(--dm-line);
|
| 92 |
+
}
|
| 93 |
+
#tab-audit-log .btn-ghost,
|
| 94 |
+
#tab-exception-log .btn-ghost {
|
| 95 |
+
color: var(--dm-accent);
|
| 96 |
+
}
|
| 97 |
+
#tab-audit-log .btn-ghost:hover,
|
| 98 |
+
#tab-exception-log .btn-ghost:hover {
|
| 99 |
+
background: var(--dm-accent-100);
|
| 100 |
+
color: var(--dm-accent-hover);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* --- Filter inputs: selects, search box, date trigger --- */
|
| 104 |
+
#tab-audit-log .dda-filter-field select,
|
| 105 |
+
#tab-audit-log .dda-filter-field .dda-search,
|
| 106 |
+
#tab-audit-log input.dda-date-input:not(.dda-date-native),
|
| 107 |
+
#tab-audit-log .dda-date-trigger,
|
| 108 |
+
#tab-exception-log .dda-filter-field select,
|
| 109 |
+
#tab-exception-log .dda-filter-field .dda-search,
|
| 110 |
+
#tab-exception-log input.dda-date-input:not(.dda-date-native),
|
| 111 |
+
#tab-exception-log .dda-date-trigger {
|
| 112 |
+
border-radius: 0;
|
| 113 |
+
border-color: var(--dm-line);
|
| 114 |
+
background: var(--dm-surface);
|
| 115 |
+
color: var(--dm-ink);
|
| 116 |
+
}
|
| 117 |
+
#tab-exception-log .dda-check {
|
| 118 |
+
font-family: var(--dm-font);
|
| 119 |
+
color: var(--dm-ink-muted);
|
| 120 |
+
}
|
| 121 |
+
#tab-exception-log .dda-check input[type="checkbox"] {
|
| 122 |
+
accent-color: var(--dm-accent);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* --- Tables — uppercase hairline headers, flat row dividers --- */
|
| 126 |
+
#tab-audit-log .dda-logs-table th,
|
| 127 |
+
#tab-exception-log .dda-logs-table th {
|
| 128 |
+
font-family: var(--dm-font);
|
| 129 |
+
font-size: 0.68rem;
|
| 130 |
+
font-weight: 800;
|
| 131 |
+
letter-spacing: 0.06em;
|
| 132 |
+
text-transform: uppercase;
|
| 133 |
+
color: var(--dm-ink-muted);
|
| 134 |
+
background: var(--dm-surface);
|
| 135 |
+
border-bottom: 2px solid var(--dm-line);
|
| 136 |
+
}
|
| 137 |
+
#tab-audit-log .dda-logs-table td,
|
| 138 |
+
#tab-exception-log .dda-logs-table td {
|
| 139 |
+
border-bottom: 1px solid var(--dm-line);
|
| 140 |
+
color: var(--dm-ink);
|
| 141 |
+
}
|
| 142 |
+
#tab-audit-log .dda-logs-table tbody tr:hover,
|
| 143 |
+
#tab-exception-log .dda-logs-table tbody tr:hover {
|
| 144 |
+
background: var(--dm-bg);
|
| 145 |
+
}
|
| 146 |
+
#tab-exception-log .dda-log-level {
|
| 147 |
+
border-radius: 2px;
|
| 148 |
+
font-family: var(--dm-font);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* --- Badges / status tags — sharp corners instead of pills --- */
|
| 152 |
+
#tab-audit-log .dda-badge,
|
| 153 |
+
#tab-exception-log .dda-badge,
|
| 154 |
+
#tab-audit-log .dda-action-badge,
|
| 155 |
+
#tab-exception-log .dda-action-badge {
|
| 156 |
+
border-radius: 2px;
|
| 157 |
+
font-family: var(--dm-font);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* --- Pagination --- */
|
| 161 |
+
#tab-audit-log .pagination button,
|
| 162 |
+
#tab-exception-log .pagination button {
|
| 163 |
+
border-radius: 0;
|
| 164 |
+
border-color: var(--dm-line);
|
| 165 |
+
background: var(--dm-surface);
|
| 166 |
+
color: var(--dm-ink-muted);
|
| 167 |
+
font-family: var(--dm-font);
|
| 168 |
+
}
|
| 169 |
+
#tab-audit-log .pagination button:hover:not(:disabled),
|
| 170 |
+
#tab-exception-log .pagination button:hover:not(:disabled) {
|
| 171 |
+
background: var(--dm-accent-100);
|
| 172 |
+
border-color: var(--dm-accent);
|
| 173 |
+
color: var(--dm-ink);
|
| 174 |
+
}
|
| 175 |
+
#tab-audit-log .pagination button.active,
|
| 176 |
+
#tab-exception-log .pagination button.active {
|
| 177 |
+
background: var(--dm-accent);
|
| 178 |
+
border-color: var(--dm-accent);
|
| 179 |
+
color: #fff;
|
| 180 |
+
font-weight: 700;
|
| 181 |
+
}
|
| 182 |
+
#tab-audit-log .pagination .page-jump input,
|
| 183 |
+
#tab-exception-log .pagination .page-jump input {
|
| 184 |
+
border-radius: 0;
|
| 185 |
+
border-color: var(--dm-line);
|
| 186 |
+
background: var(--dm-surface);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/* --- Exception Log: Exceptions / Application Logs segmented switch --- */
|
| 190 |
+
#tab-exception-log .dda-mode-toggle {
|
| 191 |
+
border: 1px solid var(--dm-line);
|
| 192 |
+
border-radius: 0;
|
| 193 |
+
overflow: hidden;
|
| 194 |
+
}
|
| 195 |
+
#tab-exception-log .dda-mode-btn {
|
| 196 |
+
font-family: var(--dm-font);
|
| 197 |
+
font-weight: 700;
|
| 198 |
+
font-size: 0.78rem;
|
| 199 |
+
letter-spacing: 0.02em;
|
| 200 |
+
color: var(--dm-ink-muted);
|
| 201 |
+
padding: 0.55rem 1.1rem;
|
| 202 |
+
}
|
| 203 |
+
#tab-exception-log .dda-mode-btn + .dda-mode-btn {
|
| 204 |
+
border-left: 1px solid var(--dm-line);
|
| 205 |
+
}
|
| 206 |
+
#tab-exception-log .dda-mode-btn:hover {
|
| 207 |
+
background: var(--dm-bg);
|
| 208 |
+
}
|
| 209 |
+
#tab-exception-log .dda-mode-btn.is-active {
|
| 210 |
+
background: var(--dm-ink);
|
| 211 |
+
color: #fff;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/* --- Exception Log: stat cards — flat hairline grid, no icons,
|
| 215 |
+
big Archivo numbers (icons stay in the DOM, just visually hidden
|
| 216 |
+
to match the flat numeric-tile look; nothing is removed). --- */
|
| 217 |
+
#tab-exception-log .dda-stat-cards {
|
| 218 |
+
display: grid;
|
| 219 |
+
grid-template-columns: repeat(4, 1fr);
|
| 220 |
+
gap: 1px;
|
| 221 |
+
background: var(--dm-line);
|
| 222 |
+
border: 1px solid var(--dm-line);
|
| 223 |
+
margin: 0 0 1.25rem;
|
| 224 |
+
}
|
| 225 |
+
#tab-exception-log .dda-stat-card {
|
| 226 |
+
background: var(--dm-surface);
|
| 227 |
+
border: none;
|
| 228 |
+
border-radius: 0;
|
| 229 |
+
padding: 1rem 1.1rem;
|
| 230 |
+
text-align: left;
|
| 231 |
+
}
|
| 232 |
+
#tab-exception-log .dda-stat-icon {
|
| 233 |
+
display: none;
|
| 234 |
+
}
|
| 235 |
+
#tab-exception-log .dda-stat-value {
|
| 236 |
+
font-family: var(--dm-font);
|
| 237 |
+
font-weight: 800;
|
| 238 |
+
font-size: 1.65rem;
|
| 239 |
+
line-height: 1.1;
|
| 240 |
+
color: var(--dm-ink);
|
| 241 |
+
}
|
| 242 |
+
#tab-exception-log .dda-stat-card.is-danger .dda-stat-value {
|
| 243 |
+
color: var(--danger);
|
| 244 |
+
}
|
| 245 |
+
#tab-exception-log .dda-stat-card.is-warn .dda-stat-value {
|
| 246 |
+
color: #b45309;
|
| 247 |
+
}
|
| 248 |
+
#tab-exception-log .dda-stat-label {
|
| 249 |
+
margin-top: 0.35rem;
|
| 250 |
+
font-size: 0.72rem;
|
| 251 |
+
font-weight: 700;
|
| 252 |
+
text-transform: uppercase;
|
| 253 |
+
letter-spacing: 0.05em;
|
| 254 |
+
color: var(--dm-ink-muted);
|
| 255 |
+
}
|
| 256 |
+
@media (max-width: 720px) {
|
| 257 |
+
#tab-exception-log .dda-stat-cards { grid-template-columns: repeat(2, 1fr); }
|
| 258 |
+
}
|
| 259 |
+
@media (max-width: 420px) {
|
| 260 |
+
#tab-exception-log .dda-stat-cards { grid-template-columns: 1fr; }
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
/* --- Exception detail modal --- */
|
| 264 |
+
#exception-detail-modal .dda-modal-panel {
|
| 265 |
+
border-radius: 0;
|
| 266 |
+
}
|
| 267 |
+
#exception-detail-modal .dda-log-msg {
|
| 268 |
+
color: var(--dm-ink);
|
| 269 |
+
}
|
static/css/dda-modernist-masters-profile.css
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist theme for Masters (Zones / Villages) and Profile.
|
| 3 |
+
Same visual language as the #tab-home redesign in dda.css
|
| 4 |
+
(flat surfaces, hairline dividers, sharp corners, one accent
|
| 5 |
+
color, Archivo for headings) — reuses the shared --dm-* tokens
|
| 6 |
+
declared at :root in dda.css. Purely visual: every rule below
|
| 7 |
+
is scoped under #tab-masters-zones, #tab-masters-villages or
|
| 8 |
+
#tab-profile, and no element id/behavior is touched.
|
| 9 |
+
============================================================ */
|
| 10 |
+
|
| 11 |
+
/* ---------- Shared: Masters (Zones + Villages) ---------- */
|
| 12 |
+
|
| 13 |
+
#tab-masters-zones h1,
|
| 14 |
+
#tab-masters-villages h1,
|
| 15 |
+
#tab-masters-zones .card-header h3,
|
| 16 |
+
#tab-masters-villages .card-header h3,
|
| 17 |
+
#tab-masters-zones .dda-reports-table,
|
| 18 |
+
#tab-masters-villages .dda-reports-table {
|
| 19 |
+
font-family: var(--dm-font);
|
| 20 |
+
}
|
| 21 |
+
#tab-masters-zones h1,
|
| 22 |
+
#tab-masters-villages h1 {
|
| 23 |
+
font-weight: 800;
|
| 24 |
+
letter-spacing: -0.01em;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Flat, hairline-bordered surfaces instead of soft shadow cards */
|
| 28 |
+
#tab-masters-zones .card,
|
| 29 |
+
#tab-masters-villages .card {
|
| 30 |
+
background: var(--dm-surface);
|
| 31 |
+
border: 1px solid var(--dm-line);
|
| 32 |
+
border-radius: 0;
|
| 33 |
+
box-shadow: none;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
#tab-masters-zones .card-header,
|
| 37 |
+
#tab-masters-villages .card-header {
|
| 38 |
+
border-bottom: 2px solid var(--dm-line);
|
| 39 |
+
}
|
| 40 |
+
#tab-masters-zones .card-header h3,
|
| 41 |
+
#tab-masters-villages .card-header h3 {
|
| 42 |
+
font-weight: 700;
|
| 43 |
+
color: var(--dm-ink);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* Title row: heading + primary action on one line */
|
| 47 |
+
#tab-masters-zones .dda-hero.dda-hero-row,
|
| 48 |
+
#tab-masters-villages .dda-hero.dda-hero-row {
|
| 49 |
+
align-items: center;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* Compact standalone search bar, mirroring the mockup's search card */
|
| 53 |
+
#tab-masters-zones .dda-masters-search-card,
|
| 54 |
+
#tab-masters-villages .dda-masters-search-card {
|
| 55 |
+
display: flex;
|
| 56 |
+
align-items: center;
|
| 57 |
+
flex-wrap: wrap;
|
| 58 |
+
gap: 0.75rem;
|
| 59 |
+
padding: 0.85rem 1.1rem;
|
| 60 |
+
margin-bottom: 1.25rem;
|
| 61 |
+
}
|
| 62 |
+
#tab-masters-zones .dda-masters-search-card .dda-search,
|
| 63 |
+
#tab-masters-villages .dda-masters-search-card .dda-search {
|
| 64 |
+
max-width: 320px;
|
| 65 |
+
margin-bottom: 0;
|
| 66 |
+
border-radius: 0;
|
| 67 |
+
background: var(--dm-surface);
|
| 68 |
+
border-color: var(--dm-line);
|
| 69 |
+
}
|
| 70 |
+
#tab-masters-villages .dda-masters-search-card select#masters-zone-filter {
|
| 71 |
+
max-width: 220px;
|
| 72 |
+
border-radius: 0;
|
| 73 |
+
background: var(--dm-surface);
|
| 74 |
+
border-color: var(--dm-line);
|
| 75 |
+
padding: 0.45rem 0.65rem;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Buttons — flat accent instead of the app-wide purple gradient */
|
| 79 |
+
#tab-masters-zones .btn,
|
| 80 |
+
#tab-masters-villages .btn,
|
| 81 |
+
#tab-masters-zones .dda-modal .btn,
|
| 82 |
+
#tab-masters-villages .dda-modal .btn {
|
| 83 |
+
border-radius: 0;
|
| 84 |
+
font-family: var(--dm-font);
|
| 85 |
+
font-weight: 700;
|
| 86 |
+
}
|
| 87 |
+
#tab-masters-zones .btn-primary,
|
| 88 |
+
#tab-masters-villages .btn-primary {
|
| 89 |
+
background: var(--dm-accent);
|
| 90 |
+
}
|
| 91 |
+
#tab-masters-zones .btn-primary:hover,
|
| 92 |
+
#tab-masters-villages .btn-primary:hover {
|
| 93 |
+
background: var(--dm-accent-hover);
|
| 94 |
+
filter: none;
|
| 95 |
+
box-shadow: none;
|
| 96 |
+
}
|
| 97 |
+
#tab-masters-zones .btn-secondary,
|
| 98 |
+
#tab-masters-villages .btn-secondary {
|
| 99 |
+
background: transparent;
|
| 100 |
+
border-color: var(--dm-line);
|
| 101 |
+
color: var(--dm-ink);
|
| 102 |
+
}
|
| 103 |
+
#tab-masters-zones .btn-secondary:hover,
|
| 104 |
+
#tab-masters-villages .btn-secondary:hover {
|
| 105 |
+
background: var(--dm-bg);
|
| 106 |
+
border-color: var(--dm-line);
|
| 107 |
+
}
|
| 108 |
+
#tab-masters-zones .btn-danger,
|
| 109 |
+
#tab-masters-villages .btn-danger {
|
| 110 |
+
box-shadow: none;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/* Table — uppercase hairline headers instead of boxed cells */
|
| 114 |
+
#tab-masters-zones .dda-reports-table,
|
| 115 |
+
#tab-masters-villages .dda-reports-table {
|
| 116 |
+
border-collapse: collapse;
|
| 117 |
+
}
|
| 118 |
+
#tab-masters-zones .dda-reports-table th,
|
| 119 |
+
#tab-masters-villages .dda-reports-table th {
|
| 120 |
+
border: none;
|
| 121 |
+
border-bottom: 2px solid var(--dm-line);
|
| 122 |
+
font-size: 0.68rem;
|
| 123 |
+
letter-spacing: 0.06em;
|
| 124 |
+
text-transform: uppercase;
|
| 125 |
+
color: var(--dm-ink-muted);
|
| 126 |
+
font-weight: 700;
|
| 127 |
+
background: transparent;
|
| 128 |
+
padding: 0.55rem 0.6rem;
|
| 129 |
+
}
|
| 130 |
+
#tab-masters-zones .dda-reports-table td,
|
| 131 |
+
#tab-masters-villages .dda-reports-table td {
|
| 132 |
+
border: none;
|
| 133 |
+
border-bottom: 1px solid var(--dm-line);
|
| 134 |
+
padding: 0.55rem 0.6rem;
|
| 135 |
+
color: var(--dm-ink);
|
| 136 |
+
}
|
| 137 |
+
#tab-masters-zones .dda-reports-table tbody tr:hover,
|
| 138 |
+
#tab-masters-villages .dda-reports-table tbody tr:hover {
|
| 139 |
+
background: var(--dm-bg);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/* Modal form fields — flat inputs/selects to match the rest of the page.
|
| 143 |
+
#masters-modal sits outside #tab-masters-zones/#tab-masters-villages in
|
| 144 |
+
the DOM (it's appended at body level so it can overlay the whole page),
|
| 145 |
+
so it's scoped via the body[data-masters] attribute each template already
|
| 146 |
+
sets instead — equivalent scoping, since each template only ever renders
|
| 147 |
+
its own single modal. */
|
| 148 |
+
body[data-masters="zones"] #masters-modal input[type="text"],
|
| 149 |
+
body[data-masters="villages"] #masters-modal input[type="text"],
|
| 150 |
+
body[data-masters="zones"] #masters-modal select,
|
| 151 |
+
body[data-masters="villages"] #masters-modal select,
|
| 152 |
+
body[data-masters="zones"] #masters-modal .card,
|
| 153 |
+
body[data-masters="villages"] #masters-modal .card,
|
| 154 |
+
body[data-masters="zones"] #masters-modal .dda-modal-panel,
|
| 155 |
+
body[data-masters="villages"] #masters-modal .dda-modal-panel {
|
| 156 |
+
border-radius: 0;
|
| 157 |
+
background: var(--dm-surface);
|
| 158 |
+
border-color: var(--dm-line);
|
| 159 |
+
box-shadow: none;
|
| 160 |
+
}
|
| 161 |
+
body[data-masters="zones"] #masters-modal .btn,
|
| 162 |
+
body[data-masters="villages"] #masters-modal .btn {
|
| 163 |
+
border-radius: 0;
|
| 164 |
+
font-family: var(--dm-font);
|
| 165 |
+
font-weight: 700;
|
| 166 |
+
}
|
| 167 |
+
body[data-masters="zones"] #masters-modal .btn-primary,
|
| 168 |
+
body[data-masters="villages"] #masters-modal .btn-primary {
|
| 169 |
+
background: var(--dm-accent);
|
| 170 |
+
}
|
| 171 |
+
body[data-masters="zones"] #masters-modal .btn-primary:hover,
|
| 172 |
+
body[data-masters="villages"] #masters-modal .btn-primary:hover {
|
| 173 |
+
background: var(--dm-accent-hover);
|
| 174 |
+
filter: none;
|
| 175 |
+
box-shadow: none;
|
| 176 |
+
}
|
| 177 |
+
body[data-masters="zones"] #masters-modal .card-header,
|
| 178 |
+
body[data-masters="villages"] #masters-modal .card-header {
|
| 179 |
+
border-bottom: 2px solid var(--dm-line);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/* ---------- Profile ---------- */
|
| 183 |
+
|
| 184 |
+
#tab-profile h1,
|
| 185 |
+
#tab-profile .card-header h3,
|
| 186 |
+
#tab-profile #profile-name,
|
| 187 |
+
#tab-profile .dda-profile-avatar {
|
| 188 |
+
font-family: var(--dm-font);
|
| 189 |
+
}
|
| 190 |
+
#tab-profile h1 { font-weight: 800; letter-spacing: -0.01em; }
|
| 191 |
+
|
| 192 |
+
#tab-profile .card {
|
| 193 |
+
background: var(--dm-surface);
|
| 194 |
+
border: 1px solid var(--dm-line);
|
| 195 |
+
border-radius: 0;
|
| 196 |
+
box-shadow: none;
|
| 197 |
+
}
|
| 198 |
+
#tab-profile .card-header {
|
| 199 |
+
border-bottom: 2px solid var(--dm-line);
|
| 200 |
+
}
|
| 201 |
+
#tab-profile .card-header h3 {
|
| 202 |
+
font-weight: 700;
|
| 203 |
+
color: var(--dm-ink);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* Narrower, fixed-width profile summary column like the mockup */
|
| 207 |
+
#tab-profile .dda-admin-2col-roles {
|
| 208 |
+
grid-template-columns: 280px minmax(0, 1fr);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
#tab-profile .dda-profile-avatar {
|
| 212 |
+
border-radius: 0;
|
| 213 |
+
background: var(--dm-ink);
|
| 214 |
+
font-weight: 800;
|
| 215 |
+
}
|
| 216 |
+
#tab-profile .dda-profile-badge {
|
| 217 |
+
border-radius: 0;
|
| 218 |
+
background: var(--dm-accent-100);
|
| 219 |
+
color: var(--dm-accent);
|
| 220 |
+
font-weight: 700;
|
| 221 |
+
}
|
| 222 |
+
#tab-profile .dda-profile-meta {
|
| 223 |
+
border-top: 1px solid var(--dm-line);
|
| 224 |
+
}
|
| 225 |
+
#tab-profile .dda-profile-meta-label { color: var(--dm-ink-muted); }
|
| 226 |
+
#tab-profile .dda-profile-meta-value { color: var(--dm-ink); }
|
| 227 |
+
|
| 228 |
+
/* Buttons — flat accent instead of the app-wide purple gradient */
|
| 229 |
+
#tab-profile .btn {
|
| 230 |
+
border-radius: 0;
|
| 231 |
+
font-family: var(--dm-font);
|
| 232 |
+
font-weight: 700;
|
| 233 |
+
}
|
| 234 |
+
#tab-profile .btn-primary { background: var(--dm-accent); }
|
| 235 |
+
#tab-profile .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
|
| 236 |
+
|
| 237 |
+
/* Flat inputs/selects inside both forms */
|
| 238 |
+
#tab-profile input[type="text"],
|
| 239 |
+
#tab-profile input[type="email"],
|
| 240 |
+
#tab-profile input[type="tel"],
|
| 241 |
+
#tab-profile input[type="password"],
|
| 242 |
+
#tab-profile select {
|
| 243 |
+
border-radius: 0;
|
| 244 |
+
background: var(--dm-surface);
|
| 245 |
+
border-color: var(--dm-line);
|
| 246 |
+
}
|
| 247 |
+
#tab-profile .dda-phone-input {
|
| 248 |
+
border-radius: 0;
|
| 249 |
+
border-color: var(--dm-line);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
#tab-profile .alert { border-radius: 0; }
|
static/css/dda-modernist-reports.css
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist Reports theme (from the Claude Design redesign).
|
| 3 |
+
Covers two screens:
|
| 4 |
+
- Reports list -> scoped under #tab-reports
|
| 5 |
+
- Report Detail page -> scoped under #page-report-detail
|
| 6 |
+
Same visual language as the Dashboard theme already shipped in
|
| 7 |
+
dda.css (search "#tab-home" there): flat surfaces, hairline
|
| 8 |
+
dividers, sharp corners, one accent color, Archivo for headings
|
| 9 |
+
and numbers. Reuses the shared --dm-* tokens declared once at
|
| 10 |
+
:root in dda.css — nothing is redeclared here. Every selector
|
| 11 |
+
below is scoped to one of the two pages above, so no other
|
| 12 |
+
screen (including the shared result modal used elsewhere) is
|
| 13 |
+
affected by this file.
|
| 14 |
+
============================================================ */
|
| 15 |
+
|
| 16 |
+
/* ================= Reports list (#tab-reports) ================= */
|
| 17 |
+
|
| 18 |
+
#tab-reports h1,
|
| 19 |
+
#tab-reports .card-title,
|
| 20 |
+
#tab-reports h3 {
|
| 21 |
+
font-family: var(--dm-font);
|
| 22 |
+
}
|
| 23 |
+
#tab-reports h1 {
|
| 24 |
+
font-weight: 800;
|
| 25 |
+
letter-spacing: -0.01em;
|
| 26 |
+
margin: 0 0 0.35rem;
|
| 27 |
+
color: var(--dm-ink);
|
| 28 |
+
}
|
| 29 |
+
#tab-reports .dda-reports-hero .sub {
|
| 30 |
+
margin: 0;
|
| 31 |
+
color: var(--dm-ink-muted);
|
| 32 |
+
}
|
| 33 |
+
#tab-reports .dda-dash-hero-actions { align-items: center; }
|
| 34 |
+
|
| 35 |
+
/* Flat, hairline-bordered surfaces instead of soft shadow cards */
|
| 36 |
+
#tab-reports .card {
|
| 37 |
+
background: var(--dm-surface);
|
| 38 |
+
border: 1px solid var(--dm-line);
|
| 39 |
+
border-radius: 0;
|
| 40 |
+
box-shadow: none;
|
| 41 |
+
}
|
| 42 |
+
#tab-reports .dda-reports-filter-card { padding: 1rem 1.1rem; }
|
| 43 |
+
#tab-reports .dda-filter-bar {
|
| 44 |
+
border: none;
|
| 45 |
+
padding: 0;
|
| 46 |
+
margin: 0;
|
| 47 |
+
background: transparent;
|
| 48 |
+
}
|
| 49 |
+
#tab-reports .dda-filter-field { color: var(--dm-ink-muted); }
|
| 50 |
+
#tab-reports .dda-filter-meta { color: var(--dm-ink-muted); }
|
| 51 |
+
|
| 52 |
+
/* Buttons — flat accent instead of the app-wide purple gradient,
|
| 53 |
+
scoped to this page only so other pages are untouched. */
|
| 54 |
+
#tab-reports .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
|
| 55 |
+
#tab-reports .btn-primary { background: var(--dm-accent); }
|
| 56 |
+
#tab-reports .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
|
| 57 |
+
#tab-reports .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
|
| 58 |
+
#tab-reports .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
|
| 59 |
+
#tab-reports .btn-ghost { color: var(--dm-accent); }
|
| 60 |
+
#tab-reports .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
|
| 61 |
+
|
| 62 |
+
/* Table — uppercase hairline header, row-hover, no per-cell boxes */
|
| 63 |
+
#tab-reports .dda-reports-table {
|
| 64 |
+
font-family: var(--font-sans);
|
| 65 |
+
}
|
| 66 |
+
#tab-reports .dda-reports-table th {
|
| 67 |
+
font-size: 0.68rem;
|
| 68 |
+
letter-spacing: 0.06em;
|
| 69 |
+
text-transform: uppercase;
|
| 70 |
+
color: var(--dm-ink-muted);
|
| 71 |
+
font-family: var(--dm-font);
|
| 72 |
+
border: none;
|
| 73 |
+
border-bottom: 2px solid var(--dm-line);
|
| 74 |
+
background: transparent;
|
| 75 |
+
}
|
| 76 |
+
#tab-reports .dda-reports-table td {
|
| 77 |
+
border: none;
|
| 78 |
+
border-bottom: 1px solid var(--dm-line);
|
| 79 |
+
}
|
| 80 |
+
#tab-reports .dda-reports-table tbody tr:hover { background: var(--dm-bg); }
|
| 81 |
+
|
| 82 |
+
/* Change-type / status colors are semantic (CHANGE_TYPE_COLORS) and are
|
| 83 |
+
left untouched here — only the pill shape follows the flat theme. */
|
| 84 |
+
#tab-reports .dda-changetype-pill { border-radius: 2px; }
|
| 85 |
+
|
| 86 |
+
/* Pagination */
|
| 87 |
+
#tab-reports .pagination button {
|
| 88 |
+
border-radius: 0;
|
| 89 |
+
font-family: var(--dm-font);
|
| 90 |
+
}
|
| 91 |
+
#tab-reports .pagination button:hover:not(:disabled) {
|
| 92 |
+
background: var(--dm-accent-100);
|
| 93 |
+
border-color: var(--dm-line);
|
| 94 |
+
color: var(--dm-ink);
|
| 95 |
+
}
|
| 96 |
+
#tab-reports .pagination button.active {
|
| 97 |
+
background: var(--dm-accent);
|
| 98 |
+
border-color: var(--dm-accent);
|
| 99 |
+
color: #fff;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
@media (max-width: 720px) {
|
| 103 |
+
#tab-reports .dda-dash-hero-actions { width: 100%; justify-content: space-between; }
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* ================= Report Detail (#page-report-detail) ================= */
|
| 107 |
+
|
| 108 |
+
#page-report-detail {
|
| 109 |
+
background: var(--dm-bg);
|
| 110 |
+
font-family: var(--font-sans);
|
| 111 |
+
}
|
| 112 |
+
/* Interactive viewer needs the full viewport (static report cards used 1100px). */
|
| 113 |
+
#page-report-detail .dda-report-page {
|
| 114 |
+
max-width: none !important;
|
| 115 |
+
padding: 0 !important;
|
| 116 |
+
width: 100%;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* Header bar — flat surface, hairline bottom border, Archivo brand type */
|
| 120 |
+
#page-report-detail .dda-header.dda-report-header {
|
| 121 |
+
background: var(--dm-surface);
|
| 122 |
+
border: 1px solid var(--dm-line);
|
| 123 |
+
border-bottom: 2px solid var(--dm-line);
|
| 124 |
+
margin: 0;
|
| 125 |
+
padding: 1rem 1.25rem;
|
| 126 |
+
}
|
| 127 |
+
#page-report-detail .app-brand span {
|
| 128 |
+
font-family: var(--dm-font);
|
| 129 |
+
font-weight: 800;
|
| 130 |
+
font-size: 0.82rem;
|
| 131 |
+
letter-spacing: 0.03em;
|
| 132 |
+
text-transform: uppercase;
|
| 133 |
+
color: var(--dm-ink);
|
| 134 |
+
}
|
| 135 |
+
#page-report-detail .dda-report-actions .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
|
| 136 |
+
#page-report-detail .dda-report-actions .btn-primary { background: var(--dm-accent); }
|
| 137 |
+
#page-report-detail .dda-report-actions .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
|
| 138 |
+
#page-report-detail .dda-report-actions .btn-primary:disabled { background: var(--dm-line); color: var(--dm-ink-dim); }
|
| 139 |
+
#page-report-detail .dda-report-actions .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
|
| 140 |
+
#page-report-detail .dda-report-actions .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
|
| 141 |
+
|
| 142 |
+
/* Cards — flat, hairline-bordered, sharp corners */
|
| 143 |
+
#page-report-detail .card {
|
| 144 |
+
background: var(--dm-surface);
|
| 145 |
+
border: 1px solid var(--dm-line);
|
| 146 |
+
border-radius: 0;
|
| 147 |
+
box-shadow: none;
|
| 148 |
+
padding: 1.4rem 1.5rem;
|
| 149 |
+
}
|
| 150 |
+
#page-report-detail .card + .card { margin-top: 1.25rem; }
|
| 151 |
+
#page-report-detail .card h1,
|
| 152 |
+
#page-report-detail .card h3 {
|
| 153 |
+
font-family: var(--dm-font);
|
| 154 |
+
color: var(--dm-ink);
|
| 155 |
+
}
|
| 156 |
+
#page-report-detail #report-title { font-weight: 800; letter-spacing: -0.01em; font-size: 1.5rem; margin: 0 0 0.35rem; }
|
| 157 |
+
#page-report-detail .card h3 { font-weight: 700; font-size: 0.95rem; margin: 0 0 0.9rem; }
|
| 158 |
+
#page-report-detail #report-meta { color: var(--dm-ink-muted); }
|
| 159 |
+
|
| 160 |
+
/* Summary stats — hairline grid of flat cells instead of a loose flex row */
|
| 161 |
+
#page-report-detail .dda-report-stats {
|
| 162 |
+
display: grid;
|
| 163 |
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
| 164 |
+
gap: 1px;
|
| 165 |
+
background: var(--dm-line);
|
| 166 |
+
border: 1px solid var(--dm-line);
|
| 167 |
+
margin-top: 1rem;
|
| 168 |
+
}
|
| 169 |
+
#page-report-detail .dda-stat {
|
| 170 |
+
background: var(--dm-surface);
|
| 171 |
+
padding: 0.85rem 0.9rem;
|
| 172 |
+
}
|
| 173 |
+
#page-report-detail .dda-stat .label {
|
| 174 |
+
font-size: 0.68rem;
|
| 175 |
+
letter-spacing: 0.05em;
|
| 176 |
+
text-transform: uppercase;
|
| 177 |
+
color: var(--dm-ink-muted);
|
| 178 |
+
}
|
| 179 |
+
#page-report-detail .dda-stat .value {
|
| 180 |
+
font-family: var(--dm-font);
|
| 181 |
+
font-weight: 800;
|
| 182 |
+
font-size: 1.35rem;
|
| 183 |
+
color: var(--dm-ink);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
#page-report-detail .dda-report-review-summary {
|
| 187 |
+
margin: 0.85rem 0 0;
|
| 188 |
+
font-size: 0.85rem;
|
| 189 |
+
}
|
| 190 |
+
#page-report-detail .dda-report-review-summary strong { color: var(--dm-ink); }
|
| 191 |
+
#page-report-detail .dda-report-actions-row {
|
| 192 |
+
display: flex;
|
| 193 |
+
flex-wrap: wrap;
|
| 194 |
+
gap: 0.6rem;
|
| 195 |
+
margin-top: 0.85rem;
|
| 196 |
+
}
|
| 197 |
+
#page-report-detail .dda-report-actions-row .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
|
| 198 |
+
#page-report-detail .dda-report-actions-row .btn-primary { background: var(--dm-accent); }
|
| 199 |
+
#page-report-detail .dda-report-actions-row .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
|
| 200 |
+
#page-report-detail .dda-report-actions-row .btn-primary:disabled { background: var(--dm-line); color: var(--dm-ink-dim); }
|
| 201 |
+
#page-report-detail .dda-report-actions-row .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
|
| 202 |
+
#page-report-detail .dda-report-actions-row .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
|
| 203 |
+
#page-report-detail #report-submit-msg { margin-top: 0.5rem; font-size: 0.8rem; }
|
| 204 |
+
|
| 205 |
+
/* Overlay image — flat bordered frame with a small labeled chip,
|
| 206 |
+
same treatment as the Before/After panel in the design mockup. */
|
| 207 |
+
#page-report-detail .dda-report-overlay-frame {
|
| 208 |
+
position: relative;
|
| 209 |
+
display: inline-block;
|
| 210 |
+
max-width: 100%;
|
| 211 |
+
}
|
| 212 |
+
#page-report-detail .dda-report-overlay-chip {
|
| 213 |
+
position: absolute;
|
| 214 |
+
top: 0.65rem;
|
| 215 |
+
left: 0.65rem;
|
| 216 |
+
z-index: 1;
|
| 217 |
+
background: var(--dm-surface);
|
| 218 |
+
border: 1px solid var(--dm-line);
|
| 219 |
+
color: var(--dm-ink);
|
| 220 |
+
font-family: var(--dm-font);
|
| 221 |
+
font-weight: 700;
|
| 222 |
+
font-size: 0.68rem;
|
| 223 |
+
letter-spacing: 0.04em;
|
| 224 |
+
text-transform: uppercase;
|
| 225 |
+
padding: 0.25rem 0.6rem;
|
| 226 |
+
}
|
| 227 |
+
#page-report-detail .dda-report-overlay {
|
| 228 |
+
border-radius: 0;
|
| 229 |
+
border: 1px solid var(--dm-line);
|
| 230 |
+
background: var(--dm-bg);
|
| 231 |
+
display: block;
|
| 232 |
+
max-width: 100%;
|
| 233 |
+
height: auto;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/* Detected regions table */
|
| 237 |
+
#page-report-detail .table-wrap { overflow-x: auto; }
|
| 238 |
+
#page-report-detail .dda-report-regions {
|
| 239 |
+
width: 100%;
|
| 240 |
+
border-collapse: collapse;
|
| 241 |
+
}
|
| 242 |
+
#page-report-detail .dda-report-regions th {
|
| 243 |
+
font-size: 0.68rem;
|
| 244 |
+
letter-spacing: 0.06em;
|
| 245 |
+
text-transform: uppercase;
|
| 246 |
+
color: var(--dm-ink-muted);
|
| 247 |
+
font-family: var(--dm-font);
|
| 248 |
+
border: none;
|
| 249 |
+
border-bottom: 2px solid var(--dm-line);
|
| 250 |
+
background: transparent;
|
| 251 |
+
text-align: left;
|
| 252 |
+
padding: 0.5rem 0.6rem;
|
| 253 |
+
}
|
| 254 |
+
#page-report-detail .dda-report-regions td {
|
| 255 |
+
border: none;
|
| 256 |
+
border-bottom: 1px solid var(--dm-line);
|
| 257 |
+
padding: 0.5rem 0.6rem;
|
| 258 |
+
}
|
| 259 |
+
#page-report-detail .dda-report-regions tbody tr:hover { background: var(--dm-bg); }
|
| 260 |
+
#page-report-detail .dda-report-regions .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
|
| 261 |
+
#page-report-detail .dda-report-regions .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
|
| 262 |
+
#page-report-detail .dda-report-regions .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
|
| 263 |
+
|
| 264 |
+
/* Email this report */
|
| 265 |
+
#page-report-detail .dda-report-email input[type="email"] {
|
| 266 |
+
border-radius: 0;
|
| 267 |
+
border: 1px solid var(--dm-line);
|
| 268 |
+
background: var(--dm-surface);
|
| 269 |
+
}
|
| 270 |
+
#page-report-detail .dda-report-email input[type="email"]:focus {
|
| 271 |
+
border-color: var(--dm-accent);
|
| 272 |
+
box-shadow: none;
|
| 273 |
+
}
|
| 274 |
+
#page-report-detail .dda-report-email .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
|
| 275 |
+
#page-report-detail .dda-report-email .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
|
| 276 |
+
#page-report-detail .dda-report-email .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
|
| 277 |
+
#page-report-detail .dda-report-email .btn-secondary:disabled { color: var(--dm-ink-dim); border-color: var(--dm-line); }
|
| 278 |
+
|
| 279 |
+
/* Loading / error states */
|
| 280 |
+
#page-report-detail #report-loading { border-radius: 0; }
|
| 281 |
+
#page-report-detail .alert-error { border-radius: 0; }
|
| 282 |
+
|
| 283 |
+
@media (max-width: 720px) {
|
| 284 |
+
#page-report-detail .dda-report-header { flex-direction: column; align-items: stretch; }
|
| 285 |
+
#page-report-detail .dda-report-stats { grid-template-columns: repeat(2, 1fr); }
|
| 286 |
+
}
|
static/css/dda-modernist-result-modal.css
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist theme for the interactive Result modal (#result-modal)
|
| 3 |
+
— shared by the Change Detection page (shown right after a run)
|
| 4 |
+
and the standalone report page. Scoped entirely to
|
| 5 |
+
#result-modal so nothing else is affected.
|
| 6 |
+
============================================================ */
|
| 7 |
+
|
| 8 |
+
#result-modal.result-modal-backdrop {
|
| 9 |
+
position: fixed !important;
|
| 10 |
+
inset: 0 !important;
|
| 11 |
+
z-index: 10000 !important;
|
| 12 |
+
/* Solid — never let the page underneath bleed through. */
|
| 13 |
+
background: var(--dm-bg, #f3f2f2) !important;
|
| 14 |
+
backdrop-filter: none !important;
|
| 15 |
+
-webkit-backdrop-filter: none !important;
|
| 16 |
+
display: flex;
|
| 17 |
+
align-items: stretch;
|
| 18 |
+
justify-content: center;
|
| 19 |
+
overflow-y: auto;
|
| 20 |
+
}
|
| 21 |
+
#result-modal .result-modal {
|
| 22 |
+
background: var(--dm-bg, #f3f2f2);
|
| 23 |
+
border-radius: 0;
|
| 24 |
+
box-shadow: none;
|
| 25 |
+
min-height: 100vh;
|
| 26 |
+
min-height: 100dvh;
|
| 27 |
+
width: 100%;
|
| 28 |
+
max-width: 100%;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* --- Header --- */
|
| 32 |
+
#result-modal .result-modal-header h3 {
|
| 33 |
+
font-family: var(--dm-font);
|
| 34 |
+
font-weight: 800;
|
| 35 |
+
font-size: 1.3rem;
|
| 36 |
+
letter-spacing: -0.01em;
|
| 37 |
+
color: var(--dm-ink);
|
| 38 |
+
}
|
| 39 |
+
#result-modal .result-modal-header-actions .btn {
|
| 40 |
+
border-radius: 0;
|
| 41 |
+
font-family: var(--dm-font);
|
| 42 |
+
font-weight: 700;
|
| 43 |
+
}
|
| 44 |
+
#result-modal .result-modal-header-actions .btn-secondary {
|
| 45 |
+
background: transparent;
|
| 46 |
+
border-color: var(--dm-line);
|
| 47 |
+
color: var(--dm-ink);
|
| 48 |
+
}
|
| 49 |
+
#result-modal .result-modal-header-actions .btn-secondary:hover { background: var(--dm-surface); }
|
| 50 |
+
#result-modal .btn-close-modal:hover { background: var(--dm-accent-100); color: var(--dm-accent); border-radius: 0; }
|
| 51 |
+
|
| 52 |
+
/* --- Stat grid --- */
|
| 53 |
+
#result-modal .result-stats {
|
| 54 |
+
display: grid;
|
| 55 |
+
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
| 56 |
+
gap: 1px;
|
| 57 |
+
background: var(--dm-line);
|
| 58 |
+
border: 1px solid var(--dm-line);
|
| 59 |
+
margin-bottom: 0.75rem;
|
| 60 |
+
}
|
| 61 |
+
#result-modal .stat-box {
|
| 62 |
+
background: var(--dm-surface);
|
| 63 |
+
border: none;
|
| 64 |
+
border-radius: 0;
|
| 65 |
+
padding: 0.85rem 0.75rem;
|
| 66 |
+
}
|
| 67 |
+
#result-modal .stat-box:hover { border-color: transparent; }
|
| 68 |
+
#result-modal .stat-box .value {
|
| 69 |
+
font-family: var(--dm-font);
|
| 70 |
+
font-weight: 800;
|
| 71 |
+
background: none;
|
| 72 |
+
-webkit-background-clip: initial;
|
| 73 |
+
background-clip: initial;
|
| 74 |
+
-webkit-text-fill-color: var(--dm-ink);
|
| 75 |
+
color: var(--dm-ink);
|
| 76 |
+
}
|
| 77 |
+
#result-modal .stat-box .label {
|
| 78 |
+
color: var(--dm-ink-muted);
|
| 79 |
+
font-weight: 600;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/* --- View toolbar --- */
|
| 83 |
+
#result-modal .dda-view-toolbar {
|
| 84 |
+
border: 1px solid var(--dm-line);
|
| 85 |
+
padding: 0.3rem;
|
| 86 |
+
display: inline-flex;
|
| 87 |
+
gap: 0;
|
| 88 |
+
width: auto;
|
| 89 |
+
}
|
| 90 |
+
#result-modal .dda-view-label { color: var(--dm-ink-muted); margin: 0 0.5rem 0 0.15rem; }
|
| 91 |
+
#result-modal .dda-view-btn {
|
| 92 |
+
border-radius: 0;
|
| 93 |
+
font-family: var(--dm-font);
|
| 94 |
+
font-weight: 700;
|
| 95 |
+
}
|
| 96 |
+
#result-modal .dda-view-btn:hover { background: var(--dm-bg); }
|
| 97 |
+
#result-modal .dda-view-btn.active {
|
| 98 |
+
background: var(--dm-accent);
|
| 99 |
+
color: #fff;
|
| 100 |
+
}
|
| 101 |
+
#result-modal #dda-locate-btn.btn-secondary {
|
| 102 |
+
border-radius: 0;
|
| 103 |
+
font-family: var(--dm-font);
|
| 104 |
+
font-weight: 700;
|
| 105 |
+
}
|
| 106 |
+
#result-modal .btn-zoom {
|
| 107 |
+
border-radius: 0;
|
| 108 |
+
font-family: var(--dm-font);
|
| 109 |
+
}
|
| 110 |
+
#result-modal .zoom-level { font-family: var(--dm-font); font-weight: 700; color: var(--dm-ink); }
|
| 111 |
+
|
| 112 |
+
#result-modal .compare-slider { border: 1px solid var(--dm-line); border-radius: 0; }
|
| 113 |
+
#result-modal .compare-label {
|
| 114 |
+
border-radius: 0;
|
| 115 |
+
font-family: var(--dm-font);
|
| 116 |
+
font-weight: 700;
|
| 117 |
+
}
|
| 118 |
+
#result-modal .compare-label-left { background: var(--dm-ink); color: #fff; }
|
| 119 |
+
#result-modal .compare-label-right { background: var(--dm-accent); color: #fff; }
|
| 120 |
+
#result-modal .compare-handle-knob { border-radius: 0; background: var(--dm-ink); color: #fff; }
|
| 121 |
+
|
| 122 |
+
#result-modal .dda-map-toolbar {
|
| 123 |
+
border: 1px solid var(--dm-line);
|
| 124 |
+
border-radius: 0;
|
| 125 |
+
}
|
| 126 |
+
#result-modal .dda-map-toolbar select,
|
| 127 |
+
#result-modal .dda-map-xyz {
|
| 128 |
+
border-radius: 0;
|
| 129 |
+
}
|
| 130 |
+
#result-modal .dda-map-toolbar .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
|
| 131 |
+
#result-modal .dda-map-canvas { border: 1px solid var(--dm-line); border-radius: 0; }
|
| 132 |
+
|
| 133 |
+
#result-modal .result-modal-layout {
|
| 134 |
+
align-items: stretch;
|
| 135 |
+
}
|
| 136 |
+
#result-modal .result-modal-side {
|
| 137 |
+
border-left-color: var(--dm-line);
|
| 138 |
+
display: flex;
|
| 139 |
+
flex-direction: column;
|
| 140 |
+
min-height: 0;
|
| 141 |
+
max-height: calc(100vh - 12rem);
|
| 142 |
+
max-height: calc(100dvh - 12rem);
|
| 143 |
+
}
|
| 144 |
+
#result-modal .dda-review-summary { color: var(--dm-ink-muted); }
|
| 145 |
+
#result-modal .dda-review-summary strong { color: var(--dm-ink); }
|
| 146 |
+
#result-modal .dda-review-actions .btn {
|
| 147 |
+
border-radius: 0;
|
| 148 |
+
font-family: var(--dm-font);
|
| 149 |
+
font-weight: 700;
|
| 150 |
+
}
|
| 151 |
+
#result-modal .dda-review-actions .btn-secondary {
|
| 152 |
+
background: transparent;
|
| 153 |
+
border-color: var(--dm-line);
|
| 154 |
+
color: var(--dm-ink);
|
| 155 |
+
}
|
| 156 |
+
#result-modal .dda-review-actions .btn-secondary:hover { background: var(--dm-surface); }
|
| 157 |
+
#result-modal .dda-review-actions .btn-primary { background: var(--dm-accent); }
|
| 158 |
+
#result-modal .dda-review-actions .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
|
| 159 |
+
|
| 160 |
+
#result-modal .regions-table {
|
| 161 |
+
border-collapse: separate;
|
| 162 |
+
border-spacing: 0;
|
| 163 |
+
}
|
| 164 |
+
#result-modal .regions-table th {
|
| 165 |
+
font-family: var(--dm-font);
|
| 166 |
+
text-transform: uppercase;
|
| 167 |
+
letter-spacing: 0.05em;
|
| 168 |
+
font-size: 0.68rem;
|
| 169 |
+
color: var(--dm-ink-muted);
|
| 170 |
+
position: sticky;
|
| 171 |
+
top: 0;
|
| 172 |
+
z-index: 3;
|
| 173 |
+
background: var(--dm-surface, #fff);
|
| 174 |
+
box-shadow: 0 1px 0 var(--dm-line);
|
| 175 |
+
}
|
| 176 |
+
#result-modal .regions-table td {
|
| 177 |
+
border-color: var(--dm-line);
|
| 178 |
+
background: var(--dm-surface, #fff);
|
| 179 |
+
}
|
| 180 |
+
#result-modal .regions-table td:nth-child(2) { color: var(--dm-ink); }
|
| 181 |
+
#result-modal .regions-table tr:hover td { background: var(--dm-bg); }
|
| 182 |
+
#result-modal .regions-table tr.region-hover td { background: var(--dm-accent-100); }
|
| 183 |
+
#result-modal .regions-table tr.region-selected td { background: var(--dm-accent-100); }
|
| 184 |
+
#result-modal .severity-badge,
|
| 185 |
+
#result-modal .review-badge {
|
| 186 |
+
border-radius: 2px;
|
| 187 |
+
}
|
| 188 |
+
#result-modal .dda-review-btns .btn { border-radius: 0; }
|
| 189 |
+
#result-modal .pagination { font-family: var(--dm-font); }
|
| 190 |
+
|
| 191 |
+
#result-modal .result-modal-side .regions-table-wrap {
|
| 192 |
+
flex: 1 1 auto;
|
| 193 |
+
min-height: 12rem;
|
| 194 |
+
max-height: none;
|
| 195 |
+
overflow: auto;
|
| 196 |
+
overscroll-behavior: contain;
|
| 197 |
+
-webkit-overflow-scrolling: touch;
|
| 198 |
+
}
|
| 199 |
+
#result-modal .result-modal-side .regions-table {
|
| 200 |
+
min-width: 960px;
|
| 201 |
+
}
|
| 202 |
+
#result-modal .result-modal-side > .pagination {
|
| 203 |
+
flex: 0 0 auto;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* ---- Standalone report page: never clip the viewport ----
|
| 207 |
+
The report page embeds the result UI in-flow (not a fixed overlay).
|
| 208 |
+
Cap-heights + body overflow:hidden were cutting the bottom of the screen. */
|
| 209 |
+
#page-report-detail {
|
| 210 |
+
overflow: auto !important;
|
| 211 |
+
height: auto !important;
|
| 212 |
+
min-height: 100vh;
|
| 213 |
+
}
|
| 214 |
+
#page-report-detail .dda-report-page {
|
| 215 |
+
overflow: visible !important;
|
| 216 |
+
height: auto !important;
|
| 217 |
+
}
|
| 218 |
+
#page-report-detail #result-modal.result-modal-backdrop,
|
| 219 |
+
.dda-report-page #result-modal.result-modal-backdrop {
|
| 220 |
+
position: relative !important;
|
| 221 |
+
inset: auto !important;
|
| 222 |
+
z-index: 1 !important;
|
| 223 |
+
display: block !important;
|
| 224 |
+
overflow: visible !important;
|
| 225 |
+
min-height: 0 !important;
|
| 226 |
+
height: auto !important;
|
| 227 |
+
background: var(--dm-bg, #f3f2f2) !important;
|
| 228 |
+
backdrop-filter: none !important;
|
| 229 |
+
}
|
| 230 |
+
#page-report-detail #result-modal .result-modal,
|
| 231 |
+
.dda-report-page #result-modal .result-modal {
|
| 232 |
+
min-height: 0 !important;
|
| 233 |
+
height: auto !important;
|
| 234 |
+
overflow: visible !important;
|
| 235 |
+
}
|
| 236 |
+
#page-report-detail #result-modal .result-modal-layout,
|
| 237 |
+
.dda-report-page #result-modal .result-modal-layout {
|
| 238 |
+
align-items: start;
|
| 239 |
+
}
|
| 240 |
+
#page-report-detail #result-modal .result-modal-side,
|
| 241 |
+
.dda-report-page #result-modal .result-modal-side {
|
| 242 |
+
max-height: none !important;
|
| 243 |
+
height: auto !important;
|
| 244 |
+
overflow: visible !important;
|
| 245 |
+
}
|
| 246 |
+
#page-report-detail #result-modal .result-modal-side .regions-table-wrap,
|
| 247 |
+
.dda-report-page #result-modal .result-modal-side .regions-table-wrap {
|
| 248 |
+
flex: none !important;
|
| 249 |
+
max-height: none !important;
|
| 250 |
+
min-height: 0 !important;
|
| 251 |
+
/* Only horizontal scroll for wide columns; vertical uses the page. */
|
| 252 |
+
overflow-x: auto !important;
|
| 253 |
+
overflow-y: visible !important;
|
| 254 |
+
}
|
| 255 |
+
#page-report-detail #result-modal .regions-table th,
|
| 256 |
+
.dda-report-page #result-modal .regions-table th {
|
| 257 |
+
position: static;
|
| 258 |
+
box-shadow: none;
|
| 259 |
+
}
|
| 260 |
+
#page-report-detail .dda-header.dda-report-header {
|
| 261 |
+
margin: 0 !important;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/* --- Editable report title --- */
|
| 265 |
+
#result-modal .result-title-edit-btn {
|
| 266 |
+
border-radius: 0;
|
| 267 |
+
color: var(--dm-ink-muted);
|
| 268 |
+
}
|
| 269 |
+
#result-modal .result-title-edit-btn:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
|
| 270 |
+
#result-modal .result-title-input {
|
| 271 |
+
border-radius: 0;
|
| 272 |
+
border-color: var(--dm-line);
|
| 273 |
+
font-family: var(--dm-font);
|
| 274 |
+
}
|
| 275 |
+
#result-modal .result-modal-title-edit .btn {
|
| 276 |
+
border-radius: 0;
|
| 277 |
+
font-family: var(--dm-font);
|
| 278 |
+
font-weight: 700;
|
| 279 |
+
}
|
| 280 |
+
#result-modal .result-modal-title-edit .btn-primary { background: var(--dm-accent); }
|
| 281 |
+
#result-modal .result-modal-title-edit .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
|
| 282 |
+
#result-modal .result-title-msg { color: var(--dm-accent); }
|
static/css/dda-modernist-sidebar.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Modernist theme for the main left sidebar (navbar_dda.html) —
|
| 3 |
+
shared, identical partial injected into every page, so this
|
| 4 |
+
reskin applies globally rather than being scoped to one page.
|
| 5 |
+
Reuses the shared --dm-* tokens declared once at :root in
|
| 6 |
+
dda.css: flat surfaces, hairline dividers, sharp corners, one
|
| 7 |
+
accent color, Archivo for the brand/nav. Pure visual restyle —
|
| 8 |
+
the DB-driven menu structure, RBAC, and every id/class app.js/
|
| 9 |
+
notifications.js select on are untouched.
|
| 10 |
+
============================================================ */
|
| 11 |
+
|
| 12 |
+
.dda-sidebar-nav {
|
| 13 |
+
background: var(--dm-bg);
|
| 14 |
+
border-right: 2px solid var(--dm-line);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/* --- Brand --- */
|
| 18 |
+
.dda-sidebar-brand > svg { color: var(--dm-accent); }
|
| 19 |
+
.dda-sidebar-brand-name {
|
| 20 |
+
font-family: var(--dm-font);
|
| 21 |
+
font-weight: 800;
|
| 22 |
+
font-size: 1rem;
|
| 23 |
+
letter-spacing: 0.02em;
|
| 24 |
+
background: none;
|
| 25 |
+
-webkit-background-clip: initial;
|
| 26 |
+
background-clip: initial;
|
| 27 |
+
color: var(--dm-ink);
|
| 28 |
+
}
|
| 29 |
+
.dda-sidebar-brand-sub { color: var(--dm-ink-muted); text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.68rem; }
|
| 30 |
+
.dda-sidebar-toggle { border-color: var(--dm-line); border-radius: 0; color: var(--dm-ink-muted); }
|
| 31 |
+
.dda-sidebar-toggle:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
|
| 32 |
+
|
| 33 |
+
/* --- Nav tabs --- */
|
| 34 |
+
.dda-tab {
|
| 35 |
+
border-radius: 0;
|
| 36 |
+
border-left: 2px solid transparent;
|
| 37 |
+
color: var(--dm-ink);
|
| 38 |
+
font-weight: 500;
|
| 39 |
+
}
|
| 40 |
+
.dda-tab:hover { background: var(--dm-bg); color: var(--dm-ink); }
|
| 41 |
+
.dda-tab.active {
|
| 42 |
+
background: var(--dm-accent-100);
|
| 43 |
+
border-left-color: var(--dm-accent);
|
| 44 |
+
color: var(--dm-accent);
|
| 45 |
+
font-weight: 700;
|
| 46 |
+
}
|
| 47 |
+
.dda-subtab { color: var(--dm-ink); font-weight: 500; }
|
| 48 |
+
.dda-subtab.active {
|
| 49 |
+
background: var(--dm-accent-100);
|
| 50 |
+
border-left-color: var(--dm-accent);
|
| 51 |
+
color: var(--dm-accent);
|
| 52 |
+
font-weight: 700;
|
| 53 |
+
}
|
| 54 |
+
.dda-sidebar-submenu { border-left-color: var(--dm-line); }
|
| 55 |
+
.dda-sidebar-group > summary.dda-tab-group { border-radius: 0; }
|
| 56 |
+
|
| 57 |
+
/* --- Footer: notifications + user menu --- */
|
| 58 |
+
.dda-sidebar-footer { border-top-color: var(--dm-line); }
|
| 59 |
+
.dda-notify-btn { border-color: var(--dm-line); border-radius: 0; color: var(--dm-ink); }
|
| 60 |
+
.dda-notify-btn:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
|
| 61 |
+
.dda-notify-badge { background: var(--dm-accent); border-radius: 0; }
|
| 62 |
+
.dda-notify-panel { border-radius: 0; border-color: var(--dm-line); background: var(--dm-surface); }
|
| 63 |
+
.dda-notify-item { border-radius: 0; }
|
| 64 |
+
.dda-notify-item:hover { background: var(--dm-bg); }
|
| 65 |
+
.dda-notify-item.unread { background: var(--dm-accent-100); }
|
| 66 |
+
|
| 67 |
+
.dda-user-avatar {
|
| 68 |
+
border-radius: 0;
|
| 69 |
+
background: var(--dm-ink);
|
| 70 |
+
}
|
| 71 |
+
.dda-user-wrap #dda-user-email { color: var(--dm-ink); }
|
| 72 |
+
.dda-user-menu-panel { border-top-color: var(--dm-line); }
|
| 73 |
+
.dda-user-menu-link { border-radius: 0; color: var(--dm-ink); }
|
| 74 |
+
.dda-user-menu-link:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
|
| 75 |
+
.dda-user-menu-link.active { color: var(--dm-accent); font-weight: 700; }
|
| 76 |
+
.dda-sidebar-nav.collapsed .dda-user-menu-panel { border-radius: 0; border-color: var(--dm-line); background: var(--dm-surface); box-shadow: none; }
|
static/css/dda.css
CHANGED
|
@@ -1,4 +1,23 @@
|
|
| 1 |
/* Vedangsoft dev UI — extends style.css */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
.dda-app {
|
| 4 |
max-width: 1600px;
|
|
@@ -287,7 +306,7 @@
|
|
| 287 |
help text collapses behind <details> — together these keep the upload
|
| 288 |
card short so the page reads as three compact sections, not four tall
|
| 289 |
stacked cards. */
|
| 290 |
-
.dda-upload-row { grid-template-columns: repeat(auto-fit, minmax(
|
| 291 |
|
| 292 |
/* Detect page: manual-mode pairs + library grid split into their own cards
|
| 293 |
(same pattern as the Library page) instead of nesting inside the main
|
|
@@ -397,6 +416,19 @@
|
|
| 397 |
.dda-tree-node-summary { list-style: none; cursor: pointer; }
|
| 398 |
.dda-tree-node-summary .dda-tree-node-btn { display: inline; width: auto; }
|
| 399 |
.dda-tree-children { padding-left: 0.65rem; border-left: 1px solid var(--border); margin-left: 0.35rem; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
.dda-tree-leaf { padding-left: 0.75rem; }
|
| 401 |
.dda-crumb { font-size: 0.78rem; line-height: 1.3; }
|
| 402 |
.dda-manage-panel { max-width: 640px; width: 100%; }
|
|
@@ -516,8 +548,9 @@
|
|
| 516 |
line-height: 1.3;
|
| 517 |
/* Fixed height regardless of path length — the variable-length crumb
|
| 518 |
text (folder path + filename) was the other source of uneven card
|
| 519 |
-
heights, wrapping to 1-
|
| 520 |
-
|
|
|
|
| 521 |
overflow: hidden;
|
| 522 |
}
|
| 523 |
.dda-card-img .meta .dda-crumb {
|
|
@@ -526,6 +559,12 @@
|
|
| 526 |
overflow: hidden;
|
| 527 |
text-overflow: ellipsis;
|
| 528 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
|
| 530 |
.dda-compare-slots {
|
| 531 |
display: grid;
|
|
@@ -607,6 +646,89 @@
|
|
| 607 |
}
|
| 608 |
.dda-detect-options .form-group { margin: 0; min-width: 160px; }
|
| 609 |
.dda-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.88rem; color: var(--text-muted); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
|
| 611 |
.dda-modal {
|
| 612 |
position: fixed;
|
|
@@ -703,6 +825,9 @@
|
|
| 703 |
}
|
| 704 |
.dda-image-viewer-status { min-height: 1.2rem; margin: 0 0 0.5rem; }
|
| 705 |
.dda-image-viewer-status.dda-viewer-error { color: var(--danger, #dc2626); }
|
|
|
|
|
|
|
|
|
|
| 706 |
|
| 707 |
.dda-viewer-mode-bar {
|
| 708 |
display: flex;
|
|
@@ -793,6 +918,19 @@
|
|
| 793 |
}
|
| 794 |
|
| 795 |
.dda-upload-form .location-row { margin-bottom: 0.5rem; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
|
| 797 |
.dda-upload-progress { margin: 0.75rem 0 1rem; }
|
| 798 |
.dda-progress-bar {
|
|
@@ -919,8 +1057,112 @@
|
|
| 919 |
.dda-report-actions-cell { white-space: nowrap; }
|
| 920 |
.dda-report-actions-cell .btn, .dda-report-actions-cell a.btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
|
| 921 |
|
| 922 |
-
/* Standalone report page has no sidebar nav —
|
| 923 |
-
.dda-report-page
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
.dda-header {
|
| 925 |
display: flex;
|
| 926 |
flex-wrap: wrap;
|
|
@@ -1014,11 +1256,18 @@
|
|
| 1014 |
.regions-table-wrap .regions-table {
|
| 1015 |
min-width: 960px;
|
| 1016 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1017 |
|
| 1018 |
@media (max-width: 768px) {
|
| 1019 |
.dda-compare-slots {
|
| 1020 |
grid-template-columns: 1fr;
|
| 1021 |
}
|
|
|
|
|
|
|
|
|
|
| 1022 |
}
|
| 1023 |
|
| 1024 |
/* ROI (region-of-interest) draw tool on the T2 preview */
|
|
@@ -1384,7 +1633,8 @@
|
|
| 1384 |
}
|
| 1385 |
.dda-filter-field .dda-search,
|
| 1386 |
.dda-filter-field select,
|
| 1387 |
-
.dda-
|
|
|
|
| 1388 |
min-width: 9.5rem;
|
| 1389 |
margin-bottom: 0;
|
| 1390 |
padding: 0.4rem 0.55rem;
|
|
@@ -1493,7 +1743,8 @@ time.dda-date {
|
|
| 1493 |
@media (max-width: 720px) {
|
| 1494 |
.dda-filter-field .dda-search,
|
| 1495 |
.dda-filter-field select,
|
| 1496 |
-
.dda-
|
|
|
|
| 1497 |
.dda-datewrap { min-width: 100%; }
|
| 1498 |
.dda-run-bar { position: static; }
|
| 1499 |
}
|
|
@@ -1508,6 +1759,50 @@ time.dda-date {
|
|
| 1508 |
}
|
| 1509 |
.dda-dash-hero-actions { display: flex; align-items: center; gap: 0.75rem; }
|
| 1510 |
.dda-dash-updated { font-size: 0.8rem; white-space: nowrap; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1511 |
|
| 1512 |
.dda-dash-kpi-grid {
|
| 1513 |
display: grid;
|
|
@@ -1518,9 +1813,20 @@ a.stat-box.dda-dash-kpi {
|
|
| 1518 |
display: flex;
|
| 1519 |
flex-direction: column;
|
| 1520 |
text-decoration: none;
|
| 1521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1522 |
}
|
| 1523 |
-
a.stat-box.dda-dash-kpi:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1524 |
|
| 1525 |
.dda-dash-latest-row { display: flex; gap: 1.25rem; align-items: flex-start; flex-wrap: wrap; }
|
| 1526 |
.dda-dash-latest-thumb {
|
|
@@ -1542,9 +1848,23 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
|
|
| 1542 |
.dda-dash-chart-grid {
|
| 1543 |
display: grid;
|
| 1544 |
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
| 1545 |
-
gap: 1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1546 |
}
|
| 1547 |
-
.dda-dash-chart-box h4 { margin: 0 0 0.6rem; font-size: 0.9rem; color: var(--text-muted); }
|
| 1548 |
|
| 1549 |
.dda-dash-compare-grid {
|
| 1550 |
display: grid;
|
|
@@ -1561,31 +1881,319 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
|
|
| 1561 |
.dda-dash-compare-col p { margin: 0 0 0.35rem; }
|
| 1562 |
.dda-dash-delta { font-weight: 700; font-family: var(--font-mono); }
|
| 1563 |
.dda-dash-history-title { margin: 0 0 0.6rem; font-size: 0.95rem; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1564 |
|
| 1565 |
@media (max-width: 720px) {
|
| 1566 |
.dda-dash-latest-row { flex-direction: column; }
|
| 1567 |
.dda-dash-latest-thumb { width: 100%; height: 160px; }
|
| 1568 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1569 |
/* Styled calendar date picker */
|
| 1570 |
.dda-datewrap {
|
| 1571 |
position: relative;
|
| 1572 |
-
min-width:
|
|
|
|
| 1573 |
overflow: visible;
|
| 1574 |
}
|
|
|
|
|
|
|
| 1575 |
.dda-date-native {
|
| 1576 |
-
position: absolute;
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1580 |
pointer-events: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1581 |
}
|
| 1582 |
.dda-date-trigger {
|
| 1583 |
display: flex;
|
| 1584 |
align-items: center;
|
| 1585 |
gap: 0.5rem;
|
| 1586 |
width: 100%;
|
|
|
|
| 1587 |
min-height: 2.35rem;
|
| 1588 |
padding: 0.4rem 0.7rem;
|
|
|
|
| 1589 |
border: 1px solid var(--border);
|
| 1590 |
border-radius: 8px;
|
| 1591 |
background: var(--bg-card);
|
|
@@ -1602,6 +2210,12 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
|
|
| 1602 |
box-shadow: 0 0 0 3px var(--accent-glow);
|
| 1603 |
}
|
| 1604 |
.dda-date-placeholder { color: var(--text-dim); font-weight: 450; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1605 |
.dda-date-clear {
|
| 1606 |
margin-left: auto;
|
| 1607 |
color: var(--text-dim);
|
|
|
|
| 1 |
/* Vedangsoft dev UI — extends style.css */
|
| 2 |
+
@import url('https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&display=swap');
|
| 3 |
+
|
| 4 |
+
/* Modernist design tokens (from the Claude Design redesign) — shared by
|
| 5 |
+
every screen that opts into the new look via its own scoped block below.
|
| 6 |
+
Global/additive: these names don't collide with anything style.css
|
| 7 |
+
already defines, so declaring them at :root can't affect pages that
|
| 8 |
+
don't reference them. */
|
| 9 |
+
:root {
|
| 10 |
+
--dm-bg: #f3f2f2;
|
| 11 |
+
--dm-surface: #ffffff;
|
| 12 |
+
--dm-line: rgba(32, 30, 29, 0.14);
|
| 13 |
+
--dm-ink: #201e1d;
|
| 14 |
+
--dm-ink-muted: #7d7979;
|
| 15 |
+
--dm-ink-dim: #9b9797;
|
| 16 |
+
--dm-accent: #ec3013;
|
| 17 |
+
--dm-accent-hover: #dd2b0f;
|
| 18 |
+
--dm-accent-100: #fff2ef;
|
| 19 |
+
--dm-font: 'Archivo', system-ui, sans-serif;
|
| 20 |
+
}
|
| 21 |
|
| 22 |
.dda-app {
|
| 23 |
max-width: 1600px;
|
|
|
|
| 306 |
help text collapses behind <details> — together these keep the upload
|
| 307 |
card short so the page reads as three compact sections, not four tall
|
| 308 |
stacked cards. */
|
| 309 |
+
.dda-upload-row { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
|
| 310 |
|
| 311 |
/* Detect page: manual-mode pairs + library grid split into their own cards
|
| 312 |
(same pattern as the Library page) instead of nesting inside the main
|
|
|
|
| 416 |
.dda-tree-node-summary { list-style: none; cursor: pointer; }
|
| 417 |
.dda-tree-node-summary .dda-tree-node-btn { display: inline; width: auto; }
|
| 418 |
.dda-tree-children { padding-left: 0.65rem; border-left: 1px solid var(--border); margin-left: 0.35rem; }
|
| 419 |
+
.dda-tree-toolbar { display: flex; gap: 0.5rem; margin: -0.35rem 0 0.6rem; }
|
| 420 |
+
.dda-tree-toolbar-btn {
|
| 421 |
+
border: none;
|
| 422 |
+
background: transparent;
|
| 423 |
+
color: var(--text-muted);
|
| 424 |
+
font-size: 0.76rem;
|
| 425 |
+
padding: 0;
|
| 426 |
+
cursor: pointer;
|
| 427 |
+
text-decoration: underline;
|
| 428 |
+
text-underline-offset: 2px;
|
| 429 |
+
}
|
| 430 |
+
.dda-tree-toolbar-btn:hover { color: var(--grad-start); }
|
| 431 |
+
.dda-tree-hit { background: rgba(255, 205, 60, 0.45); color: inherit; border-radius: 2px; }
|
| 432 |
.dda-tree-leaf { padding-left: 0.75rem; }
|
| 433 |
.dda-crumb { font-size: 0.78rem; line-height: 1.3; }
|
| 434 |
.dda-manage-panel { max-width: 640px; width: 100%; }
|
|
|
|
| 548 |
line-height: 1.3;
|
| 549 |
/* Fixed height regardless of path length — the variable-length crumb
|
| 550 |
text (folder path + filename) was the other source of uneven card
|
| 551 |
+
heights, wrapping to 1-4 lines depending on the image: crumb, optional
|
| 552 |
+
village/zone place, capture date/type/size/dims, uploaded date. */
|
| 553 |
+
height: 5.2em;
|
| 554 |
overflow: hidden;
|
| 555 |
}
|
| 556 |
.dda-card-img .meta .dda-crumb {
|
|
|
|
| 559 |
overflow: hidden;
|
| 560 |
text-overflow: ellipsis;
|
| 561 |
}
|
| 562 |
+
.dda-card-img .meta .dda-card-uploaded {
|
| 563 |
+
display: block;
|
| 564 |
+
white-space: nowrap;
|
| 565 |
+
overflow: hidden;
|
| 566 |
+
text-overflow: ellipsis;
|
| 567 |
+
}
|
| 568 |
|
| 569 |
.dda-compare-slots {
|
| 570 |
display: grid;
|
|
|
|
| 646 |
}
|
| 647 |
.dda-detect-options .form-group { margin: 0; min-width: 160px; }
|
| 648 |
.dda-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.88rem; color: var(--text-muted); }
|
| 649 |
+
.dda-sensitivity-field label { display: flex; align-items: baseline; gap: 0.35rem; }
|
| 650 |
+
.dda-sensitivity-field em { font-style: normal; color: var(--text); font-variant-numeric: tabular-nums; }
|
| 651 |
+
.dda-sensitivity-field input[type="range"] { width: 100%; min-width: 140px; }
|
| 652 |
+
.dda-field-hint { font-size: 0.78rem; margin: 0.3rem 0 0; color: var(--accent-dim); font-weight: 500; }
|
| 653 |
+
|
| 654 |
+
/* Min region area: a unified [−] [140 px] [+] stepper control — one bordered
|
| 655 |
+
group instead of a plain number input, since the browser's own spinner
|
| 656 |
+
arrows collide with our "px" suffix and can't be styled consistently. */
|
| 657 |
+
.dda-stepper-wrap {
|
| 658 |
+
display: flex;
|
| 659 |
+
align-items: stretch;
|
| 660 |
+
border: 1px solid var(--border);
|
| 661 |
+
border-radius: var(--radius-sm);
|
| 662 |
+
background: var(--bg-elevated);
|
| 663 |
+
overflow: hidden;
|
| 664 |
+
transition: border-color var(--transition), box-shadow var(--transition);
|
| 665 |
+
}
|
| 666 |
+
.dda-stepper-wrap:focus-within {
|
| 667 |
+
border-color: var(--border-focus);
|
| 668 |
+
box-shadow: 0 0 0 3px rgba(46, 51, 197, 0.18);
|
| 669 |
+
}
|
| 670 |
+
.dda-stepper-wrap .dda-input-suffix-wrap { flex: 1; min-width: 0; }
|
| 671 |
+
.dda-stepper-wrap input[type="number"] {
|
| 672 |
+
border: none;
|
| 673 |
+
border-radius: 0;
|
| 674 |
+
background: transparent;
|
| 675 |
+
}
|
| 676 |
+
.dda-stepper-wrap input[type="number"]:focus { outline: none; box-shadow: none; }
|
| 677 |
+
.dda-stepper-btn {
|
| 678 |
+
flex: 0 0 auto;
|
| 679 |
+
width: 2.25rem;
|
| 680 |
+
margin: 0;
|
| 681 |
+
padding: 0;
|
| 682 |
+
display: flex;
|
| 683 |
+
align-items: center;
|
| 684 |
+
justify-content: center;
|
| 685 |
+
border: none;
|
| 686 |
+
background: transparent;
|
| 687 |
+
color: var(--text-muted);
|
| 688 |
+
font-size: 1.15rem;
|
| 689 |
+
line-height: 1;
|
| 690 |
+
font-family: var(--font-sans);
|
| 691 |
+
cursor: pointer;
|
| 692 |
+
transition: background var(--transition), color var(--transition);
|
| 693 |
+
}
|
| 694 |
+
.dda-stepper-btn:first-child { border-right: 1px solid var(--border); }
|
| 695 |
+
.dda-stepper-btn:last-child { border-left: 1px solid var(--border); }
|
| 696 |
+
.dda-stepper-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--accent-dim); }
|
| 697 |
+
.dda-stepper-btn:active:not(:disabled) { background: var(--accent-glow); }
|
| 698 |
+
.dda-stepper-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
| 699 |
+
|
| 700 |
+
.dda-input-suffix-wrap {
|
| 701 |
+
position: relative;
|
| 702 |
+
display: flex;
|
| 703 |
+
align-items: center;
|
| 704 |
+
}
|
| 705 |
+
.dda-input-suffix-wrap input[type="number"] {
|
| 706 |
+
padding-right: 2.75rem;
|
| 707 |
+
/* Hide the native spinner — it renders at a browser-chosen width that
|
| 708 |
+
collides with our own "px" suffix instead of sitting cleanly beside it;
|
| 709 |
+
our own stepper buttons replace its increment/decrement role. */
|
| 710 |
+
-moz-appearance: textfield;
|
| 711 |
+
}
|
| 712 |
+
.dda-input-suffix-wrap input[type="number"]::-webkit-outer-spin-button,
|
| 713 |
+
.dda-input-suffix-wrap input[type="number"]::-webkit-inner-spin-button {
|
| 714 |
+
-webkit-appearance: none;
|
| 715 |
+
margin: 0;
|
| 716 |
+
}
|
| 717 |
+
.dda-input-suffix-wrap input[type="number"]:focus + .dda-input-suffix {
|
| 718 |
+
color: var(--accent-dim);
|
| 719 |
+
}
|
| 720 |
+
.dda-input-suffix {
|
| 721 |
+
position: absolute;
|
| 722 |
+
right: 1rem;
|
| 723 |
+
top: 50%;
|
| 724 |
+
transform: translateY(-50%);
|
| 725 |
+
color: var(--text-dim);
|
| 726 |
+
font-size: 0.8rem;
|
| 727 |
+
font-weight: 500;
|
| 728 |
+
letter-spacing: 0.02em;
|
| 729 |
+
pointer-events: none;
|
| 730 |
+
transition: color var(--transition);
|
| 731 |
+
}
|
| 732 |
|
| 733 |
.dda-modal {
|
| 734 |
position: fixed;
|
|
|
|
| 825 |
}
|
| 826 |
.dda-image-viewer-status { min-height: 1.2rem; margin: 0 0 0.5rem; }
|
| 827 |
.dda-image-viewer-status.dda-viewer-error { color: var(--danger, #dc2626); }
|
| 828 |
+
.dda-image-viewer-meta { margin: 0 0 0.4rem; }
|
| 829 |
+
.dda-viewer-meta-row { line-height: 1.5; }
|
| 830 |
+
.dda-viewer-meta-row .dda-meta-sep { opacity: 0.5; margin: 0 0.15rem; }
|
| 831 |
|
| 832 |
.dda-viewer-mode-bar {
|
| 833 |
display: flex;
|
|
|
|
| 918 |
}
|
| 919 |
|
| 920 |
.dda-upload-form .location-row { margin-bottom: 0.5rem; }
|
| 921 |
+
.dda-upload-form .form-group {
|
| 922 |
+
margin-bottom: 0;
|
| 923 |
+
min-width: 0;
|
| 924 |
+
}
|
| 925 |
+
.dda-upload-form .location-row > .form-group > label {
|
| 926 |
+
display: block;
|
| 927 |
+
align-items: unset;
|
| 928 |
+
gap: 0;
|
| 929 |
+
}
|
| 930 |
+
.dda-upload-form .dda-datewrap {
|
| 931 |
+
min-width: 0;
|
| 932 |
+
width: 100%;
|
| 933 |
+
}
|
| 934 |
|
| 935 |
.dda-upload-progress { margin: 0.75rem 0 1rem; }
|
| 936 |
.dda-progress-bar {
|
|
|
|
| 1057 |
.dda-report-actions-cell { white-space: nowrap; }
|
| 1058 |
.dda-report-actions-cell .btn, .dda-report-actions-cell a.btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
|
| 1059 |
|
| 1060 |
+
/* Standalone report page has no sidebar nav — full viewport interactive viewer. */
|
| 1061 |
+
.dda-report-page.dda-app,
|
| 1062 |
+
#page-report-detail .dda-report-page {
|
| 1063 |
+
display: block;
|
| 1064 |
+
max-width: none;
|
| 1065 |
+
width: 100%;
|
| 1066 |
+
margin: 0;
|
| 1067 |
+
padding: 0;
|
| 1068 |
+
min-height: 100vh;
|
| 1069 |
+
height: auto;
|
| 1070 |
+
overflow: visible;
|
| 1071 |
+
background: var(--dm-bg, #f3f2f2);
|
| 1072 |
+
}
|
| 1073 |
+
.dda-report-page .dda-report-header {
|
| 1074 |
+
padding: 0.75rem 1rem;
|
| 1075 |
+
margin: 0;
|
| 1076 |
+
border-bottom: 1px solid var(--border);
|
| 1077 |
+
}
|
| 1078 |
+
.dda-report-page #result-modal.result-modal-backdrop {
|
| 1079 |
+
position: relative !important;
|
| 1080 |
+
inset: auto !important;
|
| 1081 |
+
z-index: 1 !important;
|
| 1082 |
+
display: block !important;
|
| 1083 |
+
background: var(--dm-bg, #f3f2f2) !important;
|
| 1084 |
+
backdrop-filter: none !important;
|
| 1085 |
+
overflow: visible !important;
|
| 1086 |
+
min-height: 0;
|
| 1087 |
+
height: auto;
|
| 1088 |
+
}
|
| 1089 |
+
.dda-report-page #result-modal .result-modal {
|
| 1090 |
+
min-height: 0;
|
| 1091 |
+
height: auto;
|
| 1092 |
+
overflow: visible;
|
| 1093 |
+
box-shadow: none;
|
| 1094 |
+
}
|
| 1095 |
+
.dda-report-page #result-modal-close {
|
| 1096 |
+
display: none;
|
| 1097 |
+
}
|
| 1098 |
+
.dda-report-page #report-loading.hidden { display: none !important; }
|
| 1099 |
+
|
| 1100 |
+
.table-wrap {
|
| 1101 |
+
overflow-x: auto;
|
| 1102 |
+
width: 100%;
|
| 1103 |
+
}
|
| 1104 |
+
.dda-upload-type-error {
|
| 1105 |
+
color: var(--danger, #b91c1c);
|
| 1106 |
+
font-size: 0.85rem;
|
| 1107 |
+
margin: 0.5rem 0 0;
|
| 1108 |
+
}
|
| 1109 |
+
.dda-upload-type-error.hidden { display: none; }
|
| 1110 |
+
.dda-upload-file-list-wrap {
|
| 1111 |
+
margin-top: 0.75rem;
|
| 1112 |
+
overflow-x: auto;
|
| 1113 |
+
max-height: 220px;
|
| 1114 |
+
border: 1px solid var(--border);
|
| 1115 |
+
border-radius: 8px;
|
| 1116 |
+
}
|
| 1117 |
+
.dda-upload-file-table {
|
| 1118 |
+
width: 100%;
|
| 1119 |
+
border-collapse: collapse;
|
| 1120 |
+
font-size: 0.85rem;
|
| 1121 |
+
}
|
| 1122 |
+
.dda-upload-file-table th,
|
| 1123 |
+
.dda-upload-file-table td {
|
| 1124 |
+
padding: 0.4rem 0.55rem;
|
| 1125 |
+
border-bottom: 1px solid var(--border);
|
| 1126 |
+
text-align: left;
|
| 1127 |
+
vertical-align: middle;
|
| 1128 |
+
}
|
| 1129 |
+
.dda-upload-file-table .upload-server-name {
|
| 1130 |
+
width: 100%;
|
| 1131 |
+
min-width: 140px;
|
| 1132 |
+
font-size: 0.85rem;
|
| 1133 |
+
padding: 0.25rem 0.4rem;
|
| 1134 |
+
}
|
| 1135 |
+
.dda-lib-grid-actions {
|
| 1136 |
+
display: flex;
|
| 1137 |
+
flex-wrap: wrap;
|
| 1138 |
+
gap: 0.4rem;
|
| 1139 |
+
align-items: center;
|
| 1140 |
+
}
|
| 1141 |
+
.dda-card-img { position: relative; }
|
| 1142 |
+
.dda-card-select-wrap {
|
| 1143 |
+
position: absolute;
|
| 1144 |
+
top: 0.35rem;
|
| 1145 |
+
left: 0.35rem;
|
| 1146 |
+
z-index: 2;
|
| 1147 |
+
background: rgba(255,255,255,0.9);
|
| 1148 |
+
border-radius: 4px;
|
| 1149 |
+
padding: 0.1rem 0.2rem;
|
| 1150 |
+
line-height: 1;
|
| 1151 |
+
}
|
| 1152 |
+
.dda-card-filename {
|
| 1153 |
+
font-size: 0.85rem;
|
| 1154 |
+
font-weight: 600;
|
| 1155 |
+
}
|
| 1156 |
+
.dda-card-capture {
|
| 1157 |
+
font-size: 0.8rem;
|
| 1158 |
+
color: var(--text, #222);
|
| 1159 |
+
}
|
| 1160 |
+
.dda-upload-row {
|
| 1161 |
+
display: grid;
|
| 1162 |
+
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
| 1163 |
+
gap: 0.65rem 0.75rem;
|
| 1164 |
+
align-items: end;
|
| 1165 |
+
}
|
| 1166 |
.dda-header {
|
| 1167 |
display: flex;
|
| 1168 |
flex-wrap: wrap;
|
|
|
|
| 1256 |
.regions-table-wrap .regions-table {
|
| 1257 |
min-width: 960px;
|
| 1258 |
}
|
| 1259 |
+
/* Modal side panel owns its own scroll; don't force a second min-width fight. */
|
| 1260 |
+
.result-modal-side .regions-table-wrap .regions-table {
|
| 1261 |
+
min-width: 960px;
|
| 1262 |
+
}
|
| 1263 |
|
| 1264 |
@media (max-width: 768px) {
|
| 1265 |
.dda-compare-slots {
|
| 1266 |
grid-template-columns: 1fr;
|
| 1267 |
}
|
| 1268 |
+
.dda-detect-options .form-group {
|
| 1269 |
+
min-width: 100%;
|
| 1270 |
+
}
|
| 1271 |
}
|
| 1272 |
|
| 1273 |
/* ROI (region-of-interest) draw tool on the T2 preview */
|
|
|
|
| 1633 |
}
|
| 1634 |
.dda-filter-field .dda-search,
|
| 1635 |
.dda-filter-field select,
|
| 1636 |
+
.dda-filter-field .dda-datewrap,
|
| 1637 |
+
input.dda-date-input:not(.dda-date-native) {
|
| 1638 |
min-width: 9.5rem;
|
| 1639 |
margin-bottom: 0;
|
| 1640 |
padding: 0.4rem 0.55rem;
|
|
|
|
| 1743 |
@media (max-width: 720px) {
|
| 1744 |
.dda-filter-field .dda-search,
|
| 1745 |
.dda-filter-field select,
|
| 1746 |
+
.dda-filter-field .dda-datewrap,
|
| 1747 |
+
input.dda-date-input:not(.dda-date-native),
|
| 1748 |
.dda-datewrap { min-width: 100%; }
|
| 1749 |
.dda-run-bar { position: static; }
|
| 1750 |
}
|
|
|
|
| 1759 |
}
|
| 1760 |
.dda-dash-hero-actions { display: flex; align-items: center; gap: 0.75rem; }
|
| 1761 |
.dda-dash-updated { font-size: 0.8rem; white-space: nowrap; }
|
| 1762 |
+
.dda-dash-filter-row { margin-top: 0.75rem; flex-wrap: wrap; }
|
| 1763 |
+
|
| 1764 |
+
/* A distinct, bordered toolbar strip for the date filter — visually
|
| 1765 |
+
grouped instead of loose inline controls, so it reads as one control
|
| 1766 |
+
panel rather than stray fields sitting under the title. Label sits on
|
| 1767 |
+
its own line above the controls row so it never fights the taller
|
| 1768 |
+
labeled inputs for vertical alignment. */
|
| 1769 |
+
.dda-dash-filter-bar {
|
| 1770 |
+
margin-top: 1rem;
|
| 1771 |
+
padding: 0.85rem 1rem 1rem;
|
| 1772 |
+
background: var(--bg-elevated);
|
| 1773 |
+
border: 1px solid var(--border);
|
| 1774 |
+
border-radius: var(--radius-sm);
|
| 1775 |
+
}
|
| 1776 |
+
.dda-dash-filter-bar .card-header { margin-bottom: 0.85rem; }
|
| 1777 |
+
.dda-dash-filter-bar .card-header svg { flex-shrink: 0; color: var(--text-muted); }
|
| 1778 |
+
.dda-dash-filter-bar-controls {
|
| 1779 |
+
display: flex;
|
| 1780 |
+
align-items: flex-end;
|
| 1781 |
+
justify-content: space-between;
|
| 1782 |
+
flex-wrap: wrap;
|
| 1783 |
+
gap: 0.85rem;
|
| 1784 |
+
}
|
| 1785 |
+
.dda-dash-filter-range { display: flex; align-items: flex-end; gap: 0.65rem; }
|
| 1786 |
+
.dda-dash-filter-arrow { padding-bottom: 0.55rem; color: var(--text-dim); font-size: 0.9rem; }
|
| 1787 |
+
.dda-dash-filter-actions { display: flex; align-items: flex-end; gap: 0.6rem; }
|
| 1788 |
+
|
| 1789 |
+
.dda-dash-section + .dda-dash-section { margin-top: 2rem; }
|
| 1790 |
+
.dda-dash-section-title {
|
| 1791 |
+
font-size: 0.78rem;
|
| 1792 |
+
font-weight: 700;
|
| 1793 |
+
letter-spacing: 0.08em;
|
| 1794 |
+
text-transform: uppercase;
|
| 1795 |
+
color: var(--text-dim, var(--text-muted));
|
| 1796 |
+
margin: 0 0 0.75rem;
|
| 1797 |
+
padding-left: 0.15rem;
|
| 1798 |
+
}
|
| 1799 |
+
.dda-dash-overview-grid {
|
| 1800 |
+
display: grid;
|
| 1801 |
+
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
| 1802 |
+
gap: 1.25rem;
|
| 1803 |
+
align-items: stretch;
|
| 1804 |
+
}
|
| 1805 |
+
.dda-dash-overview-grid .card { margin-top: 0; }
|
| 1806 |
|
| 1807 |
.dda-dash-kpi-grid {
|
| 1808 |
display: grid;
|
|
|
|
| 1813 |
display: flex;
|
| 1814 |
flex-direction: column;
|
| 1815 |
text-decoration: none;
|
| 1816 |
+
border-top: 3px solid var(--accent);
|
| 1817 |
+
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
| 1818 |
+
}
|
| 1819 |
+
a.stat-box.dda-dash-kpi:hover {
|
| 1820 |
+
border-color: var(--accent);
|
| 1821 |
+
transform: translateY(-2px);
|
| 1822 |
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
| 1823 |
}
|
| 1824 |
+
a.stat-box.dda-dash-kpi:nth-child(6n+1) { border-top-color: var(--grad-start, var(--accent)); }
|
| 1825 |
+
a.stat-box.dda-dash-kpi:nth-child(6n+2) { border-top-color: var(--accent); }
|
| 1826 |
+
a.stat-box.dda-dash-kpi:nth-child(6n+3) { border-top-color: var(--success, var(--accent)); }
|
| 1827 |
+
a.stat-box.dda-dash-kpi:nth-child(6n+4) { border-top-color: var(--grad-mid, var(--accent)); }
|
| 1828 |
+
a.stat-box.dda-dash-kpi:nth-child(6n+5) { border-top-color: var(--accent-dim, var(--accent)); }
|
| 1829 |
+
a.stat-box.dda-dash-kpi:nth-child(6n+6) { border-top-color: var(--grad-end, var(--accent)); }
|
| 1830 |
|
| 1831 |
.dda-dash-latest-row { display: flex; gap: 1.25rem; align-items: flex-start; flex-wrap: wrap; }
|
| 1832 |
.dda-dash-latest-thumb {
|
|
|
|
| 1848 |
.dda-dash-chart-grid {
|
| 1849 |
display: grid;
|
| 1850 |
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
| 1851 |
+
gap: 1.25rem;
|
| 1852 |
+
}
|
| 1853 |
+
.dda-dash-chart-box-wide { grid-column: 1 / -1; }
|
| 1854 |
+
.dda-dash-chart-box {
|
| 1855 |
+
background: var(--bg-elevated);
|
| 1856 |
+
border: 1px solid var(--border);
|
| 1857 |
+
border-radius: var(--radius-sm);
|
| 1858 |
+
padding: 1rem 1.1rem 0.6rem;
|
| 1859 |
+
}
|
| 1860 |
+
.dda-dash-chart-box h4 {
|
| 1861 |
+
margin: 0 0 0.75rem;
|
| 1862 |
+
font-size: 0.82rem;
|
| 1863 |
+
font-weight: 700;
|
| 1864 |
+
text-transform: uppercase;
|
| 1865 |
+
letter-spacing: 0.03em;
|
| 1866 |
+
color: var(--text-muted);
|
| 1867 |
}
|
|
|
|
| 1868 |
|
| 1869 |
.dda-dash-compare-grid {
|
| 1870 |
display: grid;
|
|
|
|
| 1881 |
.dda-dash-compare-col p { margin: 0 0 0.35rem; }
|
| 1882 |
.dda-dash-delta { font-weight: 700; font-family: var(--font-mono); }
|
| 1883 |
.dda-dash-history-title { margin: 0 0 0.6rem; font-size: 0.95rem; }
|
| 1884 |
+
.dda-dash-class-line { font-size: 0.82rem; }
|
| 1885 |
+
|
| 1886 |
+
/* Classification mini-bar — a compact stacked-proportion visual standing in
|
| 1887 |
+
for four separate table columns (confirmed/false positive/submitted/
|
| 1888 |
+
unclassified), used across Region-wise, Village-wise, and Detection
|
| 1889 |
+
History wherever a full breakdown would otherwise need its own charts. */
|
| 1890 |
+
.dda-class-bar {
|
| 1891 |
+
display: flex;
|
| 1892 |
+
width: 100%;
|
| 1893 |
+
min-width: 90px;
|
| 1894 |
+
height: 10px;
|
| 1895 |
+
border-radius: 5px;
|
| 1896 |
+
overflow: hidden;
|
| 1897 |
+
background: var(--bg-elevated);
|
| 1898 |
+
}
|
| 1899 |
+
.dda-class-seg { display: block; height: 100%; }
|
| 1900 |
+
.dda-class-confirmed { background: #16a34a; }
|
| 1901 |
+
.dda-class-fp { background: #dc2626; }
|
| 1902 |
+
.dda-class-submitted { background: #2563eb; }
|
| 1903 |
+
.dda-class-unclassified { background: #ca8a04; }
|
| 1904 |
+
.dda-class-bar-caption {
|
| 1905 |
+
display: block;
|
| 1906 |
+
font-size: 0.7rem;
|
| 1907 |
+
margin-top: 0.2rem;
|
| 1908 |
+
font-family: var(--font-mono);
|
| 1909 |
+
}
|
| 1910 |
+
|
| 1911 |
+
.dda-accuracy-good { color: var(--success, #16a34a); font-weight: 700; }
|
| 1912 |
+
.dda-accuracy-mid { color: #ca8a04; font-weight: 700; }
|
| 1913 |
+
.dda-accuracy-low { color: var(--danger, #dc2626); font-weight: 700; }
|
| 1914 |
+
|
| 1915 |
+
.dda-dash-summary {
|
| 1916 |
+
margin: 0 0 1.25rem;
|
| 1917 |
+
padding: 0.75rem 1rem;
|
| 1918 |
+
background: var(--accent-glow);
|
| 1919 |
+
border-left: 3px solid var(--accent);
|
| 1920 |
+
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
| 1921 |
+
font-size: 0.88rem;
|
| 1922 |
+
line-height: 1.5;
|
| 1923 |
+
}
|
| 1924 |
+
.dda-dash-compare-chart { margin: 0 0 1.25rem; }
|
| 1925 |
+
|
| 1926 |
+
/* Change-type pill — colors match CHANGE_TYPE_COLORS in home.js exactly,
|
| 1927 |
+
one fixed meaning per color across every chart/table on the dashboard. */
|
| 1928 |
+
.dda-changetype-pill {
|
| 1929 |
+
display: inline-block;
|
| 1930 |
+
padding: 0.15rem 0.55rem;
|
| 1931 |
+
border-radius: 999px;
|
| 1932 |
+
font-size: 0.72rem;
|
| 1933 |
+
font-weight: 600;
|
| 1934 |
+
white-space: nowrap;
|
| 1935 |
+
}
|
| 1936 |
+
.dda-changetype-New-Construction { background: rgba(249, 115, 22, 0.15); color: #c2410c; }
|
| 1937 |
+
.dda-changetype-Demolition { background: rgba(220, 38, 38, 0.15); color: #b91c1c; }
|
| 1938 |
+
.dda-changetype-Extension { background: rgba(37, 99, 235, 0.15); color: #1d4ed8; }
|
| 1939 |
+
.dda-changetype-Vegetation-Change { background: rgba(22, 163, 74, 0.15); color: #15803d; }
|
| 1940 |
+
.dda-changetype-Other { background: rgba(100, 116, 139, 0.15); color: #475569; }
|
| 1941 |
+
.dda-changetype-pill-group { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
| 1942 |
+
.dda-dash-latest-changetypes { margin: 0 0 0.85rem; }
|
| 1943 |
|
| 1944 |
@media (max-width: 720px) {
|
| 1945 |
.dda-dash-latest-row { flex-direction: column; }
|
| 1946 |
.dda-dash-latest-thumb { width: 100%; height: 160px; }
|
| 1947 |
}
|
| 1948 |
+
|
| 1949 |
+
/* ============================================================
|
| 1950 |
+
Modernist dashboard theme (from the Claude Design redesign).
|
| 1951 |
+
Scoped entirely to #tab-home so the rest of the app keeps its
|
| 1952 |
+
existing look until/unless it gets redesigned too: flat
|
| 1953 |
+
surfaces, hairline dividers, sharp corners, one accent color,
|
| 1954 |
+
Archivo for headings and numbers. Tokens (--dm-*) are shared,
|
| 1955 |
+
declared once at :root above.
|
| 1956 |
+
============================================================ */
|
| 1957 |
+
|
| 1958 |
+
#tab-home h1,
|
| 1959 |
+
#tab-home .dda-dash-section-title,
|
| 1960 |
+
#tab-home .card-title,
|
| 1961 |
+
#tab-home .dda-dash-chart-box h4,
|
| 1962 |
+
#tab-home .dda-dash-ranked-count,
|
| 1963 |
+
#tab-home .stat-box .value {
|
| 1964 |
+
font-family: var(--dm-font);
|
| 1965 |
+
}
|
| 1966 |
+
#tab-home h1#dda-home-greeting { font-weight: 800; letter-spacing: -0.01em; }
|
| 1967 |
+
|
| 1968 |
+
#tab-home .dda-dash-section-title {
|
| 1969 |
+
font-size: 0.8rem;
|
| 1970 |
+
font-weight: 800;
|
| 1971 |
+
letter-spacing: 0.06em;
|
| 1972 |
+
border-bottom: 2px solid var(--dm-line);
|
| 1973 |
+
padding-bottom: 0.6rem;
|
| 1974 |
+
color: var(--dm-ink);
|
| 1975 |
+
}
|
| 1976 |
+
|
| 1977 |
+
/* Flat, hairline-bordered surfaces instead of soft shadow cards */
|
| 1978 |
+
#tab-home .card,
|
| 1979 |
+
#tab-home .dda-dash-chart-box,
|
| 1980 |
+
#tab-home .dda-dash-filter-bar,
|
| 1981 |
+
#tab-home .dda-dash-compare-grid {
|
| 1982 |
+
background: var(--dm-surface);
|
| 1983 |
+
border: 1px solid var(--dm-line);
|
| 1984 |
+
border-radius: 0;
|
| 1985 |
+
box-shadow: none;
|
| 1986 |
+
}
|
| 1987 |
+
#tab-home .dda-dash-chart-box h4 { font-weight: 700; color: var(--dm-ink); }
|
| 1988 |
+
#tab-home .dda-dash-filter-bar .card-header svg { color: var(--dm-accent); }
|
| 1989 |
+
#tab-home .dda-dash-filter-arrow { color: var(--dm-ink-dim); }
|
| 1990 |
+
#tab-home .dda-dash-latest-thumb { border-radius: 0; }
|
| 1991 |
+
#tab-home .dda-dash-summary { background: var(--dm-accent-100); border-left-color: var(--dm-accent); border-radius: 0; }
|
| 1992 |
+
|
| 1993 |
+
/* Buttons — flat accent instead of the app-wide purple gradient,
|
| 1994 |
+
scoped to this page only so other pages are untouched. */
|
| 1995 |
+
#tab-home .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
|
| 1996 |
+
#tab-home .btn-primary { background: var(--dm-accent); }
|
| 1997 |
+
#tab-home .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
|
| 1998 |
+
#tab-home .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
|
| 1999 |
+
#tab-home .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
|
| 2000 |
+
#tab-home .btn-ghost { color: var(--dm-accent); }
|
| 2001 |
+
#tab-home .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
|
| 2002 |
+
|
| 2003 |
+
/* Overview / Charts & Graphs / Breakdown / Image Library tab switch — a row
|
| 2004 |
+
of small flat cards (icon + label + sub-label) instead of a plain button
|
| 2005 |
+
toolbar, so each section reads as its own destination rather than one of
|
| 2006 |
+
several equal-weight pill options. */
|
| 2007 |
+
#tab-home .dda-dash-tab-cards {
|
| 2008 |
+
display: grid;
|
| 2009 |
+
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
| 2010 |
+
gap: 0.75rem;
|
| 2011 |
+
margin-bottom: 1.5rem;
|
| 2012 |
+
}
|
| 2013 |
+
#tab-home .dda-dash-tab-card {
|
| 2014 |
+
display: flex;
|
| 2015 |
+
align-items: center;
|
| 2016 |
+
gap: 0.75rem;
|
| 2017 |
+
padding: 0.9rem 1rem;
|
| 2018 |
+
background: var(--dm-surface);
|
| 2019 |
+
border: 1px solid var(--dm-line);
|
| 2020 |
+
border-radius: 0;
|
| 2021 |
+
cursor: pointer;
|
| 2022 |
+
text-align: left;
|
| 2023 |
+
transition: border-color 0.15s ease, background 0.15s ease;
|
| 2024 |
+
}
|
| 2025 |
+
#tab-home .dda-dash-tab-card:hover { background: var(--dm-bg); }
|
| 2026 |
+
#tab-home .dda-dash-tab-card-icon {
|
| 2027 |
+
flex-shrink: 0;
|
| 2028 |
+
width: 36px;
|
| 2029 |
+
height: 36px;
|
| 2030 |
+
display: flex;
|
| 2031 |
+
align-items: center;
|
| 2032 |
+
justify-content: center;
|
| 2033 |
+
color: var(--dm-ink-muted);
|
| 2034 |
+
background: var(--dm-bg);
|
| 2035 |
+
border: 1px solid var(--dm-line);
|
| 2036 |
+
}
|
| 2037 |
+
#tab-home .dda-dash-tab-card-text { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
|
| 2038 |
+
#tab-home .dda-dash-tab-card-label {
|
| 2039 |
+
font-family: var(--dm-font);
|
| 2040 |
+
font-weight: 700;
|
| 2041 |
+
font-size: 0.92rem;
|
| 2042 |
+
color: var(--dm-ink);
|
| 2043 |
+
}
|
| 2044 |
+
#tab-home .dda-dash-tab-card-sub {
|
| 2045 |
+
font-size: 0.72rem;
|
| 2046 |
+
color: var(--dm-ink-muted);
|
| 2047 |
+
white-space: nowrap;
|
| 2048 |
+
overflow: hidden;
|
| 2049 |
+
text-overflow: ellipsis;
|
| 2050 |
+
}
|
| 2051 |
+
#tab-home .dda-dash-tab-card.is-active {
|
| 2052 |
+
border-color: var(--dm-accent);
|
| 2053 |
+
background: var(--dm-accent-100);
|
| 2054 |
+
}
|
| 2055 |
+
#tab-home .dda-dash-tab-card.is-active .dda-dash-tab-card-icon {
|
| 2056 |
+
color: var(--dm-accent);
|
| 2057 |
+
border-color: var(--dm-accent);
|
| 2058 |
+
background: var(--dm-surface);
|
| 2059 |
+
}
|
| 2060 |
+
#tab-home .dda-dash-tab-card.is-active .dda-dash-tab-card-label { color: var(--dm-accent); }
|
| 2061 |
+
|
| 2062 |
+
/* KPI grid — one shared hairline grid instead of individually
|
| 2063 |
+
bordered/shadowed tiles, each cell a plain flat surface. */
|
| 2064 |
+
#tab-home .dda-dash-kpi-grid {
|
| 2065 |
+
display: grid;
|
| 2066 |
+
grid-template-columns: repeat(4, 1fr);
|
| 2067 |
+
gap: 1px;
|
| 2068 |
+
background: var(--dm-line);
|
| 2069 |
+
border: 1px solid var(--dm-line);
|
| 2070 |
+
}
|
| 2071 |
+
#tab-home a.stat-box.dda-dash-kpi {
|
| 2072 |
+
background: var(--dm-surface);
|
| 2073 |
+
border: none !important;
|
| 2074 |
+
border-radius: 0;
|
| 2075 |
+
padding: 0.9rem 0.75rem;
|
| 2076 |
+
text-align: left;
|
| 2077 |
+
align-items: flex-start;
|
| 2078 |
+
transform: none !important;
|
| 2079 |
+
box-shadow: none !important;
|
| 2080 |
+
}
|
| 2081 |
+
#tab-home a.stat-box.dda-dash-kpi:hover { background: var(--dm-accent-100); }
|
| 2082 |
+
#tab-home .stat-box .value {
|
| 2083 |
+
font-weight: 800;
|
| 2084 |
+
font-size: 1.3rem;
|
| 2085 |
+
background: none;
|
| 2086 |
+
-webkit-background-clip: initial;
|
| 2087 |
+
background-clip: initial;
|
| 2088 |
+
-webkit-text-fill-color: var(--dm-ink);
|
| 2089 |
+
color: var(--dm-ink);
|
| 2090 |
+
white-space: normal;
|
| 2091 |
+
}
|
| 2092 |
+
#tab-home .stat-box .label { color: var(--dm-ink-muted); font-weight: 600; }
|
| 2093 |
+
@media (max-width: 900px) { #tab-home .dda-dash-kpi-grid { grid-template-columns: repeat(2, 1fr); } }
|
| 2094 |
+
@media (max-width: 480px) { #tab-home .dda-dash-kpi-grid { grid-template-columns: 1fr; } }
|
| 2095 |
+
|
| 2096 |
+
/* Tables — uppercase hairline headers, no card wrapper needed per row */
|
| 2097 |
+
#tab-home .dda-reports-table th {
|
| 2098 |
+
font-size: 0.68rem;
|
| 2099 |
+
letter-spacing: 0.06em;
|
| 2100 |
+
text-transform: uppercase;
|
| 2101 |
+
color: var(--dm-ink-muted);
|
| 2102 |
+
border-bottom: 2px solid var(--dm-line);
|
| 2103 |
+
}
|
| 2104 |
+
#tab-home .dda-reports-table td { border-bottom: 1px solid var(--dm-line); }
|
| 2105 |
+
#tab-home .dda-reports-table tbody tr:hover { background: var(--dm-bg); }
|
| 2106 |
+
#tab-home .dda-dash-row-active { background: var(--dm-accent-100) !important; }
|
| 2107 |
+
|
| 2108 |
+
#tab-home .dda-changetype-pill { border-radius: 2px; }
|
| 2109 |
+
|
| 2110 |
+
/* Live detection-job banner — real job data (zone/village/method/
|
| 2111 |
+
progress) from GET /api/dda/jobs, not a mockup placeholder. */
|
| 2112 |
+
.dda-dash-live-banner {
|
| 2113 |
+
display: flex;
|
| 2114 |
+
align-items: center;
|
| 2115 |
+
gap: 1rem;
|
| 2116 |
+
flex-wrap: wrap;
|
| 2117 |
+
padding: 0.85rem 1.1rem;
|
| 2118 |
+
margin-bottom: 1.25rem;
|
| 2119 |
+
background: var(--dm-surface);
|
| 2120 |
+
border: 1px solid var(--dm-line);
|
| 2121 |
+
border-left: 3px solid var(--dm-accent);
|
| 2122 |
+
}
|
| 2123 |
+
.dda-dash-live-dot {
|
| 2124 |
+
width: 9px;
|
| 2125 |
+
height: 9px;
|
| 2126 |
+
border-radius: 50%;
|
| 2127 |
+
background: var(--dm-accent);
|
| 2128 |
+
flex-shrink: 0;
|
| 2129 |
+
animation: dda-dash-pulse 1.4s infinite;
|
| 2130 |
+
}
|
| 2131 |
+
@keyframes dda-dash-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
| 2132 |
+
.dda-dash-live-info { flex: 1; min-width: 220px; }
|
| 2133 |
+
.dda-dash-live-title { font-weight: 700; font-size: 0.9rem; color: var(--dm-ink); }
|
| 2134 |
+
.dda-dash-live-meta { font-size: 0.78rem; color: var(--dm-ink-muted); margin-top: 0.15rem; }
|
| 2135 |
+
.dda-dash-live-bar { width: 180px; height: 6px; background: var(--dm-line); flex-shrink: 0; }
|
| 2136 |
+
.dda-dash-live-bar-fill { height: 100%; background: var(--dm-accent); transition: width 0.6s ease; }
|
| 2137 |
+
.dda-dash-live-queued {
|
| 2138 |
+
font-size: 0.68rem;
|
| 2139 |
+
font-weight: 700;
|
| 2140 |
+
text-transform: uppercase;
|
| 2141 |
+
letter-spacing: 0.04em;
|
| 2142 |
+
border: 1px solid var(--dm-accent);
|
| 2143 |
+
color: var(--dm-accent);
|
| 2144 |
+
padding: 0.2rem 0.5rem;
|
| 2145 |
+
white-space: nowrap;
|
| 2146 |
+
}
|
| 2147 |
+
@media (max-width: 640px) { .dda-dash-live-bar { width: 100%; } }
|
| 2148 |
+
|
| 2149 |
+
/* Ranked change-types list — replaces a second duplicate chart with
|
| 2150 |
+
the same totals the stacked "Change Types by Zone" bar already shows. */
|
| 2151 |
+
.dda-dash-ranked-list { display: flex; flex-direction: column; gap: 0.55rem; }
|
| 2152 |
+
.dda-dash-ranked-row { display: flex; align-items: center; gap: 0.65rem; font-size: 0.85rem; }
|
| 2153 |
+
.dda-dash-ranked-swatch { width: 10px; height: 10px; flex-shrink: 0; }
|
| 2154 |
+
.dda-dash-ranked-label { flex: 1; color: var(--dm-ink); }
|
| 2155 |
+
.dda-dash-ranked-count { font-weight: 700; }
|
| 2156 |
+
.dda-dash-ranked-hint { font-size: 0.72rem; color: var(--dm-ink-dim); margin: -0.4rem 0 0.65rem; }
|
| 2157 |
+
|
| 2158 |
/* Styled calendar date picker */
|
| 2159 |
.dda-datewrap {
|
| 2160 |
position: relative;
|
| 2161 |
+
min-width: 0;
|
| 2162 |
+
width: 100%;
|
| 2163 |
overflow: visible;
|
| 2164 |
}
|
| 2165 |
+
.dda-filter-field .dda-datewrap { min-width: 9.5rem; }
|
| 2166 |
+
input.dda-date-input.dda-date-native,
|
| 2167 |
.dda-date-native {
|
| 2168 |
+
position: absolute !important;
|
| 2169 |
+
left: 0;
|
| 2170 |
+
top: 0;
|
| 2171 |
+
width: 1px !important;
|
| 2172 |
+
min-width: 0 !important;
|
| 2173 |
+
max-width: 1px !important;
|
| 2174 |
+
height: 1px !important;
|
| 2175 |
+
margin: 0 !important;
|
| 2176 |
+
padding: 0 !important;
|
| 2177 |
+
border: 0 !important;
|
| 2178 |
+
opacity: 0 !important;
|
| 2179 |
pointer-events: none;
|
| 2180 |
+
clip: rect(0, 0, 0, 0);
|
| 2181 |
+
appearance: none;
|
| 2182 |
+
-webkit-appearance: none;
|
| 2183 |
+
}
|
| 2184 |
+
.dda-date-native:focus {
|
| 2185 |
+
outline: none !important;
|
| 2186 |
+
box-shadow: none !important;
|
| 2187 |
}
|
| 2188 |
.dda-date-trigger {
|
| 2189 |
display: flex;
|
| 2190 |
align-items: center;
|
| 2191 |
gap: 0.5rem;
|
| 2192 |
width: 100%;
|
| 2193 |
+
min-width: 0;
|
| 2194 |
min-height: 2.35rem;
|
| 2195 |
padding: 0.4rem 0.7rem;
|
| 2196 |
+
overflow: hidden;
|
| 2197 |
border: 1px solid var(--border);
|
| 2198 |
border-radius: 8px;
|
| 2199 |
background: var(--bg-card);
|
|
|
|
| 2210 |
box-shadow: 0 0 0 3px var(--accent-glow);
|
| 2211 |
}
|
| 2212 |
.dda-date-placeholder { color: var(--text-dim); font-weight: 450; }
|
| 2213 |
+
.dda-date-trigger > span:not(.dda-date-clear) {
|
| 2214 |
+
min-width: 0;
|
| 2215 |
+
overflow: hidden;
|
| 2216 |
+
text-overflow: ellipsis;
|
| 2217 |
+
white-space: nowrap;
|
| 2218 |
+
}
|
| 2219 |
.dda-date-clear {
|
| 2220 |
margin-left: auto;
|
| 2221 |
color: var(--text-dim);
|
static/css/style.css
CHANGED
|
@@ -238,6 +238,7 @@ body::before {
|
|
| 238 |
input[type="email"],
|
| 239 |
input[type="password"],
|
| 240 |
input[type="text"],
|
|
|
|
| 241 |
select,
|
| 242 |
textarea {
|
| 243 |
width: 100%;
|
|
@@ -1087,6 +1088,41 @@ input:focus, select:focus, textarea:focus {
|
|
| 1087 |
font-weight: 600;
|
| 1088 |
margin: 0;
|
| 1089 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1090 |
.result-modal-header-actions {
|
| 1091 |
display: flex;
|
| 1092 |
align-items: center;
|
|
@@ -1096,7 +1132,7 @@ input:focus, select:focus, textarea:focus {
|
|
| 1096 |
display: grid;
|
| 1097 |
grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
|
| 1098 |
gap: 1rem;
|
| 1099 |
-
align-items:
|
| 1100 |
transition: grid-template-columns var(--transition);
|
| 1101 |
}
|
| 1102 |
.result-modal-layout.report-hidden {
|
|
@@ -1109,18 +1145,41 @@ input:focus, select:focus, textarea:focus {
|
|
| 1109 |
min-width: 0;
|
| 1110 |
border-left: 1px solid var(--border);
|
| 1111 |
padding-left: 1rem;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1112 |
}
|
| 1113 |
.result-modal-side .dda-review-bar {
|
| 1114 |
margin-top: 0;
|
| 1115 |
padding-top: 0;
|
| 1116 |
border-top: 0;
|
|
|
|
| 1117 |
}
|
| 1118 |
.result-modal-side .regions-table-wrap {
|
| 1119 |
-
|
|
|
|
|
|
|
| 1120 |
overflow: auto;
|
|
|
|
|
|
|
| 1121 |
}
|
| 1122 |
.result-modal-side .regions-table {
|
| 1123 |
-
min-width:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1124 |
}
|
| 1125 |
.btn-close-modal {
|
| 1126 |
background: none;
|
|
@@ -1154,9 +1213,10 @@ input:focus, select:focus, textarea:focus {
|
|
| 1154 |
border-top: 1px solid var(--border);
|
| 1155 |
padding-left: 0;
|
| 1156 |
padding-top: 0.75rem;
|
|
|
|
| 1157 |
}
|
| 1158 |
.result-modal-side .regions-table-wrap {
|
| 1159 |
-
max-height:
|
| 1160 |
}
|
| 1161 |
}
|
| 1162 |
|
|
|
|
| 238 |
input[type="email"],
|
| 239 |
input[type="password"],
|
| 240 |
input[type="text"],
|
| 241 |
+
input[type="number"],
|
| 242 |
select,
|
| 243 |
textarea {
|
| 244 |
width: 100%;
|
|
|
|
| 1088 |
font-weight: 600;
|
| 1089 |
margin: 0;
|
| 1090 |
}
|
| 1091 |
+
.result-modal-title-wrap {
|
| 1092 |
+
display: flex;
|
| 1093 |
+
align-items: center;
|
| 1094 |
+
gap: 0.4rem;
|
| 1095 |
+
min-width: 0;
|
| 1096 |
+
}
|
| 1097 |
+
.result-title-edit-btn {
|
| 1098 |
+
flex-shrink: 0;
|
| 1099 |
+
width: 26px;
|
| 1100 |
+
height: 26px;
|
| 1101 |
+
padding: 0;
|
| 1102 |
+
display: inline-flex;
|
| 1103 |
+
align-items: center;
|
| 1104 |
+
justify-content: center;
|
| 1105 |
+
}
|
| 1106 |
+
.result-modal-title-edit {
|
| 1107 |
+
display: flex;
|
| 1108 |
+
align-items: center;
|
| 1109 |
+
gap: 0.5rem;
|
| 1110 |
+
flex-wrap: wrap;
|
| 1111 |
+
margin-bottom: 1rem;
|
| 1112 |
+
}
|
| 1113 |
+
.result-title-input {
|
| 1114 |
+
flex: 1 1 320px;
|
| 1115 |
+
min-width: 200px;
|
| 1116 |
+
max-width: 480px;
|
| 1117 |
+
padding: 0.45rem 0.65rem;
|
| 1118 |
+
font-family: var(--font-sans);
|
| 1119 |
+
font-size: 0.95rem;
|
| 1120 |
+
color: var(--text);
|
| 1121 |
+
background: var(--bg-elevated);
|
| 1122 |
+
border: 1px solid var(--border);
|
| 1123 |
+
border-radius: var(--radius-sm);
|
| 1124 |
+
}
|
| 1125 |
+
.result-title-msg { font-size: 0.8rem; }
|
| 1126 |
.result-modal-header-actions {
|
| 1127 |
display: flex;
|
| 1128 |
align-items: center;
|
|
|
|
| 1132 |
display: grid;
|
| 1133 |
grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
|
| 1134 |
gap: 1rem;
|
| 1135 |
+
align-items: stretch;
|
| 1136 |
transition: grid-template-columns var(--transition);
|
| 1137 |
}
|
| 1138 |
.result-modal-layout.report-hidden {
|
|
|
|
| 1145 |
min-width: 0;
|
| 1146 |
border-left: 1px solid var(--border);
|
| 1147 |
padding-left: 1rem;
|
| 1148 |
+
display: flex;
|
| 1149 |
+
flex-direction: column;
|
| 1150 |
+
min-height: 0;
|
| 1151 |
+
max-height: calc(100vh - 12rem);
|
| 1152 |
}
|
| 1153 |
.result-modal-side .dda-review-bar {
|
| 1154 |
margin-top: 0;
|
| 1155 |
padding-top: 0;
|
| 1156 |
border-top: 0;
|
| 1157 |
+
flex: 0 0 auto;
|
| 1158 |
}
|
| 1159 |
.result-modal-side .regions-table-wrap {
|
| 1160 |
+
flex: 1 1 auto;
|
| 1161 |
+
min-height: 0;
|
| 1162 |
+
max-height: none;
|
| 1163 |
overflow: auto;
|
| 1164 |
+
overscroll-behavior: contain;
|
| 1165 |
+
margin-top: 0.5rem;
|
| 1166 |
}
|
| 1167 |
.result-modal-side .regions-table {
|
| 1168 |
+
min-width: 960px;
|
| 1169 |
+
/* sticky thead + collapse clips mid-row in Chromium */
|
| 1170 |
+
border-collapse: separate;
|
| 1171 |
+
border-spacing: 0;
|
| 1172 |
+
}
|
| 1173 |
+
.result-modal-side .regions-table th {
|
| 1174 |
+
position: sticky;
|
| 1175 |
+
top: 0;
|
| 1176 |
+
z-index: 3;
|
| 1177 |
+
background: var(--bg-elevated, #f5f5f5);
|
| 1178 |
+
box-shadow: 0 1px 0 var(--border);
|
| 1179 |
+
}
|
| 1180 |
+
.result-modal-side > .pagination {
|
| 1181 |
+
flex: 0 0 auto;
|
| 1182 |
+
padding-top: 0.5rem;
|
| 1183 |
}
|
| 1184 |
.btn-close-modal {
|
| 1185 |
background: none;
|
|
|
|
| 1213 |
border-top: 1px solid var(--border);
|
| 1214 |
padding-left: 0;
|
| 1215 |
padding-top: 0.75rem;
|
| 1216 |
+
max-height: none;
|
| 1217 |
}
|
| 1218 |
.result-modal-side .regions-table-wrap {
|
| 1219 |
+
max-height: min(50vh, 420px);
|
| 1220 |
}
|
| 1221 |
}
|
| 1222 |
|
static/js/dda/app.js
CHANGED
|
@@ -35,7 +35,7 @@ async function initDda() {
|
|
| 35 |
if (!needsLibrary && !needsDetect) return;
|
| 36 |
try {
|
| 37 |
const [me, cfg, localCfg] = await Promise.all([
|
| 38 |
-
ddaApi('GET', '/api/
|
| 39 |
ddaApi('GET', '/api/dda/config'),
|
| 40 |
ddaApi('GET', '/api/dda/local/config'),
|
| 41 |
]);
|
|
@@ -55,9 +55,7 @@ async function initDda() {
|
|
| 55 |
|
| 56 |
const folderPath = document.getElementById('lib-folder-path');
|
| 57 |
if (folderPath) {
|
| 58 |
-
folderPath.textContent = localCfg.
|
| 59 |
-
? 'Hugging Face — tree library'
|
| 60 |
-
: `Storage: ${localCfg.storageRoot || ''}`;
|
| 61 |
}
|
| 62 |
|
| 63 |
const instr = document.getElementById('lib-instructions');
|
|
@@ -74,10 +72,16 @@ async function initDda() {
|
|
| 74 |
}
|
| 75 |
|
| 76 |
if (needsLibrary) {
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
} catch (err) {
|
| 83 |
showDdaError(err.message || 'Failed to load library');
|
|
@@ -109,19 +113,35 @@ function renderLibGrid() {
|
|
| 109 |
grid.innerHTML = items.map((img) => {
|
| 110 |
const thumb = img.thumbUrl || '';
|
| 111 |
const crumb = img.breadcrumb || img.nodePath || img.filename;
|
|
|
|
| 112 |
const encPath = encodeURIComponent(img.path);
|
|
|
|
|
|
|
| 113 |
return `
|
| 114 |
<div class="dda-card-img" draggable="true" data-image-path="${encPath}" data-image-id="${img.id}" title="Click to view — ${escapeHtml(img.filename)}">
|
|
|
|
|
|
|
|
|
|
| 115 |
<button type="button" class="dda-card-delete-btn" title="Delete image" aria-label="Delete image" data-requires-permission="library:delete">×</button>
|
| 116 |
${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 117 |
<div class="meta">
|
|
|
|
| 118 |
<span class="dim dda-crumb">${escapeHtml(crumb)}</span><br/>
|
| 119 |
-
<span class="dim">${
|
|
|
|
|
|
|
|
|
|
| 120 |
</div>
|
| 121 |
</div>`;
|
| 122 |
}).join('');
|
| 123 |
if (typeof bindLibraryGridCards === 'function') bindLibraryGridCards(grid);
|
| 124 |
if (typeof applyPermissionGating === 'function') applyPermissionGating(window.ddaPermissions || {}, grid);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
if (typeof renderPaginationControls === 'function') {
|
| 127 |
renderPaginationControls(document.getElementById('lib-grid-pagination'), libGridPage, totalPages, (p) => {
|
|
@@ -137,7 +157,7 @@ async function loadLibraryImages(opts) {
|
|
| 137 |
if (!grid) return;
|
| 138 |
if (opts && opts.resetPage) libGridPage = 0;
|
| 139 |
const q = document.getElementById('lib-filter')?.value?.trim() || '';
|
| 140 |
-
const params = new URLSearchParams();
|
| 141 |
if (selectedNode.id) params.set('node_id', String(selectedNode.id));
|
| 142 |
if (q) params.set('q', q);
|
| 143 |
params.set('limit', String(LIB_GRID_PER_PAGE));
|
|
@@ -155,11 +175,6 @@ async function loadLibraryImages(opts) {
|
|
| 155 |
return loadLibraryImages();
|
| 156 |
}
|
| 157 |
renderLibGrid();
|
| 158 |
-
if (typeof loadAreaPairs === 'function') {
|
| 159 |
-
const run = () => loadAreaPairs({ nodeId: selectedNode.id || null });
|
| 160 |
-
if (typeof deferIdle === 'function') deferIdle(run);
|
| 161 |
-
else setTimeout(run, 0);
|
| 162 |
-
}
|
| 163 |
} catch (err) {
|
| 164 |
grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 165 |
}
|
|
@@ -192,7 +207,7 @@ document.getElementById('btn-refresh-lib')?.addEventListener('click', async () =
|
|
| 192 |
const emailEl = document.getElementById('dda-user-email');
|
| 193 |
if (!emailEl) return;
|
| 194 |
try {
|
| 195 |
-
const me = await ddaApi('GET', '/api/me');
|
| 196 |
emailEl.textContent = me.email || '';
|
| 197 |
const avatarEl = document.getElementById('dda-user-avatar');
|
| 198 |
if (avatarEl) avatarEl.textContent = (me.full_name || me.email || '?').trim().charAt(0).toUpperCase();
|
|
|
|
| 35 |
if (!needsLibrary && !needsDetect) return;
|
| 36 |
try {
|
| 37 |
const [me, cfg, localCfg] = await Promise.all([
|
| 38 |
+
(typeof ensureDdaMe === 'function' ? ensureDdaMe() : ddaApi('GET', '/api/me')).catch(() => null),
|
| 39 |
ddaApi('GET', '/api/dda/config'),
|
| 40 |
ddaApi('GET', '/api/dda/local/config'),
|
| 41 |
]);
|
|
|
|
| 55 |
|
| 56 |
const folderPath = document.getElementById('lib-folder-path');
|
| 57 |
if (folderPath) {
|
| 58 |
+
folderPath.textContent = `Storage: ${localCfg.storageRoot || ''}`;
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
const instr = document.getElementById('lib-instructions');
|
|
|
|
| 72 |
}
|
| 73 |
|
| 74 |
if (needsLibrary) {
|
| 75 |
+
// Grid first — tree must not block first paint of images.
|
| 76 |
+
loadLibraryImages({ resetPage: true });
|
| 77 |
+
if (typeof loadTree === 'function') {
|
| 78 |
+
loadTree().catch(() => {
|
| 79 |
+
const treeEl = document.getElementById('lib-tree');
|
| 80 |
+
if (treeEl && !treeEl.querySelector('.dda-tree-node, .dda-tree-item')) {
|
| 81 |
+
treeEl.innerHTML = '<p class="dim">Could not load folders.</p>';
|
| 82 |
+
}
|
| 83 |
+
});
|
| 84 |
+
}
|
| 85 |
}
|
| 86 |
} catch (err) {
|
| 87 |
showDdaError(err.message || 'Failed to load library');
|
|
|
|
| 113 |
grid.innerHTML = items.map((img) => {
|
| 114 |
const thumb = img.thumbUrl || '';
|
| 115 |
const crumb = img.breadcrumb || img.nodePath || img.filename;
|
| 116 |
+
const place = [img.villageName, img.zoneName].filter(Boolean).join(', ');
|
| 117 |
const encPath = encodeURIComponent(img.path);
|
| 118 |
+
const capture = img.captureDate ? String(img.captureDate).slice(0, 10) : '';
|
| 119 |
+
const year = img.year != null ? String(img.year) : '';
|
| 120 |
return `
|
| 121 |
<div class="dda-card-img" draggable="true" data-image-path="${encPath}" data-image-id="${img.id}" title="Click to view — ${escapeHtml(img.filename)}">
|
| 122 |
+
<label class="dda-card-select-wrap" onclick="event.stopPropagation()">
|
| 123 |
+
<input type="checkbox" class="dda-card-select" data-image-id="${img.id}" />
|
| 124 |
+
</label>
|
| 125 |
<button type="button" class="dda-card-delete-btn" title="Delete image" aria-label="Delete image" data-requires-permission="library:delete">×</button>
|
| 126 |
${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 127 |
<div class="meta">
|
| 128 |
+
<strong class="dda-card-filename">${escapeHtml(img.filename || img.imageName || '')}</strong><br/>
|
| 129 |
<span class="dim dda-crumb">${escapeHtml(crumb)}</span><br/>
|
| 130 |
+
${place ? `<span class="dim dda-crumb">${escapeHtml(place)}</span><br/>` : ''}
|
| 131 |
+
${capture ? `<span class="dda-card-capture">Capture: ${escapeHtml(capture)}</span><br/>` : ''}
|
| 132 |
+
<span class="dim">${year ? escapeHtml(year) + ' · ' : ''}${escapeHtml(img.imageType || '')}${img.source ? ' · ' + escapeHtml(img.source) : ''} · ${formatBytes(img.fileSizeBytes)}${img.width && img.height ? ` · ${img.width}×${img.height}` : ''}</span><br/>
|
| 133 |
+
<span class="dim dda-card-uploaded">Uploaded ${typeof formatDateIst === 'function' ? formatDateIst(img.uploadedOn) : (img.uploadedOn || '—')}</span>
|
| 134 |
</div>
|
| 135 |
</div>`;
|
| 136 |
}).join('');
|
| 137 |
if (typeof bindLibraryGridCards === 'function') bindLibraryGridCards(grid);
|
| 138 |
if (typeof applyPermissionGating === 'function') applyPermissionGating(window.ddaPermissions || {}, grid);
|
| 139 |
+
grid.querySelectorAll('.dda-card-select').forEach((cb) => {
|
| 140 |
+
cb.addEventListener('change', () => {
|
| 141 |
+
if (typeof updateLibDeleteSelectedBtn === 'function') updateLibDeleteSelectedBtn();
|
| 142 |
+
});
|
| 143 |
+
});
|
| 144 |
+
if (typeof updateLibDeleteSelectedBtn === 'function') updateLibDeleteSelectedBtn();
|
| 145 |
|
| 146 |
if (typeof renderPaginationControls === 'function') {
|
| 147 |
renderPaginationControls(document.getElementById('lib-grid-pagination'), libGridPage, totalPages, (p) => {
|
|
|
|
| 157 |
if (!grid) return;
|
| 158 |
if (opts && opts.resetPage) libGridPage = 0;
|
| 159 |
const q = document.getElementById('lib-filter')?.value?.trim() || '';
|
| 160 |
+
const params = typeof libFilterParams === 'function' ? libFilterParams() : new URLSearchParams();
|
| 161 |
if (selectedNode.id) params.set('node_id', String(selectedNode.id));
|
| 162 |
if (q) params.set('q', q);
|
| 163 |
params.set('limit', String(LIB_GRID_PER_PAGE));
|
|
|
|
| 175 |
return loadLibraryImages();
|
| 176 |
}
|
| 177 |
renderLibGrid();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
} catch (err) {
|
| 179 |
grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 180 |
}
|
|
|
|
| 207 |
const emailEl = document.getElementById('dda-user-email');
|
| 208 |
if (!emailEl) return;
|
| 209 |
try {
|
| 210 |
+
const me = typeof ensureDdaMe === 'function' ? await ensureDdaMe() : await ddaApi('GET', '/api/me');
|
| 211 |
emailEl.textContent = me.email || '';
|
| 212 |
const avatarEl = document.getElementById('dda-user-avatar');
|
| 213 |
if (avatarEl) avatarEl.textContent = (me.full_name || me.email || '?').trim().charAt(0).toUpperCase();
|
static/js/dda/audit_log.js
CHANGED
|
@@ -9,6 +9,13 @@
|
|
| 9 |
|
| 10 |
let auditPage = 0;
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
function fillSelect(select, values, current) {
|
| 13 |
if (!select) return;
|
| 14 |
const placeholder = select.options[0];
|
|
@@ -70,7 +77,7 @@
|
|
| 70 |
<td><time class="dda-date">${typeof formatDateIst === 'function' ? formatDateIst(r.timestamp) : escapeHtml(r.timestamp || '')}</time></td>
|
| 71 |
<td>${escapeHtml(r.user || '—')}</td>
|
| 72 |
<td>${escapeHtml(r.role || '—')}</td>
|
| 73 |
-
<td class="dda-action-cell">${escapeHtml(r.action)}</td>
|
| 74 |
<td>${escapeHtml(r.module || '—')}</td>
|
| 75 |
<td class="dda-log-msg">${escapeHtml(r.newValue || r.oldValue || '')}</td>
|
| 76 |
</tr>`).join('')}
|
|
|
|
| 9 |
|
| 10 |
let auditPage = 0;
|
| 11 |
|
| 12 |
+
function actionBadgeClass(action) {
|
| 13 |
+
const a = (action || '').toLowerCase();
|
| 14 |
+
if (a.includes('delete') || a.includes('remove') || a.includes('clear')) return 'is-danger';
|
| 15 |
+
if (a.includes('create') || a.includes('add') || a.includes('upload') || a.includes('login')) return 'is-ok';
|
| 16 |
+
return 'is-neutral';
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
function fillSelect(select, values, current) {
|
| 20 |
if (!select) return;
|
| 21 |
const placeholder = select.options[0];
|
|
|
|
| 77 |
<td><time class="dda-date">${typeof formatDateIst === 'function' ? formatDateIst(r.timestamp) : escapeHtml(r.timestamp || '')}</time></td>
|
| 78 |
<td>${escapeHtml(r.user || '—')}</td>
|
| 79 |
<td>${escapeHtml(r.role || '—')}</td>
|
| 80 |
+
<td class="dda-action-cell"><span class="dda-badge ${actionBadgeClass(r.action)}">${escapeHtml(r.action)}</span></td>
|
| 81 |
<td>${escapeHtml(r.module || '—')}</td>
|
| 82 |
<td class="dda-log-msg">${escapeHtml(r.newValue || r.oldValue || '')}</td>
|
| 83 |
</tr>`).join('')}
|
static/js/dda/compare.js
CHANGED
|
@@ -29,6 +29,72 @@ function initShapeToggle() {
|
|
| 29 |
apply();
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
function compareFormatBytes(n) {
|
| 33 |
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
| 34 |
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
|
|
@@ -117,12 +183,25 @@ function setDetectMode(mode, { applyPair } = { applyPair: true }) {
|
|
| 117 |
? `Automatic: the system selects the oldest image as <strong>Before</strong> and the newest as <strong>After</strong> for the chosen area. Set the interval and time, then click <strong>Start schedule</strong> — it does not run when the app launches. Use <strong>Run automatic detection</strong> for a one-off run.`
|
| 118 |
: 'Manual: pick the Before and After images yourself (dropdowns, library cards, or drag-and-drop), then run change detection. Manual runs are not scheduled.';
|
| 119 |
}
|
| 120 |
-
if (compareState.mode === 'automatic'
|
| 121 |
-
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
updateRunButton();
|
| 124 |
}
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
function initDetectMode() {
|
| 127 |
const wrap = document.getElementById('dda-detect-mode');
|
| 128 |
if (!wrap || wrap.dataset.bound) {
|
|
@@ -157,9 +236,10 @@ function renderSlotPreview(slotKey, selection) {
|
|
| 157 |
|
| 158 |
slot.classList.add('filled');
|
| 159 |
const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
|
|
|
|
| 160 |
wrap.innerHTML = `
|
| 161 |
<img class="dda-slot-preview" src="${thumb}" alt="" />
|
| 162 |
-
<div class="dda-slot-meta">${escapeHtml(selection.label || selection.filename)}
|
| 163 |
if (slotKey === 't2') setupRoiDraw();
|
| 164 |
}
|
| 165 |
|
|
@@ -339,6 +419,18 @@ function setSlot(slotKey, img, options) {
|
|
| 339 |
filename: img.filename,
|
| 340 |
thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
|
| 341 |
captureDate: img.captureDate || null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
};
|
| 343 |
if (slotKey === 't1') {
|
| 344 |
compareState.t1 = item;
|
|
@@ -509,14 +601,6 @@ async function loadCompareLibraryGrid(opts) {
|
|
| 509 |
ensureDdaState().libraryItems = page.items;
|
| 510 |
populateSelects(selectSourceItems(page.items));
|
| 511 |
renderCompareGrid(page.items, page.total);
|
| 512 |
-
if (typeof loadAreaPairs === 'function' && !opts?.keepPage) {
|
| 513 |
-
const run = () => loadAreaPairs({
|
| 514 |
-
nodeId: compareState.selectedNode?.id || null,
|
| 515 |
-
autoFillCompare: true,
|
| 516 |
-
});
|
| 517 |
-
if (typeof deferIdle === 'function') deferIdle(run);
|
| 518 |
-
else setTimeout(run, 0);
|
| 519 |
-
}
|
| 520 |
} catch (err) {
|
| 521 |
grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 522 |
if (typeof showDdaError === 'function') showDdaError(err.message);
|
|
@@ -862,6 +946,42 @@ async function pollJobUntilDone(jobId) {
|
|
| 862 |
}
|
| 863 |
}
|
| 864 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
async function runLibraryDetection() {
|
| 866 |
if (!compareState.t1 || !compareState.t2) {
|
| 867 |
if (typeof showDdaError === 'function') showDdaError(
|
|
@@ -908,9 +1028,8 @@ async function runLibraryDetection() {
|
|
| 908 |
form.append('method', document.getElementById('dda-detect-method')?.value || 'AI-Based Deep Learning');
|
| 909 |
form.append('enable_registration', String(document.getElementById('dda-detect-registration')?.checked !== false));
|
| 910 |
form.append('enable_normalization', String(document.getElementById('dda-detect-normalization')?.checked !== false));
|
| 911 |
-
form.append('detection_sensitivity', String(
|
| 912 |
-
|
| 913 |
-
if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
|
| 914 |
const notifyCb = document.getElementById('dda-detect-notify');
|
| 915 |
const notifyEmail = document.getElementById('dda-detect-notify-email');
|
| 916 |
if (notifyCb?.checked && notifyEmail?.value?.trim()) {
|
|
@@ -944,6 +1063,7 @@ let compareInitialized = false;
|
|
| 944 |
function initCompareTab() {
|
| 945 |
initShapeToggle();
|
| 946 |
initDetectMode();
|
|
|
|
| 947 |
if (!compareInitialized) {
|
| 948 |
if (typeof registerCompareTreeSidebar === 'function') {
|
| 949 |
registerCompareTreeSidebar({
|
|
@@ -967,6 +1087,11 @@ function initCompareTab() {
|
|
| 967 |
updateRunButton();
|
| 968 |
loadCompareLibraryGrid();
|
| 969 |
if (typeof loadTree === 'function') loadTree().catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
}
|
| 971 |
|
| 972 |
window.loadCompareLibraryGrid = loadCompareLibraryGrid;
|
|
|
|
| 29 |
apply();
|
| 30 |
}
|
| 31 |
|
| 32 |
+
// --- Sensitivity slider + Minimum Region validation --------------------
|
| 33 |
+
function clampSensitivity(raw) {
|
| 34 |
+
const n = Number(raw);
|
| 35 |
+
return Number.isNaN(n) ? 0.45 : Math.max(0, Math.min(1, n));
|
| 36 |
+
}
|
| 37 |
+
function clampMinRegionArea(raw) {
|
| 38 |
+
const n = Math.round(Number(raw));
|
| 39 |
+
return Number.isNaN(n) ? 50 : Math.max(50, Math.min(10000, n));
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function initDetectSettingsControls() {
|
| 43 |
+
const sens = document.getElementById('dda-detect-sensitivity');
|
| 44 |
+
const sensVal = document.getElementById('dda-detect-sensitivity-val');
|
| 45 |
+
if (sens && !sens.dataset.bound) {
|
| 46 |
+
sens.dataset.bound = '1';
|
| 47 |
+
const syncSensLabel = () => { if (sensVal) sensVal.textContent = clampSensitivity(sens.value).toFixed(2); };
|
| 48 |
+
sens.addEventListener('input', syncSensLabel);
|
| 49 |
+
syncSensLabel();
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const minArea = document.getElementById('dda-detect-min-area');
|
| 53 |
+
const minAreaHint = document.getElementById('dda-detect-min-area-hint');
|
| 54 |
+
const decBtn = document.getElementById('dda-detect-min-area-dec');
|
| 55 |
+
const incBtn = document.getElementById('dda-detect-min-area-inc');
|
| 56 |
+
const MIN_AREA_STEP = 10;
|
| 57 |
+
|
| 58 |
+
function updateMinAreaStepperState() {
|
| 59 |
+
if (!minArea) return;
|
| 60 |
+
const v = clampMinRegionArea(minArea.value);
|
| 61 |
+
if (decBtn) decBtn.disabled = v <= 50;
|
| 62 |
+
if (incBtn) incBtn.disabled = v >= 10000;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function applyMinAreaValue(rawValue, { wasBlank = false } = {}) {
|
| 66 |
+
if (!minArea) return;
|
| 67 |
+
const before = wasBlank ? NaN : Number(rawValue);
|
| 68 |
+
const clamped = clampMinRegionArea(rawValue);
|
| 69 |
+
const changed = wasBlank || Number.isNaN(before) || before !== clamped;
|
| 70 |
+
minArea.value = String(clamped);
|
| 71 |
+
if (minAreaHint) {
|
| 72 |
+
if (changed) {
|
| 73 |
+
const reason = clamped === 50 ? 'minimum allowed' : clamped === 10000 ? 'maximum allowed' : 'valid range';
|
| 74 |
+
minAreaHint.textContent = `Adjusted to ${clamped} (${reason}).`;
|
| 75 |
+
minAreaHint.classList.remove('hidden');
|
| 76 |
+
} else {
|
| 77 |
+
minAreaHint.classList.add('hidden');
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
updateMinAreaStepperState();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if (minArea && !minArea.dataset.bound) {
|
| 84 |
+
minArea.dataset.bound = '1';
|
| 85 |
+
minArea.addEventListener('blur', () => {
|
| 86 |
+
applyMinAreaValue(minArea.value, { wasBlank: minArea.value.trim() === '' });
|
| 87 |
+
});
|
| 88 |
+
decBtn?.addEventListener('click', () => {
|
| 89 |
+
applyMinAreaValue(clampMinRegionArea(minArea.value) - MIN_AREA_STEP);
|
| 90 |
+
});
|
| 91 |
+
incBtn?.addEventListener('click', () => {
|
| 92 |
+
applyMinAreaValue(clampMinRegionArea(minArea.value) + MIN_AREA_STEP);
|
| 93 |
+
});
|
| 94 |
+
updateMinAreaStepperState();
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
function compareFormatBytes(n) {
|
| 99 |
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
| 100 |
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
|
|
|
|
| 183 |
? `Automatic: the system selects the oldest image as <strong>Before</strong> and the newest as <strong>After</strong> for the chosen area. Set the interval and time, then click <strong>Start schedule</strong> — it does not run when the app launches. Use <strong>Run automatic detection</strong> for a one-off run.`
|
| 184 |
: 'Manual: pick the Before and After images yourself (dropdowns, library cards, or drag-and-drop), then run change detection. Manual runs are not scheduled.';
|
| 185 |
}
|
| 186 |
+
if (compareState.mode === 'automatic') {
|
| 187 |
+
ensureDetectPairsLoaded({ applyPair: applyPair !== false });
|
| 188 |
+
} else if (applyPair !== false && typeof applySelectedAutoPair === 'function') {
|
| 189 |
+
// no-op for manual
|
| 190 |
}
|
| 191 |
updateRunButton();
|
| 192 |
}
|
| 193 |
|
| 194 |
+
async function ensureDetectPairsLoaded({ applyPair } = {}) {
|
| 195 |
+
if (typeof loadAreaPairs !== 'function') return;
|
| 196 |
+
const nodeId = compareState.selectedNode?.id || null;
|
| 197 |
+
await loadAreaPairs({
|
| 198 |
+
nodeId,
|
| 199 |
+
includeUnpaired: true,
|
| 200 |
+
autoFillCompare: !!applyPair,
|
| 201 |
+
});
|
| 202 |
+
if (applyPair && typeof applySelectedAutoPair === 'function') applySelectedAutoPair();
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
function initDetectMode() {
|
| 206 |
const wrap = document.getElementById('dda-detect-mode');
|
| 207 |
if (!wrap || wrap.dataset.bound) {
|
|
|
|
| 236 |
|
| 237 |
slot.classList.add('filled');
|
| 238 |
const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
|
| 239 |
+
const metaHtml = typeof buildImageMetaHtml === 'function' ? buildImageMetaHtml(selection) : '';
|
| 240 |
wrap.innerHTML = `
|
| 241 |
<img class="dda-slot-preview" src="${thumb}" alt="" />
|
| 242 |
+
<div class="dda-slot-meta">${metaHtml || escapeHtml(selection.label || selection.filename)}</div>`;
|
| 243 |
if (slotKey === 't2') setupRoiDraw();
|
| 244 |
}
|
| 245 |
|
|
|
|
| 419 |
filename: img.filename,
|
| 420 |
thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
|
| 421 |
captureDate: img.captureDate || null,
|
| 422 |
+
breadcrumb: img.breadcrumb || null,
|
| 423 |
+
nodePath: img.nodePath || null,
|
| 424 |
+
imageType: img.imageType || null,
|
| 425 |
+
format: img.format || null,
|
| 426 |
+
fileSizeBytes: img.fileSizeBytes || 0,
|
| 427 |
+
width: img.width || 0,
|
| 428 |
+
height: img.height || 0,
|
| 429 |
+
hasGeoref: !!img.hasGeoref,
|
| 430 |
+
uploadedBy: img.uploadedBy || null,
|
| 431 |
+
uploadedOn: img.uploadedOn || null,
|
| 432 |
+
villageName: img.villageName || null,
|
| 433 |
+
zoneName: img.zoneName || null,
|
| 434 |
};
|
| 435 |
if (slotKey === 't1') {
|
| 436 |
compareState.t1 = item;
|
|
|
|
| 601 |
ensureDdaState().libraryItems = page.items;
|
| 602 |
populateSelects(selectSourceItems(page.items));
|
| 603 |
renderCompareGrid(page.items, page.total);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
} catch (err) {
|
| 605 |
grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 606 |
if (typeof showDdaError === 'function') showDdaError(err.message);
|
|
|
|
| 946 |
}
|
| 947 |
}
|
| 948 |
|
| 949 |
+
/** If this user already has a job running/queued (e.g. they navigated away
|
| 950 |
+
* from /detect and came back, or reloaded the page) resume showing its live
|
| 951 |
+
* progress instead of silently presenting the idle "Ready to detect?" form
|
| 952 |
+
* — that blank state is exactly what makes a still-running job look dead
|
| 953 |
+
* and tempts a server restart, which actually does kill it. */
|
| 954 |
+
async function resumeActiveJobIfAny() {
|
| 955 |
+
let jobs;
|
| 956 |
+
try {
|
| 957 |
+
jobs = typeof getDdaNotifyJobs === 'function'
|
| 958 |
+
? await getDdaNotifyJobs()
|
| 959 |
+
: ((await ddaApi('GET', '/api/dda/jobs?limit=20')).jobs || []);
|
| 960 |
+
} catch (_) {
|
| 961 |
+
return;
|
| 962 |
+
}
|
| 963 |
+
const active = jobs.find((j) => j.status === 'running' || j.status === 'queued');
|
| 964 |
+
if (!active) return;
|
| 965 |
+
|
| 966 |
+
if (typeof showDdaSuccess === 'function') {
|
| 967 |
+
showDdaSuccess('Resuming progress for a detection already in progress…');
|
| 968 |
+
}
|
| 969 |
+
forEachRunButton((btn) => { btn.disabled = true; });
|
| 970 |
+
showDetectProgress();
|
| 971 |
+
try {
|
| 972 |
+
const job = await pollJobUntilDone(active.id);
|
| 973 |
+
if (job.jobId && typeof window.markDdaJobSeen === 'function') window.markDdaJobSeen(job.jobId);
|
| 974 |
+
showDetectResult(job.result);
|
| 975 |
+
if (typeof showDdaSuccess === 'function') showDdaSuccess('Detection complete.');
|
| 976 |
+
if (typeof loadReportsList === 'function') loadReportsList();
|
| 977 |
+
} catch (err) {
|
| 978 |
+
if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
|
| 979 |
+
} finally {
|
| 980 |
+
updateRunButton();
|
| 981 |
+
hideDetectProgress(2000);
|
| 982 |
+
}
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
async function runLibraryDetection() {
|
| 986 |
if (!compareState.t1 || !compareState.t2) {
|
| 987 |
if (typeof showDdaError === 'function') showDdaError(
|
|
|
|
| 1028 |
form.append('method', document.getElementById('dda-detect-method')?.value || 'AI-Based Deep Learning');
|
| 1029 |
form.append('enable_registration', String(document.getElementById('dda-detect-registration')?.checked !== false));
|
| 1030 |
form.append('enable_normalization', String(document.getElementById('dda-detect-normalization')?.checked !== false));
|
| 1031 |
+
form.append('detection_sensitivity', String(clampSensitivity(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)));
|
| 1032 |
+
form.append('min_region_area', String(clampMinRegionArea(document.getElementById('dda-detect-min-area')?.value ?? 150)));
|
|
|
|
| 1033 |
const notifyCb = document.getElementById('dda-detect-notify');
|
| 1034 |
const notifyEmail = document.getElementById('dda-detect-notify-email');
|
| 1035 |
if (notifyCb?.checked && notifyEmail?.value?.trim()) {
|
|
|
|
| 1063 |
function initCompareTab() {
|
| 1064 |
initShapeToggle();
|
| 1065 |
initDetectMode();
|
| 1066 |
+
initDetectSettingsControls();
|
| 1067 |
if (!compareInitialized) {
|
| 1068 |
if (typeof registerCompareTreeSidebar === 'function') {
|
| 1069 |
registerCompareTreeSidebar({
|
|
|
|
| 1087 |
updateRunButton();
|
| 1088 |
loadCompareLibraryGrid();
|
| 1089 |
if (typeof loadTree === 'function') loadTree().catch(() => {});
|
| 1090 |
+
resumeActiveJobIfAny();
|
| 1091 |
+
// Automatic mode loads pairs from setDetectMode; ?pair= on Manual still needs a fetch.
|
| 1092 |
+
if (new URLSearchParams(window.location.search).get('pair') && compareState.mode !== 'automatic') {
|
| 1093 |
+
ensureDetectPairsLoaded({ applyPair: false });
|
| 1094 |
+
}
|
| 1095 |
}
|
| 1096 |
|
| 1097 |
window.loadCompareLibraryGrid = loadCompareLibraryGrid;
|
static/js/dda/exception_log.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
| 7 |
|
| 8 |
// ------------------------------------------------------------- Tabs ----
|
| 9 |
const TITLES = {
|
| 10 |
-
exceptions: ['
|
| 11 |
-
applogs: ['
|
| 12 |
};
|
| 13 |
|
| 14 |
function activateTab(tab) {
|
|
@@ -106,7 +106,7 @@
|
|
| 106 |
wrap.innerHTML = `
|
| 107 |
<table class="dda-logs-table">
|
| 108 |
<thead>
|
| 109 |
-
<tr><th>Timestamp (IST)</th><th>Type</th><th>Message</th><th>Path</th><th>Code</th><th>Handled</th><th>Details</th></tr>
|
| 110 |
</thead>
|
| 111 |
<tbody>
|
| 112 |
${rows.map((r) => `
|
|
@@ -116,6 +116,8 @@
|
|
| 116 |
<td class="dda-log-msg">${escapeHtml(r.message || '')}</td>
|
| 117 |
<td class="dda-action-cell">${escapeHtml(r.method || '')} ${escapeHtml(r.path || '')}</td>
|
| 118 |
<td>${escapeHtml(String(r.statusCode ?? ''))}</td>
|
|
|
|
|
|
|
| 119 |
<td><span class="dda-badge ${r.handled ? 'is-ok' : 'is-danger'}">${r.handled ? '✓ Yes' : '✗ No'}</span></td>
|
| 120 |
<td><button type="button" class="btn btn-secondary btn-sm exception-view-btn" data-id="${r.id}">View</button></td>
|
| 121 |
</tr>`).join('')}
|
|
@@ -141,6 +143,7 @@
|
|
| 141 |
`${entry.type} — ${entry.statusCode} (${entry.handled ? 'handled' : 'unhandled'})\n` +
|
| 142 |
`${entry.method} ${entry.path}\n` +
|
| 143 |
`User: ${entry.userEmail || '—'}\n` +
|
|
|
|
| 144 |
`Time: ${entry.timestamp}\n\n${entry.message}\n\n${entry.traceback || ''}`;
|
| 145 |
} catch (err) {
|
| 146 |
content.textContent = err.message || 'Could not load exception details.';
|
|
|
|
| 7 |
|
| 8 |
// ------------------------------------------------------------- Tabs ----
|
| 9 |
const TITLES = {
|
| 10 |
+
exceptions: ['Exception Log', 'System errors captured by the global exception handler'],
|
| 11 |
+
applogs: ['Application Logs', 'Live view of recent server events. Filter, download, or clear the buffer.'],
|
| 12 |
};
|
| 13 |
|
| 14 |
function activateTab(tab) {
|
|
|
|
| 106 |
wrap.innerHTML = `
|
| 107 |
<table class="dda-logs-table">
|
| 108 |
<thead>
|
| 109 |
+
<tr><th>Timestamp (IST)</th><th>Type</th><th>Message</th><th>Path</th><th>Code</th><th>User</th><th>Role</th><th>Handled</th><th>Details</th></tr>
|
| 110 |
</thead>
|
| 111 |
<tbody>
|
| 112 |
${rows.map((r) => `
|
|
|
|
| 116 |
<td class="dda-log-msg">${escapeHtml(r.message || '')}</td>
|
| 117 |
<td class="dda-action-cell">${escapeHtml(r.method || '')} ${escapeHtml(r.path || '')}</td>
|
| 118 |
<td>${escapeHtml(String(r.statusCode ?? ''))}</td>
|
| 119 |
+
<td>${escapeHtml(r.userEmail || '—')}</td>
|
| 120 |
+
<td>${escapeHtml(r.userRole || '—')}</td>
|
| 121 |
<td><span class="dda-badge ${r.handled ? 'is-ok' : 'is-danger'}">${r.handled ? '✓ Yes' : '✗ No'}</span></td>
|
| 122 |
<td><button type="button" class="btn btn-secondary btn-sm exception-view-btn" data-id="${r.id}">View</button></td>
|
| 123 |
</tr>`).join('')}
|
|
|
|
| 143 |
`${entry.type} — ${entry.statusCode} (${entry.handled ? 'handled' : 'unhandled'})\n` +
|
| 144 |
`${entry.method} ${entry.path}\n` +
|
| 145 |
`User: ${entry.userEmail || '—'}\n` +
|
| 146 |
+
`Role: ${entry.userRole || '—'}\n` +
|
| 147 |
`Time: ${entry.timestamp}\n\n${entry.message}\n\n${entry.traceback || ''}`;
|
| 148 |
} catch (err) {
|
| 149 |
content.textContent = err.message || 'Could not load exception details.';
|
static/js/dda/home.js
CHANGED
|
@@ -2,6 +2,35 @@
|
|
| 2 |
* change comparison, reports feed, and charts. Replaces the old
|
| 3 |
* greeting+3-cards Home page in place. */
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
function formatHomeDate(iso) {
|
| 6 |
return typeof formatDateIst === 'function' ? formatDateIst(iso) : (iso || '—');
|
| 7 |
}
|
|
@@ -16,22 +45,173 @@ function dashDelta(v, suffix) {
|
|
| 16 |
return `${sign}${typeof v === 'number' && suffix === '%' ? v.toFixed(2) : v}${suffix}`;
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const dashCharts = {};
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
function renderDashChart(canvasId, config) {
|
| 22 |
const canvas = document.getElementById(canvasId);
|
| 23 |
if (!canvas || typeof Chart === 'undefined') return;
|
| 24 |
if (dashCharts[canvasId]) {
|
| 25 |
dashCharts[canvasId].destroy();
|
| 26 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
dashCharts[canvasId] = new Chart(canvas, config);
|
| 28 |
}
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
async function loadHomeGreeting() {
|
| 31 |
const el = document.getElementById('dda-home-greeting');
|
| 32 |
if (!el) return;
|
| 33 |
try {
|
| 34 |
-
const me = await ddaApi('GET', '/api/me');
|
| 35 |
el.textContent = me.full_name ? `Welcome, ${me.full_name}` : 'Welcome';
|
| 36 |
} catch (_) {
|
| 37 |
// Not authenticated — the page-load redirect to /login should already
|
|
@@ -45,12 +225,14 @@ function renderDashKpis(summary) {
|
|
| 45 |
const el = document.getElementById('dda-dash-kpis');
|
| 46 |
if (!el) return;
|
| 47 |
const tiles = [
|
| 48 |
-
['
|
| 49 |
-
['
|
| 50 |
-
['
|
|
|
|
|
|
|
| 51 |
['Avg Change %', dashPct(summary.avgChangePercentage)],
|
| 52 |
-
['
|
| 53 |
-
['Villages Covered', summary.totalVillages],
|
| 54 |
];
|
| 55 |
el.innerHTML = tiles.map(([label, value]) => `
|
| 56 |
<a class="stat-box dda-dash-kpi" href="/reports" title="Open Reports">
|
|
@@ -59,6 +241,26 @@ function renderDashKpis(summary) {
|
|
| 59 |
</a>`).join('');
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
function renderDashLatest(run) {
|
| 63 |
const el = document.getElementById('dda-dash-latest');
|
| 64 |
if (!el) return;
|
|
@@ -78,6 +280,7 @@ function renderDashLatest(run) {
|
|
| 78 |
<span><strong>${run.regionsCount ?? '—'}</strong> regions</span>
|
| 79 |
<span><strong>${run.unclassified ?? '—'}</strong> unclassified</span>
|
| 80 |
</div>
|
|
|
|
| 81 |
<a class="btn btn-secondary btn-sm" href="/dda/reports/${run.id}" target="_blank" rel="noopener">View Report</a>
|
| 82 |
</div>
|
| 83 |
</div>`;
|
|
@@ -85,37 +288,69 @@ function renderDashLatest(run) {
|
|
| 85 |
|
| 86 |
async function loadDashboardSummary() {
|
| 87 |
try {
|
| 88 |
-
const summary = await ddaApi('GET', '/api/dda/dashboard/summary');
|
| 89 |
-
|
| 90 |
-
const empty = document.getElementById('dda-dash-empty');
|
| 91 |
-
const content = document.getElementById('dda-dash-content');
|
| 92 |
-
if (!summary.totalRuns) {
|
| 93 |
-
empty?.classList.remove('hidden');
|
| 94 |
-
content?.classList.add('hidden');
|
| 95 |
-
return summary;
|
| 96 |
-
}
|
| 97 |
-
empty?.classList.add('hidden');
|
| 98 |
-
content?.classList.remove('hidden');
|
| 99 |
-
renderDashKpis(summary);
|
| 100 |
-
renderDashLatest(summary.latestRun);
|
| 101 |
-
renderDashChart('dda-chart-status', {
|
| 102 |
-
type: 'doughnut',
|
| 103 |
-
data: {
|
| 104 |
-
labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
|
| 105 |
-
datasets: [{
|
| 106 |
-
data: [summary.totalConfirmed, summary.totalFalsePositive, summary.totalSubmitted, summary.totalUnclassified],
|
| 107 |
-
backgroundColor: ['#16a34a', '#dc2626', '#6358d4', '#ca8a04'],
|
| 108 |
-
}],
|
| 109 |
-
},
|
| 110 |
-
options: { responsive: true, plugins: { legend: { position: 'bottom' } } },
|
| 111 |
-
});
|
| 112 |
-
return summary;
|
| 113 |
} catch (err) {
|
| 114 |
document.getElementById('dda-dash-kpis').innerHTML = `<p class="dim">Could not load overview: ${escapeHtml(err.message || 'error')}</p>`;
|
| 115 |
return null;
|
| 116 |
}
|
| 117 |
}
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
function roleLabel(role) {
|
| 120 |
const r = String(role || '').trim();
|
| 121 |
return r ? r.charAt(0).toUpperCase() + r.slice(1) : 'Unknown';
|
|
@@ -159,25 +394,56 @@ function renderDashRolesTable(roles) {
|
|
| 159 |
async function loadDashboardRoles() {
|
| 160 |
const el = document.getElementById('dda-dash-roles');
|
| 161 |
try {
|
| 162 |
-
const data = await ddaApi('GET', '/api/dda/dashboard/roles');
|
| 163 |
-
|
| 164 |
-
renderDashRolesTable(roles);
|
| 165 |
-
renderDashChart('dda-chart-roles', {
|
| 166 |
-
type: 'bar',
|
| 167 |
-
data: {
|
| 168 |
-
labels: roles.map((r) => roleLabel(r.role)),
|
| 169 |
-
datasets: [{ label: 'Detections', data: roles.map((r) => r.runsCount), backgroundColor: '#16a34a' }],
|
| 170 |
-
},
|
| 171 |
-
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
|
| 172 |
-
});
|
| 173 |
} catch (err) {
|
| 174 |
if (el) el.innerHTML = `<p class="dim">Could not load role breakdown: ${escapeHtml(err.message || 'error')}</p>`;
|
| 175 |
}
|
| 176 |
}
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
let dashRegionsCache = [];
|
| 179 |
let dashSelectedZone = '';
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
function renderDashRegionsTable(regions) {
|
| 182 |
const el = document.getElementById('dda-dash-regions');
|
| 183 |
if (!el) return;
|
|
@@ -189,10 +455,13 @@ function renderDashRegionsTable(regions) {
|
|
| 189 |
<table class="dda-reports-table">
|
| 190 |
<thead>
|
| 191 |
<tr>
|
| 192 |
-
<th>
|
| 193 |
<th>Runs</th>
|
|
|
|
| 194 |
<th>Detected Changes</th>
|
| 195 |
-
<th>
|
|
|
|
|
|
|
| 196 |
<th>Villages</th>
|
| 197 |
<th>Avg Change %</th>
|
| 198 |
<th>Last Detection</th>
|
|
@@ -203,8 +472,11 @@ function renderDashRegionsTable(regions) {
|
|
| 203 |
<tr class="dda-dash-row-clickable${r.zone === dashSelectedZone ? ' dda-dash-row-active' : ''}" data-zone="${escapeHtml(r.zone)}">
|
| 204 |
<td>${escapeHtml(r.zone)}</td>
|
| 205 |
<td>${r.runsCount}</td>
|
|
|
|
| 206 |
<td>${r.detectedChanges}</td>
|
| 207 |
-
<td>${r.
|
|
|
|
|
|
|
| 208 |
<td>${r.villagesCount}</td>
|
| 209 |
<td>${dashPct(r.avgChangePercentage)}</td>
|
| 210 |
<td><time datetime="${escapeHtml(r.lastDetectionAt || '')}">${formatHomeDate(r.lastDetectionAt)}</time></td>
|
|
@@ -218,12 +490,13 @@ function renderDashRegionsTable(regions) {
|
|
| 218 |
|
| 219 |
function selectDashRegion(zone) {
|
| 220 |
dashSelectedZone = dashSelectedZone === zone ? '' : zone;
|
|
|
|
| 221 |
renderDashRegionsTable(dashRegionsCache);
|
| 222 |
loadDashboardVillages(dashSelectedZone);
|
| 223 |
const label = document.getElementById('dda-dash-village-filter-label');
|
| 224 |
const clearBtn = document.getElementById('btn-dash-village-clear');
|
| 225 |
if (dashSelectedZone) {
|
| 226 |
-
label.textContent = `Filtered to
|
| 227 |
label.classList.remove('hidden');
|
| 228 |
clearBtn.classList.remove('hidden');
|
| 229 |
} else {
|
|
@@ -234,22 +507,30 @@ function selectDashRegion(zone) {
|
|
| 234 |
|
| 235 |
async function loadDashboardRegions() {
|
| 236 |
try {
|
| 237 |
-
const data = await ddaApi('GET', '/api/dda/dashboard/regions');
|
| 238 |
-
|
| 239 |
-
renderDashRegionsTable(dashRegionsCache);
|
| 240 |
-
renderDashChart('dda-chart-regions', {
|
| 241 |
-
type: 'bar',
|
| 242 |
-
data: {
|
| 243 |
-
labels: dashRegionsCache.map((r) => r.zone),
|
| 244 |
-
datasets: [{ label: 'Detections', data: dashRegionsCache.map((r) => r.runsCount), backgroundColor: '#7a6aed' }],
|
| 245 |
-
},
|
| 246 |
-
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
|
| 247 |
-
});
|
| 248 |
} catch (err) {
|
| 249 |
document.getElementById('dda-dash-regions').innerHTML = `<p class="dim">Could not load regions: ${escapeHtml(err.message || 'error')}</p>`;
|
| 250 |
}
|
| 251 |
}
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
function renderDashVillagesTable(villages) {
|
| 254 |
const el = document.getElementById('dda-dash-villages');
|
| 255 |
if (!el) return;
|
|
@@ -261,30 +542,42 @@ function renderDashVillagesTable(villages) {
|
|
| 261 |
<table class="dda-reports-table">
|
| 262 |
<thead>
|
| 263 |
<tr>
|
| 264 |
-
<th>
|
| 265 |
<th>Village</th>
|
| 266 |
<th>Runs</th>
|
|
|
|
| 267 |
<th>Detected Changes</th>
|
| 268 |
-
<th>
|
|
|
|
|
|
|
| 269 |
<th>Avg Change %</th>
|
| 270 |
<th>Last Detection</th>
|
| 271 |
</tr>
|
| 272 |
</thead>
|
| 273 |
<tbody>
|
| 274 |
-
${villages.map((v) =>
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
| 276 |
<td>${escapeHtml(v.zone)}</td>
|
| 277 |
<td>${escapeHtml(v.village)}</td>
|
| 278 |
<td>${v.runsCount}</td>
|
|
|
|
| 279 |
<td>${v.detectedChanges}</td>
|
| 280 |
-
<td>${v.
|
|
|
|
|
|
|
| 281 |
<td>${dashPct(v.avgChangePercentage)}</td>
|
| 282 |
<td><time datetime="${escapeHtml(v.lastDetectionAt || '')}">${formatHomeDate(v.lastDetectionAt)}</time></td>
|
| 283 |
-
</tr>`
|
|
|
|
| 284 |
</tbody>
|
| 285 |
</table>`;
|
| 286 |
el.querySelectorAll('[data-village]').forEach((row) => {
|
| 287 |
row.addEventListener('click', () => {
|
|
|
|
|
|
|
| 288 |
loadDashboardLocation(row.dataset.zone, row.dataset.village);
|
| 289 |
document.getElementById('dda-dash-location-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 290 |
});
|
|
@@ -295,29 +588,51 @@ async function loadDashboardVillages(zone) {
|
|
| 295 |
const el = document.getElementById('dda-dash-villages');
|
| 296 |
if (el) el.innerHTML = '<p class="dim">Loading villages…</p>';
|
| 297 |
try {
|
| 298 |
-
const
|
| 299 |
-
|
| 300 |
-
const
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
renderDashChart('dda-chart-villages', {
|
| 304 |
-
type: 'bar',
|
| 305 |
-
data: {
|
| 306 |
-
labels: top.map((v) => v.village),
|
| 307 |
-
datasets: [{ label: 'Detections', data: top.map((v) => v.runsCount), backgroundColor: '#6358d4' }],
|
| 308 |
-
},
|
| 309 |
-
options: {
|
| 310 |
-
indexAxis: 'y',
|
| 311 |
-
responsive: true,
|
| 312 |
-
plugins: { legend: { display: false } },
|
| 313 |
-
scales: { x: { beginAtZero: true, ticks: { precision: 0 } } },
|
| 314 |
-
},
|
| 315 |
-
});
|
| 316 |
} catch (err) {
|
| 317 |
if (el) el.innerHTML = `<p class="dim">Could not load villages: ${escapeHtml(err.message || 'error')}</p>`;
|
| 318 |
}
|
| 319 |
}
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
function renderDashLocation(data) {
|
| 322 |
const el = document.getElementById('dda-dash-location');
|
| 323 |
if (!el) return;
|
|
@@ -331,32 +646,38 @@ function renderDashLocation(data) {
|
|
| 331 |
<p class="sub dim">${data.runsCount} detection run${data.runsCount === 1 ? '' : 's'} recorded for this location.</p>
|
| 332 |
<div class="dda-dash-compare-grid">
|
| 333 |
<div class="dda-dash-compare-col">
|
| 334 |
-
<span class="dim">
|
| 335 |
<h4>${escapeHtml(data.latest.title)}</h4>
|
| 336 |
-
<p>${dashPct(data.latest.changePercentage)} change
|
|
|
|
| 337 |
<time class="dim" datetime="${escapeHtml(data.latest.createdAt || '')}">${formatHomeDate(data.latest.createdAt)}</time><br/>
|
| 338 |
<a class="btn btn-secondary btn-sm" href="/dda/reports/${data.latest.id}" target="_blank" rel="noopener">View Report</a>
|
| 339 |
</div>
|
| 340 |
<div class="dda-dash-compare-col">
|
| 341 |
-
<span class="dim">Previous</span>
|
| 342 |
${data.previous ? `
|
| 343 |
<h4>${escapeHtml(data.previous.title)}</h4>
|
| 344 |
-
<p>${dashPct(data.previous.changePercentage)} change
|
|
|
|
| 345 |
<time class="dim" datetime="${escapeHtml(data.previous.createdAt || '')}">${formatHomeDate(data.previous.createdAt)}</time><br/>
|
| 346 |
<a class="btn btn-secondary btn-sm" href="/dda/reports/${data.previous.id}" target="_blank" rel="noopener">View Report</a>
|
| 347 |
` : '<p class="dim">No earlier run to compare against.</p>'}
|
| 348 |
</div>
|
| 349 |
<div class="dda-dash-compare-col">
|
| 350 |
-
<span class="dim">
|
| 351 |
${cmp ? `
|
| 352 |
<p class="dda-dash-delta">${dashDelta(cmp.changePercentageDelta, '%')} change %</p>
|
| 353 |
<p class="dda-dash-delta">${dashDelta(cmp.regionsCountDelta, '')} regions</p>
|
| 354 |
-
|
|
|
|
|
|
|
| 355 |
</div>
|
| 356 |
</div>
|
|
|
|
|
|
|
| 357 |
<h4 class="dda-dash-history-title">Detection History</h4>
|
| 358 |
<table class="dda-reports-table">
|
| 359 |
-
<thead><tr><th>Date</th><th>Title</th><th>Change %</th><th>Regions</th><th>
|
| 360 |
<tbody>
|
| 361 |
${data.history.map((r) => `
|
| 362 |
<tr>
|
|
@@ -364,11 +685,40 @@ function renderDashLocation(data) {
|
|
| 364 |
<td>${escapeHtml(r.title)}</td>
|
| 365 |
<td>${dashPct(r.changePercentage)}</td>
|
| 366 |
<td>${r.regionsCount}</td>
|
| 367 |
-
<td>${r.
|
|
|
|
|
|
|
| 368 |
<td><a class="btn btn-secondary btn-sm" href="/dda/reports/${r.id}" target="_blank" rel="noopener">Report</a></td>
|
| 369 |
</tr>`).join('')}
|
| 370 |
</tbody>
|
| 371 |
</table>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
}
|
| 373 |
|
| 374 |
async function loadDashboardLocation(zone, village) {
|
|
@@ -385,67 +735,99 @@ async function loadDashboardLocation(zone, village) {
|
|
| 385 |
async function loadDashboardTrend() {
|
| 386 |
try {
|
| 387 |
const data = await ddaApi('GET', '/api/dda/dashboard/trend?months=6');
|
| 388 |
-
|
| 389 |
-
renderDashChart('dda-chart-trend', {
|
| 390 |
-
type: 'line',
|
| 391 |
-
data: {
|
| 392 |
-
labels: buckets.map((b) => b.label),
|
| 393 |
-
datasets: [{
|
| 394 |
-
label: 'Detection runs',
|
| 395 |
-
data: buckets.map((b) => b.runsCount),
|
| 396 |
-
borderColor: '#7a6aed',
|
| 397 |
-
backgroundColor: 'rgba(122,106,237,0.15)',
|
| 398 |
-
tension: 0.3,
|
| 399 |
-
fill: true,
|
| 400 |
-
}],
|
| 401 |
-
},
|
| 402 |
-
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
|
| 403 |
-
});
|
| 404 |
} catch (_) {
|
| 405 |
// Chart stays absent — non-critical widget, other sections still work.
|
| 406 |
}
|
| 407 |
}
|
| 408 |
|
| 409 |
-
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
if (!el) return;
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
|
|
|
|
|
|
| 418 |
}
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
} catch (
|
| 448 |
-
|
| 449 |
}
|
| 450 |
}
|
| 451 |
|
|
@@ -455,31 +837,118 @@ function markDashUpdated() {
|
|
| 455 |
el.textContent = `Updated ${formatHomeDate(new Date().toISOString())}`;
|
| 456 |
}
|
| 457 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
async function loadDashboard() {
|
| 459 |
dashSelectedZone = '';
|
|
|
|
| 460 |
document.getElementById('dda-dash-village-filter-label')?.classList.add('hidden');
|
| 461 |
document.getElementById('btn-dash-village-clear')?.classList.add('hidden');
|
| 462 |
document.getElementById('dda-dash-location').innerHTML =
|
| 463 |
'<p class="dim">Select a village above to compare its latest and previous detection results.</p>';
|
| 464 |
|
| 465 |
-
|
| 466 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
markDashUpdated();
|
| 468 |
-
|
|
|
|
| 469 |
}
|
| 470 |
-
await Promise.all([
|
| 471 |
-
loadDashboardRoles(),
|
| 472 |
-
loadDashboardRegions(),
|
| 473 |
-
loadDashboardVillages(''),
|
| 474 |
-
loadDashboardTrend(),
|
| 475 |
-
loadDashboardReports(),
|
| 476 |
-
]);
|
| 477 |
-
markDashUpdated();
|
| 478 |
}
|
| 479 |
|
| 480 |
document.getElementById('btn-dash-refresh')?.addEventListener('click', loadDashboard);
|
| 481 |
document.getElementById('btn-dash-village-clear')?.addEventListener('click', () => selectDashRegion(''));
|
| 482 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
document.addEventListener('DOMContentLoaded', () => {
|
| 484 |
loadHomeGreeting();
|
| 485 |
loadDashboard();
|
|
|
|
| 2 |
* change comparison, reports feed, and charts. Replaces the old
|
| 3 |
* greeting+3-cards Home page in place. */
|
| 4 |
|
| 5 |
+
// ---------------------------------------------------------------- Tabs ----
|
| 6 |
+
// Overview / Charts & Graphs / Breakdown / Image Library, one visible at a
|
| 7 |
+
// time via small card-style tabs, so the dashboard doesn't force one long
|
| 8 |
+
// scroll through every section and sub-section.
|
| 9 |
+
const DASH_TABS = ['overview', 'charts', 'breakdown', 'library'];
|
| 10 |
+
function activateDashTab(tab) {
|
| 11 |
+
DASH_TABS.forEach((t) => {
|
| 12 |
+
const panel = document.getElementById(`dash-tab-${t}`);
|
| 13 |
+
if (panel) panel.hidden = t !== tab;
|
| 14 |
+
});
|
| 15 |
+
document.querySelectorAll('#dda-dash-tab-switch .dda-dash-tab-card').forEach((card) => {
|
| 16 |
+
const active = card.dataset.tab === tab;
|
| 17 |
+
card.classList.toggle('is-active', active);
|
| 18 |
+
card.setAttribute('aria-selected', String(active));
|
| 19 |
+
});
|
| 20 |
+
// Chart.js lays a canvas out at 0x0 if it was created while its container
|
| 21 |
+
// had `hidden` set; force every existing chart to recompute now that its
|
| 22 |
+
// panel is actually visible. Covers both the Charts & Graphs canvases and
|
| 23 |
+
// the Image Library's format doughnut.
|
| 24 |
+
if (tab === 'charts' || tab === 'library') {
|
| 25 |
+
Object.values(dashCharts).forEach((chart) => {
|
| 26 |
+
try { chart.resize(); } catch (_) { /* chart may have been destroyed/replaced */ }
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
document.querySelectorAll('#dda-dash-tab-switch .dda-dash-tab-card').forEach((card) => {
|
| 31 |
+
card.addEventListener('click', () => activateDashTab(card.dataset.tab));
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
function formatHomeDate(iso) {
|
| 35 |
return typeof formatDateIst === 'function' ? formatDateIst(iso) : (iso || '—');
|
| 36 |
}
|
|
|
|
| 45 |
return `${sign}${typeof v === 'number' && suffix === '%' ? v.toFixed(2) : v}${suffix}`;
|
| 46 |
}
|
| 47 |
|
| 48 |
+
// Fixed taxonomy every region is already classified into (see
|
| 49 |
+
// app/dda/change_type_map.py) — same colors/order used everywhere a change
|
| 50 |
+
// type shows up, so a color always means the same thing across the page.
|
| 51 |
+
const CHANGE_TYPE_ORDER = ['New Construction', 'Demolition', 'Extension', 'Vegetation Change', 'Other'];
|
| 52 |
+
const CHANGE_TYPE_COLORS = {
|
| 53 |
+
'New Construction': '#f97316',
|
| 54 |
+
'Demolition': '#dc2626',
|
| 55 |
+
'Extension': '#2563eb',
|
| 56 |
+
'Vegetation Change': '#16a34a',
|
| 57 |
+
'Other': '#64748b',
|
| 58 |
+
};
|
| 59 |
+
function changeTypeSlug(t) {
|
| 60 |
+
return String(t || 'Other').replace(/\s+/g, '-');
|
| 61 |
+
}
|
| 62 |
+
/** Small colored pill for the single most common change type in a
|
| 63 |
+
* confirmed set of counts — a lightweight way to surface "what kind of
|
| 64 |
+
* change" without adding a full breakdown column to already-dense tables. */
|
| 65 |
+
function topChangeTypePill(changeTypes) {
|
| 66 |
+
const entries = Object.entries(changeTypes || {});
|
| 67 |
+
if (!entries.length) return '<span class="dim">—</span>';
|
| 68 |
+
entries.sort((a, b) => b[1] - a[1]);
|
| 69 |
+
const [type, count] = entries[0];
|
| 70 |
+
const total = entries.reduce((sum, [, c]) => sum + c, 0);
|
| 71 |
+
return `<span class="dda-changetype-pill dda-changetype-${changeTypeSlug(type)}" title="${escapeHtml(type)}: ${count} of ${total} region(s)">${escapeHtml(type)}${entries.length > 1 ? ` +${entries.length - 1}` : ''}</span>`;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/** Every change type present, each its own pill with a count — used for a
|
| 75 |
+
* single report (Detection History), where "per report" detail matters more
|
| 76 |
+
* than the compact single-pill summary that's appropriate when a row is
|
| 77 |
+
* already an aggregate across several reports (Region-wise/Village-wise). */
|
| 78 |
+
function allChangeTypePills(changeTypes) {
|
| 79 |
+
const entries = Object.entries(changeTypes || {});
|
| 80 |
+
if (!entries.length) return '<span class="dim">No regions classified</span>';
|
| 81 |
+
entries.sort((a, b) => b[1] - a[1]);
|
| 82 |
+
return `<div class="dda-changetype-pill-group">${entries.map(([type, count]) =>
|
| 83 |
+
`<span class="dda-changetype-pill dda-changetype-${changeTypeSlug(type)}">${escapeHtml(type)}: ${count}</span>`
|
| 84 |
+
).join('')}</div>`;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/** Stacked bar — one bar per region, one colored segment per change type —
|
| 88 |
+
* the only change-type chart on the dashboard: a separate system-wide
|
| 89 |
+
* doughnut was dropped since its totals are just these bars summed, and
|
| 90 |
+
* showing both read as the same information twice. */
|
| 91 |
+
function renderChangeTypesByRegionChart(regions) {
|
| 92 |
+
const top = regions.slice(0, 10);
|
| 93 |
+
renderDashChart('dda-chart-change-types-region', {
|
| 94 |
+
type: 'bar',
|
| 95 |
+
data: {
|
| 96 |
+
labels: top.map((r) => r.zone),
|
| 97 |
+
datasets: CHANGE_TYPE_ORDER
|
| 98 |
+
.filter((t) => top.some((r) => (r.changeTypes || {})[t]))
|
| 99 |
+
.map((t) => ({
|
| 100 |
+
label: t,
|
| 101 |
+
data: top.map((r) => (r.changeTypes || {})[t] || 0),
|
| 102 |
+
backgroundColor: CHANGE_TYPE_COLORS[t],
|
| 103 |
+
})),
|
| 104 |
+
},
|
| 105 |
+
options: {
|
| 106 |
+
responsive: true,
|
| 107 |
+
plugins: { legend: { position: 'bottom' } },
|
| 108 |
+
scales: {
|
| 109 |
+
x: { stacked: true },
|
| 110 |
+
y: { stacked: true, beginAtZero: true, ticks: { precision: 0 } },
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
});
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
const dashCharts = {};
|
| 117 |
|
| 118 |
+
/** Current From/To date filter, as a URLSearchParams-ready query string
|
| 119 |
+
* fragment (without leading '?') — shared by every dashboard fetch except
|
| 120 |
+
* the trend chart, which has its own fixed month window. */
|
| 121 |
+
function dashDateParams() {
|
| 122 |
+
const from = document.getElementById('dash-filter-from')?.value || '';
|
| 123 |
+
const to = document.getElementById('dash-filter-to')?.value || '';
|
| 124 |
+
const params = new URLSearchParams();
|
| 125 |
+
if (from) params.set('date_from', from);
|
| 126 |
+
if (to) params.set('date_to', to);
|
| 127 |
+
return params.toString();
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function dashUrl(path) {
|
| 131 |
+
const qs = dashDateParams();
|
| 132 |
+
if (!qs) return path;
|
| 133 |
+
return path + (path.includes('?') ? '&' : '?') + qs;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/** Every dashboard chart goes through here so animation and hover behavior
|
| 137 |
+
* stay consistent without repeating options at each call site. Pass
|
| 138 |
+
* `onClick(index)` in config to make chart segments clickable — the cursor
|
| 139 |
+
* switches to a pointer only where a click actually does something. */
|
| 140 |
function renderDashChart(canvasId, config) {
|
| 141 |
const canvas = document.getElementById(canvasId);
|
| 142 |
if (!canvas || typeof Chart === 'undefined') return;
|
| 143 |
if (dashCharts[canvasId]) {
|
| 144 |
dashCharts[canvasId].destroy();
|
| 145 |
}
|
| 146 |
+
const onClick = config.onClick;
|
| 147 |
+
delete config.onClick;
|
| 148 |
+
|
| 149 |
+
// Rounded, hover-highlighted bars and a pop-out hover on doughnut slices —
|
| 150 |
+
// applied to every dataset that doesn't already set these, so each chart
|
| 151 |
+
// call site doesn't have to repeat the same styling.
|
| 152 |
+
(config.data?.datasets || []).forEach((ds) => {
|
| 153 |
+
if (config.type === 'bar' && ds.borderRadius === undefined) {
|
| 154 |
+
ds.borderRadius = 6;
|
| 155 |
+
ds.borderSkipped = false;
|
| 156 |
+
ds.hoverBackgroundColor = ds.hoverBackgroundColor || _dashHoverShade(ds.backgroundColor);
|
| 157 |
+
}
|
| 158 |
+
if (config.type === 'doughnut' && ds.hoverOffset === undefined) {
|
| 159 |
+
ds.hoverOffset = 14;
|
| 160 |
+
}
|
| 161 |
+
if (config.type === 'line' && ds.pointHoverRadius === undefined) {
|
| 162 |
+
ds.pointRadius = ds.pointRadius ?? 3;
|
| 163 |
+
ds.pointHoverRadius = 7;
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
const multiDataset = (config.data?.datasets || []).length > 1;
|
| 168 |
+
const incomingOptions = config.options || {};
|
| 169 |
+
const incomingPlugins = incomingOptions.plugins || {};
|
| 170 |
+
config.options = {
|
| 171 |
+
animation: { duration: 700, easing: 'easeOutQuart' },
|
| 172 |
+
interaction: { intersect: true, mode: 'nearest' },
|
| 173 |
+
...incomingOptions,
|
| 174 |
+
plugins: {
|
| 175 |
+
...incomingPlugins,
|
| 176 |
+
tooltip: {
|
| 177 |
+
padding: 10,
|
| 178 |
+
cornerRadius: 8,
|
| 179 |
+
titleFont: { weight: '600' },
|
| 180 |
+
displayColors: config.type === 'doughnut' || multiDataset,
|
| 181 |
+
...incomingPlugins.tooltip,
|
| 182 |
+
},
|
| 183 |
+
},
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
if (onClick) {
|
| 187 |
+
config.options.onClick = (evt, elements) => {
|
| 188 |
+
if (elements.length) onClick(elements[0].index);
|
| 189 |
+
};
|
| 190 |
+
config.options.onHover = (evt, elements) => {
|
| 191 |
+
evt.native.target.style.cursor = elements.length ? 'pointer' : 'default';
|
| 192 |
+
};
|
| 193 |
+
}
|
| 194 |
dashCharts[canvasId] = new Chart(canvas, config);
|
| 195 |
}
|
| 196 |
|
| 197 |
+
/** Darken/lighten a hex color a touch for a bar's hover state — cheap
|
| 198 |
+
* "interactive" affordance without needing a second color per dataset. */
|
| 199 |
+
function _dashHoverShade(hex) {
|
| 200 |
+
if (typeof hex !== 'string' || !hex.startsWith('#') || hex.length !== 7) return hex;
|
| 201 |
+
const amt = -24;
|
| 202 |
+
const num = parseInt(hex.slice(1), 16);
|
| 203 |
+
const clamp = (v) => Math.max(0, Math.min(255, v));
|
| 204 |
+
const r = clamp(((num >> 16) & 0xff) + amt);
|
| 205 |
+
const g = clamp(((num >> 8) & 0xff) + amt);
|
| 206 |
+
const b = clamp((num & 0xff) + amt);
|
| 207 |
+
return `rgb(${r}, ${g}, ${b})`;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
async function loadHomeGreeting() {
|
| 211 |
const el = document.getElementById('dda-home-greeting');
|
| 212 |
if (!el) return;
|
| 213 |
try {
|
| 214 |
+
const me = typeof ensureDdaMe === 'function' ? await ensureDdaMe() : await ddaApi('GET', '/api/me');
|
| 215 |
el.textContent = me.full_name ? `Welcome, ${me.full_name}` : 'Welcome';
|
| 216 |
} catch (_) {
|
| 217 |
// Not authenticated — the page-load redirect to /login should already
|
|
|
|
| 225 |
const el = document.getElementById('dda-dash-kpis');
|
| 226 |
if (!el) return;
|
| 227 |
const tiles = [
|
| 228 |
+
['Detection Runs', summary.totalRuns],
|
| 229 |
+
['Regions Detected', summary.totalDetectedChanges],
|
| 230 |
+
['Confirmed', summary.totalConfirmed],
|
| 231 |
+
['False Positive', summary.totalFalsePositive],
|
| 232 |
+
['Submitted', summary.totalSubmitted],
|
| 233 |
['Avg Change %', dashPct(summary.avgChangePercentage)],
|
| 234 |
+
['Zones Covered', `${summary.totalRegions}${summary.zonesTotal ? `/${summary.zonesTotal}` : ''}`],
|
| 235 |
+
['Villages Covered', `${summary.totalVillages}${summary.villagesTotal ? `/${summary.villagesTotal}` : ''}`],
|
| 236 |
];
|
| 237 |
el.innerHTML = tiles.map(([label, value]) => `
|
| 238 |
<a class="stat-box dda-dash-kpi" href="/reports" title="Open Reports">
|
|
|
|
| 241 |
</a>`).join('');
|
| 242 |
}
|
| 243 |
|
| 244 |
+
/** Ranked list of system-wide change-type totals — same numbers as the
|
| 245 |
+
* "Change Types by Zone" stacked chart, just ranked instead of charted a
|
| 246 |
+
* second time (a duplicate doughnut used to sit here and was dropped). */
|
| 247 |
+
function renderRankedChangeTypes(changeTypes) {
|
| 248 |
+
const el = document.getElementById('dda-dash-ranked-changetypes');
|
| 249 |
+
if (!el) return;
|
| 250 |
+
const entries = Object.entries(changeTypes || {});
|
| 251 |
+
if (!entries.length) {
|
| 252 |
+
el.innerHTML = '<p class="dim">No classified changes yet.</p>';
|
| 253 |
+
return;
|
| 254 |
+
}
|
| 255 |
+
entries.sort((a, b) => b[1] - a[1]);
|
| 256 |
+
el.innerHTML = `<div class="dda-dash-ranked-list">${entries.map(([type, count]) => `
|
| 257 |
+
<div class="dda-dash-ranked-row">
|
| 258 |
+
<span class="dda-dash-ranked-swatch" style="background:${CHANGE_TYPE_COLORS[type] || '#64748b'}"></span>
|
| 259 |
+
<span class="dda-dash-ranked-label">${escapeHtml(type)}</span>
|
| 260 |
+
<span class="dda-dash-ranked-count">${count}</span>
|
| 261 |
+
</div>`).join('')}</div>`;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
function renderDashLatest(run) {
|
| 265 |
const el = document.getElementById('dda-dash-latest');
|
| 266 |
if (!el) return;
|
|
|
|
| 280 |
<span><strong>${run.regionsCount ?? '—'}</strong> regions</span>
|
| 281 |
<span><strong>${run.unclassified ?? '—'}</strong> unclassified</span>
|
| 282 |
</div>
|
| 283 |
+
<div class="dda-dash-latest-changetypes">${allChangeTypePills(run.changeTypes)}</div>
|
| 284 |
<a class="btn btn-secondary btn-sm" href="/dda/reports/${run.id}" target="_blank" rel="noopener">View Report</a>
|
| 285 |
</div>
|
| 286 |
</div>`;
|
|
|
|
| 288 |
|
| 289 |
async function loadDashboardSummary() {
|
| 290 |
try {
|
| 291 |
+
const summary = await ddaApi('GET', dashUrl('/api/dda/dashboard/summary'));
|
| 292 |
+
return applyDashboardSummary(summary);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
} catch (err) {
|
| 294 |
document.getElementById('dda-dash-kpis').innerHTML = `<p class="dim">Could not load overview: ${escapeHtml(err.message || 'error')}</p>`;
|
| 295 |
return null;
|
| 296 |
}
|
| 297 |
}
|
| 298 |
|
| 299 |
+
function applyDashboardSummary(summary) {
|
| 300 |
+
dashSummaryCache = summary;
|
| 301 |
+
const empty = document.getElementById('dda-dash-empty');
|
| 302 |
+
// Only the Overview tab's KPI/Latest-result cards hide behind the empty
|
| 303 |
+
// state — Charts & Graphs, Breakdown, and Image Library stay reachable.
|
| 304 |
+
const overviewGrid = document.getElementById('dda-dash-overview-grid');
|
| 305 |
+
if (!summary.totalRuns) {
|
| 306 |
+
const filtered = !!dashDateParams();
|
| 307 |
+
const heading = empty?.querySelector('h3');
|
| 308 |
+
const body = empty?.querySelector('p');
|
| 309 |
+
const cta = empty?.querySelector('a');
|
| 310 |
+
if (heading) heading.textContent = filtered ? 'No runs in this date range' : 'No detections yet';
|
| 311 |
+
if (body) {
|
| 312 |
+
body.textContent = filtered
|
| 313 |
+
? 'Try clearing the From/To filter or picking a wider range.'
|
| 314 |
+
: 'Run your first comparison to start populating the dashboard with detection statistics, region/village breakdowns, and reports.';
|
| 315 |
+
}
|
| 316 |
+
if (cta) cta.classList.toggle('hidden', filtered);
|
| 317 |
+
empty?.classList.remove('hidden');
|
| 318 |
+
overviewGrid?.classList.add('hidden');
|
| 319 |
+
return summary;
|
| 320 |
+
}
|
| 321 |
+
empty?.classList.add('hidden');
|
| 322 |
+
overviewGrid?.classList.remove('hidden');
|
| 323 |
+
renderDashKpis(summary);
|
| 324 |
+
renderDashLatest(summary.latestRun);
|
| 325 |
+
renderRankedChangeTypes(summary.changeTypes);
|
| 326 |
+
renderDashChart('dda-chart-status', {
|
| 327 |
+
type: 'doughnut',
|
| 328 |
+
data: {
|
| 329 |
+
labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
|
| 330 |
+
datasets: [{
|
| 331 |
+
data: [summary.totalConfirmed, summary.totalFalsePositive, summary.totalSubmitted, summary.totalUnclassified],
|
| 332 |
+
backgroundColor: ['#16a34a', '#dc2626', '#2563eb', '#ca8a04'],
|
| 333 |
+
}],
|
| 334 |
+
},
|
| 335 |
+
options: {
|
| 336 |
+
responsive: true,
|
| 337 |
+
plugins: {
|
| 338 |
+
legend: { position: 'bottom' },
|
| 339 |
+
tooltip: {
|
| 340 |
+
callbacks: {
|
| 341 |
+
label: (ctx) => {
|
| 342 |
+
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
| 343 |
+
const pct = total ? Math.round((ctx.parsed / total) * 100) : 0;
|
| 344 |
+
return ` ${ctx.label}: ${ctx.parsed} (${pct}%)`;
|
| 345 |
+
},
|
| 346 |
+
},
|
| 347 |
+
},
|
| 348 |
+
},
|
| 349 |
+
},
|
| 350 |
+
});
|
| 351 |
+
return summary;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
function roleLabel(role) {
|
| 355 |
const r = String(role || '').trim();
|
| 356 |
return r ? r.charAt(0).toUpperCase() + r.slice(1) : 'Unknown';
|
|
|
|
| 394 |
async function loadDashboardRoles() {
|
| 395 |
const el = document.getElementById('dda-dash-roles');
|
| 396 |
try {
|
| 397 |
+
const data = await ddaApi('GET', dashUrl('/api/dda/dashboard/roles'));
|
| 398 |
+
applyDashboardRoles(data.roles || []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
} catch (err) {
|
| 400 |
if (el) el.innerHTML = `<p class="dim">Could not load role breakdown: ${escapeHtml(err.message || 'error')}</p>`;
|
| 401 |
}
|
| 402 |
}
|
| 403 |
|
| 404 |
+
function applyDashboardRoles(roles) {
|
| 405 |
+
const el = document.getElementById('dda-dash-roles');
|
| 406 |
+
renderDashRolesTable(roles);
|
| 407 |
+
renderDashChart('dda-chart-roles', {
|
| 408 |
+
type: 'bar',
|
| 409 |
+
data: {
|
| 410 |
+
labels: roles.map((r) => roleLabel(r.role)),
|
| 411 |
+
datasets: [{ label: 'Detections', data: roles.map((r) => r.runsCount), backgroundColor: '#2d2b2b' }],
|
| 412 |
+
},
|
| 413 |
+
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
|
| 414 |
+
});
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
let dashRegionsCache = [];
|
| 418 |
let dashSelectedZone = '';
|
| 419 |
|
| 420 |
+
/** Small horizontal stacked bar visualizing Confirmed/False Positive/
|
| 421 |
+
* Submitted/Unclassified as proportions of total regions — used wherever a
|
| 422 |
+
* classification breakdown would otherwise be four more table columns. */
|
| 423 |
+
function classificationBar(r) {
|
| 424 |
+
const confirmed = r.confirmed || 0;
|
| 425 |
+
const falsePositive = r.falsePositive || 0;
|
| 426 |
+
const submitted = r.submitted || 0;
|
| 427 |
+
const unclassified = r.unclassified || 0;
|
| 428 |
+
const total = confirmed + falsePositive + submitted + unclassified;
|
| 429 |
+
if (!total) return '<span class="dim">—</span>';
|
| 430 |
+
const seg = (val, cls) => (val > 0
|
| 431 |
+
? `<span class="dda-class-seg dda-class-${cls}" style="width:${(val / total * 100).toFixed(1)}%"></span>`
|
| 432 |
+
: '');
|
| 433 |
+
const title = `${confirmed} confirmed · ${falsePositive} false positive · ${submitted} submitted · ${unclassified} unclassified`;
|
| 434 |
+
return `
|
| 435 |
+
<div class="dda-class-bar" title="${escapeHtml(title)}">
|
| 436 |
+
${seg(confirmed, 'confirmed')}${seg(falsePositive, 'fp')}${seg(submitted, 'submitted')}${seg(unclassified, 'unclassified')}
|
| 437 |
+
</div>
|
| 438 |
+
<span class="dim dda-class-bar-caption">${confirmed}/${falsePositive}/${submitted}/${unclassified}</span>`;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
function accuracyCell(rate) {
|
| 442 |
+
if (rate == null) return '<span class="dim">Not yet reviewed</span>';
|
| 443 |
+
const cls = rate >= 66 ? 'dda-accuracy-good' : rate >= 33 ? 'dda-accuracy-mid' : 'dda-accuracy-low';
|
| 444 |
+
return `<span class="${cls}">${rate.toFixed(1)}%</span>`;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
function renderDashRegionsTable(regions) {
|
| 448 |
const el = document.getElementById('dda-dash-regions');
|
| 449 |
if (!el) return;
|
|
|
|
| 455 |
<table class="dda-reports-table">
|
| 456 |
<thead>
|
| 457 |
<tr>
|
| 458 |
+
<th>Zone</th>
|
| 459 |
<th>Runs</th>
|
| 460 |
+
<th>Images</th>
|
| 461 |
<th>Detected Changes</th>
|
| 462 |
+
<th>Top Change Type</th>
|
| 463 |
+
<th>Classification (confirmed/FP/submitted/unclassified)</th>
|
| 464 |
+
<th>Accuracy</th>
|
| 465 |
<th>Villages</th>
|
| 466 |
<th>Avg Change %</th>
|
| 467 |
<th>Last Detection</th>
|
|
|
|
| 472 |
<tr class="dda-dash-row-clickable${r.zone === dashSelectedZone ? ' dda-dash-row-active' : ''}" data-zone="${escapeHtml(r.zone)}">
|
| 473 |
<td>${escapeHtml(r.zone)}</td>
|
| 474 |
<td>${r.runsCount}</td>
|
| 475 |
+
<td>${r.imageCount ?? 0}</td>
|
| 476 |
<td>${r.detectedChanges}</td>
|
| 477 |
+
<td>${topChangeTypePill(r.changeTypes)}</td>
|
| 478 |
+
<td>${classificationBar(r)}</td>
|
| 479 |
+
<td>${accuracyCell(r.confirmedRate)}</td>
|
| 480 |
<td>${r.villagesCount}</td>
|
| 481 |
<td>${dashPct(r.avgChangePercentage)}</td>
|
| 482 |
<td><time datetime="${escapeHtml(r.lastDetectionAt || '')}">${formatHomeDate(r.lastDetectionAt)}</time></td>
|
|
|
|
| 490 |
|
| 491 |
function selectDashRegion(zone) {
|
| 492 |
dashSelectedZone = dashSelectedZone === zone ? '' : zone;
|
| 493 |
+
dashSelectedVillageKey = '';
|
| 494 |
renderDashRegionsTable(dashRegionsCache);
|
| 495 |
loadDashboardVillages(dashSelectedZone);
|
| 496 |
const label = document.getElementById('dda-dash-village-filter-label');
|
| 497 |
const clearBtn = document.getElementById('btn-dash-village-clear');
|
| 498 |
if (dashSelectedZone) {
|
| 499 |
+
label.textContent = `Filtered to zone: ${dashSelectedZone}`;
|
| 500 |
label.classList.remove('hidden');
|
| 501 |
clearBtn.classList.remove('hidden');
|
| 502 |
} else {
|
|
|
|
| 507 |
|
| 508 |
async function loadDashboardRegions() {
|
| 509 |
try {
|
| 510 |
+
const data = await ddaApi('GET', dashUrl('/api/dda/dashboard/regions'));
|
| 511 |
+
applyDashboardRegions(data.regions || []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
} catch (err) {
|
| 513 |
document.getElementById('dda-dash-regions').innerHTML = `<p class="dim">Could not load regions: ${escapeHtml(err.message || 'error')}</p>`;
|
| 514 |
}
|
| 515 |
}
|
| 516 |
|
| 517 |
+
function applyDashboardRegions(regions) {
|
| 518 |
+
dashRegionsCache = regions;
|
| 519 |
+
renderDashRegionsTable(dashRegionsCache);
|
| 520 |
+
renderDashChart('dda-chart-regions', {
|
| 521 |
+
type: 'bar',
|
| 522 |
+
data: {
|
| 523 |
+
labels: dashRegionsCache.map((r) => r.zone),
|
| 524 |
+
datasets: [{ label: 'Detections', data: dashRegionsCache.map((r) => r.runsCount), backgroundColor: '#ec3013' }],
|
| 525 |
+
},
|
| 526 |
+
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
|
| 527 |
+
onClick: (i) => selectDashRegion(dashRegionsCache[i]?.zone),
|
| 528 |
+
});
|
| 529 |
+
renderChangeTypesByRegionChart(dashRegionsCache);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
let dashSelectedVillageKey = '';
|
| 533 |
+
|
| 534 |
function renderDashVillagesTable(villages) {
|
| 535 |
const el = document.getElementById('dda-dash-villages');
|
| 536 |
if (!el) return;
|
|
|
|
| 542 |
<table class="dda-reports-table">
|
| 543 |
<thead>
|
| 544 |
<tr>
|
| 545 |
+
<th>Zone</th>
|
| 546 |
<th>Village</th>
|
| 547 |
<th>Runs</th>
|
| 548 |
+
<th>Images</th>
|
| 549 |
<th>Detected Changes</th>
|
| 550 |
+
<th>Top Change Type</th>
|
| 551 |
+
<th>Classification (confirmed/FP/submitted/unclassified)</th>
|
| 552 |
+
<th>Accuracy</th>
|
| 553 |
<th>Avg Change %</th>
|
| 554 |
<th>Last Detection</th>
|
| 555 |
</tr>
|
| 556 |
</thead>
|
| 557 |
<tbody>
|
| 558 |
+
${villages.map((v) => {
|
| 559 |
+
const key = `${v.zone} ${v.village}`;
|
| 560 |
+
return `
|
| 561 |
+
<tr class="dda-dash-row-clickable${key === dashSelectedVillageKey ? ' dda-dash-row-active' : ''}"
|
| 562 |
+
data-zone="${escapeHtml(v.zone)}" data-village="${escapeHtml(v.village)}">
|
| 563 |
<td>${escapeHtml(v.zone)}</td>
|
| 564 |
<td>${escapeHtml(v.village)}</td>
|
| 565 |
<td>${v.runsCount}</td>
|
| 566 |
+
<td>${v.imageCount ?? 0}</td>
|
| 567 |
<td>${v.detectedChanges}</td>
|
| 568 |
+
<td>${topChangeTypePill(v.changeTypes)}</td>
|
| 569 |
+
<td>${classificationBar(v)}</td>
|
| 570 |
+
<td>${accuracyCell(v.confirmedRate)}</td>
|
| 571 |
<td>${dashPct(v.avgChangePercentage)}</td>
|
| 572 |
<td><time datetime="${escapeHtml(v.lastDetectionAt || '')}">${formatHomeDate(v.lastDetectionAt)}</time></td>
|
| 573 |
+
</tr>`;
|
| 574 |
+
}).join('')}
|
| 575 |
</tbody>
|
| 576 |
</table>`;
|
| 577 |
el.querySelectorAll('[data-village]').forEach((row) => {
|
| 578 |
row.addEventListener('click', () => {
|
| 579 |
+
dashSelectedVillageKey = `${row.dataset.zone} ${row.dataset.village}`;
|
| 580 |
+
renderDashVillagesTable(villages);
|
| 581 |
loadDashboardLocation(row.dataset.zone, row.dataset.village);
|
| 582 |
document.getElementById('dda-dash-location-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 583 |
});
|
|
|
|
| 588 |
const el = document.getElementById('dda-dash-villages');
|
| 589 |
if (el) el.innerHTML = '<p class="dim">Loading villages…</p>';
|
| 590 |
try {
|
| 591 |
+
const params = new URLSearchParams(dashDateParams());
|
| 592 |
+
if (zone) params.set('zone', zone);
|
| 593 |
+
const qs = params.toString();
|
| 594 |
+
const data = await ddaApi('GET', `/api/dda/dashboard/villages${qs ? `?${qs}` : ''}`);
|
| 595 |
+
applyDashboardVillages(data.villages || []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
} catch (err) {
|
| 597 |
if (el) el.innerHTML = `<p class="dim">Could not load villages: ${escapeHtml(err.message || 'error')}</p>`;
|
| 598 |
}
|
| 599 |
}
|
| 600 |
|
| 601 |
+
function applyDashboardVillages(villages) {
|
| 602 |
+
renderDashVillagesTable(villages);
|
| 603 |
+
if (villages.length && !dashSelectedVillageKey) {
|
| 604 |
+
dashSelectedVillageKey = `${villages[0].zone} ${villages[0].village}`;
|
| 605 |
+
renderDashVillagesTable(villages);
|
| 606 |
+
loadDashboardLocation(villages[0].zone, villages[0].village);
|
| 607 |
+
}
|
| 608 |
+
const top = villages.slice(0, 15);
|
| 609 |
+
renderDashChart('dda-chart-villages', {
|
| 610 |
+
type: 'bar',
|
| 611 |
+
data: {
|
| 612 |
+
labels: top.map((v) => v.village),
|
| 613 |
+
datasets: [{ label: 'Detections', data: top.map((v) => v.runsCount), backgroundColor: '#e15b47' }],
|
| 614 |
+
},
|
| 615 |
+
options: {
|
| 616 |
+
indexAxis: 'y',
|
| 617 |
+
responsive: true,
|
| 618 |
+
plugins: { legend: { display: false } },
|
| 619 |
+
scales: { x: { beginAtZero: true, ticks: { precision: 0 } } },
|
| 620 |
+
},
|
| 621 |
+
onClick: (i) => {
|
| 622 |
+
const v = top[i];
|
| 623 |
+
if (!v) return;
|
| 624 |
+
dashSelectedVillageKey = `${v.zone} ${v.village}`;
|
| 625 |
+
renderDashVillagesTable(villages);
|
| 626 |
+
loadDashboardLocation(v.zone, v.village);
|
| 627 |
+
document.getElementById('dda-dash-location-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 628 |
+
},
|
| 629 |
+
});
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
function dashClassRow(label, r) {
|
| 633 |
+
return `<p class="dda-dash-class-line"><span class="dim">${label}</span> ${r.confirmed}/${r.falsePositive}/${r.submitted}/${r.unclassified} · ${accuracyCell(r.confirmedRate)}</p>`;
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
function renderDashLocation(data) {
|
| 637 |
const el = document.getElementById('dda-dash-location');
|
| 638 |
if (!el) return;
|
|
|
|
| 646 |
<p class="sub dim">${data.runsCount} detection run${data.runsCount === 1 ? '' : 's'} recorded for this location.</p>
|
| 647 |
<div class="dda-dash-compare-grid">
|
| 648 |
<div class="dda-dash-compare-col">
|
| 649 |
+
<span class="dim">Recent Change Detection</span>
|
| 650 |
<h4>${escapeHtml(data.latest.title)}</h4>
|
| 651 |
+
<p>${dashPct(data.latest.changePercentage)} change · ${data.latest.regionsCount} regions</p>
|
| 652 |
+
${dashClassRow('Confirmed/FP/Submitted/Unclassified', data.latest)}
|
| 653 |
<time class="dim" datetime="${escapeHtml(data.latest.createdAt || '')}">${formatHomeDate(data.latest.createdAt)}</time><br/>
|
| 654 |
<a class="btn btn-secondary btn-sm" href="/dda/reports/${data.latest.id}" target="_blank" rel="noopener">View Report</a>
|
| 655 |
</div>
|
| 656 |
<div class="dda-dash-compare-col">
|
| 657 |
+
<span class="dim">Previous Change Detection</span>
|
| 658 |
${data.previous ? `
|
| 659 |
<h4>${escapeHtml(data.previous.title)}</h4>
|
| 660 |
+
<p>${dashPct(data.previous.changePercentage)} change · ${data.previous.regionsCount} regions</p>
|
| 661 |
+
${dashClassRow('Confirmed/FP/Submitted/Unclassified', data.previous)}
|
| 662 |
<time class="dim" datetime="${escapeHtml(data.previous.createdAt || '')}">${formatHomeDate(data.previous.createdAt)}</time><br/>
|
| 663 |
<a class="btn btn-secondary btn-sm" href="/dda/reports/${data.previous.id}" target="_blank" rel="noopener">View Report</a>
|
| 664 |
` : '<p class="dim">No earlier run to compare against.</p>'}
|
| 665 |
</div>
|
| 666 |
<div class="dda-dash-compare-col">
|
| 667 |
+
<span class="dim">Recent vs Previous</span>
|
| 668 |
${cmp ? `
|
| 669 |
<p class="dda-dash-delta">${dashDelta(cmp.changePercentageDelta, '%')} change %</p>
|
| 670 |
<p class="dda-dash-delta">${dashDelta(cmp.regionsCountDelta, '')} regions</p>
|
| 671 |
+
<p class="dda-dash-delta">${dashDelta(cmp.confirmedRateDelta, '%')} accuracy</p>
|
| 672 |
+
<p class="dda-dash-delta">${dashDelta(cmp.falsePositiveDelta, '')} false positives</p>
|
| 673 |
+
` : '<p class="dim">Run a second detection here to see a comparison.</p>'}
|
| 674 |
</div>
|
| 675 |
</div>
|
| 676 |
+
${data.summary ? `<p class="dda-dash-summary">${escapeHtml(data.summary)}</p>` : ''}
|
| 677 |
+
${data.previous ? '<div class="dda-dash-chart-box dda-dash-compare-chart"><canvas id="dda-chart-location-compare" height="140"></canvas></div>' : ''}
|
| 678 |
<h4 class="dda-dash-history-title">Detection History</h4>
|
| 679 |
<table class="dda-reports-table">
|
| 680 |
+
<thead><tr><th>Date</th><th>Title</th><th>Change %</th><th>Regions</th><th>Change Types (this report)</th><th>Classification (confirmed/FP/submitted/unclassified)</th><th>Accuracy</th><th></th></tr></thead>
|
| 681 |
<tbody>
|
| 682 |
${data.history.map((r) => `
|
| 683 |
<tr>
|
|
|
|
| 685 |
<td>${escapeHtml(r.title)}</td>
|
| 686 |
<td>${dashPct(r.changePercentage)}</td>
|
| 687 |
<td>${r.regionsCount}</td>
|
| 688 |
+
<td>${allChangeTypePills(r.changeTypes)}</td>
|
| 689 |
+
<td>${classificationBar(r)}</td>
|
| 690 |
+
<td>${accuracyCell(r.confirmedRate)}</td>
|
| 691 |
<td><a class="btn btn-secondary btn-sm" href="/dda/reports/${r.id}" target="_blank" rel="noopener">Report</a></td>
|
| 692 |
</tr>`).join('')}
|
| 693 |
</tbody>
|
| 694 |
</table>`;
|
| 695 |
+
|
| 696 |
+
if (data.previous) {
|
| 697 |
+
renderDashChart('dda-chart-location-compare', {
|
| 698 |
+
type: 'bar',
|
| 699 |
+
data: {
|
| 700 |
+
labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
|
| 701 |
+
datasets: [
|
| 702 |
+
{
|
| 703 |
+
label: 'Previous',
|
| 704 |
+
data: [data.previous.confirmed, data.previous.falsePositive, data.previous.submitted, data.previous.unclassified],
|
| 705 |
+
backgroundColor: '#d7d3d3',
|
| 706 |
+
},
|
| 707 |
+
{
|
| 708 |
+
label: 'Recent',
|
| 709 |
+
data: [data.latest.confirmed, data.latest.falsePositive, data.latest.submitted, data.latest.unclassified],
|
| 710 |
+
backgroundColor: '#ec3013',
|
| 711 |
+
},
|
| 712 |
+
],
|
| 713 |
+
},
|
| 714 |
+
options: {
|
| 715 |
+
responsive: true,
|
| 716 |
+
animation: { duration: 500, easing: 'easeOutQuart' },
|
| 717 |
+
plugins: { legend: { position: 'bottom' } },
|
| 718 |
+
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
|
| 719 |
+
},
|
| 720 |
+
});
|
| 721 |
+
}
|
| 722 |
}
|
| 723 |
|
| 724 |
async function loadDashboardLocation(zone, village) {
|
|
|
|
| 735 |
async function loadDashboardTrend() {
|
| 736 |
try {
|
| 737 |
const data = await ddaApi('GET', '/api/dda/dashboard/trend?months=6');
|
| 738 |
+
applyDashboardTrend(data.buckets || []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
} catch (_) {
|
| 740 |
// Chart stays absent — non-critical widget, other sections still work.
|
| 741 |
}
|
| 742 |
}
|
| 743 |
|
| 744 |
+
function applyDashboardTrend(buckets) {
|
| 745 |
+
renderDashChart('dda-chart-trend', {
|
| 746 |
+
type: 'line',
|
| 747 |
+
data: {
|
| 748 |
+
labels: buckets.map((b) => b.label),
|
| 749 |
+
datasets: [{
|
| 750 |
+
label: 'Detection runs',
|
| 751 |
+
data: buckets.map((b) => b.runsCount),
|
| 752 |
+
borderColor: '#ec3013',
|
| 753 |
+
backgroundColor: 'rgba(236,48,19,0.12)',
|
| 754 |
+
tension: 0.3,
|
| 755 |
+
fill: true,
|
| 756 |
+
}],
|
| 757 |
+
},
|
| 758 |
+
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
|
| 759 |
+
onClick: (i) => {
|
| 760 |
+
const period = buckets[i]?.period; // "YYYY-MM"
|
| 761 |
+
if (!period) return;
|
| 762 |
+
const [y, m] = period.split('-').map(Number);
|
| 763 |
+
const lastDay = new Date(y, m, 0).getDate();
|
| 764 |
+
const from = document.getElementById('dash-filter-from');
|
| 765 |
+
const to = document.getElementById('dash-filter-to');
|
| 766 |
+
if (from) from.value = `${period}-01`;
|
| 767 |
+
if (to) to.value = `${period}-${String(lastDay).padStart(2, '0')}`;
|
| 768 |
+
loadDashboard();
|
| 769 |
+
},
|
| 770 |
+
});
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
let dashLiveJobTimer = null;
|
| 774 |
+
|
| 775 |
+
function dashElapsedLabel(startedAtIso) {
|
| 776 |
+
if (!startedAtIso) return '';
|
| 777 |
+
const started = new Date(startedAtIso).getTime();
|
| 778 |
+
if (Number.isNaN(started)) return '';
|
| 779 |
+
const secs = Math.max(0, Math.floor((Date.now() - started) / 1000));
|
| 780 |
+
const m = Math.floor(secs / 60);
|
| 781 |
+
const s = secs % 60;
|
| 782 |
+
return `started ${m}m ${s}s ago`;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
/** Banner for a detection job that's actually running right now — real
|
| 786 |
+
* progress/stage/elapsed time from GET /api/dda/jobs, not a placeholder.
|
| 787 |
+
* Polls every 15s while a job is live so it doesn't look stuck, and stops
|
| 788 |
+
* polling itself once nothing is running. */
|
| 789 |
+
function renderDashLiveBanner(jobs) {
|
| 790 |
+
const el = document.getElementById('dda-dash-live-banner');
|
| 791 |
if (!el) return;
|
| 792 |
+
const running = jobs.filter((j) => j.status === 'running');
|
| 793 |
+
const queued = jobs.filter((j) => j.status === 'queued');
|
| 794 |
+
if (!running.length) {
|
| 795 |
+
el.classList.add('hidden');
|
| 796 |
+
el.innerHTML = '';
|
| 797 |
+
if (dashLiveJobTimer) {
|
| 798 |
+
clearInterval(dashLiveJobTimer);
|
| 799 |
+
dashLiveJobTimer = null;
|
| 800 |
}
|
| 801 |
+
return;
|
| 802 |
+
}
|
| 803 |
+
const job = running[0];
|
| 804 |
+
const loc = [job.zone, job.village].filter(Boolean).join(' · ');
|
| 805 |
+
const pct = Math.max(0, Math.min(100, Math.round(job.progressPct || 0)));
|
| 806 |
+
const meta = [job.method, job.progressStage, dashElapsedLabel(job.startedAt)].filter(Boolean).join(' · ');
|
| 807 |
+
el.classList.remove('hidden');
|
| 808 |
+
el.innerHTML = `
|
| 809 |
+
<span class="dda-dash-live-dot"></span>
|
| 810 |
+
<div class="dda-dash-live-info">
|
| 811 |
+
<div class="dda-dash-live-title">Detection job running${loc ? ` — ${escapeHtml(loc)}` : ''}</div>
|
| 812 |
+
<div class="dda-dash-live-meta">${escapeHtml(meta)}</div>
|
| 813 |
+
</div>
|
| 814 |
+
<div class="dda-dash-live-bar"><div class="dda-dash-live-bar-fill" style="width:${pct}%"></div></div>
|
| 815 |
+
${queued.length ? `<span class="dda-dash-live-queued">${queued.length} queued</span>` : ''}
|
| 816 |
+
<a class="btn btn-ghost btn-sm" href="/detect">View progress</a>`;
|
| 817 |
+
|
| 818 |
+
if (!dashLiveJobTimer) {
|
| 819 |
+
dashLiveJobTimer = setInterval(loadDashboardLiveJob, 15000);
|
| 820 |
+
}
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
async function loadDashboardLiveJob() {
|
| 824 |
+
try {
|
| 825 |
+
const jobs = typeof getDdaNotifyJobs === 'function'
|
| 826 |
+
? await getDdaNotifyJobs()
|
| 827 |
+
: ((await ddaApi('GET', '/api/dda/jobs?limit=20')).jobs || []);
|
| 828 |
+
renderDashLiveBanner((jobs || []).filter((j) => j.status === 'running' || j.status === 'queued'));
|
| 829 |
+
} catch (_) {
|
| 830 |
+
// Non-critical widget — leave banner as it was on a transient failure.
|
| 831 |
}
|
| 832 |
}
|
| 833 |
|
|
|
|
| 837 |
el.textContent = `Updated ${formatHomeDate(new Date().toISOString())}`;
|
| 838 |
}
|
| 839 |
|
| 840 |
+
async function loadDashboardLibraryStats() {
|
| 841 |
+
const kpiEl = document.getElementById('dda-dash-lib-kpis');
|
| 842 |
+
const locEl = document.getElementById('dda-dash-lib-locations');
|
| 843 |
+
try {
|
| 844 |
+
const lib = await ddaApi('GET', '/api/dda/dashboard/library-stats');
|
| 845 |
+
|
| 846 |
+
const tiles = [
|
| 847 |
+
['Total Images', lib.totalImages],
|
| 848 |
+
['Assigned to a Location', lib.assignedImages],
|
| 849 |
+
['Unassigned', lib.unassignedImages],
|
| 850 |
+
['Zones Covered', lib.zonesCovered],
|
| 851 |
+
['Villages Covered', lib.villagesCovered],
|
| 852 |
+
];
|
| 853 |
+
if (kpiEl) {
|
| 854 |
+
kpiEl.innerHTML = tiles.map(([label, value]) => `
|
| 855 |
+
<a class="stat-box dda-dash-kpi" href="/library" title="Open Image Library">
|
| 856 |
+
<span class="value">${escapeHtml(String(value ?? '—'))}</span>
|
| 857 |
+
<span class="label">${escapeHtml(label)}</span>
|
| 858 |
+
</a>`).join('');
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
renderDashChart('dda-chart-lib-format', {
|
| 862 |
+
type: 'doughnut',
|
| 863 |
+
data: {
|
| 864 |
+
labels: lib.byFormat.map((f) => f.format),
|
| 865 |
+
datasets: [{
|
| 866 |
+
data: lib.byFormat.map((f) => f.count),
|
| 867 |
+
backgroundColor: ['#2d2b2b', '#ec3013', '#d7d3d3', '#2563eb', '#16a34a', '#64748b'],
|
| 868 |
+
}],
|
| 869 |
+
},
|
| 870 |
+
options: { responsive: true, plugins: { legend: { position: 'bottom' } } },
|
| 871 |
+
});
|
| 872 |
+
|
| 873 |
+
if (locEl) {
|
| 874 |
+
const zoneRows = lib.byZone.length
|
| 875 |
+
? lib.byZone.map((z) => `<tr><td>${escapeHtml(z.zone)}</td><td>${z.imageCount}</td></tr>`).join('')
|
| 876 |
+
: '<tr><td colspan="2" class="dim">No images assigned to a zone yet.</td></tr>';
|
| 877 |
+
const villageRows = lib.byVillage.length
|
| 878 |
+
? lib.byVillage.slice(0, 20).map((v) => `<tr><td>${escapeHtml(v.zone)}</td><td>${escapeHtml(v.village)}</td><td>${v.imageCount}</td></tr>`).join('')
|
| 879 |
+
: '<tr><td colspan="3" class="dim">No images assigned to a village yet.</td></tr>';
|
| 880 |
+
locEl.innerHTML = `
|
| 881 |
+
<div class="dda-dash-overview-grid">
|
| 882 |
+
<table class="dda-reports-table">
|
| 883 |
+
<thead><tr><th>Zone</th><th>Images</th></tr></thead>
|
| 884 |
+
<tbody>${zoneRows}</tbody>
|
| 885 |
+
</table>
|
| 886 |
+
<table class="dda-reports-table">
|
| 887 |
+
<thead><tr><th>Zone</th><th>Village</th><th>Images</th></tr></thead>
|
| 888 |
+
<tbody>${villageRows}</tbody>
|
| 889 |
+
</table>
|
| 890 |
+
</div>
|
| 891 |
+
${lib.unassignedImages ? `<p class="dim" style="margin-top:0.75rem">${lib.unassignedImages} image(s) have no zone/village tag yet — assign one from the Image Library upload form or an image's detail view.</p>` : ''}`;
|
| 892 |
+
}
|
| 893 |
+
} catch (err) {
|
| 894 |
+
if (kpiEl) kpiEl.innerHTML = `<p class="dim">Could not load library stats: ${escapeHtml(err.message || 'error')}</p>`;
|
| 895 |
+
if (locEl) locEl.innerHTML = '';
|
| 896 |
+
}
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
async function loadDashboard() {
|
| 900 |
dashSelectedZone = '';
|
| 901 |
+
dashSelectedVillageKey = '';
|
| 902 |
document.getElementById('dda-dash-village-filter-label')?.classList.add('hidden');
|
| 903 |
document.getElementById('btn-dash-village-clear')?.classList.add('hidden');
|
| 904 |
document.getElementById('dda-dash-location').innerHTML =
|
| 905 |
'<p class="dim">Select a village above to compare its latest and previous detection results.</p>';
|
| 906 |
|
| 907 |
+
// Independent of detection history — the library can (and usually does)
|
| 908 |
+
// have images even with zero runs yet, so this isn't gated behind
|
| 909 |
+
// summary.totalRuns like the sections below it.
|
| 910 |
+
loadDashboardLibraryStats();
|
| 911 |
+
loadDashboardLiveJob();
|
| 912 |
+
|
| 913 |
+
try {
|
| 914 |
+
const params = new URLSearchParams(dashDateParams());
|
| 915 |
+
params.set('months', '6');
|
| 916 |
+
const qs = params.toString();
|
| 917 |
+
const data = await ddaApi('GET', `/api/dda/dashboard/bootstrap${qs ? `?${qs}` : ''}`);
|
| 918 |
+
const summary = applyDashboardSummary(data.summary || {});
|
| 919 |
+
if (!summary || !summary.totalRuns) {
|
| 920 |
+
// Charts & Graphs / Breakdown stay reachable via tabs — clear placeholders.
|
| 921 |
+
renderDashRolesTable([]);
|
| 922 |
+
dashRegionsCache = [];
|
| 923 |
+
renderDashRegionsTable([]);
|
| 924 |
+
renderDashVillagesTable([]);
|
| 925 |
+
markDashUpdated();
|
| 926 |
+
return;
|
| 927 |
+
}
|
| 928 |
+
applyDashboardRoles(data.roles || []);
|
| 929 |
+
applyDashboardRegions(data.regions || []);
|
| 930 |
+
applyDashboardVillages(data.villages || []);
|
| 931 |
+
applyDashboardTrend((data.trend && data.trend.buckets) || []);
|
| 932 |
markDashUpdated();
|
| 933 |
+
} catch (err) {
|
| 934 |
+
document.getElementById('dda-dash-kpis').innerHTML = `<p class="dim">Could not load overview: ${escapeHtml(err.message || 'error')}</p>`;
|
| 935 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 936 |
}
|
| 937 |
|
| 938 |
document.getElementById('btn-dash-refresh')?.addEventListener('click', loadDashboard);
|
| 939 |
document.getElementById('btn-dash-village-clear')?.addEventListener('click', () => selectDashRegion(''));
|
| 940 |
|
| 941 |
+
// Date filter applies on an explicit Search click, not on every date pick —
|
| 942 |
+
// picking From then To would otherwise fire two reloads back to back.
|
| 943 |
+
document.getElementById('btn-dash-filter-apply')?.addEventListener('click', loadDashboard);
|
| 944 |
+
document.getElementById('btn-dash-filter-clear')?.addEventListener('click', () => {
|
| 945 |
+
const from = document.getElementById('dash-filter-from');
|
| 946 |
+
const to = document.getElementById('dash-filter-to');
|
| 947 |
+
if (from) from.value = '';
|
| 948 |
+
if (to) to.value = '';
|
| 949 |
+
loadDashboard();
|
| 950 |
+
});
|
| 951 |
+
|
| 952 |
document.addEventListener('DOMContentLoaded', () => {
|
| 953 |
loadHomeGreeting();
|
| 954 |
loadDashboard();
|
static/js/dda/library.js
CHANGED
|
@@ -115,14 +115,17 @@ function openLibraryImageViewer(img) {
|
|
| 115 |
revokeViewerObjectUrl();
|
| 116 |
libraryViewerState.image = img;
|
| 117 |
title.textContent = img.filename || img.imageName || 'Image';
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
| 126 |
imageEl.alt = img.filename || 'Library image';
|
| 127 |
imageEl.removeAttribute('src');
|
| 128 |
setViewerStatus('Loading preview…', false);
|
|
@@ -274,22 +277,116 @@ function uploadWithProgress(url, formData, onProgress) {
|
|
| 274 |
});
|
| 275 |
}
|
| 276 |
|
| 277 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
let pendingUploadFiles = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
function isLibraryImageFile(file) {
|
| 281 |
const name = (file.name || '').toLowerCase();
|
| 282 |
if (!name || name.startsWith('.') || name === '.ds_store') return false;
|
| 283 |
if ((file._relativePath || file.webkitRelativePath || '').includes('__macosx')) return false;
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
function guessImageType(file) {
|
| 288 |
-
const ext = (file.name.split('.').pop() || '').toLowerCase();
|
| 289 |
-
if (ext === 'tif' || ext === 'tiff') return 'GeoTIFF';
|
| 290 |
-
if (ext === 'png') return 'PNG';
|
| 291 |
-
if (ext === 'jpg' || ext === 'jpeg') return 'JPEG';
|
| 292 |
-
return document.getElementById('upload-image-type')?.value || 'GeoTIFF';
|
| 293 |
}
|
| 294 |
|
| 295 |
function relativeUploadPath(file) {
|
|
@@ -312,24 +409,237 @@ function fileTooLarge(file) {
|
|
| 312 |
function updateUploadSummary() {
|
| 313 |
const el = document.getElementById('upload-file-summary');
|
| 314 |
if (!el) return;
|
| 315 |
-
const
|
| 316 |
-
|
| 317 |
-
if (!pendingUploadFiles.length) {
|
| 318 |
el.textContent = 'No files selected.';
|
| 319 |
return;
|
| 320 |
}
|
| 321 |
-
el.textContent = `${
|
| 322 |
-
(skipped ? ` · ${skipped} skipped` : '');
|
| 323 |
}
|
| 324 |
|
| 325 |
-
function
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
updateUploadSummary();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
}
|
| 329 |
|
| 330 |
function setUploadTargetNode(nodeId) {
|
| 331 |
const sel = document.getElementById('upload-node');
|
| 332 |
-
if (sel && nodeId)
|
|
|
|
|
|
|
|
|
|
| 333 |
document.getElementById('upload-card')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 334 |
}
|
| 335 |
|
|
@@ -346,24 +656,80 @@ async function ensureFolderPath(parentId, segments) {
|
|
| 346 |
return current;
|
| 347 |
}
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
if (oversized) throw new Error(oversized);
|
| 352 |
-
const form = new FormData();
|
| 353 |
-
form.append('file', file);
|
| 354 |
-
form.append('image_type', document.getElementById('upload-image-type')?.value || guessImageType(file));
|
| 355 |
form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
|
| 356 |
const manualBounds = document.getElementById('upload-manual-bounds')?.value?.trim();
|
| 357 |
if (manualBounds) form.append('manual_bounds', manualBounds);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
return uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload`, form, onProgress);
|
| 359 |
}
|
| 360 |
|
| 361 |
-
async function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
if (uploadLibraryFiles._busy) return 0;
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
const dest = nodeId || document.getElementById('upload-node')?.value;
|
| 366 |
-
const needsFolders = preserveFolders &&
|
| 367 |
const canCreateFolders = typeof hasPermission === 'function'
|
| 368 |
? hasPermission(window.ddaPermissions, 'library', 'create')
|
| 369 |
: window.ddaState?.userRole === 'admin';
|
|
@@ -383,10 +749,12 @@ async function uploadLibraryFiles(files, { nodeId, preserveFolders = true } = {}
|
|
| 383 |
const folderCache = new Map();
|
| 384 |
let ok = 0;
|
| 385 |
const errors = [];
|
|
|
|
| 386 |
try {
|
| 387 |
-
for
|
| 388 |
-
|
| 389 |
-
|
|
|
|
| 390 |
const parts = rel.split('/').filter(Boolean);
|
| 391 |
const dirParts = parts.length > 1 ? parts.slice(0, -1) : [];
|
| 392 |
let targetId = dest ? Number(dest) : null;
|
|
@@ -398,33 +766,73 @@ async function uploadLibraryFiles(files, { nodeId, preserveFolders = true } = {}
|
|
| 398 |
targetId = folderCache.get(key);
|
| 399 |
}
|
| 400 |
if (!targetId) throw new Error('Select a destination folder.');
|
| 401 |
-
if (
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
}
|
| 417 |
}
|
| 418 |
if (progFill) progFill.style.width = '100%';
|
| 419 |
-
if (ok && typeof window.ddaState?.rescan === 'function') await window.ddaState.rescan();
|
| 420 |
if (errors.length && ok) {
|
| 421 |
showDdaError?.(`${ok} uploaded, ${errors.length} failed. ${errors[0]}`);
|
| 422 |
} else if (errors.length) {
|
| 423 |
throw new Error(errors[0]);
|
| 424 |
} else {
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
}
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
| 428 |
const fileInput = document.getElementById('upload-file');
|
| 429 |
const folderInput = document.getElementById('upload-folder');
|
| 430 |
if (fileInput) fileInput.value = '';
|
|
@@ -504,9 +912,14 @@ function initLibraryDropzone() {
|
|
| 504 |
const files = await readDroppedEntries(e.dataTransfer);
|
| 505 |
if (!files.length) return;
|
| 506 |
setPendingUploadFiles(files);
|
|
|
|
| 507 |
const dest = document.getElementById('upload-node')?.value;
|
| 508 |
try {
|
| 509 |
-
await uploadLibraryFiles(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
} catch (err) {
|
| 511 |
showDdaError?.(err.message || 'Upload failed.');
|
| 512 |
}
|
|
@@ -523,24 +936,343 @@ function initLibraryDropzone() {
|
|
| 523 |
document.getElementById('form-tree-upload')?.addEventListener('submit', async (e) => {
|
| 524 |
e.preventDefault();
|
| 525 |
hideDdaError?.();
|
| 526 |
-
const files = pendingUploadFiles.length
|
| 527 |
-
? pendingUploadFiles
|
| 528 |
-
: [
|
| 529 |
-
...Array.from(document.getElementById('upload-file')?.files || []),
|
| 530 |
-
...Array.from(document.getElementById('upload-folder')?.files || []),
|
| 531 |
-
];
|
| 532 |
try {
|
| 533 |
-
await uploadLibraryFiles(
|
| 534 |
nodeId: document.getElementById('upload-node')?.value,
|
| 535 |
preserveFolders: true,
|
|
|
|
| 536 |
});
|
| 537 |
} catch (err) {
|
| 538 |
showDdaError?.(err.message || 'Upload failed.');
|
| 539 |
}
|
| 540 |
});
|
| 541 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
initLibraryDropzone();
|
|
|
|
|
|
|
| 543 |
window.uploadLibraryFiles = uploadLibraryFiles;
|
| 544 |
window.setUploadTargetNode = setUploadTargetNode;
|
| 545 |
window.setPendingUploadFiles = setPendingUploadFiles;
|
| 546 |
window.readDroppedEntries = readDroppedEntries;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
revokeViewerObjectUrl();
|
| 116 |
libraryViewerState.image = img;
|
| 117 |
title.textContent = img.filename || img.imageName || 'Image';
|
| 118 |
+
meta.innerHTML = typeof buildImageMetaHtml === 'function'
|
| 119 |
+
? buildImageMetaHtml(img)
|
| 120 |
+
: [
|
| 121 |
+
img.breadcrumb || img.nodePath || '',
|
| 122 |
+
[img.villageName, img.zoneName].filter(Boolean).join(', '),
|
| 123 |
+
img.captureDate ? `Capture ${String(img.captureDate).slice(0, 10)}` : '',
|
| 124 |
+
img.year != null ? `Year ${img.year}` : '',
|
| 125 |
+
img.imageType || '',
|
| 126 |
+
img.source || '',
|
| 127 |
+
typeof formatBytes === 'function' ? formatBytes(img.fileSizeBytes) : '',
|
| 128 |
+
].filter(Boolean).join(' · ');
|
| 129 |
imageEl.alt = img.filename || 'Library image';
|
| 130 |
imageEl.removeAttribute('src');
|
| 131 |
setViewerStatus('Loading preview…', false);
|
|
|
|
| 277 |
});
|
| 278 |
}
|
| 279 |
|
| 280 |
+
const IMAGE_TYPE_EXTENSIONS = {
|
| 281 |
+
GeoTIFF: ['.tif', '.tiff'],
|
| 282 |
+
PNG: ['.png'],
|
| 283 |
+
JPEG: ['.jpg', '.jpeg'],
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
let pendingUploadFiles = [];
|
| 287 |
+
/** @type {{file: File, serverName: string, selected: boolean}[]} */
|
| 288 |
+
let pendingUploadEntries = [];
|
| 289 |
+
|
| 290 |
+
function currentUploadImageType() {
|
| 291 |
+
return document.getElementById('upload-image-type')?.value || 'GeoTIFF';
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
function allowedExtsForType(imageType) {
|
| 295 |
+
return IMAGE_TYPE_EXTENSIONS[imageType] || IMAGE_TYPE_EXTENSIONS.GeoTIFF;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function fileExtension(name) {
|
| 299 |
+
const n = String(name || '');
|
| 300 |
+
const i = n.lastIndexOf('.');
|
| 301 |
+
if (i < 0) return '';
|
| 302 |
+
return n.slice(i).toLowerCase();
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
function formatAllowedExts(exts) {
|
| 306 |
+
if (exts.length === 1) return exts[0];
|
| 307 |
+
if (exts.length === 2) return `${exts[0]} or ${exts[1]}`;
|
| 308 |
+
return `${exts.slice(0, -1).join(', ')} or ${exts[exts.length - 1]}`;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
function formatRejectedNames(names) {
|
| 312 |
+
const list = names.slice(0, 3);
|
| 313 |
+
const more = names.length - list.length;
|
| 314 |
+
const joined = list.map((n) => `"${n}"`).join(', ');
|
| 315 |
+
return more > 0 ? `${joined}, and ${more} more` : joined;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function setUploadTypeError(msg) {
|
| 319 |
+
const el = document.getElementById('upload-type-error');
|
| 320 |
+
if (el) {
|
| 321 |
+
el.textContent = msg || '';
|
| 322 |
+
el.classList.toggle('hidden', !msg);
|
| 323 |
+
}
|
| 324 |
+
if (msg) showDdaError?.(msg);
|
| 325 |
+
else hideDdaError?.();
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
function clearUploadTypeError() {
|
| 329 |
+
setUploadTypeError('');
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
function splitFilesForImageType(files, imageType) {
|
| 333 |
+
const allowed = allowedExtsForType(imageType);
|
| 334 |
+
const accepted = [];
|
| 335 |
+
const rejected = [];
|
| 336 |
+
Array.from(files || []).forEach((file) => {
|
| 337 |
+
const name = file.name || '';
|
| 338 |
+
if (!name || name.startsWith('.') || name.toLowerCase() === '.ds_store') {
|
| 339 |
+
rejected.push(file);
|
| 340 |
+
return;
|
| 341 |
+
}
|
| 342 |
+
if ((file._relativePath || file.webkitRelativePath || '').toLowerCase().includes('__macosx')) {
|
| 343 |
+
rejected.push(file);
|
| 344 |
+
return;
|
| 345 |
+
}
|
| 346 |
+
const ext = fileExtension(name);
|
| 347 |
+
if (!ext || !allowed.includes(ext)) rejected.push(file);
|
| 348 |
+
else accepted.push(file);
|
| 349 |
+
});
|
| 350 |
+
return { accepted, rejected };
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
function rejectMessage(imageType, rejected, { typeChanged = false } = {}) {
|
| 354 |
+
const allowed = allowedExtsForType(imageType);
|
| 355 |
+
const names = rejected.map((f) => f.name || 'file');
|
| 356 |
+
const named = formatRejectedNames(names);
|
| 357 |
+
const allow = formatAllowedExts(allowed);
|
| 358 |
+
if (typeChanged) {
|
| 359 |
+
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}.`;
|
| 360 |
+
}
|
| 361 |
+
if (rejected.length === 1) {
|
| 362 |
+
const ext = fileExtension(rejected[0].name);
|
| 363 |
+
if (!ext) {
|
| 364 |
+
return `Image type ${imageType} only allows ${allow}. "${rejected[0].name}" has no supported extension and was not added.`;
|
| 365 |
+
}
|
| 366 |
+
return `Image type ${imageType} only allows ${allow}. "${rejected[0].name}" was not added.`;
|
| 367 |
+
}
|
| 368 |
+
return `Image type ${imageType} only allows ${allow}. ${rejected.length} files were not added: ${named}.`;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
function syncUploadAcceptAttribute() {
|
| 372 |
+
const type = currentUploadImageType();
|
| 373 |
+
const accept = allowedExtsForType(type).join(',');
|
| 374 |
+
const fileInput = document.getElementById('upload-file');
|
| 375 |
+
const folderInput = document.getElementById('upload-folder');
|
| 376 |
+
if (fileInput) fileInput.accept = accept;
|
| 377 |
+
if (folderInput) folderInput.accept = accept;
|
| 378 |
+
const hint = document.getElementById('upload-accept-hint');
|
| 379 |
+
if (hint) {
|
| 380 |
+
hint.textContent = `${type} only: ${formatAllowedExts(allowedExtsForType(type))}. Multiple files and nested folders are supported.`;
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
|
| 384 |
function isLibraryImageFile(file) {
|
| 385 |
const name = (file.name || '').toLowerCase();
|
| 386 |
if (!name || name.startsWith('.') || name === '.ds_store') return false;
|
| 387 |
if ((file._relativePath || file.webkitRelativePath || '').includes('__macosx')) return false;
|
| 388 |
+
const allowed = allowedExtsForType(currentUploadImageType());
|
| 389 |
+
return allowed.includes(fileExtension(name));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
}
|
| 391 |
|
| 392 |
function relativeUploadPath(file) {
|
|
|
|
| 409 |
function updateUploadSummary() {
|
| 410 |
const el = document.getElementById('upload-file-summary');
|
| 411 |
if (!el) return;
|
| 412 |
+
const n = pendingUploadEntries.length;
|
| 413 |
+
if (!n) {
|
|
|
|
| 414 |
el.textContent = 'No files selected.';
|
| 415 |
return;
|
| 416 |
}
|
| 417 |
+
el.textContent = `${n} image${n === 1 ? '' : 's'} ready for ${currentUploadImageType()}.`;
|
|
|
|
| 418 |
}
|
| 419 |
|
| 420 |
+
function renderUploadFileList() {
|
| 421 |
+
const wrap = document.getElementById('upload-file-list-wrap');
|
| 422 |
+
const tbody = document.getElementById('upload-file-tbody');
|
| 423 |
+
if (!wrap || !tbody) return;
|
| 424 |
+
if (!pendingUploadEntries.length) {
|
| 425 |
+
wrap.classList.add('hidden');
|
| 426 |
+
tbody.innerHTML = '';
|
| 427 |
+
return;
|
| 428 |
+
}
|
| 429 |
+
wrap.classList.remove('hidden');
|
| 430 |
+
const type = currentUploadImageType();
|
| 431 |
+
tbody.innerHTML = pendingUploadEntries.map((entry, idx) => `
|
| 432 |
+
<tr data-idx="${idx}">
|
| 433 |
+
<td><input type="checkbox" class="upload-file-check" ${entry.selected ? 'checked' : ''} /></td>
|
| 434 |
+
<td class="dda-upload-orig">${escapeHtml(entry.file.name)}</td>
|
| 435 |
+
<td><input type="text" class="upload-server-name" value="${escapeHtml(entry.serverName)}" aria-label="Server filename" /></td>
|
| 436 |
+
<td>${escapeHtml(type)}</td>
|
| 437 |
+
<td>${typeof formatBytes === 'function' ? formatBytes(entry.file.size) : entry.file.size}</td>
|
| 438 |
+
</tr>`).join('');
|
| 439 |
+
|
| 440 |
+
tbody.querySelectorAll('.upload-file-check').forEach((cb) => {
|
| 441 |
+
cb.addEventListener('change', () => {
|
| 442 |
+
const idx = parseInt(cb.closest('tr').dataset.idx, 10);
|
| 443 |
+
if (pendingUploadEntries[idx]) pendingUploadEntries[idx].selected = cb.checked;
|
| 444 |
+
});
|
| 445 |
+
});
|
| 446 |
+
tbody.querySelectorAll('.upload-server-name').forEach((input) => {
|
| 447 |
+
input.addEventListener('change', () => {
|
| 448 |
+
const idx = parseInt(input.closest('tr').dataset.idx, 10);
|
| 449 |
+
if (pendingUploadEntries[idx]) {
|
| 450 |
+
const name = (input.value || '').trim() || pendingUploadEntries[idx].file.name;
|
| 451 |
+
pendingUploadEntries[idx].serverName = name.split(/[/\\]/).pop();
|
| 452 |
+
input.value = pendingUploadEntries[idx].serverName;
|
| 453 |
+
}
|
| 454 |
+
});
|
| 455 |
+
});
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
function setPendingUploadFiles(files, { append = false } = {}) {
|
| 459 |
+
const type = currentUploadImageType();
|
| 460 |
+
const { accepted, rejected } = splitFilesForImageType(files, type);
|
| 461 |
+
if (rejected.length) setUploadTypeError(rejectMessage(type, rejected));
|
| 462 |
+
else clearUploadTypeError();
|
| 463 |
+
|
| 464 |
+
const entries = accepted.map((file) => ({
|
| 465 |
+
file,
|
| 466 |
+
serverName: (file.name || 'upload').split(/[/\\]/).pop(),
|
| 467 |
+
selected: true,
|
| 468 |
+
}));
|
| 469 |
+
pendingUploadEntries = append ? pendingUploadEntries.concat(entries) : entries;
|
| 470 |
+
pendingUploadFiles = pendingUploadEntries.map((e) => e.file);
|
| 471 |
updateUploadSummary();
|
| 472 |
+
renderUploadFileList();
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
function revalidatePendingForImageType({ typeChanged = false } = {}) {
|
| 476 |
+
const type = currentUploadImageType();
|
| 477 |
+
syncUploadAcceptAttribute();
|
| 478 |
+
if (!pendingUploadEntries.length) {
|
| 479 |
+
clearUploadTypeError();
|
| 480 |
+
updateUploadSummary();
|
| 481 |
+
renderUploadFileList();
|
| 482 |
+
suggestSourceFromType();
|
| 483 |
+
return;
|
| 484 |
+
}
|
| 485 |
+
const files = pendingUploadEntries.map((e) => e.file);
|
| 486 |
+
const { accepted, rejected } = splitFilesForImageType(files, type);
|
| 487 |
+
if (rejected.length) {
|
| 488 |
+
const keepNames = new Set(accepted.map((f) => f));
|
| 489 |
+
pendingUploadEntries = pendingUploadEntries.filter((e) => keepNames.has(e.file));
|
| 490 |
+
pendingUploadFiles = pendingUploadEntries.map((e) => e.file);
|
| 491 |
+
setUploadTypeError(rejectMessage(type, rejected, { typeChanged: true }));
|
| 492 |
+
} else {
|
| 493 |
+
clearUploadTypeError();
|
| 494 |
+
}
|
| 495 |
+
updateUploadSummary();
|
| 496 |
+
renderUploadFileList();
|
| 497 |
+
suggestSourceFromType();
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
function suggestSourceFromType() {
|
| 501 |
+
const sourceSel = document.getElementById('upload-source');
|
| 502 |
+
if (!sourceSel || sourceSel.dataset.userPicked === '1') return;
|
| 503 |
+
const type = currentUploadImageType();
|
| 504 |
+
if (type === 'Satellite') sourceSel.value = 'satellite';
|
| 505 |
+
else if (type === 'Drone') sourceSel.value = 'drone';
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
function populateUploadYearOptions() {
|
| 509 |
+
const sel = document.getElementById('upload-year');
|
| 510 |
+
const filterSel = document.getElementById('lib-filter-year');
|
| 511 |
+
if (!sel) return;
|
| 512 |
+
const current = new Date().getFullYear();
|
| 513 |
+
const years = [];
|
| 514 |
+
for (let y = current; y >= current - 15; y--) years.push(y);
|
| 515 |
+
const prev = sel.value;
|
| 516 |
+
sel.innerHTML = '<option value="">— Select year —</option>' +
|
| 517 |
+
years.map((y) => `<option value="${y}">${y}</option>`).join('');
|
| 518 |
+
if (prev) sel.value = prev;
|
| 519 |
+
if (filterSel) {
|
| 520 |
+
const fprev = filterSel.value;
|
| 521 |
+
filterSel.innerHTML = '<option value="">All</option>' +
|
| 522 |
+
years.map((y) => `<option value="${y}">${y}</option>`).join('');
|
| 523 |
+
if (fprev) filterSel.value = fprev;
|
| 524 |
+
}
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
const uploadLocations = { zones: [], villages: [] };
|
| 528 |
+
|
| 529 |
+
function renderUploadVillageOptions() {
|
| 530 |
+
const villageSel = document.getElementById('upload-village');
|
| 531 |
+
if (!villageSel) return;
|
| 532 |
+
const zoneId = document.getElementById('upload-zone')?.value || '';
|
| 533 |
+
const previous = villageSel.value;
|
| 534 |
+
const list = zoneId
|
| 535 |
+
? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
|
| 536 |
+
: uploadLocations.villages;
|
| 537 |
+
const empty = zoneId ? '— Select village —' : '— Select village (or pick a zone first) —';
|
| 538 |
+
villageSel.innerHTML = `<option value="">${empty}</option>` + list.map((v) => {
|
| 539 |
+
const label = zoneId ? v.name : `${v.name} · ${v.zoneName || ''}`;
|
| 540 |
+
return `<option value="${v.id}">${escapeHtml(label)}</option>`;
|
| 541 |
+
}).join('');
|
| 542 |
+
if (previous && list.some((v) => String(v.id) === String(previous))) villageSel.value = previous;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
function renderLibFilterLocationOptions() {
|
| 546 |
+
const zoneSel = document.getElementById('lib-filter-zone');
|
| 547 |
+
const villageSel = document.getElementById('lib-filter-village');
|
| 548 |
+
if (zoneSel) {
|
| 549 |
+
const prev = zoneSel.value;
|
| 550 |
+
zoneSel.innerHTML = '<option value="">All</option>' + uploadLocations.zones
|
| 551 |
+
.map((z) => `<option value="${z.id}">${escapeHtml(z.name)}</option>`).join('');
|
| 552 |
+
if (prev) zoneSel.value = prev;
|
| 553 |
+
}
|
| 554 |
+
if (villageSel) {
|
| 555 |
+
const zoneId = zoneSel?.value || '';
|
| 556 |
+
const prev = villageSel.value;
|
| 557 |
+
const list = zoneId
|
| 558 |
+
? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
|
| 559 |
+
: uploadLocations.villages;
|
| 560 |
+
villageSel.innerHTML = '<option value="">All</option>' + list
|
| 561 |
+
.map((v) => `<option value="${v.id}">${escapeHtml(v.name)}</option>`).join('');
|
| 562 |
+
if (prev) villageSel.value = prev;
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
function markUploadLocationPicked() {
|
| 567 |
+
const zoneSel = document.getElementById('upload-zone');
|
| 568 |
+
if (zoneSel) zoneSel.dataset.userPicked = '1';
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
function suggestUploadLocationFromFolder() {
|
| 572 |
+
const zoneSel = document.getElementById('upload-zone');
|
| 573 |
+
const villageSel = document.getElementById('upload-village');
|
| 574 |
+
if (!zoneSel || zoneSel.dataset.userPicked === '1') return;
|
| 575 |
+
const path = document.getElementById('upload-node')?.selectedOptions?.[0]?.textContent || '';
|
| 576 |
+
const segments = path.split(/[/\\]/).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
| 577 |
+
if (!segments.length) return;
|
| 578 |
+
const zone = uploadLocations.zones.find((z) => segments.includes(String(z.name || '').toLowerCase()));
|
| 579 |
+
if (zone) zoneSel.value = String(zone.id);
|
| 580 |
+
renderUploadVillageOptions();
|
| 581 |
+
const matches = uploadLocations.villages.filter((v) => {
|
| 582 |
+
if (!segments.includes(String(v.name || '').toLowerCase())) return false;
|
| 583 |
+
if (zone && String(v.zoneId) !== String(zone.id)) return false;
|
| 584 |
+
return true;
|
| 585 |
+
});
|
| 586 |
+
if (matches.length === 1 && villageSel) {
|
| 587 |
+
if (!zoneSel.value) zoneSel.value = String(matches[0].zoneId);
|
| 588 |
+
renderUploadVillageOptions();
|
| 589 |
+
villageSel.value = String(matches[0].id);
|
| 590 |
+
}
|
| 591 |
+
// Suggest year from folder path segments
|
| 592 |
+
const yearSel = document.getElementById('upload-year');
|
| 593 |
+
if (yearSel && !yearSel.value) {
|
| 594 |
+
const yearSeg = segments.find((s) => /^\d{4}$/.test(s) && (s.startsWith('19') || s.startsWith('20')));
|
| 595 |
+
if (yearSeg) yearSel.value = yearSeg;
|
| 596 |
+
}
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
async function loadUploadLocations() {
|
| 600 |
+
const zoneSel = document.getElementById('upload-zone');
|
| 601 |
+
if (!zoneSel) return;
|
| 602 |
+
try {
|
| 603 |
+
const data = await ddaApi('GET', '/api/dda/masters/options');
|
| 604 |
+
const zoneNames = new Map((data.zones || []).map((z) => [z.id, z.name]));
|
| 605 |
+
uploadLocations.zones = data.zones || [];
|
| 606 |
+
uploadLocations.villages = (data.villages || []).map((v) => ({
|
| 607 |
+
...v, zoneName: zoneNames.get(v.zoneId) || '',
|
| 608 |
+
}));
|
| 609 |
+
} catch (_) {
|
| 610 |
+
return;
|
| 611 |
+
}
|
| 612 |
+
const previousZone = zoneSel.value;
|
| 613 |
+
zoneSel.innerHTML = '<option value="">— Select zone —</option>' + uploadLocations.zones
|
| 614 |
+
.map((z) => `<option value="${z.id}">${escapeHtml(z.name)}</option>`).join('');
|
| 615 |
+
if (previousZone) zoneSel.value = previousZone;
|
| 616 |
+
renderUploadVillageOptions();
|
| 617 |
+
renderLibFilterLocationOptions();
|
| 618 |
+
fillBulkZoneOptions();
|
| 619 |
+
|
| 620 |
+
if (!zoneSel.dataset.bound) {
|
| 621 |
+
zoneSel.dataset.bound = '1';
|
| 622 |
+
zoneSel.addEventListener('change', () => {
|
| 623 |
+
markUploadLocationPicked();
|
| 624 |
+
renderUploadVillageOptions();
|
| 625 |
+
});
|
| 626 |
+
document.getElementById('upload-village')?.addEventListener('change', (e) => {
|
| 627 |
+
markUploadLocationPicked();
|
| 628 |
+
const village = uploadLocations.villages.find((v) => String(v.id) === String(e.target.value));
|
| 629 |
+
if (village) zoneSel.value = String(village.zoneId);
|
| 630 |
+
renderUploadVillageOptions();
|
| 631 |
+
if (village) document.getElementById('upload-village').value = String(village.id);
|
| 632 |
+
});
|
| 633 |
+
document.getElementById('upload-node')?.addEventListener('change', suggestUploadLocationFromFolder);
|
| 634 |
+
}
|
| 635 |
}
|
| 636 |
|
| 637 |
function setUploadTargetNode(nodeId) {
|
| 638 |
const sel = document.getElementById('upload-node');
|
| 639 |
+
if (sel && nodeId) {
|
| 640 |
+
sel.value = String(nodeId);
|
| 641 |
+
sel.dispatchEvent(new Event('change'));
|
| 642 |
+
}
|
| 643 |
document.getElementById('upload-card')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 644 |
}
|
| 645 |
|
|
|
|
| 656 |
return current;
|
| 657 |
}
|
| 658 |
|
| 659 |
+
function appendSharedUploadFields(form) {
|
| 660 |
+
form.append('image_type', currentUploadImageType());
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
|
| 662 |
const manualBounds = document.getElementById('upload-manual-bounds')?.value?.trim();
|
| 663 |
if (manualBounds) form.append('manual_bounds', manualBounds);
|
| 664 |
+
const zoneId = document.getElementById('upload-zone')?.value || '';
|
| 665 |
+
const villageId = document.getElementById('upload-village')?.value || '';
|
| 666 |
+
if (zoneId) form.append('zone_id', zoneId);
|
| 667 |
+
if (villageId) form.append('village_id', villageId);
|
| 668 |
+
const year = document.getElementById('upload-year')?.value || '';
|
| 669 |
+
if (year) form.append('year', year);
|
| 670 |
+
const source = document.getElementById('upload-source')?.value || '';
|
| 671 |
+
if (source) form.append('source', source);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
async function uploadOneLibraryFile(nodeId, file, serverName, { onProgress } = {}) {
|
| 675 |
+
const oversized = fileTooLarge(file);
|
| 676 |
+
if (oversized) throw new Error(oversized);
|
| 677 |
+
const form = new FormData();
|
| 678 |
+
form.append('file', file, serverName || file.name);
|
| 679 |
+
form.append('server_filename', serverName || file.name);
|
| 680 |
+
appendSharedUploadFields(form);
|
| 681 |
return uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload`, form, onProgress);
|
| 682 |
}
|
| 683 |
|
| 684 |
+
async function uploadBulkLibraryFiles(nodeId, entries, { onProgress } = {}) {
|
| 685 |
+
const form = new FormData();
|
| 686 |
+
entries.forEach((entry) => {
|
| 687 |
+
form.append('files', entry.file, entry.serverName || entry.file.name);
|
| 688 |
+
form.append('filenames', entry.serverName || entry.file.name);
|
| 689 |
+
});
|
| 690 |
+
appendSharedUploadFields(form);
|
| 691 |
+
return uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload-bulk`, form, onProgress);
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
async function uploadLibraryFiles(files, { nodeId, preserveFolders = true, entries = null } = {}) {
|
| 695 |
if (uploadLibraryFiles._busy) return 0;
|
| 696 |
+
// Every upload path (drag-drop, file picker, form submit) funnels through
|
| 697 |
+
// here, so this is the one place to enforce it: a zone and village are
|
| 698 |
+
// required, full stop — no more images landing as "Unassigned".
|
| 699 |
+
const zoneId = document.getElementById('upload-zone')?.value || '';
|
| 700 |
+
const villageId = document.getElementById('upload-village')?.value || '';
|
| 701 |
+
if (!zoneId || !villageId) {
|
| 702 |
+
const msg = 'Select a zone and village before uploading — every image needs a location.';
|
| 703 |
+
showDdaError?.(msg);
|
| 704 |
+
throw new Error(msg);
|
| 705 |
+
}
|
| 706 |
+
const type = currentUploadImageType();
|
| 707 |
+
let workEntries = entries;
|
| 708 |
+
if (!workEntries) {
|
| 709 |
+
const { accepted, rejected } = splitFilesForImageType(files, type);
|
| 710 |
+
if (rejected.length) {
|
| 711 |
+
setUploadTypeError(rejectMessage(type, rejected));
|
| 712 |
+
if (!accepted.length) throw new Error(rejectMessage(type, rejected));
|
| 713 |
+
}
|
| 714 |
+
workEntries = accepted.map((file) => ({
|
| 715 |
+
file,
|
| 716 |
+
serverName: (file.name || 'upload').split(/[/\\]/).pop(),
|
| 717 |
+
selected: true,
|
| 718 |
+
}));
|
| 719 |
+
}
|
| 720 |
+
workEntries = workEntries.filter((e) => e.selected !== false);
|
| 721 |
+
if (!workEntries.length) throw new Error(`No ${type} files to upload.`);
|
| 722 |
+
|
| 723 |
+
// Final gate before POST
|
| 724 |
+
const recheck = splitFilesForImageType(workEntries.map((e) => e.file), type);
|
| 725 |
+
if (recheck.rejected.length) {
|
| 726 |
+
const msg = `Cannot upload. Image type ${type} only allows ${formatAllowedExts(allowedExtsForType(type))}.`;
|
| 727 |
+
setUploadTypeError(msg);
|
| 728 |
+
throw new Error(msg);
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
const dest = nodeId || document.getElementById('upload-node')?.value;
|
| 732 |
+
const needsFolders = preserveFolders && workEntries.some((e) => relativeUploadPath(e.file).includes('/'));
|
| 733 |
const canCreateFolders = typeof hasPermission === 'function'
|
| 734 |
? hasPermission(window.ddaPermissions, 'library', 'create')
|
| 735 |
: window.ddaState?.userRole === 'admin';
|
|
|
|
| 749 |
const folderCache = new Map();
|
| 750 |
let ok = 0;
|
| 751 |
const errors = [];
|
| 752 |
+
const storedNames = [];
|
| 753 |
try {
|
| 754 |
+
// Group by destination folder for bulk when no nested folders, or multiple files same dest
|
| 755 |
+
const byTarget = new Map();
|
| 756 |
+
for (const entry of workEntries) {
|
| 757 |
+
const rel = relativeUploadPath(entry.file);
|
| 758 |
const parts = rel.split('/').filter(Boolean);
|
| 759 |
const dirParts = parts.length > 1 ? parts.slice(0, -1) : [];
|
| 760 |
let targetId = dest ? Number(dest) : null;
|
|
|
|
| 766 |
targetId = folderCache.get(key);
|
| 767 |
}
|
| 768 |
if (!targetId) throw new Error('Select a destination folder.');
|
| 769 |
+
if (!byTarget.has(targetId)) byTarget.set(targetId, []);
|
| 770 |
+
byTarget.get(targetId).push(entry);
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
let done = 0;
|
| 774 |
+
const total = workEntries.length;
|
| 775 |
+
for (const [targetId, group] of byTarget.entries()) {
|
| 776 |
+
if (group.length === 1) {
|
| 777 |
+
const entry = group[0];
|
| 778 |
+
if (progLabel) progLabel.textContent = `Uploading ${done + 1} of ${total}: ${entry.serverName}`;
|
| 779 |
+
try {
|
| 780 |
+
const res = await uploadOneLibraryFile(targetId, entry.file, entry.serverName, {
|
| 781 |
+
onProgress: (loaded, tot) => {
|
| 782 |
+
const filePct = tot ? loaded / tot : 1;
|
| 783 |
+
if (progFill) progFill.style.width = `${Math.round(((done + filePct) / total) * 100)}%`;
|
| 784 |
+
},
|
| 785 |
+
});
|
| 786 |
+
ok += 1;
|
| 787 |
+
if (res?.image?.filename) storedNames.push(res.image.filename);
|
| 788 |
+
} catch (err) {
|
| 789 |
+
errors.push(`${entry.serverName}: ${err.message || 'failed'}`);
|
| 790 |
+
}
|
| 791 |
+
done += 1;
|
| 792 |
+
} else {
|
| 793 |
+
if (progLabel) progLabel.textContent = `Uploading ${group.length} files…`;
|
| 794 |
+
try {
|
| 795 |
+
const res = await uploadBulkLibraryFiles(targetId, group, {
|
| 796 |
+
onProgress: (loaded, tot) => {
|
| 797 |
+
const filePct = tot ? loaded / tot : 1;
|
| 798 |
+
if (progFill) progFill.style.width = `${Math.round(((done + filePct * group.length) / total) * 100)}%`;
|
| 799 |
+
},
|
| 800 |
+
});
|
| 801 |
+
ok += (res.uploaded || []).length;
|
| 802 |
+
(res.uploaded || []).forEach((img) => {
|
| 803 |
+
if (img.filename) storedNames.push(img.filename);
|
| 804 |
+
});
|
| 805 |
+
(res.failed || []).forEach((f) => errors.push(`${f.filename}: ${f.error}`));
|
| 806 |
+
} catch (err) {
|
| 807 |
+
errors.push(err.message || 'Bulk upload failed');
|
| 808 |
+
}
|
| 809 |
+
done += group.length;
|
| 810 |
}
|
| 811 |
}
|
| 812 |
if (progFill) progFill.style.width = '100%';
|
|
|
|
| 813 |
if (errors.length && ok) {
|
| 814 |
showDdaError?.(`${ok} uploaded, ${errors.length} failed. ${errors[0]}`);
|
| 815 |
} else if (errors.length) {
|
| 816 |
throw new Error(errors[0]);
|
| 817 |
} else {
|
| 818 |
+
const nameHint = storedNames.length === 1 ? ` Saved as ${storedNames[0]}.` : '';
|
| 819 |
+
showDdaSuccess?.(`Uploaded ${ok} image${ok === 1 ? '' : 's'}.${nameHint}`);
|
| 820 |
+
clearUploadTypeError();
|
| 821 |
+
}
|
| 822 |
+
// Refresh after toast so a tree/grid failure cannot swallow upload success.
|
| 823 |
+
if (ok) {
|
| 824 |
+
try {
|
| 825 |
+
if (typeof loadTree === 'function') await loadTree();
|
| 826 |
+
if (typeof loadLibraryImages === 'function') await loadLibraryImages({ resetPage: true });
|
| 827 |
+
else if (typeof window.ddaState?.refreshImages === 'function') await window.ddaState.refreshImages();
|
| 828 |
+
} catch (_) {
|
| 829 |
+
/* upload already succeeded — leave the success toast visible */
|
| 830 |
+
}
|
| 831 |
}
|
| 832 |
+
pendingUploadEntries = [];
|
| 833 |
+
pendingUploadFiles = [];
|
| 834 |
+
updateUploadSummary();
|
| 835 |
+
renderUploadFileList();
|
| 836 |
const fileInput = document.getElementById('upload-file');
|
| 837 |
const folderInput = document.getElementById('upload-folder');
|
| 838 |
if (fileInput) fileInput.value = '';
|
|
|
|
| 912 |
const files = await readDroppedEntries(e.dataTransfer);
|
| 913 |
if (!files.length) return;
|
| 914 |
setPendingUploadFiles(files);
|
| 915 |
+
if (!pendingUploadEntries.length) return;
|
| 916 |
const dest = document.getElementById('upload-node')?.value;
|
| 917 |
try {
|
| 918 |
+
await uploadLibraryFiles(null, {
|
| 919 |
+
nodeId: dest,
|
| 920 |
+
preserveFolders: true,
|
| 921 |
+
entries: pendingUploadEntries.slice(),
|
| 922 |
+
});
|
| 923 |
} catch (err) {
|
| 924 |
showDdaError?.(err.message || 'Upload failed.');
|
| 925 |
}
|
|
|
|
| 936 |
document.getElementById('form-tree-upload')?.addEventListener('submit', async (e) => {
|
| 937 |
e.preventDefault();
|
| 938 |
hideDdaError?.();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
try {
|
| 940 |
+
await uploadLibraryFiles(null, {
|
| 941 |
nodeId: document.getElementById('upload-node')?.value,
|
| 942 |
preserveFolders: true,
|
| 943 |
+
entries: pendingUploadEntries.length ? pendingUploadEntries.slice() : null,
|
| 944 |
});
|
| 945 |
} catch (err) {
|
| 946 |
showDdaError?.(err.message || 'Upload failed.');
|
| 947 |
}
|
| 948 |
});
|
| 949 |
|
| 950 |
+
document.getElementById('upload-image-type')?.addEventListener('change', () => {
|
| 951 |
+
revalidatePendingForImageType({ typeChanged: true });
|
| 952 |
+
});
|
| 953 |
+
document.getElementById('upload-source')?.addEventListener('change', (e) => {
|
| 954 |
+
e.target.dataset.userPicked = '1';
|
| 955 |
+
});
|
| 956 |
+
|
| 957 |
+
function libFilterParams() {
|
| 958 |
+
const params = new URLSearchParams();
|
| 959 |
+
const year = document.getElementById('lib-filter-year')?.value || '';
|
| 960 |
+
const type = document.getElementById('lib-filter-type')?.value || '';
|
| 961 |
+
const zone = document.getElementById('lib-filter-zone')?.value || '';
|
| 962 |
+
const village = document.getElementById('lib-filter-village')?.value || '';
|
| 963 |
+
const from = document.getElementById('lib-filter-from')?.value || '';
|
| 964 |
+
const to = document.getElementById('lib-filter-to')?.value || '';
|
| 965 |
+
if (year) params.set('year', year);
|
| 966 |
+
if (type) params.set('image_type', type);
|
| 967 |
+
if (zone) params.set('zone_id', zone);
|
| 968 |
+
if (village) params.set('village_id', village);
|
| 969 |
+
if (from) params.set('capture_date_from', from);
|
| 970 |
+
if (to) params.set('capture_date_to', to);
|
| 971 |
+
return params;
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
function exportLibrary(format) {
|
| 975 |
+
const params = libFilterParams();
|
| 976 |
+
const q = document.getElementById('lib-filter')?.value?.trim() || '';
|
| 977 |
+
if (typeof selectedNode !== 'undefined' && selectedNode?.id) {
|
| 978 |
+
params.set('node_id', String(selectedNode.id));
|
| 979 |
+
}
|
| 980 |
+
if (q) params.set('q', q);
|
| 981 |
+
params.set('format', format);
|
| 982 |
+
window.location.href = `/api/dda/tree/images/export?${params.toString()}`;
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
document.getElementById('btn-lib-export-csv')?.addEventListener('click', () => exportLibrary('csv'));
|
| 986 |
+
document.getElementById('btn-lib-export-xlsx')?.addEventListener('click', () => exportLibrary('xlsx'));
|
| 987 |
+
|
| 988 |
+
document.getElementById('btn-lib-filter-clear')?.addEventListener('click', () => {
|
| 989 |
+
['lib-filter-year', 'lib-filter-type', 'lib-filter-zone', 'lib-filter-village', 'lib-filter-from', 'lib-filter-to'].forEach((id) => {
|
| 990 |
+
const el = document.getElementById(id);
|
| 991 |
+
if (el) el.value = '';
|
| 992 |
+
});
|
| 993 |
+
if (typeof loadLibraryImages === 'function') loadLibraryImages({ resetPage: true });
|
| 994 |
+
});
|
| 995 |
+
|
| 996 |
+
['lib-filter-year', 'lib-filter-type', 'lib-filter-zone', 'lib-filter-village', 'lib-filter-from', 'lib-filter-to'].forEach((id) => {
|
| 997 |
+
document.getElementById(id)?.addEventListener('change', () => {
|
| 998 |
+
if (id === 'lib-filter-zone') renderLibFilterLocationOptions();
|
| 999 |
+
if (typeof loadLibraryImages === 'function') loadLibraryImages({ resetPage: true });
|
| 1000 |
+
});
|
| 1001 |
+
});
|
| 1002 |
+
|
| 1003 |
+
document.getElementById('btn-lib-select-all')?.addEventListener('click', () => {
|
| 1004 |
+
const checks = document.querySelectorAll('.dda-card-select');
|
| 1005 |
+
const allOn = [...checks].every((c) => c.checked);
|
| 1006 |
+
checks.forEach((c) => { c.checked = !allOn; });
|
| 1007 |
+
updateLibDeleteSelectedBtn();
|
| 1008 |
+
});
|
| 1009 |
+
|
| 1010 |
+
function updateLibDeleteSelectedBtn() {
|
| 1011 |
+
const btn = document.getElementById('btn-lib-delete-selected');
|
| 1012 |
+
if (!btn) return;
|
| 1013 |
+
const n = document.querySelectorAll('.dda-card-select:checked').length;
|
| 1014 |
+
btn.disabled = n === 0;
|
| 1015 |
+
btn.textContent = n ? `Delete selected (${n})` : 'Delete selected';
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
document.getElementById('btn-lib-delete-selected')?.addEventListener('click', async () => {
|
| 1019 |
+
const ids = [...document.querySelectorAll('.dda-card-select:checked')].map((c) => c.dataset.imageId).filter(Boolean);
|
| 1020 |
+
if (!ids.length) return;
|
| 1021 |
+
if (!confirm(`Delete ${ids.length} selected image${ids.length === 1 ? '' : 's'}?`)) return;
|
| 1022 |
+
let ok = 0;
|
| 1023 |
+
const errors = [];
|
| 1024 |
+
for (const id of ids) {
|
| 1025 |
+
try {
|
| 1026 |
+
await ddaApi('DELETE', `/api/dda/tree/images/${id}`);
|
| 1027 |
+
ok += 1;
|
| 1028 |
+
} catch (err) {
|
| 1029 |
+
errors.push(err.message || 'failed');
|
| 1030 |
+
}
|
| 1031 |
+
}
|
| 1032 |
+
if (ok && typeof loadLibraryImages === 'function') await loadLibraryImages();
|
| 1033 |
+
if (errors.length) showDdaError?.(`${ok} deleted, ${errors.length} failed. ${errors[0]}`);
|
| 1034 |
+
else showDdaSuccess?.(`Deleted ${ok} image${ok === 1 ? '' : 's'}.`);
|
| 1035 |
+
});
|
| 1036 |
+
|
| 1037 |
+
populateUploadYearOptions();
|
| 1038 |
+
syncUploadAcceptAttribute();
|
| 1039 |
+
suggestSourceFromType();
|
| 1040 |
initLibraryDropzone();
|
| 1041 |
+
loadUploadLocations();
|
| 1042 |
+
window.loadUploadLocations = loadUploadLocations;
|
| 1043 |
window.uploadLibraryFiles = uploadLibraryFiles;
|
| 1044 |
window.setUploadTargetNode = setUploadTargetNode;
|
| 1045 |
window.setPendingUploadFiles = setPendingUploadFiles;
|
| 1046 |
window.readDroppedEntries = readDroppedEntries;
|
| 1047 |
+
window.splitFilesForImageType = splitFilesForImageType;
|
| 1048 |
+
window.libFilterParams = libFilterParams;
|
| 1049 |
+
window.updateLibDeleteSelectedBtn = updateLibDeleteSelectedBtn;
|
| 1050 |
+
|
| 1051 |
+
// --- Bulk upload (Excel manifest + files) ---------------------------------
|
| 1052 |
+
|
| 1053 |
+
let bulkPendingEntries = [];
|
| 1054 |
+
|
| 1055 |
+
function populateBulkYearOptions() {
|
| 1056 |
+
const sel = document.getElementById('bulk-year');
|
| 1057 |
+
if (!sel || sel.options.length > 1) return;
|
| 1058 |
+
const now = new Date().getFullYear();
|
| 1059 |
+
for (let y = now + 1; y >= 1990; y--) {
|
| 1060 |
+
const opt = document.createElement('option');
|
| 1061 |
+
opt.value = String(y);
|
| 1062 |
+
opt.textContent = String(y);
|
| 1063 |
+
sel.appendChild(opt);
|
| 1064 |
+
}
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
function renderBulkVillageOptions() {
|
| 1068 |
+
const villageSel = document.getElementById('bulk-village');
|
| 1069 |
+
if (!villageSel) return;
|
| 1070 |
+
const zoneId = document.getElementById('bulk-zone')?.value || '';
|
| 1071 |
+
const previous = villageSel.value;
|
| 1072 |
+
const list = zoneId
|
| 1073 |
+
? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
|
| 1074 |
+
: uploadLocations.villages;
|
| 1075 |
+
villageSel.innerHTML = '<option value="">— From Excel —</option>' + list.map((v) => {
|
| 1076 |
+
const label = zoneId ? v.name : `${v.name} · ${v.zoneName || ''}`;
|
| 1077 |
+
return `<option value="${v.id}">${escapeHtml(label)}</option>`;
|
| 1078 |
+
}).join('');
|
| 1079 |
+
if (previous && list.some((v) => String(v.id) === String(previous))) villageSel.value = previous;
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
function fillBulkZoneOptions() {
|
| 1083 |
+
const zoneSel = document.getElementById('bulk-zone');
|
| 1084 |
+
if (!zoneSel) return;
|
| 1085 |
+
const previous = zoneSel.value;
|
| 1086 |
+
zoneSel.innerHTML = '<option value="">— From Excel —</option>' + uploadLocations.zones
|
| 1087 |
+
.map((z) => `<option value="${z.id}">${escapeHtml(z.name)}</option>`).join('');
|
| 1088 |
+
if (previous) zoneSel.value = previous;
|
| 1089 |
+
renderBulkVillageOptions();
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
async function loadBulkUploadConfig() {
|
| 1093 |
+
const el = document.getElementById('bulk-main-folder');
|
| 1094 |
+
try {
|
| 1095 |
+
const data = await ddaApi('GET', '/api/dda/tree/bulk-upload/config');
|
| 1096 |
+
if (el) el.textContent = data.mainFolder || '(not set)';
|
| 1097 |
+
} catch (_) {
|
| 1098 |
+
if (el) el.textContent = '(could not load Main folder)';
|
| 1099 |
+
}
|
| 1100 |
+
}
|
| 1101 |
+
|
| 1102 |
+
function renderBulkFileList() {
|
| 1103 |
+
const wrap = document.getElementById('bulk-upload-file-list-wrap');
|
| 1104 |
+
const tbody = document.getElementById('bulk-upload-file-tbody');
|
| 1105 |
+
const nameEl = document.getElementById('bulk-upload-files-name');
|
| 1106 |
+
if (!wrap || !tbody) return;
|
| 1107 |
+
const n = bulkPendingEntries.length;
|
| 1108 |
+
if (nameEl) {
|
| 1109 |
+
nameEl.textContent = n ? `${n} file${n === 1 ? '' : 's'} selected.` : 'No files selected.';
|
| 1110 |
+
}
|
| 1111 |
+
if (!n) {
|
| 1112 |
+
wrap.classList.add('hidden');
|
| 1113 |
+
tbody.innerHTML = '';
|
| 1114 |
+
return;
|
| 1115 |
+
}
|
| 1116 |
+
wrap.classList.remove('hidden');
|
| 1117 |
+
tbody.innerHTML = bulkPendingEntries.map((entry, idx) => `
|
| 1118 |
+
<tr data-idx="${idx}">
|
| 1119 |
+
<td class="dda-upload-orig">${escapeHtml(entry.file.name)}</td>
|
| 1120 |
+
<td><input type="text" class="upload-server-name bulk-server-name" value="${escapeHtml(entry.serverName)}" aria-label="Server filename" /></td>
|
| 1121 |
+
<td>${typeof formatBytes === 'function' ? formatBytes(entry.file.size) : entry.file.size}</td>
|
| 1122 |
+
</tr>`).join('');
|
| 1123 |
+
tbody.querySelectorAll('.bulk-server-name').forEach((input) => {
|
| 1124 |
+
input.addEventListener('change', () => {
|
| 1125 |
+
const idx = parseInt(input.closest('tr').dataset.idx, 10);
|
| 1126 |
+
if (bulkPendingEntries[idx]) {
|
| 1127 |
+
bulkPendingEntries[idx].serverName = (input.value || '').trim() || bulkPendingEntries[idx].file.name;
|
| 1128 |
+
}
|
| 1129 |
+
});
|
| 1130 |
+
});
|
| 1131 |
+
}
|
| 1132 |
+
|
| 1133 |
+
function setBulkPendingFiles(files) {
|
| 1134 |
+
const type = document.getElementById('bulk-image-type')?.value || '';
|
| 1135 |
+
const errEl = document.getElementById('bulk-upload-type-error');
|
| 1136 |
+
let list = Array.from(files || []);
|
| 1137 |
+
if (type && typeof splitFilesForImageType === 'function') {
|
| 1138 |
+
const { accepted, rejected } = splitFilesForImageType(list, type);
|
| 1139 |
+
list = accepted;
|
| 1140 |
+
if (errEl) {
|
| 1141 |
+
if (rejected.length) {
|
| 1142 |
+
errEl.textContent = `Skipped ${rejected.length} file(s) that do not match ${type}: ${rejected.map((f) => f.name).slice(0, 5).join(', ')}${rejected.length > 5 ? '…' : ''}`;
|
| 1143 |
+
errEl.classList.remove('hidden');
|
| 1144 |
+
} else {
|
| 1145 |
+
errEl.classList.add('hidden');
|
| 1146 |
+
errEl.textContent = '';
|
| 1147 |
+
}
|
| 1148 |
+
}
|
| 1149 |
+
} else if (errEl) {
|
| 1150 |
+
errEl.classList.add('hidden');
|
| 1151 |
+
}
|
| 1152 |
+
bulkPendingEntries = list.map((file) => ({
|
| 1153 |
+
file,
|
| 1154 |
+
serverName: file.name,
|
| 1155 |
+
}));
|
| 1156 |
+
renderBulkFileList();
|
| 1157 |
+
}
|
| 1158 |
+
|
| 1159 |
+
function openBulkUploadModal() {
|
| 1160 |
+
document.getElementById('bulk-upload-result')?.classList.add('hidden');
|
| 1161 |
+
document.getElementById('dda-bulk-upload-modal')?.classList.remove('hidden');
|
| 1162 |
+
populateBulkYearOptions();
|
| 1163 |
+
fillBulkZoneOptions();
|
| 1164 |
+
loadBulkUploadConfig();
|
| 1165 |
+
}
|
| 1166 |
+
function closeBulkUploadModal() {
|
| 1167 |
+
document.getElementById('dda-bulk-upload-modal')?.classList.add('hidden');
|
| 1168 |
+
}
|
| 1169 |
+
|
| 1170 |
+
document.getElementById('btn-open-bulk-upload')?.addEventListener('click', openBulkUploadModal);
|
| 1171 |
+
document.getElementById('dda-bulk-upload-close')?.addEventListener('click', closeBulkUploadModal);
|
| 1172 |
+
document.getElementById('dda-bulk-upload-modal')?.addEventListener('click', (e) => {
|
| 1173 |
+
if (e.target.id === 'dda-bulk-upload-modal') closeBulkUploadModal();
|
| 1174 |
+
});
|
| 1175 |
+
|
| 1176 |
+
document.getElementById('bulk-zone')?.addEventListener('change', renderBulkVillageOptions);
|
| 1177 |
+
document.getElementById('bulk-image-type')?.addEventListener('change', () => {
|
| 1178 |
+
const input = document.getElementById('bulk-upload-files');
|
| 1179 |
+
if (input?.files?.length) setBulkPendingFiles(input.files);
|
| 1180 |
+
});
|
| 1181 |
+
|
| 1182 |
+
document.getElementById('bulk-upload-excel')?.addEventListener('change', (e) => {
|
| 1183 |
+
const f = e.target.files?.[0];
|
| 1184 |
+
document.getElementById('bulk-upload-excel-name').textContent = f ? f.name : 'No file selected.';
|
| 1185 |
+
});
|
| 1186 |
+
document.getElementById('bulk-upload-files')?.addEventListener('change', (e) => {
|
| 1187 |
+
setBulkPendingFiles(e.target.files);
|
| 1188 |
+
});
|
| 1189 |
+
|
| 1190 |
+
function renderBulkUploadResult(data) {
|
| 1191 |
+
const wrap = document.getElementById('bulk-upload-result');
|
| 1192 |
+
const summaryEl = document.getElementById('bulk-upload-summary');
|
| 1193 |
+
const errorsEl = document.getElementById('bulk-upload-errors');
|
| 1194 |
+
if (!wrap || !summaryEl || !errorsEl) return;
|
| 1195 |
+
const s = data.summary || {};
|
| 1196 |
+
summaryEl.innerHTML = `
|
| 1197 |
+
<p><strong>${s.created || 0}</strong> created,
|
| 1198 |
+
<strong>${s.duplicates || 0}</strong> already existed (skipped),
|
| 1199 |
+
<strong>${s.errors || 0}</strong> error${(s.errors || 0) === 1 ? '' : 's'},
|
| 1200 |
+
<strong>${s.unmatchedFiles || 0}</strong> file${(s.unmatchedFiles || 0) === 1 ? '' : 's'} not listed in the sheet.</p>`;
|
| 1201 |
+
const rows = [
|
| 1202 |
+
...(data.errors || []).map((e) => ({ row: e.row, file: e.file, reason: e.reason })),
|
| 1203 |
+
...(data.unmatchedFiles || []).map((u) => ({ row: '—', file: u.file, reason: 'No matching row in the Excel sheet' })),
|
| 1204 |
+
];
|
| 1205 |
+
errorsEl.innerHTML = rows.length
|
| 1206 |
+
? `<table class="dda-logs-table"><thead><tr><th>Row</th><th>File</th><th>Reason</th></tr></thead><tbody>
|
| 1207 |
+
${rows.map((r) => `<tr><td>${escapeHtml(String(r.row))}</td><td>${escapeHtml(r.file || '')}</td><td>${escapeHtml(r.reason || '')}</td></tr>`).join('')}
|
| 1208 |
+
</tbody></table>`
|
| 1209 |
+
: '';
|
| 1210 |
+
wrap.classList.remove('hidden');
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
document.getElementById('btn-bulk-upload-submit')?.addEventListener('click', async () => {
|
| 1214 |
+
const excelInput = document.getElementById('bulk-upload-excel');
|
| 1215 |
+
const excelFile = excelInput?.files?.[0];
|
| 1216 |
+
const imageFiles = bulkPendingEntries.map((e) => e.file);
|
| 1217 |
+
if (!excelFile) { showDdaError?.('Choose the filled-in Excel sheet first.'); return; }
|
| 1218 |
+
if (!imageFiles.length) { showDdaError?.('Choose the image files listed in the sheet.'); return; }
|
| 1219 |
+
|
| 1220 |
+
const btn = document.getElementById('btn-bulk-upload-submit');
|
| 1221 |
+
const progWrap = document.getElementById('bulk-upload-progress');
|
| 1222 |
+
const progFill = document.getElementById('bulk-upload-progress-fill');
|
| 1223 |
+
const progLabel = document.getElementById('bulk-upload-progress-label');
|
| 1224 |
+
if (btn) btn.disabled = true;
|
| 1225 |
+
progWrap?.classList.remove('hidden');
|
| 1226 |
+
if (progLabel) progLabel.textContent = `Uploading ${imageFiles.length} file${imageFiles.length === 1 ? '' : 's'}…`;
|
| 1227 |
+
if (progFill) progFill.style.width = '0%';
|
| 1228 |
+
|
| 1229 |
+
const filenames = {};
|
| 1230 |
+
bulkPendingEntries.forEach((e) => {
|
| 1231 |
+
if (e.serverName && e.serverName !== e.file.name) {
|
| 1232 |
+
filenames[e.file.name] = e.serverName;
|
| 1233 |
+
} else if (e.serverName) {
|
| 1234 |
+
filenames[e.file.name] = e.serverName;
|
| 1235 |
+
}
|
| 1236 |
+
});
|
| 1237 |
+
|
| 1238 |
+
const form = new FormData();
|
| 1239 |
+
form.append('excel', excelFile);
|
| 1240 |
+
imageFiles.forEach((f) => form.append('files', f));
|
| 1241 |
+
const year = document.getElementById('bulk-year')?.value || '';
|
| 1242 |
+
const source = document.getElementById('bulk-source')?.value || '';
|
| 1243 |
+
const imageType = document.getElementById('bulk-image-type')?.value || '';
|
| 1244 |
+
const zoneId = document.getElementById('bulk-zone')?.value || '';
|
| 1245 |
+
const villageId = document.getElementById('bulk-village')?.value || '';
|
| 1246 |
+
if (year) form.append('year', year);
|
| 1247 |
+
if (source) form.append('source', source);
|
| 1248 |
+
if (imageType) form.append('image_type', imageType);
|
| 1249 |
+
if (zoneId) form.append('zone_id', zoneId);
|
| 1250 |
+
if (villageId) form.append('village_id', villageId);
|
| 1251 |
+
if (Object.keys(filenames).length) form.append('filenames', JSON.stringify(filenames));
|
| 1252 |
+
|
| 1253 |
+
try {
|
| 1254 |
+
const data = await uploadWithProgress('/api/dda/tree/bulk-upload', form, (loaded, total) => {
|
| 1255 |
+
if (progFill && total) progFill.style.width = `${Math.round((loaded / total) * 100)}%`;
|
| 1256 |
+
});
|
| 1257 |
+
if (progFill) progFill.style.width = '100%';
|
| 1258 |
+
renderBulkUploadResult(data);
|
| 1259 |
+
const created = data.summary?.created || 0;
|
| 1260 |
+
if (created) {
|
| 1261 |
+
showDdaSuccess?.(`Bulk upload complete — ${created} image${created === 1 ? '' : 's'} added.`);
|
| 1262 |
+
try {
|
| 1263 |
+
if (typeof loadTree === 'function') await loadTree();
|
| 1264 |
+
if (typeof loadLibraryImages === 'function') await loadLibraryImages({ resetPage: true });
|
| 1265 |
+
else if (typeof window.ddaState?.refreshImages === 'function') await window.ddaState.refreshImages();
|
| 1266 |
+
} catch (_) {
|
| 1267 |
+
/* bulk upload already succeeded — leave the success toast visible */
|
| 1268 |
+
}
|
| 1269 |
+
} else {
|
| 1270 |
+
showDdaError?.('No images were created — see the results below.');
|
| 1271 |
+
}
|
| 1272 |
+
} catch (err) {
|
| 1273 |
+
showDdaError?.(err.message || 'Bulk upload failed.');
|
| 1274 |
+
} finally {
|
| 1275 |
+
if (btn) btn.disabled = false;
|
| 1276 |
+
setTimeout(() => progWrap?.classList.add('hidden'), 1500);
|
| 1277 |
+
}
|
| 1278 |
+
});
|
static/js/dda/notifications.js
CHANGED
|
@@ -36,12 +36,23 @@ function formatNotifyTime(iso) {
|
|
| 36 |
async function fetchNotifyJobs() {
|
| 37 |
try {
|
| 38 |
const data = await ddaApi('GET', '/api/dda/jobs?limit=25');
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
| 40 |
} catch (_) {
|
| 41 |
-
return [];
|
| 42 |
}
|
| 43 |
}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
function updateNotifyBadge(jobs) {
|
| 46 |
const badge = document.getElementById('dda-notify-badge');
|
| 47 |
if (!badge) return;
|
|
@@ -179,3 +190,4 @@ function initNotifications() {
|
|
| 179 |
document.addEventListener('DOMContentLoaded', initNotifications);
|
| 180 |
window.markDdaJobSeen = markJobSeen;
|
| 181 |
window.refreshDdaNotifications = refreshNotifications;
|
|
|
|
|
|
| 36 |
async function fetchNotifyJobs() {
|
| 37 |
try {
|
| 38 |
const data = await ddaApi('GET', '/api/dda/jobs?limit=25');
|
| 39 |
+
const jobs = data.jobs || [];
|
| 40 |
+
window.ddaNotifyJobs = jobs;
|
| 41 |
+
window._ddaNotifyJobsLoaded = true;
|
| 42 |
+
return jobs;
|
| 43 |
} catch (_) {
|
| 44 |
+
return window.ddaNotifyJobs || [];
|
| 45 |
}
|
| 46 |
}
|
| 47 |
|
| 48 |
+
/** Shared jobs list for Detect resume / home live banner — avoids a second fetch. */
|
| 49 |
+
async function getDdaNotifyJobs({ force } = {}) {
|
| 50 |
+
if (!force && window._ddaNotifyJobsLoaded) {
|
| 51 |
+
return window.ddaNotifyJobs || [];
|
| 52 |
+
}
|
| 53 |
+
return fetchNotifyJobs();
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
function updateNotifyBadge(jobs) {
|
| 57 |
const badge = document.getElementById('dda-notify-badge');
|
| 58 |
if (!badge) return;
|
|
|
|
| 190 |
document.addEventListener('DOMContentLoaded', initNotifications);
|
| 191 |
window.markDdaJobSeen = markJobSeen;
|
| 192 |
window.refreshDdaNotifications = refreshNotifications;
|
| 193 |
+
window.getDdaNotifyJobs = getDdaNotifyJobs;
|