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