udata 10.4.1.dev35211__py2.py3-none-any.whl → 10.4.2__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of udata might be problematic. Click here for more details.
- udata/__init__.py +1 -1
- udata/core/activity/__init__.py +2 -0
- udata/core/activity/api.py +10 -2
- udata/core/activity/models.py +28 -1
- udata/core/activity/tasks.py +19 -4
- udata/core/dataservices/activities.py +53 -0
- udata/core/dataservices/api.py +43 -0
- udata/core/dataservices/models.py +16 -20
- udata/core/dataset/activities.py +52 -5
- udata/core/dataset/api.py +44 -0
- udata/core/dataset/models.py +49 -43
- udata/core/dataset/rdf.py +1 -1
- udata/core/metrics/commands.py +1 -0
- udata/core/metrics/helpers.py +102 -0
- udata/core/metrics/models.py +1 -0
- udata/core/metrics/tasks.py +1 -0
- udata/core/organization/activities.py +3 -2
- udata/core/organization/api.py +11 -0
- udata/core/organization/api_fields.py +6 -5
- udata/core/organization/models.py +31 -31
- udata/core/owned.py +1 -1
- udata/core/post/api.py +34 -0
- udata/core/reuse/activities.py +6 -5
- udata/core/reuse/api.py +42 -1
- udata/core/reuse/models.py +8 -16
- udata/core/site/models.py +33 -0
- udata/core/topic/activities.py +36 -0
- udata/core/topic/models.py +23 -15
- udata/core/user/activities.py +17 -6
- udata/core/user/api.py +1 -0
- udata/core/user/api_fields.py +6 -1
- udata/core/user/models.py +39 -32
- udata/migrations/2025-05-22-purge-duplicate-activities.py +101 -0
- udata/mongo/datetime_fields.py +1 -0
- udata/settings.py +4 -0
- udata/static/chunks/{10.471164b2a9fe15614797.js → 10.8ca60413647062717b1e.js} +3 -3
- udata/static/chunks/{10.471164b2a9fe15614797.js.map → 10.8ca60413647062717b1e.js.map} +1 -1
- udata/static/chunks/{11.51d706fb9521c16976bc.js → 11.b6f741fcc366abfad9c4.js} +3 -3
- udata/static/chunks/{11.51d706fb9521c16976bc.js.map → 11.b6f741fcc366abfad9c4.js.map} +1 -1
- udata/static/chunks/{13.f29411b06be1883356a3.js → 13.2d06442dd9a05d9777b5.js} +2 -2
- udata/static/chunks/{13.f29411b06be1883356a3.js.map → 13.2d06442dd9a05d9777b5.js.map} +1 -1
- udata/static/chunks/{17.3bd0340930d4a314ce9c.js → 17.e8e4caaad5cb0cc0bacc.js} +2 -2
- udata/static/chunks/{17.3bd0340930d4a314ce9c.js.map → 17.e8e4caaad5cb0cc0bacc.js.map} +1 -1
- udata/static/chunks/{19.8da42e8359d72afc2618.js → 19.f03a102365af4315f9db.js} +3 -3
- udata/static/chunks/{19.8da42e8359d72afc2618.js.map → 19.f03a102365af4315f9db.js.map} +1 -1
- udata/static/chunks/{8.54e44b102164ae5e7a67.js → 8.778091d55cd8ea39af6b.js} +2 -2
- udata/static/chunks/{8.54e44b102164ae5e7a67.js.map → 8.778091d55cd8ea39af6b.js.map} +1 -1
- udata/static/chunks/{9.07515e5187f475bce828.js → 9.033d7e190ca9e226a5d0.js} +3 -3
- udata/static/chunks/{9.07515e5187f475bce828.js.map → 9.033d7e190ca9e226a5d0.js.map} +1 -1
- udata/static/common.js +1 -1
- udata/static/common.js.map +1 -1
- udata/tests/api/test_activities_api.py +29 -1
- udata/tests/api/test_dataservices_api.py +53 -0
- udata/tests/api/test_datasets_api.py +61 -0
- udata/tests/api/test_organizations_api.py +27 -2
- udata/tests/api/test_reuses_api.py +54 -0
- udata/tests/dataset/test_dataset_model.py +49 -0
- udata/tests/test_topics.py +19 -0
- {udata-10.4.1.dev35211.dist-info → udata-10.4.2.dist-info}/METADATA +16 -2
- {udata-10.4.1.dev35211.dist-info → udata-10.4.2.dist-info}/RECORD +64 -60
- {udata-10.4.1.dev35211.dist-info → udata-10.4.2.dist-info}/LICENSE +0 -0
- {udata-10.4.1.dev35211.dist-info → udata-10.4.2.dist-info}/WHEEL +0 -0
- {udata-10.4.1.dev35211.dist-info → udata-10.4.2.dist-info}/entry_points.txt +0 -0
- {udata-10.4.1.dev35211.dist-info → udata-10.4.2.dist-info}/top_level.txt +0 -0
udata/core/dataset/rdf.py
CHANGED
|
@@ -769,7 +769,7 @@ def dataset_from_rdf(graph: Graph, dataset=None, node=None, remote_url_prefix: s
|
|
|
769
769
|
# Support dct:abstract if dct:description is missing (sometimes used instead)
|
|
770
770
|
description = d.value(DCT.description) or d.value(DCT.abstract)
|
|
771
771
|
dataset.description = sanitize_html(description)
|
|
772
|
-
dataset.frequency = frequency_from_rdf(d.value(DCT.accrualPeriodicity))
|
|
772
|
+
dataset.frequency = frequency_from_rdf(d.value(DCT.accrualPeriodicity)) or dataset.frequency
|
|
773
773
|
roles = [ # Imbricated list of contact points for each role
|
|
774
774
|
contact_points_from_rdf(d, rdf_entity, role, dataset)
|
|
775
775
|
for rdf_entity, role in CONTACT_POINT_ENTITY_TO_ROLE.items()
|
udata/core/metrics/commands.py
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections import OrderedDict
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Dict, List, Union
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from bson import ObjectId
|
|
8
|
+
from dateutil.rrule import MONTHLY, rrule
|
|
9
|
+
from flask import current_app
|
|
10
|
+
from mongoengine import QuerySet
|
|
11
|
+
from pymongo.command_cursor import CommandCursor
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_last_13_months() -> List[str]:
|
|
17
|
+
dstart = datetime.today().replace(day=1) - timedelta(days=365)
|
|
18
|
+
months = rrule(freq=MONTHLY, count=13, dtstart=dstart)
|
|
19
|
+
return [month.strftime("%Y-%m") for month in months]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_monthly_metrics(metrics_data: List[Dict], metrics_labels: List[str]) -> OrderedDict:
|
|
23
|
+
# Initialize default monthly_metrics
|
|
24
|
+
monthly_metrics = OrderedDict(
|
|
25
|
+
(month, {label: 0 for label in metrics_labels}) for month in get_last_13_months()
|
|
26
|
+
)
|
|
27
|
+
# Update monthly_metrics with metrics_data values
|
|
28
|
+
for entry in metrics_data:
|
|
29
|
+
entry_month = entry["metric_month"]
|
|
30
|
+
if entry_month in monthly_metrics:
|
|
31
|
+
for metric_label in metrics_labels:
|
|
32
|
+
label = f"monthly_{metric_label}"
|
|
33
|
+
monthly_metrics[entry_month][metric_label] = entry.get(label) or 0
|
|
34
|
+
return monthly_metrics
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def metrics_by_label(monthly_metrics: Dict, metrics_labels: List[str]) -> List[OrderedDict]:
|
|
38
|
+
metrics_by_label = []
|
|
39
|
+
for label in metrics_labels:
|
|
40
|
+
metrics_by_label.append(
|
|
41
|
+
OrderedDict((month, monthly_metrics[month][label]) for month in monthly_metrics)
|
|
42
|
+
)
|
|
43
|
+
return metrics_by_label
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_metrics_for_model(
|
|
47
|
+
model: str, id: Union[str, ObjectId, None], metrics_labels: List[str]
|
|
48
|
+
) -> List[OrderedDict]:
|
|
49
|
+
"""
|
|
50
|
+
Get distant metrics for a particular model object
|
|
51
|
+
"""
|
|
52
|
+
if not current_app.config["METRICS_API"]:
|
|
53
|
+
# TODO: How to best deal with no METRICS_API, prevent calling or return empty?
|
|
54
|
+
# raise ValueError("missing config METRICS_API to use this function")
|
|
55
|
+
return [{} for _ in range(len(metrics_labels))]
|
|
56
|
+
models = model + "s" if id else model # TODO: not clean of a hack
|
|
57
|
+
model_metrics_api = f"{current_app.config['METRICS_API']}/{models}/data/"
|
|
58
|
+
try:
|
|
59
|
+
params = {"metric_month__sort": "desc"}
|
|
60
|
+
if id:
|
|
61
|
+
params[f"{model}_id__exact"] = id
|
|
62
|
+
res = requests.get(model_metrics_api, params)
|
|
63
|
+
res.raise_for_status()
|
|
64
|
+
monthly_metrics = compute_monthly_metrics(res.json()["data"], metrics_labels)
|
|
65
|
+
return metrics_by_label(monthly_metrics, metrics_labels)
|
|
66
|
+
except requests.exceptions.RequestException as e:
|
|
67
|
+
log.exception(f"Error while getting metrics for {model}({id}): {e}")
|
|
68
|
+
return [{} for _ in range(len(metrics_labels))]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def compute_monthly_aggregated_metrics(aggregation_res: CommandCursor) -> OrderedDict:
|
|
72
|
+
monthly_metrics = OrderedDict((month, 0) for month in get_last_13_months())
|
|
73
|
+
for monthly_count in aggregation_res:
|
|
74
|
+
year, month = monthly_count["_id"].split("-")
|
|
75
|
+
monthly_label = year + "-" + month.zfill(2)
|
|
76
|
+
if monthly_label in monthly_metrics:
|
|
77
|
+
monthly_metrics[monthly_label] = monthly_count["count"]
|
|
78
|
+
return monthly_metrics
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_stock_metrics(objects: QuerySet, date_label: str = "created_at") -> OrderedDict:
|
|
82
|
+
"""
|
|
83
|
+
Get stock metrics for a particular model object
|
|
84
|
+
"""
|
|
85
|
+
pipeline = [
|
|
86
|
+
{"$match": {date_label: {"$gte": datetime.now() - timedelta(days=365)}}},
|
|
87
|
+
{
|
|
88
|
+
"$group": {
|
|
89
|
+
"_id": {
|
|
90
|
+
"$concat": [
|
|
91
|
+
{"$substr": [{"$year": f"${date_label}"}, 0, 4]},
|
|
92
|
+
"-",
|
|
93
|
+
{"$substr": [{"$month": f"${date_label}"}, 0, 12]},
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
"count": {"$sum": 1},
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
aggregation_res = objects.aggregate(*pipeline)
|
|
101
|
+
|
|
102
|
+
return compute_monthly_aggregated_metrics(aggregation_res)
|
udata/core/metrics/models.py
CHANGED
udata/core/metrics/tasks.py
CHANGED
|
@@ -32,6 +32,7 @@ def on_user_created_organization(organization):
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
@Organization.on_update.connect
|
|
35
|
-
def on_user_updated_organization(organization):
|
|
35
|
+
def on_user_updated_organization(organization, **kwargs):
|
|
36
|
+
changed_fields = kwargs.get("changed_fields", [])
|
|
36
37
|
if current_user and current_user.is_authenticated:
|
|
37
|
-
UserUpdatedOrganization.emit(organization, organization)
|
|
38
|
+
UserUpdatedOrganization.emit(organization, organization, changed_fields)
|
udata/core/organization/api.py
CHANGED
|
@@ -12,6 +12,7 @@ from udata.core.badges import api as badges_api
|
|
|
12
12
|
from udata.core.badges.fields import badge_fields
|
|
13
13
|
from udata.core.contact_point.api import ContactPointApiParser
|
|
14
14
|
from udata.core.contact_point.api_fields import contact_point_page_fields
|
|
15
|
+
from udata.core.dataservices.csv import DataserviceCsvAdapter
|
|
15
16
|
from udata.core.dataservices.models import Dataservice
|
|
16
17
|
from udata.core.dataset.api import DatasetApiParser
|
|
17
18
|
from udata.core.dataset.api_fields import dataset_page_fields
|
|
@@ -178,6 +179,16 @@ class DatasetsCsvAPI(API):
|
|
|
178
179
|
return csv.stream(adapter, "{0}-datasets".format(org.slug))
|
|
179
180
|
|
|
180
181
|
|
|
182
|
+
@ns.route("/<org:org>/dataservices.csv", endpoint="organization_dataservices_csv")
|
|
183
|
+
@api.response(404, "Organization not found")
|
|
184
|
+
@api.response(410, "Organization has been deleted")
|
|
185
|
+
class DataservicesCsv(API):
|
|
186
|
+
def get(self, org):
|
|
187
|
+
dataservices = Dataservice.objects(organization=str(org.id)).visible()
|
|
188
|
+
adapter = DataserviceCsvAdapter(dataservices)
|
|
189
|
+
return csv.stream(adapter, "{0}-dataservices".format(org.slug))
|
|
190
|
+
|
|
191
|
+
|
|
181
192
|
@ns.route("/<org:org>/discussions.csv", endpoint="organization_discussions_csv", doc=common_doc)
|
|
182
193
|
@api.response(404, "Organization not found")
|
|
183
194
|
@api.response(410, "Organization has been deleted")
|
|
@@ -42,9 +42,6 @@ org_ref_fields = api.inherit(
|
|
|
42
42
|
},
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
-
# This import is not at the top of the file to avoid circular imports
|
|
46
|
-
from udata.core.user.api_fields import user_ref_fields # noqa
|
|
47
|
-
|
|
48
45
|
|
|
49
46
|
def check_can_access_user_private_info():
|
|
50
47
|
# This endpoint is secure, only organization member has access.
|
|
@@ -64,14 +61,18 @@ def check_can_access_user_private_info():
|
|
|
64
61
|
def member_email_with_visibility_check(email):
|
|
65
62
|
if current_user_is_admin_or_self():
|
|
66
63
|
return email
|
|
64
|
+
name, domain = email.split("@")
|
|
67
65
|
if check_can_access_user_private_info():
|
|
68
66
|
# Obfuscate email partially for other members
|
|
69
|
-
name, domain = email.split("@")
|
|
70
67
|
name = name[:2] + "*" * (len(name) - 2)
|
|
71
68
|
return f"{name}@{domain}"
|
|
72
|
-
|
|
69
|
+
# Return only domain for other users
|
|
70
|
+
return f"***@{domain}"
|
|
73
71
|
|
|
74
72
|
|
|
73
|
+
# This import is not at the top of the file to avoid circular imports
|
|
74
|
+
from udata.core.user.api_fields import user_ref_fields # noqa
|
|
75
|
+
|
|
75
76
|
member_user_with_email_fields = api.inherit(
|
|
76
77
|
"MemberUserWithEmail",
|
|
77
78
|
user_ref_fields,
|
|
@@ -6,6 +6,7 @@ from mongoengine.signals import post_save, pre_save
|
|
|
6
6
|
from werkzeug.utils import cached_property
|
|
7
7
|
|
|
8
8
|
from udata.api_fields import field
|
|
9
|
+
from udata.core.activity.models import Auditable
|
|
9
10
|
from udata.core.badges.models import Badge, BadgeMixin, BadgesList
|
|
10
11
|
from udata.core.metrics.models import WithMetrics
|
|
11
12
|
from udata.core.storages import avatars, default_image_basename
|
|
@@ -110,29 +111,35 @@ class OrganizationBadgeMixin(BadgeMixin):
|
|
|
110
111
|
__badges__ = BADGES
|
|
111
112
|
|
|
112
113
|
|
|
113
|
-
class Organization(WithMetrics, OrganizationBadgeMixin, db.Datetimed, db.Document):
|
|
114
|
-
name = db.StringField(required=True)
|
|
115
|
-
acronym = db.StringField(max_length=128)
|
|
116
|
-
slug =
|
|
117
|
-
max_length=255, required=True, populate_from="name", update=True, follow=True
|
|
114
|
+
class Organization(Auditable, WithMetrics, OrganizationBadgeMixin, db.Datetimed, db.Document):
|
|
115
|
+
name = field(db.StringField(required=True))
|
|
116
|
+
acronym = field(db.StringField(max_length=128))
|
|
117
|
+
slug = field(
|
|
118
|
+
db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
|
|
119
|
+
auditable=False,
|
|
118
120
|
)
|
|
119
|
-
description = db.StringField(required=True)
|
|
120
|
-
url = db.URLField()
|
|
121
|
-
image_url = db.StringField()
|
|
122
|
-
logo =
|
|
123
|
-
|
|
121
|
+
description = field(db.StringField(required=True))
|
|
122
|
+
url = field(db.URLField())
|
|
123
|
+
image_url = field(db.StringField())
|
|
124
|
+
logo = field(
|
|
125
|
+
db.ImageField(
|
|
126
|
+
fs=avatars,
|
|
127
|
+
basename=default_image_basename,
|
|
128
|
+
max_size=LOGO_MAX_SIZE,
|
|
129
|
+
thumbnails=LOGO_SIZES,
|
|
130
|
+
)
|
|
124
131
|
)
|
|
125
|
-
business_number_id = db.StringField(max_length=ORG_BID_SIZE_LIMIT)
|
|
132
|
+
business_number_id = field(db.StringField(max_length=ORG_BID_SIZE_LIMIT))
|
|
126
133
|
|
|
127
|
-
members = db.ListField(db.EmbeddedDocumentField(Member))
|
|
128
|
-
teams = db.ListField(db.EmbeddedDocumentField(Team))
|
|
129
|
-
requests = db.ListField(db.EmbeddedDocumentField(MembershipRequest))
|
|
134
|
+
members = field(db.ListField(db.EmbeddedDocumentField(Member)))
|
|
135
|
+
teams = field(db.ListField(db.EmbeddedDocumentField(Team)))
|
|
136
|
+
requests = field(db.ListField(db.EmbeddedDocumentField(MembershipRequest)))
|
|
130
137
|
|
|
131
|
-
ext = db.MapField(db.GenericEmbeddedDocumentField())
|
|
132
|
-
zone = db.StringField()
|
|
133
|
-
extras = db.OrganizationExtrasField()
|
|
138
|
+
ext = field(db.MapField(db.GenericEmbeddedDocumentField()))
|
|
139
|
+
zone = field(db.StringField())
|
|
140
|
+
extras = field(db.OrganizationExtrasField(), auditable=False)
|
|
134
141
|
|
|
135
|
-
deleted = db.DateTimeField()
|
|
142
|
+
deleted = field(db.DateTimeField())
|
|
136
143
|
|
|
137
144
|
meta = {
|
|
138
145
|
"indexes": [
|
|
@@ -168,19 +175,12 @@ class Organization(WithMetrics, OrganizationBadgeMixin, db.Datetimed, db.Documen
|
|
|
168
175
|
on_update = Signal()
|
|
169
176
|
before_delete = Signal()
|
|
170
177
|
after_delete = Signal()
|
|
178
|
+
on_delete = Signal()
|
|
171
179
|
|
|
172
180
|
@classmethod
|
|
173
181
|
def pre_save(cls, sender, document, **kwargs):
|
|
174
182
|
cls.before_save.send(document)
|
|
175
183
|
|
|
176
|
-
@classmethod
|
|
177
|
-
def post_save(cls, sender, document, **kwargs):
|
|
178
|
-
cls.after_save.send(document)
|
|
179
|
-
if kwargs.get("created"):
|
|
180
|
-
cls.on_create.send(document)
|
|
181
|
-
else:
|
|
182
|
-
cls.on_update.send(document)
|
|
183
|
-
|
|
184
184
|
def url_for(self, *args, **kwargs):
|
|
185
185
|
return endpoint_for("organizations.show", "api.organization", org=self, *args, **kwargs)
|
|
186
186
|
|
|
@@ -296,31 +296,31 @@ class Organization(WithMetrics, OrganizationBadgeMixin, db.Datetimed, db.Documen
|
|
|
296
296
|
|
|
297
297
|
def count_members(self):
|
|
298
298
|
self.metrics["members"] = len(self.members)
|
|
299
|
-
self.save()
|
|
299
|
+
self.save(signal_kwargs={"ignores": ["post_save"]})
|
|
300
300
|
|
|
301
301
|
def count_datasets(self):
|
|
302
302
|
from udata.models import Dataset
|
|
303
303
|
|
|
304
304
|
self.metrics["datasets"] = Dataset.objects(organization=self).visible().count()
|
|
305
|
-
self.save()
|
|
305
|
+
self.save(signal_kwargs={"ignores": ["post_save"]})
|
|
306
306
|
|
|
307
307
|
def count_reuses(self):
|
|
308
308
|
from udata.models import Reuse
|
|
309
309
|
|
|
310
310
|
self.metrics["reuses"] = Reuse.objects(organization=self).visible().count()
|
|
311
|
-
self.save()
|
|
311
|
+
self.save(signal_kwargs={"ignores": ["post_save"]})
|
|
312
312
|
|
|
313
313
|
def count_dataservices(self):
|
|
314
314
|
from udata.models import Dataservice
|
|
315
315
|
|
|
316
316
|
self.metrics["dataservices"] = Dataservice.objects(organization=self).visible().count()
|
|
317
|
-
self.save()
|
|
317
|
+
self.save(signal_kwargs={"ignores": ["post_save"]})
|
|
318
318
|
|
|
319
319
|
def count_followers(self):
|
|
320
320
|
from udata.models import Follow
|
|
321
321
|
|
|
322
322
|
self.metrics["followers"] = Follow.objects(until=None).followers(self).count()
|
|
323
|
-
self.save()
|
|
323
|
+
self.save(signal_kwargs={"ignores": ["post_save"]})
|
|
324
324
|
|
|
325
325
|
|
|
326
326
|
pre_save.connect(Organization.pre_save, sender=Organization)
|
udata/core/owned.py
CHANGED
|
@@ -80,7 +80,7 @@ def check_organization_is_valid_for_current_user(organization, **_kwargs):
|
|
|
80
80
|
|
|
81
81
|
class Owned(object):
|
|
82
82
|
"""
|
|
83
|
-
A mixin to factorize owning
|
|
83
|
+
A mixin to factorize owning behavior between users and organizations.
|
|
84
84
|
"""
|
|
85
85
|
|
|
86
86
|
owner = field(
|
udata/core/post/api.py
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from feedgenerator.django.utils.feedgenerator import Atom1Feed
|
|
5
|
+
from flask import make_response, request
|
|
2
6
|
|
|
3
7
|
from udata.api import API, api, fields
|
|
4
8
|
from udata.auth import Permission as AdminPermission
|
|
@@ -11,6 +15,8 @@ from udata.core.storages.api import (
|
|
|
11
15
|
uploaded_image_fields,
|
|
12
16
|
)
|
|
13
17
|
from udata.core.user.api_fields import user_ref_fields
|
|
18
|
+
from udata.frontend.markdown import md
|
|
19
|
+
from udata.i18n import gettext as _
|
|
14
20
|
|
|
15
21
|
from .forms import PostForm
|
|
16
22
|
from .models import Post
|
|
@@ -105,6 +111,34 @@ class PostsAPI(API):
|
|
|
105
111
|
return form.save(), 201
|
|
106
112
|
|
|
107
113
|
|
|
114
|
+
@ns.route("/recent.atom", endpoint="recent_posts_atom_feed")
|
|
115
|
+
class PostsAtomFeedAPI(API):
|
|
116
|
+
@api.doc("recent_posts_atom_feed")
|
|
117
|
+
def get(self):
|
|
118
|
+
feed = Atom1Feed(
|
|
119
|
+
_("Latests posts"),
|
|
120
|
+
description=None,
|
|
121
|
+
feed_url=request.url,
|
|
122
|
+
link=request.url_root,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
posts: List[Post] = Post.objects().published().order_by("-published").limit(15)
|
|
126
|
+
for post in posts:
|
|
127
|
+
feed.add_item(
|
|
128
|
+
post.name,
|
|
129
|
+
unique_id=post.id,
|
|
130
|
+
description=post.headline,
|
|
131
|
+
content=md(post.content),
|
|
132
|
+
author_name="data.gouv.fr",
|
|
133
|
+
link=post.external_url,
|
|
134
|
+
updateddate=post.last_modified,
|
|
135
|
+
pubdate=post.published,
|
|
136
|
+
)
|
|
137
|
+
response = make_response(feed.writeString("utf-8"))
|
|
138
|
+
response.headers["Content-Type"] = "application/atom+xml"
|
|
139
|
+
return response
|
|
140
|
+
|
|
141
|
+
|
|
108
142
|
@ns.route("/<post:post>/", endpoint="post")
|
|
109
143
|
@api.response(404, "Object not found")
|
|
110
144
|
@api.param("post", "The post ID or slug")
|
udata/core/reuse/activities.py
CHANGED
|
@@ -33,17 +33,18 @@ class UserDeletedReuse(ReuseRelatedActivity, Activity):
|
|
|
33
33
|
|
|
34
34
|
@Reuse.on_create.connect
|
|
35
35
|
def on_user_created_reuse(reuse):
|
|
36
|
-
if
|
|
36
|
+
if current_user and current_user.is_authenticated:
|
|
37
37
|
UserCreatedReuse.emit(reuse, reuse.organization)
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
@Reuse.on_update.connect
|
|
41
|
-
def on_user_updated_reuse(reuse):
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
def on_user_updated_reuse(reuse, **kwargs):
|
|
42
|
+
changed_fields = kwargs.get("changed_fields", [])
|
|
43
|
+
if current_user and current_user.is_authenticated:
|
|
44
|
+
UserUpdatedReuse.emit(reuse, reuse.organization, changed_fields)
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
@Reuse.on_delete.connect
|
|
47
48
|
def on_user_deleted_reuse(reuse):
|
|
48
|
-
if
|
|
49
|
+
if current_user and current_user.is_authenticated:
|
|
49
50
|
UserDeletedReuse.emit(reuse, reuse.organization)
|
udata/core/reuse/api.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
|
+
from typing import List
|
|
2
3
|
|
|
3
4
|
import mongoengine
|
|
4
5
|
from bson.objectid import ObjectId
|
|
5
|
-
from
|
|
6
|
+
from feedgenerator.django.utils.feedgenerator import Atom1Feed
|
|
7
|
+
from flask import make_response, request
|
|
6
8
|
from flask_login import current_user
|
|
7
9
|
|
|
8
10
|
from udata.api import API, api, errors
|
|
@@ -20,6 +22,8 @@ from udata.core.storages.api import (
|
|
|
20
22
|
parse_uploaded_image,
|
|
21
23
|
uploaded_image_fields,
|
|
22
24
|
)
|
|
25
|
+
from udata.frontend.markdown import md
|
|
26
|
+
from udata.i18n import gettext as _
|
|
23
27
|
from udata.models import Dataset
|
|
24
28
|
from udata.utils import id_or_404
|
|
25
29
|
|
|
@@ -130,6 +134,43 @@ class ReuseListAPI(API):
|
|
|
130
134
|
return patch_and_save(reuse, request), 201
|
|
131
135
|
|
|
132
136
|
|
|
137
|
+
@ns.route("/recent.atom", endpoint="recent_reuses_atom_feed")
|
|
138
|
+
class ReusesAtomFeedAPI(API):
|
|
139
|
+
@api.doc("recent_reuses_atom_feed")
|
|
140
|
+
def get(self):
|
|
141
|
+
feed = Atom1Feed(
|
|
142
|
+
_("Latests reuses"),
|
|
143
|
+
description=None,
|
|
144
|
+
feed_url=request.url,
|
|
145
|
+
link=request.url_root,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
reuses: List[Reuse] = Reuse.objects.visible().order_by("-created_at").limit(15)
|
|
149
|
+
for reuse in reuses:
|
|
150
|
+
author_name = None
|
|
151
|
+
author_uri = None
|
|
152
|
+
if reuse.organization:
|
|
153
|
+
author_name = reuse.organization.name
|
|
154
|
+
author_uri = reuse.organization.external_url
|
|
155
|
+
elif reuse.owner:
|
|
156
|
+
author_name = reuse.owner.fullname
|
|
157
|
+
author_uri = reuse.owner.external_url
|
|
158
|
+
feed.add_item(
|
|
159
|
+
reuse.title,
|
|
160
|
+
unique_id=reuse.id,
|
|
161
|
+
description=reuse.description,
|
|
162
|
+
content=md(reuse.description),
|
|
163
|
+
author_name=author_name,
|
|
164
|
+
author_link=author_uri,
|
|
165
|
+
link=reuse.external_url,
|
|
166
|
+
updateddate=reuse.last_modified,
|
|
167
|
+
pubdate=reuse.created_at,
|
|
168
|
+
)
|
|
169
|
+
response = make_response(feed.writeString("utf-8"))
|
|
170
|
+
response.headers["Content-Type"] = "application/atom+xml"
|
|
171
|
+
return response
|
|
172
|
+
|
|
173
|
+
|
|
133
174
|
@ns.route("/<reuse:reuse>/", endpoint="reuse", doc=common_doc)
|
|
134
175
|
@api.response(404, "Reuse not found")
|
|
135
176
|
@api.response(410, "Reuse has been deleted")
|
udata/core/reuse/models.py
CHANGED
|
@@ -3,6 +3,7 @@ from mongoengine.signals import post_save, pre_save
|
|
|
3
3
|
from werkzeug.utils import cached_property
|
|
4
4
|
|
|
5
5
|
from udata.api_fields import field, function_field, generate_fields
|
|
6
|
+
from udata.core.activity.models import Auditable
|
|
6
7
|
from udata.core.dataset.api_fields import dataset_fields
|
|
7
8
|
from udata.core.owned import Owned, OwnedQuerySet
|
|
8
9
|
from udata.core.reuse.api_fields import BIGGEST_IMAGE_SIZE
|
|
@@ -60,7 +61,7 @@ class ReuseBadgeMixin(BadgeMixin):
|
|
|
60
61
|
additional_filters={"organization_badge": "organization.badges"},
|
|
61
62
|
mask="*,datasets{id,title,uri,page}",
|
|
62
63
|
)
|
|
63
|
-
class Reuse(db.Datetimed, WithMetrics, ReuseBadgeMixin, Owned, db.Document):
|
|
64
|
+
class Reuse(db.Datetimed, Auditable, WithMetrics, ReuseBadgeMixin, Owned, db.Document):
|
|
64
65
|
title = field(
|
|
65
66
|
db.StringField(required=True),
|
|
66
67
|
sortable=True,
|
|
@@ -71,6 +72,7 @@ class Reuse(db.Datetimed, WithMetrics, ReuseBadgeMixin, Owned, db.Document):
|
|
|
71
72
|
max_length=255, required=True, populate_from="title", update=True, follow=True
|
|
72
73
|
),
|
|
73
74
|
readonly=True,
|
|
75
|
+
auditable=False,
|
|
74
76
|
)
|
|
75
77
|
description = field(
|
|
76
78
|
db.StringField(required=True),
|
|
@@ -126,15 +128,17 @@ class Reuse(db.Datetimed, WithMetrics, ReuseBadgeMixin, Owned, db.Document):
|
|
|
126
128
|
private = field(db.BooleanField(default=False), filterable={})
|
|
127
129
|
|
|
128
130
|
ext = db.MapField(db.GenericEmbeddedDocumentField())
|
|
129
|
-
extras = field(db.ExtrasField())
|
|
131
|
+
extras = field(db.ExtrasField(), auditable=False)
|
|
130
132
|
|
|
131
133
|
featured = field(
|
|
132
134
|
db.BooleanField(),
|
|
133
135
|
filterable={},
|
|
134
136
|
readonly=True,
|
|
137
|
+
auditable=False,
|
|
135
138
|
)
|
|
136
139
|
deleted = field(
|
|
137
140
|
db.DateTimeField(),
|
|
141
|
+
auditable=False,
|
|
138
142
|
)
|
|
139
143
|
archived = field(
|
|
140
144
|
db.DateTimeField(),
|
|
@@ -181,18 +185,6 @@ class Reuse(db.Datetimed, WithMetrics, ReuseBadgeMixin, Owned, db.Document):
|
|
|
181
185
|
# Emit before_save
|
|
182
186
|
cls.before_save.send(document)
|
|
183
187
|
|
|
184
|
-
@classmethod
|
|
185
|
-
def post_save(cls, sender, document, **kwargs):
|
|
186
|
-
if "post_save" in kwargs.get("ignores", []):
|
|
187
|
-
return
|
|
188
|
-
cls.after_save.send(document)
|
|
189
|
-
if kwargs.get("created"):
|
|
190
|
-
cls.on_create.send(document)
|
|
191
|
-
else:
|
|
192
|
-
cls.on_update.send(document)
|
|
193
|
-
if document.deleted:
|
|
194
|
-
cls.on_delete.send(document)
|
|
195
|
-
|
|
196
188
|
def url_for(self, *args, **kwargs):
|
|
197
189
|
return endpoint_for("reuses.show", "api.reuse", reuse=self, *args, **kwargs)
|
|
198
190
|
|
|
@@ -289,13 +281,13 @@ class Reuse(db.Datetimed, WithMetrics, ReuseBadgeMixin, Owned, db.Document):
|
|
|
289
281
|
from udata.models import Discussion
|
|
290
282
|
|
|
291
283
|
self.metrics["discussions"] = Discussion.objects(subject=self, closed=None).count()
|
|
292
|
-
self.save()
|
|
284
|
+
self.save(signal_kwargs={"ignores": ["post_save"]})
|
|
293
285
|
|
|
294
286
|
def count_followers(self):
|
|
295
287
|
from udata.models import Follow
|
|
296
288
|
|
|
297
289
|
self.metrics["followers"] = Follow.objects(until=None).followers(self).count()
|
|
298
|
-
self.save()
|
|
290
|
+
self.save(signal_kwargs={"ignores": ["post_save"]})
|
|
299
291
|
|
|
300
292
|
|
|
301
293
|
pre_save.connect(Reuse.pre_save, sender=Reuse)
|
udata/core/site/models.py
CHANGED
|
@@ -3,6 +3,7 @@ from werkzeug.local import LocalProxy
|
|
|
3
3
|
|
|
4
4
|
from udata.core.dataservices.models import Dataservice
|
|
5
5
|
from udata.core.dataset.models import Dataset
|
|
6
|
+
from udata.core.metrics.helpers import get_metrics_for_model, get_stock_metrics
|
|
6
7
|
from udata.core.organization.models import Organization
|
|
7
8
|
from udata.core.reuse.models import Reuse
|
|
8
9
|
from udata.models import WithMetrics, db
|
|
@@ -36,15 +37,23 @@ class Site(WithMetrics, db.Document):
|
|
|
36
37
|
"max_org_reuses",
|
|
37
38
|
"max_org_datasets",
|
|
38
39
|
"datasets",
|
|
40
|
+
"datasets_visits_by_months",
|
|
39
41
|
"discussions",
|
|
40
42
|
"followers",
|
|
41
43
|
"organizations",
|
|
42
44
|
"public-service",
|
|
43
45
|
"resources",
|
|
46
|
+
"resources_downloads_by_months",
|
|
44
47
|
"reuses",
|
|
45
48
|
"dataservices",
|
|
46
49
|
"users",
|
|
47
50
|
"harvesters",
|
|
51
|
+
"users_by_months",
|
|
52
|
+
"datasets_by_months",
|
|
53
|
+
"harvesters_by_months",
|
|
54
|
+
"reuses_by_months",
|
|
55
|
+
"organizations_by_months",
|
|
56
|
+
"discussions_by_months",
|
|
48
57
|
]
|
|
49
58
|
|
|
50
59
|
def __str__(self):
|
|
@@ -72,6 +81,9 @@ class Site(WithMetrics, db.Document):
|
|
|
72
81
|
from udata.models import Dataset
|
|
73
82
|
|
|
74
83
|
self.metrics["datasets"] = Dataset.objects.visible().count()
|
|
84
|
+
self.metrics["datasets_visits_by_months"] = get_metrics_for_model(
|
|
85
|
+
"site", None, ["visit_dataset"]
|
|
86
|
+
)[0]
|
|
75
87
|
self.save()
|
|
76
88
|
|
|
77
89
|
def count_resources(self):
|
|
@@ -83,6 +95,9 @@ class Site(WithMetrics, db.Document):
|
|
|
83
95
|
),
|
|
84
96
|
{},
|
|
85
97
|
).get("count", 0)
|
|
98
|
+
self.metrics["resources_downloads_by_months"] = get_metrics_for_model(
|
|
99
|
+
"site", None, ["download_resource"]
|
|
100
|
+
)[0]
|
|
86
101
|
self.save()
|
|
87
102
|
|
|
88
103
|
def count_reuses(self):
|
|
@@ -172,6 +187,24 @@ class Site(WithMetrics, db.Document):
|
|
|
172
187
|
self.metrics["max_org_datasets"] = org.metrics["datasets"] if org else 0
|
|
173
188
|
self.save()
|
|
174
189
|
|
|
190
|
+
def count_stock_metrics(self):
|
|
191
|
+
from udata.harvest.models import HarvestSource
|
|
192
|
+
from udata.models import Discussion, User
|
|
193
|
+
|
|
194
|
+
self.metrics["users_by_months"] = get_stock_metrics(User.objects())
|
|
195
|
+
self.metrics["datasets_by_months"] = get_stock_metrics(
|
|
196
|
+
Dataset.objects().visible(), date_label="created_at_internal"
|
|
197
|
+
)
|
|
198
|
+
self.metrics["harvesters_by_months"] = get_stock_metrics(HarvestSource.objects())
|
|
199
|
+
self.metrics["reuses_by_months"] = get_stock_metrics(Reuse.objects().visible())
|
|
200
|
+
self.metrics["organizations_by_months"] = get_stock_metrics(
|
|
201
|
+
Organization.objects().visible()
|
|
202
|
+
)
|
|
203
|
+
self.metrics["discussions_by_months"] = get_stock_metrics(
|
|
204
|
+
Discussion.objects(), date_label="created"
|
|
205
|
+
)
|
|
206
|
+
self.save()
|
|
207
|
+
|
|
175
208
|
|
|
176
209
|
def get_current_site():
|
|
177
210
|
if getattr(g, "site", None) is None:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from flask_security import current_user
|
|
2
|
+
|
|
3
|
+
from udata.i18n import lazy_gettext as _
|
|
4
|
+
from udata.models import Activity, Topic, db
|
|
5
|
+
|
|
6
|
+
__all__ = ("UserCreatedTopic", "UserUpdatedTopic", "TopicRelatedActivity")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TopicRelatedActivity(object):
|
|
10
|
+
related_to = db.ReferenceField("Topic")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserCreatedTopic(TopicRelatedActivity, Activity):
|
|
14
|
+
key = "topic:created"
|
|
15
|
+
icon = "fa fa-plus"
|
|
16
|
+
badge_type = "success"
|
|
17
|
+
label = _("created a topic")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UserUpdatedTopic(TopicRelatedActivity, Activity):
|
|
21
|
+
key = "topic:updated"
|
|
22
|
+
icon = "fa fa-pencil"
|
|
23
|
+
label = _("updated a topic")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@Topic.on_create.connect
|
|
27
|
+
def on_user_created_topic(topic):
|
|
28
|
+
if current_user and current_user.is_authenticated:
|
|
29
|
+
UserCreatedTopic.emit(topic, topic.organization)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@Topic.on_update.connect
|
|
33
|
+
def on_user_updated_topic(topic, **kwargs):
|
|
34
|
+
changed_fields = kwargs.get("changed_fields", [])
|
|
35
|
+
if current_user and current_user.is_authenticated:
|
|
36
|
+
UserUpdatedTopic.emit(topic, topic.organization, changed_fields)
|