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.
- udata/api/__init__.py +2 -0
- udata/api_fields.py +120 -19
- udata/app.py +18 -20
- udata/auth/__init__.py +4 -7
- udata/auth/forms.py +3 -3
- udata/auth/views.py +13 -6
- udata/commands/dcat.py +1 -1
- udata/commands/serve.py +3 -11
- udata/core/activity/api.py +5 -6
- udata/core/badges/tests/test_tasks.py +0 -2
- udata/core/csv.py +5 -0
- udata/core/dataservices/api.py +8 -1
- udata/core/dataservices/apiv2.py +3 -6
- udata/core/dataservices/models.py +5 -2
- udata/core/dataservices/rdf.py +2 -1
- udata/core/dataservices/tasks.py +6 -2
- udata/core/dataset/api.py +30 -4
- udata/core/dataset/api_fields.py +1 -1
- udata/core/dataset/apiv2.py +1 -1
- udata/core/dataset/constants.py +2 -9
- udata/core/dataset/models.py +21 -9
- udata/core/dataset/permissions.py +31 -0
- udata/core/dataset/rdf.py +18 -16
- udata/core/dataset/tasks.py +16 -7
- udata/core/discussions/api.py +15 -1
- udata/core/discussions/models.py +6 -0
- udata/core/legal/__init__.py +0 -0
- udata/core/legal/mails.py +128 -0
- udata/core/organization/api.py +16 -5
- udata/core/organization/api_fields.py +3 -3
- udata/core/organization/apiv2.py +3 -4
- udata/core/organization/mails.py +1 -1
- udata/core/organization/models.py +40 -7
- udata/core/organization/notifications.py +84 -0
- udata/core/organization/permissions.py +1 -1
- udata/core/organization/tasks.py +3 -0
- udata/core/pages/models.py +49 -0
- udata/core/pages/tests/test_api.py +165 -1
- udata/core/post/api.py +25 -70
- udata/core/post/constants.py +8 -0
- udata/core/post/models.py +109 -17
- udata/core/post/tests/test_api.py +140 -3
- udata/core/post/tests/test_models.py +24 -0
- udata/core/reports/api.py +18 -0
- udata/core/reports/models.py +42 -2
- udata/core/reuse/api.py +8 -0
- udata/core/reuse/apiv2.py +3 -6
- udata/core/reuse/models.py +1 -1
- udata/core/spatial/forms.py +2 -2
- udata/core/topic/models.py +8 -2
- udata/core/user/api.py +10 -3
- udata/core/user/api_fields.py +3 -3
- udata/core/user/models.py +33 -8
- udata/features/notifications/api.py +7 -18
- udata/features/notifications/models.py +59 -0
- udata/features/notifications/tasks.py +25 -0
- udata/features/transfer/actions.py +2 -0
- udata/features/transfer/models.py +17 -0
- udata/features/transfer/notifications.py +96 -0
- udata/flask_mongoengine/engine.py +0 -4
- udata/flask_mongoengine/pagination.py +1 -1
- udata/frontend/markdown.py +2 -1
- udata/harvest/actions.py +20 -0
- udata/harvest/api.py +24 -7
- udata/harvest/backends/base.py +27 -1
- udata/harvest/backends/ckan/harvesters.py +21 -4
- udata/harvest/backends/dcat.py +4 -1
- udata/harvest/commands.py +33 -0
- udata/harvest/filters.py +17 -6
- udata/harvest/models.py +16 -0
- udata/harvest/permissions.py +27 -0
- udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
- udata/harvest/tests/test_actions.py +46 -2
- udata/harvest/tests/test_api.py +161 -6
- udata/harvest/tests/test_base_backend.py +86 -1
- udata/harvest/tests/test_dcat_backend.py +68 -3
- udata/harvest/tests/test_filters.py +6 -0
- udata/i18n.py +1 -4
- udata/mail.py +14 -0
- udata/migrations/2021-08-17-harvest-integrity.py +23 -16
- udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
- udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
- udata/migrations/2025-12-16-create-transfer-request-notifications.py +69 -0
- udata/migrations/2026-01-14-add-default-kind-to-posts.py +17 -0
- udata/mongo/slug_fields.py +1 -1
- udata/rdf.py +65 -11
- udata/routing.py +2 -2
- udata/settings.py +11 -0
- udata/tasks.py +2 -0
- udata/templates/mail/message.html +3 -1
- udata/tests/api/__init__.py +7 -17
- udata/tests/api/test_activities_api.py +36 -0
- udata/tests/api/test_datasets_api.py +69 -0
- udata/tests/api/test_organizations_api.py +0 -3
- udata/tests/api/test_reports_api.py +157 -0
- udata/tests/api/test_user_api.py +1 -1
- udata/tests/apiv2/test_dataservices.py +14 -0
- udata/tests/apiv2/test_organizations.py +9 -0
- udata/tests/apiv2/test_reuses.py +11 -0
- udata/tests/cli/test_cli_base.py +0 -1
- udata/tests/dataservice/test_dataservice_tasks.py +29 -0
- udata/tests/dataset/test_dataset_model.py +13 -1
- udata/tests/dataset/test_dataset_rdf.py +164 -5
- udata/tests/dataset/test_dataset_tasks.py +25 -0
- udata/tests/frontend/test_auth.py +58 -1
- udata/tests/frontend/test_csv.py +0 -3
- udata/tests/helpers.py +31 -27
- udata/tests/organization/test_notifications.py +67 -2
- udata/tests/search/test_search_integration.py +70 -0
- udata/tests/site/test_site_csv_exports.py +22 -10
- udata/tests/test_activity.py +9 -9
- udata/tests/test_api_fields.py +10 -0
- udata/tests/test_discussions.py +5 -5
- udata/tests/test_legal_mails.py +359 -0
- udata/tests/test_notifications.py +15 -57
- udata/tests/test_notifications_task.py +43 -0
- udata/tests/test_owned.py +81 -1
- udata/tests/test_transfer.py +181 -2
- udata/tests/test_uris.py +33 -0
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +309 -158
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +313 -160
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +312 -160
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +475 -202
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +317 -162
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +315 -161
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +323 -164
- udata/translations/udata.pot +169 -124
- udata/uris.py +0 -2
- udata/utils.py +23 -0
- udata-14.7.3.dev4.dist-info/METADATA +109 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +142 -135
- udata/core/post/forms.py +0 -30
- udata/flask_mongoengine/json.py +0 -38
- udata/templates/mail/base.html +0 -105
- udata/templates/mail/base.txt +0 -6
- udata/templates/mail/button.html +0 -3
- udata/templates/mail/layouts/1-column.html +0 -19
- udata/templates/mail/layouts/2-columns.html +0 -20
- udata/templates/mail/layouts/center-panel.html +0 -16
- udata-14.0.3.dev1.dist-info/METADATA +0 -132
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/top_level.txt +0 -0
udata/core/organization/api.py
CHANGED
|
@@ -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
|
|
394
|
+
if membership_request:
|
|
395
|
+
form.populate_obj(membership_request)
|
|
396
|
+
org.save()
|
|
397
|
+
else:
|
|
387
398
|
membership_request = MembershipRequest()
|
|
388
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
udata/core/organization/apiv2.py
CHANGED
|
@@ -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, **
|
|
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")
|
udata/core/organization/mails.py
CHANGED
|
@@ -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(
|
|
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)}
|
|
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"))
|
udata/core/organization/tasks.py
CHANGED
|
@@ -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
|
udata/core/pages/models.py
CHANGED
|
@@ -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)
|