|
1 | 1 | # 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.""" |
3 | 3 |
|
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 |
13 | 5 |
|
14 | 6 |
|
15 | 7 | 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" |
46 | 9 |
|
47 | | - # Classification |
48 | 10 | geofence_type = fields.Selection( |
49 | | - [ |
50 | | - ("hazard_zone", "Hazard Zone"), |
| 11 | + selection_add=[ |
51 | 12 | ("service_area", "Service Area"), |
52 | 13 | ("targeting_area", "Targeting Area"), |
53 | | - ("custom", "Custom Area"), |
54 | 14 | ], |
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 | + }, |
69 | 19 | ) |
70 | 20 |
|
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 | | - |
198 | 21 | 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 |
0 commit comments