diff --git a/spp_api_v2_gis/README.rst b/spp_api_v2_gis/README.rst index 961e531d3..289206e33 100644 --- a/spp_api_v2_gis/README.rst +++ b/spp_api_v2_gis/README.rst @@ -51,36 +51,55 @@ performs all computation: API Endpoints ------------- -**OGC API - Features (primary interface)** - -+-------------------------------------------+--------+-----------------------------+ -| Endpoint | Method | Description | -+===========================================+========+=============================+ -| ``/gis/ogc/`` | GET | OGC API landing page | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/conformance`` | GET | OGC conformance classes | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections`` | GET | List feature collections | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | -+-------------------------------------------+--------+-----------------------------+ - -**Additional endpoints** - -========================== ========== ======================= -Endpoint Method Description -========================== ========== ======================= -``/gis/query/statistics`` POST Query stats for polygon -``/gis/geofences`` POST/GET Geofence management -``/gis/geofences/{id}`` GET/DELETE Single geofence -``/gis/export/geopackage`` GET Export for offline use -========================== ========== ======================= +**OGC API - Features** + ++-------------------------------------------+----------------+--------------------------+ +| Endpoint | Method | Description | ++===========================================+================+==========================+ +| ``/gis/ogc/`` | GET | OGC API landing page | ++-------------------------------------------+----------------+--------------------------+ +| ``/gis/ogc/conformance`` | GET | OGC conformance classes | ++-------------------------------------------+----------------+--------------------------+ +| ``/gis/ogc/collections`` | GET | List feature collections | ++-------------------------------------------+----------------+--------------------------+ +| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | ++-------------------------------------------+----------------+--------------------------+ +| ``/gis/ogc/collections/{id}/items`` | GET/POST | Feature items (GeoJSON) | ++-------------------------------------------+----------------+--------------------------+ +| ``/gis/ogc/collections/{id}/items/{fid}`` | GET/PUT/DELETE | Single feature (CRUD for | +| | | geofences) | ++-------------------------------------------+----------------+--------------------------+ +| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file | +| | | (extension) | ++-------------------------------------------+----------------+--------------------------+ + +**OGC API - Processes** + ++---------------------------------------+------------+----------------------------+ +| Endpoint | Method | Description | ++=======================================+============+============================+ +| ``/gis/ogc/processes`` | GET | List available processes | ++---------------------------------------+------------+----------------------------+ +| ``/gis/ogc/processes/{id}`` | GET | Process description | ++---------------------------------------+------------+----------------------------+ +| ``/gis/ogc/processes/{id}/execution`` | POST | Execute process | +| | | (sync/async) | ++---------------------------------------+------------+----------------------------+ +| ``/gis/ogc/jobs`` | GET | List jobs | ++---------------------------------------+------------+----------------------------+ +| ``/gis/ogc/jobs/{id}`` | GET/DELETE | Job status / dismiss | ++---------------------------------------+------------+----------------------------+ +| ``/gis/ogc/jobs/{id}/results`` | GET | Job results | ++---------------------------------------+------------+----------------------------+ + +**Utility endpoints** + +========================== ====== ========================= +Endpoint Method Description +========================== ====== ========================= +``/gis/export/geopackage`` GET Export for offline use +``/gis/statistics`` GET List published statistics +========================== ====== ========================= Scopes and Data Privacy ----------------------- @@ -105,11 +124,11 @@ individual registrant records. - **OGC collections/items**: Return GeoJSON features organized by administrative area, with pre-computed aggregate values (counts, percentages). Each feature represents an *area*, not a person. -- **Spatial query statistics** (``POST /gis/query/statistics``): Accepts - a GeoJSON polygon and returns configured aggregate statistics computed - by ``spp.analytics.service``. Individual registrant IDs are computed +- **Spatial query statistics** (via OGC Processes): Accepts a GeoJSON + polygon and returns configured aggregate statistics computed by + ``spp.analytics.service``. Individual registrant IDs are computed internally for aggregation but are **explicitly stripped** from the - response before it is sent (see ``spatial_query.py``). + response before it is sent. - **Exports** (GeoPackage/GeoJSON): Contain the same area-level aggregated layer data, not registrant-level records. - **Geofences**: Store only geometry and metadata — no registrant data. diff --git a/spp_api_v2_gis/__manifest__.py b/spp_api_v2_gis/__manifest__.py index 2aad21224..f068c568a 100644 --- a/spp_api_v2_gis/__manifest__.py +++ b/spp_api_v2_gis/__manifest__.py @@ -2,7 +2,7 @@ { "name": "OpenSPP GIS API", "category": "OpenSPP/Integration", - "version": "19.0.2.0.0", + "version": "19.0.2.1.0", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", @@ -18,9 +18,13 @@ "spp_vocabulary", "spp_indicator", "spp_analytics", + "spp_programs", + "spp_cel_domain", + "job_worker", ], "data": [ "security/ir.model.access.csv", + "data/cron_job_cleanup.xml", ], "assets": {}, "demo": [], diff --git a/spp_api_v2_gis/data/cron_job_cleanup.xml b/spp_api_v2_gis/data/cron_job_cleanup.xml new file mode 100644 index 000000000..b33999d1f --- /dev/null +++ b/spp_api_v2_gis/data/cron_job_cleanup.xml @@ -0,0 +1,12 @@ + + + + GIS: Process Job Cleanup + + code + model.cron_cleanup_jobs() + 1 + days + True + + diff --git a/spp_api_v2_gis/models/__init__.py b/spp_api_v2_gis/models/__init__.py index d54fa7da4..e796ade3f 100644 --- a/spp_api_v2_gis/models/__init__.py +++ b/spp_api_v2_gis/models/__init__.py @@ -2,3 +2,4 @@ from . import api_client_scope from . import fastapi_endpoint from . import geofence +from . import process_job diff --git a/spp_api_v2_gis/models/api_client_scope.py b/spp_api_v2_gis/models/api_client_scope.py index a9c767de1..40ce1ae51 100644 --- a/spp_api_v2_gis/models/api_client_scope.py +++ b/spp_api_v2_gis/models/api_client_scope.py @@ -16,3 +16,11 @@ class ApiClientScope(models.Model): ], ondelete={"gis": "cascade", "statistics": "cascade"}, ) + + action = fields.Selection( + selection_add=[ + ("geofence", "Geofence Management"), + ("incident", "Incident Management"), + ], + ondelete={"geofence": "cascade", "incident": "cascade"}, + ) diff --git a/spp_api_v2_gis/models/fastapi_endpoint.py b/spp_api_v2_gis/models/fastapi_endpoint.py index 6261e9915..edc5363dd 100644 --- a/spp_api_v2_gis/models/fastapi_endpoint.py +++ b/spp_api_v2_gis/models/fastapi_endpoint.py @@ -20,19 +20,17 @@ def _get_fastapi_routers(self) -> list[APIRouter]: routers = super()._get_fastapi_routers() if self.app == "api_v2": from ..routers.export import export_router - from ..routers.geofence import geofence_router + from ..routers.jobs import jobs_router from ..routers.ogc_features import ogc_features_router - from ..routers.proximity import proximity_router - from ..routers.spatial_query import spatial_query_router + from ..routers.processes import processes_router from ..routers.statistics import statistics_router routers.extend( [ ogc_features_router, + processes_router, + jobs_router, export_router, - geofence_router, - proximity_router, - spatial_query_router, statistics_router, ] ) diff --git a/spp_api_v2_gis/models/process_job.py b/spp_api_v2_gis/models/process_job.py new file mode 100644 index 000000000..bec3e267d --- /dev/null +++ b/spp_api_v2_gis/models/process_job.py @@ -0,0 +1,203 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class GisProcessJob(models.Model): + _name = "spp.gis.process.job" + _description = "OGC Process Job" + _order = "create_date desc" + + job_id = fields.Char( + required=True, + index=True, + help="UUID external identifier for the job (OGC jobID)", + ) + process_id = fields.Char( + required=True, + index=True, + help="Process identifier (e.g., 'spatial-statistics')", + ) + status = fields.Selection( + selection=[ + ("accepted", "Accepted"), + ("running", "Running"), + ("successful", "Successful"), + ("failed", "Failed"), + ("dismissed", "Dismissed"), + ], + default="accepted", + required=True, + index=True, + ) + client_id = fields.Many2one( + comodel_name="spp.api.client", + index=True, + help="API client that submitted the job", + ) + message = fields.Text(help="Human-readable status message") + progress = fields.Integer(default=0, help="Job progress percentage (0-100)") + inputs = fields.Json(help="Serialized execute request inputs") + results = fields.Json(help="Serialized process output") + started_at = fields.Datetime(help="Timestamp when job execution started") + finished_at = fields.Datetime(help="Timestamp when job execution finished") + job_uuid = fields.Char( + index=True, + help="Job worker UUID for tracking the background job", + ) + + _sql_constraints = [ + ("job_id_unique", "UNIQUE(job_id)", "Job ID must be unique"), + ] + + def dismiss(self): + """Dismiss a job. + + For accepted jobs: sets status to dismissed and records finished_at. + For running jobs: raises a UserError (cannot dismiss running jobs). + For terminal jobs (successful/failed/dismissed): deletes the record. + """ + self.ensure_one() + if self.status == "accepted": + self.write( + { + "status": "dismissed", + "finished_at": fields.Datetime.now(), + } + ) + elif self.status == "running": + raise UserError(_("Cannot dismiss a running job. Wait for it to finish or check back later.")) + else: + # Terminal statuses: successful, failed, dismissed + self.unlink() + + def execute_process(self): + """Execute the process for this job. + + This method is called by the job worker. It runs the appropriate + SpatialQueryService method based on process_id, stores the results, + and updates the job status accordingly. + + For batch spatial-statistics requests, progress is updated per geometry. + """ + self.ensure_one() + # The job worker runs as the user_id stored on queue.job, which may + # lack write ACLs on this model. Escalate to superuser for the + # duration of this background task. + # nosemgrep: odoo-sudo-without-context + self = self.sudo() + + self.write( + { + "status": "running", + "started_at": fields.Datetime.now(), + } + ) + + try: + # Lazy imports to avoid circular imports + from ..services.process_execution import run_proximity_statistics, run_spatial_statistics + from ..services.process_registry import PROXIMITY_STATISTICS, SPATIAL_STATISTICS + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + inputs = self.inputs or {} + + if self.process_id == SPATIAL_STATISTICS: + on_progress = self._make_batch_progress_callback(inputs) + results = run_spatial_statistics(service, inputs, on_progress=on_progress) + elif self.process_id == PROXIMITY_STATISTICS: + results = run_proximity_statistics(service, inputs) + else: + raise ValueError(f"Unknown process_id: {self.process_id!r}") + + self.write( + { + "status": "successful", + "finished_at": fields.Datetime.now(), + "progress": 100, + "results": results, + } + ) + + except Exception: + _logger.exception("GIS process job %s failed", self.job_id) + self.write( + { + "status": "failed", + "finished_at": fields.Datetime.now(), + "message": "Process execution failed. Check server logs for details.", + } + ) + + def _make_batch_progress_callback(self, inputs): + """Return a progress callback for batch execution, or None for non-batch. + + The callback throttles DB writes: it only writes when the integer + percentage changes, avoiding up to 100 redundant ORM writes. + """ + geometry = inputs.get("geometry") + if not isinstance(geometry, list): + return None + + total = len(geometry) + last_pct = [0] # mutable container for closure + + def on_progress(completed): + pct = int(completed / total * 100) + if pct != last_pct[0]: + last_pct[0] = pct + self.write({"progress": pct}) + + return on_progress + + @api.model + def cron_cleanup_jobs(self): + """Clean up old and stale jobs. + + Called by ir.cron on a daily schedule. + + - Deletes jobs older than the configured retention period. + - Marks stale accepted/running jobs (older than 1 hour) as failed. + """ + # nosemgrep: odoo-sudo-without-context (API runs in system context; authorization enforced upstream) + IrConfig = self.env["ir.config_parameter"].sudo() + try: + retention_days = int(IrConfig.get_param("spp_gis.job_retention_days", default=7)) + except (ValueError, TypeError): + retention_days = 7 + + cutoff_date = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days) + stale_cutoff = fields.Datetime.subtract(fields.Datetime.now(), hours=1) + + # Mark stale in-progress jobs as failed first (before deletion cutoff) + stale_jobs = self.search( + [ + ("status", "in", ["accepted", "running"]), + ("create_date", "<", stale_cutoff), + ] + ) + if stale_jobs: + _logger.info("Marking %d stale GIS process jobs as failed", len(stale_jobs)) + stale_jobs.write( + { + "status": "failed", + "finished_at": fields.Datetime.now(), + "message": "Job timed out (stale)", + } + ) + + # Delete jobs older than retention period + old_jobs = self.search([("create_date", "<", cutoff_date)]) + if old_jobs: + _logger.info( + "Deleting %d GIS process jobs older than %d days", + len(old_jobs), + retention_days, + ) + old_jobs.unlink() diff --git a/spp_api_v2_gis/readme/DESCRIPTION.md b/spp_api_v2_gis/readme/DESCRIPTION.md index b9bfd937f..548dde41f 100644 --- a/spp_api_v2_gis/readme/DESCRIPTION.md +++ b/spp_api_v2_gis/readme/DESCRIPTION.md @@ -19,7 +19,7 @@ Follows thin client architecture where QGIS displays data and OpenSPP performs a ## API Endpoints -**OGC API - Features (primary interface)** +**OGC API - Features** | Endpoint | Method | Description | |----------|--------|-------------| @@ -27,18 +27,27 @@ Follows thin client architecture where QGIS displays data and OpenSPP performs a | `/gis/ogc/conformance` | GET | OGC conformance classes | | `/gis/ogc/collections` | GET | List feature collections | | `/gis/ogc/collections/{id}` | GET | Collection metadata | -| `/gis/ogc/collections/{id}/items` | GET | Feature items (GeoJSON) | -| `/gis/ogc/collections/{id}/items/{fid}` | GET | Single feature | +| `/gis/ogc/collections/{id}/items` | GET/POST | Feature items (GeoJSON) | +| `/gis/ogc/collections/{id}/items/{fid}` | GET/PUT/DELETE | Single feature (CRUD for geofences) | | `/gis/ogc/collections/{id}/qml` | GET | QGIS style file (extension) | -**Additional endpoints** +**OGC API - Processes** + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/gis/ogc/processes` | GET | List available processes | +| `/gis/ogc/processes/{id}` | GET | Process description | +| `/gis/ogc/processes/{id}/execution` | POST | Execute process (sync/async) | +| `/gis/ogc/jobs` | GET | List jobs | +| `/gis/ogc/jobs/{id}` | GET/DELETE | Job status / dismiss | +| `/gis/ogc/jobs/{id}/results` | GET | Job results | + +**Utility endpoints** | Endpoint | Method | Description | |----------|--------|-------------| -| `/gis/query/statistics` | POST | Query stats for polygon | -| `/gis/geofences` | POST/GET | Geofence management | -| `/gis/geofences/{id}` | GET/DELETE | Single geofence | | `/gis/export/geopackage` | GET | Export for offline use | +| `/gis/statistics` | GET | List published statistics | ## Scopes and Data Privacy @@ -54,7 +63,7 @@ Follows thin client architecture where QGIS displays data and OpenSPP performs a **Aggregated statistics only.** No endpoint in this module returns individual registrant records. - **OGC collections/items**: Return GeoJSON features organized by administrative area, with pre-computed aggregate values (counts, percentages). Each feature represents an *area*, not a person. -- **Spatial query statistics** (`POST /gis/query/statistics`): Accepts a GeoJSON polygon and returns configured aggregate statistics computed by `spp.analytics.service`. Individual registrant IDs are computed internally for aggregation but are **explicitly stripped** from the response before it is sent (see `spatial_query.py`). +- **Spatial query statistics** (via OGC Processes): Accepts a GeoJSON polygon and returns configured aggregate statistics computed by `spp.analytics.service`. Individual registrant IDs are computed internally for aggregation but are **explicitly stripped** from the response before it is sent. - **Exports** (GeoPackage/GeoJSON): Contain the same area-level aggregated layer data, not registrant-level records. - **Geofences**: Store only geometry and metadata — no registrant data. diff --git a/spp_api_v2_gis/readme/HISTORY.md b/spp_api_v2_gis/readme/HISTORY.md index 4aaf9afef..ee3400b20 100644 --- a/spp_api_v2_gis/readme/HISTORY.md +++ b/spp_api_v2_gis/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.1.0 + +- feat: OGC API Processes/Jobs framework (async process execution with job cleanup cron), incidents collection (CAP severity/event filters), router consolidation replacing the geofence/proximity/spatial_query routers (re-land from #76). + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_api_v2_gis/routers/__init__.py b/spp_api_v2_gis/routers/__init__.py index d5e170ae4..151660856 100644 --- a/spp_api_v2_gis/routers/__init__.py +++ b/spp_api_v2_gis/routers/__init__.py @@ -1,7 +1,6 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. from . import export -from . import geofence +from . import jobs from . import ogc_features -from . import proximity -from . import spatial_query +from . import processes from . import statistics diff --git a/spp_api_v2_gis/routers/_helpers.py b/spp_api_v2_gis/routers/_helpers.py new file mode 100644 index 000000000..ccd1e0ef6 --- /dev/null +++ b/spp_api_v2_gis/routers/_helpers.py @@ -0,0 +1,70 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Shared helpers for GIS OGC API routers.""" + +from fastapi import HTTPException, Request, Response, status + +from ..schemas.processes import StatusInfo + +# Suggested polling interval for async job status (seconds) +RETRY_AFTER_SECONDS = "5" + + +def check_gis_scope(api_client): + """Verify client has gis:read or statistics:read scope.""" + if not (api_client.has_scope("gis", "read") or api_client.has_scope("statistics", "read")): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Client does not have gis:read or statistics:read scope", + ) + + +def get_base_url(request: Request) -> str: + """Extract base URL for self-referencing links.""" + url = str(request.base_url).rstrip("/") + return f"{url}/api/v2/spp" + + +def build_status_info(job, base_url=""): + """Build a StatusInfo from a spp.gis.process.job record. + + Handles Odoo's False-for-empty convention on Text fields by + coercing falsy values to None for Pydantic compatibility. + """ + links = [] + ogc_base = f"{base_url}/gis/ogc" if base_url else "" + + if ogc_base: + links.append({"href": f"{ogc_base}/jobs/{job.job_id}", "rel": "self", "type": "application/json"}) + if job.status == "successful": + links.append( + {"href": f"{ogc_base}/jobs/{job.job_id}/results", "rel": "results", "type": "application/json"} + ) + + return StatusInfo( + jobID=job.job_id, + processID=job.process_id, + status=job.status, + message=job.message or None, + created=job.create_date.isoformat() if job.create_date else None, + started=job.started_at.isoformat() if job.started_at else None, + finished=job.finished_at.isoformat() if job.finished_at else None, + updated=job.write_date.isoformat() if job.write_date else None, + progress=job.progress, + links=links, + ) + + +def build_status_response(status_info, status_code=200, extra_headers=None): + """Build a JSON Response from a StatusInfo, with optional extra headers. + + Used when headers like Retry-After need to be set alongside the JSON body. + """ + headers = {} + if extra_headers: + headers.update(extra_headers) + return Response( + content=status_info.model_dump_json(by_alias=True, exclude_none=True), + status_code=status_code, + media_type="application/json", + headers=headers or None, + ) diff --git a/spp_api_v2_gis/routers/geofence.py b/spp_api_v2_gis/routers/geofence.py deleted file mode 100644 index 155a668e6..000000000 --- a/spp_api_v2_gis/routers/geofence.py +++ /dev/null @@ -1,312 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Geofence API endpoints for saving areas of interest.""" - -import json -import logging -from typing import Annotated - -from odoo.api import Environment -from odoo.exceptions import ValidationError - -from odoo.addons.fastapi.dependencies import odoo_env -from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client - -from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status - -from ..schemas.geofence import ( - GeofenceCreateRequest, - GeofenceListItem, - GeofenceListResponse, - GeofenceResponse, -) - -_logger = logging.getLogger(__name__) - -geofence_router = APIRouter(tags=["GIS"], prefix="/gis") - - -@geofence_router.post( - "/geofences", - response_model=GeofenceResponse, - status_code=status.HTTP_201_CREATED, - summary="Create geofence", - description="Save area of interest as a geofence from GeoJSON.", -) -async def create_geofence( - request: GeofenceCreateRequest, - env: Annotated[Environment, Depends(odoo_env)], - api_client: Annotated[dict, Depends(get_authenticated_client)], - response: Response, -): - """Create a new geofence from GeoJSON. - - Args: - request: Geofence creation request with name, geometry, and optional fields - env: Odoo environment - api_client: Authenticated API client - response: FastAPI response object for setting headers - - Returns: - GeofenceResponse with created geofence details - - Raises: - HTTPException: 403 if missing scope, 422 if validation fails - """ - # Check geofence scope - if not api_client.has_scope("gis", "geofence"): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Client does not have gis:geofence scope", - ) - - # Get the geofence model - # nosemgrep: odoo-sudo-without-context - geofence_model = env["spp.gis.geofence"].sudo() - - # Prepare kwargs for optional fields - kwargs = {} - if request.description: - kwargs["description"] = request.description - - # Handle incident_code if provided - if request.incident_code: - # Find incident by code (external ID) - # nosemgrep: odoo-sudo-without-context - incident = env["spp.hazard.incident"].sudo().search([("code", "=", request.incident_code)], limit=1) - if not incident: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Incident with code '{request.incident_code}' not found", - ) - kwargs["incident_id"] = incident.id - - try: - # Convert geometry dict to JSON string for create_from_geojson - geometry_json = json.dumps(request.geometry) - - # Create geofence using model method - geofence = geofence_model.create_from_geojson( - geojson_str=geometry_json, - name=request.name, - geofence_type=request.geofence_type, - created_from="api", - **kwargs, - ) - - # Set Location header - response.headers["Location"] = f"/api/v2/spp/gis/geofences/{geofence.id}" - - # Return response - return GeofenceResponse( - id=geofence.id, - name=geofence.name, - description=geofence.description or None, - geofence_type=geofence.geofence_type, - area_sqkm=geofence.area_sqkm, - active=geofence.active, - created_from=geofence.created_from, - ) - - except ValidationError as e: - _logger.warning("Validation error creating geofence: %s", str(e)) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=str(e), - ) from e - except Exception as e: - _logger.exception("Error creating geofence") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create geofence", - ) from e - - -@geofence_router.get( - "/geofences", - response_model=GeofenceListResponse, - summary="List geofences", - description="List saved geofences with optional filtering and pagination.", -) -async def list_geofences( - env: Annotated[Environment, Depends(odoo_env)], - api_client: Annotated[dict, Depends(get_authenticated_client)], - geofence_type: Annotated[str | None, Query()] = None, - incident_id: Annotated[int | None, Query()] = None, - active: Annotated[bool | None, Query()] = None, - count: Annotated[int, Query(alias="_count", ge=1, le=100)] = 20, - offset: Annotated[int, Query(alias="_offset", ge=0)] = 0, -): - """List geofences with optional filters. - - Args: - env: Odoo environment - api_client: Authenticated API client - geofence_type: Filter by geofence type (hazard_zone, service_area, targeting_area, custom) - incident_id: Filter by related incident ID - active: Filter by active status (default: True) - count: Number of results per page (default: 20, max: 100) - offset: Number of results to skip (default: 0) - - Returns: - GeofenceListResponse with list of geofences and pagination info - - Raises: - HTTPException: 403 if missing scope - """ - # Check read scope - if not api_client.has_scope("gis", "read"): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Client does not have gis:read scope", - ) - - # Build search domain - domain = [] - if geofence_type: - domain.append(("geofence_type", "=", geofence_type)) - if incident_id is not None: - domain.append(("incident_id", "=", incident_id)) - if active is not None: - domain.append(("active", "=", active)) - else: - # Default to active geofences only - domain.append(("active", "=", True)) - - # Get geofence model - # nosemgrep: odoo-sudo-without-context - geofence_model = env["spp.gis.geofence"].sudo() - - # Get total count - total = geofence_model.search_count(domain) - - # Search with pagination - geofences = geofence_model.search(domain, limit=count, offset=offset, order="name") - - # Convert to response schema - items = [ - GeofenceListItem( - id=geofence.id, - name=geofence.name, - geofence_type=geofence.geofence_type, - area_sqkm=geofence.area_sqkm, - active=geofence.active, - ) - for geofence in geofences - ] - - return GeofenceListResponse( - geofences=items, - total=total, - offset=offset, - count=len(items), - ) - - -@geofence_router.get( - "/geofences/{geofence_id}", - summary="Get geofence", - description="Get a single geofence with full GeoJSON representation.", -) -async def get_geofence( - geofence_id: Annotated[int, Path(description="Geofence ID")], - env: Annotated[Environment, Depends(odoo_env)], - api_client: Annotated[dict, Depends(get_authenticated_client)], -): - """Get a single geofence by ID. - - Returns the full GeoJSON Feature representation including geometry. - - Args: - geofence_id: Database ID of the geofence - env: Odoo environment - api_client: Authenticated API client - - Returns: - dict: GeoJSON Feature with geometry and properties - - Raises: - HTTPException: 403 if missing scope, 404 if not found - """ - # Check read scope - if not api_client.has_scope("gis", "read"): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Client does not have gis:read scope", - ) - - # Get geofence model - # nosemgrep: odoo-sudo-without-context - geofence_model = env["spp.gis.geofence"].sudo() - - # Search for geofence - geofence = geofence_model.browse(geofence_id) - - if not geofence.exists(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Geofence with ID {geofence_id} not found", - ) - - # Return full GeoJSON representation - try: - return geofence.to_geojson() - except Exception as e: - _logger.exception("Error converting geofence to GeoJSON") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve geofence data", - ) from e - - -@geofence_router.delete( - "/geofences/{geofence_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Archive geofence", - description="Soft delete a geofence by setting active=False.", -) -async def delete_geofence( - geofence_id: Annotated[int, Path(description="Geofence ID")], - env: Annotated[Environment, Depends(odoo_env)], - api_client: Annotated[dict, Depends(get_authenticated_client)], -): - """Archive a geofence (soft delete). - - Sets active=False rather than permanently deleting the record. - - Args: - geofence_id: Database ID of the geofence - env: Odoo environment - api_client: Authenticated API client - - Raises: - HTTPException: 403 if missing scope, 404 if not found - """ - # Check geofence scope (same as create) - if not api_client.has_scope("gis", "geofence"): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Client does not have gis:geofence scope", - ) - - # Get geofence model - # nosemgrep: odoo-sudo-without-context - geofence_model = env["spp.gis.geofence"].sudo() - - # Search for geofence - geofence = geofence_model.browse(geofence_id) - - if not geofence.exists(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Geofence with ID {geofence_id} not found", - ) - - # Soft delete by setting active=False - try: - geofence.write({"active": False}) - except Exception as e: - _logger.exception("Error archiving geofence") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to archive geofence", - ) from e diff --git a/spp_api_v2_gis/routers/jobs.py b/spp_api_v2_gis/routers/jobs.py new file mode 100644 index 000000000..c87edf6e6 --- /dev/null +++ b/spp_api_v2_gis/routers/jobs.py @@ -0,0 +1,193 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""OGC API - Processes job management endpoints. + +Endpoints: + GET /gis/ogc/jobs List jobs (filtered by client) + GET /gis/ogc/jobs/{jobId} Job status + GET /gis/ogc/jobs/{jobId}/results Job results + DELETE /gis/ogc/jobs/{jobId} Dismiss or delete a job +""" + +import logging +from typing import Annotated + +from odoo.api import Environment +from odoo.exceptions import UserError + +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status + +from ..schemas.processes import ( + BatchStatisticsResult, + JobList, + ProximityResult, + SingleStatisticsResult, + StatusInfo, +) +from ._helpers import RETRY_AFTER_SECONDS, build_status_info, build_status_response, check_gis_scope, get_base_url + +_logger = logging.getLogger(__name__) + +jobs_router = APIRouter(tags=["GIS - OGC API Processes"], prefix="/gis/ogc") + + +def _get_job_or_404(env, job_id, api_client): + """Look up a job record scoped to the authenticated client.""" + # nosemgrep: odoo-sudo-without-context + job = ( + # nosemgrep: odoo-sudo-without-context (API runs in system context; authorization enforced at the router) + env["spp.gis.process.job"] + .sudo() + .search( + [ + ("job_id", "=", job_id), + ("client_id", "=", api_client.id), + ], + limit=1, + ) + ) + if not job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Job '{job_id}' not found", + ) + return job + + +@jobs_router.get( + "/jobs", + response_model=JobList, + summary="List jobs", + description="List process jobs for the authenticated client.", +) +async def list_jobs( + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], + status_filter: Annotated[ + str | None, + Query(alias="status", description="Filter by job status"), + ] = None, + limit: Annotated[int, Query(ge=1, le=1000, description="Maximum jobs to return")] = 100, +): + """List jobs scoped to the authenticated client.""" + check_gis_scope(api_client) + + domain = [("client_id", "=", api_client.id)] + if status_filter: + valid_statuses = {"accepted", "running", "successful", "failed", "dismissed"} + if status_filter not in valid_statuses: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid status filter. Must be one of: {', '.join(sorted(valid_statuses))}", + ) + domain.append(("status", "=", status_filter)) + + # nosemgrep: odoo-sudo-without-context + jobs = env["spp.gis.process.job"].sudo().search(domain, limit=limit, order="create_date desc") + + base_url = get_base_url(request) + return JobList(jobs=[build_status_info(j, base_url) for j in jobs]) + + +@jobs_router.get( + "/jobs/{job_id}", + response_model=StatusInfo, + response_model_exclude_none=True, + summary="Job status", + description="Get the status of a process job.", +) +async def get_job_status( + job_id: Annotated[str, Path(description="Job identifier")], + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], +): + """Get job status, scoped to authenticated client.""" + check_gis_scope(api_client) + + job = _get_job_or_404(env, job_id, api_client) + base_url = get_base_url(request) + status_info = build_status_info(job, base_url) + + # Add Retry-After header for in-progress jobs to guide client polling + if job.status in ("accepted", "running"): + return build_status_response(status_info, extra_headers={"Retry-After": RETRY_AFTER_SECONDS}) + + return status_info + + +@jobs_router.get( + "/jobs/{job_id}/results", + summary="Job results", + description="Get the results of a completed process job.", + responses={ + 200: { + "description": "Process results (schema varies by process type)", + "model": SingleStatisticsResult | BatchStatisticsResult | ProximityResult, + }, + }, +) +async def get_job_results( + job_id: Annotated[str, Path(description="Job identifier")], + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], +): + """Get results from a completed job. + + Only available when job status is 'successful'. + """ + check_gis_scope(api_client) + + job = _get_job_or_404(env, job_id, api_client) + + if job.status != "successful": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Results not available. Job status is '{job.status}'", + ) + + return job.results + + +@jobs_router.delete( + "/jobs/{job_id}", + status_code=200, + summary="Dismiss or delete a job", + description="Dismiss a queued job or delete a completed job.", +) +async def dismiss_job( + job_id: Annotated[str, Path(description="Job identifier")], + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], +): + """Dismiss or delete a job. + + For accepted jobs: sets status to dismissed. + For running jobs: returns 409 Conflict. + For terminal jobs: deletes the record. + """ + check_gis_scope(api_client) + + job = _get_job_or_404(env, job_id, api_client) + + try: + was_accepted = job.status == "accepted" + job.dismiss() + + if was_accepted: + # Job was dismissed, return updated status + base_url = get_base_url(request) + return build_status_info(job, base_url) + + # Job was deleted (terminal status) + return {"message": "Job deleted"} + + except UserError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Cannot dismiss a running job", + ) from None diff --git a/spp_api_v2_gis/routers/ogc_features.py b/spp_api_v2_gis/routers/ogc_features.py index 5bbf3bc56..611503184 100644 --- a/spp_api_v2_gis/routers/ogc_features.py +++ b/spp_api_v2_gis/routers/ogc_features.py @@ -1,18 +1,24 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """OGC API - Features endpoints. -Implements the OGC API - Features Core standard (Part 1: Core) for -GovStack GIS Building Block compliance. Maps existing GIS report data -and data layers to OGC-compliant feature collections. +Implements OGC API - Features Part 1 (Core) and Part 4 (Create/Replace/Delete) +for GovStack GIS Building Block compliance. Maps existing GIS report data +and data layers to OGC-compliant feature collections. The geofences collection +supports full CRUD operations. Endpoints: - GET /gis/ogc/ Landing page - GET /gis/ogc/conformance Conformance classes - GET /gis/ogc/collections List all collections - GET /gis/ogc/collections/{id} Single collection metadata - GET /gis/ogc/collections/{id}/items Feature items (GeoJSON) - GET /gis/ogc/collections/{id}/items/{fid} Single feature - GET /gis/ogc/collections/{id}/qml QGIS style file (extension) + GET /gis/ogc/ Landing page + GET /gis/ogc/conformance Conformance classes + GET /gis/ogc/collections List all collections + GET /gis/ogc/collections/{id} Single collection metadata + GET /gis/ogc/collections/{id}/items Feature items (GeoJSON) + GET /gis/ogc/collections/{id}/items/{fid} Single feature + POST /gis/ogc/collections/geofences/items Create geofence (Part 4) + PUT /gis/ogc/collections/geofences/items/{fid} Replace geofence (Part 4) + DELETE /gis/ogc/collections/geofences/items/{fid} Delete geofence (Part 4) + POST /gis/ogc/collections/incidents/items Create incident (Part 4) + PUT /gis/ogc/collections/incidents/items/{fid} Update incident (Part 4) + GET /gis/ogc/collections/{id}/qml QGIS style file (extension) """ import json @@ -21,20 +27,36 @@ from typing import Annotated from odoo.api import Environment -from odoo.exceptions import MissingError +from odoo.exceptions import MissingError, ValidationError from odoo.addons.fastapi.dependencies import odoo_env from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client -from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response, status +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, Response, status +from ..schemas.geojson import ( + CreateGeofenceInput, + GeoJSONFeature, + GeoJSONFeatureCollection, + ReplaceGeofenceInput, +) +from ..schemas.incidents import ( + CreateIncidentInput, + ReplaceIncidentInput, +) from ..schemas.ogc import ( CollectionInfo, Collections, Conformance, LandingPage, ) -from ..services.ogc_service import OGCService +from ..services.ogc_service import ( + GEOFENCES_COLLECTION_ID, + INCIDENTS_COLLECTION_ID, + WRITABLE_COLLECTIONS, + DuplicateAlertError, + OGCService, +) from ..services.qml_template_service import QMLTemplateService _logger = logging.getLogger(__name__) @@ -72,6 +94,38 @@ def _check_gis_read_scope(api_client): ) +def _check_gis_geofence_scope(api_client): + """Verify client has gis:geofence scope for write operations. + + Args: + api_client: Authenticated API client + + Raises: + HTTPException: If scope check fails + """ + if not api_client.has_scope("gis", "geofence"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Client does not have gis:geofence scope", + ) + + +def _check_gis_incident_scope(api_client): + """Verify client has gis:incident scope for write operations. + + Args: + api_client: Authenticated API client + + Raises: + HTTPException: If scope check fails + """ + if not api_client.has_scope("gis", "incident"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Client does not have gis:incident scope", + ) + + @ogc_features_router.get( "", response_model=LandingPage, @@ -162,6 +216,7 @@ async def get_collection( "Returns features from a collection as a GeoJSON FeatureCollection. " "Supports pagination via limit/offset and spatial filtering via bbox." ), + responses={200: {"model": GeoJSONFeatureCollection}}, ) async def get_collection_items( collection_id: Annotated[str, Path(description="Collection identifier")], @@ -176,6 +231,31 @@ async def get_collection_items( description=("Bounding box filter: west,south,east,north (e.g., -180,-90,180,90)"), ), ] = None, + datetime_param: Annotated[ + str | None, + Query( + alias="datetime", + description="Temporal filter (ISO 8601 interval: start/end, ../end, start/..)", + ), + ] = None, + geofence_type: Annotated[ + str | None, + Query(description="Filter by geofence type (geofences collection only)"), + ] = None, + active: Annotated[ + bool | None, + Query(description="Include archived geofences (geofences collection only)"), + ] = None, + incident_code: Annotated[ + str | None, + Query(description="Filter geofences by linked incident code (geofences collection only)"), + ] = None, + event: Annotated[str | None, Query(description="Filter by event type (incidents collection only)")] = None, + severity: Annotated[str | None, Query(description="Filter by severity code (incidents collection only)")] = None, + incident_status: Annotated[ + str | None, + Query(alias="status", description="Filter by incident status (incidents collection only)"), + ] = None, ): """Get features from a collection. @@ -205,6 +285,13 @@ async def get_collection_items( limit=limit, offset=offset, bbox=bbox_list, + geofence_type=geofence_type, + active=active, + datetime_param=datetime_param, + event=event, + severity=severity, + incident_status=incident_status, + incident_code=incident_code, ) return Response( content=_json_dumps(result), @@ -234,20 +321,281 @@ async def options_collection_items( """Handle OPTIONS requests from OAPIF clients (e.g. QGIS). QGIS sends OPTIONS to discover allowed methods before fetching features. + Geofences collection advertises write methods (POST, PUT, DELETE). """ + if collection_id == GEOFENCES_COLLECTION_ID: + allow = "GET, HEAD, OPTIONS, POST, PUT, DELETE" + elif collection_id == INCIDENTS_COLLECTION_ID: + allow = "GET, HEAD, OPTIONS, POST, PUT" + else: + allow = "GET, HEAD, OPTIONS" + return Response( status_code=200, headers={ - "Allow": "GET, HEAD, OPTIONS", + "Allow": allow, "Accept": "application/geo+json, application/json", }, ) +@ogc_features_router.post( + "/collections/geofences/items", + summary="Create geofence (OGC Part 4)", + description="Create a new geofence feature.", + status_code=status.HTTP_201_CREATED, + responses={201: {"model": GeoJSONFeature}}, +) +async def post_geofence_item( + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], + feature: Annotated[CreateGeofenceInput, Body(...)], +): + """Create a new geofence (OGC API - Features Part 4).""" + _check_gis_geofence_scope(api_client) + + try: + base_url = _get_base_url(request) + service = OGCService(env, base_url) + body = feature.model_dump() + result = service.create_geofence_feature(body) + + return Response( + content=_json_dumps(result["feature"]), + status_code=status.HTTP_201_CREATED, + media_type="application/geo+json", + headers={ + "Location": result["location"], + "Content-Crs": "", + }, + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@ogc_features_router.post( + "/collections/incidents/items", + summary="Create incident (OGC Part 4)", + description="Create a new incident from an external alert. Returns 409 Conflict if source_alert_id already exists.", + status_code=status.HTTP_201_CREATED, + responses={201: {"model": GeoJSONFeature}}, +) +async def post_incident_item( + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], + feature: Annotated[CreateIncidentInput, Body(...)], +): + """Create a new incident (OGC API - Features Part 4).""" + _check_gis_incident_scope(api_client) + + try: + base_url = _get_base_url(request) + service = OGCService(env, base_url) + body = feature.model_dump() + result = service.create_incident_feature(body) + + return Response( + content=_json_dumps(result["feature"]), + status_code=status.HTTP_201_CREATED, + media_type="application/geo+json", + headers={ + "Location": result["location"], + "Content-Crs": "", + }, + ) + except DuplicateAlertError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + headers={"Location": e.location} if e.location else None, + ) from e + except (ValueError, ValidationError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@ogc_features_router.post( + "/collections/{collection_id}/items", + summary="Create feature (OGC Part 4)", + description="Create a new feature in the collection. Only supported for geofences and incidents collections.", + status_code=status.HTTP_201_CREATED, + responses={201: {"model": GeoJSONFeature}}, + include_in_schema=False, +) +async def post_collection_item( + collection_id: Annotated[str, Path(description="Collection identifier")], + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], + feature: Annotated[CreateGeofenceInput, Body(...)], +): + """Create a new feature (OGC API - Features Part 4). Fallback for unsupported collections.""" + if collection_id not in WRITABLE_COLLECTIONS: + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail=f"POST is not supported for collection '{collection_id}'", + ) + # Specific collection routes handle geofences and incidents; + # this route only fires for unexpected paths. + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail=f"POST is not supported for collection '{collection_id}'", + ) + + +@ogc_features_router.put( + "/collections/geofences/items/{feature_id}", + summary="Replace geofence (OGC Part 4)", + description="Replace a geofence feature.", + responses={200: {"model": GeoJSONFeature}}, +) +async def put_geofence_item( + feature_id: Annotated[str, Path(description="Feature identifier (UUID)")], + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], + feature: Annotated[ReplaceGeofenceInput, Body(...)], +): + """Replace a geofence (OGC API - Features Part 4).""" + _check_gis_geofence_scope(api_client) + + try: + base_url = _get_base_url(request) + service = OGCService(env, base_url) + body = feature.model_dump() + result = service.replace_geofence_feature(feature_id, body) + + return Response( + content=_json_dumps(result), + media_type="application/geo+json", + headers={"Content-Crs": ""}, + ) + except MissingError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@ogc_features_router.put( + "/collections/incidents/items/{feature_id}", + summary="Update incident (OGC Part 4)", + description="Update an incident. Geometry is optional (updates the hazard_zone geofence if provided).", + responses={200: {"model": GeoJSONFeature}}, +) +async def put_incident_item( + feature_id: Annotated[str, Path(description="Feature identifier (UUID)")], + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], + feature: Annotated[ReplaceIncidentInput, Body(...)], +): + """Update an incident (OGC API - Features Part 4).""" + _check_gis_incident_scope(api_client) + + try: + base_url = _get_base_url(request) + service = OGCService(env, base_url) + body = feature.model_dump() + result = service.replace_incident_feature(feature_id, body) + + return Response( + content=_json_dumps(result), + media_type="application/geo+json", + headers={"Content-Crs": ""}, + ) + except MissingError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except (ValueError, ValidationError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@ogc_features_router.put( + "/collections/{collection_id}/items/{feature_id}", + summary="Replace feature (OGC Part 4)", + description="Replace a feature in the collection.", + responses={200: {"model": GeoJSONFeature}}, + include_in_schema=False, +) +async def put_collection_item( + collection_id: Annotated[str, Path(description="Collection identifier")], + feature_id: Annotated[str, Path(description="Feature identifier (UUID)")], + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], + feature: Annotated[ReplaceGeofenceInput, Body(...)], +): + """Replace a feature (OGC API - Features Part 4). Fallback for unsupported collections.""" + if collection_id not in WRITABLE_COLLECTIONS: + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail=f"PUT is not supported for collection '{collection_id}'", + ) + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail=f"Use the collection-specific PUT endpoint for '{collection_id}'", + ) + + +@ogc_features_router.delete( + "/collections/{collection_id}/items/{feature_id}", + summary="Delete feature (OGC Part 4)", + description="Delete a feature from the collection. Only supported for the geofences collection.", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_collection_item( + collection_id: Annotated[str, Path(description="Collection identifier")], + feature_id: Annotated[str, Path(description="Feature identifier (UUID)")], + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], +): + """Delete a feature (OGC API - Features Part 4).""" + if collection_id != GEOFENCES_COLLECTION_ID: + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="DELETE is only supported for the geofences collection", + ) + + _check_gis_geofence_scope(api_client) + + try: + service = OGCService(env) + service.delete_geofence_feature(feature_id) + except MissingError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e + + @ogc_features_router.get( "/collections/{collection_id}/items/{feature_id}", summary="Single feature", description="Returns a single feature from a collection.", + responses={200: {"model": GeoJSONFeature}}, ) async def get_collection_item( collection_id: Annotated[str, Path(description="Collection identifier")], diff --git a/spp_api_v2_gis/routers/processes.py b/spp_api_v2_gis/routers/processes.py new file mode 100644 index 000000000..93594b109 --- /dev/null +++ b/spp_api_v2_gis/routers/processes.py @@ -0,0 +1,326 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""OGC API - Processes endpoints. + +Implements process discovery and execution per OGC API - Processes Part 1: Core. + +Endpoints: + GET /gis/ogc/processes List available processes + GET /gis/ogc/processes/{processId} Process description + POST /gis/ogc/processes/{processId}/execution Execute a process +""" + +import logging +import uuid +from typing import Annotated + +from odoo.api import Environment + +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client + +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Request, Response, status + +from ..schemas.processes import ( + BatchStatisticsResult, + ExecuteRequest, + ProcessDescription, + ProcessList, + ProcessSummary, + ProximityResult, + SingleStatisticsResult, + StatusInfo, +) +from ..services.process_execution import run_proximity_statistics, run_spatial_statistics +from ..services.process_registry import ( + DEFAULT_MAX_PROXIMITY_POINTS, + MAX_BATCH_GEOMETRIES, + PROXIMITY_STATISTICS, + SPATIAL_STATISTICS, + VALID_PROCESS_IDS, + ProcessRegistry, +) +from ..services.spatial_query_service import SpatialQueryService +from ._helpers import RETRY_AFTER_SECONDS, build_status_info, check_gis_scope, get_base_url + +_logger = logging.getLogger(__name__) + +processes_router = APIRouter(tags=["GIS - OGC API Processes"], prefix="/gis/ogc") + +# Maximum geometries allowed in a sync request before forcing async +_DEFAULT_FORCED_ASYNC_THRESHOLD = 10 + + +@processes_router.get( + "/processes", + response_model=ProcessList, + summary="List available processes", + description="Returns a list of all available OGC processes.", +) +async def list_processes( + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], +): + """List all available OGC processes.""" + check_gis_scope(api_client) + + registry = ProcessRegistry(env) + process_dicts = registry.list_processes() + + processes = [ProcessSummary(**p) for p in process_dicts] + return ProcessList(processes=processes) + + +@processes_router.get( + "/processes/{process_id}", + response_model=ProcessDescription, + summary="Process description", + description="Returns the full description of a process, including input/output schemas.", +) +async def get_process_description( + process_id: Annotated[str, Path(description="Process identifier")], + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], +): + """Get full process description with input/output schemas.""" + check_gis_scope(api_client) + + registry = ProcessRegistry(env) + description = registry.get_process(process_id) + + if description is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Process '{process_id}' not found", + ) + + return ProcessDescription(**description) + + +@processes_router.post( + "/processes/{process_id}/execution", + summary="Execute a process", + description="Execute an OGC process synchronously or asynchronously.", + status_code=200, + responses={ + 200: { + "description": "Synchronous result (schema varies by process)", + "model": SingleStatisticsResult | BatchStatisticsResult | ProximityResult, + }, + 201: { + "description": "Asynchronous execution accepted", + "model": StatusInfo, + }, + }, +) +async def execute_process( + process_id: Annotated[str, Path(description="Process identifier")], + execute_request: Annotated[ExecuteRequest, Body(...)], + request: Request, + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], + prefer: Annotated[str | None, Header()] = None, +): + """Execute a process. + + Supports both synchronous and asynchronous execution. + Use the Prefer: respond-async header to request async execution. + Batch requests with more than the configured threshold of geometries + are automatically forced to async. + """ + check_gis_scope(api_client) + + # Validate process ID + if process_id not in VALID_PROCESS_IDS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Process '{process_id}' not found", + ) + + inputs = execute_request.inputs + + # Validate and determine sync vs async + wants_async = prefer and "respond-async" in prefer + forced_async = False + + if process_id == SPATIAL_STATISTICS: + _validate_spatial_statistics_inputs(inputs) + geometry = inputs.get("geometry") + if isinstance(geometry, list): + # Get forced async threshold + # nosemgrep: odoo-sudo-without-context + try: + threshold = int( + # nosemgrep: odoo-sudo-without-context (system-context read; auth at router) + env["ir.config_parameter"] + .sudo() + .get_param("spp_gis.forced_async_threshold", _DEFAULT_FORCED_ASYNC_THRESHOLD) + ) + except (ValueError, TypeError): + threshold = _DEFAULT_FORCED_ASYNC_THRESHOLD + if len(geometry) > threshold: + forced_async = True + elif process_id == PROXIMITY_STATISTICS: + # nosemgrep: odoo-sudo-without-context + try: + max_points = int( + # nosemgrep: odoo-sudo-without-context (system-context read; auth at router) + env["ir.config_parameter"] + .sudo() + .get_param("spp_gis.max_proximity_points", DEFAULT_MAX_PROXIMITY_POINTS) + ) + except (ValueError, TypeError): + max_points = DEFAULT_MAX_PROXIMITY_POINTS + _validate_proximity_statistics_inputs(inputs, max_points) + + use_async = wants_async or forced_async + + if use_async: + return _execute_async(env, api_client, process_id, inputs, request) + + return _execute_sync(env, process_id, inputs) + + +def _validate_spatial_statistics_inputs(inputs): + """Validate inputs for spatial-statistics process.""" + geometry = inputs.get("geometry") + if geometry is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'geometry' input is required", + ) + + if isinstance(geometry, list): + if len(geometry) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'geometry' array must not be empty", + ) + if len(geometry) > MAX_BATCH_GEOMETRIES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum {MAX_BATCH_GEOMETRIES} geometries allowed per request", + ) + # Validate each item has {id, value} wrapper + for i, item in enumerate(geometry): + if not isinstance(item, dict) or "id" not in item or "value" not in item: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Geometry array item {i} must be an object with 'id' and 'value' keys. " + "Bare GeoJSON arrays are not supported; wrap each geometry in {{id, value}}.", + ) + elif not isinstance(geometry, dict): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'geometry' must be a GeoJSON object or an array of {id, value} objects", + ) + + +def _validate_proximity_statistics_inputs(inputs, max_points=DEFAULT_MAX_PROXIMITY_POINTS): + """Validate inputs for proximity-statistics process.""" + reference_points = inputs.get("reference_points") + if not reference_points: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'reference_points' input is required", + ) + if not isinstance(reference_points, list) or len(reference_points) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'reference_points' must be a non-empty array", + ) + if len(reference_points) > max_points: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum {max_points:,} reference points allowed", + ) + + radius_km = inputs.get("radius_km") + if radius_km is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'radius_km' input is required", + ) + if not isinstance(radius_km, (int, float)) or radius_km <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'radius_km' must be a positive number", + ) + if radius_km > 500: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'radius_km' must not exceed 500", + ) + + relation = inputs.get("relation", "within") + if relation not in ("within", "beyond"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'relation' must be 'within' or 'beyond'", + ) + + +def _execute_sync(env, process_id, inputs): + """Execute a process synchronously and return results directly.""" + service = SpatialQueryService(env) + + try: + if process_id == SPATIAL_STATISTICS: + result = run_spatial_statistics(service, inputs) + else: + result = run_proximity_statistics(service, inputs) + + return result + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + except Exception: + _logger.exception("Process execution failed for %s", process_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Process execution failed", + ) from None + + +def _execute_async(env, api_client, process_id, inputs, request): + """Execute a process asynchronously via job_worker.""" + job_id = str(uuid.uuid4()) + + # Create job record + # nosemgrep: odoo-sudo-without-context + job = ( + # nosemgrep: odoo-sudo-without-context (API runs in system context; authorization enforced at the router) + env["spp.gis.process.job"] + .sudo() + .create( + { + "job_id": job_id, + "process_id": process_id, + "client_id": api_client.id, + "inputs": inputs, + } + ) + ) + + # Enqueue via job_worker + delayed = job.with_delay( + channel="gis", + timeout=300, + description=f"OGC Process: {process_id} ({job_id})", + ).execute_process() + job.job_uuid = delayed.uuid + + base_url = get_base_url(request) + status_info = build_status_info(job, base_url) + + return Response( + content=status_info.model_dump_json(by_alias=True, exclude_none=True), + status_code=201, + media_type="application/json", + headers={ + "Location": f"{base_url}/gis/ogc/jobs/{job_id}", + "Retry-After": RETRY_AFTER_SECONDS, + }, + ) diff --git a/spp_api_v2_gis/routers/proximity.py b/spp_api_v2_gis/routers/proximity.py deleted file mode 100644 index 17c8a8431..000000000 --- a/spp_api_v2_gis/routers/proximity.py +++ /dev/null @@ -1,81 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Proximity query API endpoint for finding registrants within/beyond radius of reference points.""" - -import logging -from typing import Annotated - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env -from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client - -from fastapi import APIRouter, Body, Depends, HTTPException, status - -from ..schemas.query import ProximityQueryRequest, ProximityQueryResponse -from ..services.spatial_query_service import SpatialQueryService - -_logger = logging.getLogger(__name__) - -proximity_router = APIRouter(tags=["GIS"], prefix="/gis") - - -@proximity_router.post( - "/query/proximity", - summary="Query statistics by proximity to reference points", - description="Find registrants within or beyond a given radius from a set of " - "reference points (e.g., health centers). Supports thousands of reference points " - "using pre-buffered spatial indexes.", - response_model=ProximityQueryResponse, -) -async def query_proximity( - request: Annotated[ProximityQueryRequest, Body(...)], - env: Annotated[Environment, Depends(odoo_env)], - api_client: Annotated[dict, Depends(get_authenticated_client)], -) -> ProximityQueryResponse: - """Query registrant statistics by proximity to reference points. - - Accepts a list of reference point coordinates and a radius. Returns - aggregated statistics for registrants that are within or beyond the - specified distance from any of the reference points. - - The server pre-buffers reference points and uses ST_Intersects against - indexed registrant coordinates for efficient queries. - """ - # Check read scope - accept either gis:read or statistics:read - if not (api_client.has_scope("gis", "read") or api_client.has_scope("statistics", "read")): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Client does not have gis:read or statistics:read scope", - ) - - try: - service = SpatialQueryService(env) - - # Convert pydantic models to dicts for the service - reference_points = [{"longitude": pt.longitude, "latitude": pt.latitude} for pt in request.reference_points] - - result = service.query_proximity( - reference_points=reference_points, - radius_km=request.radius_km, - relation=request.relation, - filters=request.filters, - variables=request.variables, - ) - - # Remove internal registrant_ids from response - result.pop("registrant_ids", None) - - return ProximityQueryResponse(**result) - - except ValueError as e: - _logger.warning("Invalid proximity query parameters: %s", e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from None - except Exception: - _logger.exception("Proximity query failed") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Proximity query failed", - ) from None diff --git a/spp_api_v2_gis/routers/spatial_query.py b/spp_api_v2_gis/routers/spatial_query.py deleted file mode 100644 index 81a163324..000000000 --- a/spp_api_v2_gis/routers/spatial_query.py +++ /dev/null @@ -1,137 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Spatial query API endpoints for querying statistics within arbitrary polygons.""" - -import logging -from typing import Annotated - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env -from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client - -from fastapi import APIRouter, Body, Depends, HTTPException, status - -from ..schemas.query import ( - BatchSpatialQueryRequest, - BatchSpatialQueryResponse, - SpatialQueryRequest, - SpatialQueryResponse, -) -from ..services.spatial_query_service import SpatialQueryService - -_logger = logging.getLogger(__name__) - -spatial_query_router = APIRouter(tags=["GIS"], prefix="/gis") - - -@spatial_query_router.post( - "/query/statistics", - summary="Query statistics for polygon", - description="Query registrant statistics within arbitrary polygon using PostGIS.", - response_model=SpatialQueryResponse, -) -async def query_statistics( - request: Annotated[SpatialQueryRequest, Body(...)], - env: Annotated[Environment, Depends(odoo_env)], - api_client: Annotated[dict, Depends(get_authenticated_client)], -) -> SpatialQueryResponse: - """Query statistics within polygon. - - This endpoint accepts a GeoJSON polygon and returns aggregated statistics - for registrants within that area. It uses PostGIS spatial queries for - efficient computation. - - Query methods: - - coordinates: Direct spatial query when registrants have coordinates (preferred) - - area_fallback: Match via area_id when coordinates not available - - Statistics are computed by the unified aggregation service. - """ - # Check read scope - accept either gis:read or statistics:read - if not (api_client.has_scope("gis", "read") or api_client.has_scope("statistics", "read")): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Client does not have gis:read or statistics:read scope", - ) - - try: - # Initialize service - service = SpatialQueryService(env) - - # Execute spatial query - result = service.query_statistics( - geometry=request.geometry, - filters=request.filters, - variables=request.variables, - ) - - # Remove internal registrant_ids from response - result.pop("registrant_ids", None) - - return SpatialQueryResponse(**result) - - except ValueError as e: - _logger.warning("Invalid query parameters: %s", e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - except Exception as e: - _logger.exception("Spatial query failed") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Spatial query failed", - ) from e - - -@spatial_query_router.post( - "/query/statistics/batch", - summary="Batch query statistics for multiple polygons", - description="Query registrant statistics for multiple polygons individually. " - "Returns per-geometry results plus an aggregate summary.", - response_model=BatchSpatialQueryResponse, -) -async def query_statistics_batch( - request: Annotated[BatchSpatialQueryRequest, Body(...)], - env: Annotated[Environment, Depends(odoo_env)], - api_client: Annotated[dict, Depends(get_authenticated_client)], -) -> BatchSpatialQueryResponse: - """Batch query statistics for multiple geometries. - - Each geometry is queried independently, returning per-shape statistics - that can be used for thematic map visualization. A summary field provides - the deduplicated aggregate across all shapes. - """ - # Check read scope - accept either gis:read or statistics:read - if not (api_client.has_scope("gis", "read") or api_client.has_scope("statistics", "read")): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Client does not have gis:read or statistics:read scope", - ) - - try: - service = SpatialQueryService(env) - - # Convert pydantic models to dicts for the service - geometries = [{"id": item.id, "geometry": item.geometry} for item in request.geometries] - - result = service.query_statistics_batch( - geometries=geometries, - filters=request.filters, - variables=request.variables, - ) - - return BatchSpatialQueryResponse(**result) - - except ValueError as e: - _logger.warning("Invalid batch query parameters: %s", e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from None - except Exception: - _logger.exception("Batch spatial query failed") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Batch spatial query failed", - ) from None diff --git a/spp_api_v2_gis/routers/statistics.py b/spp_api_v2_gis/routers/statistics.py index 976f67890..86413dae7 100644 --- a/spp_api_v2_gis/routers/statistics.py +++ b/spp_api_v2_gis/routers/statistics.py @@ -1,5 +1,9 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Statistics discovery API endpoint.""" +"""Statistics discovery API endpoint. + +Delegates to the ProcessRegistry for statistics metadata, +keeping this endpoint as a convenience alias. +""" import logging from typing import Annotated @@ -16,6 +20,7 @@ IndicatorInfo, IndicatorsListResponse, ) +from ..services.process_registry import ProcessRegistry _logger = logging.getLogger(__name__) @@ -35,7 +40,8 @@ async def list_statistics( """List all GIS-published statistics grouped by category. Used by the QGIS plugin to discover what statistics are available - for spatial queries and map visualization. + for spatial queries and map visualization. Delegates to the + ProcessRegistry for consistent metadata. """ # Check read scope if not (api_client.has_scope("gis", "read") or api_client.has_scope("statistics", "read")): @@ -45,35 +51,29 @@ async def list_statistics( ) try: - # nosemgrep: odoo-sudo-without-context - Statistic = env["spp.indicator"].sudo() - stats_by_category = Statistic.get_published_by_category("gis") + registry = ProcessRegistry(env) + _variable_names, categories_data = registry.get_statistics_metadata() categories = [] total_count = 0 - for category_code, stat_records in stats_by_category.items(): - # Get category metadata - category_record = stat_records[0].category_id if stat_records else None - - stat_items = [] - for stat in stat_records: - config = stat.get_context_config("gis") - stat_items.append( - IndicatorInfo( - name=stat.name, - label=config.get("label", stat.label), - description=stat.description, - format=config.get("format", stat.format), - unit=stat.unit, - ) + for cat in categories_data: + stat_items = [ + IndicatorInfo( + name=s["name"], + label=s["label"], + description=s.get("description"), + format=s["format"], + unit=s.get("unit"), ) + for s in cat["statistics"] + ] categories.append( IndicatorCategoryInfo( - code=category_code, - name=category_record.name if category_record else category_code.replace("_", " ").title(), - icon=getattr(category_record, "icon", None) if category_record else None, + code=cat["code"], + name=cat["name"], + icon=cat.get("icon"), statistics=stat_items, ) ) diff --git a/spp_api_v2_gis/schemas/__init__.py b/spp_api_v2_gis/schemas/__init__.py index 7a73017a7..4a7ac3c32 100644 --- a/spp_api_v2_gis/schemas/__init__.py +++ b/spp_api_v2_gis/schemas/__init__.py @@ -1,5 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -from . import geofence from . import geojson +from . import incidents from . import ogc +from . import processes +from . import statistics from . import query diff --git a/spp_api_v2_gis/schemas/geofence.py b/spp_api_v2_gis/schemas/geofence.py deleted file mode 100644 index d53f64106..000000000 --- a/spp_api_v2_gis/schemas/geofence.py +++ /dev/null @@ -1,45 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Pydantic schemas for Geofence API.""" - -from pydantic import BaseModel, Field - - -class GeofenceCreateRequest(BaseModel): - """Request to create a geofence.""" - - name: str = Field(..., description="Name of the geofence") - description: str | None = Field(default=None, description="Description of the geofence") - geometry: dict = Field(..., description="Geometry as GeoJSON (Polygon or MultiPolygon)") - geofence_type: str = Field(default="custom", description="Type of geofence") - incident_code: str | None = Field(default=None, description="Related incident code") - - -class GeofenceResponse(BaseModel): - """Response from geofence operations.""" - - id: int = Field(..., description="Geofence database identifier") - name: str = Field(..., description="Geofence name") - description: str | None = Field(default=None, description="Geofence description") - geofence_type: str = Field(..., description="Type of geofence") - area_sqkm: float = Field(..., description="Area in square kilometers") - active: bool = Field(..., description="Whether the geofence is active") - created_from: str = Field(..., description="Source of creation") - - -class GeofenceListItem(BaseModel): - """Geofence item in list response.""" - - id: int = Field(..., description="Geofence database identifier") - name: str = Field(..., description="Geofence name") - geofence_type: str = Field(..., description="Type of geofence") - area_sqkm: float = Field(..., description="Area in square kilometers") - active: bool = Field(..., description="Whether the geofence is active") - - -class GeofenceListResponse(BaseModel): - """Response from geofence list endpoint.""" - - geofences: list[GeofenceListItem] = Field(..., description="List of geofences") - total: int = Field(..., description="Total count of geofences") - offset: int = Field(..., description="Offset used for pagination") - count: int = Field(..., description="Number of items returned") diff --git a/spp_api_v2_gis/schemas/geojson.py b/spp_api_v2_gis/schemas/geojson.py index 62debb15e..927fccb14 100644 --- a/spp_api_v2_gis/schemas/geojson.py +++ b/spp_api_v2_gis/schemas/geojson.py @@ -1,35 +1,280 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Pydantic schemas for GeoJSON responses.""" +"""Pydantic schemas for GeoJSON responses and geofence input validation.""" -from pydantic import BaseModel, Field +from typing import Literal +from pydantic import BaseModel, ConfigDict, Field -class GeoJSONGeometry(BaseModel): - """GeoJSON geometry.""" +from .ogc import OGCLink + +# RFC 7946 geometry types +GeometryType = Literal[ + "Point", + "MultiPoint", + "LineString", + "MultiLineString", + "Polygon", + "MultiPolygon", + "GeometryCollection", +] - type: str = Field(..., description="Geometry type") - coordinates: list = Field(..., description="Coordinates array") +class GeoJSONGeometry(BaseModel): + """GeoJSON geometry object (RFC 7946).""" -class GeoJSONFeatureProperties(BaseModel): - """Properties for a GeoJSON feature.""" + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], + }, + { + "type": "Point", + "coordinates": [100.5, 0.5], + }, + ], + }, + ) - # Flexible properties - subclass for specific feature types - pass + type: GeometryType = Field( + ..., + description="The type of the GeoJSON geometry.", + ) + coordinates: list = Field( + ..., + description=( + "The coordinates of the geometry. For Point, it is [longitude, latitude]. " + "For Polygon, it is an array of linear ring coordinate arrays." + ), + ) class GeoJSONFeature(BaseModel): - """GeoJSON Feature.""" + """GeoJSON Feature (RFC 7946).""" - type: str = Field(default="Feature", description="GeoJSON type") + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "type": "Feature", + "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], + }, + "properties": { + "uuid": "d290f1ee-6c54-4b01-90e6-d701748f0851", + "name": "Flood Response Zone A", + "description": "Northern flood-affected area", + "geofence_type": "hazard_zone", + "geofence_type_label": "Hazard Zone", + "area_sqkm": 12345.67, + "tags": ["flood", "response-2024"], + "created_from": "api", + "created_by": "Admin User", + "create_date": "2024-06-15T10:30:00Z", + }, + "links": [ + { + "href": "/api/v2/spp/gis/ogc/collections/geofences/items/d290f1ee", + "rel": "self", + "type": "application/geo+json", + }, + { + "href": "/api/v2/spp/gis/ogc/collections/geofences", + "rel": "collection", + "type": "application/json", + }, + ], + }, + ], + }, + ) + + type: Literal["Feature"] = Field(default="Feature", description="GeoJSON type") + id: str | int | None = Field(default=None, description="Feature identifier") properties: dict = Field(..., description="Feature properties") - geometry: dict | None = Field(default=None, description="GeoJSON geometry") + geometry: GeoJSONGeometry | None = Field(default=None, description="GeoJSON geometry") + links: list[OGCLink] | None = Field(default=None, description="OGC navigation links") class GeoJSONFeatureCollection(BaseModel): - """GeoJSON FeatureCollection.""" + """GeoJSON FeatureCollection (OGC API - Features Part 1).""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "type": "FeatureCollection", + "numberMatched": 42, + "numberReturned": 10, + "features": [ + { + "type": "Feature", + "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], + }, + "properties": { + "name": "Flood Response Zone A", + "geofence_type": "hazard_zone", + "area_sqkm": 12345.67, + }, + }, + ], + "links": [ + { + "href": "/api/v2/spp/gis/ogc/collections/geofences/items?limit=10&offset=0", + "rel": "self", + "type": "application/geo+json", + "title": "This page", + }, + { + "href": "/api/v2/spp/gis/ogc/collections/geofences/items?limit=10&offset=10", + "rel": "next", + "type": "application/geo+json", + "title": "Next page", + }, + ], + }, + ], + }, + ) - type: str = Field(default="FeatureCollection", description="GeoJSON type") + type: Literal["FeatureCollection"] = Field(default="FeatureCollection", description="GeoJSON type") features: list[GeoJSONFeature] = Field(..., description="List of features") - metadata: dict | None = Field(default=None, description="Collection metadata") - styling: dict | None = Field(default=None, description="Styling hints for QGIS") + timeStamp: str | None = Field( # noqa: N815 + default=None, description="ISO 8601 timestamp of the response" + ) + numberMatched: int | None = Field( # noqa: N815 + default=None, description="Total number of features matching the query" + ) + numberReturned: int | None = Field( # noqa: N815 + default=None, description="Number of features in this response" + ) + links: list[OGCLink] | None = Field(default=None, description="OGC navigation and pagination links") + + +# --- Geofence-specific models for POST/PUT input validation --- + + +class GeofenceProperties(BaseModel): + """Typed properties for a geofence feature.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "name": "Flood Response Zone A", + "geofence_type": "hazard_zone", + "description": "Northern flood-affected area", + "tags": ["flood", "response-2024"], + }, + ], + }, + ) + + name: str = Field(..., description="Geofence name") + description: str | None = Field(default=None, description="Geofence description") + geofence_type: str | None = Field(default=None, description="Geofence type classification") + tags: list[str] | None = Field(default=None, description="Tags for categorization") + area_sqkm: float | None = Field(default=None, description="Area in square kilometres") + + +class CreateGeofenceInput(BaseModel): + """Request body for POST /collections/geofences/items (OGC Part 4).""" + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={ + "examples": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], + }, + "properties": { + "name": "Flood Response Zone A", + "geofence_type": "hazard_zone", + "description": "Northern flood-affected area", + "tags": ["flood", "response-2024"], + }, + }, + ], + }, + ) + + type: Literal["Feature"] = Field(default="Feature", description="Must be 'Feature'") + geometry: GeoJSONGeometry = Field(..., description="Geofence geometry (required)") + properties: GeofenceProperties = Field(..., description="Geofence properties") + + +class ReplaceGeofenceInput(BaseModel): + """Request body for PUT /collections/geofences/items/{fid} (OGC Part 4).""" + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={ + "examples": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [102.0, 2.0], + [103.0, 2.0], + [103.0, 3.0], + [102.0, 3.0], + [102.0, 2.0], + ] + ], + }, + "properties": { + "name": "Updated Response Zone", + "geofence_type": "service_area", + "description": "Expanded service coverage area", + }, + }, + ], + }, + ) + + type: Literal["Feature"] = Field(default="Feature", description="Must be 'Feature'") + geometry: GeoJSONGeometry = Field(..., description="Geofence geometry (required)") + properties: GeofenceProperties = Field(..., description="Geofence properties") diff --git a/spp_api_v2_gis/schemas/incidents.py b/spp_api_v2_gis/schemas/incidents.py new file mode 100644 index 000000000..9276d0b81 --- /dev/null +++ b/spp_api_v2_gis/schemas/incidents.py @@ -0,0 +1,111 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Pydantic schemas for incident input validation (OGC Features Part 4).""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from .geojson import GeoJSONGeometry + + +class IncidentProperties(BaseModel): + """CAP-aligned properties for incident creation/update.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "event": "Flood", + "headline": "Major Flooding in Region A", + "severity": "extreme", + "urgency": "immediate", + "certainty": "observed", + "effective": "2026-04-01T00:00:00Z", + "expires": "2026-04-15T00:00:00Z", + "source": "INAM Mozambique", + "source_alert_id": "MOZ-FLOOD-2026-042", + "cap_msg_type": "alert", + }, + ], + }, + ) + + event: str = Field(..., description="Event type (e.g., 'Flood', 'Typhoon')") + headline: str = Field(..., description="Alert headline (maps to incident name)") + severity: str | None = Field(default=None, description="CAP severity vocabulary code") + urgency: str | None = Field(default=None, description="CAP urgency vocabulary code") + certainty: str | None = Field(default=None, description="CAP certainty vocabulary code") + effective: str | None = Field(default=None, description="ISO 8601 datetime when alert becomes active") + expires: str | None = Field(default=None, description="ISO 8601 datetime when alert expires") + source: str | None = Field(default=None, description="Organization that issued the alert") + source_alert_id: str | None = Field(default=None, description="External alert reference ID from the EWS") + cap_msg_type: str | None = Field(default="alert", description="CAP message type: alert, update, cancel") + + +class CreateIncidentInput(BaseModel): + """Request body for POST /collections/incidents/items.""" + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={ + "examples": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [34.0, -15.0], + [36.0, -15.0], + [36.0, -13.0], + [34.0, -13.0], + [34.0, -15.0], + ] + ], + }, + "properties": { + "event": "Flood", + "headline": "Severe Flooding in Zambezi Basin", + "severity": "extreme", + "urgency": "immediate", + "certainty": "observed", + "effective": "2026-04-01T00:00:00Z", + "source": "INAM Mozambique", + "source_alert_id": "MOZ-FLOOD-2026-042", + }, + }, + ], + }, + ) + + type: Literal["Feature"] = Field(default="Feature", description="Must be 'Feature'") + geometry: GeoJSONGeometry = Field(..., description="Alert geometry (required, creates hazard_zone geofence)") + properties: IncidentProperties = Field(..., description="CAP-aligned alert properties") + + +class ReplaceIncidentInput(BaseModel): + """Request body for PUT /collections/incidents/items/{fid}.""" + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={ + "examples": [ + { + "type": "Feature", + "geometry": None, + "properties": { + "event": "Flood", + "headline": "Severe Flooding - Update 2", + "severity": "extreme", + "cap_msg_type": "update", + }, + }, + ], + }, + ) + + type: Literal["Feature"] = Field(default="Feature", description="Must be 'Feature'") + geometry: GeoJSONGeometry | None = Field( + default=None, description="Alert geometry (optional on update, updates hazard_zone geofence if provided)" + ) + properties: IncidentProperties = Field(..., description="CAP-aligned alert properties") diff --git a/spp_api_v2_gis/schemas/ogc.py b/spp_api_v2_gis/schemas/ogc.py index c3c7b55ac..0c2d9e980 100644 --- a/spp_api_v2_gis/schemas/ogc.py +++ b/spp_api_v2_gis/schemas/ogc.py @@ -20,6 +20,35 @@ class OGCLink(BaseModel): class LandingPage(BaseModel): """OGC API - Features landing page.""" + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "title": "OpenSPP GIS API", + "description": "OGC API - Features endpoints for OpenSPP geospatial data.", + "links": [ + { + "href": "/api/v2/spp/gis/ogc", + "rel": "self", + "type": "application/json", + "title": "This document", + }, + { + "href": "/api/v2/spp/gis/ogc/conformance", + "rel": "conformance", + "type": "application/json", + }, + { + "href": "/api/v2/spp/gis/ogc/collections", + "rel": "data", + "type": "application/json", + }, + ], + }, + ], + }, + ) + title: str = Field(..., description="API title") description: str = Field(..., description="API description") links: list[OGCLink] = Field(..., description="Navigation links") @@ -28,6 +57,21 @@ class LandingPage(BaseModel): class Conformance(BaseModel): """OGC API conformance declaration.""" + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "conformsTo": [ + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", + "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete", + ], + }, + ], + }, + ) + conformsTo: list[str] = Field( # noqa: N815 ..., description="List of conformance class URIs" ) @@ -36,6 +80,17 @@ class Conformance(BaseModel): class SpatialExtent(BaseModel): """Spatial extent with bounding box.""" + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "bbox": [[95.0, -11.0, 141.0, 6.0]], + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + ], + }, + ) + bbox: list[list[float]] = Field(..., description="Bounding box coordinates [[west, south, east, north]]") crs: str = Field( default="http://www.opengis.net/def/crs/OGC/1.3/CRS84", @@ -46,12 +101,38 @@ class SpatialExtent(BaseModel): class TemporalExtent(BaseModel): """Temporal extent with time interval.""" + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "interval": [["2024-01-01T00:00:00Z", None]], + }, + ], + }, + ) + interval: list[list[str | None]] = Field(..., description="Time interval [[start, end]]") class Extent(BaseModel): """Collection extent (spatial and temporal).""" + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "spatial": { + "bbox": [[95.0, -11.0, 141.0, 6.0]], + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + "temporal": { + "interval": [["2024-01-01T00:00:00Z", None]], + }, + }, + ], + }, + ) + spatial: SpatialExtent | None = Field(default=None, description="Spatial extent") temporal: TemporalExtent | None = Field(default=None, description="Temporal extent") @@ -59,7 +140,82 @@ class Extent(BaseModel): class CollectionInfo(BaseModel): """OGC API collection metadata.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "id": "pop_density_adm2", + "title": "Population Density (District)", + "description": "Population density statistics per district", + "itemType": "feature", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], + "extent": { + "spatial": { + "bbox": [[95.0, -11.0, 141.0, 6.0]], + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + }, + "links": [ + { + "href": "/api/v2/spp/gis/ogc/collections/pop_density_adm2", + "rel": "self", + "type": "application/json", + }, + { + "href": "/api/v2/spp/gis/ogc/collections/pop_density_adm2/items", + "rel": "items", + "type": "application/geo+json", + }, + { + "href": "/api/v2/spp/gis/ogc/collections/pop_density_adm2/qml", + "rel": "describedby", + "type": "text/xml", + }, + ], + }, + { + "id": "geofences", + "title": "Geofences", + "description": "User-defined geographic areas of interest", + "itemType": "feature", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], + "storageCrs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "links": [ + { + "href": "/api/v2/spp/gis/ogc/collections/geofences", + "rel": "self", + "type": "application/json", + }, + { + "href": "/api/v2/spp/gis/ogc/collections/geofences/items", + "rel": "items", + "type": "application/geo+json", + }, + ], + }, + { + "id": "layer_42", + "title": "Health Facilities", + "description": "Data layer from spp.gis.data.layer", + "itemType": "feature", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], + "links": [ + { + "href": "/api/v2/spp/gis/ogc/collections/layer_42", + "rel": "self", + "type": "application/json", + }, + { + "href": "/api/v2/spp/gis/ogc/collections/layer_42/items", + "rel": "items", + "type": "application/geo+json", + }, + ], + }, + ], + }, + ) id: str = Field(..., description="Collection identifier") title: str = Field(..., description="Human-readable title") @@ -73,11 +229,55 @@ class CollectionInfo(BaseModel): default=["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], description="Supported CRS list", ) + storageCrs: str | None = Field( # noqa: N815 + default=None, + description="CRS used to store features in this collection", + ) links: list[OGCLink] = Field(default_factory=list, description="Navigation links") class Collections(BaseModel): """OGC API collections list.""" + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "links": [ + { + "href": "/api/v2/spp/gis/ogc/collections", + "rel": "self", + "type": "application/json", + }, + ], + "collections": [ + { + "id": "pop_density_adm2", + "title": "Population Density (District)", + "itemType": "feature", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], + "links": [], + }, + { + "id": "geofences", + "title": "Geofences", + "itemType": "feature", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], + "storageCrs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "links": [], + }, + { + "id": "layer_42", + "title": "Health Facilities", + "itemType": "feature", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], + "links": [], + }, + ], + }, + ], + }, + ) + links: list[OGCLink] = Field(default_factory=list, description="Navigation links") collections: list[CollectionInfo] = Field(..., description="Available collections") diff --git a/spp_api_v2_gis/schemas/processes.py b/spp_api_v2_gis/schemas/processes.py new file mode 100644 index 000000000..c823ade94 --- /dev/null +++ b/spp_api_v2_gis/schemas/processes.py @@ -0,0 +1,488 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Pydantic schemas for OGC API - Processes endpoints.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from odoo.addons.spp_api_v2.utils.openapi_polymorphic import polymorphic_body + +from .geojson import GeoJSONGeometry +from .ogc import OGCLink + + +class ProcessSummary(BaseModel): + """Summary of a single process, used in process list responses.""" + + model_config = ConfigDict( + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "id": "spatial-statistics", + "title": "Spatial Statistics", + "description": "Compute aggregate registrant statistics within arbitrary polygons.", + "version": "1.0.0", + "jobControlOptions": ["sync-execute", "async-execute", "dismiss"], + "links": [], + }, + ], + }, + ) + + id: str = Field(..., description="Process identifier (e.g. 'spatial-statistics')") + title: str = Field(..., description="Human-readable process title") + description: str | None = Field(default=None, description="Process description") + version: str = Field(default="1.0.0", description="Process version") + jobControlOptions: list[str] = Field( # noqa: N815 + ..., + alias="jobControlOptions", + description="Supported job control options (e.g. sync-execute, async-execute, dismiss)", + ) + links: list[OGCLink] = Field(default_factory=list, description="Related links") + + +class ProcessDescription(BaseModel): + """Full process description including input and output schemas.""" + + model_config = ConfigDict( + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "id": "proximity-statistics", + "title": "Proximity Statistics", + "description": "Compute statistics within a radius from reference points.", + "version": "1.0.0", + "jobControlOptions": ["sync-execute", "async-execute", "dismiss"], + "inputs": { + "reference_points": { + "title": "Reference Points", + "description": "Locations to measure proximity from.", + "minOccurs": 1, + "schema": { + "type": "object", + "properties": { + "longitude": {"type": "number"}, + "latitude": {"type": "number"}, + }, + "required": ["longitude", "latitude"], + }, + }, + "radius_km": { + "title": "Search Radius", + "schema": {"type": "number", "maximum": 500}, + }, + }, + "outputs": { + "result": { + "title": "Proximity Statistics Result", + "schema": {"type": "object"}, + }, + }, + "links": [], + }, + ], + }, + ) + + id: str = Field(..., description="Process identifier (e.g. 'spatial-statistics')") + title: str = Field(..., description="Human-readable process title") + description: str | None = Field(default=None, description="Process description") + version: str = Field(default="1.0.0", description="Process version") + jobControlOptions: list[str] = Field( # noqa: N815 + ..., + alias="jobControlOptions", + description="Supported job control options (e.g. sync-execute, async-execute, dismiss)", + ) + inputs: dict = Field(..., description="Input parameter definitions") + outputs: dict = Field(..., description="Output schema definitions") + links: list[OGCLink] = Field(default_factory=list, description="Related links") + + +class ProcessList(BaseModel): + """List of available processes.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "processes": [ + { + "id": "spatial-statistics", + "title": "Spatial Statistics", + "version": "1.0.0", + "jobControlOptions": ["sync-execute", "async-execute", "dismiss"], + }, + { + "id": "proximity-statistics", + "title": "Proximity Statistics", + "version": "1.0.0", + "jobControlOptions": ["sync-execute", "async-execute", "dismiss"], + }, + ], + "links": [], + }, + ], + }, + ) + + processes: list[ProcessSummary] = Field(..., description="Available processes") + links: list[OGCLink] = Field(default_factory=list, description="Navigation links") + + +class BatchGeometryItem(BaseModel): + """A single geometry with an identifier for batch spatial-statistics queries.""" + + id: str = Field(..., description="Unique identifier for this geometry (e.g., feature ID)") + value: GeoJSONGeometry = Field(..., description="GeoJSON geometry (Polygon or MultiPolygon)") + + +class SpatialStatisticsInputs(BaseModel): + """Inputs for the spatial-statistics process.""" + + geometry: GeoJSONGeometry | list[BatchGeometryItem] = Field( + ..., + description=( + "Query geometry as a single GeoJSON object or a list of {id, value} objects for batch processing." + ), + ) + filters: dict | None = Field( + default=None, + description="Additional filters for registrants (e.g. {'is_group': true})", + ) + variables: list[str] | None = Field( + default=None, + description="List of statistic names to compute (defaults to GIS-published statistics)", + ) + group_by: list[str] | None = Field( + default=None, + description="List of dimension names for demographic breakdown (e.g. ['gender'])", + ) + population_filter: dict | None = Field( + default=None, + description=( + "Filter registrants by program membership or CEL expression. Example: {'program': 'HCP', 'mode': 'and'}" + ), + ) + + +class ProximityStatisticsInputs(BaseModel): + """Inputs for the proximity-statistics process.""" + + reference_points: list[dict] = Field( + ..., + description="Reference locations as lon/lat points: [{'longitude': 100.5, 'latitude': 0.5}]", + ) + radius_km: float = Field( + ..., + gt=0, + le=500, + description="Search radius in kilometres", + ) + relation: Literal["within", "beyond"] = Field( + default="within", + description="'within' returns registrants inside the radius; 'beyond' returns those outside", + ) + filters: dict | None = Field(default=None, description="Additional filters for registrants") + variables: list[str] | None = Field(default=None, description="List of statistic names to compute") + group_by: list[str] | None = Field(default=None, description="Demographic breakdown dimensions") + population_filter: dict | None = Field(default=None, description="Population program/CEL filter") + + +class ExecuteRequest(BaseModel): + """Request body for POST /processes/{id}/execution.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "inputs": { + "geometry": { + "type": "Polygon", + "coordinates": [[[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]]], + }, + "variables": ["total_households", "total_individuals"], + }, + }, + { + "inputs": { + "geometry": [ + { + "id": "zone_1", + "value": { + "type": "Polygon", + "coordinates": [ + [[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]] + ], + }, + }, + ], + }, + }, + { + "inputs": { + "reference_points": [{"longitude": 100.5, "latitude": 0.5}], + "radius_km": 10.0, + }, + }, + ], + }, + ) + + inputs: dict = polymorphic_body( + SpatialStatisticsInputs, + ProximityStatisticsInputs, + description="Process input values. Structure depends on the process being executed.", + ) + outputs: dict | None = Field(default=None, description="Requested output values") + response: Literal["raw", "document"] | None = Field( + default=None, + description="Response type: raw or document", + ) + + +class StatusInfo(BaseModel): + """OGC API job status information.""" + + model_config = ConfigDict( + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "jobID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "type": "process", + "processID": "spatial-statistics", + "status": "successful", + "created": "2024-06-15T10:30:00Z", + "started": "2024-06-15T10:30:01Z", + "finished": "2024-06-15T10:30:05Z", + "progress": 100, + "links": [ + { + "href": "/api/v2/spp/gis/ogc/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "rel": "self", + "type": "application/json", + }, + { + "href": "/api/v2/spp/gis/ogc/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/results", + "rel": "results", + "type": "application/json", + }, + ], + }, + ], + }, + ) + + jobID: str = Field(..., alias="jobID", description="Unique job identifier") # noqa: N815 + type: str = Field(default="process", description="Resource type") + processID: str | None = Field( # noqa: N815 + default=None, + alias="processID", + description="Identifier of the process that created this job", + ) + status: str = Field( + ..., + description="Job status: accepted, running, successful, failed, or dismissed", + ) + message: str | None = Field(default=None, description="Status message or error detail") + created: str | None = Field(default=None, description="ISO 8601 creation datetime") + started: str | None = Field(default=None, description="ISO 8601 start datetime") + finished: str | None = Field(default=None, description="ISO 8601 finish datetime") + updated: str | None = Field(default=None, description="ISO 8601 last updated datetime") + progress: int | None = Field(default=None, description="Completion percentage (0-100)") + links: list[OGCLink] = Field(default_factory=list, description="Related links") + + +class JobList(BaseModel): + """List of jobs.""" + + jobs: list[StatusInfo] = Field(..., description="Jobs") + + +class SingleStatisticsResult(BaseModel): + """Result of a spatial statistics query for a single geometry.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "total_count": 1250, + "query_method": "coordinates", + "areas_matched": 3, + "statistics": { + "total_households": {"value": 312, "suppressed": False}, + "total_individuals": {"value": 1250, "suppressed": False}, + }, + "breakdown": { + "1": {"count": 620, "labels": {"gender": {"value": "1", "display": "Male"}}}, + "2": {"count": 630, "labels": {"gender": {"value": "2", "display": "Female"}}}, + }, + "access_level": "aggregate", + "from_cache": False, + "computed_at": "2024-06-15T10:30:05Z", + }, + ], + }, + ) + + total_count: int = Field(..., description="Total number of matched records") + query_method: str = Field(..., description="Method used for the spatial query (coordinates or area_fallback)") + areas_matched: int = Field(..., description="Number of geographic areas matched (0 if using coordinates)") + statistics: dict = Field( + ..., + description=( + "Computed statistics by indicator name. Each value is an object with 'value' and 'suppressed' boolean." + ), + ) + breakdown: dict | None = Field( + default=None, + description=( + "Demographic breakdown by dimension combinations (e.g. gender, age). " + "Map from cell ID to object with 'count' and 'labels'." + ), + ) + access_level: str | None = Field(default=None, description="Data access level applied (aggregate/individual)") + from_cache: bool = Field(default=False, description="Whether the result was served from cache") + computed_at: str | None = Field(default=None, description="ISO 8601 datetime of computation") + + +class BatchResultItem(BaseModel): + """Statistics result for a single geometry within a batch request.""" + + id: str = Field(..., description="Geometry identifier from the request") + total_count: int = Field(..., description="Total number of matched records") + query_method: str = Field(..., description="Method used for the spatial query (coordinates or area_fallback)") + areas_matched: int = Field(..., description="Number of geographic areas matched (0 if using coordinates)") + statistics: dict = Field( + ..., + description=( + "Computed statistics for this geometry. Map of indicator name to " + "object with 'value' and 'suppressed' boolean." + ), + ) + breakdown: dict | None = Field(default=None, description="Demographic breakdown by dimension") + access_level: str | None = Field(default=None, description="Data access level applied (aggregate/individual)") + from_cache: bool = Field(default=False, description="Whether this result was served from cache") + computed_at: str | None = Field(default=None, description="ISO 8601 datetime of computation") + error: str | None = Field(default=None, description="Error message if this item failed") + + +class BatchSummary(BaseModel): + """Aggregate summary across all geometries in a batch request.""" + + total_count: int = Field(..., description="Total number of unique matched records across all geometries") + geometries_queried: int = Field(..., description="Number of geometries successfully queried") + geometries_failed: int = Field(default=0, description="Number of geometries that failed") + statistics: dict = Field( + ..., + description="Aggregated statistics (deduplicated) across all geometries.", + ) + breakdown: dict | None = Field(default=None, description="Aggregated demographic breakdown") + access_level: str | None = Field(default=None, description="Data access level applied") + from_cache: bool = Field(default=False, description="Whether all results were served from cache") + computed_at: str | None = Field(default=None, description="ISO 8601 datetime of computation") + + +class BatchStatisticsResult(BaseModel): + """Result of a spatial statistics query for multiple geometries.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "results": [ + { + "id": "zone_1", + "total_count": 800, + "query_method": "coordinates", + "areas_matched": 2, + "statistics": {"total_individuals": {"value": 800, "suppressed": False}}, + "access_level": "aggregate", + "from_cache": False, + "computed_at": "2024-06-15T10:30:05Z", + }, + { + "id": "zone_2", + "total_count": 450, + "query_method": "area_fallback", + "areas_matched": 1, + "statistics": {"total_individuals": {"value": 450, "suppressed": False}}, + "access_level": "aggregate", + "from_cache": False, + "computed_at": "2024-06-15T10:30:06Z", + }, + ], + "summary": { + "total_count": 1250, + "geometries_queried": 2, + "geometries_failed": 0, + "statistics": {"total_individuals": {"value": 1250, "suppressed": False}}, + "access_level": "aggregate", + "from_cache": False, + "computed_at": "2024-06-15T10:30:06Z", + }, + }, + ], + }, + ) + + results: list[BatchResultItem] = Field(..., description="Per-geometry results") + summary: BatchSummary = Field(..., description="Aggregate summary across all geometries") + + +class ProximityResult(BaseModel): + """Result of a proximity-based spatial statistics query.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "total_count": 340, + "query_method": "coordinates", + "areas_matched": 0, + "reference_points_count": 5, + "radius_km": 50.0, + "relation": "within", + "statistics": { + "total_households": {"value": 85, "suppressed": False}, + "total_individuals": {"value": 340, "suppressed": False}, + }, + "access_level": "aggregate", + "from_cache": False, + "computed_at": "2024-06-15T10:30:05Z", + }, + ], + }, + ) + + total_count: int = Field(..., description="Total number of matched records") + query_method: str = Field(..., description="Method used for the spatial query (coordinates or area_fallback)") + areas_matched: int = Field(..., description="Number of geographic areas matched (0 if using coordinates)") + reference_points_count: int = Field(..., description="Number of reference points used") + radius_km: float = Field(..., description="Search radius in kilometres") + relation: str = Field(..., description="Spatial relation used (within, beyond)") + statistics: dict = Field( + ..., + description=( + "Computed statistics by indicator. Map of indicator name to object with 'value' and 'suppressed' flag." + ), + ) + breakdown: dict | None = Field(default=None, description="Demographic breakdown by dimension") + access_level: str | None = Field(default=None, description="Data access level applied") + from_cache: bool = Field(default=False, description="Whether the result was served from cache") + computed_at: str | None = Field(default=None, description="ISO 8601 datetime of computation") + + +BatchGeometryItem.model_rebuild() +SpatialStatisticsInputs.model_rebuild() +ProximityStatisticsInputs.model_rebuild() +ExecuteRequest.model_rebuild() +StatusInfo.model_rebuild() +BatchResultItem.model_rebuild() +BatchSummary.model_rebuild() +BatchStatisticsResult.model_rebuild() +ProximityResult.model_rebuild() diff --git a/spp_api_v2_gis/schemas/statistics.py b/spp_api_v2_gis/schemas/statistics.py index 6184a9988..6d725475b 100644 --- a/spp_api_v2_gis/schemas/statistics.py +++ b/spp_api_v2_gis/schemas/statistics.py @@ -1,14 +1,27 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Pydantic schemas for statistics discovery API.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class IndicatorInfo(BaseModel): """Information about a single published statistic.""" - name: str = Field(..., description="Technical name (e.g., 'children_under_5')") - label: str = Field(..., description="Display label (e.g., 'Children Under 5')") + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "name": "total_households", + "label": "Total Households", + "description": "Total number of households in the area", + "format": "count", + }, + ], + }, + ) + + name: str = Field(..., description="Technical name (e.g., 'total_households')") + label: str = Field(..., description="Display label (e.g., 'Total Households')") description: str | None = Field(default=None, description="Detailed description") format: str = Field(..., description="Aggregation format (count, sum, avg, percent, ratio, currency)") unit: str | None = Field(default=None, description="Unit of measurement") @@ -17,6 +30,25 @@ class IndicatorInfo(BaseModel): class IndicatorCategoryInfo(BaseModel): """Information about a category of statistics.""" + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "code": "demographics", + "name": "Demographics", + "icon": "fa-users", + "statistics": [ + { + "name": "total_individuals", + "label": "Total Individuals", + "format": "count", + }, + ], + }, + ], + }, + ) + code: str = Field(..., description="Category code (e.g., 'demographics')") name: str = Field(..., description="Display name (e.g., 'Demographics')") icon: str | None = Field(default=None, description="Font Awesome icon class") diff --git a/spp_api_v2_gis/security/ir.model.access.csv b/spp_api_v2_gis/security/ir.model.access.csv index 97dd8b917..74e6b735d 100644 --- a/spp_api_v2_gis/security/ir.model.access.csv +++ b/spp_api_v2_gis/security/ir.model.access.csv @@ -1 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_gis_process_job_admin,spp.gis.process.job admin,model_spp_gis_process_job,base.group_system,1,1,1,1 diff --git a/spp_api_v2_gis/services/__init__.py b/spp_api_v2_gis/services/__init__.py index a09978c48..8304fab15 100644 --- a/spp_api_v2_gis/services/__init__.py +++ b/spp_api_v2_gis/services/__init__.py @@ -3,14 +3,7 @@ from . import export_service from . import layers_service from . import ogc_service +from . import process_execution +from . import process_registry from . import qml_template_service from . import spatial_query_service - -__all__ = [ - "catalog_service", - "export_service", - "layers_service", - "ogc_service", - "qml_template_service", - "spatial_query_service", -] diff --git a/spp_api_v2_gis/services/layers_service.py b/spp_api_v2_gis/services/layers_service.py index ffdf0d9f5..28d8c2d84 100644 --- a/spp_api_v2_gis/services/layers_service.py +++ b/spp_api_v2_gis/services/layers_service.py @@ -379,20 +379,20 @@ def _fetch_layer_features(self, layer, include_geometry, limit=None, offset=0, b geo_value = getattr(record, geo_field_name) if geo_value: try: - # Try parsing as JSON first (GeoJSON format) - geometry = json.loads(geo_value) - feature["geometry"] = geometry - except (json.JSONDecodeError, TypeError): - # Try parsing as WKT - try: - from shapely import wkt - - shape = wkt.loads(geo_value) - feature["geometry"] = shape.__geo_interface__ - except ImportError: - _logger.warning("shapely not available for WKT parsing") - except Exception as e: - _logger.warning("Failed to parse geometry: %s", e) + # GeoField returns Shapely geometry objects + if hasattr(geo_value, "__geo_interface__"): + feature["geometry"] = geo_value.__geo_interface__ + else: + # Fallback for string values (GeoJSON or WKT) + try: + feature["geometry"] = json.loads(geo_value) + except (json.JSONDecodeError, TypeError): + from shapely import wkt + + shape = wkt.loads(geo_value) + feature["geometry"] = shape.__geo_interface__ + except Exception as e: + _logger.warning("Failed to parse geometry: %s", e) features.append(feature) @@ -500,6 +500,12 @@ def _get_report_feature_by_id(self, report_code, feature_id): # Build properties has_data = data.raw_value is not None + + # Look up threshold range for the bucket + sorted_thresholds = report.threshold_ids.sorted("sequence") + threshold_ranges = {i: (t.min_value, t.max_value) for i, t in enumerate(sorted_thresholds)} + bucket_range = threshold_ranges.get(data.bucket_index, (None, None)) + properties = { "area_id": data.area_id.id, "area_code": data.area_code, @@ -510,6 +516,13 @@ def _get_report_feature_by_id(self, report_code, feature_id): "normalized_value": data.normalized_value, "display_value": data.display_value if has_data else "No Data", "record_count": data.record_count, + "bucket": { + "index": data.bucket_index, + "color": data.bucket_color, + "label": data.bucket_label, + "min_value": bucket_range[0], + "max_value": bucket_range[1], + }, } # Build geometry @@ -581,15 +594,20 @@ def _get_layer_feature_by_id(self, layer_id, feature_id): geo_value = getattr(record, geo_field_name) if geo_value: try: - geometry = json.loads(geo_value) - except (json.JSONDecodeError, TypeError): - try: - from shapely import wkt + # GeoField returns Shapely geometry objects + if hasattr(geo_value, "__geo_interface__"): + geometry = geo_value.__geo_interface__ + else: + # Fallback for string values (GeoJSON or WKT) + try: + geometry = json.loads(geo_value) + except (json.JSONDecodeError, TypeError): + from shapely import wkt - shape = wkt.loads(geo_value) - geometry = shape.__geo_interface__ - except (ImportError, Exception) as e: - _logger.warning("Failed to parse geometry: %s", e) + shape = wkt.loads(geo_value) + geometry = shape.__geo_interface__ + except Exception as e: + _logger.warning("Failed to parse geometry: %s", e) # nosemgrep: odoo-expose-database-id return { diff --git a/spp_api_v2_gis/services/ogc_service.py b/spp_api_v2_gis/services/ogc_service.py index ea8761157..b32179e44 100644 --- a/spp_api_v2_gis/services/ogc_service.py +++ b/spp_api_v2_gis/services/ogc_service.py @@ -5,9 +5,12 @@ calls, producing OGC-compliant responses for GovStack GIS BB compliance. """ +import json import logging import re +from psycopg2 import sql + from odoo.exceptions import MissingError from .catalog_service import CatalogService @@ -15,13 +18,32 @@ _logger = logging.getLogger(__name__) -# OGC API - Features conformance classes +# OGC API conformance classes (Features + Processes per OGC API Common Part 2) CONFORMANCE_CLASSES = [ + # OGC API - Features "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", + # OGC API - Processes + "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/ogc-process-description", + "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/json", + "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/job-list", + "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/dismiss", + # OGC API - Features Part 4 (Create/Replace/Delete) + "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete", ] +# Collection ID constants +GEOFENCES_COLLECTION_ID = "geofences" +INCIDENTS_COLLECTION_ID = "incidents" + +# Writable collections (OGC Features Part 4) +WRITABLE_COLLECTIONS = {GEOFENCES_COLLECTION_ID, INCIDENTS_COLLECTION_ID} + +# Allowed geometry types for geofence creation/update +_ALLOWED_GEOFENCE_GEOMETRY_TYPES = {"Polygon", "MultiPolygon"} + class OGCService: """Adapter service for OGC API - Features. @@ -75,6 +97,12 @@ def get_landing_page(self): "type": "application/json", "title": "Feature collections", }, + { + "href": f"{ogc_base}/processes", + "rel": "http://www.opengis.net/def/rel/ogc/1.0/processes", + "type": "application/json", + "title": "Processes", + }, { "href": f"{self.base_url}/openapi.json", "rel": "service-desc", @@ -122,6 +150,14 @@ def get_collections(self): collection = self._data_layer_to_collection(layer) collections.append(collection) + # Add geofences as a static collection + geofence_collection = self._geofences_to_collection() + collections.append(geofence_collection) + + # Add incidents as a static collection + incidents_collection = self._incidents_to_collection() + collections.append(incidents_collection) + return { "links": [ { @@ -153,6 +189,14 @@ def get_collection(self, collection_id): """ layer_type, layer_id, admin_level = self._parse_collection_id(collection_id) + # Geofences collection + if layer_type == "geofence": + return self._geofences_to_collection() + + # Incidents collection + if layer_type == "incident": + return self._incidents_to_collection() + # Data layer lookup if layer_type == "layer": catalog = self.catalog_service.get_catalog() @@ -180,6 +224,13 @@ def get_collection_items( limit=1000, offset=0, bbox=None, + geofence_type=None, + active=None, + datetime_param=None, + event=None, + severity=None, + incident_status=None, + incident_code=None, ): """Get features from a collection. @@ -193,6 +244,13 @@ def get_collection_items( limit: Maximum features to return (default 1000) offset: Pagination offset bbox: Bounding box filter [west, south, east, north] + geofence_type: Filter by geofence type (geofences collection only) + active: Include archived geofences (geofences collection only) + datetime_param: OGC datetime filter string (incidents/geofences) + event: Filter by event type (incidents collection only) + severity: Filter by severity code (incidents collection only) + incident_status: Filter by status (incidents collection only) + incident_code: Filter geofences by incident code Returns: dict: GeoJSON FeatureCollection with OGC pagination links @@ -202,6 +260,30 @@ def get_collection_items( """ layer_type, layer_id, admin_level = self._parse_collection_id(collection_id) + # Incident collection: handle separately + if layer_type == "incident": + return self._get_incident_items( + limit=limit, + offset=offset, + bbox=bbox, + datetime_param=datetime_param, + event=event, + severity=severity, + status=incident_status, + ) + + # Geofence collection: handle separately + if layer_type == "geofence": + return self._get_geofence_items( + limit=limit, + offset=offset, + bbox=bbox, + geofence_type=geofence_type, + active=active, + datetime_param=datetime_param, + incident_code=incident_code, + ) + # For bare report codes, default to base_area_level if layer_type == "report" and admin_level is None: admin_level = self._get_report_base_level(layer_id) @@ -300,6 +382,14 @@ def get_collection_item(self, collection_id, feature_id): """ layer_type, layer_id, _admin_level = self._parse_collection_id(collection_id) + # Geofence: look up by UUID + if layer_type == "geofence": + return self._get_geofence_item(feature_id) + + # Incident: look up by UUID + if layer_type == "incident": + return self._get_incident_item(feature_id) + feature = self.layers_service.get_feature_by_id( layer_id=layer_id, feature_id=feature_id, @@ -434,7 +524,7 @@ def _data_layer_to_collection(self, layer): } ) - return { + collection = { "id": collection_id, "title": layer["name"], "description": f"Data layer from {layer.get('source_model', 'unknown')}", @@ -443,6 +533,60 @@ def _data_layer_to_collection(self, layer): "links": links, } + bbox = self._compute_data_layer_bbox(layer["id"]) + if bbox: + collection["extent"] = { + "spatial": { + "bbox": [bbox], + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + } + + return collection + + def _compute_data_layer_bbox(self, layer_id): + """Compute spatial bounding box for a data layer via PostGIS. + + Args: + layer_id: Data layer database ID + + Returns: + list: [west, south, east, north] or None if no geometry + """ + try: + # nosemgrep: odoo-sudo-without-context + Layer = self.env["spp.gis.data.layer"].sudo() + layer = Layer.browse(int(layer_id)) + if not layer.exists() or not layer.geo_field_id or not layer.model_name: + return None + + table_name = self.env[layer.model_name]._table + column_name = layer.geo_field_id.name + + # Identifiers come from model metadata; quote them safely via + # psycopg2.sql to avoid any SQL injection risk. + query = sql.SQL( + """ + SELECT + ST_XMin(ST_Extent({col})), + ST_YMin(ST_Extent({col})), + ST_XMax(ST_Extent({col})), + ST_YMax(ST_Extent({col})) + FROM {table} + WHERE {col} IS NOT NULL + """ + ).format( + col=sql.Identifier(column_name), + table=sql.Identifier(table_name), + ) + self.env.cr.execute(query) + row = self.env.cr.fetchone() + if row and row[0] is not None: + return [row[0], row[1], row[2], row[3]] + except Exception as e: + _logger.warning("Failed to compute bbox for data layer %s: %s", layer_id, e) + return None + def _compute_report_bbox(self, report_code): """Compute spatial bounding box for a report's areas via PostGIS. @@ -478,6 +622,7 @@ def _parse_collection_id(self, collection_id): """Parse collection ID into layer type, layer ID, and admin level. Supported formats: + - "geofences" → ("geofence", None, None) - "layer_{id}" → ("layer", "{id}", None) - "{code}_adm{N}" → ("report", "{code}", N) - "{code}" → ("report", "{code}", None) @@ -488,6 +633,12 @@ def _parse_collection_id(self, collection_id): Returns: tuple: (layer_type, layer_id, admin_level) """ + if collection_id == GEOFENCES_COLLECTION_ID: + return "geofence", None, None + + if collection_id == INCIDENTS_COLLECTION_ID: + return "incident", None, None + if collection_id.startswith("layer_"): return "layer", collection_id[6:], None @@ -497,6 +648,461 @@ def _parse_collection_id(self, collection_id): return "report", collection_id, None + # --- Geofence collection methods --- + + def _geofences_to_collection(self): + """Build OGC collection metadata for geofences. + + Returns: + dict: OGC CollectionInfo for geofences + """ + ogc_base = f"{self.base_url}/gis/ogc" + collection = { + "id": GEOFENCES_COLLECTION_ID, + "title": "Geofences", + "description": "User-defined geographic areas of interest", + "itemType": "feature", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], + "links": [ + { + "href": f"{ogc_base}/collections/{GEOFENCES_COLLECTION_ID}", + "rel": "self", + "type": "application/json", + "title": "Collection metadata", + }, + { + "href": f"{ogc_base}/collections/{GEOFENCES_COLLECTION_ID}/items", + "rel": "items", + "type": "application/geo+json", + "title": "Feature items", + }, + ], + } + + # Advertise geometry type so QGIS recognizes this as a spatial layer + # even when the collection is empty + collection["storageCrs"] = "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + collection["geometryDimension"] = 2 + + bbox = self._compute_geofence_bbox() + if bbox: + collection["extent"] = { + "spatial": { + "bbox": [bbox], + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + } + + return collection + + def _compute_geofence_bbox(self): + """Compute spatial bounding box from all active geofence geometries. + + Returns: + list: [west, south, east, north] or None if no geometries + """ + try: + self.env.cr.execute( + """ + SELECT + ST_XMin(ST_Extent(geometry::geometry)), + ST_YMin(ST_Extent(geometry::geometry)), + ST_XMax(ST_Extent(geometry::geometry)), + ST_YMax(ST_Extent(geometry::geometry)) + FROM spp_gis_geofence + WHERE active = TRUE AND geometry IS NOT NULL + """ + ) + row = self.env.cr.fetchone() + if row and row[0] is not None: + return [row[0], row[1], row[2], row[3]] + except Exception as e: + _logger.warning("Failed to compute bbox for geofences: %s", e) + return None + + def _get_geofence_items( + self, limit=1000, offset=0, bbox=None, geofence_type=None, active=None, datetime_param=None, incident_code=None + ): + """Get geofence features as a GeoJSON FeatureCollection. + + Args: + limit: Maximum features to return + offset: Pagination offset + bbox: Bounding box filter [west, south, east, north] + geofence_type: Filter by geofence type + active: Include archived (default: only active) + datetime_param: OGC datetime filter (filters on create_date) + incident_code: Filter by linked incident code + + Returns: + dict: GeoJSON FeatureCollection with OGC pagination + """ + # nosemgrep: odoo-sudo-without-context + Geofence = self.env["spp.gis.geofence"].sudo() + + domain = [] + if active is not None: + domain.append(("active", "=", active)) + else: + domain.append(("active", "=", True)) + + if geofence_type: + domain.append(("geofence_type", "=", geofence_type)) + + if incident_code: + # nosemgrep: odoo-sudo-without-context + incident = self.env["spp.hazard.incident"].sudo().search([("code", "=", incident_code)], limit=1) + if incident: + domain.append(("incident_id", "=", incident.id)) + else: + # No matching incident: return empty + domain.append(("id", "=", 0)) + + if datetime_param: + dt_start, dt_end = self._parse_datetime_param(datetime_param) + if dt_start: + domain.append(("create_date", ">=", dt_start)) + if dt_end: + domain.append(("create_date", "<=", dt_end)) + + if bbox: + bbox_geojson = self.layers_service._bbox_to_geojson(bbox) + domain.append(("geometry", "gis_intersects", bbox_geojson)) + + total_count = Geofence.search_count(domain) + geofences = Geofence.search(domain, limit=limit, offset=offset, order="name") + + # Prefetch related fields to avoid N+1 queries + geofences.mapped("tag_ids.name") + geofences.mapped("create_uid.name") + + features = [rec.to_geojson() for rec in geofences] + + # Build OGC response with pagination links + ogc_base = f"{self.base_url}/gis/ogc" + items_url = f"{ogc_base}/collections/{GEOFENCES_COLLECTION_ID}/items" + + links = [ + { + "href": f"{items_url}?limit={limit}&offset={offset}", + "rel": "self", + "type": "application/geo+json", + "title": "This page", + }, + { + "href": f"{ogc_base}/collections/{GEOFENCES_COLLECTION_ID}", + "rel": "collection", + "type": "application/json", + "title": "Collection metadata", + }, + ] + + if offset + limit < total_count: + links.append( + { + "href": f"{items_url}?limit={limit}&offset={offset + limit}", + "rel": "next", + "type": "application/geo+json", + "title": "Next page", + } + ) + + if offset > 0: + links.append( + { + "href": f"{items_url}?limit={limit}&offset={max(0, offset - limit)}", + "rel": "prev", + "type": "application/geo+json", + "title": "Previous page", + } + ) + + return { + "type": "FeatureCollection", + "features": features, + "links": links, + "numberMatched": total_count, + "numberReturned": len(features), + } + + def _get_geofence_item(self, feature_id): + """Get a single geofence by UUID. + + Args: + feature_id: Geofence UUID + + Returns: + dict: GeoJSON Feature with OGC links + + Raises: + MissingError: If geofence not found or inactive + """ + # nosemgrep: odoo-sudo-without-context + geofence = ( + # nosemgrep: odoo-sudo-without-context (system-context read; auth at router) + self.env["spp.gis.geofence"].sudo().search([("uuid", "=", feature_id), ("active", "=", True)], limit=1) + ) + if not geofence: + raise MissingError(f"Feature {feature_id} not found in collection geofences") + + feature = geofence.to_geojson() + + # Add OGC links + ogc_base = f"{self.base_url}/gis/ogc" + feature.setdefault("links", []) + feature["links"].append( + { + "href": f"{ogc_base}/collections/{GEOFENCES_COLLECTION_ID}/items/{feature_id}", + "rel": "self", + "type": "application/geo+json", + } + ) + feature["links"].append( + { + "href": f"{ogc_base}/collections/{GEOFENCES_COLLECTION_ID}", + "rel": "collection", + "type": "application/json", + } + ) + return feature + + # --- Geofence write methods (OGC Features Part 4) --- + + def _validate_geofence_input(self, feature_input): + """Validate and extract fields from a GeoJSON Feature for geofence create/replace. + + Args: + feature_input: GeoJSON Feature dict + + Returns: + tuple: (geometry_dict, properties_dict, name, geofence_type) + + Raises: + ValueError: If validation fails + """ + geometry = feature_input.get("geometry") + properties = feature_input.get("properties", {}) + + if not geometry: + raise ValueError("Geometry is required") + geom_type = geometry.get("type", "") + if geom_type not in _ALLOWED_GEOFENCE_GEOMETRY_TYPES: + raise ValueError(f"Geometry type '{geom_type}' is not allowed. Must be Polygon or MultiPolygon.") + + name = properties.get("name") + if not name: + raise ValueError("Property 'name' is required") + + geofence_type = properties.get("geofence_type", "custom") + self._validate_geofence_type(geofence_type) + + return geometry, properties, name, geofence_type + + def create_geofence_feature(self, feature_input): + """Create a geofence from a GeoJSON Feature. + + Args: + feature_input: GeoJSON Feature dict with geometry and properties + + Returns: + dict: {"feature": GeoJSON Feature, "location": URL string} + + Raises: + ValueError: If input validation fails + """ + geometry, properties, name, geofence_type = self._validate_geofence_input(feature_input) + + # Build create vals + vals = { + "name": name, + "geometry": json.dumps(geometry), + "geofence_type": geofence_type, + "created_from": "api", + } + + if properties.get("description"): + vals["description"] = properties["description"] + + # Resolve tags + if properties.get("tags"): + vals["tag_ids"] = self._resolve_geofence_tags(properties["tags"]) + + # Resolve incident_code + if properties.get("incident_code"): + vals["incident_id"] = self._resolve_incident_code(properties["incident_code"]) + + # nosemgrep: odoo-sudo-without-context + geofence = self.env["spp.gis.geofence"].sudo().create(vals) + + feature = geofence.to_geojson() + ogc_base = f"{self.base_url}/gis/ogc" + location = f"{ogc_base}/collections/{GEOFENCES_COLLECTION_ID}/items/{geofence.uuid}" + + return {"feature": feature, "location": location} + + def replace_geofence_feature(self, feature_id, feature_input): + """Replace a geofence (PUT semantics: full replacement). + + Args: + feature_id: Geofence UUID + feature_input: GeoJSON Feature dict + + Returns: + dict: Updated GeoJSON Feature + + Raises: + MissingError: If geofence not found + ValueError: If input validation fails + """ + # nosemgrep: odoo-sudo-without-context + geofence = ( + # nosemgrep: odoo-sudo-without-context (system-context read; auth at router) + self.env["spp.gis.geofence"].sudo().search([("uuid", "=", feature_id), ("active", "=", True)], limit=1) + ) + if not geofence: + raise MissingError(f"Feature {feature_id} not found in collection geofences") + + geometry, properties, name, geofence_type = self._validate_geofence_input(feature_input) + + # Build write vals (full replacement) + vals = { + "name": name, + "geometry": json.dumps(geometry), + "geofence_type": geofence_type, + "description": properties.get("description", False), + } + + # Resolve tags (replace all) + if "tags" in properties: + vals["tag_ids"] = self._resolve_geofence_tags(properties["tags"]) + else: + # PUT is full replacement: clear tags if not provided + from odoo import Command + + vals["tag_ids"] = [Command.clear()] + + # Resolve incident_code + if properties.get("incident_code"): + vals["incident_id"] = self._resolve_incident_code(properties["incident_code"]) + else: + vals["incident_id"] = False + + geofence.write(vals) + + return geofence.to_geojson() + + def delete_geofence_feature(self, feature_id): + """Soft delete a geofence (set active=False). + + Args: + feature_id: Geofence UUID + + Raises: + MissingError: If geofence not found + ValueError: If geofence is referenced by a program + """ + # nosemgrep: odoo-sudo-without-context + geofence = ( + # nosemgrep: odoo-sudo-without-context (system-context read; auth at router) + self.env["spp.gis.geofence"].sudo().search([("uuid", "=", feature_id), ("active", "=", True)], limit=1) + ) + if not geofence: + raise MissingError(f"Feature {feature_id} not found in collection geofences") + + self._check_geofence_not_referenced(geofence) + geofence.write({"active": False}) + + def _check_geofence_not_referenced(self, geofence): + """Block deletion if the geofence is linked to any program. + + Checks both active and inactive programs since geofences serve as + historical records of a program's geographic scope. + + Args: + geofence: spp.gis.geofence record + + Raises: + ValueError: If geofence is referenced by one or more programs + """ + if "spp.program" not in self.env or "geofence_ids" not in self.env["spp.program"]._fields: + return + # sudo: deletion-integrity check must see every program (incl. archived + # and those outside the caller's record rules) that references the + # geofence, otherwise a referenced geofence could be wrongly deleted. + # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models + program_model = self.env["spp.program"].sudo() + programs = program_model.with_context(active_test=False).search([("geofence_ids", "in", geofence.ids)], limit=5) + if programs: + names = ", ".join(programs.mapped("name")[:5]) + raise ValueError(f"Cannot delete geofence: referenced by program(s): {names}") + + def _validate_geofence_type(self, geofence_type): + """Validate geofence_type against available selection values. + + Args: + geofence_type: Type string to validate + + Raises: + ValueError: If type is not valid, with message listing valid options + """ + # nosemgrep: odoo-sudo-without-context + field = self.env["spp.gis.geofence"].sudo()._fields["geofence_type"] + valid_values = [key for key, _label in field.selection] + if geofence_type not in valid_values: + raise ValueError(f"Invalid geofence_type '{geofence_type}'. Valid options: {', '.join(valid_values)}") + + def _resolve_geofence_tags(self, tag_names): + """Resolve tag names to Many2many write commands (search-or-create). + + Batch-searches existing tags first to avoid N+1 queries. + + Args: + tag_names: List of tag name strings + + Returns: + list: Odoo Command list for tag_ids field + """ + from odoo import Command + + if not tag_names: + return [Command.clear()] + + # nosemgrep: odoo-sudo-without-context + Tag = self.env["spp.gis.geofence.tag"].sudo() + + # Batch search: one query for all existing tags + existing = Tag.search([("name", "in", tag_names)]) + existing_by_name = {t.name: t.id for t in existing} + + # Create only the missing tags + tag_ids = [] + for name in tag_names: + if name in existing_by_name: + tag_ids.append(existing_by_name[name]) + else: + new_tag = Tag.create({"name": name}) + tag_ids.append(new_tag.id) + + return [Command.set(tag_ids)] + + def _resolve_incident_code(self, incident_code): + """Resolve incident code to incident record ID. + + Args: + incident_code: Hazard incident code string + + Returns: + int: Incident record ID + + Raises: + ValueError: If incident not found + """ + # nosemgrep: odoo-sudo-without-context + incident = self.env["spp.hazard.incident"].sudo().search([("code", "=", incident_code)], limit=1) + if not incident: + raise ValueError(f"Incident with code '{incident_code}' not found") + return incident.id + def _get_report_base_level(self, report_code): """Look up the base_area_level for a report by code. @@ -514,3 +1120,302 @@ def _get_report_base_level(self, report_code): if report: return report.base_area_level return None + + # --- Incident collection methods --- + + def _incidents_to_collection(self): + """Build OGC collection metadata for incidents. + + Returns: + dict: OGC CollectionInfo for incidents + """ + ogc_base = f"{self.base_url}/gis/ogc" + return { + "id": INCIDENTS_COLLECTION_ID, + "title": "Hazard Incidents", + "description": "Hazard incidents from external alert systems and internal reporting", + "itemType": "feature", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"], + "links": [ + { + "href": f"{ogc_base}/collections/{INCIDENTS_COLLECTION_ID}", + "rel": "self", + "type": "application/json", + "title": "Collection metadata", + }, + { + "href": f"{ogc_base}/collections/{INCIDENTS_COLLECTION_ID}/items", + "rel": "items", + "type": "application/geo+json", + "title": "Feature items", + }, + ], + "storageCrs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "geometryDimension": 2, + } + + def _get_incident_items( + self, limit=1000, offset=0, bbox=None, datetime_param=None, event=None, severity=None, status=None + ): + """Get incident features as a GeoJSON FeatureCollection. + + Args: + limit: Maximum features to return + offset: Pagination offset + bbox: Bounding box filter (filters through linked geofences) + datetime_param: OGC datetime filter on [effective, expires] + event: Filter by cap_event + severity: Filter by severity vocabulary code + status: Filter by incident status + + Returns: + dict: GeoJSON FeatureCollection with OGC pagination + """ + # nosemgrep: odoo-sudo-without-context + Incident = self.env["spp.hazard.incident"].sudo() + + domain = [] + + if event: + domain.append(("cap_event", "=ilike", event)) + + if severity: + # nosemgrep: odoo-sudo-without-context (system-context read; auth at router) + VocabCode = self.env["spp.vocabulary.code"].sudo() + severity_code = VocabCode.get_code("urn:oasis:names:tc:cap:severity", severity) + if severity_code: + domain.append(("severity_id", "=", severity_code.id)) + else: + domain.append(("id", "=", 0)) # No match + + if status: + domain.append(("status", "=", status)) + + if datetime_param: + dt_start, dt_end = self._parse_datetime_param(datetime_param) + # Temporal overlap: effective <= end AND (expires >= start OR expires IS NULL) + # Incidents with no effective date have no temporal extent and must not match. + if dt_end: + domain.append(("effective", "!=", False)) + domain.append(("effective", "<=", dt_end)) + if dt_start: + domain.append("|") + domain.append(("expires", ">=", dt_start)) + domain.append(("expires", "=", False)) + + if bbox: + # Filter through linked geofences + # nosemgrep: odoo-sudo-without-context + Geofence = self.env["spp.gis.geofence"].sudo() + bbox_geojson = self.layers_service._bbox_to_geojson(bbox) + geofences = Geofence.search( + [ + ("active", "=", True), + ("geofence_type", "=", "hazard_zone"), + ("geometry", "gis_intersects", bbox_geojson), + ] + ) + incident_ids = geofences.mapped("incident_id").ids + domain.append(("id", "in", incident_ids)) + + total_count = Incident.search_count(domain) + incidents = Incident.search(domain, limit=limit, offset=offset, order="start_date desc, name") + + # Prefetch related fields + incidents.mapped("severity_id.code") + incidents.mapped("cap_urgency_id.code") + incidents.mapped("cap_certainty_id.code") + incidents.mapped("cap_msg_type_id.code") + incidents.mapped("category_id.name") + + features = [rec.to_geojson() for rec in incidents] + + # Build OGC response with pagination links + ogc_base = f"{self.base_url}/gis/ogc" + items_url = f"{ogc_base}/collections/{INCIDENTS_COLLECTION_ID}/items" + + links = [ + { + "href": f"{items_url}?limit={limit}&offset={offset}", + "rel": "self", + "type": "application/geo+json", + "title": "This page", + }, + { + "href": f"{ogc_base}/collections/{INCIDENTS_COLLECTION_ID}", + "rel": "collection", + "type": "application/json", + "title": "Collection metadata", + }, + ] + + if offset + limit < total_count: + links.append( + { + "href": f"{items_url}?limit={limit}&offset={offset + limit}", + "rel": "next", + "type": "application/geo+json", + "title": "Next page", + } + ) + + if offset > 0: + links.append( + { + "href": f"{items_url}?limit={limit}&offset={max(0, offset - limit)}", + "rel": "prev", + "type": "application/geo+json", + "title": "Previous page", + } + ) + + return { + "type": "FeatureCollection", + "features": features, + "links": links, + "numberMatched": total_count, + "numberReturned": len(features), + } + + def _get_incident_item(self, feature_id): + """Get a single incident by UUID. + + Args: + feature_id: Incident UUID + + Returns: + dict: GeoJSON Feature with OGC links + + Raises: + MissingError: If incident not found + """ + # nosemgrep: odoo-sudo-without-context + incident = self.env["spp.hazard.incident"].sudo().search([("uuid", "=", feature_id)], limit=1) + if not incident: + raise MissingError(f"Feature {feature_id} not found in collection incidents") + + feature = incident.to_geojson() + + # Add OGC links + ogc_base = f"{self.base_url}/gis/ogc" + feature.setdefault("links", []) + feature["links"].append( + { + "href": f"{ogc_base}/collections/{INCIDENTS_COLLECTION_ID}/items/{feature_id}", + "rel": "self", + "type": "application/geo+json", + } + ) + feature["links"].append( + { + "href": f"{ogc_base}/collections/{INCIDENTS_COLLECTION_ID}", + "rel": "collection", + "type": "application/json", + } + ) + return feature + + def create_incident_feature(self, feature_input): + """Create an incident from a GeoJSON Feature. + + Delegates to spp.hazard.incident.create_from_alert(). + + Args: + feature_input: GeoJSON Feature dict with geometry and properties + + Returns: + dict: {"feature": GeoJSON Feature, "location": URL string} + + Raises: + ValueError: If input validation fails or duplicate detected + """ + geometry = feature_input.get("geometry") + properties = feature_input.get("properties", {}) + + if not geometry: + raise ValueError("Geometry is required for incident creation") + + # Duplicate detection: check source_alert_id + source_alert_id = properties.get("source_alert_id") + if source_alert_id: + # nosemgrep: odoo-sudo-without-context + existing = ( + # nosemgrep: odoo-sudo-without-context (system-context read; auth at router) + self.env["spp.hazard.incident"].sudo().search([("source_alert_id", "=", source_alert_id)], limit=1) + ) + if existing: + ogc_base = f"{self.base_url}/gis/ogc" + location = f"{ogc_base}/collections/{INCIDENTS_COLLECTION_ID}/items/{existing.uuid}" + raise DuplicateAlertError( + f"Incident with source_alert_id '{source_alert_id}' already exists", + location=location, + ) + + # nosemgrep: odoo-sudo-without-context + Incident = self.env["spp.hazard.incident"].sudo() + incident = Incident.create_from_alert(geometry, properties) + + feature = incident.to_geojson() + ogc_base = f"{self.base_url}/gis/ogc" + location = f"{ogc_base}/collections/{INCIDENTS_COLLECTION_ID}/items/{incident.uuid}" + + return {"feature": feature, "location": location} + + def replace_incident_feature(self, feature_id, feature_input): + """Update an incident (PUT semantics). + + Args: + feature_id: Incident UUID + feature_input: GeoJSON Feature dict + + Returns: + dict: Updated GeoJSON Feature + + Raises: + MissingError: If incident not found + """ + # nosemgrep: odoo-sudo-without-context + incident = self.env["spp.hazard.incident"].sudo().search([("uuid", "=", feature_id)], limit=1) + if not incident: + raise MissingError(f"Feature {feature_id} not found in collection incidents") + + geometry = feature_input.get("geometry") + properties = feature_input.get("properties", {}) + + incident.update_from_alert(geometry, properties) + + return incident.to_geojson() + + # --- Datetime parsing --- + + def _parse_datetime_param(self, datetime_str): + """Parse OGC datetime parameter into (start, end) tuple. + + Formats: + "2026-04-01T00:00:00Z" -> (instant, instant) + "2026-01-01/2026-06-01" -> (start, end) + "../2026-06-01" -> (None, end) + "2026-01-01/.." -> (start, None) + + Returns: + tuple: (start_str, end_str) - either may be None for open intervals + """ + if "/" in datetime_str: + parts = datetime_str.split("/", 1) + start = parts[0] if parts[0] != ".." else None + end = parts[1] if parts[1] != ".." else None + return start, end + # Single instant + return datetime_str, datetime_str + + +class DuplicateAlertError(ValueError): + """Raised when a POST arrives with a source_alert_id that already exists. + + Carries a location URL pointing to the existing resource so the router + can return 409 Conflict with a Location header. + """ + + def __init__(self, message, location=None): + super().__init__(message) + self.location = location diff --git a/spp_api_v2_gis/services/process_execution.py b/spp_api_v2_gis/services/process_execution.py new file mode 100644 index 000000000..117cf415f --- /dev/null +++ b/spp_api_v2_gis/services/process_execution.py @@ -0,0 +1,81 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Process execution logic shared between sync (router) and async (model) paths.""" + +import logging + +_logger = logging.getLogger(__name__) + + +def run_spatial_statistics(service, inputs, on_progress=None): + """Run spatial-statistics process and return results. + + Args: + service: SpatialQueryService instance + inputs: Validated process inputs dict + on_progress: Optional callback(completed_count) for batch progress tracking + + Returns: + dict: Statistics results (registrant_ids stripped) + """ + geometry = inputs.get("geometry") + filters = inputs.get("filters") + variables = inputs.get("variables") + group_by = inputs.get("group_by") + population_filter = inputs.get("population_filter") + + _logger.info( + "run_spatial_statistics: variables=%r, filters=%r, geometry_type=%s", + variables, + filters, + type(geometry).__name__, + ) + + if isinstance(geometry, list): + # Batch mode: geometry is a list of {id, value} dicts + geometries = [{"id": g["id"], "geometry": g["value"]} for g in geometry] + result = service.query_statistics_batch( + geometries=geometries, + filters=filters, + variables=variables, + group_by=group_by, + on_progress=on_progress, + population_filter=population_filter, + ) + for item in result.get("results", []): + item.pop("registrant_ids", None) + return result + + # Single geometry mode + result = service.query_statistics( + geometry=geometry, + filters=filters, + variables=variables, + group_by=group_by, + population_filter=population_filter, + ) + result.pop("registrant_ids", None) + return result + + +def run_proximity_statistics(service, inputs): + """Run proximity-statistics process and return results. + + Args: + service: SpatialQueryService instance + inputs: Validated process inputs dict + + Returns: + dict: Proximity statistics results (registrant_ids stripped) + """ + population_filter = inputs.get("population_filter") + result = service.query_proximity( + reference_points=inputs["reference_points"], + radius_km=inputs["radius_km"], + relation=inputs.get("relation", "within"), + filters=inputs.get("filters"), + variables=inputs.get("variables"), + group_by=inputs.get("group_by"), + population_filter=population_filter, + ) + result.pop("registrant_ids", None) + return result diff --git a/spp_api_v2_gis/services/process_registry.py b/spp_api_v2_gis/services/process_registry.py new file mode 100644 index 000000000..bde28d188 --- /dev/null +++ b/spp_api_v2_gis/services/process_registry.py @@ -0,0 +1,379 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Process registry for OGC API - Processes. + +Provides process definitions for spatial-statistics and proximity-statistics, +dynamically generating input schemas from spp.statistic records. +""" + +import logging + +_logger = logging.getLogger(__name__) + +# Process IDs +SPATIAL_STATISTICS = "spatial-statistics" +PROXIMITY_STATISTICS = "proximity-statistics" + +VALID_PROCESS_IDS = {SPATIAL_STATISTICS, PROXIMITY_STATISTICS} + +# Maximum geometries allowed per batch request +MAX_BATCH_GEOMETRIES = 100 + +# Default maximum reference points for proximity queries. +# Configurable via ir.config_parameter key "spp_gis.max_proximity_points". +DEFAULT_MAX_PROXIMITY_POINTS = 50000 + + +class ProcessRegistry: + """Registry of available OGC processes. + + Generates process descriptions dynamically from spp.statistic records, + so that available statistics are always in sync with the database. + """ + + def __init__(self, env): + self.env = env + + def list_processes(self): + """Return summary list of all available processes.""" + return [ + { + "id": SPATIAL_STATISTICS, + "title": "Spatial Statistics", + "description": "Compute aggregate registrant statistics within arbitrary polygons using PostGIS.", + "version": "1.0.0", + "jobControlOptions": ["sync-execute", "async-execute", "dismiss"], + }, + { + "id": PROXIMITY_STATISTICS, + "title": "Proximity Statistics", + "description": ( + "Compute aggregate registrant statistics within or beyond a given radius from reference points." + ), + "version": "1.0.0", + "jobControlOptions": ["sync-execute", "async-execute", "dismiss"], + }, + ] + + def get_process(self, process_id): + """Return full process description including input/output schemas. + + Returns None if process_id is not recognized. + """ + if process_id == SPATIAL_STATISTICS: + return self._build_spatial_statistics_description() + if process_id == PROXIMITY_STATISTICS: + return self._build_proximity_statistics_description() + return None + + def get_statistics_metadata(self): + """Get statistics metadata organized by category. + + Used by both process descriptions (for x-openspp-statistics extension) + and the GET /gis/statistics endpoint. + + Returns: + tuple: (variable_names, categories_metadata) + - variable_names: list of str (statistic names for enum) + - categories_metadata: list of dicts with category info + """ + # nosemgrep: odoo-sudo-without-context + Statistic = self.env["spp.indicator"].sudo() + stats_by_category = Statistic.get_published_by_category("gis") + + variable_names = [] + categories = [] + + for category_code, stat_records in stats_by_category.items(): + category_record = stat_records[0].category_id if stat_records else None + + stat_items = [] + for stat in stat_records: + config = stat.get_context_config("gis") + variable_names.append(stat.name) + stat_items.append( + { + "name": stat.name, + "label": config.get("label", stat.label), + "description": stat.description, + "format": config.get("format", stat.format), + "unit": stat.unit, + } + ) + + categories.append( + { + "code": category_code, + "name": category_record.name if category_record else category_code.replace("_", " ").title(), + "icon": getattr(category_record, "icon", None) if category_record else None, + "statistics": stat_items, + } + ) + + return variable_names, categories + + def _build_group_by_input(self): + """Build the group_by input definition with dynamic enum from active dimensions.""" + # nosemgrep: odoo-sudo-without-context + Dimension = self.env["spp.demographic.dimension"].sudo() + active_dimensions = Dimension.search([("active", "=", True)]) + + dimension_names = [dim.name for dim in active_dimensions] + dimension_metadata = [{"name": dim.name, "label": dim.label} for dim in active_dimensions] + + group_by_input = { + "title": "Disaggregation Dimensions", + "description": "Dimension names to break down results by. Maximum 3.", + "minOccurs": 0, + "schema": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 3, + }, + } + + if dimension_names: + group_by_input["schema"]["items"]["enum"] = dimension_names + + if dimension_metadata: + group_by_input["x-openspp-dimensions"] = dimension_metadata + + return group_by_input + + def _build_population_filter_input(self): + """Build population_filter input with dynamic enum from programs and expressions.""" + # TODO: Replace program ID with code field (see gis-analytics-enrichment.md Task 1) + # sudo: builds the API input enum from all program codes; read-only, + # and the API routers enforce authorization before reaching this builder. + # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models + Program = self.env["spp.program"].sudo() + programs = Program.search([]) + program_ids = [p.id for p in programs] + program_metadata = [{"id": p.id, "name": p.name} for p in programs] + + # nosemgrep: odoo-sudo-without-context + Expression = self.env["spp.cel.expression"].sudo() + expressions = Expression.search( + [ + ("expression_type", "=", "filter"), + ("code", "!=", False), + ] + ) + expression_codes = [e.code for e in expressions] + expression_metadata = [{"code": e.code, "name": e.name, "context_type": e.context_type} for e in expressions] + + population_filter = { + "title": "Population Filter", + "description": ( + "Filter registrants by program enrollment and/or eligibility criteria. " + "Use 'gap' mode to find eligible but not enrolled registrants." + ), + "minOccurs": 0, + "schema": { + "type": "object", + "properties": { + "program": { + # TODO: Replace with string type + code enum once spp.program has a code field + "type": "integer", + "description": "Program ID to filter by enrollment.", + }, + "cel_expression": { + "type": "string", + "description": "CEL expression code to filter by criteria.", + }, + "mode": { + "type": "string", + "enum": ["and", "or", "gap"], + "default": "and", + "description": ( + "'and': both filters, 'or': either filter, 'gap': matches CEL but NOT enrolled in program." + ), + }, + }, + }, + } + + if program_ids: + population_filter["schema"]["properties"]["program"]["enum"] = program_ids + if expression_codes: + population_filter["schema"]["properties"]["cel_expression"]["enum"] = expression_codes + if program_metadata: + population_filter["x-openspp-programs"] = program_metadata + if expression_metadata: + population_filter["x-openspp-expressions"] = expression_metadata + + return population_filter + + def _build_variables_input(self): + """Build the variables input definition with dynamic enum and x-openspp-statistics.""" + variable_names, categories = self.get_statistics_metadata() + + variables_input = { + "title": "Statistics Variables", + "description": "Names of statistics to compute. Omit for all GIS-published statistics.", + "minOccurs": 0, + "schema": { + "type": "array", + "items": {"type": "string"}, + }, + } + + # Add enum if we have published indicators + if variable_names: + variables_input["schema"]["items"]["enum"] = variable_names + + # Add x-openspp-statistics extension for rich UI metadata + if categories: + variables_input["x-openspp-statistics"] = {"categories": categories} + + return variables_input + + def _build_spatial_statistics_description(self): + """Build full process description for spatial-statistics.""" + return { + "id": SPATIAL_STATISTICS, + "title": "Spatial Statistics", + "description": ( + "Compute aggregate registrant statistics within arbitrary polygons " + "using PostGIS. Accepts a single geometry or multiple geometries for " + "batch processing." + ), + "version": "1.0.0", + "jobControlOptions": ["sync-execute", "async-execute", "dismiss"], + "x-openspp-batch-limit": MAX_BATCH_GEOMETRIES, + "inputs": { + "geometry": { + "title": "Query Geometry", + "description": ( + f"GeoJSON Polygon or MultiPolygon. Provide one for a single query, " + f"or an array of {{id, value}} objects for batch processing. " + f"Maximum {MAX_BATCH_GEOMETRIES} geometries." + ), + "minOccurs": 1, + "maxOccurs": MAX_BATCH_GEOMETRIES, + "schema": { + "oneOf": [ + {"format": "geojson-geometry"}, + { + "type": "object", + "properties": { + "id": {"type": "string"}, + "value": {"format": "geojson-geometry"}, + }, + "required": ["id", "value"], + }, + ], + }, + }, + "variables": self._build_variables_input(), + "group_by": self._build_group_by_input(), + "filters": { + "title": "Registrant Filters", + "description": "Additional filters (e.g., is_group, disabled).", + "minOccurs": 0, + "schema": {"type": "object"}, + }, + "population_filter": self._build_population_filter_input(), + }, + "outputs": { + "result": { + "title": "Statistics Result", + "schema": { + "oneOf": [ + { + "type": "object", + "description": "Single geometry result", + "properties": { + "total_count": {"type": "integer"}, + "query_method": {"type": "string"}, + "areas_matched": {"type": "integer"}, + "statistics": {"type": "object"}, + "access_level": {"type": "string"}, + "computed_at": {"type": "string", "format": "date-time"}, + }, + "required": ["total_count", "query_method", "areas_matched", "statistics"], + }, + { + "type": "object", + "description": "Batch result (when multiple geometries provided)", + "properties": { + "results": {"type": "array"}, + "summary": {"type": "object"}, + }, + "required": ["results", "summary"], + }, + ], + }, + }, + }, + } + + def _build_proximity_statistics_description(self): + """Build full process description for proximity-statistics.""" + return { + "id": PROXIMITY_STATISTICS, + "title": "Proximity Statistics", + "description": ( + "Compute aggregate registrant statistics within or beyond a given " + "radius from reference points (e.g., health centers, schools)." + ), + "version": "1.0.0", + "jobControlOptions": ["sync-execute", "async-execute", "dismiss"], + "inputs": { + "reference_points": { + "title": "Reference Points", + "description": ( + f"Locations to measure proximity from. Maximum {DEFAULT_MAX_PROXIMITY_POINTS:,} points." + ), + "minOccurs": 1, + "maxOccurs": DEFAULT_MAX_PROXIMITY_POINTS, + "x-openspp-batch-limit": DEFAULT_MAX_PROXIMITY_POINTS, + "schema": { + "type": "object", + "properties": { + "longitude": {"type": "number", "minimum": -180, "maximum": 180}, + "latitude": {"type": "number", "minimum": -90, "maximum": 90}, + }, + "required": ["longitude", "latitude"], + }, + }, + "radius_km": { + "title": "Search Radius", + "description": "Search radius in kilometers.", + "schema": {"type": "number", "exclusiveMinimum": 0, "maximum": 500}, + }, + "relation": { + "title": "Spatial Relation", + "description": "'within' returns registrants inside the radius; 'beyond' returns those outside.", + "minOccurs": 0, + "schema": {"type": "string", "enum": ["within", "beyond"], "default": "within"}, + }, + "variables": self._build_variables_input(), + "group_by": self._build_group_by_input(), + "filters": { + "title": "Registrant Filters", + "minOccurs": 0, + "schema": {"type": "object"}, + }, + "population_filter": self._build_population_filter_input(), + }, + "outputs": { + "result": { + "title": "Proximity Statistics Result", + "schema": { + "type": "object", + "properties": { + "total_count": {"type": "integer"}, + "query_method": {"type": "string"}, + "areas_matched": {"type": "integer"}, + "reference_points_count": {"type": "integer"}, + "radius_km": {"type": "number"}, + "relation": {"type": "string"}, + "statistics": {"type": "object"}, + "access_level": {"type": "string"}, + "computed_at": {"type": "string", "format": "date-time"}, + }, + "required": ["total_count", "query_method", "areas_matched", "statistics"], + }, + }, + }, + } diff --git a/spp_api_v2_gis/services/qml_template_service.py b/spp_api_v2_gis/services/qml_template_service.py index b97e566e0..7c7502bc7 100644 --- a/spp_api_v2_gis/services/qml_template_service.py +++ b/spp_api_v2_gis/services/qml_template_service.py @@ -138,8 +138,8 @@ def _generate_graduated_polygon( for threshold in thresholds: threshold_defs.append( { - "lower": threshold.min_value if threshold.min_value is not None else 0, - "upper": threshold.max_value if threshold.max_value is not None else 999999, + "lower": threshold.min_value if threshold.min_value else 0, + "upper": threshold.max_value if threshold.max_value else 999999, "label": threshold.label or f"Class {len(threshold_defs) + 1}", "color": threshold.color or "#808080", } @@ -252,9 +252,10 @@ def _render_graduated_polygon(self, template, threshold_defs, field_name, opacit color_rgb = self._hex_to_rgb(color_hex) + label_with_range = f"{label} ({lower:.1f} - {upper:.1f})" ranges_xml.append( f' ' + f'label="{self._escape_xml(label_with_range)}" render="true"/>' ) symbols_xml.append( diff --git a/spp_api_v2_gis/services/spatial_query_service.py b/spp_api_v2_gis/services/spatial_query_service.py index 6c49aba85..89da7f6d6 100644 --- a/spp_api_v2_gis/services/spatial_query_service.py +++ b/spp_api_v2_gis/services/spatial_query_service.py @@ -3,6 +3,7 @@ import json import logging +from datetime import UTC, datetime from odoo.addons.spp_analytics.services import build_explicit_scope @@ -30,7 +31,15 @@ def __init__(self, env): """ self.env = env - def query_statistics_batch(self, geometries, filters=None, variables=None): + def query_statistics_batch( + self, + geometries, + filters=None, + variables=None, + group_by=None, + on_progress=None, + population_filter=None, + ): """Execute spatial query for multiple geometries. Queries each geometry individually and computes an aggregate summary. @@ -39,6 +48,7 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): geometries: List of dicts with 'id' and 'geometry' keys filters: Additional filters for registrants (dict) variables: List of statistic names to compute + on_progress: Optional callback(completed_count) called after each geometry Returns: dict: Batch results with per-geometry results and summary @@ -47,6 +57,7 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): """ results = [] all_registrant_ids = set() + geometries_failed = 0 for item in geometries: geometry_id = item["id"] @@ -57,6 +68,8 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): geometry=geometry, filters=filters, variables=variables, + group_by=group_by, + population_filter=population_filter, ) # Collect registrant IDs for deduplication in summary registrant_ids = result.pop("registrant_ids", []) @@ -69,6 +82,7 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): "query_method": result["query_method"], "areas_matched": result["areas_matched"], "statistics": result["statistics"], + "breakdown": result.get("breakdown"), "access_level": result.get("access_level"), "from_cache": result.get("from_cache", False), "computed_at": result.get("computed_at"), @@ -76,6 +90,7 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): ) except Exception as e: _logger.warning("Batch query failed for geometry '%s': %s", geometry_id, e) + geometries_failed += 1 results.append( { "id": geometry_id, @@ -83,21 +98,29 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): "query_method": "error", "areas_matched": 0, "statistics": {}, + "breakdown": None, "access_level": None, "from_cache": False, "computed_at": None, } ) + if on_progress: + on_progress(len(results)) + # Compute summary by aggregating unique registrants with metadata summary_stats_with_metadata = {"statistics": {}} if all_registrant_ids: - summary_stats_with_metadata = self._compute_statistics(list(all_registrant_ids), variables or []) + summary_stats_with_metadata = self._compute_statistics( + list(all_registrant_ids), variables or [], group_by=group_by + ) summary = { "total_count": len(all_registrant_ids), "geometries_queried": len(geometries), + "geometries_failed": geometries_failed, "statistics": summary_stats_with_metadata.get("statistics", {}), + "breakdown": summary_stats_with_metadata.get("breakdown"), "access_level": summary_stats_with_metadata.get("access_level"), "from_cache": summary_stats_with_metadata.get("from_cache", False), "computed_at": summary_stats_with_metadata.get("computed_at"), @@ -108,7 +131,7 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): "summary": summary, } - def query_statistics(self, geometry, filters=None, variables=None): + def query_statistics(self, geometry, filters=None, variables=None, group_by=None, population_filter=None): """Execute spatial query for statistics within polygon. Args: @@ -134,14 +157,14 @@ def query_statistics(self, geometry, filters=None, variables=None): # Try coordinate-based query first (preferred method) try: - result = self._query_by_coordinates(geometry_json, filters) + result = self._query_by_coordinates(geometry_json, filters, population_filter=population_filter) if result["total_count"] > 0: _logger.info( "Spatial query using coordinates: %s registrants found", result["total_count"], ) # Compute statistics for the matched registrants with metadata - stats_with_metadata = self._compute_statistics(result["registrant_ids"], variables) + stats_with_metadata = self._compute_statistics(result["registrant_ids"], variables, group_by=group_by) result.update(stats_with_metadata) return result except Exception as e: @@ -151,18 +174,18 @@ def query_statistics(self, geometry, filters=None, variables=None): ) # Fall back to area-based query - result = self._query_by_area(geometry_json, filters) + result = self._query_by_area(geometry_json, filters, population_filter=population_filter) _logger.info( f"Spatial query using area fallback: {result['total_count']} registrants in {result['areas_matched']} areas" ) # Compute statistics for the matched registrants with metadata - stats_with_metadata = self._compute_statistics(result["registrant_ids"], variables) + stats_with_metadata = self._compute_statistics(result["registrant_ids"], variables, group_by=group_by) result.update(stats_with_metadata) return result - def _query_by_coordinates(self, geometry_json, filters): + def _query_by_coordinates(self, geometry_json, filters, population_filter=None): """Query registrants by coordinates using ST_Intersects. This is the preferred method when registrants have coordinate data. @@ -176,11 +199,11 @@ def _query_by_coordinates(self, geometry_json, filters): """ # Build WHERE clause from filters where_clauses = ["p.is_registrant = true"] - params = [geometry_json] + filter_params = [] if filters.get("is_group") is not None: where_clauses.append("p.is_group = %s") - params.append(filters["is_group"]) + filter_params.append(filters["is_group"]) if filters.get("disabled") is not None: if filters["disabled"]: @@ -190,6 +213,8 @@ def _query_by_coordinates(self, geometry_json, filters): where_clause = " AND ".join(where_clauses) + pop_where, pop_params = self._build_population_filter_sql(population_filter) + # Query using ST_Intersects with coordinates # Note: This assumes res.partner has a 'coordinates' GeoPointField # For now, we'll check if the field exists, otherwise return empty result @@ -206,11 +231,11 @@ def _query_by_coordinates(self, geometry_json, filters): AND ST_Intersects( p.coordinates, ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326) - ) + ){pop_where} """ # nosec B608 - SQL clauses built from hardcoded fragments, data uses %s params - # Add geometry parameter at the beginning - params = [geometry_json] + params[1:] + # Params ordered to match SQL: filter params, geometry, population filter + params = filter_params + [geometry_json] + pop_params self.env.cr.execute(query, params) registrant_ids = [row[0] for row in self.env.cr.fetchall()] @@ -222,7 +247,7 @@ def _query_by_coordinates(self, geometry_json, filters): "registrant_ids": registrant_ids, } - def _query_by_area(self, geometry_json, filters): + def _query_by_area(self, geometry_json, filters, population_filter=None): """Query registrants by area intersection (fallback method). This method finds areas that intersect the query polygon, @@ -277,6 +302,10 @@ def _query_by_area(self, geometry_json, filters): extra_where = (" AND " + " AND ".join(extra_clauses)) if extra_clauses else "" + pop_where, pop_params = self._build_population_filter_sql(population_filter) + extra_where += pop_where + extra_params += pop_params + # Query registrants: those directly in matched areas PLUS # individuals whose group (household) is in a matched area. # Individuals often lack area_id; they inherit it from their group. @@ -312,12 +341,13 @@ def _query_by_area(self, geometry_json, filters): "registrant_ids": registrant_ids, } - def _compute_statistics(self, registrant_ids, variables): + def _compute_statistics(self, registrant_ids, variables, group_by=None): """Compute statistics using the unified aggregation engine only. Args: registrant_ids: List of registrant IDs variables: List of statistic names to compute + group_by: Optional list of dimension names for demographic breakdown Returns: dict: Statistics with metadata (statistics, access_level, from_cache, computed_at) @@ -327,15 +357,15 @@ def _compute_statistics(self, registrant_ids, variables): "statistics": self._get_empty_statistics(), "access_level": None, "from_cache": False, - "computed_at": None, + "computed_at": datetime.now(UTC).isoformat(), } if "spp.analytics.service" not in self.env: raise RuntimeError("spp.analytics.service is required for GIS statistics queries.") - return self._compute_via_aggregation_service(registrant_ids, variables) + return self._compute_via_aggregation_service(registrant_ids, variables, group_by=group_by) - def _compute_via_aggregation_service(self, registrant_ids, variables): + def _compute_via_aggregation_service(self, registrant_ids, variables, group_by=None): """Compute statistics using AggregationService. Delegates to the unified aggregation service for statistics computation @@ -344,6 +374,7 @@ def _compute_via_aggregation_service(self, registrant_ids, variables): Args: registrant_ids: List of registrant IDs variables: List of statistic names to compute (or None for GIS defaults) + group_by: Optional list of dimension names for demographic breakdown Returns: dict: Statistics with metadata (statistics, access_level, from_cache, computed_at) @@ -362,13 +393,19 @@ def _compute_via_aggregation_service(self, registrant_ids, variables): Statistic = self.env["spp.indicator"].sudo() gis_stats = Statistic.get_published_for_context("gis") statistics_to_compute = [stat.name for stat in gis_stats] if gis_stats else None + _logger.info( + "No variables requested, falling back to GIS-published: %r", + statistics_to_compute, + ) + else: + _logger.info("Computing requested variables: %r", statistics_to_compute) if not statistics_to_compute: return { "statistics": {}, "access_level": None, "from_cache": False, - "computed_at": None, + "computed_at": datetime.now(UTC).isoformat(), } # Call AggregationService (no sudo - let service determine access level from calling user) @@ -376,6 +413,7 @@ def _compute_via_aggregation_service(self, registrant_ids, variables): result = aggregation_service.compute_aggregation( scope=scope, statistics=statistics_to_compute, + group_by=group_by, context="gis", use_cache=False, # Spatial queries are dynamic, don't cache ) @@ -447,12 +485,22 @@ def _convert_aggregation_result(self, agg_result, registrant_ids=None): # Return statistics with metadata return { "statistics": result, + "breakdown": agg_result.get("breakdown"), "access_level": agg_result.get("access_level"), "from_cache": agg_result.get("from_cache", False), "computed_at": agg_result.get("computed_at"), } - def query_proximity(self, reference_points, radius_km, relation="within", filters=None, variables=None): + def query_proximity( + self, + reference_points, + radius_km, + relation="within", + filters=None, + variables=None, + group_by=None, + population_filter=None, + ): """Query registrants by proximity to reference points. Uses a temp table with pre-buffered geometries and ST_Intersects @@ -485,7 +533,13 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter # Try coordinate-based query first try: - result = self._proximity_by_coordinates(reference_points, radius_meters, relation, filters) + result = self._proximity_by_coordinates( + reference_points, + radius_meters, + relation, + filters, + population_filter=population_filter, + ) if result["total_count"] > 0: _logger.info( "Proximity query (%s, %.1f km) using coordinates: %s registrants found", @@ -494,7 +548,7 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter result["total_count"], ) registrant_ids = result["registrant_ids"] - stats_with_metadata = self._compute_statistics(registrant_ids, variables) + stats_with_metadata = self._compute_statistics(registrant_ids, variables, group_by=group_by) result.update(stats_with_metadata) result["reference_points_count"] = len(reference_points) result["radius_km"] = radius_km @@ -507,7 +561,13 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter ) # Fall back to area-based query - result = self._proximity_by_area(reference_points, radius_meters, relation, filters) + result = self._proximity_by_area( + reference_points, + radius_meters, + relation, + filters, + population_filter=population_filter, + ) _logger.info( "Proximity query (%s, %.1f km) using area fallback: %s registrants in %s areas", relation, @@ -516,7 +576,7 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter result["areas_matched"], ) registrant_ids = result["registrant_ids"] - stats_with_metadata = self._compute_statistics(registrant_ids, variables) + stats_with_metadata = self._compute_statistics(registrant_ids, variables, group_by=group_by) result.update(stats_with_metadata) result["reference_points_count"] = len(reference_points) result["radius_km"] = radius_km @@ -594,7 +654,157 @@ def _build_filter_clauses(self, filters): extra_where = (" AND " + " AND ".join(extra_clauses)) if extra_clauses else "" return extra_where, extra_params - def _proximity_by_coordinates(self, reference_points, radius_meters, relation, filters): + def _build_population_filter_sql(self, population_filter): + """Build SQL clause from population_filter input. + + Args: + population_filter: Dict with optional keys: program, cel_expression, mode + + Returns: + tuple: (sql_clause, params) where sql_clause is a string like + "AND p.id IN (...)" and params is a list of query parameters. + Returns ("", []) if no filter is active. + """ + if not population_filter: + return "", [] + + # TODO: Replace program ID with code field (see gis-analytics-enrichment.md Task 1) + program_id = population_filter.get("program") + cel_expression_code = population_filter.get("cel_expression") + mode = population_filter.get("mode", "and") + + if mode not in ("and", "or", "gap"): + raise ValueError(f"Invalid population_filter mode: {mode!r}. Must be 'and', 'or', or 'gap'.") + + if program_id is not None and not isinstance(program_id, int): + raise ValueError("population_filter.program must be an integer") + + program_sql = "" + program_params = [] + cel_sql = "" + cel_params = [] + + # Build program filter subquery + if program_id: + program_sql = """ + SELECT pm.partner_id FROM spp_program_membership pm + WHERE pm.program_id = %s AND pm.state = 'enrolled' + """ + program_params = [program_id] + + # Build CEL expression filter subquery + if cel_expression_code: + # nosemgrep: odoo-sudo-without-context + Expression = self.env["spp.cel.expression"].sudo() + expression = Expression.search([("code", "=", cel_expression_code)], limit=1) + if not expression: + _logger.warning("Population filter: expression '%s' not found", cel_expression_code) + return "AND false", [] + + # Use the correct profile based on the expression's context_type + context_type = expression.context_type or "group" + profile = "registry_individuals" if context_type == "individual" else "registry_groups" + + cel_service = self.env["spp.cel.service"] + result = cel_service.compile_expression( + expression.cel_expression, + profile=profile, + limit=0, + ) + if not result.get("valid"): + _logger.warning( + "Population filter: CEL expression '%s' failed to compile: %s", + cel_expression_code, + result.get("error"), + ) + return "AND false", [] + + domain = result.get("domain", []) + # sudo: population filters must count partners across the whole + # population regardless of the caller's record rules; read-only. + # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models + Partner = self.env["res.partner"].sudo() + matching_ids = Partner.search(domain).ids + if not matching_ids: + return "AND false", [] + + # For individual-context expressions, resolve to group IDs. + # The spatial query operates on groups (they have coordinates/area_id), + # so we find groups that contain matching individuals. + if context_type == "individual": + matching_ids = self._resolve_individuals_to_groups(matching_ids) + if not matching_ids: + return "AND false", [] + + if len(matching_ids) > 10000: + _logger.warning( + "Population filter: CEL expression '%s' matched %d registrants. " + "Consider using SQL subquery optimization for large result sets.", + cel_expression_code, + len(matching_ids), + ) + + cel_sql = "SELECT unnest(%s::int[])" + cel_params = [list(matching_ids)] + + # Combine based on mode + if program_sql and cel_sql: + if mode == "and": + return ( + "AND p.id IN (" + program_sql + ") AND p.id IN (" + cel_sql + ")", + program_params + cel_params, + ) + elif mode == "or": + return ( + "AND (p.id IN (" + program_sql + ") OR p.id IN (" + cel_sql + "))", + program_params + cel_params, + ) + elif mode == "gap": + return ( + "AND p.id IN (" + cel_sql + ") AND p.id NOT IN (" + program_sql + ")", + cel_params + program_params, + ) + elif program_sql: + return "AND p.id IN (" + program_sql + ")", program_params + elif cel_sql: + return "AND p.id IN (" + cel_sql + ")", cel_params + + return "", [] + + def _resolve_individuals_to_groups(self, individual_ids): + """Resolve individual partner IDs to their group (household) IDs. + + The spatial query operates on groups (they have coordinates/area_id). + For individual-context CEL expressions, we need to find which groups + contain the matching individuals. + + Args: + individual_ids: List of individual partner IDs + + Returns: + list: Group partner IDs that contain at least one matching individual + """ + if not individual_ids: + return [] + + self.env.cr.execute( + """ + SELECT DISTINCT gm."group" + FROM spp_group_membership gm + WHERE gm.individual = ANY(%s) + AND gm.is_ended = false + """, + [list(individual_ids)], + ) + group_ids = [row[0] for row in self.env.cr.fetchall()] + _logger.info( + "Resolved %d individuals to %d groups for population filter", + len(individual_ids), + len(group_ids), + ) + return group_ids + + def _proximity_by_coordinates(self, reference_points, radius_meters, relation, filters, population_filter=None): """Query registrants by coordinate proximity to reference points. Args: @@ -614,6 +824,10 @@ def _proximity_by_coordinates(self, reference_points, radius_meters, relation, f extra_where, extra_params = self._build_filter_clauses(filters) + pop_where, pop_params = self._build_population_filter_sql(population_filter) + extra_where += pop_where + extra_params += pop_params + if relation == "within": # Find registrants whose coordinates intersect any buffer query = f""" @@ -655,7 +869,7 @@ def _proximity_by_coordinates(self, reference_points, radius_meters, relation, f "registrant_ids": registrant_ids, } - def _proximity_by_area(self, reference_points, radius_meters, relation, filters): + def _proximity_by_area(self, reference_points, radius_meters, relation, filters, population_filter=None): """Query registrants by area proximity (fallback when coordinates unavailable). Uses ST_Intersects between area polygons and buffered reference points @@ -708,6 +922,10 @@ def _proximity_by_area(self, reference_points, radius_meters, relation, filters) area_tuple = tuple(area_ids) extra_where, extra_params = self._build_filter_clauses(filters) + pop_where, pop_params = self._build_population_filter_sql(population_filter) + extra_where += pop_where + extra_params += pop_params + # Reuse the same registrant lookup as _query_by_area (includes group membership) registrants_query = f""" SELECT DISTINCT p.id diff --git a/spp_api_v2_gis/static/description/index.html b/spp_api_v2_gis/static/description/index.html index b7c6f7e27..364427fa2 100644 --- a/spp_api_v2_gis/static/description/index.html +++ b/spp_api_v2_gis/static/description/index.html @@ -398,12 +398,12 @@

Architecture

API Endpoints

-

OGC API - Features (primary interface)

+

OGC API - Features

---+++ @@ -429,25 +429,27 @@

API Endpoints

- + - - + + - +
Endpoint Collection metadata
/gis/ogc/collections/{id}/itemsGETGET/POST Feature items (GeoJSON)
/gis/ogc/collections/{id}/items/{fid}GETSingle featureGET/PUT/DELETESingle feature (CRUD for +geofences)
/gis/ogc/collections/{id}/qml GETQGIS style file (extension)QGIS style file +(extension)
-

Additional endpoints

+

OGC API - Processes

---+++ @@ -456,22 +458,55 @@

API Endpoints

- + + + + + + + + + - + - - - + + + - + - + + + + + + +
Endpoint
/gis/query/statistics
/gis/ogc/processesGETList available processes
/gis/ogc/processes/{id}GETProcess description
/gis/ogc/processes/{id}/execution POSTQuery stats for polygonExecute process +(sync/async)
/gis/geofencesPOST/GETGeofence management
/gis/ogc/jobsGETList jobs
/gis/geofences/{id}
/gis/ogc/jobs/{id} GET/DELETESingle geofenceJob status / dismiss
/gis/ogc/jobs/{id}/resultsGETJob results
+

Utility endpoints

+ +++++ + + + + + + + + + + +
EndpointMethodDescription
/gis/export/geopackage GET Export for offline use
/gis/statisticsGETList published statistics
@@ -510,11 +545,11 @@

Scopes and Data Privacy

  • OGC collections/items: Return GeoJSON features organized by administrative area, with pre-computed aggregate values (counts, percentages). Each feature represents an area, not a person.
  • -
  • Spatial query statistics (POST /gis/query/statistics): Accepts -a GeoJSON polygon and returns configured aggregate statistics computed -by spp.analytics.service. Individual registrant IDs are computed +
  • Spatial query statistics (via OGC Processes): Accepts a GeoJSON +polygon and returns configured aggregate statistics computed by +spp.analytics.service. Individual registrant IDs are computed internally for aggregation but are explicitly stripped from the -response before it is sent (see spatial_query.py).
  • +response before it is sent.
  • Exports (GeoPackage/GeoJSON): Contain the same area-level aggregated layer data, not registrant-level records.
  • Geofences: Store only geometry and metadata — no registrant data.
  • diff --git a/spp_api_v2_gis/tests/__init__.py b/spp_api_v2_gis/tests/__init__.py index 06c22da07..dc5930193 100644 --- a/spp_api_v2_gis/tests/__init__.py +++ b/spp_api_v2_gis/tests/__init__.py @@ -5,8 +5,13 @@ from . import test_layers_service from . import test_ogc_features from . import test_ogc_http +from . import test_ogc_processes from . import test_qml_template_service from . import test_spatial_query_service from . import test_statistics_endpoint from . import test_batch_query from . import test_proximity_query +from . import test_ogc_geofence_crud +from . import test_ogc_crud_http +from . import test_ogc_incidents +from . import test_population_filter diff --git a/spp_api_v2_gis/tests/test_batch_query.py b/spp_api_v2_gis/tests/test_batch_query.py index 19cb9fa66..344f83412 100644 --- a/spp_api_v2_gis/tests/test_batch_query.py +++ b/spp_api_v2_gis/tests/test_batch_query.py @@ -253,13 +253,13 @@ def test_batch_request_schema(self): } ], filters={"is_group": True}, - variables=["children_under_5"], + variables=["total_households"], ) self.assertEqual(len(request.geometries), 1) self.assertEqual(request.geometries[0].id, "zone_1") self.assertEqual(request.filters, {"is_group": True}) - self.assertEqual(request.variables, ["children_under_5"]) + self.assertEqual(request.variables, ["total_households"]) def test_batch_request_requires_geometries(self): """Test that BatchSpatialQueryRequest requires at least one geometry.""" diff --git a/spp_api_v2_gis/tests/test_layers_service.py b/spp_api_v2_gis/tests/test_layers_service.py index e9796602c..24cfd126a 100644 --- a/spp_api_v2_gis/tests/test_layers_service.py +++ b/spp_api_v2_gis/tests/test_layers_service.py @@ -3,6 +3,7 @@ import logging +from odoo import fields from odoo.exceptions import MissingError from odoo.tests import tagged from odoo.tests.common import TransactionCase @@ -99,17 +100,22 @@ def setUpClass(cls): } ) - # Create data layer if geo field exists + # Create data layer if the geo field and a GIS view are available. cls.geo_field = cls.env["ir.model.fields"].search( - [("model", "=", "spp.area"), ("name", "=", "polygon")], + [("model", "=", "spp.area"), ("name", "=", "geo_polygon")], limit=1, ) - if cls.geo_field: + cls.gis_view = cls.env["ir.ui.view"].search( + [("model", "=", "spp.area"), ("type", "=", "gis")], + limit=1, + ) + if cls.geo_field and cls.gis_view: cls.data_layer = cls.env["spp.gis.data.layer"].create( { "name": "Test Areas Layer", "model_name": "spp.area", "geo_field_id": cls.geo_field.id, + "view_id": cls.gis_view.id, "geo_repr": "basic", "domain": "[('level', '=', 2)]", } @@ -522,6 +528,192 @@ def test_get_feature_count_without_admin_level(self): self.assertIsInstance(count, int) self.assertGreaterEqual(count, 0) + # ------------------------------------------------------------------ + # Module-level coordinate/bbox helpers + # ------------------------------------------------------------------ + def test_extract_all_coordinates_geometry_types(self): + """Coordinates are extracted from every supported geometry type.""" + from ..services.layers_service import _extract_all_coordinates + + self.assertEqual(_extract_all_coordinates({"type": "Point", "coordinates": [1, 2]}), [[1, 2]]) + self.assertEqual( + _extract_all_coordinates({"type": "MultiPoint", "coordinates": [[1, 2], [3, 4]]}), + [[1, 2], [3, 4]], + ) + self.assertEqual( + _extract_all_coordinates({"type": "LineString", "coordinates": [[1, 2], [3, 4]]}), + [[1, 2], [3, 4]], + ) + polygon = {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]} + self.assertEqual(len(_extract_all_coordinates(polygon)), 4) + multilinestring = {"type": "MultiLineString", "coordinates": [[[0, 0], [1, 1]], [[2, 2], [3, 3]]]} + self.assertEqual(len(_extract_all_coordinates(multilinestring)), 4) + multipolygon = { + "type": "MultiPolygon", + "coordinates": [[[[0, 0], [1, 0], [0, 0]]], [[[2, 2], [3, 2], [2, 2]]]], + } + self.assertEqual(len(_extract_all_coordinates(multipolygon)), 6) + + def test_extract_all_coordinates_empty_or_unknown(self): + """Empty coordinates or unknown geometry types yield an empty list.""" + from ..services.layers_service import _extract_all_coordinates + + self.assertEqual(_extract_all_coordinates({"type": "Point", "coordinates": None}), []) + self.assertEqual(_extract_all_coordinates({"type": "GeometryCollection", "coordinates": [[1, 2]]}), []) + + def test_bbox_to_geojson(self): + """A bbox is converted to a closed GeoJSON polygon ring.""" + from ..services.layers_service import LayersService + + geometry = LayersService(self.env)._bbox_to_geojson([0, 1, 2, 3]) + self.assertEqual(geometry["type"], "Polygon") + ring = geometry["coordinates"][0] + self.assertEqual(len(ring), 5) + self.assertEqual(ring[0], [0, 1]) + self.assertEqual(ring[0], ring[-1]) + + # ------------------------------------------------------------------ + # Feature count / feature-by-id + # ------------------------------------------------------------------ + def _make_report_data(self, **overrides): + vals = { + "report_id": self.report.id, + "area_id": self.child_area1.id, + "area_code": self.child_area1.code, + "area_name": "Test Child Area 1", + "area_level": 2, + "raw_value": 5.0, + "normalized_value": 0.5, + "display_value": "5", + "record_count": 5, + "bucket_index": 0, + "bucket_color": "#440154", + "bucket_label": "Low", + "computed_at": fields.Datetime.now(), + } + vals.update(overrides) + return self.env["spp.gis.report.data"].create(vals) + + def test_get_feature_count_report(self): + """Report feature count honours the optional admin-level filter.""" + from ..services.layers_service import LayersService + + data = self._make_report_data() + service = LayersService(self.env) + self.assertEqual(service.get_feature_count("test_layers_report", layer_type="report"), 1) + # area_level is related to area_id.area_level; use the record's actual value. + self.assertEqual( + service.get_feature_count("test_layers_report", layer_type="report", admin_level=data.area_level), 1 + ) + self.assertEqual( + service.get_feature_count("test_layers_report", layer_type="report", admin_level=data.area_level + 99), 0 + ) + + def test_get_feature_count_report_not_found(self): + from ..services.layers_service import LayersService + + with self.assertRaises(MissingError): + LayersService(self.env).get_feature_count("nonexistent", layer_type="report") + + def test_get_feature_count_layer(self): + if not self.data_layer: + self.skipTest("No geo field available on spp.area") + from ..services.layers_service import LayersService + + count = LayersService(self.env).get_feature_count(str(self.data_layer.id), layer_type="layer") + self.assertIsInstance(count, int) + + def test_get_feature_count_layer_invalid_id(self): + from ..services.layers_service import LayersService + + with self.assertRaises(ValueError): + LayersService(self.env).get_feature_count("not-an-int", layer_type="layer") + + def test_get_feature_count_layer_not_found(self): + from ..services.layers_service import LayersService + + with self.assertRaises(MissingError): + LayersService(self.env).get_feature_count("999999", layer_type="layer") + + def test_get_feature_count_invalid_type(self): + from ..services.layers_service import LayersService + + with self.assertRaises(ValueError): + LayersService(self.env).get_feature_count("x", layer_type="bogus") + + def test_get_feature_by_id_invalid_type(self): + from ..services.layers_service import LayersService + + with self.assertRaises(ValueError): + LayersService(self.env).get_feature_by_id("x", "y", layer_type="bogus") + + def test_get_report_feature_by_id(self): + """A report feature is returned with properties and bucket range.""" + from ..services.layers_service import LayersService + + self._make_report_data() + feature = LayersService(self.env).get_feature_by_id( + "test_layers_report", self.child_area1.code, layer_type="report" + ) + self.assertEqual(feature["type"], "Feature") + self.assertEqual(feature["id"], self.child_area1.code) + self.assertEqual(feature["properties"]["area_code"], self.child_area1.code) + self.assertTrue(feature["properties"]["has_data"]) + self.assertEqual(feature["properties"]["bucket"]["index"], 0) + self.assertEqual(feature["properties"]["bucket"]["min_value"], 0) + + def test_get_report_feature_by_id_report_not_found(self): + from ..services.layers_service import LayersService + + with self.assertRaises(MissingError): + LayersService(self.env).get_feature_by_id("nonexistent", "x", layer_type="report") + + def test_get_report_feature_by_id_feature_not_found(self): + from ..services.layers_service import LayersService + + with self.assertRaises(MissingError): + LayersService(self.env).get_feature_by_id("test_layers_report", "no_such_area", layer_type="report") + + def test_get_layer_feature_by_id_success(self): + if not self.data_layer: + self.skipTest("No geo field available on spp.area") + from ..services.layers_service import LayersService + + feature = LayersService(self.env).get_feature_by_id( + str(self.data_layer.id), str(self.child_area1.id), layer_type="layer" + ) + self.assertEqual(feature["type"], "Feature") + self.assertEqual(feature["id"], self.child_area1.id) + self.assertEqual(feature["properties"]["id"], self.child_area1.id) + + def test_get_layer_feature_by_id_layer_not_found(self): + from ..services.layers_service import LayersService + + with self.assertRaises(MissingError): + LayersService(self.env).get_feature_by_id("999999", "1", layer_type="layer") + + def test_get_layer_feature_by_id_invalid_layer_id(self): + from ..services.layers_service import LayersService + + with self.assertRaises(ValueError): + LayersService(self.env).get_feature_by_id("not-int", "1", layer_type="layer") + + def test_get_layer_feature_by_id_feature_not_found(self): + if not self.data_layer: + self.skipTest("No geo field available on spp.area") + from ..services.layers_service import LayersService + + with self.assertRaises(MissingError): + LayersService(self.env).get_feature_by_id(str(self.data_layer.id), "999999", layer_type="layer") + + def test_get_layer_feature_by_id_invalid_feature_id(self): + if not self.data_layer: + self.skipTest("No geo field available on spp.area") + from ..services.layers_service import LayersService + + with self.assertRaises(MissingError): + LayersService(self.env).get_feature_by_id(str(self.data_layer.id), "not-int", layer_type="layer") + @tagged("post_install", "-at_install") class TestBboxFeatureFilter(TransactionCase): diff --git a/spp_api_v2_gis/tests/test_ogc_crud_http.py b/spp_api_v2_gis/tests/test_ogc_crud_http.py new file mode 100644 index 000000000..2ecbe8992 --- /dev/null +++ b/spp_api_v2_gis/tests/test_ogc_crud_http.py @@ -0,0 +1,91 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""HTTP integration tests for OGC API - Features write endpoints (Part 4): +scope enforcement, geofence creation, and the OPTIONS discovery handler.""" + +import json + +from odoo.tests import tagged + +from odoo.addons.spp_api_v2.tests.common import ApiV2HttpTestCase + +API_BASE = "/api/v2/spp" +OGC_BASE = f"{API_BASE}/gis/ogc" + +GEOFENCE_FEATURE = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [[[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]]], + }, + "properties": { + "name": "CRUD Test Zone", + "geofence_type": "area_of_interest", + "description": "created via OGC POST test", + }, +} + + +@tagged("post_install", "-at_install") +class TestOGCCrudHTTP(ApiV2HttpTestCase): + """HTTP tests for OGC Part 4 write endpoints on the geofences collection.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.geofence_client = cls.create_api_client( + cls, + name="OGC Geofence Client", + scopes=[ + {"resource": "gis", "action": "read"}, + {"resource": "gis", "action": "geofence"}, + ], + ) + cls.geofence_token = cls.generate_jwt_token(cls, cls.geofence_client) + + cls.read_only_client = cls.create_api_client( + cls, + name="OGC Read-Only Client", + scopes=[{"resource": "gis", "action": "read"}], + ) + cls.read_only_token = cls.generate_jwt_token(cls, cls.read_only_client) + + def _headers(self, token): + return {"Content-Type": "application/json", "Authorization": f"Bearer {token}"} + + def test_options_geofences_items_advertises_write_methods(self): + """OPTIONS on the geofences items endpoint advertises write methods.""" + response = self.opener.options( + self.base_url() + f"{OGC_BASE}/collections/geofences/items", + headers=self._headers(self.geofence_token), + ) + self.assertEqual(response.status_code, 200) + self.assertIn("POST", response.headers.get("Allow", "")) + + def test_post_geofence_without_scope_returns_403(self): + """Creating a geofence without gis:geofence scope is forbidden.""" + response = self.url_open( + f"{OGC_BASE}/collections/geofences/items", + data=json.dumps(GEOFENCE_FEATURE), + headers=self._headers(self.read_only_token), + ) + self.assertEqual(response.status_code, 403) + + def test_post_geofence_with_scope_returns_201(self): + """A client with gis:geofence scope can create a geofence.""" + response = self.url_open( + f"{OGC_BASE}/collections/geofences/items", + data=json.dumps(GEOFENCE_FEATURE), + headers=self._headers(self.geofence_token), + ) + self.assertEqual(response.status_code, 201) + body = response.json() + self.assertEqual(body["type"], "Feature") + self.assertEqual(body["properties"]["name"], "CRUD Test Zone") + + def test_delete_geofence_without_scope_returns_403(self): + """Deleting a geofence without gis:geofence scope is forbidden.""" + response = self.url_delete( + f"{OGC_BASE}/collections/geofences/items/00000000-0000-0000-0000-000000000000", + headers=self._headers(self.read_only_token), + ) + self.assertEqual(response.status_code, 403) diff --git a/spp_api_v2_gis/tests/test_ogc_geofence_crud.py b/spp_api_v2_gis/tests/test_ogc_geofence_crud.py new file mode 100644 index 000000000..c35ccc4af --- /dev/null +++ b/spp_api_v2_gis/tests/test_ogc_geofence_crud.py @@ -0,0 +1,550 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for OGC API - Features Part 4: CRUD for Geofences. + +Tests cover: +- Phase 1: Read path (collection discovery, GET items, GET single item) +- Phase 2: Write path (POST, PUT, DELETE) +""" + +import json +import logging + +from odoo.exceptions import MissingError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + +# Sample polygon covering a small area in Southeast Asia +SAMPLE_POLYGON = { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], +} + +# Another polygon, shifted east +SAMPLE_POLYGON_2 = { + "type": "Polygon", + "coordinates": [ + [ + [102.0, 2.0], + [103.0, 2.0], + [103.0, 3.0], + [102.0, 3.0], + [102.0, 2.0], + ] + ], +} + + +@tagged("post_install", "-at_install") +class TestOGCGeofenceRead(TransactionCase): + """Phase 1: Read path tests for geofence OGC collection.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.Geofence = cls.env["spp.gis.geofence"] + + cls.geofence1 = cls.Geofence.create( + { + "name": "OGC Read Test Area 1", + "geometry": json.dumps(SAMPLE_POLYGON), + "geofence_type": "area_of_interest", + "created_from": "api", + } + ) + cls.geofence2 = cls.Geofence.create( + { + "name": "OGC Read Test Area 2", + "geometry": json.dumps(SAMPLE_POLYGON_2), + "geofence_type": "custom", + "created_from": "ui", + } + ) + # Inactive geofence (soft-deleted) + cls.geofence_inactive = cls.Geofence.create( + { + "name": "OGC Read Inactive", + "geometry": json.dumps(SAMPLE_POLYGON), + "geofence_type": "custom", + "active": False, + } + ) + + def _make_service(self, base_url="http://localhost:8069/api/v2/spp"): + from ..services.ogc_service import OGCService + + return OGCService(self.env, base_url) + + # --- to_geojson top-level id --- + + def test_to_geojson_has_top_level_id(self): + """to_geojson() must emit 'id' at the Feature top level (OGC Req 23).""" + feature = self.geofence1.to_geojson() + self.assertIn("id", feature) + self.assertEqual(feature["id"], self.geofence1.uuid) + + # --- Collection discovery --- + + def test_collections_includes_geofences(self): + """GET /collections must include a 'geofences' collection.""" + service = self._make_service() + result = service.get_collections() + + ids = [c["id"] for c in result["collections"]] + self.assertIn("geofences", ids) + + def test_geofences_collection_has_bbox(self): + """Geofences collection metadata must include extent.spatial.bbox.""" + service = self._make_service() + collection = service.get_collection("geofences") + + self.assertIn("extent", collection) + self.assertIn("spatial", collection["extent"]) + self.assertIn("bbox", collection["extent"]["spatial"]) + bbox = collection["extent"]["spatial"]["bbox"][0] + self.assertEqual(len(bbox), 4) + # bbox should encompass both test geofences: lon 100..103, lat 0..3 + self.assertLessEqual(bbox[0], 100.0) # west + self.assertLessEqual(bbox[1], 0.0) # south + self.assertGreaterEqual(bbox[2], 103.0) # east + self.assertGreaterEqual(bbox[3], 3.0) # north + + def test_geofences_collection_has_items_link(self): + """Geofences collection must have an 'items' link.""" + service = self._make_service() + collection = service.get_collection("geofences") + + link_rels = [link["rel"] for link in collection["links"]] + self.assertIn("items", link_rels) + self.assertIn("self", link_rels) + + # --- _parse_collection_id --- + + def test_parse_collection_id_geofences(self): + """'geofences' must parse as ('geofence', None, None).""" + service = self._make_service() + layer_type, layer_id, admin_level = service._parse_collection_id("geofences") + + self.assertEqual(layer_type, "geofence") + self.assertIsNone(layer_id) + self.assertIsNone(admin_level) + + # --- GET items --- + + def test_get_items_returns_feature_collection(self): + """GET items returns GeoJSON FeatureCollection with pagination.""" + service = self._make_service() + result = service.get_collection_items("geofences") + + self.assertEqual(result["type"], "FeatureCollection") + self.assertIn("numberMatched", result) + self.assertIn("numberReturned", result) + self.assertIn("features", result) + self.assertGreaterEqual(result["numberMatched"], 2) + + def test_get_items_features_have_top_level_id(self): + """Each feature in GET items must have a top-level 'id'.""" + service = self._make_service() + result = service.get_collection_items("geofences") + + for feature in result["features"]: + self.assertIn("id", feature) + self.assertIsNotNone(feature["id"]) + + def test_get_items_features_have_expected_properties(self): + """Features must include all documented properties.""" + service = self._make_service() + result = service.get_collection_items("geofences") + + self.assertGreater(len(result["features"]), 0) + props = result["features"][0]["properties"] + + expected_keys = [ + "uuid", + "name", + "description", + "geofence_type", + "geofence_type_label", + "area_sqkm", + "tags", + "created_from", + "created_by", + "create_date", + ] + for key in expected_keys: + self.assertIn(key, props, f"Missing property: {key}") + + def test_get_items_excludes_inactive(self): + """GET items must exclude inactive (soft-deleted) geofences by default.""" + service = self._make_service() + result = service.get_collection_items("geofences") + + uuids = [f["id"] for f in result["features"]] + self.assertNotIn(self.geofence_inactive.uuid, uuids) + + def test_get_items_pagination(self): + """GET items respects limit and offset.""" + service = self._make_service() + result = service.get_collection_items("geofences", limit=1, offset=0) + + self.assertEqual(result["numberReturned"], 1) + self.assertGreaterEqual(result["numberMatched"], 2) + + def test_get_items_bbox_filter(self): + """bbox filter returns only geofences intersecting the box.""" + service = self._make_service() + # bbox covering only geofence1 (100-101, 0-1), not geofence2 (102-103, 2-3) + result = service.get_collection_items("geofences", bbox=[99.5, -0.5, 101.5, 1.5]) + + uuids = [f["id"] for f in result["features"]] + self.assertIn(self.geofence1.uuid, uuids) + self.assertNotIn(self.geofence2.uuid, uuids) + + def test_get_items_geofence_type_filter(self): + """geofence_type filter returns only matching geofences.""" + service = self._make_service() + result = service.get_collection_items("geofences", geofence_type="area_of_interest") + + for feature in result["features"]: + self.assertEqual(feature["properties"]["geofence_type"], "area_of_interest") + + # --- GET single item --- + + def test_get_item_by_uuid(self): + """GET single item by UUID returns the correct feature.""" + service = self._make_service() + feature = service.get_collection_item("geofences", self.geofence1.uuid) + + self.assertEqual(feature["type"], "Feature") + self.assertEqual(feature["id"], self.geofence1.uuid) + self.assertEqual(feature["properties"]["name"], "OGC Read Test Area 1") + + def test_get_item_inactive_returns_404(self): + """GET single inactive item returns MissingError (404).""" + service = self._make_service() + + with self.assertRaises(MissingError): + service.get_collection_item("geofences", self.geofence_inactive.uuid) + + def test_get_item_nonexistent_returns_404(self): + """GET single item with bad UUID returns MissingError (404).""" + service = self._make_service() + + with self.assertRaises(MissingError): + service.get_collection_item("geofences", "nonexistent-uuid-12345") + + # --- Conformance --- + + def test_conformance_includes_crud_class(self): + """Conformance must include OGC Features Part 4 create-replace-delete.""" + service = self._make_service() + conf = service.get_conformance() + + self.assertIn( + "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete", + conf["conformsTo"], + ) + + +@tagged("post_install", "-at_install") +class TestOGCGeofenceWrite(TransactionCase): + """Phase 2: Write path tests for geofence OGC collection.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.Geofence = cls.env["spp.gis.geofence"] + + # Create a geofence to test PUT and DELETE on + cls.existing_geofence = cls.Geofence.create( + { + "name": "OGC Write Existing", + "geometry": json.dumps(SAMPLE_POLYGON), + "geofence_type": "custom", + "created_from": "api", + } + ) + + def _make_service(self, base_url="http://localhost:8069/api/v2/spp"): + from ..services.ogc_service import OGCService + + return OGCService(self.env, base_url) + + # --- POST (create) --- + + def test_post_creates_geofence(self): + """POST creates a geofence and returns 201-style result.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON_2, + "properties": { + "name": "OGC POST Test", + "geofence_type": "area_of_interest", + }, + } + + result = service.create_geofence_feature(feature_input) + + self.assertEqual(result["feature"]["type"], "Feature") + self.assertIn("id", result["feature"]) + self.assertEqual(result["feature"]["properties"]["name"], "OGC POST Test") + self.assertEqual(result["feature"]["properties"]["created_from"], "api") + self.assertIsNotNone(result["location"]) + + def test_post_with_tags_resolves_or_creates(self): + """POST with tags creates tag records if they don't exist.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": { + "name": "OGC POST Tags Test", + "tags": ["ogc-tag-alpha", "ogc-tag-beta"], + }, + } + + result = service.create_geofence_feature(feature_input) + + props = result["feature"]["properties"] + self.assertIn("ogc-tag-alpha", props["tags"]) + self.assertIn("ogc-tag-beta", props["tags"]) + + def test_post_with_incident_code(self): + """POST with incident_code links to hazard incident.""" + # Create hazard incident + category = self.env["spp.hazard.category"].create({"name": "OGC Test Cat", "code": "OGC_TEST_CAT"}) + self.env["spp.hazard.incident"].create( + { + "name": "OGC Test Flood", + "code": "OGC-FLOOD-001", + "category_id": category.id, + "start_date": "2026-01-01", + } + ) + + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": { + "name": "OGC POST Incident Test", + "geofence_type": "hazard_zone", + "incident_code": "OGC-FLOOD-001", + }, + } + + result = service.create_geofence_feature(feature_input) + + props = result["feature"]["properties"] + self.assertEqual(props["incident_id"], "OGC-FLOOD-001") + self.assertEqual(props["incident_name"], "OGC Test Flood") + + def test_post_invalid_geofence_type_returns_error(self): + """POST with invalid geofence_type raises ValueError with valid options.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": { + "name": "Bad Type Test", + "geofence_type": "nonexistent_type", + }, + } + + with self.assertRaises(ValueError) as cm: + service.create_geofence_feature(feature_input) + + # Error message should list valid options + self.assertIn("geofence_type", str(cm.exception)) + + def test_post_invalid_geometry_type_returns_error(self): + """POST with Point geometry raises ValueError.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [100.0, 0.0]}, + "properties": {"name": "Bad Geom Test"}, + } + + with self.assertRaises(ValueError) as cm: + service.create_geofence_feature(feature_input) + + self.assertIn("Polygon", str(cm.exception)) + + def test_post_missing_name_returns_error(self): + """POST without 'name' property raises ValueError.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": {}, + } + + with self.assertRaises(ValueError): + service.create_geofence_feature(feature_input) + + # --- PUT (replace) --- + + def test_put_replaces_geofence(self): + """PUT replaces geofence geometry and properties.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON_2, + "properties": { + "name": "OGC PUT Replaced", + "geofence_type": "area_of_interest", + "description": "Updated via PUT", + }, + } + + result = service.replace_geofence_feature(self.existing_geofence.uuid, feature_input) + + self.assertEqual(result["type"], "Feature") + self.assertEqual(result["properties"]["name"], "OGC PUT Replaced") + self.assertEqual(result["properties"]["description"], "Updated via PUT") + self.assertEqual(result["properties"]["geofence_type"], "area_of_interest") + + def test_put_recomputes_area(self): + """PUT recomputes area_sqkm from the new geometry.""" + service = self._make_service() + + # Get original area + original_area = self.existing_geofence.area_sqkm + + # Replace with a larger polygon + large_polygon = { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [105.0, 0.0], + [105.0, 5.0], + [100.0, 5.0], + [100.0, 0.0], + ] + ], + } + feature_input = { + "type": "Feature", + "geometry": large_polygon, + "properties": { + "name": "OGC PUT Area Recompute", + }, + } + + result = service.replace_geofence_feature(self.existing_geofence.uuid, feature_input) + + new_area = result["properties"]["area_sqkm"] + self.assertGreater(new_area, original_area) + + def test_put_missing_feature_returns_error(self): + """PUT on nonexistent UUID raises MissingError.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": {"name": "No Such Feature"}, + } + + with self.assertRaises(MissingError): + service.replace_geofence_feature("nonexistent-uuid-99999", feature_input) + + # --- DELETE --- + + def test_delete_soft_deletes(self): + """DELETE sets active=False; subsequent GET returns 404.""" + # Create a geofence specifically for deletion + geofence = self.Geofence.create( + { + "name": "OGC Delete Target", + "geometry": json.dumps(SAMPLE_POLYGON), + "geofence_type": "custom", + } + ) + uuid = geofence.uuid + + service = self._make_service() + service.delete_geofence_feature(uuid) + + # Verify soft-deleted + geofence.invalidate_recordset() + self.assertFalse(geofence.with_context(active_test=False).active) + + # GET should now return 404 + with self.assertRaises(MissingError): + service.get_collection_item("geofences", uuid) + + def test_delete_missing_feature_returns_error(self): + """DELETE on nonexistent UUID raises MissingError.""" + service = self._make_service() + + with self.assertRaises(MissingError): + service.delete_geofence_feature("nonexistent-uuid-99999") + + def test_delete_allowed_when_no_program_module(self): + """DELETE succeeds when spp_program_geofence is not installed.""" + # spp_program_geofence is not a dependency of spp_api_v2_gis, + # so spp.program should not have geofence_ids in this test env. + # _check_geofence_not_referenced should be a no-op. + geofence = self.Geofence.create( + { + "name": "OGC Delete No Program", + "geometry": json.dumps(SAMPLE_POLYGON), + "geofence_type": "custom", + } + ) + uuid = geofence.uuid + + service = self._make_service() + service.delete_geofence_feature(uuid) + + geofence.invalidate_recordset() + self.assertFalse(geofence.with_context(active_test=False).active) + + def test_delete_blocked_when_referenced_by_program(self): + """DELETE raises ValueError if geofence is linked to a program.""" + geofence = self.Geofence.create( + { + "name": "OGC Delete Referenced", + "geometry": json.dumps(SAMPLE_POLYGON), + "geofence_type": "custom", + } + ) + + service = self._make_service() + + # Patch _check_geofence_not_referenced to simulate a program reference + original_check = service._check_geofence_not_referenced + + def mock_check(gf): + raise ValueError("Cannot delete geofence: referenced by program(s): Test Program") + + service._check_geofence_not_referenced = mock_check + + with self.assertRaises(ValueError) as cm: + service.delete_geofence_feature(geofence.uuid) + + self.assertIn("referenced by program", str(cm.exception)) + + # Verify geofence is still active (delete was blocked) + geofence.invalidate_recordset() + self.assertTrue(geofence.active) + + # Restore and verify normal delete still works + service._check_geofence_not_referenced = original_check + service.delete_geofence_feature(geofence.uuid) + geofence.invalidate_recordset() + self.assertFalse(geofence.with_context(active_test=False).active) diff --git a/spp_api_v2_gis/tests/test_ogc_incidents.py b/spp_api_v2_gis/tests/test_ogc_incidents.py new file mode 100644 index 000000000..50be3e882 --- /dev/null +++ b/spp_api_v2_gis/tests/test_ogc_incidents.py @@ -0,0 +1,528 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for OGC API - Features: Incidents collection. + +Tests cover: +- Collection discovery and metadata +- GET items with filters (event, severity, status, datetime, bbox) +- GET single item by UUID +- POST (create incident from alert) +- PUT (update incident) +- Duplicate detection (409 Conflict) +- Scope enforcement +""" + +import json +import logging + +from odoo.exceptions import MissingError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + +SAMPLE_POLYGON = { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], +} + +SAMPLE_POLYGON_2 = { + "type": "Polygon", + "coordinates": [ + [ + [102.0, 2.0], + [103.0, 2.0], + [103.0, 3.0], + [102.0, 3.0], + [102.0, 2.0], + ] + ], +} + + +@tagged("post_install", "-at_install") +class TestOGCIncidentRead(TransactionCase): + """Read path tests for incidents OGC collection.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + VocabCode = cls.env["spp.vocabulary.code"] + cls.severity_extreme = VocabCode.get_code("urn:oasis:names:tc:cap:severity", "extreme") + cls.severity_severe = VocabCode.get_code("urn:oasis:names:tc:cap:severity", "severe") + + Incident = cls.env["spp.hazard.incident"] + + # Create incidents via create_from_alert for realistic test data + cls.incident1 = Incident.create_from_alert( + SAMPLE_POLYGON, + { + "event": "Flood", + "headline": "OGC Test Flood", + "severity": "extreme", + "urgency": "immediate", + "source": "Test Agency", + "source_alert_id": "OGC-INC-001", + "effective": "2026-04-01T00:00:00Z", + "expires": "2026-04-15T00:00:00Z", + }, + ) + + cls.incident2 = Incident.create_from_alert( + SAMPLE_POLYGON_2, + { + "event": "Drought", + "headline": "OGC Test Drought", + "severity": "severe", + "source_alert_id": "OGC-INC-002", + "effective": "2026-03-01T00:00:00Z", + }, + ) + + def _make_service(self, base_url="http://localhost:8069/api/v2/spp"): + from ..services.ogc_service import OGCService + + return OGCService(self.env, base_url) + + # --- Collection discovery --- + + def test_collections_includes_incidents(self): + """GET /collections must include an 'incidents' collection.""" + service = self._make_service() + result = service.get_collections() + + ids = [c["id"] for c in result["collections"]] + self.assertIn("incidents", ids) + + def test_incidents_collection_has_items_link(self): + """Incidents collection must have an 'items' link.""" + service = self._make_service() + collection = service.get_collection("incidents") + + link_rels = [link["rel"] for link in collection["links"]] + self.assertIn("items", link_rels) + self.assertIn("self", link_rels) + + # --- _parse_collection_id --- + + def test_parse_collection_id_incidents(self): + """'incidents' must parse as ('incident', None, None).""" + service = self._make_service() + layer_type, layer_id, admin_level = service._parse_collection_id("incidents") + + self.assertEqual(layer_type, "incident") + self.assertIsNone(layer_id) + self.assertIsNone(admin_level) + + # --- GET items --- + + def test_get_items_returns_feature_collection(self): + """GET items returns GeoJSON FeatureCollection.""" + service = self._make_service() + result = service.get_collection_items("incidents") + + self.assertEqual(result["type"], "FeatureCollection") + self.assertIn("numberMatched", result) + self.assertGreaterEqual(result["numberMatched"], 2) + + def test_get_items_features_have_top_level_id(self): + """Each feature must have a top-level 'id' (UUID).""" + service = self._make_service() + result = service.get_collection_items("incidents") + + for feature in result["features"]: + self.assertIn("id", feature) + self.assertIsNotNone(feature["id"]) + + def test_get_items_features_have_cap_properties(self): + """Features must include CAP-aligned properties.""" + service = self._make_service() + result = service.get_collection_items("incidents") + + self.assertGreater(len(result["features"]), 0) + props = result["features"][0]["properties"] + + expected_keys = ["code", "event", "severity", "headline", "status"] + for key in expected_keys: + self.assertIn(key, props, f"Missing property: {key}") + + def test_get_items_filter_by_event(self): + """event filter returns only matching incidents.""" + service = self._make_service() + result = service.get_collection_items("incidents", event="Flood") + + for feature in result["features"]: + self.assertEqual(feature["properties"]["event"], "Flood") + + def test_get_items_filter_by_severity(self): + """severity filter returns only matching incidents.""" + service = self._make_service() + result = service.get_collection_items("incidents", severity="extreme") + + for feature in result["features"]: + self.assertEqual(feature["properties"]["severity"], "extreme") + + def test_get_items_filter_by_status(self): + """status filter returns only matching incidents.""" + service = self._make_service() + result = service.get_collection_items("incidents", incident_status="active") + + for feature in result["features"]: + self.assertEqual(feature["properties"]["status"], "active") + + def test_get_items_pagination(self): + """GET items respects limit and offset.""" + service = self._make_service() + result = service.get_collection_items("incidents", limit=1, offset=0) + + self.assertEqual(result["numberReturned"], 1) + self.assertGreaterEqual(result["numberMatched"], 2) + + # --- GET single item --- + + def test_get_item_by_uuid(self): + """GET single item by UUID returns the correct feature.""" + service = self._make_service() + feature = service.get_collection_item("incidents", self.incident1.uuid) + + self.assertEqual(feature["type"], "Feature") + self.assertEqual(feature["id"], self.incident1.uuid) + self.assertEqual(feature["properties"]["headline"], "OGC Test Flood") + + def test_get_item_has_geometry(self): + """GET single item has geometry from linked geofence.""" + service = self._make_service() + feature = service.get_collection_item("incidents", self.incident1.uuid) + + self.assertIsNotNone(feature["geometry"]) + self.assertEqual(feature["geometry"]["type"], "Polygon") + + def test_get_item_nonexistent_returns_404(self): + """GET single item with bad UUID returns MissingError.""" + service = self._make_service() + + with self.assertRaises(MissingError): + service.get_collection_item("incidents", "nonexistent-uuid-12345") + + +@tagged("post_install", "-at_install") +class TestOGCIncidentWrite(TransactionCase): + """Write path tests for incidents OGC collection.""" + + def _make_service(self, base_url="http://localhost:8069/api/v2/spp"): + from ..services.ogc_service import OGCService + + return OGCService(self.env, base_url) + + # --- POST (create) --- + + def test_post_creates_incident(self): + """POST creates an incident and returns 201-style result.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": { + "event": "Flood", + "headline": "OGC POST Test Flood", + "severity": "extreme", + "source_alert_id": "POST-TEST-001", + }, + } + + result = service.create_incident_feature(feature_input) + + self.assertEqual(result["feature"]["type"], "Feature") + self.assertIn("id", result["feature"]) + self.assertEqual(result["feature"]["properties"]["headline"], "OGC POST Test Flood") + self.assertIsNotNone(result["location"]) + + def test_post_missing_geometry_returns_error(self): + """POST without geometry raises ValueError.""" + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": None, + "properties": { + "event": "Flood", + "headline": "No Geometry Test", + }, + } + + with self.assertRaises(ValueError): + service.create_incident_feature(feature_input) + + def test_post_duplicate_source_alert_id_returns_409(self): + """POST with existing source_alert_id raises DuplicateAlertError.""" + from ..services.ogc_service import DuplicateAlertError + + service = self._make_service() + feature_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": { + "event": "Flood", + "headline": "First Alert", + "source_alert_id": "DUP-TEST-001", + }, + } + + # First POST succeeds + result = service.create_incident_feature(feature_input) + self.assertIsNotNone(result["feature"]) + + # Second POST with same source_alert_id returns 409 + feature_input["properties"]["headline"] = "Duplicate Alert" + with self.assertRaises(DuplicateAlertError) as cm: + service.create_incident_feature(feature_input) + + self.assertIn("DUP-TEST-001", str(cm.exception)) + self.assertIsNotNone(cm.exception.location) + + # --- PUT (update) --- + + def test_put_updates_incident(self): + """PUT updates incident properties.""" + service = self._make_service() + + # Create first + create_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": { + "event": "Storm", + "headline": "Initial Storm", + "source_alert_id": "PUT-TEST-001", + }, + } + create_result = service.create_incident_feature(create_input) + uuid = create_result["feature"]["id"] + + # Update + update_input = { + "type": "Feature", + "geometry": None, + "properties": { + "event": "Storm", + "headline": "Updated Storm", + "severity": "extreme", + }, + } + result = service.replace_incident_feature(uuid, update_input) + + self.assertEqual(result["properties"]["headline"], "Updated Storm") + self.assertEqual(result["properties"]["severity"], "extreme") + + def test_put_with_geometry_updates_geofence(self): + """PUT with geometry updates the linked geofence.""" + service = self._make_service() + + create_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON, + "properties": { + "event": "Storm", + "headline": "Geometry Update Test", + "source_alert_id": "PUT-GEO-001", + }, + } + create_result = service.create_incident_feature(create_input) + uuid = create_result["feature"]["id"] + + # Update with new geometry + update_input = { + "type": "Feature", + "geometry": SAMPLE_POLYGON_2, + "properties": { + "event": "Storm", + "headline": "Geometry Updated", + }, + } + result = service.replace_incident_feature(uuid, update_input) + + self.assertIsNotNone(result["geometry"]) + + def test_put_nonexistent_returns_404(self): + """PUT on nonexistent UUID raises MissingError.""" + service = self._make_service() + update_input = { + "type": "Feature", + "geometry": None, + "properties": { + "event": "Storm", + "headline": "Not Found", + }, + } + + with self.assertRaises(MissingError): + service.replace_incident_feature("nonexistent-uuid-99999", update_input) + + +@tagged("post_install", "-at_install") +class TestOGCDatetimeParsing(TransactionCase): + """Tests for OGC datetime parameter parsing.""" + + def _make_service(self): + from ..services.ogc_service import OGCService + + return OGCService(self.env) + + def test_instant(self): + """Single datetime returns same value for start and end.""" + service = self._make_service() + start, end = service._parse_datetime_param("2026-04-01T00:00:00Z") + self.assertEqual(start, "2026-04-01T00:00:00Z") + self.assertEqual(end, "2026-04-01T00:00:00Z") + + def test_bounded_interval(self): + """start/end returns both values.""" + service = self._make_service() + start, end = service._parse_datetime_param("2026-01-01/2026-06-01") + self.assertEqual(start, "2026-01-01") + self.assertEqual(end, "2026-06-01") + + def test_open_start(self): + """../end returns None for start.""" + service = self._make_service() + start, end = service._parse_datetime_param("../2026-06-01") + self.assertIsNone(start) + self.assertEqual(end, "2026-06-01") + + def test_open_end(self): + """start/.. returns None for end.""" + service = self._make_service() + start, end = service._parse_datetime_param("2026-01-01/..") + self.assertEqual(start, "2026-01-01") + self.assertIsNone(end) + + +@tagged("post_install", "-at_install") +class TestIncidentScopeEnforcement(TransactionCase): + """Tests that scope checks block or allow incident write operations.""" + + def _make_client(self, scopes): + """Create an spp.api.client record with the given scopes. + + Args: + scopes: List of {"resource": str, "action": str} dicts + + Returns: + spp.api.client record + """ + partner = self.env["res.partner"].create({"name": "Scope Test Org"}) + org_type = self.env.ref("spp_consent.org_type_government", raise_if_not_found=False) + if not org_type: + org_type = self.env["spp.consent.org.type"].search([("code", "=", "government")], limit=1) + if not org_type: + org_type = self.env["spp.consent.org.type"].create({"name": "Government", "code": "government"}) + client = self.env["spp.api.client"].create( + { + "name": "Scope Test Client", + "partner_id": partner.id, + "organization_type_id": org_type.id, + } + ) + for scope_def in scopes: + self.env["spp.api.client.scope"].create( + { + "client_id": client.id, + "resource": scope_def["resource"], + "action": scope_def["action"], + } + ) + return client + + def test_gis_read_scope_cannot_post_incident(self): + """A client with only gis:read scope must be denied gis:incident write access.""" + from fastapi import HTTPException + + from ..routers.ogc_features import _check_gis_incident_scope + + client = self._make_client([{"resource": "gis", "action": "read"}]) + + self.assertFalse(client.has_scope("gis", "incident")) + with self.assertRaises(HTTPException) as cm: + _check_gis_incident_scope(client) + self.assertEqual(cm.exception.status_code, 403) + + def test_gis_geofence_scope_cannot_post_incident(self): + """A client with only gis:geofence scope must be denied gis:incident write access.""" + from fastapi import HTTPException + + from ..routers.ogc_features import _check_gis_incident_scope + + client = self._make_client([{"resource": "gis", "action": "geofence"}]) + + self.assertFalse(client.has_scope("gis", "incident")) + with self.assertRaises(HTTPException) as cm: + _check_gis_incident_scope(client) + self.assertEqual(cm.exception.status_code, 403) + + def test_gis_incident_scope_allows_post_incident(self): + """A client with gis:incident scope must pass the incident scope check.""" + from ..routers.ogc_features import _check_gis_incident_scope + + client = self._make_client([{"resource": "gis", "action": "incident"}]) + + self.assertTrue(client.has_scope("gis", "incident")) + # Must not raise + _check_gis_incident_scope(client) + + +@tagged("post_install", "-at_install") +class TestOGCGeofenceIncidentFilter(TransactionCase): + """Tests for incident_code filter on geofences collection.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + Incident = cls.env["spp.hazard.incident"] + + cls.incident = Incident.create_from_alert( + SAMPLE_POLYGON, + { + "event": "Flood", + "headline": "Geofence Filter Test", + "source_alert_id": "GF-FILTER-001", + }, + ) + + # Create an unrelated geofence + cls.env["spp.gis.geofence"].create( + { + "name": "Unrelated Geofence", + "geometry": json.dumps(SAMPLE_POLYGON_2), + "geofence_type": "custom", + } + ) + + def _make_service(self, base_url="http://localhost:8069/api/v2/spp"): + from ..services.ogc_service import OGCService + + return OGCService(self.env, base_url) + + def test_incident_code_filter_returns_linked_geofences(self): + """incident_code filter returns only geofences linked to that incident.""" + service = self._make_service() + result = service.get_collection_items("geofences", incident_code="GF-FILTER-001") + + self.assertGreaterEqual(result["numberReturned"], 1) + for feature in result["features"]: + # All returned geofences should have hazard_zone type + self.assertEqual(feature["properties"]["geofence_type"], "hazard_zone") + + def test_incident_code_filter_no_match_returns_empty(self): + """incident_code filter with nonexistent code returns empty collection.""" + service = self._make_service() + result = service.get_collection_items("geofences", incident_code="NONEXISTENT-CODE") + + self.assertEqual(result["numberReturned"], 0) diff --git a/spp_api_v2_gis/tests/test_ogc_processes.py b/spp_api_v2_gis/tests/test_ogc_processes.py new file mode 100644 index 000000000..2dfe66ba8 --- /dev/null +++ b/spp_api_v2_gis/tests/test_ogc_processes.py @@ -0,0 +1,1196 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for OGC API - Processes endpoints. + +Covers process registry, schemas, job model, and HTTP integration. +""" + +import json +import logging +import os +import unittest + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.spp_api_v2.tests.common import ApiV2HttpTestCase + +_logger = logging.getLogger(__name__) + +API_BASE = "/api/v2/spp" +OGC_BASE = f"{API_BASE}/gis/ogc" + + +class TestProcessRegistry(TransactionCase): + """Unit tests for ProcessRegistry service.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create test indicator data + Category = cls.env["spp.metric.category"] + cls.category = Category.search([("code", "=", "test_proc_category")], limit=1) + if not cls.category: + cls.category = Category.create( + { + "name": "Test Process Category", + "code": "test_proc_category", + "sequence": 50, + } + ) + + cls.cel_variable = cls.env["spp.cel.variable"].create( + { + "name": "test_proc_var", + "cel_accessor": "test_proc_accessor", + "source_type": "computed", + "cel_expression": "true", + "value_type": "number", + "state": "active", + } + ) + + cls.indicator = cls.env["spp.indicator"].create( + { + "name": "proc_test_stat", + "label": "Process Test Stat", + "description": "A test statistic for process tests", + "variable_id": cls.cel_variable.id, + "format": "count", + "unit": "people", + "is_published_gis": True, + "category_id": cls.category.id, + } + ) + + def test_list_processes_returns_two_processes(self): + """Registry returns spatial-statistics and proximity-statistics.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + processes = registry.list_processes() + + self.assertEqual(len(processes), 2) + ids = {p["id"] for p in processes} + self.assertEqual(ids, {"spatial-statistics", "proximity-statistics"}) + + def test_get_process_spatial_statistics(self): + """spatial-statistics description includes inputs and outputs.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + desc = registry.get_process("spatial-statistics") + + self.assertIsNotNone(desc) + self.assertEqual(desc["id"], "spatial-statistics") + self.assertIn("geometry", desc["inputs"]) + self.assertIn("variables", desc["inputs"]) + self.assertIn("filters", desc["inputs"]) + self.assertIn("result", desc["outputs"]) + + def test_get_process_proximity_statistics(self): + """proximity-statistics description includes inputs and outputs.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + desc = registry.get_process("proximity-statistics") + + self.assertIsNotNone(desc) + self.assertEqual(desc["id"], "proximity-statistics") + self.assertIn("reference_points", desc["inputs"]) + self.assertIn("radius_km", desc["inputs"]) + self.assertIn("relation", desc["inputs"]) + + def test_get_process_unknown_returns_none(self): + """Unknown process ID returns None.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + self.assertIsNone(registry.get_process("nonexistent")) + + def test_variables_enum_reflects_indicators(self): + """Variables input enum includes published spp.statistic names.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + desc = registry.get_process("spatial-statistics") + + variables_input = desc["inputs"]["variables"] + schema = variables_input["schema"] + items = schema["items"] + + # Should have enum with at least our test indicator + self.assertIn("enum", items) + self.assertIn("proc_test_stat", items["enum"]) + + def test_x_openspp_statistics_extension(self): + """Process description includes x-openspp-statistics for UI metadata.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + desc = registry.get_process("spatial-statistics") + + variables_input = desc["inputs"]["variables"] + self.assertIn("x-openspp-statistics", variables_input) + + categories = variables_input["x-openspp-statistics"]["categories"] + self.assertIsInstance(categories, list) + self.assertGreater(len(categories), 0) + + # Find our test category + test_cats = [c for c in categories if c["code"] == "test_proc_category"] + self.assertEqual(len(test_cats), 1) + self.assertEqual(test_cats[0]["name"], "Test Process Category") + + # Category should contain our indicator + stat_names = [s["name"] for s in test_cats[0]["statistics"]] + self.assertIn("proc_test_stat", stat_names) + + def test_get_statistics_metadata(self): + """get_statistics_metadata returns variable names and categories.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + variable_names, categories = registry.get_statistics_metadata() + + self.assertIn("proc_test_stat", variable_names) + self.assertIsInstance(categories, list) + self.assertGreater(len(categories), 0) + + +class TestProcessSchemas(TransactionCase): + """Unit tests for Pydantic process schemas.""" + + def test_process_summary_creation(self): + """ProcessSummary validates correctly.""" + from ..schemas.processes import ProcessSummary + + summary = ProcessSummary( + id="test", + title="Test Process", + description="A test", + version="1.0.0", + jobControlOptions=["sync-execute"], + ) + self.assertEqual(summary.id, "test") + self.assertEqual(summary.jobControlOptions, ["sync-execute"]) + + def test_execute_request_minimal(self): + """ExecuteRequest works with just inputs.""" + from ..schemas.processes import ExecuteRequest + + req = ExecuteRequest(inputs={"geometry": {"type": "Polygon", "coordinates": []}}) + self.assertIsNotNone(req.inputs) + self.assertIsNone(req.outputs) + self.assertIsNone(req.response) + + def test_status_info_serialization(self): + """StatusInfo serializes with camelCase aliases.""" + from ..schemas.processes import StatusInfo + + info = StatusInfo( + jobID="abc-123", + processID="spatial-statistics", + status="accepted", + ) + dumped = info.model_dump(by_alias=True) + self.assertIn("jobID", dumped) + self.assertIn("processID", dumped) + self.assertEqual(dumped["jobID"], "abc-123") + + def test_job_list_creation(self): + """JobList contains StatusInfo objects.""" + from ..schemas.processes import JobList, StatusInfo + + job_list = JobList( + jobs=[ + StatusInfo(jobID="j1", status="accepted"), + StatusInfo(jobID="j2", status="running"), + ] + ) + self.assertEqual(len(job_list.jobs), 2) + + +class TestGisProcessJobModel(TransactionCase): + """Unit tests for spp.gis.process.job Odoo model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a test API client + partner = cls.env["res.partner"].create({"name": "Test Process Client"}) + org_type = cls.env.ref("spp_consent.org_type_government", raise_if_not_found=False) + if not org_type: + org_type = cls.env["spp.consent.org.type"].search([("code", "=", "government")], limit=1) + if not org_type: + org_type = cls.env["spp.consent.org.type"].create({"name": "Government", "code": "government"}) + + cls.api_client = cls.env["spp.api.client"].create( + { + "name": "Process Test Client", + "partner_id": partner.id, + "organization_type_id": org_type.id, + } + ) + + def _create_job(self, **kwargs): + """Helper to create a job record.""" + import uuid + + vals = { + "job_id": str(uuid.uuid4()), + "process_id": "spatial-statistics", + "client_id": self.api_client.id, + "inputs": {"geometry": {"type": "Polygon", "coordinates": []}}, + } + vals.update(kwargs) + return self.env["spp.gis.process.job"].create(vals) + + def test_create_job_defaults(self): + """New job has default status=accepted and progress=0.""" + job = self._create_job() + self.assertEqual(job.status, "accepted") + self.assertEqual(job.progress, 0) + self.assertFalse(job.started_at) + self.assertFalse(job.finished_at) + + def test_batch_progress_callback_none_for_non_batch(self): + """No progress callback is produced when geometry is not a list.""" + job = self._create_job() + self.assertIsNone(job._make_batch_progress_callback({"geometry": {"type": "Polygon"}})) + self.assertIsNone(job._make_batch_progress_callback({})) + + def test_batch_progress_callback_writes_throttled_progress(self): + """The batch callback writes progress only when the percentage changes.""" + job = self._create_job() + callback = job._make_batch_progress_callback({"geometry": [1, 2, 3, 4]}) + self.assertTrue(callable(callback)) + + callback(2) # 2/4 -> 50% + self.assertEqual(job.progress, 50) + + callback(2) # same percentage -> throttled, no change + self.assertEqual(job.progress, 50) + + callback(4) # 4/4 -> 100% + self.assertEqual(job.progress, 100) + + def test_dismiss_accepted_job(self): + """Dismissing an accepted job sets status to dismissed.""" + job = self._create_job() + job.dismiss() + self.assertEqual(job.status, "dismissed") + self.assertTrue(job.finished_at) + + def test_dismiss_running_job_raises(self): + """Dismissing a running job raises UserError.""" + from odoo.exceptions import UserError + + job = self._create_job() + job.status = "running" + + with self.assertRaises(UserError): + job.dismiss() + + def test_dismiss_successful_job_deletes(self): + """Dismissing a terminal job deletes the record.""" + job = self._create_job() + job.status = "successful" + job_id = job.id + + job.dismiss() + + # Record should be deleted + self.assertFalse(self.env["spp.gis.process.job"].browse(job_id).exists()) + + def test_dismiss_failed_job_deletes(self): + """Dismissing a failed job deletes the record.""" + job = self._create_job() + job.status = "failed" + job_id = job.id + + job.dismiss() + self.assertFalse(self.env["spp.gis.process.job"].browse(job_id).exists()) + + def test_cron_cleanup_stale_jobs(self): + """Cron marks stale accepted/running jobs as failed.""" + from datetime import datetime, timedelta + + job = self._create_job() + # Backdate the create_date to 2 hours ago + two_hours_ago = datetime.now() - timedelta(hours=2) + self.env.cr.execute( + "UPDATE spp_gis_process_job SET create_date = %s WHERE id = %s", + (two_hours_ago, job.id), + ) + job.invalidate_recordset() + + self.env["spp.gis.process.job"].cron_cleanup_jobs() + + job.invalidate_recordset() + self.assertEqual(job.status, "failed") + self.assertEqual(job.message, "Job timed out (stale)") + + def test_cron_cleanup_old_jobs(self): + """Cron deletes jobs older than retention period.""" + from datetime import datetime, timedelta + + job = self._create_job() + job.status = "successful" + # Backdate to 10 days ago + ten_days_ago = datetime.now() - timedelta(days=10) + self.env.cr.execute( + "UPDATE spp_gis_process_job SET create_date = %s WHERE id = %s", + (ten_days_ago, job.id), + ) + job.invalidate_recordset() + job_id = job.id + + self.env["spp.gis.process.job"].cron_cleanup_jobs() + + self.assertFalse(self.env["spp.gis.process.job"].browse(job_id).exists()) + + def test_execute_process_unknown_process(self): + """execute_process with unknown process_id sets status to failed.""" + job = self._create_job(process_id="nonexistent-process") + job.execute_process() + + self.assertEqual(job.status, "failed") + self.assertIn("Process execution failed", job.message) + + +class TestOGCConformanceUpdated(TransactionCase): + """Test that OGC conformance now includes Processes classes.""" + + def test_conformance_includes_processes(self): + """Conformance declaration includes OGC API - Processes classes.""" + from ..services.ogc_service import CONFORMANCE_CLASSES + + processes_classes = [c for c in CONFORMANCE_CLASSES if "processes" in c] + self.assertGreaterEqual(len(processes_classes), 5) + self.assertIn("http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core", CONFORMANCE_CLASSES) + self.assertIn("http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/json", CONFORMANCE_CLASSES) + self.assertIn("http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/job-list", CONFORMANCE_CLASSES) + self.assertIn("http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/dismiss", CONFORMANCE_CLASSES) + self.assertIn( + "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/ogc-process-description", + CONFORMANCE_CLASSES, + ) + + def test_landing_page_includes_processes_link(self): + """Landing page includes link to /gis/ogc/processes.""" + from ..services.ogc_service import OGCService + + service = OGCService(self.env, base_url="http://test") + landing = service.get_landing_page() + + link_rels = [link["rel"] for link in landing["links"]] + self.assertIn("http://www.opengis.net/def/rel/ogc/1.0/processes", link_rels) + + +class TestProcessInputValidation(TransactionCase): + """Unit tests for input validation logic in the processes router.""" + + def test_validate_spatial_single_geometry(self): + """Single GeoJSON geometry is valid.""" + from ..routers.processes import _validate_spatial_statistics_inputs + + # Should not raise + _validate_spatial_statistics_inputs( + {"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]}} + ) + + def test_validate_spatial_batch_with_id_value(self): + """Batch with {id, value} wrapper is valid.""" + from ..routers.processes import _validate_spatial_statistics_inputs + + _validate_spatial_statistics_inputs( + { + "geometry": [ + {"id": "a1", "value": {"type": "Polygon", "coordinates": []}}, + {"id": "a2", "value": {"type": "Polygon", "coordinates": []}}, + ] + } + ) + + def test_validate_spatial_bare_array_rejected(self): + """Bare geometry arrays (no {id, value} wrapper) are rejected.""" + from fastapi import HTTPException + + from ..routers.processes import _validate_spatial_statistics_inputs + + with self.assertRaises(HTTPException) as ctx: + _validate_spatial_statistics_inputs( + { + "geometry": [ + {"type": "Polygon", "coordinates": []}, + ] + } + ) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("id", ctx.exception.detail) + + def test_validate_spatial_empty_array_rejected(self): + """Empty geometry array is rejected.""" + from fastapi import HTTPException + + from ..routers.processes import _validate_spatial_statistics_inputs + + with self.assertRaises(HTTPException) as ctx: + _validate_spatial_statistics_inputs({"geometry": []}) + self.assertEqual(ctx.exception.status_code, 400) + + def test_validate_spatial_max_geometries(self): + """More than 100 geometries is rejected.""" + from fastapi import HTTPException + + from ..routers.processes import _validate_spatial_statistics_inputs + + geometries = [{"id": f"g{i}", "value": {"type": "Polygon"}} for i in range(101)] + with self.assertRaises(HTTPException) as ctx: + _validate_spatial_statistics_inputs({"geometry": geometries}) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("100", ctx.exception.detail) + + def test_validate_spatial_missing_geometry(self): + """Missing geometry input is rejected.""" + from fastapi import HTTPException + + from ..routers.processes import _validate_spatial_statistics_inputs + + with self.assertRaises(HTTPException) as ctx: + _validate_spatial_statistics_inputs({}) + self.assertEqual(ctx.exception.status_code, 400) + + def test_validate_proximity_valid(self): + """Valid proximity inputs pass validation.""" + from ..routers.processes import _validate_proximity_statistics_inputs + + _validate_proximity_statistics_inputs( + { + "reference_points": [{"longitude": 1.0, "latitude": 2.0}], + "radius_km": 10.0, + } + ) + + def test_validate_proximity_missing_radius(self): + """Missing radius_km is rejected.""" + from fastapi import HTTPException + + from ..routers.processes import _validate_proximity_statistics_inputs + + with self.assertRaises(HTTPException) as ctx: + _validate_proximity_statistics_inputs({"reference_points": [{"longitude": 1.0, "latitude": 2.0}]}) + self.assertEqual(ctx.exception.status_code, 400) + + def test_validate_proximity_invalid_relation(self): + """Invalid relation value is rejected.""" + from fastapi import HTTPException + + from ..routers.processes import _validate_proximity_statistics_inputs + + with self.assertRaises(HTTPException) as ctx: + _validate_proximity_statistics_inputs( + { + "reference_points": [{"longitude": 1.0, "latitude": 2.0}], + "radius_km": 10.0, + "relation": "intersects", + } + ) + self.assertEqual(ctx.exception.status_code, 400) + + def test_validate_proximity_radius_too_large(self): + """radius_km > 500 is rejected.""" + from fastapi import HTTPException + + from ..routers.processes import _validate_proximity_statistics_inputs + + with self.assertRaises(HTTPException) as ctx: + _validate_proximity_statistics_inputs( + { + "reference_points": [{"longitude": 1.0, "latitude": 2.0}], + "radius_km": 501, + } + ) + self.assertEqual(ctx.exception.status_code, 400) + + +@tagged("post_install", "-at_install") +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "Skipped via SKIP_HTTP_CASE") +class TestOGCProcessesHTTP(ApiV2HttpTestCase): + """HTTP integration tests for OGC Processes endpoints.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create API client with gis:read scope + cls.gis_client = cls.create_api_client( + cls, + name="GIS Processes Client", + scopes=[{"resource": "gis", "action": "read"}], + ) + cls.gis_token = cls.generate_jwt_token(cls, cls.gis_client) + + # Client without gis scope + cls.no_gis_client = cls.create_api_client( + cls, + name="No GIS Processes Client", + scopes=[{"resource": "individual", "action": "read"}], + ) + cls.no_gis_token = cls.generate_jwt_token(cls, cls.no_gis_client) + + # Create a second client for job scoping tests + cls.other_client = cls.create_api_client( + cls, + name="Other GIS Client", + scopes=[{"resource": "gis", "action": "read"}], + ) + cls.other_token = cls.generate_jwt_token(cls, cls.other_client) + + # Create test indicator for enum tests + Category = cls.env["spp.metric.category"] + cls.test_category = Category.search([("code", "=", "http_proc_cat")], limit=1) + if not cls.test_category: + cls.test_category = Category.create({"name": "HTTP Proc Category", "code": "http_proc_cat", "sequence": 60}) + + cls.cel_variable = cls.env["spp.cel.variable"].create( + { + "name": "http_proc_var", + "cel_accessor": "http_proc_accessor", + "source_type": "computed", + "cel_expression": "true", + "value_type": "number", + "state": "active", + } + ) + + cls.test_indicator = cls.env["spp.indicator"].create( + { + "name": "http_proc_test_stat", + "label": "HTTP Process Test Stat", + "variable_id": cls.cel_variable.id, + "format": "count", + "is_published_gis": True, + "category_id": cls.test_category.id, + } + ) + + def _gis_headers(self): + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.gis_token}", + } + + def _no_gis_headers(self): + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.no_gis_token}", + } + + def _other_headers(self): + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.other_token}", + } + + # === Process list === + + def test_list_processes_returns_200(self): + """GET /processes returns 200 with process list.""" + response = self.url_open(f"{OGC_BASE}/processes", headers=self._gis_headers()) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("processes", data) + self.assertEqual(len(data["processes"]), 2) + + def test_list_processes_no_scope_returns_403(self): + """GET /processes without gis scope returns 403.""" + response = self.url_open(f"{OGC_BASE}/processes", headers=self._no_gis_headers()) + self.assertEqual(response.status_code, 403) + + # === Process description === + + def test_process_description_spatial_returns_200(self): + """GET /processes/spatial-statistics returns 200.""" + response = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics", + headers=self._gis_headers(), + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["id"], "spatial-statistics") + self.assertIn("inputs", data) + self.assertIn("outputs", data) + + def test_process_description_proximity_returns_200(self): + """GET /processes/proximity-statistics returns 200.""" + response = self.url_open( + f"{OGC_BASE}/processes/proximity-statistics", + headers=self._gis_headers(), + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["id"], "proximity-statistics") + + def test_process_description_unknown_returns_404(self): + """GET /processes/nonexistent returns 404.""" + response = self.url_open( + f"{OGC_BASE}/processes/nonexistent", + headers=self._gis_headers(), + ) + self.assertEqual(response.status_code, 404) + + def test_process_description_includes_indicator_enum(self): + """Process description includes indicator names in variables enum.""" + response = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics", + headers=self._gis_headers(), + ) + data = response.json() + variables = data["inputs"]["variables"] + schema_items = variables["schema"]["items"] + self.assertIn("enum", schema_items) + self.assertIn("http_proc_test_stat", schema_items["enum"]) + + def test_process_description_includes_x_openspp_statistics(self): + """Process description includes x-openspp-statistics extension.""" + response = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics", + headers=self._gis_headers(), + ) + data = response.json() + variables = data["inputs"]["variables"] + self.assertIn("x-openspp-statistics", variables) + + # === Execution: invalid inputs === + + def test_execute_unknown_process_returns_404(self): + """POST /processes/nonexistent/execution returns 404.""" + response = self.url_open( + f"{OGC_BASE}/processes/nonexistent/execution", + data=json.dumps({"inputs": {}}), + headers=self._gis_headers(), + ) + self.assertEqual(response.status_code, 404) + + def test_execute_missing_geometry_returns_400(self): + """POST spatial-statistics without geometry returns 400.""" + response = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps({"inputs": {}}), + headers=self._gis_headers(), + ) + self.assertEqual(response.status_code, 400) + + def test_execute_bare_geometry_array_returns_400(self): + """POST spatial-statistics with bare geometry array returns 400.""" + response = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps( + { + "inputs": { + "geometry": [ + {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + ] + } + } + ), + headers=self._gis_headers(), + ) + self.assertEqual(response.status_code, 400) + + def test_execute_no_scope_returns_403(self): + """POST execution without gis scope returns 403.""" + response = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps({"inputs": {"geometry": {"type": "Polygon", "coordinates": []}}}), + headers=self._no_gis_headers(), + ) + self.assertEqual(response.status_code, 403) + + # === Execution: async flow === + + def test_async_execution_returns_201(self): + """POST with Prefer: respond-async returns 201 with Location header.""" + headers = self._gis_headers() + headers["Prefer"] = "respond-async" + + response = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps( + { + "inputs": { + "geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + } + } + ), + headers=headers, + ) + self.assertEqual(response.status_code, 201) + self.assertIn("Location", response.headers) + data = response.json() + self.assertIn("jobID", data) + self.assertEqual(data["status"], "accepted") + + def test_forced_async_for_large_batch(self): + """Batch with >10 geometries forces async regardless of Prefer header.""" + # Set threshold to 5 for testing + self.env["ir.config_parameter"].sudo().set_param("spp_gis.forced_async_threshold", "5") + + geometries = [ + {"id": f"g{i}", "value": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}} + for i in range(6) + ] + + response = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps({"inputs": {"geometry": geometries}}), + headers=self._gis_headers(), + ) + self.assertEqual(response.status_code, 201) + self.assertIn("Location", response.headers) + + # === Jobs API === + + def test_list_jobs_empty(self): + """GET /jobs returns empty list when no jobs exist for client.""" + response = self.url_open(f"{OGC_BASE}/jobs", headers=self._gis_headers()) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("jobs", data) + + def test_job_scoping(self): + """Client A cannot see Client B's jobs.""" + # Create a job via async execution for client A + headers_a = self._gis_headers() + headers_a["Prefer"] = "respond-async" + resp_a = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps( + {"inputs": {"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}}} + ), + headers=headers_a, + ) + self.assertEqual(resp_a.status_code, 201) + job_id = resp_a.json()["jobID"] + + # Client A can see the job + resp_status_a = self.url_open(f"{OGC_BASE}/jobs/{job_id}", headers=self._gis_headers()) + self.assertEqual(resp_status_a.status_code, 200) + + # Client B cannot see it + resp_status_b = self.url_open(f"{OGC_BASE}/jobs/{job_id}", headers=self._other_headers()) + self.assertEqual(resp_status_b.status_code, 404) + + def test_job_status_not_found(self): + """GET /jobs/nonexistent returns 404.""" + response = self.url_open( + f"{OGC_BASE}/jobs/00000000-0000-0000-0000-000000000000", + headers=self._gis_headers(), + ) + self.assertEqual(response.status_code, 404) + + def test_job_results_not_ready(self): + """GET /jobs/{id}/results for non-successful job returns 404.""" + # Create an async job (status=accepted) + headers = self._gis_headers() + headers["Prefer"] = "respond-async" + resp = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps( + {"inputs": {"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}}} + ), + headers=headers, + ) + job_id = resp.json()["jobID"] + + # Results not available for accepted job + resp_results = self.url_open( + f"{OGC_BASE}/jobs/{job_id}/results", + headers=self._gis_headers(), + ) + self.assertEqual(resp_results.status_code, 404) + + def test_dismiss_accepted_job(self): + """DELETE /jobs/{id} for accepted job returns 200 with dismissed status.""" + headers = self._gis_headers() + headers["Prefer"] = "respond-async" + resp = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps( + {"inputs": {"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}}} + ), + headers=headers, + ) + job_id = resp.json()["jobID"] + + # Dismiss the job + resp_delete = self.url_delete( + f"{OGC_BASE}/jobs/{job_id}", + headers=self._gis_headers(), + ) + self.assertEqual(resp_delete.status_code, 200) + data = resp_delete.json() + self.assertEqual(data.get("status"), "dismissed") + + def test_dismiss_running_job_returns_409(self): + """DELETE /jobs/{id} for running job returns 409.""" + # Create job and manually set to running + headers = self._gis_headers() + headers["Prefer"] = "respond-async" + resp = self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps( + {"inputs": {"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}}} + ), + headers=headers, + ) + job_id = resp.json()["jobID"] + + # Manually set to running + job = self.env["spp.gis.process.job"].sudo().search([("job_id", "=", job_id)], limit=1) + job.status = "running" + self.env.cr.flush() + + resp_delete = self.url_delete( + f"{OGC_BASE}/jobs/{job_id}", + headers=self._gis_headers(), + ) + self.assertEqual(resp_delete.status_code, 409) + + def test_list_jobs_with_status_filter(self): + """GET /jobs?status=accepted filters jobs.""" + # Create a job + headers = self._gis_headers() + headers["Prefer"] = "respond-async" + self.url_open( + f"{OGC_BASE}/processes/spatial-statistics/execution", + data=json.dumps( + {"inputs": {"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}}} + ), + headers=headers, + ) + + # Filter by accepted + resp = self.url_open(f"{OGC_BASE}/jobs?status=accepted", headers=self._gis_headers()) + self.assertEqual(resp.status_code, 200) + data = resp.json() + for job in data["jobs"]: + self.assertEqual(job["status"], "accepted") + + def test_list_jobs_invalid_status_returns_400(self): + """GET /jobs?status=invalid returns 400.""" + resp = self.url_open(f"{OGC_BASE}/jobs?status=invalid", headers=self._gis_headers()) + self.assertEqual(resp.status_code, 400) + + # === Conformance === + + def test_conformance_includes_processes(self): + """GET /conformance includes Processes conformance classes.""" + response = self.url_open(f"{OGC_BASE}/conformance", headers=self._gis_headers()) + self.assertEqual(response.status_code, 200) + data = response.json() + conforms = data["conformsTo"] + self.assertIn("http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core", conforms) + + # === Landing page === + + def test_landing_page_includes_processes_link(self): + """Landing page includes link to processes.""" + response = self.url_open(OGC_BASE, headers=self._gis_headers()) + self.assertEqual(response.status_code, 200) + data = response.json() + link_rels = [link["rel"] for link in data["links"]] + self.assertIn("http://www.opengis.net/def/rel/ogc/1.0/processes", link_rels) + + # === OpenAPI schema === + + def test_execute_request_inputs_documented_as_oneof(self): + """ExecuteRequest.inputs should document oneOf of typed inputs in OpenAPI. + + Regression guard for the loss of typed input documentation in commit + 46c2c364 ("revert ExecuteRequest.inputs to dict type"). + """ + response = self.url_open(f"{API_BASE}/openapi.json") + self.assertEqual(response.status_code, 200, response.text) + schema = response.json() + + components = schema["components"]["schemas"] + self.assertIn("ExecuteRequest", components) + inputs_schema = components["ExecuteRequest"]["properties"]["inputs"] + self.assertIn("oneOf", inputs_schema, "ExecuteRequest.inputs must declare oneOf") + + refs = [item.get("$ref") for item in inputs_schema["oneOf"]] + self.assertIn("#/components/schemas/SpatialStatisticsInputs", refs) + self.assertIn("#/components/schemas/ProximityStatisticsInputs", refs) + + # Both referenced models must actually be present. + self.assertIn("SpatialStatisticsInputs", components) + self.assertIn("ProximityStatisticsInputs", components) + + +class TestProcessGroupByInput(TransactionCase): + """Unit tests for group_by input and breakdown response in OGC processes.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a demographic dimension for testing + cls.gender_dimension = cls.env["spp.demographic.dimension"].search([("name", "=", "gender")], limit=1) + if not cls.gender_dimension: + cls.gender_dimension = cls.env["spp.demographic.dimension"].create( + { + "name": "gender", + "label": "Gender", + "dimension_type": "field", + "field_path": "gender_id.code", + "value_labels_json": {"1": "Male", "2": "Female"}, + "default_value": "unknown", + } + ) + + def test_spatial_statistics_includes_group_by_input(self): + """spatial-statistics process description includes group_by input.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + desc = registry.get_process("spatial-statistics") + + self.assertIn("group_by", desc["inputs"]) + group_by = desc["inputs"]["group_by"] + self.assertEqual(group_by["title"], "Disaggregation Dimensions") + self.assertEqual(group_by["minOccurs"], 0) + self.assertEqual(group_by["schema"]["type"], "array") + self.assertEqual(group_by["schema"]["maxItems"], 3) + + def test_proximity_statistics_includes_group_by_input(self): + """proximity-statistics process description includes group_by input.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + desc = registry.get_process("proximity-statistics") + + self.assertIn("group_by", desc["inputs"]) + + def test_group_by_enum_reflects_active_dimensions(self): + """group_by enum includes active dimension names.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + desc = registry.get_process("spatial-statistics") + + group_by = desc["inputs"]["group_by"] + items = group_by["schema"]["items"] + + # Should have enum with at least our test dimension + self.assertIn("enum", items) + self.assertIn("gender", items["enum"]) + + def test_group_by_x_openspp_dimensions_metadata(self): + """group_by includes x-openspp-dimensions extension with labels.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + desc = registry.get_process("spatial-statistics") + + group_by = desc["inputs"]["group_by"] + self.assertIn("x-openspp-dimensions", group_by) + + dim_metadata = group_by["x-openspp-dimensions"] + gender_dims = [d for d in dim_metadata if d["name"] == "gender"] + self.assertEqual(len(gender_dims), 1) + self.assertEqual(gender_dims[0]["label"], "Gender") + + def test_breakdown_schema_field_on_single_result(self): + """SingleStatisticsResult schema accepts optional breakdown field.""" + from ..schemas.processes import SingleStatisticsResult + + # Without breakdown + result = SingleStatisticsResult( + total_count=10, + query_method="coordinates", + areas_matched=1, + statistics={"count": 10}, + ) + self.assertIsNone(result.breakdown) + + # With breakdown + result_with = SingleStatisticsResult( + total_count=10, + query_method="coordinates", + areas_matched=1, + statistics={"count": 10}, + breakdown={"1": {"count": 6, "labels": {"gender": {"value": "1", "display": "Male"}}}}, + ) + self.assertIsNotNone(result_with.breakdown) + + def test_breakdown_schema_field_on_batch_result(self): + """BatchResultItem and BatchSummary schemas accept optional breakdown field.""" + from ..schemas.processes import BatchResultItem, BatchSummary + + item = BatchResultItem( + id="area_1", + total_count=10, + query_method="coordinates", + areas_matched=1, + statistics={}, + breakdown={"1": {"count": 6}}, + ) + self.assertIsNotNone(item.breakdown) + + summary = BatchSummary( + total_count=20, + geometries_queried=2, + statistics={}, + breakdown={"1": {"count": 12}}, + ) + self.assertIsNotNone(summary.breakdown) + + def test_breakdown_schema_field_on_proximity_result(self): + """ProximityResult schema accepts optional breakdown field.""" + from ..schemas.processes import ProximityResult + + result = ProximityResult( + total_count=10, + query_method="coordinates", + areas_matched=1, + reference_points_count=1, + radius_km=5.0, + relation="within", + statistics={}, + breakdown={"1": {"count": 6}}, + ) + self.assertIsNotNone(result.breakdown) + + def test_run_spatial_statistics_passes_group_by(self): + """run_spatial_statistics extracts and passes group_by from inputs.""" + from unittest.mock import MagicMock + + from ..services.process_execution import run_spatial_statistics + + mock_service = MagicMock() + mock_service.query_statistics.return_value = { + "total_count": 5, + "query_method": "area_fallback", + "areas_matched": 1, + "statistics": {}, + "breakdown": {"1": {"count": 3}}, + "registrant_ids": [1, 2, 3, 4, 5], + } + + result = run_spatial_statistics( + mock_service, + { + "geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + "group_by": ["gender"], + }, + ) + + mock_service.query_statistics.assert_called_once() + call_kwargs = mock_service.query_statistics.call_args + self.assertEqual(call_kwargs.kwargs.get("group_by") or call_kwargs[1].get("group_by"), ["gender"]) + self.assertEqual(result.get("breakdown"), {"1": {"count": 3}}) + + def test_run_spatial_statistics_no_group_by_backward_compatible(self): + """run_spatial_statistics without group_by passes None (backward compatible).""" + from unittest.mock import MagicMock + + from ..services.process_execution import run_spatial_statistics + + mock_service = MagicMock() + mock_service.query_statistics.return_value = { + "total_count": 5, + "query_method": "area_fallback", + "areas_matched": 1, + "statistics": {}, + "registrant_ids": [1, 2, 3, 4, 5], + } + + result = run_spatial_statistics( + mock_service, + { + "geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + }, + ) + + call_kwargs = mock_service.query_statistics.call_args + group_by_value = call_kwargs.kwargs.get("group_by") if call_kwargs.kwargs else call_kwargs[1].get("group_by") + self.assertIsNone(group_by_value) + self.assertIsNone(result.get("breakdown")) + + def test_run_proximity_statistics_passes_group_by(self): + """run_proximity_statistics extracts and passes group_by from inputs.""" + from unittest.mock import MagicMock + + from ..services.process_execution import run_proximity_statistics + + mock_service = MagicMock() + mock_service.query_proximity.return_value = { + "total_count": 3, + "query_method": "area_fallback", + "areas_matched": 1, + "statistics": {}, + "breakdown": {"2": {"count": 2}}, + "reference_points_count": 1, + "radius_km": 10.0, + "relation": "within", + "registrant_ids": [1, 2, 3], + } + + result = run_proximity_statistics( + mock_service, + { + "reference_points": [{"longitude": 1.0, "latitude": 2.0}], + "radius_km": 10.0, + "group_by": ["gender"], + }, + ) + + call_kwargs = mock_service.query_proximity.call_args + self.assertEqual(call_kwargs.kwargs.get("group_by"), ["gender"]) + self.assertEqual(result.get("breakdown"), {"2": {"count": 2}}) + + def test_run_spatial_statistics_batch_passes_group_by(self): + """run_spatial_statistics in batch mode passes group_by to query_statistics_batch.""" + from unittest.mock import MagicMock + + from ..services.process_execution import run_spatial_statistics + + mock_service = MagicMock() + mock_service.query_statistics_batch.return_value = { + "results": [ + { + "id": "g1", + "total_count": 5, + "query_method": "area_fallback", + "areas_matched": 1, + "statistics": {}, + "breakdown": {"1": {"count": 3}}, + }, + ], + "summary": { + "total_count": 5, + "geometries_queried": 1, + "statistics": {}, + "breakdown": {"1": {"count": 3}}, + }, + } + + result = run_spatial_statistics( + mock_service, + { + "geometry": [ + {"id": "g1", "value": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}}, + ], + "group_by": ["gender"], + }, + ) + + mock_service.query_statistics_batch.assert_called_once() + call_kwargs = mock_service.query_statistics_batch.call_args + self.assertEqual(call_kwargs.kwargs.get("group_by"), ["gender"]) + self.assertIn("breakdown", result["summary"]) + self.assertIn("breakdown", result["results"][0]) diff --git a/spp_api_v2_gis/tests/test_population_filter.py b/spp_api_v2_gis/tests/test_population_filter.py new file mode 100644 index 000000000..fd14efdc6 --- /dev/null +++ b/spp_api_v2_gis/tests/test_population_filter.py @@ -0,0 +1,525 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for population_filter parameter on spatial queries.""" + +from datetime import date + +from odoo.tests.common import TransactionCase + + +class TestPopulationFilter(TransactionCase): + """Test population_filter parameter on spatial queries.""" + + @classmethod + def setUpClass(cls): + """Set up test data with programs, memberships, and areas.""" + super().setUpClass() + + # Create two test areas + cls.area_1 = cls.env["spp.area"].create({"draft_name": "Filter Test District 1", "code": "FILT-DIST-001"}) + cls.area_2 = cls.env["spp.area"].create({"draft_name": "Filter Test District 2", "code": "FILT-DIST-002"}) + + # Create 4 households (groups), 2 per area + cls.group_a1 = cls.env["res.partner"].create( + { + "name": "HH A1", + "is_registrant": True, + "is_group": True, + "area_id": cls.area_1.id, + } + ) + cls.group_a2 = cls.env["res.partner"].create( + { + "name": "HH A2", + "is_registrant": True, + "is_group": True, + "area_id": cls.area_1.id, + } + ) + cls.group_b1 = cls.env["res.partner"].create( + { + "name": "HH B1", + "is_registrant": True, + "is_group": True, + "area_id": cls.area_2.id, + } + ) + cls.group_b2 = cls.env["res.partner"].create( + { + "name": "HH B2", + "is_registrant": True, + "is_group": True, + "area_id": cls.area_2.id, + } + ) + + # Create individuals for each household + cls.indiv_a1 = cls.env["res.partner"].create( + { + "name": "Indiv A1", + "is_registrant": True, + "is_group": False, + "area_id": cls.area_1.id, + "birthdate": date(1990, 1, 1), + } + ) + cls.indiv_a2 = cls.env["res.partner"].create( + { + "name": "Indiv A2", + "is_registrant": True, + "is_group": False, + "area_id": cls.area_1.id, + "birthdate": date(2000, 6, 15), + } + ) + cls.indiv_b1 = cls.env["res.partner"].create( + { + "name": "Indiv B1", + "is_registrant": True, + "is_group": False, + "area_id": cls.area_2.id, + "birthdate": date(1985, 3, 20), + } + ) + cls.indiv_b2 = cls.env["res.partner"].create( + { + "name": "Indiv B2", + "is_registrant": True, + "is_group": False, + "area_id": cls.area_2.id, + "birthdate": date(1975, 11, 30), + } + ) + + # Create group memberships + Membership = cls.env["spp.group.membership"] + Membership.create({"group": cls.group_a1.id, "individual": cls.indiv_a1.id}) + Membership.create({"group": cls.group_a2.id, "individual": cls.indiv_a2.id}) + Membership.create({"group": cls.group_b1.id, "individual": cls.indiv_b1.id}) + Membership.create({"group": cls.group_b2.id, "individual": cls.indiv_b2.id}) + + # Create a program + cls.program = cls.env["spp.program"].create({"name": "Test CCT Program"}) + + # Enroll group_a1 and group_b1 (2 of 4 households) + cls.env["spp.program.membership"].create( + { + "partner_id": cls.group_a1.id, + "program_id": cls.program.id, + "state": "enrolled", + } + ) + cls.env["spp.program.membership"].create( + { + "partner_id": cls.group_b1.id, + "program_id": cls.program.id, + "state": "enrolled", + } + ) + + # All group IDs for reference + cls.all_group_ids = {cls.group_a1.id, cls.group_a2.id, cls.group_b1.id, cls.group_b2.id} + cls.enrolled_group_ids = {cls.group_a1.id, cls.group_b1.id} + cls.not_enrolled_group_ids = {cls.group_a2.id, cls.group_b2.id} + + def _get_service(self): + from ..services.spatial_query_service import SpatialQueryService + + return SpatialQueryService(self.env) + + +class TestPopulationFilterSQL(TestPopulationFilter): + """Test _build_population_filter_sql() directly.""" + + def test_no_filter_returns_empty(self): + """Without population_filter, no SQL clause is generated.""" + service = self._get_service() + sql, params = service._build_population_filter_sql(None) + self.assertEqual(sql, "") + self.assertEqual(params, []) + + def test_empty_dict_returns_empty(self): + """Empty population_filter dict generates no SQL clause.""" + service = self._get_service() + sql, params = service._build_population_filter_sql({}) + self.assertEqual(sql, "") + self.assertEqual(params, []) + + def test_program_filter_generates_sql(self): + """Program filter generates SQL with program membership subquery.""" + service = self._get_service() + sql, params = service._build_population_filter_sql({"program": self.program.id}) + self.assertIn("spp_program_membership", sql) + self.assertIn("AND p.id IN", sql) + self.assertEqual(params, [self.program.id]) + + def test_program_filter_restricts_results(self): + """Program filter restricts registrant IDs to enrolled beneficiaries.""" + service = self._get_service() + + # Build filter SQL + pop_sql, pop_params = service._build_population_filter_sql({"program": self.program.id}) + + # Execute a query using the filter to verify it works + all_ids = list(self.all_group_ids) + query = f""" + SELECT p.id FROM res_partner p + WHERE p.id IN %s {pop_sql} + """ + params = [tuple(all_ids)] + pop_params + self.env.cr.execute(query, params) + result_ids = {row[0] for row in self.env.cr.fetchall()} + + self.assertEqual(result_ids, self.enrolled_group_ids) + + def test_unknown_program_id_returns_empty(self): + """Program ID with no enrollees returns no results (but valid SQL).""" + service = self._get_service() + + # Use a non-existent program ID + pop_sql, pop_params = service._build_population_filter_sql({"program": 999999}) + + # SQL should still be valid, just match nothing + all_ids = list(self.all_group_ids) + query = f""" + SELECT p.id FROM res_partner p + WHERE p.id IN %s {pop_sql} + """ + params = [tuple(all_ids)] + pop_params + self.env.cr.execute(query, params) + result_ids = {row[0] for row in self.env.cr.fetchall()} + + self.assertEqual(result_ids, set()) + + def test_invalid_mode_raises_error(self): + """Invalid mode value raises ValueError.""" + service = self._get_service() + with self.assertRaises(ValueError): + service._build_population_filter_sql({"program": self.program.id, "mode": "invalid_mode"}) + + def test_non_integer_program_raises_error(self): + """Non-integer program value raises ValueError.""" + service = self._get_service() + with self.assertRaises(ValueError): + service._build_population_filter_sql({"program": "not_an_int"}) + + +class TestPopulationFilterCEL(TestPopulationFilter): + """Test CEL expression filter functionality.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create a simple CEL expression that always evaluates to true + # (matches all groups). Use 'true' which is the simplest valid CEL. + cls.cel_expression = cls.env["spp.cel.expression"].create( + { + "name": "Test All Groups", + "code": "test_all_groups", + "expression_type": "filter", + "cel_expression": "true", + "output_type": "boolean", + "context_type": "group", + } + ) + + def test_cel_filter_generates_sql(self): + """CEL expression filter generates SQL with matching IDs.""" + service = self._get_service() + sql, params = service._build_population_filter_sql({"cel_expression": "test_all_groups"}) + # Should produce a valid filter (the 'true' expression matches all groups) + if sql == "AND false": + self.skipTest("CEL expression matched no groups in test DB") + self.assertIn("AND p.id IN", sql) + self.assertIn("unnest", sql) + + def test_cel_filter_unknown_code_returns_false(self): + """Unknown CEL expression code returns 'AND false' (empty results).""" + service = self._get_service() + sql, params = service._build_population_filter_sql({"cel_expression": "nonexistent_expression_code"}) + self.assertEqual(sql, "AND false") + self.assertEqual(params, []) + + def test_individual_context_resolves_to_groups(self): + """Individual-context CEL expressions resolve matched individuals to their groups.""" + # Create an individual-context expression matching adults (age >= 18) + self.env["spp.cel.expression"].create( + { + "name": "Test Adults", + "code": "test_adults_18", + "expression_type": "filter", + "cel_expression": "age_years(r.birthdate) >= 18", + "output_type": "boolean", + "context_type": "individual", + } + ) + + service = self._get_service() + sql, params = service._build_population_filter_sql({"cel_expression": "test_adults_18"}) + + # Should not be "AND false" since our test individuals are all adults + self.assertNotEqual(sql, "AND false", "Individual CEL expression should match adult individuals") + self.assertIn("AND p.id IN", sql) + + # Verify the matched IDs are group IDs (not individual IDs) + # Execute against all groups to verify they're matched + all_ids = list(self.all_group_ids) + query = f""" + SELECT p.id FROM res_partner p + WHERE p.id IN %s {sql} + """ + exec_params = [tuple(all_ids)] + params + self.env.cr.execute(query, exec_params) + result_ids = {row[0] for row in self.env.cr.fetchall()} + + # All 4 groups should match (all individuals are adults born 1975-2000) + self.assertEqual(result_ids, self.all_group_ids) + + def test_individual_context_no_match_returns_false(self): + """Individual-context expression matching no individuals returns AND false.""" + # Create an expression matching very old people (age >= 200) + self.env["spp.cel.expression"].create( + { + "name": "Test Ancient", + "code": "test_ancient", + "expression_type": "filter", + "cel_expression": "age_years(r.birthdate) >= 200", + "output_type": "boolean", + "context_type": "individual", + } + ) + + service = self._get_service() + sql, params = service._build_population_filter_sql({"cel_expression": "test_ancient"}) + self.assertEqual(sql, "AND false") + + +class TestPopulationFilterCombined(TestPopulationFilter): + """Test combined program + CEL filter modes.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create a CEL expression that matches all groups (true) + cls.cel_expression = cls.env["spp.cel.expression"].create( + { + "name": "Test Match All", + "code": "test_match_all", + "expression_type": "filter", + "cel_expression": "true", + "output_type": "boolean", + "context_type": "group", + } + ) + + def test_and_mode_with_program_and_cel(self): + """AND mode generates SQL with both program and CEL conditions.""" + service = self._get_service() + sql, params = service._build_population_filter_sql( + { + "program": self.program.id, + "cel_expression": "test_match_all", + "mode": "and", + } + ) + if sql == "AND false": + self.skipTest("CEL expression matched no groups in test DB") + # Should have both program membership and CEL subqueries + self.assertIn("spp_program_membership", sql) + self.assertIn("unnest", sql) + self.assertEqual(sql.count("AND p.id IN"), 2) + + def test_or_mode_with_program_and_cel(self): + """OR mode generates SQL combining program and CEL with OR.""" + service = self._get_service() + sql, params = service._build_population_filter_sql( + { + "program": self.program.id, + "cel_expression": "test_match_all", + "mode": "or", + } + ) + if sql == "AND false": + self.skipTest("CEL expression matched no groups in test DB") + self.assertIn("OR", sql) + + def test_gap_mode_with_program_and_cel(self): + """Gap mode generates SQL: matches CEL but NOT in program.""" + service = self._get_service() + sql, params = service._build_population_filter_sql( + { + "program": self.program.id, + "cel_expression": "test_match_all", + "mode": "gap", + } + ) + if sql == "AND false": + self.skipTest("CEL expression matched no groups in test DB") + self.assertIn("NOT IN", sql) + self.assertIn("spp_program_membership", sql) + + def test_gap_mode_excludes_enrolled(self): + """Gap mode returns CEL matches that are NOT enrolled in the program.""" + service = self._get_service() + pop_sql, pop_params = service._build_population_filter_sql( + { + "program": self.program.id, + "cel_expression": "test_match_all", + "mode": "gap", + } + ) + if pop_sql == "AND false": + self.skipTest("CEL expression matched no groups in test DB") + + # Execute against all groups + all_ids = list(self.all_group_ids) + query = f""" + SELECT p.id FROM res_partner p + WHERE p.id IN %s {pop_sql} + """ + params = [tuple(all_ids)] + pop_params + self.env.cr.execute(query, params) + result_ids = {row[0] for row in self.env.cr.fetchall()} + + # Gap = CEL matches (all) minus enrolled (a1, b1) = not enrolled (a2, b2) + self.assertEqual(result_ids, self.not_enrolled_group_ids) + + +class TestPopulationFilterBatchQuery(TestPopulationFilter): + """Test population filter with batch spatial queries.""" + + def test_batch_query_passes_filter(self): + """Population filter is applied in batch queries.""" + service = self._get_service() + + geometries = [ + { + "id": "zone_1", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + }, + ] + + # Query without filter + result_all = service.query_statistics_batch(geometries=geometries) + + # Query with program filter + result_filtered = service.query_statistics_batch( + geometries=geometries, + population_filter={"program": self.program.id}, + ) + + # Both should return valid structure + self.assertIn("results", result_all) + self.assertIn("results", result_filtered) + self.assertEqual(len(result_all["results"]), 1) + self.assertEqual(len(result_filtered["results"]), 1) + + # Filtered count should be <= unfiltered count + self.assertLessEqual( + result_filtered["results"][0]["total_count"], + result_all["results"][0]["total_count"], + ) + + +class TestPopulationFilterProcessDescription(TransactionCase): + """Test population_filter in process descriptions.""" + + def test_spatial_statistics_has_population_filter(self): + """Spatial statistics process description includes population_filter input.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + process = registry.get_process("spatial-statistics") + + self.assertIn("population_filter", process["inputs"]) + + pf = process["inputs"]["population_filter"] + self.assertEqual(pf["title"], "Population Filter") + self.assertEqual(pf["minOccurs"], 0) + + schema = pf["schema"] + self.assertEqual(schema["type"], "object") + self.assertIn("program", schema["properties"]) + self.assertIn("cel_expression", schema["properties"]) + self.assertIn("mode", schema["properties"]) + + # Mode should have enum with and/or/gap + mode_schema = schema["properties"]["mode"] + self.assertEqual(mode_schema["enum"], ["and", "or", "gap"]) + self.assertEqual(mode_schema["default"], "and") + + def test_proximity_statistics_has_population_filter(self): + """Proximity statistics process description includes population_filter input.""" + from ..services.process_registry import ProcessRegistry + + registry = ProcessRegistry(self.env) + process = registry.get_process("proximity-statistics") + + self.assertIn("population_filter", process["inputs"]) + + def test_process_description_includes_program_metadata(self): + """x-openspp-programs contains program IDs and names when programs exist.""" + from ..services.process_registry import ProcessRegistry + + # Create a program to ensure metadata is populated + program = self.env["spp.program"].create({"name": "Test Discovery Program"}) + + registry = ProcessRegistry(self.env) + process = registry.get_process("spatial-statistics") + pf = process["inputs"]["population_filter"] + + self.assertIn("x-openspp-programs", pf) + programs = pf["x-openspp-programs"] + program_ids = [p["id"] for p in programs] + self.assertIn(program.id, program_ids) + + # Each program metadata should have id and name + test_prog = next(p for p in programs if p["id"] == program.id) + self.assertEqual(test_prog["name"], "Test Discovery Program") + + def test_process_description_includes_expression_metadata(self): + """x-openspp-expressions contains expression codes and names.""" + from ..services.process_registry import ProcessRegistry + + # Create a CEL expression + expr = self.env["spp.cel.expression"].create( + { + "name": "Test Discovery Expression", + "code": "test_discovery_expr", + "expression_type": "filter", + "cel_expression": "true", + "output_type": "boolean", + "context_type": "group", + } + ) + + registry = ProcessRegistry(self.env) + process = registry.get_process("spatial-statistics") + pf = process["inputs"]["population_filter"] + + self.assertIn("x-openspp-expressions", pf) + expressions = pf["x-openspp-expressions"] + expr_codes = [e["code"] for e in expressions] + self.assertIn(expr.code, expr_codes) + + # Each expression metadata should have code, name, context_type + test_expr = next(e for e in expressions if e["code"] == expr.code) + self.assertEqual(test_expr["name"], "Test Discovery Expression") + self.assertEqual(test_expr["context_type"], "group") + + def test_process_description_without_programs(self): + """Population filter input works when no programs exist.""" + from ..services.process_registry import ProcessRegistry + + # Delete all programs to test empty state + self.env["spp.program"].search([]).unlink() + + registry = ProcessRegistry(self.env) + process = registry.get_process("spatial-statistics") + pf = process["inputs"]["population_filter"] + + # Should still have the input, just without enum/metadata + self.assertEqual(pf["schema"]["type"], "object") + self.assertNotIn("enum", pf["schema"]["properties"]["program"]) diff --git a/spp_api_v2_gis/tests/test_qml_template_service.py b/spp_api_v2_gis/tests/test_qml_template_service.py index e45b5ebc4..538ddef8e 100644 --- a/spp_api_v2_gis/tests/test_qml_template_service.py +++ b/spp_api_v2_gis/tests/test_qml_template_service.py @@ -100,10 +100,10 @@ def test_generate_graduated_polygon_qml(self): self.assertIn("", qml) self.assertIn("0.7", qml) - # Verify ranges are present - self.assertIn('label="Low"', qml) - self.assertIn('label="Medium"', qml) - self.assertIn('label="High"', qml) + # Verify ranges are present (labels include numeric ranges) + self.assertIn('label="Low (0.0 - 10.0)"', qml) + self.assertIn('label="Medium (10.0 - 50.0)"', qml) + self.assertIn("High (50.0 - 999999.0)", qml) # Verify colors are present (converted to RGB) self.assertIn("68,1,84,255", qml) # #440154 @@ -354,9 +354,10 @@ def test_per_level_thresholds_when_single_bucket(self): # Per-level QML should have different threshold ranges than global # Global has lower="0" upper="10" for Low bucket # Per-level should have ranges within the 2.0-6.0 range - self.assertIn('label="Low"', qml_level) - self.assertIn('label="Medium"', qml_level) - self.assertIn('label="High"', qml_level) + # Labels include numeric ranges from the recomputed per-level thresholds + self.assertIn("Low (", qml_level) + self.assertIn("Medium (", qml_level) + self.assertIn("High (", qml_level) # The per-level thresholds should NOT use the global 0-10 range self.assertNotIn('lower="0" upper="10"', qml_level) diff --git a/spp_api_v2_gis/tests/test_spatial_query_service.py b/spp_api_v2_gis/tests/test_spatial_query_service.py index f441041d0..4d541a557 100644 --- a/spp_api_v2_gis/tests/test_spatial_query_service.py +++ b/spp_api_v2_gis/tests/test_spatial_query_service.py @@ -348,6 +348,50 @@ def test_suppression_precedence_uses_stricter_threshold(self): self.assertIn("suppression_precedence_stat", result["statistics"]) self.assertEqual(result["statistics"]["suppression_precedence_stat"], "<10") + def test_get_empty_statistics(self): + """The empty-statistics structure is an empty dict.""" + from ..services.spatial_query_service import SpatialQueryService + + self.assertEqual(SpatialQueryService(self.env)._get_empty_statistics(), {}) + + def test_build_filter_clauses(self): + """SQL filter clauses are built from the is_group / disabled keys.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + self.assertEqual(service._build_filter_clauses({}), ("", [])) + + where, params = service._build_filter_clauses({"is_group": True}) + self.assertIn("p.is_group = %s", where) + self.assertEqual(params, [True]) + + where, _params = service._build_filter_clauses({"disabled": True}) + self.assertIn("p.disabled IS NOT NULL", where) + + where, _params = service._build_filter_clauses({"disabled": False}) + self.assertIn("p.disabled IS NULL", where) + + where, params = service._build_filter_clauses({"is_group": False, "disabled": True}) + self.assertIn("p.is_group = %s", where) + self.assertIn("p.disabled IS NOT NULL", where) + self.assertEqual(params, [False]) + + def test_resolve_individuals_to_groups_empty(self): + """An empty individual list resolves to no groups.""" + from ..services.spatial_query_service import SpatialQueryService + + self.assertEqual(SpatialQueryService(self.env)._resolve_individuals_to_groups([]), []) + + def test_resolve_individuals_to_groups(self): + """Individuals resolve to the groups whose membership contains them.""" + if "spp.group.membership" not in self.env: + self.skipTest("spp.group.membership not available") + from ..services.spatial_query_service import SpatialQueryService + + group_ids = SpatialQueryService(self.env)._resolve_individuals_to_groups([self.member_adult_male.id]) + self.assertIn(self.group.id, group_ids) + class TestSpatialQueryServicePublicUser(TransactionCase): """Tests for spatial query service running as public user. diff --git a/spp_api_v2_gis/tests/test_statistics_endpoint.py b/spp_api_v2_gis/tests/test_statistics_endpoint.py index 3a4e7ca8e..10a78fe3e 100644 --- a/spp_api_v2_gis/tests/test_statistics_endpoint.py +++ b/spp_api_v2_gis/tests/test_statistics_endpoint.py @@ -64,9 +64,9 @@ def setUpClass(cls): cls.gis_stat_2 = cls.env["spp.indicator"].create( { - "name": "disabled_members_disc", - "label": "Disabled Members", - "description": "Count of members with disability", + "name": "pwd_members_disc", + "label": "Members with Disability", + "description": "Count of members with a recorded disability", "variable_id": cls.cel_variable.id, "format": "count", "unit": "people", @@ -100,9 +100,9 @@ def test_get_published_by_category_returns_gis_stats(self): demo_names = [s.name for s in by_category["demographics"]] self.assertIn("total_households_disc", demo_names) - # Vulnerability should contain disabled_members + # Vulnerability should contain pwd_members vuln_names = [s.name for s in by_category["vulnerability"]] - self.assertIn("disabled_members_disc", vuln_names) + self.assertIn("pwd_members_disc", vuln_names) def test_non_gis_stats_excluded(self): """Test that non-GIS statistics are excluded."""