Spaces:
Sleeping
Sleeping
Commit ·
214c544
1
Parent(s): 19f24a5
Implement unlimited-depth tree library architecture for dev DDA.
Browse filesReplace dual year-folder and zone/village models with dda_tree_nodes, image_library, and audit_logs. Adds tree CRUD APIs, node upload, Delhi seed migration, recursive TreeView UI, and wires compare/detect to tree paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
- DEPLOYMENT.md +24 -3
- app/dda/admin_routes.py +2 -2
- app/dda/bootstrap.py +30 -12
- app/dda/config.py +17 -3
- app/dda/job_runner.py +1 -1
- app/dda/jobs_routes.py +1 -1
- app/dda/library_routes.py +10 -31
- app/dda/local_routes.py +37 -127
- app/dda/tree/__init__.py +1 -0
- app/dda/tree/audit_service.py +28 -0
- app/dda/tree/image_service.py +160 -0
- app/dda/tree/migration.py +114 -0
- app/dda/tree/models.py +64 -0
- app/dda/tree/path_service.py +66 -0
- app/dda/tree/path_slugs.py +32 -0
- app/dda/tree/routes.py +172 -0
- app/dda/tree/tree_service.py +258 -0
- app/main.py +9 -2
- static/css/dda.css +29 -0
- static/js/dda/app.js +44 -77
- static/js/dda/compare.js +6 -6
- static/js/dda/library.js +17 -57
- static/js/dda/tree.js +211 -0
- templates/index_dda.html +73 -29
DEPLOYMENT.md
CHANGED
|
@@ -133,7 +133,8 @@ Set these in each Space’s **Settings → Repository secrets / Variables** if n
|
|
| 133 |
| Variable | Purpose |
|
| 134 |
|----------|---------|
|
| 135 |
| `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
|
| 136 |
-
| `
|
|
|
|
| 137 |
| `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **5120** = 5 GB on dev) |
|
| 138 |
| `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
|
| 139 |
| `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
|
|
@@ -148,13 +149,33 @@ Dev Space can omit `SECRET_KEY` (login is disabled on both Spaces).
|
|
| 148 |
|
| 149 |
---
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
## UAT checklist (satdetect-dev)
|
| 152 |
|
| 153 |
Run before promoting any DDA feature to production:
|
| 154 |
|
| 155 |
1. **Health** — `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` ≥ 0
|
| 156 |
-
2. **
|
| 157 |
-
3. **Compare** — Select T1/T2; Run Detection completes
|
| 158 |
4. **Viewer** — Slider / T1 / T2 / Overlay modes; click region to locate
|
| 159 |
5. **Review** — Confirm and False Positive; Export confirmed CSV; Submit confirmed
|
| 160 |
6. **Reports** — PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
|
|
|
|
| 133 |
| Variable | Purpose |
|
| 134 |
|----------|---------|
|
| 135 |
| `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
|
| 136 |
+
| `STORAGE_ROOT` | Tree library root directory (default: `data/library_sources/`) |
|
| 137 |
+
| `LOCAL_LIBRARY_ROOT` | Alias for storage root override |
|
| 138 |
| `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **5120** = 5 GB on dev) |
|
| 139 |
| `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
|
| 140 |
| `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
|
|
|
|
| 149 |
|
| 150 |
---
|
| 151 |
|
| 152 |
+
## Tree library layout (unlimited depth)
|
| 153 |
+
|
| 154 |
+
Images are organized in a **configurable tree** (zone → area → year → image type, or any depth):
|
| 155 |
+
|
| 156 |
+
```text
|
| 157 |
+
library_sources/
|
| 158 |
+
central_delhi/
|
| 159 |
+
karol_bagh/
|
| 160 |
+
2025/
|
| 161 |
+
Images/
|
| 162 |
+
satellite.tif
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
- **API:** `GET /api/dda/tree`, `POST /api/dda/tree/nodes`, upload via `POST /api/dda/tree/nodes/{id}/images/upload`
|
| 166 |
+
- **UI:** Recursive tree sidebar, **Manage** (admin) for create/rename/move/delete
|
| 167 |
+
- **Storage:** Slug-based disk paths; display names in `node_path`
|
| 168 |
+
- **Legacy:** Flat `library_sources/YEAR/` files auto-migrate to `Unassigned/Legacy/{year}/Images/` on startup
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
## UAT checklist (satdetect-dev)
|
| 173 |
|
| 174 |
Run before promoting any DDA feature to production:
|
| 175 |
|
| 176 |
1. **Health** — `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` ≥ 0
|
| 177 |
+
2. **Tree library** — Create zone → area → year nodes (admin); upload GeoTIFF to node; tree + grid show breadcrumb
|
| 178 |
+
3. **Compare** — Select T1/T2 from tree library; Run Detection completes
|
| 179 |
4. **Viewer** — Slider / T1 / T2 / Overlay modes; click region to locate
|
| 180 |
5. **Review** — Confirm and False Positive; Export confirmed CSV; Submit confirmed
|
| 181 |
6. **Reports** — PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
|
app/dda/admin_routes.py
CHANGED
|
@@ -12,7 +12,7 @@ from ..database import DATA_DIR, get_db
|
|
| 12 |
from ..models import DetectionRun, User
|
| 13 |
from .dda_auth import current_dda_user, get_user_role, require_min_role
|
| 14 |
from .job_runner import is_job_runner_busy, reconcile_stale_jobs
|
| 15 |
-
from .
|
| 16 |
from .models import DetectionJob, RegionReview
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
|
@@ -43,7 +43,7 @@ def admin_status(
|
|
| 43 |
disk = shutil.disk_usage(DATA_DIR)
|
| 44 |
return {
|
| 45 |
"role": get_user_role(db, user),
|
| 46 |
-
"libraryImages": len(
|
| 47 |
"detectionRuns": runs,
|
| 48 |
"jobsQueued": jobs_queued,
|
| 49 |
"jobsRunning": jobs_running,
|
|
|
|
| 12 |
from ..models import DetectionRun, User
|
| 13 |
from .dda_auth import current_dda_user, get_user_role, require_min_role
|
| 14 |
from .job_runner import is_job_runner_busy, reconcile_stale_jobs
|
| 15 |
+
from .tree.image_service import list_all_images
|
| 16 |
from .models import DetectionJob, RegionReview
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
|
|
|
| 43 |
disk = shutil.disk_usage(DATA_DIR)
|
| 44 |
return {
|
| 45 |
"role": get_user_role(db, user),
|
| 46 |
+
"libraryImages": len(list_all_images(db)),
|
| 47 |
"detectionRuns": runs,
|
| 48 |
"jobsQueued": jobs_queued,
|
| 49 |
"jobsRunning": jobs_running,
|
app/dda/bootstrap.py
CHANGED
|
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|
| 4 |
from sqlalchemy import text as sa_text
|
| 5 |
|
| 6 |
from ..database import engine
|
| 7 |
-
from .config import IS_DDA_MODE, ensure_library_dirs,
|
| 8 |
from .admin_routes import router as admin_router
|
| 9 |
from .jobs_routes import router as jobs_router
|
| 10 |
from .library_routes import router as library_router
|
|
@@ -12,7 +12,7 @@ from .local_routes import router as local_router
|
|
| 12 |
from .reports_routes import router as reports_router
|
| 13 |
from .review_routes import router as review_router
|
| 14 |
from .training_routes import router as training_router
|
| 15 |
-
from .
|
| 16 |
from .dda_auth import seed_dda_admin
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
|
@@ -23,7 +23,7 @@ def init_dda_database():
|
|
| 23 |
if not IS_DDA_MODE:
|
| 24 |
return
|
| 25 |
ensure_library_dirs()
|
| 26 |
-
|
| 27 |
try:
|
| 28 |
with engine.connect() as conn:
|
| 29 |
for stmt in (
|
|
@@ -46,33 +46,51 @@ def init_dda_database():
|
|
| 46 |
except Exception as exc:
|
| 47 |
logger.warning("DDA schema migration skipped: %s", exc)
|
| 48 |
|
| 49 |
-
from ..database import SessionLocal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
db = SessionLocal()
|
| 51 |
try:
|
| 52 |
-
seed_delhi_hierarchy(db)
|
| 53 |
seed_dda_admin(db)
|
|
|
|
|
|
|
|
|
|
| 54 |
from .job_runner import reconcile_stale_jobs
|
| 55 |
reconcile_stale_jobs(db)
|
| 56 |
finally:
|
| 57 |
db.close()
|
| 58 |
|
| 59 |
try:
|
| 60 |
-
from .
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
logger.info(
|
| 63 |
-
"DDA library ready (hosted=%s): %d images,
|
| 64 |
is_hf_hosted(),
|
| 65 |
-
|
| 66 |
-
|
| 67 |
)
|
| 68 |
except Exception as exc:
|
| 69 |
-
logger.warning("
|
| 70 |
|
| 71 |
|
| 72 |
def setup_dda(app: FastAPI) -> None:
|
| 73 |
if not IS_DDA_MODE:
|
| 74 |
logger.info("APP_MODE=legacy — DDA routes disabled")
|
| 75 |
return
|
|
|
|
| 76 |
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
| 77 |
app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
|
| 78 |
app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
|
|
@@ -80,4 +98,4 @@ def setup_dda(app: FastAPI) -> None:
|
|
| 80 |
app.include_router(training_router, prefix="/api/dda", tags=["dda-training"])
|
| 81 |
app.include_router(admin_router, prefix="/api/dda", tags=["dda-admin"])
|
| 82 |
app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
|
| 83 |
-
logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, reports, review, training, admin, local)")
|
|
|
|
| 4 |
from sqlalchemy import text as sa_text
|
| 5 |
|
| 6 |
from ..database import engine
|
| 7 |
+
from .config import IS_DDA_MODE, ensure_library_dirs, get_storage_root, is_hf_hosted
|
| 8 |
from .admin_routes import router as admin_router
|
| 9 |
from .jobs_routes import router as jobs_router
|
| 10 |
from .library_routes import router as library_router
|
|
|
|
| 12 |
from .reports_routes import router as reports_router
|
| 13 |
from .review_routes import router as review_router
|
| 14 |
from .training_routes import router as training_router
|
| 15 |
+
from .tree.routes import router as tree_router
|
| 16 |
from .dda_auth import seed_dda_admin
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
|
|
|
| 23 |
if not IS_DDA_MODE:
|
| 24 |
return
|
| 25 |
ensure_library_dirs()
|
| 26 |
+
get_storage_root().mkdir(parents=True, exist_ok=True)
|
| 27 |
try:
|
| 28 |
with engine.connect() as conn:
|
| 29 |
for stmt in (
|
|
|
|
| 46 |
except Exception as exc:
|
| 47 |
logger.warning("DDA schema migration skipped: %s", exc)
|
| 48 |
|
| 49 |
+
from ..database import Base, SessionLocal
|
| 50 |
+
from .tree.models import AuditLog, ImageLibrary, TreeNode # noqa: F401
|
| 51 |
+
try:
|
| 52 |
+
Base.metadata.create_all(bind=engine, tables=[
|
| 53 |
+
TreeNode.__table__,
|
| 54 |
+
ImageLibrary.__table__,
|
| 55 |
+
AuditLog.__table__,
|
| 56 |
+
])
|
| 57 |
+
except Exception as exc:
|
| 58 |
+
logger.warning("Tree table create skipped: %s", exc)
|
| 59 |
+
|
| 60 |
db = SessionLocal()
|
| 61 |
try:
|
|
|
|
| 62 |
seed_dda_admin(db)
|
| 63 |
+
from .tree.migration import run_tree_migration
|
| 64 |
+
mig = run_tree_migration(db)
|
| 65 |
+
logger.info("Tree migration: %s", mig)
|
| 66 |
from .job_runner import reconcile_stale_jobs
|
| 67 |
reconcile_stale_jobs(db)
|
| 68 |
finally:
|
| 69 |
db.close()
|
| 70 |
|
| 71 |
try:
|
| 72 |
+
from .tree.image_service import list_all_images
|
| 73 |
+
from ..database import SessionLocal as SL
|
| 74 |
+
sdb = SL()
|
| 75 |
+
try:
|
| 76 |
+
total = len(list_all_images(sdb))
|
| 77 |
+
finally:
|
| 78 |
+
sdb.close()
|
| 79 |
logger.info(
|
| 80 |
+
"DDA tree library ready (hosted=%s): %d images, storage=%s",
|
| 81 |
is_hf_hosted(),
|
| 82 |
+
total,
|
| 83 |
+
get_storage_root(),
|
| 84 |
)
|
| 85 |
except Exception as exc:
|
| 86 |
+
logger.warning("Tree library scan at startup failed: %s", exc)
|
| 87 |
|
| 88 |
|
| 89 |
def setup_dda(app: FastAPI) -> None:
|
| 90 |
if not IS_DDA_MODE:
|
| 91 |
logger.info("APP_MODE=legacy — DDA routes disabled")
|
| 92 |
return
|
| 93 |
+
app.include_router(tree_router, prefix="/api/dda", tags=["dda-tree"])
|
| 94 |
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
| 95 |
app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
|
| 96 |
app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
|
|
|
|
| 98 |
app.include_router(training_router, prefix="/api/dda", tags=["dda-training"])
|
| 99 |
app.include_router(admin_router, prefix="/api/dda", tags=["dda-admin"])
|
| 100 |
app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
|
| 101 |
+
logger.info("APP_MODE=dda — DDA routes enabled (tree, library, jobs, reports, review, training, admin, local)")
|
app/dda/config.py
CHANGED
|
@@ -44,12 +44,26 @@ def get_writable_library_root() -> Path:
|
|
| 44 |
return (DATA_DIR / "library_sources").resolve()
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def get_library_roots() -> List[Path]:
|
| 48 |
-
"""Folders scanned for
|
| 49 |
roots: List[Path] = []
|
|
|
|
|
|
|
|
|
|
| 50 |
if os.environ.get("LOCAL_LIBRARY_ROOT"):
|
| 51 |
-
|
| 52 |
-
|
|
|
|
| 53 |
if is_hf_hosted():
|
| 54 |
wr = get_writable_library_root()
|
| 55 |
if wr not in roots:
|
|
|
|
| 44 |
return (DATA_DIR / "library_sources").resolve()
|
| 45 |
|
| 46 |
|
| 47 |
+
def get_storage_root() -> Path:
|
| 48 |
+
"""Tree library root (doc: root_directory / STORAGE_ROOT)."""
|
| 49 |
+
explicit = os.environ.get("STORAGE_ROOT", "").strip()
|
| 50 |
+
if explicit:
|
| 51 |
+
return Path(explicit).resolve()
|
| 52 |
+
if os.environ.get("LOCAL_LIBRARY_ROOT"):
|
| 53 |
+
return Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve()
|
| 54 |
+
return get_writable_library_root()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
def get_library_roots() -> List[Path]:
|
| 58 |
+
"""Folders scanned for library images (tree storage root)."""
|
| 59 |
roots: List[Path] = []
|
| 60 |
+
sr = get_storage_root()
|
| 61 |
+
if sr not in roots:
|
| 62 |
+
roots.append(sr)
|
| 63 |
if os.environ.get("LOCAL_LIBRARY_ROOT"):
|
| 64 |
+
p = Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve()
|
| 65 |
+
if p not in roots:
|
| 66 |
+
roots.append(p)
|
| 67 |
if is_hf_hosted():
|
| 68 |
wr = get_writable_library_root()
|
| 69 |
if wr not in roots:
|
app/dda/job_runner.py
CHANGED
|
@@ -17,7 +17,7 @@ from ..models import DetectionRun
|
|
| 17 |
from .config import get_detection_max_side
|
| 18 |
from .detect_service import run_detection_and_save
|
| 19 |
from .geotiff_io import load_rgb_pil
|
| 20 |
-
from .
|
| 21 |
from .models import DetectionJob
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
|
|
|
| 17 |
from .config import get_detection_max_side
|
| 18 |
from .detect_service import run_detection_and_save
|
| 19 |
from .geotiff_io import load_rgb_pil
|
| 20 |
+
from .local_routes import safe_resolve
|
| 21 |
from .models import DetectionJob
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
app/dda/jobs_routes.py
CHANGED
|
@@ -17,7 +17,7 @@ from .job_runner import (
|
|
| 17 |
is_job_runner_busy,
|
| 18 |
job_to_dict,
|
| 19 |
)
|
| 20 |
-
from .
|
| 21 |
from .models import DetectionJob
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
|
|
|
| 17 |
is_job_runner_busy,
|
| 18 |
job_to_dict,
|
| 19 |
)
|
| 20 |
+
from .local_routes import safe_resolve
|
| 21 |
from .models import DetectionJob
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
app/dda/library_routes.py
CHANGED
|
@@ -65,7 +65,7 @@ def _image_to_dict(asset: ImageAsset, zone_name: str = "", village_name: str = "
|
|
| 65 |
@router.get("/config")
|
| 66 |
def dda_config():
|
| 67 |
_require_dda()
|
| 68 |
-
from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, get_library_roots
|
| 69 |
max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
|
| 70 |
return {
|
| 71 |
"mode": "dda",
|
|
@@ -77,8 +77,9 @@ def dda_config():
|
|
| 77 |
"maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
|
| 78 |
"geotiffEnabled": geotiff_io_available(),
|
| 79 |
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 80 |
-
"hierarchyMode": "
|
| 81 |
-
"librarySource": "
|
|
|
|
| 82 |
"localLibraryPaths": [str(r) for r in get_library_roots()],
|
| 83 |
}
|
| 84 |
|
|
@@ -86,34 +87,8 @@ def dda_config():
|
|
| 86 |
@router.get("/hierarchy")
|
| 87 |
def get_hierarchy(db: Session = Depends(get_db)):
|
| 88 |
_require_dda()
|
| 89 |
-
|
| 90 |
-
tree
|
| 91 |
-
for zone in zones:
|
| 92 |
-
villages = (
|
| 93 |
-
db.query(DdaVillage)
|
| 94 |
-
.filter(DdaVillage.zone_id == zone.id)
|
| 95 |
-
.order_by(DdaVillage.name)
|
| 96 |
-
.all()
|
| 97 |
-
)
|
| 98 |
-
image_counts = {}
|
| 99 |
-
for v in villages:
|
| 100 |
-
cnt = db.query(ImageAsset).filter(ImageAsset.village_id == v.id).count()
|
| 101 |
-
if cnt:
|
| 102 |
-
image_counts[v.id] = cnt
|
| 103 |
-
tree.append({
|
| 104 |
-
"id": zone.id,
|
| 105 |
-
"name": zone.name,
|
| 106 |
-
"mode": zone.mode,
|
| 107 |
-
"villages": [
|
| 108 |
-
{
|
| 109 |
-
"id": v.id,
|
| 110 |
-
"name": v.name,
|
| 111 |
-
"imageCount": image_counts.get(v.id, 0),
|
| 112 |
-
}
|
| 113 |
-
for v in villages
|
| 114 |
-
],
|
| 115 |
-
})
|
| 116 |
-
return {"zones": tree}
|
| 117 |
|
| 118 |
|
| 119 |
@router.get("/images")
|
|
@@ -195,6 +170,10 @@ async def upload_image(
|
|
| 195 |
db: Session = Depends(get_db),
|
| 196 |
):
|
| 197 |
_require_dda()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
ensure_library_dirs()
|
| 199 |
user = get_or_create_guest_user(db)
|
| 200 |
|
|
|
|
| 65 |
@router.get("/config")
|
| 66 |
def dda_config():
|
| 67 |
_require_dda()
|
| 68 |
+
from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, get_library_roots, get_storage_root
|
| 69 |
max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
|
| 70 |
return {
|
| 71 |
"mode": "dda",
|
|
|
|
| 77 |
"maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
|
| 78 |
"geotiffEnabled": geotiff_io_available(),
|
| 79 |
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 80 |
+
"hierarchyMode": "tree",
|
| 81 |
+
"librarySource": "tree_library",
|
| 82 |
+
"storageRoot": str(get_storage_root()),
|
| 83 |
"localLibraryPaths": [str(r) for r in get_library_roots()],
|
| 84 |
}
|
| 85 |
|
|
|
|
| 87 |
@router.get("/hierarchy")
|
| 88 |
def get_hierarchy(db: Session = Depends(get_db)):
|
| 89 |
_require_dda()
|
| 90 |
+
from .tree.tree_service import build_tree
|
| 91 |
+
return {"tree": build_tree(db)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
@router.get("/images")
|
|
|
|
| 170 |
db: Session = Depends(get_db),
|
| 171 |
):
|
| 172 |
_require_dda()
|
| 173 |
+
raise HTTPException(
|
| 174 |
+
status_code=410,
|
| 175 |
+
detail="Deprecated: use POST /api/dda/tree/nodes/{node_id}/images/upload",
|
| 176 |
+
)
|
| 177 |
ensure_library_dirs()
|
| 178 |
user = get_or_create_guest_user(db)
|
| 179 |
|
app/dda/local_routes.py
CHANGED
|
@@ -1,16 +1,16 @@
|
|
| 1 |
-
"""
|
| 2 |
import logging
|
| 3 |
from pathlib import Path
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, Depends,
|
| 7 |
from fastapi.responses import FileResponse
|
| 8 |
from PIL import Image
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from ..database import get_db
|
| 12 |
from ..models import User
|
| 13 |
-
from .dda_auth import current_dda_user
|
| 14 |
from .detect_service import run_detection_and_save
|
| 15 |
from .geotiff_io import load_rgb_pil
|
| 16 |
|
|
@@ -20,19 +20,12 @@ from .config import (
|
|
| 20 |
geotiff_io_available,
|
| 21 |
get_detection_max_side,
|
| 22 |
get_library_roots,
|
| 23 |
-
|
| 24 |
is_hf_hosted,
|
| 25 |
-
max_upload_bytes_for_extension,
|
| 26 |
)
|
| 27 |
-
from .
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
library_debug_info,
|
| 31 |
-
safe_resolve,
|
| 32 |
-
scan_images,
|
| 33 |
-
scan_years,
|
| 34 |
-
)
|
| 35 |
-
from .upload_io import stream_upload_to_file
|
| 36 |
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
router = APIRouter()
|
|
@@ -46,37 +39,35 @@ def _require_dda():
|
|
| 46 |
)
|
| 47 |
|
| 48 |
|
| 49 |
-
def
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
@router.get("/local/config")
|
| 57 |
def local_library_config():
|
| 58 |
_require_dda()
|
| 59 |
-
|
| 60 |
-
writable = str(get_writable_library_root())
|
| 61 |
hosted = is_hf_hosted()
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
instructions = (
|
| 69 |
-
"Copy .tif images into library_sources/YEAR/ in your project folder, then Refresh. "
|
| 70 |
-
"Or use Upload to save into data/library_sources/."
|
| 71 |
-
)
|
| 72 |
return {
|
| 73 |
-
"source": "
|
| 74 |
"isHosted": hosted,
|
| 75 |
"spaceId": __import__("os").environ.get("SPACE_ID", ""),
|
| 76 |
"appMode": "dda" if IS_DDA_MODE else "legacy",
|
| 77 |
-
"rootPath":
|
| 78 |
-
"rootPaths":
|
| 79 |
-
"writablePath":
|
|
|
|
| 80 |
"instructions": instructions,
|
| 81 |
"geotiffEnabled": geotiff_io_available(),
|
| 82 |
"detectionMaxSide": get_detection_max_side(),
|
|
@@ -86,40 +77,14 @@ def local_library_config():
|
|
| 86 |
}
|
| 87 |
|
| 88 |
|
| 89 |
-
@router.get("/local/debug")
|
| 90 |
-
def local_debug():
|
| 91 |
-
_require_dda()
|
| 92 |
-
return library_debug_info()
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
@router.get("/local/years")
|
| 96 |
-
def local_years():
|
| 97 |
-
_require_dda()
|
| 98 |
-
return {"years": scan_years(), "rootPaths": [str(r) for r in get_library_roots()]}
|
| 99 |
-
|
| 100 |
-
|
| 101 |
@router.get("/local/images")
|
| 102 |
def local_images(
|
| 103 |
-
|
| 104 |
q: Optional[str] = Query(None),
|
|
|
|
| 105 |
):
|
| 106 |
_require_dda()
|
| 107 |
-
|
| 108 |
-
return [entry_to_dict(e) for e in entries]
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
@router.get("/local/images/detail")
|
| 112 |
-
def local_image_detail(path: str = Query(..., description="Relative path e.g. 2025/aerial.tif")):
|
| 113 |
-
_require_dda()
|
| 114 |
-
entries = scan_images()
|
| 115 |
-
norm = path.replace("\\", "/")
|
| 116 |
-
match = next((e for e in entries if e.path == norm), None)
|
| 117 |
-
if not match:
|
| 118 |
-
safe_resolve(path)
|
| 119 |
-
match = next((e for e in scan_images() if e.path == norm), None)
|
| 120 |
-
if not match:
|
| 121 |
-
raise HTTPException(status_code=404, detail="Image not found in library scan")
|
| 122 |
-
return entry_to_dict(match, include_meta=True)
|
| 123 |
|
| 124 |
|
| 125 |
@router.get("/local/thumb")
|
|
@@ -128,8 +93,6 @@ def local_thumb(path: str = Query(...)):
|
|
| 128 |
try:
|
| 129 |
thumb = get_or_build_thumb(path)
|
| 130 |
return FileResponse(thumb, media_type="image/png")
|
| 131 |
-
except HTTPException:
|
| 132 |
-
raise
|
| 133 |
except Exception as exc:
|
| 134 |
logger.warning("Thumb endpoint fallback for %s: %s", path, exc)
|
| 135 |
from .config import LOCAL_THUMB_CACHE
|
|
@@ -140,66 +103,16 @@ def local_thumb(path: str = Query(...)):
|
|
| 140 |
return FileResponse(cache, media_type="image/png")
|
| 141 |
|
| 142 |
|
| 143 |
-
@router.post("/local/upload")
|
| 144 |
-
async def local_upload(
|
| 145 |
-
request: Request,
|
| 146 |
-
file: UploadFile = File(...),
|
| 147 |
-
year: int = Form(...),
|
| 148 |
-
db: Session = Depends(get_db),
|
| 149 |
-
user: User = Depends(current_dda_user),
|
| 150 |
-
):
|
| 151 |
-
"""Upload GeoTIFF into persistent library_sources/YEAR/ (required on HF)."""
|
| 152 |
-
_require_dda()
|
| 153 |
-
require_min_role(user, db, "uploader")
|
| 154 |
-
if year < 1990 or year > 2100:
|
| 155 |
-
raise HTTPException(status_code=400, detail="year must be between 1990 and 2100")
|
| 156 |
-
|
| 157 |
-
original = _safe_basename(file.filename or "upload")
|
| 158 |
-
ext = Path(original).suffix.lower()
|
| 159 |
-
from .config import ALLOWED_EXTENSIONS
|
| 160 |
-
if ext not in ALLOWED_EXTENSIONS:
|
| 161 |
-
raise HTTPException(status_code=400, detail=f"Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
|
| 162 |
-
|
| 163 |
-
root = get_writable_library_root()
|
| 164 |
-
dest = root / str(year) / original
|
| 165 |
-
if dest.exists():
|
| 166 |
-
stem = Path(original).stem
|
| 167 |
-
suffix = Path(original).suffix
|
| 168 |
-
n = 1
|
| 169 |
-
while dest.exists():
|
| 170 |
-
dest = root / str(year) / f"{stem}_{n}{suffix}"
|
| 171 |
-
n += 1
|
| 172 |
-
|
| 173 |
-
size = await stream_upload_to_file(file, dest, max_upload_bytes_for_extension(ext))
|
| 174 |
-
rel = dest.relative_to(root).as_posix()
|
| 175 |
-
logger.info("Library upload: %s (%d bytes) -> %s", original, size, dest)
|
| 176 |
-
|
| 177 |
-
entries = scan_images(year=year)
|
| 178 |
-
match = next((e for e in entries if e.path == rel or e.filename == dest.name), None)
|
| 179 |
-
if match:
|
| 180 |
-
return {"status": "success", "path": match.path, "image": entry_to_dict(match)}
|
| 181 |
-
return {
|
| 182 |
-
"status": "success",
|
| 183 |
-
"path": f"{year}/{dest.name}",
|
| 184 |
-
"fileSizeBytes": size,
|
| 185 |
-
"writablePath": str(dest),
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
|
| 189 |
@router.post("/local/rescan")
|
| 190 |
-
def local_rescan():
|
| 191 |
_require_dda()
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
info = library_debug_info()
|
| 195 |
-
logger.info("Library rescan: %d images, roots=%s", total, info.get("roots"))
|
| 196 |
return {
|
| 197 |
"ok": True,
|
| 198 |
-
"
|
| 199 |
-
"totalImages":
|
| 200 |
-
"
|
| 201 |
-
"writablePath": str(get_writable_library_root()),
|
| 202 |
-
"debug": info,
|
| 203 |
}
|
| 204 |
|
| 205 |
|
|
@@ -220,7 +133,7 @@ async def detect_from_library(
|
|
| 220 |
db: Session = Depends(get_db),
|
| 221 |
user: User = Depends(current_dda_user),
|
| 222 |
):
|
| 223 |
-
"""Run change detection on two library images by relative path
|
| 224 |
_require_dda()
|
| 225 |
base_norm = base_path.replace("\\", "/").strip()
|
| 226 |
comp_norm = comparison_path.replace("\\", "/").strip()
|
|
@@ -245,12 +158,9 @@ async def detect_from_library(
|
|
| 245 |
except Exception as exc:
|
| 246 |
raise HTTPException(status_code=400, detail=f"Could not load images: {exc}") from exc
|
| 247 |
|
| 248 |
-
# Match dimensions so registration and overlay align with the before image
|
| 249 |
if before_pil.size != after_pil.size:
|
| 250 |
after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
|
| 251 |
|
| 252 |
-
max_side = get_detection_max_side()
|
| 253 |
-
|
| 254 |
if title == "Untitled run":
|
| 255 |
title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
|
| 256 |
|
|
@@ -268,7 +178,7 @@ async def detect_from_library(
|
|
| 268 |
detection_sensitivity=detection_sensitivity,
|
| 269 |
min_region_area=min_region_area,
|
| 270 |
notify_email=notify_email,
|
| 271 |
-
max_size=
|
| 272 |
geo_bounds_path=base_file,
|
| 273 |
user_id=user.id,
|
| 274 |
)
|
|
|
|
| 1 |
+
"""Slim local helpers: thumb, resolve, detect — backed by tree library."""
|
| 2 |
import logging
|
| 3 |
from pathlib import Path
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
|
| 7 |
from fastapi.responses import FileResponse
|
| 8 |
from PIL import Image
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from ..database import get_db
|
| 12 |
from ..models import User
|
| 13 |
+
from .dda_auth import current_dda_user
|
| 14 |
from .detect_service import run_detection_and_save
|
| 15 |
from .geotiff_io import load_rgb_pil
|
| 16 |
|
|
|
|
| 20 |
geotiff_io_available,
|
| 21 |
get_detection_max_side,
|
| 22 |
get_library_roots,
|
| 23 |
+
get_storage_root,
|
| 24 |
is_hf_hosted,
|
|
|
|
| 25 |
)
|
| 26 |
+
from .tree.image_service import get_or_build_thumb, list_all_images
|
| 27 |
+
from .tree.path_service import resolve_file
|
| 28 |
+
from .tree.tree_service import build_tree
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
router = APIRouter()
|
|
|
|
| 39 |
)
|
| 40 |
|
| 41 |
|
| 42 |
+
def safe_resolve(relative_path: str) -> Path:
|
| 43 |
+
try:
|
| 44 |
+
return resolve_file(relative_path)
|
| 45 |
+
except FileNotFoundError:
|
| 46 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 47 |
+
except ValueError as exc:
|
| 48 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 49 |
|
| 50 |
|
| 51 |
@router.get("/local/config")
|
| 52 |
def local_library_config():
|
| 53 |
_require_dda()
|
| 54 |
+
storage = str(get_storage_root())
|
|
|
|
| 55 |
hosted = is_hf_hosted()
|
| 56 |
+
instructions = (
|
| 57 |
+
"Select a node in the tree, choose image type, and upload. "
|
| 58 |
+
"Files are stored under {zone}/{area}/…/Images/ in persistent storage."
|
| 59 |
+
if hosted
|
| 60 |
+
else "Use the tree library to organize images by zone/area/year, or upload via the form."
|
| 61 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
return {
|
| 63 |
+
"source": "tree_library",
|
| 64 |
"isHosted": hosted,
|
| 65 |
"spaceId": __import__("os").environ.get("SPACE_ID", ""),
|
| 66 |
"appMode": "dda" if IS_DDA_MODE else "legacy",
|
| 67 |
+
"rootPath": storage,
|
| 68 |
+
"rootPaths": [str(r) for r in get_library_roots()],
|
| 69 |
+
"writablePath": storage,
|
| 70 |
+
"storageRoot": storage,
|
| 71 |
"instructions": instructions,
|
| 72 |
"geotiffEnabled": geotiff_io_available(),
|
| 73 |
"detectionMaxSide": get_detection_max_side(),
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
@router.get("/local/images")
|
| 81 |
def local_images(
|
| 82 |
+
node_id: Optional[int] = Query(None),
|
| 83 |
q: Optional[str] = Query(None),
|
| 84 |
+
db: Session = Depends(get_db),
|
| 85 |
):
|
| 86 |
_require_dda()
|
| 87 |
+
return list_all_images(db, node_id=node_id, query=q)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
@router.get("/local/thumb")
|
|
|
|
| 93 |
try:
|
| 94 |
thumb = get_or_build_thumb(path)
|
| 95 |
return FileResponse(thumb, media_type="image/png")
|
|
|
|
|
|
|
| 96 |
except Exception as exc:
|
| 97 |
logger.warning("Thumb endpoint fallback for %s: %s", path, exc)
|
| 98 |
from .config import LOCAL_THUMB_CACHE
|
|
|
|
| 103 |
return FileResponse(cache, media_type="image/png")
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
@router.post("/local/rescan")
|
| 107 |
+
def local_rescan(db: Session = Depends(get_db)):
|
| 108 |
_require_dda()
|
| 109 |
+
tree = build_tree(db)
|
| 110 |
+
images = list_all_images(db)
|
|
|
|
|
|
|
| 111 |
return {
|
| 112 |
"ok": True,
|
| 113 |
+
"tree": tree,
|
| 114 |
+
"totalImages": len(images),
|
| 115 |
+
"storageRoot": str(get_storage_root()),
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
|
|
|
|
| 133 |
db: Session = Depends(get_db),
|
| 134 |
user: User = Depends(current_dda_user),
|
| 135 |
):
|
| 136 |
+
"""Run change detection on two library images by relative path."""
|
| 137 |
_require_dda()
|
| 138 |
base_norm = base_path.replace("\\", "/").strip()
|
| 139 |
comp_norm = comparison_path.replace("\\", "/").strip()
|
|
|
|
| 158 |
except Exception as exc:
|
| 159 |
raise HTTPException(status_code=400, detail=f"Could not load images: {exc}") from exc
|
| 160 |
|
|
|
|
| 161 |
if before_pil.size != after_pil.size:
|
| 162 |
after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
|
| 163 |
|
|
|
|
|
|
|
| 164 |
if title == "Untitled run":
|
| 165 |
title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
|
| 166 |
|
|
|
|
| 178 |
detection_sensitivity=detection_sensitivity,
|
| 179 |
min_region_area=min_region_area,
|
| 180 |
notify_email=notify_email,
|
| 181 |
+
max_size=get_detection_max_side(),
|
| 182 |
geo_bounds_path=base_file,
|
| 183 |
user_id=user.id,
|
| 184 |
)
|
app/dda/tree/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Unlimited-depth tree library for DDA (dev)."""
|
app/dda/tree/audit_service.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audit logging for tree mutations."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
from typing import Any, Optional
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from .models import AuditLog
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def log_action(
|
| 13 |
+
db: Session,
|
| 14 |
+
action: str,
|
| 15 |
+
*,
|
| 16 |
+
node_id: Optional[int] = None,
|
| 17 |
+
old_value: Any = None,
|
| 18 |
+
new_value: Any = None,
|
| 19 |
+
action_by: str = "",
|
| 20 |
+
) -> None:
|
| 21 |
+
entry = AuditLog(
|
| 22 |
+
action=action,
|
| 23 |
+
node_id=node_id,
|
| 24 |
+
old_value=json.dumps(old_value, default=str) if old_value is not None else "",
|
| 25 |
+
new_value=json.dumps(new_value, default=str) if new_value is not None else "",
|
| 26 |
+
action_by=action_by or "",
|
| 27 |
+
)
|
| 28 |
+
db.add(entry)
|
app/dda/tree/image_service.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image upload and listing for tree nodes."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import hashlib
|
| 5 |
+
import logging
|
| 6 |
+
from datetime import date, datetime
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
from urllib.parse import quote
|
| 10 |
+
|
| 11 |
+
from fastapi import HTTPException, UploadFile
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from ..config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, max_upload_bytes_for_extension
|
| 15 |
+
from ..geotiff_io import bounds_to_json, inspect_image, raster_to_preview_png, write_placeholder_png
|
| 16 |
+
from ..upload_io import stream_upload_to_file
|
| 17 |
+
from .audit_service import log_action
|
| 18 |
+
from .models import ImageLibrary, TreeNode
|
| 19 |
+
from .path_service import images_dir, resolve_file, storage_root
|
| 20 |
+
from .tree_service import get_node_or_404
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
IMAGE_TYPES = {"Satellite", "Drone", "Orthomosaic", "DEM", "GeoTIFF", "Raster", "PNG", "JPEG"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _safe_basename(filename: str) -> str:
|
| 28 |
+
name = Path(filename or "upload").name
|
| 29 |
+
if not name or name in (".", ".."):
|
| 30 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 31 |
+
return name
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _thumb_cache_path(relative_path: str) -> Path:
|
| 35 |
+
key = hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:32]
|
| 36 |
+
return LOCAL_THUMB_CACHE / f"{key}.png"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def image_to_dict(img: ImageLibrary, node: Optional[TreeNode] = None) -> dict:
|
| 40 |
+
encoded = quote(img.file_path, safe="/")
|
| 41 |
+
return {
|
| 42 |
+
"id": img.id,
|
| 43 |
+
"nodeId": img.node_id,
|
| 44 |
+
"path": img.file_path,
|
| 45 |
+
"filename": img.image_name,
|
| 46 |
+
"imageName": img.image_name,
|
| 47 |
+
"imageType": img.image_type,
|
| 48 |
+
"nodePath": node.node_path if node else "",
|
| 49 |
+
"breadcrumb": f"{node.node_path}/{img.image_name}" if node else img.image_name,
|
| 50 |
+
"fileSizeBytes": img.file_size_bytes,
|
| 51 |
+
"captureDate": img.capture_date.isoformat() if img.capture_date else None,
|
| 52 |
+
"uploadedBy": img.uploaded_by,
|
| 53 |
+
"uploadedOn": img.uploaded_on.isoformat() if img.uploaded_on else None,
|
| 54 |
+
"thumbUrl": f"/api/dda/local/thumb?path={encoded}",
|
| 55 |
+
"hasGeoref": img.has_georef,
|
| 56 |
+
"width": img.width,
|
| 57 |
+
"height": img.height,
|
| 58 |
+
"format": img.format,
|
| 59 |
+
"source": "tree_library",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def upload_image(
|
| 64 |
+
db: Session,
|
| 65 |
+
node_id: int,
|
| 66 |
+
file: UploadFile,
|
| 67 |
+
*,
|
| 68 |
+
image_type: str,
|
| 69 |
+
capture_date: Optional[str],
|
| 70 |
+
uploaded_by: str,
|
| 71 |
+
) -> ImageLibrary:
|
| 72 |
+
node = get_node_or_404(db, node_id)
|
| 73 |
+
itype = image_type.strip() or "GeoTIFF"
|
| 74 |
+
if itype not in IMAGE_TYPES:
|
| 75 |
+
raise HTTPException(status_code=400, detail=f"image_type must be one of: {', '.join(sorted(IMAGE_TYPES))}")
|
| 76 |
+
|
| 77 |
+
original = _safe_basename(file.filename or "upload")
|
| 78 |
+
ext = Path(original).suffix.lower()
|
| 79 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 80 |
+
raise HTTPException(status_code=400, detail=f"Allowed extensions: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
|
| 81 |
+
|
| 82 |
+
dest_dir = images_dir(node.physical_path)
|
| 83 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 84 |
+
dest = dest_dir / original
|
| 85 |
+
if dest.exists():
|
| 86 |
+
stem, suffix = Path(original).stem, Path(original).suffix
|
| 87 |
+
n = 1
|
| 88 |
+
while dest.exists():
|
| 89 |
+
dest = dest_dir / f"{stem}_{n}{suffix}"
|
| 90 |
+
n += 1
|
| 91 |
+
|
| 92 |
+
size = await stream_upload_to_file(file, dest, max_upload_bytes_for_extension(ext))
|
| 93 |
+
rel = dest.relative_to(storage_root()).as_posix()
|
| 94 |
+
|
| 95 |
+
cap = None
|
| 96 |
+
if capture_date:
|
| 97 |
+
try:
|
| 98 |
+
cap = datetime.fromisoformat(capture_date.strip())
|
| 99 |
+
except ValueError:
|
| 100 |
+
try:
|
| 101 |
+
cap = datetime.combine(date.fromisoformat(capture_date.strip()), datetime.min.time())
|
| 102 |
+
except ValueError:
|
| 103 |
+
raise HTTPException(status_code=400, detail="capture_date must be YYYY-MM-DD")
|
| 104 |
+
|
| 105 |
+
meta = inspect_image(dest)
|
| 106 |
+
img = ImageLibrary(
|
| 107 |
+
node_id=node.id,
|
| 108 |
+
image_name=dest.name,
|
| 109 |
+
image_type=itype,
|
| 110 |
+
file_path=rel,
|
| 111 |
+
capture_date=cap,
|
| 112 |
+
uploaded_by=uploaded_by or "",
|
| 113 |
+
file_size_bytes=size,
|
| 114 |
+
thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
|
| 115 |
+
width=meta.width,
|
| 116 |
+
height=meta.height,
|
| 117 |
+
has_georef=meta.has_georef,
|
| 118 |
+
bounds_json=bounds_to_json(meta.bounds_wgs84) or "",
|
| 119 |
+
format=meta.format,
|
| 120 |
+
)
|
| 121 |
+
db.add(img)
|
| 122 |
+
log_action(db, "upload", node_id=node.id, new_value={"file": rel, "type": itype}, action_by=uploaded_by)
|
| 123 |
+
db.commit()
|
| 124 |
+
db.refresh(img)
|
| 125 |
+
logger.info("Uploaded %s -> %s", original, rel)
|
| 126 |
+
return img
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def list_images_for_node(db: Session, node_id: int) -> List[dict]:
|
| 130 |
+
node = get_node_or_404(db, node_id)
|
| 131 |
+
rows = db.query(ImageLibrary).filter(ImageLibrary.node_id == node_id).order_by(ImageLibrary.uploaded_on.desc()).all()
|
| 132 |
+
return [image_to_dict(r, node) for r in rows]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def list_all_images(db: Session, *, node_id: Optional[int] = None, query: Optional[str] = None) -> List[dict]:
|
| 136 |
+
q = db.query(ImageLibrary, TreeNode).join(TreeNode, ImageLibrary.node_id == TreeNode.id).filter(TreeNode.is_active == True) # noqa: E712
|
| 137 |
+
if node_id:
|
| 138 |
+
q = q.filter(ImageLibrary.node_id == node_id)
|
| 139 |
+
if query:
|
| 140 |
+
like = f"%{query.strip().lower()}%"
|
| 141 |
+
q = q.filter(
|
| 142 |
+
(ImageLibrary.image_name.ilike(like)) | (TreeNode.node_path.ilike(like))
|
| 143 |
+
)
|
| 144 |
+
q = q.order_by(ImageLibrary.uploaded_on.desc())
|
| 145 |
+
return [image_to_dict(img, node) for img, node in q.all()]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
| 149 |
+
full = resolve_file(relative_path)
|
| 150 |
+
cache = _thumb_cache_path(relative_path)
|
| 151 |
+
try:
|
| 152 |
+
if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
|
| 153 |
+
return cache
|
| 154 |
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
raster_to_preview_png(full, cache, max_side=max_side)
|
| 156 |
+
return cache
|
| 157 |
+
except Exception as exc:
|
| 158 |
+
logger.warning("Thumb failed for %s: %s", relative_path, exc)
|
| 159 |
+
write_placeholder_png(cache, Path(relative_path).name, max_side)
|
| 160 |
+
return cache
|
app/dda/tree/migration.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-time migration: Delhi seed + flat year folders -> tree."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import shutil
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
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 ImageLibrary, 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
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
LEGACY_ZONE = "Unassigned"
|
| 21 |
+
LEGACY_AREA = "Legacy"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _is_year_dir(name: str) -> bool:
|
| 25 |
+
return len(name) == 4 and name.isdigit() and 1990 <= int(name) <= 2100
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def seed_delhi_tree(db: Session) -> dict:
|
| 29 |
+
if db.query(TreeNode).count() > 0:
|
| 30 |
+
return {"seeded": False, "nodes": db.query(TreeNode).count()}
|
| 31 |
+
|
| 32 |
+
zones_created = 0
|
| 33 |
+
areas_created = 0
|
| 34 |
+
for zone_name, areas in DELHI_ZONES.items():
|
| 35 |
+
zone = create_node(db, parent_id=None, node_name=zone_name, node_type="Zone", created_by="system")
|
| 36 |
+
zones_created += 1
|
| 37 |
+
for area_name in areas:
|
| 38 |
+
create_node(db, parent_id=zone.id, node_name=area_name, node_type="Area", created_by="system")
|
| 39 |
+
areas_created += 1
|
| 40 |
+
|
| 41 |
+
return {"seeded": True, "zones": zones_created, "areas": areas_created}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _find_or_create_legacy_branch(db: Session) -> TreeNode:
|
| 45 |
+
zone = db.query(TreeNode).filter(TreeNode.parent_id == None, TreeNode.node_name == LEGACY_ZONE).first() # noqa: E711
|
| 46 |
+
if not zone:
|
| 47 |
+
zone = create_node(db, parent_id=None, node_name=LEGACY_ZONE, node_type="Zone", created_by="system")
|
| 48 |
+
area = db.query(TreeNode).filter(TreeNode.parent_id == zone.id, TreeNode.node_name == LEGACY_AREA).first()
|
| 49 |
+
if not area:
|
| 50 |
+
area = create_node(db, parent_id=zone.id, node_name=LEGACY_AREA, node_type="Area", created_by="system")
|
| 51 |
+
return area
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _find_or_create_year_node(db: Session, parent: TreeNode, year: str) -> TreeNode:
|
| 55 |
+
existing = db.query(TreeNode).filter(TreeNode.parent_id == parent.id, TreeNode.node_name == year).first()
|
| 56 |
+
if existing:
|
| 57 |
+
return existing
|
| 58 |
+
return create_node(db, parent_id=parent.id, node_name=year, node_type="Year", created_by="system")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def migrate_flat_year_folders(db: Session) -> dict:
|
| 62 |
+
moved = 0
|
| 63 |
+
indexed = 0
|
| 64 |
+
legacy_area = _find_or_create_legacy_branch(db)
|
| 65 |
+
root = get_storage_root()
|
| 66 |
+
|
| 67 |
+
for lib_root in get_library_roots():
|
| 68 |
+
if not lib_root.exists() or lib_root.resolve() != root.resolve():
|
| 69 |
+
continue
|
| 70 |
+
for entry in sorted(lib_root.iterdir()):
|
| 71 |
+
if not entry.is_dir() or not _is_year_dir(entry.name):
|
| 72 |
+
continue
|
| 73 |
+
year_node = _find_or_create_year_node(db, legacy_area, entry.name)
|
| 74 |
+
dest_images = ensure_node_directory(year_node.physical_path) / "Images"
|
| 75 |
+
for path in sorted(entry.iterdir()):
|
| 76 |
+
if not path.is_file() or path.suffix.lower() not in ALLOWED_EXTENSIONS:
|
| 77 |
+
continue
|
| 78 |
+
target = dest_images / path.name
|
| 79 |
+
if target.exists():
|
| 80 |
+
stem, suffix = path.stem, path.suffix
|
| 81 |
+
n = 1
|
| 82 |
+
while target.exists():
|
| 83 |
+
target = dest_images / f"{stem}_{n}{suffix}"
|
| 84 |
+
n += 1
|
| 85 |
+
if path.resolve() != target.resolve():
|
| 86 |
+
shutil.move(str(path), str(target))
|
| 87 |
+
rel = target.relative_to(root).as_posix()
|
| 88 |
+
if not db.query(ImageLibrary).filter(ImageLibrary.file_path == rel).first():
|
| 89 |
+
db.add(ImageLibrary(
|
| 90 |
+
node_id=year_node.id,
|
| 91 |
+
image_name=target.name,
|
| 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()):
|
| 101 |
+
entry.rmdir()
|
| 102 |
+
except OSError:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
if moved or indexed:
|
| 106 |
+
db.commit()
|
| 107 |
+
logger.info("Tree migration: moved=%d indexed=%d", moved, indexed)
|
| 108 |
+
return {"moved": moved, "indexed": indexed}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def run_tree_migration(db: Session) -> dict:
|
| 112 |
+
seed_result = seed_delhi_tree(db)
|
| 113 |
+
migrate_result = migrate_flat_year_folders(db)
|
| 114 |
+
return {"seed": seed_result, "migrate": migrate_result}
|
app/dda/tree/models.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy models for unlimited-depth tree library."""
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
|
| 7 |
+
from ...database import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _utcnow():
|
| 11 |
+
return datetime.now(timezone.utc)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TreeNode(Base):
|
| 15 |
+
__tablename__ = "dda_tree_nodes"
|
| 16 |
+
|
| 17 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 18 |
+
parent_id = Column(Integer, ForeignKey("dda_tree_nodes.id"), nullable=True, index=True)
|
| 19 |
+
node_name = Column(String(500), nullable=False)
|
| 20 |
+
node_type = Column(String(100), default="Folder")
|
| 21 |
+
node_level = Column(Integer, default=0)
|
| 22 |
+
node_path = Column(Text, default="") # display path: North Zone/Area A/2025
|
| 23 |
+
slug = Column(String(64), nullable=False, index=True)
|
| 24 |
+
physical_path = Column(Text, default="") # relative slug path from storage root
|
| 25 |
+
created_by = Column(String(100), default="")
|
| 26 |
+
created_at = Column(DateTime, default=_utcnow)
|
| 27 |
+
is_active = Column(Boolean, default=True, index=True)
|
| 28 |
+
|
| 29 |
+
parent = relationship("TreeNode", remote_side=[id], backref="children")
|
| 30 |
+
images = relationship("ImageLibrary", back_populates="node", cascade="all, delete-orphan")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ImageLibrary(Base):
|
| 34 |
+
__tablename__ = "dda_image_library"
|
| 35 |
+
|
| 36 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 37 |
+
node_id = Column(Integer, ForeignKey("dda_tree_nodes.id"), nullable=False, index=True)
|
| 38 |
+
image_name = Column(String(500), nullable=False)
|
| 39 |
+
image_type = Column(String(100), default="GeoTIFF")
|
| 40 |
+
file_path = Column(Text, nullable=False, unique=True) # relative to storage root
|
| 41 |
+
capture_date = Column(DateTime, nullable=True)
|
| 42 |
+
uploaded_by = Column(String(100), default="")
|
| 43 |
+
uploaded_on = Column(DateTime, default=_utcnow)
|
| 44 |
+
file_size_bytes = Column(Integer, default=0)
|
| 45 |
+
thumb_cache_key = Column(String(64), default="")
|
| 46 |
+
width = Column(Integer, default=0)
|
| 47 |
+
height = Column(Integer, default=0)
|
| 48 |
+
has_georef = Column(Boolean, default=False)
|
| 49 |
+
bounds_json = Column(Text, default="")
|
| 50 |
+
format = Column(String(32), default="")
|
| 51 |
+
|
| 52 |
+
node = relationship("TreeNode", back_populates="images")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class AuditLog(Base):
|
| 56 |
+
__tablename__ = "dda_audit_logs"
|
| 57 |
+
|
| 58 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 59 |
+
action = Column(String(100), nullable=False)
|
| 60 |
+
node_id = Column(Integer, ForeignKey("dda_tree_nodes.id"), nullable=True, index=True)
|
| 61 |
+
old_value = Column(Text, default="")
|
| 62 |
+
new_value = Column(Text, default="")
|
| 63 |
+
action_date = Column(DateTime, default=_utcnow)
|
| 64 |
+
action_by = Column(String(100), default="")
|
app/dda/tree/path_service.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Physical directory sync for tree nodes."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import shutil
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from ..config import get_storage_root
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def storage_root() -> Path:
|
| 14 |
+
root = get_storage_root()
|
| 15 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 16 |
+
return root
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def absolute_path(relative_physical_path: str) -> Path:
|
| 20 |
+
rel = (relative_physical_path or "").replace("\\", "/").strip("/")
|
| 21 |
+
return storage_root() / rel if rel else storage_root()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def images_dir(relative_physical_path: str) -> Path:
|
| 25 |
+
return absolute_path(relative_physical_path) / "Images"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def ensure_node_directory(relative_physical_path: str) -> Path:
|
| 29 |
+
path = absolute_path(relative_physical_path)
|
| 30 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
(path / "Images").mkdir(parents=True, exist_ok=True)
|
| 32 |
+
return path
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def move_directory(old_rel: str, new_rel: str) -> None:
|
| 36 |
+
old_abs = absolute_path(old_rel)
|
| 37 |
+
new_abs = absolute_path(new_rel)
|
| 38 |
+
if not old_abs.exists():
|
| 39 |
+
ensure_node_directory(new_rel)
|
| 40 |
+
return
|
| 41 |
+
new_abs.parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
if new_abs.exists():
|
| 43 |
+
raise OSError(f"Destination already exists: {new_rel}")
|
| 44 |
+
shutil.move(str(old_abs), str(new_abs))
|
| 45 |
+
logger.info("Moved tree folder %s -> %s", old_rel, new_rel)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def delete_directory(relative_physical_path: str) -> None:
|
| 49 |
+
path = absolute_path(relative_physical_path)
|
| 50 |
+
if path.exists() and path.is_dir():
|
| 51 |
+
shutil.rmtree(path)
|
| 52 |
+
logger.info("Deleted tree folder %s", relative_physical_path)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def resolve_file(relative_file_path: str) -> Path:
|
| 56 |
+
rel = relative_file_path.replace("\\", "/").lstrip("/")
|
| 57 |
+
if not rel or ".." in rel.split("/"):
|
| 58 |
+
raise ValueError("Invalid file path")
|
| 59 |
+
full = (storage_root() / rel).resolve()
|
| 60 |
+
try:
|
| 61 |
+
full.relative_to(storage_root().resolve())
|
| 62 |
+
except ValueError as exc:
|
| 63 |
+
raise ValueError("Path escapes storage root") from exc
|
| 64 |
+
if not full.is_file():
|
| 65 |
+
raise FileNotFoundError(relative_file_path)
|
| 66 |
+
return full
|
app/dda/tree/path_slugs.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Filesystem-safe slugs for tree node paths."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import unicodedata
|
| 6 |
+
from typing import Set
|
| 7 |
+
|
| 8 |
+
RESERVED = frozenset({"images", "thumbs", "cache", "legacy"})
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def slugify(name: str, *, fallback: str = "node") -> str:
|
| 12 |
+
text = unicodedata.normalize("NFKD", (name or "").strip())
|
| 13 |
+
text = text.encode("ascii", "ignore").decode("ascii")
|
| 14 |
+
text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)
|
| 15 |
+
text = re.sub(r"[\s_-]+", "_", text).strip("_").lower()
|
| 16 |
+
if not text:
|
| 17 |
+
text = fallback
|
| 18 |
+
if text in RESERVED:
|
| 19 |
+
text = f"{text}_1"
|
| 20 |
+
return text[:64]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def unique_slug(base: str, existing: Set[str]) -> str:
|
| 24 |
+
slug = slugify(base)
|
| 25 |
+
if slug not in existing:
|
| 26 |
+
return slug
|
| 27 |
+
n = 2
|
| 28 |
+
while True:
|
| 29 |
+
candidate = f"{slug}-{n}"[:64]
|
| 30 |
+
if candidate not in existing:
|
| 31 |
+
return candidate
|
| 32 |
+
n += 1
|
app/dda/tree/routes.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI routes for unlimited-depth tree library."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Optional
|
| 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, require_min_role
|
| 14 |
+
from .image_service import IMAGE_TYPES, image_to_dict, list_all_images, list_images_for_node, upload_image
|
| 15 |
+
from .tree_service import build_tree, create_node, delete_node, get_node_or_404, move_node, rename_node
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _require_dda():
|
| 21 |
+
if not IS_DDA_MODE:
|
| 22 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class NodeCreateBody(BaseModel):
|
| 26 |
+
parent_id: Optional[int] = None
|
| 27 |
+
node_name: str = Field(..., min_length=1, max_length=500)
|
| 28 |
+
node_type: str = Field(default="Folder", max_length=100)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class NodeRenameBody(BaseModel):
|
| 32 |
+
node_name: str = Field(..., min_length=1, max_length=500)
|
| 33 |
+
rename_physical: bool = False
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class NodeMoveBody(BaseModel):
|
| 37 |
+
parent_id: Optional[int] = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class NodeDeleteBody(BaseModel):
|
| 41 |
+
delete_files: bool = False
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.get("/me")
|
| 45 |
+
def tree_me(user: User = Depends(current_dda_user), db: Session = Depends(get_db)):
|
| 46 |
+
_require_dda()
|
| 47 |
+
return {"userId": user.id, "role": get_user_role(db, user), "email": user.email}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.get("/tree")
|
| 51 |
+
def get_tree(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 52 |
+
_require_dda()
|
| 53 |
+
return {"tree": build_tree(db), "imageTypes": sorted(IMAGE_TYPES)}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("/tree/nodes")
|
| 57 |
+
def api_create_node(
|
| 58 |
+
body: NodeCreateBody,
|
| 59 |
+
db: Session = Depends(get_db),
|
| 60 |
+
user: User = Depends(current_dda_user),
|
| 61 |
+
):
|
| 62 |
+
_require_dda()
|
| 63 |
+
require_min_role(user, db, "admin")
|
| 64 |
+
node = create_node(
|
| 65 |
+
db,
|
| 66 |
+
parent_id=body.parent_id,
|
| 67 |
+
node_name=body.node_name,
|
| 68 |
+
node_type=body.node_type,
|
| 69 |
+
created_by=user.email or str(user.id),
|
| 70 |
+
)
|
| 71 |
+
return {"status": True, "message": "Node Created Successfully", "node": {
|
| 72 |
+
"id": node.id, "name": node.node_name, "nodePath": node.node_path,
|
| 73 |
+
}}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.put("/tree/nodes/{node_id}/rename")
|
| 77 |
+
def api_rename_node(
|
| 78 |
+
node_id: int,
|
| 79 |
+
body: NodeRenameBody,
|
| 80 |
+
db: Session = Depends(get_db),
|
| 81 |
+
user: User = Depends(current_dda_user),
|
| 82 |
+
):
|
| 83 |
+
_require_dda()
|
| 84 |
+
require_min_role(user, db, "admin")
|
| 85 |
+
node = rename_node(
|
| 86 |
+
db, node_id, body.node_name,
|
| 87 |
+
action_by=user.email or str(user.id),
|
| 88 |
+
rename_physical=body.rename_physical,
|
| 89 |
+
)
|
| 90 |
+
return {"status": True, "message": "Node Renamed Successfully", "node": {
|
| 91 |
+
"id": node.id, "name": node.node_name, "nodePath": node.node_path,
|
| 92 |
+
}}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.post("/tree/nodes/{node_id}/move")
|
| 96 |
+
def api_move_node(
|
| 97 |
+
node_id: int,
|
| 98 |
+
body: NodeMoveBody,
|
| 99 |
+
db: Session = Depends(get_db),
|
| 100 |
+
user: User = Depends(current_dda_user),
|
| 101 |
+
):
|
| 102 |
+
_require_dda()
|
| 103 |
+
require_min_role(user, db, "admin")
|
| 104 |
+
node = move_node(db, node_id, body.parent_id, action_by=user.email or str(user.id))
|
| 105 |
+
return {"status": True, "message": "Node Moved Successfully", "node": {
|
| 106 |
+
"id": node.id, "parentId": node.parent_id, "nodePath": node.node_path,
|
| 107 |
+
}}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.delete("/tree/nodes/{node_id}")
|
| 111 |
+
def api_delete_node(
|
| 112 |
+
node_id: int,
|
| 113 |
+
body: NodeDeleteBody = NodeDeleteBody(),
|
| 114 |
+
db: Session = Depends(get_db),
|
| 115 |
+
user: User = Depends(current_dda_user),
|
| 116 |
+
):
|
| 117 |
+
_require_dda()
|
| 118 |
+
require_min_role(user, db, "admin")
|
| 119 |
+
result = delete_node(db, node_id, delete_files=body.delete_files, action_by=user.email or str(user.id))
|
| 120 |
+
return {"status": True, "message": "Node Deleted Successfully", **result}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@router.get("/tree/nodes/{node_id}")
|
| 124 |
+
def api_get_node(node_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 125 |
+
_require_dda()
|
| 126 |
+
node = get_node_or_404(db, node_id)
|
| 127 |
+
return {
|
| 128 |
+
"id": node.id,
|
| 129 |
+
"parentId": node.parent_id,
|
| 130 |
+
"name": node.node_name,
|
| 131 |
+
"nodeType": node.node_type,
|
| 132 |
+
"nodePath": node.node_path,
|
| 133 |
+
"physicalPath": node.physical_path,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@router.get("/tree/nodes/{node_id}/images")
|
| 138 |
+
def api_list_node_images(node_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 139 |
+
_require_dda()
|
| 140 |
+
return {"images": list_images_for_node(db, node_id)}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.get("/tree/images")
|
| 144 |
+
def api_list_images(
|
| 145 |
+
node_id: Optional[int] = None,
|
| 146 |
+
q: Optional[str] = None,
|
| 147 |
+
db: Session = Depends(get_db),
|
| 148 |
+
user: User = Depends(current_dda_user),
|
| 149 |
+
):
|
| 150 |
+
_require_dda()
|
| 151 |
+
return list_all_images(db, node_id=node_id, query=q)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@router.post("/tree/nodes/{node_id}/images/upload")
|
| 155 |
+
async def api_upload_image(
|
| 156 |
+
node_id: int,
|
| 157 |
+
file: UploadFile = File(...),
|
| 158 |
+
image_type: str = Form("GeoTIFF"),
|
| 159 |
+
capture_date: str = Form(""),
|
| 160 |
+
db: Session = Depends(get_db),
|
| 161 |
+
user: User = Depends(current_dda_user),
|
| 162 |
+
):
|
| 163 |
+
_require_dda()
|
| 164 |
+
require_min_role(user, db, "uploader")
|
| 165 |
+
img = await upload_image(
|
| 166 |
+
db, node_id, file,
|
| 167 |
+
image_type=image_type,
|
| 168 |
+
capture_date=capture_date or None,
|
| 169 |
+
uploaded_by=user.email or str(user.id),
|
| 170 |
+
)
|
| 171 |
+
node = get_node_or_404(db, node_id)
|
| 172 |
+
return {"status": True, "message": "Image Uploaded Successfully", "image": image_to_dict(img, node)}
|
app/dda/tree/tree_service.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tree node CRUD and recursive tree building."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from .audit_service import log_action
|
| 11 |
+
from .models import ImageLibrary, TreeNode
|
| 12 |
+
from .path_service import delete_directory, ensure_node_directory, move_directory
|
| 13 |
+
from .path_slugs import unique_slug
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _sibling_slugs(db: Session, parent_id: Optional[int]) -> set:
|
| 19 |
+
q = db.query(TreeNode).filter(TreeNode.parent_id == parent_id, TreeNode.is_active == True) # noqa: E712
|
| 20 |
+
return {n.slug for n in q.all() if n.slug}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _sibling_names(db: Session, parent_id: Optional[int], exclude_id: Optional[int] = None) -> set:
|
| 24 |
+
q = db.query(TreeNode).filter(TreeNode.parent_id == parent_id, TreeNode.is_active == True) # noqa: E712
|
| 25 |
+
if exclude_id:
|
| 26 |
+
q = q.filter(TreeNode.id != exclude_id)
|
| 27 |
+
return {n.node_name.strip().lower() for n in q.all()}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _node_to_dict(node: TreeNode, *, image_count: int = 0, children: Optional[list] = None) -> dict:
|
| 31 |
+
return {
|
| 32 |
+
"id": node.id,
|
| 33 |
+
"parentId": node.parent_id,
|
| 34 |
+
"name": node.node_name,
|
| 35 |
+
"nodeName": node.node_name,
|
| 36 |
+
"nodeType": node.node_type,
|
| 37 |
+
"nodeLevel": node.node_level,
|
| 38 |
+
"nodePath": node.node_path,
|
| 39 |
+
"slug": node.slug,
|
| 40 |
+
"physicalPath": node.physical_path,
|
| 41 |
+
"imageCount": image_count,
|
| 42 |
+
"children": children or [],
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def build_tree(db: Session, parent_id: Optional[int] = None) -> List[dict]:
|
| 47 |
+
nodes = (
|
| 48 |
+
db.query(TreeNode)
|
| 49 |
+
.filter(TreeNode.parent_id == parent_id, TreeNode.is_active == True) # noqa: E712
|
| 50 |
+
.order_by(TreeNode.node_name)
|
| 51 |
+
.all()
|
| 52 |
+
)
|
| 53 |
+
result = []
|
| 54 |
+
for node in nodes:
|
| 55 |
+
children = build_tree(db, node.id)
|
| 56 |
+
img_count = db.query(ImageLibrary).filter(ImageLibrary.node_id == node.id).count()
|
| 57 |
+
child_img = sum(c.get("imageCount", 0) for c in children)
|
| 58 |
+
result.append(_node_to_dict(node, image_count=img_count + child_img, children=children))
|
| 59 |
+
return result
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_node_or_404(db: Session, node_id: int) -> TreeNode:
|
| 63 |
+
node = db.query(TreeNode).filter(TreeNode.id == node_id, TreeNode.is_active == True).first() # noqa: E712
|
| 64 |
+
if not node:
|
| 65 |
+
raise HTTPException(status_code=404, detail="Node not found")
|
| 66 |
+
return node
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def create_node(
|
| 70 |
+
db: Session,
|
| 71 |
+
*,
|
| 72 |
+
parent_id: Optional[int],
|
| 73 |
+
node_name: str,
|
| 74 |
+
node_type: str,
|
| 75 |
+
created_by: str,
|
| 76 |
+
) -> TreeNode:
|
| 77 |
+
name = node_name.strip()
|
| 78 |
+
if not name:
|
| 79 |
+
raise HTTPException(status_code=400, detail="node_name is required")
|
| 80 |
+
if name.lower() in _sibling_names(db, parent_id):
|
| 81 |
+
raise HTTPException(status_code=400, detail="Duplicate node name under same parent")
|
| 82 |
+
|
| 83 |
+
parent = None
|
| 84 |
+
level = 0
|
| 85 |
+
parent_physical = ""
|
| 86 |
+
parent_display = ""
|
| 87 |
+
if parent_id is not None:
|
| 88 |
+
parent = get_node_or_404(db, parent_id)
|
| 89 |
+
level = parent.node_level + 1
|
| 90 |
+
parent_physical = parent.physical_path or ""
|
| 91 |
+
parent_display = parent.node_path or parent.node_name
|
| 92 |
+
|
| 93 |
+
slug = unique_slug(name, _sibling_slugs(db, parent_id))
|
| 94 |
+
physical_path = f"{parent_physical}/{slug}".strip("/") if parent_physical else slug
|
| 95 |
+
node_path = f"{parent_display}/{name}".strip("/") if parent_display else name
|
| 96 |
+
|
| 97 |
+
node = TreeNode(
|
| 98 |
+
parent_id=parent_id,
|
| 99 |
+
node_name=name,
|
| 100 |
+
node_type=node_type or "Folder",
|
| 101 |
+
node_level=level,
|
| 102 |
+
node_path=node_path,
|
| 103 |
+
slug=slug,
|
| 104 |
+
physical_path=physical_path,
|
| 105 |
+
created_by=created_by or "",
|
| 106 |
+
)
|
| 107 |
+
db.add(node)
|
| 108 |
+
db.flush()
|
| 109 |
+
ensure_node_directory(physical_path)
|
| 110 |
+
log_action(db, "create", node_id=node.id, new_value={"name": name, "path": node_path}, action_by=created_by)
|
| 111 |
+
db.commit()
|
| 112 |
+
db.refresh(node)
|
| 113 |
+
logger.info("Created tree node %s (%s)", node_path, physical_path)
|
| 114 |
+
return node
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def rename_node(
|
| 118 |
+
db: Session,
|
| 119 |
+
node_id: int,
|
| 120 |
+
new_name: str,
|
| 121 |
+
*,
|
| 122 |
+
action_by: str,
|
| 123 |
+
rename_physical: bool = False,
|
| 124 |
+
) -> TreeNode:
|
| 125 |
+
node = get_node_or_404(db, node_id)
|
| 126 |
+
name = new_name.strip()
|
| 127 |
+
if not name:
|
| 128 |
+
raise HTTPException(status_code=400, detail="node_name is required")
|
| 129 |
+
if name.lower() in _sibling_names(db, node.parent_id, exclude_id=node_id):
|
| 130 |
+
raise HTTPException(status_code=400, detail="Duplicate node name under same parent")
|
| 131 |
+
|
| 132 |
+
old = {"name": node.node_name, "path": node.node_path}
|
| 133 |
+
node.node_name = name
|
| 134 |
+
|
| 135 |
+
if node.parent_id:
|
| 136 |
+
parent = get_node_or_404(db, node.parent_id)
|
| 137 |
+
node.node_path = f"{parent.node_path}/{name}"
|
| 138 |
+
else:
|
| 139 |
+
node.node_path = name
|
| 140 |
+
|
| 141 |
+
if rename_physical:
|
| 142 |
+
old_physical = node.physical_path
|
| 143 |
+
parent_physical = ""
|
| 144 |
+
if node.parent_id:
|
| 145 |
+
parent = get_node_or_404(db, node.parent_id)
|
| 146 |
+
parent_physical = parent.physical_path or ""
|
| 147 |
+
new_slug = unique_slug(name, _sibling_slugs(db, node.parent_id) - {node.slug})
|
| 148 |
+
new_physical = f"{parent_physical}/{new_slug}".strip("/") if parent_physical else new_slug
|
| 149 |
+
if old_physical != new_physical:
|
| 150 |
+
move_directory(old_physical, new_physical)
|
| 151 |
+
node.slug = new_slug
|
| 152 |
+
node.physical_path = new_physical
|
| 153 |
+
_update_descendant_paths(db, node)
|
| 154 |
+
|
| 155 |
+
log_action(db, "rename", node_id=node.id, old_value=old, new_value={"name": name, "path": node.node_path}, action_by=action_by)
|
| 156 |
+
db.commit()
|
| 157 |
+
db.refresh(node)
|
| 158 |
+
return node
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _update_descendant_paths(db: Session, parent: TreeNode) -> None:
|
| 162 |
+
children = db.query(TreeNode).filter(TreeNode.parent_id == parent.id, TreeNode.is_active == True).all() # noqa: E712
|
| 163 |
+
for child in children:
|
| 164 |
+
child.node_path = f"{parent.node_path}/{child.node_name}"
|
| 165 |
+
child.physical_path = f"{parent.physical_path}/{child.slug}".strip("/")
|
| 166 |
+
ensure_node_directory(child.physical_path)
|
| 167 |
+
_update_descendant_paths(db, child)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def move_node(
|
| 171 |
+
db: Session,
|
| 172 |
+
node_id: int,
|
| 173 |
+
new_parent_id: Optional[int],
|
| 174 |
+
*,
|
| 175 |
+
action_by: str,
|
| 176 |
+
) -> TreeNode:
|
| 177 |
+
node = get_node_or_404(db, node_id)
|
| 178 |
+
if new_parent_id == node.id:
|
| 179 |
+
raise HTTPException(status_code=400, detail="Cannot move node under itself")
|
| 180 |
+
|
| 181 |
+
if new_parent_id is not None:
|
| 182 |
+
new_parent = get_node_or_404(db, new_parent_id)
|
| 183 |
+
# prevent cycle
|
| 184 |
+
cursor = new_parent
|
| 185 |
+
while cursor.parent_id is not None:
|
| 186 |
+
if cursor.parent_id == node.id:
|
| 187 |
+
raise HTTPException(status_code=400, detail="Cannot move node under its descendant")
|
| 188 |
+
cursor = get_node_or_404(db, cursor.parent_id)
|
| 189 |
+
|
| 190 |
+
if node.node_name.lower() in _sibling_names(db, new_parent_id, exclude_id=node_id):
|
| 191 |
+
raise HTTPException(status_code=400, detail="Duplicate node name under target parent")
|
| 192 |
+
|
| 193 |
+
old = {"parentId": node.parent_id, "path": node.node_path, "physical": node.physical_path}
|
| 194 |
+
old_physical = node.physical_path
|
| 195 |
+
|
| 196 |
+
node.parent_id = new_parent_id
|
| 197 |
+
if new_parent_id is None:
|
| 198 |
+
node.node_level = 0
|
| 199 |
+
node.node_path = node.node_name
|
| 200 |
+
new_physical = node.slug
|
| 201 |
+
else:
|
| 202 |
+
new_parent = get_node_or_404(db, new_parent_id)
|
| 203 |
+
node.node_level = new_parent.node_level + 1
|
| 204 |
+
node.node_path = f"{new_parent.node_path}/{node.node_name}"
|
| 205 |
+
new_physical = f"{new_parent.physical_path}/{node.slug}".strip("/")
|
| 206 |
+
|
| 207 |
+
if old_physical != new_physical:
|
| 208 |
+
move_directory(old_physical, new_physical)
|
| 209 |
+
node.physical_path = new_physical
|
| 210 |
+
_update_descendant_paths(db, node)
|
| 211 |
+
|
| 212 |
+
log_action(db, "move", node_id=node.id, old_value=old, new_value={"parentId": new_parent_id, "path": node.node_path}, action_by=action_by)
|
| 213 |
+
db.commit()
|
| 214 |
+
db.refresh(node)
|
| 215 |
+
return node
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _descendant_ids(db: Session, node_id: int) -> List[int]:
|
| 219 |
+
ids = [node_id]
|
| 220 |
+
children = db.query(TreeNode.id).filter(TreeNode.parent_id == node_id, TreeNode.is_active == True).all() # noqa: E712
|
| 221 |
+
for (cid,) in children:
|
| 222 |
+
ids.extend(_descendant_ids(db, cid))
|
| 223 |
+
return ids
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def delete_node(
|
| 227 |
+
db: Session,
|
| 228 |
+
node_id: int,
|
| 229 |
+
*,
|
| 230 |
+
delete_files: bool,
|
| 231 |
+
action_by: str,
|
| 232 |
+
) -> dict:
|
| 233 |
+
node = get_node_or_404(db, node_id)
|
| 234 |
+
ids = _descendant_ids(db, node_id)
|
| 235 |
+
img_count = db.query(ImageLibrary).filter(ImageLibrary.node_id.in_(ids)).count()
|
| 236 |
+
if img_count > 0 and not delete_files:
|
| 237 |
+
raise HTTPException(status_code=400, detail="Node has images; set delete_files=true to remove")
|
| 238 |
+
|
| 239 |
+
old = {"path": node.node_path, "physical": node.physical_path}
|
| 240 |
+
if delete_files:
|
| 241 |
+
for nid in reversed(ids):
|
| 242 |
+
n = db.query(TreeNode).filter(TreeNode.id == nid).first()
|
| 243 |
+
if n and n.physical_path:
|
| 244 |
+
delete_directory(n.physical_path)
|
| 245 |
+
db.query(ImageLibrary).filter(ImageLibrary.node_id == nid).delete()
|
| 246 |
+
if n:
|
| 247 |
+
n.is_active = False
|
| 248 |
+
else:
|
| 249 |
+
child_count = len(ids) - 1
|
| 250 |
+
if child_count > 0:
|
| 251 |
+
raise HTTPException(status_code=400, detail="Node has child nodes; set delete_files=true or remove children first")
|
| 252 |
+
if img_count > 0:
|
| 253 |
+
raise HTTPException(status_code=400, detail="Node has images")
|
| 254 |
+
node.is_active = False
|
| 255 |
+
|
| 256 |
+
log_action(db, "delete", node_id=node_id, old_value=old, new_value={"delete_files": delete_files}, action_by=action_by)
|
| 257 |
+
db.commit()
|
| 258 |
+
return {"ok": True, "deletedIds": ids}
|
app/main.py
CHANGED
|
@@ -29,6 +29,7 @@ from .database import Base, engine, get_db, DATA_DIR
|
|
| 29 |
from .models import User, DetectionRun
|
| 30 |
from . import dda as _dda_pkg # noqa: F401 — register DDA tables
|
| 31 |
from .dda.models import DdaZone, DdaVillage, ImageAsset, DetectionJob, RegionReview # noqa: F401
|
|
|
|
| 32 |
from .dda.config import IS_DDA_MODE
|
| 33 |
from .dda.bootstrap import init_dda_database, setup_dda
|
| 34 |
from .notifier import send_notification, send_test_email
|
|
@@ -118,9 +119,15 @@ def health():
|
|
| 118 |
if IS_DDA_MODE:
|
| 119 |
try:
|
| 120 |
from .dda.job_runner import is_job_runner_busy
|
| 121 |
-
from .dda.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
payload["dda"] = {
|
| 123 |
-
"libraryImages":
|
| 124 |
"jobRunnerBusy": is_job_runner_busy(),
|
| 125 |
}
|
| 126 |
except Exception as exc:
|
|
|
|
| 29 |
from .models import User, DetectionRun
|
| 30 |
from . import dda as _dda_pkg # noqa: F401 — register DDA tables
|
| 31 |
from .dda.models import DdaZone, DdaVillage, ImageAsset, DetectionJob, RegionReview # noqa: F401
|
| 32 |
+
from .dda.tree.models import TreeNode, ImageLibrary, AuditLog # noqa: F401
|
| 33 |
from .dda.config import IS_DDA_MODE
|
| 34 |
from .dda.bootstrap import init_dda_database, setup_dda
|
| 35 |
from .notifier import send_notification, send_test_email
|
|
|
|
| 119 |
if IS_DDA_MODE:
|
| 120 |
try:
|
| 121 |
from .dda.job_runner import is_job_runner_busy
|
| 122 |
+
from .dda.tree.image_service import list_all_images
|
| 123 |
+
from .database import SessionLocal
|
| 124 |
+
_sdb = SessionLocal()
|
| 125 |
+
try:
|
| 126 |
+
lib_count = len(list_all_images(_sdb))
|
| 127 |
+
finally:
|
| 128 |
+
_sdb.close()
|
| 129 |
payload["dda"] = {
|
| 130 |
+
"libraryImages": lib_count,
|
| 131 |
"jobRunnerBusy": is_job_runner_busy(),
|
| 132 |
}
|
| 133 |
except Exception as exc:
|
static/css/dda.css
CHANGED
|
@@ -83,6 +83,35 @@
|
|
| 83 |
background: var(--bg-hover);
|
| 84 |
color: var(--grad-start);
|
| 85 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
.dda-folder-path {
|
| 88 |
font-size: 0.78rem;
|
|
|
|
| 83 |
background: var(--bg-hover);
|
| 84 |
color: var(--grad-start);
|
| 85 |
}
|
| 86 |
+
.dda-sidebar-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
| 87 |
+
.dda-tree-all, .dda-tree-node-btn {
|
| 88 |
+
display: block;
|
| 89 |
+
width: 100%;
|
| 90 |
+
text-align: left;
|
| 91 |
+
padding: 0.35rem 0.5rem;
|
| 92 |
+
margin-bottom: 0.2rem;
|
| 93 |
+
border: none;
|
| 94 |
+
background: transparent;
|
| 95 |
+
color: var(--text-muted);
|
| 96 |
+
cursor: pointer;
|
| 97 |
+
border-radius: 6px;
|
| 98 |
+
font-size: 0.88rem;
|
| 99 |
+
}
|
| 100 |
+
.dda-tree-all:hover, .dda-tree-all.active,
|
| 101 |
+
.dda-tree-node-btn:hover, .dda-tree-node-btn.active {
|
| 102 |
+
background: var(--bg-hover);
|
| 103 |
+
color: var(--grad-start);
|
| 104 |
+
}
|
| 105 |
+
.dda-tree-node-wrap { margin-bottom: 0.15rem; }
|
| 106 |
+
.dda-tree-node-summary { list-style: none; cursor: pointer; }
|
| 107 |
+
.dda-tree-node-summary .dda-tree-node-btn { display: inline; width: auto; }
|
| 108 |
+
.dda-tree-children { padding-left: 0.65rem; border-left: 1px solid var(--border); margin-left: 0.35rem; }
|
| 109 |
+
.dda-tree-leaf { padding-left: 0.75rem; }
|
| 110 |
+
.dda-crumb { font-size: 0.78rem; line-height: 1.3; }
|
| 111 |
+
.dda-manage-panel { max-width: 540px; width: 100%; }
|
| 112 |
+
.dda-manage-section { margin-bottom: 1rem; }
|
| 113 |
+
.dda-manage-section h4 { margin: 0 0 0.5rem; font-size: 0.9rem; }
|
| 114 |
+
.dda-manage-section .location-row { flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
|
| 115 |
|
| 116 |
.dda-folder-path {
|
| 117 |
font-size: 0.78rem;
|
static/js/dda/app.js
CHANGED
|
@@ -55,15 +55,16 @@ function formatBytes(n) {
|
|
| 55 |
}
|
| 56 |
|
| 57 |
let ddaConfig = null;
|
| 58 |
-
|
| 59 |
-
let selectedYear = null;
|
| 60 |
|
| 61 |
window.ddaState = {
|
| 62 |
get config() { return ddaConfig; },
|
| 63 |
get localCfg() { return window._localCfg; },
|
| 64 |
-
get
|
| 65 |
-
get
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
refreshImages: () => loadLibraryImages(),
|
| 68 |
rescan: () => rescanLibrary(),
|
| 69 |
};
|
|
@@ -83,8 +84,8 @@ document.querySelectorAll('.dda-tab').forEach((btn) => {
|
|
| 83 |
|
| 84 |
async function rescanLibrary() {
|
| 85 |
const data = await ddaApi('POST', '/api/dda/local/rescan');
|
| 86 |
-
|
| 87 |
-
if (typeof
|
| 88 |
await loadLibraryImages();
|
| 89 |
return data;
|
| 90 |
}
|
|
@@ -92,125 +93,91 @@ async function rescanLibrary() {
|
|
| 92 |
async function initDda() {
|
| 93 |
hideDdaError();
|
| 94 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
ddaConfig = await ddaApi('GET', '/api/dda/config');
|
| 96 |
const localCfg = await ddaApi('GET', '/api/dda/local/config');
|
| 97 |
window._localCfg = localCfg;
|
| 98 |
|
|
|
|
|
|
|
| 99 |
const hint = document.getElementById('lib-config-hint');
|
| 100 |
-
if (hint)
|
| 101 |
-
|
| 102 |
-
}
|
| 103 |
-
const paths = (localCfg.rootPaths || []).filter(Boolean);
|
| 104 |
const pathEl = document.getElementById('lib-path-display');
|
| 105 |
if (pathEl) {
|
| 106 |
pathEl.textContent = [
|
| 107 |
-
|
| 108 |
-
localCfg.
|
| 109 |
-
|
| 110 |
].filter(Boolean).join('\n');
|
| 111 |
}
|
|
|
|
| 112 |
const folderPath = document.getElementById('lib-folder-path');
|
| 113 |
if (folderPath) {
|
| 114 |
folderPath.textContent = localCfg.isHosted
|
| 115 |
-
? 'Hugging Face —
|
| 116 |
-
:
|
| 117 |
}
|
|
|
|
| 118 |
const instr = document.getElementById('lib-instructions');
|
| 119 |
if (instr && localCfg.instructions) instr.textContent = localCfg.instructions;
|
| 120 |
|
| 121 |
-
const
|
| 122 |
-
if (hfUpload) hfUpload.classList.toggle('hidden', !localCfg.isHosted);
|
| 123 |
-
|
| 124 |
-
const resHint = document.getElementById('dda-detect-res-hint');
|
| 125 |
-
if (resHint && localCfg.detectionMaxSide) {
|
| 126 |
-
resHint.textContent = `Detection runs at up to ${localCfg.detectionMaxSide}px per side for sharper results (set DETECTION_MAX_SIDE to change).`;
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
const uploadLimit = document.getElementById('hf-upload-limit');
|
| 130 |
if (uploadLimit && localCfg.maxUploadGb) {
|
| 131 |
-
uploadLimit.textContent = `
|
| 132 |
}
|
| 133 |
|
| 134 |
-
const
|
| 135 |
-
if (
|
| 136 |
-
|
| 137 |
}
|
| 138 |
|
| 139 |
const urlTab = new URLSearchParams(window.location.search).get('tab');
|
| 140 |
-
if (urlTab) {
|
| 141 |
-
document.querySelector(`.dda-tab[data-tab="${urlTab}"]`)?.click();
|
| 142 |
-
}
|
| 143 |
|
| 144 |
-
|
| 145 |
-
localYears = yearsData.years || [];
|
| 146 |
-
if (typeof renderYearTree === 'function') renderYearTree(localYears);
|
| 147 |
await loadLibraryImages();
|
| 148 |
-
await loadHierarchyTree();
|
| 149 |
} catch (err) {
|
| 150 |
showDdaError(err.message || 'Failed to load library');
|
| 151 |
}
|
| 152 |
}
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
if (!el) return;
|
| 157 |
-
try {
|
| 158 |
-
const data = await ddaApi('GET', '/api/dda/hierarchy');
|
| 159 |
-
const zones = data.zones || [];
|
| 160 |
-
if (!zones.length) {
|
| 161 |
-
el.innerHTML = '<p class="dim">No zones seeded.</p>';
|
| 162 |
-
return;
|
| 163 |
-
}
|
| 164 |
-
el.innerHTML = `<p class="dim" style="margin-bottom:0.5rem">DB hierarchy (upload API). Local library uses year folders.</p>` +
|
| 165 |
-
zones.map((z) => {
|
| 166 |
-
const villages = z.villages || [];
|
| 167 |
-
const zoneCount = villages.reduce((s, v) => s + (v.imageCount || 0), 0);
|
| 168 |
-
const villageItems = villages.map((v) =>
|
| 169 |
-
`<li class="dda-hierarchy-village">${v.name}${v.imageCount ? ` <span class="dim">(${v.imageCount})</span>` : ''}</li>`
|
| 170 |
-
).join('');
|
| 171 |
-
return `
|
| 172 |
-
<details class="dda-hierarchy-zone" open>
|
| 173 |
-
<summary>${z.name}${zoneCount ? ` <span class="dim">(${zoneCount})</span>` : ''}</summary>
|
| 174 |
-
<ul class="dda-hierarchy-list">${villageItems || '<li class="dim">No villages</li>'}</ul>
|
| 175 |
-
</details>`;
|
| 176 |
-
}).join('');
|
| 177 |
-
} catch (_) {
|
| 178 |
-
el.innerHTML = '<p class="dim">Zone tree unavailable.</p>';
|
| 179 |
-
}
|
| 180 |
}
|
| 181 |
|
| 182 |
-
window.loadHierarchyTree = loadHierarchyTree;
|
| 183 |
-
|
| 184 |
async function loadLibraryImages() {
|
| 185 |
const grid = document.getElementById('lib-grid');
|
| 186 |
const title = document.getElementById('lib-grid-title');
|
| 187 |
if (!grid) return;
|
| 188 |
const q = document.getElementById('lib-filter')?.value?.trim() || '';
|
| 189 |
const params = new URLSearchParams();
|
| 190 |
-
if (
|
| 191 |
if (q) params.set('q', q);
|
| 192 |
-
if (title) {
|
| 193 |
-
|
| 194 |
-
}
|
| 195 |
try {
|
| 196 |
const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
|
| 197 |
window.ddaState.libraryItems = items;
|
| 198 |
if (!items.length) {
|
| 199 |
-
|
| 200 |
-
grid.innerHTML = hf
|
| 201 |
-
? `<p class="dim">No images on this Space yet. Use <strong>Upload to Space storage</strong> above (2025 / 2026), then click Refresh.</p>`
|
| 202 |
-
: `<p class="dim">No images in ${selectedYear || 'library_sources'}. Copy .tif files into <code>library_sources/${selectedYear || 'YEAR'}/</code> and click Refresh.</p>`;
|
| 203 |
return;
|
| 204 |
}
|
| 205 |
grid.innerHTML = items.map((img) => {
|
| 206 |
-
const thumb = img.thumbUrl
|
|
|
|
| 207 |
return `
|
| 208 |
<div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '"')}" title="${escapeHtml(img.filename)}">
|
| 209 |
${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 210 |
<div class="meta">
|
| 211 |
-
<
|
| 212 |
-
${
|
| 213 |
-
<span class="dim">${formatBytes(img.fileSizeBytes)}</span>
|
| 214 |
</div>
|
| 215 |
</div>`;
|
| 216 |
}).join('');
|
|
@@ -236,7 +203,7 @@ document.getElementById('btn-refresh-lib')?.addEventListener('click', async () =
|
|
| 236 |
btn.disabled = true;
|
| 237 |
try {
|
| 238 |
const data = await rescanLibrary();
|
| 239 |
-
showDdaSuccess(`Library refreshed — ${data.totalImages || 0} image(s)
|
| 240 |
} catch (err) {
|
| 241 |
showDdaError(err.message);
|
| 242 |
} finally {
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
let ddaConfig = null;
|
| 58 |
+
const selectedNode = { id: null, path: '' };
|
|
|
|
| 59 |
|
| 60 |
window.ddaState = {
|
| 61 |
get config() { return ddaConfig; },
|
| 62 |
get localCfg() { return window._localCfg; },
|
| 63 |
+
get selectedNode() { return { ...selectedNode }; },
|
| 64 |
+
get userRole() { return window._ddaUserRole || 'analyst'; },
|
| 65 |
+
set userRole(r) { window._ddaUserRole = r; },
|
| 66 |
+
setNode(n) { selectedNode.id = n.id; selectedNode.path = n.path || ''; },
|
| 67 |
+
clearNode() { selectedNode.id = null; selectedNode.path = ''; },
|
| 68 |
refreshImages: () => loadLibraryImages(),
|
| 69 |
rescan: () => rescanLibrary(),
|
| 70 |
};
|
|
|
|
| 84 |
|
| 85 |
async function rescanLibrary() {
|
| 86 |
const data = await ddaApi('POST', '/api/dda/local/rescan');
|
| 87 |
+
if (typeof renderTree === 'function') renderTree({ tree: data.tree }, []);
|
| 88 |
+
if (typeof populateManageNodeSelect === 'function') populateManageNodeSelect();
|
| 89 |
await loadLibraryImages();
|
| 90 |
return data;
|
| 91 |
}
|
|
|
|
| 93 |
async function initDda() {
|
| 94 |
hideDdaError();
|
| 95 |
try {
|
| 96 |
+
try {
|
| 97 |
+
const me = await ddaApi('GET', '/api/dda/me');
|
| 98 |
+
window.ddaState.userRole = me.role || 'analyst';
|
| 99 |
+
} catch (_) {
|
| 100 |
+
window.ddaState.userRole = 'analyst';
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
ddaConfig = await ddaApi('GET', '/api/dda/config');
|
| 104 |
const localCfg = await ddaApi('GET', '/api/dda/local/config');
|
| 105 |
window._localCfg = localCfg;
|
| 106 |
|
| 107 |
+
document.getElementById('btn-manage-library')?.classList.toggle('hidden', window.ddaState.userRole !== 'admin');
|
| 108 |
+
|
| 109 |
const hint = document.getElementById('lib-config-hint');
|
| 110 |
+
if (hint) hint.textContent = localCfg.geotiffEnabled ? 'GeoTIFF ready' : 'GeoTIFF limited';
|
| 111 |
+
|
|
|
|
|
|
|
| 112 |
const pathEl = document.getElementById('lib-path-display');
|
| 113 |
if (pathEl) {
|
| 114 |
pathEl.textContent = [
|
| 115 |
+
'Storage root:',
|
| 116 |
+
localCfg.storageRoot || localCfg.writablePath || '',
|
| 117 |
+
'Layout: {zone}/{area}/…/Images/file.tif',
|
| 118 |
].filter(Boolean).join('\n');
|
| 119 |
}
|
| 120 |
+
|
| 121 |
const folderPath = document.getElementById('lib-folder-path');
|
| 122 |
if (folderPath) {
|
| 123 |
folderPath.textContent = localCfg.isHosted
|
| 124 |
+
? 'Hugging Face — tree library'
|
| 125 |
+
: `Storage: ${localCfg.storageRoot || ''}`;
|
| 126 |
}
|
| 127 |
+
|
| 128 |
const instr = document.getElementById('lib-instructions');
|
| 129 |
if (instr && localCfg.instructions) instr.textContent = localCfg.instructions;
|
| 130 |
|
| 131 |
+
const uploadLimit = document.getElementById('upload-limit-hint');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
if (uploadLimit && localCfg.maxUploadGb) {
|
| 133 |
+
uploadLimit.textContent = `Select a tree node and image type. Max ${localCfg.maxUploadGb} GB per GeoTIFF.`;
|
| 134 |
}
|
| 135 |
|
| 136 |
+
const resHint = document.getElementById('dda-detect-res-hint');
|
| 137 |
+
if (resHint && localCfg.detectionMaxSide) {
|
| 138 |
+
resHint.textContent = `Detection runs at up to ${localCfg.detectionMaxSide}px per side.`;
|
| 139 |
}
|
| 140 |
|
| 141 |
const urlTab = new URLSearchParams(window.location.search).get('tab');
|
| 142 |
+
if (urlTab) document.querySelector(`.dda-tab[data-tab="${urlTab}"]`)?.click();
|
|
|
|
|
|
|
| 143 |
|
| 144 |
+
if (typeof loadTree === 'function') await loadTree();
|
|
|
|
|
|
|
| 145 |
await loadLibraryImages();
|
|
|
|
| 146 |
} catch (err) {
|
| 147 |
showDdaError(err.message || 'Failed to load library');
|
| 148 |
}
|
| 149 |
}
|
| 150 |
|
| 151 |
+
function selectionTitle() {
|
| 152 |
+
return selectedNode.path || 'All images';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
| 154 |
|
|
|
|
|
|
|
| 155 |
async function loadLibraryImages() {
|
| 156 |
const grid = document.getElementById('lib-grid');
|
| 157 |
const title = document.getElementById('lib-grid-title');
|
| 158 |
if (!grid) return;
|
| 159 |
const q = document.getElementById('lib-filter')?.value?.trim() || '';
|
| 160 |
const params = new URLSearchParams();
|
| 161 |
+
if (selectedNode.id) params.set('node_id', String(selectedNode.id));
|
| 162 |
if (q) params.set('q', q);
|
| 163 |
+
if (title) title.textContent = `Images — ${selectionTitle()}`;
|
| 164 |
+
|
|
|
|
| 165 |
try {
|
| 166 |
const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
|
| 167 |
window.ddaState.libraryItems = items;
|
| 168 |
if (!items.length) {
|
| 169 |
+
grid.innerHTML = `<p class="dim">No images in <strong>${escapeHtml(selectionTitle())}</strong>. Select a node and upload, or use Manage to create folders.</p>`;
|
|
|
|
|
|
|
|
|
|
| 170 |
return;
|
| 171 |
}
|
| 172 |
grid.innerHTML = items.map((img) => {
|
| 173 |
+
const thumb = img.thumbUrl || '';
|
| 174 |
+
const crumb = img.breadcrumb || img.nodePath || img.filename;
|
| 175 |
return `
|
| 176 |
<div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '"')}" title="${escapeHtml(img.filename)}">
|
| 177 |
${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 178 |
<div class="meta">
|
| 179 |
+
<span class="dim dda-crumb">${escapeHtml(crumb)}</span><br/>
|
| 180 |
+
<span class="dim">${img.imageType || ''} · ${formatBytes(img.fileSizeBytes)}</span>
|
|
|
|
| 181 |
</div>
|
| 182 |
</div>`;
|
| 183 |
}).join('');
|
|
|
|
| 203 |
btn.disabled = true;
|
| 204 |
try {
|
| 205 |
const data = await rescanLibrary();
|
| 206 |
+
showDdaSuccess(`Library refreshed — ${data.totalImages || 0} image(s).`);
|
| 207 |
} catch (err) {
|
| 208 |
showDdaError(err.message);
|
| 209 |
} finally {
|
static/js/dda/compare.js
CHANGED
|
@@ -49,7 +49,7 @@ function renderSlotPreview(slotKey, selection) {
|
|
| 49 |
const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
|
| 50 |
wrap.innerHTML = `
|
| 51 |
<img class="dda-slot-preview" src="${thumb}" alt="" />
|
| 52 |
-
<div class="dda-slot-meta">
|
| 53 |
}
|
| 54 |
|
| 55 |
function populateSelects(items) {
|
|
@@ -59,7 +59,7 @@ function populateSelects(items) {
|
|
| 59 |
const current = sel.value;
|
| 60 |
const label = id === 'select-t1' ? '— Choose base image —' : '— Choose comparison image —';
|
| 61 |
sel.innerHTML = `<option value="">${label}</option>` +
|
| 62 |
-
items.map((img) => `<option value="${encodePath(img.path)}">${img.
|
| 63 |
if (current) sel.value = current;
|
| 64 |
});
|
| 65 |
}
|
|
@@ -68,7 +68,7 @@ function setSlot(slotKey, img) {
|
|
| 68 |
if (!img || !img.path) return;
|
| 69 |
const item = {
|
| 70 |
path: img.path,
|
| 71 |
-
|
| 72 |
filename: img.filename,
|
| 73 |
thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
|
| 74 |
};
|
|
@@ -95,7 +95,7 @@ function findLibraryItem(path) {
|
|
| 95 |
const items = ensureDdaState().libraryItems || [];
|
| 96 |
return items.find((i) => i.path === norm) || {
|
| 97 |
path: norm,
|
| 98 |
-
|
| 99 |
filename: norm.split('/').pop(),
|
| 100 |
thumbUrl: thumbUrlFor(norm),
|
| 101 |
};
|
|
@@ -136,7 +136,7 @@ async function loadCompareLibraryGrid() {
|
|
| 136 |
<div class="dda-compare-card dda-card-img${t1Sel}${t2Sel}" data-image-path="${enc}" draggable="true">
|
| 137 |
${thumb ? `<img src="${thumb}" alt="" loading="lazy" draggable="false" />` : '<div class="meta">No preview</div>'}
|
| 138 |
<div class="meta">
|
| 139 |
-
<
|
| 140 |
${safeName}<br/>
|
| 141 |
<span class="dim">${compareFormatBytes(img.fileSizeBytes)}</span>
|
| 142 |
</div>
|
|
@@ -191,7 +191,7 @@ async function openPicker(slotKey) {
|
|
| 191 |
return `
|
| 192 |
<button type="button" class="dda-picker-item" data-path="${enc}">
|
| 193 |
<img src="${img.thumbUrl || thumbUrlFor(img.path)}" alt="" loading="lazy" />
|
| 194 |
-
<span>
|
| 195 |
</button>`;
|
| 196 |
}).join('');
|
| 197 |
list.querySelectorAll('.dda-picker-item').forEach((btn) => {
|
|
|
|
| 49 |
const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
|
| 50 |
wrap.innerHTML = `
|
| 51 |
<img class="dda-slot-preview" src="${thumb}" alt="" />
|
| 52 |
+
<div class="dda-slot-meta">${escapeHtml(selection.label || selection.filename)}</div>`;
|
| 53 |
}
|
| 54 |
|
| 55 |
function populateSelects(items) {
|
|
|
|
| 59 |
const current = sel.value;
|
| 60 |
const label = id === 'select-t1' ? '— Choose base image —' : '— Choose comparison image —';
|
| 61 |
sel.innerHTML = `<option value="">${label}</option>` +
|
| 62 |
+
items.map((img) => `<option value="${encodePath(img.path)}">${escapeHtml(img.breadcrumb || img.nodePath || img.filename)}</option>`).join('');
|
| 63 |
if (current) sel.value = current;
|
| 64 |
});
|
| 65 |
}
|
|
|
|
| 68 |
if (!img || !img.path) return;
|
| 69 |
const item = {
|
| 70 |
path: img.path,
|
| 71 |
+
label: img.breadcrumb || img.nodePath || img.filename,
|
| 72 |
filename: img.filename,
|
| 73 |
thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
|
| 74 |
};
|
|
|
|
| 95 |
const items = ensureDdaState().libraryItems || [];
|
| 96 |
return items.find((i) => i.path === norm) || {
|
| 97 |
path: norm,
|
| 98 |
+
label: norm.split('/').pop(),
|
| 99 |
filename: norm.split('/').pop(),
|
| 100 |
thumbUrl: thumbUrlFor(norm),
|
| 101 |
};
|
|
|
|
| 136 |
<div class="dda-compare-card dda-card-img${t1Sel}${t2Sel}" data-image-path="${enc}" draggable="true">
|
| 137 |
${thumb ? `<img src="${thumb}" alt="" loading="lazy" draggable="false" />` : '<div class="meta">No preview</div>'}
|
| 138 |
<div class="meta">
|
| 139 |
+
<span class="dim">${escapeHtml(img.breadcrumb || img.nodePath || '')}</span><br/>
|
| 140 |
${safeName}<br/>
|
| 141 |
<span class="dim">${compareFormatBytes(img.fileSizeBytes)}</span>
|
| 142 |
</div>
|
|
|
|
| 191 |
return `
|
| 192 |
<button type="button" class="dda-picker-item" data-path="${enc}">
|
| 193 |
<img src="${img.thumbUrl || thumbUrlFor(img.path)}" alt="" loading="lazy" />
|
| 194 |
+
<span>${escapeHtml(img.breadcrumb || img.filename)}</span>
|
| 195 |
</button>`;
|
| 196 |
}).join('');
|
| 197 |
list.querySelectorAll('.dda-picker-item').forEach((btn) => {
|
static/js/dda/library.js
CHANGED
|
@@ -1,37 +1,3 @@
|
|
| 1 |
-
function renderYearTree(years) {
|
| 2 |
-
const tree = document.getElementById('lib-tree');
|
| 3 |
-
if (!tree) return;
|
| 4 |
-
const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
|
| 5 |
-
|
| 6 |
-
const allBtn = `
|
| 7 |
-
<button type="button" class="dda-tree-year ${window.ddaState.selectedYear === null ? 'active' : ''}" data-year="">
|
| 8 |
-
All years
|
| 9 |
-
</button>`;
|
| 10 |
-
|
| 11 |
-
const yearBtns = (years || [])
|
| 12 |
-
.filter((y) => !filter || String(y.year).includes(filter))
|
| 13 |
-
.map((y) => `
|
| 14 |
-
<button type="button" class="dda-tree-year ${window.ddaState.selectedYear === y.year ? 'active' : ''}" data-year="${y.year}">
|
| 15 |
-
${y.year} <span class="dim">(${y.imageCount})</span>
|
| 16 |
-
</button>`).join('');
|
| 17 |
-
|
| 18 |
-
tree.innerHTML = allBtn + yearBtns;
|
| 19 |
-
|
| 20 |
-
tree.querySelectorAll('.dda-tree-year').forEach((btn) => {
|
| 21 |
-
btn.addEventListener('click', () => {
|
| 22 |
-
tree.querySelectorAll('.dda-tree-year').forEach((b) => b.classList.remove('active'));
|
| 23 |
-
btn.classList.add('active');
|
| 24 |
-
const raw = btn.dataset.year;
|
| 25 |
-
window.ddaState.setYear(raw ? parseInt(raw, 10) : null);
|
| 26 |
-
window.ddaState.refreshImages();
|
| 27 |
-
});
|
| 28 |
-
});
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
document.getElementById('lib-tree-search')?.addEventListener('input', () => {
|
| 32 |
-
if (window.ddaState?.years) renderYearTree(window.ddaState.years);
|
| 33 |
-
});
|
| 34 |
-
|
| 35 |
function uploadWithProgress(url, formData, onProgress) {
|
| 36 |
return new Promise((resolve, reject) => {
|
| 37 |
const xhr = new XMLHttpRequest();
|
|
@@ -51,53 +17,47 @@ function uploadWithProgress(url, formData, onProgress) {
|
|
| 51 |
});
|
| 52 |
}
|
| 53 |
|
| 54 |
-
|
| 55 |
-
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
| 56 |
-
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
|
| 57 |
-
return (n / 1024).toFixed(0) + ' KB';
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
document.getElementById('form-hf-upload')?.addEventListener('submit', async (e) => {
|
| 61 |
e.preventDefault();
|
| 62 |
hideDdaError?.();
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
const file = fileInput?.files?.[0];
|
| 65 |
-
if (!
|
| 66 |
-
|
| 67 |
-
return;
|
| 68 |
-
}
|
| 69 |
|
| 70 |
const maxBytes = window.ddaState?.localCfg?.maxGeotiffBytes
|
| 71 |
|| (window.ddaState?.localCfg?.maxGeotiffMb || 5120) * 1024 * 1024;
|
| 72 |
if (file.size > maxBytes) {
|
| 73 |
-
showDdaError?.(`File is ${formatBytes(file.size)} —
|
| 74 |
-
return;
|
| 75 |
}
|
| 76 |
|
| 77 |
const form = new FormData();
|
| 78 |
form.append('file', file);
|
| 79 |
-
form.append('
|
|
|
|
| 80 |
|
| 81 |
-
const btn = document.getElementById('btn-
|
| 82 |
-
const progWrap = document.getElementById('
|
| 83 |
-
const progFill = document.getElementById('
|
| 84 |
-
const progLabel = document.getElementById('
|
| 85 |
|
| 86 |
btn.disabled = true;
|
| 87 |
progWrap?.classList.remove('hidden');
|
| 88 |
if (progFill) progFill.style.width = '0%';
|
| 89 |
|
| 90 |
try {
|
| 91 |
-
await uploadWithProgress(
|
| 92 |
const pct = total ? Math.round((loaded / total) * 100) : 0;
|
| 93 |
if (progFill) progFill.style.width = pct + '%';
|
| 94 |
-
if (progLabel) progLabel.textContent = `Uploading… ${pct}%
|
| 95 |
});
|
| 96 |
-
showDdaSuccess?.('Uploaded
|
| 97 |
fileInput.value = '';
|
| 98 |
await window.ddaState.rescan();
|
| 99 |
} catch (err) {
|
| 100 |
-
showDdaError?.(err.message || 'Upload failed.
|
| 101 |
} finally {
|
| 102 |
btn.disabled = false;
|
| 103 |
setTimeout(() => progWrap?.classList.add('hidden'), 2000);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
function uploadWithProgress(url, formData, onProgress) {
|
| 2 |
return new Promise((resolve, reject) => {
|
| 3 |
const xhr = new XMLHttpRequest();
|
|
|
|
| 17 |
});
|
| 18 |
}
|
| 19 |
|
| 20 |
+
document.getElementById('form-tree-upload')?.addEventListener('submit', async (e) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
e.preventDefault();
|
| 22 |
hideDdaError?.();
|
| 23 |
+
|
| 24 |
+
const nodeId = document.getElementById('upload-node')?.value;
|
| 25 |
+
const fileInput = document.getElementById('upload-file');
|
| 26 |
const file = fileInput?.files?.[0];
|
| 27 |
+
if (!nodeId) return showDdaError?.('Select a tree node.');
|
| 28 |
+
if (!file) return showDdaError?.('Select a file.');
|
|
|
|
|
|
|
| 29 |
|
| 30 |
const maxBytes = window.ddaState?.localCfg?.maxGeotiffBytes
|
| 31 |
|| (window.ddaState?.localCfg?.maxGeotiffMb || 5120) * 1024 * 1024;
|
| 32 |
if (file.size > maxBytes) {
|
| 33 |
+
return showDdaError?.(`File is ${formatBytes(file.size)} — max ${formatBytes(maxBytes)}.`);
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
const form = new FormData();
|
| 37 |
form.append('file', file);
|
| 38 |
+
form.append('image_type', document.getElementById('upload-image-type')?.value || 'GeoTIFF');
|
| 39 |
+
form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
|
| 40 |
|
| 41 |
+
const btn = document.getElementById('btn-tree-upload');
|
| 42 |
+
const progWrap = document.getElementById('upload-progress');
|
| 43 |
+
const progFill = document.getElementById('upload-progress-fill');
|
| 44 |
+
const progLabel = document.getElementById('upload-progress-label');
|
| 45 |
|
| 46 |
btn.disabled = true;
|
| 47 |
progWrap?.classList.remove('hidden');
|
| 48 |
if (progFill) progFill.style.width = '0%';
|
| 49 |
|
| 50 |
try {
|
| 51 |
+
await uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload`, form, (loaded, total) => {
|
| 52 |
const pct = total ? Math.round((loaded / total) * 100) : 0;
|
| 53 |
if (progFill) progFill.style.width = pct + '%';
|
| 54 |
+
if (progLabel) progLabel.textContent = `Uploading… ${pct}%`;
|
| 55 |
});
|
| 56 |
+
showDdaSuccess?.('Uploaded. Refreshing…');
|
| 57 |
fileInput.value = '';
|
| 58 |
await window.ddaState.rescan();
|
| 59 |
} catch (err) {
|
| 60 |
+
showDdaError?.(err.message || 'Upload failed.');
|
| 61 |
} finally {
|
| 62 |
btn.disabled = false;
|
| 63 |
setTimeout(() => progWrap?.classList.add('hidden'), 2000);
|
static/js/dda/tree.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Unlimited-depth tree library UI + admin management. */
|
| 2 |
+
|
| 3 |
+
let treeData = null;
|
| 4 |
+
let imageTypes = [];
|
| 5 |
+
|
| 6 |
+
function isAdmin() {
|
| 7 |
+
return window.ddaState?.userRole === 'admin';
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function renderTreeNodes(nodes, depth = 0) {
|
| 11 |
+
if (!nodes || !nodes.length) return '';
|
| 12 |
+
const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
|
| 13 |
+
const sel = window.ddaState?.selectedNode;
|
| 14 |
+
|
| 15 |
+
return nodes.map((node) => {
|
| 16 |
+
const name = node.name || node.nodeName;
|
| 17 |
+
const children = node.children || [];
|
| 18 |
+
const matches = !filter ||
|
| 19 |
+
name.toLowerCase().includes(filter) ||
|
| 20 |
+
(node.nodePath || '').toLowerCase().includes(filter) ||
|
| 21 |
+
children.some((c) => JSON.stringify(c).toLowerCase().includes(filter));
|
| 22 |
+
|
| 23 |
+
if (!matches && filter) return '';
|
| 24 |
+
|
| 25 |
+
const active = sel?.id === node.id;
|
| 26 |
+
const hasKids = children.length > 0;
|
| 27 |
+
const count = node.imageCount ? ` <span class="dim">(${node.imageCount})</span>` : '';
|
| 28 |
+
const indent = depth > 0 ? ` style="padding-left:${depth * 0.5}rem"` : '';
|
| 29 |
+
|
| 30 |
+
if (hasKids) {
|
| 31 |
+
return `
|
| 32 |
+
<details class="dda-tree-node-wrap" open${indent ? '' : ''}>
|
| 33 |
+
<summary class="dda-tree-node-summary ${active ? 'active' : ''}" data-node-id="${node.id}">
|
| 34 |
+
<button type="button" class="dda-tree-node-btn ${active ? 'active' : ''}" data-node-id="${node.id}"
|
| 35 |
+
data-node-path="${escapeHtml(node.nodePath || name)}">${escapeHtml(name)}${count}</button>
|
| 36 |
+
</summary>
|
| 37 |
+
<div class="dda-tree-children">${renderTreeNodes(children, depth + 1)}</div>
|
| 38 |
+
</details>`;
|
| 39 |
+
}
|
| 40 |
+
return `
|
| 41 |
+
<button type="button" class="dda-tree-node-btn dda-tree-leaf ${active ? 'active' : ''}" data-node-id="${node.id}"
|
| 42 |
+
data-node-path="${escapeHtml(node.nodePath || name)}"${indent}>${escapeHtml(name)}${count}</button>`;
|
| 43 |
+
}).join('');
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function renderTree(tree, types) {
|
| 47 |
+
const el = document.getElementById('lib-tree');
|
| 48 |
+
if (!el) return;
|
| 49 |
+
treeData = tree;
|
| 50 |
+
if (types) imageTypes = types;
|
| 51 |
+
|
| 52 |
+
const nodes = tree?.tree || tree || [];
|
| 53 |
+
const allActive = !window.ddaState?.selectedNode?.id;
|
| 54 |
+
let html = `<button type="button" class="dda-tree-all ${allActive ? 'active' : ''}" id="btn-tree-all">All images</button>`;
|
| 55 |
+
html += renderTreeNodes(nodes);
|
| 56 |
+
if (!nodes.length) {
|
| 57 |
+
html += '<p class="dim">No nodes yet. Admins can use Manage to add zones.</p>';
|
| 58 |
+
}
|
| 59 |
+
el.innerHTML = html;
|
| 60 |
+
|
| 61 |
+
document.getElementById('btn-tree-all')?.addEventListener('click', () => {
|
| 62 |
+
window.ddaState.clearNode();
|
| 63 |
+
window.ddaState.refreshImages();
|
| 64 |
+
renderTree(treeData, imageTypes);
|
| 65 |
+
syncUploadNodeSelect();
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
el.querySelectorAll('.dda-tree-node-btn').forEach((btn) => {
|
| 69 |
+
btn.addEventListener('click', (e) => {
|
| 70 |
+
e.stopPropagation();
|
| 71 |
+
window.ddaState.setNode({
|
| 72 |
+
id: parseInt(btn.dataset.nodeId, 10),
|
| 73 |
+
path: btn.dataset.nodePath,
|
| 74 |
+
});
|
| 75 |
+
window.ddaState.refreshImages();
|
| 76 |
+
renderTree(treeData, imageTypes);
|
| 77 |
+
syncUploadNodeSelect();
|
| 78 |
+
});
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
document.getElementById('lib-tree-search')?.addEventListener('input', () => {
|
| 83 |
+
if (treeData) renderTree(treeData, imageTypes);
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
async function loadTree() {
|
| 87 |
+
const data = await ddaApi('GET', '/api/dda/tree');
|
| 88 |
+
renderTree(data, data.imageTypes);
|
| 89 |
+
populateNodeSelects(data.tree);
|
| 90 |
+
if (typeof populateManageNodeSelect === 'function') populateManageNodeSelect();
|
| 91 |
+
return data;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
window.loadTree = loadTree;
|
| 95 |
+
window.renderTree = renderTree;
|
| 96 |
+
|
| 97 |
+
function flattenNodes(nodes, out = []) {
|
| 98 |
+
(nodes || []).forEach((n) => {
|
| 99 |
+
out.push(n);
|
| 100 |
+
flattenNodes(n.children, out);
|
| 101 |
+
});
|
| 102 |
+
return out;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function populateNodeSelects(tree) {
|
| 106 |
+
const flat = flattenNodes(tree || []);
|
| 107 |
+
const opts = '<option value="">— Select node —</option>' +
|
| 108 |
+
flat.map((n) => `<option value="${n.id}">${escapeHtml(n.nodePath || n.name)}</option>`).join('');
|
| 109 |
+
|
| 110 |
+
['upload-node', 'manage-parent', 'move-parent', 'add-child-parent'].forEach((id) => {
|
| 111 |
+
const el = document.getElementById(id);
|
| 112 |
+
if (el) el.innerHTML = id === 'manage-parent' ? '<option value="">— Root —</option>' + flat.map((n) =>
|
| 113 |
+
`<option value="${n.id}">${escapeHtml(n.nodePath || n.name)}</option>`).join('') : opts;
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
const typeSel = document.getElementById('upload-image-type');
|
| 117 |
+
if (typeSel && imageTypes.length) {
|
| 118 |
+
typeSel.innerHTML = imageTypes.map((t) => `<option value="${t}">${t}</option>`).join('');
|
| 119 |
+
}
|
| 120 |
+
syncUploadNodeSelect();
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function syncUploadNodeSelect() {
|
| 124 |
+
const sel = document.getElementById('upload-node');
|
| 125 |
+
const node = window.ddaState?.selectedNode;
|
| 126 |
+
if (sel && node?.id) sel.value = String(node.id);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* Manage modal */
|
| 130 |
+
function openManage() {
|
| 131 |
+
document.getElementById('dda-manage-modal')?.classList.remove('hidden');
|
| 132 |
+
if (treeData) populateNodeSelects(treeData.tree || treeData);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function closeManage() {
|
| 136 |
+
document.getElementById('dda-manage-modal')?.classList.add('hidden');
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
document.getElementById('btn-manage-library')?.addEventListener('click', openManage);
|
| 140 |
+
document.getElementById('dda-manage-close')?.addEventListener('click', closeManage);
|
| 141 |
+
|
| 142 |
+
document.getElementById('btn-add-node')?.addEventListener('click', async () => {
|
| 143 |
+
const parentVal = document.getElementById('add-child-parent')?.value;
|
| 144 |
+
const name = document.getElementById('add-node-name')?.value?.trim();
|
| 145 |
+
const type = document.getElementById('add-node-type')?.value || 'Folder';
|
| 146 |
+
if (!name) return showDdaError('Enter a node name.');
|
| 147 |
+
const body = { node_name: name, node_type: type, parent_id: parentVal ? parseInt(parentVal, 10) : null };
|
| 148 |
+
try {
|
| 149 |
+
await ddaApi('POST', '/api/dda/tree/nodes', { body: JSON.stringify(body) });
|
| 150 |
+
document.getElementById('add-node-name').value = '';
|
| 151 |
+
showDdaSuccess('Node created.');
|
| 152 |
+
await window.ddaState.rescan();
|
| 153 |
+
} catch (err) {
|
| 154 |
+
showDdaError(err.message);
|
| 155 |
+
}
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
document.getElementById('btn-rename-node')?.addEventListener('click', async () => {
|
| 159 |
+
const id = parseInt(document.getElementById('manage-node-select')?.value || '0', 10);
|
| 160 |
+
const name = document.getElementById('rename-node-name')?.value?.trim();
|
| 161 |
+
if (!id || !name) return showDdaError('Select a node and enter a new name.');
|
| 162 |
+
try {
|
| 163 |
+
await ddaApi('PUT', `/api/dda/tree/nodes/${id}/rename`, { body: JSON.stringify({ node_name: name }) });
|
| 164 |
+
showDdaSuccess('Node renamed.');
|
| 165 |
+
await window.ddaState.rescan();
|
| 166 |
+
} catch (err) {
|
| 167 |
+
showDdaError(err.message);
|
| 168 |
+
}
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
document.getElementById('btn-delete-node')?.addEventListener('click', async () => {
|
| 172 |
+
const id = parseInt(document.getElementById('manage-node-select')?.value || '0', 10);
|
| 173 |
+
if (!id || !confirm('Delete this node? Check delete files if removing images.')) return;
|
| 174 |
+
const deleteFiles = document.getElementById('delete-files-check')?.checked;
|
| 175 |
+
try {
|
| 176 |
+
await ddaApi('DELETE', `/api/dda/tree/nodes/${id}`, { body: JSON.stringify({ delete_files: !!deleteFiles }) });
|
| 177 |
+
showDdaSuccess('Node deleted.');
|
| 178 |
+
await window.ddaState.rescan();
|
| 179 |
+
} catch (err) {
|
| 180 |
+
showDdaError(err.message);
|
| 181 |
+
}
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
document.getElementById('btn-move-node')?.addEventListener('click', async () => {
|
| 185 |
+
const id = parseInt(document.getElementById('manage-node-select')?.value || '0', 10);
|
| 186 |
+
const parentVal = document.getElementById('move-parent')?.value;
|
| 187 |
+
if (!id) return showDdaError('Select a node to move.');
|
| 188 |
+
try {
|
| 189 |
+
await ddaApi('POST', `/api/dda/tree/nodes/${id}/move`, {
|
| 190 |
+
body: JSON.stringify({ parent_id: parentVal ? parseInt(parentVal, 10) : null }),
|
| 191 |
+
});
|
| 192 |
+
showDdaSuccess('Node moved.');
|
| 193 |
+
await window.ddaState.rescan();
|
| 194 |
+
} catch (err) {
|
| 195 |
+
showDdaError(err.message);
|
| 196 |
+
}
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
function populateManageNodeSelect() {
|
| 200 |
+
if (!treeData) return;
|
| 201 |
+
const flat = flattenNodes(treeData.tree || treeData || []);
|
| 202 |
+
const sel = document.getElementById('manage-node-select');
|
| 203 |
+
if (sel) sel.innerHTML = flat.map((n) =>
|
| 204 |
+
`<option value="${n.id}">${escapeHtml(n.nodePath || n.name)}</option>`).join('');
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
document.getElementById('dda-manage-modal')?.addEventListener('click', (e) => {
|
| 208 |
+
if (e.target.id === 'dda-manage-modal') closeManage();
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
window.populateManageNodeSelect = populateManageNodeSelect;
|
templates/index_dda.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
-
<link rel="stylesheet" href="/static/css/dda.css?v=
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
@@ -32,55 +32,62 @@
|
|
| 32 |
<div id="dda-error" class="alert alert-error hidden"></div>
|
| 33 |
<div id="dda-success" class="alert alert-success hidden"></div>
|
| 34 |
|
| 35 |
-
<!-- Tab: Image Library —
|
| 36 |
<section id="tab-library" class="dda-panel active" role="tabpanel">
|
| 37 |
<div class="dda-layout">
|
| 38 |
<aside class="dda-sidebar card">
|
| 39 |
<div class="card-header">
|
| 40 |
-
<h3>
|
| 41 |
-
<
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
<div id="lib-folder-path" class="dda-folder-path dim"></div>
|
| 44 |
-
<input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter
|
| 45 |
<div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
|
| 46 |
-
<div class="dda-hierarchy-wrap">
|
| 47 |
-
<h4 class="dda-hierarchy-title">DDA zones</h4>
|
| 48 |
-
<div id="lib-hierarchy" class="dda-hierarchy"><p class="dim">Loading…</p></div>
|
| 49 |
-
</div>
|
| 50 |
</aside>
|
| 51 |
<main class="dda-main">
|
| 52 |
<div class="card dda-instructions">
|
| 53 |
<div class="card-header">
|
| 54 |
-
<h3>
|
| 55 |
<span class="dim" id="lib-config-hint"></span>
|
| 56 |
</div>
|
| 57 |
-
<p class="sub" id="lib-instructions">
|
| 58 |
-
Copy <code>.tif</code> images into <code>library_sources/YEAR/</code> in your project folder, then click <strong>Refresh</strong>.
|
| 59 |
-
</p>
|
| 60 |
<pre id="lib-path-display" class="dda-path-code"></pre>
|
| 61 |
</div>
|
| 62 |
-
<div class="card
|
| 63 |
<div class="card-header">
|
| 64 |
-
<h3>Upload
|
| 65 |
-
<span class="dim">Required on Hugging Face</span>
|
| 66 |
</div>
|
| 67 |
-
<p class="sub" id="
|
| 68 |
-
<form id="form-
|
| 69 |
<div class="location-row">
|
| 70 |
<div class="form-group">
|
| 71 |
-
<label for="
|
| 72 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
</div>
|
| 74 |
<div class="form-group">
|
| 75 |
-
<label for="
|
| 76 |
-
<input type="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
</div>
|
| 79 |
-
<div id="
|
| 80 |
-
<div class="dda-progress-bar"><div id="
|
| 81 |
-
<span id="
|
| 82 |
</div>
|
| 83 |
-
<button type="submit" class="btn btn-primary" id="btn-
|
| 84 |
</form>
|
| 85 |
</div>
|
| 86 |
<div class="card">
|
|
@@ -88,7 +95,7 @@
|
|
| 88 |
<h3 id="lib-grid-title">Images</h3>
|
| 89 |
<input type="search" id="lib-filter" class="dda-search" placeholder="Search filenames…" />
|
| 90 |
</div>
|
| 91 |
-
<div id="lib-grid" class="dda-grid"><p class="dim">Select a
|
| 92 |
</div>
|
| 93 |
</main>
|
| 94 |
</div>
|
|
@@ -259,8 +266,45 @@
|
|
| 259 |
</div>
|
| 260 |
</div>
|
| 261 |
|
| 262 |
-
<
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
<script src="/static/js/dda/result.js?v=6"></script>
|
| 265 |
<script src="/static/js/dda/compare.js?v=9"></script>
|
| 266 |
<script src="/static/js/dda/reports.js?v=4"></script>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
+
<link rel="stylesheet" href="/static/css/dda.css?v=13" />
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
|
|
| 32 |
<div id="dda-error" class="alert alert-error hidden"></div>
|
| 33 |
<div id="dda-success" class="alert alert-success hidden"></div>
|
| 34 |
|
| 35 |
+
<!-- Tab: Image Library — unlimited-depth tree -->
|
| 36 |
<section id="tab-library" class="dda-panel active" role="tabpanel">
|
| 37 |
<div class="dda-layout">
|
| 38 |
<aside class="dda-sidebar card">
|
| 39 |
<div class="card-header">
|
| 40 |
+
<h3>Library tree</h3>
|
| 41 |
+
<div class="dda-sidebar-actions">
|
| 42 |
+
<button type="button" class="btn btn-secondary btn-sm hidden" id="btn-manage-library">Manage</button>
|
| 43 |
+
<button type="button" class="btn btn-secondary btn-sm" id="btn-refresh-lib">Refresh</button>
|
| 44 |
+
</div>
|
| 45 |
</div>
|
| 46 |
<div id="lib-folder-path" class="dda-folder-path dim"></div>
|
| 47 |
+
<input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter nodes…" />
|
| 48 |
<div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
</aside>
|
| 50 |
<main class="dda-main">
|
| 51 |
<div class="card dda-instructions">
|
| 52 |
<div class="card-header">
|
| 53 |
+
<h3>Tree library</h3>
|
| 54 |
<span class="dim" id="lib-config-hint"></span>
|
| 55 |
</div>
|
| 56 |
+
<p class="sub" id="lib-instructions">Organize images in a configurable zone → area → year → type hierarchy.</p>
|
|
|
|
|
|
|
| 57 |
<pre id="lib-path-display" class="dda-path-code"></pre>
|
| 58 |
</div>
|
| 59 |
+
<div class="card" id="upload-card">
|
| 60 |
<div class="card-header">
|
| 61 |
+
<h3>Upload image</h3>
|
|
|
|
| 62 |
</div>
|
| 63 |
+
<p class="sub" id="upload-limit-hint">Select a tree node and image type.</p>
|
| 64 |
+
<form id="form-tree-upload" class="dda-upload-form">
|
| 65 |
<div class="location-row">
|
| 66 |
<div class="form-group">
|
| 67 |
+
<label for="upload-node">Tree node</label>
|
| 68 |
+
<select id="upload-node" required><option value="">— Select node —</option></select>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="form-group">
|
| 71 |
+
<label for="upload-image-type">Image type</label>
|
| 72 |
+
<select id="upload-image-type">
|
| 73 |
+
<option>GeoTIFF</option><option>Satellite</option><option>Drone</option>
|
| 74 |
+
<option>Orthomosaic</option><option>DEM</option><option>Raster</option>
|
| 75 |
+
</select>
|
| 76 |
</div>
|
| 77 |
<div class="form-group">
|
| 78 |
+
<label for="upload-capture-date">Capture date</label>
|
| 79 |
+
<input type="date" id="upload-capture-date" />
|
| 80 |
+
</div>
|
| 81 |
+
<div class="form-group">
|
| 82 |
+
<label for="upload-file">File</label>
|
| 83 |
+
<input type="file" id="upload-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
|
| 84 |
</div>
|
| 85 |
</div>
|
| 86 |
+
<div id="upload-progress" class="dda-upload-progress hidden">
|
| 87 |
+
<div class="dda-progress-bar"><div id="upload-progress-fill" class="dda-progress-fill"></div></div>
|
| 88 |
+
<span id="upload-progress-label" class="dim">Uploading…</span>
|
| 89 |
</div>
|
| 90 |
+
<button type="submit" class="btn btn-primary" id="btn-tree-upload">Upload to node</button>
|
| 91 |
</form>
|
| 92 |
</div>
|
| 93 |
<div class="card">
|
|
|
|
| 95 |
<h3 id="lib-grid-title">Images</h3>
|
| 96 |
<input type="search" id="lib-filter" class="dda-search" placeholder="Search filenames…" />
|
| 97 |
</div>
|
| 98 |
+
<div id="lib-grid" class="dda-grid"><p class="dim">Select a node in the tree.</p></div>
|
| 99 |
</div>
|
| 100 |
</main>
|
| 101 |
</div>
|
|
|
|
| 266 |
</div>
|
| 267 |
</div>
|
| 268 |
|
| 269 |
+
<div id="dda-manage-modal" class="dda-modal hidden" role="dialog">
|
| 270 |
+
<div class="dda-modal-panel card dda-manage-panel">
|
| 271 |
+
<div class="card-header">
|
| 272 |
+
<h3>Manage tree</h3>
|
| 273 |
+
<button type="button" class="btn btn-secondary btn-sm" id="dda-manage-close">Close</button>
|
| 274 |
+
</div>
|
| 275 |
+
<div class="dda-manage-section">
|
| 276 |
+
<h4>Add node</h4>
|
| 277 |
+
<div class="location-row">
|
| 278 |
+
<select id="add-child-parent"><option value="">— Root —</option></select>
|
| 279 |
+
<input type="text" id="add-node-name" placeholder="Node name" />
|
| 280 |
+
<select id="add-node-type">
|
| 281 |
+
<option>Zone</option><option>Area</option><option>Year</option>
|
| 282 |
+
<option>Satellite Images</option><option>Drone Survey</option><option>Change Detection</option>
|
| 283 |
+
<option>Folder</option>
|
| 284 |
+
</select>
|
| 285 |
+
<button type="button" class="btn btn-primary btn-sm" id="btn-add-node">Add</button>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
<div class="dda-manage-section">
|
| 289 |
+
<h4>Edit / move / delete</h4>
|
| 290 |
+
<select id="manage-node-select"></select>
|
| 291 |
+
<div class="location-row">
|
| 292 |
+
<input type="text" id="rename-node-name" placeholder="Rename to…" />
|
| 293 |
+
<button type="button" class="btn btn-secondary btn-sm" id="btn-rename-node">Rename</button>
|
| 294 |
+
</div>
|
| 295 |
+
<div class="location-row">
|
| 296 |
+
<select id="move-parent"><option value="">— Move to root —</option></select>
|
| 297 |
+
<button type="button" class="btn btn-secondary btn-sm" id="btn-move-node">Move</button>
|
| 298 |
+
</div>
|
| 299 |
+
<label class="dda-check"><input type="checkbox" id="delete-files-check" /> Delete files on disk</label>
|
| 300 |
+
<button type="button" class="btn btn-secondary btn-sm" id="btn-delete-node">Delete node</button>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
|
| 305 |
+
<script src="/static/js/dda/app.js?v=14"></script>
|
| 306 |
+
<script src="/static/js/dda/tree.js?v=1"></script>
|
| 307 |
+
<script src="/static/js/dda/library.js?v=8"></script>
|
| 308 |
<script src="/static/js/dda/result.js?v=6"></script>
|
| 309 |
<script src="/static/js/dda/compare.js?v=9"></script>
|
| 310 |
<script src="/static/js/dda/reports.js?v=4"></script>
|