goodmap 1.1.7__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+
goodmap/clustering.py ADDED
@@ -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
goodmap/config.py ADDED
@@ -0,0 +1,42 @@
1
+ import sys
2
+ import typing as t
3
+
4
+ import yaml
5
+ from platzky.config import Config as PlatzkyConfig
6
+ from pydantic import Field
7
+
8
+
9
+ class GoodmapConfig(PlatzkyConfig):
10
+ """Extended configuration for Goodmap with additional frontend library URL."""
11
+
12
+ goodmap_frontend_lib_url: str = Field(
13
+ default="https://cdn.jsdelivr.net/npm/@problematy/goodmap@0.4.2",
14
+ alias="GOODMAP_FRONTEND_LIB_URL",
15
+ )
16
+
17
+ @classmethod
18
+ def model_validate(
19
+ cls,
20
+ obj: t.Any,
21
+ *,
22
+ strict: bool | None = None,
23
+ from_attributes: bool | None = None,
24
+ context: dict[str, t.Any] | None = None,
25
+ ) -> "GoodmapConfig":
26
+ """Override to return correct type for GoodmapConfig."""
27
+ return t.cast(
28
+ "GoodmapConfig",
29
+ super().model_validate(
30
+ obj, strict=strict, from_attributes=from_attributes, context=context
31
+ ),
32
+ )
33
+
34
+ @classmethod
35
+ def parse_yaml(cls, path: str) -> "GoodmapConfig":
36
+ """Parse YAML configuration file and return GoodmapConfig instance."""
37
+ try:
38
+ with open(path, "r") as f:
39
+ return cls.model_validate(yaml.safe_load(f))
40
+ except FileNotFoundError:
41
+ print(f"Config file not found: {path}", file=sys.stderr)
42
+ raise SystemExit(1)
goodmap/core.py ADDED
@@ -0,0 +1,46 @@
1
+ from typing import Any, Dict, List
2
+
3
+ # TODO move filtering to db site
4
+
5
+
6
+ def does_fulfill_requirement(entry, requirements):
7
+ matches = []
8
+ for category, values in requirements:
9
+ if not values:
10
+ continue
11
+ matches.append(all(entry_value in entry[category] for entry_value in values))
12
+ return all(matches)
13
+
14
+
15
+ def sort_by_distance(data: List[Dict[str, Any]], query_params: Dict[str, List[str]]):
16
+ try:
17
+ if "lat" in query_params and "lon" in query_params:
18
+ lat = float(query_params["lat"][0])
19
+ lon = float(query_params["lon"][0])
20
+ data.sort(key=lambda x: (x["position"][0] - lat) ** 2 + (x["position"][1] - lon) ** 2)
21
+ return data
22
+ return data
23
+ except (ValueError, KeyError, IndexError):
24
+ return data
25
+
26
+
27
+ def limit(data, query_params):
28
+ try:
29
+ if "limit" in query_params:
30
+ limit = int(query_params["limit"][0])
31
+ data = data[:limit]
32
+ return data
33
+ return data
34
+ except (ValueError, KeyError, IndexError):
35
+ return data
36
+
37
+
38
+ def get_queried_data(all_data, categories, query_params):
39
+ requirements = []
40
+ for key in categories.keys():
41
+ requirements.append((key, query_params.get(key)))
42
+
43
+ filtered_data = [x for x in all_data if does_fulfill_requirement(x, requirements)]
44
+ final_data = sort_by_distance(filtered_data, query_params)
45
+ final_data = limit(final_data, query_params)
46
+ return final_data
goodmap/core_api.py ADDED
@@ -0,0 +1,467 @@
1
+ import importlib.metadata
2
+ import logging
3
+ import uuid
4
+
5
+ import numpy
6
+ import pysupercluster
7
+ from flask import Blueprint, jsonify, make_response, request
8
+ from flask_babel import gettext
9
+ from flask_restx import Api, Resource, fields
10
+ from platzky.config import LanguagesMapping
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
+ )
23
+ from goodmap.formatter import prepare_pin
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
+
38
+
39
+ def make_tuple_translation(keys_to_translate):
40
+ return [(x, gettext(x)) for x in keys_to_translate]
41
+
42
+
43
+ def get_or_none(data, *keys):
44
+ for key in keys:
45
+ if isinstance(data, dict):
46
+ data = data.get(key)
47
+ else:
48
+ return None
49
+ return data
50
+
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
+
73
+ def core_pages(
74
+ database,
75
+ languages: LanguagesMapping,
76
+ notifier_function,
77
+ csrf_generator,
78
+ location_model,
79
+ feature_flags={},
80
+ ) -> Blueprint:
81
+ core_api_blueprint = Blueprint("api", __name__, url_prefix="/api")
82
+ core_api = Api(core_api_blueprint, doc="/doc", version="0.1")
83
+
84
+ location_report_model = core_api.model(
85
+ "LocationReport",
86
+ {
87
+ "id": fields.String(required=True, description="Location ID"),
88
+ "description": fields.String(required=True, description="Description of the problem"),
89
+ },
90
+ )
91
+
92
+ # TODO get this from Location pydantic model
93
+ suggested_location_model = core_api.model(
94
+ "LocationSuggestion",
95
+ {
96
+ "name": fields.String(required=False, description="Organization name"),
97
+ "position": fields.String(required=True, description="Location of the suggestion"),
98
+ "photo": fields.String(required=False, description="Photo of the location"),
99
+ },
100
+ )
101
+
102
+ @core_api.route("/suggest-new-point")
103
+ class NewLocation(Resource):
104
+ @core_api.expect(suggested_location_model)
105
+ def post(self):
106
+ """Suggest new location"""
107
+ try:
108
+ suggested_location = request.get_json()
109
+ suggested_location.update({"uuid": str(uuid.uuid4())})
110
+ location = location_model.model_validate(suggested_location)
111
+ database.add_suggestion(location.model_dump())
112
+ message = (
113
+ f"A new location has been suggested under uuid: '{location.uuid}' "
114
+ f"at position: {location.position}"
115
+ )
116
+ notifier_function(message)
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
+ )
131
+ return make_response(jsonify({"message": "Location suggested"}), 200)
132
+
133
+ @core_api.route("/report-location")
134
+ class ReportLocation(Resource):
135
+ @core_api.expect(location_report_model)
136
+ def post(self):
137
+ """Report location"""
138
+ try:
139
+ location_report = request.get_json()
140
+ report = {
141
+ "uuid": str(uuid.uuid4()),
142
+ "location_id": location_report["id"],
143
+ "description": location_report["description"],
144
+ "status": "pending",
145
+ "priority": "medium",
146
+ }
147
+ database.add_report(report)
148
+ message = (
149
+ f"A location has been reported: '{location_report['id']}' "
150
+ f"with problem: {location_report['description']}"
151
+ )
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)
156
+ except KeyError as e:
157
+ logger.warning(
158
+ "Missing required field in report location", extra={"missing_field": str(e)}
159
+ )
160
+ error_message = gettext("Error reporting location")
161
+ return make_response(jsonify({"message": error_message}), 400)
162
+ except Exception:
163
+ logger.error("Error in report location endpoint", exc_info=True)
164
+ error_message = gettext("Error sending notification")
165
+ return make_response(jsonify({"message": error_message}), 500)
166
+ return make_response(jsonify({"message": gettext("Location reported")}), 200)
167
+
168
+ @core_api.route("/locations")
169
+ class GetLocations(Resource):
170
+ def get(self):
171
+ """
172
+ Shows list of locations with uuid and position
173
+ """
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
+ )
226
+
227
+ @core_api.route("/location/<location_id>")
228
+ class GetLocation(Resource):
229
+ def get(self, location_id):
230
+ """
231
+ Shows a single location with all data
232
+ """
233
+ location = database.get_location(location_id)
234
+ visible_data = database.get_visible_data()
235
+ meta_data = database.get_meta_data()
236
+
237
+ formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data)
238
+ return jsonify(formatted_data)
239
+
240
+ @core_api.route("/version")
241
+ class Version(Resource):
242
+ def get(self):
243
+ """Shows backend version"""
244
+ version_info = {"backend": importlib.metadata.version("goodmap")}
245
+ return jsonify(version_info)
246
+
247
+ @core_api.route("/categories")
248
+ class Categories(Resource):
249
+ def get(self):
250
+ """Shows all available categories"""
251
+ raw_categories = database.get_categories()
252
+ categories = make_tuple_translation(raw_categories)
253
+
254
+ if not feature_flags.get("CATEGORIES_HELP", False):
255
+ return jsonify(categories)
256
+ else:
257
+ category_data = database.get_category_data()
258
+ categories_help = category_data["categories_help"]
259
+ proper_categories_help = []
260
+ if categories_help is not None:
261
+ for option in categories_help:
262
+ proper_categories_help.append(
263
+ {option: gettext(f"categories_help_{option}")}
264
+ )
265
+
266
+ return jsonify({"categories": categories, "categories_help": proper_categories_help})
267
+
268
+ @core_api.route("/languages")
269
+ class Languages(Resource):
270
+ def get(self):
271
+ """Shows all available languages"""
272
+ return jsonify(languages)
273
+
274
+ @core_api.route("/category/<category_type>")
275
+ class CategoryTypes(Resource):
276
+ def get(self, category_type):
277
+ """Shows all available types in category"""
278
+ category_data = database.get_category_data(category_type)
279
+ local_data = make_tuple_translation(category_data["categories"][category_type])
280
+
281
+ categories_options_help = get_or_none(
282
+ category_data, "categories_options_help", category_type
283
+ )
284
+ proper_categories_options_help = []
285
+ if categories_options_help is not None:
286
+ for option in categories_options_help:
287
+ proper_categories_options_help.append(
288
+ {option: gettext(f"categories_options_help_{option}")}
289
+ )
290
+ if not feature_flags.get("CATEGORIES_HELP", False):
291
+ return jsonify(local_data)
292
+ else:
293
+ return jsonify(
294
+ {
295
+ "categories_options": local_data,
296
+ "categories_options_help": proper_categories_options_help,
297
+ }
298
+ )
299
+
300
+ @core_api.route("/generate-csrf-token")
301
+ class CsrfToken(Resource):
302
+ def get(self):
303
+ csrf_token = csrf_generator()
304
+ return {"csrf_token": csrf_token}
305
+
306
+ @core_api.route("/admin/locations")
307
+ class AdminManageLocations(Resource):
308
+ def get(self):
309
+ """
310
+ Shows full list of locations, with optional server-side pagination, sorting,
311
+ and filtering.
312
+ """
313
+ query_params = request.args.to_dict(flat=False)
314
+ if "sort_by" not in query_params:
315
+ query_params["sort_by"] = ["name"]
316
+ result = database.get_locations_paginated(query_params)
317
+ return jsonify(result)
318
+
319
+ def post(self):
320
+ """
321
+ Creates a new location
322
+ """
323
+ location_data = request.get_json()
324
+ try:
325
+ location_data.update({"uuid": str(uuid.uuid4())})
326
+ location = location_model.model_validate(location_data)
327
+ database.add_location(location.model_dump())
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)
337
+ return jsonify(location.model_dump())
338
+
339
+ @core_api.route("/admin/locations/<location_id>")
340
+ class AdminManageLocation(Resource):
341
+ def put(self, location_id):
342
+ """
343
+ Updates a single location
344
+ """
345
+ location_data = request.get_json()
346
+ try:
347
+ location_data.update({"uuid": location_id})
348
+ location = location_model.model_validate(location_data)
349
+ database.update_location(location_id, location.model_dump())
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)
362
+ return jsonify(location.model_dump())
363
+
364
+ def delete(self, location_id):
365
+ """
366
+ Deletes a single location
367
+ """
368
+ try:
369
+ database.delete_location(location_id)
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)
376
+ return "", 204
377
+
378
+ @core_api.route("/admin/suggestions")
379
+ class AdminManageSuggestions(Resource):
380
+ def get(self):
381
+ """
382
+ List location suggestions, with optional server-side pagination, sorting,
383
+ and filtering by status.
384
+ """
385
+ query_params = request.args.to_dict(flat=False)
386
+ result = database.get_suggestions_paginated(query_params)
387
+ return jsonify(result)
388
+
389
+ @core_api.route("/admin/suggestions/<suggestion_id>")
390
+ class AdminManageSuggestion(Resource):
391
+ def put(self, suggestion_id):
392
+ """
393
+ Accept or reject a location suggestion
394
+ """
395
+ try:
396
+ data = request.get_json()
397
+ status = data.get("status")
398
+ if status not in ("accepted", "rejected"):
399
+ return make_response(jsonify({"message": "Invalid status"}), 400)
400
+ suggestion = database.get_suggestion(suggestion_id)
401
+ if not suggestion:
402
+ return make_response(jsonify({"message": "Suggestion not found"}), 404)
403
+ if suggestion.get("status") != "pending":
404
+ return make_response(jsonify({"message": "Suggestion already processed"}), 400)
405
+ if status == "accepted":
406
+ suggestion_data = {k: v for k, v in suggestion.items() if k != "status"}
407
+ database.add_location(suggestion_data)
408
+ database.update_suggestion(suggestion_id, status)
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)
423
+ return jsonify(database.get_suggestion(suggestion_id))
424
+
425
+ @core_api.route("/admin/reports")
426
+ class AdminManageReports(Resource):
427
+ def get(self):
428
+ """
429
+ List location reports, with optional server-side pagination, sorting,
430
+ and filtering by status/priority.
431
+ """
432
+ query_params = request.args.to_dict(flat=False)
433
+ result = database.get_reports_paginated(query_params)
434
+ return jsonify(result)
435
+
436
+ @core_api.route("/admin/reports/<report_id>")
437
+ class AdminManageReport(Resource):
438
+ def put(self, report_id):
439
+ """
440
+ Update a report's status and/or priority
441
+ """
442
+ try:
443
+ data = request.get_json()
444
+ status = data.get("status")
445
+ priority = data.get("priority")
446
+ valid_status = ("resolved", "rejected")
447
+ valid_priority = ("critical", "high", "medium", "low")
448
+ if status and status not in valid_status:
449
+ return make_response(jsonify({"message": "Invalid status"}), 400)
450
+ if priority and priority not in valid_priority:
451
+ return make_response(jsonify({"message": "Invalid priority"}), 400)
452
+ report = database.get_report(report_id)
453
+ if not report:
454
+ return make_response(jsonify({"message": "Report not found"}), 404)
455
+ database.update_report(report_id, status=status, priority=priority)
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)
465
+ return jsonify(database.get_report(report_id))
466
+
467
+ return core_api_blueprint