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/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")
|
|
@@ -121,7 +76,7 @@ class PostsAtomFeedAPI(API):
|
|
|
121
76
|
link=request.url_root,
|
|
122
77
|
)
|
|
123
78
|
|
|
124
|
-
posts: list[Post] = Post.objects().published().order_by("-published").limit(15)
|
|
79
|
+
posts: list[Post] = Post.objects(kind="news").published().order_by("-published").limit(15)
|
|
125
80
|
for post in posts:
|
|
126
81
|
feed.add_item(
|
|
127
82
|
post.name,
|
|
@@ -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/constants.py
CHANGED
udata/core/post/models.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
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
|
|
6
|
+
from udata.core.pages.models import Page
|
|
4
7
|
from udata.core.storages import default_image_basename, images
|
|
8
|
+
from udata.core.user.api_fields import user_ref_fields
|
|
5
9
|
from udata.i18n import lazy_gettext as _
|
|
6
10
|
from udata.mongo import db
|
|
7
11
|
from udata.uris import cdata_url
|
|
8
12
|
|
|
9
|
-
from .constants import BODY_TYPES, IMAGE_SIZES
|
|
13
|
+
from .constants import BODY_TYPES, IMAGE_SIZES, POST_KINDS
|
|
10
14
|
|
|
11
15
|
__all__ = ("Post",)
|
|
12
16
|
|
|
@@ -16,27 +20,97 @@ class PostQuerySet(db.BaseQuerySet):
|
|
|
16
20
|
return self(published__ne=None).order_by("-published")
|
|
17
21
|
|
|
18
22
|
|
|
23
|
+
@generate_fields(
|
|
24
|
+
searchable=True,
|
|
25
|
+
additional_sorts=[
|
|
26
|
+
{"key": "created_at", "value": "created_at"},
|
|
27
|
+
{"key": "modified", "value": "last_modified"},
|
|
28
|
+
],
|
|
29
|
+
default_sort="-published",
|
|
30
|
+
)
|
|
19
31
|
class Post(db.Datetimed, Linkable, db.Document):
|
|
20
|
-
name =
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
name = field(
|
|
33
|
+
db.StringField(max_length=255, required=True),
|
|
34
|
+
sortable=True,
|
|
35
|
+
show_as_ref=True,
|
|
36
|
+
)
|
|
37
|
+
slug = field(
|
|
38
|
+
db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
|
|
39
|
+
readonly=True,
|
|
40
|
+
)
|
|
41
|
+
headline = field(
|
|
42
|
+
db.StringField(),
|
|
43
|
+
sortable=True,
|
|
44
|
+
)
|
|
45
|
+
content = field(
|
|
46
|
+
db.StringField(),
|
|
47
|
+
markdown=True,
|
|
48
|
+
)
|
|
49
|
+
content_as_page = field(
|
|
50
|
+
db.ReferenceField("Page", reverse_delete_rule=db.DENY),
|
|
51
|
+
nested_fields=Page.__read_fields__,
|
|
52
|
+
allow_null=True,
|
|
53
|
+
description="Reference to a Page when body_type is 'blocs'",
|
|
54
|
+
)
|
|
55
|
+
image_url = field(
|
|
56
|
+
db.StringField(),
|
|
57
|
+
)
|
|
58
|
+
image = field(
|
|
59
|
+
db.ImageField(fs=images, basename=default_image_basename, thumbnails=IMAGE_SIZES),
|
|
60
|
+
readonly=True,
|
|
61
|
+
thumbnail_info={"size": 100},
|
|
23
62
|
)
|
|
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
63
|
|
|
29
|
-
credit_to =
|
|
30
|
-
|
|
64
|
+
credit_to = field(
|
|
65
|
+
db.StringField(),
|
|
66
|
+
description="An optional credit line (associated to the image)",
|
|
67
|
+
)
|
|
68
|
+
credit_url = field(
|
|
69
|
+
db.URLField(),
|
|
70
|
+
description="An optional link associated to the credits",
|
|
71
|
+
)
|
|
31
72
|
|
|
32
|
-
tags =
|
|
33
|
-
|
|
34
|
-
|
|
73
|
+
tags = field(
|
|
74
|
+
db.ListField(db.StringField()),
|
|
75
|
+
description="Some keywords to help in search",
|
|
76
|
+
)
|
|
77
|
+
datasets = field(
|
|
78
|
+
db.ListField(
|
|
79
|
+
field(
|
|
80
|
+
db.ReferenceField("Dataset", reverse_delete_rule=db.PULL),
|
|
81
|
+
nested_fields=dataset_fields,
|
|
82
|
+
)
|
|
83
|
+
),
|
|
84
|
+
description="The post datasets",
|
|
85
|
+
)
|
|
86
|
+
reuses = field(
|
|
87
|
+
db.ListField(db.ReferenceField("Reuse", reverse_delete_rule=db.PULL)),
|
|
88
|
+
description="The post reuses",
|
|
89
|
+
)
|
|
35
90
|
|
|
36
|
-
owner =
|
|
37
|
-
|
|
91
|
+
owner = field(
|
|
92
|
+
db.ReferenceField("User"),
|
|
93
|
+
nested_fields=user_ref_fields,
|
|
94
|
+
readonly=True,
|
|
95
|
+
allow_null=True,
|
|
96
|
+
description="The owner user",
|
|
97
|
+
)
|
|
98
|
+
published = field(
|
|
99
|
+
db.DateTimeField(),
|
|
100
|
+
readonly=True,
|
|
101
|
+
sortable=True,
|
|
102
|
+
description="The post publication date",
|
|
103
|
+
)
|
|
38
104
|
|
|
39
|
-
body_type =
|
|
105
|
+
body_type = field(
|
|
106
|
+
db.StringField(choices=list(BODY_TYPES), default="markdown", required=False),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
kind = field(
|
|
110
|
+
db.StringField(choices=list(POST_KINDS), default="news", required=False),
|
|
111
|
+
filterable={},
|
|
112
|
+
description="Post kind (news or page)",
|
|
113
|
+
)
|
|
40
114
|
|
|
41
115
|
meta = {
|
|
42
116
|
"ordering": ["-created_at"],
|
|
@@ -54,17 +128,35 @@ class Post(db.Datetimed, Linkable, db.Document):
|
|
|
54
128
|
|
|
55
129
|
verbose_name = _("post")
|
|
56
130
|
|
|
131
|
+
def clean(self):
|
|
132
|
+
if self.body_type == "blocs":
|
|
133
|
+
if not self.content_as_page:
|
|
134
|
+
raise db.ValidationError("content_as_page is required when body_type is 'blocs'")
|
|
135
|
+
else:
|
|
136
|
+
if not self.content:
|
|
137
|
+
raise db.ValidationError(
|
|
138
|
+
"content is required when body_type is 'markdown' or 'html'"
|
|
139
|
+
)
|
|
140
|
+
|
|
57
141
|
def __str__(self):
|
|
58
142
|
return self.name or ""
|
|
59
143
|
|
|
60
144
|
def self_web_url(self, **kwargs):
|
|
61
|
-
return cdata_url(f"/posts/{self._link_id(**kwargs)}
|
|
145
|
+
return cdata_url(f"/posts/{self._link_id(**kwargs)}", **kwargs)
|
|
62
146
|
|
|
63
147
|
def self_api_url(self, **kwargs):
|
|
64
148
|
return url_for(
|
|
65
149
|
"api.post", post=self._link_id(**kwargs), **self._self_api_url_kwargs(**kwargs)
|
|
66
150
|
)
|
|
67
151
|
|
|
152
|
+
@field(description="The API URI for this post")
|
|
153
|
+
def uri(self):
|
|
154
|
+
return self.self_api_url()
|
|
155
|
+
|
|
156
|
+
@field(description="The post web page URL")
|
|
157
|
+
def page(self):
|
|
158
|
+
return self.self_web_url()
|
|
159
|
+
|
|
68
160
|
def count_discussions(self):
|
|
69
161
|
# There are no metrics on Post to store discussions count
|
|
70
162
|
pass
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
from flask import url_for
|
|
2
2
|
|
|
3
3
|
from udata.core.dataset.factories import DatasetFactory
|
|
4
|
+
from udata.core.pages.factories import PageFactory
|
|
5
|
+
from udata.core.pages.models import DatasetsListBloc
|
|
4
6
|
from udata.core.post.factories import PostFactory
|
|
5
7
|
from udata.core.post.models import Post
|
|
6
8
|
from udata.core.reuse.factories import ReuseFactory
|
|
7
|
-
from udata.core.user.factories import AdminFactory
|
|
9
|
+
from udata.core.user.factories import AdminFactory, UserFactory
|
|
8
10
|
from udata.tests.api import APITestCase
|
|
9
|
-
from udata.tests.helpers import assert200, assert201, assert204
|
|
11
|
+
from udata.tests.helpers import assert200, assert201, assert204, assert400
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class PostsAPITest(APITestCase):
|
|
@@ -56,9 +58,13 @@ class PostsAPITest(APITestCase):
|
|
|
56
58
|
|
|
57
59
|
def test_post_api_get(self):
|
|
58
60
|
"""It should fetch a post from the API"""
|
|
59
|
-
|
|
61
|
+
admin = AdminFactory()
|
|
62
|
+
post = PostFactory(owner=admin)
|
|
60
63
|
response = self.get(url_for("api.post", post=post))
|
|
61
64
|
assert200(response)
|
|
65
|
+
owner = response.json["owner"]
|
|
66
|
+
assert isinstance(owner, dict)
|
|
67
|
+
assert owner["id"] == str(admin.id)
|
|
62
68
|
|
|
63
69
|
def test_post_api_create(self):
|
|
64
70
|
"""It should create a post from the API"""
|
|
@@ -136,3 +142,134 @@ class PostsAPITest(APITestCase):
|
|
|
136
142
|
|
|
137
143
|
post.reload()
|
|
138
144
|
assert post.published is None
|
|
145
|
+
|
|
146
|
+
def test_post_api_create_with_empty_credit_url(self):
|
|
147
|
+
"""It should create a post with an empty credit_url (converted to None)"""
|
|
148
|
+
data = PostFactory.as_dict()
|
|
149
|
+
data["datasets"] = [str(d.id) for d in data["datasets"]]
|
|
150
|
+
data["reuses"] = [str(r.id) for r in data["reuses"]]
|
|
151
|
+
data["credit_url"] = ""
|
|
152
|
+
self.login(AdminFactory())
|
|
153
|
+
response = self.post(url_for("api.posts"), data)
|
|
154
|
+
assert201(response)
|
|
155
|
+
assert Post.objects.count() == 1
|
|
156
|
+
post = Post.objects.first()
|
|
157
|
+
assert post.credit_url is None
|
|
158
|
+
|
|
159
|
+
def test_post_api_list_with_drafts_non_admin(self):
|
|
160
|
+
"""Non-admin users should not see drafts even with with_drafts=True"""
|
|
161
|
+
PostFactory.create_batch(3)
|
|
162
|
+
PostFactory(published=None)
|
|
163
|
+
|
|
164
|
+
self.login(UserFactory())
|
|
165
|
+
response = self.get(url_for("api.posts", with_drafts=True))
|
|
166
|
+
assert200(response)
|
|
167
|
+
assert len(response.json["data"]) == 3
|
|
168
|
+
|
|
169
|
+
def test_post_api_create_with_blocs_body_type_and_page(self):
|
|
170
|
+
"""It should create a post with body_type='blocs' when content_as_page is provided"""
|
|
171
|
+
page = PageFactory()
|
|
172
|
+
data = PostFactory.as_dict()
|
|
173
|
+
data["datasets"] = [str(d.id) for d in data["datasets"]]
|
|
174
|
+
data["reuses"] = [str(r.id) for r in data["reuses"]]
|
|
175
|
+
data["body_type"] = "blocs"
|
|
176
|
+
data["content_as_page"] = str(page.id)
|
|
177
|
+
self.login(AdminFactory())
|
|
178
|
+
response = self.post(url_for("api.posts"), data)
|
|
179
|
+
assert201(response)
|
|
180
|
+
assert Post.objects.count() == 1
|
|
181
|
+
post = Post.objects.first()
|
|
182
|
+
assert post.body_type == "blocs"
|
|
183
|
+
assert post.content_as_page.id == page.id
|
|
184
|
+
|
|
185
|
+
def test_post_api_create_with_blocs_body_type_without_page(self):
|
|
186
|
+
"""It should fail to create a post with body_type='blocs' without content_as_page"""
|
|
187
|
+
data = PostFactory.as_dict()
|
|
188
|
+
data["datasets"] = [str(d.id) for d in data["datasets"]]
|
|
189
|
+
data["reuses"] = [str(r.id) for r in data["reuses"]]
|
|
190
|
+
data["body_type"] = "blocs"
|
|
191
|
+
self.login(AdminFactory())
|
|
192
|
+
response = self.post(url_for("api.posts"), data)
|
|
193
|
+
assert400(response)
|
|
194
|
+
|
|
195
|
+
def test_post_api_get_with_blocs_returns_page_blocs(self):
|
|
196
|
+
"""It should return blocs from the associated page when fetching a post"""
|
|
197
|
+
datasets = DatasetFactory.create_batch(2)
|
|
198
|
+
bloc = DatasetsListBloc(title="Featured datasets", datasets=datasets)
|
|
199
|
+
page = PageFactory(blocs=[bloc])
|
|
200
|
+
post = PostFactory(body_type="blocs", content_as_page=page)
|
|
201
|
+
response = self.get(url_for("api.post", post=post))
|
|
202
|
+
assert200(response)
|
|
203
|
+
assert response.json["body_type"] == "blocs"
|
|
204
|
+
assert "content_as_page" in response.json
|
|
205
|
+
page_data = response.json["content_as_page"]
|
|
206
|
+
assert "blocs" in page_data
|
|
207
|
+
assert len(page_data["blocs"]) == 1
|
|
208
|
+
assert page_data["blocs"][0]["class"] == "DatasetsListBloc"
|
|
209
|
+
assert page_data["blocs"][0]["title"] == "Featured datasets"
|
|
210
|
+
assert len(page_data["blocs"][0]["datasets"]) == 2
|
|
211
|
+
|
|
212
|
+
def test_post_api_update_to_blocs_without_content_as_page(self):
|
|
213
|
+
"""It should fail to update body_type to 'blocs' without providing content_as_page"""
|
|
214
|
+
post = PostFactory(body_type="markdown")
|
|
215
|
+
self.login(AdminFactory())
|
|
216
|
+
response = self.put(url_for("api.post", post=post), {"body_type": "blocs"})
|
|
217
|
+
assert400(response)
|
|
218
|
+
|
|
219
|
+
def test_post_api_update_to_blocs_with_content_as_page(self):
|
|
220
|
+
"""It should update body_type to 'blocs' when content_as_page is provided"""
|
|
221
|
+
post = PostFactory(body_type="markdown")
|
|
222
|
+
page = PageFactory()
|
|
223
|
+
self.login(AdminFactory())
|
|
224
|
+
response = self.put(
|
|
225
|
+
url_for("api.post", post=post), {"body_type": "blocs", "content_as_page": str(page.id)}
|
|
226
|
+
)
|
|
227
|
+
assert200(response)
|
|
228
|
+
post.reload()
|
|
229
|
+
assert post.body_type == "blocs"
|
|
230
|
+
assert post.content_as_page.id == page.id
|
|
231
|
+
|
|
232
|
+
def test_post_api_update_remove_content_as_page_from_blocs_post(self):
|
|
233
|
+
"""It should fail to remove content_as_page from a post with body_type='blocs'"""
|
|
234
|
+
page = PageFactory()
|
|
235
|
+
post = PostFactory(body_type="blocs", content_as_page=page)
|
|
236
|
+
self.login(AdminFactory())
|
|
237
|
+
response = self.put(url_for("api.post", post=post), {"content_as_page": None})
|
|
238
|
+
assert400(response)
|
|
239
|
+
|
|
240
|
+
def test_post_api_update_body_type_preserves_content_as_page(self):
|
|
241
|
+
"""Switching from 'blocs' to 'markdown' preserves content_as_page so user can switch back"""
|
|
242
|
+
page = PageFactory()
|
|
243
|
+
post = PostFactory(body_type="blocs", content_as_page=page)
|
|
244
|
+
self.login(AdminFactory())
|
|
245
|
+
response = self.put(url_for("api.post", post=post), {"body_type": "markdown"})
|
|
246
|
+
assert200(response)
|
|
247
|
+
post.reload()
|
|
248
|
+
assert post.body_type == "markdown"
|
|
249
|
+
assert post.content_as_page.id == page.id
|
|
250
|
+
|
|
251
|
+
def test_post_api_filter_by_kind(self):
|
|
252
|
+
"""It should filter posts by kind"""
|
|
253
|
+
news_post = PostFactory(kind="news")
|
|
254
|
+
page_post = PostFactory(kind="page")
|
|
255
|
+
|
|
256
|
+
response = self.get(url_for("api.posts", kind="news"))
|
|
257
|
+
assert200(response)
|
|
258
|
+
assert len(response.json["data"]) == 1
|
|
259
|
+
assert response.json["data"][0]["id"] == str(news_post.id)
|
|
260
|
+
|
|
261
|
+
response = self.get(url_for("api.posts", kind="page"))
|
|
262
|
+
assert200(response)
|
|
263
|
+
assert len(response.json["data"]) == 1
|
|
264
|
+
assert response.json["data"][0]["id"] == str(page_post.id)
|
|
265
|
+
|
|
266
|
+
def test_rss_feed_only_returns_news(self):
|
|
267
|
+
"""RSS feed should only return posts with kind=news"""
|
|
268
|
+
news_post = PostFactory(kind="news")
|
|
269
|
+
page_post = PostFactory(kind="page")
|
|
270
|
+
|
|
271
|
+
response = self.get(url_for("api.recent_posts_atom_feed"))
|
|
272
|
+
assert200(response)
|
|
273
|
+
content = response.data.decode("utf-8")
|
|
274
|
+
assert news_post.name in content
|
|
275
|
+
assert page_post.name not in content
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import mongoengine
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from udata.core.pages.factories import PageFactory
|
|
5
|
+
from udata.core.pages.models import Page
|
|
6
|
+
from udata.core.post.factories import PostFactory
|
|
7
|
+
from udata.tests.api import PytestOnlyDBTestCase
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PostTest(PytestOnlyDBTestCase):
|
|
11
|
+
def test_page_deletion_raises_if_reference_still_exists(self):
|
|
12
|
+
page = PageFactory()
|
|
13
|
+
post = PostFactory(body_type="blocs", content_as_page=page)
|
|
14
|
+
|
|
15
|
+
assert Page.objects().count() == 1
|
|
16
|
+
|
|
17
|
+
with pytest.raises(mongoengine.errors.OperationError):
|
|
18
|
+
page.delete()
|
|
19
|
+
|
|
20
|
+
# Delete the post referencing the page before being able to delete the page itself
|
|
21
|
+
post.delete()
|
|
22
|
+
page.delete()
|
|
23
|
+
|
|
24
|
+
assert Page.objects().count() == 0
|
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):
|
udata/core/reports/models.py
CHANGED
|
@@ -2,7 +2,8 @@ from datetime import datetime
|
|
|
2
2
|
|
|
3
3
|
from bson import DBRef
|
|
4
4
|
from flask import url_for
|
|
5
|
-
from
|
|
5
|
+
from flask_restx import inputs
|
|
6
|
+
from mongoengine import DO_NOTHING, NULLIFY, Q, signals
|
|
6
7
|
|
|
7
8
|
from udata.api_fields import field, generate_fields
|
|
8
9
|
from udata.core.user.api_fields import user_ref_fields
|
|
@@ -12,7 +13,32 @@ from udata.mongo import db
|
|
|
12
13
|
from .constants import REPORT_REASONS_CHOICES, REPORTABLE_MODELS
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
class ReportQuerySet(db.BaseQuerySet):
|
|
17
|
+
def unhandled(self):
|
|
18
|
+
return self.filter(dismissed_at=None, subject_deleted_at=None)
|
|
19
|
+
|
|
20
|
+
def handled(self):
|
|
21
|
+
return self.filter(Q(dismissed_at__ne=None) | Q(subject_deleted_at__ne=None))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def filter_by_handled(base_query, filter_value):
|
|
25
|
+
if filter_value is True:
|
|
26
|
+
return base_query.handled()
|
|
27
|
+
elif filter_value is False:
|
|
28
|
+
return base_query.unhandled()
|
|
29
|
+
else:
|
|
30
|
+
return base_query
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@generate_fields(
|
|
34
|
+
standalone_filters=[
|
|
35
|
+
{
|
|
36
|
+
"key": "handled",
|
|
37
|
+
"query": filter_by_handled,
|
|
38
|
+
"type": inputs.boolean,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
)
|
|
16
42
|
class Report(db.Document):
|
|
17
43
|
by = field(
|
|
18
44
|
db.ReferenceField(User, reverse_delete_rule=NULLIFY),
|
|
@@ -44,8 +70,22 @@ class Report(db.Document):
|
|
|
44
70
|
reported_at = field(
|
|
45
71
|
db.DateTimeField(default=datetime.utcnow, required=True),
|
|
46
72
|
readonly=True,
|
|
73
|
+
sortable=True,
|
|
47
74
|
)
|
|
48
75
|
|
|
76
|
+
dismissed_at = field(
|
|
77
|
+
db.DateTimeField(),
|
|
78
|
+
)
|
|
79
|
+
dismissed_by = field(
|
|
80
|
+
db.ReferenceField(User, reverse_delete_rule=NULLIFY),
|
|
81
|
+
nested_fields=user_ref_fields,
|
|
82
|
+
allow_null=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
meta = {
|
|
86
|
+
"queryset_class": ReportQuerySet,
|
|
87
|
+
}
|
|
88
|
+
|
|
49
89
|
@field(description="Link to the API endpoint for this report")
|
|
50
90
|
def self_api_url(self):
|
|
51
91
|
return url_for("api.report", report=self, _external=True)
|
udata/core/reuse/api.py
CHANGED
|
@@ -15,6 +15,7 @@ from udata.core.badges.fields import badge_fields
|
|
|
15
15
|
from udata.core.dataservices.models import Dataservice
|
|
16
16
|
from udata.core.dataset.api_fields import dataset_ref_fields
|
|
17
17
|
from udata.core.followers.api import FollowAPI
|
|
18
|
+
from udata.core.legal.mails import add_send_legal_notice_argument, send_legal_notice_on_deletion
|
|
18
19
|
from udata.core.organization.models import Organization
|
|
19
20
|
from udata.core.reuse.constants import REUSE_TOPICS, REUSE_TYPES
|
|
20
21
|
from udata.core.storages.api import (
|
|
@@ -170,6 +171,9 @@ class ReusesAtomFeedAPI(API):
|
|
|
170
171
|
return response
|
|
171
172
|
|
|
172
173
|
|
|
174
|
+
reuse_delete_parser = add_send_legal_notice_argument(api.parser())
|
|
175
|
+
|
|
176
|
+
|
|
173
177
|
@ns.route("/<reuse:reuse>/", endpoint="reuse", doc=common_doc)
|
|
174
178
|
@api.response(404, "Reuse not found")
|
|
175
179
|
@api.response(410, "Reuse has been deleted")
|
|
@@ -202,12 +206,16 @@ class ReuseAPI(API):
|
|
|
202
206
|
|
|
203
207
|
@api.secure
|
|
204
208
|
@api.doc("delete_reuse")
|
|
209
|
+
@api.expect(reuse_delete_parser)
|
|
205
210
|
@api.response(204, "Reuse deleted")
|
|
206
211
|
def delete(self, reuse):
|
|
207
212
|
"""Delete a given reuse"""
|
|
213
|
+
args = reuse_delete_parser.parse_args()
|
|
208
214
|
if reuse.deleted:
|
|
209
215
|
api.abort(410, "This reuse has been deleted")
|
|
210
216
|
reuse.permissions["delete"].test()
|
|
217
|
+
send_legal_notice_on_deletion(reuse, args)
|
|
218
|
+
|
|
211
219
|
reuse.deleted = datetime.utcnow()
|
|
212
220
|
reuse.save()
|
|
213
221
|
return "", 204
|