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/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 werkzeug.exceptions import BadRequest
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
- ERROR_INTERNAL_ERROR = "An internal error occurred"
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, as_basic_info=False):
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 (either as objects or basic_info dicts)
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
- # 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
- },
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
- @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:
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({"message": f"Zoom must be between {MIN_ZOOM} and {MAX_ZOOM}"}),
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
- 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
- )
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
- 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)
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": "An error occurred during clustering"}), 500
261
+ jsonify({"message": f"Zoom must be between {MIN_ZOOM} and {MAX_ZOOM}"}),
262
+ 400,
247
263
  )
248
264
 
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})
265
+ points = get_locations_from_request(database, request.args)
266
+ if not points:
267
+ return jsonify([])
306
268
 
307
- @core_api.route("/languages")
308
- class Languages(Resource):
309
- def get(self):
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
- @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])
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
- categories_options_help = get_or_none(
321
- category_data, "categories_options_help", category_type
281
+ clusters = index.getClusters(
282
+ top_left=(-180.0, 90.0),
283
+ bottom_right=(180.0, -90.0),
284
+ zoom=zoom,
322
285
  )
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
- }
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
- @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})
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