goodmap 1.1.4__tar.gz → 1.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: goodmap
3
- Version: 1.1.4
3
+ Version: 1.1.7
4
4
  Summary: Map engine to serve all the people :)
5
5
  License-File: LICENSE.md
6
6
  Author: Krzysztof Kolodzinski
@@ -26,8 +26,11 @@ Requires-Dist: gql (>=3.4.0,<4.0.0)
26
26
  Requires-Dist: gunicorn (>=20.1.0,<21.0.0)
27
27
  Requires-Dist: humanize (>=4.6.0,<5.0.0)
28
28
  Requires-Dist: myst-parser (>=4.0.0,<5.0.0) ; extra == "docs"
29
+ Requires-Dist: numpy (>=2.2.0,<3.0.0)
29
30
  Requires-Dist: platzky (>=1.0.0,<2.0.0)
30
31
  Requires-Dist: pydantic (>=2.7.1,<3.0.0)
32
+ Requires-Dist: pysupercluster-problematy (>=0.7.8,<0.8.0)
33
+ Requires-Dist: scipy (>=1.15.1,<2.0.0)
31
34
  Requires-Dist: sphinx (>=8.0.0,<9.0.0) ; extra == "docs"
32
35
  Requires-Dist: sphinx-rtd-theme (>=3.0.0,<4.0.0) ; extra == "docs"
33
36
  Requires-Dist: tomli (>=2.0.0,<3.0.0) ; extra == "docs"
@@ -82,9 +85,9 @@ poetry run <command>
82
85
 
83
86
  ### TL;DR
84
87
  If you don't want to go through all the configuration, e.g. you just simply want to test if everything works,
85
- you can simply run app with test dataset provided in `tests/e2e_tests` directory:
88
+ you can simply run app with test dataset provided in `examples` directory:
86
89
 
87
- > poetry run flask --app 'goodmap.goodmap:create_app(config_path="./tests/e2e_tests/e2e_test_config.yml")' run
90
+ > poetry run flask --app 'goodmap.goodmap:create_app(config_path="./examples/e2e_test_config.yml")' run
88
91
 
89
92
  ### Configuration
90
93
 
@@ -102,7 +105,7 @@ Afterwards run it with:
102
105
 
103
106
  ## Database
104
107
 
105
- The database is stored in JSON, in the `map` section. For an example database see `tests/e2e_tests/e2e_test_data.json`. The first subsection `data` consists of the actual datapoints, representing points on a map.
108
+ The database is stored in JSON, in the `map` section. For an example database see `examples/e2e_test_data.json`. The first subsection `data` consists of the actual datapoints, representing points on a map.
106
109
 
107
110
  Datapoints have fields. The next subsections define special types of fields:
108
111
  - `obligatory_fields` - here are explicitely stated all the fields that the application assumes are presnt in all datapoints. E.g.
@@ -130,9 +133,10 @@ You can define the fields in all these subsections. Besides these types of field
130
133
 
131
134
  ## Examples
132
135
 
133
- You can find examples of working configuration and database in `tests/e2e_tests` named:
134
- - `e2e_test_config.yml`
135
- - `e2e_test_data.json`
136
+ You can find examples of working configuration and database in `examples/` directory:
137
+ - `e2e_test_config.yml` - Basic configuration example
138
+ - `e2e_test_data.json` - Example database with sample location data
139
+ - `mongo_e2e_test_config.yml` - MongoDB configuration example
136
140
 
137
141
 
138
142
 
@@ -47,9 +47,9 @@ poetry run <command>
47
47
 
48
48
  ### TL;DR
49
49
  If you don't want to go through all the configuration, e.g. you just simply want to test if everything works,
50
- you can simply run app with test dataset provided in `tests/e2e_tests` directory:
50
+ you can simply run app with test dataset provided in `examples` directory:
51
51
 
52
- > poetry run flask --app 'goodmap.goodmap:create_app(config_path="./tests/e2e_tests/e2e_test_config.yml")' run
52
+ > poetry run flask --app 'goodmap.goodmap:create_app(config_path="./examples/e2e_test_config.yml")' run
53
53
 
54
54
  ### Configuration
55
55
 
@@ -67,7 +67,7 @@ Afterwards run it with:
67
67
 
68
68
  ## Database
69
69
 
70
- The database is stored in JSON, in the `map` section. For an example database see `tests/e2e_tests/e2e_test_data.json`. The first subsection `data` consists of the actual datapoints, representing points on a map.
70
+ The database is stored in JSON, in the `map` section. For an example database see `examples/e2e_test_data.json`. The first subsection `data` consists of the actual datapoints, representing points on a map.
71
71
 
72
72
  Datapoints have fields. The next subsections define special types of fields:
73
73
  - `obligatory_fields` - here are explicitely stated all the fields that the application assumes are presnt in all datapoints. E.g.
@@ -95,8 +95,9 @@ You can define the fields in all these subsections. Besides these types of field
95
95
 
96
96
  ## Examples
97
97
 
98
- You can find examples of working configuration and database in `tests/e2e_tests` named:
99
- - `e2e_test_config.yml`
100
- - `e2e_test_data.json`
98
+ You can find examples of working configuration and database in `examples/` directory:
99
+ - `e2e_test_config.yml` - Basic configuration example
100
+ - `e2e_test_data.json` - Example database with sample location data
101
+ - `mongo_e2e_test_config.yml` - MongoDB configuration example
101
102
 
102
103
 
@@ -0,0 +1,75 @@
1
+ import logging
2
+ import uuid
3
+
4
+ from scipy.spatial import KDTree
5
+
6
+ # Maximum distance to consider a point-cluster match (accounts for floating point errors)
7
+ DISTANCE_THRESHOLD = 1e-8
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def map_clustering_data_to_proper_lazy_loading_object(input_array):
13
+ response_array = []
14
+ for item in input_array:
15
+ if item["count"] == 1:
16
+ response_object = {
17
+ "position": [item["longitude"], item["latitude"]],
18
+ "uuid": item["uuid"],
19
+ "cluster_uuid": None,
20
+ "cluster_count": None,
21
+ "type": "point",
22
+ }
23
+ response_array.append(response_object)
24
+ continue
25
+ response_object = {
26
+ "position": [item["longitude"], item["latitude"]],
27
+ "uuid": None,
28
+ "cluster_uuid": str(uuid.uuid4()),
29
+ "cluster_count": item["count"],
30
+ "type": "cluster",
31
+ }
32
+ response_array.append(response_object)
33
+ return response_array
34
+
35
+
36
+ # Since there can be some floating point errors
37
+ # we need to check if the distance is close enough to 0
38
+ def match_clusters_uuids(points, clusters):
39
+ """
40
+ Match single-point clusters to their original point UUIDs.
41
+
42
+ For clusters containing exactly one point, this function attempts to match the cluster
43
+ coordinates back to the original point to retrieve its UUID. The 'uuid' key is optional
44
+ and will only be present in single-point clusters where a matching point is found.
45
+
46
+ Args:
47
+ points: List of point dicts with 'position' and 'uuid' keys
48
+ clusters: List of cluster dicts with 'longitude', 'latitude', and 'count' keys.
49
+ For single-point clusters (count=1), a 'uuid' key will be added if a
50
+ matching point is found (modified in place)
51
+
52
+ Returns:
53
+ The modified clusters list with 'uuid' keys added to matched single-point clusters
54
+ """
55
+ points_coords = [(point["position"][0], point["position"][1]) for point in points]
56
+ tree = KDTree(points_coords)
57
+ for cluster in clusters:
58
+ if cluster["count"] == 1:
59
+ cluster_coords = (cluster["longitude"], cluster["latitude"])
60
+ dist, idx = tree.query(cluster_coords)
61
+ if dist < DISTANCE_THRESHOLD:
62
+ closest_point = points[idx]
63
+ cluster["uuid"] = closest_point["uuid"]
64
+ else:
65
+ # Log warning when no match is found - indicates data inconsistency
66
+ logger.warning(
67
+ "No matching UUID found for cluster at coordinates (%f, %f). "
68
+ "Distance to nearest point: %f (threshold: %f)",
69
+ cluster["longitude"],
70
+ cluster["latitude"],
71
+ dist,
72
+ DISTANCE_THRESHOLD,
73
+ )
74
+ cluster["uuid"] = None
75
+ return clusters
@@ -1,13 +1,40 @@
1
1
  import importlib.metadata
2
+ import logging
2
3
  import uuid
3
4
 
5
+ import numpy
6
+ import pysupercluster
4
7
  from flask import Blueprint, jsonify, make_response, request
5
8
  from flask_babel import gettext
6
9
  from flask_restx import Api, Resource, fields
7
10
  from platzky.config import LanguagesMapping
8
-
11
+ from werkzeug.exceptions import BadRequest
12
+
13
+ from goodmap.clustering import (
14
+ map_clustering_data_to_proper_lazy_loading_object,
15
+ match_clusters_uuids,
16
+ )
17
+ from goodmap.exceptions import (
18
+ LocationAlreadyExistsError,
19
+ LocationNotFoundError,
20
+ LocationValidationError,
21
+ ReportNotFoundError,
22
+ )
9
23
  from goodmap.formatter import prepare_pin
10
24
 
25
+ # SuperCluster configuration constants
26
+ MIN_ZOOM = 0
27
+ MAX_ZOOM = 16
28
+ CLUSTER_RADIUS = 200
29
+ CLUSTER_EXTENT = 512
30
+
31
+ # Error message constants
32
+ ERROR_INVALID_REQUEST_DATA = "Invalid request data"
33
+ ERROR_INVALID_LOCATION_DATA = "Invalid location data"
34
+ ERROR_INTERNAL_ERROR = "An internal error occurred"
35
+
36
+ logger = logging.getLogger(__name__)
37
+
11
38
 
12
39
  def make_tuple_translation(keys_to_translate):
13
40
  return [(x, gettext(x)) for x in keys_to_translate]
@@ -22,6 +49,27 @@ def get_or_none(data, *keys):
22
49
  return data
23
50
 
24
51
 
52
+ def get_locations_from_request(database, request_args, as_basic_info=False):
53
+ """
54
+ Shared helper to fetch locations from database based on request arguments.
55
+
56
+ Args:
57
+ database: Database instance
58
+ request_args: Request arguments (flask.request.args)
59
+ as_basic_info: If True, returns list of basic_info dicts, otherwise returns Location objects
60
+
61
+ Returns:
62
+ List of locations (either as objects or basic_info dicts)
63
+ """
64
+ query_params = request_args.to_dict(flat=False)
65
+ all_locations = database.get_locations(query_params)
66
+
67
+ if as_basic_info:
68
+ return [x.basic_info() for x in all_locations]
69
+
70
+ return all_locations
71
+
72
+
25
73
  def core_pages(
26
74
  database,
27
75
  languages: LanguagesMapping,
@@ -66,10 +114,20 @@ def core_pages(
66
114
  f"at position: {location.position}"
67
115
  )
68
116
  notifier_function(message)
69
- except ValueError as e:
70
- return make_response(jsonify({"message": f"Invalid location data: {e}"}), 400)
71
- except Exception as e:
72
- return make_response(jsonify({"message": f"Error sending notification : {e}"}), 400)
117
+ except BadRequest:
118
+ logger.warning("Invalid JSON in suggest endpoint")
119
+ return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
120
+ except LocationValidationError as e:
121
+ logger.warning(
122
+ "Location validation failed in suggest endpoint",
123
+ extra={"errors": e.validation_errors},
124
+ )
125
+ return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
126
+ except Exception:
127
+ logger.error("Error in suggest location endpoint", exc_info=True)
128
+ return make_response(
129
+ jsonify({"message": "An error occurred while processing your suggestion"}), 500
130
+ )
73
131
  return make_response(jsonify({"message": "Location suggested"}), 200)
74
132
 
75
133
  @core_api.route("/report-location")
@@ -92,12 +150,19 @@ def core_pages(
92
150
  f"with problem: {location_report['description']}"
93
151
  )
94
152
  notifier_function(message)
153
+ except BadRequest:
154
+ logger.warning("Invalid JSON in report location endpoint")
155
+ return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
95
156
  except KeyError as e:
157
+ logger.warning(
158
+ "Missing required field in report location", extra={"missing_field": str(e)}
159
+ )
96
160
  error_message = gettext("Error reporting location")
97
- return make_response(jsonify({"message": f"{error_message} : {e}"}), 400)
98
- except Exception as e:
161
+ return make_response(jsonify({"message": error_message}), 400)
162
+ except Exception:
163
+ logger.error("Error in report location endpoint", exc_info=True)
99
164
  error_message = gettext("Error sending notification")
100
- return make_response(jsonify({"message": f"{error_message} : {e}"}), 400)
165
+ return make_response(jsonify({"message": error_message}), 500)
101
166
  return make_response(jsonify({"message": gettext("Location reported")}), 200)
102
167
 
103
168
  @core_api.route("/locations")
@@ -106,9 +171,58 @@ def core_pages(
106
171
  """
107
172
  Shows list of locations with uuid and position
108
173
  """
109
- query_params = request.args.to_dict(flat=False)
110
- all_locations = database.get_locations(query_params)
111
- return jsonify([x.basic_info() for x in all_locations])
174
+ locations = get_locations_from_request(database, request.args, as_basic_info=True)
175
+ return jsonify(locations)
176
+
177
+ @core_api.route("/locations-clustered")
178
+ class GetLocationsClustered(Resource):
179
+ def get(self):
180
+ """
181
+ Shows list of locations with uuid, position and clusters
182
+ """
183
+ try:
184
+ query_params = request.args.to_dict(flat=False)
185
+ zoom = int(query_params.get("zoom", [7])[0])
186
+
187
+ # Validate zoom level (aligned with SuperCluster min_zoom/max_zoom)
188
+ if not MIN_ZOOM <= zoom <= MAX_ZOOM:
189
+ return make_response(
190
+ jsonify({"message": f"Zoom must be between {MIN_ZOOM} and {MAX_ZOOM}"}),
191
+ 400,
192
+ )
193
+
194
+ points = get_locations_from_request(database, request.args, as_basic_info=True)
195
+ if not points:
196
+ return jsonify([])
197
+
198
+ points_numpy = numpy.array(
199
+ [(point["position"][0], point["position"][1]) for point in points]
200
+ )
201
+
202
+ index = pysupercluster.SuperCluster(
203
+ points_numpy,
204
+ min_zoom=MIN_ZOOM,
205
+ max_zoom=MAX_ZOOM,
206
+ radius=CLUSTER_RADIUS,
207
+ extent=CLUSTER_EXTENT,
208
+ )
209
+
210
+ clusters = index.getClusters(
211
+ top_left=(-180.0, 90.0),
212
+ bottom_right=(180.0, -90.0),
213
+ zoom=zoom,
214
+ )
215
+ clusters = match_clusters_uuids(points, clusters)
216
+
217
+ return jsonify(map_clustering_data_to_proper_lazy_loading_object(clusters))
218
+ except ValueError as e:
219
+ logger.warning("Invalid parameter in clustering request: %s", e)
220
+ return make_response(jsonify({"message": "Invalid parameters provided"}), 400)
221
+ except Exception as e:
222
+ logger.error("Clustering operation failed: %s", e, exc_info=True)
223
+ return make_response(
224
+ jsonify({"message": "An error occurred during clustering"}), 500
225
+ )
112
226
 
113
227
  @core_api.route("/location/<location_id>")
114
228
  class GetLocation(Resource):
@@ -211,10 +325,15 @@ def core_pages(
211
325
  location_data.update({"uuid": str(uuid.uuid4())})
212
326
  location = location_model.model_validate(location_data)
213
327
  database.add_location(location.model_dump())
214
- except ValueError as e:
215
- return make_response(jsonify({"message": f"Invalid location data: {e}"}), 400)
216
- except Exception as e:
217
- return make_response(jsonify({"message": f"Error creating location: {e}"}), 400)
328
+ except LocationValidationError as e:
329
+ logger.warning(
330
+ "Location validation failed",
331
+ extra={"uuid": e.uuid, "errors": e.validation_errors},
332
+ )
333
+ return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
334
+ except Exception:
335
+ logger.error("Error creating location", exc_info=True)
336
+ return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
218
337
  return jsonify(location.model_dump())
219
338
 
220
339
  @core_api.route("/admin/locations/<location_id>")
@@ -228,10 +347,18 @@ def core_pages(
228
347
  location_data.update({"uuid": location_id})
229
348
  location = location_model.model_validate(location_data)
230
349
  database.update_location(location_id, location.model_dump())
231
- except ValueError as e:
232
- return make_response(jsonify({"message": f"Invalid location data: {e}"}), 400)
233
- except Exception as e:
234
- return make_response(jsonify({"message": f"Error updating location: {e}"}), 400)
350
+ except LocationValidationError as e:
351
+ logger.warning(
352
+ "Location validation failed",
353
+ extra={"uuid": e.uuid, "errors": e.validation_errors},
354
+ )
355
+ return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
356
+ except LocationNotFoundError as e:
357
+ logger.info("Location not found for update", extra={"uuid": e.uuid})
358
+ return make_response(jsonify({"message": "Location not found"}), 404)
359
+ except Exception:
360
+ logger.error("Error updating location", exc_info=True)
361
+ return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
235
362
  return jsonify(location.model_dump())
236
363
 
237
364
  def delete(self, location_id):
@@ -240,10 +367,12 @@ def core_pages(
240
367
  """
241
368
  try:
242
369
  database.delete_location(location_id)
243
- except ValueError as e:
244
- return make_response(jsonify({"message": f"Location not found: {e}"}), 404)
245
- except Exception as e:
246
- return make_response(jsonify({"message": f"Error deleting location: {e}"}), 400)
370
+ except LocationNotFoundError as e:
371
+ logger.info("Location not found for deletion", extra={"uuid": e.uuid})
372
+ return make_response(jsonify({"message": "Location not found"}), 404)
373
+ except Exception:
374
+ logger.error("Error deleting location", exc_info=True)
375
+ return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
247
376
  return "", 204
248
377
 
249
378
  @core_api.route("/admin/suggestions")
@@ -267,7 +396,7 @@ def core_pages(
267
396
  data = request.get_json()
268
397
  status = data.get("status")
269
398
  if status not in ("accepted", "rejected"):
270
- return make_response(jsonify({"message": f"Invalid status: {status}"}), 400)
399
+ return make_response(jsonify({"message": "Invalid status"}), 400)
271
400
  suggestion = database.get_suggestion(suggestion_id)
272
401
  if not suggestion:
273
402
  return make_response(jsonify({"message": "Suggestion not found"}), 404)
@@ -277,8 +406,20 @@ def core_pages(
277
406
  suggestion_data = {k: v for k, v in suggestion.items() if k != "status"}
278
407
  database.add_location(suggestion_data)
279
408
  database.update_suggestion(suggestion_id, status)
280
- except ValueError as e:
281
- return make_response(jsonify({"message": f"{e}"}), 400)
409
+ except LocationValidationError as e:
410
+ logger.warning(
411
+ "Location validation failed in suggestion",
412
+ extra={"uuid": e.uuid, "errors": e.validation_errors},
413
+ )
414
+ return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
415
+ except LocationAlreadyExistsError as e:
416
+ logger.warning(
417
+ "Attempted to create duplicate location from suggestion", extra={"uuid": e.uuid}
418
+ )
419
+ return make_response(jsonify({"message": "Location already exists"}), 409)
420
+ except Exception:
421
+ logger.error("Error processing suggestion", exc_info=True)
422
+ return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
282
423
  return jsonify(database.get_suggestion(suggestion_id))
283
424
 
284
425
  @core_api.route("/admin/reports")
@@ -305,15 +446,22 @@ def core_pages(
305
446
  valid_status = ("resolved", "rejected")
306
447
  valid_priority = ("critical", "high", "medium", "low")
307
448
  if status and status not in valid_status:
308
- return make_response(jsonify({"message": f"Invalid status: {status}"}), 400)
449
+ return make_response(jsonify({"message": "Invalid status"}), 400)
309
450
  if priority and priority not in valid_priority:
310
- return make_response(jsonify({"message": f"Invalid priority: {priority}"}), 400)
451
+ return make_response(jsonify({"message": "Invalid priority"}), 400)
311
452
  report = database.get_report(report_id)
312
453
  if not report:
313
454
  return make_response(jsonify({"message": "Report not found"}), 404)
314
455
  database.update_report(report_id, status=status, priority=priority)
315
- except ValueError as e:
316
- return make_response(jsonify({"message": f"{e}"}), 400)
456
+ except BadRequest:
457
+ logger.warning("Invalid JSON in report update endpoint")
458
+ return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
459
+ except ReportNotFoundError as e:
460
+ logger.info("Report not found for update", extra={"uuid": e.uuid})
461
+ return make_response(jsonify({"message": "Report not found"}), 404)
462
+ except Exception:
463
+ logger.error("Error updating report", exc_info=True)
464
+ return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
317
465
  return jsonify(database.get_report(report_id))
318
466
 
319
467
  return core_api_blueprint
@@ -1,6 +1,15 @@
1
1
  from typing import Any, Type
2
2
 
3
- from pydantic import BaseModel, Field, create_model, field_validator
3
+ from pydantic import (
4
+ BaseModel,
5
+ Field,
6
+ ValidationError,
7
+ create_model,
8
+ field_validator,
9
+ model_validator,
10
+ )
11
+
12
+ from goodmap.exceptions import LocationValidationError
4
13
 
5
14
 
6
15
  class LocationBase(BaseModel, extra="allow"):
@@ -16,6 +25,24 @@ class LocationBase(BaseModel, extra="allow"):
16
25
  raise ValueError("longitude must be in range -180 to 180")
17
26
  return v
18
27
 
28
+ @model_validator(mode="before")
29
+ @classmethod
30
+ def validate_uuid_exists(cls, data: Any) -> Any:
31
+ """Ensure UUID is present before validation for better error messages."""
32
+ if isinstance(data, dict) and "uuid" not in data:
33
+ raise ValueError("Location data must include 'uuid' field")
34
+ return data
35
+
36
+ @model_validator(mode="wrap")
37
+ @classmethod
38
+ def enrich_validation_errors(cls, data, handler):
39
+ """Wrap validation errors with UUID context for better debugging."""
40
+ try:
41
+ return handler(data)
42
+ except ValidationError as e:
43
+ uuid = data.get("uuid") if isinstance(data, dict) else None
44
+ raise LocationValidationError(e, uuid=uuid) from e
45
+
19
46
  def basic_info(self):
20
47
  return {
21
48
  "uuid": self.uuid,
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import logging
2
3
  import os
3
4
  import tempfile
4
5
  from functools import partial
@@ -6,6 +7,14 @@ from typing import Any
6
7
 
7
8
  from goodmap.core import get_queried_data
8
9
  from goodmap.data_models.location import LocationBase
10
+ from goodmap.exceptions import (
11
+ AlreadyExistsError,
12
+ LocationAlreadyExistsError,
13
+ LocationNotFoundError,
14
+ ReportNotFoundError,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
9
18
 
10
19
  # TODO file is temporary solution to be compatible with old, static code,
11
20
  # it should be replaced with dynamic solution
@@ -227,61 +236,28 @@ class FileIOHelper:
227
236
  return json_data.get(data_key, {})
228
237
 
229
238
 
230
- class ErrorHelper:
231
- """Common error handling utilities."""
232
-
233
- @staticmethod
234
- def raise_already_exists_error(item_type, uuid):
235
- """Raise standardized 'already exists' error."""
236
- raise ValueError(f"{item_type} with uuid {uuid} already exists")
239
+ class CRUDHelper:
240
+ """Common CRUD operation utilities to eliminate duplication."""
237
241
 
238
242
  @staticmethod
239
- def raise_not_found_error(item_type, uuid):
240
- """Raise standardized 'not found' error."""
241
- raise ValueError(f"{item_type} with uuid {uuid} not found")
243
+ def add_item_to_json_db(db_data, collection_name, item_data, default_status=None):
244
+ """Add item to JSON in-memory database."""
245
+ collection = db_data.setdefault(collection_name, [])
246
+ uuid = item_data.get("uuid")
247
+ resource_type = collection_name.rstrip("s").capitalize()
242
248
 
243
- @staticmethod
244
- def check_item_exists(items, uuid, item_type):
245
- """Check if item with UUID exists and raise error if it does."""
249
+ # Check if item already exists
246
250
  existing = next(
247
251
  (
248
252
  item
249
- for item in items
253
+ for item in collection
250
254
  if (item.get("uuid") if isinstance(item, dict) else getattr(item, "uuid", None))
251
255
  == uuid
252
256
  ),
253
257
  None,
254
258
  )
255
259
  if existing:
256
- ErrorHelper.raise_already_exists_error(item_type, uuid)
257
-
258
- @staticmethod
259
- def find_item_by_uuid(items, uuid, item_type):
260
- """Find item by UUID and raise error if not found."""
261
- item = next(
262
- (
263
- item
264
- for item in items
265
- if (item.get("uuid") if isinstance(item, dict) else getattr(item, "uuid", None))
266
- == uuid
267
- ),
268
- None,
269
- )
270
- if not item:
271
- ErrorHelper.raise_not_found_error(item_type, uuid)
272
- return item
273
-
274
-
275
- class CRUDHelper:
276
- """Common CRUD operation utilities to eliminate duplication."""
277
-
278
- @staticmethod
279
- def add_item_to_json_db(db_data, collection_name, item_data, default_status=None):
280
- """Add item to JSON in-memory database."""
281
- collection = db_data.setdefault(collection_name, [])
282
- ErrorHelper.check_item_exists(
283
- collection, item_data.get("uuid"), collection_name.rstrip("s").capitalize()
284
- )
260
+ raise AlreadyExistsError(uuid, resource_type)
285
261
 
286
262
  record = dict(item_data)
287
263
  if default_status:
@@ -294,9 +270,21 @@ class CRUDHelper:
294
270
  json_file = FileIOHelper.read_json_file(file_path)
295
271
  collection = json_file["map"].get(collection_name, [])
296
272
 
297
- ErrorHelper.check_item_exists(
298
- collection, item_data.get("uuid"), collection_name.rstrip("s").capitalize()
273
+ uuid = item_data.get("uuid")
274
+ resource_type = collection_name.rstrip("s").capitalize()
275
+
276
+ # Check if item already exists
277
+ existing = next(
278
+ (
279
+ item
280
+ for item in collection
281
+ if (item.get("uuid") if isinstance(item, dict) else getattr(item, "uuid", None))
282
+ == uuid
283
+ ),
284
+ None,
299
285
  )
286
+ if existing:
287
+ raise AlreadyExistsError(uuid, resource_type)
300
288
 
301
289
  record = dict(item_data)
302
290
  if default_status:
@@ -311,7 +299,7 @@ class CRUDHelper:
311
299
  """Add item to MongoDB database."""
312
300
  existing = db_collection.find_one({"uuid": item_data.get("uuid")})
313
301
  if existing:
314
- ErrorHelper.raise_already_exists_error(item_type, item_data.get("uuid"))
302
+ raise AlreadyExistsError(item_data.get("uuid"), item_type)
315
303
 
316
304
  record = dict(item_data)
317
305
  if default_status:
@@ -787,7 +775,7 @@ def json_file_db_add_location(self, location_data, location_model):
787
775
  (i for i, point in enumerate(map_data) if point.get("uuid") == location_data["uuid"]), None
788
776
  )
789
777
  if idx is not None:
790
- raise ValueError(f"Location with uuid {location_data['uuid']} already exists")
778
+ raise LocationAlreadyExistsError(location_data["uuid"])
791
779
 
792
780
  map_data.append(location.model_dump())
793
781
  json_file["map"]["data"] = map_data
@@ -806,7 +794,7 @@ def json_db_add_location(self, location_data, location_model):
806
794
  None,
807
795
  )
808
796
  if idx is not None:
809
- raise ValueError(f"Location with uuid {location_data['uuid']} already exists")
797
+ raise LocationAlreadyExistsError(location_data["uuid"])
810
798
  self.data["data"].append(location.model_dump())
811
799
 
812
800
 
@@ -814,7 +802,7 @@ def mongodb_db_add_location(self, location_data, location_model):
814
802
  location = location_model.model_validate(location_data)
815
803
  existing = self.db.locations.find_one({"uuid": location_data["uuid"]})
816
804
  if existing:
817
- raise ValueError(f"Location with uuid {location_data['uuid']} already exists")
805
+ raise LocationAlreadyExistsError(location_data["uuid"])
818
806
  self.db.locations.insert_one(location.model_dump())
819
807
 
820
808
 
@@ -834,7 +822,7 @@ def json_file_db_update_location(self, uuid, location_data, location_model):
834
822
  map_data = json_file["map"].get("data", [])
835
823
  idx = next((i for i, point in enumerate(map_data) if point.get("uuid") == uuid), None)
836
824
  if idx is None:
837
- raise ValueError(f"Location with uuid {uuid} not found")
825
+ raise LocationNotFoundError(uuid)
838
826
 
839
827
  map_data[idx] = location.model_dump()
840
828
  json_file["map"]["data"] = map_data
@@ -848,7 +836,7 @@ def json_db_update_location(self, uuid, location_data, location_model):
848
836
  (i for i, point in enumerate(self.data.get("data", [])) if point.get("uuid") == uuid), None
849
837
  )
850
838
  if idx is None:
851
- raise ValueError(f"Location with uuid {uuid} not found")
839
+ raise LocationNotFoundError(uuid)
852
840
  self.data["data"][idx] = location.model_dump()
853
841
 
854
842
 
@@ -856,7 +844,7 @@ def mongodb_db_update_location(self, uuid, location_data, location_model):
856
844
  location = location_model.model_validate(location_data)
857
845
  result = self.db.locations.update_one({"uuid": uuid}, {"$set": location.model_dump()})
858
846
  if result.matched_count == 0:
859
- raise ValueError(f"Location with uuid {uuid} not found")
847
+ raise LocationNotFoundError(uuid)
860
848
 
861
849
 
862
850
  def update_location(db, uuid, location_data, location_model):
@@ -874,7 +862,7 @@ def json_file_db_delete_location(self, uuid):
874
862
  map_data = json_file["map"].get("data", [])
875
863
  idx = next((i for i, point in enumerate(map_data) if point.get("uuid") == uuid), None)
876
864
  if idx is None:
877
- raise ValueError(f"Location with uuid {uuid} not found")
865
+ raise LocationNotFoundError(uuid)
878
866
 
879
867
  del map_data[idx]
880
868
  json_file["map"]["data"] = map_data
@@ -887,14 +875,14 @@ def json_db_delete_location(self, uuid):
887
875
  (i for i, point in enumerate(self.data.get("data", [])) if point.get("uuid") == uuid), None
888
876
  )
889
877
  if idx is None:
890
- raise ValueError(f"Location with uuid {uuid} not found")
878
+ raise LocationNotFoundError(uuid)
891
879
  del self.data["data"][idx]
892
880
 
893
881
 
894
882
  def mongodb_db_delete_location(self, uuid):
895
883
  result = self.db.locations.delete_one({"uuid": uuid})
896
884
  if result.deleted_count == 0:
897
- raise ValueError(f"Location with uuid {uuid} not found")
885
+ raise LocationNotFoundError(uuid)
898
886
 
899
887
 
900
888
  def delete_location(db, uuid):
@@ -1359,7 +1347,7 @@ def json_db_update_report(self, report_id, status=None, priority=None):
1359
1347
  if priority:
1360
1348
  r["priority"] = priority
1361
1349
  return
1362
- raise ValueError(f"Report with uuid {report_id} not found")
1350
+ raise ReportNotFoundError(report_id)
1363
1351
 
1364
1352
 
1365
1353
  def json_file_db_update_report(self, report_id, status=None, priority=None):
@@ -1375,7 +1363,7 @@ def json_file_db_update_report(self, report_id, status=None, priority=None):
1375
1363
  r["priority"] = priority
1376
1364
  break
1377
1365
  else:
1378
- raise ValueError(f"Report with uuid {report_id} not found")
1366
+ raise ReportNotFoundError(report_id)
1379
1367
 
1380
1368
  json_file["map"]["reports"] = reports
1381
1369
 
@@ -1392,7 +1380,7 @@ def mongodb_db_update_report(self, report_id, status=None, priority=None):
1392
1380
  if update_doc:
1393
1381
  result = self.db.reports.update_one({"uuid": report_id}, {"$set": update_doc})
1394
1382
  if result.matched_count == 0:
1395
- raise ValueError(f"Report with uuid {report_id} not found")
1383
+ raise ReportNotFoundError(report_id)
1396
1384
 
1397
1385
 
1398
1386
  def google_json_db_update_report(self, report_id, status=None, priority=None):
@@ -1412,7 +1400,7 @@ def json_db_delete_report(self, report_id):
1412
1400
  reports = self.data.get("reports", [])
1413
1401
  idx = next((i for i, r in enumerate(reports) if r.get("uuid") == report_id), None)
1414
1402
  if idx is None:
1415
- raise ValueError(f"Report with uuid {report_id} not found")
1403
+ raise ReportNotFoundError(report_id)
1416
1404
  del reports[idx]
1417
1405
 
1418
1406
 
@@ -1423,7 +1411,7 @@ def json_file_db_delete_report(self, report_id):
1423
1411
  reports = json_file["map"].get("reports", [])
1424
1412
  idx = next((i for i, r in enumerate(reports) if r.get("uuid") == report_id), None)
1425
1413
  if idx is None:
1426
- raise ValueError(f"Report with uuid {report_id} not found")
1414
+ raise ReportNotFoundError(report_id)
1427
1415
 
1428
1416
  del reports[idx]
1429
1417
  json_file["map"]["reports"] = reports
@@ -1434,7 +1422,7 @@ def json_file_db_delete_report(self, report_id):
1434
1422
  def mongodb_db_delete_report(self, report_id):
1435
1423
  result = self.db.reports.delete_one({"uuid": report_id})
1436
1424
  if result.deleted_count == 0:
1437
- raise ValueError(f"Report with uuid {report_id} not found")
1425
+ raise ReportNotFoundError(report_id)
1438
1426
 
1439
1427
 
1440
1428
  def google_json_db_delete_report(self, report_id):
@@ -0,0 +1,100 @@
1
+ """Custom exceptions for Goodmap application."""
2
+
3
+ import logging
4
+ import uuid as uuid_lib
5
+
6
+ from pydantic import ValidationError as PydanticValidationError
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def _sanitize_uuid(uuid: str | None) -> str:
12
+ """Validate and sanitize UUID to prevent injection attacks."""
13
+ if uuid is None:
14
+ return "<unknown>"
15
+ try:
16
+ # Validate UUID format
17
+ uuid_lib.UUID(uuid)
18
+ return uuid
19
+ except (ValueError, AttributeError, TypeError):
20
+ logger.warning("Invalid UUID format detected", extra={"raw_uuid": repr(uuid)})
21
+ return "<invalid-uuid>"
22
+
23
+
24
+ class GoodmapError(Exception):
25
+ """Base exception for all Goodmap errors."""
26
+
27
+ pass
28
+
29
+
30
+ class ValidationError(GoodmapError):
31
+ """Base validation error."""
32
+
33
+ pass
34
+
35
+
36
+ class LocationValidationError(ValidationError):
37
+ """Validation error for location data with enhanced context."""
38
+
39
+ def __init__(self, validation_error: PydanticValidationError, uuid: str | None = None):
40
+ self.uuid = _sanitize_uuid(uuid)
41
+ self.original_error = validation_error
42
+ self.validation_errors = validation_error.errors()
43
+ super().__init__(str(validation_error))
44
+
45
+ def __str__(self):
46
+ # Don't expose error details in string representation
47
+ if self.uuid and self.uuid not in ("<unknown>", "<invalid-uuid>"):
48
+ return f"Validation failed for location '{self.uuid}'"
49
+ return "Validation failed"
50
+
51
+
52
+ class NotFoundError(GoodmapError):
53
+ """Resource not found error."""
54
+
55
+ def __init__(self, uuid: str, resource_type: str = "Resource"):
56
+ self.uuid = _sanitize_uuid(uuid)
57
+ super().__init__(f"{resource_type} with uuid '{self.uuid}' not found")
58
+
59
+
60
+ class LocationNotFoundError(NotFoundError):
61
+ """Location with specified UUID not found."""
62
+
63
+ def __init__(self, uuid: str):
64
+ super().__init__(uuid, "Location")
65
+
66
+
67
+ class AlreadyExistsError(GoodmapError):
68
+ """Resource already exists error."""
69
+
70
+ def __init__(self, uuid: str, resource_type: str = "Resource"):
71
+ self.uuid = _sanitize_uuid(uuid)
72
+ super().__init__(f"{resource_type} with uuid '{self.uuid}' already exists")
73
+
74
+
75
+ class LocationAlreadyExistsError(AlreadyExistsError):
76
+ """Location with specified UUID already exists."""
77
+
78
+ def __init__(self, uuid: str):
79
+ super().__init__(uuid, "Location")
80
+
81
+
82
+ class SuggestionNotFoundError(NotFoundError):
83
+ """Suggestion with specified UUID not found."""
84
+
85
+ def __init__(self, uuid: str):
86
+ super().__init__(uuid, "Suggestion")
87
+
88
+
89
+ class SuggestionAlreadyExistsError(AlreadyExistsError):
90
+ """Suggestion with specified UUID already exists."""
91
+
92
+ def __init__(self, uuid: str):
93
+ super().__init__(uuid, "Suggestion")
94
+
95
+
96
+ class ReportNotFoundError(NotFoundError):
97
+ """Report with specified UUID not found."""
98
+
99
+ def __init__(self, uuid: str):
100
+ super().__init__(uuid, "Report")
@@ -116,6 +116,7 @@ window.PRIMARY_COLOR = "{{ primary_color }}";
116
116
  window.SHOW_SUGGEST_NEW_POINT_BUTTON = {{ feature_flags.SHOW_SUGGEST_NEW_POINT_BUTTON | default(false) | tojson }};
117
117
  window.SHOW_SEARCH_BAR = {{ feature_flags.SHOW_SEARCH_BAR | default(false) | tojson }};
118
118
  window.USE_LAZY_LOADING = {{ feature_flags.USE_LAZY_LOADING | default(false) | tojson }};
119
+ window.USE_SERVER_SIDE_CLUSTERING = {{ feature_flags.USE_SERVER_SIDE_CLUSTERING | default(false) | tojson }};
119
120
  window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }};
120
121
  window.FEATURE_FLAGS = {{ feature_flags | tojson }};
121
122
  </script>
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "goodmap"
3
- version = "1.1.4"
3
+ version = "1.1.7"
4
4
  description = "Map engine to serve all the people :)"
5
5
  authors = ["Krzysztof Kolodzinski <krzysztof.kolodzinski@problematy.pl>"]
6
6
  readme = "README.md"
@@ -22,6 +22,11 @@ aiohttp = "^3.8.4"
22
22
  pydantic = "^2.7.1"
23
23
  platzky = "^1.0.0"
24
24
  deprecation = "^2.1.0"
25
+ numpy = "^2.2.0"
26
+ # Using fork because official PyPI version (0.7.7) has outdated numpy setup hack
27
+ # that breaks with numpy >= 2.0. Fork simply removes the obsolete hack.
28
+ pysupercluster-problematy = "^0.7.8"
29
+ scipy = "^1.15.1"
25
30
  sphinx = {version = "^8.0.0", optional = true}
26
31
  sphinx-rtd-theme = {version = "^3.0.0", optional = true}
27
32
  tomli = {version = "^2.0.0", optional = true}
@@ -51,7 +56,7 @@ interrogate = "^1.7.0"
51
56
  platzky = {path = "vendor/platzky", develop = true}
52
57
 
53
58
  [build-system]
54
- requires = ["poetry-core>=1.0.0"]
59
+ requires = ["poetry-core>=1.0.0", "numpy"]
55
60
  build-backend = "poetry.core.masonry.api"
56
61
 
57
62
  [tool.pyright]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes