Skip to content

Commit 0524a81

Browse files
committed
[Fixes #13800] Refact metadata i18n mechanism
1 parent 1df44a6 commit 0524a81

File tree

12 files changed

+134
-103
lines changed

12 files changed

+134
-103
lines changed

geonode/base/apps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,11 @@ class BaseAppConfig(NotificationsAppConfigBase, AppConfig):
4242
_("Resource is created"),
4343
),
4444
)
45+
46+
def ready(self):
47+
"""Finalize setup"""
48+
from geonode.base.signals import connect_signals
49+
50+
connect_signals()
51+
52+
super(BaseAppConfig, self).ready()
Lines changed: 45 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import logging
2-
from datetime import datetime
32

4-
from cachetools import FIFOCache
53
from django.db import connection
4+
from django.utils.translation import get_language, gettext as _
65

76
from geonode.base.models import ThesaurusKeywordLabel, Thesaurus
87

9-
108
logger = logging.getLogger(__name__)
119

1210
I18N_THESAURUS_IDENTIFIER = "labels-i18n"
@@ -47,6 +45,7 @@ def get_localized_tkeywords(lang, thesaurus_identifier: str):
4745
return sorted(ret.values(), key=lambda i: i["about"].lower())
4846

4947

48+
# TODO: deprecate and use LabelResolver.gettext()
5049
def get_localized_label(lang, about):
5150
# look for override
5251
ovr_qs = ThesaurusKeywordLabel.objects.filter(
@@ -65,32 +64,37 @@ def get_localized_label(lang, about):
6564
)
6665

6766

68-
class I18nCache:
67+
class I18nCacheEntry:
68+
def __init__(self):
69+
# the date field of the thesaurus when it was last loaded, it's used for the expiration check
70+
self.date: str | None = None
71+
self.caches: dict = {} # the caches for this language
72+
6973

70-
DATA_KEY_SCHEMA = "schema"
71-
DATA_KEY_LABELS = "labels"
74+
class I18nCache:
75+
"""
76+
Caches language related data.
77+
Synch is performed via date field in the "labels-i18n" thesaurus.
78+
"""
7279

7380
def __init__(self):
74-
# the cache has the lang as key, and various info in the dict value:
75-
# - date: the date field of the thesaurus when it was last loaded, it's used for the expiration check
76-
# - labels: the keyword labels from the i18n thesaurus
77-
# - schema: the localized json schema
78-
# FIFO bc we want to renew the data once in a while
79-
self.cache = FIFOCache(16)
81+
# the cache has the lang as key, and I18nCacheEntry as a value:
82+
self.lang_cache = {}
8083

8184
def get_entry(self, lang, data_key):
8285
"""
8386
returns date:str, data
8487
date is needed for checking the entry freshness when setting info
8588
data may be None if not cached or expired
8689
"""
87-
cached_entry = self.cache.get(lang, None)
90+
cached_entry = self.lang_cache.get(lang, None)
8891

92+
# TODO: thesaurus date check should be done only after a given time interval from last check
8993
thesaurus_date = ( # may be none if thesaurus does not exist
9094
Thesaurus.objects.filter(identifier=I18N_THESAURUS_IDENTIFIER).values_list("date", flat=True).first()
9195
)
9296
if cached_entry:
93-
if thesaurus_date == cached_entry["date"]:
97+
if thesaurus_date == cached_entry.date:
9498
# only return cached data if thesaurus has not been modified
9599
return thesaurus_date, cached_entry.get(data_key, None)
96100
else:
@@ -99,7 +103,7 @@ def get_entry(self, lang, data_key):
99103
return thesaurus_date, None
100104

101105
def set(self, lang: str, data_key: str, data: dict, request_date: str):
102-
cached_entry: dict = self.cache.setdefault(lang, {})
106+
cached_entry: I18nCacheEntry = self.lang_cache.setdefault(lang, I18nCacheEntry())
103107

104108
latest_date = (
105109
Thesaurus.objects.filter(identifier=I18N_THESAURUS_IDENTIFIER).values_list("date", flat=True).first()
@@ -108,60 +112,43 @@ def set(self, lang: str, data_key: str, data: dict, request_date: str):
108112
if request_date == latest_date:
109113
# no changes after processing, set the info right away
110114
logger.debug(f"Caching lang:{lang} key:{data_key} date:{request_date}")
111-
cached_entry.update({"date": latest_date, data_key: data})
115+
cached_entry.date = latest_date
116+
cached_entry.caches[data_key] = data
112117
else:
113118
logger.warning(
114119
f"Cache will not be updated for lang:{lang} key:{data_key} reqdate:{request_date} latest:{latest_date}"
115120
)
116121

117-
def get_labels(self, lang):
118-
date, labels = self.get_entry(lang, self.DATA_KEY_LABELS)
119-
if labels is None:
120-
labels = {}
121-
for i in get_localized_tkeywords(lang, I18N_THESAURUS_IDENTIFIER):
122-
about = i["about"]
123-
if about.endswith(OVR_SUFFIX) and not i["label"]:
124-
# we don't want default values for override entries
125-
continue
126-
labels[about] = i["label"] or i["default"]
127-
self.set(lang, self.DATA_KEY_LABELS, labels, date)
128-
return labels
129-
130122
def clear_schema_cache(self):
131123
logger.info("Clearing schema cache")
132-
while True:
133-
try:
134-
self.cache.popitem()
135-
except KeyError:
136-
return
137-
124+
self.lang_cache.clear()
138125

139-
def thesaurus_changed(sender, instance, **kwargs):
140-
if instance.identifier == I18N_THESAURUS_IDENTIFIER:
141-
if hasattr(instance, "_signal_handled"): # avoid signal recursion
142-
return
143-
logger.debug(f"Thesaurus changed: {instance.identifier}")
144-
_update_thesaurus_date()
145126

127+
class LabelResolver:
128+
CACHE_KEY_LABELS = "labels"
146129

147-
def thesaurusk_changed(sender, instance, **kwargs):
148-
if instance.thesaurus.identifier == I18N_THESAURUS_IDENTIFIER:
149-
logger.debug(f"ThesaurusKeyword changed: {instance.about} ALT:{instance.alt_label}")
150-
_update_thesaurus_date()
130+
def gettext(self, key, lang=None, fallback=True):
131+
lang = lang or get_language()
132+
return self.get_labels(lang).get(key, None) or (_(key) if fallback else None)
151133

134+
def get_labels(self, lang):
135+
date, labels = i18nCache.get_entry(lang, self.CACHE_KEY_LABELS)
136+
if labels is None:
137+
labels = self._create_labels_cache(lang)
138+
i18nCache.set(lang, self.CACHE_KEY_LABELS, labels, date)
139+
return labels
152140

153-
def thesauruskl_changed(sender, instance, **kwargs):
154-
if instance.keyword.thesaurus.identifier == I18N_THESAURUS_IDENTIFIER:
155-
logger.debug(
156-
f"ThesaurusKeywordLabel changed: {instance.keyword.about} ALT:{instance.keyword.alt_label} L:{instance.lang}"
157-
)
158-
_update_thesaurus_date()
141+
def _create_labels_cache(self, lang):
142+
labels = {}
143+
for i in get_localized_tkeywords(lang, I18N_THESAURUS_IDENTIFIER):
144+
about = i["about"]
145+
if about.endswith(OVR_SUFFIX) and not i["label"]:
146+
# we don't want default values for override entries
147+
continue
148+
labels[about] = i["label"] or i["default"]
149+
return labels
159150

160151

161-
def _update_thesaurus_date():
162-
logger.debug("Updating label thesaurus date")
163-
# update timestamp to invalidate other processes also
164-
i18n_thesaurus = Thesaurus.objects.get(identifier=I18N_THESAURUS_IDENTIFIER)
165-
i18n_thesaurus.date = datetime.now().replace(microsecond=0).isoformat()
166-
i18n_thesaurus._signal_handled = True
167-
i18n_thesaurus.save()
152+
i18nCache = I18nCache()
153+
labelResolver = LabelResolver()
154+
gettext = labelResolver.gettext

geonode/base/signals.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import logging
2+
from datetime import datetime
3+
4+
from django.db.models.signals import post_save
5+
6+
from geonode.base.i18n import I18N_THESAURUS_IDENTIFIER
7+
from geonode.base.models import Thesaurus, ThesaurusKeyword, ThesaurusKeywordLabel
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def connect_signals():
13+
logger.debug("Setting up signal connections...")
14+
post_save.connect(thesaurus_changed, sender=Thesaurus, weak=False, dispatch_uid="metadata_reset_t")
15+
post_save.connect(thesaurusk_changed, sender=ThesaurusKeyword, weak=False, dispatch_uid="metadata_reset_tk")
16+
post_save.connect(thesauruskl_changed, sender=ThesaurusKeywordLabel, weak=False, dispatch_uid="metadata_reset_tkl")
17+
logger.debug("Signal connections set")
18+
19+
20+
def thesaurus_changed(sender, instance, **kwargs):
21+
if instance.identifier == I18N_THESAURUS_IDENTIFIER:
22+
if hasattr(instance, "_signal_handled"): # avoid signal recursion
23+
return
24+
logger.debug(f"Thesaurus changed: {instance.identifier}")
25+
_update_thesaurus_date()
26+
27+
28+
def thesaurusk_changed(sender, instance, **kwargs):
29+
if instance.thesaurus.identifier == I18N_THESAURUS_IDENTIFIER:
30+
logger.debug(f"ThesaurusKeyword changed: {instance.about} ALT:{instance.alt_label}")
31+
_update_thesaurus_date()
32+
33+
34+
def thesauruskl_changed(sender, instance, **kwargs):
35+
if instance.keyword.thesaurus.identifier == I18N_THESAURUS_IDENTIFIER:
36+
logger.debug(
37+
f"ThesaurusKeywordLabel changed: {instance.keyword.about} ALT:{instance.keyword.alt_label} L:{instance.lang}"
38+
)
39+
_update_thesaurus_date()
40+
41+
42+
def _update_thesaurus_date():
43+
logger.debug("Updating label thesaurus date")
44+
# update timestamp to invalidate other processes also
45+
i18n_thesaurus = Thesaurus.objects.get(identifier=I18N_THESAURUS_IDENTIFIER)
46+
i18n_thesaurus.date = datetime.now().replace(microsecond=0).isoformat()
47+
i18n_thesaurus._signal_handled = True
48+
i18n_thesaurus.save()

geonode/metadata/api/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from geonode.base.views import LinkedResourcesAutocomplete, RegionAutocomplete, HierarchicalKeywordAutocomplete
3838
from geonode.groups.models import GroupProfile
3939
from geonode.metadata.handlers.abstract import MetadataHandler
40-
from geonode.metadata.i18n import get_localized_label
40+
from geonode.base.i18n import get_localized_label
4141
from geonode.metadata.manager import metadata_manager
4242
from geonode.metadata.multilang import utils as multi
4343
from geonode.people.utils import get_available_users

geonode/metadata/apps.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ def ready(self):
1919
def run_setup_hooks(*args, **kwargs):
2020
setup_metadata_handlers()
2121

22-
from geonode.metadata.signals import connect_signals
23-
24-
connect_signals()
25-
2622

2723
def setup_metadata_handlers():
2824
from geonode.metadata.manager import metadata_manager

geonode/metadata/handlers/abstract.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
from django.utils.translation import gettext as _
2727

2828
from geonode.base.models import ResourceBase
29-
from geonode.metadata.i18n import OVR_SUFFIX
29+
from geonode.base.i18n import OVR_SUFFIX
30+
from geonode.metadata.manager import CONTEXT_KEY_LABELS
3031

3132
logger = logging.getLogger(__name__)
3233

@@ -51,7 +52,7 @@ def update_schema(self, jsonschema: dict, context: dict, lang=None):
5152
It adds the subschema handled by the handler, and returns the
5253
augmented instance of the JSON Schema.
5354
Context is populated by the manager with some common info:
54-
- key "labels": contains the localized label loaded from the db as a dict, where key is the ThesaurusKeyword about
55+
- key CONTEXT_KEY_LABELS: contains the localized label loaded from the db as a dict, where key is the ThesaurusKeyword about
5556
and value is the localized ThesaurusKeywordLabel, or the AltLabel if the localized label does not exist.
5657
"""
5758
pass
@@ -72,7 +73,7 @@ def update_resource(
7273
):
7374
"""
7475
Called when persisting data, updates the field `field_name` of the resource
75-
with the content content, where json_instance is the full JSON Schema instance,
76+
with the content, where json_instance is the full JSON Schema instance,
7677
in case the handler needs some cross related data contained in the resource.
7778
"""
7879
pass
@@ -172,12 +173,14 @@ def localize_message(context: dict, msg_code: str, msg_info: dict):
172173

173174
@staticmethod
174175
def _localize_label(context, lang: str, text: str):
176+
# TODO: deprecate and use LabelResolver.gettext(...fallback=true)
175177
label = MetadataHandler._get_tkl_labels(context, lang, text)
176178
return label or _(text)
177179

178180
@staticmethod
179181
def _get_tkl_labels(context, lang: str | None, text: str):
180-
return context.get("labels", {}).get(text, None)
182+
# TODO: deprecate and use LabelResolver.gettext(...fallback=false)
183+
return context.get(CONTEXT_KEY_LABELS, {}).get(text, None)
181184

182185
@staticmethod
183186
def _localize_subschema_labels(context, subschema: dict, lang: str, property_name: str = None):

geonode/metadata/handlers/sparse.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@
2424

2525
from geonode.metadata.handlers.abstract import MetadataHandler
2626
from geonode.metadata.exceptions import UnsetFieldException
27-
from geonode.metadata.i18n import get_localized_tkeywords
2827
from geonode.metadata.models import SparseField
2928

30-
logger = logging.getLogger(__name__)
29+
from geonode.base.i18n import get_localized_tkeywords
3130

31+
logger = logging.getLogger(__name__)
3232

3333
CONTEXT_ID = "sparse"
3434

geonode/metadata/manager.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,19 @@
2727
from geonode.indexing.manager import index_manager
2828
from geonode.metadata.handlers.abstract import MetadataHandler
2929
from geonode.metadata.exceptions import UnsetFieldException
30-
from geonode.metadata.i18n import I18nCache
30+
from geonode.base.i18n import I18nCache
3131
from geonode.metadata.settings import MODEL_SCHEMA
3232

3333
logger = logging.getLogger(__name__)
3434

35+
CACHE_KEY_SCHEMA = "schema"
36+
CONTEXT_KEY_LABELS = "labels"
37+
3538

3639
class MetadataManager:
3740
"""
3841
The metadata manager is the bridge between the API and the geonode model.
39-
The metadata manager will loop over all of the registered metadata handlers,
42+
The metadata manager will loop over all the registered metadata handlers,
4043
calling their update_schema(jsonschema) which will add the subschemas of the
4144
fields handled by each handler. At the end of the loop, the schema will be ready
4245
to be delivered to the caller.
@@ -58,7 +61,7 @@ def post_init(self):
5861
handler.post_init()
5962

6063
def _init_schema_context(self, lang):
61-
return {"labels": self._i18n_cache.get_labels(lang)}
64+
return {CONTEXT_KEY_LABELS: self._i18n_cache.get_labels(lang)}
6265

6366
def build_schema(self, lang=None):
6467
logger.debug(f"build_schema {lang}")
@@ -83,12 +86,12 @@ def build_schema(self, lang=None):
8386

8487
def get_schema(self, lang=None):
8588
lang = str(lang)
86-
thesaurus_date, schema = self._i18n_cache.get_entry(lang, I18nCache.DATA_KEY_SCHEMA)
89+
thesaurus_date, schema = self._i18n_cache.get_entry(lang, CACHE_KEY_SCHEMA)
8790
if schema is None:
8891
logger.info(f"Building schema for {lang}")
8992
schema = self.build_schema(lang)
9093
logger.debug("Schema built")
91-
self._i18n_cache.set(lang, I18nCache.DATA_KEY_SCHEMA, schema, thesaurus_date)
94+
self._i18n_cache.set(lang, CACHE_KEY_SCHEMA, schema, thesaurus_date)
9295
return schema
9396

9497
def build_schema_instance(self, resource, lang=None):

geonode/metadata/signals.py

Lines changed: 0 additions & 16 deletions
This file was deleted.

0 commit comments

Comments
 (0)