udata 13.0.1.dev10__py3-none-any.whl → 14.0.0__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/api/__init__.py +2 -8
- udata/app.py +12 -30
- udata/auth/forms.py +6 -4
- udata/commands/__init__.py +2 -14
- udata/commands/db.py +13 -25
- udata/commands/info.py +0 -18
- udata/core/avatars/api.py +43 -0
- udata/core/avatars/test_avatar_api.py +30 -0
- udata/core/dataservices/models.py +14 -2
- udata/core/dataset/tasks.py +36 -8
- udata/core/metrics/__init__.py +0 -6
- udata/core/site/models.py +2 -6
- udata/core/spatial/commands.py +2 -4
- udata/core/spatial/models.py +0 -10
- udata/core/spatial/tests/test_api.py +1 -36
- udata/core/user/models.py +10 -1
- udata/cors.py +2 -5
- udata/db/migrations.py +279 -0
- udata/frontend/__init__.py +3 -122
- udata/harvest/actions.py +3 -8
- udata/harvest/api.py +5 -14
- udata/harvest/backends/__init__.py +21 -9
- udata/harvest/backends/base.py +2 -2
- udata/harvest/backends/ckan/harvesters.py +2 -0
- udata/harvest/backends/dcat.py +3 -0
- udata/harvest/backends/maaf.py +1 -0
- udata/harvest/commands.py +6 -4
- udata/harvest/forms.py +9 -6
- udata/harvest/tasks.py +3 -5
- udata/harvest/tests/ckan/test_ckan_backend.py +2 -2
- udata/harvest/tests/ckan/test_ckan_backend_errors.py +1 -1
- udata/harvest/tests/ckan/test_ckan_backend_filters.py +1 -1
- udata/harvest/tests/ckan/test_dkan_backend.py +1 -1
- udata/harvest/tests/dcat/udata.xml +6 -6
- udata/harvest/tests/factories.py +1 -1
- udata/harvest/tests/test_actions.py +5 -3
- udata/harvest/tests/test_api.py +2 -1
- udata/harvest/tests/test_base_backend.py +2 -0
- udata/harvest/tests/test_dcat_backend.py +3 -3
- udata/i18n.py +14 -273
- udata/migrations/2025-11-13-delete-user-email-index.py +25 -0
- udata/models/__init__.py +0 -8
- udata/routing.py +0 -8
- udata/sentry.py +4 -10
- udata/settings.py +16 -17
- udata/tasks.py +3 -3
- udata/tests/__init__.py +1 -10
- udata/tests/api/test_dataservices_api.py +29 -1
- udata/tests/api/test_datasets_api.py +1 -2
- udata/tests/api/test_security_api.py +2 -1
- udata/tests/api/test_user_api.py +12 -0
- udata/tests/apiv2/test_topics.py +1 -1
- udata/tests/dataset/test_resource_preview.py +0 -1
- udata/tests/helpers.py +12 -0
- udata/tests/test_cors.py +1 -1
- udata/tests/test_mail.py +2 -2
- udata/tests/test_migrations.py +181 -481
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +267 -279
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +269 -281
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +267 -279
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +278 -290
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +269 -281
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +269 -281
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +270 -282
- udata/utils.py +5 -0
- {udata-13.0.1.dev10.dist-info → udata-14.0.0.dist-info}/METADATA +1 -3
- {udata-13.0.1.dev10.dist-info → udata-14.0.0.dist-info}/RECORD +78 -89
- {udata-13.0.1.dev10.dist-info → udata-14.0.0.dist-info}/entry_points.txt +3 -5
- udata/core/followers/views.py +0 -15
- udata/entrypoints.py +0 -94
- udata/features/identicon/__init__.py +0 -0
- udata/features/identicon/api.py +0 -13
- udata/features/identicon/backends.py +0 -131
- udata/features/identicon/tests/__init__.py +0 -0
- udata/features/identicon/tests/test_backends.py +0 -18
- udata/features/territories/__init__.py +0 -49
- udata/features/territories/api.py +0 -25
- udata/features/territories/models.py +0 -51
- udata/migrations/__init__.py +0 -367
- udata/tests/cli/test_db_cli.py +0 -68
- udata/tests/features/territories/__init__.py +0 -20
- udata/tests/features/territories/test_territories_api.py +0 -185
- udata/tests/frontend/test_hooks.py +0 -149
- {udata-13.0.1.dev10.dist-info → udata-14.0.0.dist-info}/WHEEL +0 -0
- {udata-13.0.1.dev10.dist-info → udata-14.0.0.dist-info}/licenses/LICENSE +0 -0
- {udata-13.0.1.dev10.dist-info → udata-14.0.0.dist-info}/top_level.txt +0 -0
udata/cors.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
from flask import current_app,
|
|
3
|
+
from flask import current_app, request
|
|
4
4
|
from werkzeug.datastructures import Headers
|
|
5
5
|
|
|
6
6
|
log = logging.getLogger(__name__)
|
|
@@ -32,10 +32,7 @@ def is_preflight_request() -> bool:
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def is_allowed_cors_route():
|
|
35
|
-
|
|
36
|
-
path: str = request.path.removeprefix(f"/{g.lang_code}")
|
|
37
|
-
else:
|
|
38
|
-
path: str = request.path
|
|
35
|
+
path: str = request.path
|
|
39
36
|
|
|
40
37
|
# Allow to keep clean CORS when `udata` and the frontend are on the same domain
|
|
41
38
|
# (as it's the case in data.gouv with cdata/udata).
|
udata/db/migrations.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data migrations logic
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import traceback
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from logging.handlers import QueueHandler
|
|
13
|
+
|
|
14
|
+
from mongoengine.connection import get_db
|
|
15
|
+
from pymongo import ReturnDocument
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MigrationError(Exception):
|
|
21
|
+
"""
|
|
22
|
+
Raised on migration execution error.
|
|
23
|
+
|
|
24
|
+
:param msg str: A human readable message (a reason)
|
|
25
|
+
:param output str: An optionnal array of logging output
|
|
26
|
+
:param exc Exception: An optionnal underlying exception
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, msg, output=None, exc=None, traceback=None):
|
|
30
|
+
super().__init__(msg)
|
|
31
|
+
self.msg = msg
|
|
32
|
+
self.output = output
|
|
33
|
+
self.exc = exc
|
|
34
|
+
self.traceback = traceback
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Record(dict):
|
|
38
|
+
"""
|
|
39
|
+
A simple wrapper to migrations document
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
__getattr__ = dict.get
|
|
43
|
+
|
|
44
|
+
def load(self):
|
|
45
|
+
specs = {"filename": self["filename"]}
|
|
46
|
+
self.clear()
|
|
47
|
+
data = get_db().migrations.find_one(specs)
|
|
48
|
+
self.update(data or specs)
|
|
49
|
+
|
|
50
|
+
def exists(self):
|
|
51
|
+
return bool(self._id)
|
|
52
|
+
|
|
53
|
+
def __bool__(self):
|
|
54
|
+
return self.exists()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def collection(self):
|
|
58
|
+
return get_db().migrations
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def status(self):
|
|
62
|
+
"""
|
|
63
|
+
Status is the status of the last operation.
|
|
64
|
+
|
|
65
|
+
Will be `None` if the record doesn't exist.
|
|
66
|
+
Returns "success" or "error".
|
|
67
|
+
"""
|
|
68
|
+
if not self.exists():
|
|
69
|
+
return None
|
|
70
|
+
op = self.ops[-1]
|
|
71
|
+
return "success" if op["success"] else "error"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def last_date(self):
|
|
75
|
+
if not self.exists():
|
|
76
|
+
return
|
|
77
|
+
op = self.ops[-1]
|
|
78
|
+
return op["date"]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def ok(self):
|
|
82
|
+
"""
|
|
83
|
+
Is true if the migration is considered as successfully applied
|
|
84
|
+
"""
|
|
85
|
+
if not self.exists():
|
|
86
|
+
return False
|
|
87
|
+
op = self.ops[-1]
|
|
88
|
+
return op["success"] and op["type"] in ("migrate", "record")
|
|
89
|
+
|
|
90
|
+
def add(self, _type, migration, output, state, success):
|
|
91
|
+
script = inspect.getsource(migration)
|
|
92
|
+
return Record(
|
|
93
|
+
self.collection.find_one_and_update(
|
|
94
|
+
{"filename": self.filename},
|
|
95
|
+
{
|
|
96
|
+
"$push": {
|
|
97
|
+
"ops": {
|
|
98
|
+
"date": datetime.utcnow(),
|
|
99
|
+
"type": _type,
|
|
100
|
+
"script": script,
|
|
101
|
+
"output": output,
|
|
102
|
+
"state": state,
|
|
103
|
+
"success": success,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
upsert=True,
|
|
108
|
+
return_document=ReturnDocument.AFTER,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def delete(self):
|
|
113
|
+
return self.collection.delete_one({"_id": self._id})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Migration:
|
|
117
|
+
def __init__(self, filename):
|
|
118
|
+
self.filename = filename
|
|
119
|
+
self._record = None
|
|
120
|
+
# Load module immediately - migration must exist on disk
|
|
121
|
+
module = load_migration(self.filename)
|
|
122
|
+
if module is None:
|
|
123
|
+
raise FileNotFoundError(f"Migration {self.filename} file not found")
|
|
124
|
+
# Extract and store the migrate function
|
|
125
|
+
if not hasattr(module, "migrate"):
|
|
126
|
+
raise MigrationError(
|
|
127
|
+
f"Migration {self.filename} is missing required migrate() function"
|
|
128
|
+
)
|
|
129
|
+
self.module = module
|
|
130
|
+
self.migrate = module.migrate
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def collection(self):
|
|
134
|
+
return get_db().migrations
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def db_query(self):
|
|
138
|
+
return {"filename": self.filename}
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def label(self):
|
|
142
|
+
return self.filename
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def record(self):
|
|
146
|
+
if self._record is None:
|
|
147
|
+
specs = {"filename": self.filename}
|
|
148
|
+
data = get_db().migrations.find_one(specs)
|
|
149
|
+
self._record = Record(data or specs)
|
|
150
|
+
return self._record
|
|
151
|
+
|
|
152
|
+
def __eq__(self, value):
|
|
153
|
+
return isinstance(value, Migration) and getattr(value, "filename") == self.filename
|
|
154
|
+
|
|
155
|
+
def execute(self, recordonly=False, dryrun=False):
|
|
156
|
+
"""
|
|
157
|
+
Execute a migration
|
|
158
|
+
|
|
159
|
+
If recordonly is True, the migration is only recorded
|
|
160
|
+
If dryrun is True, the migration is neither executed nor recorded
|
|
161
|
+
"""
|
|
162
|
+
q = queue.Queue()
|
|
163
|
+
logger = getattr(self.module, "log", logging.getLogger(self.module.__name__))
|
|
164
|
+
|
|
165
|
+
# Logs only go to the queue handler are not shown.
|
|
166
|
+
# They will be formatted below to be shown all at once at the end
|
|
167
|
+
# of the migration.
|
|
168
|
+
logger.addHandler(QueueHandler(q))
|
|
169
|
+
logger.propagate = False
|
|
170
|
+
|
|
171
|
+
out = [["info", "Recorded only"]] if recordonly else []
|
|
172
|
+
|
|
173
|
+
if not recordonly and not dryrun:
|
|
174
|
+
db = get_db()
|
|
175
|
+
try:
|
|
176
|
+
self.migrate(db)
|
|
177
|
+
out = _extract_output(q)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
out = _extract_output(q)
|
|
180
|
+
tb = traceback.format_exc()
|
|
181
|
+
self.add_record("migrate", out, False, traceback=tb)
|
|
182
|
+
raise MigrationError(
|
|
183
|
+
"Error while executing migration", output=out, exc=e, traceback=tb
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if not dryrun:
|
|
187
|
+
self.add_record("migrate", out, True)
|
|
188
|
+
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
def add_record(self, type, output, success, traceback=None):
|
|
192
|
+
script = inspect.getsource(self.module)
|
|
193
|
+
return Record(
|
|
194
|
+
self.collection.find_one_and_update(
|
|
195
|
+
self.db_query,
|
|
196
|
+
{
|
|
197
|
+
"$push": {
|
|
198
|
+
"ops": {
|
|
199
|
+
"date": datetime.utcnow(),
|
|
200
|
+
"type": type,
|
|
201
|
+
"script": script,
|
|
202
|
+
"output": output,
|
|
203
|
+
"success": success,
|
|
204
|
+
"traceback": traceback,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
upsert=True,
|
|
209
|
+
return_document=ReturnDocument.AFTER,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get(filename):
|
|
215
|
+
"""Get a migration"""
|
|
216
|
+
return Migration(filename)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def unrecord(filename):
|
|
220
|
+
"""
|
|
221
|
+
Delete a migration record from database
|
|
222
|
+
|
|
223
|
+
:returns: True if record was deleted, False if it didn't exist
|
|
224
|
+
"""
|
|
225
|
+
specs = {"filename": filename}
|
|
226
|
+
db = get_db()
|
|
227
|
+
record = db.migrations.find_one(specs)
|
|
228
|
+
if not record:
|
|
229
|
+
return False
|
|
230
|
+
return bool(db.migrations.delete_one(specs).deleted_count)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def list_available():
|
|
234
|
+
"""
|
|
235
|
+
List available migrations from udata/migrations
|
|
236
|
+
|
|
237
|
+
Returns a list of Migration objects sorted by filename
|
|
238
|
+
"""
|
|
239
|
+
from importlib.resources import files
|
|
240
|
+
|
|
241
|
+
migrations_path = files("udata").joinpath("migrations")
|
|
242
|
+
|
|
243
|
+
migrations = [Migration(item.name) for item in migrations_path.iterdir() if item.is_file()]
|
|
244
|
+
|
|
245
|
+
return sorted(migrations, key=lambda m: m.filename)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def load_migration(filename):
|
|
249
|
+
"""
|
|
250
|
+
Load a migration from its python file
|
|
251
|
+
|
|
252
|
+
:returns: the loaded module or None if file doesn't exist
|
|
253
|
+
"""
|
|
254
|
+
from importlib.resources import files
|
|
255
|
+
|
|
256
|
+
basename = os.path.splitext(os.path.basename(filename))[0]
|
|
257
|
+
name = f"udata.migrations.{basename}"
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
script = files("udata").joinpath("migrations", filename).read_bytes()
|
|
261
|
+
except Exception:
|
|
262
|
+
# Return None if file doesn't exist instead of raising
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
spec = importlib.util.spec_from_loader(name, loader=None)
|
|
266
|
+
module = importlib.util.module_from_spec(spec)
|
|
267
|
+
exec(script, module.__dict__)
|
|
268
|
+
module.__file__ = str(files("udata").joinpath("migrations", filename))
|
|
269
|
+
return module
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _extract_output(q):
|
|
273
|
+
"""Extract log output from a QueueHandler queue"""
|
|
274
|
+
out = []
|
|
275
|
+
while not q.empty():
|
|
276
|
+
record = q.get()
|
|
277
|
+
# Use list instead of tuple to have the same data before and after mongo persist
|
|
278
|
+
out.append([record.levelname.lower(), record.getMessage()])
|
|
279
|
+
return out
|
udata/frontend/__init__.py
CHANGED
|
@@ -1,132 +1,13 @@
|
|
|
1
|
-
import inspect
|
|
2
1
|
import logging
|
|
3
|
-
from importlib import import_module
|
|
4
2
|
|
|
5
|
-
import pkg_resources
|
|
6
|
-
from jinja2 import pass_context
|
|
7
|
-
from markupsafe import Markup
|
|
8
|
-
|
|
9
|
-
from udata import entrypoints
|
|
10
|
-
from udata.i18n import I18nBlueprint
|
|
11
|
-
|
|
12
|
-
from .markdown import UdataCleaner
|
|
13
3
|
from .markdown import init_app as init_markdown
|
|
14
4
|
|
|
15
5
|
log = logging.getLogger(__name__)
|
|
16
6
|
|
|
17
7
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
_template_hooks = {}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@hook.app_template_global()
|
|
24
|
-
def package_version(name: str) -> str:
|
|
25
|
-
return pkg_resources.get_distribution(name).version
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@hook.app_template_filter()
|
|
29
|
-
def avatar_placeholder(url):
|
|
30
|
-
if url:
|
|
31
|
-
return url
|
|
32
|
-
|
|
33
|
-
return "data:image/png;base64,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"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _wrapper(func, name=None, when=None):
|
|
37
|
-
name = name or func.__name__
|
|
38
|
-
if name not in _template_hooks:
|
|
39
|
-
_template_hooks[name] = []
|
|
40
|
-
_template_hooks[name].append((func, when))
|
|
41
|
-
return func
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def template_hook(func_or_name, when=None):
|
|
45
|
-
if callable(func_or_name):
|
|
46
|
-
return _wrapper(func_or_name)
|
|
47
|
-
elif isinstance(func_or_name, str):
|
|
48
|
-
|
|
49
|
-
def wrapper(func):
|
|
50
|
-
return _wrapper(func, func_or_name, when=when)
|
|
51
|
-
|
|
52
|
-
return wrapper
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def has_template_hook(name):
|
|
56
|
-
return name in _template_hooks
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class HookRenderer:
|
|
60
|
-
def __init__(self, funcs, ctx, *args, **kwargs):
|
|
61
|
-
self.funcs = funcs
|
|
62
|
-
self.ctx = ctx
|
|
63
|
-
self.args = args
|
|
64
|
-
self.kwargs = kwargs
|
|
65
|
-
|
|
66
|
-
def __html__(self):
|
|
67
|
-
return Markup(
|
|
68
|
-
"".join(
|
|
69
|
-
f(self.ctx, *self.args, **self.kwargs)
|
|
70
|
-
for f, w in self.funcs
|
|
71
|
-
if w is None or w(self.ctx)
|
|
72
|
-
)
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
def __iter__(self):
|
|
76
|
-
for func, when in self.funcs:
|
|
77
|
-
if when is None or when(self.ctx):
|
|
78
|
-
yield Markup(func(self.ctx, *self.args, **self.kwargs))
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@pass_context
|
|
82
|
-
def render_template_hook(ctx, name, *args, **kwargs):
|
|
83
|
-
if not has_template_hook(name):
|
|
84
|
-
return ""
|
|
85
|
-
return HookRenderer(_template_hooks[name], ctx, *args, **kwargs)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@hook.app_context_processor
|
|
89
|
-
def inject_hooks():
|
|
90
|
-
return {
|
|
91
|
-
"hook": render_template_hook,
|
|
92
|
-
"has_hook": has_template_hook,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
class SafeMarkup(Markup):
|
|
97
|
-
"""Markup object bypasses Jinja's escaping. This override allows to sanitize the resulting html."""
|
|
98
|
-
|
|
99
|
-
def __new__(cls, base, *args, **kwargs):
|
|
100
|
-
cleaner = UdataCleaner()
|
|
101
|
-
return super().__new__(cls, cleaner.clean(base), *args, **kwargs)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _load_views(app, module):
|
|
105
|
-
views = module if inspect.ismodule(module) else import_module(module)
|
|
106
|
-
blueprint = getattr(views, "blueprint", None)
|
|
107
|
-
if blueprint:
|
|
108
|
-
app.register_blueprint(blueprint)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
VIEWS = ["core.storages"]
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def init_app(app, views=None):
|
|
115
|
-
views = views or VIEWS
|
|
8
|
+
def init_app(app):
|
|
9
|
+
from udata.core.storages.views import blueprint as storage_blueprint
|
|
116
10
|
|
|
117
11
|
init_markdown(app)
|
|
118
12
|
|
|
119
|
-
|
|
120
|
-
_load_views(app, "udata.{}.views".format(view))
|
|
121
|
-
|
|
122
|
-
# Load hook blueprint
|
|
123
|
-
app.register_blueprint(hook)
|
|
124
|
-
|
|
125
|
-
# Load all plugins views and blueprints
|
|
126
|
-
for module in entrypoints.get_enabled("udata.views", app).values():
|
|
127
|
-
_load_views(app, module)
|
|
128
|
-
|
|
129
|
-
# Load all plugins views and blueprints
|
|
130
|
-
for module in entrypoints.get_enabled("udata.front", app).values():
|
|
131
|
-
front_module = module if inspect.ismodule(module) else import_module(module)
|
|
132
|
-
front_module.init_app(app)
|
|
13
|
+
app.register_blueprint(storage_blueprint)
|
udata/harvest/actions.py
CHANGED
|
@@ -34,11 +34,6 @@ def get_source(ident):
|
|
|
34
34
|
return HarvestSource.get(ident)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def list_backends():
|
|
38
|
-
"""List all available backends"""
|
|
39
|
-
return backends.get_all(current_app).values()
|
|
40
|
-
|
|
41
|
-
|
|
42
37
|
def list_sources(owner=None, deleted=False):
|
|
43
38
|
"""List all harvest sources"""
|
|
44
39
|
sources = HarvestSource.objects
|
|
@@ -177,7 +172,7 @@ def purge_jobs():
|
|
|
177
172
|
|
|
178
173
|
def run(source: HarvestSource):
|
|
179
174
|
"""Launch or resume an harvesting for a given source if none is running"""
|
|
180
|
-
cls = backends.
|
|
175
|
+
cls = backends.get_backend(source.backend)
|
|
181
176
|
backend = cls(source)
|
|
182
177
|
backend.harvest()
|
|
183
178
|
|
|
@@ -189,7 +184,7 @@ def launch(source: HarvestSource):
|
|
|
189
184
|
|
|
190
185
|
def preview(source: HarvestSource):
|
|
191
186
|
"""Preview an harvesting for a given source"""
|
|
192
|
-
cls = backends.
|
|
187
|
+
cls = backends.get_backend(source.backend)
|
|
193
188
|
max_items = current_app.config["HARVEST_PREVIEW_MAX_ITEMS"]
|
|
194
189
|
backend = cls(source, dryrun=True, max_items=max_items)
|
|
195
190
|
return backend.harvest()
|
|
@@ -226,7 +221,7 @@ def preview_from_config(
|
|
|
226
221
|
active=active,
|
|
227
222
|
autoarchive=autoarchive,
|
|
228
223
|
)
|
|
229
|
-
cls = backends.
|
|
224
|
+
cls = backends.get_backend(source.backend)
|
|
230
225
|
max_items = current_app.config["HARVEST_PREVIEW_MAX_ITEMS"]
|
|
231
226
|
backend = cls(source, dryrun=True, max_items=max_items)
|
|
232
227
|
return backend.harvest()
|
udata/harvest/api.py
CHANGED
|
@@ -10,6 +10,7 @@ from udata.core.dataset.permissions import OwnablePermission
|
|
|
10
10
|
from udata.core.organization.api_fields import org_ref_fields
|
|
11
11
|
from udata.core.organization.permissions import EditOrganizationPermission
|
|
12
12
|
from udata.core.user.api_fields import user_ref_fields
|
|
13
|
+
from udata.harvest.backends import get_enabled_backends
|
|
13
14
|
|
|
14
15
|
from . import actions
|
|
15
16
|
from .forms import HarvestSourceForm, HarvestSourceValidationForm
|
|
@@ -25,10 +26,6 @@ from .models import (
|
|
|
25
26
|
ns = api.namespace("harvest", "Harvest related operations")
|
|
26
27
|
|
|
27
28
|
|
|
28
|
-
def backends_ids():
|
|
29
|
-
return [b.name for b in actions.list_backends()]
|
|
30
|
-
|
|
31
|
-
|
|
32
29
|
error_fields = api.model(
|
|
33
30
|
"HarvestError",
|
|
34
31
|
{
|
|
@@ -126,7 +123,9 @@ source_fields = api.model(
|
|
|
126
123
|
"description": fields.Markdown(description="The source description"),
|
|
127
124
|
"url": fields.String(description="The source base URL", required=True),
|
|
128
125
|
"backend": fields.String(
|
|
129
|
-
description="The source backend",
|
|
126
|
+
description="The source backend",
|
|
127
|
+
enum=lambda: list(get_enabled_backends().keys()),
|
|
128
|
+
required=True,
|
|
130
129
|
),
|
|
131
130
|
"config": fields.Raw(description="The configuration as key-value pairs"),
|
|
132
131
|
"created_at": fields.ISODateTime(
|
|
@@ -457,15 +456,7 @@ class ListBackendsAPI(API):
|
|
|
457
456
|
"features": [f.as_dict() for f in b.features],
|
|
458
457
|
"extra_configs": [f.as_dict() for f in b.extra_configs],
|
|
459
458
|
}
|
|
460
|
-
for b in
|
|
459
|
+
for b in get_enabled_backends().values()
|
|
461
460
|
],
|
|
462
461
|
key=lambda b: b["label"],
|
|
463
462
|
)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
@ns.route("/job_status/", endpoint="havest_job_status")
|
|
467
|
-
class ListHarvesterAPI(API):
|
|
468
|
-
@api.doc(model=[str])
|
|
469
|
-
def get(self):
|
|
470
|
-
"""List all available harvesters"""
|
|
471
|
-
return actions.list_backends()
|
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
from
|
|
1
|
+
from fnmatch import fnmatch
|
|
2
|
+
from importlib.metadata import entry_points
|
|
2
3
|
|
|
4
|
+
from flask import current_app
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
from .base import BaseBackend, HarvestExtraConfig, HarvestFeature, HarvestFilter # noqa
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_backend(name: str) -> type[BaseBackend] | None:
|
|
10
|
+
backend = get_enabled_backends().get(name)
|
|
7
11
|
if not backend:
|
|
8
|
-
|
|
9
|
-
raise EntrypointError(msg)
|
|
12
|
+
raise ValueError(f"Backend {name} unknown. Make sure it is declared in HARVESTER_BACKENDS.")
|
|
10
13
|
return backend
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
def
|
|
14
|
-
|
|
16
|
+
def get_all_backends() -> dict[str, type[BaseBackend]]:
|
|
17
|
+
# Note that we use the `BaseBackend.name` and not `ep.name`. The entrypoint name
|
|
18
|
+
# is not used anymore.
|
|
19
|
+
return {ep.load().name: ep.load() for ep in entry_points(group="udata.harvesters")}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_backend_enabled(backend: type[BaseBackend]) -> bool:
|
|
23
|
+
return any(fnmatch(backend.name, g) for g in current_app.config["HARVESTER_BACKENDS"])
|
|
15
24
|
|
|
16
25
|
|
|
17
|
-
|
|
26
|
+
def get_enabled_backends() -> dict[str, type[BaseBackend]]:
|
|
27
|
+
return {
|
|
28
|
+
name: backend for name, backend in get_all_backends().items() if is_backend_enabled(backend)
|
|
29
|
+
}
|
udata/harvest/backends/base.py
CHANGED
|
@@ -85,8 +85,8 @@ class BaseBackend(object):
|
|
|
85
85
|
Also provides a few helpers needed on all or some backends.
|
|
86
86
|
"""
|
|
87
87
|
|
|
88
|
-
name
|
|
89
|
-
display_name = None
|
|
88
|
+
name: str
|
|
89
|
+
display_name: str | None = None
|
|
90
90
|
verify_ssl = True
|
|
91
91
|
|
|
92
92
|
# Define some allowed filters on the backend
|
|
@@ -25,6 +25,7 @@ ALLOWED_RESOURCE_TYPES = ("dkan", "file", "file.upload", "api", "metadata")
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class CkanBackend(BaseBackend):
|
|
28
|
+
name = "ckan"
|
|
28
29
|
display_name = "CKAN"
|
|
29
30
|
filters = (
|
|
30
31
|
HarvestFilter(_("Organization"), "organization", str, _("A CKAN Organization name")),
|
|
@@ -265,5 +266,6 @@ class CkanBackend(BaseBackend):
|
|
|
265
266
|
|
|
266
267
|
|
|
267
268
|
class DkanBackend(CkanBackend):
|
|
269
|
+
name = "dkan"
|
|
268
270
|
schema = dkan_schema
|
|
269
271
|
filters = []
|
udata/harvest/backends/dcat.py
CHANGED
|
@@ -63,6 +63,7 @@ def extract_graph(source, target, node, specs):
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
class DcatBackend(BaseBackend):
|
|
66
|
+
name = "dcat"
|
|
66
67
|
display_name = "DCAT"
|
|
67
68
|
|
|
68
69
|
def __init__(self, *args, **kwargs):
|
|
@@ -256,6 +257,7 @@ class CswDcatBackend(DcatBackend):
|
|
|
256
257
|
The parsing of items is then the same as for the DcatBackend.
|
|
257
258
|
"""
|
|
258
259
|
|
|
260
|
+
name = "csw-dcat"
|
|
259
261
|
display_name = "CSW-DCAT"
|
|
260
262
|
|
|
261
263
|
# CSW_REQUEST is based on:
|
|
@@ -424,6 +426,7 @@ class CswIso19139DcatBackend(CswDcatBackend):
|
|
|
424
426
|
The parsing of items is then the same as for the DcatBackend.
|
|
425
427
|
"""
|
|
426
428
|
|
|
429
|
+
name = "csw-iso-19139"
|
|
427
430
|
display_name = "CSW-ISO-19139"
|
|
428
431
|
|
|
429
432
|
extra_configs = (
|
udata/harvest/backends/maaf.py
CHANGED
udata/harvest/commands.py
CHANGED
|
@@ -2,7 +2,8 @@ import logging
|
|
|
2
2
|
|
|
3
3
|
import click
|
|
4
4
|
|
|
5
|
-
from udata.commands import cli
|
|
5
|
+
from udata.commands import KO, OK, cli, green, red
|
|
6
|
+
from udata.harvest.backends import get_all_backends, is_backend_enabled
|
|
6
7
|
|
|
7
8
|
from . import actions
|
|
8
9
|
|
|
@@ -89,9 +90,10 @@ def sources(scheduled=False):
|
|
|
89
90
|
@grp.command()
|
|
90
91
|
def backends():
|
|
91
92
|
"""List available backends"""
|
|
92
|
-
|
|
93
|
-
for backend in
|
|
94
|
-
|
|
93
|
+
print("Available backends:")
|
|
94
|
+
for backend in get_all_backends().values():
|
|
95
|
+
status = green(OK) if is_backend_enabled(backend) else red(KO)
|
|
96
|
+
click.echo("{0} {1} ({2})".format(status, backend.display_name, backend.name))
|
|
95
97
|
|
|
96
98
|
|
|
97
99
|
@grp.command()
|