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.
- udata/api_fields.py +85 -15
- udata/auth/forms.py +1 -1
- udata/core/badges/tests/test_tasks.py +0 -2
- udata/core/dataservices/apiv2.py +1 -1
- udata/core/dataset/models.py +15 -3
- udata/core/dataset/rdf.py +10 -14
- udata/core/organization/apiv2.py +1 -1
- udata/core/organization/models.py +25 -5
- udata/core/pages/models.py +49 -0
- udata/core/pages/tests/test_api.py +165 -1
- udata/core/post/api.py +1 -1
- udata/core/post/constants.py +8 -0
- udata/core/post/models.py +27 -3
- udata/core/post/tests/test_api.py +116 -2
- udata/core/post/tests/test_models.py +24 -0
- udata/core/reuse/apiv2.py +1 -1
- udata/core/user/models.py +21 -6
- udata/features/notifications/models.py +4 -1
- udata/features/transfer/actions.py +2 -0
- udata/features/transfer/models.py +17 -0
- udata/features/transfer/notifications.py +96 -0
- udata/harvest/backends/ckan/harvesters.py +10 -2
- udata/migrations/2021-08-17-harvest-integrity.py +23 -16
- 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/tasks.py +1 -0
- 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/dataset/test_dataset_rdf.py +49 -0
- udata/tests/search/test_search_integration.py +37 -0
- udata/tests/test_transfer.py +181 -2
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +310 -158
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +314 -160
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +313 -160
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +476 -202
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +318 -162
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +316 -161
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +324 -164
- udata/translations/udata.pot +169 -124
- udata/utils.py +23 -0
- {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/METADATA +2 -2
- {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +54 -50
- udata/tests/apiv2/test_search.py +0 -30
- {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
- {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
- {udata-14.5.1.dev9.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
#
|
|
738
|
-
#
|
|
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=_("
|
|
52
|
+
validators.DataRequired(message=_("You must accept the terms of use to continue."))
|
|
53
53
|
],
|
|
54
54
|
)
|
|
55
55
|
|
udata/core/dataservices/apiv2.py
CHANGED
|
@@ -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")
|
udata/core/dataset/models.py
CHANGED
|
@@ -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(
|
|
564
|
-
|
|
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
|
|
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,
|
|
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
|
-
#
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
#
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
udata/core/organization/apiv2.py
CHANGED
|
@@ -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(
|
|
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()
|
udata/core/pages/models.py
CHANGED
|
@@ -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,
|