udata 14.0.0__py3-none-any.whl → 14.4.1.dev7__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.
Potentially problematic release.
This version of udata might be problematic. Click here for more details.
- udata/api_fields.py +35 -4
- udata/app.py +18 -20
- udata/auth/__init__.py +29 -6
- udata/auth/forms.py +2 -2
- udata/auth/views.py +6 -3
- udata/commands/serve.py +3 -11
- udata/commands/tests/test_fixtures.py +9 -9
- udata/core/access_type/api.py +1 -1
- udata/core/access_type/constants.py +12 -8
- udata/core/activity/api.py +5 -6
- udata/core/badges/tests/test_commands.py +6 -6
- udata/core/csv.py +5 -0
- udata/core/dataservices/models.py +1 -1
- udata/core/dataservices/tasks.py +7 -0
- udata/core/dataset/api.py +2 -0
- udata/core/dataset/models.py +2 -2
- udata/core/dataset/permissions.py +31 -0
- udata/core/dataset/tasks.py +17 -5
- udata/core/discussions/models.py +1 -0
- udata/core/organization/api.py +8 -5
- udata/core/organization/mails.py +1 -1
- udata/core/organization/models.py +9 -1
- udata/core/organization/notifications.py +84 -0
- udata/core/organization/permissions.py +1 -1
- udata/core/organization/tasks.py +3 -0
- udata/core/pages/tests/test_api.py +32 -0
- udata/core/post/api.py +24 -69
- udata/core/post/models.py +84 -16
- udata/core/post/tests/test_api.py +24 -1
- udata/core/reports/api.py +18 -0
- udata/core/reports/models.py +42 -2
- udata/core/reuse/models.py +1 -1
- udata/core/reuse/tasks.py +7 -0
- udata/core/spatial/forms.py +2 -2
- udata/core/user/models.py +5 -1
- udata/features/notifications/api.py +7 -18
- udata/features/notifications/models.py +56 -0
- udata/features/notifications/tasks.py +25 -0
- udata/flask_mongoengine/engine.py +0 -4
- udata/frontend/markdown.py +2 -1
- udata/harvest/actions.py +21 -1
- udata/harvest/api.py +25 -8
- udata/harvest/backends/base.py +27 -1
- udata/harvest/backends/ckan/harvesters.py +11 -2
- 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 +58 -5
- udata/harvest/tests/test_api.py +276 -122
- udata/harvest/tests/test_base_backend.py +86 -1
- udata/harvest/tests/test_dcat_backend.py +57 -10
- udata/harvest/tests/test_filters.py +6 -0
- udata/i18n.py +1 -4
- udata/mail.py +5 -1
- 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/mongo/slug_fields.py +1 -1
- udata/rdf.py +45 -6
- udata/routing.py +2 -2
- udata/settings.py +7 -0
- udata/tasks.py +1 -0
- udata/templates/mail/message.html +5 -31
- udata/tests/__init__.py +27 -2
- udata/tests/api/__init__.py +108 -21
- udata/tests/api/test_activities_api.py +36 -0
- udata/tests/api/test_auth_api.py +121 -95
- udata/tests/api/test_base_api.py +7 -4
- udata/tests/api/test_datasets_api.py +44 -19
- udata/tests/api/test_organizations_api.py +192 -197
- udata/tests/api/test_reports_api.py +157 -0
- udata/tests/api/test_reuses_api.py +147 -147
- udata/tests/api/test_security_api.py +12 -12
- udata/tests/api/test_swagger.py +4 -4
- udata/tests/api/test_tags_api.py +8 -8
- udata/tests/api/test_user_api.py +1 -1
- udata/tests/apiv2/test_swagger.py +4 -4
- udata/tests/cli/test_cli_base.py +8 -9
- udata/tests/dataset/test_dataset_commands.py +4 -4
- udata/tests/dataset/test_dataset_model.py +66 -26
- udata/tests/dataset/test_dataset_rdf.py +99 -5
- udata/tests/frontend/test_auth.py +24 -1
- udata/tests/frontend/test_csv.py +0 -3
- udata/tests/helpers.py +25 -27
- udata/tests/organization/test_notifications.py +67 -2
- udata/tests/plugin.py +6 -261
- udata/tests/site/test_site_csv_exports.py +22 -10
- udata/tests/test_activity.py +9 -9
- udata/tests/test_dcat_commands.py +2 -2
- udata/tests/test_discussions.py +5 -5
- udata/tests/test_migrations.py +21 -21
- 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_storages.py +25 -19
- udata/tests/test_topics.py +77 -61
- udata/tests/test_uris.py +33 -0
- udata/tests/workers/test_jobs_commands.py +23 -23
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +187 -108
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +187 -108
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +187 -108
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +188 -109
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +187 -108
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +187 -108
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +187 -108
- udata/translations/udata.pot +215 -106
- udata/uris.py +0 -2
- udata-14.4.1.dev7.dist-info/METADATA +109 -0
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/RECORD +121 -123
- 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.0.dist-info/METADATA +0 -132
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/WHEEL +0 -0
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/entry_points.txt +0 -0
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/licenses/LICENSE +0 -0
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/top_level.txt +0 -0
udata/core/dataset/tasks.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import collections
|
|
2
|
+
import gzip
|
|
2
3
|
import os
|
|
3
4
|
from datetime import date, datetime
|
|
4
5
|
from tempfile import NamedTemporaryFile
|
|
@@ -15,6 +16,7 @@ from udata.core.dataservices.models import Dataservice
|
|
|
15
16
|
from udata.core.dataset.constants import INSPIRE
|
|
16
17
|
from udata.core.organization.constants import CERTIFIED, PUBLIC_SERVICE
|
|
17
18
|
from udata.core.organization.models import Organization
|
|
19
|
+
from udata.core.pages.models import Page
|
|
18
20
|
from udata.harvest.models import HarvestJob
|
|
19
21
|
from udata.models import Activity, Discussion, Follow, TopicElement, Transfer, db
|
|
20
22
|
from udata.storage.s3 import store_bytes
|
|
@@ -54,6 +56,12 @@ def purge_datasets(self):
|
|
|
54
56
|
dataservice.update(datasets=datasets)
|
|
55
57
|
# Remove HarvestItem references
|
|
56
58
|
HarvestJob.objects(items__dataset=dataset).update(set__items__S__dataset=None)
|
|
59
|
+
# Remove datasets in pages (mongoengine doesn't support updating a field in a generic embed)
|
|
60
|
+
Page._get_collection().update_many(
|
|
61
|
+
{"blocs.datasets": dataset.id},
|
|
62
|
+
{"$pull": {"blocs.$[b].datasets": dataset.id}},
|
|
63
|
+
array_filters=[{"b.datasets": dataset.id}],
|
|
64
|
+
)
|
|
57
65
|
# Remove associated Transfers
|
|
58
66
|
Transfer.objects(subject=dataset).delete()
|
|
59
67
|
# Remove each dataset's resource's file
|
|
@@ -87,8 +95,7 @@ def get_queryset(model_cls):
|
|
|
87
95
|
for attr in attrs:
|
|
88
96
|
if getattr(model_cls, attr, None):
|
|
89
97
|
params[attr] = False
|
|
90
|
-
|
|
91
|
-
return model_cls.objects.filter(**params).no_cache()
|
|
98
|
+
return model_cls.objects.filter(**params)
|
|
92
99
|
|
|
93
100
|
|
|
94
101
|
def get_resource_for_csv_export_model(model, dataset):
|
|
@@ -166,7 +173,12 @@ def export_csv_for_model(model, dataset, replace: bool = False):
|
|
|
166
173
|
dataset.save()
|
|
167
174
|
# remove previous catalog if exists and replace is True
|
|
168
175
|
if replace and fs_filename_to_remove:
|
|
169
|
-
|
|
176
|
+
try:
|
|
177
|
+
storages.resources.delete(fs_filename_to_remove)
|
|
178
|
+
except FileNotFoundError:
|
|
179
|
+
log.error(
|
|
180
|
+
f"File not found while deleting resource #{resource.id} ({fs_filename_to_remove}) in export_csv_for_model cleanup"
|
|
181
|
+
)
|
|
170
182
|
return resource
|
|
171
183
|
finally:
|
|
172
184
|
csvfile.close()
|
|
@@ -210,8 +222,8 @@ def export_csv(self, model=None):
|
|
|
210
222
|
with storages.resources.open(resource.fs_filename, "rb") as f:
|
|
211
223
|
store_bytes(
|
|
212
224
|
bucket=current_app.config["EXPORT_CSV_ARCHIVE_S3_BUCKET"],
|
|
213
|
-
filename=f"{current_app.config['EXPORT_CSV_ARCHIVE_S3_FILENAME_PREFIX']}{resource.title}",
|
|
214
|
-
bytes=f.read(),
|
|
225
|
+
filename=f"{current_app.config['EXPORT_CSV_ARCHIVE_S3_FILENAME_PREFIX']}{resource.title}.gz",
|
|
226
|
+
bytes=gzip.compress(f.read()),
|
|
215
227
|
)
|
|
216
228
|
|
|
217
229
|
|
udata/core/discussions/models.py
CHANGED
|
@@ -14,6 +14,7 @@ log = logging.getLogger(__name__)
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class Message(SpamMixin, db.EmbeddedDocument):
|
|
17
|
+
id = db.AutoUUIDField()
|
|
17
18
|
content = db.StringField(required=True)
|
|
18
19
|
posted_on = db.DateTimeField(default=datetime.utcnow, required=True)
|
|
19
20
|
posted_by = db.ReferenceField("User")
|
udata/core/organization/api.py
CHANGED
|
@@ -383,12 +383,13 @@ class MembershipRequestAPI(API):
|
|
|
383
383
|
|
|
384
384
|
form = api.validate(MembershipRequestForm, membership_request)
|
|
385
385
|
|
|
386
|
-
if
|
|
386
|
+
if membership_request:
|
|
387
|
+
form.populate_obj(membership_request)
|
|
388
|
+
org.save()
|
|
389
|
+
else:
|
|
387
390
|
membership_request = MembershipRequest()
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
form.populate_obj(membership_request)
|
|
391
|
-
org.save()
|
|
391
|
+
form.populate_obj(membership_request)
|
|
392
|
+
org.add_membership_request(membership_request)
|
|
392
393
|
|
|
393
394
|
notify_membership_request.delay(str(org.id), str(membership_request.id))
|
|
394
395
|
|
|
@@ -424,6 +425,7 @@ class MembershipAcceptAPI(MembershipAPI):
|
|
|
424
425
|
org.members.append(member)
|
|
425
426
|
org.count_members()
|
|
426
427
|
org.save()
|
|
428
|
+
MembershipRequest.after_handle.send(membership_request, org=org)
|
|
427
429
|
|
|
428
430
|
notify_membership_response.delay(str(org.id), str(membership_request.id))
|
|
429
431
|
|
|
@@ -446,6 +448,7 @@ class MembershipRefuseAPI(MembershipAPI):
|
|
|
446
448
|
membership_request.refusal_comment = form.comment.data
|
|
447
449
|
|
|
448
450
|
org.save()
|
|
451
|
+
MembershipRequest.after_handle.send(membership_request, org=org)
|
|
449
452
|
|
|
450
453
|
notify_membership_response.delay(str(org.id), str(membership_request.id))
|
|
451
454
|
|
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
|
|
|
@@ -81,6 +81,9 @@ class MembershipRequest(db.EmbeddedDocument):
|
|
|
81
81
|
comment = db.StringField()
|
|
82
82
|
refusal_comment = db.StringField()
|
|
83
83
|
|
|
84
|
+
after_create = Signal()
|
|
85
|
+
after_handle = Signal()
|
|
86
|
+
|
|
84
87
|
@property
|
|
85
88
|
def status_label(self):
|
|
86
89
|
return MEMBERSHIP_STATUS[self.status]
|
|
@@ -198,7 +201,7 @@ class Organization(
|
|
|
198
201
|
cls.before_save.send(document)
|
|
199
202
|
|
|
200
203
|
def self_web_url(self, **kwargs):
|
|
201
|
-
return cdata_url(f"/organizations/{self._link_id(**kwargs)}
|
|
204
|
+
return cdata_url(f"/organizations/{self._link_id(**kwargs)}", **kwargs)
|
|
202
205
|
|
|
203
206
|
def self_api_url(self, **kwargs):
|
|
204
207
|
return url_for(
|
|
@@ -304,6 +307,11 @@ class Organization(
|
|
|
304
307
|
def views_count(self):
|
|
305
308
|
return self.metrics.get("views", 0)
|
|
306
309
|
|
|
310
|
+
def add_membership_request(self, membership_request):
|
|
311
|
+
self.requests.append(membership_request)
|
|
312
|
+
self.save()
|
|
313
|
+
MembershipRequest.after_create.send(membership_request, org=self)
|
|
314
|
+
|
|
307
315
|
def count_members(self):
|
|
308
316
|
self.metrics["members"] = len(self.members)
|
|
309
317
|
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
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from flask import url_for
|
|
2
2
|
|
|
3
|
+
from udata.core.dataset import tasks
|
|
3
4
|
from udata.core.dataset.factories import DatasetFactory
|
|
4
5
|
from udata.core.pages.models import Page
|
|
6
|
+
from udata.core.user.factories import AdminFactory
|
|
5
7
|
from udata.tests.api import APITestCase
|
|
6
8
|
|
|
7
9
|
|
|
@@ -71,3 +73,33 @@ class PageAPITest(APITestCase):
|
|
|
71
73
|
self.assertEqual("more information", response.json["blocs"][0]["subtitle"])
|
|
72
74
|
self.assertEqual(len(response.json["blocs"][0]["datasets"]), 1)
|
|
73
75
|
self.assertEqual(str(datasets[2].id), response.json["blocs"][0]["datasets"][0]["id"])
|
|
76
|
+
|
|
77
|
+
def test_page_with_deleted_dataset(self):
|
|
78
|
+
self.login(AdminFactory())
|
|
79
|
+
datasets = DatasetFactory.create_batch(3)
|
|
80
|
+
|
|
81
|
+
response = self.post(
|
|
82
|
+
url_for("api.pages"),
|
|
83
|
+
{
|
|
84
|
+
"blocs": [
|
|
85
|
+
{
|
|
86
|
+
"class": "DatasetsListBloc",
|
|
87
|
+
"title": "My awesome title",
|
|
88
|
+
"datasets": [str(d.id) for d in datasets],
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
self.assert201(response)
|
|
94
|
+
page_id = response.json["id"]
|
|
95
|
+
|
|
96
|
+
response = self.delete(url_for("api.dataset", dataset=datasets[0].id))
|
|
97
|
+
self.assert204(response)
|
|
98
|
+
|
|
99
|
+
response = self.get(url_for("api.page", page=page_id))
|
|
100
|
+
self.assert200(response)
|
|
101
|
+
|
|
102
|
+
tasks.purge_datasets()
|
|
103
|
+
|
|
104
|
+
response = self.get(url_for("api.page", page=page_id))
|
|
105
|
+
self.assert200(response)
|
udata/core/post/api.py
CHANGED
|
@@ -2,70 +2,27 @@ from datetime import datetime
|
|
|
2
2
|
|
|
3
3
|
from feedgenerator.django.utils.feedgenerator import Atom1Feed
|
|
4
4
|
from flask import make_response, request
|
|
5
|
+
from flask_login import current_user
|
|
5
6
|
|
|
6
|
-
from udata.api import API, api
|
|
7
|
+
from udata.api import API, api
|
|
8
|
+
from udata.api_fields import patch, patch_and_save
|
|
7
9
|
from udata.auth import Permission as AdminPermission
|
|
8
10
|
from udata.auth import admin_permission
|
|
9
|
-
from udata.core.dataset.api_fields import dataset_fields
|
|
10
|
-
from udata.core.reuse.models import Reuse
|
|
11
11
|
from udata.core.storages.api import (
|
|
12
12
|
image_parser,
|
|
13
13
|
parse_uploaded_image,
|
|
14
14
|
uploaded_image_fields,
|
|
15
15
|
)
|
|
16
|
-
from udata.core.user.api_fields import user_ref_fields
|
|
17
16
|
from udata.frontend.markdown import md
|
|
18
17
|
from udata.i18n import gettext as _
|
|
19
18
|
|
|
20
|
-
from .forms import PostForm
|
|
21
19
|
from .models import Post
|
|
22
20
|
|
|
23
21
|
DEFAULT_SORTING = "-published"
|
|
24
22
|
|
|
25
23
|
ns = api.namespace("posts", "Posts related operations")
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
"Post",
|
|
29
|
-
{
|
|
30
|
-
"id": fields.String(description="The post identifier"),
|
|
31
|
-
"name": fields.String(description="The post name", required=True),
|
|
32
|
-
"slug": fields.String(description="The post permalink string", readonly=True),
|
|
33
|
-
"headline": fields.String(description="The post headline", required=True),
|
|
34
|
-
"content": fields.Markdown(description="The post content in Markdown", required=True),
|
|
35
|
-
"image": fields.ImageField(description="The post image", readonly=True),
|
|
36
|
-
"credit_to": fields.String(description="An optional credit line (associated to the image)"),
|
|
37
|
-
"credit_url": fields.String(description="An optional link associated to the credits"),
|
|
38
|
-
"tags": fields.List(fields.String, description="Some keywords to help in search"),
|
|
39
|
-
"datasets": fields.List(fields.Nested(dataset_fields), description="The post datasets"),
|
|
40
|
-
"reuses": fields.List(fields.Nested(Reuse.__read_fields__), description="The post reuses"),
|
|
41
|
-
"owner": fields.Nested(
|
|
42
|
-
user_ref_fields, description="The owner user", readonly=True, allow_null=True
|
|
43
|
-
),
|
|
44
|
-
"created_at": fields.ISODateTime(description="The post creation date", readonly=True),
|
|
45
|
-
"last_modified": fields.ISODateTime(
|
|
46
|
-
description="The post last modification date", readonly=True
|
|
47
|
-
),
|
|
48
|
-
"published": fields.ISODateTime(description="The post publication date", readonly=True),
|
|
49
|
-
"body_type": fields.String(description="HTML or markdown body type", default="markdown"),
|
|
50
|
-
"uri": fields.String(
|
|
51
|
-
attribute=lambda p: p.self_api_url(),
|
|
52
|
-
description="The API URI for this post",
|
|
53
|
-
readonly=True,
|
|
54
|
-
),
|
|
55
|
-
"page": fields.String(
|
|
56
|
-
attribute=lambda p: p.self_web_url(),
|
|
57
|
-
description="The post web page URL",
|
|
58
|
-
readonly=True,
|
|
59
|
-
),
|
|
60
|
-
},
|
|
61
|
-
mask="*,datasets{id,title,acronym,uri,page},reuses{id,title,image,image_thumbnail,uri,page}",
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
post_page_fields = api.model("PostPage", fields.pager(post_fields))
|
|
65
|
-
|
|
66
|
-
parser = api.page_parser()
|
|
67
|
-
|
|
68
|
-
parser.add_argument("sort", type=str, location="args", help="The sorting attribute")
|
|
25
|
+
parser = Post.__index_parser__
|
|
69
26
|
parser.add_argument(
|
|
70
27
|
"with_drafts",
|
|
71
28
|
type=bool,
|
|
@@ -73,16 +30,13 @@ parser.add_argument(
|
|
|
73
30
|
location="args",
|
|
74
31
|
help="`True` also returns the unpublished posts (only for super-admins)",
|
|
75
32
|
)
|
|
76
|
-
parser.add_argument(
|
|
77
|
-
"q", type=str, location="args", help="query string to search through resources titles"
|
|
78
|
-
)
|
|
79
33
|
|
|
80
34
|
|
|
81
35
|
@ns.route("/", endpoint="posts")
|
|
82
36
|
class PostsAPI(API):
|
|
83
37
|
@api.doc("list_posts")
|
|
84
38
|
@api.expect(parser)
|
|
85
|
-
@api.marshal_with(
|
|
39
|
+
@api.marshal_with(Post.__page_fields__)
|
|
86
40
|
def get(self):
|
|
87
41
|
"""List all posts"""
|
|
88
42
|
args = parser.parse_args()
|
|
@@ -92,22 +46,23 @@ class PostsAPI(API):
|
|
|
92
46
|
if not (AdminPermission().can() and args["with_drafts"]):
|
|
93
47
|
posts = posts.published()
|
|
94
48
|
|
|
95
|
-
if
|
|
96
|
-
|
|
97
|
-
posts = posts.search_text(phrase_query)
|
|
98
|
-
|
|
99
|
-
sort = args["sort"] or ("$text_score" if args["q"] else None) or DEFAULT_SORTING
|
|
100
|
-
return posts.order_by(sort).paginate(args["page"], args["page_size"])
|
|
49
|
+
# The search is already handled by apply_sort_filters if searchable=True
|
|
50
|
+
return Post.apply_pagination(Post.apply_sort_filters(posts))
|
|
101
51
|
|
|
102
52
|
@api.doc("create_post")
|
|
103
53
|
@api.secure(admin_permission)
|
|
104
|
-
@api.expect(
|
|
105
|
-
@api.marshal_with(
|
|
54
|
+
@api.expect(Post.__write_fields__)
|
|
55
|
+
@api.marshal_with(Post.__read_fields__)
|
|
106
56
|
@api.response(400, "Validation error")
|
|
107
57
|
def post(self):
|
|
108
58
|
"""Create a post"""
|
|
109
|
-
|
|
110
|
-
|
|
59
|
+
post = patch(Post(), request)
|
|
60
|
+
|
|
61
|
+
if not post.owner:
|
|
62
|
+
post.owner = current_user._get_current_object()
|
|
63
|
+
|
|
64
|
+
post.save()
|
|
65
|
+
return post, 201
|
|
111
66
|
|
|
112
67
|
|
|
113
68
|
@ns.route("/recent.atom", endpoint="recent_posts_atom_feed")
|
|
@@ -143,20 +98,20 @@ class PostsAtomFeedAPI(API):
|
|
|
143
98
|
@api.param("post", "The post ID or slug")
|
|
144
99
|
class PostAPI(API):
|
|
145
100
|
@api.doc("get_post")
|
|
146
|
-
@api.marshal_with(
|
|
101
|
+
@api.marshal_with(Post.__read_fields__)
|
|
147
102
|
def get(self, post):
|
|
148
103
|
"""Get a given post"""
|
|
149
104
|
return post
|
|
150
105
|
|
|
151
106
|
@api.doc("update_post")
|
|
152
107
|
@api.secure(admin_permission)
|
|
153
|
-
@api.expect(
|
|
154
|
-
@api.marshal_with(
|
|
108
|
+
@api.expect(Post.__write_fields__)
|
|
109
|
+
@api.marshal_with(Post.__read_fields__)
|
|
155
110
|
@api.response(400, "Validation error")
|
|
156
111
|
def put(self, post):
|
|
157
112
|
"""Update a given post"""
|
|
158
|
-
|
|
159
|
-
return
|
|
113
|
+
post = patch_and_save(post, request)
|
|
114
|
+
return post
|
|
160
115
|
|
|
161
116
|
@api.secure(admin_permission)
|
|
162
117
|
@api.doc("delete_post")
|
|
@@ -171,7 +126,7 @@ class PostAPI(API):
|
|
|
171
126
|
class PublishPostAPI(API):
|
|
172
127
|
@api.secure(admin_permission)
|
|
173
128
|
@api.doc("publish_post")
|
|
174
|
-
@api.marshal_with(
|
|
129
|
+
@api.marshal_with(Post.__read_fields__)
|
|
175
130
|
def post(self, post):
|
|
176
131
|
"""Publish an existing post"""
|
|
177
132
|
post.modify(published=datetime.utcnow())
|
|
@@ -179,9 +134,9 @@ class PublishPostAPI(API):
|
|
|
179
134
|
|
|
180
135
|
@api.secure(admin_permission)
|
|
181
136
|
@api.doc("unpublish_post")
|
|
182
|
-
@api.marshal_with(
|
|
137
|
+
@api.marshal_with(Post.__read_fields__)
|
|
183
138
|
def delete(self, post):
|
|
184
|
-
"""
|
|
139
|
+
"""Unpublish an existing post"""
|
|
185
140
|
post.modify(published=None)
|
|
186
141
|
return post
|
|
187
142
|
|
udata/core/post/models.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from flask import url_for
|
|
2
2
|
|
|
3
|
+
from udata.api_fields import field, generate_fields
|
|
4
|
+
from udata.core.dataset.api_fields import dataset_fields
|
|
3
5
|
from udata.core.linkable import Linkable
|
|
4
6
|
from udata.core.storages import default_image_basename, images
|
|
5
7
|
from udata.i18n import lazy_gettext as _
|
|
@@ -16,27 +18,85 @@ class PostQuerySet(db.BaseQuerySet):
|
|
|
16
18
|
return self(published__ne=None).order_by("-published")
|
|
17
19
|
|
|
18
20
|
|
|
21
|
+
@generate_fields(
|
|
22
|
+
searchable=True,
|
|
23
|
+
additional_sorts=[
|
|
24
|
+
{"key": "created_at", "value": "created_at"},
|
|
25
|
+
{"key": "modified", "value": "last_modified"},
|
|
26
|
+
],
|
|
27
|
+
default_sort="-published",
|
|
28
|
+
)
|
|
19
29
|
class Post(db.Datetimed, Linkable, db.Document):
|
|
20
|
-
name =
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
name = field(
|
|
31
|
+
db.StringField(max_length=255, required=True),
|
|
32
|
+
sortable=True,
|
|
33
|
+
show_as_ref=True,
|
|
34
|
+
)
|
|
35
|
+
slug = field(
|
|
36
|
+
db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
|
|
37
|
+
readonly=True,
|
|
38
|
+
)
|
|
39
|
+
headline = field(
|
|
40
|
+
db.StringField(),
|
|
41
|
+
sortable=True,
|
|
42
|
+
)
|
|
43
|
+
content = field(
|
|
44
|
+
db.StringField(required=True),
|
|
45
|
+
markdown=True,
|
|
46
|
+
)
|
|
47
|
+
image_url = field(
|
|
48
|
+
db.StringField(),
|
|
49
|
+
)
|
|
50
|
+
image = field(
|
|
51
|
+
db.ImageField(fs=images, basename=default_image_basename, thumbnails=IMAGE_SIZES),
|
|
52
|
+
readonly=True,
|
|
53
|
+
thumbnail_info={"size": 100},
|
|
23
54
|
)
|
|
24
|
-
headline = db.StringField()
|
|
25
|
-
content = db.StringField(required=True)
|
|
26
|
-
image_url = db.StringField()
|
|
27
|
-
image = db.ImageField(fs=images, basename=default_image_basename, thumbnails=IMAGE_SIZES)
|
|
28
55
|
|
|
29
|
-
credit_to =
|
|
30
|
-
|
|
56
|
+
credit_to = field(
|
|
57
|
+
db.StringField(),
|
|
58
|
+
description="An optional credit line (associated to the image)",
|
|
59
|
+
)
|
|
60
|
+
credit_url = field(
|
|
61
|
+
db.URLField(),
|
|
62
|
+
description="An optional link associated to the credits",
|
|
63
|
+
)
|
|
31
64
|
|
|
32
|
-
tags =
|
|
33
|
-
|
|
34
|
-
|
|
65
|
+
tags = field(
|
|
66
|
+
db.ListField(db.StringField()),
|
|
67
|
+
description="Some keywords to help in search",
|
|
68
|
+
)
|
|
69
|
+
datasets = field(
|
|
70
|
+
db.ListField(
|
|
71
|
+
field(
|
|
72
|
+
db.ReferenceField("Dataset", reverse_delete_rule=db.PULL),
|
|
73
|
+
nested_fields=dataset_fields,
|
|
74
|
+
)
|
|
75
|
+
),
|
|
76
|
+
description="The post datasets",
|
|
77
|
+
)
|
|
78
|
+
reuses = field(
|
|
79
|
+
db.ListField(db.ReferenceField("Reuse", reverse_delete_rule=db.PULL)),
|
|
80
|
+
description="The post reuses",
|
|
81
|
+
)
|
|
35
82
|
|
|
36
|
-
owner =
|
|
37
|
-
|
|
83
|
+
owner = field(
|
|
84
|
+
db.ReferenceField("User"),
|
|
85
|
+
readonly=True,
|
|
86
|
+
allow_null=True,
|
|
87
|
+
description="The owner user",
|
|
88
|
+
)
|
|
89
|
+
published = field(
|
|
90
|
+
db.DateTimeField(),
|
|
91
|
+
readonly=True,
|
|
92
|
+
sortable=True,
|
|
93
|
+
description="The post publication date",
|
|
94
|
+
)
|
|
38
95
|
|
|
39
|
-
body_type =
|
|
96
|
+
body_type = field(
|
|
97
|
+
db.StringField(choices=list(BODY_TYPES), default="markdown", required=False),
|
|
98
|
+
description="HTML or markdown body type",
|
|
99
|
+
)
|
|
40
100
|
|
|
41
101
|
meta = {
|
|
42
102
|
"ordering": ["-created_at"],
|
|
@@ -58,13 +118,21 @@ class Post(db.Datetimed, Linkable, db.Document):
|
|
|
58
118
|
return self.name or ""
|
|
59
119
|
|
|
60
120
|
def self_web_url(self, **kwargs):
|
|
61
|
-
return cdata_url(f"/posts/{self._link_id(**kwargs)}
|
|
121
|
+
return cdata_url(f"/posts/{self._link_id(**kwargs)}", **kwargs)
|
|
62
122
|
|
|
63
123
|
def self_api_url(self, **kwargs):
|
|
64
124
|
return url_for(
|
|
65
125
|
"api.post", post=self._link_id(**kwargs), **self._self_api_url_kwargs(**kwargs)
|
|
66
126
|
)
|
|
67
127
|
|
|
128
|
+
@field(description="The API URI for this post")
|
|
129
|
+
def uri(self):
|
|
130
|
+
return self.self_api_url()
|
|
131
|
+
|
|
132
|
+
@field(description="The post web page URL")
|
|
133
|
+
def page(self):
|
|
134
|
+
return self.self_web_url()
|
|
135
|
+
|
|
68
136
|
def count_discussions(self):
|
|
69
137
|
# There are no metrics on Post to store discussions count
|
|
70
138
|
pass
|
|
@@ -4,7 +4,7 @@ from udata.core.dataset.factories import DatasetFactory
|
|
|
4
4
|
from udata.core.post.factories import PostFactory
|
|
5
5
|
from udata.core.post.models import Post
|
|
6
6
|
from udata.core.reuse.factories import ReuseFactory
|
|
7
|
-
from udata.core.user.factories import AdminFactory
|
|
7
|
+
from udata.core.user.factories import AdminFactory, UserFactory
|
|
8
8
|
from udata.tests.api import APITestCase
|
|
9
9
|
from udata.tests.helpers import assert200, assert201, assert204
|
|
10
10
|
|
|
@@ -136,3 +136,26 @@ class PostsAPITest(APITestCase):
|
|
|
136
136
|
|
|
137
137
|
post.reload()
|
|
138
138
|
assert post.published is None
|
|
139
|
+
|
|
140
|
+
def test_post_api_create_with_empty_credit_url(self):
|
|
141
|
+
"""It should create a post with an empty credit_url (converted to None)"""
|
|
142
|
+
data = PostFactory.as_dict()
|
|
143
|
+
data["datasets"] = [str(d.id) for d in data["datasets"]]
|
|
144
|
+
data["reuses"] = [str(r.id) for r in data["reuses"]]
|
|
145
|
+
data["credit_url"] = ""
|
|
146
|
+
self.login(AdminFactory())
|
|
147
|
+
response = self.post(url_for("api.posts"), data)
|
|
148
|
+
assert201(response)
|
|
149
|
+
assert Post.objects.count() == 1
|
|
150
|
+
post = Post.objects.first()
|
|
151
|
+
assert post.credit_url is None
|
|
152
|
+
|
|
153
|
+
def test_post_api_list_with_drafts_non_admin(self):
|
|
154
|
+
"""Non-admin users should not see drafts even with with_drafts=True"""
|
|
155
|
+
PostFactory.create_batch(3)
|
|
156
|
+
PostFactory(published=None)
|
|
157
|
+
|
|
158
|
+
self.login(UserFactory())
|
|
159
|
+
response = self.get(url_for("api.posts", with_drafts=True))
|
|
160
|
+
assert200(response)
|
|
161
|
+
assert len(response.json["data"]) == 3
|
udata/core/reports/api.py
CHANGED
|
@@ -42,6 +42,24 @@ class ReportAPI(API):
|
|
|
42
42
|
def get(self, report):
|
|
43
43
|
return report
|
|
44
44
|
|
|
45
|
+
@api.doc("update_report", responses={400: "Validation error"})
|
|
46
|
+
@api.secure(admin_permission)
|
|
47
|
+
@api.expect(Report.__write_fields__)
|
|
48
|
+
@api.marshal_with(Report.__read_fields__, code=200)
|
|
49
|
+
def patch(self, report):
|
|
50
|
+
dismiss_has_changed = (
|
|
51
|
+
"dismissed_at" in request.json and request.json["dismissed_at"] != report.dismissed_at
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
report = patch(report, request)
|
|
55
|
+
if dismiss_has_changed:
|
|
56
|
+
report.dismissed_by = (
|
|
57
|
+
current_user._get_current_object() if report.dismissed_at else None
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
report.save()
|
|
61
|
+
return report, 200
|
|
62
|
+
|
|
45
63
|
|
|
46
64
|
@ns.route("/reasons/", endpoint="reports_reasons")
|
|
47
65
|
class ReportsReasonsAPI(API):
|