udata 14.5.1.dev9__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.
Files changed (55) hide show
  1. udata/api_fields.py +85 -15
  2. udata/auth/forms.py +1 -1
  3. udata/core/badges/tests/test_tasks.py +0 -2
  4. udata/core/dataservices/apiv2.py +1 -1
  5. udata/core/dataset/models.py +15 -3
  6. udata/core/dataset/rdf.py +10 -14
  7. udata/core/organization/apiv2.py +1 -1
  8. udata/core/organization/models.py +25 -5
  9. udata/core/pages/models.py +49 -0
  10. udata/core/pages/tests/test_api.py +165 -1
  11. udata/core/post/api.py +1 -1
  12. udata/core/post/constants.py +8 -0
  13. udata/core/post/models.py +27 -3
  14. udata/core/post/tests/test_api.py +116 -2
  15. udata/core/post/tests/test_models.py +24 -0
  16. udata/core/reuse/apiv2.py +1 -1
  17. udata/core/user/models.py +21 -6
  18. udata/features/notifications/models.py +4 -1
  19. udata/features/transfer/actions.py +2 -0
  20. udata/features/transfer/models.py +17 -0
  21. udata/features/transfer/notifications.py +96 -0
  22. udata/harvest/backends/ckan/harvesters.py +10 -2
  23. udata/migrations/2021-08-17-harvest-integrity.py +23 -16
  24. udata/migrations/2025-12-16-create-transfer-request-notifications.py +69 -0
  25. udata/migrations/2026-01-14-add-default-kind-to-posts.py +17 -0
  26. udata/tasks.py +1 -0
  27. udata/tests/apiv2/test_dataservices.py +14 -0
  28. udata/tests/apiv2/test_organizations.py +9 -0
  29. udata/tests/apiv2/test_reuses.py +11 -0
  30. udata/tests/dataset/test_dataset_rdf.py +49 -0
  31. udata/tests/search/test_search_integration.py +37 -0
  32. udata/tests/test_transfer.py +181 -2
  33. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  34. udata/translations/ar/LC_MESSAGES/udata.po +310 -158
  35. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  36. udata/translations/de/LC_MESSAGES/udata.po +314 -160
  37. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  38. udata/translations/es/LC_MESSAGES/udata.po +313 -160
  39. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  40. udata/translations/fr/LC_MESSAGES/udata.po +476 -202
  41. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  42. udata/translations/it/LC_MESSAGES/udata.po +318 -162
  43. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  44. udata/translations/pt/LC_MESSAGES/udata.po +316 -161
  45. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  46. udata/translations/sr/LC_MESSAGES/udata.po +324 -164
  47. udata/translations/udata.pot +169 -124
  48. udata/utils.py +23 -0
  49. {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/METADATA +2 -2
  50. {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +54 -50
  51. udata/tests/apiv2/test_search.py +0 -30
  52. {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
  53. {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
  54. {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
  55. {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/top_level.txt +0 -0
udata/api_fields.py CHANGED
@@ -40,6 +40,32 @@ from udata.api import api, base_reference
40
40
  from udata.mongo.errors import FieldValidationError
41
41
  from udata.mongo.queryset import DBPaginator, UDataQuerySet
42
42
 
43
+
44
+ def required_if(**conditions):
45
+ """Check helper that makes a field required when other fields have specific values.
46
+
47
+ Usage:
48
+ page_id = field(
49
+ db.ReferenceField("Page"),
50
+ checks=[required_if(body_type="blocs")],
51
+ )
52
+ """
53
+
54
+ def check(value, data, field, obj, **_kwargs):
55
+ if value is not None:
56
+ return
57
+ for condition_field, condition_value in conditions.items():
58
+ actual_condition = data.get(condition_field, getattr(obj, condition_field, None))
59
+ if actual_condition == condition_value:
60
+ raise FieldValidationError(
61
+ f"'{field}' is required when '{condition_field}' is '{condition_value}'",
62
+ field=field,
63
+ )
64
+
65
+ check.run_even_if_missing = True
66
+ return check
67
+
68
+
43
69
  lazy_reference = api.model(
44
70
  "LazyReference",
45
71
  {
@@ -79,13 +105,15 @@ def convert_db_to_field(key, field, info) -> tuple[Callable | None, Callable | N
79
105
  user-supplied overrides, setting the readonly flag…), it's easier to have to do this only once at the end of the function.
80
106
 
81
107
  """
108
+ from udata.mongo.engine import db
109
+
82
110
  params: dict = {}
83
111
  params["required"] = field.required
84
112
 
85
113
  read_params: dict = {}
86
114
  write_params: dict = {}
87
115
 
88
- constructor: Callable
116
+ constructor: Callable | None = None
89
117
  constructor_read: Callable | None = None
90
118
  constructor_write: Callable | None = None
91
119
 
@@ -204,13 +232,34 @@ def convert_db_to_field(key, field, info) -> tuple[Callable | None, Callable | N
204
232
  def constructor_write(**kwargs):
205
233
  return restx_fields.List(field_write, **kwargs)
206
234
 
207
- elif isinstance(
208
- field, (mongo_fields.GenericReferenceField, mongoengine.fields.GenericLazyReferenceField)
209
- ):
235
+ elif isinstance(field, mongoengine.fields.GenericLazyReferenceField):
210
236
 
211
237
  def constructor(**kwargs):
212
238
  return restx_fields.Nested(lazy_reference, **kwargs)
213
239
 
240
+ elif isinstance(field, mongo_fields.GenericReferenceField):
241
+ if field.choices:
242
+ generic_fields = {}
243
+ for cls in field.choices:
244
+ cls = db.resolve_model(cls) if isinstance(cls, str) else cls
245
+ generic_fields[cls.__name__] = convert_db_to_field(
246
+ f"{key}.{cls.__name__}",
247
+ # Instead of having GenericReferenceField() we'll create fields for each
248
+ # of the subclasses with ReferenceField(Organization)…
249
+ mongoengine.fields.ReferenceField(cls),
250
+ info,
251
+ )
252
+
253
+ def constructor_read(**kwargs):
254
+ return GenericField({k: v[0].model for k, v in generic_fields.items()}, **kwargs)
255
+
256
+ def constructor_write(**kwargs):
257
+ return GenericField({k: v[1].model for k, v in generic_fields.items()}, **kwargs)
258
+ else:
259
+
260
+ def constructor(**kwargs):
261
+ return restx_fields.Nested(lazy_reference, **kwargs)
262
+
214
263
  elif isinstance(field, mongo_fields.ReferenceField | mongo_fields.LazyReferenceField):
215
264
  # For reference we accept while writing a String representing the ID of the referenced model.
216
265
  # For reading, if the user supplied a `nested_fields` (RestX model), we use it to convert
@@ -658,6 +707,19 @@ def field(
658
707
  return inner
659
708
 
660
709
 
710
+ def run_check(check, value, key, obj, data):
711
+ check(
712
+ value,
713
+ **{
714
+ "is_creation": obj._created,
715
+ "is_update": not obj._created,
716
+ "field": key,
717
+ "obj": obj,
718
+ "data": data,
719
+ },
720
+ )
721
+
722
+
661
723
  def patch(obj, request) -> type:
662
724
  """Patch the object with the data from the request.
663
725
 
@@ -667,6 +729,7 @@ def patch(obj, request) -> type:
667
729
  from udata.mongo.engine import db
668
730
 
669
731
  data = request.json if isinstance(request, Request) else request
732
+
670
733
  for key, value in data.items():
671
734
  field = obj.__write_fields__.get(key)
672
735
  if field is not None and not field.readonly:
@@ -734,23 +797,30 @@ def patch(obj, request) -> type:
734
797
 
735
798
  value = objects
736
799
 
737
- # `checks` field attribute allows to do validation from the request before setting
738
- # the attribute
800
+ # Run checks if value is modified.
801
+ # We run checks here (before setattr) to compare old vs new value.
739
802
  checks = info.get("checks", [])
740
-
741
803
  if is_value_modified(getattr(obj, key), value):
742
804
  for check in checks:
743
- check(
744
- value,
745
- **{
746
- "is_creation": obj._created,
747
- "is_update": not obj._created,
748
- "field": key,
749
- },
750
- ) # TODO add other model attributes in function parameters
805
+ run_check(check, value, key, obj, data)
751
806
 
752
807
  setattr(obj, key, value)
753
808
 
809
+ # Run checks marked with `run_even_if_missing` on fields not in request.
810
+ # Some checks (like `required_if`) need to run even when their field is absent
811
+ # from the request, because they validate cross-field constraints based on
812
+ # other fields in the request (e.g. "page_id is required if body_type is blocs").
813
+ for key, _, info in get_fields(obj.__class__):
814
+ if key in data:
815
+ continue
816
+ checks = info.get("checks", [])
817
+ value = getattr(obj, key, None)
818
+
819
+ for check in checks:
820
+ if not getattr(check, "run_even_if_missing", False):
821
+ continue
822
+ run_check(check, value, key, obj, data)
823
+
754
824
  return obj
755
825
 
756
826
 
udata/auth/forms.py CHANGED
@@ -49,7 +49,7 @@ class ExtendedRegisterForm(WithCaptcha, RegisterFormV2):
49
49
  accept_conditions = fields.BooleanField(
50
50
  _("J'accepte les conditions générales d'utilisation"),
51
51
  validators=[
52
- validators.DataRequired(message=_("Vous devez accepter les CGU pour continuer."))
52
+ validators.DataRequired(message=_("You must accept the terms of use to continue."))
53
53
  ],
54
54
  )
55
55
 
@@ -1,5 +1,3 @@
1
- import udata.core.dataservices.tasks # noqa
2
- import udata.core.dataset.tasks # noqa
3
1
  from udata.core.badges.tasks import update_badges
4
2
  from udata.core.constants import HVD
5
3
  from udata.core.dataservices.factories import DataserviceFactory
@@ -15,7 +15,7 @@ apiv2.inherit("AccessAudience (read)", AccessAudience.__read_fields__)
15
15
 
16
16
  ns = apiv2.namespace("dataservices", "Dataservice related operations")
17
17
 
18
- search_parser = DataserviceSearch.as_request_parser()
18
+ search_parser = DataserviceSearch.as_request_parser(store_missing=False)
19
19
 
20
20
 
21
21
  @ns.route("/search/", endpoint="dataservice_search")
@@ -15,17 +15,19 @@ from mongoengine.fields import DateTimeField
15
15
  from mongoengine.signals import post_save, pre_init, pre_save
16
16
  from werkzeug.utils import cached_property
17
17
 
18
- from udata.api_fields import field
18
+ from udata.api_fields import field, generate_fields
19
19
  from udata.app import cache
20
20
  from udata.core import storages
21
21
  from udata.core.access_type.constants import AccessType
22
22
  from udata.core.access_type.models import WithAccessType, check_only_one_condition_per_role
23
23
  from udata.core.activity.models import Auditable
24
24
  from udata.core.constants import HVD
25
+ from udata.core.dataset.api_fields import temporal_coverage_fields
25
26
  from udata.core.dataset.preview import TabularAPIPreview
26
27
  from udata.core.linkable import Linkable
27
28
  from udata.core.metrics.helpers import get_stock_metrics
28
29
  from udata.core.owned import Owned, OwnedQuerySet
30
+ from udata.core.spatial.api_fields import spatial_coverage_fields
29
31
  from udata.frontend.markdown import mdstrip
30
32
  from udata.i18n import lazy_gettext as _
31
33
  from udata.models import Badge, BadgeMixin, BadgesList, SpatialCoverage, WithMetrics, db
@@ -89,6 +91,7 @@ def get_json_ld_extra(key, value):
89
91
  }
90
92
 
91
93
 
94
+ @generate_fields()
92
95
  class HarvestDatasetMetadata(db.EmbeddedDocument):
93
96
  backend = db.StringField()
94
97
  created_at = db.DateTimeField()
@@ -114,6 +117,7 @@ class HarvestResourceMetadata(db.EmbeddedDocument):
114
117
  dct_identifier = db.StringField()
115
118
 
116
119
 
120
+ @generate_fields()
117
121
  class Schema(db.EmbeddedDocument):
118
122
  """
119
123
  Schema can only be two things right now:
@@ -482,6 +486,7 @@ class ResourceMixin(object):
482
486
  return result
483
487
 
484
488
 
489
+ @generate_fields()
485
490
  class Resource(ResourceMixin, WithMetrics, db.EmbeddedDocument):
486
491
  """
487
492
  Local file, remote file or API provided by the original provider of the
@@ -533,6 +538,7 @@ class DatasetBadgeMixin(BadgeMixin):
533
538
  __badges__ = BADGES
534
539
 
535
540
 
541
+ @generate_fields()
536
542
  class Dataset(
537
543
  Auditable, WithMetrics, WithAccessType, DatasetBadgeMixin, Owned, Linkable, db.Document
538
544
  ):
@@ -560,8 +566,14 @@ class Dataset(
560
566
 
561
567
  frequency = field(db.EnumField(UpdateFrequency))
562
568
  frequency_date = field(db.DateTimeField(verbose_name=_("Future date of update")))
563
- temporal_coverage = field(db.EmbeddedDocumentField(db.DateRange))
564
- spatial = field(db.EmbeddedDocumentField(SpatialCoverage))
569
+ temporal_coverage = field(
570
+ db.EmbeddedDocumentField(db.DateRange),
571
+ nested_fields=temporal_coverage_fields,
572
+ )
573
+ spatial = field(
574
+ db.EmbeddedDocumentField(SpatialCoverage),
575
+ nested_fields=spatial_coverage_fields,
576
+ )
565
577
  schema = field(db.EmbeddedDocumentField(Schema))
566
578
 
567
579
  ext = field(db.MapField(db.GenericEmbeddedDocumentField()), auditable=False)
udata/core/dataset/rdf.py CHANGED
@@ -5,7 +5,7 @@ This module centralize dataset helpers for RDF/DCAT serialization and parsing
5
5
  import calendar
6
6
  import json
7
7
  import logging
8
- from datetime import date, datetime
8
+ from datetime import date
9
9
 
10
10
  from dateutil.parser import parse as parse_dt
11
11
  from flask import current_app
@@ -51,7 +51,7 @@ from udata.rdf import (
51
51
  themes_from_rdf,
52
52
  url_from_rdf,
53
53
  )
54
- from udata.utils import get_by, safe_unicode, to_naive_datetime
54
+ from udata.utils import get_by, safe_harvest_datetime, safe_unicode
55
55
 
56
56
  from .constants import OGC_SERVICE_FORMATS, UpdateFrequency
57
57
  from .models import Checksum, Dataset, License, Resource
@@ -729,12 +729,10 @@ def resource_from_rdf(graph_or_distrib, dataset=None, is_additionnal=False):
729
729
  resource.harvest = HarvestResourceMetadata()
730
730
  resource.harvest.issued_at = issued_at
731
731
 
732
- # In the past, we've encountered future `modified_at` during harvesting
733
- # do not save it. :FutureHarvestModifiedAt
734
- if modified_at and to_naive_datetime(modified_at) > datetime.utcnow():
735
- log.warning(f"Future `DCT.modified` date '{modified_at}' in resource")
736
- else:
737
- resource.harvest.modified_at = modified_at
732
+ # :FutureHarvestModifiedAt
733
+ resource.harvest.modified_at = safe_harvest_datetime(
734
+ modified_at, "DCT.modified (resource)", refuse_future=True
735
+ )
738
736
 
739
737
  resource.harvest.dct_identifier = identifier
740
738
  resource.harvest.uri = uri
@@ -845,12 +843,10 @@ def dataset_from_rdf(
845
843
  dataset.harvest.created_at = created_at
846
844
  dataset.harvest.issued_at = issued_at
847
845
 
848
- # In the past, we've encountered future `modified_at` during harvesting
849
- # do not save it. :FutureHarvestModifiedAt
850
- if modified_at and to_naive_datetime(modified_at) > datetime.utcnow():
851
- log.warning(f"Future `DCT.modified` date '{modified_at}' in dataset")
852
- else:
853
- dataset.harvest.modified_at = modified_at
846
+ # :FutureHarvestModifiedAt
847
+ dataset.harvest.modified_at = safe_harvest_datetime(
848
+ modified_at, "DCT.modified (dataset)", refuse_future=True
849
+ )
854
850
 
855
851
  return dataset
856
852
 
@@ -15,7 +15,7 @@ apiv2.inherit("ContactPoint", contact_point_fields)
15
15
 
16
16
 
17
17
  ns = apiv2.namespace("organizations", "Organization related operations")
18
- search_parser = OrganizationSearch.as_request_parser()
18
+ search_parser = OrganizationSearch.as_request_parser(store_missing=False)
19
19
 
20
20
  DEFAULT_SORTING = "-created_at"
21
21
 
@@ -7,7 +7,7 @@ from flask_babel import LazyString
7
7
  from mongoengine.signals import post_save, pre_save
8
8
  from werkzeug.utils import cached_property
9
9
 
10
- from udata.api_fields import field
10
+ from udata.api_fields import field, generate_fields
11
11
  from udata.core.activity.models import Auditable
12
12
  from udata.core.badges.models import Badge, BadgeMixin, BadgesList
13
13
  from udata.core.linkable import Linkable
@@ -21,6 +21,7 @@ from udata.uris import cdata_url
21
21
 
22
22
  from .constants import (
23
23
  ASSOCIATION,
24
+ BIGGEST_LOGO_SIZE,
24
25
  CERTIFIED,
25
26
  COMPANY,
26
27
  DEFAULT_ROLE,
@@ -44,6 +45,7 @@ BADGES: dict[str, LazyString] = {
44
45
  }
45
46
 
46
47
 
48
+ @generate_fields()
47
49
  class Team(db.EmbeddedDocument):
48
50
  name = db.StringField(required=True)
49
51
  slug = db.SlugField(
@@ -54,6 +56,7 @@ class Team(db.EmbeddedDocument):
54
56
  members = db.ListField(db.ReferenceField("User"))
55
57
 
56
58
 
59
+ @generate_fields()
57
60
  class Member(db.EmbeddedDocument):
58
61
  user = db.ReferenceField("User")
59
62
  role = db.StringField(choices=list(ORG_ROLES), default=DEFAULT_ROLE)
@@ -64,6 +67,7 @@ class Member(db.EmbeddedDocument):
64
67
  return ORG_ROLES[self.role]
65
68
 
66
69
 
70
+ @generate_fields()
67
71
  class MembershipRequest(db.EmbeddedDocument):
68
72
  """
69
73
  Pending organization membership requests
@@ -113,18 +117,22 @@ class OrganizationBadge(Badge):
113
117
 
114
118
 
115
119
  class OrganizationBadgeMixin(BadgeMixin):
116
- badges = field(BadgesList(OrganizationBadge), **BadgeMixin.default_badges_list_params)
120
+ badges = field(
121
+ BadgesList(OrganizationBadge), show_as_ref=True, **BadgeMixin.default_badges_list_params
122
+ )
117
123
  __badges__ = BADGES
118
124
 
119
125
 
126
+ @generate_fields()
120
127
  class Organization(
121
128
  Auditable, WithMetrics, OrganizationBadgeMixin, Linkable, db.Datetimed, db.Document
122
129
  ):
123
- name = field(db.StringField(required=True))
124
- acronym = field(db.StringField(max_length=128))
130
+ name = field(db.StringField(required=True), show_as_ref=True)
131
+ acronym = field(db.StringField(max_length=128), show_as_ref=True)
125
132
  slug = field(
126
133
  db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
127
134
  auditable=False,
135
+ show_as_ref=True,
128
136
  )
129
137
  description = field(
130
138
  db.StringField(required=True),
@@ -138,7 +146,11 @@ class Organization(
138
146
  basename=default_image_basename,
139
147
  max_size=LOGO_MAX_SIZE,
140
148
  thumbnails=LOGO_SIZES,
141
- )
149
+ ),
150
+ show_as_ref=True,
151
+ thumbnail_info={
152
+ "size": BIGGEST_LOGO_SIZE,
153
+ },
142
154
  )
143
155
  business_number_id = field(db.StringField(max_length=ORG_BID_SIZE_LIMIT))
144
156
 
@@ -265,6 +277,14 @@ class Organization(
265
277
  return request
266
278
  return None
267
279
 
280
+ @field(description="Link to the API endpoint for this organization", show_as_ref=True)
281
+ def uri(self, *args, **kwargs):
282
+ return self.self_api_url(*args, **kwargs)
283
+
284
+ @field(description="Link to the udata web page for this organization", show_as_ref=True)
285
+ def page(self, *args, **kwargs):
286
+ return self.self_web_url(*args, **kwargs)
287
+
268
288
  @classmethod
269
289
  def get(cls, id_or_slug):
270
290
  obj = cls.objects(slug=id_or_slug).first()
@@ -83,6 +83,55 @@ class LinksListBloc(BlocWithTitleMixin, Bloc):
83
83
  links = field(db.EmbeddedDocumentListField(LinkInBloc))
84
84
 
85
85
 
86
+ HERO_COLORS = ("primary", "green", "purple")
87
+
88
+
89
+ @generate_fields()
90
+ class HeroBloc(Bloc):
91
+ title = field(db.StringField(required=True))
92
+ description = field(db.StringField())
93
+ color = field(db.StringField(choices=HERO_COLORS))
94
+
95
+
96
+ @generate_fields()
97
+ class MarkdownBloc(Bloc):
98
+ # Not using BlocWithTitleMixin because title should be optional here
99
+ title = field(db.StringField())
100
+ subtitle = field(db.StringField())
101
+ content = field(
102
+ db.StringField(required=True),
103
+ markdown=True,
104
+ )
105
+
106
+
107
+ BLOCS_DISALLOWED_IN_ACCORDION = ("AccordionListBloc", "HeroBloc")
108
+
109
+
110
+ def check_no_recursive_blocs(blocs, **kwargs):
111
+ for bloc in blocs:
112
+ if bloc.__class__.__name__ in BLOCS_DISALLOWED_IN_ACCORDION:
113
+ raise db.ValidationError(
114
+ f"{bloc.__class__.__name__} cannot be nested inside an accordion"
115
+ )
116
+
117
+
118
+ @generate_fields()
119
+ class AccordionItemBloc(db.EmbeddedDocument):
120
+ title = field(db.StringField(required=True))
121
+ content = field(
122
+ db.EmbeddedDocumentListField(Bloc),
123
+ generic=True,
124
+ checks=[check_no_recursive_blocs],
125
+ )
126
+
127
+
128
+ @generate_fields()
129
+ class AccordionListBloc(Bloc):
130
+ title = field(db.StringField())
131
+ description = field(db.StringField())
132
+ items = field(db.EmbeddedDocumentListField(AccordionItemBloc))
133
+
134
+
86
135
  @generate_fields()
87
136
  class Page(Auditable, Owned, Datetimed, db.Document):
88
137
  blocs = field(
@@ -2,7 +2,7 @@ from flask import url_for
2
2
 
3
3
  from udata.core.dataset import tasks
4
4
  from udata.core.dataset.factories import DatasetFactory
5
- from udata.core.pages.models import Page
5
+ from udata.core.pages.models import AccordionItemBloc, AccordionListBloc, Page
6
6
  from udata.core.user.factories import AdminFactory
7
7
  from udata.tests.api import APITestCase
8
8
 
@@ -103,3 +103,167 @@ class PageAPITest(APITestCase):
103
103
 
104
104
  response = self.get(url_for("api.page", page=page_id))
105
105
  self.assert200(response)
106
+
107
+ def test_hero_bloc(self):
108
+ self.login()
109
+
110
+ response = self.post(
111
+ url_for("api.pages"),
112
+ {
113
+ "blocs": [
114
+ {
115
+ "class": "HeroBloc",
116
+ "title": "Welcome to our portal",
117
+ "description": "Discover our datasets",
118
+ "color": "primary",
119
+ }
120
+ ],
121
+ },
122
+ )
123
+ self.assert201(response)
124
+
125
+ self.assertEqual("HeroBloc", response.json["blocs"][0]["class"])
126
+ self.assertEqual("Welcome to our portal", response.json["blocs"][0]["title"])
127
+ self.assertEqual("Discover our datasets", response.json["blocs"][0]["description"])
128
+ self.assertEqual("primary", response.json["blocs"][0]["color"])
129
+
130
+ page = Page.objects().first()
131
+ response = self.get(url_for("api.page", page=page))
132
+ self.assert200(response)
133
+
134
+ self.assertEqual("HeroBloc", response.json["blocs"][0]["class"])
135
+ self.assertEqual("Welcome to our portal", response.json["blocs"][0]["title"])
136
+ self.assertEqual("Discover our datasets", response.json["blocs"][0]["description"])
137
+ self.assertEqual("primary", response.json["blocs"][0]["color"])
138
+
139
+ def test_accordion_bloc(self):
140
+ self.login()
141
+ datasets = DatasetFactory.create_batch(2)
142
+
143
+ response = self.post(
144
+ url_for("api.pages"),
145
+ {
146
+ "blocs": [
147
+ {
148
+ "class": "AccordionListBloc",
149
+ "title": "FAQ",
150
+ "description": "Frequently asked questions",
151
+ "items": [
152
+ {
153
+ "title": "What is udata?",
154
+ "content": [
155
+ {
156
+ "class": "LinksListBloc",
157
+ "title": "Related links",
158
+ "links": [
159
+ {
160
+ "title": "Documentation",
161
+ "url": "https://doc.data.gouv.fr",
162
+ },
163
+ ],
164
+ }
165
+ ],
166
+ },
167
+ {
168
+ "title": "How to use datasets?",
169
+ "content": [
170
+ {
171
+ "class": "DatasetsListBloc",
172
+ "title": "Example datasets",
173
+ "datasets": [str(d.id) for d in datasets],
174
+ }
175
+ ],
176
+ },
177
+ {
178
+ "title": "What is markdown?",
179
+ "content": [
180
+ {
181
+ "class": "MarkdownBloc",
182
+ "content": "# Hello\n\nThis is **bold** text.",
183
+ }
184
+ ],
185
+ },
186
+ ],
187
+ }
188
+ ],
189
+ },
190
+ )
191
+ self.assert201(response)
192
+
193
+ bloc = response.json["blocs"][0]
194
+ self.assertEqual("AccordionListBloc", bloc["class"])
195
+ self.assertEqual("FAQ", bloc["title"])
196
+ self.assertEqual("Frequently asked questions", bloc["description"])
197
+ self.assertEqual(3, len(bloc["items"]))
198
+
199
+ self.assertEqual("What is udata?", bloc["items"][0]["title"])
200
+ self.assertEqual("LinksListBloc", bloc["items"][0]["content"][0]["class"])
201
+ self.assertEqual("Documentation", bloc["items"][0]["content"][0]["links"][0]["title"])
202
+
203
+ self.assertEqual("How to use datasets?", bloc["items"][1]["title"])
204
+ self.assertEqual("DatasetsListBloc", bloc["items"][1]["content"][0]["class"])
205
+ self.assertEqual(2, len(bloc["items"][1]["content"][0]["datasets"]))
206
+
207
+ self.assertEqual("What is markdown?", bloc["items"][2]["title"])
208
+ self.assertEqual("MarkdownBloc", bloc["items"][2]["content"][0]["class"])
209
+
210
+ page = Page.objects().first()
211
+ self.assertIsInstance(page.blocs[0], AccordionListBloc)
212
+ self.assertIsInstance(page.blocs[0].items[0], AccordionItemBloc)
213
+ self.assertEqual("What is udata?", page.blocs[0].items[0].title)
214
+
215
+ response = self.get(url_for("api.page", page=page))
216
+ self.assert200(response)
217
+ self.assertEqual("AccordionListBloc", response.json["blocs"][0]["class"])
218
+ self.assertEqual(3, len(response.json["blocs"][0]["items"]))
219
+
220
+ response = self.put(
221
+ url_for("api.page", page=page),
222
+ {
223
+ "blocs": [
224
+ {
225
+ "class": "AccordionListBloc",
226
+ "title": "Updated FAQ",
227
+ "items": [
228
+ {
229
+ "title": "Single question",
230
+ "content": [],
231
+ }
232
+ ],
233
+ }
234
+ ],
235
+ },
236
+ )
237
+ self.assert200(response)
238
+ self.assertEqual("Updated FAQ", response.json["blocs"][0]["title"])
239
+ self.assertIsNone(response.json["blocs"][0]["description"])
240
+ self.assertEqual(1, len(response.json["blocs"][0]["items"]))
241
+ self.assertEqual("Single question", response.json["blocs"][0]["items"][0]["title"])
242
+
243
+ def test_accordion_bloc_cannot_be_nested(self):
244
+ self.login()
245
+
246
+ response = self.post(
247
+ url_for("api.pages"),
248
+ {
249
+ "blocs": [
250
+ {
251
+ "class": "AccordionListBloc",
252
+ "title": "FAQ",
253
+ "items": [
254
+ {
255
+ "title": "Question",
256
+ "content": [
257
+ {
258
+ "class": "AccordionListBloc",
259
+ "title": "Nested accordion",
260
+ "items": [],
261
+ }
262
+ ],
263
+ },
264
+ ],
265
+ }
266
+ ],
267
+ },
268
+ )
269
+ self.assert400(response)
udata/core/post/api.py CHANGED
@@ -76,7 +76,7 @@ class PostsAtomFeedAPI(API):
76
76
  link=request.url_root,
77
77
  )
78
78
 
79
- 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)
80
80
  for post in posts:
81
81
  feed.add_item(
82
82
  post.name,
@@ -8,5 +8,13 @@ BODY_TYPES = OrderedDict(
8
8
  [
9
9
  ("markdown", _("Markdown")),
10
10
  ("html", _("HTML")),
11
+ ("blocs", _("Blocs")),
12
+ ]
13
+ )
14
+
15
+ POST_KINDS = OrderedDict(
16
+ [
17
+ ("news", _("News")),
18
+ ("page", _("Page")),
11
19
  ]
12
20
  )