udata 14.0.3.dev1__py3-none-any.whl → 14.7.3.dev4__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 (151) hide show
  1. udata/api/__init__.py +2 -0
  2. udata/api_fields.py +120 -19
  3. udata/app.py +18 -20
  4. udata/auth/__init__.py +4 -7
  5. udata/auth/forms.py +3 -3
  6. udata/auth/views.py +13 -6
  7. udata/commands/dcat.py +1 -1
  8. udata/commands/serve.py +3 -11
  9. udata/core/activity/api.py +5 -6
  10. udata/core/badges/tests/test_tasks.py +0 -2
  11. udata/core/csv.py +5 -0
  12. udata/core/dataservices/api.py +8 -1
  13. udata/core/dataservices/apiv2.py +3 -6
  14. udata/core/dataservices/models.py +5 -2
  15. udata/core/dataservices/rdf.py +2 -1
  16. udata/core/dataservices/tasks.py +6 -2
  17. udata/core/dataset/api.py +30 -4
  18. udata/core/dataset/api_fields.py +1 -1
  19. udata/core/dataset/apiv2.py +1 -1
  20. udata/core/dataset/constants.py +2 -9
  21. udata/core/dataset/models.py +21 -9
  22. udata/core/dataset/permissions.py +31 -0
  23. udata/core/dataset/rdf.py +18 -16
  24. udata/core/dataset/tasks.py +16 -7
  25. udata/core/discussions/api.py +15 -1
  26. udata/core/discussions/models.py +6 -0
  27. udata/core/legal/__init__.py +0 -0
  28. udata/core/legal/mails.py +128 -0
  29. udata/core/organization/api.py +16 -5
  30. udata/core/organization/api_fields.py +3 -3
  31. udata/core/organization/apiv2.py +3 -4
  32. udata/core/organization/mails.py +1 -1
  33. udata/core/organization/models.py +40 -7
  34. udata/core/organization/notifications.py +84 -0
  35. udata/core/organization/permissions.py +1 -1
  36. udata/core/organization/tasks.py +3 -0
  37. udata/core/pages/models.py +49 -0
  38. udata/core/pages/tests/test_api.py +165 -1
  39. udata/core/post/api.py +25 -70
  40. udata/core/post/constants.py +8 -0
  41. udata/core/post/models.py +109 -17
  42. udata/core/post/tests/test_api.py +140 -3
  43. udata/core/post/tests/test_models.py +24 -0
  44. udata/core/reports/api.py +18 -0
  45. udata/core/reports/models.py +42 -2
  46. udata/core/reuse/api.py +8 -0
  47. udata/core/reuse/apiv2.py +3 -6
  48. udata/core/reuse/models.py +1 -1
  49. udata/core/spatial/forms.py +2 -2
  50. udata/core/topic/models.py +8 -2
  51. udata/core/user/api.py +10 -3
  52. udata/core/user/api_fields.py +3 -3
  53. udata/core/user/models.py +33 -8
  54. udata/features/notifications/api.py +7 -18
  55. udata/features/notifications/models.py +59 -0
  56. udata/features/notifications/tasks.py +25 -0
  57. udata/features/transfer/actions.py +2 -0
  58. udata/features/transfer/models.py +17 -0
  59. udata/features/transfer/notifications.py +96 -0
  60. udata/flask_mongoengine/engine.py +0 -4
  61. udata/flask_mongoengine/pagination.py +1 -1
  62. udata/frontend/markdown.py +2 -1
  63. udata/harvest/actions.py +20 -0
  64. udata/harvest/api.py +24 -7
  65. udata/harvest/backends/base.py +27 -1
  66. udata/harvest/backends/ckan/harvesters.py +21 -4
  67. udata/harvest/backends/dcat.py +4 -1
  68. udata/harvest/commands.py +33 -0
  69. udata/harvest/filters.py +17 -6
  70. udata/harvest/models.py +16 -0
  71. udata/harvest/permissions.py +27 -0
  72. udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
  73. udata/harvest/tests/test_actions.py +46 -2
  74. udata/harvest/tests/test_api.py +161 -6
  75. udata/harvest/tests/test_base_backend.py +86 -1
  76. udata/harvest/tests/test_dcat_backend.py +68 -3
  77. udata/harvest/tests/test_filters.py +6 -0
  78. udata/i18n.py +1 -4
  79. udata/mail.py +14 -0
  80. udata/migrations/2021-08-17-harvest-integrity.py +23 -16
  81. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  82. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  83. udata/migrations/2025-12-16-create-transfer-request-notifications.py +69 -0
  84. udata/migrations/2026-01-14-add-default-kind-to-posts.py +17 -0
  85. udata/mongo/slug_fields.py +1 -1
  86. udata/rdf.py +65 -11
  87. udata/routing.py +2 -2
  88. udata/settings.py +11 -0
  89. udata/tasks.py +2 -0
  90. udata/templates/mail/message.html +3 -1
  91. udata/tests/api/__init__.py +7 -17
  92. udata/tests/api/test_activities_api.py +36 -0
  93. udata/tests/api/test_datasets_api.py +69 -0
  94. udata/tests/api/test_organizations_api.py +0 -3
  95. udata/tests/api/test_reports_api.py +157 -0
  96. udata/tests/api/test_user_api.py +1 -1
  97. udata/tests/apiv2/test_dataservices.py +14 -0
  98. udata/tests/apiv2/test_organizations.py +9 -0
  99. udata/tests/apiv2/test_reuses.py +11 -0
  100. udata/tests/cli/test_cli_base.py +0 -1
  101. udata/tests/dataservice/test_dataservice_tasks.py +29 -0
  102. udata/tests/dataset/test_dataset_model.py +13 -1
  103. udata/tests/dataset/test_dataset_rdf.py +164 -5
  104. udata/tests/dataset/test_dataset_tasks.py +25 -0
  105. udata/tests/frontend/test_auth.py +58 -1
  106. udata/tests/frontend/test_csv.py +0 -3
  107. udata/tests/helpers.py +31 -27
  108. udata/tests/organization/test_notifications.py +67 -2
  109. udata/tests/search/test_search_integration.py +70 -0
  110. udata/tests/site/test_site_csv_exports.py +22 -10
  111. udata/tests/test_activity.py +9 -9
  112. udata/tests/test_api_fields.py +10 -0
  113. udata/tests/test_discussions.py +5 -5
  114. udata/tests/test_legal_mails.py +359 -0
  115. udata/tests/test_notifications.py +15 -57
  116. udata/tests/test_notifications_task.py +43 -0
  117. udata/tests/test_owned.py +81 -1
  118. udata/tests/test_transfer.py +181 -2
  119. udata/tests/test_uris.py +33 -0
  120. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  121. udata/translations/ar/LC_MESSAGES/udata.po +309 -158
  122. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  123. udata/translations/de/LC_MESSAGES/udata.po +313 -160
  124. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  125. udata/translations/es/LC_MESSAGES/udata.po +312 -160
  126. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  127. udata/translations/fr/LC_MESSAGES/udata.po +475 -202
  128. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  129. udata/translations/it/LC_MESSAGES/udata.po +317 -162
  130. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  131. udata/translations/pt/LC_MESSAGES/udata.po +315 -161
  132. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  133. udata/translations/sr/LC_MESSAGES/udata.po +323 -164
  134. udata/translations/udata.pot +169 -124
  135. udata/uris.py +0 -2
  136. udata/utils.py +23 -0
  137. udata-14.7.3.dev4.dist-info/METADATA +109 -0
  138. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +142 -135
  139. udata/core/post/forms.py +0 -30
  140. udata/flask_mongoengine/json.py +0 -38
  141. udata/templates/mail/base.html +0 -105
  142. udata/templates/mail/base.txt +0 -6
  143. udata/templates/mail/button.html +0 -3
  144. udata/templates/mail/layouts/1-column.html +0 -19
  145. udata/templates/mail/layouts/2-columns.html +0 -20
  146. udata/templates/mail/layouts/center-panel.html +0 -16
  147. udata-14.0.3.dev1.dist-info/METADATA +0 -132
  148. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
  149. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
  150. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
  151. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,69 @@
1
+ """
2
+ Create TransferRequestNotification for all pending transfer requests
3
+ """
4
+
5
+ import logging
6
+
7
+ import click
8
+
9
+ from udata.features.notifications.models import Notification
10
+ from udata.features.transfer.models import Transfer
11
+ from udata.features.transfer.notifications import TransferRequestNotificationDetails
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def migrate(db):
17
+ log.info("Processing pending transfer requests...")
18
+
19
+ created_count = 0
20
+
21
+ # Get all pending transfers
22
+ transfers = Transfer.objects(status="pending")
23
+
24
+ with click.progressbar(transfers, length=transfers.count()) as transfer_list:
25
+ for transfer in transfer_list:
26
+ try:
27
+ # Get the recipient (could be a user or an organization)
28
+ recipient = transfer.recipient
29
+
30
+ # For organizations, we need to find admins who should receive notifications
31
+ if recipient._cls == "Organization":
32
+ # Get all admin users for this organization
33
+ recipient_users = [
34
+ member.user for member in recipient.members if member.role == "admin"
35
+ ]
36
+ else:
37
+ # For users, just use the recipient directly
38
+ recipient_users = [recipient]
39
+
40
+ # Create a notification for each recipient user
41
+ for recipient_user in recipient_users:
42
+ try:
43
+ # Check if notification already exists
44
+ existing = Notification.objects(
45
+ user=recipient_user,
46
+ details__transfer_recipient=recipient,
47
+ details__transfer_owner=transfer.owner,
48
+ details__transfer_subject=transfer.subject,
49
+ ).first()
50
+ if not existing:
51
+ notification = Notification(user=recipient_user)
52
+ notification.details = TransferRequestNotificationDetails(
53
+ transfer_owner=transfer.owner,
54
+ transfer_recipient=recipient,
55
+ transfer_subject=transfer.subject,
56
+ )
57
+ # Set the created_at to match the transfer creation date
58
+ notification.created_at = transfer.created
59
+ notification.save()
60
+ created_count += 1
61
+ except Exception as e:
62
+ log.error(
63
+ f"Error creating notification for user {recipient_user.id} "
64
+ f"and transfer {transfer.id}: {e}"
65
+ )
66
+ except Exception as e:
67
+ log.error(f"Error creating notification for transfer {transfer.id}: {e}")
68
+
69
+ log.info(f"Created {created_count} TransferRequestNotifications")
@@ -0,0 +1,17 @@
1
+ """
2
+ This migration sets Post.kind to "news" for posts that don't have a kind.
3
+ This is necessary because the default value is only applied to new documents,
4
+ not existing ones.
5
+ """
6
+
7
+ import logging
8
+
9
+ from udata.models import Post
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def migrate(db):
15
+ log.info("Processing posts without kind...")
16
+ count = Post.objects(kind__exists=False).update(kind="news")
17
+ log.info(f"\tSet kind='news' for {count} posts")
@@ -180,7 +180,7 @@ def populate_slug(instance, field):
180
180
  return qs(**{field.db_field: slug}).clear_cls_query().limit(1).count(True) > 0
181
181
 
182
182
  def get_existing_slug_suffixes(slug):
183
- qs_suffix = qs(slug__regex=f"^{slug}-\d*$").clear_cls_query().only(field.db_field)
183
+ qs_suffix = qs(slug__regex=rf"^{slug}-\d*$").clear_cls_query().only(field.db_field)
184
184
  return [getattr(obj, field.db_field) for obj in qs_suffix]
185
185
 
186
186
  def trim_base_slug(base_slug, index):
udata/rdf.py CHANGED
@@ -5,6 +5,7 @@ This module centralize udata-wide RDF helpers and configuration
5
5
  import logging
6
6
  import re
7
7
  from html.parser import HTMLParser
8
+ from urllib.parse import quote
8
9
 
9
10
  import mongoengine
10
11
  from flask import abort, current_app, request, url_for
@@ -12,6 +13,7 @@ from rdflib import BNode, Graph, Literal, URIRef
12
13
  from rdflib.namespace import (
13
14
  DCTERMS,
14
15
  FOAF,
16
+ ORG,
15
17
  RDF,
16
18
  RDFS,
17
19
  SKOS,
@@ -20,6 +22,7 @@ from rdflib.namespace import (
20
22
  NamespaceManager,
21
23
  )
22
24
  from rdflib.resource import Resource as RdfResource
25
+ from rdflib.term import _is_valid_uri
23
26
  from rdflib.util import SUFFIX_FORMAT_MAP
24
27
  from rdflib.util import guess_format as raw_guess_format
25
28
 
@@ -353,12 +356,24 @@ def theme_labels_from_rdf(rdf):
353
356
 
354
357
 
355
358
  def themes_from_rdf(rdf):
356
- tags = [tag.toPython() for tag in rdf.objects(DCAT.keyword)]
359
+ tags = []
360
+ for tag in rdf.objects(DCAT.keyword):
361
+ if isinstance(tag, RdfResource):
362
+ # dcat:keyword should be Literal, not a Resource/URIRef
363
+ log.warning(f"Ignoring dcat:keyword with URI value: {tag.identifier}")
364
+ continue
365
+ tags.append(tag.toPython())
357
366
  tags += theme_labels_from_rdf(rdf)
358
367
  return list(set(tags))
359
368
 
360
369
 
361
- def contact_points_from_rdf(rdf, prop, role, dataset):
370
+ def contact_point_name(agent_name: str | None, org_name: str | None) -> str:
371
+ if agent_name and org_name:
372
+ return f"{agent_name} ({org_name})"
373
+ return agent_name or org_name or ""
374
+
375
+
376
+ def contact_points_from_rdf(rdf, prop, role, dataset, dryrun=False):
362
377
  if not dataset.organization and not dataset.owner:
363
378
  return
364
379
  for contact_point in rdf.objects(prop):
@@ -369,7 +384,10 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
369
384
  email = None
370
385
  contact_form = None
371
386
  elif prop == DCAT.contactPoint: # Could be split on the type of contact_point instead
372
- name = rdf_value(contact_point, VCARD.fn) or ""
387
+ name = contact_point_name(
388
+ rdf_value(contact_point, VCARD.fn),
389
+ rdf_value(contact_point, VCARD["organization-name"]),
390
+ )
373
391
  email = (
374
392
  rdf_value(contact_point, VCARD.hasEmail)
375
393
  or rdf_value(contact_point, VCARD.email)
@@ -378,12 +396,16 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
378
396
  email = email.replace("mailto:", "").strip() if email else None
379
397
  contact_form = rdf_value(contact_point, VCARD.hasUrl)
380
398
  else:
381
- name = (
382
- rdf_value(contact_point, FOAF.name)
383
- or rdf_value(contact_point, SKOS.prefLabel)
384
- or ""
399
+ contact_point_org = contact_point.value(ORG.memberOf)
400
+ name = contact_point_name(
401
+ rdf_value(contact_point, FOAF.name) or rdf_value(contact_point, SKOS.prefLabel),
402
+ rdf_value(contact_point_org, FOAF.name) if contact_point_org else None,
403
+ )
404
+ email = (
405
+ rdf_value(contact_point, FOAF.mbox)
406
+ or (contact_point_org and rdf_value(contact_point_org, FOAF.mbox))
407
+ or None
385
408
  )
386
- email = rdf_value(contact_point, FOAF.mbox)
387
409
  email = email.replace("mailto:", "").strip() if email else None
388
410
  contact_form = None
389
411
 
@@ -398,9 +420,18 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
398
420
  else:
399
421
  org_or_owner = {"owner": dataset.owner}
400
422
  try:
401
- contact, _ = ContactPoint.objects.get_or_create(
402
- name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
403
- )
423
+ if dryrun:
424
+ # In dryrun mode, only reuse existing contact points, don't create new ones.
425
+ # Mongoengine doesn't allow referencing unsaved documents.
426
+ contact = ContactPoint.objects.filter(
427
+ name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
428
+ ).first()
429
+ if not contact:
430
+ continue
431
+ else:
432
+ contact, _ = ContactPoint.objects.get_or_create(
433
+ name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
434
+ )
404
435
  except mongoengine.errors.ValidationError as validation_error:
405
436
  log.warning(f"Unable to validate contact point: {validation_error}", exc_info=True)
406
437
  continue
@@ -568,6 +599,27 @@ def paginate_catalog(catalog, graph, datasets, _format, rdf_catalog_endpoint, **
568
599
  return catalog
569
600
 
570
601
 
602
+ def escape_uri_in_graph(graph: Graph) -> Graph:
603
+ """
604
+ Some invalid uri could exist in the graph and they can't be serialized in N3/Turtle.
605
+ We use a urllib.parse.quote to escape these at best for invalid URIRef.
606
+ """
607
+ escaped_graph = Graph()
608
+ for s, p, o in graph:
609
+ try:
610
+ if isinstance(s, URIRef) and not _is_valid_uri(str(s)):
611
+ encoded_uri = quote(str(s), safe=":/?#[]@!$&'()*+,;=")
612
+ s = URIRef(encoded_uri)
613
+ if isinstance(o, URIRef) and not _is_valid_uri(str(o)):
614
+ encoded_uri = quote(str(o), safe=":/?#[]@!$&'()*+,;=")
615
+ o = URIRef(encoded_uri)
616
+ escaped_graph.add((s, p, o))
617
+ except Exception as e:
618
+ log.exception(f"Failing to escape uri on triplet {s} {p} {o} : {e}")
619
+ continue
620
+ return escaped_graph
621
+
622
+
571
623
  def graph_response(graph, format):
572
624
  """
573
625
  Return a proper flask response for a RDF resource given an expected format.
@@ -581,6 +633,8 @@ def graph_response(graph, format):
581
633
  kwargs["context"] = CONTEXT
582
634
  if isinstance(graph, RdfResource):
583
635
  graph = graph.graph
636
+ if fmt in ["n3", "nt", "turtle", "trig"]:
637
+ graph = escape_uri_in_graph(graph)
584
638
  return escape_xml_illegal_chars(graph.serialize(format=fmt, **kwargs)), 200, headers
585
639
 
586
640
 
udata/routing.py CHANGED
@@ -1,3 +1,4 @@
1
+ from urllib.parse import quote
1
2
  from uuid import UUID
2
3
 
3
4
  from bson import ObjectId
@@ -5,7 +6,6 @@ from flask import redirect, request, url_for
5
6
  from mongoengine.errors import InvalidQueryError, ValidationError
6
7
  from werkzeug.exceptions import NotFound
7
8
  from werkzeug.routing import BaseConverter, PathConverter
8
- from werkzeug.urls import url_quote
9
9
 
10
10
  from udata import models
11
11
  from udata.core.dataservices.models import Dataservice
@@ -79,7 +79,7 @@ class ModelConverter(BaseConverter):
79
79
  if self.has_slug:
80
80
  return self.model.slug.slugify(value)
81
81
  else:
82
- return url_quote(value)
82
+ return quote(value)
83
83
 
84
84
  def to_python(self, value):
85
85
  try:
udata/settings.py CHANGED
@@ -69,11 +69,13 @@ class Defaults(object):
69
69
  # Flask mail settings
70
70
 
71
71
  MAIL_DEFAULT_SENDER = "webmaster@udata"
72
+ MAIL_LOGO_URL = "https://www.data.gouv.fr/nuxt_images/udata_mails_external_logo.png"
72
73
 
73
74
  # Flask security settings
74
75
 
75
76
  SESSION_COOKIE_SECURE = True
76
77
  SESSION_COOKIE_SAMESITE = None # Can be set to 'Lax' or 'Strict'. See https://flask.palletsprojects.com/en/2.3.x/security/#security-cookie
78
+ SECURITY_USE_REGISTER_V2 = True
77
79
 
78
80
  # Flask-Security-Too settings
79
81
 
@@ -172,6 +174,10 @@ class Defaults(object):
172
174
  SITE_AUTHOR = "Udata"
173
175
  SITE_GITHUB_URL = "https://github.com/etalab/udata"
174
176
 
177
+ TERMS_OF_USE_URL = None
178
+ TERMS_OF_USE_DELETION_ARTICLE = None
179
+ TELERECOURS_URL = None
180
+
175
181
  UDATA_INSTANCE_NAME = "udata"
176
182
 
177
183
  HARVESTER_BACKENDS = []
@@ -478,6 +484,11 @@ class Defaults(object):
478
484
  # Padding (in percent) used by the internal provider
479
485
  AVATAR_INTERNAL_PADDING = 10
480
486
 
487
+ # Notification settings
488
+ ###########################################################################
489
+ # Notifications are deleted after being handled for 90 days
490
+ DAYS_AFTER_NOTIFICATION_EXPIRED = 90
491
+
481
492
  # Post settings
482
493
  ###########################################################################
483
494
  # Discussions on posts are disabled by default
udata/tasks.py CHANGED
@@ -161,6 +161,7 @@ def init_app(app):
161
161
  import udata.core.metrics.tasks # noqa
162
162
  import udata.core.tags.tasks # noqa
163
163
  import udata.core.activity.tasks # noqa
164
+ import udata.core.dataservices.tasks # noqa
164
165
  import udata.core.dataset.tasks # noqa
165
166
  import udata.core.dataset.transport # noqa
166
167
  import udata.core.dataset.recommendations # noqa
@@ -172,6 +173,7 @@ def init_app(app):
172
173
  import udata.core.discussions.tasks # noqa
173
174
  import udata.core.badges.tasks # noqa
174
175
  import udata.core.storages.tasks # noqa
176
+ import udata.features.notifications.tasks # noqa
175
177
  import udata.harvest.tasks # noqa
176
178
  import udata.db.tasks # noqa
177
179
 
@@ -6,9 +6,11 @@
6
6
  <title>{{ message.subject }}</title>
7
7
  </head>
8
8
  <body style="font-family: Marianne, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; background-color: #ffffff; max-width: 600px; margin: 0 auto; padding: 20px;">
9
+ {% if config.MAIL_LOGO_URL %}
9
10
  <div style="margin-bottom: 30px;">
10
- <img width="186" src="data:image/png;base64,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" alt="Logo">
11
+ <img width="186" src="{{ config.MAIL_LOGO_URL }}" alt="Logo">
11
12
  </div>
13
+ {% endif %}
12
14
 
13
15
  <p style="margin-bottom: 20px;">{{ _('Hi %(user)s', user=recipient.first_name) }},</p>
14
16
 
@@ -3,6 +3,7 @@ from urllib.parse import urlparse
3
3
 
4
4
  import pytest
5
5
  from flask import json
6
+ from flask_security.utils import login_user, logout_user, set_request_attr
6
7
 
7
8
  from udata.core.user.factories import UserFactory
8
9
  from udata.mongo import db
@@ -55,27 +56,16 @@ class APITestCaseMixin:
55
56
 
56
57
  def login(self, user=None):
57
58
  """Login a user via session authentication."""
58
- from flask import current_app
59
- from flask_principal import Identity, identity_changed
60
-
61
- user = user or UserFactory()
62
- with self.client.session_transaction() as session:
63
- # Since flask-security-too 4.0.0, the user.fs_uniquifier is used instead of user.id for auth
64
- user_id = getattr(user, current_app.login_manager.id_attribute)()
65
- session["user_id"] = user_id
66
- session["_fresh"] = True
67
- session["_id"] = current_app.login_manager._session_identifier_generator()
68
- current_app.login_manager._update_request_context_with_user(user)
69
- identity_changed.send(current_app._get_current_object(), identity=Identity(user.id))
70
- self.user = user
59
+ self.user = user or UserFactory()
60
+
61
+ login_user(self.user)
62
+ set_request_attr("fs_authn_via", "session")
63
+
71
64
  return self.user
72
65
 
73
66
  def logout(self):
74
67
  """Logout the current user."""
75
- with self.client.session_transaction() as session:
76
- del session["user_id"]
77
- del session["_fresh"]
78
- del session["_id"]
68
+ logout_user()
79
69
  self.user = None
80
70
 
81
71
  def perform(self, verb, url, **kwargs):
@@ -4,6 +4,7 @@ from werkzeug.test import TestResponse
4
4
  from udata.core.activity.models import Activity
5
5
  from udata.core.dataset.factories import DatasetFactory
6
6
  from udata.core.dataset.models import Dataset
7
+ from udata.core.organization.factories import OrganizationFactory
7
8
  from udata.core.reuse.factories import ReuseFactory
8
9
  from udata.core.reuse.models import Reuse
9
10
  from udata.core.topic.factories import TopicFactory
@@ -111,3 +112,38 @@ class ActivityAPITest(APITestCase):
111
112
  assert activity_data["related_to_id"] == str(topic.id)
112
113
  assert activity_data["related_to_kind"] == "Topic"
113
114
  assert activity_data["related_to_url"] == topic.self_api_url()
115
+
116
+ def test_activity_api_list_with_private_visible_to_owner(self) -> None:
117
+ """Owner should see activities about their own private objects."""
118
+ owner = UserFactory()
119
+ dataset = DatasetFactory(private=True, owner=owner)
120
+ FakeDatasetActivity.objects.create(actor=UserFactory(), related_to=dataset)
121
+
122
+ # Anonymous user won't see it
123
+ response = self.get(url_for("api.activity"))
124
+ assert200(response)
125
+ assert len(response.json["data"]) == 0
126
+
127
+ # Owner should see their own private dataset activity
128
+ self.login(owner)
129
+ response = self.get(url_for("api.activity"))
130
+ assert200(response)
131
+ assert len(response.json["data"]) == 1
132
+
133
+ def test_activity_api_list_with_private_visible_to_org_member(self) -> None:
134
+ """Organization members should see activities about their org's private objects."""
135
+ member = UserFactory()
136
+ org = OrganizationFactory(admins=[member])
137
+ dataset = DatasetFactory(private=True, organization=org)
138
+ FakeDatasetActivity.objects.create(actor=UserFactory(), related_to=dataset)
139
+
140
+ # Anonymous user won't see it
141
+ response = self.get(url_for("api.activity"))
142
+ assert200(response)
143
+ assert len(response.json["data"]) == 0
144
+
145
+ # Org member should see the private dataset activity
146
+ self.login(member)
147
+ response = self.get(url_for("api.activity"))
148
+ assert200(response)
149
+ assert len(response.json["data"]) == 1
@@ -724,6 +724,19 @@ class DatasetAPITest(APITestCase):
724
724
  dataset = Dataset.objects.first()
725
725
  self.assertEqual(dataset.spatial.geom, SAMPLE_GEOM)
726
726
 
727
+ def test_dataset_api_create_with_invalid_geom_coordinates(self):
728
+ """It should return 400 with invalid GeoJSON coordinates, not 500"""
729
+ self.login()
730
+ data = DatasetFactory.as_dict()
731
+ # Invalid GeoJSON: {} in coordinates instead of numbers (Sentry issue)
732
+ data["spatial"] = {"geom": {"type": "Point", "coordinates": {}}}
733
+ response = self.post(url_for("api.datasets"), data)
734
+ self.assert400(response)
735
+ self.assertEqual(Dataset.objects.count(), 0)
736
+ # Verify error is properly captured in form validation errors
737
+ self.assertIn("errors", response.json)
738
+ self.assertIn("spatial", response.json["errors"])
739
+
727
740
  def test_dataset_api_create_with_legacy_frequency(self):
728
741
  """It should create a dataset from the API with a legacy frequency"""
729
742
  self.login()
@@ -1542,6 +1555,44 @@ class DatasetsFeedAPItest(APITestCase):
1542
1555
  entry = feed.entries[0]
1543
1556
  assert uris.validate(entry["id"])
1544
1557
 
1558
+ @pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
1559
+ def test_recent_feed_with_organization_filter(self):
1560
+ org1 = OrganizationFactory()
1561
+ org2 = OrganizationFactory()
1562
+ DatasetFactory(title="Dataset Org1", organization=org1, resources=[ResourceFactory()])
1563
+ DatasetFactory(title="Dataset Org2", organization=org2, resources=[ResourceFactory()])
1564
+
1565
+ response = self.get(url_for("api.recent_datasets_atom_feed", organization=str(org1.id)))
1566
+ self.assert200(response)
1567
+
1568
+ feed = feedparser.parse(response.data)
1569
+ self.assertEqual(len(feed.entries), 1)
1570
+ self.assertEqual(feed.entries[0].title, "Dataset Org1")
1571
+
1572
+ @pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
1573
+ def test_recent_feed_with_tag_filter(self):
1574
+ DatasetFactory(title="Tagged", tags=["transport"], resources=[ResourceFactory()])
1575
+ DatasetFactory(title="Not Tagged", tags=["other"], resources=[ResourceFactory()])
1576
+
1577
+ response = self.get(url_for("api.recent_datasets_atom_feed", tag="transport"))
1578
+ self.assert200(response)
1579
+
1580
+ feed = feedparser.parse(response.data)
1581
+ self.assertEqual(len(feed.entries), 1)
1582
+ self.assertEqual(feed.entries[0].title, "Tagged")
1583
+
1584
+ @pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
1585
+ def test_recent_feed_with_search_query(self):
1586
+ DatasetFactory(title="Transport public", resources=[ResourceFactory()])
1587
+ DatasetFactory(title="Environnement", resources=[ResourceFactory()])
1588
+
1589
+ response = self.get(url_for("api.recent_datasets_atom_feed", q="transport"))
1590
+ self.assert200(response)
1591
+
1592
+ feed = feedparser.parse(response.data)
1593
+ self.assertEqual(len(feed.entries), 1)
1594
+ self.assertEqual(feed.entries[0].title, "Transport public")
1595
+
1545
1596
 
1546
1597
  class DatasetBadgeAPITest(APITestCase):
1547
1598
  @classmethod
@@ -1693,6 +1744,12 @@ class DatasetResourceAPITest(APITestCase):
1693
1744
  self.dataset.reload()
1694
1745
  self.assertEqual(len(self.dataset.resources), 2)
1695
1746
 
1747
+ def test_create_with_list_returns_400(self):
1748
+ """It should return 400 when sending a list instead of a dict"""
1749
+ data = [ResourceFactory.as_dict()]
1750
+ response = self.post(url_for("api.resources", dataset=self.dataset), data)
1751
+ self.assert400(response)
1752
+
1696
1753
  def test_create_with_file(self):
1697
1754
  """It should create a resource from the API with a file"""
1698
1755
  user = self.login()
@@ -1834,6 +1891,18 @@ class DatasetResourceAPITest(APITestCase):
1834
1891
  f"Resource ids must match existing ones in dataset, ie: {set(str(r.id) for r in self.dataset.resources)}",
1835
1892
  )
1836
1893
 
1894
+ def test_invalid_reorder_dict_without_id(self):
1895
+ """It should return 400 when dict in resources list has no 'id' key"""
1896
+ self.dataset.resources = ResourceFactory.build_batch(3)
1897
+ self.dataset.save()
1898
+
1899
+ # Dict without 'id' key should fail gracefully, not raise KeyError
1900
+ wrong_order_dict_without_id = [{"title": "foo"}, {"title": "bar"}, {"title": "baz"}]
1901
+ response = self.put(
1902
+ url_for("api.resources", dataset=self.dataset), wrong_order_dict_without_id
1903
+ )
1904
+ self.assertStatus(response, 400)
1905
+
1837
1906
  def test_update_local(self):
1838
1907
  resource = ResourceFactory()
1839
1908
  self.dataset.resources.append(resource)
@@ -1017,7 +1017,6 @@ class OrganizationCsvExportsTest(PytestOnlyAPITestCase):
1017
1017
 
1018
1018
  assert200(response)
1019
1019
  assert response.mimetype == "text/csv"
1020
- assert response.charset == "utf-8"
1021
1020
 
1022
1021
  csvfile = StringIO(response.data.decode("utf-8"))
1023
1022
  reader = csv.get_reader(csvfile)
@@ -1045,7 +1044,6 @@ class OrganizationCsvExportsTest(PytestOnlyAPITestCase):
1045
1044
 
1046
1045
  assert200(response)
1047
1046
  assert response.mimetype == "text/csv"
1048
- assert response.charset == "utf-8"
1049
1047
 
1050
1048
  csvfile = StringIO(response.data.decode("utf-8"))
1051
1049
  reader = csv.get_reader(csvfile)
@@ -1083,7 +1081,6 @@ class OrganizationCsvExportsTest(PytestOnlyAPITestCase):
1083
1081
 
1084
1082
  assert200(response)
1085
1083
  assert response.mimetype == "text/csv"
1086
- assert response.charset == "utf-8"
1087
1084
 
1088
1085
  csvfile = StringIO(response.data.decode("utf-8"))
1089
1086
  reader = csv.get_reader(csvfile)