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
@@ -21,6 +21,7 @@ from udata.core.discussions.api import discussion_fields
21
21
  from udata.core.discussions.csv import DiscussionCsvAdapter
22
22
  from udata.core.discussions.models import Discussion
23
23
  from udata.core.followers.api import FollowAPI
24
+ from udata.core.legal.mails import add_send_legal_notice_argument, send_legal_notice_on_deletion
24
25
  from udata.core.reuse.models import Reuse
25
26
  from udata.core.storages.api import (
26
27
  image_parser,
@@ -137,6 +138,9 @@ class OrganizationListAPI(API):
137
138
  return organization, 201
138
139
 
139
140
 
141
+ org_delete_parser = add_send_legal_notice_argument(api.parser())
142
+
143
+
140
144
  @ns.route("/<org:org>/", endpoint="organization", doc=common_doc)
141
145
  @api.response(404, "Organization not found")
142
146
  @api.response(410, "Organization has been deleted")
@@ -170,12 +174,16 @@ class OrganizationAPI(API):
170
174
 
171
175
  @api.secure
172
176
  @api.doc("delete_organization")
177
+ @api.expect(org_delete_parser)
173
178
  @api.response(204, "Organization deleted")
174
179
  def delete(self, org):
175
180
  """Delete a organization given its identifier"""
181
+ args = org_delete_parser.parse_args()
176
182
  if org.deleted:
177
183
  api.abort(410, "Organization has been deleted")
178
184
  EditOrganizationPermission(org).test()
185
+ send_legal_notice_on_deletion(org, args)
186
+
179
187
  org.deleted = datetime.utcnow()
180
188
  org.save()
181
189
  return "", 204
@@ -383,12 +391,13 @@ class MembershipRequestAPI(API):
383
391
 
384
392
  form = api.validate(MembershipRequestForm, membership_request)
385
393
 
386
- if not membership_request:
394
+ if membership_request:
395
+ form.populate_obj(membership_request)
396
+ org.save()
397
+ else:
387
398
  membership_request = MembershipRequest()
388
- org.requests.append(membership_request)
389
-
390
- form.populate_obj(membership_request)
391
- org.save()
399
+ form.populate_obj(membership_request)
400
+ org.add_membership_request(membership_request)
392
401
 
393
402
  notify_membership_request.delay(str(org.id), str(membership_request.id))
394
403
 
@@ -424,6 +433,7 @@ class MembershipAcceptAPI(MembershipAPI):
424
433
  org.members.append(member)
425
434
  org.count_members()
426
435
  org.save()
436
+ MembershipRequest.after_handle.send(membership_request, org=org)
427
437
 
428
438
  notify_membership_response.delay(str(org.id), str(membership_request.id))
429
439
 
@@ -446,6 +456,7 @@ class MembershipRefuseAPI(MembershipAPI):
446
456
  membership_request.refusal_comment = form.comment.data
447
457
 
448
458
  org.save()
459
+ MembershipRequest.after_handle.send(membership_request, org=org)
449
460
 
450
461
  notify_membership_response.delay(str(org.id), str(membership_request.id))
451
462
 
@@ -14,7 +14,7 @@ org_ref_fields = api.inherit(
14
14
  "name": fields.String(description="The organization name", readonly=True),
15
15
  "acronym": fields.String(description="The organization acronym"),
16
16
  "slug": fields.String(
17
- description="The organization string used as permalink", required=True
17
+ description="The organization string used as permalink", readonly=True
18
18
  ),
19
19
  "uri": fields.String(
20
20
  attribute=lambda o: o.self_api_url(),
@@ -122,12 +122,12 @@ member_fields = api.model(
122
122
  org_fields = api.model(
123
123
  "Organization",
124
124
  {
125
- "id": fields.String(description="The organization identifier", required=True),
125
+ "id": fields.String(description="The organization identifier", readonly=True),
126
126
  "name": fields.String(description="The organization name", required=True),
127
127
  "acronym": fields.String(description="The organization acronym"),
128
128
  "url": fields.String(description="The organization website URL"),
129
129
  "slug": fields.String(
130
- description="The organization string used as permalink", required=True
130
+ description="The organization string used as permalink", readonly=True
131
131
  ),
132
132
  "description": fields.Markdown(
133
133
  description="The organization description in Markdown", required=True
@@ -3,7 +3,6 @@ from flask import request
3
3
  from udata import search
4
4
  from udata.api import API, apiv2
5
5
  from udata.core.contact_point.api_fields import contact_point_fields
6
- from udata.utils import multi_to_dict
7
6
 
8
7
  from .api_fields import member_fields, org_fields, org_page_fields
9
8
  from .permissions import EditOrganizationPermission
@@ -16,7 +15,7 @@ apiv2.inherit("ContactPoint", contact_point_fields)
16
15
 
17
16
 
18
17
  ns = apiv2.namespace("organizations", "Organization related operations")
19
- search_parser = OrganizationSearch.as_request_parser()
18
+ search_parser = OrganizationSearch.as_request_parser(store_missing=False)
20
19
 
21
20
  DEFAULT_SORTING = "-created_at"
22
21
 
@@ -30,8 +29,8 @@ class OrganizationSearchAPI(API):
30
29
  @apiv2.marshal_with(org_page_fields)
31
30
  def get(self):
32
31
  """Search all organizations"""
33
- search_parser.parse_args()
34
- return search.query(OrganizationSearch, **multi_to_dict(request.args))
32
+ args = search_parser.parse_args()
33
+ return search.query(OrganizationSearch, **args)
35
34
 
36
35
 
37
36
  @ns.route("/<org:org>/extras/", endpoint="organization_extras")
@@ -16,7 +16,7 @@ def new_membership_request(org: Organization, request: MembershipRequest) -> Mai
16
16
  )
17
17
  ),
18
18
  LabelledContent(_("Reason for the request:"), request.comment),
19
- MailCTA(_("See the request"), cdata_url(f"/admin/organizations/{org.id}/members/")),
19
+ MailCTA(_("See the request"), cdata_url(f"/admin/organizations/{org.id}/members")),
20
20
  ],
21
21
  )
22
22
 
@@ -7,7 +7,7 @@ from flask_babel import LazyString
7
7
  from mongoengine.signals import post_save, pre_save
8
8
  from werkzeug.utils import cached_property
9
9
 
10
- from udata.api_fields import field
10
+ from udata.api_fields import field, generate_fields
11
11
  from udata.core.activity.models import Auditable
12
12
  from udata.core.badges.models import Badge, BadgeMixin, BadgesList
13
13
  from udata.core.linkable import Linkable
@@ -21,6 +21,7 @@ from udata.uris import cdata_url
21
21
 
22
22
  from .constants import (
23
23
  ASSOCIATION,
24
+ BIGGEST_LOGO_SIZE,
24
25
  CERTIFIED,
25
26
  COMPANY,
26
27
  DEFAULT_ROLE,
@@ -44,6 +45,7 @@ BADGES: dict[str, LazyString] = {
44
45
  }
45
46
 
46
47
 
48
+ @generate_fields()
47
49
  class Team(db.EmbeddedDocument):
48
50
  name = db.StringField(required=True)
49
51
  slug = db.SlugField(
@@ -54,6 +56,7 @@ class Team(db.EmbeddedDocument):
54
56
  members = db.ListField(db.ReferenceField("User"))
55
57
 
56
58
 
59
+ @generate_fields()
57
60
  class Member(db.EmbeddedDocument):
58
61
  user = db.ReferenceField("User")
59
62
  role = db.StringField(choices=list(ORG_ROLES), default=DEFAULT_ROLE)
@@ -64,6 +67,7 @@ class Member(db.EmbeddedDocument):
64
67
  return ORG_ROLES[self.role]
65
68
 
66
69
 
70
+ @generate_fields()
67
71
  class MembershipRequest(db.EmbeddedDocument):
68
72
  """
69
73
  Pending organization membership requests
@@ -81,6 +85,9 @@ class MembershipRequest(db.EmbeddedDocument):
81
85
  comment = db.StringField()
82
86
  refusal_comment = db.StringField()
83
87
 
88
+ after_create = Signal()
89
+ after_handle = Signal()
90
+
84
91
  @property
85
92
  def status_label(self):
86
93
  return MEMBERSHIP_STATUS[self.status]
@@ -110,20 +117,27 @@ class OrganizationBadge(Badge):
110
117
 
111
118
 
112
119
  class OrganizationBadgeMixin(BadgeMixin):
113
- badges = field(BadgesList(OrganizationBadge), **BadgeMixin.default_badges_list_params)
120
+ badges = field(
121
+ BadgesList(OrganizationBadge), show_as_ref=True, **BadgeMixin.default_badges_list_params
122
+ )
114
123
  __badges__ = BADGES
115
124
 
116
125
 
126
+ @generate_fields()
117
127
  class Organization(
118
128
  Auditable, WithMetrics, OrganizationBadgeMixin, Linkable, db.Datetimed, db.Document
119
129
  ):
120
- name = field(db.StringField(required=True))
121
- acronym = field(db.StringField(max_length=128))
130
+ name = field(db.StringField(required=True), show_as_ref=True)
131
+ acronym = field(db.StringField(max_length=128), show_as_ref=True)
122
132
  slug = field(
123
133
  db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
124
134
  auditable=False,
135
+ show_as_ref=True,
136
+ )
137
+ description = field(
138
+ db.StringField(required=True),
139
+ markdown=True,
125
140
  )
126
- description = field(db.StringField(required=True))
127
141
  url = field(db.URLField())
128
142
  image_url = field(db.StringField())
129
143
  logo = field(
@@ -132,7 +146,11 @@ class Organization(
132
146
  basename=default_image_basename,
133
147
  max_size=LOGO_MAX_SIZE,
134
148
  thumbnails=LOGO_SIZES,
135
- )
149
+ ),
150
+ show_as_ref=True,
151
+ thumbnail_info={
152
+ "size": BIGGEST_LOGO_SIZE,
153
+ },
136
154
  )
137
155
  business_number_id = field(db.StringField(max_length=ORG_BID_SIZE_LIMIT))
138
156
 
@@ -162,6 +180,8 @@ class Organization(
162
180
  "auto_create_index_on_save": True,
163
181
  }
164
182
 
183
+ verbose_name = _("organization")
184
+
165
185
  def __str__(self):
166
186
  return self.name or ""
167
187
 
@@ -198,7 +218,7 @@ class Organization(
198
218
  cls.before_save.send(document)
199
219
 
200
220
  def self_web_url(self, **kwargs):
201
- return cdata_url(f"/organizations/{self._link_id(**kwargs)}/", **kwargs)
221
+ return cdata_url(f"/organizations/{self._link_id(**kwargs)}", **kwargs)
202
222
 
203
223
  def self_api_url(self, **kwargs):
204
224
  return url_for(
@@ -257,6 +277,14 @@ class Organization(
257
277
  return request
258
278
  return None
259
279
 
280
+ @field(description="Link to the API endpoint for this organization", show_as_ref=True)
281
+ def uri(self, *args, **kwargs):
282
+ return self.self_api_url(*args, **kwargs)
283
+
284
+ @field(description="Link to the udata web page for this organization", show_as_ref=True)
285
+ def page(self, *args, **kwargs):
286
+ return self.self_web_url(*args, **kwargs)
287
+
260
288
  @classmethod
261
289
  def get(cls, id_or_slug):
262
290
  obj = cls.objects(slug=id_or_slug).first()
@@ -304,6 +332,11 @@ class Organization(
304
332
  def views_count(self):
305
333
  return self.metrics.get("views", 0)
306
334
 
335
+ def add_membership_request(self, membership_request):
336
+ self.requests.append(membership_request)
337
+ self.save()
338
+ MembershipRequest.after_create.send(membership_request, org=self)
339
+
307
340
  def count_members(self):
308
341
  self.metrics["members"] = len(self.members)
309
342
  self.save(signal_kwargs={"ignores": ["post_save"]})
@@ -1,10 +1,94 @@
1
1
  import logging
2
2
 
3
+ from udata.api_fields import field, generate_fields
4
+ from udata.core.organization.api_fields import org_ref_fields
5
+ from udata.core.organization.models import MembershipRequest, Organization
6
+ from udata.core.user.api_fields import user_ref_fields
7
+ from udata.core.user.models import User
3
8
  from udata.features.notifications.actions import notifier
9
+ from udata.models import db
4
10
 
5
11
  log = logging.getLogger(__name__)
6
12
 
7
13
 
14
+ @generate_fields()
15
+ class MembershipRequestNotificationDetails(db.EmbeddedDocument):
16
+ request_organization = field(
17
+ db.ReferenceField(Organization),
18
+ readonly=True,
19
+ nested_fields=org_ref_fields,
20
+ auditable=False,
21
+ allow_null=True,
22
+ filterable={},
23
+ )
24
+ request_user = field(
25
+ db.ReferenceField(User),
26
+ nested_fields=user_ref_fields,
27
+ readonly=True,
28
+ auditable=False,
29
+ allow_null=True,
30
+ filterable={},
31
+ )
32
+
33
+
34
+ @MembershipRequest.after_create.connect
35
+ def on_new_membership_request(request: MembershipRequest, **kwargs):
36
+ from udata.features.notifications.models import Notification
37
+
38
+ """Create notification when a new membership request is created"""
39
+ organization = kwargs.get("org")
40
+
41
+ if organization is None:
42
+ return
43
+
44
+ # Get all admin users for the organization
45
+ admin_users = [member.user for member in organization.members if member.role == "admin"]
46
+
47
+ # For each pending request, check if a notification already exists
48
+ for admin_user in admin_users:
49
+ try:
50
+ # Check if notification already exists
51
+ existing = Notification.objects(
52
+ user=admin_user,
53
+ details__request_organization=organization,
54
+ details__request_user=request.user,
55
+ ).first()
56
+
57
+ if not existing:
58
+ notification = Notification(
59
+ user=admin_user,
60
+ details=MembershipRequestNotificationDetails(
61
+ request_organization=organization, request_user=request.user
62
+ ),
63
+ )
64
+ notification.created_at = request.created
65
+ notification.save()
66
+ except Exception as e:
67
+ log.error(
68
+ f"Error creating notification for user {admin_user.id} "
69
+ f"and organization {organization.id}: {e}"
70
+ )
71
+
72
+
73
+ @MembershipRequest.after_handle.connect
74
+ def on_handle_membership_request(request: MembershipRequest, **kwargs):
75
+ from udata.features.notifications.models import Notification
76
+
77
+ organization = kwargs.get("org")
78
+
79
+ if organization is None:
80
+ return
81
+
82
+ notifications = Notification.objects(
83
+ details__request_organization=organization,
84
+ details__request_user=request.user,
85
+ )
86
+
87
+ for notification in notifications:
88
+ notification.handled_at = request.handled_on
89
+ notification.save()
90
+
91
+
8
92
  @notifier("membership_request")
9
93
  def membership_request_notifications(user):
10
94
  """Notify user about pending membership requests"""
@@ -2,7 +2,7 @@ from collections import namedtuple
2
2
  from functools import partial
3
3
 
4
4
  from udata.auth import Permission, current_user, identity_loaded
5
- from udata.models import Organization
5
+ from udata.core.organization.models import Organization
6
6
  from udata.utils import get_by
7
7
 
8
8
  OrganizationNeed = namedtuple("organization", ("role", "value"))
@@ -1,5 +1,6 @@
1
1
  from udata.core import storages
2
2
  from udata.core.badges.tasks import notify_new_badge
3
+ from udata.features.notifications.models import Notification
3
4
  from udata.models import Activity, ContactPoint, Dataset, Follow, Transfer
4
5
  from udata.search import reindex
5
6
  from udata.tasks import get_logger, job, task
@@ -25,6 +26,8 @@ def purge_organizations(self):
25
26
  Transfer.objects(owner=organization).delete()
26
27
  # Remove related contact points
27
28
  ContactPoint.objects(organization=organization).delete()
29
+ # Remove related notifications
30
+ Notification.objects.with_organization_in_details(organization).delete()
28
31
  # Store datasets for later reindexation
29
32
  d_ids = [d.id for d in Dataset.objects(organization=organization)]
30
33
  # Remove organization's logo in all sizes
@@ -83,6 +83,55 @@ class LinksListBloc(BlocWithTitleMixin, Bloc):
83
83
  links = field(db.EmbeddedDocumentListField(LinkInBloc))
84
84
 
85
85
 
86
+ HERO_COLORS = ("primary", "green", "purple")
87
+
88
+
89
+ @generate_fields()
90
+ class HeroBloc(Bloc):
91
+ title = field(db.StringField(required=True))
92
+ description = field(db.StringField())
93
+ color = field(db.StringField(choices=HERO_COLORS))
94
+
95
+
96
+ @generate_fields()
97
+ class MarkdownBloc(Bloc):
98
+ # Not using BlocWithTitleMixin because title should be optional here
99
+ title = field(db.StringField())
100
+ subtitle = field(db.StringField())
101
+ content = field(
102
+ db.StringField(required=True),
103
+ markdown=True,
104
+ )
105
+
106
+
107
+ BLOCS_DISALLOWED_IN_ACCORDION = ("AccordionListBloc", "HeroBloc")
108
+
109
+
110
+ def check_no_recursive_blocs(blocs, **kwargs):
111
+ for bloc in blocs:
112
+ if bloc.__class__.__name__ in BLOCS_DISALLOWED_IN_ACCORDION:
113
+ raise db.ValidationError(
114
+ f"{bloc.__class__.__name__} cannot be nested inside an accordion"
115
+ )
116
+
117
+
118
+ @generate_fields()
119
+ class AccordionItemBloc(db.EmbeddedDocument):
120
+ title = field(db.StringField(required=True))
121
+ content = field(
122
+ db.EmbeddedDocumentListField(Bloc),
123
+ generic=True,
124
+ checks=[check_no_recursive_blocs],
125
+ )
126
+
127
+
128
+ @generate_fields()
129
+ class AccordionListBloc(Bloc):
130
+ title = field(db.StringField())
131
+ description = field(db.StringField())
132
+ items = field(db.EmbeddedDocumentListField(AccordionItemBloc))
133
+
134
+
86
135
  @generate_fields()
87
136
  class Page(Auditable, Owned, Datetimed, db.Document):
88
137
  blocs = field(
@@ -2,7 +2,7 @@ from flask import url_for
2
2
 
3
3
  from udata.core.dataset import tasks
4
4
  from udata.core.dataset.factories import DatasetFactory
5
- from udata.core.pages.models import Page
5
+ from udata.core.pages.models import AccordionItemBloc, AccordionListBloc, Page
6
6
  from udata.core.user.factories import AdminFactory
7
7
  from udata.tests.api import APITestCase
8
8
 
@@ -103,3 +103,167 @@ class PageAPITest(APITestCase):
103
103
 
104
104
  response = self.get(url_for("api.page", page=page_id))
105
105
  self.assert200(response)
106
+
107
+ def test_hero_bloc(self):
108
+ self.login()
109
+
110
+ response = self.post(
111
+ url_for("api.pages"),
112
+ {
113
+ "blocs": [
114
+ {
115
+ "class": "HeroBloc",
116
+ "title": "Welcome to our portal",
117
+ "description": "Discover our datasets",
118
+ "color": "primary",
119
+ }
120
+ ],
121
+ },
122
+ )
123
+ self.assert201(response)
124
+
125
+ self.assertEqual("HeroBloc", response.json["blocs"][0]["class"])
126
+ self.assertEqual("Welcome to our portal", response.json["blocs"][0]["title"])
127
+ self.assertEqual("Discover our datasets", response.json["blocs"][0]["description"])
128
+ self.assertEqual("primary", response.json["blocs"][0]["color"])
129
+
130
+ page = Page.objects().first()
131
+ response = self.get(url_for("api.page", page=page))
132
+ self.assert200(response)
133
+
134
+ self.assertEqual("HeroBloc", response.json["blocs"][0]["class"])
135
+ self.assertEqual("Welcome to our portal", response.json["blocs"][0]["title"])
136
+ self.assertEqual("Discover our datasets", response.json["blocs"][0]["description"])
137
+ self.assertEqual("primary", response.json["blocs"][0]["color"])
138
+
139
+ def test_accordion_bloc(self):
140
+ self.login()
141
+ datasets = DatasetFactory.create_batch(2)
142
+
143
+ response = self.post(
144
+ url_for("api.pages"),
145
+ {
146
+ "blocs": [
147
+ {
148
+ "class": "AccordionListBloc",
149
+ "title": "FAQ",
150
+ "description": "Frequently asked questions",
151
+ "items": [
152
+ {
153
+ "title": "What is udata?",
154
+ "content": [
155
+ {
156
+ "class": "LinksListBloc",
157
+ "title": "Related links",
158
+ "links": [
159
+ {
160
+ "title": "Documentation",
161
+ "url": "https://doc.data.gouv.fr",
162
+ },
163
+ ],
164
+ }
165
+ ],
166
+ },
167
+ {
168
+ "title": "How to use datasets?",
169
+ "content": [
170
+ {
171
+ "class": "DatasetsListBloc",
172
+ "title": "Example datasets",
173
+ "datasets": [str(d.id) for d in datasets],
174
+ }
175
+ ],
176
+ },
177
+ {
178
+ "title": "What is markdown?",
179
+ "content": [
180
+ {
181
+ "class": "MarkdownBloc",
182
+ "content": "# Hello\n\nThis is **bold** text.",
183
+ }
184
+ ],
185
+ },
186
+ ],
187
+ }
188
+ ],
189
+ },
190
+ )
191
+ self.assert201(response)
192
+
193
+ bloc = response.json["blocs"][0]
194
+ self.assertEqual("AccordionListBloc", bloc["class"])
195
+ self.assertEqual("FAQ", bloc["title"])
196
+ self.assertEqual("Frequently asked questions", bloc["description"])
197
+ self.assertEqual(3, len(bloc["items"]))
198
+
199
+ self.assertEqual("What is udata?", bloc["items"][0]["title"])
200
+ self.assertEqual("LinksListBloc", bloc["items"][0]["content"][0]["class"])
201
+ self.assertEqual("Documentation", bloc["items"][0]["content"][0]["links"][0]["title"])
202
+
203
+ self.assertEqual("How to use datasets?", bloc["items"][1]["title"])
204
+ self.assertEqual("DatasetsListBloc", bloc["items"][1]["content"][0]["class"])
205
+ self.assertEqual(2, len(bloc["items"][1]["content"][0]["datasets"]))
206
+
207
+ self.assertEqual("What is markdown?", bloc["items"][2]["title"])
208
+ self.assertEqual("MarkdownBloc", bloc["items"][2]["content"][0]["class"])
209
+
210
+ page = Page.objects().first()
211
+ self.assertIsInstance(page.blocs[0], AccordionListBloc)
212
+ self.assertIsInstance(page.blocs[0].items[0], AccordionItemBloc)
213
+ self.assertEqual("What is udata?", page.blocs[0].items[0].title)
214
+
215
+ response = self.get(url_for("api.page", page=page))
216
+ self.assert200(response)
217
+ self.assertEqual("AccordionListBloc", response.json["blocs"][0]["class"])
218
+ self.assertEqual(3, len(response.json["blocs"][0]["items"]))
219
+
220
+ response = self.put(
221
+ url_for("api.page", page=page),
222
+ {
223
+ "blocs": [
224
+ {
225
+ "class": "AccordionListBloc",
226
+ "title": "Updated FAQ",
227
+ "items": [
228
+ {
229
+ "title": "Single question",
230
+ "content": [],
231
+ }
232
+ ],
233
+ }
234
+ ],
235
+ },
236
+ )
237
+ self.assert200(response)
238
+ self.assertEqual("Updated FAQ", response.json["blocs"][0]["title"])
239
+ self.assertIsNone(response.json["blocs"][0]["description"])
240
+ self.assertEqual(1, len(response.json["blocs"][0]["items"]))
241
+ self.assertEqual("Single question", response.json["blocs"][0]["items"][0]["title"])
242
+
243
+ def test_accordion_bloc_cannot_be_nested(self):
244
+ self.login()
245
+
246
+ response = self.post(
247
+ url_for("api.pages"),
248
+ {
249
+ "blocs": [
250
+ {
251
+ "class": "AccordionListBloc",
252
+ "title": "FAQ",
253
+ "items": [
254
+ {
255
+ "title": "Question",
256
+ "content": [
257
+ {
258
+ "class": "AccordionListBloc",
259
+ "title": "Nested accordion",
260
+ "items": [],
261
+ }
262
+ ],
263
+ },
264
+ ],
265
+ }
266
+ ],
267
+ },
268
+ )
269
+ self.assert400(response)