Initial public release
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
geointel_backend.egg-info
|
||||
storage
|
||||
dist
|
||||
node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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")
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.models.entities import AnalysisRun, Area, Dataset, Export, Project
|
||||
|
||||
__all__ = ["AnalysisRun", "Area", "Dataset", "Export", "Project"]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
from app.schemas.operations import VectorSelectionBBox
|
||||
|
||||
|
||||
ExportKind = Literal["dataset", "detection_run", "segmentation_run", "vector_selection"]
|
||||
DetectionExportIntendedUse = Literal["review", "operational"]
|
||||
MapResultMode = Literal["current", "evolution"]
|
||||
|
||||
|
||||
class GeoJsonExportRequest(BaseModel):
|
||||
dataset_id: UUID | None = None
|
||||
analysis_run_id: UUID | None = None
|
||||
area_id: UUID | None = None
|
||||
export_kind: ExportKind = "dataset"
|
||||
name: str | None = None
|
||||
bbox: VectorSelectionBBox | None = None
|
||||
limit: int = 250
|
||||
intended_use: DetectionExportIntendedUse = "review"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_target(self) -> "GeoJsonExportRequest":
|
||||
if self.export_kind == "dataset" and self.dataset_id is None:
|
||||
raise ValueError("dataset_id is required for dataset GeoJSON exports")
|
||||
if self.export_kind == "vector_selection":
|
||||
if self.dataset_id is None:
|
||||
raise ValueError("dataset_id is required for vector selection GeoJSON exports")
|
||||
if self.bbox is None:
|
||||
raise ValueError("bbox is required for vector selection GeoJSON exports")
|
||||
if self.export_kind in {"detection_run", "segmentation_run"} and self.analysis_run_id is None:
|
||||
raise ValueError("analysis_run_id is required for run GeoJSON exports")
|
||||
if self.intended_use == "operational" and self.export_kind != "detection_run":
|
||||
raise ValueError("operational intended_use is supported only for detection run exports")
|
||||
return self
|
||||
|
||||
|
||||
class MetadataExportRequest(BaseModel):
|
||||
project_id: UUID
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class ReportExportRequest(BaseModel):
|
||||
project_id: UUID
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class MapResultExportRequest(BaseModel):
|
||||
project_id: UUID
|
||||
mode: MapResultMode
|
||||
bbox: VectorSelectionBBox
|
||||
dataset_id: UUID | None = None
|
||||
earlier_dataset_id: UUID | None = None
|
||||
later_dataset_id: UUID | None = None
|
||||
area_id: UUID | None = None
|
||||
partitioned: bool = False
|
||||
product_key: str | None = None
|
||||
partition_scope_key: str | None = None
|
||||
theme_id: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_map_target(self) -> "MapResultExportRequest":
|
||||
if self.mode == "current" and self.dataset_id is None:
|
||||
raise ValueError("dataset_id is required for current map-result exports")
|
||||
if self.mode == "evolution" and (
|
||||
self.earlier_dataset_id is None or self.later_dataset_id is None
|
||||
):
|
||||
raise ValueError("earlier_dataset_id and later_dataset_id are required for evolution exports")
|
||||
if self.partitioned and not self.product_key and not self.partition_scope_key:
|
||||
raise ValueError("product_key or partition_scope_key is required for partitioned exports")
|
||||
return self
|
||||
|
||||
|
||||
class ExportRead(BaseModel):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
analysis_run_id: UUID | None = None
|
||||
export_type: str
|
||||
storage_path: str
|
||||
metadata_json: dict | None = None
|
||||
created_at: datetime | None = None
|
||||
status: str = "ready"
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ExportCreateResponse(BaseModel):
|
||||
export_id: UUID
|
||||
path: str
|
||||
status: str
|
||||
export_type: str
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class ExportListResponse(BaseModel):
|
||||
items: list[ExportRead]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ExportContentResponse(BaseModel):
|
||||
export_id: UUID
|
||||
export_type: str
|
||||
content: dict
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProviderCapabilityResponse(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
class ProviderCapabilitiesResponse(BaseModel):
|
||||
providers: list[ProviderCapabilityResponse]
|
||||
|
||||
|
||||
class ProviderLayersResponse(BaseModel):
|
||||
provider_name: str
|
||||
layers: list[str]
|
||||
|
||||
|
||||
class ProviderStatusResponse(BaseModel):
|
||||
provider_name: str
|
||||
configured: bool
|
||||
status: str
|
||||
limitation_message: str
|
||||
|
||||
|
||||
class ExternalFetchRequest(BaseModel):
|
||||
project_id: UUID
|
||||
area_id: UUID | None = None
|
||||
layers: list[str] = []
|
||||
|
||||
|
||||
class ExternalFetchResponse(BaseModel):
|
||||
provider: str
|
||||
status: str
|
||||
message: str
|
||||
requested_layers: list[str]
|
||||
project_id: UUID
|
||||
area_id: UUID | None = None
|
||||
|
||||
|
||||
class ProviderImportRequest(BaseModel):
|
||||
project_id: str
|
||||
area_id: str | None = None
|
||||
layers: list[str] = []
|
||||
dataset_role: str | None = None
|
||||
|
||||
|
||||
class ProviderImportResponse(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
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .operations import VectorSelectionBBox
|
||||
|
||||
|
||||
class FloodHazardAcquireRequest(BaseModel):
|
||||
bbox: VectorSelectionBBox
|
||||
area_id: UUID | None = None
|
||||
product_key: str = "pluviaal_current_t100"
|
||||
resolution_m: float | None = Field(default=None, ge=2.0, le=20.0)
|
||||
force_refresh: bool = False
|
||||
|
||||
|
||||
class FloodHazardProductRead(BaseModel):
|
||||
key: str
|
||||
display_name: str
|
||||
mechanism: str
|
||||
climate_context: str
|
||||
probability_class: str
|
||||
return_period_years: int
|
||||
coverage_id: str
|
||||
native_resolution_m: float
|
||||
source_crs: str
|
||||
source_value_unit: str
|
||||
normalized_value_unit: str
|
||||
published_on: str
|
||||
catalog_url: str
|
||||
attribution: str
|
||||
limitation_message: str
|
||||
|
||||
|
||||
class FloodHazardAcquisitionResult(BaseModel):
|
||||
output_dataset_id: UUID
|
||||
reused: bool
|
||||
provider: str
|
||||
product_key: str
|
||||
display_name: str
|
||||
mechanism: str
|
||||
climate_context: str
|
||||
probability_class: str
|
||||
return_period_years: int
|
||||
coverage_id: str
|
||||
resolution_m: float
|
||||
width: int
|
||||
height: int
|
||||
inundated_pixel_count: int
|
||||
bbox_epsg4326: list[float]
|
||||
bbox_epsg31370: list[float]
|
||||
attribution: str
|
||||
limitation_message: str
|
||||
|
||||
|
||||
class FloodHazardSelectionRequest(BaseModel):
|
||||
bbox: VectorSelectionBBox
|
||||
area_id: UUID | None = None
|
||||
|
||||
|
||||
class FloodHazardPartitionSelectionRequest(FloodHazardSelectionRequest):
|
||||
product_key: str = "pluviaal_current_t100"
|
||||
dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=4096)
|
||||
|
||||
|
||||
class FloodHazardMetric(BaseModel):
|
||||
metric_key: str
|
||||
metric_label: str
|
||||
metric_value: float
|
||||
metric_unit: str
|
||||
aggregation_method: str
|
||||
derived: bool = True
|
||||
|
||||
|
||||
class FloodHazardSelectionSummary(BaseModel):
|
||||
metric_label: str
|
||||
metric_value: float
|
||||
metric_unit: str
|
||||
aggregation_method: str
|
||||
primary_metric_key: str
|
||||
metrics: list[FloodHazardMetric]
|
||||
|
||||
|
||||
class FloodHazardSelectionResponse(BaseModel):
|
||||
dataset_id: UUID
|
||||
dataset_ids: list[UUID] = Field(default_factory=list)
|
||||
partition_count: int = Field(default=1, ge=1)
|
||||
product_key: str
|
||||
mechanism: str
|
||||
climate_context: str
|
||||
probability_class: str
|
||||
return_period_years: int
|
||||
selection_bbox: VectorSelectionBBox
|
||||
selection_area_id: UUID | None = None
|
||||
# Three populations kept apart: cells drawn, cells the model covers, and
|
||||
# cells with a positive modelled depth. ``inundated_fraction`` is a share
|
||||
# of the modelled cells, and is null when nothing was modelled — absence
|
||||
# of a model is not evidence of zero risk.
|
||||
selected_cell_count: int
|
||||
valid_cell_count: int = 0
|
||||
no_data_cell_count: int = 0
|
||||
data_coverage_ratio: float = 1.0
|
||||
inundated_cell_count: int
|
||||
inundated_fraction: float | None = None
|
||||
coverage_warning: str | None = None
|
||||
resolution_m: float
|
||||
summary: FloodHazardSelectionSummary
|
||||
unsupported_metrics: list[str]
|
||||
limitation_message: str
|
||||
generated_at: str
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .operations import VectorSelectionBBox
|
||||
|
||||
|
||||
class GrbAcquireRequest(BaseModel):
|
||||
bbox: VectorSelectionBBox
|
||||
area_id: UUID | None = None
|
||||
product_key: str = "buildings"
|
||||
force_refresh: bool = False
|
||||
|
||||
|
||||
class GrbProductRead(BaseModel):
|
||||
key: str
|
||||
display_name: str
|
||||
reference_layer_name: str
|
||||
collections: list[str]
|
||||
geometry_types: list[str]
|
||||
source_crs: str
|
||||
authority_level: str
|
||||
catalog_url: str
|
||||
attribution: str
|
||||
license_note: str
|
||||
limitation_message: str
|
||||
|
||||
|
||||
class GrbAcquisitionResult(BaseModel):
|
||||
output_dataset_id: UUID
|
||||
reused: bool
|
||||
provider: str
|
||||
product_key: str
|
||||
display_name: str
|
||||
reference_layer_name: str
|
||||
collections: list[str]
|
||||
feature_count: int
|
||||
candidate_feature_count: int
|
||||
page_count: int
|
||||
bbox_epsg4326: list[float]
|
||||
source_version: str
|
||||
attribution: str
|
||||
limitation_message: str
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
GrbRefreshLayerStatus = Literal[
|
||||
"current",
|
||||
"update_available",
|
||||
"not_loaded",
|
||||
"review_required",
|
||||
"remote_unavailable",
|
||||
]
|
||||
|
||||
|
||||
class GrbRefreshLayerPlan(BaseModel):
|
||||
theme: Literal["buildings", "roads", "water", "parcels"]
|
||||
display_name: str
|
||||
collections: list[str]
|
||||
temporal_series_key: str
|
||||
status: GrbRefreshLayerStatus
|
||||
local_dataset_id: UUID | None = None
|
||||
local_source_version: str | None = None
|
||||
local_observed_at: datetime | None = None
|
||||
local_imported_at: datetime | None = None
|
||||
local_feature_count: int | None = None
|
||||
local_size_bytes: int | None = None
|
||||
retained_after_refresh: bool = True
|
||||
action_message: str
|
||||
|
||||
|
||||
class GrbRefreshPlanSummary(BaseModel):
|
||||
layer_count: int
|
||||
current_count: int
|
||||
update_available_count: int
|
||||
not_loaded_count: int
|
||||
review_required_count: int
|
||||
remote_unavailable_count: int
|
||||
new_dataset_count_if_applied: int
|
||||
retained_dataset_count: int
|
||||
current_feature_count: int
|
||||
current_size_bytes: int
|
||||
|
||||
|
||||
class GrbRefreshPlan(BaseModel):
|
||||
project_id: UUID
|
||||
scope: str
|
||||
generated_at: datetime
|
||||
remote_status: str
|
||||
remote_version: str | None = None
|
||||
remote_edition_date: date | None = None
|
||||
catalog_checked_at: datetime | None = None
|
||||
summary: GrbRefreshPlanSummary
|
||||
layers: list[GrbRefreshLayerPlan]
|
||||
execution_mode: Literal["operator_stage_then_apply"] = "operator_stage_then_apply"
|
||||
staging_required: bool = True
|
||||
automatic_import: bool = False
|
||||
destructive_replacement: bool = False
|
||||
message: str
|
||||
limitations: list[str]
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProviderCapability(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
service: str
|
||||
version: str
|
||||
build_sha: str | None = None
|
||||
build_time: str | None = None
|
||||
database: str | None = None
|
||||
postgis: str | None = None
|
||||
migration: str | None = None
|
||||
storage: str | None = None
|
||||
checks: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SystemCapabilities(BaseModel):
|
||||
postgis: bool
|
||||
rasterio: bool
|
||||
geopandas: bool
|
||||
yolo: bool | str
|
||||
yolo_status: str
|
||||
sam: bool | str
|
||||
grb: str
|
||||
sentinel: str
|
||||
version: str
|
||||
build_sha: str | None = None
|
||||
providers: list[ProviderCapability] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemCapabilitiesEnvelope(BaseModel):
|
||||
data: SystemCapabilities
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
job_type: str
|
||||
project_id: UUID
|
||||
dataset_id: UUID | None = None
|
||||
input_dataset_id: UUID | None = None
|
||||
output_dataset_id: UUID | None = None
|
||||
parameters_json: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobRead(BaseModel):
|
||||
id: UUID
|
||||
job_type: str
|
||||
status: str
|
||||
project_id: UUID
|
||||
dataset_id: UUID | None = None
|
||||
input_dataset_id: UUID | None = None
|
||||
output_dataset_id: UUID | 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
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class JobStatus(BaseModel):
|
||||
id: UUID
|
||||
status: str
|
||||
error_message: str | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
result_json: dict | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class JobList(BaseModel):
|
||||
items: list[JobRead]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .operations import VectorSelectionBBox
|
||||
|
||||
|
||||
class OfficialVectorAcquireRequest(BaseModel):
|
||||
bbox: VectorSelectionBBox
|
||||
area_id: UUID | None = None
|
||||
product_key: str
|
||||
force_refresh: bool = False
|
||||
|
||||
|
||||
class OfficialVectorProductRead(BaseModel):
|
||||
key: str
|
||||
display_name: str
|
||||
theme: str
|
||||
provider: str
|
||||
source_name: str
|
||||
reference_layer_name: str
|
||||
service_type: str
|
||||
collection: str
|
||||
geometry_types: list[str]
|
||||
source_crs: str
|
||||
source_version: str
|
||||
observation_label: str
|
||||
authority_level: str
|
||||
catalog_url: str
|
||||
attribution: str
|
||||
license_note: str
|
||||
limitation_message: str
|
||||
coverage_zones: list[str]
|
||||
|
||||
|
||||
class OfficialVectorAcquisitionResult(BaseModel):
|
||||
output_dataset_id: UUID
|
||||
reused: bool
|
||||
product_key: str
|
||||
display_name: str
|
||||
theme: str
|
||||
provider: str
|
||||
source_name: str
|
||||
reference_layer_name: str
|
||||
service_type: str
|
||||
collection: str
|
||||
feature_count: int
|
||||
candidate_feature_count: int
|
||||
page_count: int
|
||||
bbox_epsg4326: list[float]
|
||||
source_version: str
|
||||
attribution: str
|
||||
limitation_message: str
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class VectorOperationResult(BaseModel):
|
||||
feature_count: int
|
||||
geometry_type_summary: dict[str, int]
|
||||
bounds_json: dict | None = None
|
||||
crs: str | None = None
|
||||
source_dataset_id: str
|
||||
|
||||
|
||||
class VectorOperationRequest(BaseModel):
|
||||
output_name: str | None = None
|
||||
|
||||
|
||||
class VectorClipRequest(VectorOperationRequest):
|
||||
area_id: str
|
||||
|
||||
|
||||
class VectorBufferRequest(VectorOperationRequest):
|
||||
distance_m: float
|
||||
dissolve: bool = False
|
||||
|
||||
|
||||
class VectorIntersectRequest(VectorOperationRequest):
|
||||
other_dataset_id: str
|
||||
|
||||
|
||||
class VectorStatsRequest(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class RasterReadyResponse(BaseModel):
|
||||
dataset_id: str
|
||||
ready: bool
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class RasterOperationResult(BaseModel):
|
||||
dataset_id: str
|
||||
ready: bool
|
||||
metadata: dict | None = None
|
||||
output_dataset_id: str | None = None
|
||||
operation: str | None = None
|
||||
|
||||
|
||||
class RasterMetadataResponse(BaseModel):
|
||||
dataset_id: str
|
||||
driver: str | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
band_count: int | None = None
|
||||
crs: str | None = None
|
||||
bounds: list[float] | None = None
|
||||
resolution: list[float] | None = None
|
||||
dtype: list[str] | None = None
|
||||
nodata: list[float] | float | None = None
|
||||
transform: list[float] | None = None
|
||||
size_bytes: int | None = None
|
||||
checksum_sha256: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class RasterPreviewResponse(BaseModel):
|
||||
dataset_id: str
|
||||
ready: bool
|
||||
preview: dict
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class RasterBandStats(BaseModel):
|
||||
band_index: int
|
||||
dtype: str | None = None
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
mean: float | None = None
|
||||
std: float | None = None
|
||||
nodata_count: int
|
||||
nodata_ratio: float
|
||||
valid_pixel_count: int
|
||||
histogram: list[int] | None = None
|
||||
histogram_bins: list[float] | None = None
|
||||
|
||||
|
||||
class RasterStatsResponse(BaseModel):
|
||||
dataset_id: str
|
||||
source_dataset_id: str | None = None
|
||||
bands: list[RasterBandStats]
|
||||
generated_at: str | None = None
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class RasterReprojectRequest(BaseModel):
|
||||
target_crs: str | None = "EPSG:31370"
|
||||
resampling: str = "nearest"
|
||||
output_name: str | None = None
|
||||
|
||||
|
||||
class RasterClipRequest(BaseModel):
|
||||
area_id: str
|
||||
output_name: str | None = None
|
||||
|
||||
|
||||
class RasterTileRequest(BaseModel):
|
||||
tile_size: int = 512
|
||||
overlap: int = 64
|
||||
output_name: str | None = None
|
||||
|
||||
|
||||
class RasterIndexBaseRequest(BaseModel):
|
||||
output_name: str | None = None
|
||||
|
||||
|
||||
class RasterNdviRequest(RasterIndexBaseRequest):
|
||||
nir_band: int
|
||||
red_band: int
|
||||
|
||||
|
||||
class RasterNdwiRequest(RasterIndexBaseRequest):
|
||||
green_band: int
|
||||
nir_band: int
|
||||
|
||||
|
||||
class RasterNdbiRequest(RasterIndexBaseRequest):
|
||||
swir_band: int
|
||||
nir_band: int
|
||||
|
||||
|
||||
class RasterTileManifestTile(BaseModel):
|
||||
path: str
|
||||
pixel_window: list[int]
|
||||
bounds: list[float]
|
||||
transform: list[float]
|
||||
index: int
|
||||
|
||||
|
||||
class RasterTileManifest(BaseModel):
|
||||
tile_set_id: str
|
||||
source_dataset_id: str
|
||||
source_raster_id: str
|
||||
bounds: list[float]
|
||||
tile_size: int
|
||||
overlap: int
|
||||
parameters: dict[str, str | int | float | bool | None]
|
||||
created_at: str
|
||||
tile_paths: list[str]
|
||||
count: int
|
||||
tiles: list[RasterTileManifestTile]
|
||||
ai_inference: bool = False
|
||||
tile_server: str | None = None
|
||||
|
||||
|
||||
class RasterTileResponse(BaseModel):
|
||||
dataset_id: str
|
||||
ready: bool
|
||||
operation: str
|
||||
tile_set_id: str
|
||||
tile_size: int
|
||||
overlap: int
|
||||
manifest_path: str
|
||||
count: int
|
||||
manifest: RasterTileManifest
|
||||
|
||||
|
||||
class RasterReprojectResponse(BaseModel):
|
||||
dataset_id: str
|
||||
ready: bool
|
||||
operation: str
|
||||
output_dataset_id: str
|
||||
source_dataset_id: str
|
||||
target_dataset_id: str | None = None
|
||||
|
||||
|
||||
class RasterOperationUnavailable(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class VectorBBoxResponse(BaseModel):
|
||||
dataset_id: str
|
||||
bounds_json: dict | None
|
||||
feature_count: int
|
||||
crs: str | None = None
|
||||
|
||||
|
||||
class VectorStatsResponse(BaseModel):
|
||||
dataset_id: str
|
||||
feature_count: int
|
||||
geometry_type_summary: dict[str, int]
|
||||
bounds_json: dict | None
|
||||
crs: str | None = None
|
||||
|
||||
|
||||
class VectorSelectionBBox(BaseModel):
|
||||
min_x: float
|
||||
min_y: float
|
||||
max_x: float
|
||||
max_y: float
|
||||
crs: str = "EPSG:4326"
|
||||
|
||||
@field_validator("crs")
|
||||
@classmethod
|
||||
def validate_crs(cls, value: str) -> str:
|
||||
if value.upper() != "EPSG:4326":
|
||||
raise ValueError("Only EPSG:4326 bbox selection is supported")
|
||||
return "EPSG:4326"
|
||||
|
||||
|
||||
class VectorSelectionRequest(BaseModel):
|
||||
bbox: VectorSelectionBBox
|
||||
area_id: UUID | None = None
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
|
||||
|
||||
class VectorSelectionDeriveRequest(VectorSelectionRequest):
|
||||
output_name: str | None = None
|
||||
|
||||
|
||||
class VectorSelectionMetric(BaseModel):
|
||||
metric_key: str
|
||||
metric_label: str
|
||||
metric_value: float
|
||||
metric_unit: str
|
||||
aggregation_method: str
|
||||
is_estimate: bool = False
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
class VectorSelectionSummary(BaseModel):
|
||||
metric_label: str
|
||||
metric_value: float
|
||||
metric_unit: str
|
||||
aggregation_method: str
|
||||
primary_metric_key: str | None = None
|
||||
# ``feature_count`` counts whole features that touch the selection, while
|
||||
# area and length metrics clip to it. These fields say how far the two
|
||||
# populations diverge, so the numbers on one panel can be read together.
|
||||
feature_count: int
|
||||
fully_covered_feature_count: int | None = None
|
||||
partially_covered_feature_count: int | None = None
|
||||
selection_edge_warning: str | None = None
|
||||
is_estimate: bool = False
|
||||
warning: str | None = None
|
||||
metrics: list[VectorSelectionMetric] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VectorSelectionResponse(BaseModel):
|
||||
selection_bbox: VectorSelectionBBox
|
||||
selection_area_id: UUID | None = None
|
||||
feature_count: int
|
||||
total_feature_count: int | None = None
|
||||
limit: int
|
||||
truncated: bool
|
||||
geojson: dict
|
||||
summary: VectorSelectionSummary | None = None
|
||||
partition_count: int | None = None
|
||||
available_partition_count: int | None = None
|
||||
partition_scope_key: str | None = None
|
||||
source_name: str | None = None
|
||||
dataset_ids: list[UUID] | None = None
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .operations import VectorSelectionBBox
|
||||
|
||||
|
||||
class OrthophotoAcquireRequest(BaseModel):
|
||||
bbox: VectorSelectionBBox
|
||||
area_id: UUID | None = None
|
||||
product_key: str = "most_recent"
|
||||
force_refresh: bool = False
|
||||
resolution_m: float | None = Field(default=None, ge=0.1, le=2.0)
|
||||
|
||||
|
||||
class OrthophotoProductRead(BaseModel):
|
||||
key: str
|
||||
display_name: str
|
||||
observation_label: str
|
||||
temporal_granularity: str
|
||||
native_resolution_m: float
|
||||
supports_detection: bool
|
||||
color_mode: str
|
||||
catalog_url: str
|
||||
limitation_message: str
|
||||
provider: str
|
||||
coverage_zone: str
|
||||
attribution: str
|
||||
license_note: str
|
||||
|
||||
|
||||
class OrthophotoAcquisitionResult(BaseModel):
|
||||
output_dataset_id: UUID
|
||||
reused: bool
|
||||
provider: str
|
||||
product_key: str
|
||||
display_name: str
|
||||
observation_label: str
|
||||
temporal_granularity: str
|
||||
supports_detection: bool
|
||||
layer: str
|
||||
width: int
|
||||
height: int
|
||||
resolution_m: float
|
||||
bbox_epsg4326: list[float]
|
||||
bbox_epsg31370: list[float]
|
||||
attribution: str
|
||||
limitation_message: str
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
region: str | None = "Belgium and Belgian North Sea"
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
region: str | None = None
|
||||
status: Literal["active", "archived"] | None = None
|
||||
|
||||
|
||||
class ProjectRead(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
region: str
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProjectListItem(ProjectRead):
|
||||
pass
|
||||
|
||||
|
||||
class ProjectList(BaseModel):
|
||||
items: list[ProjectRead]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ProjectDeleteResult(BaseModel):
|
||||
deleted: bool
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.common import GeoJsonFeatureCollection
|
||||
|
||||
|
||||
class QaProviderComparisonRequest(BaseModel):
|
||||
candidate_dataset_id: UUID
|
||||
reference_dataset_id: UUID
|
||||
iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
area_id: UUID | None = None
|
||||
|
||||
|
||||
class QaProviderComparisonResult(BaseModel):
|
||||
status: str
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
# Counts of the population that was actually matched, so that
|
||||
# ``matches + false_positives == candidate_feature_count`` holds even when
|
||||
# an area filter or an unparseable geometry removed features. The ``_raw``
|
||||
# fields keep the untouched dataset totals visible next to them.
|
||||
candidate_feature_count: int
|
||||
reference_feature_count: int
|
||||
candidate_feature_count_raw: int | None = None
|
||||
reference_feature_count_raw: int | None = None
|
||||
matches: int
|
||||
false_positives: int
|
||||
false_negatives: int
|
||||
precision: float | None
|
||||
recall: float | None
|
||||
f1_score: float | None
|
||||
mean_iou: float | None
|
||||
iou_threshold: float
|
||||
unsupported_geometry: bool = False
|
||||
unsupported_geometries: list[str] = Field(default_factory=list)
|
||||
match_evidence: list[dict] = Field(default_factory=list)
|
||||
false_positive_evidence: list[dict] = Field(default_factory=list)
|
||||
false_negative_evidence: list[dict] = Field(default_factory=list)
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class MetricRead(BaseModel):
|
||||
id: UUID
|
||||
quality_check_id: UUID | None = None
|
||||
analysis_run_id: UUID | None = None
|
||||
metric_key: str
|
||||
metric_value: float | None = None
|
||||
metric_unit: str | None = None
|
||||
label: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class QualityCheckRead(BaseModel):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
job_id: UUID | None = None
|
||||
analysis_run_id: UUID | None = None
|
||||
candidate_dataset_id: UUID | None = None
|
||||
reference_dataset_id: UUID
|
||||
check_type: str
|
||||
status: str
|
||||
score: float | None = None
|
||||
parameters_json: dict | None = None
|
||||
findings_json: dict | None = None
|
||||
created_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
metrics: list[MetricRead] = Field(default_factory=list)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class QualityCheckList(BaseModel):
|
||||
items: list[QualityCheckRead]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class QualityEvidenceResponse(BaseModel):
|
||||
quality_check_id: UUID
|
||||
project_id: UUID
|
||||
candidate_dataset_id: UUID | None = None
|
||||
reference_dataset_id: UUID
|
||||
analysis_run_id: UUID | None = None
|
||||
# The overlay is capped so a regional check stays reviewable; the counts in
|
||||
# the quality check itself are always complete.
|
||||
feature_count: int
|
||||
total_feature_count: int | None = None
|
||||
role_counts: dict[str, int] = Field(default_factory=dict)
|
||||
truncated: bool = False
|
||||
limit: int | None = None
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
geojson: GeoJsonFeatureCollection
|
||||
|
||||
|
||||
class AnalysisQaResponse(BaseModel):
|
||||
status: str
|
||||
quality_check_id: UUID
|
||||
analysis_run_id: UUID
|
||||
reference_dataset_id: UUID
|
||||
candidate_feature_count: int
|
||||
reference_feature_count: int
|
||||
candidate_feature_count_raw: int | None = None
|
||||
reference_feature_count_raw: int | None = None
|
||||
matches: int
|
||||
false_positives: int
|
||||
false_negatives: int
|
||||
precision: float | None = None
|
||||
recall: float | None = None
|
||||
f1_score: float | None = None
|
||||
mean_iou: float | None = None
|
||||
iou_threshold: float
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
coverage: dict[str, Any] | None = None
|
||||
temporal_compatibility: dict[str, Any] | None = None
|
||||
box_to_footprint_diagnostics: dict[str, Any] | None = None
|
||||
precision_recall_curve: dict[str, Any] | None = None
|
||||
calibration_sweep: list[dict[str, Any]] = Field(default_factory=list)
|
||||
match_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
false_positive_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
false_negative_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.detection import DetectionModelCapability
|
||||
|
||||
|
||||
SegmentationModelCapability = DetectionModelCapability
|
||||
|
||||
|
||||
class SegmentationModelsResponse(BaseModel):
|
||||
models: list[SegmentationModelCapability]
|
||||
|
||||
|
||||
class SegmentationRunRequest(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
project_id: UUID
|
||||
dataset_id: UUID
|
||||
model_id: str
|
||||
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 SegmentationQaRequest(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)
|
||||
# Read off the one matching pass, exactly as for detection.
|
||||
calibration_thresholds: list[float] = Field(default_factory=list, max_length=32)
|
||||
|
||||
|
||||
class SegmentationRunResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
analysis_run_id: UUID
|
||||
job_id: UUID
|
||||
project_id: UUID
|
||||
dataset_id: UUID
|
||||
model_id: str
|
||||
status: str
|
||||
segmentation_count: int
|
||||
error_code: str | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class SegmentationRunRead(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 SegmentationRunListResponse(BaseModel):
|
||||
items: list[SegmentationRunRead]
|
||||
# ``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 SegmentationRead(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 | None = None
|
||||
bbox_json: dict | None = None
|
||||
area_m2: float | None = None
|
||||
mask_path: str | None = None
|
||||
source_tile_path: str | None = None
|
||||
tile_index: int | None = None
|
||||
properties_json: dict | None = None
|
||||
provenance_json: dict | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class SegmentationListResponse(BaseModel):
|
||||
items: list[SegmentationRead]
|
||||
# ``total`` describes the complete filtered population; ``items`` is one
|
||||
# stable confidence-ranked page of it.
|
||||
total: int
|
||||
limit: int | None = None
|
||||
offset: int = 0
|
||||
truncated: bool = False
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user