goodmap 1.1.14__py3-none-any.whl → 1.2.0__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/api_models.py +105 -0
- goodmap/core.py +39 -0
- goodmap/core_api.py +518 -407
- goodmap/data_models/location.py +154 -23
- goodmap/formatter.py +20 -0
- goodmap/goodmap.py +92 -8
- goodmap/json_security.py +102 -0
- {goodmap-1.1.14.dist-info → goodmap-1.2.0.dist-info}/METADATA +2 -2
- goodmap-1.2.0.dist-info/RECORD +19 -0
- goodmap-1.1.14.dist-info/RECORD +0 -17
- {goodmap-1.1.14.dist-info → goodmap-1.2.0.dist-info}/LICENSE.md +0 -0
- {goodmap-1.1.14.dist-info → goodmap-1.2.0.dist-info}/WHEEL +0 -0
goodmap/core_api.py
CHANGED
|
@@ -7,10 +7,20 @@ import numpy
|
|
|
7
7
|
import pysupercluster
|
|
8
8
|
from flask import Blueprint, jsonify, make_response, request
|
|
9
9
|
from flask_babel import gettext
|
|
10
|
-
from flask_restx import Api, Resource, fields
|
|
11
10
|
from platzky.config import LanguagesMapping
|
|
11
|
+
from spectree import Response, SpecTree
|
|
12
12
|
from werkzeug.exceptions import BadRequest
|
|
13
13
|
|
|
14
|
+
from goodmap.api_models import (
|
|
15
|
+
CSRFTokenResponse,
|
|
16
|
+
ErrorResponse,
|
|
17
|
+
LocationReportRequest,
|
|
18
|
+
LocationReportResponse,
|
|
19
|
+
ReportUpdateRequest,
|
|
20
|
+
SuccessResponse,
|
|
21
|
+
SuggestionStatusRequest,
|
|
22
|
+
VersionResponse,
|
|
23
|
+
)
|
|
14
24
|
from goodmap.clustering import (
|
|
15
25
|
map_clustering_data_to_proper_lazy_loading_object,
|
|
16
26
|
match_clusters_uuids,
|
|
@@ -22,6 +32,12 @@ from goodmap.exceptions import (
|
|
|
22
32
|
ReportNotFoundError,
|
|
23
33
|
)
|
|
24
34
|
from goodmap.formatter import prepare_pin
|
|
35
|
+
from goodmap.json_security import (
|
|
36
|
+
MAX_JSON_DEPTH_LOCATION,
|
|
37
|
+
JSONDepthError,
|
|
38
|
+
JSONSizeError,
|
|
39
|
+
safe_json_loads,
|
|
40
|
+
)
|
|
25
41
|
|
|
26
42
|
# SuperCluster configuration constants
|
|
27
43
|
MIN_ZOOM = 0
|
|
@@ -33,6 +49,7 @@ CLUSTER_EXTENT = 512
|
|
|
33
49
|
ERROR_INVALID_REQUEST_DATA = "Invalid request data"
|
|
34
50
|
ERROR_INVALID_LOCATION_DATA = "Invalid location data"
|
|
35
51
|
ERROR_INTERNAL_ERROR = "An internal error occurred"
|
|
52
|
+
ERROR_LOCATION_NOT_FOUND = "Location not found"
|
|
36
53
|
|
|
37
54
|
logger = logging.getLogger(__name__)
|
|
38
55
|
|
|
@@ -50,25 +67,20 @@ def get_or_none(data, *keys):
|
|
|
50
67
|
return data
|
|
51
68
|
|
|
52
69
|
|
|
53
|
-
def get_locations_from_request(database, request_args
|
|
70
|
+
def get_locations_from_request(database, request_args):
|
|
54
71
|
"""
|
|
55
72
|
Shared helper to fetch locations from database based on request arguments.
|
|
56
73
|
|
|
57
74
|
Args:
|
|
58
75
|
database: Database instance
|
|
59
76
|
request_args: Request arguments (flask.request.args)
|
|
60
|
-
as_basic_info: If True, returns list of basic_info dicts, otherwise returns Location objects
|
|
61
77
|
|
|
62
78
|
Returns:
|
|
63
|
-
List of locations
|
|
79
|
+
List of locations as basic_info dicts
|
|
64
80
|
"""
|
|
65
81
|
query_params = request_args.to_dict(flat=False)
|
|
66
82
|
all_locations = database.get_locations(query_params)
|
|
67
|
-
|
|
68
|
-
if as_basic_info:
|
|
69
|
-
return [x.basic_info() for x in all_locations]
|
|
70
|
-
|
|
71
|
-
return all_locations
|
|
83
|
+
return [x.basic_info() for x in all_locations]
|
|
72
84
|
|
|
73
85
|
|
|
74
86
|
def core_pages(
|
|
@@ -80,421 +92,520 @@ def core_pages(
|
|
|
80
92
|
feature_flags={},
|
|
81
93
|
) -> Blueprint:
|
|
82
94
|
core_api_blueprint = Blueprint("api", __name__, url_prefix="/api")
|
|
83
|
-
core_api = Api(core_api_blueprint, doc="/doc", version="0.1")
|
|
84
|
-
|
|
85
|
-
location_report_model = core_api.model(
|
|
86
|
-
"LocationReport",
|
|
87
|
-
{
|
|
88
|
-
"id": fields.String(required=True, description="Location ID"),
|
|
89
|
-
"description": fields.String(required=True, description="Description of the problem"),
|
|
90
|
-
},
|
|
91
|
-
)
|
|
92
95
|
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
# Initialize Spectree for API documentation and validation
|
|
97
|
+
# Use simple naming strategy without hashes for cleaner schema names
|
|
98
|
+
from typing import Any, Type
|
|
99
|
+
|
|
100
|
+
def _clean_model_name(model: Type[Any]) -> str:
|
|
101
|
+
return model.__name__
|
|
102
|
+
|
|
103
|
+
spec = SpecTree(
|
|
104
|
+
"flask",
|
|
105
|
+
title="Goodmap API",
|
|
106
|
+
version="0.1",
|
|
107
|
+
path="doc",
|
|
108
|
+
annotations=True,
|
|
109
|
+
naming_strategy=_clean_model_name, # Use clean model names without hash
|
|
101
110
|
)
|
|
102
111
|
|
|
103
|
-
@
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"status": "pending",
|
|
167
|
-
"priority": "medium",
|
|
168
|
-
}
|
|
169
|
-
database.add_report(report)
|
|
170
|
-
message = (
|
|
171
|
-
f"A location has been reported: '{location_report['id']}' "
|
|
172
|
-
f"with problem: {location_report['description']}"
|
|
173
|
-
)
|
|
174
|
-
notifier_function(message)
|
|
175
|
-
except BadRequest:
|
|
176
|
-
logger.warning("Invalid JSON in report location endpoint")
|
|
177
|
-
return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
|
|
178
|
-
except KeyError as e:
|
|
179
|
-
logger.warning(
|
|
180
|
-
"Missing required field in report location", extra={"missing_field": str(e)}
|
|
181
|
-
)
|
|
182
|
-
error_message = gettext("Error reporting location")
|
|
183
|
-
return make_response(jsonify({"message": error_message}), 400)
|
|
184
|
-
except Exception:
|
|
185
|
-
logger.error("Error in report location endpoint", exc_info=True)
|
|
186
|
-
error_message = gettext("Error sending notification")
|
|
187
|
-
return make_response(jsonify({"message": error_message}), 500)
|
|
188
|
-
return make_response(jsonify({"message": gettext("Location reported")}), 200)
|
|
189
|
-
|
|
190
|
-
@core_api.route("/locations")
|
|
191
|
-
class GetLocations(Resource):
|
|
192
|
-
def get(self):
|
|
193
|
-
"""
|
|
194
|
-
Shows list of locations with uuid and position
|
|
195
|
-
"""
|
|
196
|
-
locations = get_locations_from_request(database, request.args, as_basic_info=True)
|
|
197
|
-
return jsonify(locations)
|
|
198
|
-
|
|
199
|
-
@core_api.route("/locations-clustered")
|
|
200
|
-
class GetLocationsClustered(Resource):
|
|
201
|
-
def get(self):
|
|
202
|
-
"""
|
|
203
|
-
Shows list of locations with uuid, position and clusters
|
|
204
|
-
"""
|
|
205
|
-
try:
|
|
206
|
-
query_params = request.args.to_dict(flat=False)
|
|
207
|
-
zoom = int(query_params.get("zoom", [7])[0])
|
|
208
|
-
|
|
209
|
-
# Validate zoom level (aligned with SuperCluster min_zoom/max_zoom)
|
|
210
|
-
if not MIN_ZOOM <= zoom <= MAX_ZOOM:
|
|
112
|
+
@core_api_blueprint.route("/suggest-new-point", methods=["POST"])
|
|
113
|
+
@spec.validate(resp=Response(HTTP_200=SuccessResponse, HTTP_400=ErrorResponse))
|
|
114
|
+
def suggest_new_point():
|
|
115
|
+
"""Suggest new location for review.
|
|
116
|
+
|
|
117
|
+
Accepts location data either as JSON or multipart/form-data.
|
|
118
|
+
All fields are validated using Pydantic location model.
|
|
119
|
+
"""
|
|
120
|
+
import json as json_lib
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# Handle both multipart/form-data (with file uploads) and JSON
|
|
124
|
+
if request.content_type and request.content_type.startswith("multipart/form-data"):
|
|
125
|
+
# Parse form data dynamically
|
|
126
|
+
suggested_location = {}
|
|
127
|
+
|
|
128
|
+
for key in request.form:
|
|
129
|
+
value = request.form[key]
|
|
130
|
+
# Try to parse as JSON for complex types (arrays, objects, position)
|
|
131
|
+
try:
|
|
132
|
+
# SECURITY: Use safe_json_loads with strict depth limit
|
|
133
|
+
# MAX_JSON_DEPTH_LOCATION=1: arrays/objects of primitives only
|
|
134
|
+
suggested_location[key] = safe_json_loads(
|
|
135
|
+
value, max_depth=MAX_JSON_DEPTH_LOCATION
|
|
136
|
+
)
|
|
137
|
+
except (JSONDepthError, JSONSizeError) as e:
|
|
138
|
+
# Log security event and return 400
|
|
139
|
+
logger.warning(
|
|
140
|
+
f"JSON parsing blocked for security: {e}",
|
|
141
|
+
extra={"field": key, "value_size": len(value)},
|
|
142
|
+
)
|
|
143
|
+
return make_response(
|
|
144
|
+
jsonify(
|
|
145
|
+
{
|
|
146
|
+
"message": (
|
|
147
|
+
"Invalid request: JSON payload too complex or too large"
|
|
148
|
+
),
|
|
149
|
+
"error": str(e),
|
|
150
|
+
}
|
|
151
|
+
),
|
|
152
|
+
400,
|
|
153
|
+
)
|
|
154
|
+
except ValueError: # JSONDecodeError inherits from ValueError
|
|
155
|
+
# If not JSON, use as-is (simple string values)
|
|
156
|
+
suggested_location[key] = value
|
|
157
|
+
|
|
158
|
+
# TODO: Handle photo file upload from request.files['photo']
|
|
159
|
+
# For now, we just ignore it as the backend doesn't store photos yet
|
|
160
|
+
else:
|
|
161
|
+
# Parse JSON data with security checks (depth/size protection)
|
|
162
|
+
raw_data = request.get_data(as_text=True)
|
|
163
|
+
if not raw_data:
|
|
164
|
+
logger.warning("Empty JSON body in suggest endpoint")
|
|
165
|
+
return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
|
|
166
|
+
try:
|
|
167
|
+
suggested_location = safe_json_loads(
|
|
168
|
+
raw_data, max_depth=MAX_JSON_DEPTH_LOCATION
|
|
169
|
+
)
|
|
170
|
+
except (JSONDepthError, JSONSizeError) as e:
|
|
171
|
+
logger.warning(
|
|
172
|
+
f"JSON parsing blocked for security: {e}",
|
|
173
|
+
extra={"value_size": len(raw_data)},
|
|
174
|
+
)
|
|
211
175
|
return make_response(
|
|
212
|
-
jsonify(
|
|
176
|
+
jsonify(
|
|
177
|
+
{
|
|
178
|
+
"message": (
|
|
179
|
+
"Invalid request: JSON payload too complex or too large"
|
|
180
|
+
),
|
|
181
|
+
"error": str(e),
|
|
182
|
+
}
|
|
183
|
+
),
|
|
213
184
|
400,
|
|
214
185
|
)
|
|
186
|
+
except ValueError:
|
|
187
|
+
logger.warning("Invalid JSON in suggest endpoint")
|
|
188
|
+
return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
|
|
189
|
+
if suggested_location is None:
|
|
190
|
+
logger.warning("Null JSON value in suggest endpoint")
|
|
191
|
+
return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
|
|
192
|
+
|
|
193
|
+
suggested_location.update({"uuid": str(uuid.uuid4())})
|
|
194
|
+
location = location_model.model_validate(suggested_location)
|
|
195
|
+
database.add_suggestion(location.model_dump())
|
|
196
|
+
message = gettext("A new location has been suggested with details")
|
|
197
|
+
notifier_message = f"{message}: {json_lib.dumps(suggested_location, indent=2)}"
|
|
198
|
+
notifier_function(notifier_message)
|
|
199
|
+
except LocationValidationError as e:
|
|
200
|
+
logger.warning(
|
|
201
|
+
"Location validation failed in suggest endpoint",
|
|
202
|
+
extra={"errors": e.validation_errors},
|
|
203
|
+
)
|
|
204
|
+
return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
|
|
205
|
+
except Exception:
|
|
206
|
+
logger.error("Error in suggest location endpoint", exc_info=True)
|
|
207
|
+
return make_response(
|
|
208
|
+
jsonify({"message": "An error occurred while processing your suggestion"}), 500
|
|
209
|
+
)
|
|
210
|
+
return make_response(jsonify({"message": "Location suggested"}), 200)
|
|
215
211
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
212
|
+
@core_api_blueprint.route("/report-location", methods=["POST"])
|
|
213
|
+
@spec.validate(
|
|
214
|
+
json=LocationReportRequest,
|
|
215
|
+
resp=Response(HTTP_200=LocationReportResponse, HTTP_400=ErrorResponse),
|
|
216
|
+
)
|
|
217
|
+
def report_location():
|
|
218
|
+
"""Report a problem with a location.
|
|
219
|
+
|
|
220
|
+
Allows users to report issues with existing locations,
|
|
221
|
+
such as incorrect information or closed establishments.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
location_report = request.get_json()
|
|
225
|
+
report = {
|
|
226
|
+
"uuid": str(uuid.uuid4()),
|
|
227
|
+
"location_id": location_report["id"],
|
|
228
|
+
"description": location_report["description"],
|
|
229
|
+
"status": "pending",
|
|
230
|
+
"priority": "medium",
|
|
231
|
+
}
|
|
232
|
+
database.add_report(report)
|
|
233
|
+
message = (
|
|
234
|
+
f"A location has been reported: '{location_report['id']}' "
|
|
235
|
+
f"with problem: {location_report['description']}"
|
|
236
|
+
)
|
|
237
|
+
notifier_function(message)
|
|
238
|
+
except Exception:
|
|
239
|
+
logger.error("Error in report location endpoint", exc_info=True)
|
|
240
|
+
error_message = gettext("Error sending notification")
|
|
241
|
+
return make_response(jsonify({"message": error_message}), 500)
|
|
242
|
+
return make_response(jsonify({"message": gettext("Location reported")}), 200)
|
|
243
|
+
|
|
244
|
+
@core_api_blueprint.route("/locations", methods=["GET"])
|
|
245
|
+
@spec.validate()
|
|
246
|
+
def get_locations():
|
|
247
|
+
"""Get list of locations with basic info.
|
|
248
|
+
|
|
249
|
+
Returns locations filtered by query parameters,
|
|
250
|
+
showing only uuid, position, and remark flag.
|
|
251
|
+
"""
|
|
252
|
+
locations = get_locations_from_request(database, request.args)
|
|
253
|
+
return jsonify(locations)
|
|
254
|
+
|
|
255
|
+
@core_api_blueprint.route("/locations-clustered", methods=["GET"])
|
|
256
|
+
@spec.validate(resp=Response(HTTP_400=ErrorResponse))
|
|
257
|
+
def get_locations_clustered():
|
|
258
|
+
"""Get clustered locations for map display.
|
|
259
|
+
|
|
260
|
+
Returns locations grouped into clusters based on zoom level,
|
|
261
|
+
optimized for rendering on interactive maps.
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
query_params = request.args.to_dict(flat=False)
|
|
265
|
+
zoom = int(query_params.get("zoom", [7])[0])
|
|
231
266
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
bottom_right=(180.0, -90.0),
|
|
235
|
-
zoom=zoom,
|
|
236
|
-
)
|
|
237
|
-
clusters = match_clusters_uuids(points, clusters)
|
|
238
|
-
|
|
239
|
-
return jsonify(map_clustering_data_to_proper_lazy_loading_object(clusters))
|
|
240
|
-
except ValueError as e:
|
|
241
|
-
logger.warning("Invalid parameter in clustering request: %s", e)
|
|
242
|
-
return make_response(jsonify({"message": "Invalid parameters provided"}), 400)
|
|
243
|
-
except Exception as e:
|
|
244
|
-
logger.error("Clustering operation failed: %s", e, exc_info=True)
|
|
267
|
+
# Validate zoom level (aligned with SuperCluster min_zoom/max_zoom)
|
|
268
|
+
if not MIN_ZOOM <= zoom <= MAX_ZOOM:
|
|
245
269
|
return make_response(
|
|
246
|
-
jsonify({"message": "
|
|
270
|
+
jsonify({"message": f"Zoom must be between {MIN_ZOOM} and {MAX_ZOOM}"}),
|
|
271
|
+
400,
|
|
247
272
|
)
|
|
248
273
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
"""
|
|
253
|
-
Shows a single location with all data
|
|
254
|
-
"""
|
|
255
|
-
location = database.get_location(location_id)
|
|
256
|
-
visible_data = database.get_visible_data()
|
|
257
|
-
meta_data = database.get_meta_data()
|
|
258
|
-
|
|
259
|
-
formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data)
|
|
260
|
-
return jsonify(formatted_data)
|
|
261
|
-
|
|
262
|
-
@core_api.route("/version")
|
|
263
|
-
class Version(Resource):
|
|
264
|
-
def get(self):
|
|
265
|
-
"""Shows backend version"""
|
|
266
|
-
version_info = {"backend": importlib.metadata.version("goodmap")}
|
|
267
|
-
return jsonify(version_info)
|
|
268
|
-
|
|
269
|
-
@core_api.route("/generate-csrf-token")
|
|
270
|
-
class CsrfToken(Resource):
|
|
271
|
-
@deprecation.deprecated(
|
|
272
|
-
deprecated_in="1.1.8",
|
|
273
|
-
details="This endpoint for explicit CSRF token generation is deprecated. "
|
|
274
|
-
"CSRF protection remains active in the application.",
|
|
275
|
-
)
|
|
276
|
-
def get(self):
|
|
277
|
-
"""
|
|
278
|
-
Generate CSRF token (DEPRECATED)
|
|
279
|
-
|
|
280
|
-
This endpoint is deprecated and maintained only for backward compatibility.
|
|
281
|
-
CSRF protection remains active in the application.
|
|
282
|
-
"""
|
|
283
|
-
csrf_token = csrf_generator()
|
|
284
|
-
return {"csrf_token": csrf_token}
|
|
285
|
-
|
|
286
|
-
@core_api.route("/categories")
|
|
287
|
-
class Categories(Resource):
|
|
288
|
-
def get(self):
|
|
289
|
-
"""Shows all available categories"""
|
|
290
|
-
raw_categories = database.get_categories()
|
|
291
|
-
categories = make_tuple_translation(raw_categories)
|
|
292
|
-
|
|
293
|
-
if not feature_flags.get("CATEGORIES_HELP", False):
|
|
294
|
-
return jsonify(categories)
|
|
295
|
-
else:
|
|
296
|
-
category_data = database.get_category_data()
|
|
297
|
-
categories_help = category_data["categories_help"]
|
|
298
|
-
proper_categories_help = []
|
|
299
|
-
if categories_help is not None:
|
|
300
|
-
for option in categories_help:
|
|
301
|
-
proper_categories_help.append(
|
|
302
|
-
{option: gettext(f"categories_help_{option}")}
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
return jsonify({"categories": categories, "categories_help": proper_categories_help})
|
|
274
|
+
points = get_locations_from_request(database, request.args)
|
|
275
|
+
if not points:
|
|
276
|
+
return jsonify([])
|
|
306
277
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
"""Shows all available languages"""
|
|
311
|
-
return jsonify(languages)
|
|
278
|
+
points_numpy = numpy.array(
|
|
279
|
+
[(point["position"][0], point["position"][1]) for point in points]
|
|
280
|
+
)
|
|
312
281
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
282
|
+
index = pysupercluster.SuperCluster(
|
|
283
|
+
points_numpy,
|
|
284
|
+
min_zoom=MIN_ZOOM,
|
|
285
|
+
max_zoom=MAX_ZOOM,
|
|
286
|
+
radius=CLUSTER_RADIUS,
|
|
287
|
+
extent=CLUSTER_EXTENT,
|
|
288
|
+
)
|
|
319
289
|
|
|
320
|
-
|
|
321
|
-
|
|
290
|
+
clusters = index.getClusters(
|
|
291
|
+
top_left=(-180.0, 90.0),
|
|
292
|
+
bottom_right=(180.0, -90.0),
|
|
293
|
+
zoom=zoom,
|
|
322
294
|
)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
295
|
+
clusters = match_clusters_uuids(points, clusters)
|
|
296
|
+
|
|
297
|
+
return jsonify(map_clustering_data_to_proper_lazy_loading_object(clusters))
|
|
298
|
+
except ValueError as e:
|
|
299
|
+
logger.warning("Invalid parameter in clustering request: %s", e)
|
|
300
|
+
return make_response(jsonify({"message": "Invalid parameters provided"}), 400)
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.error("Clustering operation failed: %s", e, exc_info=True)
|
|
303
|
+
return make_response(jsonify({"message": "An error occurred during clustering"}), 500)
|
|
304
|
+
|
|
305
|
+
@core_api_blueprint.route("/location/<location_id>", methods=["GET"])
|
|
306
|
+
@spec.validate(resp=Response(HTTP_404=ErrorResponse))
|
|
307
|
+
def get_location(location_id):
|
|
308
|
+
"""Get detailed information for a single location.
|
|
309
|
+
|
|
310
|
+
Returns full location data including all custom fields,
|
|
311
|
+
formatted for display in the location details view.
|
|
312
|
+
"""
|
|
313
|
+
location = database.get_location(location_id)
|
|
314
|
+
if location is None:
|
|
315
|
+
logger.info(ERROR_LOCATION_NOT_FOUND, extra={"uuid": location_id})
|
|
316
|
+
return make_response(jsonify({"message": ERROR_LOCATION_NOT_FOUND}), 404)
|
|
317
|
+
|
|
318
|
+
visible_data = database.get_visible_data()
|
|
319
|
+
meta_data = database.get_meta_data()
|
|
320
|
+
|
|
321
|
+
formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data)
|
|
322
|
+
return jsonify(formatted_data)
|
|
323
|
+
|
|
324
|
+
@core_api_blueprint.route("/version", methods=["GET"])
|
|
325
|
+
@spec.validate(resp=Response(HTTP_200=VersionResponse))
|
|
326
|
+
def get_version():
|
|
327
|
+
"""Get backend version information.
|
|
328
|
+
|
|
329
|
+
Returns the current version of the Goodmap backend.
|
|
330
|
+
"""
|
|
331
|
+
version_info = {"backend": importlib.metadata.version("goodmap")}
|
|
332
|
+
return jsonify(version_info)
|
|
333
|
+
|
|
334
|
+
@core_api_blueprint.route("/generate-csrf-token", methods=["GET"])
|
|
335
|
+
@spec.validate(resp=Response(HTTP_200=CSRFTokenResponse))
|
|
336
|
+
@deprecation.deprecated(
|
|
337
|
+
deprecated_in="1.1.8",
|
|
338
|
+
details="This endpoint for explicit CSRF token generation is deprecated. "
|
|
339
|
+
"CSRF protection remains active in the application.",
|
|
340
|
+
)
|
|
341
|
+
def generate_csrf_token():
|
|
342
|
+
"""Generate CSRF token (DEPRECATED).
|
|
343
|
+
|
|
344
|
+
This endpoint is deprecated and maintained only for backward compatibility.
|
|
345
|
+
CSRF protection remains active in the application.
|
|
346
|
+
"""
|
|
347
|
+
csrf_token = csrf_generator()
|
|
348
|
+
return {"csrf_token": csrf_token}
|
|
349
|
+
|
|
350
|
+
@core_api_blueprint.route("/categories", methods=["GET"])
|
|
351
|
+
@spec.validate()
|
|
352
|
+
def get_categories():
|
|
353
|
+
"""Get all available location categories.
|
|
354
|
+
|
|
355
|
+
Returns list of categories with optional help text
|
|
356
|
+
if CATEGORIES_HELP feature flag is enabled.
|
|
357
|
+
"""
|
|
358
|
+
raw_categories = database.get_categories()
|
|
359
|
+
categories = make_tuple_translation(raw_categories)
|
|
360
|
+
|
|
361
|
+
if not feature_flags.get("CATEGORIES_HELP", False):
|
|
362
|
+
return jsonify(categories)
|
|
363
|
+
else:
|
|
364
|
+
category_data = database.get_category_data()
|
|
365
|
+
categories_help = category_data.get("categories_help")
|
|
366
|
+
proper_categories_help = []
|
|
367
|
+
if categories_help is not None:
|
|
368
|
+
for option in categories_help:
|
|
369
|
+
proper_categories_help.append({option: gettext(f"categories_help_{option}")})
|
|
370
|
+
|
|
371
|
+
return jsonify({"categories": categories, "categories_help": proper_categories_help})
|
|
372
|
+
|
|
373
|
+
@core_api_blueprint.route("/languages", methods=["GET"])
|
|
374
|
+
@spec.validate()
|
|
375
|
+
def get_languages():
|
|
376
|
+
"""Get all available interface languages.
|
|
377
|
+
|
|
378
|
+
Returns list of supported languages for the application.
|
|
379
|
+
"""
|
|
380
|
+
return jsonify(languages)
|
|
381
|
+
|
|
382
|
+
@core_api_blueprint.route("/category/<category_type>", methods=["GET"])
|
|
383
|
+
@spec.validate()
|
|
384
|
+
def get_category_types(category_type):
|
|
385
|
+
"""Get all available options for a specific category.
|
|
386
|
+
|
|
387
|
+
Returns list of category options with optional help text
|
|
388
|
+
if CATEGORIES_HELP feature flag is enabled.
|
|
389
|
+
"""
|
|
390
|
+
category_data = database.get_category_data(category_type)
|
|
391
|
+
local_data = make_tuple_translation(category_data["categories"][category_type])
|
|
392
|
+
|
|
393
|
+
categories_options_help = get_or_none(
|
|
394
|
+
category_data, "categories_options_help", category_type
|
|
395
|
+
)
|
|
396
|
+
proper_categories_options_help = []
|
|
397
|
+
if categories_options_help is not None:
|
|
398
|
+
for option in categories_options_help:
|
|
399
|
+
proper_categories_options_help.append(
|
|
400
|
+
{option: gettext(f"categories_options_help_{option}")}
|
|
337
401
|
)
|
|
402
|
+
if not feature_flags.get("CATEGORIES_HELP", False):
|
|
403
|
+
return jsonify(local_data)
|
|
404
|
+
else:
|
|
405
|
+
return jsonify(
|
|
406
|
+
{
|
|
407
|
+
"categories_options": local_data,
|
|
408
|
+
"categories_options_help": proper_categories_options_help,
|
|
409
|
+
}
|
|
410
|
+
)
|
|
338
411
|
|
|
339
|
-
@
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
""
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
@
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
""
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
return make_response(jsonify({"message":
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return jsonify(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
"""
|
|
473
|
-
|
|
474
|
-
""
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
412
|
+
@core_api_blueprint.route("/admin/locations", methods=["GET"])
|
|
413
|
+
@spec.validate()
|
|
414
|
+
def admin_get_locations():
|
|
415
|
+
"""Get paginated list of all locations for admin panel.
|
|
416
|
+
|
|
417
|
+
Supports server-side pagination, sorting, and filtering.
|
|
418
|
+
Defaults to sorting by name.
|
|
419
|
+
"""
|
|
420
|
+
query_params = request.args.to_dict(flat=False)
|
|
421
|
+
if "sort_by" not in query_params:
|
|
422
|
+
query_params["sort_by"] = ["name"]
|
|
423
|
+
result = database.get_locations_paginated(query_params)
|
|
424
|
+
return jsonify(result)
|
|
425
|
+
|
|
426
|
+
@core_api_blueprint.route("/admin/locations", methods=["POST"])
|
|
427
|
+
@spec.validate(resp=Response(HTTP_400=ErrorResponse))
|
|
428
|
+
def admin_create_location():
|
|
429
|
+
"""Create a new location (admin only).
|
|
430
|
+
|
|
431
|
+
Validates location data using Pydantic model and
|
|
432
|
+
adds it to the database.
|
|
433
|
+
"""
|
|
434
|
+
location_data = request.get_json()
|
|
435
|
+
if location_data is None:
|
|
436
|
+
logger.warning("Empty or invalid JSON in admin create location endpoint")
|
|
437
|
+
return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
|
|
438
|
+
try:
|
|
439
|
+
location_data.update({"uuid": str(uuid.uuid4())})
|
|
440
|
+
location = location_model.model_validate(location_data)
|
|
441
|
+
database.add_location(location.model_dump())
|
|
442
|
+
except LocationValidationError as e:
|
|
443
|
+
logger.warning(
|
|
444
|
+
"Location validation failed",
|
|
445
|
+
extra={"uuid": e.uuid, "errors": e.validation_errors},
|
|
446
|
+
)
|
|
447
|
+
return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
|
|
448
|
+
except Exception:
|
|
449
|
+
logger.error("Error creating location", exc_info=True)
|
|
450
|
+
return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
|
|
451
|
+
return jsonify(location.model_dump())
|
|
452
|
+
|
|
453
|
+
@core_api_blueprint.route("/admin/locations/<location_id>", methods=["PUT"])
|
|
454
|
+
@spec.validate(resp=Response(HTTP_400=ErrorResponse, HTTP_404=ErrorResponse))
|
|
455
|
+
def admin_update_location(location_id):
|
|
456
|
+
"""Update an existing location (admin only).
|
|
457
|
+
|
|
458
|
+
Validates updated location data and persists changes.
|
|
459
|
+
Returns 404 if location not found.
|
|
460
|
+
"""
|
|
461
|
+
location_data = request.get_json()
|
|
462
|
+
if location_data is None:
|
|
463
|
+
logger.warning("Empty or invalid JSON in admin update location endpoint")
|
|
464
|
+
return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
|
|
465
|
+
try:
|
|
466
|
+
location_data.update({"uuid": location_id})
|
|
467
|
+
location = location_model.model_validate(location_data)
|
|
468
|
+
database.update_location(location_id, location.model_dump())
|
|
469
|
+
except LocationValidationError as e:
|
|
470
|
+
logger.warning(
|
|
471
|
+
"Location validation failed",
|
|
472
|
+
extra={"uuid": e.uuid, "errors": e.validation_errors},
|
|
473
|
+
)
|
|
474
|
+
return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
|
|
475
|
+
except LocationNotFoundError as e:
|
|
476
|
+
logger.info("Location not found for update", extra={"uuid": e.uuid})
|
|
477
|
+
return make_response(jsonify({"message": ERROR_LOCATION_NOT_FOUND}), 404)
|
|
478
|
+
except Exception:
|
|
479
|
+
logger.error("Error updating location", exc_info=True)
|
|
480
|
+
return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
|
|
481
|
+
return jsonify(location.model_dump())
|
|
482
|
+
|
|
483
|
+
@core_api_blueprint.route("/admin/locations/<location_id>", methods=["DELETE"])
|
|
484
|
+
@spec.validate(resp=Response(HTTP_404=ErrorResponse))
|
|
485
|
+
def admin_delete_location(location_id):
|
|
486
|
+
"""Delete a location (admin only).
|
|
487
|
+
|
|
488
|
+
Permanently removes location from database.
|
|
489
|
+
Returns 204 on success, 404 if not found.
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
database.delete_location(location_id)
|
|
493
|
+
except LocationNotFoundError as e:
|
|
494
|
+
logger.info("Location not found for deletion", extra={"uuid": e.uuid})
|
|
495
|
+
return make_response(jsonify({"message": ERROR_LOCATION_NOT_FOUND}), 404)
|
|
496
|
+
except Exception:
|
|
497
|
+
logger.error("Error deleting location", exc_info=True)
|
|
498
|
+
return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
|
|
499
|
+
return "", 204
|
|
500
|
+
|
|
501
|
+
@core_api_blueprint.route("/admin/suggestions", methods=["GET"])
|
|
502
|
+
@spec.validate()
|
|
503
|
+
def admin_get_suggestions():
|
|
504
|
+
"""Get paginated list of location suggestions (admin only).
|
|
505
|
+
|
|
506
|
+
Supports server-side pagination, sorting, and filtering by status.
|
|
507
|
+
"""
|
|
508
|
+
query_params = request.args.to_dict(flat=False)
|
|
509
|
+
result = database.get_suggestions_paginated(query_params)
|
|
510
|
+
return jsonify(result)
|
|
511
|
+
|
|
512
|
+
@core_api_blueprint.route("/admin/suggestions/<suggestion_id>", methods=["PUT"])
|
|
513
|
+
@spec.validate(
|
|
514
|
+
json=SuggestionStatusRequest,
|
|
515
|
+
resp=Response(HTTP_400=ErrorResponse, HTTP_404=ErrorResponse, HTTP_409=ErrorResponse),
|
|
516
|
+
)
|
|
517
|
+
def admin_update_suggestion(suggestion_id):
|
|
518
|
+
"""Accept or reject a location suggestion (admin only).
|
|
519
|
+
|
|
520
|
+
Accepted suggestions are added to locations database.
|
|
521
|
+
Rejected suggestions are marked as rejected.
|
|
522
|
+
"""
|
|
523
|
+
try:
|
|
524
|
+
data = request.get_json()
|
|
525
|
+
status = data["status"] # Validated by Spectree
|
|
526
|
+
suggestion = database.get_suggestion(suggestion_id)
|
|
527
|
+
if not suggestion:
|
|
528
|
+
return make_response(jsonify({"message": "Suggestion not found"}), 404)
|
|
529
|
+
if suggestion.get("status") != "pending":
|
|
530
|
+
return make_response(jsonify({"message": "Suggestion already processed"}), 409)
|
|
531
|
+
if status == "accepted":
|
|
532
|
+
suggestion_data = {k: v for k, v in suggestion.items() if k != "status"}
|
|
533
|
+
database.add_location(suggestion_data)
|
|
534
|
+
database.update_suggestion(suggestion_id, status)
|
|
535
|
+
except LocationValidationError as e:
|
|
536
|
+
logger.warning(
|
|
537
|
+
"Location validation failed in suggestion",
|
|
538
|
+
extra={"uuid": e.uuid, "errors": e.validation_errors},
|
|
539
|
+
)
|
|
540
|
+
return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
|
|
541
|
+
except LocationAlreadyExistsError as e:
|
|
542
|
+
logger.warning(
|
|
543
|
+
"Attempted to create duplicate location from suggestion", extra={"uuid": e.uuid}
|
|
544
|
+
)
|
|
545
|
+
return make_response(jsonify({"message": "Location already exists"}), 409)
|
|
546
|
+
except Exception:
|
|
547
|
+
logger.error("Error processing suggestion", exc_info=True)
|
|
548
|
+
return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
|
|
549
|
+
return jsonify(database.get_suggestion(suggestion_id))
|
|
550
|
+
|
|
551
|
+
@core_api_blueprint.route("/admin/reports", methods=["GET"])
|
|
552
|
+
@spec.validate()
|
|
553
|
+
def admin_get_reports():
|
|
554
|
+
"""Get paginated list of location reports (admin only).
|
|
555
|
+
|
|
556
|
+
Supports server-side pagination, sorting,
|
|
557
|
+
and filtering by status/priority.
|
|
558
|
+
"""
|
|
559
|
+
query_params = request.args.to_dict(flat=False)
|
|
560
|
+
result = database.get_reports_paginated(query_params)
|
|
561
|
+
return jsonify(result)
|
|
562
|
+
|
|
563
|
+
@core_api_blueprint.route("/admin/reports/<report_id>", methods=["PUT"])
|
|
564
|
+
@spec.validate(
|
|
565
|
+
json=ReportUpdateRequest,
|
|
566
|
+
resp=Response(HTTP_400=ErrorResponse, HTTP_404=ErrorResponse),
|
|
567
|
+
)
|
|
568
|
+
def admin_update_report(report_id):
|
|
569
|
+
"""Update a report's status and/or priority (admin only).
|
|
570
|
+
|
|
571
|
+
Allows changing report status to resolved/rejected
|
|
572
|
+
and adjusting priority level.
|
|
573
|
+
"""
|
|
574
|
+
try:
|
|
575
|
+
data = request.get_json()
|
|
576
|
+
status = data.get("status")
|
|
577
|
+
priority = data.get("priority")
|
|
578
|
+
report = database.get_report(report_id)
|
|
579
|
+
if not report:
|
|
494
580
|
return make_response(jsonify({"message": "Report not found"}), 404)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
return jsonify(
|
|
581
|
+
database.update_report(report_id, status=status, priority=priority)
|
|
582
|
+
except BadRequest:
|
|
583
|
+
logger.warning("Invalid JSON in report update endpoint")
|
|
584
|
+
return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
|
|
585
|
+
except ReportNotFoundError as e:
|
|
586
|
+
logger.info("Report not found for update", extra={"uuid": e.uuid})
|
|
587
|
+
return make_response(jsonify({"message": "Report not found"}), 404)
|
|
588
|
+
except Exception:
|
|
589
|
+
logger.error("Error updating report", exc_info=True)
|
|
590
|
+
return make_response(jsonify({"message": ERROR_INTERNAL_ERROR}), 500)
|
|
591
|
+
return jsonify(database.get_report(report_id))
|
|
592
|
+
|
|
593
|
+
# Register Spectree with blueprint after all routes are defined
|
|
594
|
+
spec.register(core_api_blueprint)
|
|
595
|
+
|
|
596
|
+
@core_api_blueprint.route("/doc")
|
|
597
|
+
def api_doc_index():
|
|
598
|
+
"""Return links to available API documentation formats."""
|
|
599
|
+
html = """<!DOCTYPE html>
|
|
600
|
+
<html><head><title>API Documentation</title></head>
|
|
601
|
+
<body>
|
|
602
|
+
<h1>API Documentation</h1>
|
|
603
|
+
<ul>
|
|
604
|
+
<li><a href="/api/doc/swagger/">Swagger UI</a></li>
|
|
605
|
+
<li><a href="/api/doc/redoc/">ReDoc</a></li>
|
|
606
|
+
<li><a href="/api/doc/openapi.json">OpenAPI JSON</a></li>
|
|
607
|
+
</ul>
|
|
608
|
+
</body></html>"""
|
|
609
|
+
return html, 200, {"Content-Type": "text/html"}
|
|
499
610
|
|
|
500
611
|
return core_api_blueprint
|