Skip to content

Commit c356a07

Browse files
authored
Merge branch 'miltilingual-suuport' into sonarcloud-patch-1
2 parents eded3c0 + 914e8a1 commit c356a07

File tree

13 files changed

+1547
-82
lines changed

13 files changed

+1547
-82
lines changed

.github/workflows/test.yml

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,36 @@ jobs:
4848
pip install -r requirements.txt
4949
pip install -r dev-requirements.txt
5050
pip install -e .
51-
- name: Setup CKAN extensions (harvest, scheming, dcat)
51+
- name: Setup CKAN extensions (harvest, scheming, dcat, dataset-series, fluent)
5252
run: |
5353
# Harvest v1.6.1 from GitHub
5454
git clone https://github.com/ckan/ckanext-harvest
5555
cd ckanext-harvest
5656
git checkout tags/v1.6.1
5757
pip install -e .
5858
pip install -r requirements.txt
59-
60-
# Scheming (Civity fork)
61-
pip install -e 'git+https://github.com/CivityNL/[email protected]#egg=ckanext-scheming[requirements]'
62-
63-
# DCAT v2.4.0 from PyPI
64-
pip install ckanext-dcat==2.4.0
59+
cd ..
60+
61+
# Scheming release 3.1.0
62+
pip install -e 'git+https://github.com/ckan/[email protected]#egg=ckanext-scheming[requirements]'
63+
64+
# DCAT extension maintained for the GDI user portal
65+
pip install -e 'git+https://github.com/GenomicDataInfrastructure/[email protected]#egg=ckanext-dcat'
66+
if [ -f "${PIP_SRC}/ckanext-dcat/requirements.txt" ]; then
67+
pip install -r "${PIP_SRC}/ckanext-dcat/requirements.txt"
68+
fi
69+
70+
# Dataset series extension
71+
pip install -e 'git+https://github.com/GenomicDataInfrastructure/[email protected]#egg=ckanext-dataset-series'
72+
if [ -f "${PIP_SRC}/ckanext-dataset-series/requirements.txt" ]; then
73+
pip install -r "${PIP_SRC}/ckanext-dataset-series/requirements.txt"
74+
fi
75+
76+
# Fluent extension for multilingual support
77+
pip install -e 'git+https://github.com/ckan/ckanext-fluent.git#egg=ckanext-fluent'
78+
if [ -f "${PIP_SRC}/ckanext-fluent/requirements.txt" ]; then
79+
pip install -r "${PIP_SRC}/ckanext-fluent/requirements.txt"
80+
fi
6581
- name: Setup extension
6682
run: |
6783
sed -i -e 's/use = config:.*/use = config:\/srv\/app\/src\/ckan\/test-core.ini/' test.ini
@@ -74,8 +90,10 @@ jobs:
7490
coverage xml -o coverage.xml
7591
- name: Install unzip
7692
run: apt-get update && apt-get install -y unzip
77-
- name: SonarCloud Scan
78-
uses: SonarSource/[email protected]
93+
- name: Sonar scan
94+
uses: SonarSource/sonarqube-scan-action@v6
95+
7996
env:
80-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
97+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8198
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
99+
SONAR_HOST_URL: https://sonarcloud.io

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,10 @@ __pycache__
7878

7979
*.egg-info
8080

81-
venv
81+
venv
82+
.idea/.gitignore
83+
.idea/ckanext-gdi-userportal.iml
84+
.idea/misc.xml
85+
.idea/modules.xml
86+
.idea/vcs.xml
87+
.idea/inspectionProfiles/profiles_settings.xml

ckanext/gdi_userportal/helpers.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# SPDX-FileCopyrightText: 2024 PNED G.I.E.
2+
# SPDX-FileContributor: Stichting Health-RI
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
from __future__ import annotations
7+
8+
from collections.abc import Iterable
9+
from typing import Any
10+
11+
import ckan.plugins.toolkit as tk
12+
13+
14+
def _ensure_data_dict(data: dict[str, Any] | None, package_id: str | None) -> dict[str, Any]:
15+
"""Return a dataset dict for the current helper call.
16+
17+
When a dataset dict is not provided but an id is, fetch it via the
18+
``package_show`` action. Failures are ignored so the helper still returns
19+
a sensible default instead of aborting rendering.
20+
"""
21+
if data is not None:
22+
return data
23+
24+
if package_id:
25+
try:
26+
return tk.get_action("package_show")(
27+
{"ignore_auth": True}, {"id": package_id}
28+
)
29+
except (tk.ObjectNotFound, tk.NotAuthorized):
30+
pass
31+
32+
return {}
33+
34+
35+
def _value_from_extras(data_dict: dict[str, Any], field_name: str) -> Any:
36+
extras = data_dict.get("extras")
37+
if isinstance(extras, dict):
38+
return extras.get(field_name)
39+
if isinstance(extras, Iterable):
40+
for extra in extras:
41+
if not isinstance(extra, dict):
42+
continue
43+
if extra.get("key") == field_name:
44+
return extra.get("value")
45+
return None
46+
47+
48+
def _extract_field_value(field: dict[str, Any], data_dict: dict[str, Any]) -> Any:
49+
field_name = field.get("field_name")
50+
if not field_name or not data_dict:
51+
return None
52+
53+
if field_name in data_dict:
54+
return data_dict[field_name]
55+
56+
return _value_from_extras(data_dict, field_name)
57+
58+
59+
def _is_missing_value(value: Any) -> bool:
60+
if value is None:
61+
return True
62+
63+
if isinstance(value, str):
64+
return value.strip() == ""
65+
66+
if isinstance(value, dict):
67+
return not value or all(_is_missing_value(v) for v in value.values())
68+
69+
if isinstance(value, (list, tuple, set)):
70+
return not value or all(_is_missing_value(v) for v in value)
71+
72+
return False
73+
74+
75+
def scheming_missing_required_fields(
76+
pages: list[dict[str, Any]],
77+
data: dict[str, Any] | None = None,
78+
package_id: str | None = None,
79+
) -> list[list[str]]:
80+
"""Return a list of missing required fields grouped per form page.
81+
82+
This helper acts as the base implementation expected by
83+
``ckanext-fluent``. It mirrors the behaviour from the forked
84+
ckanext-scheming version previously used in this project and makes sure
85+
chained helpers can extend the result again.
86+
"""
87+
data_dict = _ensure_data_dict(data, package_id)
88+
89+
missing_per_page: list[list[str]] = []
90+
91+
for page in pages or []:
92+
page_missing: list[str] = []
93+
for field in page.get("fields", []):
94+
# Ignore non-required fields early.
95+
if not tk.h.scheming_field_required(field):
96+
continue
97+
98+
value = _extract_field_value(field, data_dict)
99+
100+
# Repeating subfields can contain a list of child values; treat the
101+
# field as present when at least one entry contains data.
102+
if field.get("repeating_subfields") and isinstance(value, list):
103+
if any(not _is_missing_value(item) for item in value):
104+
continue
105+
elif not _is_missing_value(value):
106+
continue
107+
108+
if field_name := field.get("field_name"):
109+
page_missing.append(field_name)
110+
111+
missing_per_page.append(page_missing)
112+
113+
return missing_per_page
114+
115+
116+
def get_helpers() -> dict[str, Any]:
117+
return {"scheming_missing_required_fields": scheming_missing_required_fields}

ckanext/gdi_userportal/logic/action/get.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ def enhanced_package_search(context, data_dict) -> Dict:
2828
lang = toolkit.request.headers.get("Accept-Language")
2929
translations = get_translations(values_to_translate, lang=lang)
3030
result["results"] = [
31-
replace_package(package, translations) for package in result["results"]
31+
replace_package(package, translations, lang=lang)
32+
for package in result["results"]
3233
]
3334
if "search_facets" in result.keys():
3435
result["search_facets"] = replace_search_facets(
@@ -43,4 +44,4 @@ def enhanced_package_show(context, data_dict) -> Dict:
4344
values_to_translate = collect_values_to_translate(result)
4445
lang = toolkit.request.headers.get("Accept-Language")
4546
translations = get_translations(values_to_translate, lang=lang)
46-
return replace_package(result, translations)
47+
return replace_package(result, translations, lang=lang)

ckanext/gdi_userportal/logic/action/post.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,17 @@ def enhanced_package_search(context, data_dict=None) -> Dict:
2525
try:
2626
result = toolkit.get_action("package_search")(context, data_dict)
2727
values_to_translate = collect_values_to_translate(result)
28-
translations = get_translations(values_to_translate)
28+
lang = toolkit.request.headers.get("Accept-Language")
29+
translations = get_translations(values_to_translate, lang=lang)
2930

3031
result["results"] = [
31-
replace_package(package, translations) for package in result["results"]
32+
replace_package(package, translations, lang=lang)
33+
for package in result["results"]
3234
]
3335

3436
if "search_facets" in result:
3537
result["search_facets"] = replace_search_facets(
36-
result["search_facets"], translations
38+
result["search_facets"], translations, lang=lang
3739
)
3840

3941
return result

ckanext/gdi_userportal/logic/action/translation_utils.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
from dataclasses import dataclass
88
import logging
9-
from typing import Any, Dict, List
9+
from typing import Any, Dict, List, Optional
1010

11-
from ckan.common import config, request
11+
from ckan.common import config
1212

1313
# -*- coding: utf-8 -*-
1414
from ckan.plugins import toolkit
@@ -23,6 +23,13 @@
2323
"dcat_type",
2424
]
2525
RESOURCE_REPLACE_FIELDS = ["format", "language"]
26+
TRANSLATED_SUFFIX = "_translated"
27+
LANGUAGE_VALUE_FIELDS = {
28+
"population_coverage",
29+
"publisher_note",
30+
"provenance",
31+
"rights",
32+
}
2633
DEFAULT_FALLBACK_LANG = "en"
2734
SUPPORTED_LANGUAGES = {DEFAULT_FALLBACK_LANG, "nl"}
2835

@@ -121,13 +128,18 @@ def collect_values_to_translate(data: Any) -> List:
121128
return list(set(values_to_translate))
122129

123130

124-
def replace_package(data, translation_dict):
131+
def replace_package(data, translation_dict, lang: Optional[str] = None):
132+
preferred_lang = _get_language(lang)
133+
134+
_apply_translated_properties(data, preferred_lang)
135+
125136
data = _translate_fields(data, PACKAGE_REPLACE_FIELDS, translation_dict)
126137
resources = data.get("resources", [])
127138
data["resources"] = [
128139
_translate_fields(item, RESOURCE_REPLACE_FIELDS, translation_dict)
129140
for item in resources
130141
]
142+
131143
return data
132144

133145

@@ -156,11 +168,77 @@ def _change_facet(facet, translation_dict):
156168

157169

158170
def replace_search_facets(data, translation_dict, lang):
171+
preferred_lang = _get_language(lang)
159172
new_facets = {}
160173
for key, facet in data.items():
161174
title = facet["title"]
162-
new_facets[key] = {"title": get_translations([title], lang=lang).get(title, title)}
175+
new_facets[key] = {
176+
"title": get_translations([title], lang=preferred_lang).get(title, title)
177+
}
163178
new_facets[key]["items"] = [
164179
_change_facet(item, translation_dict) for item in facet["items"]
165180
]
166181
return new_facets
182+
183+
184+
def _apply_translated_properties(data: Any, preferred_lang: str, fallback_lang: str = DEFAULT_FALLBACK_LANG):
185+
if not isinstance(data, (dict, list)):
186+
return data
187+
188+
if isinstance(data, list):
189+
return [
190+
_apply_translated_properties(item, preferred_lang, fallback_lang)
191+
if isinstance(item, (dict, list))
192+
else item
193+
for item in data
194+
]
195+
196+
for key, value in list(data.items()):
197+
if isinstance(value, dict):
198+
_apply_translated_properties(value, preferred_lang, fallback_lang)
199+
elif isinstance(value, list):
200+
data[key] = [
201+
_apply_translated_properties(item, preferred_lang, fallback_lang)
202+
if isinstance(item, (dict, list))
203+
else item
204+
for item in value
205+
]
206+
207+
for key, value in list(data.items()):
208+
if key.endswith(TRANSLATED_SUFFIX) and isinstance(value, dict):
209+
base_key = key[:-len(TRANSLATED_SUFFIX)]
210+
merged_values = value.copy()
211+
existing_value = data.get(base_key)
212+
if isinstance(existing_value, dict):
213+
merged_values.update(existing_value)
214+
data[base_key] = _select_translated_value(
215+
merged_values, preferred_lang, fallback_lang
216+
)
217+
elif key in LANGUAGE_VALUE_FIELDS and isinstance(value, dict):
218+
data[key] = _select_translated_value(value, preferred_lang, fallback_lang)
219+
220+
return data
221+
222+
223+
def _select_translated_value(values: Dict[str, Any], preferred_lang: str, fallback_lang: str) -> Any:
224+
if not isinstance(values, dict):
225+
return values
226+
227+
for lang in (preferred_lang, fallback_lang):
228+
translated = values.get(lang)
229+
if _has_content(translated):
230+
return translated
231+
232+
for translated in values.values():
233+
if _has_content(translated):
234+
return translated
235+
236+
return next(iter(values.values()), "")
237+
238+
239+
def _has_content(value: Any) -> bool:
240+
if value is None:
241+
return False
242+
if isinstance(value, str):
243+
return bool(value.strip())
244+
return bool(value) if isinstance(value, (list, dict)) else True

ckanext/gdi_userportal/plugin.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import ckan.plugins as plugins
88
import ckan.plugins.toolkit as toolkit
9+
from ckanext.gdi_userportal.helpers import get_helpers as get_portal_helpers
910
from ckanext.gdi_userportal.logic.action.get import (
1011
enhanced_package_search,
1112
enhanced_package_show,
@@ -50,6 +51,7 @@ class GdiUserPortalPlugin(plugins.SingletonPlugin):
5051
plugins.implements(plugins.IActions)
5152
plugins.implements(plugins.IPackageController)
5253
plugins.implements(plugins.IValidators)
54+
plugins.implements(plugins.ITemplateHelpers, inherit=True)
5355
plugins.implements(plugins.IMiddleware, inherit=True)
5456
plugins.implements(plugins.IConfigurable, inherit=True)
5557

@@ -74,8 +76,14 @@ def update_config(self, config_):
7476
def update_config_schema(self, schema):
7577
ignore_missing = toolkit.get_validator("ignore_missing")
7678
unicode_safe = toolkit.get_validator("unicode_safe")
79+
int_validator = toolkit.get_validator("int_validator")
7780
schema.update(
78-
{"ckanext.gdi_userportal.intro_text": [ignore_missing, unicode_safe]}
81+
{
82+
"ckanext.gdi_userportal.intro_text": [ignore_missing, unicode_safe],
83+
"ckan.harvest.timeout": [ignore_missing, int_validator],
84+
"ckan.harvest.mq.type": [ignore_missing, unicode_safe],
85+
"ckan.harvest.mq.hostname": [ignore_missing, unicode_safe],
86+
}
7987
)
8088
return schema
8189

@@ -105,6 +113,9 @@ def get_actions(self):
105113
"enhanced_package_show": enhanced_package_show,
106114
}
107115

116+
def get_helpers(self):
117+
return get_portal_helpers()
118+
108119
def read(self, entity):
109120
pass
110121

0 commit comments

Comments
 (0)