udata 10.9.1.dev37462__py2.py3-none-any.whl → 10.9.1.dev37604__py2.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/__init__.py +0 -1
- udata/core/dataset/api.py +1 -1
- udata/core/dataset/search.py +5 -2
- udata/core/dataset/tasks.py +2 -5
- udata/core/reuse/tasks.py +3 -0
- udata/core/topic/__init__.py +1 -0
- udata/core/topic/api_fields.py +87 -0
- udata/core/topic/apiv2.py +116 -194
- udata/core/topic/factories.py +69 -8
- udata/core/topic/forms.py +58 -4
- udata/core/topic/models.py +65 -20
- udata/core/topic/parsers.py +40 -0
- udata/core/topic/tasks.py +11 -0
- udata/forms/fields.py +8 -1
- udata/harvest/backends/dcat.py +41 -20
- udata/harvest/tests/test_dcat_backend.py +89 -0
- udata/migrations/2025-05-26-migrate-topics-to-elements.py +59 -0
- udata/migrations/2025-06-02-delete-topic-name-index.py +19 -0
- udata/static/chunks/{11.51d706fb9521c16976bc.js → 11.822f6ccb39c92c796d13.js} +3 -3
- udata/static/chunks/{11.51d706fb9521c16976bc.js.map → 11.822f6ccb39c92c796d13.js.map} +1 -1
- udata/static/chunks/{13.f29411b06be1883356a3.js → 13.d9c1735d14038b94c17e.js} +2 -2
- udata/static/chunks/{13.f29411b06be1883356a3.js.map → 13.d9c1735d14038b94c17e.js.map} +1 -1
- udata/static/chunks/{17.3bd0340930d4a314ce9c.js → 17.81c57c0dedf812e43013.js} +2 -2
- udata/static/chunks/{17.3bd0340930d4a314ce9c.js.map → 17.81c57c0dedf812e43013.js.map} +1 -1
- udata/static/chunks/{8.b966402f5d680d4bdf4a.js → 8.0f42630e6d8ff782928e.js} +2 -2
- udata/static/chunks/{8.b966402f5d680d4bdf4a.js.map → 8.0f42630e6d8ff782928e.js.map} +1 -1
- udata/static/common.js +1 -1
- udata/static/common.js.map +1 -1
- udata/tasks.py +1 -0
- udata/tests/api/test_datasets_api.py +3 -2
- udata/tests/apiv2/test_me_api.py +2 -2
- udata/tests/apiv2/test_topics.py +457 -127
- udata/tests/dataset/test_dataset_tasks.py +7 -2
- udata/tests/reuse/test_reuse_task.py +9 -0
- udata/tests/search/test_adapter.py +43 -0
- udata/tests/test_topics.py +19 -8
- udata/tests/topic/test_topic_tasks.py +27 -0
- {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/METADATA +4 -2
- {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/RECORD +43 -40
- udata/core/topic/api.py +0 -145
- udata/tests/api/test_topics_api.py +0 -284
- {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/LICENSE +0 -0
- {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/WHEEL +0 -0
- {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/entry_points.txt +0 -0
- {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/top_level.txt +0 -0
udata/api/__init__.py
CHANGED
|
@@ -352,7 +352,6 @@ def init_app(app):
|
|
|
352
352
|
import udata.core.reports.api # noqa
|
|
353
353
|
import udata.core.site.api # noqa
|
|
354
354
|
import udata.core.tags.api # noqa
|
|
355
|
-
import udata.core.topic.api # noqa
|
|
356
355
|
import udata.core.topic.apiv2 # noqa
|
|
357
356
|
import udata.core.post.api # noqa
|
|
358
357
|
import udata.core.contact_point.api # noqa
|
udata/core/dataset/api.py
CHANGED
|
@@ -213,7 +213,7 @@ class DatasetApiParser(ModelApiParser):
|
|
|
213
213
|
except Topic.DoesNotExist:
|
|
214
214
|
pass
|
|
215
215
|
else:
|
|
216
|
-
datasets = datasets.filter(id__in=
|
|
216
|
+
datasets = datasets.filter(id__in=topic.get_nested_elements_ids("Dataset"))
|
|
217
217
|
if args.get("dataservice"):
|
|
218
218
|
if not ObjectId.is_valid(args["dataservice"]):
|
|
219
219
|
api.abort(400, "Dataservice arg must be an identifier")
|
udata/core/dataset/search.py
CHANGED
|
@@ -3,6 +3,7 @@ import datetime
|
|
|
3
3
|
from udata.core.dataset.api import DEFAULT_SORTING, DatasetApiParser
|
|
4
4
|
from udata.core.spatial.constants import ADMIN_LEVEL_MAX
|
|
5
5
|
from udata.core.spatial.models import admin_levels
|
|
6
|
+
from udata.core.topic.models import TopicElement
|
|
6
7
|
from udata.models import Dataset, GeoZone, License, Organization, Topic, User
|
|
7
8
|
from udata.search import (
|
|
8
9
|
BoolFilter,
|
|
@@ -73,7 +74,9 @@ class DatasetSearch(ModelSearchAdapter):
|
|
|
73
74
|
organization = None
|
|
74
75
|
owner = None
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
topic_ids = list(
|
|
78
|
+
set(te.topic.id for te in TopicElement.objects(element=dataset) if te.topic)
|
|
79
|
+
)
|
|
77
80
|
|
|
78
81
|
if dataset.organization:
|
|
79
82
|
org = Organization.objects(id=dataset.organization.id).first()
|
|
@@ -112,7 +115,7 @@ class DatasetSearch(ModelSearchAdapter):
|
|
|
112
115
|
"owner": str(owner.id) if owner else None,
|
|
113
116
|
"format": [r.format.lower() for r in dataset.resources if r.format],
|
|
114
117
|
"schema": [r.schema.name for r in dataset.resources if r.schema],
|
|
115
|
-
"topics": [str(
|
|
118
|
+
"topics": [str(tid) for tid in topic_ids],
|
|
116
119
|
}
|
|
117
120
|
extras = {}
|
|
118
121
|
for key, value in dataset.extras.items():
|
udata/core/dataset/tasks.py
CHANGED
|
@@ -13,7 +13,7 @@ from udata.core import csv, storages
|
|
|
13
13
|
from udata.core.dataservices.models import Dataservice
|
|
14
14
|
from udata.harvest.models import HarvestJob
|
|
15
15
|
from udata.i18n import lazy_gettext as _
|
|
16
|
-
from udata.models import Activity, Discussion, Follow, Organization,
|
|
16
|
+
from udata.models import Activity, Discussion, Follow, Organization, TopicElement, Transfer, db
|
|
17
17
|
from udata.tasks import job
|
|
18
18
|
|
|
19
19
|
from .constants import UPDATE_FREQUENCIES
|
|
@@ -43,10 +43,7 @@ def purge_datasets(self):
|
|
|
43
43
|
# Remove activity
|
|
44
44
|
Activity.objects(related_to=dataset).delete()
|
|
45
45
|
# Remove topics' related dataset
|
|
46
|
-
|
|
47
|
-
datasets = topic.datasets
|
|
48
|
-
datasets.remove(dataset)
|
|
49
|
-
topic.update(datasets=datasets)
|
|
46
|
+
TopicElement.objects(element=dataset).update(element=None)
|
|
50
47
|
# Remove dataservices related dataset
|
|
51
48
|
for dataservice in Dataservice.objects(datasets=dataset):
|
|
52
49
|
datasets = dataservice.datasets
|
udata/core/reuse/tasks.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from udata import mail
|
|
2
2
|
from udata.core import storages
|
|
3
|
+
from udata.core.topic.models import TopicElement
|
|
3
4
|
from udata.i18n import lazy_gettext as _
|
|
4
5
|
from udata.models import Activity, Discussion, Follow, Transfer
|
|
5
6
|
from udata.tasks import get_logger, job, task
|
|
@@ -21,6 +22,8 @@ def purge_reuses(self) -> None:
|
|
|
21
22
|
Activity.objects(related_to=reuse).delete()
|
|
22
23
|
# Remove transfers
|
|
23
24
|
Transfer.objects(subject=reuse).delete()
|
|
25
|
+
# Remove reuses references in Topics
|
|
26
|
+
TopicElement.objects(element=reuse).update(element=None)
|
|
24
27
|
# Remove reuse's logo in all sizes
|
|
25
28
|
if reuse.image.filename is not None:
|
|
26
29
|
storage = storages.images
|
udata/core/topic/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DEFAULT_PAGE_SIZE = 20
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from flask import url_for
|
|
2
|
+
|
|
3
|
+
from udata.api import api, apiv2, fields
|
|
4
|
+
from udata.core.organization.api_fields import org_ref_fields
|
|
5
|
+
from udata.core.spatial.api_fields import spatial_coverage_fields
|
|
6
|
+
from udata.core.topic import DEFAULT_PAGE_SIZE
|
|
7
|
+
from udata.core.user.api_fields import user_ref_fields
|
|
8
|
+
|
|
9
|
+
topic_fields = apiv2.model(
|
|
10
|
+
"Topic",
|
|
11
|
+
{
|
|
12
|
+
"id": fields.String(description="The topic identifier"),
|
|
13
|
+
"name": fields.String(description="The topic name", required=True),
|
|
14
|
+
"slug": fields.String(description="The topic permalink string", readonly=True),
|
|
15
|
+
"description": fields.Markdown(
|
|
16
|
+
description="The topic description in Markdown", required=True
|
|
17
|
+
),
|
|
18
|
+
"tags": fields.List(
|
|
19
|
+
fields.String, description="Some keywords to help in search", required=True
|
|
20
|
+
),
|
|
21
|
+
"elements": fields.Raw(
|
|
22
|
+
attribute=lambda o: {
|
|
23
|
+
"rel": "subsection",
|
|
24
|
+
"href": url_for(
|
|
25
|
+
"apiv2.topic_elements",
|
|
26
|
+
topic=o.id,
|
|
27
|
+
page=1,
|
|
28
|
+
page_size=DEFAULT_PAGE_SIZE,
|
|
29
|
+
_external=True,
|
|
30
|
+
),
|
|
31
|
+
"type": "GET",
|
|
32
|
+
"total": len(o.elements),
|
|
33
|
+
},
|
|
34
|
+
description="Link to the topic elements",
|
|
35
|
+
),
|
|
36
|
+
"featured": fields.Boolean(description="Is the topic featured"),
|
|
37
|
+
"private": fields.Boolean(description="Is the topic private"),
|
|
38
|
+
"created_at": fields.ISODateTime(description="The topic creation date", readonly=True),
|
|
39
|
+
"spatial": fields.Nested(
|
|
40
|
+
spatial_coverage_fields, allow_null=True, description="The spatial coverage"
|
|
41
|
+
),
|
|
42
|
+
"last_modified": fields.ISODateTime(
|
|
43
|
+
description="The topic last modification date", readonly=True
|
|
44
|
+
),
|
|
45
|
+
"organization": fields.Nested(
|
|
46
|
+
org_ref_fields,
|
|
47
|
+
allow_null=True,
|
|
48
|
+
description="The publishing organization",
|
|
49
|
+
readonly=True,
|
|
50
|
+
),
|
|
51
|
+
"owner": fields.Nested(
|
|
52
|
+
user_ref_fields, description="The owner user", readonly=True, allow_null=True
|
|
53
|
+
),
|
|
54
|
+
"uri": fields.String(
|
|
55
|
+
attribute=lambda t: url_for("apiv2.topic", topic=t),
|
|
56
|
+
description="The topic API URI",
|
|
57
|
+
readonly=True,
|
|
58
|
+
),
|
|
59
|
+
"extras": fields.Raw(description="Extras attributes as key-value pairs"),
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
topic_page_fields = apiv2.model("TopicPage", fields.pager(topic_fields))
|
|
64
|
+
|
|
65
|
+
element_fields = apiv2.model(
|
|
66
|
+
"TopicElement",
|
|
67
|
+
{
|
|
68
|
+
"id": fields.String(description="The element id"),
|
|
69
|
+
"title": fields.String(description="The element title"),
|
|
70
|
+
"description": fields.String(description="The element description"),
|
|
71
|
+
"tags": fields.List(fields.String, description="The element tags"),
|
|
72
|
+
"extras": fields.Raw(description="Extras attributes as key-value pairs"),
|
|
73
|
+
"element": fields.Nested(
|
|
74
|
+
api.model_reference, description="The element target object", allow_null=True
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
element_page_fields = apiv2.model("TopicElementPage", fields.pager(element_fields))
|
|
80
|
+
|
|
81
|
+
topic_input_fields = apiv2.clone(
|
|
82
|
+
"TopicInput",
|
|
83
|
+
topic_fields,
|
|
84
|
+
{
|
|
85
|
+
"elements": fields.List(fields.Nested(element_fields, description="The topic elements")),
|
|
86
|
+
},
|
|
87
|
+
)
|
udata/core/topic/apiv2.py
CHANGED
|
@@ -1,110 +1,37 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
3
|
import mongoengine
|
|
4
|
-
from
|
|
5
|
-
from flask import request, url_for
|
|
4
|
+
from flask import request
|
|
6
5
|
from flask_security import current_user
|
|
7
6
|
|
|
8
|
-
from udata.api import API,
|
|
9
|
-
from udata.core.
|
|
10
|
-
from udata.core.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
from udata.core.topic.
|
|
7
|
+
from udata.api import API, api, apiv2
|
|
8
|
+
from udata.core.discussions.models import Discussion
|
|
9
|
+
from udata.core.topic.api_fields import (
|
|
10
|
+
element_fields,
|
|
11
|
+
element_page_fields,
|
|
12
|
+
topic_fields,
|
|
13
|
+
topic_input_fields,
|
|
14
|
+
topic_page_fields,
|
|
15
|
+
)
|
|
16
|
+
from udata.core.topic.forms import TopicElementForm, TopicForm
|
|
17
|
+
from udata.core.topic.models import Topic, TopicElement
|
|
18
|
+
from udata.core.topic.parsers import TopicApiParser, TopicElementsParser
|
|
18
19
|
from udata.core.topic.permissions import TopicEditPermission
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
apiv2.inherit("ModelReference", api.model_reference)
|
|
20
22
|
|
|
21
23
|
DEFAULT_SORTING = "-created_at"
|
|
22
|
-
DEFAULT_PAGE_SIZE = 20
|
|
23
24
|
|
|
24
25
|
log = logging.getLogger(__name__)
|
|
25
26
|
|
|
26
27
|
ns = apiv2.namespace("topics", "Topics related operations")
|
|
27
28
|
|
|
28
29
|
topic_parser = TopicApiParser()
|
|
29
|
-
|
|
30
|
-
dataset_parser = DatasetApiParser()
|
|
31
|
-
reuse_parser = ReuseApiParser()
|
|
30
|
+
elements_parser = TopicElementsParser()
|
|
32
31
|
|
|
33
32
|
common_doc = {"params": {"topic": "The topic ID"}}
|
|
34
33
|
|
|
35
34
|
|
|
36
|
-
topic_fields = apiv2.model(
|
|
37
|
-
"Topic",
|
|
38
|
-
{
|
|
39
|
-
"id": fields.String(description="The topic identifier"),
|
|
40
|
-
"name": fields.String(description="The topic name", required=True),
|
|
41
|
-
"slug": fields.String(description="The topic permalink string", readonly=True),
|
|
42
|
-
"description": fields.Markdown(
|
|
43
|
-
description="The topic description in Markdown", required=True
|
|
44
|
-
),
|
|
45
|
-
"tags": fields.List(
|
|
46
|
-
fields.String, description="Some keywords to help in search", required=True
|
|
47
|
-
),
|
|
48
|
-
"datasets": fields.Raw(
|
|
49
|
-
attribute=lambda o: {
|
|
50
|
-
"rel": "subsection",
|
|
51
|
-
"href": url_for(
|
|
52
|
-
"apiv2.topic_datasets",
|
|
53
|
-
topic=o.id,
|
|
54
|
-
page=1,
|
|
55
|
-
page_size=DEFAULT_PAGE_SIZE,
|
|
56
|
-
_external=True,
|
|
57
|
-
),
|
|
58
|
-
"type": "GET",
|
|
59
|
-
"total": len(o.datasets),
|
|
60
|
-
},
|
|
61
|
-
description="Link to the topic datasets",
|
|
62
|
-
),
|
|
63
|
-
"reuses": fields.Raw(
|
|
64
|
-
attribute=lambda o: {
|
|
65
|
-
"rel": "subsection",
|
|
66
|
-
"href": url_for(
|
|
67
|
-
"apiv2.topic_reuses",
|
|
68
|
-
topic=o.id,
|
|
69
|
-
page=1,
|
|
70
|
-
page_size=DEFAULT_PAGE_SIZE,
|
|
71
|
-
_external=True,
|
|
72
|
-
),
|
|
73
|
-
"type": "GET",
|
|
74
|
-
"total": len(o.reuses),
|
|
75
|
-
},
|
|
76
|
-
description="Link to the topic reuses",
|
|
77
|
-
),
|
|
78
|
-
"featured": fields.Boolean(description="Is the topic featured"),
|
|
79
|
-
"private": fields.Boolean(description="Is the topic private"),
|
|
80
|
-
"created_at": fields.ISODateTime(description="The topic creation date", readonly=True),
|
|
81
|
-
"spatial": fields.Nested(
|
|
82
|
-
spatial_coverage_fields, allow_null=True, description="The spatial coverage"
|
|
83
|
-
),
|
|
84
|
-
"last_modified": fields.ISODateTime(
|
|
85
|
-
description="The topic last modification date", readonly=True
|
|
86
|
-
),
|
|
87
|
-
"organization": fields.Nested(
|
|
88
|
-
org_ref_fields,
|
|
89
|
-
allow_null=True,
|
|
90
|
-
description="The publishing organization",
|
|
91
|
-
readonly=True,
|
|
92
|
-
),
|
|
93
|
-
"owner": fields.Nested(
|
|
94
|
-
user_ref_fields, description="The owner user", readonly=True, allow_null=True
|
|
95
|
-
),
|
|
96
|
-
"uri": fields.String(
|
|
97
|
-
attribute=lambda t: url_for("api.topic", topic=t),
|
|
98
|
-
description="The topic API URI",
|
|
99
|
-
readonly=True,
|
|
100
|
-
),
|
|
101
|
-
"extras": fields.Raw(description="Extras attributes as key-value pairs"),
|
|
102
|
-
},
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
topic_page_fields = apiv2.model("TopicPage", fields.pager(topic_fields))
|
|
106
|
-
|
|
107
|
-
|
|
108
35
|
@ns.route("/", endpoint="topics_list")
|
|
109
36
|
class TopicsAPI(API):
|
|
110
37
|
@apiv2.expect(topic_parser.parser)
|
|
@@ -117,6 +44,16 @@ class TopicsAPI(API):
|
|
|
117
44
|
sort = args["sort"] or ("$text_score" if args["q"] else None) or DEFAULT_SORTING
|
|
118
45
|
return topics.order_by(sort).paginate(args["page"], args["page_size"])
|
|
119
46
|
|
|
47
|
+
@apiv2.secure
|
|
48
|
+
@apiv2.doc("create_topic")
|
|
49
|
+
@apiv2.expect(topic_input_fields)
|
|
50
|
+
@apiv2.marshal_with(topic_fields)
|
|
51
|
+
@apiv2.response(400, "Validation error")
|
|
52
|
+
def post(self):
|
|
53
|
+
"""Create a topic"""
|
|
54
|
+
form = apiv2.validate(TopicForm)
|
|
55
|
+
return form.save(), 201
|
|
56
|
+
|
|
120
57
|
|
|
121
58
|
@ns.route("/<topic:topic>/", endpoint="topic", doc=common_doc)
|
|
122
59
|
@apiv2.response(404, "Topic not found")
|
|
@@ -127,37 +64,52 @@ class TopicAPI(API):
|
|
|
127
64
|
"""Get a given topic"""
|
|
128
65
|
return topic
|
|
129
66
|
|
|
67
|
+
@apiv2.secure
|
|
68
|
+
@apiv2.doc("update_topic")
|
|
69
|
+
@apiv2.expect(topic_input_fields)
|
|
70
|
+
@apiv2.marshal_with(topic_fields)
|
|
71
|
+
@apiv2.response(400, "Validation error")
|
|
72
|
+
@apiv2.response(403, "Forbidden")
|
|
73
|
+
def put(self, topic):
|
|
74
|
+
"""Update a given topic"""
|
|
75
|
+
if not TopicEditPermission(topic).can():
|
|
76
|
+
apiv2.abort(403, "Forbidden")
|
|
77
|
+
form = apiv2.validate(TopicForm, topic)
|
|
78
|
+
return form.save()
|
|
130
79
|
|
|
131
|
-
|
|
132
|
-
"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
)
|
|
80
|
+
@apiv2.secure
|
|
81
|
+
@apiv2.doc("delete_topic")
|
|
82
|
+
@apiv2.response(204, "Object deleted")
|
|
83
|
+
@apiv2.response(403, "Forbidden")
|
|
84
|
+
def delete(self, topic):
|
|
85
|
+
"""Delete a given topic"""
|
|
86
|
+
if not TopicEditPermission(topic).can():
|
|
87
|
+
apiv2.abort(403, "Forbidden")
|
|
88
|
+
# Remove discussions linked to the topic
|
|
89
|
+
Discussion.objects(subject=topic).delete()
|
|
90
|
+
topic.delete()
|
|
91
|
+
return "", 204
|
|
138
92
|
|
|
139
93
|
|
|
140
|
-
@ns.route("/<topic:topic>/
|
|
141
|
-
class
|
|
142
|
-
@apiv2.doc("
|
|
143
|
-
@apiv2.expect(
|
|
144
|
-
@apiv2.marshal_with(
|
|
94
|
+
@ns.route("/<topic:topic>/elements/", endpoint="topic_elements", doc=common_doc)
|
|
95
|
+
class TopicElementsAPI(API):
|
|
96
|
+
@apiv2.doc("topic_elements")
|
|
97
|
+
@apiv2.expect(elements_parser.parser)
|
|
98
|
+
@apiv2.marshal_with(element_page_fields)
|
|
145
99
|
def get(self, topic):
|
|
146
|
-
"""Get a given topic
|
|
147
|
-
args =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return
|
|
100
|
+
"""Get a given topic's elements with pagination."""
|
|
101
|
+
args = elements_parser.parse()
|
|
102
|
+
elements = elements_parser.parse_filters(
|
|
103
|
+
topic.elements,
|
|
104
|
+
args,
|
|
105
|
+
)
|
|
106
|
+
return elements.paginate(args["page"], args["page_size"])
|
|
153
107
|
|
|
154
108
|
@apiv2.secure
|
|
155
|
-
@apiv2.doc("
|
|
156
|
-
@apiv2.expect([
|
|
109
|
+
@apiv2.doc("topic_elements_create")
|
|
110
|
+
@apiv2.expect([api.model_reference])
|
|
157
111
|
@apiv2.marshal_with(topic_fields)
|
|
158
|
-
@apiv2.response(400, "Malformed object id(s) in request")
|
|
159
112
|
@apiv2.response(400, "Expecting a list")
|
|
160
|
-
@apiv2.response(400, "Expecting a list of dicts with id attribute")
|
|
161
113
|
@apiv2.response(404, "Topic not found")
|
|
162
114
|
@apiv2.response(403, "Forbidden")
|
|
163
115
|
def post(self, topic):
|
|
@@ -168,113 +120,83 @@ class TopicDatasetsAPI(API):
|
|
|
168
120
|
|
|
169
121
|
if not isinstance(data, list):
|
|
170
122
|
apiv2.abort(400, "Expecting a list")
|
|
171
|
-
if not all(isinstance(d, dict) and d.get("id") for d in data):
|
|
172
|
-
apiv2.abort(400, "Expecting a list of dicts with id attribute")
|
|
173
123
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
124
|
+
errors = []
|
|
125
|
+
elements = []
|
|
126
|
+
for element_data in data:
|
|
127
|
+
form = TopicElementForm.from_json(element_data, meta={"csrf": False})
|
|
128
|
+
if not form.validate():
|
|
129
|
+
errors.append(form.errors)
|
|
130
|
+
else:
|
|
131
|
+
element = TopicElement()
|
|
132
|
+
form.populate_obj(element)
|
|
133
|
+
element.save()
|
|
134
|
+
elements.append(element)
|
|
135
|
+
|
|
136
|
+
if errors:
|
|
137
|
+
apiv2.abort(400, errors=errors)
|
|
138
|
+
|
|
139
|
+
for element in elements:
|
|
140
|
+
element.topic = topic
|
|
141
|
+
element.save()
|
|
179
142
|
|
|
180
|
-
|
|
181
|
-
topic.datasets += [ObjectId(did) for did in diff]
|
|
182
|
-
topic.save()
|
|
143
|
+
topic.save()
|
|
183
144
|
|
|
184
145
|
return topic, 201
|
|
185
146
|
|
|
186
|
-
|
|
187
|
-
@ns.route(
|
|
188
|
-
"/<topic:topic>/datasets/<dataset:dataset>/",
|
|
189
|
-
endpoint="topic_dataset",
|
|
190
|
-
doc={"params": {"topic": "The topic ID", "dataset": "The dataset ID"}},
|
|
191
|
-
)
|
|
192
|
-
class TopicDatasetAPI(API):
|
|
193
147
|
@apiv2.secure
|
|
148
|
+
@apiv2.doc("topic_elements_delete")
|
|
194
149
|
@apiv2.response(404, "Topic not found")
|
|
195
|
-
@apiv2.response(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
150
|
+
@apiv2.response(403, "Forbidden")
|
|
151
|
+
def delete(self, topic):
|
|
152
|
+
"""Delete all elements from a Topic
|
|
153
|
+
|
|
154
|
+
This a workaround for https://github.com/kvesteri/wtforms-json/issues/43
|
|
155
|
+
-> we can't use PUT /api/2/topics/{topic}/ with an empty list of elements
|
|
156
|
+
"""
|
|
199
157
|
if not TopicEditPermission(topic).can():
|
|
200
158
|
apiv2.abort(403, "Forbidden")
|
|
201
159
|
|
|
202
|
-
|
|
203
|
-
apiv2.abort(404, "Dataset not found in topic")
|
|
204
|
-
topic.datasets = [d for d in topic.datasets if d.id != dataset.id]
|
|
205
|
-
topic.save()
|
|
160
|
+
topic.elements.delete()
|
|
206
161
|
|
|
207
162
|
return None, 204
|
|
208
163
|
|
|
209
164
|
|
|
210
|
-
@ns.route(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
"""Get a given topic reuses, with filters"""
|
|
217
|
-
args = reuse_parser.parse()
|
|
218
|
-
reuses = Reuse.objects(deleted=None, private__ne=True).filter(
|
|
219
|
-
id__in=[d.id for d in topic.reuses]
|
|
220
|
-
)
|
|
221
|
-
# warning: topic in reuse_parser is different from Topic
|
|
222
|
-
reuses = reuse_parser.parse_filters(reuses, args)
|
|
223
|
-
sort = args["sort"] or ("$text_score" if args["q"] else None) or DEFAULT_SORTING
|
|
224
|
-
return reuses.order_by(sort).paginate(args["page"], args["page_size"])
|
|
225
|
-
|
|
165
|
+
@ns.route(
|
|
166
|
+
"/<topic:topic>/elements/<element_id>/",
|
|
167
|
+
endpoint="topic_element",
|
|
168
|
+
doc={"params": {"topic": "The topic ID", "element": "The element ID"}},
|
|
169
|
+
)
|
|
170
|
+
class TopicElementAPI(API):
|
|
226
171
|
@apiv2.secure
|
|
227
|
-
@apiv2.doc("topic_reuses_create")
|
|
228
|
-
@apiv2.expect([topic_add_items_fields])
|
|
229
|
-
@apiv2.marshal_with(topic_fields)
|
|
230
|
-
@apiv2.response(400, "Malformed object id(s) in request")
|
|
231
|
-
@apiv2.response(400, "Expecting a list")
|
|
232
|
-
@apiv2.response(400, "Expecting a list of dicts with id attribute")
|
|
233
172
|
@apiv2.response(404, "Topic not found")
|
|
234
|
-
@apiv2.response(
|
|
235
|
-
|
|
236
|
-
|
|
173
|
+
@apiv2.response(404, "Element not found in topic")
|
|
174
|
+
@apiv2.response(204, "Success")
|
|
175
|
+
def delete(self, topic, element_id):
|
|
176
|
+
"""Delete a given element from the given topic"""
|
|
237
177
|
if not TopicEditPermission(topic).can():
|
|
238
178
|
apiv2.abort(403, "Forbidden")
|
|
239
179
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if not isinstance(data, list):
|
|
243
|
-
apiv2.abort(400, "Expecting a list")
|
|
244
|
-
if not all(isinstance(d, dict) and d.get("id") for d in data):
|
|
245
|
-
apiv2.abort(400, "Expecting a list of dicts with id attribute")
|
|
246
|
-
|
|
247
|
-
try:
|
|
248
|
-
reuses = Reuse.objects.filter(id__in=[r["id"] for r in data]).only("id")
|
|
249
|
-
diff = set(d.id for d in reuses) - set(d.id for d in topic.reuses)
|
|
250
|
-
except mongoengine.errors.ValidationError:
|
|
251
|
-
apiv2.abort(400, "Malformed object id(s) in request")
|
|
252
|
-
|
|
253
|
-
if diff:
|
|
254
|
-
topic.reuses += [ObjectId(rid) for rid in diff]
|
|
255
|
-
topic.save()
|
|
256
|
-
|
|
257
|
-
return topic, 201
|
|
180
|
+
element = TopicElement.objects.get_or_404(pk=element_id)
|
|
181
|
+
element.delete()
|
|
258
182
|
|
|
183
|
+
return None, 204
|
|
259
184
|
|
|
260
|
-
@ns.route(
|
|
261
|
-
"/<topic:topic>/reuses/<reuse:reuse>/",
|
|
262
|
-
endpoint="topic_reuse",
|
|
263
|
-
doc={"params": {"topic": "The topic ID", "reuse": "The reuse ID"}},
|
|
264
|
-
)
|
|
265
|
-
class TopicReuseAPI(API):
|
|
266
185
|
@apiv2.secure
|
|
186
|
+
@apiv2.doc("topic_element_update")
|
|
187
|
+
@apiv2.expect(element_fields)
|
|
188
|
+
@apiv2.marshal_with(element_fields)
|
|
267
189
|
@apiv2.response(404, "Topic not found")
|
|
268
|
-
@apiv2.response(404, "
|
|
190
|
+
@apiv2.response(404, "Element not found in topic")
|
|
269
191
|
@apiv2.response(204, "Success")
|
|
270
|
-
def
|
|
271
|
-
"""
|
|
192
|
+
def put(self, topic, element_id):
|
|
193
|
+
"""Update a given element from the given topic"""
|
|
272
194
|
if not TopicEditPermission(topic).can():
|
|
273
195
|
apiv2.abort(403, "Forbidden")
|
|
274
196
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
197
|
+
element = TopicElement.objects.get_or_404(pk=element_id)
|
|
198
|
+
form = apiv2.validate(TopicElementForm, element)
|
|
199
|
+
form.populate_obj(element)
|
|
200
|
+
element.save()
|
|
279
201
|
|
|
280
|
-
return
|
|
202
|
+
return element
|
udata/core/topic/factories.py
CHANGED
|
@@ -2,10 +2,40 @@ import factory
|
|
|
2
2
|
|
|
3
3
|
from udata import utils
|
|
4
4
|
from udata.core.dataset.factories import DatasetFactory
|
|
5
|
-
from udata.core.reuse.factories import
|
|
5
|
+
from udata.core.reuse.factories import ReuseFactory
|
|
6
6
|
from udata.factories import ModelFactory
|
|
7
7
|
|
|
8
|
-
from .models import Topic
|
|
8
|
+
from .models import Topic, TopicElement
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TopicElementFactory(ModelFactory):
|
|
12
|
+
class Meta:
|
|
13
|
+
model = TopicElement
|
|
14
|
+
|
|
15
|
+
title = factory.Faker("sentence")
|
|
16
|
+
description = factory.Faker("text")
|
|
17
|
+
topic = factory.SubFactory("udata.core.topic.factories.TopicFactory")
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def element_as_payload(cls, elt) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"element": {"id": str(elt["element"].id), "class": elt["element"].__class__.__name__},
|
|
23
|
+
"title": elt["title"],
|
|
24
|
+
"description": elt["description"],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def as_payload(cls) -> dict:
|
|
29
|
+
elt = cls.as_dict()
|
|
30
|
+
return cls.element_as_payload(elt)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TopicElementDatasetFactory(TopicElementFactory):
|
|
34
|
+
element = factory.SubFactory(DatasetFactory)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TopicElementReuseFactory(TopicElementFactory):
|
|
38
|
+
element = factory.SubFactory(ReuseFactory)
|
|
9
39
|
|
|
10
40
|
|
|
11
41
|
class TopicFactory(ModelFactory):
|
|
@@ -17,10 +47,41 @@ class TopicFactory(ModelFactory):
|
|
|
17
47
|
tags = factory.LazyAttribute(lambda o: [utils.unique_string(16) for _ in range(3)])
|
|
18
48
|
private = False
|
|
19
49
|
|
|
20
|
-
@factory.lazy_attribute
|
|
21
|
-
def datasets(self):
|
|
22
|
-
return DatasetFactory.create_batch(3)
|
|
23
50
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
51
|
+
class TopicWithElementsFactory(TopicFactory):
|
|
52
|
+
"""Factory that creates a topic with associated elements"""
|
|
53
|
+
|
|
54
|
+
@factory.post_generation
|
|
55
|
+
def elements(self, create, extracted, **kwargs):
|
|
56
|
+
if not create:
|
|
57
|
+
return
|
|
58
|
+
# Create associated elements
|
|
59
|
+
TopicElementDatasetFactory.create_batch(2, topic=self)
|
|
60
|
+
TopicElementReuseFactory.create(topic=self)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def elements_as_payload(cls, elements: list) -> dict:
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
"element": {"id": str(elt.element.id), "class": elt.element.__class__.__name__},
|
|
67
|
+
"title": elt.title,
|
|
68
|
+
"description": elt.description,
|
|
69
|
+
"tags": elt.tags,
|
|
70
|
+
"extras": elt.extras,
|
|
71
|
+
}
|
|
72
|
+
for elt in elements
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def as_payload(cls) -> dict:
|
|
77
|
+
# Build topic without saving
|
|
78
|
+
topic = cls.build()
|
|
79
|
+
payload = topic.to_dict()
|
|
80
|
+
# Build elements without saving, but create datasets/reuses for valid references
|
|
81
|
+
elements = [
|
|
82
|
+
TopicElementDatasetFactory.build(topic=topic, element=DatasetFactory.create()),
|
|
83
|
+
TopicElementDatasetFactory.build(topic=topic, element=DatasetFactory.create()),
|
|
84
|
+
TopicElementReuseFactory.build(topic=topic, element=ReuseFactory.create()),
|
|
85
|
+
]
|
|
86
|
+
payload["elements"] = cls.elements_as_payload(elements)
|
|
87
|
+
return payload
|