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.
- {goodmap-1.1.4 → goodmap-1.1.7}/PKG-INFO +11 -7
- {goodmap-1.1.4 → goodmap-1.1.7}/README.md +7 -6
- goodmap-1.1.7/goodmap/clustering.py +75 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/core_api.py +178 -30
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/data_models/location.py +28 -1
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/db.py +49 -61
- goodmap-1.1.7/goodmap/exceptions.py +100 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/templates/map.html +1 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/pyproject.toml +7 -2
- {goodmap-1.1.4 → goodmap-1.1.7}/LICENSE.md +0 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/__init__.py +0 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/config.py +0 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/core.py +0 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/data_validator.py +0 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/formatter.py +0 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/goodmap.py +0 -0
- {goodmap-1.1.4 → goodmap-1.1.7}/goodmap/templates/goodmap-admin.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: goodmap
|
|
3
|
-
Version: 1.1.
|
|
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 `
|
|
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="./
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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="./
|
|
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 `
|
|
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 `
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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":
|
|
98
|
-
except Exception
|
|
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":
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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":
|
|
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
|
|
281
|
-
|
|
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":
|
|
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":
|
|
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
|
|
316
|
-
|
|
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
|
|
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
|
|
231
|
-
"""Common
|
|
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
|
|
240
|
-
"""
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|