diff --git a/spp_api_v2_gis/README.rst b/spp_api_v2_gis/README.rst index 961e531d..e41b0f9b 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. @@ -156,6 +175,14 @@ Dependencies Changelog ========= +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 ~~~~~~~~~~ diff --git a/spp_api_v2_gis/__manifest__.py b/spp_api_v2_gis/__manifest__.py index 2aad2122..f068c568 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 00000000..b33999d1 --- /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 d54fa7da..e796ade3 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 a9c767de..40ce1ae5 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 6261e991..edc5363d 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 00000000..bec3e267 --- /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 b9bfd937..548dde41 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 4aaf9afe..ee3400b2 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 d5e170ae..15166085 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 00000000..ccd1e0ef --- /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 155a668e..00000000 --- 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 00000000..c87edf6e --- /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 5bbf3bc5..61150318 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 00000000..93594b10 --- /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 17c8a843..00000000 --- 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 81a16332..00000000 --- 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 976f6789..86413dae 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 7a73017a..4a7ac3c3 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 d53f6410..00000000 --- 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 62debb15..927fccb1 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 00000000..9276d0b8 --- /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 c3c7b55a..0c2d9e98 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 00000000..c823ade9 --- /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 6184a998..6d725475 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 97dd8b91..74e6b735 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 a09978c4..8304fab1 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 ffdf0d9f..28d8c2d8 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 ea876115..b32179e4 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 00000000..117cf415 --- /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 00000000..bde28d18 --- /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 b97e566e..7c7502bc 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 6c49aba8..89da7f6d 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 b7c6f7e2..4f79be63 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.
  • @@ -557,24 +592,34 @@

    Dependencies

    Changelog

    -

    19.0.2.0.0

    +

    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
    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -582,7 +627,7 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    diff --git a/spp_api_v2_gis/tests/__init__.py b/spp_api_v2_gis/tests/__init__.py index 06c22da0..dc593019 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 19cb9fa6..344f8341 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 e9796602..24cfd126 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 00000000..2ecbe899 --- /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 00000000..c35ccc4a --- /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 00000000..50be3e88 --- /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 00000000..2dfe66ba --- /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 00000000..fd14efdc --- /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 e45b5ebc..538ddef8 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 f441041d..4d541a55 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 3a4e7ca8..10a78fe3 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."""