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.

Files changed (130) hide show
  1. udata/api_fields.py +35 -4
  2. udata/app.py +18 -20
  3. udata/auth/__init__.py +29 -6
  4. udata/auth/forms.py +2 -2
  5. udata/auth/views.py +6 -3
  6. udata/commands/serve.py +3 -11
  7. udata/commands/tests/test_fixtures.py +9 -9
  8. udata/core/access_type/api.py +1 -1
  9. udata/core/access_type/constants.py +12 -8
  10. udata/core/activity/api.py +5 -6
  11. udata/core/badges/tests/test_commands.py +6 -6
  12. udata/core/csv.py +5 -0
  13. udata/core/dataservices/models.py +1 -1
  14. udata/core/dataservices/tasks.py +7 -0
  15. udata/core/dataset/api.py +2 -0
  16. udata/core/dataset/models.py +2 -2
  17. udata/core/dataset/permissions.py +31 -0
  18. udata/core/dataset/tasks.py +17 -5
  19. udata/core/discussions/models.py +1 -0
  20. udata/core/organization/api.py +8 -5
  21. udata/core/organization/mails.py +1 -1
  22. udata/core/organization/models.py +9 -1
  23. udata/core/organization/notifications.py +84 -0
  24. udata/core/organization/permissions.py +1 -1
  25. udata/core/organization/tasks.py +3 -0
  26. udata/core/pages/tests/test_api.py +32 -0
  27. udata/core/post/api.py +24 -69
  28. udata/core/post/models.py +84 -16
  29. udata/core/post/tests/test_api.py +24 -1
  30. udata/core/reports/api.py +18 -0
  31. udata/core/reports/models.py +42 -2
  32. udata/core/reuse/models.py +1 -1
  33. udata/core/reuse/tasks.py +7 -0
  34. udata/core/spatial/forms.py +2 -2
  35. udata/core/user/models.py +5 -1
  36. udata/features/notifications/api.py +7 -18
  37. udata/features/notifications/models.py +56 -0
  38. udata/features/notifications/tasks.py +25 -0
  39. udata/flask_mongoengine/engine.py +0 -4
  40. udata/frontend/markdown.py +2 -1
  41. udata/harvest/actions.py +21 -1
  42. udata/harvest/api.py +25 -8
  43. udata/harvest/backends/base.py +27 -1
  44. udata/harvest/backends/ckan/harvesters.py +11 -2
  45. udata/harvest/commands.py +33 -0
  46. udata/harvest/filters.py +17 -6
  47. udata/harvest/models.py +16 -0
  48. udata/harvest/permissions.py +27 -0
  49. udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
  50. udata/harvest/tests/test_actions.py +58 -5
  51. udata/harvest/tests/test_api.py +276 -122
  52. udata/harvest/tests/test_base_backend.py +86 -1
  53. udata/harvest/tests/test_dcat_backend.py +57 -10
  54. udata/harvest/tests/test_filters.py +6 -0
  55. udata/i18n.py +1 -4
  56. udata/mail.py +5 -1
  57. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  58. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  59. udata/mongo/slug_fields.py +1 -1
  60. udata/rdf.py +45 -6
  61. udata/routing.py +2 -2
  62. udata/settings.py +7 -0
  63. udata/tasks.py +1 -0
  64. udata/templates/mail/message.html +5 -31
  65. udata/tests/__init__.py +27 -2
  66. udata/tests/api/__init__.py +108 -21
  67. udata/tests/api/test_activities_api.py +36 -0
  68. udata/tests/api/test_auth_api.py +121 -95
  69. udata/tests/api/test_base_api.py +7 -4
  70. udata/tests/api/test_datasets_api.py +44 -19
  71. udata/tests/api/test_organizations_api.py +192 -197
  72. udata/tests/api/test_reports_api.py +157 -0
  73. udata/tests/api/test_reuses_api.py +147 -147
  74. udata/tests/api/test_security_api.py +12 -12
  75. udata/tests/api/test_swagger.py +4 -4
  76. udata/tests/api/test_tags_api.py +8 -8
  77. udata/tests/api/test_user_api.py +1 -1
  78. udata/tests/apiv2/test_swagger.py +4 -4
  79. udata/tests/cli/test_cli_base.py +8 -9
  80. udata/tests/dataset/test_dataset_commands.py +4 -4
  81. udata/tests/dataset/test_dataset_model.py +66 -26
  82. udata/tests/dataset/test_dataset_rdf.py +99 -5
  83. udata/tests/frontend/test_auth.py +24 -1
  84. udata/tests/frontend/test_csv.py +0 -3
  85. udata/tests/helpers.py +25 -27
  86. udata/tests/organization/test_notifications.py +67 -2
  87. udata/tests/plugin.py +6 -261
  88. udata/tests/site/test_site_csv_exports.py +22 -10
  89. udata/tests/test_activity.py +9 -9
  90. udata/tests/test_dcat_commands.py +2 -2
  91. udata/tests/test_discussions.py +5 -5
  92. udata/tests/test_migrations.py +21 -21
  93. udata/tests/test_notifications.py +15 -57
  94. udata/tests/test_notifications_task.py +43 -0
  95. udata/tests/test_owned.py +81 -1
  96. udata/tests/test_storages.py +25 -19
  97. udata/tests/test_topics.py +77 -61
  98. udata/tests/test_uris.py +33 -0
  99. udata/tests/workers/test_jobs_commands.py +23 -23
  100. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  101. udata/translations/ar/LC_MESSAGES/udata.po +187 -108
  102. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  103. udata/translations/de/LC_MESSAGES/udata.po +187 -108
  104. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  105. udata/translations/es/LC_MESSAGES/udata.po +187 -108
  106. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  107. udata/translations/fr/LC_MESSAGES/udata.po +188 -109
  108. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  109. udata/translations/it/LC_MESSAGES/udata.po +187 -108
  110. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  111. udata/translations/pt/LC_MESSAGES/udata.po +187 -108
  112. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  113. udata/translations/sr/LC_MESSAGES/udata.po +187 -108
  114. udata/translations/udata.pot +215 -106
  115. udata/uris.py +0 -2
  116. udata-14.4.1.dev7.dist-info/METADATA +109 -0
  117. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/RECORD +121 -123
  118. udata/core/post/forms.py +0 -30
  119. udata/flask_mongoengine/json.py +0 -38
  120. udata/templates/mail/base.html +0 -105
  121. udata/templates/mail/base.txt +0 -6
  122. udata/templates/mail/button.html +0 -3
  123. udata/templates/mail/layouts/1-column.html +0 -19
  124. udata/templates/mail/layouts/2-columns.html +0 -20
  125. udata/templates/mail/layouts/center-panel.html +0 -16
  126. udata-14.0.0.dist-info/METADATA +0 -132
  127. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/WHEEL +0 -0
  128. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/entry_points.txt +0 -0
  129. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/licenses/LICENSE +0 -0
  130. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/top_level.txt +0 -0
@@ -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
- # no_cache to avoid eating up too much RAM
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
- storages.resources.delete(fs_filename_to_remove)
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
 
@@ -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")
@@ -383,12 +383,13 @@ class MembershipRequestAPI(API):
383
383
 
384
384
  form = api.validate(MembershipRequestForm, membership_request)
385
385
 
386
- if not membership_request:
386
+ if membership_request:
387
+ form.populate_obj(membership_request)
388
+ org.save()
389
+ else:
387
390
  membership_request = MembershipRequest()
388
- org.requests.append(membership_request)
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
 
@@ -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)}/", **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"))
@@ -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, fields
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
- post_fields = api.model(
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(post_page_fields)
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 args["q"]:
96
- phrase_query = " ".join([f'"{elem}"' for elem in args["q"].split(" ")])
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(post_fields)
105
- @api.marshal_with(post_fields)
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
- form = api.validate(PostForm)
110
- return form.save(), 201
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(post_fields)
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(post_fields)
154
- @api.marshal_with(post_fields)
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
- form = api.validate(PostForm, post)
159
- return form.save()
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(post_fields)
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(post_fields)
137
+ @api.marshal_with(Post.__read_fields__)
183
138
  def delete(self, post):
184
- """Publish an existing post"""
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 = db.StringField(max_length=255, required=True)
21
- slug = db.SlugField(
22
- max_length=255, required=True, populate_from="name", update=True, follow=True
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 = db.StringField()
30
- credit_url = db.URLField()
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 = db.ListField(db.StringField())
33
- datasets = db.ListField(db.ReferenceField("Dataset", reverse_delete_rule=db.PULL))
34
- reuses = db.ListField(db.ReferenceField("Reuse", reverse_delete_rule=db.PULL))
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 = db.ReferenceField("User")
37
- published = db.DateTimeField()
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 = db.StringField(choices=list(BODY_TYPES), default="markdown", required=False)
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)}/", **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):