Publish curated GeoIntel source snapshot
This commit is contained in:
commit
0aae654d6e
764 files changed
+169154
No files matched your search
@@ -0,0 +1,57 @@
|
|||||||
|
# Build context exclusions for deploy/unraid/Dockerfile.all-in-one.
|
||||||
|
# Everything listed here is NOT sent to the Docker daemon.
|
||||||
|
# Keep this aggressive: the all-in-one image only needs
|
||||||
|
# backend/, frontend/, fixtures/, scripts/, deploy/ and VERSION.
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
|
||||||
|
# Node dependencies at every level (root test harness + frontend).
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
frontend/*.tsbuildinfo
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
frontend-src.tar.gz
|
||||||
|
|
||||||
|
backend/.pytest_cache
|
||||||
|
backend/**/*.pyc
|
||||||
|
backend/**/__pycache__
|
||||||
|
|
||||||
|
# Accidental self-copy of the repository into itself.
|
||||||
|
# Without this the build context is duplicated and the build stalls.
|
||||||
|
/geointel
|
||||||
|
|
||||||
|
# Local work products, never needed inside the image.
|
||||||
|
.codex-input
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
artifacts
|
||||||
|
RELEASE_NOTES
|
||||||
|
adr
|
||||||
|
docs
|
||||||
|
checklists
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data and secrets: mounted at runtime, never baked in.
|
||||||
|
storage
|
||||||
|
postgres-data
|
||||||
|
backups
|
||||||
|
datasets/raw
|
||||||
|
datasets/processed
|
||||||
|
datasets/cache
|
||||||
|
exports
|
||||||
|
models
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
# Backend
|
||||||
|
GEOINTEL_ENV=development
|
||||||
|
GEOINTEL_API_PREFIX=/api/v1
|
||||||
|
DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1
|
||||||
|
STORAGE_ROOT=./storage
|
||||||
|
MAX_UPLOAD_MB=500
|
||||||
|
GEOINTEL_MAX_IN_MEMORY_VECTOR_MB=64
|
||||||
|
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
|
||||||
|
|
||||||
|
# Optional single-operator access gate. Store only a PBKDF2-SHA256 hash and
|
||||||
|
# a unique 32+ character signing secret. Guest access is opt-in and should be
|
||||||
|
# enabled only on a dedicated demo installation with bounded public data.
|
||||||
|
GEOINTEL_AUTH_ENABLED=false
|
||||||
|
GEOINTEL_AUTH_REQUIRE_HTTPS=false
|
||||||
|
GEOINTEL_AUTH_USERNAME=
|
||||||
|
GEOINTEL_AUTH_PASSWORD_HASH=
|
||||||
|
GEOINTEL_AUTH_SESSION_SECRET=
|
||||||
|
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
|
||||||
|
GEOINTEL_PUBLIC_BASE_URL=http://localhost:1202
|
||||||
|
GEOINTEL_AUTHENTIK_ISSUER=
|
||||||
|
GEOINTEL_AUTHENTIK_CLIENT_ID=
|
||||||
|
GEOINTEL_AUTHENTIK_CLIENT_SECRET=
|
||||||
|
GEOINTEL_AUTHENTIK_ALLOWED_EMAIL=
|
||||||
|
GEOINTEL_GUEST_ACCESS_ENABLED=false
|
||||||
|
GEOINTEL_GUEST_DISPLAY_NAME=Gast
|
||||||
|
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
|
||||||
|
ORTHOPHOTO_ENABLED=true
|
||||||
|
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
|
||||||
|
SPW_ORTHOPHOTO_WMS_URL=https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer
|
||||||
|
BRUSSELS_ORTHOPHOTO_WMS_URL=https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows
|
||||||
|
ORTHOPHOTO_WMS_LAYER=Ortho
|
||||||
|
ORTHOPHOTO_RESOLUTION_M=1.0
|
||||||
|
ORTHOPHOTO_MIN_SIDE_M=128
|
||||||
|
ORTHOPHOTO_MAX_SIDE_M=1024
|
||||||
|
ORTHOPHOTO_CACHE_TTL_HOURS=24
|
||||||
|
SOURCE_CATALOG_PROBE_ENABLED=true
|
||||||
|
SOURCE_CATALOG_GRB_WFS_URL=https://geo.api.vlaanderen.be/GRB/wfs
|
||||||
|
GRB_ENABLED=true
|
||||||
|
GRB_OGC_API_URL=https://geo.api.vlaanderen.be/GRB/ogc/features/v1
|
||||||
|
GRB_MIN_SIDE_M=10
|
||||||
|
GRB_MAX_SIDE_M=20000
|
||||||
|
GRB_PAGE_SIZE=1000
|
||||||
|
GRB_MAX_PAGES=200
|
||||||
|
GRB_MAX_FEATURES=150000
|
||||||
|
GRB_TIMEOUT_SECONDS=180
|
||||||
|
GRB_MAX_RESPONSE_MB=20
|
||||||
|
GRB_MAX_TOTAL_RESPONSE_MB=256
|
||||||
|
GRB_CACHE_TTL_HOURS=24
|
||||||
|
OFFICIAL_VECTOR_ENABLED=true
|
||||||
|
BWK_WFS_URL=https://geo.api.vlaanderen.be/BWK/wfs
|
||||||
|
DOV_SOIL_WFS_URL=https://www.dov.vlaanderen.be/geoserver/wfs
|
||||||
|
SPW_PICC_ENABLED=true
|
||||||
|
SPW_PICC_MAPSERVER_URL=https://geoservices.wallonie.be/arcgis/rest/services/TOPOGRAPHIE/PICC_VDIFF/MapServer
|
||||||
|
SPW_FLOOD_HAZARD_ENABLED=true
|
||||||
|
SPW_FLOOD_HAZARD_MAPSERVER_URL=https://geoservices.wallonie.be/arcgis/rest/services/EAU/ALEA_INOND/MapServer
|
||||||
|
URBIS_ENABLED=true
|
||||||
|
URBIS_WFS_URL=https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows
|
||||||
|
OFFICIAL_VECTOR_MIN_SIDE_M=10
|
||||||
|
OFFICIAL_VECTOR_MAX_SIDE_M=20000
|
||||||
|
OFFICIAL_VECTOR_PAGE_SIZE=1000
|
||||||
|
OFFICIAL_VECTOR_MAX_PAGES=200
|
||||||
|
OFFICIAL_VECTOR_MAX_FEATURES=100000
|
||||||
|
OFFICIAL_VECTOR_TIMEOUT_SECONDS=180
|
||||||
|
OFFICIAL_VECTOR_MAX_RESPONSE_MB=20
|
||||||
|
OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB=256
|
||||||
|
OFFICIAL_VECTOR_CACHE_TTL_HOURS=24
|
||||||
|
SOURCE_CATALOG_STATBEL_DCAT_URL=https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl
|
||||||
|
SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB=5
|
||||||
|
SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen
|
||||||
|
SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS=10
|
||||||
|
SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB=2
|
||||||
|
SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS=900
|
||||||
|
DHMV_ENABLED=true
|
||||||
|
DHMV_WCS_URL=https://geo.api.vlaanderen.be/DHMV/wcs
|
||||||
|
DHMV_RESOLUTION_M=5.0
|
||||||
|
DHMV_MIN_SIDE_M=10
|
||||||
|
DHMV_MAX_SIDE_M=20000
|
||||||
|
DHMV_MAX_PIXELS=12000000
|
||||||
|
DHMV_TIMEOUT_SECONDS=300
|
||||||
|
DHMV_MAX_RESPONSE_MB=160
|
||||||
|
FLOOD_HAZARD_ENABLED=true
|
||||||
|
FLOOD_HAZARD_WCS_URL=https://geoservice.waterinfo.be/OGRK/wcs
|
||||||
|
FLOOD_HAZARD_RESOLUTION_M=5.0
|
||||||
|
FLOOD_HAZARD_MIN_SIDE_M=10
|
||||||
|
FLOOD_HAZARD_MAX_SIDE_M=20000
|
||||||
|
FLOOD_HAZARD_MAX_PIXELS=12000000
|
||||||
|
FLOOD_HAZARD_TIMEOUT_SECONDS=300
|
||||||
|
FLOOD_HAZARD_MAX_RESPONSE_MB=160
|
||||||
|
BATHYMETRY_PROFILES_ENABLED=true
|
||||||
|
BATHYMETRY_PROFILES_LAYER_URL=https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0
|
||||||
|
BATHYMETRY_WATERCOURSE_LAYER_URL=https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1
|
||||||
|
BATHYMETRY_PROFILES_PAGE_SIZE=1000
|
||||||
|
BATHYMETRY_PROFILES_MAX_PAGES=200
|
||||||
|
BATHYMETRY_PROFILES_MAX_FEATURES=50000
|
||||||
|
BATHYMETRY_PROFILES_TIMEOUT_SECONDS=120
|
||||||
|
BATHYMETRY_PROFILES_MAX_RESPONSE_MB=32
|
||||||
|
MDK_BATHYMETRY_PROBE_ENABLED=true
|
||||||
|
MDK_BATHYMETRY_WCS_URL=https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs
|
||||||
|
MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS=20
|
||||||
|
MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB=4
|
||||||
|
# Bounded MDK acquisition stays fail-closed until the readiness probe reports
|
||||||
|
# "reachable" and an advertised coverage id is configured explicitly.
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_ENABLED=false
|
||||||
|
MDK_BATHYMETRY_COVERAGE_ID=
|
||||||
|
MDK_BATHYMETRY_REQUEST_CRS=EPSG:4326
|
||||||
|
MDK_BATHYMETRY_MAX_BBOX_DEG2=0.25
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS=120
|
||||||
|
MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB=160
|
||||||
|
THEMATIC_RASTER_ENABLED=true
|
||||||
|
THEMATIC_RASTER_WCS_URL=https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs
|
||||||
|
THEMATIC_RASTER_MIN_SIDE_M=100
|
||||||
|
THEMATIC_RASTER_MAX_SIDE_M=60000
|
||||||
|
THEMATIC_RASTER_MAX_PIXELS=30000000
|
||||||
|
THEMATIC_RASTER_TIMEOUT_SECONDS=300
|
||||||
|
THEMATIC_RASTER_MAX_RESPONSE_MB=160
|
||||||
|
WALOUS_ENABLED=true
|
||||||
|
WALOUS_SOURCE_DIR=/app/storage/source-cache/walous
|
||||||
|
WALOUS_ANALYSIS_RESOLUTION_M=10
|
||||||
|
WALOUS_MAX_SIDE_M=60000
|
||||||
|
WALOUS_MAX_PIXELS=36000000
|
||||||
|
YOLO_ENABLED=false
|
||||||
|
YOLO_MODELS_DIR=/app/models
|
||||||
|
YOLO_MODEL_PATH=
|
||||||
|
YOLO_MODEL_ID=yolo-configured
|
||||||
|
YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector
|
||||||
|
YOLO_MODEL_VERSION=
|
||||||
|
YOLO_MODEL_CLASSES=building
|
||||||
|
YOLO_ENFORCE_VALIDATION_SCOPE=false
|
||||||
|
# Required when scope enforcement is enabled. The manifest is bound to exact
|
||||||
|
# model bytes and contains the allowed EPSG:4326 validation geometry.
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_PATH=
|
||||||
|
YOLO_VALIDATION_SCOPE_MANIFEST_SHA256=
|
||||||
|
# Deprecated display metadata; never used as an inference authorization gate.
|
||||||
|
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
|
||||||
|
YOLO_CONFIG_DIR=./storage/ultralytics
|
||||||
|
YOLO_DEVICE=cpu
|
||||||
|
YOLO_REQUIRE_CUDA=false
|
||||||
|
YOLO_IMAGE_SIZE=640
|
||||||
|
YOLO_MAX_TILES=100
|
||||||
|
YOLO_MAX_DETECTIONS=1000
|
||||||
|
YOLO_DUPLICATE_IOU_THRESHOLD=0.5
|
||||||
|
# Tiles per model.predict call. 1 leaves most of a modern GPU idle on a run of
|
||||||
|
# a hundred tiles; raise it until VRAM is the limit.
|
||||||
|
YOLO_BATCH_SIZE=1
|
||||||
|
# Drop boxes truncated by an interior tile edge. Because tiles overlap, the
|
||||||
|
# neighbouring tile saw the same object whole, so the truncated half is a
|
||||||
|
# duplicate and a shape error at once. Boxes on the outer raster edge are kept.
|
||||||
|
YOLO_SUPPRESS_TILE_EDGE_DETECTIONS=true
|
||||||
|
# Intersection over the smaller box. scripts/evaluate_belgium_building_candidate.py
|
||||||
|
# freezes this during calibration (--containment-nms) before the protected test.
|
||||||
|
# Serving a promoted model at a different value means the runtime suppresses
|
||||||
|
# detections its gate counted, so set this to the value the candidate was gated at.
|
||||||
|
YOLO_CONTAINMENT_NMS_THRESHOLD=0.85
|
||||||
|
# Segmentation carries its own value: masks and boxes overlap differently,
|
||||||
|
# so one threshold need not fit both.
|
||||||
|
SEGMENTATION_CONTAINMENT_NMS_THRESHOLD=0.85
|
||||||
|
|
||||||
|
# Local segmentation models. GeoIntel never downloads model weights
|
||||||
|
# automatically; point these to existing local files to enable inference.
|
||||||
|
YOLO_SEG_ENABLED=false
|
||||||
|
YOLO_SEG_MODEL_PATH=
|
||||||
|
YOLO_SEG_MODEL_ID=yolo-seg-configured
|
||||||
|
YOLO_SEG_MODEL_DISPLAY_NAME=Configured YOLO segmentation
|
||||||
|
YOLO_SEG_MODEL_VERSION=
|
||||||
|
SAM_ENABLED=false
|
||||||
|
SAM_MODEL_PATH=
|
||||||
|
SAM_MODEL_ID=sam-configured
|
||||||
|
SAM_MODEL_DISPLAY_NAME=Configured SAM segmentation
|
||||||
|
SAM_MODEL_VERSION=
|
||||||
|
SEGMENTATION_MAX_MASKS_PER_TILE=300
|
||||||
|
SEGMENTATION_DUPLICATE_IOU_THRESHOLD=0.5
|
||||||
|
ENABLE_GRB_WFS=false
|
||||||
|
GRB_WFS_URL=
|
||||||
|
OSM_OVERPASS_URL=https://overpass-api.de/api/interpreter
|
||||||
|
|
||||||
|
# Install backend raster dependencies when needed:
|
||||||
|
# python -m pip install rasterio
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_API_PROXY_TARGET=http://localhost:8000
|
||||||
|
# Leave empty to use the local/demo OpenStreetMap fallback with visible attribution.
|
||||||
|
# Set this to a managed MapLibre style URL for production or heavier tile traffic.
|
||||||
|
VITE_MAP_STYLE_URL=
|
||||||
|
|
||||||
|
# Docker Compose / Unraid
|
||||||
|
GEOINTEL_FRONTEND_PORT=1202
|
||||||
|
GEOINTEL_BACKEND_PORT=8000
|
||||||
|
GEOINTEL_INSTALL_AI=false
|
||||||
|
GEOINTEL_STORAGE_PATH=./storage
|
||||||
|
GEOINTEL_BACKUPS_PATH=./backups
|
||||||
|
GEOINTEL_MODELS_PATH=./models
|
||||||
|
GEOINTEL_POSTGIS_DATA_PATH=./postgres-data
|
||||||
|
GEOINTEL_POSTGRES_DB=geointel
|
||||||
|
GEOINTEL_POSTGRES_USER=geointel
|
||||||
|
GEOINTEL_POSTGRES_PASSWORD=geointel
|
||||||
|
GEOINTEL_CORS_ORIGINS=https://geointel.example.com,http://localhost:1202,http://127.0.0.1:1202
|
||||||
|
GEOINTEL_MAX_UPLOAD_MB=500
|
||||||
|
GEOINTEL_AOI_WORKER_ENABLED=false
|
||||||
|
GEOINTEL_AOI_WORKER_POLL_SECONDS=2
|
||||||
|
# Executes queued detection.run / segmentation.run jobs from POST
|
||||||
|
# /detection/run-async, so tiled GPU inference never blocks an HTTP request.
|
||||||
|
GEOINTEL_ANALYSIS_WORKER_ENABLED=false
|
||||||
|
GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS=2
|
||||||
|
# Analysis consumes only artifacts under STORAGE_ROOT: a tile manifest path
|
||||||
|
# arrives in the request and a manifest entry may name an absolute tile
|
||||||
|
# path, so without this an API field is an unbounded filesystem reference.
|
||||||
|
# Provisioning workflows that stage tiles elsewhere before ingest can opt out.
|
||||||
|
GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS=false
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
deploy/unraid/gosu-setpriv text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.ini text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
|
||||||
|
# Retained audit exports preserve source bytes, including line endings and
|
||||||
|
# tool-emitted whitespace.
|
||||||
|
artifacts/evidence/accuracy/** -text -whitespace
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Local source-transfer archives; preserve them outside version control.
|
||||||
|
/frontend-src.tar.gz
|
||||||
|
|
||||||
|
# Generated evidence, model output and operational snapshots stay outside Git.
|
||||||
|
/artifacts/
|
||||||
|
/.cache/
|
||||||
|
/datasets/raw/*
|
||||||
|
/datasets/processed/*
|
||||||
|
/datasets/cache/*
|
||||||
|
/storage/uploads/*
|
||||||
|
/storage/tiles/*
|
||||||
|
/storage/masks/*
|
||||||
|
/storage/reports/*
|
||||||
|
/storage/exports/*
|
||||||
|
/storage/rasters/*
|
||||||
|
/storage/models/*
|
||||||
|
/storage/operator-data/*
|
||||||
|
/storage/operator-evidence/*
|
||||||
|
/storage/release-evidence/*
|
||||||
|
/storage/previews/*
|
||||||
|
/storage/training/*
|
||||||
|
/storage/ultralytics/*
|
||||||
|
/exports/*
|
||||||
|
/models/*
|
||||||
|
/backend/storage/uploads/*
|
||||||
|
/backend/storage/tiles/*
|
||||||
|
/backend/storage/masks/*
|
||||||
|
/backend/storage/reports/*
|
||||||
|
/backend/storage/exports/*
|
||||||
|
/backups/*
|
||||||
|
/postgres-data/*
|
||||||
|
|
||||||
|
# Keep folder placeholders
|
||||||
|
!**/.gitkeep
|
||||||
|
!**/README.md
|
||||||
|
|
||||||
|
# Runtime-generated operator documentation is not repository documentation.
|
||||||
|
/storage/operator-data/README.md
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Local investigation scratch is never repository input.
|
||||||
|
/.codex-input/
|
||||||
|
/.codex-artifacts/
|
||||||
|
/.playwright-mcp/
|
||||||
|
/.mcp.json
|
||||||
|
/data/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
[[allowlists]]
|
||||||
|
description = "Public registry identifiers that resemble generic API keys"
|
||||||
|
regexTarget = "match"
|
||||||
|
regexes = [
|
||||||
|
'''key="population_density_2019"''',
|
||||||
|
'''source_key="inbo_bwk_natura2000"''',
|
||||||
|
'''product_key": "population_density_2019"''',
|
||||||
|
'''key="spw_flood_hazard_2021"''',
|
||||||
|
'''key="phase2-mutation"''',
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Keep GeoIntel changes reproducible, privacy-preserving and free from generated workspace state.
|
||||||
|
|
||||||
|
- Branch from the protected default branch.
|
||||||
|
- Use synthetic or explicitly redistributable imagery, coordinates and datasets in tests and documentation.
|
||||||
|
- Do not commit `.codex-input` contents, SQLite/WAL/SHM files, archives, contact sheets, model outputs, private maps, credentials or local deployment data.
|
||||||
|
- Document the origin, license and intended use of every external dataset, model and media asset.
|
||||||
|
- Add tests for behavioural changes and run the documented backend, frontend, geospatial and managed validation gates.
|
||||||
|
- Explain data-model, coordinate-system, model, privacy and deployment impact in the pull request.
|
||||||
|
|
||||||
|
Report security issues according to `SECURITY.md`.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 Jens / ITWorx.tech
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
PYTHON_BIN := $(shell command -v python3 >/dev/null 2>&1 && echo python3 || echo python)
|
||||||
|
|
||||||
|
.PHONY: readiness docs fixtures preflight backend-install backend-test backend-dev frontend-install frontend-typecheck frontend-build m13 m14
|
||||||
|
|
||||||
|
readiness:
|
||||||
|
bash scripts/run_readiness_check.sh
|
||||||
|
|
||||||
|
backend-install:
|
||||||
|
cd backend && \
|
||||||
|
$(PYTHON_BIN) -m pip install -e .[dev]
|
||||||
|
|
||||||
|
backend-test:
|
||||||
|
cd backend && \
|
||||||
|
$(PYTHON_BIN) -m pytest
|
||||||
|
|
||||||
|
backend-dev:
|
||||||
|
cd backend && \
|
||||||
|
$(PYTHON_BIN) -m uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
frontend-install:
|
||||||
|
cd frontend && \
|
||||||
|
npm install
|
||||||
|
|
||||||
|
frontend-typecheck:
|
||||||
|
cd frontend && \
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
cd frontend && \
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
docs:
|
||||||
|
$(PYTHON_BIN) scripts/smoke_docs.py
|
||||||
|
|
||||||
|
fixtures:
|
||||||
|
$(PYTHON_BIN) scripts/validate_fixtures.py
|
||||||
|
|
||||||
|
preflight:
|
||||||
|
bash scripts/codex_preflight.sh || true
|
||||||
|
$(PYTHON_BIN) scripts/preimplementation_audit.py
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: m13
|
||||||
|
m13:
|
||||||
|
$(PYTHON_BIN) scripts/validate_m13_codex_assets.py
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: m14
|
||||||
|
m14:
|
||||||
|
$(PYTHON_BIN) scripts/validate_m14_launch_assets.py
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Public source export
|
||||||
|
|
||||||
|
This source snapshot was generated from the private canonical repository at revision `ac977bcd4b7b08bcce38edac2bcb9b05da0555ed`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
|||||||
|
# GeoIntel · public source
|
||||||
|
|
||||||
|
GeoIntel is a GeoAI workbench for Belgium and the Belgian North Sea. This repository is the curated public source snapshot. The private canonical repository contains development history and operational material; public updates are generated from reviewed source revisions.
|
||||||
|
|
||||||
|
The source includes a FastAPI backend, a React frontend, API contracts, tests, and synthetic geospatial fixtures. See `docker-compose.yml` and `.env.example` for local setup. AI models and external geospatial data providers must be configured separately.
|
||||||
|
|
||||||
|
Licensed under [LICENSE](LICENSE). Report vulnerabilities using [SECURITY.md](SECURITY.md).
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported code
|
||||||
|
|
||||||
|
Security fixes target the current `main` release line. Historical research,
|
||||||
|
calibration and development branches are not supported release channels unless
|
||||||
|
explicitly documented otherwise.
|
||||||
|
|
||||||
|
## Reporting vulnerabilities
|
||||||
|
|
||||||
|
Report suspected security issues privately to `security@itworx.tech`. Do not put
|
||||||
|
credentials, access tokens, private infrastructure, precise sensitive
|
||||||
|
locations, proprietary imagery or datasets, model artifacts, production
|
||||||
|
database contents, personal data or exploit-sensitive evidence in a public
|
||||||
|
issue.
|
||||||
|
|
||||||
|
Include the affected commit and component, minimal reproduction conditions
|
||||||
|
using synthetic or public data where possible, expected and observed
|
||||||
|
behaviour, impact, and whether the issue affects ingestion, geospatial
|
||||||
|
processing, authentication or authorization, uploads, archive or path handling,
|
||||||
|
model inference, reports and exports, deployment, data retention or secrets.
|
||||||
|
|
||||||
|
## Repository and data boundary
|
||||||
|
|
||||||
|
Geospatial inputs and derived outputs can reveal people, assets and sensitive
|
||||||
|
locations. Treat screenshots, contact sheets, coordinates, source archives,
|
||||||
|
SQLite/WAL state, model output and exports as potentially sensitive. These
|
||||||
|
belong in controlled external storage, not the source repository.
|
||||||
|
|
||||||
|
Local Codex inputs, calibration databases, runtime data, generated reports and
|
||||||
|
cross-project scratch are not release source. Never commit live `.env` files,
|
||||||
|
private keys, production backups or databases, customer or operator data,
|
||||||
|
unpublished imagery, credentials or private datasets. Public examples and tests
|
||||||
|
must use synthetic or explicitly redistributable material.
|
||||||
|
|
||||||
|
## Disclosure
|
||||||
|
|
||||||
|
Coordinate remediation and disclosure with the repository owner before publishing details that would materially increase exploitation risk.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
geointel_backend.egg-info
|
||||||
|
storage
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
Whitespace-only changes.
@@ -0,0 +1,36 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG GEOINTEL_INSTALL_AI=false
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
gdal-bin \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgdal-dev \
|
||||||
|
libgeos-dev \
|
||||||
|
libproj-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libsm6 \
|
||||||
|
libx11-6 \
|
||||||
|
libxcb1 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender1 \
|
||||||
|
proj-bin \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md /app/
|
||||||
|
COPY app /app/app
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip setuptools
|
||||||
|
RUN extras=".[gis]" \
|
||||||
|
&& if [ "$GEOINTEL_INSTALL_AI" = "true" ]; then extras=".[gis,ai]"; fi \
|
||||||
|
&& pip install --no-cache-dir "$extras"
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
RUN python scripts/gis_import_smoke.py
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
+2035
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql+psycopg://geointel:geointel@localhost:5432/geointel
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
class_ = logging.Formatter
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.base import Base
|
||||||
|
import app.models.entities # noqa: F401
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
${message}
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
${imports}
|
||||||
|
|
||||||
|
revision = ${repr(revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Initial PostGIS schema for Sprint 1 foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
revision = "202601110001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS postgis_topology")
|
||||||
|
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"projects",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("region", sa.Text(), nullable=False, server_default="Kempen"),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"areas",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("geometry", Geometry("MULTIPOLYGON", srid=4326), nullable=False),
|
||||||
|
sa.Column("original_crs", sa.Text(), nullable=True),
|
||||||
|
sa.Column("area_m2", sa.Float(), nullable=True),
|
||||||
|
sa.Column("bbox", Geometry("POLYGON", srid=4326), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"datasets",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("area_id", sa.UUID(as_uuid=True), sa.ForeignKey("areas.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("dataset_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("source", sa.Text(), nullable=False),
|
||||||
|
sa.Column("storage_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("derived_from_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("crs", sa.Text(), nullable=True),
|
||||||
|
sa.Column("bounds_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("resolution_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("bands_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="created"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"dataset_versions",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("storage_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"analysis_runs",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("area_id", sa.UUID(as_uuid=True), sa.ForeignKey("areas.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("analysis_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False),
|
||||||
|
sa.Column("parameters_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"exports",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("export_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("storage_path", sa.Text(), nullable=False),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("ix_areas_geometry", "areas", ["geometry"], postgresql_using="gist")
|
||||||
|
op.create_index("ix_areas_project_id", "areas", ["project_id"])
|
||||||
|
op.create_index("ix_datasets_project_id", "datasets", ["project_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_datasets_project_id", table_name="datasets")
|
||||||
|
op.drop_index("ix_areas_project_id", table_name="areas")
|
||||||
|
op.drop_index("ix_areas_geometry", table_name="areas", postgresql_using="gist")
|
||||||
|
op.drop_table("exports")
|
||||||
|
op.drop_table("analysis_runs")
|
||||||
|
op.drop_table("dataset_versions")
|
||||||
|
op.drop_table("datasets")
|
||||||
|
op.drop_table("areas")
|
||||||
|
op.drop_table("projects")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add dataset storage metadata columns."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202601120001"
|
||||||
|
down_revision = "202601110001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("datasets", sa.Column("original_filename", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("stored_filename", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("content_type", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("size_bytes", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("checksum_sha256", sa.Text(), nullable=True))
|
||||||
|
op.alter_column("datasets", "status", server_default="uploaded")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("datasets", "checksum_sha256")
|
||||||
|
op.drop_column("datasets", "size_bytes")
|
||||||
|
op.drop_column("datasets", "content_type")
|
||||||
|
op.drop_column("datasets", "stored_filename")
|
||||||
|
op.drop_column("datasets", "original_filename")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add lightweight job table for sprint-3 async architecture foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260611212435"
|
||||||
|
down_revision = "202601120001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"jobs",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("job_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="queued"),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("input_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("output_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("parameters_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("result_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("ix_jobs_project_id", "jobs", ["project_id"])
|
||||||
|
op.create_index("ix_jobs_status", "jobs", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_jobs_status", table_name="jobs")
|
||||||
|
op.drop_index("ix_jobs_project_id", table_name="jobs")
|
||||||
|
op.drop_table("jobs")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add dataset reference and provenance metadata columns."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202606120001"
|
||||||
|
down_revision = "20260611212435"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("datasets", sa.Column("dataset_role", sa.Text(), nullable=False, server_default="source"))
|
||||||
|
op.add_column("datasets", sa.Column("source_name", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("reference_layer_name", sa.Text(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("source_metadata", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("provenance_metadata", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("imported_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("datasets", "imported_at")
|
||||||
|
op.drop_column("datasets", "provenance_metadata")
|
||||||
|
op.drop_column("datasets", "source_metadata")
|
||||||
|
op.drop_column("datasets", "reference_layer_name")
|
||||||
|
op.drop_column("datasets", "source_name")
|
||||||
|
op.drop_column("datasets", "dataset_role")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Add Sprint 7A vector feature and QA persistence foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202606120700"
|
||||||
|
down_revision = "202606120001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"vector_features",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("feature_class", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_feature_id", sa.Text(), nullable=True),
|
||||||
|
sa.Column("properties_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("geometry", Geometry("GEOMETRY", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_vector_features_dataset_id", "vector_features", ["dataset_id"])
|
||||||
|
op.create_index("ix_vector_features_geometry", "vector_features", ["geometry"], postgresql_using="gist")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"quality_checks",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("candidate_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("reference_dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("check_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False),
|
||||||
|
sa.Column("score", sa.Float(), nullable=True),
|
||||||
|
sa.Column("parameters_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("findings_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_quality_checks_project_id", "quality_checks", ["project_id"])
|
||||||
|
op.create_index("ix_quality_checks_reference_dataset_id", "quality_checks", ["reference_dataset_id"])
|
||||||
|
op.create_index("ix_quality_checks_candidate_dataset_id", "quality_checks", ["candidate_dataset_id"])
|
||||||
|
op.create_index("ix_quality_checks_analysis_run_id", "quality_checks", ["analysis_run_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"metrics",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("quality_check_id", sa.UUID(as_uuid=True), sa.ForeignKey("quality_checks.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("metric_key", sa.Text(), nullable=False),
|
||||||
|
sa.Column("metric_value", sa.Float(), nullable=True),
|
||||||
|
sa.Column("metric_unit", sa.Text(), nullable=True),
|
||||||
|
sa.Column("label", sa.Text(), nullable=True),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_metrics_quality_check_id", "metrics", ["quality_check_id"])
|
||||||
|
op.create_index("ix_metrics_analysis_run_id", "metrics", ["analysis_run_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_metrics_analysis_run_id", table_name="metrics")
|
||||||
|
op.drop_index("ix_metrics_quality_check_id", table_name="metrics")
|
||||||
|
op.drop_table("metrics")
|
||||||
|
op.drop_index("ix_quality_checks_analysis_run_id", table_name="quality_checks")
|
||||||
|
op.drop_index("ix_quality_checks_candidate_dataset_id", table_name="quality_checks")
|
||||||
|
op.drop_index("ix_quality_checks_reference_dataset_id", table_name="quality_checks")
|
||||||
|
op.drop_index("ix_quality_checks_project_id", table_name="quality_checks")
|
||||||
|
op.drop_table("quality_checks")
|
||||||
|
op.drop_index("ix_vector_features_geometry", table_name="vector_features", postgresql_using="gist")
|
||||||
|
op.drop_index("ix_vector_features_dataset_id", table_name="vector_features")
|
||||||
|
op.drop_table("vector_features")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Add Sprint 8 detection foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202606120800"
|
||||||
|
down_revision = "202606120700"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("analysis_runs", sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("model_name", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("model_version", sa.String(length=120), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("result_json", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("analysis_runs", sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"detections",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("model_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("model_version", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("class_name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("confidence", sa.Float(), nullable=False),
|
||||||
|
sa.Column("geometry", Geometry("GEOMETRY", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("bbox_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("source_tile_path", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("properties_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_detections_project_id", "detections", ["project_id"])
|
||||||
|
op.create_index("ix_detections_dataset_id", "detections", ["dataset_id"])
|
||||||
|
op.create_index("ix_detections_analysis_run_id", "detections", ["analysis_run_id"])
|
||||||
|
op.create_index("ix_detections_class_name", "detections", ["class_name"])
|
||||||
|
op.create_index("ix_detections_geometry", "detections", ["geometry"], postgresql_using="gist")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_detections_geometry", table_name="detections", postgresql_using="gist")
|
||||||
|
op.drop_index("ix_detections_class_name", table_name="detections")
|
||||||
|
op.drop_index("ix_detections_analysis_run_id", table_name="detections")
|
||||||
|
op.drop_index("ix_detections_dataset_id", table_name="detections")
|
||||||
|
op.drop_index("ix_detections_project_id", table_name="detections")
|
||||||
|
op.drop_table("detections")
|
||||||
|
|
||||||
|
op.drop_column("analysis_runs", "created_at")
|
||||||
|
op.drop_column("analysis_runs", "result_json")
|
||||||
|
op.drop_column("analysis_runs", "model_version")
|
||||||
|
op.drop_column("analysis_runs", "model_name")
|
||||||
|
op.drop_column("analysis_runs", "job_id")
|
||||||
|
op.drop_column("analysis_runs", "dataset_id")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Add Sprint 9 segmentation foundation."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202606120900"
|
||||||
|
down_revision = "202606120800"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"segmentations",
|
||||||
|
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("dataset_id", sa.UUID(as_uuid=True), sa.ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("job_id", sa.UUID(as_uuid=True), sa.ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("analysis_run_id", sa.UUID(as_uuid=True), sa.ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("model_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("model_version", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("class_name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("confidence", sa.Float(), nullable=True),
|
||||||
|
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("bbox_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("area_m2", sa.Float(), nullable=True),
|
||||||
|
sa.Column("mask_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_tile_path", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("tile_index", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("properties_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("provenance_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_segmentations_project_id", "segmentations", ["project_id"])
|
||||||
|
op.create_index("ix_segmentations_dataset_id", "segmentations", ["dataset_id"])
|
||||||
|
op.create_index("ix_segmentations_analysis_run_id", "segmentations", ["analysis_run_id"])
|
||||||
|
op.create_index("ix_segmentations_job_id", "segmentations", ["job_id"])
|
||||||
|
op.create_index("ix_segmentations_class_name", "segmentations", ["class_name"])
|
||||||
|
op.create_index("ix_segmentations_geometry", "segmentations", ["geometry"], postgresql_using="gist")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_segmentations_geometry", table_name="segmentations", postgresql_using="gist")
|
||||||
|
op.drop_index("ix_segmentations_class_name", table_name="segmentations")
|
||||||
|
op.drop_index("ix_segmentations_job_id", table_name="segmentations")
|
||||||
|
op.drop_index("ix_segmentations_analysis_run_id", table_name="segmentations")
|
||||||
|
op.drop_index("ix_segmentations_dataset_id", table_name="segmentations")
|
||||||
|
op.drop_index("ix_segmentations_project_id", table_name="segmentations")
|
||||||
|
op.drop_table("segmentations")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Add temporal dataset metadata and durable dataset-version provenance."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607140001"
|
||||||
|
down_revision = "202606120900"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("datasets", sa.Column("temporal_series_key", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("observed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("temporal_granularity", sa.String(length=32), nullable=True))
|
||||||
|
op.add_column("datasets", sa.Column("source_version", sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
op.add_column("dataset_versions", sa.Column("source_version", sa.String(length=120), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("observed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("checksum_sha256", sa.String(length=64), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("source_metadata", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("dataset_versions", sa.Column("provenance_metadata", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"ix_datasets_project_temporal_series_observed",
|
||||||
|
"datasets",
|
||||||
|
["project_id", "temporal_series_key", "observed_at"],
|
||||||
|
)
|
||||||
|
op.create_index("ix_dataset_versions_dataset_version", "dataset_versions", ["dataset_id", "version"], unique=True)
|
||||||
|
op.create_index(
|
||||||
|
"ix_vector_features_dataset_source_feature",
|
||||||
|
"vector_features",
|
||||||
|
["dataset_id", "source_feature_id"],
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_datasets_temporal_valid_range",
|
||||||
|
"datasets",
|
||||||
|
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_dataset_versions_temporal_valid_range",
|
||||||
|
"dataset_versions",
|
||||||
|
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_dataset_versions_temporal_valid_range", "dataset_versions", type_="check")
|
||||||
|
op.drop_constraint("ck_datasets_temporal_valid_range", "datasets", type_="check")
|
||||||
|
op.drop_index("ix_vector_features_dataset_source_feature", table_name="vector_features")
|
||||||
|
op.drop_index("ix_dataset_versions_dataset_version", table_name="dataset_versions")
|
||||||
|
op.drop_index("ix_datasets_project_temporal_series_observed", table_name="datasets")
|
||||||
|
|
||||||
|
op.drop_column("dataset_versions", "provenance_metadata")
|
||||||
|
op.drop_column("dataset_versions", "source_metadata")
|
||||||
|
op.drop_column("dataset_versions", "checksum_sha256")
|
||||||
|
op.drop_column("dataset_versions", "valid_to")
|
||||||
|
op.drop_column("dataset_versions", "valid_from")
|
||||||
|
op.drop_column("dataset_versions", "observed_at")
|
||||||
|
op.drop_column("dataset_versions", "source_version")
|
||||||
|
|
||||||
|
op.drop_column("datasets", "source_version")
|
||||||
|
op.drop_column("datasets", "temporal_granularity")
|
||||||
|
op.drop_column("datasets", "valid_to")
|
||||||
|
op.drop_column("datasets", "valid_from")
|
||||||
|
op.drop_column("datasets", "observed_at")
|
||||||
|
op.drop_column("datasets", "temporal_series_key")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Add durable operator review decisions for detection QA evidence."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607150001"
|
||||||
|
down_revision = "202607140001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"detection_reviews",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("quality_check_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("analysis_run_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("evidence_role", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("evidence_feature_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("detection_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("reference_feature_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("decision", sa.String(length=64), server_default="unreviewed", nullable=False),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("reviewed_by", sa.String(length=120), server_default="operator", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"evidence_role IN ('false_positive', 'false_negative')",
|
||||||
|
name="ck_detection_reviews_evidence_role",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"decision IN ('confirmed_model_false_positive', 'confirmed_model_false_negative', "
|
||||||
|
"'reference_gap_or_change', 'qa_alignment_mismatch', "
|
||||||
|
"'imagery_obscured_or_uncertain', 'uncertain', 'unreviewed')",
|
||||||
|
name="ck_detection_reviews_decision",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["analysis_run_id"], ["analysis_runs.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["detection_id"], ["detections.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["quality_check_id"], ["quality_checks.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["reference_feature_id"], ["vector_features.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"quality_check_id",
|
||||||
|
"evidence_role",
|
||||||
|
"evidence_feature_id",
|
||||||
|
name="uq_detection_reviews_evidence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_detection_reviews_project_id", "detection_reviews", ["project_id"])
|
||||||
|
op.create_index("ix_detection_reviews_quality_check_id", "detection_reviews", ["quality_check_id"])
|
||||||
|
op.create_index("ix_detection_reviews_analysis_run_id", "detection_reviews", ["analysis_run_id"])
|
||||||
|
op.create_index("ix_detection_reviews_decision", "detection_reviews", ["decision"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_detection_reviews_decision", table_name="detection_reviews")
|
||||||
|
op.drop_index("ix_detection_reviews_analysis_run_id", table_name="detection_reviews")
|
||||||
|
op.drop_index("ix_detection_reviews_quality_check_id", table_name="detection_reviews")
|
||||||
|
op.drop_index("ix_detection_reviews_project_id", table_name="detection_reviews")
|
||||||
|
op.drop_table("detection_reviews")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Index partitioned vector features by dataset and municipality."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607160001"
|
||||||
|
down_revision = "202607150001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"ix_vector_features_dataset_municipality",
|
||||||
|
"vector_features",
|
||||||
|
["dataset_id", sa.text("(properties_json ->> 'municipality')")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_vector_features_dataset_municipality", table_name="vector_features")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Add resumable AOI parent and partition operations."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607260001"
|
||||||
|
down_revision = "202607160001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"aoi_operations",
|
||||||
|
sa.Column("id", sa.UUID(), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.UUID(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("area_id", sa.UUID(), sa.ForeignKey("areas.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("parent_job_id", sa.UUID(), sa.ForeignKey("jobs.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("operation_type", sa.String(128), nullable=False),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False),
|
||||||
|
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("request_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("plan_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("result_json", sa.JSON()),
|
||||||
|
sa.Column("error_message", sa.Text()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.CheckConstraint("status IN ('queued', 'running', 'partial', 'success', 'failed', 'cancelled')", name="ck_aoi_operations_status"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_aoi_operations_project_status", "aoi_operations", ["project_id", "status"])
|
||||||
|
op.create_index("ix_aoi_operations_geometry", "aoi_operations", ["geometry"], postgresql_using="gist")
|
||||||
|
op.create_table(
|
||||||
|
"aoi_operation_partitions",
|
||||||
|
sa.Column("id", sa.UUID(), primary_key=True),
|
||||||
|
sa.Column("operation_id", sa.UUID(), sa.ForeignKey("aoi_operations.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("child_job_id", sa.UUID(), sa.ForeignKey("jobs.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("partition_key", sa.String(255), nullable=False),
|
||||||
|
sa.Column("provider_key", sa.String(120), nullable=False),
|
||||||
|
sa.Column("product_key", sa.String(120), nullable=False),
|
||||||
|
sa.Column("ordinal", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False),
|
||||||
|
sa.Column("geometry", Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False),
|
||||||
|
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
|
||||||
|
sa.Column("checkpoint_json", sa.JSON()),
|
||||||
|
sa.Column("result_json", sa.JSON()),
|
||||||
|
sa.Column("error_message", sa.Text()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.CheckConstraint("status IN ('queued', 'running', 'success', 'failed', 'skipped')", name="ck_aoi_operation_partitions_status"),
|
||||||
|
sa.UniqueConstraint("operation_id", "partition_key", name="uq_aoi_operation_partition_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_aoi_operation_partitions_operation_status", "aoi_operation_partitions", ["operation_id", "status"])
|
||||||
|
op.create_index("ix_aoi_operation_partitions_geometry", "aoi_operation_partitions", ["geometry"], postgresql_using="gist")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("aoi_operation_partitions")
|
||||||
|
op.drop_table("aoi_operations")
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
"""Configure the immutable model source registry for governed snapshots.
|
||||||
|
|
||||||
|
The phase-2 seed intentionally registered model artifacts as unknown. Runtime
|
||||||
|
model provenance now records exact immutable snapshots, so the server-owned
|
||||||
|
registry must advertise that configured capability. The write guard is only
|
||||||
|
disabled for this narrowly-scoped, versioned migration and is restored in the
|
||||||
|
same transaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202608230001"
|
||||||
|
down_revision = "202608010001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_status(*, freshness_status: str, ingest_status: str) -> None:
|
||||||
|
op.execute("ALTER TABLE source_registry DISABLE TRIGGER trg_source_registry_write_guard")
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE source_registry
|
||||||
|
SET freshness_status = '{freshness_status}',
|
||||||
|
ingest_status = '{ingest_status}',
|
||||||
|
registry_metadata_json = (
|
||||||
|
registry_metadata_json::jsonb ||
|
||||||
|
'{{"runtime_model_contract": {{"key": "geointel.model.pytorch", "version": "1.0.0"}}}}'::jsonb
|
||||||
|
)::json,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE source_key = 'model'
|
||||||
|
AND registry_metadata_json ->> 'registry_owner' = 'server'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE source_registry ENABLE TRIGGER trg_source_registry_write_guard")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_set_status(freshness_status="current", ingest_status="configured")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_set_status(freshness_status="unknown", ingest_status="registered")
|
||||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
|||||||
|
from app.models.entities import AnalysisRun, Area, Dataset, Export, Project
|
||||||
|
|
||||||
|
__all__ = ["AnalysisRun", "Area", "Dataset", "Export", "Project"]
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
|
||||||
|
|
||||||
|
def guest_project_scope(request: Request) -> UUID | None:
|
||||||
|
principal = getattr(request.state, "auth_principal", None)
|
||||||
|
if getattr(principal, "role", None) != "guest":
|
||||||
|
return None
|
||||||
|
project_id = getattr(principal, "project_id", None)
|
||||||
|
if isinstance(project_id, UUID):
|
||||||
|
return project_id
|
||||||
|
raise AppError(
|
||||||
|
code="GUEST_PROJECT_SCOPE_REQUIRED",
|
||||||
|
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_guest_project_scope(request: Request, project_id: UUID) -> None:
|
||||||
|
guest_project_id = guest_project_scope(request)
|
||||||
|
if guest_project_id is not None and project_id != guest_project_id:
|
||||||
|
raise AppError(
|
||||||
|
code="GUEST_PROJECT_SCOPE_REQUIRED",
|
||||||
|
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def guest_scoped_project_filter(
|
||||||
|
request: Request,
|
||||||
|
requested_project_id: UUID | None,
|
||||||
|
) -> UUID | None:
|
||||||
|
guest_project_id = guest_project_scope(request)
|
||||||
|
if guest_project_id is None:
|
||||||
|
return requested_project_id
|
||||||
|
if requested_project_id is not None:
|
||||||
|
assert_guest_project_scope(request, requested_project_id)
|
||||||
|
return guest_project_id
|
||||||
Whitespace-only changes.
@@ -0,0 +1,15 @@
|
|||||||
|
__all__ = [
|
||||||
|
"analysis",
|
||||||
|
"areas",
|
||||||
|
"assistant",
|
||||||
|
"auth",
|
||||||
|
"datasets",
|
||||||
|
"exports",
|
||||||
|
"external",
|
||||||
|
"health",
|
||||||
|
"jobs",
|
||||||
|
"projects",
|
||||||
|
"qa",
|
||||||
|
"source_registry",
|
||||||
|
"temporal",
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.guest_scope import assert_guest_project_scope
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Dataset
|
||||||
|
from app.schemas import Envelope, JobRead
|
||||||
|
from app.schemas.analysis import ChangeDetectionRequest
|
||||||
|
from app.services.change_detection_service import ChangeDetectionService
|
||||||
|
from app.services.job_service import JobService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/analysis", tags=["analysis"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-detection", response_model=Envelope[JobRead])
|
||||||
|
def run_change_detection(
|
||||||
|
payload: ChangeDetectionRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
source_dataset = db.get(Dataset, payload.source_dataset_id)
|
||||||
|
if not source_dataset:
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="Source dataset not found", status_code=404)
|
||||||
|
assert_guest_project_scope(request, source_dataset.project_id)
|
||||||
|
ChangeDetectionService._get_project_vector_dataset(db, payload.source_dataset_id, source_dataset.project_id, "Source")
|
||||||
|
ChangeDetectionService._get_project_vector_dataset(db, payload.target_dataset_id, source_dataset.project_id, "Target")
|
||||||
|
job = JobService.run_sync_job(
|
||||||
|
db=db,
|
||||||
|
project_id=source_dataset.project_id,
|
||||||
|
job_type="analysis.change-detection",
|
||||||
|
parameters=payload.model_dump(mode="json"),
|
||||||
|
input_dataset_id=payload.source_dataset_id,
|
||||||
|
operation=lambda: ChangeDetectionService.compare_vector_datasets(
|
||||||
|
db=db,
|
||||||
|
project_id=source_dataset.project_id,
|
||||||
|
source_dataset_id=payload.source_dataset_id,
|
||||||
|
target_dataset_id=payload.target_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
modified_threshold=payload.modified_threshold,
|
||||||
|
include_unchanged=payload.include_unchanged,
|
||||||
|
bbox=payload.bbox.model_dump() if payload.bbox is not None else None,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
preview_limit=payload.preview_limit,
|
||||||
|
).model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
return envelope(job)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas.aoi_operation import AoiOperationCreate, AoiOperationList, AoiOperationRead, AoiPartitionCheckpoint, AoiPartitionComplete, AoiPartitionFail, AoiPartitionRead
|
||||||
|
from app.schemas.common import Envelope
|
||||||
|
from app.services.aoi_operation_service import AoiOperationService
|
||||||
|
from app.services.aoi_operation_executor import AoiOperationExecutor
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/aoi-operations", tags=["aoi-operations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201, response_model=Envelope[AoiOperationRead])
|
||||||
|
def create_operation(project_id: UUID, payload: AoiOperationCreate, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.create(db, project_id, payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=Envelope[AoiOperationList])
|
||||||
|
def list_operations(project_id: UUID, limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.list(db, project_id, limit))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{operation_id}", response_model=Envelope[AoiOperationRead])
|
||||||
|
def read_operation(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.read(db, project_id, operation_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{operation_id}/partitions/claim", response_model=Envelope[AoiPartitionRead | None])
|
||||||
|
def claim_partition(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
partition = AoiOperationService.claim_next(db, project_id, operation_id)
|
||||||
|
return envelope(AoiPartitionRead.model_validate(partition).model_dump() if partition else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{operation_id}/execute-next", response_model=Envelope[AoiOperationRead])
|
||||||
|
def execute_next_partition(project_id: UUID, operation_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationExecutor.execute_next(db, project_id, operation_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{operation_id}/partitions/{partition_id}/checkpoint", response_model=Envelope[AoiPartitionRead])
|
||||||
|
def checkpoint_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionCheckpoint, db: Session = Depends(get_db)):
|
||||||
|
partition = AoiOperationService.checkpoint(db, project_id, operation_id, partition_id, payload.checkpoint_json)
|
||||||
|
return envelope(AoiPartitionRead.model_validate(partition).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{operation_id}/partitions/{partition_id}/complete", response_model=Envelope[AoiOperationRead])
|
||||||
|
def complete_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionComplete, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.complete(db, project_id, operation_id, partition_id, payload.result_json, payload.skipped))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{operation_id}/partitions/{partition_id}/fail", response_model=Envelope[AoiOperationRead])
|
||||||
|
def fail_partition(project_id: UUID, operation_id: UUID, partition_id: UUID, payload: AoiPartitionFail, db: Session = Depends(get_db)):
|
||||||
|
return envelope(AoiOperationService.fail(db, project_id, operation_id, partition_id, payload.error_message, payload.retryable, payload.details))
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Area
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate, MunicipalitySearchList
|
||||||
|
from app.services.area_service import AreaService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=Envelope[AreaList])
|
||||||
|
def list_areas(
|
||||||
|
project_id: UUID,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
areas, total = AreaService.list_areas(db, project_id=project_id, limit=limit, offset=offset)
|
||||||
|
return envelope({"items": [AreaService.serialize_area(area) for area in areas], "total": total, "limit": limit, "offset": offset})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201, response_model=Envelope[AreaRead])
|
||||||
|
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
|
||||||
|
area = AreaService.create_area(db, project_id, payload)
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/municipalities", response_model=Envelope[MunicipalitySearchList])
|
||||||
|
def search_municipalities(
|
||||||
|
project_id: UUID,
|
||||||
|
query: str = Query(default="", max_length=120),
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
items, total = AreaService.search_municipalities(db, project_id, query, limit)
|
||||||
|
return envelope({"items": items, "total": total})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/municipalities/{niscode}/activate", response_model=Envelope[AreaRead])
|
||||||
|
def activate_municipality(project_id: UUID, niscode: str, db: Session = Depends(get_db)):
|
||||||
|
area = AreaService.activate_municipality(db, project_id, niscode)
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{area_id}", response_model=Envelope[AreaRead])
|
||||||
|
def get_area(
|
||||||
|
project_id: UUID,
|
||||||
|
area_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
area = AreaService.get_area(db, area_id)
|
||||||
|
if area.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Area not found")
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{area_id}", response_model=Envelope[AreaRead])
|
||||||
|
def update_area(
|
||||||
|
project_id: UUID,
|
||||||
|
area_id: UUID,
|
||||||
|
payload: AreaUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = db.get(Area, area_id)
|
||||||
|
if not existing or existing.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Area not found")
|
||||||
|
area = AreaService.update_area(db, area_id, payload)
|
||||||
|
return envelope(AreaService.serialize_area(area))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.assistant import (
|
||||||
|
AssistantModelList,
|
||||||
|
AssistantQueryRequest,
|
||||||
|
AssistantQueryResponse,
|
||||||
|
AssistantStatus,
|
||||||
|
)
|
||||||
|
from app.services.geo_assistant_service import GeoAssistantService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["assistant"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assistant/status", response_model=Envelope[AssistantStatus])
|
||||||
|
def assistant_status() -> dict:
|
||||||
|
return envelope(GeoAssistantService().status().model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assistant/models", response_model=Envelope[AssistantModelList])
|
||||||
|
def assistant_models() -> dict:
|
||||||
|
service = GeoAssistantService()
|
||||||
|
models = service.list_models()
|
||||||
|
return envelope(
|
||||||
|
{
|
||||||
|
"items": [model.model_dump() for model in models],
|
||||||
|
"total": len(models),
|
||||||
|
"default_model": service.settings.ollama_default_model,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/assistant/query",
|
||||||
|
response_model=Envelope[AssistantQueryResponse],
|
||||||
|
)
|
||||||
|
def assistant_query(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: AssistantQueryRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(GeoAssistantService().query(db, project_id=project_id, payload=payload).model_dump())
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from ipaddress import ip_address, ip_network
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
|
||||||
|
from app.services.auth_service import AuthPrincipal, AuthService
|
||||||
|
from app.services.authentik_oidc_service import AuthentikOidcService
|
||||||
|
from app.services.demo_workflow_service import DemoWorkflowService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
COOKIE_NAME = "geointel_session"
|
||||||
|
OIDC_FLOW_COOKIE_NAME = "geointel_oidc_flow"
|
||||||
|
logger = logging.getLogger("geointel.auth")
|
||||||
|
_TRUSTED_PROXY_NETWORKS = (
|
||||||
|
ip_network("127.0.0.0/8"),
|
||||||
|
ip_network("::1/128"),
|
||||||
|
ip_network("172.16.0.0/12"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_is_trusted_proxy(request: Request) -> bool:
|
||||||
|
if request.client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
peer_address = ip_address(request.client.host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(peer_address in network for network in _TRUSTED_PROXY_NETWORKS)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_is_https(request: Request) -> bool:
|
||||||
|
if request.url.scheme == "https":
|
||||||
|
return True
|
||||||
|
if not _peer_is_trusted_proxy(request):
|
||||||
|
return False
|
||||||
|
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
||||||
|
return forwarded_proto == "https"
|
||||||
|
|
||||||
|
|
||||||
|
def _client_host(request: Request) -> str:
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if not _peer_is_trusted_proxy(request):
|
||||||
|
return peer
|
||||||
|
forwarded = request.headers.get("x-real-ip", "").strip()
|
||||||
|
if not forwarded:
|
||||||
|
return peer
|
||||||
|
try:
|
||||||
|
return str(ip_address(forwarded))
|
||||||
|
except ValueError:
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
def _session_from_principal(
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
*,
|
||||||
|
guest_access_enabled: bool,
|
||||||
|
authentik_enabled: bool,
|
||||||
|
) -> AuthSession:
|
||||||
|
return AuthSession(
|
||||||
|
authentication_required=True,
|
||||||
|
authenticated=True,
|
||||||
|
username=principal.username,
|
||||||
|
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
|
||||||
|
role=principal.role,
|
||||||
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
|
guest_project_id=principal.project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_payload(request: Request) -> AuthSession:
|
||||||
|
settings = get_settings()
|
||||||
|
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
|
||||||
|
authentik_enabled = AuthentikOidcService(settings).enabled
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
return AuthSession(
|
||||||
|
authentication_required=False,
|
||||||
|
authenticated=True,
|
||||||
|
guest_access_enabled=False,
|
||||||
|
authentik_enabled=False,
|
||||||
|
)
|
||||||
|
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
|
||||||
|
if principal is None:
|
||||||
|
return AuthSession(
|
||||||
|
authentication_required=True,
|
||||||
|
authenticated=False,
|
||||||
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
|
)
|
||||||
|
return _session_from_principal(
|
||||||
|
principal,
|
||||||
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
token: str,
|
||||||
|
max_age: int,
|
||||||
|
) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=max_age,
|
||||||
|
httponly=True,
|
||||||
|
secure=_request_is_https(request),
|
||||||
|
samesite="strict",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", response_model=AuthSessionEnvelope)
|
||||||
|
def session(request: Request) -> AuthSessionEnvelope:
|
||||||
|
return AuthSessionEnvelope(data=_session_payload(request))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=AuthSessionEnvelope)
|
||||||
|
def login(payload: AuthLoginRequest, request: Request, response: Response) -> AuthSessionEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
raise AppError(
|
||||||
|
code="AUTHENTICATION_DISABLED",
|
||||||
|
message="Operator authentication is not enabled on this runtime",
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
if settings.auth_require_https and not _request_is_https(request):
|
||||||
|
raise AppError(
|
||||||
|
code="AUTH_HTTPS_REQUIRED",
|
||||||
|
message="Operator authentication requires HTTPS on this runtime",
|
||||||
|
status_code=status.HTTP_426_UPGRADE_REQUIRED,
|
||||||
|
)
|
||||||
|
client_host = _client_host(request)
|
||||||
|
throttle_key = f"{client_host}:{payload.username.casefold()}"
|
||||||
|
retry_after = AuthService.retry_after_seconds(throttle_key)
|
||||||
|
if retry_after:
|
||||||
|
raise AppError(
|
||||||
|
code="LOGIN_RATE_LIMITED",
|
||||||
|
message="Te veel mislukte aanmeldpogingen. Probeer later opnieuw.",
|
||||||
|
details={"retry_after_seconds": retry_after},
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
if not AuthService.credentials_match(payload.username, payload.password, settings):
|
||||||
|
AuthService.record_failure(throttle_key)
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_CREDENTIALS",
|
||||||
|
message="Gebruikersnaam of wachtwoord is onjuist.",
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
AuthService.clear_failures(throttle_key)
|
||||||
|
token = AuthService.create_session_token(payload.username, settings)
|
||||||
|
principal = AuthService.verify_session_token(token, settings)
|
||||||
|
if principal is None: # pragma: no cover - defensive invariant
|
||||||
|
raise AppError(
|
||||||
|
code="SESSION_CREATION_FAILED",
|
||||||
|
message="De beveiligde sessie kon niet worden aangemaakt.",
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
_set_session_cookie(
|
||||||
|
request=request,
|
||||||
|
response=response,
|
||||||
|
token=token,
|
||||||
|
max_age=settings.auth_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
return AuthSessionEnvelope(
|
||||||
|
data=_session_from_principal(
|
||||||
|
principal,
|
||||||
|
guest_access_enabled=settings.guest_access_enabled,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authentik/start")
|
||||||
|
def authentik_start(request: Request) -> RedirectResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
service = AuthentikOidcService(settings)
|
||||||
|
try:
|
||||||
|
location, flow = service.start()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Authentik authorization start failed: %s", type(exc).__name__)
|
||||||
|
raise AppError(
|
||||||
|
code="AUTHENTIK_UNAVAILABLE",
|
||||||
|
message="Authentik is momenteel niet beschikbaar.",
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
) from exc
|
||||||
|
response = RedirectResponse(location, status_code=status.HTTP_302_FOUND)
|
||||||
|
response.set_cookie(
|
||||||
|
OIDC_FLOW_COOKIE_NAME,
|
||||||
|
flow,
|
||||||
|
max_age=600,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path=f"{settings.api_prefix}/auth/authentik",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authentik/callback")
|
||||||
|
def authentik_callback(
|
||||||
|
request: Request,
|
||||||
|
code: str = "",
|
||||||
|
state: str = "",
|
||||||
|
) -> RedirectResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
service = AuthentikOidcService(settings)
|
||||||
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
|
try:
|
||||||
|
service.finish(
|
||||||
|
code=code,
|
||||||
|
state=state,
|
||||||
|
flow_cookie=request.cookies.get(OIDC_FLOW_COOKIE_NAME, ""),
|
||||||
|
)
|
||||||
|
token = AuthService.create_session_token(
|
||||||
|
settings.auth_username or "operator",
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Authentik callback rejected: %s", type(exc).__name__)
|
||||||
|
response = RedirectResponse(
|
||||||
|
f"{base_url}/?authentik=error",
|
||||||
|
status_code=status.HTTP_302_FOUND,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = RedirectResponse(
|
||||||
|
f"{base_url}/",
|
||||||
|
status_code=status.HTTP_302_FOUND,
|
||||||
|
)
|
||||||
|
_set_session_cookie(
|
||||||
|
request=request,
|
||||||
|
response=response,
|
||||||
|
token=token,
|
||||||
|
max_age=settings.auth_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
response.delete_cookie(
|
||||||
|
OIDC_FLOW_COOKIE_NAME,
|
||||||
|
path=f"{settings.api_prefix}/auth/authentik",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/guest", response_model=AuthSessionEnvelope)
|
||||||
|
def guest_login(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AuthSessionEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.auth_enabled or not settings.guest_access_enabled:
|
||||||
|
raise AppError(
|
||||||
|
code="GUEST_ACCESS_DISABLED",
|
||||||
|
message="Gasttoegang is niet ingeschakeld op deze GeoIntel-installatie.",
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
client_host = _client_host(request)
|
||||||
|
retry_after = AuthService.consume_guest_request(
|
||||||
|
f"guest-login:{client_host}",
|
||||||
|
max_requests=settings.guest_login_requests_per_minute,
|
||||||
|
)
|
||||||
|
if retry_after:
|
||||||
|
raise AppError(
|
||||||
|
code="GUEST_LOGIN_RATE_LIMITED",
|
||||||
|
message="Too many guest sessions were requested. Try again later.",
|
||||||
|
details={"retry_after_seconds": retry_after},
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
demo = DemoWorkflowService.seed(db)
|
||||||
|
token = AuthService.create_session_token(
|
||||||
|
settings.guest_display_name,
|
||||||
|
settings,
|
||||||
|
role="guest",
|
||||||
|
project_id=demo.project_id,
|
||||||
|
ttl_seconds=settings.guest_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
principal = AuthService.verify_session_token(token, settings)
|
||||||
|
if principal is None: # pragma: no cover - defensive invariant
|
||||||
|
raise AppError(
|
||||||
|
code="SESSION_CREATION_FAILED",
|
||||||
|
message="De tijdelijke gastensessie kon niet worden aangemaakt.",
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
_set_session_cookie(
|
||||||
|
request=request,
|
||||||
|
response=response,
|
||||||
|
token=token,
|
||||||
|
max_age=settings.guest_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
return AuthSessionEnvelope(
|
||||||
|
data=_session_from_principal(
|
||||||
|
principal,
|
||||||
|
guest_access_enabled=True,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=AuthSessionEnvelope)
|
||||||
|
def logout(response: Response) -> AuthSessionEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
|
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
|
||||||
|
return AuthSessionEnvelope(
|
||||||
|
data=AuthSession(
|
||||||
|
authentication_required=settings.auth_enabled,
|
||||||
|
authenticated=not settings.auth_enabled,
|
||||||
|
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.demo import DemoWorkflowResponse
|
||||||
|
from app.services.demo_workflow_service import DemoWorkflowService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/demo", tags=["demo"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/workflow",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
response_model=Envelope[DemoWorkflowResponse],
|
||||||
|
)
|
||||||
|
def seed_demo_workflow(db: Session = Depends(get_db)) -> dict:
|
||||||
|
result: DemoWorkflowResponse = DemoWorkflowService.seed(db)
|
||||||
|
return envelope(result.model_dump())
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.guest_scope import (
|
||||||
|
assert_guest_project_scope,
|
||||||
|
guest_project_scope,
|
||||||
|
guest_scoped_project_filter,
|
||||||
|
)
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import (
|
||||||
|
AnalysisQaResponse,
|
||||||
|
DetectionListResponse,
|
||||||
|
DetectionModelsResponse,
|
||||||
|
DetectionComparisonRequest,
|
||||||
|
DetectionComparisonResponse,
|
||||||
|
DetectionQaRequest,
|
||||||
|
DetectionRead,
|
||||||
|
DetectionRunListResponse,
|
||||||
|
DetectionRunRead,
|
||||||
|
DetectionRunRequest,
|
||||||
|
DetectionRunResponse,
|
||||||
|
Envelope,
|
||||||
|
GeoJsonFeatureCollection,
|
||||||
|
JobRead,
|
||||||
|
ModelAssetListResponse,
|
||||||
|
YoloPreflightResponse,
|
||||||
|
)
|
||||||
|
from app.services.detection_comparison_service import DetectionComparisonService
|
||||||
|
from app.services.dataset_service import DatasetService
|
||||||
|
from app.services.detection_service import DetectionService
|
||||||
|
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
||||||
|
from app.services.model_registry_service import ModelRegistryService
|
||||||
|
from app.services.yolo_preflight_service import YoloPreflightService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/detection", tags=["detection"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=Envelope[DetectionModelsResponse])
|
||||||
|
def list_detection_models() -> dict:
|
||||||
|
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-assets", response_model=Envelope[ModelAssetListResponse])
|
||||||
|
def list_detection_model_assets() -> dict:
|
||||||
|
return envelope(ModelAssetCatalogService.list_assets().model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/yolo/preflight", response_model=Envelope[YoloPreflightResponse])
|
||||||
|
def get_yolo_preflight(
|
||||||
|
tile_manifest_path: str | None = None,
|
||||||
|
check_model_load: bool = False,
|
||||||
|
model_asset_id: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(
|
||||||
|
YoloPreflightService.run(
|
||||||
|
tile_manifest_path=tile_manifest_path,
|
||||||
|
check_model_load=check_model_load,
|
||||||
|
model_asset_id=model_asset_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run", response_model=Envelope[DetectionRunResponse])
|
||||||
|
def run_detection(
|
||||||
|
payload: DetectionRunRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
assert_guest_project_scope(request, payload.project_id)
|
||||||
|
result = DetectionService.run_detection(
|
||||||
|
db=db,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
dataset_id=payload.dataset_id,
|
||||||
|
model_id=payload.model_id,
|
||||||
|
model_asset_id=payload.model_asset_id,
|
||||||
|
confidence_threshold=payload.confidence_threshold,
|
||||||
|
class_filter=payload.class_filter,
|
||||||
|
tile_manifest_path=payload.tile_manifest_path,
|
||||||
|
parameters_json=payload.parameters_json,
|
||||||
|
)
|
||||||
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run-async", response_model=Envelope[JobRead])
|
||||||
|
def queue_detection(
|
||||||
|
payload: DetectionRunRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Queue a detection run for the background worker.
|
||||||
|
|
||||||
|
Tiled GPU inference takes minutes; ``POST /detection/run`` performs it
|
||||||
|
inside the request and is only appropriate for a handful of tiles. Poll
|
||||||
|
``GET /jobs/{id}`` for the queued run instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert_guest_project_scope(request, payload.project_id)
|
||||||
|
job = DetectionService.enqueue_detection(
|
||||||
|
db=db,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
dataset_id=payload.dataset_id,
|
||||||
|
model_id=payload.model_id,
|
||||||
|
model_asset_id=payload.model_asset_id,
|
||||||
|
confidence_threshold=payload.confidence_threshold,
|
||||||
|
class_filter=payload.class_filter,
|
||||||
|
tile_manifest_path=payload.tile_manifest_path,
|
||||||
|
parameters_json=payload.parameters_json,
|
||||||
|
)
|
||||||
|
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs", response_model=Envelope[DetectionRunListResponse])
|
||||||
|
def list_detection_runs(
|
||||||
|
request: Request,
|
||||||
|
project_id: UUID | None = None,
|
||||||
|
dataset_id: UUID | None = None,
|
||||||
|
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
project_id = guest_scoped_project_filter(request, project_id)
|
||||||
|
return envelope(
|
||||||
|
DetectionService.list_runs(
|
||||||
|
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
|
||||||
|
def get_detection_run(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
run = DetectionService.get_run(db, analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
return envelope(run.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/runs/{analysis_run_id}/detections",
|
||||||
|
response_model=Envelope[DetectionListResponse],
|
||||||
|
)
|
||||||
|
def list_detection_run_detections(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
dataset_id: UUID | None = None,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
run = DetectionService.get_run(db, analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
return envelope(
|
||||||
|
DetectionService.list_detections(
|
||||||
|
db,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/datasets/{dataset_id}/detections",
|
||||||
|
response_model=Envelope[DetectionListResponse],
|
||||||
|
)
|
||||||
|
def list_dataset_detections(
|
||||||
|
dataset_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
analysis_run_id: UUID | None = None,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
dataset = DatasetService.get_dataset(db, dataset_id)
|
||||||
|
assert_guest_project_scope(request, dataset.project_id)
|
||||||
|
return envelope(
|
||||||
|
DetectionService.list_detections(
|
||||||
|
db,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
|
||||||
|
def get_detection(
|
||||||
|
detection_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
detection = DetectionService.get_detection(db, detection_id)
|
||||||
|
assert_guest_project_scope(request, detection.project_id)
|
||||||
|
return envelope(detection.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/runs/{analysis_run_id}/geojson",
|
||||||
|
response_model=Envelope[GeoJsonFeatureCollection],
|
||||||
|
)
|
||||||
|
def get_detection_run_geojson(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
run = DetectionService.get_run(db, analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
return envelope(
|
||||||
|
DetectionService.detections_to_geojson(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/datasets/{dataset_id}/geojson",
|
||||||
|
response_model=Envelope[GeoJsonFeatureCollection],
|
||||||
|
)
|
||||||
|
def get_dataset_detection_geojson(
|
||||||
|
dataset_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
analysis_run_id: UUID | None = None,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
dataset = DatasetService.get_dataset(db, dataset_id)
|
||||||
|
assert_guest_project_scope(request, dataset.project_id)
|
||||||
|
return envelope(
|
||||||
|
DetectionService.detections_to_geojson(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs/compare", response_model=Envelope[DetectionComparisonResponse])
|
||||||
|
def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
|
"""Rank several runs against one reference on average precision.
|
||||||
|
|
||||||
|
The workbench ranks model variants by a stored F1 measured at each
|
||||||
|
variant's own confidence threshold, which orders the thresholds as much as
|
||||||
|
the models. Average precision describes the whole ranking a model produced.
|
||||||
|
Comparability is reported first: runs over different rasters, different
|
||||||
|
references or different inference coverage are not alternatives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return envelope(
|
||||||
|
DetectionComparisonService.compare_runs(
|
||||||
|
db,
|
||||||
|
analysis_run_ids=payload.analysis_run_ids,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/runs/{analysis_run_id}/qa/reference",
|
||||||
|
response_model=Envelope[AnalysisQaResponse],
|
||||||
|
)
|
||||||
|
def compare_detection_run_with_reference(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
payload: DetectionQaRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
run = DetectionService.get_run(db, analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
return envelope(
|
||||||
|
DetectionService.compare_detections_with_reference(
|
||||||
|
db=db,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
class_name=payload.class_name,
|
||||||
|
min_confidence=payload.min_confidence,
|
||||||
|
calibration_thresholds=payload.calibration_thresholds,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.guest_scope import assert_guest_project_scope, guest_project_scope
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.export import (
|
||||||
|
ExportContentResponse,
|
||||||
|
ExportCreateResponse,
|
||||||
|
ExportListResponse,
|
||||||
|
ExportRead,
|
||||||
|
GeoJsonExportRequest,
|
||||||
|
MapResultExportRequest,
|
||||||
|
MetadataExportRequest,
|
||||||
|
ReportExportRequest,
|
||||||
|
)
|
||||||
|
from app.services.export_service import ExportService
|
||||||
|
from app.services.dataset_service import DatasetService
|
||||||
|
from app.services.detection_service import DetectionService
|
||||||
|
from app.services.segmentation_service import SegmentationService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/exports", tags=["exports"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/geojson", response_model=Envelope[ExportCreateResponse])
|
||||||
|
def export_geojson(
|
||||||
|
payload: GeoJsonExportRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
if payload.export_kind in {"dataset", "vector_selection"} and payload.dataset_id is not None:
|
||||||
|
dataset = DatasetService.get_dataset(db, payload.dataset_id)
|
||||||
|
assert_guest_project_scope(request, dataset.project_id)
|
||||||
|
elif payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
|
||||||
|
run = DetectionService.get_run(db, payload.analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
elif payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
|
||||||
|
run = SegmentationService.get_run(db, payload.analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
|
||||||
|
return envelope(
|
||||||
|
ExportService.export_vector_selection_geojson(
|
||||||
|
db,
|
||||||
|
payload.dataset_id,
|
||||||
|
payload.bbox.model_dump(),
|
||||||
|
area_id=payload.area_id,
|
||||||
|
limit=payload.limit,
|
||||||
|
name=payload.name,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
if payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
|
||||||
|
return envelope(
|
||||||
|
ExportService.export_detection_run_geojson(
|
||||||
|
db,
|
||||||
|
payload.analysis_run_id,
|
||||||
|
payload.name,
|
||||||
|
intended_use=payload.intended_use,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
if payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
|
||||||
|
return envelope(
|
||||||
|
ExportService.export_segmentation_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
if payload.dataset_id is not None:
|
||||||
|
return envelope(ExportService.export_dataset_geojson(db, payload.dataset_id, payload.name).model_dump(mode="json"))
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_EXPORT_REQUEST",
|
||||||
|
message="GeoJSON export request does not match any supported export target",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
|
||||||
|
def export_project_metadata(
|
||||||
|
payload: MetadataExportRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
assert_guest_project_scope(request, payload.project_id)
|
||||||
|
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/report", response_model=Envelope[ExportCreateResponse])
|
||||||
|
def export_project_report(
|
||||||
|
payload: ReportExportRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
assert_guest_project_scope(request, payload.project_id)
|
||||||
|
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/map-result", response_model=Envelope[ExportCreateResponse])
|
||||||
|
def export_map_result(
|
||||||
|
payload: MapResultExportRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
assert_guest_project_scope(request, payload.project_id)
|
||||||
|
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/exports",
|
||||||
|
response_model=Envelope[ExportListResponse],
|
||||||
|
)
|
||||||
|
def list_project_exports(
|
||||||
|
project_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
assert_guest_project_scope(request, project_id)
|
||||||
|
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{export_id}", response_model=Envelope[ExportRead])
|
||||||
|
def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
|
||||||
|
export = ExportService.get_export(db, export_id)
|
||||||
|
assert_guest_project_scope(request, export.project_id)
|
||||||
|
return envelope(export.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{export_id}/download")
|
||||||
|
def download_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
export = ExportService.get_export(db, export_id)
|
||||||
|
assert_guest_project_scope(request, export.project_id)
|
||||||
|
path = ExportService.get_export_download_path(db, export_id)
|
||||||
|
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
|
||||||
|
return FileResponse(path, filename=path.name, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse])
|
||||||
|
def get_export_content(export_id: UUID, request: Request, db: Session = Depends(get_db)):
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
export = ExportService.get_export(db, export_id)
|
||||||
|
assert_guest_project_scope(request, export.project_id)
|
||||||
|
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Area, Project
|
||||||
|
from app.providers.registry import fetch_provider_data, get_provider, import_provider_dataset, list_provider_capabilities
|
||||||
|
from app.schemas import (
|
||||||
|
CoverageCatalogResponse,
|
||||||
|
CoverageResolveRequest,
|
||||||
|
CoverageResolveResponse,
|
||||||
|
Envelope,
|
||||||
|
ExternalFetchRequest,
|
||||||
|
ExternalFetchResponse,
|
||||||
|
ProviderCapabilitiesResponse,
|
||||||
|
ProviderCapabilityResponse,
|
||||||
|
ProviderImportRequest,
|
||||||
|
ProviderImportResponse,
|
||||||
|
ProviderLayersResponse,
|
||||||
|
ProviderStatusResponse,
|
||||||
|
)
|
||||||
|
from app.services.coverage_registry_service import CoverageRegistryService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/external", tags=["external"])
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_area_in_project(db: Session, project_id, area_id: str | None) -> None:
|
||||||
|
if area_id is None:
|
||||||
|
return
|
||||||
|
area = db.get(Area, area_id)
|
||||||
|
if not area:
|
||||||
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||||
|
if area.project_id != project_id:
|
||||||
|
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_project_exists(db: Session, project_id):
|
||||||
|
project = db.get(Project, project_id)
|
||||||
|
if not project:
|
||||||
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_guest_project_scope(request: Request, project_id) -> None:
|
||||||
|
principal = getattr(request.state, "auth_principal", None)
|
||||||
|
if (
|
||||||
|
getattr(principal, "role", None) == "guest"
|
||||||
|
and getattr(principal, "project_id", None) != project_id
|
||||||
|
):
|
||||||
|
raise AppError(
|
||||||
|
code="GUEST_PROJECT_SCOPE_REQUIRED",
|
||||||
|
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_layer_input(layers: list[str] | None) -> list[str]:
|
||||||
|
return [layer.strip() for layer in (layers or []) if isinstance(layer, str) and layer.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_payload(provider_name: str) -> dict:
|
||||||
|
return get_provider(provider_name).capability.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response_model=Envelope[ProviderCapabilitiesResponse])
|
||||||
|
def list_external_providers() -> dict:
|
||||||
|
return envelope({
|
||||||
|
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/coverage/catalog", response_model=Envelope[CoverageCatalogResponse])
|
||||||
|
def get_coverage_catalog() -> dict:
|
||||||
|
return envelope(CoverageRegistryService.catalog().model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/coverage/resolve", response_model=Envelope[CoverageResolveResponse])
|
||||||
|
def resolve_project_coverage(
|
||||||
|
payload: CoverageResolveRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_assert_guest_project_scope(request, payload.project_id)
|
||||||
|
result = CoverageRegistryService.resolve(
|
||||||
|
db,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
bbox=payload.bbox,
|
||||||
|
themes=payload.themes,
|
||||||
|
)
|
||||||
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/capabilities",
|
||||||
|
response_model=Envelope[ProviderCapabilitiesResponse],
|
||||||
|
)
|
||||||
|
def get_external_provider_capabilities() -> dict:
|
||||||
|
return envelope({
|
||||||
|
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/{provider_name}",
|
||||||
|
response_model=Envelope[ProviderCapabilityResponse],
|
||||||
|
)
|
||||||
|
def get_external_provider(provider_name: str) -> dict:
|
||||||
|
return envelope(_provider_payload(provider_name))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/{provider_name}/layers",
|
||||||
|
response_model=Envelope[ProviderLayersResponse],
|
||||||
|
)
|
||||||
|
def get_external_provider_layers(provider_name: str) -> dict:
|
||||||
|
provider = get_provider(provider_name)
|
||||||
|
return envelope({
|
||||||
|
"provider_name": provider.provider_name,
|
||||||
|
"layers": provider.supported_layers,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/{provider_name}/status",
|
||||||
|
response_model=Envelope[ProviderStatusResponse],
|
||||||
|
)
|
||||||
|
def get_external_provider_status(provider_name: str) -> dict:
|
||||||
|
provider = get_provider(provider_name)
|
||||||
|
return envelope({
|
||||||
|
"provider_name": provider.provider_name,
|
||||||
|
"configured": provider.is_configured,
|
||||||
|
"status": provider.capability.status,
|
||||||
|
"limitation_message": provider.limitation_message,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/providers/{provider_name}/import",
|
||||||
|
response_model=Envelope[ProviderImportResponse],
|
||||||
|
)
|
||||||
|
def import_external_provider_dataset(provider_name: str, payload: ProviderImportRequest) -> dict:
|
||||||
|
result = import_provider_dataset(
|
||||||
|
provider_name=provider_name,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
layers=_normalize_layer_input(payload.layers),
|
||||||
|
requested_dataset_role=payload.dataset_role,
|
||||||
|
)
|
||||||
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
def _run_fetch(payload: ExternalFetchRequest, provider_name: str) -> ExternalFetchResponse:
|
||||||
|
area_id_str = str(payload.area_id) if payload.area_id else None
|
||||||
|
response = fetch_provider_data(
|
||||||
|
provider_name=provider_name,
|
||||||
|
project_id=str(payload.project_id),
|
||||||
|
area_id=area_id_str,
|
||||||
|
layers=_normalize_layer_input(payload.layers),
|
||||||
|
)
|
||||||
|
return ExternalFetchResponse(
|
||||||
|
provider=provider_name,
|
||||||
|
status=response.get("status", "not_configured"),
|
||||||
|
message=response.get("message", "Provider fetch executed."),
|
||||||
|
requested_layers=_normalize_layer_input(payload.layers),
|
||||||
|
project_id=payload.project_id,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/osm/fetch", response_model=Envelope[ExternalFetchResponse])
|
||||||
|
def fetch_osm(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
|
_assert_project_exists(db, payload.project_id)
|
||||||
|
_validate_area_in_project(db, payload.project_id, payload.area_id)
|
||||||
|
return envelope(_run_fetch(payload, "osm").model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/grb/fetch", response_model=Envelope[ExternalFetchResponse])
|
||||||
|
def fetch_grb(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
|
_assert_project_exists(db, payload.project_id)
|
||||||
|
_validate_area_in_project(db, payload.project_id, payload.area_id)
|
||||||
|
return envelope(_run_fetch(payload, "grb").model_dump())
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
from fastapi import APIRouter, Response, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.session import get_engine
|
||||||
|
from app.providers.registry import list_provider_capabilities
|
||||||
|
from app.schemas.health import (
|
||||||
|
HealthResponse,
|
||||||
|
SystemCapabilities,
|
||||||
|
SystemCapabilitiesEnvelope,
|
||||||
|
)
|
||||||
|
from app.services.model_registry_service import ModelRegistryService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _dependency_enabled(module: str) -> bool:
|
||||||
|
try:
|
||||||
|
import_module(module)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_migration_heads() -> list[str]:
|
||||||
|
backend_root = Path(__file__).resolve().parents[3]
|
||||||
|
config = Config(str(backend_root / "alembic.ini"))
|
||||||
|
config.set_main_option("script_location", str(backend_root / "alembic"))
|
||||||
|
return list(ScriptDirectory.from_config(config).get_heads())
|
||||||
|
|
||||||
|
|
||||||
|
def _database_checks() -> dict[str, str]:
|
||||||
|
checks = {
|
||||||
|
"database": "degraded",
|
||||||
|
"postgis": "degraded",
|
||||||
|
"migration": "degraded",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with get_engine().connect() as connection:
|
||||||
|
connection.execute(text("SELECT 1"))
|
||||||
|
checks["database"] = "ok"
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT PostGIS_Version()")
|
||||||
|
).scalar_one()
|
||||||
|
checks["postgis"] = "ok"
|
||||||
|
database_head = connection.execute(
|
||||||
|
text("SELECT version_num FROM alembic_version")
|
||||||
|
).scalar_one()
|
||||||
|
expected_heads = _expected_migration_heads()
|
||||||
|
if len(expected_heads) == 1 and database_head == expected_heads[0]:
|
||||||
|
checks["migration"] = "ok"
|
||||||
|
else:
|
||||||
|
checks["migration"] = "degraded"
|
||||||
|
except Exception:
|
||||||
|
return checks
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_check(storage_root: str) -> str:
|
||||||
|
root = Path(storage_root).expanduser()
|
||||||
|
try:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
with NamedTemporaryFile(
|
||||||
|
prefix=".geointel-readiness-",
|
||||||
|
dir=root,
|
||||||
|
delete=True,
|
||||||
|
) as handle:
|
||||||
|
handle.write(b"ok")
|
||||||
|
handle.flush()
|
||||||
|
return "ok"
|
||||||
|
except OSError:
|
||||||
|
return "degraded"
|
||||||
|
|
||||||
|
|
||||||
|
def _readiness_payload() -> HealthResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
checks = _database_checks()
|
||||||
|
checks["storage"] = _storage_check(settings.storage_root)
|
||||||
|
ready = all(
|
||||||
|
value == "ok" or value.startswith("ok:")
|
||||||
|
for value in checks.values()
|
||||||
|
)
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok" if ready else "degraded",
|
||||||
|
service="geointel-backend",
|
||||||
|
version="public",
|
||||||
|
database=checks["database"],
|
||||||
|
postgis=checks["postgis"],
|
||||||
|
migration=checks["migration"],
|
||||||
|
storage=checks["storage"],
|
||||||
|
checks=checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/live", response_model=HealthResponse)
|
||||||
|
def liveness() -> HealthResponse:
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok",
|
||||||
|
service="geointel-backend",
|
||||||
|
version="public",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _readiness_response(response: Response) -> HealthResponse:
|
||||||
|
payload = _readiness_payload()
|
||||||
|
if payload.status != "ok":
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse)
|
||||||
|
def readiness(response: Response) -> HealthResponse:
|
||||||
|
return _readiness_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/ready", response_model=HealthResponse)
|
||||||
|
def readiness_explicit(response: Response) -> HealthResponse:
|
||||||
|
return _readiness_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/system/capabilities",
|
||||||
|
response_model=SystemCapabilitiesEnvelope,
|
||||||
|
)
|
||||||
|
def capabilities() -> SystemCapabilitiesEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
|
providers = [item.to_dict() for item in list_provider_capabilities()]
|
||||||
|
configured_yolo = ModelRegistryService.get_model_capability(
|
||||||
|
settings.yolo_model_id,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
yolo_configured = bool(configured_yolo and configured_yolo.configured)
|
||||||
|
yolo_status = configured_yolo.status if configured_yolo else "not_configured"
|
||||||
|
configured_sam = ModelRegistryService.get_model_capability(
|
||||||
|
settings.sam_model_id,
|
||||||
|
settings=settings,
|
||||||
|
task_type="segmentation",
|
||||||
|
)
|
||||||
|
postgis_ready = _database_checks()["postgis"].startswith("ok:")
|
||||||
|
return SystemCapabilitiesEnvelope(
|
||||||
|
data=SystemCapabilities(
|
||||||
|
postgis=postgis_ready,
|
||||||
|
rasterio=_dependency_enabled("rasterio"),
|
||||||
|
geopandas=_dependency_enabled("geopandas"),
|
||||||
|
yolo=yolo_configured,
|
||||||
|
yolo_status=yolo_status,
|
||||||
|
sam=bool(configured_sam and configured_sam.configured),
|
||||||
|
grb="bounded",
|
||||||
|
sentinel="planned",
|
||||||
|
version=settings.app_version,
|
||||||
|
build_sha=settings.build_sha,
|
||||||
|
providers=providers,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope, JobCreate, JobList, JobRead, JobStatus
|
||||||
|
from app.services.job_service import JobService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}", tags=["jobs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs", status_code=201, response_model=Envelope[JobRead])
|
||||||
|
def create_job(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: JobCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if payload.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=400, detail="project_id mismatch")
|
||||||
|
return envelope(JobService.create_job(db, payload).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs", response_model=Envelope[JobList])
|
||||||
|
def list_jobs(
|
||||||
|
project_id: UUID,
|
||||||
|
dataset_id: UUID | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
items, total = JobService.list_jobs(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return envelope(JobList(items=items, total=total, limit=limit, offset=offset).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}", response_model=Envelope[JobRead])
|
||||||
|
def read_job(
|
||||||
|
project_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
job = JobService.get_job(db, job_id)
|
||||||
|
if job.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return envelope(job.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/status", response_model=Envelope[JobStatus])
|
||||||
|
def read_job_status(
|
||||||
|
project_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
status_row = JobService.get_job_status(db, job_id)
|
||||||
|
if status_row["project_id"] != str(project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return envelope(JobStatus(**status_row).model_dump())
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope
|
||||||
|
from app.schemas.project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
|
||||||
|
from app.services.project_service import ProjectService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=Envelope[ProjectList])
|
||||||
|
def list_projects(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
name: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
principal = getattr(request.state, "auth_principal", None)
|
||||||
|
if principal is not None and principal.role == "guest":
|
||||||
|
project = ProjectService.get_project(db, principal.project_id)
|
||||||
|
status_matches = bool(
|
||||||
|
project is not None
|
||||||
|
and (project_status == "all" or project.status == project_status)
|
||||||
|
)
|
||||||
|
name_matches = bool(
|
||||||
|
project is not None
|
||||||
|
and (name is None or name.casefold() in project.name.casefold())
|
||||||
|
)
|
||||||
|
matches = project is not None and status_matches and name_matches
|
||||||
|
visible = [project] if matches and offset == 0 else []
|
||||||
|
return envelope(
|
||||||
|
{
|
||||||
|
"items": [ProjectRead.model_validate(item).model_dump() for item in visible[:limit]],
|
||||||
|
"total": 1 if matches else 0,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
projects, total = ProjectService.list_projects(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
name=name,
|
||||||
|
project_status=project_status,
|
||||||
|
)
|
||||||
|
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED, response_model=Envelope[ProjectRead])
|
||||||
|
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
|
||||||
|
project = ProjectService.create_project(db, payload)
|
||||||
|
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}", response_model=Envelope[ProjectRead])
|
||||||
|
def get_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
project = ProjectService.get_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{project_id}", response_model=Envelope[ProjectRead])
|
||||||
|
def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)):
|
||||||
|
project = ProjectService.update_project(db, project_id, payload)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{project_id}",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
response_model=Envelope[ProjectDeleteResult],
|
||||||
|
)
|
||||||
|
def delete_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
if not ProjectService.delete_project(db, project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return envelope({"deleted": True})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models import Dataset, Job
|
||||||
|
from app.schemas import Envelope, JobRead, QaProviderComparisonRequest
|
||||||
|
from app.services.qa_service import QaService
|
||||||
|
from app.services.job_service import JobService
|
||||||
|
from app.services.quality_service import QualityService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/qa", tags=["qa"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/detections-vs-reference", response_model=Envelope[JobRead])
|
||||||
|
def compare_candidate_with_reference(
|
||||||
|
payload: QaProviderComparisonRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
candidate_dataset = db.get(Dataset, payload.candidate_dataset_id)
|
||||||
|
if not candidate_dataset:
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="Candidate dataset not found", status_code=404)
|
||||||
|
job = JobService.run_sync_job(
|
||||||
|
db=db,
|
||||||
|
project_id=candidate_dataset.project_id,
|
||||||
|
job_type="qa.compare-candidate-with-reference",
|
||||||
|
parameters=payload.model_dump(mode="json"),
|
||||||
|
input_dataset_id=candidate_dataset.id,
|
||||||
|
operation=lambda: QaService.compare_candidate_with_reference(
|
||||||
|
db=db,
|
||||||
|
project_id=candidate_dataset.project_id,
|
||||||
|
candidate_dataset_id=payload.candidate_dataset_id,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
area_id=payload.area_id,
|
||||||
|
).model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
result_json = job.get("result_json") if isinstance(job, dict) else None
|
||||||
|
if isinstance(result_json, dict) and job.get("status") == "success":
|
||||||
|
quality_check = QualityService.persist_quality_check(
|
||||||
|
db=db,
|
||||||
|
project_id=candidate_dataset.project_id,
|
||||||
|
job_id=uuid.UUID(str(job["id"])),
|
||||||
|
candidate_dataset_id=payload.candidate_dataset_id,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
check_type="candidate_vs_reference",
|
||||||
|
status=str(result_json.get("status", "ok")),
|
||||||
|
score=result_json.get("f1_score"),
|
||||||
|
parameters=payload.model_dump(mode="json"),
|
||||||
|
findings={
|
||||||
|
"matches": result_json.get("matches"),
|
||||||
|
"false_positives": result_json.get("false_positives"),
|
||||||
|
"false_negatives": result_json.get("false_negatives"),
|
||||||
|
"warnings": result_json.get("warnings", []),
|
||||||
|
"unsupported_geometry": result_json.get("unsupported_geometry", False),
|
||||||
|
"unsupported_geometries": result_json.get("unsupported_geometries", []),
|
||||||
|
"match_evidence": result_json.get("match_evidence", []),
|
||||||
|
"false_positive_evidence": result_json.get("false_positive_evidence", []),
|
||||||
|
"false_negative_evidence": result_json.get("false_negative_evidence", []),
|
||||||
|
},
|
||||||
|
metrics={
|
||||||
|
"precision": result_json.get("precision"),
|
||||||
|
"recall": result_json.get("recall"),
|
||||||
|
"f1": result_json.get("f1_score"),
|
||||||
|
"mean_iou": result_json.get("mean_iou"),
|
||||||
|
"false_positive_count": result_json.get("false_positives"),
|
||||||
|
"false_negative_count": result_json.get("false_negatives"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result_json["quality_check_id"] = str(quality_check.id)
|
||||||
|
|
||||||
|
job_record = db.get(Job, uuid.UUID(str(job["id"])))
|
||||||
|
if job_record:
|
||||||
|
job_record.result_json = result_json
|
||||||
|
db.add(job_record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return envelope(job)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope, QualityEvidenceResponse
|
||||||
|
from app.schemas.detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewUpsert
|
||||||
|
from app.schemas.qa import QualityCheckList
|
||||||
|
from app.services.detection_review_service import DetectionReviewService
|
||||||
|
from app.services.quality_evidence_service import QualityEvidenceService
|
||||||
|
from app.services.quality_check_service import QualityCheckService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}", tags=["quality-checks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/quality-checks", response_model=Envelope[QualityCheckList])
|
||||||
|
def list_quality_checks(
|
||||||
|
project_id: UUID,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
items, total = QualityCheckService.list_quality_checks(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/quality-checks/{quality_check_id}/evidence/geojson",
|
||||||
|
response_model=Envelope[QualityEvidenceResponse],
|
||||||
|
)
|
||||||
|
def get_quality_check_evidence_geojson(
|
||||||
|
project_id: UUID,
|
||||||
|
quality_check_id: UUID,
|
||||||
|
limit: int = Query(
|
||||||
|
default=QualityEvidenceService.DEFAULT_EVIDENCE_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=100_000,
|
||||||
|
description="Maximum evidence features to draw; 0 returns everything. Misses and false positives first.",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(
|
||||||
|
QualityEvidenceService.evidence_geojson(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
quality_check_id=quality_check_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/quality-checks/{quality_check_id}/reviews",
|
||||||
|
response_model=Envelope[DetectionReviewList],
|
||||||
|
)
|
||||||
|
def list_detection_reviews(
|
||||||
|
project_id: UUID,
|
||||||
|
quality_check_id: UUID,
|
||||||
|
evidence_role: str | None = Query(default=None, pattern="^(false_positive|false_negative)$"),
|
||||||
|
decision: str | None = Query(default=None, max_length=64),
|
||||||
|
reviewed: bool | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(
|
||||||
|
DetectionReviewService.list_reviews(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
quality_check_id=quality_check_id,
|
||||||
|
evidence_role=evidence_role,
|
||||||
|
decision=decision,
|
||||||
|
reviewed=reviewed,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/quality-checks/{quality_check_id}/reviews",
|
||||||
|
response_model=Envelope[DetectionReviewRead],
|
||||||
|
)
|
||||||
|
def upsert_detection_review(
|
||||||
|
project_id: UUID,
|
||||||
|
quality_check_id: UUID,
|
||||||
|
payload: DetectionReviewUpsert,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return envelope(
|
||||||
|
DetectionReviewService.upsert_review(
|
||||||
|
db,
|
||||||
|
project_id=project_id,
|
||||||
|
quality_check_id=quality_check_id,
|
||||||
|
payload=payload,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.guest_scope import (
|
||||||
|
assert_guest_project_scope,
|
||||||
|
guest_project_scope,
|
||||||
|
guest_scoped_project_filter,
|
||||||
|
)
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import (
|
||||||
|
AnalysisQaResponse,
|
||||||
|
Envelope,
|
||||||
|
GeoJsonFeatureCollection,
|
||||||
|
JobRead,
|
||||||
|
SegmentationListResponse,
|
||||||
|
SegmentationModelsResponse,
|
||||||
|
SegmentationQaRequest,
|
||||||
|
SegmentationRead,
|
||||||
|
SegmentationRunListResponse,
|
||||||
|
SegmentationRunRead,
|
||||||
|
SegmentationRunRequest,
|
||||||
|
SegmentationRunResponse,
|
||||||
|
)
|
||||||
|
from app.services.model_registry_service import ModelRegistryService
|
||||||
|
from app.services.dataset_service import DatasetService
|
||||||
|
from app.services.detection_service import DetectionService
|
||||||
|
from app.services.segmentation_service import SegmentationService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/segmentation", tags=["segmentation"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=Envelope[SegmentationModelsResponse])
|
||||||
|
def list_segmentation_models() -> dict:
|
||||||
|
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run", response_model=Envelope[SegmentationRunResponse])
|
||||||
|
def run_segmentation(
|
||||||
|
payload: SegmentationRunRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
assert_guest_project_scope(request, payload.project_id)
|
||||||
|
result = SegmentationService.run_segmentation(
|
||||||
|
db=db,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
dataset_id=payload.dataset_id,
|
||||||
|
model_id=payload.model_id,
|
||||||
|
confidence_threshold=payload.confidence_threshold,
|
||||||
|
class_filter=payload.class_filter,
|
||||||
|
tile_manifest_path=payload.tile_manifest_path,
|
||||||
|
parameters_json=payload.parameters_json,
|
||||||
|
)
|
||||||
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run-async", response_model=Envelope[JobRead])
|
||||||
|
def queue_segmentation(
|
||||||
|
payload: SegmentationRunRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Queue a segmentation run for the background worker.
|
||||||
|
|
||||||
|
Configured segmentation walks the same tile manifest as detection and is
|
||||||
|
just as unsuited to running inside the request. Poll ``GET /jobs/{id}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert_guest_project_scope(request, payload.project_id)
|
||||||
|
job = SegmentationService.enqueue_segmentation(
|
||||||
|
db=db,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
dataset_id=payload.dataset_id,
|
||||||
|
model_id=payload.model_id,
|
||||||
|
confidence_threshold=payload.confidence_threshold,
|
||||||
|
class_filter=payload.class_filter,
|
||||||
|
tile_manifest_path=payload.tile_manifest_path,
|
||||||
|
parameters_json=payload.parameters_json,
|
||||||
|
)
|
||||||
|
return envelope(JobRead.model_validate(job).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
|
||||||
|
def list_segmentation_runs(
|
||||||
|
request: Request,
|
||||||
|
project_id: UUID | None = None,
|
||||||
|
dataset_id: UUID | None = None,
|
||||||
|
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
project_id = guest_scoped_project_filter(request, project_id)
|
||||||
|
return envelope(
|
||||||
|
SegmentationService.list_runs(
|
||||||
|
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
|
||||||
|
def get_segmentation_run(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
run = SegmentationService.get_run(db, analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
return envelope(run.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/runs/{analysis_run_id}/segmentations",
|
||||||
|
response_model=Envelope[SegmentationListResponse],
|
||||||
|
)
|
||||||
|
def list_segmentation_run_outputs(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
dataset_id: UUID | None = None,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
run = SegmentationService.get_run(db, analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
return envelope(
|
||||||
|
SegmentationService.list_segmentations(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/datasets/{dataset_id}/segmentations",
|
||||||
|
response_model=Envelope[SegmentationListResponse],
|
||||||
|
)
|
||||||
|
def list_dataset_segmentations(
|
||||||
|
dataset_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
analysis_run_id: UUID | None = None,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
dataset = DatasetService.get_dataset(db, dataset_id)
|
||||||
|
assert_guest_project_scope(request, dataset.project_id)
|
||||||
|
return envelope(
|
||||||
|
SegmentationService.list_segmentations(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
|
||||||
|
def get_segmentation(
|
||||||
|
segmentation_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
segmentation = SegmentationService.get_segmentation(db, segmentation_id)
|
||||||
|
assert_guest_project_scope(request, segmentation.project_id)
|
||||||
|
return envelope(segmentation.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/runs/{analysis_run_id}/geojson",
|
||||||
|
response_model=Envelope[GeoJsonFeatureCollection],
|
||||||
|
)
|
||||||
|
def get_segmentation_run_geojson(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
run = SegmentationService.get_run(db, analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
return envelope(
|
||||||
|
SegmentationService.segmentations_to_geojson(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/datasets/{dataset_id}/geojson",
|
||||||
|
response_model=Envelope[GeoJsonFeatureCollection],
|
||||||
|
)
|
||||||
|
def get_dataset_segmentation_geojson(
|
||||||
|
dataset_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
analysis_run_id: UUID | None = None,
|
||||||
|
class_name: str | None = None,
|
||||||
|
min_confidence: float | None = None,
|
||||||
|
limit: int = Query(
|
||||||
|
default=DetectionService.DEFAULT_RESULT_LIMIT,
|
||||||
|
ge=0,
|
||||||
|
le=50_000,
|
||||||
|
description="Maximum results to return; 0 returns everything. Highest confidence first.",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
dataset = DatasetService.get_dataset(db, dataset_id)
|
||||||
|
assert_guest_project_scope(request, dataset.project_id)
|
||||||
|
return envelope(
|
||||||
|
SegmentationService.segmentations_to_geojson(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
class_name=class_name,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/runs/{analysis_run_id}/qa/reference",
|
||||||
|
response_model=Envelope[AnalysisQaResponse],
|
||||||
|
)
|
||||||
|
def compare_segmentation_run_with_reference(
|
||||||
|
analysis_run_id: UUID,
|
||||||
|
payload: SegmentationQaRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if guest_project_scope(request) is not None:
|
||||||
|
run = SegmentationService.get_run(db, analysis_run_id)
|
||||||
|
assert_guest_project_scope(request, run.project_id)
|
||||||
|
return envelope(
|
||||||
|
SegmentationService.compare_segmentations_with_reference(
|
||||||
|
db=db,
|
||||||
|
analysis_run_id=analysis_run_id,
|
||||||
|
reference_dataset_id=payload.reference_dataset_id,
|
||||||
|
iou_threshold=payload.iou_threshold,
|
||||||
|
class_name=payload.class_name,
|
||||||
|
min_confidence=payload.min_confidence,
|
||||||
|
calibration_thresholds=payload.calibration_thresholds,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Area, Dataset
|
||||||
|
from app.schemas.common import Envelope
|
||||||
|
from app.schemas.operations import VectorSelectionResponse
|
||||||
|
from app.schemas.selection_partitions import VectorPartitionSelectionRequest
|
||||||
|
from app.services.vector_feature_service import VectorFeatureService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}", tags=["selection-partitions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _product_identity(dataset: Dataset) -> str:
|
||||||
|
metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
|
||||||
|
return str(metadata.get("product_key") or dataset.reference_layer_name or "")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/datasets/vector/partitions/select",
|
||||||
|
response_model=Envelope[VectorSelectionResponse],
|
||||||
|
)
|
||||||
|
def select_vector_partitions(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: VectorPartitionSelectionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
datasets = db.query(Dataset).filter(Dataset.id.in_(payload.dataset_ids)).all()
|
||||||
|
by_id = {dataset.id: dataset for dataset in datasets}
|
||||||
|
ordered = [by_id.get(dataset_id) for dataset_id in payload.dataset_ids]
|
||||||
|
if any(dataset is None or dataset.project_id != project_id for dataset in ordered):
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="One or more selection partitions were not found", status_code=404)
|
||||||
|
typed_datasets = [dataset for dataset in ordered if dataset is not None]
|
||||||
|
if any(dataset.dataset_type not in {"vector", "geojson"} or dataset.status != "ready" for dataset in typed_datasets):
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_VECTOR_PARTITIONS",
|
||||||
|
message="Every selection partition must be a ready vector dataset",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
source_names = {dataset.source_name for dataset in typed_datasets}
|
||||||
|
product_keys = {_product_identity(dataset) for dataset in typed_datasets}
|
||||||
|
if len(source_names) != 1 or len(product_keys) != 1:
|
||||||
|
raise AppError(
|
||||||
|
code="VECTOR_PARTITION_SOURCE_MISMATCH",
|
||||||
|
message="Selection partitions must belong to one governed source product",
|
||||||
|
details={"source_names": sorted(str(value) for value in source_names), "product_keys": sorted(product_keys)},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
selection_geometry = None
|
||||||
|
selection_area_id = None
|
||||||
|
if payload.area_id is not None:
|
||||||
|
selection_area = db.get(Area, payload.area_id)
|
||||||
|
if selection_area is None or selection_area.project_id != project_id:
|
||||||
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||||
|
selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
|
||||||
|
payload.bbox.model_dump(),
|
||||||
|
selection_area.geometry,
|
||||||
|
)
|
||||||
|
selection_area_id = selection_area.id
|
||||||
|
|
||||||
|
representative = typed_datasets[0]
|
||||||
|
dataset_ids = [dataset.id for dataset in typed_datasets]
|
||||||
|
result = VectorFeatureService.select_features_by_bbox(
|
||||||
|
db,
|
||||||
|
dataset_id=representative.id,
|
||||||
|
dataset_ids=dataset_ids,
|
||||||
|
bbox=payload.bbox.model_dump(),
|
||||||
|
limit=payload.limit,
|
||||||
|
dataset=representative,
|
||||||
|
selection_geometry=selection_geometry,
|
||||||
|
selection_area_id=selection_area_id,
|
||||||
|
deduplicate_source_features=True,
|
||||||
|
)
|
||||||
|
result.update(
|
||||||
|
partition_count=len(dataset_ids),
|
||||||
|
source_name=representative.source_name,
|
||||||
|
dataset_ids=dataset_ids,
|
||||||
|
)
|
||||||
|
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Dataset, DatasetLineageEdge, DatasetQuarantine, Project, SourceRegistry, SourceSnapshot
|
||||||
|
from app.schemas import (
|
||||||
|
DatasetLineageEdgeRead,
|
||||||
|
DatasetProvenanceRead,
|
||||||
|
DatasetQuarantineRead,
|
||||||
|
Envelope,
|
||||||
|
ItemList,
|
||||||
|
SourceRegistryDetailRead,
|
||||||
|
SourceRegistryRead,
|
||||||
|
SourceSnapshotRead,
|
||||||
|
)
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["source-registry"])
|
||||||
|
|
||||||
|
|
||||||
|
def _source_read(source: SourceRegistry, *, snapshot_count: int = 0) -> SourceRegistryRead:
|
||||||
|
return SourceRegistryRead.model_validate(source).model_copy(update={"snapshot_count": int(snapshot_count)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/source-registry", response_model=Envelope[ItemList[SourceRegistryRead]])
|
||||||
|
def list_source_registry(
|
||||||
|
classification: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
query = (
|
||||||
|
db.query(SourceRegistry, func.count(SourceSnapshot.id).label("snapshot_count"))
|
||||||
|
.outerjoin(SourceSnapshot, SourceSnapshot.source_registry_id == SourceRegistry.id)
|
||||||
|
)
|
||||||
|
if classification:
|
||||||
|
query = query.filter(SourceRegistry.classification == classification.strip().lower())
|
||||||
|
rows = (
|
||||||
|
query.group_by(SourceRegistry.id)
|
||||||
|
.order_by(SourceRegistry.classification.asc(), SourceRegistry.display_name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
items = [_source_read(source, snapshot_count=count) for source, count in rows]
|
||||||
|
return envelope({"items": items, "total": len(items)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/source-registry/{source_key}", response_model=Envelope[SourceRegistryDetailRead])
|
||||||
|
def get_source_registry_entry(source_key: str, db: Session = Depends(get_db)) -> dict:
|
||||||
|
normalized_key = source_key.strip().lower()
|
||||||
|
source = db.query(SourceRegistry).filter(SourceRegistry.source_key == normalized_key).one_or_none()
|
||||||
|
if source is None:
|
||||||
|
raise AppError(code="SOURCE_REGISTRY_ENTRY_NOT_FOUND", message="Source registry entry was not found", status_code=404)
|
||||||
|
snapshots = (
|
||||||
|
db.query(SourceSnapshot)
|
||||||
|
.filter(SourceSnapshot.source_registry_id == source.id)
|
||||||
|
.order_by(SourceSnapshot.fetched_at.desc(), SourceSnapshot.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
detail = SourceRegistryDetailRead(
|
||||||
|
source=_source_read(source, snapshot_count=len(snapshots)),
|
||||||
|
snapshots=[SourceSnapshotRead.model_validate(snapshot) for snapshot in snapshots],
|
||||||
|
)
|
||||||
|
return envelope(detail)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/datasets/{dataset_id}/provenance",
|
||||||
|
response_model=Envelope[DatasetProvenanceRead],
|
||||||
|
)
|
||||||
|
def get_dataset_provenance(
|
||||||
|
project_id: UUID,
|
||||||
|
dataset_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if db.get(Project, project_id) is None:
|
||||||
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||||
|
dataset = db.get(Dataset, dataset_id)
|
||||||
|
if dataset is None or dataset.project_id != project_id:
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
||||||
|
|
||||||
|
source = db.get(SourceRegistry, dataset.source_registry_id) if dataset.source_registry_id else None
|
||||||
|
snapshot = db.get(SourceSnapshot, dataset.source_snapshot_id) if dataset.source_snapshot_id else None
|
||||||
|
lineage = (
|
||||||
|
db.query(DatasetLineageEdge)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
DatasetLineageEdge.parent_dataset_id == dataset.id,
|
||||||
|
DatasetLineageEdge.child_dataset_id == dataset.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(DatasetLineageEdge.created_at.asc(), DatasetLineageEdge.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
quarantines = (
|
||||||
|
db.query(DatasetQuarantine)
|
||||||
|
.filter(DatasetQuarantine.dataset_id == dataset.id)
|
||||||
|
.order_by(DatasetQuarantine.created_at.desc(), DatasetQuarantine.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
result = DatasetProvenanceRead(
|
||||||
|
dataset_id=dataset.id,
|
||||||
|
source=_source_read(source) if source else None,
|
||||||
|
snapshot=SourceSnapshotRead.model_validate(snapshot) if snapshot else None,
|
||||||
|
data_contract_key=dataset.data_contract_key,
|
||||||
|
data_contract_version=dataset.data_contract_version,
|
||||||
|
validation_status=dataset.validation_status,
|
||||||
|
validation_report_json=dataset.validation_report_json,
|
||||||
|
provenance_status=dataset.provenance_status,
|
||||||
|
lineage_status=dataset.lineage_status,
|
||||||
|
quarantine_status=dataset.quarantine_status,
|
||||||
|
lineage=[DatasetLineageEdgeRead.model_validate(item) for item in lineage],
|
||||||
|
quarantines=[DatasetQuarantineRead.model_validate(item) for item in quarantines],
|
||||||
|
)
|
||||||
|
return envelope(result)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope, ItemList
|
||||||
|
from app.schemas.temporal import (
|
||||||
|
TemporalComparisonRequest,
|
||||||
|
TemporalComparisonResponse,
|
||||||
|
TemporalSeriesRead,
|
||||||
|
)
|
||||||
|
from app.services.temporal_analysis_service import TemporalAnalysisService
|
||||||
|
from app.utils.response import envelope
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/temporal", tags=["temporal"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/series", response_model=Envelope[ItemList[TemporalSeriesRead]])
|
||||||
|
def list_temporal_series(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
series = TemporalAnalysisService.list_series(db, project_id)
|
||||||
|
return envelope({"items": [item.model_dump() for item in series], "total": len(series)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compare", response_model=Envelope[TemporalComparisonResponse])
|
||||||
|
def compare_temporal_snapshots(
|
||||||
|
project_id: UUID,
|
||||||
|
payload: TemporalComparisonRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
return envelope(TemporalAnalysisService.compare(db, project_id=project_id, payload=payload).model_dump())
|
||||||
Whitespace-only changes.
@@ -0,0 +1,584 @@
|
|||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, Field, field_validator, model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_env: str = Field(default="development", validation_alias="GEOINTEL_ENV")
|
||||||
|
app_version: str = Field(
|
||||||
|
default="1.0.0",
|
||||||
|
validation_alias="GEOINTEL_APP_VERSION",
|
||||||
|
)
|
||||||
|
build_sha: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_SHA")
|
||||||
|
build_time: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_TIME")
|
||||||
|
api_prefix: str = Field(default="/api/v1", validation_alias="GEOINTEL_API_PREFIX")
|
||||||
|
auth_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AUTH_ENABLED")
|
||||||
|
auth_require_https: bool = Field(default=False, validation_alias="GEOINTEL_AUTH_REQUIRE_HTTPS")
|
||||||
|
auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME")
|
||||||
|
auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH")
|
||||||
|
auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET")
|
||||||
|
authentik_issuer: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ISSUER")
|
||||||
|
authentik_client_id: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_ID")
|
||||||
|
authentik_client_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_SECRET")
|
||||||
|
authentik_allowed_email: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ALLOWED_EMAIL")
|
||||||
|
public_base_url: str = Field(
|
||||||
|
default="http://localhost:1202",
|
||||||
|
validation_alias="GEOINTEL_PUBLIC_BASE_URL",
|
||||||
|
)
|
||||||
|
auth_session_ttl_seconds: int = Field(
|
||||||
|
default=43_200,
|
||||||
|
ge=900,
|
||||||
|
le=604_800,
|
||||||
|
validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS",
|
||||||
|
)
|
||||||
|
guest_access_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias="GEOINTEL_GUEST_ACCESS_ENABLED",
|
||||||
|
)
|
||||||
|
guest_display_name: str = Field(
|
||||||
|
default="Gast",
|
||||||
|
min_length=1,
|
||||||
|
max_length=64,
|
||||||
|
validation_alias="GEOINTEL_GUEST_DISPLAY_NAME",
|
||||||
|
)
|
||||||
|
guest_session_ttl_seconds: int = Field(
|
||||||
|
default=7_200,
|
||||||
|
ge=900,
|
||||||
|
le=86_400,
|
||||||
|
validation_alias="GEOINTEL_GUEST_SESSION_TTL_SECONDS",
|
||||||
|
)
|
||||||
|
guest_login_requests_per_minute: int = Field(
|
||||||
|
default=3,
|
||||||
|
ge=1,
|
||||||
|
le=60,
|
||||||
|
validation_alias="GEOINTEL_GUEST_LOGIN_REQUESTS_PER_MINUTE",
|
||||||
|
)
|
||||||
|
guest_compute_requests_per_minute: int = Field(
|
||||||
|
default=4,
|
||||||
|
ge=1,
|
||||||
|
le=120,
|
||||||
|
validation_alias="GEOINTEL_GUEST_COMPUTE_REQUESTS_PER_MINUTE",
|
||||||
|
)
|
||||||
|
guest_compute_max_concurrency: int = Field(
|
||||||
|
default=2,
|
||||||
|
ge=1,
|
||||||
|
le=16,
|
||||||
|
validation_alias="GEOINTEL_GUEST_COMPUTE_MAX_CONCURRENCY",
|
||||||
|
)
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1",
|
||||||
|
validation_alias="DATABASE_URL",
|
||||||
|
)
|
||||||
|
storage_root: str = Field(default="./storage", validation_alias="STORAGE_ROOT")
|
||||||
|
# Analysis consumes only artifacts under storage_root. Provisioning
|
||||||
|
# workflows that stage tiles elsewhere before ingest can opt out.
|
||||||
|
allow_external_artifact_paths: bool = Field(
|
||||||
|
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
|
||||||
|
)
|
||||||
|
max_upload_mb: int = Field(
|
||||||
|
default=500,
|
||||||
|
ge=1,
|
||||||
|
le=2_048,
|
||||||
|
validation_alias=AliasChoices("GEOINTEL_MAX_UPLOAD_MB", "MAX_UPLOAD_MB"),
|
||||||
|
)
|
||||||
|
max_in_memory_vector_mb: int = Field(
|
||||||
|
default=64,
|
||||||
|
ge=1,
|
||||||
|
le=256,
|
||||||
|
validation_alias="GEOINTEL_MAX_IN_MEMORY_VECTOR_MB",
|
||||||
|
)
|
||||||
|
max_raster_pixels: int = Field(
|
||||||
|
default=40_000_000,
|
||||||
|
ge=1,
|
||||||
|
le=500_000_000,
|
||||||
|
validation_alias="GEOINTEL_MAX_RASTER_PIXELS",
|
||||||
|
)
|
||||||
|
max_raster_bands: int = Field(
|
||||||
|
default=16,
|
||||||
|
ge=1,
|
||||||
|
le=256,
|
||||||
|
validation_alias="GEOINTEL_MAX_RASTER_BANDS",
|
||||||
|
)
|
||||||
|
max_decoded_raster_mb: int = Field(
|
||||||
|
default=1024,
|
||||||
|
ge=16,
|
||||||
|
le=8192,
|
||||||
|
validation_alias="GEOINTEL_MAX_DECODED_RASTER_MB",
|
||||||
|
)
|
||||||
|
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
|
||||||
|
orthophoto_wms_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
|
||||||
|
validation_alias="ORTHOPHOTO_WMS_URL",
|
||||||
|
)
|
||||||
|
orthophoto_wms_layer: str = Field(default="Ortho", validation_alias="ORTHOPHOTO_WMS_LAYER")
|
||||||
|
spw_orthophoto_wms_url: str = Field(
|
||||||
|
default="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer",
|
||||||
|
validation_alias="SPW_ORTHOPHOTO_WMS_URL",
|
||||||
|
)
|
||||||
|
brussels_orthophoto_wms_url: str = Field(
|
||||||
|
default="https://geoservices-grid.irisnet.be/geoserver/urbisgrid/ows",
|
||||||
|
validation_alias="BRUSSELS_ORTHOPHOTO_WMS_URL",
|
||||||
|
)
|
||||||
|
orthophoto_resolution_m: float = Field(default=1.0, gt=0, validation_alias="ORTHOPHOTO_RESOLUTION_M")
|
||||||
|
orthophoto_min_side_m: float = Field(default=128.0, gt=0, validation_alias="ORTHOPHOTO_MIN_SIDE_M")
|
||||||
|
orthophoto_max_side_m: float = Field(default=1024.0, gt=0, validation_alias="ORTHOPHOTO_MAX_SIDE_M")
|
||||||
|
orthophoto_timeout_seconds: int = Field(default=120, ge=1, validation_alias="ORTHOPHOTO_TIMEOUT_SECONDS")
|
||||||
|
orthophoto_max_response_mb: int = Field(default=32, ge=1, validation_alias="ORTHOPHOTO_MAX_RESPONSE_MB")
|
||||||
|
orthophoto_cache_ttl_hours: int = Field(default=24, ge=0, validation_alias="ORTHOPHOTO_CACHE_TTL_HOURS")
|
||||||
|
source_catalog_probe_enabled: bool = Field(default=True, validation_alias="SOURCE_CATALOG_PROBE_ENABLED")
|
||||||
|
source_catalog_grb_wfs_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/GRB/wfs",
|
||||||
|
validation_alias="SOURCE_CATALOG_GRB_WFS_URL",
|
||||||
|
)
|
||||||
|
source_catalog_alz_release_url: str = Field(
|
||||||
|
default="https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen",
|
||||||
|
validation_alias="SOURCE_CATALOG_ALZ_RELEASE_URL",
|
||||||
|
)
|
||||||
|
source_catalog_statbel_dcat_url: str = Field(
|
||||||
|
default="https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl",
|
||||||
|
validation_alias="SOURCE_CATALOG_STATBEL_DCAT_URL",
|
||||||
|
)
|
||||||
|
source_catalog_statbel_max_response_mb: int = Field(
|
||||||
|
default=5,
|
||||||
|
ge=1,
|
||||||
|
le=10,
|
||||||
|
validation_alias="SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
source_catalog_probe_timeout_seconds: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
le=60,
|
||||||
|
validation_alias="SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
source_catalog_probe_max_response_mb: int = Field(
|
||||||
|
default=2,
|
||||||
|
ge=1,
|
||||||
|
le=10,
|
||||||
|
validation_alias="SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
source_catalog_probe_cache_ttl_seconds: int = Field(
|
||||||
|
default=900,
|
||||||
|
ge=0,
|
||||||
|
le=86_400,
|
||||||
|
validation_alias="SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS",
|
||||||
|
)
|
||||||
|
grb_enabled: bool = Field(default=True, validation_alias="GRB_ENABLED")
|
||||||
|
grb_ogc_api_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/GRB/ogc/features/v1",
|
||||||
|
validation_alias="GRB_OGC_API_URL",
|
||||||
|
)
|
||||||
|
grb_min_side_m: float = Field(default=10.0, gt=0, validation_alias="GRB_MIN_SIDE_M")
|
||||||
|
grb_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="GRB_MAX_SIDE_M")
|
||||||
|
grb_page_size: int = Field(default=1000, ge=1, le=1000, validation_alias="GRB_PAGE_SIZE")
|
||||||
|
grb_max_pages: int = Field(default=200, ge=1, le=1000, validation_alias="GRB_MAX_PAGES")
|
||||||
|
grb_max_features: int = Field(default=150_000, ge=1, validation_alias="GRB_MAX_FEATURES")
|
||||||
|
grb_timeout_seconds: int = Field(default=180, ge=1, le=600, validation_alias="GRB_TIMEOUT_SECONDS")
|
||||||
|
grb_max_response_mb: int = Field(default=20, ge=1, le=100, validation_alias="GRB_MAX_RESPONSE_MB")
|
||||||
|
grb_max_total_response_mb: int = Field(
|
||||||
|
default=256,
|
||||||
|
ge=1,
|
||||||
|
le=2048,
|
||||||
|
validation_alias="GRB_MAX_TOTAL_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
grb_cache_ttl_hours: int = Field(default=24, ge=0, le=8760, validation_alias="GRB_CACHE_TTL_HOURS")
|
||||||
|
official_vector_enabled: bool = Field(default=True, validation_alias="OFFICIAL_VECTOR_ENABLED")
|
||||||
|
bwk_wfs_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/BWK/wfs",
|
||||||
|
validation_alias="BWK_WFS_URL",
|
||||||
|
)
|
||||||
|
dov_soil_wfs_url: str = Field(
|
||||||
|
default="https://www.dov.vlaanderen.be/geoserver/wfs",
|
||||||
|
validation_alias="DOV_SOIL_WFS_URL",
|
||||||
|
)
|
||||||
|
official_vector_min_side_m: float = Field(
|
||||||
|
default=10.0,
|
||||||
|
gt=0,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MIN_SIDE_M",
|
||||||
|
)
|
||||||
|
official_vector_max_side_m: float = Field(
|
||||||
|
default=20_000.0,
|
||||||
|
gt=0,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_SIDE_M",
|
||||||
|
)
|
||||||
|
official_vector_page_size: int = Field(
|
||||||
|
default=1000,
|
||||||
|
ge=1,
|
||||||
|
le=2000,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_PAGE_SIZE",
|
||||||
|
)
|
||||||
|
official_vector_max_pages: int = Field(
|
||||||
|
default=200,
|
||||||
|
ge=1,
|
||||||
|
le=1000,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_PAGES",
|
||||||
|
)
|
||||||
|
official_vector_max_features: int = Field(
|
||||||
|
default=100_000,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_FEATURES",
|
||||||
|
)
|
||||||
|
official_vector_timeout_seconds: int = Field(
|
||||||
|
default=180,
|
||||||
|
ge=1,
|
||||||
|
le=600,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
official_vector_max_response_mb: int = Field(
|
||||||
|
default=20,
|
||||||
|
ge=1,
|
||||||
|
le=100,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
official_vector_max_total_response_mb: int = Field(
|
||||||
|
default=256,
|
||||||
|
ge=1,
|
||||||
|
le=2048,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
official_vector_cache_ttl_hours: int = Field(
|
||||||
|
default=24,
|
||||||
|
ge=0,
|
||||||
|
le=8760,
|
||||||
|
validation_alias="OFFICIAL_VECTOR_CACHE_TTL_HOURS",
|
||||||
|
)
|
||||||
|
spw_picc_enabled: bool = Field(default=True, validation_alias="SPW_PICC_ENABLED")
|
||||||
|
spw_picc_mapserver_url: str = Field(
|
||||||
|
default=(
|
||||||
|
"https://geoservices.wallonie.be/arcgis/rest/services/"
|
||||||
|
"TOPOGRAPHIE/PICC_VDIFF/MapServer"
|
||||||
|
),
|
||||||
|
validation_alias="SPW_PICC_MAPSERVER_URL",
|
||||||
|
)
|
||||||
|
spw_flood_hazard_enabled: bool = Field(default=True, validation_alias="SPW_FLOOD_HAZARD_ENABLED")
|
||||||
|
spw_flood_hazard_mapserver_url: str = Field(
|
||||||
|
default=(
|
||||||
|
"https://geoservices.wallonie.be/arcgis/rest/services/"
|
||||||
|
"EAU/ALEA_INOND/MapServer"
|
||||||
|
),
|
||||||
|
validation_alias="SPW_FLOOD_HAZARD_MAPSERVER_URL",
|
||||||
|
)
|
||||||
|
urbis_enabled: bool = Field(default=True, validation_alias="URBIS_ENABLED")
|
||||||
|
urbis_wfs_url: str = Field(
|
||||||
|
default="https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows",
|
||||||
|
validation_alias="URBIS_WFS_URL",
|
||||||
|
)
|
||||||
|
dhmv_enabled: bool = Field(default=True, validation_alias="DHMV_ENABLED")
|
||||||
|
dhmv_wcs_url: str = Field(
|
||||||
|
default="https://geo.api.vlaanderen.be/DHMV/wcs",
|
||||||
|
validation_alias="DHMV_WCS_URL",
|
||||||
|
)
|
||||||
|
dhmv_resolution_m: float = Field(default=5.0, ge=1.0, le=10.0, validation_alias="DHMV_RESOLUTION_M")
|
||||||
|
dhmv_min_side_m: float = Field(default=10.0, gt=0, validation_alias="DHMV_MIN_SIDE_M")
|
||||||
|
dhmv_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="DHMV_MAX_SIDE_M")
|
||||||
|
dhmv_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="DHMV_MAX_PIXELS")
|
||||||
|
dhmv_timeout_seconds: int = Field(default=300, ge=1, validation_alias="DHMV_TIMEOUT_SECONDS")
|
||||||
|
dhmv_max_response_mb: int = Field(default=160, ge=1, validation_alias="DHMV_MAX_RESPONSE_MB")
|
||||||
|
flood_hazard_enabled: bool = Field(default=True, validation_alias="FLOOD_HAZARD_ENABLED")
|
||||||
|
flood_hazard_wcs_url: str = Field(
|
||||||
|
default="https://geoservice.waterinfo.be/OGRK/wcs",
|
||||||
|
validation_alias="FLOOD_HAZARD_WCS_URL",
|
||||||
|
)
|
||||||
|
flood_hazard_resolution_m: float = Field(default=5.0, ge=2.0, le=20.0, validation_alias="FLOOD_HAZARD_RESOLUTION_M")
|
||||||
|
flood_hazard_min_side_m: float = Field(default=10.0, gt=0, validation_alias="FLOOD_HAZARD_MIN_SIDE_M")
|
||||||
|
flood_hazard_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="FLOOD_HAZARD_MAX_SIDE_M")
|
||||||
|
flood_hazard_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="FLOOD_HAZARD_MAX_PIXELS")
|
||||||
|
flood_hazard_timeout_seconds: int = Field(default=300, ge=1, validation_alias="FLOOD_HAZARD_TIMEOUT_SECONDS")
|
||||||
|
flood_hazard_max_response_mb: int = Field(default=160, ge=1, validation_alias="FLOOD_HAZARD_MAX_RESPONSE_MB")
|
||||||
|
bathymetry_profiles_enabled: bool = Field(default=True, validation_alias="BATHYMETRY_PROFILES_ENABLED")
|
||||||
|
bathymetry_profiles_layer_url: str = Field(
|
||||||
|
default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0",
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_LAYER_URL",
|
||||||
|
)
|
||||||
|
bathymetry_watercourse_layer_url: str = Field(
|
||||||
|
default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1",
|
||||||
|
validation_alias="BATHYMETRY_WATERCOURSE_LAYER_URL",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_page_size: int = Field(
|
||||||
|
default=1000,
|
||||||
|
ge=1,
|
||||||
|
le=2000,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_PAGE_SIZE",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_max_features: int = Field(
|
||||||
|
default=50_000,
|
||||||
|
ge=1,
|
||||||
|
le=250_000,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_MAX_FEATURES",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_max_pages: int = Field(
|
||||||
|
default=200,
|
||||||
|
ge=1,
|
||||||
|
le=5_000,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_MAX_PAGES",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_timeout_seconds: int = Field(
|
||||||
|
default=120,
|
||||||
|
ge=1,
|
||||||
|
le=600,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
bathymetry_profiles_max_response_mb: int = Field(
|
||||||
|
default=32,
|
||||||
|
ge=1,
|
||||||
|
le=256,
|
||||||
|
validation_alias="BATHYMETRY_PROFILES_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
bathymetry_raster_max_pixels: int = Field(
|
||||||
|
default=30_000_000,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="BATHYMETRY_RASTER_MAX_PIXELS",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_probe_enabled: bool = Field(default=True, validation_alias="MDK_BATHYMETRY_PROBE_ENABLED")
|
||||||
|
mdk_bathymetry_wcs_url: str = Field(
|
||||||
|
default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs",
|
||||||
|
validation_alias="MDK_BATHYMETRY_WCS_URL",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_probe_timeout_seconds: int = Field(
|
||||||
|
default=20,
|
||||||
|
ge=1,
|
||||||
|
le=120,
|
||||||
|
validation_alias="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_probe_max_response_mb: int = Field(
|
||||||
|
default=4,
|
||||||
|
ge=1,
|
||||||
|
le=16,
|
||||||
|
validation_alias="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
thematic_raster_enabled: bool = Field(default=True, validation_alias="THEMATIC_RASTER_ENABLED")
|
||||||
|
thematic_raster_wcs_url: str = Field(
|
||||||
|
default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs",
|
||||||
|
validation_alias="THEMATIC_RASTER_WCS_URL",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_acquisition_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_ENABLED",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_coverage_id: str | None = Field(default=None, validation_alias="MDK_BATHYMETRY_COVERAGE_ID")
|
||||||
|
mdk_bathymetry_request_crs: str = Field(default="EPSG:4326", validation_alias="MDK_BATHYMETRY_REQUEST_CRS")
|
||||||
|
mdk_bathymetry_max_bbox_deg2: float = Field(
|
||||||
|
default=0.25,
|
||||||
|
gt=0,
|
||||||
|
validation_alias="MDK_BATHYMETRY_MAX_BBOX_DEG2",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_acquisition_timeout_seconds: int = Field(
|
||||||
|
default=120,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
mdk_bathymetry_acquisition_max_response_mb: int = Field(
|
||||||
|
default=160,
|
||||||
|
ge=1,
|
||||||
|
validation_alias="MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB",
|
||||||
|
)
|
||||||
|
thematic_raster_min_side_m: float = Field(default=100.0, gt=0, validation_alias="THEMATIC_RASTER_MIN_SIDE_M")
|
||||||
|
thematic_raster_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="THEMATIC_RASTER_MAX_SIDE_M")
|
||||||
|
thematic_raster_max_pixels: int = Field(default=30_000_000, ge=1, validation_alias="THEMATIC_RASTER_MAX_PIXELS")
|
||||||
|
thematic_raster_timeout_seconds: int = Field(default=300, ge=1, validation_alias="THEMATIC_RASTER_TIMEOUT_SECONDS")
|
||||||
|
thematic_raster_max_response_mb: int = Field(default=160, ge=1, validation_alias="THEMATIC_RASTER_MAX_RESPONSE_MB")
|
||||||
|
walous_enabled: bool = Field(default=True, validation_alias="WALOUS_ENABLED")
|
||||||
|
walous_source_dir: str = Field(
|
||||||
|
default="/app/storage/source-cache/walous",
|
||||||
|
validation_alias="WALOUS_SOURCE_DIR",
|
||||||
|
)
|
||||||
|
walous_analysis_resolution_m: float = Field(
|
||||||
|
default=10.0,
|
||||||
|
ge=1.0,
|
||||||
|
le=100.0,
|
||||||
|
validation_alias="WALOUS_ANALYSIS_RESOLUTION_M",
|
||||||
|
)
|
||||||
|
walous_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="WALOUS_MAX_SIDE_M")
|
||||||
|
walous_max_pixels: int = Field(default=36_000_000, ge=1, validation_alias="WALOUS_MAX_PIXELS")
|
||||||
|
spw_terrain_enabled: bool = Field(default=True, validation_alias="SPW_TERRAIN_ENABLED")
|
||||||
|
spw_terrain_source_dir: str = Field(
|
||||||
|
default="/app/storage/source-cache/spw-terrain",
|
||||||
|
validation_alias="SPW_TERRAIN_SOURCE_DIR",
|
||||||
|
)
|
||||||
|
spw_terrain_analysis_resolution_m: float = Field(
|
||||||
|
default=5.0,
|
||||||
|
ge=1.0,
|
||||||
|
le=10.0,
|
||||||
|
validation_alias="SPW_TERRAIN_ANALYSIS_RESOLUTION_M",
|
||||||
|
)
|
||||||
|
spw_terrain_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="SPW_TERRAIN_MAX_SIDE_M")
|
||||||
|
spw_terrain_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="SPW_TERRAIN_MAX_PIXELS")
|
||||||
|
redis_url: str | None = Field(default=None, validation_alias="REDIS_URL")
|
||||||
|
log_level: str = Field(default="INFO", validation_alias="GEOINTEL_LOG_LEVEL")
|
||||||
|
sql_log_level: str = Field(default="WARNING", validation_alias="GEOINTEL_SQL_LOG_LEVEL")
|
||||||
|
reconcile_interrupted_runs_on_startup: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias="GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP",
|
||||||
|
)
|
||||||
|
aoi_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AOI_WORKER_ENABLED")
|
||||||
|
aoi_worker_poll_seconds: float = Field(default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_AOI_WORKER_POLL_SECONDS")
|
||||||
|
# Executes queued detection.run / segmentation.run jobs so tiled GPU
|
||||||
|
# inference never blocks an HTTP request.
|
||||||
|
analysis_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_ANALYSIS_WORKER_ENABLED")
|
||||||
|
analysis_worker_poll_seconds: float = Field(
|
||||||
|
default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS"
|
||||||
|
)
|
||||||
|
database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS")
|
||||||
|
yolo_enabled: bool = Field(default=False, validation_alias="YOLO_ENABLED")
|
||||||
|
yolo_models_dir: str = Field(default="/app/models", validation_alias="YOLO_MODELS_DIR")
|
||||||
|
yolo_model_path: str | None = Field(default=None, validation_alias="YOLO_MODEL_PATH")
|
||||||
|
yolo_model_id: str = Field(default="yolo-configured", validation_alias="YOLO_MODEL_ID")
|
||||||
|
yolo_model_display_name: str = Field(default="Configured YOLO detector", validation_alias="YOLO_MODEL_DISPLAY_NAME")
|
||||||
|
yolo_model_version: str | None = Field(default=None, validation_alias="YOLO_MODEL_VERSION")
|
||||||
|
yolo_model_classes: str = Field(default="building", validation_alias="YOLO_MODEL_CLASSES")
|
||||||
|
yolo_enforce_validation_scope: bool = Field(default=False, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE")
|
||||||
|
yolo_validation_scope_manifest_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_PATH",
|
||||||
|
)
|
||||||
|
yolo_validation_scope_manifest_sha256: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256",
|
||||||
|
)
|
||||||
|
# Deprecated compatibility field. Mutable Area names are never an
|
||||||
|
# inference authorization boundary; deployments must use the immutable
|
||||||
|
# checksum-bound scope manifest above.
|
||||||
|
yolo_validated_area_names: str = Field(default="Mol,Kempen", validation_alias="YOLO_VALIDATED_AREA_NAMES")
|
||||||
|
yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE")
|
||||||
|
yolo_require_cuda: bool = Field(default=False, validation_alias="YOLO_REQUIRE_CUDA")
|
||||||
|
yolo_image_size: int = Field(default=640, validation_alias="YOLO_IMAGE_SIZE")
|
||||||
|
yolo_max_tiles: int = Field(default=100, validation_alias="YOLO_MAX_TILES")
|
||||||
|
yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS")
|
||||||
|
yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD")
|
||||||
|
yolo_suppress_tile_edge_detections: bool = Field(
|
||||||
|
default=True, validation_alias="YOLO_SUPPRESS_TILE_EDGE_DETECTIONS"
|
||||||
|
)
|
||||||
|
# Intersection over the smaller box. The candidate evaluation freezes this
|
||||||
|
# during calibration; serving a promoted model at a different value means
|
||||||
|
# the runtime suppresses detections the gate counted.
|
||||||
|
yolo_containment_nms_threshold: float = Field(
|
||||||
|
default=0.85, ge=0.0, le=1.0, validation_alias="YOLO_CONTAINMENT_NMS_THRESHOLD"
|
||||||
|
)
|
||||||
|
yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE")
|
||||||
|
yolo_seg_enabled: bool = Field(default=False, validation_alias="YOLO_SEG_ENABLED")
|
||||||
|
yolo_seg_model_path: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_PATH")
|
||||||
|
yolo_seg_model_id: str = Field(default="yolo-seg-configured", validation_alias="YOLO_SEG_MODEL_ID")
|
||||||
|
yolo_seg_model_display_name: str = Field(
|
||||||
|
default="Configured YOLO segmentation",
|
||||||
|
validation_alias="YOLO_SEG_MODEL_DISPLAY_NAME",
|
||||||
|
)
|
||||||
|
yolo_seg_model_version: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_VERSION")
|
||||||
|
sam_enabled: bool = Field(default=False, validation_alias="SAM_ENABLED")
|
||||||
|
sam_model_path: str | None = Field(default=None, validation_alias="SAM_MODEL_PATH")
|
||||||
|
sam_model_id: str = Field(default="sam-configured", validation_alias="SAM_MODEL_ID")
|
||||||
|
sam_model_display_name: str = Field(
|
||||||
|
default="Configured SAM segmentation",
|
||||||
|
validation_alias="SAM_MODEL_DISPLAY_NAME",
|
||||||
|
)
|
||||||
|
sam_model_version: str | None = Field(default=None, validation_alias="SAM_MODEL_VERSION")
|
||||||
|
segmentation_max_masks_per_tile: int = Field(default=300, ge=1, validation_alias="SEGMENTATION_MAX_MASKS_PER_TILE")
|
||||||
|
# Masks and boxes overlap differently, so segmentation carries its own
|
||||||
|
# containment value rather than borrowing the detector's.
|
||||||
|
segmentation_containment_nms_threshold: float = Field(
|
||||||
|
default=0.85,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
validation_alias="SEGMENTATION_CONTAINMENT_NMS_THRESHOLD",
|
||||||
|
)
|
||||||
|
segmentation_duplicate_iou_threshold: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
validation_alias="SEGMENTATION_DUPLICATE_IOU_THRESHOLD",
|
||||||
|
)
|
||||||
|
ollama_enabled: bool = Field(default=False, validation_alias="OLLAMA_ENABLED")
|
||||||
|
ollama_base_url: str = Field(default="http://127.0.0.1:11434", validation_alias="OLLAMA_BASE_URL")
|
||||||
|
ollama_default_model: str = Field(default="qwen3.5:9b", validation_alias="OLLAMA_DEFAULT_MODEL")
|
||||||
|
ollama_timeout_seconds: int = Field(default=120, ge=5, le=600, validation_alias="OLLAMA_TIMEOUT_SECONDS")
|
||||||
|
ollama_max_output_tokens: int = Field(default=1_200, ge=100, le=4_000, validation_alias="OLLAMA_MAX_OUTPUT_TOKENS")
|
||||||
|
ollama_context_tokens: int = Field(default=16_384, ge=4_096, le=131_072, validation_alias="OLLAMA_CONTEXT_TOKENS")
|
||||||
|
cors_origins: list[str] | str = Field(
|
||||||
|
default=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
validation_alias="CORS_ORIGINS",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("cors_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_cors_origins(cls, value: object) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
|
@field_validator("ollama_base_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_ollama_base_url(cls, value: str) -> str:
|
||||||
|
normalized = value.strip().rstrip("/")
|
||||||
|
if not normalized.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("OLLAMA_BASE_URL must use http or https")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_operator_auth(self) -> "Settings":
|
||||||
|
self.guest_display_name = self.guest_display_name.strip()
|
||||||
|
if not self.guest_display_name:
|
||||||
|
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
|
||||||
|
for field_name in (
|
||||||
|
"authentik_issuer",
|
||||||
|
"authentik_client_id",
|
||||||
|
"authentik_client_secret",
|
||||||
|
"authentik_allowed_email",
|
||||||
|
):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
setattr(self, field_name, value.strip() if value else None)
|
||||||
|
self.public_base_url = self.public_base_url.strip().rstrip("/")
|
||||||
|
authentik_values = (
|
||||||
|
self.authentik_issuer,
|
||||||
|
self.authentik_client_id,
|
||||||
|
self.authentik_client_secret,
|
||||||
|
self.authentik_allowed_email,
|
||||||
|
)
|
||||||
|
if any(authentik_values) and not all(authentik_values):
|
||||||
|
raise ValueError("All GEOINTEL_AUTHENTIK_* values must be configured together")
|
||||||
|
if all(authentik_values):
|
||||||
|
if not self.auth_enabled:
|
||||||
|
raise ValueError("GEOINTEL_AUTH_ENABLED must be true when Authentik is configured")
|
||||||
|
for label, value in (
|
||||||
|
("GEOINTEL_AUTHENTIK_ISSUER", self.authentik_issuer),
|
||||||
|
("GEOINTEL_PUBLIC_BASE_URL", self.public_base_url),
|
||||||
|
):
|
||||||
|
parsed = urlsplit(str(value))
|
||||||
|
if (
|
||||||
|
parsed.scheme != "https"
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.username
|
||||||
|
or parsed.password
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
raise ValueError(f"{label} must be an absolute HTTPS URL without credentials, query or fragment")
|
||||||
|
public_url = urlsplit(self.public_base_url)
|
||||||
|
if public_url.path not in ("", "/"):
|
||||||
|
raise ValueError("GEOINTEL_PUBLIC_BASE_URL must not contain a path")
|
||||||
|
if "@" not in str(self.authentik_allowed_email) or any(
|
||||||
|
character.isspace() for character in str(self.authentik_allowed_email)
|
||||||
|
):
|
||||||
|
raise ValueError("GEOINTEL_AUTHENTIK_ALLOWED_EMAIL must be one valid e-mail address")
|
||||||
|
if not self.auth_enabled:
|
||||||
|
return self
|
||||||
|
if not (self.auth_username or "").strip():
|
||||||
|
raise ValueError("GEOINTEL_AUTH_USERNAME is required when authentication is enabled")
|
||||||
|
if not (self.auth_password_hash or "").startswith("pbkdf2_sha256$"):
|
||||||
|
raise ValueError("GEOINTEL_AUTH_PASSWORD_HASH must be a PBKDF2-SHA256 hash")
|
||||||
|
if len(self.auth_session_secret or "") < 32:
|
||||||
|
raise ValueError("GEOINTEL_AUTH_SESSION_SECRET must contain at least 32 characters")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class AppError(Exception):
|
||||||
|
"""Domain error used by services to return canonical API errors."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
details: dict | list | None = None,
|
||||||
|
status_code: int = 400,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.details = details or {}
|
||||||
|
self.status_code = status_code
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "INFO", sql_level: str = "WARNING") -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
for name in ["uvicorn", "uvicorn.error", "uvicorn.access"]:
|
||||||
|
logging.getLogger(name).setLevel(level)
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(sql_level)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
# Stable server-owned identity: a public session must never attach itself to an
|
||||||
|
# operator project merely because the display names happen to match.
|
||||||
|
PUBLIC_DEMO_PROJECT_ID = UUID("6f7e6f12-9b62-4a3f-a5a0-4b3bb6b2c901")
|
||||||
|
PUBLIC_DEMO_PROJECT_NAME = "GeoIntel Demo - Building QA"
|
||||||
|
PUBLIC_DEMO_PROJECT_MARKER = "geointel:public-demo:v1"
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_demo_project(project_id: UUID) -> bool:
|
||||||
|
return project_id == PUBLIC_DEMO_PROJECT_ID
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
|
||||||
|
|
||||||
|
_request_id: ContextVar[str] = ContextVar("geointel_request_id", default="-")
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id() -> str:
|
||||||
|
return _request_id.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_request_id(value: str) -> Token:
|
||||||
|
return _request_id.set(value)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_request_id(token: Token) -> None:
|
||||||
|
_request_id.reset(token)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
|||||||
|
from .base import Base
|
||||||
|
from .session import get_db, get_engine
|
||||||
|
|
||||||
|
__all__ = ["Base", "get_db", "get_engine"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine(get_settings().database_url, pool_pre_ping=True, future=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
return engine
|
||||||
Whitespace-only changes.
@@ -0,0 +1,467 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api.routes import analysis, aoi_operations, areas, assistant, auth, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, source_registry, temporal
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.core.logging import configure_logging
|
||||||
|
from app.core.request_context import reset_request_id, set_request_id
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.services.runtime_reconciliation_service import RuntimeReconciliationService
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.services.analysis_job_worker import AnalysisJobWorker
|
||||||
|
from app.services.aoi_operation_worker import AoiOperationWorker
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("geointel")
|
||||||
|
SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||||
|
UNSAFE_HOST = re.compile(r"[/\\@?#\s\x00-\x1f\x7f]")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_error_payload(
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
details: dict | list | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"error": code,
|
||||||
|
"message": message,
|
||||||
|
"details": details or {},
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(settings.log_level, settings.sql_log_level)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
worker_stop = asyncio.Event()
|
||||||
|
worker_task = None
|
||||||
|
analysis_worker_task = None
|
||||||
|
if settings.reconcile_interrupted_runs_on_startup:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = RuntimeReconciliationService.reconcile(db)
|
||||||
|
logger.info(
|
||||||
|
"Runtime reconciliation completed: jobs=%s analysis_runs=%s resumed_aoi_partitions=%s exhausted_aoi_partitions=%s",
|
||||||
|
result.interrupted_jobs,
|
||||||
|
result.interrupted_analysis_runs,
|
||||||
|
result.resumed_aoi_partitions,
|
||||||
|
result.exhausted_aoi_partitions,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Runtime reconciliation failed")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if settings.aoi_worker_enabled:
|
||||||
|
worker_task = asyncio.create_task(AoiOperationWorker.run(worker_stop, settings.aoi_worker_poll_seconds))
|
||||||
|
if settings.analysis_worker_enabled:
|
||||||
|
analysis_worker_task = asyncio.create_task(
|
||||||
|
AnalysisJobWorker.run(worker_stop, settings.analysis_worker_poll_seconds)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
worker_stop.set()
|
||||||
|
for task in (worker_task, analysis_worker_task):
|
||||||
|
if task is not None:
|
||||||
|
await task
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="GeoIntel",
|
||||||
|
version=settings.app_version,
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(auth.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(analysis.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(aoi_operations.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(projects.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(areas.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(datasets.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(jobs.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(quality_checks.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(exports.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(external.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(source_registry.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(demo.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(qa.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(detection.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(segmentation.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(selection_partitions.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(temporal.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(assistant.router, prefix=settings.api_prefix)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_identity(request: Request, call_next):
|
||||||
|
supplied_request_id = request.headers.get("x-request-id", "")
|
||||||
|
request_id = supplied_request_id if SAFE_REQUEST_ID.fullmatch(supplied_request_id) else str(uuid.uuid4())
|
||||||
|
request.state.request_id = request_id
|
||||||
|
token = set_request_id(request_id)
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
raw_path = str(request.scope.get("path") or "")
|
||||||
|
guest_compute_acquired = False
|
||||||
|
try:
|
||||||
|
host = request.headers.get("host", "")
|
||||||
|
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||||
|
if not raw_path.startswith("/") or not host or UNSAFE_HOST.search(host):
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"INVALID_REQUEST_TARGET",
|
||||||
|
"The request target or Host header is invalid",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
if content_type == "application/x-www-form-urlencoded":
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=415,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"UNSUPPORTED_CONTENT_TYPE",
|
||||||
|
"URL-encoded form bodies are not supported",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
public_auth_paths = {
|
||||||
|
f"{settings.api_prefix}/auth/session",
|
||||||
|
f"{settings.api_prefix}/auth/login",
|
||||||
|
f"{settings.api_prefix}/auth/guest",
|
||||||
|
f"{settings.api_prefix}/auth/logout",
|
||||||
|
f"{settings.api_prefix}/auth/authentik/start",
|
||||||
|
f"{settings.api_prefix}/auth/authentik/callback",
|
||||||
|
}
|
||||||
|
direct_loopback_request = (
|
||||||
|
request.client is not None
|
||||||
|
and request.client.host in {"127.0.0.1", "::1"}
|
||||||
|
and not request.headers.get("x-real-ip")
|
||||||
|
and not request.headers.get("x-forwarded-for")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
settings.auth_enabled
|
||||||
|
and raw_path.startswith(f"{settings.api_prefix}/")
|
||||||
|
and raw_path not in public_auth_paths
|
||||||
|
and not direct_loopback_request
|
||||||
|
):
|
||||||
|
principal = AuthService.verify_session_token(
|
||||||
|
request.cookies.get(auth.COOKIE_NAME),
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
if principal is None:
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"AUTHENTICATION_REQUIRED",
|
||||||
|
"Meld u aan om de GeoIntel API te gebruiken.",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
request.state.auth_principal = principal
|
||||||
|
if principal.role == "guest":
|
||||||
|
project_path_prefix = f"{settings.api_prefix}/projects/"
|
||||||
|
guest_project_root = f"{project_path_prefix}{principal.project_id}"
|
||||||
|
if raw_path.startswith(project_path_prefix):
|
||||||
|
scoped_path = raw_path[len(project_path_prefix):]
|
||||||
|
requested_project_id = scoped_path.split("/", 1)[0]
|
||||||
|
if str(principal.project_id) != requested_project_id:
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"GUEST_PROJECT_SCOPE_REQUIRED",
|
||||||
|
"Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
query_project_id = request.query_params.get("project_id")
|
||||||
|
if query_project_id and query_project_id != str(principal.project_id):
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"GUEST_PROJECT_SCOPE_REQUIRED",
|
||||||
|
"Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
guest_safe_read_paths = {
|
||||||
|
f"{settings.api_prefix}/projects",
|
||||||
|
f"{settings.api_prefix}/external/providers",
|
||||||
|
f"{settings.api_prefix}/assistant/status",
|
||||||
|
f"{settings.api_prefix}/assistant/models",
|
||||||
|
f"{settings.api_prefix}/detection/models",
|
||||||
|
f"{settings.api_prefix}/detection/model-assets",
|
||||||
|
f"{settings.api_prefix}/detection/yolo/preflight",
|
||||||
|
f"{settings.api_prefix}/segmentation/models",
|
||||||
|
}
|
||||||
|
normalized_path = raw_path.rstrip("/") or "/"
|
||||||
|
guest_project_read = (
|
||||||
|
normalized_path == guest_project_root
|
||||||
|
or normalized_path.startswith(f"{guest_project_root}/")
|
||||||
|
)
|
||||||
|
is_read_request = request.method in {"GET", "HEAD", "OPTIONS"}
|
||||||
|
if is_read_request:
|
||||||
|
if (
|
||||||
|
normalized_path == f"{settings.api_prefix}/detection/yolo/preflight"
|
||||||
|
and request.query_params.get("check_model_load", "").lower() in {"1", "true", "yes", "on"}
|
||||||
|
):
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"GUEST_MODEL_LOAD_FORBIDDEN",
|
||||||
|
"Model loading is available to authenticated operators only.",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
guest_scoped_analysis_read = (
|
||||||
|
query_project_id == str(principal.project_id)
|
||||||
|
and normalized_path.startswith(
|
||||||
|
(
|
||||||
|
f"{settings.api_prefix}/detection/",
|
||||||
|
f"{settings.api_prefix}/segmentation/",
|
||||||
|
f"{settings.api_prefix}/exports/",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
normalized_path not in guest_safe_read_paths
|
||||||
|
and not guest_project_read
|
||||||
|
and not guest_scoped_analysis_read
|
||||||
|
):
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"GUEST_ROUTE_NOT_AVAILABLE",
|
||||||
|
"Deze API-route maakt geen deel uit van de afgeschermde GeoIntel-demo.",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
guest_safe_post_paths = {
|
||||||
|
f"{settings.api_prefix}/demo/workflow",
|
||||||
|
f"{settings.api_prefix}/external/coverage/resolve",
|
||||||
|
f"{settings.api_prefix}/analysis/change-detection",
|
||||||
|
}
|
||||||
|
guest_scoped_analysis_post_paths = {
|
||||||
|
f"{settings.api_prefix}/detection/run",
|
||||||
|
f"{settings.api_prefix}/detection/run-async",
|
||||||
|
f"{settings.api_prefix}/segmentation/run",
|
||||||
|
f"{settings.api_prefix}/segmentation/run-async",
|
||||||
|
f"{settings.api_prefix}/qa/detections-vs-reference",
|
||||||
|
f"{settings.api_prefix}/exports/geojson",
|
||||||
|
f"{settings.api_prefix}/exports/metadata",
|
||||||
|
f"{settings.api_prefix}/exports/report",
|
||||||
|
f"{settings.api_prefix}/exports/map-result",
|
||||||
|
}
|
||||||
|
guest_safe_post_suffixes = (
|
||||||
|
"/acquire",
|
||||||
|
"/vector/select",
|
||||||
|
"/vector/select/derive",
|
||||||
|
"/raster/tile",
|
||||||
|
"/raster/bathymetry/select",
|
||||||
|
"/raster/terrain/select",
|
||||||
|
"/raster/flood-hazard/select",
|
||||||
|
"/raster/thematic/select",
|
||||||
|
"/raster/walous/select",
|
||||||
|
"/temporal/compare",
|
||||||
|
"/datasets/vector/partitions/select",
|
||||||
|
"/datasets/bathymetry/profiles/partitions/select",
|
||||||
|
)
|
||||||
|
is_guest_safe_post = request.method == "POST" and (
|
||||||
|
raw_path in guest_safe_post_paths
|
||||||
|
or (
|
||||||
|
raw_path in guest_scoped_analysis_post_paths
|
||||||
|
and query_project_id == str(principal.project_id)
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
query_project_id == str(principal.project_id)
|
||||||
|
and raw_path.startswith(
|
||||||
|
(
|
||||||
|
f"{settings.api_prefix}/detection/runs/",
|
||||||
|
f"{settings.api_prefix}/segmentation/runs/",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
and raw_path.endswith("/qa/reference")
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
raw_path.startswith(project_path_prefix)
|
||||||
|
and (
|
||||||
|
raw_path.endswith(guest_safe_post_suffixes)
|
||||||
|
or raw_path.endswith("/assistant/query")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not is_guest_safe_post:
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"GUEST_READ_ONLY",
|
||||||
|
"Gasttoegang laat alleen projectgebonden demo-analyses toe. Meld u aan als operator voor beheerwijzigingen.",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
retry_after = AuthService.consume_guest_request(
|
||||||
|
f"guest-compute:{principal.session_id}",
|
||||||
|
max_requests=settings.guest_compute_requests_per_minute,
|
||||||
|
)
|
||||||
|
if retry_after:
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"GUEST_COMPUTE_RATE_LIMITED",
|
||||||
|
"The public demo compute budget is temporarily exhausted.",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["retry-after"] = str(retry_after)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
guest_compute_acquired = AuthService.try_acquire_guest_compute(
|
||||||
|
max_concurrency=settings.guest_compute_max_concurrency,
|
||||||
|
)
|
||||||
|
if not guest_compute_acquired:
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"GUEST_COMPUTE_BUSY",
|
||||||
|
"The public demo is already processing its maximum number of jobs.",
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response.headers["retry-after"] = "10"
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
return response
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
logger.info(
|
||||||
|
"request_complete request_id=%s method=%s path=%s status=%s duration_ms=%.1f",
|
||||||
|
request_id,
|
||||||
|
request.method,
|
||||||
|
raw_path,
|
||||||
|
response.status_code,
|
||||||
|
(time.perf_counter() - started_at) * 1000,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
if guest_compute_acquired:
|
||||||
|
AuthService.release_guest_compute()
|
||||||
|
reset_request_id(token)
|
||||||
|
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def app_error(request: Request, exc: AppError): # noqa: ARG001
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=_to_error_payload(
|
||||||
|
exc.code,
|
||||||
|
exc.message,
|
||||||
|
exc.details,
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_error(request: Request, exc: HTTPException): # noqa: ARG001
|
||||||
|
code = "HTTP_ERROR"
|
||||||
|
message = str(exc.detail)
|
||||||
|
details = {}
|
||||||
|
if isinstance(exc.detail, dict):
|
||||||
|
code = str(exc.detail.get("error") or exc.detail.get("code") or code)
|
||||||
|
message = str(exc.detail.get("message") or message)
|
||||||
|
raw_details = exc.detail.get("details")
|
||||||
|
details = raw_details if isinstance(raw_details, (dict, list)) else {}
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=_to_error_payload(
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_error(request: Request, exc: RequestValidationError): # noqa: ARG001
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
"Validation failed",
|
||||||
|
exc.errors(),
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unexpected_error(request: Request, exc: Exception):
|
||||||
|
logger.exception(
|
||||||
|
"Unhandled request error request_id=%s method=%s path=%s",
|
||||||
|
request.state.request_id,
|
||||||
|
request.method,
|
||||||
|
str(request.scope.get("path") or ""),
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=_to_error_payload(
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
"Unexpected server error",
|
||||||
|
{"type": exc.__class__.__name__},
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=settings.app_env == "development",
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from app.models import * # noqa: F403 - legacy compatibility shim re-exports the package API
|
||||||
Whitespace-only changes.
@@ -0,0 +1,43 @@
|
|||||||
|
from .entities import (
|
||||||
|
AoiOperation,
|
||||||
|
AoiOperationPartition,
|
||||||
|
AnalysisRun,
|
||||||
|
Area,
|
||||||
|
Dataset,
|
||||||
|
DatasetLineageEdge,
|
||||||
|
DatasetQuarantine,
|
||||||
|
DatasetVersion,
|
||||||
|
Detection,
|
||||||
|
DetectionReview,
|
||||||
|
Export,
|
||||||
|
Job,
|
||||||
|
Metric,
|
||||||
|
Project,
|
||||||
|
QualityCheck,
|
||||||
|
Segmentation,
|
||||||
|
SourceRegistry,
|
||||||
|
SourceSnapshot,
|
||||||
|
VectorFeature,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AnalysisRun",
|
||||||
|
"AoiOperation",
|
||||||
|
"AoiOperationPartition",
|
||||||
|
"Area",
|
||||||
|
"Dataset",
|
||||||
|
"DatasetLineageEdge",
|
||||||
|
"DatasetQuarantine",
|
||||||
|
"DatasetVersion",
|
||||||
|
"Detection",
|
||||||
|
"DetectionReview",
|
||||||
|
"Export",
|
||||||
|
"Job",
|
||||||
|
"Metric",
|
||||||
|
"Project",
|
||||||
|
"QualityCheck",
|
||||||
|
"Segmentation",
|
||||||
|
"SourceRegistry",
|
||||||
|
"SourceSnapshot",
|
||||||
|
"VectorFeature",
|
||||||
|
]
|
||||||
@@ -0,0 +1,788 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from geoalchemy2 import Geometry
|
||||||
|
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Float, Index, JSON, String, Text, UniqueConstraint, func, text
|
||||||
|
from sqlalchemy.sql.sqltypes import Integer
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_CLASSIFICATIONS = (
|
||||||
|
"authoritative",
|
||||||
|
"corroborative",
|
||||||
|
"contextual",
|
||||||
|
"derived",
|
||||||
|
"experimental",
|
||||||
|
)
|
||||||
|
SOURCE_FRESHNESS_STATUSES = (
|
||||||
|
"unknown",
|
||||||
|
"current",
|
||||||
|
"due",
|
||||||
|
"stale",
|
||||||
|
"not_applicable",
|
||||||
|
"review_required",
|
||||||
|
)
|
||||||
|
SOURCE_INGEST_STATUSES = (
|
||||||
|
"registered",
|
||||||
|
"configured",
|
||||||
|
"not_configured",
|
||||||
|
"available",
|
||||||
|
"ingested",
|
||||||
|
"failed",
|
||||||
|
"quarantined",
|
||||||
|
"legacy_unverified",
|
||||||
|
)
|
||||||
|
PROVENANCE_STATUSES = ("complete", "incomplete", "not_applicable")
|
||||||
|
LINEAGE_STATUSES = ("complete", "incomplete", "not_applicable")
|
||||||
|
VALIDATION_STATUSES = ("not_validated", "passed", "failed")
|
||||||
|
QUARANTINE_STATUSES = ("not_quarantined", "quarantined")
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
region: Mapped[str] = mapped_column(String(120), default="Belgium and Belgian North Sea")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
areas: Mapped[list["Area"]] = relationship("Area", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
datasets: Mapped[list["Dataset"]] = relationship("Dataset", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Area(Base):
|
||||||
|
__tablename__ = "areas"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
geometry: Mapped[str] = mapped_column(Geometry("MultiPolygon", srid=4326), nullable=False)
|
||||||
|
original_crs: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
area_m2: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
bbox: Mapped[str | None] = mapped_column(Geometry("Polygon", srid=4326), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
project: Mapped[Project] = relationship("Project", back_populates="areas")
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRegistry(Base):
|
||||||
|
"""Server-owned source identity and authority contract.
|
||||||
|
|
||||||
|
Dataset metadata remains descriptive until a governed importer binds a
|
||||||
|
dataset to both this registry entry and an immutable SourceSnapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "source_registry"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source_key", name="uq_source_registry_source_key"),
|
||||||
|
CheckConstraint(
|
||||||
|
"classification IN ('authoritative', 'corroborative', 'contextual', 'derived', 'experimental')",
|
||||||
|
name="ck_source_registry_classification",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"freshness_status IN ('unknown', 'current', 'due', 'stale', 'not_applicable', 'review_required')",
|
||||||
|
name="ck_source_registry_freshness_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"ingest_status IN ('registered', 'configured', 'not_configured', 'available', 'ingested', "
|
||||||
|
"'failed', 'quarantined', 'legacy_unverified')",
|
||||||
|
name="ck_source_registry_ingest_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
source_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
classification: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
authority_name: Mapped[str] = mapped_column(String(255), nullable=False, default="unknown", server_default="unknown")
|
||||||
|
authority_scope_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
provider_adapter_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
license_name: Mapped[str] = mapped_column(String(255), nullable=False, default="unknown", server_default="unknown")
|
||||||
|
license_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
usage_restrictions: Mapped[str] = mapped_column(Text, nullable=False, default="unknown", server_default="unknown")
|
||||||
|
default_crs: Mapped[str] = mapped_column(String(64), nullable=False, default="unknown", server_default="unknown")
|
||||||
|
default_units: Mapped[str] = mapped_column(String(120), nullable=False, default="unknown", server_default="unknown")
|
||||||
|
spatial_resolution_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
temporal_coverage_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
geographic_coverage_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
expected_geometry_types_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||||
|
expected_attributes_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
usage_policy_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
freshness_status: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, default="unknown", server_default="unknown"
|
||||||
|
)
|
||||||
|
ingest_status: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, default="registered", server_default="registered"
|
||||||
|
)
|
||||||
|
known_limitations_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||||
|
registry_metadata_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
snapshots: Mapped[list["SourceSnapshot"]] = relationship(
|
||||||
|
"SourceSnapshot", back_populates="source_registry", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
datasets: Mapped[list["Dataset"]] = relationship("Dataset", back_populates="source_registry")
|
||||||
|
dataset_versions: Mapped[list["DatasetVersion"]] = relationship("DatasetVersion", back_populates="source_registry")
|
||||||
|
|
||||||
|
|
||||||
|
class SourceSnapshot(Base):
|
||||||
|
"""Immutable source-version evidence recorded by governed ingestion."""
|
||||||
|
|
||||||
|
__tablename__ = "source_snapshots"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source_registry_id", "snapshot_key", name="uq_source_snapshots_registry_key"),
|
||||||
|
CheckConstraint(
|
||||||
|
"freshness_status IN ('unknown', 'current', 'due', 'stale', 'not_applicable', 'review_required')",
|
||||||
|
name="ck_source_snapshots_freshness_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"ingest_status IN ('registered', 'configured', 'not_configured', 'available', 'ingested', "
|
||||||
|
"'failed', 'quarantined', 'legacy_unverified')",
|
||||||
|
name="ck_source_snapshots_ingest_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"checksum_sha256 = lower(checksum_sha256) AND checksum_sha256 ~ '^[0-9a-f]{64}$'",
|
||||||
|
name="ck_source_snapshots_checksum_sha256",
|
||||||
|
),
|
||||||
|
Index("ix_source_snapshots_registry_fetched", "source_registry_id", "fetched_at"),
|
||||||
|
Index("ix_source_snapshots_checksum", "checksum_sha256"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
source_registry_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("source_registry.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
snapshot_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
source_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
crs: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
units: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
spatial_resolution_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
temporal_coverage_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
geographic_coverage_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
observed_schema_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
freshness_status: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, default="unknown", server_default="unknown"
|
||||||
|
)
|
||||||
|
ingest_status: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, default="registered", server_default="registered"
|
||||||
|
)
|
||||||
|
known_limitations_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||||
|
snapshot_metadata_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
source_registry: Mapped[SourceRegistry] = relationship("SourceRegistry", back_populates="snapshots")
|
||||||
|
datasets: Mapped[list["Dataset"]] = relationship("Dataset", back_populates="source_snapshot")
|
||||||
|
dataset_versions: Mapped[list["DatasetVersion"]] = relationship("DatasetVersion", back_populates="source_snapshot")
|
||||||
|
quarantines: Mapped[list["DatasetQuarantine"]] = relationship("DatasetQuarantine", back_populates="source_snapshot")
|
||||||
|
|
||||||
|
|
||||||
|
class Dataset(Base):
|
||||||
|
__tablename__ = "datasets"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
|
||||||
|
name="ck_datasets_temporal_valid_range",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"validation_status IN ('not_validated', 'passed', 'failed')",
|
||||||
|
name="ck_datasets_validation_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"provenance_status IN ('complete', 'incomplete', 'not_applicable')",
|
||||||
|
name="ck_datasets_provenance_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"lineage_status IN ('complete', 'incomplete', 'not_applicable')",
|
||||||
|
name="ck_datasets_lineage_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"quarantine_status IN ('not_quarantined', 'quarantined')",
|
||||||
|
name="ck_datasets_quarantine_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"ingest_key IS NULL OR btrim(ingest_key) <> ''",
|
||||||
|
name="ck_datasets_ingest_key_not_blank",
|
||||||
|
),
|
||||||
|
UniqueConstraint("project_id", "ingest_key", name="uq_datasets_project_ingest_key"),
|
||||||
|
Index(
|
||||||
|
"ix_datasets_project_temporal_series_observed",
|
||||||
|
"project_id",
|
||||||
|
"temporal_series_key",
|
||||||
|
"observed_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
area_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("areas.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
dataset_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
source: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
original_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
stored_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
content_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
ingest_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
derived_from_dataset_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("datasets.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
crs: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
bounds_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
resolution_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
bands_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
dataset_role: Mapped[str] = mapped_column(String(32), nullable=False, default="source", server_default="source")
|
||||||
|
source_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
reference_layer_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
source_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
provenance_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
source_registry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("source_registry.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
source_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("source_snapshots.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
data_contract_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
data_contract_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
validation_status: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
default="not_validated",
|
||||||
|
server_default="not_validated",
|
||||||
|
comment="not_validated | passed | failed",
|
||||||
|
)
|
||||||
|
validation_report_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
provenance_status: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
default="incomplete",
|
||||||
|
server_default="incomplete",
|
||||||
|
comment="complete | incomplete | not_applicable",
|
||||||
|
)
|
||||||
|
lineage_status: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
default="incomplete",
|
||||||
|
server_default="incomplete",
|
||||||
|
comment="complete | incomplete | not_applicable",
|
||||||
|
)
|
||||||
|
quarantine_status: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
default="not_quarantined",
|
||||||
|
server_default="not_quarantined",
|
||||||
|
comment="not_quarantined | quarantined",
|
||||||
|
)
|
||||||
|
imported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
temporal_series_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
valid_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
temporal_granularity: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
source_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="uploaded")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
project: Mapped[Project] = relationship("Project", back_populates="datasets")
|
||||||
|
versions: Mapped[list["DatasetVersion"]] = relationship(
|
||||||
|
"DatasetVersion",
|
||||||
|
back_populates="dataset",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
vector_features: Mapped[list["VectorFeature"]] = relationship(
|
||||||
|
"VectorFeature",
|
||||||
|
back_populates="dataset",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
source_registry: Mapped[SourceRegistry | None] = relationship("SourceRegistry", back_populates="datasets")
|
||||||
|
source_snapshot: Mapped[SourceSnapshot | None] = relationship("SourceSnapshot", back_populates="datasets")
|
||||||
|
parent_lineage_edges: Mapped[list["DatasetLineageEdge"]] = relationship(
|
||||||
|
"DatasetLineageEdge",
|
||||||
|
foreign_keys="DatasetLineageEdge.parent_dataset_id",
|
||||||
|
back_populates="parent_dataset",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
child_lineage_edges: Mapped[list["DatasetLineageEdge"]] = relationship(
|
||||||
|
"DatasetLineageEdge",
|
||||||
|
foreign_keys="DatasetLineageEdge.child_dataset_id",
|
||||||
|
back_populates="child_dataset",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
quarantines: Mapped[list["DatasetQuarantine"]] = relationship(
|
||||||
|
"DatasetQuarantine", back_populates="dataset", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetVersion(Base):
|
||||||
|
__tablename__ = "dataset_versions"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from",
|
||||||
|
name="ck_dataset_versions_temporal_valid_range",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"validation_status IN ('not_validated', 'passed', 'failed')",
|
||||||
|
name="ck_dataset_versions_validation_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"provenance_status IN ('complete', 'incomplete', 'not_applicable')",
|
||||||
|
name="ck_dataset_versions_provenance_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"lineage_status IN ('complete', 'incomplete', 'not_applicable')",
|
||||||
|
name="ck_dataset_versions_lineage_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"ingest_key IS NULL OR btrim(ingest_key) <> ''",
|
||||||
|
name="ck_dataset_versions_ingest_key_not_blank",
|
||||||
|
),
|
||||||
|
Index("ix_dataset_versions_dataset_version", "dataset_id", "version", unique=True),
|
||||||
|
UniqueConstraint("dataset_id", "ingest_key", name="uq_dataset_versions_dataset_ingest_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
dataset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
source_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
valid_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
ingest_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
source_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
provenance_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
source_registry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("source_registry.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
source_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("source_snapshots.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
data_contract_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
data_contract_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
validation_status: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
default="not_validated",
|
||||||
|
server_default="not_validated",
|
||||||
|
comment="not_validated | passed | failed",
|
||||||
|
)
|
||||||
|
validation_report_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
provenance_status: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
default="incomplete",
|
||||||
|
server_default="incomplete",
|
||||||
|
comment="complete | incomplete | not_applicable",
|
||||||
|
)
|
||||||
|
lineage_status: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
default="incomplete",
|
||||||
|
server_default="incomplete",
|
||||||
|
comment="complete | incomplete | not_applicable",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
dataset: Mapped[Dataset] = relationship("Dataset", back_populates="versions")
|
||||||
|
source_registry: Mapped[SourceRegistry | None] = relationship("SourceRegistry", back_populates="dataset_versions")
|
||||||
|
source_snapshot: Mapped[SourceSnapshot | None] = relationship("SourceSnapshot", back_populates="dataset_versions")
|
||||||
|
quarantines: Mapped[list["DatasetQuarantine"]] = relationship("DatasetQuarantine", back_populates="dataset_version")
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageEdge(Base):
|
||||||
|
"""Immutable relationship between input/output datasets and transforms."""
|
||||||
|
|
||||||
|
__tablename__ = "dataset_lineage_edges"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("parent_dataset_id <> child_dataset_id", name="ck_dataset_lineage_edges_distinct_datasets"),
|
||||||
|
UniqueConstraint(
|
||||||
|
"parent_dataset_id",
|
||||||
|
"child_dataset_id",
|
||||||
|
"relation_type",
|
||||||
|
"transformation_name",
|
||||||
|
name="uq_dataset_lineage_edges_relation",
|
||||||
|
),
|
||||||
|
Index("ix_dataset_lineage_edges_parent", "parent_dataset_id"),
|
||||||
|
Index("ix_dataset_lineage_edges_child", "child_dataset_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
parent_dataset_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
child_dataset_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
parent_dataset_version_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("dataset_versions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
child_dataset_version_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("dataset_versions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
relation_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
transformation_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
transformation_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
parameters_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
input_checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
output_checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
parent_dataset: Mapped[Dataset] = relationship(
|
||||||
|
"Dataset", foreign_keys=[parent_dataset_id], back_populates="parent_lineage_edges"
|
||||||
|
)
|
||||||
|
child_dataset: Mapped[Dataset] = relationship(
|
||||||
|
"Dataset", foreign_keys=[child_dataset_id], back_populates="child_lineage_edges"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetQuarantine(Base):
|
||||||
|
"""Durable fail-closed record for rejected or doubtful source artifacts."""
|
||||||
|
|
||||||
|
__tablename__ = "dataset_quarantines"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"dataset_id IS NOT NULL OR dataset_version_id IS NOT NULL OR source_snapshot_id IS NOT NULL",
|
||||||
|
name="ck_dataset_quarantines_target_present",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('quarantined', 'released', 'rejected')",
|
||||||
|
name="ck_dataset_quarantines_status",
|
||||||
|
),
|
||||||
|
Index("ix_dataset_quarantines_dataset_status", "dataset_id", "status"),
|
||||||
|
Index("ix_dataset_quarantines_snapshot_status", "source_snapshot_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
dataset_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
dataset_version_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("dataset_versions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
source_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("source_snapshots.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
stage: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
details_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
artifact_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
artifact_checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, default="quarantined", server_default="quarantined"
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
resolved_by: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
|
||||||
|
dataset: Mapped[Dataset | None] = relationship("Dataset", back_populates="quarantines")
|
||||||
|
dataset_version: Mapped[DatasetVersion | None] = relationship("DatasetVersion", back_populates="quarantines")
|
||||||
|
source_snapshot: Mapped[SourceSnapshot | None] = relationship("SourceSnapshot", back_populates="quarantines")
|
||||||
|
|
||||||
|
|
||||||
|
class VectorFeature(Base):
|
||||||
|
__tablename__ = "vector_features"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_vector_features_dataset_id", "dataset_id"),
|
||||||
|
Index("ix_vector_features_geometry", "geometry", postgresql_using="gist"),
|
||||||
|
Index("ix_vector_features_dataset_source_feature", "dataset_id", "source_feature_id"),
|
||||||
|
Index(
|
||||||
|
"ix_vector_features_dataset_municipality",
|
||||||
|
"dataset_id",
|
||||||
|
text("(properties_json ->> 'municipality')"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
dataset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
feature_class: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
source_feature_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
properties_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
geometry: Mapped[str] = mapped_column(Geometry("Geometry", srid=4326, spatial_index=False), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
dataset: Mapped[Dataset] = relationship("Dataset", back_populates="vector_features")
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisRun(Base):
|
||||||
|
__tablename__ = "analysis_runs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
area_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("areas.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
analysis_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
model_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
model_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
parameters_json: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Detection(Base):
|
||||||
|
__tablename__ = "detections"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_detections_project_id", "project_id"),
|
||||||
|
Index("ix_detections_dataset_id", "dataset_id"),
|
||||||
|
Index("ix_detections_analysis_run_id", "analysis_run_id"),
|
||||||
|
Index("ix_detections_class_name", "class_name"),
|
||||||
|
Index("ix_detections_geometry", "geometry", postgresql_using="gist"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
model_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
model_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
class_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
confidence: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
geometry: Mapped[str] = mapped_column(Geometry("Geometry", srid=4326, spatial_index=False), nullable=False)
|
||||||
|
bbox_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
source_tile_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
properties_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Segmentation(Base):
|
||||||
|
__tablename__ = "segmentations"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_segmentations_project_id", "project_id"),
|
||||||
|
Index("ix_segmentations_dataset_id", "dataset_id"),
|
||||||
|
Index("ix_segmentations_analysis_run_id", "analysis_run_id"),
|
||||||
|
Index("ix_segmentations_job_id", "job_id"),
|
||||||
|
Index("ix_segmentations_class_name", "class_name"),
|
||||||
|
Index("ix_segmentations_geometry", "geometry", postgresql_using="gist"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
model_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
model_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
class_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
geometry: Mapped[str] = mapped_column(Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False)
|
||||||
|
bbox_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
area_m2: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
mask_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_tile_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
tile_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
properties_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
provenance_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class QualityCheck(Base):
|
||||||
|
__tablename__ = "quality_checks"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_quality_checks_project_id", "project_id"),
|
||||||
|
Index("ix_quality_checks_reference_dataset_id", "reference_dataset_id"),
|
||||||
|
Index("ix_quality_checks_candidate_dataset_id", "candidate_dataset_id"),
|
||||||
|
Index("ix_quality_checks_analysis_run_id", "analysis_run_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
candidate_dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
reference_dataset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
check_type: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
parameters_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
findings_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Metric(Base):
|
||||||
|
__tablename__ = "metrics"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_metrics_quality_check_id", "quality_check_id"),
|
||||||
|
Index("ix_metrics_analysis_run_id", "analysis_run_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
quality_check_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("quality_checks.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
metric_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
metric_value: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
metric_unit: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
label: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionReview(Base):
|
||||||
|
__tablename__ = "detection_reviews"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"evidence_role IN ('false_positive', 'false_negative')",
|
||||||
|
name="ck_detection_reviews_evidence_role",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"decision IN ('confirmed_model_false_positive', 'confirmed_model_false_negative', "
|
||||||
|
"'reference_gap_or_change', 'qa_alignment_mismatch', "
|
||||||
|
"'imagery_obscured_or_uncertain', 'uncertain', 'unreviewed')",
|
||||||
|
name="ck_detection_reviews_decision",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"quality_check_id",
|
||||||
|
"evidence_role",
|
||||||
|
"evidence_feature_id",
|
||||||
|
name="uq_detection_reviews_evidence",
|
||||||
|
),
|
||||||
|
Index("ix_detection_reviews_project_id", "project_id"),
|
||||||
|
Index("ix_detection_reviews_quality_check_id", "quality_check_id"),
|
||||||
|
Index("ix_detection_reviews_analysis_run_id", "analysis_run_id"),
|
||||||
|
Index("ix_detection_reviews_decision", "decision"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
quality_check_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("quality_checks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("analysis_runs.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
evidence_role: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
evidence_feature_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
detection_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("detections.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
reference_feature_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("vector_features.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
decision: Mapped[str] = mapped_column(String(64), nullable=False, default="unreviewed", server_default="unreviewed")
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reviewed_by: Mapped[str] = mapped_column(String(120), nullable=False, default="operator", server_default="operator")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Export(Base):
|
||||||
|
__tablename__ = "exports"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
analysis_run_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("analysis_runs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
export_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
storage_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Job(Base):
|
||||||
|
__tablename__ = "jobs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
job_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
input_dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
output_dataset_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("datasets.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
parameters_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AoiOperation(Base):
|
||||||
|
__tablename__ = "aoi_operations"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('queued', 'running', 'partial', 'success', 'failed', 'cancelled')",
|
||||||
|
name="ck_aoi_operations_status",
|
||||||
|
),
|
||||||
|
Index("ix_aoi_operations_project_status", "project_id", "status"),
|
||||||
|
Index("ix_aoi_operations_geometry", "geometry", postgresql_using="gist"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
area_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("areas.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
parent_job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
operation_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
||||||
|
geometry: Mapped[str] = mapped_column(Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False)
|
||||||
|
request_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
plan_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AoiOperationPartition(Base):
|
||||||
|
__tablename__ = "aoi_operation_partitions"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('queued', 'running', 'success', 'failed', 'skipped')",
|
||||||
|
name="ck_aoi_operation_partitions_status",
|
||||||
|
),
|
||||||
|
UniqueConstraint("operation_id", "partition_key", name="uq_aoi_operation_partition_key"),
|
||||||
|
Index("ix_aoi_operation_partitions_operation_status", "operation_id", "status"),
|
||||||
|
Index("ix_aoi_operation_partitions_geometry", "geometry", postgresql_using="gist"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
operation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("aoi_operations.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
child_job_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
partition_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
provider_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
product_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
ordinal: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
||||||
|
geometry: Mapped[str] = mapped_column(Geometry("MultiPolygon", srid=4326, spatial_index=False), nullable=False)
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||||
|
checkpoint_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.providers import base, fixture, grb, manual, osm, registry
|
||||||
|
|
||||||
|
__all__ = ["base", "fixture", "grb", "manual", "osm", "registry"]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProviderCapability:
|
||||||
|
provider_name: str
|
||||||
|
display_name: str
|
||||||
|
authority_level: str
|
||||||
|
supported_layers: list[str]
|
||||||
|
supported_geometry_types: list[str]
|
||||||
|
supported_query_modes: list[str]
|
||||||
|
fetch_signature: str
|
||||||
|
configured: bool
|
||||||
|
status: str
|
||||||
|
limitation_message: str
|
||||||
|
attribution: str
|
||||||
|
license_note: str
|
||||||
|
not_configured_reason: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"provider_name": self.provider_name,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"authority_level": self.authority_level,
|
||||||
|
"supported_layers": self.supported_layers,
|
||||||
|
"supported_geometry_types": self.supported_geometry_types,
|
||||||
|
"supported_query_modes": self.supported_query_modes,
|
||||||
|
"fetch_signature": self.fetch_signature,
|
||||||
|
"configured": self.configured,
|
||||||
|
"status": self.status,
|
||||||
|
"limitation_message": self.limitation_message,
|
||||||
|
"attribution": self.attribution,
|
||||||
|
"license_note": self.license_note,
|
||||||
|
"not_configured_reason": self.not_configured_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BaseReferenceProvider:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider_name: str,
|
||||||
|
display_name: str,
|
||||||
|
authority_level: str,
|
||||||
|
supported_layers: list[str],
|
||||||
|
supported_geometry_types: list[str],
|
||||||
|
supported_query_modes: list[str],
|
||||||
|
fetch_signature: str,
|
||||||
|
limitation_message: str,
|
||||||
|
attribution: str,
|
||||||
|
license_note: str,
|
||||||
|
configured: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.provider_name = provider_name
|
||||||
|
self.display_name = display_name
|
||||||
|
self.authority_level = authority_level
|
||||||
|
self.supported_layers = supported_layers
|
||||||
|
self.supported_geometry_types = supported_geometry_types
|
||||||
|
self.supported_query_modes = supported_query_modes
|
||||||
|
self.fetch_signature = fetch_signature
|
||||||
|
self.limitation_message = limitation_message
|
||||||
|
self.attribution = attribution
|
||||||
|
self.license_note = license_note
|
||||||
|
self._configured = configured
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capability(self) -> ProviderCapability:
|
||||||
|
return ProviderCapability(
|
||||||
|
provider_name=self.provider_name,
|
||||||
|
display_name=self.display_name,
|
||||||
|
authority_level=self.authority_level,
|
||||||
|
supported_layers=self.supported_layers,
|
||||||
|
supported_geometry_types=self.supported_geometry_types,
|
||||||
|
supported_query_modes=self.supported_query_modes,
|
||||||
|
fetch_signature=self.fetch_signature,
|
||||||
|
configured=self.is_configured,
|
||||||
|
status="configured" if self.is_configured else "not_configured",
|
||||||
|
limitation_message=self.limitation_message,
|
||||||
|
attribution=self.attribution,
|
||||||
|
license_note=self.license_note,
|
||||||
|
not_configured_reason=None if self.is_configured else "Provider integration is not configured yet",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
return self._configured
|
||||||
|
|
||||||
|
def fetch(self, project_id: str, area_id: str | None, layers: list[str]) -> dict[str, Any]:
|
||||||
|
del project_id, area_id, layers
|
||||||
|
return {
|
||||||
|
"provider": self.provider_name,
|
||||||
|
"status": "not_configured",
|
||||||
|
"message": "Provider integration is not configured yet",
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.providers.base import BaseReferenceProvider
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureProvider(BaseReferenceProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
provider_name="fixture",
|
||||||
|
display_name="Fixture data",
|
||||||
|
authority_level="fixture",
|
||||||
|
supported_layers=["buildings", "roads", "water", "landuse", "custom"],
|
||||||
|
supported_geometry_types=["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"],
|
||||||
|
supported_query_modes=["fixture"],
|
||||||
|
fetch_signature="tests/fixtures and demo fixture upload flow",
|
||||||
|
limitation_message="Fixture provider represents local test/demo fixtures only.",
|
||||||
|
attribution="GeoIntel local fixtures",
|
||||||
|
license_note="Fixtures are for local development and tests; do not present them as official data.",
|
||||||
|
configured=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.providers.base import BaseReferenceProvider
|
||||||
|
|
||||||
|
|
||||||
|
class GRBProvider(BaseReferenceProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
provider_name="grb",
|
||||||
|
display_name="GRB",
|
||||||
|
authority_level="authoritative",
|
||||||
|
supported_layers=["buildings", "roads", "water", "parcels"],
|
||||||
|
supported_geometry_types=["Polygon", "MultiPolygon", "LineString", "MultiLineString"],
|
||||||
|
supported_query_modes=["bbox", "persisted_area"],
|
||||||
|
fetch_signature="POST /api/v1/projects/{project_id}/datasets/grb/acquire",
|
||||||
|
limitation_message=(
|
||||||
|
"Alleen expliciet begrensde selecties tot 20 km per zijde worden opgehaald. "
|
||||||
|
"Volledige providerdownloads en onbeperkte queries zijn niet toegestaan."
|
||||||
|
),
|
||||||
|
attribution="Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen",
|
||||||
|
license_note="Hergebruik volgens de open-datavoorwaarden en bronvermelding van Digitaal Vlaanderen.",
|
||||||
|
configured=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch(self, project_id: str, area_id: str | None, layers: list[str]) -> dict:
|
||||||
|
del project_id, area_id, layers
|
||||||
|
return {
|
||||||
|
"provider": self.provider_name,
|
||||||
|
"status": "bounded_request_required",
|
||||||
|
"message": (
|
||||||
|
"Use POST /api/v1/projects/{project_id}/datasets/grb/acquire with an EPSG:4326 "
|
||||||
|
"bounding box and one governed product key."
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.providers.base import BaseReferenceProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ManualProvider(BaseReferenceProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
provider_name="manual",
|
||||||
|
display_name="Manual upload",
|
||||||
|
authority_level="manual",
|
||||||
|
supported_layers=["buildings", "roads", "water", "landuse", "custom"],
|
||||||
|
supported_geometry_types=["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"],
|
||||||
|
supported_query_modes=["upload"],
|
||||||
|
fetch_signature="POST /api/v1/projects/{project_id}/datasets/upload",
|
||||||
|
limitation_message="Manual provider data is supplied through the existing dataset upload flow.",
|
||||||
|
attribution="User supplied",
|
||||||
|
license_note="License and attribution must be supplied by the uploader in source metadata.",
|
||||||
|
configured=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.providers.base import BaseReferenceProvider
|
||||||
|
|
||||||
|
|
||||||
|
class OSMProvider(BaseReferenceProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
provider_name="osm",
|
||||||
|
display_name="OpenStreetMap",
|
||||||
|
authority_level="contextual",
|
||||||
|
supported_layers=["buildings", "roads", "water", "landuse"],
|
||||||
|
supported_geometry_types=["Polygon", "MultiPolygon", "LineString", "MultiLineString"],
|
||||||
|
supported_query_modes=["area"],
|
||||||
|
fetch_signature="POST /api/v1/external/osm/fetch",
|
||||||
|
limitation_message="OSM live Overpass/download integration is not configured in Sprint 7B.",
|
||||||
|
attribution="OpenStreetMap contributors",
|
||||||
|
license_note="OpenStreetMap data is available under ODbL; attribution is required.",
|
||||||
|
configured=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.providers.base import ProviderCapability
|
||||||
|
from app.providers.fixture import FixtureProvider
|
||||||
|
from app.providers.grb import GRBProvider
|
||||||
|
from app.providers.manual import ManualProvider
|
||||||
|
from app.providers.osm import OSMProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderDatasetMapping(BaseModel):
|
||||||
|
provider_name: str
|
||||||
|
dataset_role: str
|
||||||
|
source_name: str
|
||||||
|
reference_required: bool
|
||||||
|
write_path: str = "DatasetService"
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderImportResult(BaseModel):
|
||||||
|
provider_name: str
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
requested_layers: list[str]
|
||||||
|
dataset_id: str | None = None
|
||||||
|
dataset_role: str | None = None
|
||||||
|
source_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalProviderRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.providers = {
|
||||||
|
"grb": GRBProvider(),
|
||||||
|
"osm": OSMProvider(),
|
||||||
|
"manual": ManualProvider(),
|
||||||
|
"fixture": FixtureProvider(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_capabilities(self) -> list[ProviderCapability]:
|
||||||
|
return [provider.capability for provider in self.providers.values()]
|
||||||
|
|
||||||
|
def get(self, provider_name: str):
|
||||||
|
normalized = provider_name.strip().lower()
|
||||||
|
if normalized not in self.providers:
|
||||||
|
raise AppError(code="PROVIDER_NOT_FOUND", message="Provider not found", status_code=404)
|
||||||
|
return self.providers[normalized]
|
||||||
|
|
||||||
|
def fetch(self, provider_name: str, project_id: str, area_id: str | None, layers: list[str]) -> dict:
|
||||||
|
provider = self.get(provider_name)
|
||||||
|
return provider.fetch(project_id=project_id, area_id=area_id, layers=layers)
|
||||||
|
|
||||||
|
def dataset_mapping(self, provider_name: str, requested_dataset_role: str | None = None) -> ProviderDatasetMapping:
|
||||||
|
provider = self.get(provider_name)
|
||||||
|
if provider.provider_name == "osm":
|
||||||
|
dataset_role = "reference" if requested_dataset_role == "reference" else "source"
|
||||||
|
return ProviderDatasetMapping(
|
||||||
|
provider_name="osm",
|
||||||
|
dataset_role=dataset_role,
|
||||||
|
source_name="osm",
|
||||||
|
reference_required=requested_dataset_role == "reference",
|
||||||
|
)
|
||||||
|
return ProviderDatasetMapping(
|
||||||
|
provider_name=provider.provider_name,
|
||||||
|
dataset_role="reference",
|
||||||
|
source_name=provider.provider_name,
|
||||||
|
reference_required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def import_contract(
|
||||||
|
self,
|
||||||
|
provider_name: str,
|
||||||
|
project_id: str,
|
||||||
|
area_id: str | None,
|
||||||
|
layers: list[str],
|
||||||
|
requested_dataset_role: str | None = None,
|
||||||
|
) -> ProviderImportResult:
|
||||||
|
del project_id, area_id
|
||||||
|
provider = self.get(provider_name)
|
||||||
|
mapping = self.dataset_mapping(provider.provider_name, requested_dataset_role=requested_dataset_role)
|
||||||
|
if provider.provider_name == "grb":
|
||||||
|
return ProviderImportResult(
|
||||||
|
provider_name="grb",
|
||||||
|
status="bounded_request_required",
|
||||||
|
message=(
|
||||||
|
"Use the governed project GRB acquisition endpoint with an EPSG:4326 bounding box "
|
||||||
|
"and one supported layer."
|
||||||
|
),
|
||||||
|
requested_layers=layers,
|
||||||
|
dataset_role=mapping.dataset_role,
|
||||||
|
source_name=mapping.source_name,
|
||||||
|
)
|
||||||
|
if provider.provider_name == "osm":
|
||||||
|
return ProviderImportResult(
|
||||||
|
provider_name=provider.provider_name,
|
||||||
|
status="not_configured",
|
||||||
|
message=f"No live {provider.display_name} import is configured.",
|
||||||
|
requested_layers=layers,
|
||||||
|
dataset_role=mapping.dataset_role,
|
||||||
|
source_name=mapping.source_name,
|
||||||
|
)
|
||||||
|
if provider.provider_name == "manual":
|
||||||
|
return ProviderImportResult(
|
||||||
|
provider_name="manual",
|
||||||
|
status="upload_flow_required",
|
||||||
|
message="Manual provider data must use the existing dataset upload/reference flow.",
|
||||||
|
requested_layers=layers,
|
||||||
|
dataset_role=mapping.dataset_role,
|
||||||
|
source_name=mapping.source_name,
|
||||||
|
)
|
||||||
|
return ProviderImportResult(
|
||||||
|
provider_name="fixture",
|
||||||
|
status="fixture_flow_required",
|
||||||
|
message="Fixture provider data must use checked-in demo/test fixture flows.",
|
||||||
|
requested_layers=layers,
|
||||||
|
dataset_role=mapping.dataset_role,
|
||||||
|
source_name=mapping.source_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_registry = ExternalProviderRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def list_provider_capabilities() -> list[ProviderCapability]:
|
||||||
|
return _registry.list_capabilities()
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(provider_name: str):
|
||||||
|
return _registry.get(provider_name)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_provider_data(provider_name: str, project_id: str, area_id: str | None, layers: list[str]) -> dict:
|
||||||
|
return _registry.fetch(provider_name, project_id, area_id, layers)
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_dataset_mapping(provider_name: str, requested_dataset_role: str | None = None) -> ProviderDatasetMapping:
|
||||||
|
return _registry.dataset_mapping(provider_name, requested_dataset_role=requested_dataset_role)
|
||||||
|
|
||||||
|
|
||||||
|
def import_provider_dataset(
|
||||||
|
provider_name: str,
|
||||||
|
project_id: str,
|
||||||
|
area_id: str | None,
|
||||||
|
layers: list[str],
|
||||||
|
requested_dataset_role: str | None = None,
|
||||||
|
) -> ProviderImportResult:
|
||||||
|
return _registry.import_contract(
|
||||||
|
provider_name=provider_name,
|
||||||
|
project_id=project_id,
|
||||||
|
area_id=area_id,
|
||||||
|
layers=layers,
|
||||||
|
requested_dataset_role=requested_dataset_role,
|
||||||
|
)
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,357 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .common import (
|
||||||
|
ApiErrorEnvelope,
|
||||||
|
ApiErrorItem,
|
||||||
|
Envelope,
|
||||||
|
GeoJsonFeature,
|
||||||
|
GeoJsonFeatureCollection,
|
||||||
|
ItemList,
|
||||||
|
PaginationEnvelope,
|
||||||
|
)
|
||||||
|
from .coverage import (
|
||||||
|
CoverageBBox,
|
||||||
|
CoverageCatalogResponse,
|
||||||
|
CoverageResolutionItem,
|
||||||
|
CoverageResolveRequest,
|
||||||
|
CoverageResolveResponse,
|
||||||
|
CoverageSourceContract,
|
||||||
|
)
|
||||||
|
from .project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
|
||||||
|
from .area import AreaCreate, AreaList, AreaRead, AreaUpdate
|
||||||
|
from .analysis import ChangeDetectionRequest, ChangeDetectionSummary
|
||||||
|
from .dataset import DatasetCreateResponse, DatasetList
|
||||||
|
from .source_freshness import (
|
||||||
|
SourceFreshnessItem,
|
||||||
|
SourceFreshnessReport,
|
||||||
|
SourceFreshnessSummary,
|
||||||
|
SourceIntegritySummary,
|
||||||
|
)
|
||||||
|
from .source_catalog import (
|
||||||
|
SourceCatalogProbeItem,
|
||||||
|
SourceCatalogProbeReport,
|
||||||
|
SourceCatalogProbeSummary,
|
||||||
|
)
|
||||||
|
from .source_registry import (
|
||||||
|
DatasetProvenanceRead,
|
||||||
|
DatasetLineageEdgeRead,
|
||||||
|
DatasetQuarantineRead,
|
||||||
|
SourceRegistryDetailRead,
|
||||||
|
SourceRegistryRead,
|
||||||
|
SourceSnapshotRead,
|
||||||
|
)
|
||||||
|
from .grb_refresh import GrbRefreshLayerPlan, GrbRefreshPlan, GrbRefreshPlanSummary
|
||||||
|
from .grb import GrbAcquireRequest, GrbAcquisitionResult, GrbProductRead
|
||||||
|
from .official_vector import (
|
||||||
|
OfficialVectorAcquireRequest,
|
||||||
|
OfficialVectorAcquisitionResult,
|
||||||
|
OfficialVectorProductRead,
|
||||||
|
)
|
||||||
|
from .detection import (
|
||||||
|
DetectionListResponse,
|
||||||
|
DetectionModelCapability,
|
||||||
|
DetectionModelsResponse,
|
||||||
|
DetectionQaRequest,
|
||||||
|
DetectionRead,
|
||||||
|
DetectionRunListResponse,
|
||||||
|
DetectionRunRead,
|
||||||
|
DetectionRunRequest,
|
||||||
|
DetectionComparisonRequest,
|
||||||
|
DetectionComparisonResponse,
|
||||||
|
DetectionRunResponse,
|
||||||
|
ModelAssetListResponse,
|
||||||
|
ModelAssetRead,
|
||||||
|
YoloPreflightResponse,
|
||||||
|
)
|
||||||
|
from .detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewSummary, DetectionReviewUpsert
|
||||||
|
from .segmentation import (
|
||||||
|
SegmentationListResponse,
|
||||||
|
SegmentationModelCapability,
|
||||||
|
SegmentationModelsResponse,
|
||||||
|
SegmentationQaRequest,
|
||||||
|
SegmentationRead,
|
||||||
|
SegmentationRunListResponse,
|
||||||
|
SegmentationRunRead,
|
||||||
|
SegmentationRunRequest,
|
||||||
|
SegmentationRunResponse,
|
||||||
|
)
|
||||||
|
from .health import HealthResponse, SystemCapabilities
|
||||||
|
from .job import JobCreate, JobList, JobRead, JobStatus
|
||||||
|
from .orthophoto import OrthophotoAcquireRequest, OrthophotoAcquisitionResult, OrthophotoProductRead
|
||||||
|
from .dhmv import (
|
||||||
|
DhmvAcquireRequest,
|
||||||
|
DhmvAcquisitionResult,
|
||||||
|
DhmvProductRead,
|
||||||
|
TerrainMetric,
|
||||||
|
TerrainPartitionSelectionRequest,
|
||||||
|
TerrainSelectionRequest,
|
||||||
|
TerrainSelectionResponse,
|
||||||
|
TerrainSelectionSummary,
|
||||||
|
)
|
||||||
|
from .spw_terrain import (
|
||||||
|
SpwTerrainAcquireRequest,
|
||||||
|
SpwTerrainAcquisitionResult,
|
||||||
|
SpwTerrainProductRead,
|
||||||
|
)
|
||||||
|
from .flood_hazard import (
|
||||||
|
FloodHazardAcquireRequest,
|
||||||
|
FloodHazardAcquisitionResult,
|
||||||
|
FloodHazardMetric,
|
||||||
|
FloodHazardPartitionSelectionRequest,
|
||||||
|
FloodHazardProductRead,
|
||||||
|
FloodHazardSelectionRequest,
|
||||||
|
FloodHazardSelectionResponse,
|
||||||
|
FloodHazardSelectionSummary,
|
||||||
|
)
|
||||||
|
from .bathymetry import (
|
||||||
|
BathymetryPartitionFinalizeRequest,
|
||||||
|
BathymetryPartitionFinalizationResult,
|
||||||
|
BathymetryProfileAcquireRequest,
|
||||||
|
BathymetryProfileAcquisitionResult,
|
||||||
|
BathymetryRasterMetric,
|
||||||
|
BathymetryRasterSelectionRequest,
|
||||||
|
BathymetryRasterSelectionResponse,
|
||||||
|
BathymetryRasterSelectionSummary,
|
||||||
|
BathymetrySourceProbeRead,
|
||||||
|
BathymetrySourceRead,
|
||||||
|
MdkBathymetryAcquireRequest,
|
||||||
|
MdkBathymetryAcquisitionResult,
|
||||||
|
)
|
||||||
|
from .thematic_raster import (
|
||||||
|
ThematicRasterAcquireRequest,
|
||||||
|
ThematicRasterAcquisitionResult,
|
||||||
|
ThematicRasterMetric,
|
||||||
|
ThematicRasterProductRead,
|
||||||
|
ThematicRasterSelectionRequest,
|
||||||
|
ThematicRasterSelectionResponse,
|
||||||
|
ThematicRasterSelectionSummary,
|
||||||
|
)
|
||||||
|
from .external import (
|
||||||
|
ExternalFetchRequest,
|
||||||
|
ExternalFetchResponse,
|
||||||
|
ProviderCapabilitiesResponse,
|
||||||
|
ProviderCapabilityResponse,
|
||||||
|
ProviderImportRequest,
|
||||||
|
ProviderImportResponse,
|
||||||
|
ProviderLayersResponse,
|
||||||
|
ProviderStatusResponse,
|
||||||
|
)
|
||||||
|
from .export import (
|
||||||
|
ExportContentResponse,
|
||||||
|
ExportCreateResponse,
|
||||||
|
ExportListResponse,
|
||||||
|
ExportRead,
|
||||||
|
GeoJsonExportRequest,
|
||||||
|
MetadataExportRequest,
|
||||||
|
ReportExportRequest,
|
||||||
|
)
|
||||||
|
from .qa import (
|
||||||
|
AnalysisQaResponse,
|
||||||
|
QaProviderComparisonRequest,
|
||||||
|
QaProviderComparisonResult,
|
||||||
|
QualityEvidenceResponse,
|
||||||
|
)
|
||||||
|
from .operations import (
|
||||||
|
RasterClipRequest,
|
||||||
|
RasterIndexBaseRequest,
|
||||||
|
RasterMetadataResponse,
|
||||||
|
RasterNdviRequest,
|
||||||
|
RasterNdwiRequest,
|
||||||
|
RasterNdbiRequest,
|
||||||
|
RasterOperationResult,
|
||||||
|
RasterPreviewResponse,
|
||||||
|
RasterReprojectRequest,
|
||||||
|
RasterReprojectResponse,
|
||||||
|
RasterStatsResponse,
|
||||||
|
RasterTileManifest,
|
||||||
|
RasterTileManifestTile,
|
||||||
|
RasterTileRequest,
|
||||||
|
RasterTileResponse,
|
||||||
|
VectorBBoxResponse,
|
||||||
|
VectorBufferRequest,
|
||||||
|
VectorClipRequest,
|
||||||
|
VectorIntersectRequest,
|
||||||
|
VectorOperationRequest,
|
||||||
|
VectorOperationResult,
|
||||||
|
VectorSelectionBBox,
|
||||||
|
VectorSelectionDeriveRequest,
|
||||||
|
VectorSelectionRequest,
|
||||||
|
VectorSelectionResponse,
|
||||||
|
VectorSelectionMetric,
|
||||||
|
VectorSelectionSummary,
|
||||||
|
VectorStatsRequest,
|
||||||
|
VectorStatsResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Envelope",
|
||||||
|
"ItemList",
|
||||||
|
"GeoJsonFeature",
|
||||||
|
"GeoJsonFeatureCollection",
|
||||||
|
"ApiErrorEnvelope",
|
||||||
|
"ApiErrorItem",
|
||||||
|
"PaginationEnvelope",
|
||||||
|
"CoverageBBox",
|
||||||
|
"CoverageCatalogResponse",
|
||||||
|
"CoverageResolutionItem",
|
||||||
|
"CoverageResolveRequest",
|
||||||
|
"CoverageResolveResponse",
|
||||||
|
"CoverageSourceContract",
|
||||||
|
"ProjectCreate",
|
||||||
|
"ProjectRead",
|
||||||
|
"ProjectUpdate",
|
||||||
|
"ProjectList",
|
||||||
|
"ProjectDeleteResult",
|
||||||
|
"AreaCreate",
|
||||||
|
"AreaRead",
|
||||||
|
"AreaUpdate",
|
||||||
|
"AreaList",
|
||||||
|
"ChangeDetectionRequest",
|
||||||
|
"ChangeDetectionSummary",
|
||||||
|
"DatasetCreateResponse",
|
||||||
|
"DatasetList",
|
||||||
|
"SourceFreshnessItem",
|
||||||
|
"SourceFreshnessReport",
|
||||||
|
"SourceFreshnessSummary",
|
||||||
|
"SourceIntegritySummary",
|
||||||
|
"SourceCatalogProbeItem",
|
||||||
|
"SourceCatalogProbeReport",
|
||||||
|
"SourceCatalogProbeSummary",
|
||||||
|
"SourceRegistryRead",
|
||||||
|
"SourceRegistryDetailRead",
|
||||||
|
"SourceSnapshotRead",
|
||||||
|
"DatasetLineageEdgeRead",
|
||||||
|
"DatasetQuarantineRead",
|
||||||
|
"DatasetProvenanceRead",
|
||||||
|
"GrbRefreshLayerPlan",
|
||||||
|
"GrbRefreshPlan",
|
||||||
|
"GrbRefreshPlanSummary",
|
||||||
|
"GrbAcquireRequest",
|
||||||
|
"GrbAcquisitionResult",
|
||||||
|
"GrbProductRead",
|
||||||
|
"OfficialVectorAcquireRequest",
|
||||||
|
"OfficialVectorAcquisitionResult",
|
||||||
|
"OfficialVectorProductRead",
|
||||||
|
"DetectionListResponse",
|
||||||
|
"DetectionModelCapability",
|
||||||
|
"DetectionModelsResponse",
|
||||||
|
"DetectionQaRequest",
|
||||||
|
"DetectionRead",
|
||||||
|
"DetectionRunListResponse",
|
||||||
|
"DetectionRunRead",
|
||||||
|
"DetectionRunRequest",
|
||||||
|
"DetectionComparisonRequest",
|
||||||
|
"DetectionComparisonResponse",
|
||||||
|
"DetectionRunResponse",
|
||||||
|
"ModelAssetListResponse",
|
||||||
|
"ModelAssetRead",
|
||||||
|
"YoloPreflightResponse",
|
||||||
|
"DetectionReviewList",
|
||||||
|
"DetectionReviewRead",
|
||||||
|
"DetectionReviewSummary",
|
||||||
|
"DetectionReviewUpsert",
|
||||||
|
"SegmentationListResponse",
|
||||||
|
"SegmentationModelCapability",
|
||||||
|
"SegmentationModelsResponse",
|
||||||
|
"SegmentationQaRequest",
|
||||||
|
"SegmentationRead",
|
||||||
|
"SegmentationRunListResponse",
|
||||||
|
"SegmentationRunRead",
|
||||||
|
"SegmentationRunRequest",
|
||||||
|
"SegmentationRunResponse",
|
||||||
|
"HealthResponse",
|
||||||
|
"SystemCapabilities",
|
||||||
|
"JobCreate",
|
||||||
|
"JobList",
|
||||||
|
"JobRead",
|
||||||
|
"JobStatus",
|
||||||
|
"OrthophotoAcquireRequest",
|
||||||
|
"OrthophotoAcquisitionResult",
|
||||||
|
"OrthophotoProductRead",
|
||||||
|
"DhmvAcquireRequest",
|
||||||
|
"DhmvAcquisitionResult",
|
||||||
|
"DhmvProductRead",
|
||||||
|
"SpwTerrainAcquireRequest",
|
||||||
|
"SpwTerrainAcquisitionResult",
|
||||||
|
"SpwTerrainProductRead",
|
||||||
|
"TerrainMetric",
|
||||||
|
"TerrainPartitionSelectionRequest",
|
||||||
|
"TerrainSelectionRequest",
|
||||||
|
"TerrainSelectionResponse",
|
||||||
|
"TerrainSelectionSummary",
|
||||||
|
"FloodHazardAcquireRequest",
|
||||||
|
"FloodHazardAcquisitionResult",
|
||||||
|
"FloodHazardMetric",
|
||||||
|
"FloodHazardPartitionSelectionRequest",
|
||||||
|
"FloodHazardProductRead",
|
||||||
|
"FloodHazardSelectionRequest",
|
||||||
|
"FloodHazardSelectionResponse",
|
||||||
|
"FloodHazardSelectionSummary",
|
||||||
|
"BathymetryProfileAcquireRequest",
|
||||||
|
"BathymetryProfileAcquisitionResult",
|
||||||
|
"BathymetryRasterMetric",
|
||||||
|
"BathymetryRasterSelectionRequest",
|
||||||
|
"BathymetryRasterSelectionResponse",
|
||||||
|
"BathymetryRasterSelectionSummary",
|
||||||
|
"BathymetryPartitionFinalizeRequest",
|
||||||
|
"BathymetryPartitionFinalizationResult",
|
||||||
|
"BathymetrySourceProbeRead",
|
||||||
|
"BathymetrySourceRead",
|
||||||
|
"MdkBathymetryAcquireRequest",
|
||||||
|
"MdkBathymetryAcquisitionResult",
|
||||||
|
"ThematicRasterAcquireRequest",
|
||||||
|
"ThematicRasterAcquisitionResult",
|
||||||
|
"ThematicRasterMetric",
|
||||||
|
"ThematicRasterProductRead",
|
||||||
|
"ThematicRasterSelectionRequest",
|
||||||
|
"ThematicRasterSelectionResponse",
|
||||||
|
"ThematicRasterSelectionSummary",
|
||||||
|
"VectorBBoxResponse",
|
||||||
|
"VectorClipRequest",
|
||||||
|
"VectorBufferRequest",
|
||||||
|
"VectorIntersectRequest",
|
||||||
|
"VectorOperationRequest",
|
||||||
|
"VectorOperationResult",
|
||||||
|
"VectorSelectionBBox",
|
||||||
|
"VectorSelectionDeriveRequest",
|
||||||
|
"VectorSelectionRequest",
|
||||||
|
"VectorSelectionResponse",
|
||||||
|
"VectorSelectionMetric",
|
||||||
|
"VectorSelectionSummary",
|
||||||
|
"RasterClipRequest",
|
||||||
|
"RasterStatsResponse",
|
||||||
|
"RasterReprojectRequest",
|
||||||
|
"RasterReprojectResponse",
|
||||||
|
"RasterTileRequest",
|
||||||
|
"RasterMetadataResponse",
|
||||||
|
"RasterOperationResult",
|
||||||
|
"RasterPreviewResponse",
|
||||||
|
"RasterTileManifestTile",
|
||||||
|
"RasterTileManifest",
|
||||||
|
"RasterTileResponse",
|
||||||
|
"RasterIndexBaseRequest",
|
||||||
|
"RasterNdviRequest",
|
||||||
|
"RasterNdwiRequest",
|
||||||
|
"RasterNdbiRequest",
|
||||||
|
"VectorStatsRequest",
|
||||||
|
"VectorStatsResponse",
|
||||||
|
"ExternalFetchRequest",
|
||||||
|
"ExternalFetchResponse",
|
||||||
|
"ProviderCapabilitiesResponse",
|
||||||
|
"ProviderCapabilityResponse",
|
||||||
|
"ProviderImportRequest",
|
||||||
|
"ProviderImportResponse",
|
||||||
|
"ProviderLayersResponse",
|
||||||
|
"ProviderStatusResponse",
|
||||||
|
"GeoJsonExportRequest",
|
||||||
|
"MetadataExportRequest",
|
||||||
|
"ReportExportRequest",
|
||||||
|
"ExportRead",
|
||||||
|
"ExportCreateResponse",
|
||||||
|
"ExportListResponse",
|
||||||
|
"ExportContentResponse",
|
||||||
|
"QaProviderComparisonRequest",
|
||||||
|
"QaProviderComparisonResult",
|
||||||
|
"AnalysisQaResponse",
|
||||||
|
"QualityEvidenceResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.operations import VectorSelectionBBox
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeDetectionRequest(BaseModel):
|
||||||
|
source_dataset_id: UUID
|
||||||
|
target_dataset_id: UUID
|
||||||
|
iou_threshold: float = Field(default=0.8, ge=0.0, le=1.0)
|
||||||
|
# Below this the two footprints are separate objects rather than one that
|
||||||
|
# was redrawn; between the two thresholds the change class is "modified".
|
||||||
|
modified_threshold: float = Field(default=0.3, ge=0.0, le=1.0)
|
||||||
|
include_unchanged: bool = True
|
||||||
|
# Without a selection the comparison covers both datasets in full, which is
|
||||||
|
# rarely the question and never a response a map can draw.
|
||||||
|
bbox: VectorSelectionBBox | None = None
|
||||||
|
area_id: UUID | None = None
|
||||||
|
preview_limit: int = Field(default=2_000, ge=1, le=20_000)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeDetectionSummary(BaseModel):
|
||||||
|
source_dataset_id: UUID
|
||||||
|
target_dataset_id: UUID
|
||||||
|
source_feature_count: int
|
||||||
|
target_feature_count: int
|
||||||
|
added_count: int
|
||||||
|
removed_count: int
|
||||||
|
# A footprint that was redrawn rather than demolished and rebuilt. Without
|
||||||
|
# this class it appeared as one removal plus one addition.
|
||||||
|
modified_count: int = 0
|
||||||
|
unchanged_count: int
|
||||||
|
iou_threshold: float
|
||||||
|
modified_iou_threshold: float | None = None
|
||||||
|
selection_area_id: UUID | None = None
|
||||||
|
# Counts describe the whole selection; the GeoJSON is capped so a regional
|
||||||
|
# comparison does not return both datasets in one response.
|
||||||
|
preview_limit: int | None = None
|
||||||
|
preview_truncated: bool = False
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
generated_at: datetime
|
||||||
|
geojson: dict
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.operations import VectorSelectionBBox
|
||||||
|
|
||||||
|
|
||||||
|
class AoiOperationCreate(BaseModel):
|
||||||
|
area_id: UUID | None = None
|
||||||
|
bbox: VectorSelectionBBox | None = None
|
||||||
|
operation_type: str = Field(min_length=1, max_length=128)
|
||||||
|
provider_key: str = Field(min_length=1, max_length=120)
|
||||||
|
product_key: str = Field(min_length=1, max_length=120)
|
||||||
|
coverage_zone: str | None = Field(default=None, max_length=64)
|
||||||
|
max_partition_side_m: float | None = Field(default=None, gt=0, le=60_000)
|
||||||
|
max_attempts: int = Field(default=3, ge=1, le=10)
|
||||||
|
parameters_json: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AoiPartitionRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
partition_key: str
|
||||||
|
provider_key: str
|
||||||
|
product_key: str
|
||||||
|
ordinal: int
|
||||||
|
status: str
|
||||||
|
attempt_count: int
|
||||||
|
max_attempts: int
|
||||||
|
checkpoint_json: dict | None = None
|
||||||
|
result_json: dict | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AoiOperationRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
area_id: UUID | None = None
|
||||||
|
parent_job_id: UUID | None = None
|
||||||
|
operation_type: str
|
||||||
|
status: str
|
||||||
|
request_json: dict
|
||||||
|
plan_json: dict
|
||||||
|
result_json: dict | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
progress: float
|
||||||
|
partition_counts: dict[str, int]
|
||||||
|
partitions: list[AoiPartitionRead] = Field(default_factory=list)
|
||||||
|
created_at: datetime | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AoiOperationList(BaseModel):
|
||||||
|
items: list[AoiOperationRead]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AoiPartitionCheckpoint(BaseModel):
|
||||||
|
checkpoint_json: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AoiPartitionComplete(BaseModel):
|
||||||
|
result_json: dict = Field(default_factory=dict)
|
||||||
|
skipped: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AoiPartitionFail(BaseModel):
|
||||||
|
error_message: str = Field(min_length=1, max_length=4000)
|
||||||
|
retryable: bool = True
|
||||||
|
details: dict = Field(default_factory=dict)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AreaCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
geometry: dict
|
||||||
|
crs: str | None = "EPSG:4326"
|
||||||
|
|
||||||
|
|
||||||
|
class AreaUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
geometry: dict | None = None
|
||||||
|
crs: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AreaRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
name: str
|
||||||
|
original_crs: str | None
|
||||||
|
area_m2: float | None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
geometry_type: str | None = None
|
||||||
|
geometry: dict | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AreaListItem(AreaRead):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AreaList(BaseModel):
|
||||||
|
items: list[AreaRead]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalitySearchItem(BaseModel):
|
||||||
|
niscode: str
|
||||||
|
name: str
|
||||||
|
name_nl: str | None = None
|
||||||
|
name_fr: str | None = None
|
||||||
|
name_de: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalitySearchList(BaseModel):
|
||||||
|
items: list[MunicipalitySearchItem]
|
||||||
|
total: int
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.operations import VectorSelectionBBox
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantChatMessage(BaseModel):
|
||||||
|
role: Literal["user", "assistant"]
|
||||||
|
content: str = Field(min_length=1, max_length=4_000)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantQueryRequest(BaseModel):
|
||||||
|
question: str = Field(min_length=2, max_length=2_000)
|
||||||
|
model: str | None = Field(default=None, max_length=255)
|
||||||
|
bbox: VectorSelectionBBox | None = None
|
||||||
|
area_id: UUID | None = None
|
||||||
|
history: list[AssistantChatMessage] = Field(default_factory=list, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantModelRead(BaseModel):
|
||||||
|
name: str
|
||||||
|
size_bytes: int | None = None
|
||||||
|
parameter_size: str | None = None
|
||||||
|
quantization_level: str | None = None
|
||||||
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantModelList(BaseModel):
|
||||||
|
items: list[AssistantModelRead]
|
||||||
|
total: int
|
||||||
|
default_model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantStatus(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
reachable: bool
|
||||||
|
status: str
|
||||||
|
base_url: str
|
||||||
|
default_model: str | None = None
|
||||||
|
model_count: int = 0
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantContextMetric(BaseModel):
|
||||||
|
theme: str
|
||||||
|
label: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
source: str
|
||||||
|
dataset_id: UUID
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
is_estimate: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantTemporalSeries(BaseModel):
|
||||||
|
temporal_series_key: str
|
||||||
|
label: str
|
||||||
|
source: str
|
||||||
|
first_year: int
|
||||||
|
last_year: int
|
||||||
|
observation_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantEstimateDisclosure(BaseModel):
|
||||||
|
"""A value in the answer that the source itself calls an estimate.
|
||||||
|
|
||||||
|
Derived from metric metadata rather than from the generated sentences, so
|
||||||
|
the disclosure is present whatever wording the model chose.
|
||||||
|
"""
|
||||||
|
|
||||||
|
theme: str
|
||||||
|
label: str
|
||||||
|
unit: str
|
||||||
|
source: str
|
||||||
|
dataset_id: UUID
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantQueryResponse(BaseModel):
|
||||||
|
answer: str
|
||||||
|
model: str
|
||||||
|
scope_label: str
|
||||||
|
context_metrics: list[AssistantContextMetric]
|
||||||
|
temporal_series: list[AssistantTemporalSeries]
|
||||||
|
estimate_disclosures: list[AssistantEstimateDisclosure] = Field(default_factory=list)
|
||||||
|
source_dataset_ids: list[UUID]
|
||||||
|
warnings: list[str]
|
||||||
|
generated_at: datetime
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.common import Envelope
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSession(BaseModel):
|
||||||
|
authentication_required: bool
|
||||||
|
authenticated: bool
|
||||||
|
username: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
role: Literal["operator", "guest"] | None = None
|
||||||
|
guest_access_enabled: bool = False
|
||||||
|
authentik_enabled: bool = False
|
||||||
|
guest_project_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionEnvelope(Envelope[AuthSession]):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from .operations import VectorSelectionBBox
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetryProfileAcquireRequest(BaseModel):
|
||||||
|
bbox: VectorSelectionBBox
|
||||||
|
area_id: UUID | None = None
|
||||||
|
force_refresh: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetrySourceRead(BaseModel):
|
||||||
|
key: str
|
||||||
|
display_name: str
|
||||||
|
owner: str
|
||||||
|
authority_level: Literal["authoritative", "contextual"]
|
||||||
|
geographic_coverage: str
|
||||||
|
data_kind: str
|
||||||
|
query_modes: list[str]
|
||||||
|
vertical_reference: str
|
||||||
|
horizontal_crs: str
|
||||||
|
native_resolution: str | None = None
|
||||||
|
integration_status: Literal["operational", "probe_only", "available_not_integrated", "catalog_only"]
|
||||||
|
acquisition_supported: bool
|
||||||
|
configured: bool
|
||||||
|
service_url: str | None = None
|
||||||
|
catalog_url: str
|
||||||
|
attribution: str
|
||||||
|
license_note: str
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetryProfileAcquisitionResult(BaseModel):
|
||||||
|
output_dataset_id: UUID
|
||||||
|
reused: bool
|
||||||
|
provider: str
|
||||||
|
profile_count: int = Field(ge=0)
|
||||||
|
document_count: int = Field(ge=0)
|
||||||
|
structured_depth_count: int = Field(ge=0)
|
||||||
|
structured_width_count: int = Field(ge=0)
|
||||||
|
watercourse_count: int = Field(ge=0)
|
||||||
|
bbox_epsg4326: list[float]
|
||||||
|
clipped_to_area_id: UUID | None = None
|
||||||
|
measurement_date_min: str | None = None
|
||||||
|
measurement_date_max: str | None = None
|
||||||
|
attribution: str
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetryPartitionFinalizeRequest(BaseModel):
|
||||||
|
partition_scope_key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9][a-z0-9_-]*$")
|
||||||
|
expected_area_ids: list[UUID] = Field(min_length=1, max_length=500)
|
||||||
|
dataset_ids: list[UUID] = Field(default_factory=list, max_length=500)
|
||||||
|
no_profile_area_ids: list[UUID] = Field(default_factory=list, max_length=500)
|
||||||
|
manifest_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||||
|
observed_at: datetime
|
||||||
|
|
||||||
|
@field_validator("expected_area_ids", "dataset_ids", "no_profile_area_ids")
|
||||||
|
@classmethod
|
||||||
|
def require_unique_ids(cls, value: list[UUID]) -> list[UUID]:
|
||||||
|
if len(value) != len(set(value)):
|
||||||
|
raise ValueError("Partition identifiers must be unique")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetryPartitionFinalizationResult(BaseModel):
|
||||||
|
partition_scope_key: str
|
||||||
|
regional_partitions_complete: bool
|
||||||
|
partition_count: int = Field(ge=1)
|
||||||
|
data_partition_count: int = Field(ge=0)
|
||||||
|
no_profile_partition_count: int = Field(ge=0)
|
||||||
|
profile_count: int = Field(ge=0)
|
||||||
|
document_count: int = Field(ge=0)
|
||||||
|
structured_depth_count: int = Field(ge=0)
|
||||||
|
measurement_date_min: str | None = None
|
||||||
|
measurement_date_max: str | None = None
|
||||||
|
dataset_ids: list[UUID]
|
||||||
|
manifest_sha256: str
|
||||||
|
observed_at: datetime
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetrySourceProbeRead(BaseModel):
|
||||||
|
source_key: str
|
||||||
|
status: Literal[
|
||||||
|
"disabled",
|
||||||
|
"invalid_configuration",
|
||||||
|
"tls_error",
|
||||||
|
"endpoint_unavailable",
|
||||||
|
"invalid_capabilities",
|
||||||
|
"reachable",
|
||||||
|
]
|
||||||
|
configured_url: str
|
||||||
|
capabilities_url: str | None = None
|
||||||
|
tls_verified: bool
|
||||||
|
capabilities_reachable: bool
|
||||||
|
acquisition_supported: bool = False
|
||||||
|
wcs_version: str | None = None
|
||||||
|
coverage_identifiers: list[str] = Field(default_factory=list)
|
||||||
|
advertised_formats: list[str] = Field(default_factory=list)
|
||||||
|
advertised_crs: list[str] = Field(default_factory=list)
|
||||||
|
response_sha256: str | None = None
|
||||||
|
checked_at: datetime
|
||||||
|
message: str
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MdkBathymetryAcquireRequest(BaseModel):
|
||||||
|
bbox: VectorSelectionBBox
|
||||||
|
area_id: UUID | None = None
|
||||||
|
force_refresh: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MdkBathymetryAcquisitionResult(BaseModel):
|
||||||
|
output_dataset_id: UUID
|
||||||
|
reused: bool
|
||||||
|
provider: str
|
||||||
|
coverage_id: str
|
||||||
|
bbox_epsg4326: list[float]
|
||||||
|
vertical_reference: str
|
||||||
|
resolution_m: float = Field(gt=0)
|
||||||
|
attribution: str
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetryRasterSelectionRequest(BaseModel):
|
||||||
|
bbox: VectorSelectionBBox
|
||||||
|
area_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetryRasterMetric(BaseModel):
|
||||||
|
metric_key: str
|
||||||
|
metric_label: str
|
||||||
|
metric_value: float
|
||||||
|
metric_unit: str
|
||||||
|
aggregation_method: str
|
||||||
|
is_estimate: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetryRasterSelectionSummary(BaseModel):
|
||||||
|
metric_label: str
|
||||||
|
metric_value: float
|
||||||
|
metric_unit: str
|
||||||
|
aggregation_method: str
|
||||||
|
primary_metric_key: str
|
||||||
|
metrics: list[BathymetryRasterMetric]
|
||||||
|
|
||||||
|
|
||||||
|
class BathymetryRasterSelectionResponse(BaseModel):
|
||||||
|
dataset_id: UUID
|
||||||
|
product_key: str
|
||||||
|
selection_bbox: VectorSelectionBBox
|
||||||
|
selection_area_id: UUID | None = None
|
||||||
|
selected_cell_count: int = Field(ge=1)
|
||||||
|
valid_cell_count: int = Field(ge=1)
|
||||||
|
coverage_ratio: float = Field(ge=0, le=1)
|
||||||
|
# Set when the drawn selection is smaller than one source cell and the
|
||||||
|
# analysis was widened to the cells it touches, so the value covers more
|
||||||
|
# ground than was requested.
|
||||||
|
cell_selection_warning: str | None = None
|
||||||
|
resolution_m: float = Field(gt=0)
|
||||||
|
vertical_reference: str
|
||||||
|
survey_period: str
|
||||||
|
summary: BathymetryRasterSelectionSummary
|
||||||
|
unsupported_metrics: list[str]
|
||||||
|
limitation_message: str
|
||||||
|
generated_at: str
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Generic, Literal, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
DataT = TypeVar("DataT")
|
||||||
|
|
||||||
|
|
||||||
|
class Envelope(BaseModel, Generic[DataT]):
|
||||||
|
data: DataT
|
||||||
|
|
||||||
|
|
||||||
|
class ItemList(BaseModel, Generic[DataT]):
|
||||||
|
items: list[DataT]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedEnvelope(ItemList[DataT], Generic[DataT]):
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationEnvelope(BaseModel):
|
||||||
|
items: list
|
||||||
|
total: int
|
||||||
|
limit: int = Field(default=50)
|
||||||
|
offset: int = Field(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiErrorItem(BaseModel):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
details: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiErrorEnvelope(BaseModel):
|
||||||
|
error: str
|
||||||
|
message: str
|
||||||
|
details: dict | list = Field(default_factory=dict)
|
||||||
|
request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GeoJsonFeature(BaseModel):
|
||||||
|
type: Literal["Feature"]
|
||||||
|
id: str | int | None = None
|
||||||
|
geometry: dict[str, Any] | None
|
||||||
|
properties: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class GeoJsonFeatureCollection(BaseModel):
|
||||||
|
type: Literal["FeatureCollection"]
|
||||||
|
features: list[GeoJsonFeature]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
CoverageStatus = Literal["operational", "partial", "not_configured", "unsupported"]
|
||||||
|
CoverageAuthority = Literal["authoritative", "official_context", "contextual"]
|
||||||
|
CoverageAcquisitionMode = Literal[
|
||||||
|
"operator_archive",
|
||||||
|
"operator_wfs",
|
||||||
|
"bounded_api",
|
||||||
|
"bounded_raster",
|
||||||
|
"catalog_only",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageBBox(BaseModel):
|
||||||
|
minx: float = Field(ge=-180, le=180)
|
||||||
|
miny: float = Field(ge=-90, le=90)
|
||||||
|
maxx: float = Field(ge=-180, le=180)
|
||||||
|
maxy: float = Field(ge=-90, le=90)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_extent(self) -> "CoverageBBox":
|
||||||
|
if self.maxx <= self.minx or self.maxy <= self.miny:
|
||||||
|
raise ValueError("bbox max values must be greater than min values")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageSourceContract(BaseModel):
|
||||||
|
source_name: str
|
||||||
|
display_name: str
|
||||||
|
authority_level: CoverageAuthority
|
||||||
|
coverage_zones: list[str]
|
||||||
|
themes: list[str]
|
||||||
|
native_layers: list[str]
|
||||||
|
supported_geometry_types: list[str]
|
||||||
|
acquisition_mode: CoverageAcquisitionMode
|
||||||
|
integration_status: CoverageStatus
|
||||||
|
source_url: str
|
||||||
|
attribution: str
|
||||||
|
license_note: str
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageCatalogResponse(BaseModel):
|
||||||
|
themes: list[str]
|
||||||
|
zones: list[str]
|
||||||
|
statuses: list[CoverageStatus]
|
||||||
|
sources: list[CoverageSourceContract]
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageResolveRequest(BaseModel):
|
||||||
|
project_id: UUID
|
||||||
|
bbox: CoverageBBox
|
||||||
|
themes: list[str] = Field(default_factory=list, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageResolutionItem(BaseModel):
|
||||||
|
zone: str
|
||||||
|
theme: str
|
||||||
|
status: CoverageStatus
|
||||||
|
source_names: list[str]
|
||||||
|
materialized_dataset_ids: list[UUID]
|
||||||
|
evidence: list["CoverageEvidenceItem"] = Field(default_factory=list)
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageEvidenceItem(BaseModel):
|
||||||
|
dataset_id: UUID
|
||||||
|
source_name: str
|
||||||
|
authority_level: CoverageAuthority
|
||||||
|
source_version: str | None = None
|
||||||
|
observed_at: str | None = None
|
||||||
|
published_at: str | None = None
|
||||||
|
crs: str | None = None
|
||||||
|
resolution: dict | None = None
|
||||||
|
coverage_bbox_epsg4326: list[float] | None = None
|
||||||
|
attribution: str | None = None
|
||||||
|
license_note: str | None = None
|
||||||
|
checksum_sha256: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageResolveResponse(BaseModel):
|
||||||
|
project_id: UUID
|
||||||
|
bbox: CoverageBBox
|
||||||
|
requested_themes: list[str]
|
||||||
|
intersected_zones: list[str]
|
||||||
|
outside_supported_scope: bool
|
||||||
|
items: list[CoverageResolutionItem]
|
||||||
|
warnings: list[str]
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetStorageResponse(BaseModel):
|
||||||
|
original_filename: str | None = None
|
||||||
|
stored_filename: str | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
checksum_sha256: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetVectorSummary(BaseModel):
|
||||||
|
feature_count: int | None = None
|
||||||
|
geometry_types: list[str] | None = None
|
||||||
|
bounds_json: dict | None = None
|
||||||
|
approximate_area_m2: float | None = None
|
||||||
|
crs: str | None = None
|
||||||
|
feature_geometry_count: int | None = None
|
||||||
|
invalid_features: int | None = None
|
||||||
|
crs_assumed: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetCreateResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
dataset_type: str
|
||||||
|
source: str
|
||||||
|
dataset_role: str = "source"
|
||||||
|
source_name: str | None = None
|
||||||
|
reference_layer_name: str | None = None
|
||||||
|
source_metadata: dict | None = None
|
||||||
|
provenance_metadata: dict | None = None
|
||||||
|
ingest_key: str | None = None
|
||||||
|
source_registry_id: UUID | None = None
|
||||||
|
source_snapshot_id: UUID | None = None
|
||||||
|
data_contract_key: str | None = None
|
||||||
|
data_contract_version: str | None = None
|
||||||
|
validation_status: str | None = None
|
||||||
|
validation_report_json: dict | None = None
|
||||||
|
provenance_status: str | None = None
|
||||||
|
lineage_status: str | None = None
|
||||||
|
quarantine_status: str | None = None
|
||||||
|
imported_at: datetime | None = None
|
||||||
|
temporal_series_key: str | None = None
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_to: datetime | None = None
|
||||||
|
temporal_granularity: str | None = None
|
||||||
|
source_version: str | None = None
|
||||||
|
project_id: UUID
|
||||||
|
area_id: UUID | None = None
|
||||||
|
storage_path: str | None = None
|
||||||
|
original_filename: str | None = None
|
||||||
|
stored_filename: str | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
checksum_sha256: str | None = None
|
||||||
|
crs: str | None = None
|
||||||
|
bounds_json: dict | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
vector_summary: DatasetVectorSummary | None = None
|
||||||
|
status: str
|
||||||
|
derived_from_dataset_id: UUID | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
feature_count: int | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetList(BaseModel):
|
||||||
|
items: list[DatasetCreateResponse]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetMetadataRefresh(BaseModel):
|
||||||
|
feature_count: int | None = None
|
||||||
|
geometry_types: list[str] | None = None
|
||||||
|
bounds_json: dict | None = None
|
||||||
|
crs: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetTemporalUpdate(BaseModel):
|
||||||
|
temporal_series_key: str
|
||||||
|
observed_at: datetime
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_to: datetime | None = None
|
||||||
|
temporal_granularity: str = "snapshot"
|
||||||
|
source_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetVersionRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
dataset_id: UUID
|
||||||
|
version: int
|
||||||
|
storage_path: str | None = None
|
||||||
|
source_version: str | None = None
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_to: datetime | None = None
|
||||||
|
checksum_sha256: str | None = None
|
||||||
|
source_metadata: dict | None = None
|
||||||
|
provenance_metadata: dict | None = None
|
||||||
|
ingest_key: str | None = None
|
||||||
|
source_registry_id: UUID | None = None
|
||||||
|
source_snapshot_id: UUID | None = None
|
||||||
|
data_contract_key: str | None = None
|
||||||
|
data_contract_version: str | None = None
|
||||||
|
validation_status: str | None = None
|
||||||
|
validation_report_json: dict | None = None
|
||||||
|
provenance_status: str | None = None
|
||||||
|
lineage_status: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRequest(BaseModel):
|
||||||
|
dataset_id: UUID
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRead(BaseModel):
|
||||||
|
export_id: UUID
|
||||||
|
path: str
|
||||||
|
status: str
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DemoWorkflowResponse(BaseModel):
|
||||||
|
project_id: UUID
|
||||||
|
area_id: UUID
|
||||||
|
reference_dataset_id: UUID
|
||||||
|
candidate_dataset_id: UUID
|
||||||
|
raster_dataset_id: UUID | None = None
|
||||||
|
quality_check_id: UUID
|
||||||
|
metric_count: int
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
created: bool
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionModelCapability(BaseModel):
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
model_id: str
|
||||||
|
display_name: str
|
||||||
|
framework: str
|
||||||
|
task_type: str
|
||||||
|
supported_classes: list[str]
|
||||||
|
configured: bool
|
||||||
|
status: str
|
||||||
|
limitation_message: str
|
||||||
|
version: str | None = None
|
||||||
|
training_scope: str | None = None
|
||||||
|
validation_scope: str | None = None
|
||||||
|
validated_regions: list[str] = Field(default_factory=list)
|
||||||
|
nationally_validated: bool = False
|
||||||
|
operator_review_required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionModelsResponse(BaseModel):
|
||||||
|
models: list[DetectionModelCapability]
|
||||||
|
|
||||||
|
|
||||||
|
class ModelAssetRead(BaseModel):
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
model_asset_id: str
|
||||||
|
filename: str
|
||||||
|
display_name: str
|
||||||
|
model_path: str
|
||||||
|
suffix: str
|
||||||
|
framework: str
|
||||||
|
task_type: str
|
||||||
|
size_bytes: int
|
||||||
|
sha256: str
|
||||||
|
active: bool
|
||||||
|
runtime_available: bool
|
||||||
|
runtime_status: str
|
||||||
|
governed_validation_status: str
|
||||||
|
promotion_status: str
|
||||||
|
status: str
|
||||||
|
limitation_message: str
|
||||||
|
will_download_models: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ModelAssetListResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
items: list[ModelAssetRead]
|
||||||
|
total: int
|
||||||
|
model_directory: str
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionRunRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
project_id: UUID
|
||||||
|
dataset_id: UUID
|
||||||
|
model_id: str
|
||||||
|
model_asset_id: str | None = None
|
||||||
|
confidence_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||||
|
class_filter: list[str] | None = None
|
||||||
|
tile_manifest_path: str | None = None
|
||||||
|
parameters_json: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionQaRequest(BaseModel):
|
||||||
|
reference_dataset_id: UUID
|
||||||
|
iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||||
|
class_name: str | None = None
|
||||||
|
min_confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
# Confidence cuts to report alongside the run's own operating point. They
|
||||||
|
# are read off the one matching pass, so a sweep costs no extra inference.
|
||||||
|
calibration_thresholds: list[float] = Field(default_factory=list, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionComparisonRequest(BaseModel):
|
||||||
|
"""Place several runs side by side against one reference."""
|
||||||
|
|
||||||
|
analysis_run_ids: list[UUID] = Field(min_length=2, max_length=12)
|
||||||
|
reference_dataset_id: UUID
|
||||||
|
iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionComparisonResponse(BaseModel):
|
||||||
|
reference_dataset_id: UUID
|
||||||
|
iou_threshold: float
|
||||||
|
# Whether these runs answer the same question at all, and why not if they
|
||||||
|
# do not. Numbers from incomparable runs are reported but never ranked as
|
||||||
|
# if they were alternatives.
|
||||||
|
comparability: dict
|
||||||
|
ranking_metric: str
|
||||||
|
rows: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionRunResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
analysis_run_id: UUID
|
||||||
|
job_id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
dataset_id: UUID
|
||||||
|
model_id: str
|
||||||
|
status: str
|
||||||
|
detection_count: int
|
||||||
|
error_code: str | None = None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionRunRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
dataset_id: UUID | None = None
|
||||||
|
job_id: UUID | None = None
|
||||||
|
analysis_type: str
|
||||||
|
status: str
|
||||||
|
model_name: str | None = None
|
||||||
|
model_version: str | None = None
|
||||||
|
parameters_json: dict
|
||||||
|
result_json: dict | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionRunListResponse(BaseModel):
|
||||||
|
items: list[DetectionRunRead]
|
||||||
|
# ``total`` counts every run; ``items`` is the most recent page of them.
|
||||||
|
total: int
|
||||||
|
limit: int | None = None
|
||||||
|
offset: int = 0
|
||||||
|
truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
dataset_id: UUID | None = None
|
||||||
|
analysis_run_id: UUID | None = None
|
||||||
|
job_id: UUID | None = None
|
||||||
|
model_name: str
|
||||||
|
model_version: str | None = None
|
||||||
|
class_name: str
|
||||||
|
confidence: float
|
||||||
|
bbox_json: dict | None = None
|
||||||
|
source_tile_path: str | None = None
|
||||||
|
properties_json: dict | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionListResponse(BaseModel):
|
||||||
|
items: list[DetectionRead]
|
||||||
|
# ``total`` is the complete population; ``items`` is one page of it.
|
||||||
|
total: int
|
||||||
|
limit: int | None = None
|
||||||
|
offset: int = 0
|
||||||
|
truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class YoloPreflightChecks(BaseModel):
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
dependencies_available: bool | None = None
|
||||||
|
accelerator_ready: bool | None = None
|
||||||
|
model_path_set: bool | None = None
|
||||||
|
model_file_exists: bool | None = None
|
||||||
|
model_provenance_manifest_path: str | None = None
|
||||||
|
model_provenance_valid: bool | None = None
|
||||||
|
model_load_requested: bool
|
||||||
|
model_load_ok: bool | None = None
|
||||||
|
manifest_path_set: bool | None = None
|
||||||
|
manifest_valid: bool | None = None
|
||||||
|
tile_paths_exist: bool | None = None
|
||||||
|
tile_limit_ok: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class YoloRuntimeDetails(BaseModel):
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
dependencies_assumed: bool
|
||||||
|
model_directory: str | None = None
|
||||||
|
yolo_config_dir: str | None = None
|
||||||
|
torch_version: str | None = None
|
||||||
|
ultralytics_version: str | None = None
|
||||||
|
cuda_available: bool | None = None
|
||||||
|
configured_device: str
|
||||||
|
cuda_required: bool
|
||||||
|
|
||||||
|
|
||||||
|
class YoloPreflightResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
model_id: str
|
||||||
|
model_asset_id: str | None = None
|
||||||
|
model_path: str | None = None
|
||||||
|
tile_manifest_path: str | None = None
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
checks: YoloPreflightChecks
|
||||||
|
tile_count: int
|
||||||
|
max_tiles: int
|
||||||
|
will_download_models: bool
|
||||||
|
will_run_inference: bool
|
||||||
|
runtime: YoloRuntimeDetails
|
||||||
|
error_code: str | None = None
|
||||||
|
details: dict | None = None
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
DetectionEvidenceRole = Literal["false_positive", "false_negative"]
|
||||||
|
DetectionReviewDecision = Literal[
|
||||||
|
"confirmed_model_false_positive",
|
||||||
|
"confirmed_model_false_negative",
|
||||||
|
"reference_gap_or_change",
|
||||||
|
"qa_alignment_mismatch",
|
||||||
|
"imagery_obscured_or_uncertain",
|
||||||
|
"uncertain",
|
||||||
|
"unreviewed",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionReviewUpsert(BaseModel):
|
||||||
|
evidence_role: DetectionEvidenceRole
|
||||||
|
evidence_feature_id: str = Field(min_length=1, max_length=255)
|
||||||
|
decision: DetectionReviewDecision
|
||||||
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
reviewed_by: str = Field(default="operator", min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionReviewRead(BaseModel):
|
||||||
|
id: UUID | None = None
|
||||||
|
project_id: UUID
|
||||||
|
quality_check_id: UUID
|
||||||
|
analysis_run_id: UUID | None = None
|
||||||
|
evidence_role: DetectionEvidenceRole
|
||||||
|
evidence_feature_id: str
|
||||||
|
detection_id: UUID | None = None
|
||||||
|
reference_feature_id: UUID | None = None
|
||||||
|
decision: DetectionReviewDecision = "unreviewed"
|
||||||
|
notes: str | None = None
|
||||||
|
reviewed_by: str | None = None
|
||||||
|
confidence: float | None = None
|
||||||
|
class_name: str | None = None
|
||||||
|
source_tile_path: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionReviewSummary(BaseModel):
|
||||||
|
total: int
|
||||||
|
reviewed: int
|
||||||
|
remaining: int
|
||||||
|
false_positive_total: int
|
||||||
|
false_negative_total: int
|
||||||
|
decision_counts: dict[str, int]
|
||||||
|
# The score with the operator's verdicts applied, next to the raw one. A
|
||||||
|
# finding adjudicated as a reference gap is not the model's error, and an
|
||||||
|
# interval covers what the unreviewed remainder could still turn out to be.
|
||||||
|
reviewed_metrics: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionReviewList(BaseModel):
|
||||||
|
items: list[DetectionReviewRead]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
summary: DetectionReviewSummary
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .operations import VectorSelectionBBox
|
||||||
|
|
||||||
|
|
||||||
|
class DhmvAcquireRequest(BaseModel):
|
||||||
|
bbox: VectorSelectionBBox
|
||||||
|
area_id: UUID | None = None
|
||||||
|
product_key: str = "dtm_1m"
|
||||||
|
resolution_m: float | None = Field(default=None, ge=1.0, le=10.0)
|
||||||
|
force_refresh: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DhmvProductRead(BaseModel):
|
||||||
|
key: str
|
||||||
|
display_name: str
|
||||||
|
surface_model: str
|
||||||
|
coverage_id: str
|
||||||
|
native_resolution_m: float
|
||||||
|
source_crs: str
|
||||||
|
vertical_reference: str
|
||||||
|
acquisition_period: str
|
||||||
|
catalog_url: str
|
||||||
|
attribution: str
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class DhmvAcquisitionResult(BaseModel):
|
||||||
|
output_dataset_id: UUID
|
||||||
|
reused: bool
|
||||||
|
provider: str
|
||||||
|
product_key: str
|
||||||
|
display_name: str
|
||||||
|
surface_model: str
|
||||||
|
coverage_id: str
|
||||||
|
native_resolution_m: float
|
||||||
|
resolution_m: float
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
valid_pixel_count: int
|
||||||
|
nodata_value: float
|
||||||
|
bbox_epsg4326: list[float]
|
||||||
|
bbox_epsg31370: list[float]
|
||||||
|
vertical_reference: str
|
||||||
|
acquisition_period: str
|
||||||
|
attribution: str
|
||||||
|
limitation_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class TerrainSelectionRequest(BaseModel):
|
||||||
|
bbox: VectorSelectionBBox
|
||||||
|
area_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TerrainPartitionSelectionRequest(TerrainSelectionRequest):
|
||||||
|
product_key: str = "dtm_1m"
|
||||||
|
dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=4096)
|
||||||
|
|
||||||
|
|
||||||
|
class TerrainMetric(BaseModel):
|
||||||
|
metric_key: str
|
||||||
|
metric_label: str
|
||||||
|
metric_value: float
|
||||||
|
metric_unit: str
|
||||||
|
aggregation_method: str
|
||||||
|
derived: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TerrainSelectionSummary(BaseModel):
|
||||||
|
metric_label: str
|
||||||
|
metric_value: float
|
||||||
|
metric_unit: str
|
||||||
|
aggregation_method: str
|
||||||
|
primary_metric_key: str
|
||||||
|
metrics: list[TerrainMetric]
|
||||||
|
|
||||||
|
|
||||||
|
class TerrainSelectionResponse(BaseModel):
|
||||||
|
dataset_id: UUID
|
||||||
|
dataset_ids: list[UUID] = Field(default_factory=list)
|
||||||
|
partition_count: int = Field(default=1, ge=1)
|
||||||
|
product_key: str
|
||||||
|
surface_model: str
|
||||||
|
selection_bbox: VectorSelectionBBox
|
||||||
|
selection_area_id: UUID | None = None
|
||||||
|
sample_count: int
|
||||||
|
slope_sample_count: int
|
||||||
|
coverage_ratio: float
|
||||||
|
# Set when the drawn selection is smaller than one source cell and the
|
||||||
|
# analysis was widened to the cells it touches, so the value covers more
|
||||||
|
# ground than was requested.
|
||||||
|
cell_selection_warning: str | None = None
|
||||||
|
resolution_m: float
|
||||||
|
vertical_reference: str
|
||||||
|
summary: TerrainSelectionSummary
|
||||||
|
unsupported_metrics: list[str]
|
||||||
|
limitation_message: str
|
||||||
|
generated_at: str
|
||||||
Loaded 100 of 764 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user