goodmap 0.5.0__tar.gz → 0.5.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: goodmap
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Map engine to serve all the people :)
5
5
  Author: Krzysztof Kolodzinski
6
6
  Author-email: krzysztof.kolodzinski@problematy.pl
@@ -22,7 +22,7 @@ Requires-Dist: google-cloud-storage (>=2.7.0,<3.0.0)
22
22
  Requires-Dist: gql (>=3.4.0,<4.0.0)
23
23
  Requires-Dist: gunicorn (>=20.1.0,<21.0.0)
24
24
  Requires-Dist: humanize (>=4.6.0,<5.0.0)
25
- Requires-Dist: platzky (>=0.3.1,<0.4.0)
25
+ Requires-Dist: platzky (>=0.3.6,<0.4.0)
26
26
  Requires-Dist: pydantic (>=2.7.1,<3.0.0)
27
27
  Description-Content-Type: text/markdown
28
28
 
@@ -82,11 +82,17 @@ you can simply run app with test dataset provided in `tests/e2e_tests` directory
82
82
  ### Configuration
83
83
 
84
84
  If you want to serve app with your configuration rename config-template.yml to config.yml and change its contents according to your needs.
85
- Values descriptions you can find inside config-template.yml.
86
85
 
87
86
  Afterwards run it with:
88
87
  > poetry run flask --app 'goodmap.goodmap:create_app(config_path="/PATH/TO/YOUR/CONFIG")' --debug run
89
88
 
89
+
90
+ | Option | Description |
91
+ |--------------------------|------------------------------------------------------------------------------------------------------------------------------------|
92
+ | USE_LAZY_LOADING | Loads point data only after the user clicks a point. If set to false, point data is loaded together with the initial map. |
93
+ | FAKE_LOGIN | If set to true, allows access to the admin panel by simply selecting the role instead of logging in. **DO NOT USE IN PRODUCTION!** |
94
+ | SHOW_ACCESSIBILITY_TABLE | If set as true it shows special view to help with accessing application. |
95
+
90
96
  ## Database
91
97
 
92
98
  The database is stored in JSON, in the `map` section. For an example database see `tests/e2e_tests/e2e_test_data.json`. The first subsection `data` consists of the actual datapoints, representing points on a map.
@@ -121,3 +127,5 @@ You can find examples of working configuration and database in `tests/e2e_tests`
121
127
  - `e2e_test_config.yml`
122
128
  - `e2e_test_data.json`
123
129
 
130
+
131
+
@@ -54,11 +54,17 @@ you can simply run app with test dataset provided in `tests/e2e_tests` directory
54
54
  ### Configuration
55
55
 
56
56
  If you want to serve app with your configuration rename config-template.yml to config.yml and change its contents according to your needs.
57
- Values descriptions you can find inside config-template.yml.
58
57
 
59
58
  Afterwards run it with:
60
59
  > poetry run flask --app 'goodmap.goodmap:create_app(config_path="/PATH/TO/YOUR/CONFIG")' --debug run
61
60
 
61
+
62
+ | Option | Description |
63
+ |--------------------------|------------------------------------------------------------------------------------------------------------------------------------|
64
+ | USE_LAZY_LOADING | Loads point data only after the user clicks a point. If set to false, point data is loaded together with the initial map. |
65
+ | FAKE_LOGIN | If set to true, allows access to the admin panel by simply selecting the role instead of logging in. **DO NOT USE IN PRODUCTION!** |
66
+ | SHOW_ACCESSIBILITY_TABLE | If set as true it shows special view to help with accessing application. |
67
+
62
68
  ## Database
63
69
 
64
70
  The database is stored in JSON, in the `map` section. For an example database see `tests/e2e_tests/e2e_test_data.json`. The first subsection `data` consists of the actual datapoints, representing points on a map.
@@ -92,3 +98,5 @@ You can define the fields in all these subsections. Besides these types of field
92
98
  You can find examples of working configuration and database in `tests/e2e_tests` named:
93
99
  - `e2e_test_config.yml`
94
100
  - `e2e_test_data.json`
101
+
102
+
@@ -0,0 +1,352 @@
1
+ import importlib.metadata
2
+ import uuid
3
+
4
+ from flask import Blueprint, jsonify, make_response, request
5
+ from flask_babel import gettext
6
+ from flask_restx import Api, Resource, fields
7
+ from platzky.config import LanguagesMapping
8
+
9
+ from goodmap.formatter import prepare_pin
10
+
11
+
12
+ def make_tuple_translation(keys_to_translate):
13
+ return [(x, gettext(x)) for x in keys_to_translate]
14
+
15
+
16
+ def paginate_results(items, raw_params, sort_by_default=None):
17
+ """
18
+ Apply pagination and sorting to a list of items.
19
+
20
+ Args:
21
+ items: The list of items to paginate
22
+ raw_params: The query parameters dictionary
23
+
24
+ Returns:
25
+ Tuple of (paginated_items, pagination_metadata)
26
+ """
27
+ # Extract pagination parameters
28
+ try:
29
+ page = int(raw_params.pop("page", ["1"])[0])
30
+ except ValueError:
31
+ page = 1
32
+
33
+ per_page_raw = raw_params.pop("per_page", [None])[0]
34
+ if per_page_raw is None:
35
+ per_page = 20
36
+ elif per_page_raw == "all":
37
+ per_page = None
38
+ else:
39
+ try:
40
+ per_page = max(1, int(per_page_raw))
41
+ except ValueError:
42
+ per_page = 20
43
+
44
+ sort_by = raw_params.pop("sort_by", [None])[0] or sort_by_default
45
+ sort_order = raw_params.pop("sort_order", ["asc"])[0].lower()
46
+
47
+ def get_sort_key(item):
48
+ if not sort_by:
49
+ return None
50
+
51
+ value = None
52
+ if isinstance(item, dict):
53
+ value = item.get(sort_by)
54
+ else:
55
+ value = getattr(item, sort_by, None)
56
+
57
+ return (value is not None, value)
58
+
59
+ if sort_by:
60
+ reverse = sort_order == "desc"
61
+ items.sort(key=get_sort_key, reverse=reverse)
62
+
63
+ # Apply pagination
64
+ total = len(items)
65
+ if per_page:
66
+ start = (page - 1) * per_page
67
+ end = start + per_page
68
+ page_items = items[start:end]
69
+ total_pages = (total + per_page - 1) // per_page
70
+ else:
71
+ page_items = items
72
+ total_pages = 1
73
+ page = 1
74
+ per_page = total
75
+
76
+ return page_items, {
77
+ "total": total,
78
+ "page": page,
79
+ "per_page": per_page,
80
+ "total_pages": total_pages,
81
+ }
82
+
83
+
84
+ def core_pages(
85
+ database, languages: LanguagesMapping, notifier_function, csrf_generator, location_model
86
+ ) -> Blueprint:
87
+ core_api_blueprint = Blueprint("api", __name__, url_prefix="/api")
88
+ core_api = Api(core_api_blueprint, doc="/doc", version="0.1")
89
+
90
+ location_report_model = core_api.model(
91
+ "LocationReport",
92
+ {
93
+ "id": fields.String(required=True, description="Location ID"),
94
+ "description": fields.String(required=True, description="Description of the problem"),
95
+ },
96
+ )
97
+
98
+ # TODO get this from Location pydantic model
99
+ suggested_location_model = core_api.model(
100
+ "LocationSuggestion",
101
+ {
102
+ "name": fields.String(required=False, description="Organization name"),
103
+ "position": fields.String(required=True, description="Location of the suggestion"),
104
+ "photo": fields.String(required=False, description="Photo of the location"),
105
+ },
106
+ )
107
+
108
+ @core_api.route("/suggest-new-point")
109
+ class NewLocation(Resource):
110
+ @core_api.expect(suggested_location_model)
111
+ def post(self):
112
+ """Suggest new location"""
113
+ try:
114
+ suggested_location = request.get_json()
115
+ suggested_location.update({"uuid": str(uuid.uuid4())})
116
+ location = location_model.model_validate(suggested_location)
117
+ database.add_suggestion(location.model_dump())
118
+ message = (
119
+ f"A new location has been suggested under uuid: '{location.uuid}' "
120
+ f"at position: {location.position}"
121
+ )
122
+ notifier_function(message)
123
+ except ValueError as e:
124
+ return make_response(jsonify({"message": f"Invalid location data: {e}"}), 400)
125
+ except Exception as e:
126
+ return make_response(jsonify({"message": f"Error sending notification : {e}"}), 400)
127
+ return make_response(jsonify({"message": "Location suggested"}), 200)
128
+
129
+ @core_api.route("/report-location")
130
+ class ReportLocation(Resource):
131
+ @core_api.expect(location_report_model)
132
+ def post(self):
133
+ """Report location"""
134
+ try:
135
+ location_report = request.get_json()
136
+ report = {
137
+ "uuid": str(uuid.uuid4()),
138
+ "location_id": location_report["id"],
139
+ "description": location_report["description"],
140
+ "status": "pending",
141
+ "priority": "medium",
142
+ }
143
+ database.add_report(report)
144
+ message = (
145
+ f"A location has been reported: '{location_report['id']}' "
146
+ f"with problem: {location_report['description']}"
147
+ )
148
+ notifier_function(message)
149
+ except KeyError as e:
150
+ error_message = gettext("Error reporting location")
151
+ return make_response(jsonify({"message": f"{error_message} : {e}"}), 400)
152
+ except Exception as e:
153
+ error_message = gettext("Error sending notification")
154
+ return make_response(jsonify({"message": f"{error_message} : {e}"}), 400)
155
+ return make_response(jsonify({"message": gettext("Location reported")}), 200)
156
+
157
+ @core_api.route("/locations")
158
+ class GetLocations(Resource):
159
+ def get(self):
160
+ """
161
+ Shows list of locations with uuid and position
162
+ """
163
+ query_params = request.args.to_dict(flat=False)
164
+ all_locations = database.get_locations(query_params)
165
+ return jsonify([x.basic_info() for x in all_locations])
166
+
167
+ @core_api.route("/location/<location_id>")
168
+ class GetLocation(Resource):
169
+ def get(self, location_id):
170
+ """
171
+ Shows a single location with all data
172
+ """
173
+ location = database.get_location(location_id)
174
+
175
+ # TODO getting visible_data and meta_data should be taken from db methods
176
+ # e.g. db.get_visible_data() and db.get_meta_data()
177
+ # visible_data and meta_data should be models
178
+ all_data = database.get_data()
179
+ visible_data = all_data["visible_data"]
180
+ meta_data = all_data["meta_data"]
181
+
182
+ formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data)
183
+ return jsonify(formatted_data)
184
+
185
+ @core_api.route("/version")
186
+ class Version(Resource):
187
+ def get(self):
188
+ """Shows backend version"""
189
+ version_info = {"backend": importlib.metadata.version("goodmap")}
190
+ return jsonify(version_info)
191
+
192
+ @core_api.route("/categories")
193
+ class Categories(Resource):
194
+ def get(self):
195
+ """Shows all available categories"""
196
+ all_data = database.get_data()
197
+ categories = make_tuple_translation(all_data["categories"].keys())
198
+ return jsonify(categories)
199
+
200
+ @core_api.route("/languages")
201
+ class Languages(Resource):
202
+ def get(self):
203
+ """Shows all available languages"""
204
+ return jsonify(languages)
205
+
206
+ @core_api.route("/category/<category_type>")
207
+ class CategoryTypes(Resource):
208
+ def get(self, category_type):
209
+ """Shows all available types in category"""
210
+ all_data = database.get_data()
211
+ local_data = make_tuple_translation(all_data["categories"][category_type])
212
+ return jsonify(local_data)
213
+
214
+ @core_api.route("/generate-csrf-token")
215
+ class CsrfToken(Resource):
216
+ def get(self):
217
+ csrf_token = csrf_generator()
218
+ return {"csrf_token": csrf_token}
219
+
220
+ @core_api.route("/admin/locations")
221
+ class AdminManageLocations(Resource):
222
+ def get(self):
223
+ """
224
+ Shows full list of locations, with optional server-side pagination, sorting,
225
+ and filtering.
226
+ """
227
+ # Raw query params from request
228
+ raw_params = request.args.to_dict(flat=False)
229
+ all_locations = database.get_locations(raw_params)
230
+ page_items, pagination = paginate_results(
231
+ all_locations, raw_params, sort_by_default="name"
232
+ )
233
+ items = [x.model_dump() for x in page_items]
234
+ return jsonify({"items": items, **pagination})
235
+
236
+ def post(self):
237
+ """
238
+ Creates a new location
239
+ """
240
+ location_data = request.get_json()
241
+ try:
242
+ location_data.update({"uuid": str(uuid.uuid4())})
243
+ location = location_model.model_validate(location_data)
244
+ database.add_location(location.model_dump())
245
+ except ValueError as e:
246
+ return make_response(jsonify({"message": f"Invalid location data: {e}"}), 400)
247
+ except Exception as e:
248
+ return make_response(jsonify({"message": f"Error creating location: {e}"}), 400)
249
+ return jsonify(location.model_dump())
250
+
251
+ @core_api.route("/admin/locations/<location_id>")
252
+ class AdminManageLocation(Resource):
253
+ def put(self, location_id):
254
+ """
255
+ Updates a single location
256
+ """
257
+ location_data = request.get_json()
258
+ try:
259
+ location_data.update({"uuid": location_id})
260
+ location = location_model.model_validate(location_data)
261
+ database.update_location(location_id, location.model_dump())
262
+ except ValueError as e:
263
+ return make_response(jsonify({"message": f"Invalid location data: {e}"}), 400)
264
+ except Exception as e:
265
+ return make_response(jsonify({"message": f"Error updating location: {e}"}), 400)
266
+ return jsonify(location.model_dump())
267
+
268
+ def delete(self, location_id):
269
+ """
270
+ Deletes a single location
271
+ """
272
+ try:
273
+ database.delete_location(location_id)
274
+ except ValueError as e:
275
+ return make_response(jsonify({"message": f"Location not found: {e}"}), 404)
276
+ except Exception as e:
277
+ return make_response(jsonify({"message": f"Error deleting location: {e}"}), 400)
278
+ return "", 204
279
+
280
+ @core_api.route("/admin/suggestions")
281
+ class AdminManageSuggestions(Resource):
282
+ def get(self):
283
+ """
284
+ List location suggestions, with optional server-side pagination, sorting,
285
+ and filtering by status.
286
+ """
287
+ raw_params = request.args.to_dict(flat=False)
288
+ suggestions = database.get_suggestions(raw_params)
289
+ page_items, pagination = paginate_results(suggestions, raw_params)
290
+ return jsonify({"items": page_items, **pagination})
291
+
292
+ @core_api.route("/admin/suggestions/<suggestion_id>")
293
+ class AdminManageSuggestion(Resource):
294
+ def put(self, suggestion_id):
295
+ """
296
+ Accept or reject a location suggestion
297
+ """
298
+ try:
299
+ data = request.get_json()
300
+ status = data.get("status")
301
+ if status not in ("accepted", "rejected"):
302
+ return make_response(jsonify({"message": f"Invalid status: {status}"}), 400)
303
+ suggestion = database.get_suggestion(suggestion_id)
304
+ if not suggestion:
305
+ return make_response(jsonify({"message": "Suggestion not found"}), 404)
306
+ if suggestion.get("status") != "pending":
307
+ return make_response(jsonify({"message": "Suggestion already processed"}), 400)
308
+ if status == "accepted":
309
+ suggestion_data = {k: v for k, v in suggestion.items() if k != "status"}
310
+ database.add_location(suggestion_data)
311
+ database.update_suggestion(suggestion_id, status)
312
+ except ValueError as e:
313
+ return make_response(jsonify({"message": f"{e}"}), 400)
314
+ return jsonify(database.get_suggestion(suggestion_id))
315
+
316
+ @core_api.route("/admin/reports")
317
+ class AdminManageReports(Resource):
318
+ def get(self):
319
+ """
320
+ List location reports, with optional server-side pagination, sorting,
321
+ and filtering by status/priority.
322
+ """
323
+ raw_params = request.args.to_dict(flat=False)
324
+ reports = database.get_reports(raw_params)
325
+ page_items, pagination = paginate_results(reports, raw_params)
326
+ return jsonify({"items": page_items, **pagination})
327
+
328
+ @core_api.route("/admin/reports/<report_id>")
329
+ class AdminManageReport(Resource):
330
+ def put(self, report_id):
331
+ """
332
+ Update a report's status and/or priority
333
+ """
334
+ try:
335
+ data = request.get_json()
336
+ status = data.get("status")
337
+ priority = data.get("priority")
338
+ valid_status = ("resolved", "rejected")
339
+ valid_priority = ("critical", "high", "medium", "low")
340
+ if status and status not in valid_status:
341
+ return make_response(jsonify({"message": f"Invalid status: {status}"}), 400)
342
+ if priority and priority not in valid_priority:
343
+ return make_response(jsonify({"message": f"Invalid priority: {priority}"}), 400)
344
+ report = database.get_report(report_id)
345
+ if not report:
346
+ return make_response(jsonify({"message": "Report not found"}), 404)
347
+ database.update_report(report_id, status=status, priority=priority)
348
+ except ValueError as e:
349
+ return make_response(jsonify({"message": f"{e}"}), 400)
350
+ return jsonify(database.get_report(report_id))
351
+
352
+ return core_api_blueprint
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, create_model, field_validator
6
6
  class LocationBase(BaseModel, extra="allow"):
7
7
  position: tuple[float, float]
8
8
  uuid: str
9
+ remark: str = ""
9
10
 
10
11
  @field_validator("position")
11
12
  @classmethod
@@ -17,7 +18,7 @@ class LocationBase(BaseModel, extra="allow"):
17
18
  return v
18
19
 
19
20
  def basic_info(self):
20
- return {"uuid": self.uuid, "position": self.position}
21
+ return {"uuid": self.uuid, "position": self.position, "remark": bool(self.remark)}
21
22
 
22
23
 
23
24
  def create_location_model(obligatory_fields: list[tuple[str, Type[Any]]]) -> Type[BaseModel]: