Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 53 additions & 34 deletions spp_api_v2_gis/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------------------
Expand All @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion spp_api_v2_gis/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [],
Expand Down
12 changes: 12 additions & 0 deletions spp_api_v2_gis/data/cron_job_cleanup.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="ir_cron_gis_job_cleanup" model="ir.cron">
<field name="name">GIS: Process Job Cleanup</field>
<field name="model_id" ref="model_spp_gis_process_job" />
<field name="state">code</field>
<field name="code">model.cron_cleanup_jobs()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</odoo>
1 change: 1 addition & 0 deletions spp_api_v2_gis/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
from . import api_client_scope
from . import fastapi_endpoint
from . import geofence
from . import process_job
8 changes: 8 additions & 0 deletions spp_api_v2_gis/models/api_client_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
)
10 changes: 4 additions & 6 deletions spp_api_v2_gis/models/fastapi_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
)
Expand Down
203 changes: 203 additions & 0 deletions spp_api_v2_gis/models/process_job.py
Original file line number Diff line number Diff line change
@@ -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()
Loading