goodmap 1.1.13__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/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, as_basic_info=False):
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 (either as objects or basic_info dicts)
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
- # TODO get this from Location pydantic model
94
- suggested_location_model = core_api.model(
95
- "LocationSuggestion",
96
- {
97
- "name": fields.String(required=False, description="Organization name"),
98
- "position": fields.String(required=True, description="Location of the suggestion"),
99
- "photo": fields.String(required=False, description="Photo of the location"),
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
- @core_api.route("/suggest-new-point")
104
- class NewLocation(Resource):
105
- @core_api.expect(suggested_location_model)
106
- def post(self):
107
- """Suggest new location"""
108
- try:
109
- # Handle both multipart/form-data (with file uploads) and JSON
110
- if request.content_type and request.content_type.startswith("multipart/form-data"):
111
- # Parse form data dynamically
112
- import json as json_lib
113
-
114
- suggested_location = {}
115
-
116
- for key in request.form:
117
- value = request.form[key]
118
- # Try to parse as JSON for complex types (arrays, objects, position)
119
- try:
120
- suggested_location[key] = json_lib.loads(value)
121
- except ValueError: # JSONDecodeError inherits from ValueError
122
- # If not JSON, use as-is (simple string values)
123
- suggested_location[key] = value
124
-
125
- # TODO: Handle photo file upload from request.files['photo']
126
- # For now, we just ignore it as the backend doesn't store photos yet
127
- else:
128
- # Parse JSON data
129
- suggested_location = request.get_json()
130
-
131
- suggested_location.update({"uuid": str(uuid.uuid4())})
132
- location = location_model.model_validate(suggested_location)
133
- database.add_suggestion(location.model_dump())
134
- message = (
135
- f"A new location has been suggested under uuid: '{location.uuid}' "
136
- f"at position: {location.position}"
137
- )
138
- notifier_function(message)
139
- except BadRequest:
140
- logger.warning("Invalid request data in suggest endpoint")
141
- return make_response(jsonify({"message": ERROR_INVALID_REQUEST_DATA}), 400)
142
- except LocationValidationError as e:
143
- logger.warning(
144
- "Location validation failed in suggest endpoint",
145
- extra={"errors": e.validation_errors},
146
- )
147
- return make_response(jsonify({"message": ERROR_INVALID_LOCATION_DATA}), 400)
148
- except Exception:
149
- logger.error("Error in suggest location endpoint", exc_info=True)
150
- return make_response(
151
- jsonify({"message": "An error occurred while processing your suggestion"}), 500
152
- )
153
- return make_response(jsonify({"message": "Location suggested"}), 200)
154
-
155
- @core_api.route("/report-location")
156
- class ReportLocation(Resource):
157
- @core_api.expect(location_report_model)
158
- def post(self):
159
- """Report location"""
160
- try:
161
- location_report = request.get_json()
162
- report = {
163
- "uuid": str(uuid.uuid4()),
164
- "location_id": location_report["id"],
165
- "description": location_report["description"],
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({"message": f"Zoom must be between {MIN_ZOOM} and {MAX_ZOOM}"}),
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
- points = get_locations_from_request(database, request.args, as_basic_info=True)
217
- if not points:
218
- return jsonify([])
219
-
220
- points_numpy = numpy.array(
221
- [(point["position"][0], point["position"][1]) for point in points]
222
- )
223
-
224
- index = pysupercluster.SuperCluster(
225
- points_numpy,
226
- min_zoom=MIN_ZOOM,
227
- max_zoom=MAX_ZOOM,
228
- radius=CLUSTER_RADIUS,
229
- extent=CLUSTER_EXTENT,
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
- clusters = index.getClusters(
233
- top_left=(-180.0, 90.0),
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": "An error occurred during clustering"}), 500
270
+ jsonify({"message": f"Zoom must be between {MIN_ZOOM} and {MAX_ZOOM}"}),
271
+ 400,
247
272
  )
248
273
 
249
- @core_api.route("/location/<location_id>")
250
- class GetLocation(Resource):
251
- def get(self, location_id):
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
- @core_api.route("/languages")
308
- class Languages(Resource):
309
- def get(self):
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
- @core_api.route("/category/<category_type>")
314
- class CategoryTypes(Resource):
315
- def get(self, category_type):
316
- """Shows all available types in category"""
317
- category_data = database.get_category_data(category_type)
318
- local_data = make_tuple_translation(category_data["categories"][category_type])
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
- categories_options_help = get_or_none(
321
- category_data, "categories_options_help", category_type
290
+ clusters = index.getClusters(
291
+ top_left=(-180.0, 90.0),
292
+ bottom_right=(180.0, -90.0),
293
+ zoom=zoom,
322
294
  )
323
- proper_categories_options_help = []
324
- if categories_options_help is not None:
325
- for option in categories_options_help:
326
- proper_categories_options_help.append(
327
- {option: gettext(f"categories_options_help_{option}")}
328
- )
329
- if not feature_flags.get("CATEGORIES_HELP", False):
330
- return jsonify(local_data)
331
- else:
332
- return jsonify(
333
- {
334
- "categories_options": local_data,
335
- "categories_options_help": proper_categories_options_help,
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
- @core_api.route("/admin/locations")
340
- class AdminManageLocations(Resource):
341
- def get(self):
342
- """
343
- Shows full list of locations, with optional server-side pagination, sorting,
344
- and filtering.
345
- """
346
- query_params = request.args.to_dict(flat=False)
347
- if "sort_by" not in query_params:
348
- query_params["sort_by"] = ["name"]
349
- result = database.get_locations_paginated(query_params)
350
- return jsonify(result)
351
-
352
- def post(self):
353
- """
354
- Creates a new location
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})
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
- 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))
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