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.

Files changed (45) hide show
  1. udata/api/__init__.py +0 -1
  2. udata/core/dataset/api.py +1 -1
  3. udata/core/dataset/search.py +5 -2
  4. udata/core/dataset/tasks.py +2 -5
  5. udata/core/reuse/tasks.py +3 -0
  6. udata/core/topic/__init__.py +1 -0
  7. udata/core/topic/api_fields.py +87 -0
  8. udata/core/topic/apiv2.py +116 -194
  9. udata/core/topic/factories.py +69 -8
  10. udata/core/topic/forms.py +58 -4
  11. udata/core/topic/models.py +65 -20
  12. udata/core/topic/parsers.py +40 -0
  13. udata/core/topic/tasks.py +11 -0
  14. udata/forms/fields.py +8 -1
  15. udata/harvest/backends/dcat.py +41 -20
  16. udata/harvest/tests/test_dcat_backend.py +89 -0
  17. udata/migrations/2025-05-26-migrate-topics-to-elements.py +59 -0
  18. udata/migrations/2025-06-02-delete-topic-name-index.py +19 -0
  19. udata/static/chunks/{11.51d706fb9521c16976bc.js → 11.822f6ccb39c92c796d13.js} +3 -3
  20. udata/static/chunks/{11.51d706fb9521c16976bc.js.map → 11.822f6ccb39c92c796d13.js.map} +1 -1
  21. udata/static/chunks/{13.f29411b06be1883356a3.js → 13.d9c1735d14038b94c17e.js} +2 -2
  22. udata/static/chunks/{13.f29411b06be1883356a3.js.map → 13.d9c1735d14038b94c17e.js.map} +1 -1
  23. udata/static/chunks/{17.3bd0340930d4a314ce9c.js → 17.81c57c0dedf812e43013.js} +2 -2
  24. udata/static/chunks/{17.3bd0340930d4a314ce9c.js.map → 17.81c57c0dedf812e43013.js.map} +1 -1
  25. udata/static/chunks/{8.b966402f5d680d4bdf4a.js → 8.0f42630e6d8ff782928e.js} +2 -2
  26. udata/static/chunks/{8.b966402f5d680d4bdf4a.js.map → 8.0f42630e6d8ff782928e.js.map} +1 -1
  27. udata/static/common.js +1 -1
  28. udata/static/common.js.map +1 -1
  29. udata/tasks.py +1 -0
  30. udata/tests/api/test_datasets_api.py +3 -2
  31. udata/tests/apiv2/test_me_api.py +2 -2
  32. udata/tests/apiv2/test_topics.py +457 -127
  33. udata/tests/dataset/test_dataset_tasks.py +7 -2
  34. udata/tests/reuse/test_reuse_task.py +9 -0
  35. udata/tests/search/test_adapter.py +43 -0
  36. udata/tests/test_topics.py +19 -8
  37. udata/tests/topic/test_topic_tasks.py +27 -0
  38. {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/METADATA +4 -2
  39. {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/RECORD +43 -40
  40. udata/core/topic/api.py +0 -145
  41. udata/tests/api/test_topics_api.py +0 -284
  42. {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/LICENSE +0 -0
  43. {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/WHEEL +0 -0
  44. {udata-10.9.1.dev37462.dist-info → udata-10.9.1.dev37604.dist-info}/entry_points.txt +0 -0
  45. {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=[d.id for d in topic.datasets])
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")
@@ -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
- topics = Topic.objects(datasets=dataset).only("id")
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(t.id) for t in topics if topics],
118
+ "topics": [str(tid) for tid in topic_ids],
116
119
  }
117
120
  extras = {}
118
121
  for key, value in dataset.extras.items():
@@ -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, Topic, Transfer, db
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
- for topic in Topic.objects(datasets=dataset):
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
@@ -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 bson import ObjectId
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, apiv2, fields
9
- from udata.core.dataset.api import DatasetApiParser
10
- from udata.core.dataset.apiv2 import dataset_page_fields
11
- from udata.core.dataset.models import Dataset
12
- from udata.core.organization.api_fields import org_ref_fields
13
- from udata.core.reuse.api import ReuseApiParser
14
- from udata.core.reuse.models import Reuse
15
- from udata.core.spatial.api_fields import spatial_coverage_fields
16
- from udata.core.topic.models import Topic
17
- from udata.core.topic.parsers import TopicApiParser
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
- from udata.core.user.api_fields import user_ref_fields
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
- generic_parser = apiv2.page_parser()
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
- topic_add_items_fields = apiv2.model(
132
- "TopicItemsAdd",
133
- {
134
- "id": fields.String(description="Id of the item to add", required=True),
135
- },
136
- location="json",
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>/datasets/", endpoint="topic_datasets", doc=common_doc)
141
- class TopicDatasetsAPI(API):
142
- @apiv2.doc("topic_datasets")
143
- @apiv2.expect(dataset_parser.parser)
144
- @apiv2.marshal_with(dataset_page_fields)
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 datasets, with filters"""
147
- args = dataset_parser.parse()
148
- args["topic"] = topic.id
149
- datasets = Dataset.objects(archived=None, deleted=None, private=False)
150
- datasets = dataset_parser.parse_filters(datasets, args)
151
- sort = args["sort"] or ("$text_score" if args["q"] else None) or "-created_at_internal"
152
- return datasets.order_by(sort).paginate(args["page"], args["page_size"])
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("topic_datasets_create")
156
- @apiv2.expect([topic_add_items_fields])
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
- try:
175
- datasets = Dataset.objects.filter(id__in=[d["id"] for d in data]).only("id")
176
- diff = set(d.id for d in datasets) - set(d.id for d in topic.datasets)
177
- except mongoengine.errors.ValidationError:
178
- apiv2.abort(400, "Malformed object id(s) in request")
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
- if diff:
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(404, "Dataset not found in topic")
196
- @apiv2.response(204, "Success")
197
- def delete(self, topic, dataset):
198
- """Delete a given dataset from the given topic"""
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
- if dataset.id not in (d.id for d in topic.datasets):
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("/<topic:topic>/reuses/", endpoint="topic_reuses", doc=common_doc)
211
- class TopicReusesAPI(API):
212
- @apiv2.doc("topic_reuses")
213
- @apiv2.expect(reuse_parser.parser)
214
- @apiv2.marshal_with(Reuse.__page_fields__)
215
- def get(self, topic):
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(403, "Forbidden")
235
- def post(self, topic):
236
- """Add reuses to a given topic from a list of reuses ids"""
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
- data = request.json
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, "Reuse not found in topic")
190
+ @apiv2.response(404, "Element not found in topic")
269
191
  @apiv2.response(204, "Success")
270
- def delete(self, topic, reuse):
271
- """Delete a given reuse from the given topic"""
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
- if reuse.id not in (d.id for d in topic.reuses):
276
- apiv2.abort(404, "Reuse not found in topic")
277
- topic.reuses = [d for d in topic.reuses if d.id != reuse.id]
278
- topic.save()
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 None, 204
202
+ return element
@@ -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 VisibleReuseFactory
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
- @factory.lazy_attribute
25
- def reuses(self):
26
- return VisibleReuseFactory.create_batch(3)
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