geovisio 2.6.0__py3-none-any.whl → 2.7.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.
Files changed (57) hide show
  1. geovisio/__init__.py +36 -7
  2. geovisio/admin_cli/db.py +1 -4
  3. geovisio/config_app.py +40 -1
  4. geovisio/db_migrations.py +24 -3
  5. geovisio/templates/main.html +13 -13
  6. geovisio/templates/viewer.html +3 -3
  7. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  8. geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
  9. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
  11. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  13. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  15. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  16. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  17. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  19. geovisio/translations/messages.pot +686 -0
  20. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
  22. geovisio/utils/__init__.py +1 -1
  23. geovisio/utils/auth.py +50 -11
  24. geovisio/utils/db.py +65 -0
  25. geovisio/utils/excluded_areas.py +83 -0
  26. geovisio/utils/extent.py +30 -0
  27. geovisio/utils/fields.py +1 -1
  28. geovisio/utils/filesystems.py +0 -1
  29. geovisio/utils/link.py +14 -0
  30. geovisio/utils/params.py +20 -0
  31. geovisio/utils/pictures.py +92 -68
  32. geovisio/utils/reports.py +171 -0
  33. geovisio/utils/sequences.py +264 -126
  34. geovisio/utils/tokens.py +37 -42
  35. geovisio/utils/upload_set.py +654 -0
  36. geovisio/web/auth.py +37 -37
  37. geovisio/web/collections.py +286 -302
  38. geovisio/web/configuration.py +14 -0
  39. geovisio/web/docs.py +241 -14
  40. geovisio/web/excluded_areas.py +377 -0
  41. geovisio/web/items.py +156 -108
  42. geovisio/web/map.py +20 -20
  43. geovisio/web/params.py +69 -26
  44. geovisio/web/pictures.py +14 -31
  45. geovisio/web/reports.py +399 -0
  46. geovisio/web/rss.py +13 -7
  47. geovisio/web/stac.py +129 -134
  48. geovisio/web/tokens.py +98 -109
  49. geovisio/web/upload_set.py +768 -0
  50. geovisio/web/users.py +100 -73
  51. geovisio/web/utils.py +28 -9
  52. geovisio/workers/runner_pictures.py +252 -204
  53. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/METADATA +16 -13
  54. geovisio-2.7.0.dist-info/RECORD +66 -0
  55. geovisio-2.6.0.dist-info/RECORD +0 -41
  56. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
  57. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,399 @@
1
+ from flask import current_app, request, Blueprint, url_for
2
+ from flask_babel import gettext as _
3
+ from uuid import UUID
4
+ from enum import Enum
5
+ from typing import Optional
6
+ from typing_extensions import Self
7
+ from pydantic import BaseModel, ConfigDict, ValidationError, model_validator, Field
8
+ from psycopg.rows import class_row
9
+ from psycopg.sql import SQL, Identifier, Literal
10
+ from geovisio.utils import db, auth
11
+ from geovisio.utils.reports import Report, ReportType, get_report, list_reports, is_picture_owner
12
+ from geovisio.utils.params import validation_error
13
+ from geovisio.errors import InvalidAPIUsage, InternalError
14
+
15
+
16
+ bp = Blueprint("reports", __name__, url_prefix="/api")
17
+
18
+
19
+ class ReportCreationParameter(BaseModel):
20
+ """Parameters used to create a Report"""
21
+
22
+ issue: ReportType
23
+ """Nature of the issue you want to report"""
24
+
25
+ picture_id: Optional[UUID] = None
26
+ """The ID of the picture concerned by this report. You should either set picture_id or sequence_id."""
27
+
28
+ sequence_id: Optional[UUID] = None
29
+ """The ID of the sequence concerned by this report. You should either set picture_id or sequence_id. If no picture_id is set, report will concern the whole sequence."""
30
+
31
+ reporter_email: Optional[str] = None
32
+ """The reporter email, optional but can be useful to get an answer or if precisions are necessary."""
33
+
34
+ reporter_comments: Optional[str] = None
35
+ """Optional details about the issue."""
36
+
37
+ model_config = ConfigDict(use_attribute_docstrings=True)
38
+
39
+ @model_validator(mode="after")
40
+ def check_ids(self) -> Self:
41
+ if self.picture_id is None and self.sequence_id is None:
42
+ raise ValueError("At least one ID between picture_id and sequence_id must be set")
43
+ return self
44
+
45
+
46
+ def create_report(params: ReportCreationParameter, accountId: Optional[UUID]) -> Report:
47
+ params_as_dict = params.model_dump(exclude_none=True) | {"reporter_account_id": accountId}
48
+
49
+ fields = [SQL(f) for f in params_as_dict.keys()] # type: ignore (we can ignore psycopg types there as we control those keys since they are the attributes of UploadSetCreationParameter)
50
+ values = [SQL(f"%({f})s") for f in params_as_dict.keys()] # type: ignore
51
+
52
+ return db.fetchone(
53
+ current_app,
54
+ SQL("INSERT INTO reports({fields}) VALUES({values}) RETURNING *").format(
55
+ fields=SQL(", ").join(fields), values=SQL(", ").join(values)
56
+ ),
57
+ params_as_dict,
58
+ row_factory=class_row(Report),
59
+ )
60
+
61
+
62
+ @bp.route("/reports", methods=["POST"])
63
+ def postReport():
64
+ """
65
+ Create a new report
66
+
67
+ Note that this call can be authenticated to make report associated to your account.
68
+ ---
69
+ tags:
70
+ - Reports
71
+ requestBody:
72
+ content:
73
+ application/json:
74
+ schema:
75
+ $ref: '#/components/schemas/GeoVisioPostReport'
76
+ responses:
77
+ 200:
78
+ description: the Report metadata
79
+ content:
80
+ application/json:
81
+ schema:
82
+ $ref: '#/components/schemas/GeoVisioReport'
83
+ """
84
+
85
+ if request.is_json and request.json is not None:
86
+ try:
87
+ params = ReportCreationParameter(**request.json)
88
+ except ValidationError as ve:
89
+ raise InvalidAPIUsage(_("Impossible to create a Report"), payload=validation_error(ve))
90
+ else:
91
+ raise InvalidAPIUsage(_("Parameter for creating a Report should be a valid JSON"), status_code=415)
92
+
93
+ account = auth.get_current_account()
94
+ account_id = UUID(account.id) if account is not None else None
95
+
96
+ try:
97
+ report = create_report(params, account_id)
98
+ except Exception as e:
99
+ raise InternalError(_("Impossible to create a Report"), status_code=500, payload={"details": str(e)})
100
+
101
+ return (
102
+ report.for_public().model_dump_json(exclude_none=True),
103
+ 200,
104
+ {
105
+ "Content-Type": "application/json",
106
+ },
107
+ )
108
+
109
+
110
+ @bp.route("/reports/<uuid:report_id>", methods=["GET"])
111
+ def getReport(report_id):
112
+ """Get an existing Report
113
+
114
+ Note that you can only retrieve reports related to your account:
115
+ - Reports you created
116
+ - Reports made by others on your pictures/sequences
117
+
118
+ Accounts with admin role can retrieve any report.
119
+ ---
120
+ tags:
121
+ - Reports
122
+ parameters:
123
+ - name: report_id
124
+ in: path
125
+ description: ID of the Report to retrieve
126
+ required: true
127
+ schema:
128
+ type: string
129
+ security:
130
+ - bearerToken: []
131
+ - cookieAuth: []
132
+ responses:
133
+ 200:
134
+ description: the Report metadata
135
+ content:
136
+ application/json:
137
+ schema:
138
+ $ref: '#/components/schemas/GeoVisioReport'
139
+ """
140
+
141
+ account = auth.get_current_account()
142
+
143
+ if account is None:
144
+ raise InvalidAPIUsage(_("Only authenticated users can access reports"), status_code=401)
145
+
146
+ report = get_report(report_id)
147
+ if report is None:
148
+ raise InvalidAPIUsage(_("Report doesn't exist"), status_code=404)
149
+
150
+ # Check if user is legimitate to access report
151
+ if not account.can_check_reports(): # Is admin ?
152
+ if str(report.reporter_account_id) == account.id: # Is reporter ?
153
+ report = report.for_public()
154
+ elif is_picture_owner(report, account.id): # Is owner of concerned picture/sequence ?
155
+ report = report.for_public()
156
+ else: # Is going home 😂
157
+ raise InvalidAPIUsage(_("You're not authorized to access this report"), status_code=403)
158
+
159
+ return report.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
160
+
161
+
162
+ class ReportStatusEdit(Enum):
163
+ waiting = "waiting"
164
+ closed_solved = "closed_solved"
165
+ closed_ignored = "closed_ignored"
166
+
167
+
168
+ class UserReportRole(Enum):
169
+ reporter = "reporter"
170
+ owner = "owner"
171
+ admin = "admin"
172
+
173
+
174
+ class EditReportParameter(BaseModel):
175
+ """Parameters to edit a report details"""
176
+
177
+ issue: Optional[ReportType] = None
178
+ """Nature of the issue"""
179
+ status: Optional[ReportStatusEdit] = None
180
+ """New report status"""
181
+ reporter_email: Optional[str] = None
182
+ """Email of the person who created the issue"""
183
+ resolver_comments: Optional[str] = None
184
+
185
+ # Context for validation
186
+ editor_role: Optional[UserReportRole] = Field(None, exclude=True)
187
+
188
+ @model_validator(mode="before")
189
+ def check_rights(cls, values):
190
+ status = values.get("status")
191
+ editor_role = UserReportRole(values.get("editor_role"))
192
+ issue = values.get("issue")
193
+ reporter_email = values.get("reporter_email")
194
+ resolver_comments = values.get("resolver_comments")
195
+
196
+ if status:
197
+ if editor_role is None:
198
+ raise ValueError("status can't be changed by anonymous role")
199
+ elif editor_role == UserReportRole.reporter and status == ReportStatusEdit.closed_ignored:
200
+ raise ValueError("status can't be 'closed_ignored' for reporter")
201
+
202
+ if issue and editor_role != UserReportRole.admin:
203
+ raise ValueError("issue type can't be changed by non-admin role")
204
+
205
+ if reporter_email and editor_role != UserReportRole.admin:
206
+ raise ValueError("reporter email can't be changed by non-admin role")
207
+
208
+ if resolver_comments and editor_role not in [UserReportRole.owner, UserReportRole.admin]:
209
+ raise ValueError("resolver comments can't be changed by reporter")
210
+
211
+ return values
212
+
213
+
214
+ def edit_report(report: Report, params: EditReportParameter, accountId: Optional[UUID]) -> Report:
215
+ params_as_dict = params.model_dump(exclude=["editor_role"], exclude_none=True)
216
+ if params.status in [ReportStatusEdit.closed_ignored, ReportStatusEdit.closed_solved]:
217
+ params_as_dict["resolver_account_id"] = accountId
218
+
219
+ changes = SQL(", ").join([SQL("{c} = {v}").format(c=Identifier(c), v=Literal(v)) for c, v in params_as_dict.items()])
220
+ return db.fetchone(
221
+ current_app,
222
+ SQL("UPDATE reports SET {changes} WHERE id = %(id)s RETURNING *").format(changes=changes),
223
+ {"id": report.id},
224
+ row_factory=class_row(Report),
225
+ )
226
+
227
+
228
+ @bp.route("/reports/<uuid:report_id>", methods=["PATCH"])
229
+ @auth.login_required_with_redirect()
230
+ def editReport(account, report_id):
231
+ """Edit an existing Report
232
+
233
+ Only a limited set of edits are available:
234
+ - Reports you created: set "status" to waiting/closed_solved
235
+ - Reports on your pictures: set "status" to waiting/closed_solved/closed_ignored, edit "resolver_comments"
236
+ - If you're admin: you can do anything you like 😄
237
+ ---
238
+ tags:
239
+ - Reports
240
+ parameters:
241
+ - name: report_id
242
+ in: path
243
+ description: ID of the Report
244
+ required: true
245
+ schema:
246
+ type: string
247
+ requestBody:
248
+ content:
249
+ application/json:
250
+ schema:
251
+ $ref: '#/components/schemas/GeoVisioPatchReport'
252
+ security:
253
+ - bearerToken: []
254
+ - cookieAuth: []
255
+ responses:
256
+ 200:
257
+ description: the Report metadata
258
+ content:
259
+ application/json:
260
+ schema:
261
+ $ref: '#/components/schemas/GeoVisioReport'
262
+ """
263
+
264
+ report = get_report(report_id)
265
+ if report is None:
266
+ raise InvalidAPIUsage(_("Report doesn't exist"), status_code=404)
267
+
268
+ # Who is trying to edit ?
269
+ who = None
270
+ if account.can_check_reports():
271
+ who = UserReportRole.admin
272
+ elif str(report.reporter_account_id) == account.id:
273
+ who = UserReportRole.reporter
274
+ elif is_picture_owner(report, account.id):
275
+ who = UserReportRole.owner
276
+ else:
277
+ raise InvalidAPIUsage(_("You're not authorized to edit this Report"), status_code=403)
278
+
279
+ # Parse parameters
280
+ if request.is_json and request.json is not None:
281
+ try:
282
+ params = EditReportParameter(**request.json, editor_role=who.value)
283
+ except ValidationError as ve:
284
+ raise InvalidAPIUsage(_("Impossible to edit the Report"), payload=validation_error(ve))
285
+ else:
286
+ raise InvalidAPIUsage(_("Parameter for editing the Report should be a valid JSON"), status_code=415)
287
+
288
+ # Edit
289
+ report = edit_report(report, params, account.id)
290
+ if who != UserReportRole.admin:
291
+ report = report.for_public()
292
+
293
+ return (
294
+ report.model_dump_json(exclude_none=True),
295
+ 200,
296
+ {
297
+ "Content-Type": "application/json",
298
+ "Access-Control-Expose-Headers": "Location", # Needed for allowing web browsers access Location header
299
+ "Location": url_for("reports.getReport", _external=True, report_id=report.id),
300
+ },
301
+ )
302
+
303
+
304
+ class ListReportsParameter(BaseModel):
305
+ """Parameters used to list user's reports"""
306
+
307
+ account_id: UUID
308
+ limit: int = Field(default=100, ge=0, le=1000)
309
+ filter: Optional[str] = "status IN ('open', 'open_autofix', 'waiting') AND (reporter = 'me' OR owner = 'me')"
310
+ """Filter to apply to the list of reports. The filter should be a valid SQL WHERE clause"""
311
+
312
+
313
+ @bp.route("/reports", methods=["GET"])
314
+ @auth.login_required_with_redirect()
315
+ def listReports(account):
316
+ """List reports
317
+
318
+ This route is only available for admins, to see your own reports, use /api/users/me/reports route instead.
319
+ ---
320
+ tags:
321
+ - Reports
322
+ parameters:
323
+ - $ref: '#/components/parameters/GeoVisioReports_filter'
324
+ - name: limit
325
+ in: query
326
+ description: limit to the number of reports to retrieve
327
+ required: true
328
+ schema:
329
+ type: integer
330
+ minimum: 1
331
+ maximum: 100
332
+ security:
333
+ - bearerToken: []
334
+ - cookieAuth: []
335
+ responses:
336
+ 200:
337
+ description: the Report metadata
338
+ content:
339
+ application/json:
340
+ schema:
341
+ $ref: '#/components/schemas/GeoVisioReports'
342
+ """
343
+ try:
344
+ params = request.args.copy()
345
+ if "filter" not in params:
346
+ params["filter"] = "status IN ('open', 'open_autofix', 'waiting')"
347
+ params = ListReportsParameter(account_id=UUID(account.id), **params)
348
+ except ValidationError as ve:
349
+ raise InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
350
+
351
+ if not account.can_check_reports():
352
+ raise InvalidAPIUsage(_("You're not authorized to list reports"), status_code=403)
353
+
354
+ reports = list_reports(account_id=params.account_id, limit=params.limit, filter=params.filter, forceAccount=False)
355
+
356
+ return reports.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
357
+
358
+
359
+ @bp.route("/users/me/reports", methods=["GET"])
360
+ @auth.login_required_with_redirect()
361
+ def listUserReports(account):
362
+ """List reports associated to current user
363
+
364
+ This concerns reports you created, as long as reports on your pictures or sequences.
365
+ ---
366
+ tags:
367
+ - Reports
368
+ parameters:
369
+ - $ref: '#/components/parameters/GeoVisioUserReports_filter'
370
+ - name: limit
371
+ in: query
372
+ description: limit to the number of reports to retrieve
373
+ required: true
374
+ schema:
375
+ type: integer
376
+ minimum: 1
377
+ maximum: 100
378
+ security:
379
+ - bearerToken: []
380
+ - cookieAuth: []
381
+ responses:
382
+ 200:
383
+ description: the Report metadata
384
+ content:
385
+ application/json:
386
+ schema:
387
+ $ref: '#/components/schemas/GeoVisioReports'
388
+ """
389
+ try:
390
+ params = ListReportsParameter(account_id=UUID(account.id), **request.args)
391
+ except ValidationError as ve:
392
+ raise InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
393
+
394
+ reports = list_reports(account_id=params.account_id, limit=params.limit, filter=params.filter)
395
+
396
+ if not account.can_check_reports():
397
+ reports.reports = [r.for_public() for r in reports.reports]
398
+
399
+ return reports.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
geovisio/web/rss.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from rfeed import *
2
2
  from flask import url_for
3
+ from flask_babel import gettext as _, get_locale
3
4
  import datetime
4
5
  from . import utils
5
6
 
@@ -14,12 +15,17 @@ def dbSequencesToGeoRSS(dbSequences):
14
15
  url = utils.get_viewerpage_url() + f"#focus=map&map=18/{dbSeq['y1']}/{dbSeq['x1']}"
15
16
  urlJson = url_for("stac_collections.getCollection", _external=True, collectionId=dbSeq["id"])
16
17
  urlThumb = url_for("stac_collections.getCollectionThumbnail", _external=True, collectionId=dbSeq["id"])
17
-
18
+ desc = _(
19
+ """Sequence "%(name)s" by "%(user)s" was captured on %(date)s.""",
20
+ name=dbSeq["name"],
21
+ user=dbSeq["account_name"],
22
+ date=str(dbSeq["mints"]),
23
+ )
18
24
  items.append(
19
25
  Item(
20
26
  title=dbSeq["name"],
21
27
  link=url,
22
- description=f"Sequence \"{dbSeq['name']}\" by \"{dbSeq['account_name']}\" was captured on {str(dbSeq['mints'])}.",
28
+ description=desc,
23
29
  author=dbSeq["account_name"],
24
30
  guid=Guid(urlJson),
25
31
  pubDate=dbSeq["created"],
@@ -30,8 +36,8 @@ def dbSequencesToGeoRSS(dbSequences):
30
36
  f"""
31
37
  <p>
32
38
  <img src="{urlThumb}" /><br />
33
- Sequence \"{dbSeq['name']}\" by \"{dbSeq['account_name']}\" was captured on {str(dbSeq['mints'])}.<br />
34
- <a href="{url}">View on the map</a> - <a href="{urlJson}">JSON metadata</a>
39
+ {desc}<br />
40
+ <a href="{url}">{_('View on the map')}</a> - <a href="{urlJson}">{_('JSON metadata')}</a>
35
41
  </p>
36
42
  """
37
43
  ),
@@ -40,10 +46,10 @@ def dbSequencesToGeoRSS(dbSequences):
40
46
  )
41
47
 
42
48
  return Feed(
43
- title="GeoVisio collections",
49
+ title=_("GeoVisio collections"),
44
50
  link=urlHome,
45
- description="List of collections from this GeoVisio server",
46
- language="en-GB",
51
+ description=_("List of collections from this GeoVisio server"),
52
+ language=get_locale().language,
47
53
  lastBuildDate=datetime.datetime.now(),
48
54
  items=items,
49
55
  docs="https://cyber.harvard.edu/rss/rss.html",