udata 13.0.1.dev12__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.

Files changed (77) hide show
  1. udata/api/__init__.py +2 -8
  2. udata/app.py +12 -30
  3. udata/auth/forms.py +6 -4
  4. udata/commands/__init__.py +2 -14
  5. udata/commands/db.py +13 -25
  6. udata/commands/info.py +0 -16
  7. udata/core/avatars/api.py +43 -0
  8. udata/core/avatars/test_avatar_api.py +30 -0
  9. udata/core/dataservices/models.py +14 -2
  10. udata/core/dataset/tasks.py +36 -8
  11. udata/core/metrics/__init__.py +0 -6
  12. udata/core/site/models.py +2 -6
  13. udata/core/spatial/commands.py +2 -4
  14. udata/core/spatial/models.py +0 -10
  15. udata/core/spatial/tests/test_api.py +1 -36
  16. udata/core/user/models.py +10 -1
  17. udata/cors.py +2 -5
  18. udata/db/migrations.py +279 -0
  19. udata/frontend/__init__.py +3 -122
  20. udata/harvest/actions.py +3 -8
  21. udata/harvest/api.py +5 -14
  22. udata/harvest/backends/__init__.py +21 -9
  23. udata/harvest/backends/base.py +2 -2
  24. udata/harvest/backends/ckan/harvesters.py +2 -0
  25. udata/harvest/backends/dcat.py +3 -0
  26. udata/harvest/backends/maaf.py +1 -0
  27. udata/harvest/commands.py +6 -4
  28. udata/harvest/forms.py +9 -6
  29. udata/harvest/tasks.py +3 -5
  30. udata/harvest/tests/ckan/test_ckan_backend.py +2 -2
  31. udata/harvest/tests/ckan/test_ckan_backend_errors.py +1 -1
  32. udata/harvest/tests/ckan/test_ckan_backend_filters.py +1 -1
  33. udata/harvest/tests/ckan/test_dkan_backend.py +1 -1
  34. udata/harvest/tests/dcat/udata.xml +6 -6
  35. udata/harvest/tests/factories.py +1 -1
  36. udata/harvest/tests/test_actions.py +5 -3
  37. udata/harvest/tests/test_api.py +2 -1
  38. udata/harvest/tests/test_base_backend.py +2 -0
  39. udata/harvest/tests/test_dcat_backend.py +3 -3
  40. udata/i18n.py +14 -273
  41. udata/migrations/2025-11-13-delete-user-email-index.py +25 -0
  42. udata/models/__init__.py +0 -8
  43. udata/routing.py +0 -8
  44. udata/sentry.py +4 -10
  45. udata/settings.py +16 -17
  46. udata/tasks.py +3 -3
  47. udata/tests/__init__.py +1 -10
  48. udata/tests/api/test_dataservices_api.py +29 -1
  49. udata/tests/api/test_datasets_api.py +1 -2
  50. udata/tests/api/test_user_api.py +12 -0
  51. udata/tests/apiv2/test_topics.py +1 -1
  52. udata/tests/dataset/test_resource_preview.py +0 -1
  53. udata/tests/helpers.py +12 -0
  54. udata/tests/test_cors.py +1 -1
  55. udata/tests/test_migrations.py +181 -481
  56. udata/utils.py +5 -0
  57. {udata-13.0.1.dev12.dist-info → udata-14.0.0.dist-info}/METADATA +1 -2
  58. {udata-13.0.1.dev12.dist-info → udata-14.0.0.dist-info}/RECORD +62 -73
  59. {udata-13.0.1.dev12.dist-info → udata-14.0.0.dist-info}/entry_points.txt +3 -5
  60. udata/core/followers/views.py +0 -15
  61. udata/entrypoints.py +0 -93
  62. udata/features/identicon/__init__.py +0 -0
  63. udata/features/identicon/api.py +0 -13
  64. udata/features/identicon/backends.py +0 -131
  65. udata/features/identicon/tests/__init__.py +0 -0
  66. udata/features/identicon/tests/test_backends.py +0 -18
  67. udata/features/territories/__init__.py +0 -49
  68. udata/features/territories/api.py +0 -25
  69. udata/features/territories/models.py +0 -51
  70. udata/migrations/__init__.py +0 -367
  71. udata/tests/cli/test_db_cli.py +0 -68
  72. udata/tests/features/territories/__init__.py +0 -20
  73. udata/tests/features/territories/test_territories_api.py +0 -185
  74. udata/tests/frontend/test_hooks.py +0 -149
  75. {udata-13.0.1.dev12.dist-info → udata-14.0.0.dist-info}/WHEEL +0 -0
  76. {udata-13.0.1.dev12.dist-info → udata-14.0.0.dist-info}/licenses/LICENSE +0 -0
  77. {udata-13.0.1.dev12.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, g, request
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
- if g and hasattr(g, "lang_code"):
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
@@ -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
- hook = I18nBlueprint("hook", __name__)
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
- for view in views:
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.get(current_app, source.backend)
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.get(current_app, source.backend)
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.get(current_app, source.backend)
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", enum=backends_ids, required=True
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 actions.list_backends()
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 udata.entrypoints import EntrypointError, get_enabled
1
+ from fnmatch import fnmatch
2
+ from importlib.metadata import entry_points
2
3
 
4
+ from flask import current_app
3
5
 
4
- def get(app, name):
5
- """Get a backend given its name"""
6
- backend = get_all(app).get(name)
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
- msg = 'Harvest backend "{0}" is not registered'.format(name)
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 get_all(app):
14
- return get_enabled("udata.harvesters", app)
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
- from .base import BaseBackend, HarvestFeature, HarvestFilter, HarvestExtraConfig # noqa
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
+ }
@@ -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 = None
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 = []
@@ -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 = (
@@ -129,6 +129,7 @@ def dictize(element):
129
129
 
130
130
 
131
131
  class MaafBackend(BaseBackend):
132
+ name = "maaf"
132
133
  display_name = "MAAF"
133
134
  verify_ssl = False
134
135
 
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
- log.info("Available backends:")
93
- for backend in actions.list_backends():
94
- log.info("%s (%s)", backend.name, backend.display_name or backend.name)
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()