udata 7.0.4.dev27782__py2.py3-none-any.whl → 7.0.5__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of udata might be problematic. Click here for more details.

Files changed (71) hide show
  1. udata/__init__.py +1 -1
  2. udata/api/__init__.py +1 -1
  3. udata/core/dataset/api.py +14 -14
  4. udata/core/dataset/api_fields.py +7 -7
  5. udata/core/dataset/apiv2.py +3 -3
  6. udata/core/dataset/rdf.py +43 -1
  7. udata/core/organization/csv.py +27 -1
  8. udata/core/organization/models.py +20 -1
  9. udata/core/organization/tasks.py +61 -1
  10. udata/core/spatial/commands.py +26 -2
  11. udata/core/topic/api.py +6 -0
  12. udata/core/topic/apiv2.py +6 -0
  13. udata/core/topic/forms.py +5 -0
  14. udata/core/topic/models.py +3 -5
  15. udata/forms/fields.py +10 -0
  16. udata/frontend/csv.py +8 -8
  17. udata/harvest/actions.py +11 -0
  18. udata/harvest/api.py +3 -3
  19. udata/harvest/backends/dcat.py +42 -5
  20. udata/harvest/tests/dcat/bnodes.xml +16 -2
  21. udata/harvest/tests/test_dcat_backend.py +87 -1
  22. udata/settings.py +9 -0
  23. udata/static/chunks/{11.c0ccea08914b6b41568e.js → 11.a23c110811a9ac943478.js} +3 -3
  24. udata/static/chunks/{11.c0ccea08914b6b41568e.js.map → 11.a23c110811a9ac943478.js.map} +1 -1
  25. udata/static/chunks/{13.526a25163ababaa44409.js → 13.0889e093f8664e38568c.js} +2 -2
  26. udata/static/chunks/{13.526a25163ababaa44409.js.map → 13.0889e093f8664e38568c.js.map} +1 -1
  27. udata/static/chunks/{16.7901839b4227881947f6.js → 16.f41599478d3e97ad9a30.js} +2 -2
  28. udata/static/chunks/{16.7901839b4227881947f6.js.map → 16.f41599478d3e97ad9a30.js.map} +1 -1
  29. udata/static/chunks/{19.471d5a2a08eef6e5338a.js → 19.2b534a26af8b17e9170b.js} +3 -3
  30. udata/static/chunks/{19.471d5a2a08eef6e5338a.js.map → 19.2b534a26af8b17e9170b.js.map} +1 -1
  31. udata/static/chunks/{5.534e0531d0e2b150146f.js → 5.7115454a1183e5c12eef.js} +3 -3
  32. udata/static/chunks/{5.534e0531d0e2b150146f.js.map → 5.7115454a1183e5c12eef.js.map} +1 -1
  33. udata/static/chunks/{6.e56975229e6065f68d2a.js → 6.16bb24fb8240f2746488.js} +3 -3
  34. udata/static/chunks/{6.e56975229e6065f68d2a.js.map → 6.16bb24fb8240f2746488.js.map} +1 -1
  35. udata/static/chunks/{9.534426728626f11f4571.js → 9.3e752966ff14e47e11f2.js} +2 -2
  36. udata/static/chunks/{9.534426728626f11f4571.js.map → 9.3e752966ff14e47e11f2.js.map} +1 -1
  37. udata/static/common.js +1 -1
  38. udata/static/common.js.map +1 -1
  39. udata/storage/__init__.py +0 -0
  40. udata/storage/s3.py +54 -0
  41. udata/templates/mail/badge_added_association.html +33 -0
  42. udata/templates/mail/badge_added_association.txt +11 -0
  43. udata/templates/mail/badge_added_company.html +33 -0
  44. udata/templates/mail/badge_added_company.txt +11 -0
  45. udata/templates/mail/badge_added_local_authority.html +33 -0
  46. udata/templates/mail/badge_added_local_authority.txt +11 -0
  47. udata/tests/api/test_datasets_api.py +27 -0
  48. udata/tests/api/test_topics_api.py +31 -1
  49. udata/tests/apiv2/test_topics.py +4 -0
  50. udata/tests/organization/test_csv_adapter.py +43 -0
  51. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  52. udata/translations/ar/LC_MESSAGES/udata.po +90 -44
  53. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  54. udata/translations/de/LC_MESSAGES/udata.po +91 -45
  55. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  56. udata/translations/es/LC_MESSAGES/udata.po +90 -44
  57. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  58. udata/translations/fr/LC_MESSAGES/udata.po +91 -45
  59. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  60. udata/translations/it/LC_MESSAGES/udata.po +90 -44
  61. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  62. udata/translations/pt/LC_MESSAGES/udata.po +91 -45
  63. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  64. udata/translations/sr/LC_MESSAGES/udata.po +91 -45
  65. udata/translations/udata.pot +91 -45
  66. {udata-7.0.4.dev27782.dist-info → udata-7.0.5.dist-info}/METADATA +20 -3
  67. {udata-7.0.4.dev27782.dist-info → udata-7.0.5.dist-info}/RECORD +71 -62
  68. {udata-7.0.4.dev27782.dist-info → udata-7.0.5.dist-info}/LICENSE +0 -0
  69. {udata-7.0.4.dev27782.dist-info → udata-7.0.5.dist-info}/WHEEL +0 -0
  70. {udata-7.0.4.dev27782.dist-info → udata-7.0.5.dist-info}/entry_points.txt +0 -0
  71. {udata-7.0.4.dev27782.dist-info → udata-7.0.5.dist-info}/top_level.txt +0 -0
udata/__init__.py CHANGED
@@ -4,5 +4,5 @@
4
4
  udata
5
5
  '''
6
6
 
7
- __version__ = '7.0.4.dev'
7
+ __version__ = '7.0.5'
8
8
  __description__ = 'Open data portal'
udata/api/__init__.py CHANGED
@@ -147,7 +147,7 @@ class UDataApi(Api):
147
147
 
148
148
  def validate(self, form_cls, obj=None):
149
149
  '''Validate a form from the request and handle errors'''
150
- if 'application/json' not in request.headers.get('Content-Type'):
150
+ if 'application/json' not in request.headers.get('Content-Type', ''):
151
151
  errors = {'Content-Type': 'expecting application/json'}
152
152
  self.abort(400, errors=errors)
153
153
  form = form_cls.from_json(request.json, obj=obj, instance=obj,
udata/core/dataset/api.py CHANGED
@@ -197,7 +197,7 @@ class DatasetListAPI(API):
197
197
  @api.secure
198
198
  @api.doc('create_dataset', responses={400: 'Validation error'})
199
199
  @api.expect(dataset_fields)
200
- @api.marshal_with(dataset_fields)
200
+ @api.marshal_with(dataset_fields, code=201)
201
201
  def post(self):
202
202
  '''Create a new dataset'''
203
203
  form = api.validate(DatasetForm)
@@ -344,9 +344,9 @@ class ResourceRedirectAPI(API):
344
344
  @ns.route('/<dataset:dataset>/resources/', endpoint='resources')
345
345
  class ResourcesAPI(API):
346
346
  @api.secure
347
- @api.doc('create_resource', **common_doc)
347
+ @api.doc('create_resource', **common_doc, responses={400: 'Validation error'})
348
348
  @api.expect(resource_fields)
349
- @api.marshal_with(resource_fields)
349
+ @api.marshal_with(resource_fields, code=201)
350
350
  def post(self, dataset):
351
351
  '''Create a new resource for a given dataset'''
352
352
  ResourceEditPermission(dataset).test()
@@ -361,7 +361,7 @@ class ResourcesAPI(API):
361
361
  return resource, 201
362
362
 
363
363
  @api.secure
364
- @api.doc('update_resources', **common_doc)
364
+ @api.doc('update_resources', **common_doc, responses={400: 'Validation error'})
365
365
  @api.expect([resource_fields])
366
366
  @api.marshal_list_with(resource_fields)
367
367
  def put(self, dataset):
@@ -397,9 +397,9 @@ class UploadMixin(object):
397
397
  @api.doc(**common_doc)
398
398
  class UploadNewDatasetResource(UploadMixin, API):
399
399
  @api.secure
400
- @api.doc('upload_new_dataset_resource')
400
+ @api.doc('upload_new_dataset_resource', responses={415: 'Incorrect file content type', 400: 'Upload error'})
401
401
  @api.expect(upload_parser)
402
- @api.marshal_with(upload_fields)
402
+ @api.marshal_with(upload_fields, code=201)
403
403
  def post(self, dataset):
404
404
  '''Upload a new dataset resource'''
405
405
  ResourceEditPermission(dataset).test()
@@ -416,9 +416,9 @@ class UploadNewDatasetResource(UploadMixin, API):
416
416
  @api.doc(**common_doc)
417
417
  class UploadNewCommunityResources(UploadMixin, API):
418
418
  @api.secure
419
- @api.doc('upload_new_community_resource')
419
+ @api.doc('upload_new_community_resource', responses={415: 'Incorrect file content type', 400: 'Upload error'})
420
420
  @api.expect(upload_parser)
421
- @api.marshal_with(upload_fields)
421
+ @api.marshal_with(upload_fields, code=201)
422
422
  def post(self, dataset):
423
423
  '''Upload a new community resource'''
424
424
  infos = self.handle_upload(dataset)
@@ -442,7 +442,7 @@ class ResourceMixin(object):
442
442
  @api.param('rid', 'The resource unique identifier')
443
443
  class UploadDatasetResource(ResourceMixin, UploadMixin, API):
444
444
  @api.secure
445
- @api.doc('upload_dataset_resource')
445
+ @api.doc('upload_dataset_resource', responses={415: 'Incorrect file content type', 400: 'Upload error'})
446
446
  @api.marshal_with(upload_fields)
447
447
  def post(self, dataset, rid):
448
448
  '''Upload a file related to a given resource on a given dataset'''
@@ -465,7 +465,7 @@ class UploadDatasetResource(ResourceMixin, UploadMixin, API):
465
465
  @api.param('community', 'The community resource unique identifier')
466
466
  class ReuploadCommunityResource(ResourceMixin, UploadMixin, API):
467
467
  @api.secure
468
- @api.doc('upload_community_resource')
468
+ @api.doc('upload_community_resource', responses={415: 'Incorrect file content type', 400: 'Upload error'})
469
469
  @api.marshal_with(upload_fields)
470
470
  def post(self, community):
471
471
  '''Update the file related to a given community resource'''
@@ -493,7 +493,7 @@ class ResourceAPI(ResourceMixin, API):
493
493
  return resource
494
494
 
495
495
  @api.secure
496
- @api.doc('update_resource')
496
+ @api.doc('update_resource', responses={400: 'Validation error'})
497
497
  @api.expect(resource_fields)
498
498
  @api.marshal_with(resource_fields)
499
499
  def put(self, dataset, rid):
@@ -546,9 +546,9 @@ class CommunityResourcesAPI(API):
546
546
  .paginate(args['page'], args['page_size']))
547
547
 
548
548
  @api.secure
549
- @api.doc('create_community_resource')
549
+ @api.doc('create_community_resource', responses={400: 'Validation error'})
550
550
  @api.expect(community_resource_fields)
551
- @api.marshal_with(community_resource_fields)
551
+ @api.marshal_with(community_resource_fields, code=201)
552
552
  def post(self):
553
553
  '''Create a new community resource'''
554
554
  form = api.validate(CommunityResourceForm)
@@ -578,7 +578,7 @@ class CommunityResourceAPI(API):
578
578
  return community
579
579
 
580
580
  @api.secure
581
- @api.doc('update_community_resource')
581
+ @api.doc('update_community_resource', responses={400: 'Validation error'})
582
582
  @api.expect(community_resource_fields)
583
583
  @api.marshal_with(community_resource_fields)
584
584
  def put(self, community):
@@ -33,9 +33,9 @@ schema_fields = api.model('Schema', {
33
33
  dataset_harvest_fields = api.model('HarvestDatasetMetadata', {
34
34
  'backend': fields.String(description='Harvest backend used', allow_null=True),
35
35
  'created_at': fields.ISODateTime(description='The dataset harvested creation date',
36
- allow_null=True),
36
+ allow_null=True, readonly=True),
37
37
  'modified_at': fields.ISODateTime(description='The dataset harvest last modification date',
38
- allow_null=True),
38
+ allow_null=True, readonly=True),
39
39
  'source_id': fields.String(description='The harvester id', allow_null=True),
40
40
  'remote_id': fields.String(description='The dataset remote id on the source portal',
41
41
  allow_null=True),
@@ -54,9 +54,9 @@ dataset_harvest_fields = api.model('HarvestDatasetMetadata', {
54
54
 
55
55
  resource_harvest_fields = api.model('HarvestResourceMetadata', {
56
56
  'created_at': fields.ISODateTime(description='The resource harvested creation date',
57
- allow_null=True),
57
+ allow_null=True, readonly=True),
58
58
  'modified_at': fields.ISODateTime(description='The resource harvest last modification date',
59
- allow_null=True),
59
+ allow_null=True, readonly=True),
60
60
  'uri': fields.String(description='The resource harvest uri', allow_null=True)
61
61
  })
62
62
 
@@ -199,10 +199,10 @@ dataset_fields = api.model('Dataset', {
199
199
  'description': fields.Markdown(
200
200
  description='The dataset description in markdown', required=True),
201
201
  'created_at': fields.ISODateTime(
202
- description='This date is computed between harvested creation date if any and site\'s internal creation date' , required=True),
202
+ description='This date is computed between harvested creation date if any and site\'s internal creation date' , required=True, readonly=True),
203
203
  'last_modified': fields.ISODateTime(
204
- description='The dataset last modification date', required=True),
205
- 'deleted': fields.ISODateTime(description='The deletion date if deleted'),
204
+ description='The dataset last modification date', required=True, readonly=True),
205
+ 'deleted': fields.ISODateTime(description='The deletion date if deleted', readonly=True),
206
206
  'archived': fields.ISODateTime(description='The archival date if archived'),
207
207
  'featured': fields.Boolean(description='Is the dataset featured'),
208
208
  'private': fields.Boolean(
@@ -73,10 +73,10 @@ dataset_fields = apiv2.model('Dataset', {
73
73
  'description': fields.Markdown(
74
74
  description='The dataset description in markdown', required=True),
75
75
  'created_at': fields.ISODateTime(
76
- description='The dataset creation date', required=True),
76
+ description='The dataset creation date', required=True, readonly=True),
77
77
  'last_modified': fields.ISODateTime(
78
- description='The dataset last modification date', required=True),
79
- 'deleted': fields.ISODateTime(description='The deletion date if deleted'),
78
+ description='The dataset last modification date', required=True, readonly=True),
79
+ 'deleted': fields.ISODateTime(description='The deletion date if deleted', readonly=True),
80
80
  'archived': fields.ISODateTime(description='The archival date if archived'),
81
81
  'featured': fields.Boolean(description='Is the dataset featured'),
82
82
  'private': fields.Boolean(
udata/core/dataset/rdf.py CHANGED
@@ -2,17 +2,21 @@
2
2
  This module centralize dataset helpers for RDF/DCAT serialization and parsing
3
3
  '''
4
4
  import calendar
5
+ import json
5
6
  import logging
6
7
 
7
8
  from datetime import date
8
9
  from html.parser import HTMLParser
9
10
  from dateutil.parser import parse as parse_dt
10
11
  from flask import current_app
12
+ from geomet import wkt
11
13
  from rdflib import Graph, URIRef, Literal, BNode
12
14
  from rdflib.resource import Resource as RdfResource
13
15
  from rdflib.namespace import RDF
16
+ from mongoengine.errors import ValidationError
14
17
 
15
18
  from udata import i18n, uris
19
+ from udata.core.spatial.models import SpatialCoverage
16
20
  from udata.frontend.markdown import parse_html
17
21
  from udata.core.dataset.models import HarvestDatasetMetadata, HarvestResourceMetadata
18
22
  from udata.models import db, ContactPoint
@@ -334,6 +338,40 @@ def contact_point_from_rdf(rdf, dataset):
334
338
  ContactPoint(name=name, email=email, owner=dataset.owner).save())
335
339
 
336
340
 
341
+ def spatial_from_rdf(graph):
342
+ for term in graph.objects(DCT.spatial):
343
+ for object in term.objects():
344
+ if isinstance(object, Literal):
345
+ if object.datatype.__str__() == 'https://www.iana.org/assignments/media-types/application/vnd.geo+json':
346
+ try:
347
+ geojson = json.loads(object.toPython())
348
+ except ValueError as e:
349
+ log.warning(f"Invalid JSON in spatial GeoJSON {object.toPython()} {e}")
350
+ continue
351
+ elif object.datatype.__str__() == 'http://www.opengis.net/rdf#wktLiteral':
352
+ try:
353
+ # .upper() si here because geomet doesn't support Polygon but only POLYGON
354
+ geojson = wkt.loads(object.toPython().strip().upper())
355
+ except ValueError as e:
356
+ log.warning(f"Invalid JSON in spatial WKT {object.toPython()} {e}")
357
+ continue
358
+ else:
359
+ continue
360
+
361
+ if geojson['type'] == 'Polygon':
362
+ geojson['type'] = 'MultiPolygon'
363
+ geojson['coordinates'] = [geojson['coordinates']]
364
+
365
+ spatial_coverage = SpatialCoverage(geom=geojson)
366
+
367
+ try:
368
+ spatial_coverage.clean()
369
+ return spatial_coverage
370
+ except ValidationError:
371
+ continue
372
+
373
+ return None
374
+
337
375
  def frequency_from_rdf(term):
338
376
  if isinstance(term, str):
339
377
  try:
@@ -488,7 +526,7 @@ def resource_from_rdf(graph_or_distrib, dataset=None, is_additionnal=False):
488
526
  return resource
489
527
 
490
528
 
491
- def dataset_from_rdf(graph, dataset=None, node=None):
529
+ def dataset_from_rdf(graph: Graph, dataset=None, node=None):
492
530
  '''
493
531
  Create or update a dataset from a RDF/DCAT graph
494
532
  '''
@@ -509,6 +547,10 @@ def dataset_from_rdf(graph, dataset=None, node=None):
509
547
  if schema:
510
548
  dataset.schema = schema
511
549
 
550
+ spatial_coverage = spatial_from_rdf(d)
551
+ if spatial_coverage:
552
+ dataset.spatial = spatial_coverage
553
+
512
554
  acronym = rdf_value(d, SKOS.altLabel)
513
555
  if acronym:
514
556
  dataset.acronym = acronym
@@ -1,3 +1,4 @@
1
+ from udata.core.dataset.models import Dataset
1
2
  from udata.frontend import csv
2
3
 
3
4
  from .models import Organization
@@ -5,6 +6,8 @@ from .models import Organization
5
6
 
6
7
  @csv.adapter(Organization)
7
8
  class OrganizationCsvAdapter(csv.Adapter):
9
+ downloads_counts = None
10
+
8
11
  fields = (
9
12
  'id',
10
13
  'name',
@@ -18,4 +21,27 @@ class OrganizationCsvAdapter(csv.Adapter):
18
21
  )
19
22
 
20
23
  def dynamic_fields(self):
21
- return csv.metric_fields(Organization)
24
+ return csv.metric_fields(Organization) + self.get_dynamic_field_downloads()
25
+
26
+ def get_dynamic_field_downloads(self):
27
+ downloads_counts = self.get_downloads_counts()
28
+ return [('downloads', lambda o: downloads_counts.get(str(o.id), 0))]
29
+
30
+ def get_downloads_counts(self):
31
+ '''
32
+ Prefetch all the resources' downloads for all selected organization into memory
33
+ '''
34
+ if self.downloads_counts is not None:
35
+ return self.downloads_counts
36
+
37
+ self.downloads_counts = {}
38
+
39
+ ids = [o.id for o in self.queryset]
40
+ for dataset in Dataset.objects(organization__in=ids):
41
+ org_id = str(dataset.organization.id)
42
+ if self.downloads_counts.get(org_id) is None:
43
+ self.downloads_counts[org_id] = 0
44
+
45
+ self.downloads_counts[org_id] += sum(resource.metrics.get('views', 0) for resource in dataset.resources)
46
+
47
+ return self.downloads_counts
@@ -14,7 +14,8 @@ from udata.uris import endpoint_for
14
14
 
15
15
  __all__ = (
16
16
  'Organization', 'Team', 'Member', 'MembershipRequest',
17
- 'ORG_ROLES', 'MEMBERSHIP_STATUS', 'PUBLIC_SERVICE', 'CERTIFIED'
17
+ 'ORG_ROLES', 'MEMBERSHIP_STATUS', 'PUBLIC_SERVICE', 'CERTIFIED',
18
+ 'ASSOCIATION', 'COMPANY', 'LOCAL_AUTHORITY'
18
19
  )
19
20
 
20
21
 
@@ -36,6 +37,9 @@ LOGO_SIZES = [100, 60, 25]
36
37
 
37
38
  PUBLIC_SERVICE = 'public-service'
38
39
  CERTIFIED = 'certified'
40
+ ASSOCIATION = 'Association'
41
+ COMPANY = 'Company'
42
+ LOCAL_AUTHORITY = 'Local authority'
39
43
 
40
44
  TITLE_SIZE_LIMIT = 350
41
45
  DESCRIPTION_SIZE_LIMIT = 100000
@@ -140,6 +144,9 @@ class Organization(WithMetrics, BadgeMixin, db.Datetimed, db.Document):
140
144
  __badges__ = {
141
145
  PUBLIC_SERVICE: _('Public Service'),
142
146
  CERTIFIED: _('Certified'),
147
+ ASSOCIATION: _('Association'),
148
+ COMPANY: _('Company'),
149
+ LOCAL_AUTHORITY: _('Local authority'),
143
150
  }
144
151
 
145
152
  __metrics_keys__ = [
@@ -199,6 +206,18 @@ class Organization(WithMetrics, BadgeMixin, db.Datetimed, db.Document):
199
206
  is_public_service = any(b.kind == PUBLIC_SERVICE for b in self.badges)
200
207
  return self.certified and is_public_service
201
208
 
209
+ @property
210
+ def company(self):
211
+ return any(b.kind == COMPANY for b in self.badges)
212
+
213
+ @property
214
+ def association(self):
215
+ return any(b.kind == ASSOCIATION for b in self.badges)
216
+
217
+ @property
218
+ def local_authority(self):
219
+ return any(b.kind == LOCAL_AUTHORITY for b in self.badges)
220
+
202
221
  def member(self, user):
203
222
  for member in self.members:
204
223
  if member.user == user:
@@ -7,7 +7,7 @@ from udata.tasks import job, task, get_logger
7
7
 
8
8
  from udata.core.badges.tasks import notify_new_badge
9
9
 
10
- from .models import Organization, CERTIFIED, PUBLIC_SERVICE
10
+ from .models import Organization, CERTIFIED, PUBLIC_SERVICE, COMPANY, ASSOCIATION, LOCAL_AUTHORITY
11
11
 
12
12
  log = get_logger(__name__)
13
13
 
@@ -114,3 +114,63 @@ def notify_badge_public_service(org_id):
114
114
  organization=org,
115
115
  badge=org.get_badge(PUBLIC_SERVICE)
116
116
  )
117
+
118
+
119
+ @notify_new_badge(Organization, COMPANY)
120
+ def notify_badge_company(org_id):
121
+ '''
122
+ Send an email when a `COMPANY` badge is added to an `Organization`
123
+ '''
124
+ org = Organization.objects.get(pk=org_id)
125
+ recipients = [member.user for member in org.members]
126
+ subject = _(
127
+ 'Your organization "%(name)s" has been identified as a company',
128
+ name=org.name
129
+ )
130
+ mail.send(
131
+ subject,
132
+ recipients,
133
+ 'badge_added_company',
134
+ organization=org,
135
+ badge=org.get_badge(COMPANY)
136
+ )
137
+
138
+
139
+ @notify_new_badge(Organization, ASSOCIATION)
140
+ def notify_badge_association(org_id):
141
+ '''
142
+ Send an email when a `ASSOCIATION` badge is added to an `Organization`
143
+ '''
144
+ org = Organization.objects.get(pk=org_id)
145
+ recipients = [member.user for member in org.members]
146
+ subject = _(
147
+ 'Your organization "%(name)s" has been identified as an association',
148
+ name=org.name
149
+ )
150
+ mail.send(
151
+ subject,
152
+ recipients,
153
+ 'badge_added_association',
154
+ organization=org,
155
+ badge=org.get_badge(ASSOCIATION)
156
+ )
157
+
158
+
159
+ @notify_new_badge(Organization, LOCAL_AUTHORITY)
160
+ def notify_badge_local_authority(org_id):
161
+ '''
162
+ Send an email when a `LOCAL_AUTHORITY` badge is added to an `Organization`
163
+ '''
164
+ org = Organization.objects.get(pk=org_id)
165
+ recipients = [member.user for member in org.members]
166
+ subject = _(
167
+ 'Your organization "%(name)s" has been identified as a local authority',
168
+ name=org.name
169
+ )
170
+ mail.send(
171
+ subject,
172
+ recipients,
173
+ 'badge_added_local_authority',
174
+ organization=org,
175
+ badge=org.get_badge(LOCAL_AUTHORITY)
176
+ )
@@ -44,7 +44,10 @@ def load_levels(col, json_levels):
44
44
 
45
45
 
46
46
  def load_zones(col, json_geozones):
47
- for i, geozone in enumerate(json_geozones):
47
+ loaded_geozones = 0
48
+ for _, geozone in enumerate(json_geozones):
49
+ if geozone.get('is_deleted', False):
50
+ continue
48
51
  params = {
49
52
  'slug': slugify.slugify(geozone['nom'], separator='-'),
50
53
  'level': str(geozone['level']),
@@ -56,11 +59,12 @@ def load_zones(col, json_geozones):
56
59
  col.objects(id=geozone['_id']).modify(upsert=True, **{
57
60
  'set__{0}'.format(k): v for k, v in params.items()
58
61
  })
62
+ loaded_geozones += 1
59
63
  except errors.ValidationError as e:
60
64
  log.warning('Validation error (%s) for %s with %s',
61
65
  e, geozone['nom'], params)
62
66
  continue
63
- return i
67
+ return loaded_geozones
64
68
 
65
69
 
66
70
  @contextmanager
@@ -137,6 +141,10 @@ def load(geozones_file, levels_file, drop=False):
137
141
  total = load_zones(GeoZone, json_geozones)
138
142
  log.info('Loaded {total} zones'.format(total=total))
139
143
 
144
+ log.info('Clean removed geozones in datasets')
145
+ count = fixup_removed_geozone()
146
+ log.info(f'{count} geozones removed from datasets')
147
+
140
148
 
141
149
  @grp.command()
142
150
  def migrate():
@@ -184,3 +192,19 @@ def migrate():
184
192
  '''.format(level_summary, **counter)), level_summary])
185
193
  log.info(summary)
186
194
  log.info('Done')
195
+
196
+ def fixup_removed_geozone():
197
+ count = 0
198
+ all_datasets = Dataset.objects(spatial__zones__0__exists=True).timeout(False)
199
+ for dataset in all_datasets:
200
+ zones = dataset.spatial.zones
201
+ new_zones = [z for z in zones if getattr(z, 'name', None) is not None]
202
+
203
+ if len(new_zones) < len(zones):
204
+ log.debug(f"Removing deleted zones from dataset '{dataset.title}'")
205
+ count += len(zones) - len(new_zones)
206
+ dataset.spatial.zones = new_zones
207
+ dataset.save()
208
+
209
+ return count
210
+
udata/core/topic/api.py CHANGED
@@ -3,6 +3,7 @@ from udata.core.dataset.api_fields import dataset_fields
3
3
  from udata.core.discussions.models import Discussion
4
4
  from udata.core.organization.api_fields import org_ref_fields
5
5
  from udata.core.reuse.api_fields import reuse_fields
6
+ from udata.core.spatial.api_fields import spatial_coverage_fields
6
7
  from udata.core.topic.permissions import TopicEditPermission
7
8
  from udata.core.topic.parsers import TopicApiParser
8
9
  from udata.core.user.api_fields import user_ref_fields
@@ -37,6 +38,11 @@ topic_fields = api.model('Topic', {
37
38
  'private': fields.Boolean(description='Is the topic private'),
38
39
  'created_at': fields.ISODateTime(
39
40
  description='The topic creation date', readonly=True),
41
+ 'spatial': fields.Nested(
42
+ spatial_coverage_fields, allow_null=True,
43
+ description='The spatial coverage'),
44
+ 'last_modified': fields.ISODateTime(
45
+ description='The topic last modification date', readonly=True),
40
46
  'organization': fields.Nested(
41
47
  org_ref_fields, allow_null=True,
42
48
  description='The publishing organization', readonly=True),
udata/core/topic/apiv2.py CHANGED
@@ -13,6 +13,7 @@ from udata.core.organization.api_fields import org_ref_fields
13
13
  from udata.core.reuse.api import ReuseApiParser
14
14
  from udata.core.reuse.apiv2 import reuse_page_fields
15
15
  from udata.core.reuse.models import Reuse
16
+ from udata.core.spatial.api_fields import spatial_coverage_fields
16
17
  from udata.core.topic.models import Topic
17
18
  from udata.core.topic.parsers import TopicApiParser
18
19
  from udata.core.topic.permissions import TopicEditPermission
@@ -63,6 +64,11 @@ topic_fields = apiv2.model('Topic', {
63
64
  'private': fields.Boolean(description='Is the topic private'),
64
65
  'created_at': fields.ISODateTime(
65
66
  description='The topic creation date', readonly=True),
67
+ 'spatial': fields.Nested(
68
+ spatial_coverage_fields, allow_null=True,
69
+ description='The spatial coverage'),
70
+ 'last_modified': fields.ISODateTime(
71
+ description='The topic last modification date', readonly=True),
66
72
  'organization': fields.Nested(
67
73
  org_ref_fields, allow_null=True,
68
74
  description='The publishing organization', readonly=True),
udata/core/topic/forms.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from udata.forms import ModelForm, fields, validators
2
+ from udata.core.spatial.forms import SpatialCoverageField
2
3
  from udata.i18n import lazy_gettext as _
3
4
 
4
5
  from .models import Topic
@@ -20,6 +21,10 @@ class TopicForm(ModelForm):
20
21
  datasets = fields.DatasetListField(_('Associated datasets'))
21
22
  reuses = fields.ReuseListField(_('Associated reuses'))
22
23
 
24
+ spatial = SpatialCoverageField(
25
+ _('Spatial coverage'),
26
+ description=_('The geographical area covered by the data.'))
27
+
23
28
  tags = fields.TagField(_('Tags'), [validators.DataRequired()])
24
29
  private = fields.BooleanField(_('Private'))
25
30
  featured = fields.BooleanField(_('Featured'))
@@ -1,8 +1,6 @@
1
- from datetime import datetime
2
1
  from flask import url_for
3
- from mongoengine.fields import DateTimeField
4
2
  from mongoengine.signals import pre_save
5
- from udata.models import db
3
+ from udata.models import db, SpatialCoverage
6
4
  from udata.search import reindex
7
5
  from udata.tasks import as_task_param
8
6
 
@@ -10,7 +8,7 @@ from udata.tasks import as_task_param
10
8
  __all__ = ('Topic', )
11
9
 
12
10
 
13
- class Topic(db.Document, db.Owned):
11
+ class Topic(db.Document, db.Owned, db.Datetimed):
14
12
  name = db.StringField(required=True)
15
13
  slug = db.SlugField(max_length=255, required=True, populate_from='name',
16
14
  update=True, follow=True)
@@ -28,7 +26,7 @@ class Topic(db.Document, db.Owned):
28
26
  private = db.BooleanField()
29
27
  extras = db.ExtrasField()
30
28
 
31
- created_at = DateTimeField(default=datetime.utcnow, required=True)
29
+ spatial = db.EmbeddedDocumentField(SpatialCoverage)
32
30
 
33
31
  meta = {
34
32
  'indexes': [
udata/forms/fields.py CHANGED
@@ -180,6 +180,16 @@ class BooleanField(FieldHelper, fields.BooleanField):
180
180
  self.stacked = kwargs.pop('stacked', False)
181
181
  super(BooleanField, self).__init__(*args, **kwargs)
182
182
 
183
+ def process_formdata(self, valuelist):
184
+ # We override this so that when no value is provided
185
+ # the form doesn't think the value is `False` instead
186
+ # the value is not present and the model can keep the
187
+ # existing value
188
+ if not valuelist:
189
+ return
190
+
191
+ super().process_formdata(valuelist)
192
+
183
193
 
184
194
  class RadioField(FieldHelper, fields.RadioField):
185
195
  def __init__(self, *args, **kwargs):
udata/frontend/csv.py CHANGED
@@ -58,8 +58,8 @@ class Adapter(object):
58
58
  else:
59
59
  field_tuple = (name, self.getter(*field))
60
60
  except Exception as e: # Catch all errors intentionally.
61
- log.error('Error exporting CSV for {name}: {error}'.format(
62
- name=self.__class__.__name__, error=e))
61
+ log.exception('Error exporting CSV for {name}: {error_class} {error}'.format(
62
+ name=self.__class__.__name__, error_class=e.__class__.__name__, error=e), stack_info=True)
63
63
  self._fields.append(field_tuple)
64
64
  return self._fields
65
65
 
@@ -89,8 +89,8 @@ class Adapter(object):
89
89
  try:
90
90
  content = safestr(getter(obj))
91
91
  except Exception as e: # Catch all errors intentionally.
92
- log.error('Error exporting CSV for {name}: {error}'.format(
93
- name=self.__class__.__name__, error=e))
92
+ log.exception('Error exporting CSV for {name}: {error_class} {error}'.format(
93
+ name=self.__class__.__name__, error_class=e.__class__.__name__, error=e), stack_info=True)
94
94
  row.append(content)
95
95
  return row
96
96
 
@@ -130,8 +130,8 @@ class NestedAdapter(Adapter):
130
130
  else:
131
131
  field_tuple = (name, self.getter(*field))
132
132
  except Exception as e: # Catch all errors intentionally.
133
- log.error('Error exporting CSV for {name}: {error}'.format(
134
- name=self.__class__.__name__, error=e))
133
+ log.exception('Error exporting CSV for {name}: {error_class} {error}'.format(
134
+ name=self.__class__.__name__, error_class=e.__class__.__name__, error=e), stack_info=True)
135
135
  self._nested_fields.append(field_tuple)
136
136
  return self._nested_fields
137
137
 
@@ -155,8 +155,8 @@ class NestedAdapter(Adapter):
155
155
  try:
156
156
  content = safestr(getter(nested))
157
157
  except Exception as e: # Catch all errors intentionally.
158
- log.error('Error exporting CSV for {name}: {error}'.format(
159
- name=self.__class__.__name__, error=e))
158
+ log.exception('Error exporting CSV for {name}: {error_class} {error}'.format(
159
+ name=self.__class__.__name__, error_class=e.__class__.__name__, error=e), stack_info=True)
160
160
  row.append(content)
161
161
  return row
162
162
 
udata/harvest/actions.py CHANGED
@@ -10,6 +10,7 @@ from flask import current_app
10
10
  from udata.auth import current_user
11
11
  from udata.core.dataset.models import HarvestDatasetMetadata
12
12
  from udata.models import User, Organization, PeriodicTask, Dataset
13
+ from udata.storage.s3 import delete_file
13
14
 
14
15
  from . import backends, signals
15
16
  from .models import (
@@ -162,6 +163,16 @@ def purge_jobs():
162
163
  '''Delete jobs older than retention policy'''
163
164
  retention = current_app.config['HARVEST_JOBS_RETENTION_DAYS']
164
165
  expiration = datetime.utcnow() - timedelta(days=retention)
166
+
167
+ jobs_with_external_files = HarvestJob.objects(data__filename__exists=True, created__lt=expiration)
168
+ for job in jobs_with_external_files:
169
+ bucket = current_app.config.get('HARVEST_GRAPHS_S3_BUCKET')
170
+ if bucket is None:
171
+ log.error(f"Bucket isn't configured anymore, but jobs still exist with external filenames. Could not delete them.")
172
+ break
173
+
174
+ delete_file(bucket, job.data['filename'])
175
+
165
176
  return HarvestJob.objects(created__lt=expiration).delete()
166
177
 
167
178