udata 14.0.3.dev1__py3-none-any.whl → 14.7.3.dev4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- udata/api/__init__.py +2 -0
- udata/api_fields.py +120 -19
- udata/app.py +18 -20
- udata/auth/__init__.py +4 -7
- udata/auth/forms.py +3 -3
- udata/auth/views.py +13 -6
- udata/commands/dcat.py +1 -1
- udata/commands/serve.py +3 -11
- udata/core/activity/api.py +5 -6
- udata/core/badges/tests/test_tasks.py +0 -2
- udata/core/csv.py +5 -0
- udata/core/dataservices/api.py +8 -1
- udata/core/dataservices/apiv2.py +3 -6
- udata/core/dataservices/models.py +5 -2
- udata/core/dataservices/rdf.py +2 -1
- udata/core/dataservices/tasks.py +6 -2
- udata/core/dataset/api.py +30 -4
- udata/core/dataset/api_fields.py +1 -1
- udata/core/dataset/apiv2.py +1 -1
- udata/core/dataset/constants.py +2 -9
- udata/core/dataset/models.py +21 -9
- udata/core/dataset/permissions.py +31 -0
- udata/core/dataset/rdf.py +18 -16
- udata/core/dataset/tasks.py +16 -7
- udata/core/discussions/api.py +15 -1
- udata/core/discussions/models.py +6 -0
- udata/core/legal/__init__.py +0 -0
- udata/core/legal/mails.py +128 -0
- udata/core/organization/api.py +16 -5
- udata/core/organization/api_fields.py +3 -3
- udata/core/organization/apiv2.py +3 -4
- udata/core/organization/mails.py +1 -1
- udata/core/organization/models.py +40 -7
- udata/core/organization/notifications.py +84 -0
- udata/core/organization/permissions.py +1 -1
- udata/core/organization/tasks.py +3 -0
- udata/core/pages/models.py +49 -0
- udata/core/pages/tests/test_api.py +165 -1
- udata/core/post/api.py +25 -70
- udata/core/post/constants.py +8 -0
- udata/core/post/models.py +109 -17
- udata/core/post/tests/test_api.py +140 -3
- udata/core/post/tests/test_models.py +24 -0
- udata/core/reports/api.py +18 -0
- udata/core/reports/models.py +42 -2
- udata/core/reuse/api.py +8 -0
- udata/core/reuse/apiv2.py +3 -6
- udata/core/reuse/models.py +1 -1
- udata/core/spatial/forms.py +2 -2
- udata/core/topic/models.py +8 -2
- udata/core/user/api.py +10 -3
- udata/core/user/api_fields.py +3 -3
- udata/core/user/models.py +33 -8
- udata/features/notifications/api.py +7 -18
- udata/features/notifications/models.py +59 -0
- udata/features/notifications/tasks.py +25 -0
- udata/features/transfer/actions.py +2 -0
- udata/features/transfer/models.py +17 -0
- udata/features/transfer/notifications.py +96 -0
- udata/flask_mongoengine/engine.py +0 -4
- udata/flask_mongoengine/pagination.py +1 -1
- udata/frontend/markdown.py +2 -1
- udata/harvest/actions.py +20 -0
- udata/harvest/api.py +24 -7
- udata/harvest/backends/base.py +27 -1
- udata/harvest/backends/ckan/harvesters.py +21 -4
- udata/harvest/backends/dcat.py +4 -1
- udata/harvest/commands.py +33 -0
- udata/harvest/filters.py +17 -6
- udata/harvest/models.py +16 -0
- udata/harvest/permissions.py +27 -0
- udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
- udata/harvest/tests/test_actions.py +46 -2
- udata/harvest/tests/test_api.py +161 -6
- udata/harvest/tests/test_base_backend.py +86 -1
- udata/harvest/tests/test_dcat_backend.py +68 -3
- udata/harvest/tests/test_filters.py +6 -0
- udata/i18n.py +1 -4
- udata/mail.py +14 -0
- udata/migrations/2021-08-17-harvest-integrity.py +23 -16
- udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
- udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
- udata/migrations/2025-12-16-create-transfer-request-notifications.py +69 -0
- udata/migrations/2026-01-14-add-default-kind-to-posts.py +17 -0
- udata/mongo/slug_fields.py +1 -1
- udata/rdf.py +65 -11
- udata/routing.py +2 -2
- udata/settings.py +11 -0
- udata/tasks.py +2 -0
- udata/templates/mail/message.html +3 -1
- udata/tests/api/__init__.py +7 -17
- udata/tests/api/test_activities_api.py +36 -0
- udata/tests/api/test_datasets_api.py +69 -0
- udata/tests/api/test_organizations_api.py +0 -3
- udata/tests/api/test_reports_api.py +157 -0
- udata/tests/api/test_user_api.py +1 -1
- udata/tests/apiv2/test_dataservices.py +14 -0
- udata/tests/apiv2/test_organizations.py +9 -0
- udata/tests/apiv2/test_reuses.py +11 -0
- udata/tests/cli/test_cli_base.py +0 -1
- udata/tests/dataservice/test_dataservice_tasks.py +29 -0
- udata/tests/dataset/test_dataset_model.py +13 -1
- udata/tests/dataset/test_dataset_rdf.py +164 -5
- udata/tests/dataset/test_dataset_tasks.py +25 -0
- udata/tests/frontend/test_auth.py +58 -1
- udata/tests/frontend/test_csv.py +0 -3
- udata/tests/helpers.py +31 -27
- udata/tests/organization/test_notifications.py +67 -2
- udata/tests/search/test_search_integration.py +70 -0
- udata/tests/site/test_site_csv_exports.py +22 -10
- udata/tests/test_activity.py +9 -9
- udata/tests/test_api_fields.py +10 -0
- udata/tests/test_discussions.py +5 -5
- udata/tests/test_legal_mails.py +359 -0
- udata/tests/test_notifications.py +15 -57
- udata/tests/test_notifications_task.py +43 -0
- udata/tests/test_owned.py +81 -1
- udata/tests/test_transfer.py +181 -2
- udata/tests/test_uris.py +33 -0
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +309 -158
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +313 -160
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +312 -160
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +475 -202
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +317 -162
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +315 -161
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +323 -164
- udata/translations/udata.pot +169 -124
- udata/uris.py +0 -2
- udata/utils.py +23 -0
- udata-14.7.3.dev4.dist-info/METADATA +109 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +142 -135
- udata/core/post/forms.py +0 -30
- udata/flask_mongoengine/json.py +0 -38
- udata/templates/mail/base.html +0 -105
- udata/templates/mail/base.txt +0 -6
- udata/templates/mail/button.html +0 -3
- udata/templates/mail/layouts/1-column.html +0 -19
- udata/templates/mail/layouts/2-columns.html +0 -20
- udata/templates/mail/layouts/center-panel.html +0 -16
- udata-14.0.3.dev1.dist-info/METADATA +0 -132
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Create TransferRequestNotification for all pending transfer requests
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from udata.features.notifications.models import Notification
|
|
10
|
+
from udata.features.transfer.models import Transfer
|
|
11
|
+
from udata.features.transfer.notifications import TransferRequestNotificationDetails
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def migrate(db):
|
|
17
|
+
log.info("Processing pending transfer requests...")
|
|
18
|
+
|
|
19
|
+
created_count = 0
|
|
20
|
+
|
|
21
|
+
# Get all pending transfers
|
|
22
|
+
transfers = Transfer.objects(status="pending")
|
|
23
|
+
|
|
24
|
+
with click.progressbar(transfers, length=transfers.count()) as transfer_list:
|
|
25
|
+
for transfer in transfer_list:
|
|
26
|
+
try:
|
|
27
|
+
# Get the recipient (could be a user or an organization)
|
|
28
|
+
recipient = transfer.recipient
|
|
29
|
+
|
|
30
|
+
# For organizations, we need to find admins who should receive notifications
|
|
31
|
+
if recipient._cls == "Organization":
|
|
32
|
+
# Get all admin users for this organization
|
|
33
|
+
recipient_users = [
|
|
34
|
+
member.user for member in recipient.members if member.role == "admin"
|
|
35
|
+
]
|
|
36
|
+
else:
|
|
37
|
+
# For users, just use the recipient directly
|
|
38
|
+
recipient_users = [recipient]
|
|
39
|
+
|
|
40
|
+
# Create a notification for each recipient user
|
|
41
|
+
for recipient_user in recipient_users:
|
|
42
|
+
try:
|
|
43
|
+
# Check if notification already exists
|
|
44
|
+
existing = Notification.objects(
|
|
45
|
+
user=recipient_user,
|
|
46
|
+
details__transfer_recipient=recipient,
|
|
47
|
+
details__transfer_owner=transfer.owner,
|
|
48
|
+
details__transfer_subject=transfer.subject,
|
|
49
|
+
).first()
|
|
50
|
+
if not existing:
|
|
51
|
+
notification = Notification(user=recipient_user)
|
|
52
|
+
notification.details = TransferRequestNotificationDetails(
|
|
53
|
+
transfer_owner=transfer.owner,
|
|
54
|
+
transfer_recipient=recipient,
|
|
55
|
+
transfer_subject=transfer.subject,
|
|
56
|
+
)
|
|
57
|
+
# Set the created_at to match the transfer creation date
|
|
58
|
+
notification.created_at = transfer.created
|
|
59
|
+
notification.save()
|
|
60
|
+
created_count += 1
|
|
61
|
+
except Exception as e:
|
|
62
|
+
log.error(
|
|
63
|
+
f"Error creating notification for user {recipient_user.id} "
|
|
64
|
+
f"and transfer {transfer.id}: {e}"
|
|
65
|
+
)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
log.error(f"Error creating notification for transfer {transfer.id}: {e}")
|
|
68
|
+
|
|
69
|
+
log.info(f"Created {created_count} TransferRequestNotifications")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This migration sets Post.kind to "news" for posts that don't have a kind.
|
|
3
|
+
This is necessary because the default value is only applied to new documents,
|
|
4
|
+
not existing ones.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from udata.models import Post
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def migrate(db):
|
|
15
|
+
log.info("Processing posts without kind...")
|
|
16
|
+
count = Post.objects(kind__exists=False).update(kind="news")
|
|
17
|
+
log.info(f"\tSet kind='news' for {count} posts")
|
udata/mongo/slug_fields.py
CHANGED
|
@@ -180,7 +180,7 @@ def populate_slug(instance, field):
|
|
|
180
180
|
return qs(**{field.db_field: slug}).clear_cls_query().limit(1).count(True) > 0
|
|
181
181
|
|
|
182
182
|
def get_existing_slug_suffixes(slug):
|
|
183
|
-
qs_suffix = qs(slug__regex=
|
|
183
|
+
qs_suffix = qs(slug__regex=rf"^{slug}-\d*$").clear_cls_query().only(field.db_field)
|
|
184
184
|
return [getattr(obj, field.db_field) for obj in qs_suffix]
|
|
185
185
|
|
|
186
186
|
def trim_base_slug(base_slug, index):
|
udata/rdf.py
CHANGED
|
@@ -5,6 +5,7 @@ This module centralize udata-wide RDF helpers and configuration
|
|
|
5
5
|
import logging
|
|
6
6
|
import re
|
|
7
7
|
from html.parser import HTMLParser
|
|
8
|
+
from urllib.parse import quote
|
|
8
9
|
|
|
9
10
|
import mongoengine
|
|
10
11
|
from flask import abort, current_app, request, url_for
|
|
@@ -12,6 +13,7 @@ from rdflib import BNode, Graph, Literal, URIRef
|
|
|
12
13
|
from rdflib.namespace import (
|
|
13
14
|
DCTERMS,
|
|
14
15
|
FOAF,
|
|
16
|
+
ORG,
|
|
15
17
|
RDF,
|
|
16
18
|
RDFS,
|
|
17
19
|
SKOS,
|
|
@@ -20,6 +22,7 @@ from rdflib.namespace import (
|
|
|
20
22
|
NamespaceManager,
|
|
21
23
|
)
|
|
22
24
|
from rdflib.resource import Resource as RdfResource
|
|
25
|
+
from rdflib.term import _is_valid_uri
|
|
23
26
|
from rdflib.util import SUFFIX_FORMAT_MAP
|
|
24
27
|
from rdflib.util import guess_format as raw_guess_format
|
|
25
28
|
|
|
@@ -353,12 +356,24 @@ def theme_labels_from_rdf(rdf):
|
|
|
353
356
|
|
|
354
357
|
|
|
355
358
|
def themes_from_rdf(rdf):
|
|
356
|
-
tags = [
|
|
359
|
+
tags = []
|
|
360
|
+
for tag in rdf.objects(DCAT.keyword):
|
|
361
|
+
if isinstance(tag, RdfResource):
|
|
362
|
+
# dcat:keyword should be Literal, not a Resource/URIRef
|
|
363
|
+
log.warning(f"Ignoring dcat:keyword with URI value: {tag.identifier}")
|
|
364
|
+
continue
|
|
365
|
+
tags.append(tag.toPython())
|
|
357
366
|
tags += theme_labels_from_rdf(rdf)
|
|
358
367
|
return list(set(tags))
|
|
359
368
|
|
|
360
369
|
|
|
361
|
-
def
|
|
370
|
+
def contact_point_name(agent_name: str | None, org_name: str | None) -> str:
|
|
371
|
+
if agent_name and org_name:
|
|
372
|
+
return f"{agent_name} ({org_name})"
|
|
373
|
+
return agent_name or org_name or ""
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def contact_points_from_rdf(rdf, prop, role, dataset, dryrun=False):
|
|
362
377
|
if not dataset.organization and not dataset.owner:
|
|
363
378
|
return
|
|
364
379
|
for contact_point in rdf.objects(prop):
|
|
@@ -369,7 +384,10 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
|
369
384
|
email = None
|
|
370
385
|
contact_form = None
|
|
371
386
|
elif prop == DCAT.contactPoint: # Could be split on the type of contact_point instead
|
|
372
|
-
name =
|
|
387
|
+
name = contact_point_name(
|
|
388
|
+
rdf_value(contact_point, VCARD.fn),
|
|
389
|
+
rdf_value(contact_point, VCARD["organization-name"]),
|
|
390
|
+
)
|
|
373
391
|
email = (
|
|
374
392
|
rdf_value(contact_point, VCARD.hasEmail)
|
|
375
393
|
or rdf_value(contact_point, VCARD.email)
|
|
@@ -378,12 +396,16 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
|
378
396
|
email = email.replace("mailto:", "").strip() if email else None
|
|
379
397
|
contact_form = rdf_value(contact_point, VCARD.hasUrl)
|
|
380
398
|
else:
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
or rdf_value(contact_point, SKOS.prefLabel)
|
|
384
|
-
|
|
399
|
+
contact_point_org = contact_point.value(ORG.memberOf)
|
|
400
|
+
name = contact_point_name(
|
|
401
|
+
rdf_value(contact_point, FOAF.name) or rdf_value(contact_point, SKOS.prefLabel),
|
|
402
|
+
rdf_value(contact_point_org, FOAF.name) if contact_point_org else None,
|
|
403
|
+
)
|
|
404
|
+
email = (
|
|
405
|
+
rdf_value(contact_point, FOAF.mbox)
|
|
406
|
+
or (contact_point_org and rdf_value(contact_point_org, FOAF.mbox))
|
|
407
|
+
or None
|
|
385
408
|
)
|
|
386
|
-
email = rdf_value(contact_point, FOAF.mbox)
|
|
387
409
|
email = email.replace("mailto:", "").strip() if email else None
|
|
388
410
|
contact_form = None
|
|
389
411
|
|
|
@@ -398,9 +420,18 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
|
398
420
|
else:
|
|
399
421
|
org_or_owner = {"owner": dataset.owner}
|
|
400
422
|
try:
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
423
|
+
if dryrun:
|
|
424
|
+
# In dryrun mode, only reuse existing contact points, don't create new ones.
|
|
425
|
+
# Mongoengine doesn't allow referencing unsaved documents.
|
|
426
|
+
contact = ContactPoint.objects.filter(
|
|
427
|
+
name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
|
|
428
|
+
).first()
|
|
429
|
+
if not contact:
|
|
430
|
+
continue
|
|
431
|
+
else:
|
|
432
|
+
contact, _ = ContactPoint.objects.get_or_create(
|
|
433
|
+
name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
|
|
434
|
+
)
|
|
404
435
|
except mongoengine.errors.ValidationError as validation_error:
|
|
405
436
|
log.warning(f"Unable to validate contact point: {validation_error}", exc_info=True)
|
|
406
437
|
continue
|
|
@@ -568,6 +599,27 @@ def paginate_catalog(catalog, graph, datasets, _format, rdf_catalog_endpoint, **
|
|
|
568
599
|
return catalog
|
|
569
600
|
|
|
570
601
|
|
|
602
|
+
def escape_uri_in_graph(graph: Graph) -> Graph:
|
|
603
|
+
"""
|
|
604
|
+
Some invalid uri could exist in the graph and they can't be serialized in N3/Turtle.
|
|
605
|
+
We use a urllib.parse.quote to escape these at best for invalid URIRef.
|
|
606
|
+
"""
|
|
607
|
+
escaped_graph = Graph()
|
|
608
|
+
for s, p, o in graph:
|
|
609
|
+
try:
|
|
610
|
+
if isinstance(s, URIRef) and not _is_valid_uri(str(s)):
|
|
611
|
+
encoded_uri = quote(str(s), safe=":/?#[]@!$&'()*+,;=")
|
|
612
|
+
s = URIRef(encoded_uri)
|
|
613
|
+
if isinstance(o, URIRef) and not _is_valid_uri(str(o)):
|
|
614
|
+
encoded_uri = quote(str(o), safe=":/?#[]@!$&'()*+,;=")
|
|
615
|
+
o = URIRef(encoded_uri)
|
|
616
|
+
escaped_graph.add((s, p, o))
|
|
617
|
+
except Exception as e:
|
|
618
|
+
log.exception(f"Failing to escape uri on triplet {s} {p} {o} : {e}")
|
|
619
|
+
continue
|
|
620
|
+
return escaped_graph
|
|
621
|
+
|
|
622
|
+
|
|
571
623
|
def graph_response(graph, format):
|
|
572
624
|
"""
|
|
573
625
|
Return a proper flask response for a RDF resource given an expected format.
|
|
@@ -581,6 +633,8 @@ def graph_response(graph, format):
|
|
|
581
633
|
kwargs["context"] = CONTEXT
|
|
582
634
|
if isinstance(graph, RdfResource):
|
|
583
635
|
graph = graph.graph
|
|
636
|
+
if fmt in ["n3", "nt", "turtle", "trig"]:
|
|
637
|
+
graph = escape_uri_in_graph(graph)
|
|
584
638
|
return escape_xml_illegal_chars(graph.serialize(format=fmt, **kwargs)), 200, headers
|
|
585
639
|
|
|
586
640
|
|
udata/routing.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from urllib.parse import quote
|
|
1
2
|
from uuid import UUID
|
|
2
3
|
|
|
3
4
|
from bson import ObjectId
|
|
@@ -5,7 +6,6 @@ from flask import redirect, request, url_for
|
|
|
5
6
|
from mongoengine.errors import InvalidQueryError, ValidationError
|
|
6
7
|
from werkzeug.exceptions import NotFound
|
|
7
8
|
from werkzeug.routing import BaseConverter, PathConverter
|
|
8
|
-
from werkzeug.urls import url_quote
|
|
9
9
|
|
|
10
10
|
from udata import models
|
|
11
11
|
from udata.core.dataservices.models import Dataservice
|
|
@@ -79,7 +79,7 @@ class ModelConverter(BaseConverter):
|
|
|
79
79
|
if self.has_slug:
|
|
80
80
|
return self.model.slug.slugify(value)
|
|
81
81
|
else:
|
|
82
|
-
return
|
|
82
|
+
return quote(value)
|
|
83
83
|
|
|
84
84
|
def to_python(self, value):
|
|
85
85
|
try:
|
udata/settings.py
CHANGED
|
@@ -69,11 +69,13 @@ class Defaults(object):
|
|
|
69
69
|
# Flask mail settings
|
|
70
70
|
|
|
71
71
|
MAIL_DEFAULT_SENDER = "webmaster@udata"
|
|
72
|
+
MAIL_LOGO_URL = "https://www.data.gouv.fr/nuxt_images/udata_mails_external_logo.png"
|
|
72
73
|
|
|
73
74
|
# Flask security settings
|
|
74
75
|
|
|
75
76
|
SESSION_COOKIE_SECURE = True
|
|
76
77
|
SESSION_COOKIE_SAMESITE = None # Can be set to 'Lax' or 'Strict'. See https://flask.palletsprojects.com/en/2.3.x/security/#security-cookie
|
|
78
|
+
SECURITY_USE_REGISTER_V2 = True
|
|
77
79
|
|
|
78
80
|
# Flask-Security-Too settings
|
|
79
81
|
|
|
@@ -172,6 +174,10 @@ class Defaults(object):
|
|
|
172
174
|
SITE_AUTHOR = "Udata"
|
|
173
175
|
SITE_GITHUB_URL = "https://github.com/etalab/udata"
|
|
174
176
|
|
|
177
|
+
TERMS_OF_USE_URL = None
|
|
178
|
+
TERMS_OF_USE_DELETION_ARTICLE = None
|
|
179
|
+
TELERECOURS_URL = None
|
|
180
|
+
|
|
175
181
|
UDATA_INSTANCE_NAME = "udata"
|
|
176
182
|
|
|
177
183
|
HARVESTER_BACKENDS = []
|
|
@@ -478,6 +484,11 @@ class Defaults(object):
|
|
|
478
484
|
# Padding (in percent) used by the internal provider
|
|
479
485
|
AVATAR_INTERNAL_PADDING = 10
|
|
480
486
|
|
|
487
|
+
# Notification settings
|
|
488
|
+
###########################################################################
|
|
489
|
+
# Notifications are deleted after being handled for 90 days
|
|
490
|
+
DAYS_AFTER_NOTIFICATION_EXPIRED = 90
|
|
491
|
+
|
|
481
492
|
# Post settings
|
|
482
493
|
###########################################################################
|
|
483
494
|
# Discussions on posts are disabled by default
|
udata/tasks.py
CHANGED
|
@@ -161,6 +161,7 @@ def init_app(app):
|
|
|
161
161
|
import udata.core.metrics.tasks # noqa
|
|
162
162
|
import udata.core.tags.tasks # noqa
|
|
163
163
|
import udata.core.activity.tasks # noqa
|
|
164
|
+
import udata.core.dataservices.tasks # noqa
|
|
164
165
|
import udata.core.dataset.tasks # noqa
|
|
165
166
|
import udata.core.dataset.transport # noqa
|
|
166
167
|
import udata.core.dataset.recommendations # noqa
|
|
@@ -172,6 +173,7 @@ def init_app(app):
|
|
|
172
173
|
import udata.core.discussions.tasks # noqa
|
|
173
174
|
import udata.core.badges.tasks # noqa
|
|
174
175
|
import udata.core.storages.tasks # noqa
|
|
176
|
+
import udata.features.notifications.tasks # noqa
|
|
175
177
|
import udata.harvest.tasks # noqa
|
|
176
178
|
import udata.db.tasks # noqa
|
|
177
179
|
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
<title>{{ message.subject }}</title>
|
|
7
7
|
</head>
|
|
8
8
|
<body style="font-family: Marianne, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; background-color: #ffffff; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
9
|
+
{% if config.MAIL_LOGO_URL %}
|
|
9
10
|
<div style="margin-bottom: 30px;">
|
|
10
|
-
<img width="186" src="
|
|
11
|
+
<img width="186" src="{{ config.MAIL_LOGO_URL }}" alt="Logo">
|
|
11
12
|
</div>
|
|
13
|
+
{% endif %}
|
|
12
14
|
|
|
13
15
|
<p style="margin-bottom: 20px;">{{ _('Hi %(user)s', user=recipient.first_name) }},</p>
|
|
14
16
|
|
udata/tests/api/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@ from urllib.parse import urlparse
|
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
from flask import json
|
|
6
|
+
from flask_security.utils import login_user, logout_user, set_request_attr
|
|
6
7
|
|
|
7
8
|
from udata.core.user.factories import UserFactory
|
|
8
9
|
from udata.mongo import db
|
|
@@ -55,27 +56,16 @@ class APITestCaseMixin:
|
|
|
55
56
|
|
|
56
57
|
def login(self, user=None):
|
|
57
58
|
"""Login a user via session authentication."""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# Since flask-security-too 4.0.0, the user.fs_uniquifier is used instead of user.id for auth
|
|
64
|
-
user_id = getattr(user, current_app.login_manager.id_attribute)()
|
|
65
|
-
session["user_id"] = user_id
|
|
66
|
-
session["_fresh"] = True
|
|
67
|
-
session["_id"] = current_app.login_manager._session_identifier_generator()
|
|
68
|
-
current_app.login_manager._update_request_context_with_user(user)
|
|
69
|
-
identity_changed.send(current_app._get_current_object(), identity=Identity(user.id))
|
|
70
|
-
self.user = user
|
|
59
|
+
self.user = user or UserFactory()
|
|
60
|
+
|
|
61
|
+
login_user(self.user)
|
|
62
|
+
set_request_attr("fs_authn_via", "session")
|
|
63
|
+
|
|
71
64
|
return self.user
|
|
72
65
|
|
|
73
66
|
def logout(self):
|
|
74
67
|
"""Logout the current user."""
|
|
75
|
-
|
|
76
|
-
del session["user_id"]
|
|
77
|
-
del session["_fresh"]
|
|
78
|
-
del session["_id"]
|
|
68
|
+
logout_user()
|
|
79
69
|
self.user = None
|
|
80
70
|
|
|
81
71
|
def perform(self, verb, url, **kwargs):
|
|
@@ -4,6 +4,7 @@ from werkzeug.test import TestResponse
|
|
|
4
4
|
from udata.core.activity.models import Activity
|
|
5
5
|
from udata.core.dataset.factories import DatasetFactory
|
|
6
6
|
from udata.core.dataset.models import Dataset
|
|
7
|
+
from udata.core.organization.factories import OrganizationFactory
|
|
7
8
|
from udata.core.reuse.factories import ReuseFactory
|
|
8
9
|
from udata.core.reuse.models import Reuse
|
|
9
10
|
from udata.core.topic.factories import TopicFactory
|
|
@@ -111,3 +112,38 @@ class ActivityAPITest(APITestCase):
|
|
|
111
112
|
assert activity_data["related_to_id"] == str(topic.id)
|
|
112
113
|
assert activity_data["related_to_kind"] == "Topic"
|
|
113
114
|
assert activity_data["related_to_url"] == topic.self_api_url()
|
|
115
|
+
|
|
116
|
+
def test_activity_api_list_with_private_visible_to_owner(self) -> None:
|
|
117
|
+
"""Owner should see activities about their own private objects."""
|
|
118
|
+
owner = UserFactory()
|
|
119
|
+
dataset = DatasetFactory(private=True, owner=owner)
|
|
120
|
+
FakeDatasetActivity.objects.create(actor=UserFactory(), related_to=dataset)
|
|
121
|
+
|
|
122
|
+
# Anonymous user won't see it
|
|
123
|
+
response = self.get(url_for("api.activity"))
|
|
124
|
+
assert200(response)
|
|
125
|
+
assert len(response.json["data"]) == 0
|
|
126
|
+
|
|
127
|
+
# Owner should see their own private dataset activity
|
|
128
|
+
self.login(owner)
|
|
129
|
+
response = self.get(url_for("api.activity"))
|
|
130
|
+
assert200(response)
|
|
131
|
+
assert len(response.json["data"]) == 1
|
|
132
|
+
|
|
133
|
+
def test_activity_api_list_with_private_visible_to_org_member(self) -> None:
|
|
134
|
+
"""Organization members should see activities about their org's private objects."""
|
|
135
|
+
member = UserFactory()
|
|
136
|
+
org = OrganizationFactory(admins=[member])
|
|
137
|
+
dataset = DatasetFactory(private=True, organization=org)
|
|
138
|
+
FakeDatasetActivity.objects.create(actor=UserFactory(), related_to=dataset)
|
|
139
|
+
|
|
140
|
+
# Anonymous user won't see it
|
|
141
|
+
response = self.get(url_for("api.activity"))
|
|
142
|
+
assert200(response)
|
|
143
|
+
assert len(response.json["data"]) == 0
|
|
144
|
+
|
|
145
|
+
# Org member should see the private dataset activity
|
|
146
|
+
self.login(member)
|
|
147
|
+
response = self.get(url_for("api.activity"))
|
|
148
|
+
assert200(response)
|
|
149
|
+
assert len(response.json["data"]) == 1
|
|
@@ -724,6 +724,19 @@ class DatasetAPITest(APITestCase):
|
|
|
724
724
|
dataset = Dataset.objects.first()
|
|
725
725
|
self.assertEqual(dataset.spatial.geom, SAMPLE_GEOM)
|
|
726
726
|
|
|
727
|
+
def test_dataset_api_create_with_invalid_geom_coordinates(self):
|
|
728
|
+
"""It should return 400 with invalid GeoJSON coordinates, not 500"""
|
|
729
|
+
self.login()
|
|
730
|
+
data = DatasetFactory.as_dict()
|
|
731
|
+
# Invalid GeoJSON: {} in coordinates instead of numbers (Sentry issue)
|
|
732
|
+
data["spatial"] = {"geom": {"type": "Point", "coordinates": {}}}
|
|
733
|
+
response = self.post(url_for("api.datasets"), data)
|
|
734
|
+
self.assert400(response)
|
|
735
|
+
self.assertEqual(Dataset.objects.count(), 0)
|
|
736
|
+
# Verify error is properly captured in form validation errors
|
|
737
|
+
self.assertIn("errors", response.json)
|
|
738
|
+
self.assertIn("spatial", response.json["errors"])
|
|
739
|
+
|
|
727
740
|
def test_dataset_api_create_with_legacy_frequency(self):
|
|
728
741
|
"""It should create a dataset from the API with a legacy frequency"""
|
|
729
742
|
self.login()
|
|
@@ -1542,6 +1555,44 @@ class DatasetsFeedAPItest(APITestCase):
|
|
|
1542
1555
|
entry = feed.entries[0]
|
|
1543
1556
|
assert uris.validate(entry["id"])
|
|
1544
1557
|
|
|
1558
|
+
@pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
|
|
1559
|
+
def test_recent_feed_with_organization_filter(self):
|
|
1560
|
+
org1 = OrganizationFactory()
|
|
1561
|
+
org2 = OrganizationFactory()
|
|
1562
|
+
DatasetFactory(title="Dataset Org1", organization=org1, resources=[ResourceFactory()])
|
|
1563
|
+
DatasetFactory(title="Dataset Org2", organization=org2, resources=[ResourceFactory()])
|
|
1564
|
+
|
|
1565
|
+
response = self.get(url_for("api.recent_datasets_atom_feed", organization=str(org1.id)))
|
|
1566
|
+
self.assert200(response)
|
|
1567
|
+
|
|
1568
|
+
feed = feedparser.parse(response.data)
|
|
1569
|
+
self.assertEqual(len(feed.entries), 1)
|
|
1570
|
+
self.assertEqual(feed.entries[0].title, "Dataset Org1")
|
|
1571
|
+
|
|
1572
|
+
@pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
|
|
1573
|
+
def test_recent_feed_with_tag_filter(self):
|
|
1574
|
+
DatasetFactory(title="Tagged", tags=["transport"], resources=[ResourceFactory()])
|
|
1575
|
+
DatasetFactory(title="Not Tagged", tags=["other"], resources=[ResourceFactory()])
|
|
1576
|
+
|
|
1577
|
+
response = self.get(url_for("api.recent_datasets_atom_feed", tag="transport"))
|
|
1578
|
+
self.assert200(response)
|
|
1579
|
+
|
|
1580
|
+
feed = feedparser.parse(response.data)
|
|
1581
|
+
self.assertEqual(len(feed.entries), 1)
|
|
1582
|
+
self.assertEqual(feed.entries[0].title, "Tagged")
|
|
1583
|
+
|
|
1584
|
+
@pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
|
|
1585
|
+
def test_recent_feed_with_search_query(self):
|
|
1586
|
+
DatasetFactory(title="Transport public", resources=[ResourceFactory()])
|
|
1587
|
+
DatasetFactory(title="Environnement", resources=[ResourceFactory()])
|
|
1588
|
+
|
|
1589
|
+
response = self.get(url_for("api.recent_datasets_atom_feed", q="transport"))
|
|
1590
|
+
self.assert200(response)
|
|
1591
|
+
|
|
1592
|
+
feed = feedparser.parse(response.data)
|
|
1593
|
+
self.assertEqual(len(feed.entries), 1)
|
|
1594
|
+
self.assertEqual(feed.entries[0].title, "Transport public")
|
|
1595
|
+
|
|
1545
1596
|
|
|
1546
1597
|
class DatasetBadgeAPITest(APITestCase):
|
|
1547
1598
|
@classmethod
|
|
@@ -1693,6 +1744,12 @@ class DatasetResourceAPITest(APITestCase):
|
|
|
1693
1744
|
self.dataset.reload()
|
|
1694
1745
|
self.assertEqual(len(self.dataset.resources), 2)
|
|
1695
1746
|
|
|
1747
|
+
def test_create_with_list_returns_400(self):
|
|
1748
|
+
"""It should return 400 when sending a list instead of a dict"""
|
|
1749
|
+
data = [ResourceFactory.as_dict()]
|
|
1750
|
+
response = self.post(url_for("api.resources", dataset=self.dataset), data)
|
|
1751
|
+
self.assert400(response)
|
|
1752
|
+
|
|
1696
1753
|
def test_create_with_file(self):
|
|
1697
1754
|
"""It should create a resource from the API with a file"""
|
|
1698
1755
|
user = self.login()
|
|
@@ -1834,6 +1891,18 @@ class DatasetResourceAPITest(APITestCase):
|
|
|
1834
1891
|
f"Resource ids must match existing ones in dataset, ie: {set(str(r.id) for r in self.dataset.resources)}",
|
|
1835
1892
|
)
|
|
1836
1893
|
|
|
1894
|
+
def test_invalid_reorder_dict_without_id(self):
|
|
1895
|
+
"""It should return 400 when dict in resources list has no 'id' key"""
|
|
1896
|
+
self.dataset.resources = ResourceFactory.build_batch(3)
|
|
1897
|
+
self.dataset.save()
|
|
1898
|
+
|
|
1899
|
+
# Dict without 'id' key should fail gracefully, not raise KeyError
|
|
1900
|
+
wrong_order_dict_without_id = [{"title": "foo"}, {"title": "bar"}, {"title": "baz"}]
|
|
1901
|
+
response = self.put(
|
|
1902
|
+
url_for("api.resources", dataset=self.dataset), wrong_order_dict_without_id
|
|
1903
|
+
)
|
|
1904
|
+
self.assertStatus(response, 400)
|
|
1905
|
+
|
|
1837
1906
|
def test_update_local(self):
|
|
1838
1907
|
resource = ResourceFactory()
|
|
1839
1908
|
self.dataset.resources.append(resource)
|
|
@@ -1017,7 +1017,6 @@ class OrganizationCsvExportsTest(PytestOnlyAPITestCase):
|
|
|
1017
1017
|
|
|
1018
1018
|
assert200(response)
|
|
1019
1019
|
assert response.mimetype == "text/csv"
|
|
1020
|
-
assert response.charset == "utf-8"
|
|
1021
1020
|
|
|
1022
1021
|
csvfile = StringIO(response.data.decode("utf-8"))
|
|
1023
1022
|
reader = csv.get_reader(csvfile)
|
|
@@ -1045,7 +1044,6 @@ class OrganizationCsvExportsTest(PytestOnlyAPITestCase):
|
|
|
1045
1044
|
|
|
1046
1045
|
assert200(response)
|
|
1047
1046
|
assert response.mimetype == "text/csv"
|
|
1048
|
-
assert response.charset == "utf-8"
|
|
1049
1047
|
|
|
1050
1048
|
csvfile = StringIO(response.data.decode("utf-8"))
|
|
1051
1049
|
reader = csv.get_reader(csvfile)
|
|
@@ -1083,7 +1081,6 @@ class OrganizationCsvExportsTest(PytestOnlyAPITestCase):
|
|
|
1083
1081
|
|
|
1084
1082
|
assert200(response)
|
|
1085
1083
|
assert response.mimetype == "text/csv"
|
|
1086
|
-
assert response.charset == "utf-8"
|
|
1087
1084
|
|
|
1088
1085
|
csvfile = StringIO(response.data.decode("utf-8"))
|
|
1089
1086
|
reader = csv.get_reader(csvfile)
|