diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1fcccba..385cafa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,7 @@ jobs: pip install -r requirements.txt pip install -r dev-requirements.txt pip install -e . - - name: Setup CKAN extensions (harvest, scheming, dcat) + - name: Setup CKAN extensions (harvest, scheming, dcat, dataset-series, fluent) run: | # Harvest v1.6.1 from GitHub git clone https://github.com/ckan/ckanext-harvest @@ -56,12 +56,28 @@ jobs: git checkout tags/v1.6.1 pip install -e . pip install -r requirements.txt - - # Scheming (Civity fork) - pip install -e 'git+https://github.com/CivityNL/ckanext-scheming.git@3.0.0-civity-1#egg=ckanext-scheming[requirements]' - - # DCAT v2.4.0 from PyPI - pip install ckanext-dcat==2.4.0 + cd .. + + # Scheming release 3.1.0 + pip install -e 'git+https://github.com/ckan/ckanext-scheming.git@release-3.1.0#egg=ckanext-scheming[requirements]' + + # DCAT extension maintained for the GDI user portal + pip install -e 'git+https://github.com/GenomicDataInfrastructure/gdi-userportal-ckanext-dcat.git@v2.3.3#egg=ckanext-dcat' + if [ -f "${PIP_SRC}/ckanext-dcat/requirements.txt" ]; then + pip install -r "${PIP_SRC}/ckanext-dcat/requirements.txt" + fi + + # Dataset series extension + pip install -e 'git+https://github.com/GenomicDataInfrastructure/gdi-userportal-ckanext-dataset-series.git@v1.0.0#egg=ckanext-dataset-series' + if [ -f "${PIP_SRC}/ckanext-dataset-series/requirements.txt" ]; then + pip install -r "${PIP_SRC}/ckanext-dataset-series/requirements.txt" + fi + + # Fluent extension for multilingual support + pip install -e 'git+https://github.com/ckan/ckanext-fluent.git#egg=ckanext-fluent' + if [ -f "${PIP_SRC}/ckanext-fluent/requirements.txt" ]; then + pip install -r "${PIP_SRC}/ckanext-fluent/requirements.txt" + fi - name: Setup extension run: | sed -i -e 's/use = config:.*/use = config:\/srv\/app\/src\/ckan\/test-core.ini/' test.ini @@ -74,8 +90,9 @@ jobs: coverage xml -o coverage.xml - name: Install unzip run: apt-get update && apt-get install -y unzip - - name: SonarCloud Scan - uses: sonarsource/sonarcloud-github-action@v5 + - name: Sonar scan + uses: SonarSource/sonarqube-scan-action@v6 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: https://sonarcloud.io diff --git a/.gitignore b/.gitignore index 6094823..6e08c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,10 @@ __pycache__ *.egg-info -venv \ No newline at end of file +venv +.idea/.gitignore +.idea/ckanext-gdi-userportal.iml +.idea/misc.xml +.idea/modules.xml +.idea/vcs.xml +.idea/inspectionProfiles/profiles_settings.xml diff --git a/ckanext/gdi_userportal/helpers.py b/ckanext/gdi_userportal/helpers.py new file mode 100644 index 0000000..19fba6a --- /dev/null +++ b/ckanext/gdi_userportal/helpers.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# SPDX-FileContributor: Stichting Health-RI +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +import ckan.plugins.toolkit as tk + + +def _ensure_data_dict(data: dict[str, Any] | None, package_id: str | None) -> dict[str, Any]: + """Return a dataset dict for the current helper call. + + When a dataset dict is not provided but an id is, fetch it via the + ``package_show`` action. Failures are ignored so the helper still returns + a sensible default instead of aborting rendering. + """ + if data is not None: + return data + + if package_id: + try: + return tk.get_action("package_show")( + {"ignore_auth": True}, {"id": package_id} + ) + except (tk.ObjectNotFound, tk.NotAuthorized): + pass + + return {} + + +def _value_from_extras(data_dict: dict[str, Any], field_name: str) -> Any: + extras = data_dict.get("extras") + if isinstance(extras, dict): + return extras.get(field_name) + if isinstance(extras, Iterable): + for extra in extras: + if not isinstance(extra, dict): + continue + if extra.get("key") == field_name: + return extra.get("value") + return None + + +def _extract_field_value(field: dict[str, Any], data_dict: dict[str, Any]) -> Any: + field_name = field.get("field_name") + if not field_name or not data_dict: + return None + + if field_name in data_dict: + return data_dict[field_name] + + return _value_from_extras(data_dict, field_name) + + +def _is_missing_value(value: Any) -> bool: + if value is None: + return True + + if isinstance(value, str): + return value.strip() == "" + + if isinstance(value, dict): + return not value or all(_is_missing_value(v) for v in value.values()) + + if isinstance(value, (list, tuple, set)): + return not value or all(_is_missing_value(v) for v in value) + + return False + + +def scheming_missing_required_fields( + pages: list[dict[str, Any]], + data: dict[str, Any] | None = None, + package_id: str | None = None, +) -> list[list[str]]: + """Return a list of missing required fields grouped per form page. + + This helper acts as the base implementation expected by + ``ckanext-fluent``. It mirrors the behaviour from the forked + ckanext-scheming version previously used in this project and makes sure + chained helpers can extend the result again. + """ + data_dict = _ensure_data_dict(data, package_id) + + missing_per_page: list[list[str]] = [] + + for page in pages or []: + page_missing: list[str] = [] + for field in page.get("fields", []): + # Ignore non-required fields early. + if not tk.h.scheming_field_required(field): + continue + + value = _extract_field_value(field, data_dict) + + # Repeating subfields can contain a list of child values; treat the + # field as present when at least one entry contains data. + if field.get("repeating_subfields") and isinstance(value, list): + if any(not _is_missing_value(item) for item in value): + continue + elif not _is_missing_value(value): + continue + + if field_name := field.get("field_name"): + page_missing.append(field_name) + + missing_per_page.append(page_missing) + + return missing_per_page + + +def get_helpers() -> dict[str, Any]: + return {"scheming_missing_required_fields": scheming_missing_required_fields} diff --git a/ckanext/gdi_userportal/logic/action/get.py b/ckanext/gdi_userportal/logic/action/get.py index 0a0d85c..d34ae17 100644 --- a/ckanext/gdi_userportal/logic/action/get.py +++ b/ckanext/gdi_userportal/logic/action/get.py @@ -28,7 +28,8 @@ def enhanced_package_search(context, data_dict) -> Dict: lang = toolkit.request.headers.get("Accept-Language") translations = get_translations(values_to_translate, lang=lang) result["results"] = [ - replace_package(package, translations) for package in result["results"] + replace_package(package, translations, lang=lang) + for package in result["results"] ] if "search_facets" in result.keys(): result["search_facets"] = replace_search_facets( @@ -43,4 +44,4 @@ def enhanced_package_show(context, data_dict) -> Dict: values_to_translate = collect_values_to_translate(result) lang = toolkit.request.headers.get("Accept-Language") translations = get_translations(values_to_translate, lang=lang) - return replace_package(result, translations) + return replace_package(result, translations, lang=lang) diff --git a/ckanext/gdi_userportal/logic/action/post.py b/ckanext/gdi_userportal/logic/action/post.py index 9fa0e4c..dd1348b 100644 --- a/ckanext/gdi_userportal/logic/action/post.py +++ b/ckanext/gdi_userportal/logic/action/post.py @@ -25,15 +25,17 @@ def enhanced_package_search(context, data_dict=None) -> Dict: try: result = toolkit.get_action("package_search")(context, data_dict) values_to_translate = collect_values_to_translate(result) - translations = get_translations(values_to_translate) + lang = toolkit.request.headers.get("Accept-Language") + translations = get_translations(values_to_translate, lang=lang) result["results"] = [ - replace_package(package, translations) for package in result["results"] + replace_package(package, translations, lang=lang) + for package in result["results"] ] if "search_facets" in result: result["search_facets"] = replace_search_facets( - result["search_facets"], translations + result["search_facets"], translations, lang=lang ) return result diff --git a/ckanext/gdi_userportal/logic/action/translation_utils.py b/ckanext/gdi_userportal/logic/action/translation_utils.py index 3568d7f..e9110ea 100644 --- a/ckanext/gdi_userportal/logic/action/translation_utils.py +++ b/ckanext/gdi_userportal/logic/action/translation_utils.py @@ -6,9 +6,9 @@ from dataclasses import dataclass import logging -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional -from ckan.common import config, request +from ckan.common import config # -*- coding: utf-8 -*- from ckan.plugins import toolkit @@ -23,6 +23,13 @@ "dcat_type", ] RESOURCE_REPLACE_FIELDS = ["format", "language"] +TRANSLATED_SUFFIX = "_translated" +LANGUAGE_VALUE_FIELDS = { + "population_coverage", + "publisher_note", + "provenance", + "rights", +} DEFAULT_FALLBACK_LANG = "en" SUPPORTED_LANGUAGES = {DEFAULT_FALLBACK_LANG, "nl"} @@ -121,13 +128,18 @@ def collect_values_to_translate(data: Any) -> List: return list(set(values_to_translate)) -def replace_package(data, translation_dict): +def replace_package(data, translation_dict, lang: Optional[str] = None): + preferred_lang = _get_language(lang) + + _apply_translated_properties(data, preferred_lang) + data = _translate_fields(data, PACKAGE_REPLACE_FIELDS, translation_dict) resources = data.get("resources", []) data["resources"] = [ _translate_fields(item, RESOURCE_REPLACE_FIELDS, translation_dict) for item in resources ] + return data @@ -156,11 +168,77 @@ def _change_facet(facet, translation_dict): def replace_search_facets(data, translation_dict, lang): + preferred_lang = _get_language(lang) new_facets = {} for key, facet in data.items(): title = facet["title"] - new_facets[key] = {"title": get_translations([title], lang=lang).get(title, title)} + new_facets[key] = { + "title": get_translations([title], lang=preferred_lang).get(title, title) + } new_facets[key]["items"] = [ _change_facet(item, translation_dict) for item in facet["items"] ] return new_facets + + +def _apply_translated_properties(data: Any, preferred_lang: str, fallback_lang: str = DEFAULT_FALLBACK_LANG): + if not isinstance(data, (dict, list)): + return data + + if isinstance(data, list): + return [ + _apply_translated_properties(item, preferred_lang, fallback_lang) + if isinstance(item, (dict, list)) + else item + for item in data + ] + + for key, value in list(data.items()): + if isinstance(value, dict): + _apply_translated_properties(value, preferred_lang, fallback_lang) + elif isinstance(value, list): + data[key] = [ + _apply_translated_properties(item, preferred_lang, fallback_lang) + if isinstance(item, (dict, list)) + else item + for item in value + ] + + for key, value in list(data.items()): + if key.endswith(TRANSLATED_SUFFIX) and isinstance(value, dict): + base_key = key[:-len(TRANSLATED_SUFFIX)] + merged_values = value.copy() + existing_value = data.get(base_key) + if isinstance(existing_value, dict): + merged_values.update(existing_value) + data[base_key] = _select_translated_value( + merged_values, preferred_lang, fallback_lang + ) + elif key in LANGUAGE_VALUE_FIELDS and isinstance(value, dict): + data[key] = _select_translated_value(value, preferred_lang, fallback_lang) + + return data + + +def _select_translated_value(values: Dict[str, Any], preferred_lang: str, fallback_lang: str) -> Any: + if not isinstance(values, dict): + return values + + for lang in (preferred_lang, fallback_lang): + translated = values.get(lang) + if _has_content(translated): + return translated + + for translated in values.values(): + if _has_content(translated): + return translated + + return next(iter(values.values()), "") + + +def _has_content(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + return bool(value) if isinstance(value, (list, dict)) else True diff --git a/ckanext/gdi_userportal/plugin.py b/ckanext/gdi_userportal/plugin.py index 7707f29..f71929f 100644 --- a/ckanext/gdi_userportal/plugin.py +++ b/ckanext/gdi_userportal/plugin.py @@ -6,6 +6,7 @@ import json import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit +from ckanext.gdi_userportal.helpers import get_helpers as get_portal_helpers from ckanext.gdi_userportal.logic.action.get import ( enhanced_package_search, enhanced_package_show, @@ -50,6 +51,7 @@ class GdiUserPortalPlugin(plugins.SingletonPlugin): plugins.implements(plugins.IActions) plugins.implements(plugins.IPackageController) plugins.implements(plugins.IValidators) + plugins.implements(plugins.ITemplateHelpers, inherit=True) plugins.implements(plugins.IMiddleware, inherit=True) plugins.implements(plugins.IConfigurable, inherit=True) @@ -74,8 +76,14 @@ def update_config(self, config_): def update_config_schema(self, schema): ignore_missing = toolkit.get_validator("ignore_missing") unicode_safe = toolkit.get_validator("unicode_safe") + int_validator = toolkit.get_validator("int_validator") schema.update( - {"ckanext.gdi_userportal.intro_text": [ignore_missing, unicode_safe]} + { + "ckanext.gdi_userportal.intro_text": [ignore_missing, unicode_safe], + "ckan.harvest.timeout": [ignore_missing, int_validator], + "ckan.harvest.mq.type": [ignore_missing, unicode_safe], + "ckan.harvest.mq.hostname": [ignore_missing, unicode_safe], + } ) return schema @@ -105,6 +113,9 @@ def get_actions(self): "enhanced_package_show": enhanced_package_show, } + def get_helpers(self): + return get_portal_helpers() + def read(self, entity): pass diff --git a/ckanext/gdi_userportal/scheming/schemas/dataset_multilingual.yaml b/ckanext/gdi_userportal/scheming/schemas/dataset_multilingual.yaml new file mode 100644 index 0000000..854a64b --- /dev/null +++ b/ckanext/gdi_userportal/scheming/schemas/dataset_multilingual.yaml @@ -0,0 +1,940 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# +# SPDX-License-Identifier: Apache-2.0 + +scheming_version: 2 +dataset_type: dataset +about: HealthDCAT-AP schema extended with GDI-specific fields +about_url: https://github.com/GenomicDataInfrastructure/gdi-userportal-ckan-docker + +form_languages: [en, nl] + +dataset_fields: + +- field_name: title_translated + label: Title + preset: fluent_core_translated + help_text: A descriptive title for the dataset. + +- field_name: name + label: URL + preset: dataset_slug + form_placeholder: eg. my-dataset + +- field_name: notes_translated + label: Description + preset: fluent_core_translated + form_snippet: fluent_markdown.html + display_snippet: fluent_markdown.html + help_text: A free-text account of the dataset. + +- field_name: tags_translated + label: Keywords + preset: fluent_tags + form_placeholder: eg. economy, mental health, government + help_text: Keywords or tags describing the dataset. Use commas to separate multiple values. + +- field_name: contact + label: Contact points + repeating_label: Contact point + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: name + label: Name + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Name of the contact point in each language. + + - field_name: email + label: Email + display_snippet: email.html + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the contact point. Such as a ROR ID. + + help_text: Contact information for enquiries about the dataset. + +- field_name: publisher + label: Publisher + repeating_label: Publisher + repeating_once: true + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: name + label: Name + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Name of the entity or person who published the dataset in each language. + + - field_name: email + label: Email + display_snippet: email.html + + - field_name: url + label: URL + display_snippet: link.html + + - field_name: type + label: Type + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the publisher, such as a ROR ID. + help_text: Entity responsible for making the dataset available. + +- field_name: creator + label: Creator + repeating_label: Creator + repeating_once: true + repeating_subfields: + + - field_name: uri + label: URI + help_text: URI of the creator, if available. + + - field_name: name + label: Name + help_text: Name of the entity or person who created the dataset. + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Name of the entity or person who created the dataset in each language. + + - field_name: email + label: Email + display_snippet: email.html + help_text: Contact email of the creator. + + - field_name: url + label: URL + display_snippet: link.html + help_text: URL for more information about the creator. + + - field_name: type + label: Type + help_text: Type of creator (e.g., Organization, Person). + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the creator, such as an ORCID or ROR ID. + +- field_name: license_id + label: License + form_snippet: license.html + help_text: License definitions and additional information can be found at http://opendefinition.org/. + +- field_name: owner_org + label: Organization + preset: dataset_organization + help_text: The CKAN organization the dataset belongs to. + +- field_name: url + label: Landing page + form_placeholder: http://example.com/dataset.json + display_snippet: link.html + help_text: Web page that can be navigated to gain access to the dataset, its distributions and/or additional information. + +# Note: this will fall back to metadata_created if not present +- field_name: issued + label: Release date + preset: datetime_flex + help_text: Date of publication of the dataset. + +# Note: this will fall back to metadata_modified if not present +- field_name: modified + label: Modification date + preset: datetime_flex + help_text: Most recent date on which the dataset was changed, updated or modified. + +- field_name: temporal_start + label: Temporal start date + preset: datetime_flex + help_inline: true + help_text: Start of the time period that the dataset covers. + +- field_name: temporal_end + label: Temporal end date + preset: datetime_flex + help_inline: true + help_text: End of the time period that the dataset covers. + +- field_name: in_series + label: In series + form_snippet: multiple_select.html + display_snippet: multiple_choice.html + validators: ignore_missing series_validator + output_validators: scheming_multiple_choice_output + convert: convert_to_extras + choices_helper: in_series_choices + form_select_attrs: + data-module: autocomplete + class: ~ + help_text: Link this dataset to one or more dataset series. + +- field_name: version + label: Version + validators: ignore_missing unicode_safe package_version_validator + help_text: Version number or other version designation of the dataset. + +- field_name: version_notes + label: Version notes + preset: fluent_markdown + help_text: A description of the differences between this version and a previous version of the dataset. + +# Note: CKAN will generate a unique identifier for each dataset +- field_name: identifier + label: Identifier + help_text: A unique identifier of the dataset. + +- field_name: frequency + label: Frequency + help_text: The frequency at which dataset is published. + +- field_name: provenance + label: Provenance + preset: fluent_markdown + help_text: A statement about the lineage of the dataset. + +- field_name: dcat_type + label: Type + help_text: The type of the dataset. + # TODO: controlled vocabulary? + +- field_name: temporal_coverage + label: Temporal coverage + repeating_subfields: + + - field_name: start + label: Start + preset: datetime_flex + + - field_name: end + label: End + preset: datetime_flex + help_text: The temporal period or periods the dataset covers. + +- field_name: temporal_resolution + label: Temporal resolution + help_text: Minimum time period resolvable in the dataset. + +- field_name: spatial_coverage + label: Spatial coverage + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: text + label: Label + + - field_name: geom + label: Geometry + + - field_name: bbox + label: Bounding Box + + - field_name: centroid + label: Centroid + help_text: A geographic region that is covered by the dataset. + +- field_name: spatial_resolution_in_meters + label: Spatial resolution in meters + help_text: Minimum spatial separation resolvable in a dataset, measured in meters. + +- field_name: access_rights + label: Access rights + validators: ignore_missing unicode_safe + help_text: Information that indicates whether the dataset is Open Data, has access restrictions or is not public. + +- field_name: alternate_identifier + label: Other identifier + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: This property refers to a secondary identifier of the dataset, such as MAST/ADS, DataCite, DOI, etc. + +- field_name: theme + label: Theme + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: A category of the dataset. A Dataset may be associated with multiple themes. + +- field_name: language + label: Language + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: Language or languages of the dataset. + # TODO: language form snippet / validator / graph + +- field_name: documentation + label: Documentation + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: A page or document about this dataset. + +- field_name: conforms_to + label: Conforms to + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: An implementing rule or other specification that the dataset follows. + +- field_name: is_referenced_by + label: Is referenced by + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: A related resource, such as a publication, that references, cites, or otherwise points to the dataset. + +- field_name: analytics + label: Analytics + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: > + An analytics distribution of the dataset. + Publishers are encouraged to provide URLs pointing to API endpoints or document + repositories where users can access or request associated resources such as + technical reports of the dataset, quality measurements, usability indicators,... + or analytics services. + +- field_name: applicable_legislation + label: Applicable legislation + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: The legislation that mandates the creation or management of the dataset. + +- field_name: has_version + label: Has version + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_inline: true + help_text: This property refers to a related Dataset that is a version, edition, or adaptation of the described Dataset. + +- field_name: code_values + label: Code values + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: Health classifications and their codes associated with the dataset. + +- field_name: coding_system + label: Coding system + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: > + Coding systems in use (e.g., ICD-10-CM, DGRs, SNOMED CT, ...). + To comply with HealthDCAT-AP, Wikidata URIs MUST be used. + +- field_name: purpose + label: Purpose + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: A free text statement of the purpose of the processing of data or personal data. + +- field_name: health_category + label: Health category + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: > + The health category to which this dataset belongs as described in the Commission Regulation on + the European Health Data Space laying down a list of categories of electronic data for + secondary use, Art.33. + +- field_name: health_theme + label: Health theme + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: > + A category of the Dataset or tag describing the Dataset. + +- field_name: legal_basis + label: Legal basis + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: The legal basis used to justify processing of personal data. + +- field_name: min_typical_age + label: Minimum typical age + validators: ignore_missing int_validator + form_snippet: number.html + help_text: Minimum typical age of the population within the dataset. + +- field_name: max_typical_age + label: Maximum typical age + validators: ignore_missing int_validator + form_snippet: number.html + help_text: Maximum typical age of the population within the dataset. + +- field_name: number_of_records + label: Number of records + validators: ignore_missing int_validator + form_snippet: number.html + help_text: Size of the dataset in terms of the number of records. + +- field_name: number_of_unique_individuals + label: Number of records for unique individuals. + validators: ignore_missing int_validator + form_snippet: number.html + help_text: Number of records for unique individuals. + +- field_name: personal_data + label: Personal data + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: Key elements that represent an individual in the dataset. + +- field_name: publisher_note + label: Publisher note + preset: fluent_markdown + help_text: A description of the publisher activities. + +- field_name: publisher_type + label: Publisher type + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: A type of organisation that makes the Dataset available. + +- field_name: trusted_data_holder + label: Trusted Data Holder + preset: select + choices: + - value: false + label: "No" + - value: true + label: "Yes" + validators: ignore_missing boolean_validator + help_text: Indicates whether the dataset is held by a trusted data holder. + output_validators: boolean_validator + +- field_name: population_coverage + label: Population coverage + preset: fluent_markdown + help_text: A definition of the population within the dataset. + +- field_name: retention_period + label: Retention period + repeating_subfields: + + - field_name: start + label: Start + preset: datetime_flex + + - field_name: end + label: End + preset: datetime_flex + + help_text: A temporal period which the dataset is available for secondary use. + +- field_name: hdab + label: Health data access body + repeating_label: Health data access body + repeating_once: true + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: name + label: Name + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Name of the health data access body in each language. + + - field_name: email + label: Email + display_snippet: email.html + + - field_name: url + label: URL + display_snippet: link.html + + - field_name: type + label: Type + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the HDAB, such as a ROR ID. + help_text: Health Data Access Body supporting access to data in the Member State. + +- field_name: qualified_relation + label: Qualified relation + repeating_label: Relationship + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: relation + label: Relation + help_text: The resource related to the source resource. + + - field_name: role + label: Role + help_text: The function of an entity or agent with respect to another entity or resource. + help_text: A description of a relationship with another resource. + +- field_name: provenance_activity + label: Provenance activity + repeating_label: Provenance activity + repeating_once: true + repeating_subfields: + - field_name: uri + label: Activity URI + help_text: URI of the provenance activity (if available). + - field_name: label + label: Label + help_text: Human-readable label for the activity. + - field_name: type + label: Activity type + help_text: Type of the activity. + - field_name: seeAlso + label: See also + help_text: Related link for the activity. + - field_name: dct_type + label: Type + help_text: Type of the activity (URI). + - field_name: startedAtTime + label: Started at time + preset: datetime_flex + help_text: When the activity started (ISO 8601). + - field_name: wasAssociatedWith + label: Associated agent + repeating_label: Agent + repeating_once: true + repeating_subfields: + - field_name: uri + label: URI + - field_name: name + label: Name + - field_name: email + label: Email + display_snippet: email.html + - field_name: url + label: URL + display_snippet: link.html + - field_name: homepage + label: Homepage + display_snippet: link.html + - field_name: type + label: Type + - field_name: identifier + label: Identifier + - field_name: actedOnBehalfOf + label: Acted on behalf of + repeating_label: Organization + repeating_once: true + repeating_subfields: + - field_name: uri + label: URI + - field_name: name + label: Name + - field_name: email + label: Email + display_snippet: email.html + - field_name: url + label: URL + display_snippet: link.html + - field_name: type + label: Type + - field_name: identifier + label: Identifier + help_text: Structured provenance activity information, including agents and organizations. + +- field_name: qualified_attribution + label: Qualified attribution + repeating_label: Attribution + repeating_once: true + repeating_subfields: + - field_name: agent + label: Agent + repeating_label: Agent + repeating_once: true + repeating_subfields: + - field_name: uri + label: URI + - field_name: name + label: Name + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Agent name in each language. + + - field_name: email + label: Email + display_snippet: email.html + - field_name: url + label: URL + display_snippet: link.html + - field_name: homepage + label: Homepage + display_snippet: link.html + - field_name: type + label: Type + - field_name: identifier + label: Identifier + - field_name: role + label: Role + help_text: Role of the agent (e.g., data processor, contributor). + help_text: Structured qualified attribution information including agent and role. + +- field_name: quality_annotation + label: Quality annotations + repeating_label: Quality annotation + repeating_subfields: + - field_name: body + label: Body + help_text: Content of the quality annotation (e.g., URL to certificate, measurement value, assessment result). + - field_name: target + label: Target + help_text: Aspect of the dataset being annotated (e.g., URI or description of what is being assessed). + - field_name: motivated_by + label: Motivated by + help_text: Motivation or reason for the quality annotation. + help_text: Quality annotations following DQV and Web Annotation standards. + +# Note: if not provided, this will be autogenerated +- field_name: uri + label: URI + help_text: An URI for this dataset (if not provided it will be autogenerated). + +resource_fields: + +- field_name: url + label: URL + preset: resource_url_upload + +- field_name: name_translated + label: Name + preset: fluent_core_translated + help_text: A descriptive title for the resource. + +- field_name: description_translated + label: Description + preset: fluent_core_translated + form_snippet: fluent_markdown.html + display_snippet: fluent_markdown.html + help_text: A free-text account of the resource. + +- field_name: format + label: Format + preset: resource_format_autocomplete + help_text: File format. If not provided it will be guessed. + +- field_name: mimetype + label: Media type + validators: if_empty_guess_format ignore_missing unicode_safe + help_text: Media type for this format. If not provided it will be guessed. + +- field_name: compress_format + label: Compress format + help_text: The format of the file in which the data is contained in a compressed form. + +- field_name: package_format + label: Package format + help_text: The format of the file in which one or more data files are grouped together. + +- field_name: size + label: Size + validators: ignore_missing int_validator + form_snippet: number.html + display_snippet: file_size.html + help_text: File size in bytes. + +- field_name: hash + label: Hash + help_text: Checksum of the downloaded file. + +- field_name: hash_algorithm + label: Hash Algorithm + help_text: Algorithm used to calculate to checksum. + +- field_name: rights + label: Rights + preset: fluent_markdown + help_text: Some statement about the rights associated with the resource. + +- field_name: availability + label: Availability + help_text: Indicates how long it is planned to keep the resource available. + +- field_name: status + label: Status + preset: select + choices: + - value: http://purl.org/adms/status/Completed + label: Completed + - value: http://purl.org/adms/status/UnderDevelopment + label: Under Development + - value: http://purl.org/adms/status/Deprecated + label: Deprecated + - value: http://purl.org/adms/status/Withdrawn + label: Withdrawn + help_text: The status of the resource in the context of maturity lifecycle. + +- field_name: license + label: License + help_text: License in which the resource is made available. If not provided will be inherited from the dataset. + +# Note: this falls back to the standard resource url field +- field_name: access_url + label: Access URL + help_text: URL that gives access to the dataset (defaults to the standard resource URL). + +# Note: this falls back to the standard resource url field +- field_name: download_url + label: Download URL + display_snippet: link.html + help_text: URL that provides a direct link to a downloadable file (defaults to the standard resource URL). + +- field_name: issued + label: Release date + preset: datetime_flex + help_text: Date of publication of the resource. + +- field_name: modified + label: Modification date + preset: datetime_flex + help_text: Most recent date on which the resource was changed, updated or modified. + +- field_name: retention_period + label: Retention period + repeating_subfields: + - field_name: start + label: Start + preset: datetime_flex + + - field_name: end + label: End + preset: datetime_flex + help_text: Temporal period during which the resource remains available for use. + +- field_name: temporal_resolution + label: Temporal resolution + help_text: Minimum time period resolvable in the distribution. + +- field_name: spatial_resolution_in_meters + label: Spatial resolution in meters + help_text: Minimum spatial separation resolvable in the distribution, measured in meters. + +- field_name: language + label: Language + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: Language or languages of the resource. + +- field_name: documentation + label: Documentation + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: A page or document about this resource. + +- field_name: conforms_to + label: Conforms to + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: An established schema to which the described resource conforms. + +- field_name: applicable_legislation + label: Applicable legislation + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: The legislation that mandates the creation or management of the resource. + +- field_name: access_services + label: Access services + repeating_label: Access service + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: title + label: Title + + - field_name: description + label: Description + form_snippet: markdown.html + help_text: A free-text account of the data service. + + - field_name: endpoint_description + label: Endpoint description + + - field_name: endpoint_url + label: Endpoint URL + preset: multiple_text + + - field_name: serves_dataset + label: Serves dataset + preset: multiple_text + validators: ignore_missing scheming_multiple_text + + - field_name: access_rights + label: Access rights + validators: ignore_missing unicode_safe + help_text: Information regarding access or restrictions based on privacy, security, or other policies. + + - field_name: conforms_to + label: Conforms to + preset: multiple_text + validators: ignore_missing scheming_multiple_text + + - field_name: format + label: Format + preset: multiple_text + validators: ignore_missing scheming_multiple_text + + - field_name: identifier + label: Identifier + + - field_name: language + label: Language + preset: multiple_text + validators: ignore_missing scheming_multiple_text + + - field_name: rights + label: Rights + form_snippet: markdown.html + help_text: Rights statement for the data service. + + - field_name: landing_page + label: Landing page + preset: multiple_text + validators: ignore_missing scheming_multiple_text + + - field_name: keyword + label: Keywords + preset: tag_string_autocomplete + form_placeholder: eg. economy, mental health, government + help_text: Keywords or tags describing the data service. Use commas to separate multiple values. + + - field_name: applicable_legislation + label: Applicable legislation + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: The legislation that mandates the creation or management of the data service. + + - field_name: contact + label: Contact point + repeating_label: Contact point + repeating_once: true + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: name + label: Name + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Name of the contact point in each language. + + - field_name: email + label: Email + display_snippet: email.html + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the contact point, such as a ROR ID. + + - field_name: url + label: URL + display_snippet: link.html + help_text: Contact information for enquiries about the data service. + + - field_name: creator + label: Creator + repeating_label: Creator + repeating_subfields: + + - field_name: uri + label: URI + help_text: URI of the creator, if available. + + - field_name: name + label: Name + help_text: Name of the entity or person who created the data service. + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Name of the entity or person who created the data service in each language. + + - field_name: email + label: Email + display_snippet: email.html + help_text: Contact email of the creator. + + - field_name: url + label: URL + display_snippet: link.html + help_text: URL for more information about the creator. + + - field_name: type + label: Type + help_text: Type of creator (e.g., Organization, Person). + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the creator, such as an ORCID or ROR ID. + + - field_name: publisher + label: Publisher + repeating_label: Publisher + repeating_once: true + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: name + label: Name + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Name of the entity or person who publishes the data service in each language. + + - field_name: email + label: Email + display_snippet: email.html + + - field_name: url + label: URL + display_snippet: link.html + + - field_name: type + label: Type + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the publisher, such as a ROR ID. + help_text: Entity responsible for making the data service available. + + - field_name: license + label: License + help_text: License in which the data service is made available. + + - field_name: modified + label: Modification date + preset: datetime_flex + help_text: Most recent date on which the data service was changed, updated or modified. + + help_text: A data service that gives access to the resource. + +# Note: if not provided, this will be autogenerated +- field_name: uri + label: URI + help_text: An URI for this resource (if not provided it will be autogenerated). diff --git a/ckanext/gdi_userportal/scheming/schemas/dataset_series_multilingual.yaml b/ckanext/gdi_userportal/scheming/schemas/dataset_series_multilingual.yaml new file mode 100644 index 0000000..f8eb4e0 --- /dev/null +++ b/ckanext/gdi_userportal/scheming/schemas/dataset_series_multilingual.yaml @@ -0,0 +1,214 @@ +# SPDX-FileCopyrightText: 2025 Health-ri +# +# SPDX-License-Identifier: Apache-2.0 + +scheming_version: 2 +dataset_type: dataset_series +about: Multilingual DCAT-AP Dataset Series schema with Fluent support +about_url: http://github.com/ckan/ckanext-dcat +form_languages: [en, nl] + +dataset_fields: + +- field_name: title_translated + label: + en: Title + nl: Titel + preset: fluent_core_translated + help_text: A descriptive title for the dataset series in each language. + +- field_name: name + label: URL + preset: dataset_slug + form_placeholder: eg. my-dataset-series + +- field_name: notes_translated + label: + en: Description + nl: Beschrijving + preset: fluent_core_translated + form_snippet: fluent_markdown.html + display_snippet: fluent_markdown.html + help_text: A free-text account of the dataset series. + +- field_name: tags_translated + label: + en: Keywords + nl: Trefwoorden + preset: fluent_tags + help_text: Keywords or tags describing the dataset series, per language. + +- field_name: contact + label: Contact points + repeating_label: Contact point + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: name + label: Name + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Localised name of the contact point. + + - field_name: email + label: Email + display_snippet: email.html + + - field_name: url + label: URL + display_snippet: link.html + help_text: Contact information for enquiries about the dataset series. + +- field_name: publisher + label: Publisher + repeating_label: Publisher + repeating_once: true + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: name + label: Name + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Localised name of the publisher. + + - field_name: email + label: Email + display_snippet: email.html + + - field_name: url + label: URL + display_snippet: link.html + + - field_name: type + label: Type + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the publisher, such as a ROR ID. + help_text: Entity responsible for ensuring the coherency of the dataset series. + +- field_name: creator + label: Creator + repeating_label: Creator + repeating_once: true + repeating_subfields: + + - field_name: uri + label: URI + help_text: URI of the creator, if available. + + - field_name: name + label: Name + help_text: Name of the entity or person who created the dataset series. + + - field_name: name_translated + label: Name (translations) + preset: fluent_core_translated + help_text: Localised name of the creator. + + - field_name: email + label: Email + display_snippet: email.html + help_text: Contact email of the creator. + + - field_name: url + label: URL + display_snippet: link.html + help_text: URL for more information about the creator. + + - field_name: type + label: Type + help_text: Type of creator (e.g., Organisation, Person). + + - field_name: identifier + label: Identifier + help_text: Unique identifier for the creator, such as an ORCID or ROR ID. + +- field_name: owner_org + label: Organization + preset: dataset_organization + help_text: The CKAN organization the dataset series belongs to. + +- field_name: applicable_legislation + label: Applicable legislation + preset: multiple_text + validators: ignore_missing scheming_multiple_text + help_text: The legislation that mandates the creation or management of the dataset series. + +- field_name: temporal_coverage + label: Temporal coverage + repeating_subfields: + + - field_name: start + label: Start + preset: dcat_date + + - field_name: end + label: End + preset: dcat_date + help_text: The temporal period or periods the dataset series covers. + +- field_name: spatial_coverage + label: Spatial coverage + repeating_subfields: + + - field_name: uri + label: URI + + - field_name: text + label: Label + + - field_name: geom + label: Geometry + + - field_name: bbox + label: Bounding Box + + - field_name: centroid + label: Centroid + help_text: A geographic region that is covered by the dataset series. + +- field_name: series_order_field + preset: dataset_series_order + help_text: If the series is ordered, the field in the member datasets that will be used for sorting. + +- field_name: series_order_type + preset: dataset_series_order_type + help_text: The type of sorting that needs to be performed on series members. + + +resource_fields: + +- field_name: url + label: URL + preset: resource_url_upload + +- field_name: name_translated + label: + en: Name + nl: Naam + preset: fluent_core_translated + help_text: A descriptive title for the resource. + +- field_name: description_translated + label: + en: Description + nl: Beschrijving + preset: fluent_core_translated + form_snippet: fluent_markdown.html + display_snippet: fluent_markdown.html + help_text: A free-text account of the resource. + +- field_name: format + label: Format + preset: resource_format_autocomplete + help_text: File format. If not provided it will be guessed. diff --git a/ckanext/gdi_userportal/scheming/schemas/gdi_userportal.yaml b/ckanext/gdi_userportal/scheming/schemas/gdi_userportal.yaml deleted file mode 100644 index 147265c..0000000 --- a/ckanext/gdi_userportal/scheming/schemas/gdi_userportal.yaml +++ /dev/null @@ -1,57 +0,0 @@ -#SPDX-FileCopyrightText: 2024 PNED G.I.E. -# -#SPDX-License-Identifier: Apache-2.0 - -scheming_version: 2 -dataset_type: dataset -about: DCAT-AP 3 compatible schema -about_url: http://github.com/ckan/ckanext-dcat - -dataset_fields: -- field_name: issued - label: Issued Date - preset: datetime_flex - help_text: "[dct:issued] This property contains the date of formal issuance (e.g., publication) of the Dataset." - -- field_name: modified - label: Modification Date - preset: datetime_flex - help_text: "[dct:modified] This property contains the most recent date on which the Dataset was changed or modified." - -- field_name: temporal_start - label: Temporal Start Date - help_inline: true - help_text: "[dct:temporal] This property refers to a temporal period that the Dataset covers." - preset: datetime_flex - -- field_name: temporal_end - label: Temporal End Date - help_inline: true - help_text: "[dct:temporal] This property refers to a temporal period that the Dataset covers." - preset: datetime_flex - -# Series fields -- field_name: in_series - preset: dataset_series_in_series - -resource_fields: -- field_name: issued - label: Issued Date - preset: datetime_flex - help_text: "[dct:issued] This property contains the date of formal issuance (e.g., publication) of the Resource." - -- field_name: modified - label: Modification Date - preset: datetime_flex - help_text: "[dct:modified] This property contains the most recent date on which the Resource was changed or modified." - -- field_name: retention_period - label: Retention period - repeating_subfields: - - field_name: start - label: Start - preset: datetime_flex - - - field_name: end - label: End - preset: datetime_flex \ No newline at end of file diff --git a/ckanext/gdi_userportal/tests/test_translation_utils.py b/ckanext/gdi_userportal/tests/test_translation_utils.py new file mode 100644 index 0000000..c8603bd --- /dev/null +++ b/ckanext/gdi_userportal/tests/test_translation_utils.py @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: 2025 Helath-RI +# +# SPDX-License-Identifier: Apache-2.0 + +from copy import deepcopy +from pathlib import Path +import sys + +ROOT_DIR = Path(__file__).resolve().parents[3] +SRC_DIR = ROOT_DIR.parent + +for path in (ROOT_DIR, SRC_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.append(path_str) + +from unittest.mock import patch + +from ckanext.gdi_userportal.logic.action.translation_utils import ( + replace_package, + replace_search_facets, +) + + +def _base_package(): + return { + "title": "Original title", + "title_translated": {"en": "English Title", "nl": "Nederlandse titel"}, + "notes": "Original notes", + "notes_translated": { + "en": "English Notes", + "nl": "Nederlandse toelichting", + }, + "provenance": {"en": "English provenance", "nl": "Nederlandse herkomst"}, + "population_coverage": { + "en": "English coverage", + "nl": "Nederlandse dekking", + }, + "publisher_note": { + "en": "English publisher note", + "nl": "Nederlandse uitgeversnotitie", + }, + "resources": [ + { + "name": "Original resource name", + "name_translated": { + "en": "English resource", + "nl": "Nederlandse resource", + }, + "rights": { + "en": "English rights", + "nl": "Nederlandse rechten", + }, + } + ], + "qualified_attribution": [ + { + "role": "some-role", + "agent": [ + { + "name": "Original agent", + "name_translated": { + "en": "English agent", + "nl": "Nederlandse agent", + }, + } + ], + } + ], + } + + +def test_replace_package_prefers_requested_language(): + package = deepcopy(_base_package()) + + result = replace_package(package, translation_dict={}, lang="nl") + + assert result["title"] == "Nederlandse titel" + assert result["notes"] == "Nederlandse toelichting" + assert result["provenance"] == "Nederlandse herkomst" + assert result["population_coverage"] == "Nederlandse dekking" + assert result["publisher_note"] == "Nederlandse uitgeversnotitie" + + resource = result["resources"][0] + assert resource["name"] == "Nederlandse resource" + assert resource["rights"] == "Nederlandse rechten" + + attribution_agent = result["qualified_attribution"][0]["agent"][0] + assert attribution_agent["name"] == "Nederlandse agent" + + +def test_replace_package_requested_language_empty_or_none(): + package = deepcopy(_base_package()) + + # Set the requested language values to empty string and None + package["title"] = {"en": "English title", "nl": ""} + package["notes"] = {"en": "English notes", "nl": None} + package["provenance"] = {"en": "English provenance", "nl": ""} + package["population_coverage"] = {"en": "English coverage", "nl": None} + package["publisher_note"] = {"en": "English publisher note", "nl": ""} + + package["resources"][0]["name"] = {"en": "English resource", "nl": ""} + package["resources"][0]["rights"] = {"en": "English rights", "nl": None} + + package["qualified_attribution"][0]["agent"][0]["name"] = {"en": "English agent", "nl": ""} + + result = replace_package(package, translation_dict={}, lang="nl") + + # Should fallback to English when nl is empty or None + assert result["title"] == "English title" + assert result["notes"] == "English notes" + assert result["provenance"] == "English provenance" + assert result["population_coverage"] == "English coverage" + assert result["publisher_note"] == "English publisher note" + + resource = result["resources"][0] + assert resource["name"] == "English resource" + assert resource["rights"] == "English rights" + + attribution_agent = result["qualified_attribution"][0]["agent"][0] + assert attribution_agent["name"] == "English agent" + + +def test_replace_search_facets_translates_titles(): + facets = { + "theme": { + "title": "Theme", + "items": [{"name": "health"}, {"name": "science"}], + } + } + + translation_dict = {"science": "Wetenschap"} + + with patch( + "ckanext.gdi_userportal.logic.action.translation_utils.get_translations", + return_value={"Theme": "Thema"}, + ) as mocked_get_translations: + result = replace_search_facets(facets, translation_dict, lang="nl") + + mocked_get_translations.assert_called_once_with(["Theme"], lang="nl") + theme_facet = result["theme"] + assert theme_facet["title"] == "Thema" + assert theme_facet["items"][0]["display_name"] == "health" + assert theme_facet["items"][1]["display_name"] == "Wetenschap" + + +def test_replace_search_facets_falls_back_to_term_name(): + facets = { + "format": { + "title": "Format", + "items": [{"name": "csv"}], + } + } + + translation_dict = {} + + with patch( + "ckanext.gdi_userportal.logic.action.translation_utils.get_translations", + return_value={}, + ): + result = replace_search_facets(facets, translation_dict, lang="en") + + format_facet = result["format"] + assert format_facet["title"] == "Format" + assert format_facet["items"][0]["display_name"] == "csv" + + +def test_replace_package_falls_back_to_default_language(): + package = deepcopy(_base_package()) + + result = replace_package(package, translation_dict={}, lang="fr") + + assert result["title"] == "English Title" + assert result["notes"] == "English Notes" + assert result["provenance"] == "English provenance" + assert result["population_coverage"] == "English coverage" + assert result["publisher_note"] == "English publisher note" + + resource = result["resources"][0] + assert resource["name"] == "English resource" + assert resource["rights"] == "English rights" + + attribution_agent = result["qualified_attribution"][0]["agent"][0] + assert attribution_agent["name"] == "English agent" diff --git a/test.ini b/test.ini index 93f9601..4fc5b54 100644 --- a/test.ini +++ b/test.ini @@ -9,12 +9,12 @@ error_email_from = ckan@localhost [app:main] use = config:../../src/ckan/test-core.ini -ckanext.dcat.rdf.profiles = euro_dcat_ap +ckanext.dcat.rdf.profiles = euro_dcat_ap gdi_userportal sqlalchemy.url = postgresql://ckan_default:password@localhost:5432/ckan_default # Insert any custom config settings to be used when running your extension's # tests here. These will override the one defined in CKAN core's test-core.ini -ckan.plugins = harvest gdi_userportalharvester gdi_userportal +ckan.plugins = harvest scheming_datasets dataset_series dcat fluent gdi_userportalharvester gdi_userportal # Logging configuration [loggers]