Skip to content

Commit 140d95e

Browse files
authored
Merge pull request #74 from OpenSPP/improve/spp-simulation
refactor: move spp.gis.geofence from spp_api_v2_gis to spp_gis
2 parents cbf2691 + ebca8f5 commit 140d95e

File tree

21 files changed

+1250
-719
lines changed

21 files changed

+1250
-719
lines changed

spp_api_v2_gis/models/geofence.py

Lines changed: 13 additions & 269 deletions
Original file line numberDiff line numberDiff line change
@@ -1,282 +1,26 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2-
"""Geofence model for saved geographic areas of interest."""
2+
"""Extend geofence model with API-specific types and properties."""
33

4-
import json
5-
import logging
6-
7-
from shapely.geometry import mapping
8-
9-
from odoo import _, api, fields, models
10-
from odoo.exceptions import ValidationError
11-
12-
_logger = logging.getLogger(__name__)
4+
from odoo import fields, models
135

146

157
class GisGeofence(models.Model):
16-
"""Saved Geographic Areas of Interest.
17-
18-
Geofences are user-defined polygons that can be:
19-
- Created from QGIS plugin
20-
- Used for spatial queries and reports
21-
- Tagged for classification
22-
- Linked to hazard incidents for disaster management
23-
"""
24-
25-
_name = "spp.gis.geofence"
26-
_description = "Saved Geographic Areas of Interest"
27-
_inherit = ["mail.thread", "mail.activity.mixin"]
28-
_order = "name"
29-
30-
name = fields.Char(
31-
required=True,
32-
tracking=True,
33-
help="Name of this geofence",
34-
)
35-
description = fields.Text(
36-
tracking=True,
37-
help="Description of this area of interest",
38-
)
39-
40-
# Geometry field using GeoPolygonField from spp_gis
41-
geometry = fields.GeoPolygonField(
42-
string="Geographic Polygon",
43-
required=True,
44-
help="Geographic boundary as polygon or multipolygon",
45-
)
8+
_inherit = "spp.gis.geofence"
469

47-
# Classification
4810
geofence_type = fields.Selection(
49-
[
50-
("hazard_zone", "Hazard Zone"),
11+
selection_add=[
5112
("service_area", "Service Area"),
5213
("targeting_area", "Targeting Area"),
53-
("custom", "Custom Area"),
5414
],
55-
default="custom",
56-
required=True,
57-
tracking=True,
58-
help="Type of geofence",
59-
)
60-
61-
# Tags for flexible classification
62-
tag_ids = fields.Many2many(
63-
"spp.vocabulary",
64-
"spp_gis_geofence_tag_rel",
65-
"geofence_id",
66-
"tag_id",
67-
string="Tags",
68-
help="Classification tags for this geofence",
15+
ondelete={
16+
"service_area": "set default",
17+
"targeting_area": "set default",
18+
},
6919
)
7020

71-
# Optional relationship to hazard incident
72-
incident_id = fields.Many2one(
73-
"spp.hazard.incident",
74-
string="Related Incident",
75-
ondelete="set null",
76-
tracking=True,
77-
help="Hazard incident associated with this geofence (if applicable)",
78-
)
79-
80-
# Status
81-
active = fields.Boolean(
82-
default=True,
83-
tracking=True,
84-
help="Uncheck to archive this geofence",
85-
)
86-
87-
# Audit fields
88-
created_by_id = fields.Many2one(
89-
"res.users",
90-
string="Created By",
91-
default=lambda self: self.env.user,
92-
readonly=True,
93-
help="User who created this geofence",
94-
)
95-
created_from = fields.Selection(
96-
[
97-
("qgis", "QGIS Plugin"),
98-
("api", "External API"),
99-
("ui", "OpenSPP UI"),
100-
],
101-
default="ui",
102-
required=True,
103-
string="Created From",
104-
help="Source of geofence creation",
105-
)
106-
107-
# Computed area in square kilometers
108-
area_sqkm = fields.Float(
109-
string="Area (sq km)",
110-
compute="_compute_area_sqkm",
111-
store=True,
112-
help="Area of the polygon in square kilometers (computed from geometry)",
113-
)
114-
115-
@api.depends("geometry")
116-
def _compute_area_sqkm(self):
117-
"""Compute area in square kilometers from geometry using PostGIS.
118-
119-
Uses ST_Area with geography type for accurate area calculation
120-
in square meters, then converts to square kilometers.
121-
"""
122-
for rec in self:
123-
if not rec.geometry or not rec.id:
124-
rec.area_sqkm = 0.0
125-
continue
126-
127-
try:
128-
# Use PostGIS ST_Area with geography cast for accurate measurement
129-
# Geography type automatically uses spheroid calculations
130-
query = """
131-
SELECT ST_Area(ST_Transform(geometry::geometry, 4326)::geography) / 1000000.0 as area_sqkm
132-
FROM spp_gis_geofence
133-
WHERE id = %s
134-
"""
135-
self.env.cr.execute(query, (rec.id,))
136-
result = self.env.cr.fetchone()
137-
rec.area_sqkm = result[0] if result else 0.0
138-
except Exception as e:
139-
_logger.warning("Failed to compute area for geofence %s: %s", rec.id, str(e))
140-
rec.area_sqkm = 0.0
141-
142-
@api.constrains("name", "active")
143-
def _check_name_unique_active(self):
144-
"""Ensure name is unique among active geofences."""
145-
for rec in self:
146-
if rec.active:
147-
existing = self.search(
148-
[
149-
("name", "=", rec.name),
150-
("active", "=", True),
151-
("id", "!=", rec.id),
152-
],
153-
limit=1,
154-
)
155-
if existing:
156-
raise ValidationError(
157-
_("A geofence with the name '%s' already exists. Please use a unique name.") % rec.name
158-
)
159-
160-
@api.constrains("geometry")
161-
def _check_geometry_valid(self):
162-
"""Validate that geometry is not empty and is a valid polygon."""
163-
for rec in self:
164-
if not rec.geometry:
165-
raise ValidationError(_("Geometry cannot be empty."))
166-
167-
# Geometry validity is handled by the GeoPolygonField itself
168-
# We just ensure it exists and is not empty
169-
170-
def to_geojson(self):
171-
"""Return GeoJSON Feature representation of this geofence.
172-
173-
Returns:
174-
dict: GeoJSON Feature with geometry and properties
175-
"""
176-
self.ensure_one()
177-
178-
if not self.geometry:
179-
return {
180-
"type": "Feature",
181-
"geometry": None,
182-
"properties": self._get_geojson_properties(),
183-
}
184-
185-
# Convert shapely geometry to GeoJSON
186-
try:
187-
geometry_dict = mapping(self.geometry)
188-
except Exception as e:
189-
_logger.warning("Failed to convert geometry to GeoJSON for geofence %s: %s", self.id, str(e))
190-
geometry_dict = None
191-
192-
return {
193-
"type": "Feature",
194-
"geometry": geometry_dict,
195-
"properties": self._get_geojson_properties(),
196-
}
197-
19821
def _get_geojson_properties(self):
199-
"""Get properties dictionary for GeoJSON representation.
200-
201-
Returns:
202-
dict: Properties including name, type, tags, etc.
203-
"""
204-
self.ensure_one()
205-
206-
# nosemgrep: odoo-expose-database-id
207-
return {
208-
"id": self.id,
209-
"name": self.name,
210-
"description": self.description or "",
211-
"geofence_type": self.geofence_type,
212-
"geofence_type_label": dict(self._fields["geofence_type"].selection).get(self.geofence_type, ""),
213-
"area_sqkm": self.area_sqkm,
214-
"tags": self.tag_ids.mapped("name"),
215-
"incident_id": self.incident_id.id if self.incident_id else None,
216-
"incident_name": self.incident_id.name if self.incident_id else None,
217-
"created_from": self.created_from,
218-
"created_by": self.created_by_id.name,
219-
"create_date": self.create_date.isoformat() if self.create_date else None,
220-
}
221-
222-
def to_geojson_collection(self):
223-
"""Return GeoJSON FeatureCollection for multiple geofences.
224-
225-
Returns:
226-
dict: GeoJSON FeatureCollection with all features
227-
"""
228-
features = [rec.to_geojson() for rec in self]
229-
return {
230-
"type": "FeatureCollection",
231-
"features": features,
232-
}
233-
234-
@api.model
235-
def create_from_geojson(self, geojson_str, name, geofence_type="custom", created_from="api", **kwargs):
236-
"""Create a geofence from GeoJSON string.
237-
238-
Args:
239-
geojson_str: GeoJSON string (Feature or FeatureCollection)
240-
name: Name for the geofence
241-
geofence_type: Type of geofence (default: custom)
242-
created_from: Source of creation (default: api)
243-
**kwargs: Additional field values
244-
245-
Returns:
246-
Created geofence record
247-
248-
Raises:
249-
ValidationError: If GeoJSON is invalid
250-
"""
251-
try:
252-
geojson_data = json.loads(geojson_str) if isinstance(geojson_str, str) else geojson_str
253-
except json.JSONDecodeError as e:
254-
raise ValidationError(_("Invalid GeoJSON format: %s") % str(e)) from e
255-
256-
# Handle FeatureCollection or Feature
257-
if geojson_data.get("type") == "FeatureCollection":
258-
if not geojson_data.get("features"):
259-
raise ValidationError(_("FeatureCollection must contain at least one feature"))
260-
# Use first feature's geometry
261-
geometry = geojson_data["features"][0].get("geometry")
262-
elif geojson_data.get("type") == "Feature":
263-
geometry = geojson_data.get("geometry")
264-
else:
265-
# Assume it's a raw geometry
266-
geometry = geojson_data
267-
268-
if not geometry:
269-
raise ValidationError(_("No geometry found in GeoJSON"))
270-
271-
# Convert geometry dict to GeoJSON string for the GeoPolygonField
272-
geometry_str = json.dumps(geometry)
273-
274-
vals = {
275-
"name": name,
276-
"geometry": geometry_str,
277-
"geofence_type": geofence_type,
278-
"created_from": created_from,
279-
}
280-
vals.update(kwargs)
281-
282-
return self.create(vals)
22+
"""Extend properties with incident info from spp_hazard."""
23+
props = super()._get_geojson_properties()
24+
props["incident_id"] = self.incident_id.code if self.incident_id else None
25+
props["incident_name"] = self.incident_id.name if self.incident_id else None
26+
return props
Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
11
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2-
access_spp_gis_geofence_admin,Geofence Admin,model_spp_gis_geofence,spp_security.group_spp_admin,1,1,1,1
3-
access_spp_gis_geofence_manager,Geofence Manager,model_spp_gis_geofence,spp_registry.group_registry_manager,1,1,1,1
4-
access_spp_gis_geofence_officer,Geofence Officer,model_spp_gis_geofence,spp_registry.group_registry_officer,1,1,1,0
5-
access_spp_gis_geofence_read,Geofence Read,model_spp_gis_geofence,spp_registry.group_registry_read,1,0,0,0

0 commit comments

Comments
 (0)