From ccbe26c9dbf92a8eb999845e1b3c58c906746b64 Mon Sep 17 00:00:00 2001 From: level09 Date: Sun, 19 Jul 2026 13:29:48 +0300 Subject: [PATCH 01/10] feat(admin): show translated titles in labels/sources tables and include translation fields in label tree payload --- enferno/admin/models/Label.py | 5 ++++- enferno/admin/models/Source.py | 1 + enferno/admin/templates/admin/labels.html | 1 + enferno/admin/templates/admin/sources.html | 13 +++++++++++-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/enferno/admin/models/Label.py b/enferno/admin/models/Label.py index 4419c34be..bd9d2dee5 100644 --- a/enferno/admin/models/Label.py +++ b/enferno/admin/models/Label.py @@ -77,7 +77,7 @@ def _is_valid_parent(self, parent_id) -> bool: @staticmethod def build_tree(verified=None): """Build nested tree structure using raw SQL for performance.""" - query = "SELECT id, title, parent_label_id, verified, for_bulletin, for_actor, for_incident, for_offline FROM label" + query = "SELECT id, title, parent_label_id, verified, for_bulletin, for_actor, for_incident, for_offline, title_ar, comments, comments_ar FROM label" conditions = [] if verified is True: conditions.append("verified = true") @@ -101,6 +101,9 @@ def build_tree(verified=None): "for_actor": r[5], "for_incident": r[6], "for_offline": r[7], + "title_ar": r[8], + "comments": r[9], + "comments_ar": r[10], "children": [], } diff --git a/enferno/admin/models/Source.py b/enferno/admin/models/Source.py index bfab81d7e..cb210da60 100644 --- a/enferno/admin/models/Source.py +++ b/enferno/admin/models/Source.py @@ -61,6 +61,7 @@ def to_dict(self) -> dict[str, Any]: return { "id": self.id, "title": self.title, + "title_ar": self.title_ar, "etl_id": self.etl_id, "parent": {"id": self.parent.id, "title": self.parent.title} if self.parent else None, "comments": self.comments, diff --git a/enferno/admin/templates/admin/labels.html b/enferno/admin/templates/admin/labels.html index 7677ac08c..f25e067b6 100644 --- a/enferno/admin/templates/admin/labels.html +++ b/enferno/admin/templates/admin/labels.html @@ -246,6 +246,7 @@ headers: [ {title: "{{_('ID')}}", value: "id", width: 80}, {title: "{{_('Title')}}", value: "title"}, + {title: "{{_('Title (AR)')}}", value: "title_ar"}, {title: "{{_('Parent')}}", value: "parent", sortable: false}, {title: "{{_('Available In')}}", value: "available_in"}, {% if current_user.roles_in(['Admin','Mod']) %} diff --git a/enferno/admin/templates/admin/sources.html b/enferno/admin/templates/admin/sources.html index 967eda329..6c55754de 100644 --- a/enferno/admin/templates/admin/sources.html +++ b/enferno/admin/templates/admin/sources.html @@ -53,7 +53,7 @@ - + + + + + + - + Date: Sun, 19 Jul 2026 13:59:50 +0300 Subject: [PATCH 02/10] feat(search-field): locale-aware bilingual titles and translated collapsed paths in autocomplete --- enferno/admin/models/Label.py | 13 ++++- enferno/static/js/components/SearchField.js | 64 +++++++++++++++++++-- tests/test_lookup_crud.py | 20 +++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/enferno/admin/models/Label.py b/enferno/admin/models/Label.py index bd9d2dee5..8f5bbf60a 100644 --- a/enferno/admin/models/Label.py +++ b/enferno/admin/models/Label.py @@ -41,14 +41,19 @@ class Label(db.Model, BaseMixin): parent_label_id = db.Column(db.Integer, db.ForeignKey("label.id"), index=True, nullable=True) parent = db.relationship("Label", remote_side=id, backref="sub_label") - def _build_path(self) -> str: - """Walk up parent chain, return 'Grandparent > Parent' (excludes self).""" + def _build_path(self, translated: bool = False) -> str: + """Walk up parent chain, return 'Grandparent > Parent' (excludes self). + + With translated=True, use each ancestor's Arabic title, falling back to the + English title per level for ancestors that have no translation yet. + """ parts = [] current = self.parent seen = set() while current and current.id not in seen: seen.add(current.id) - parts.append(current.title) + title = (current.title_ar or current.title) if translated else current.title + parts.append(title) current = current.parent parts.reverse() return " > ".join(parts) if parts else "" @@ -184,7 +189,9 @@ def to_mode2(self) -> dict[str, Any]: return { "id": self.id, "title": self.title, + "title_ar": self.title_ar, "path": self._build_path(), + "path_ar": self._build_path(translated=True), "verified": self.verified, "for_bulletin": self.for_bulletin, "for_actor": self.for_actor, diff --git a/enferno/static/js/components/SearchField.js b/enferno/static/js/components/SearchField.js index ae52633a0..e01fc60d6 100644 --- a/enferno/static/js/components/SearchField.js +++ b/enferno/static/js/components/SearchField.js @@ -34,6 +34,14 @@ const SearchField = Vue.defineComponent({ type: String, default: null, }, + // Field holding the translated title (e.g. 'title_ar' or 'title_tr'). + // When null, a '_ar' / '_tr' key on the fetched items + // is picked up automatically. The user's interface language decides which + // language renders as the primary text and which as the secondary one. + itemTranslation: { + type: String, + default: null, + }, retainSearch: { type: Boolean, default: false, @@ -52,9 +60,51 @@ const SearchField = Vue.defineComponent({ return this.filterItems(this.items); } return this.items; - } + }, + isArabic() { + return window.__lang__ === 'ar'; + }, }, methods: { + translationField(raw) { + if (this.itemTranslation) return this.itemTranslation; + if (!raw) return null; + for (const suffix of ['_ar', '_tr']) { + const key = this.itemTitle + suffix; + if (key in raw) return key; + } + return null; + }, + primaryTitle(raw) { + const field = this.translationField(raw); + const translated = field ? raw[field] : null; + if (this.isArabic && translated) return translated; + return raw[this.itemTitle] || translated || ''; + }, + secondaryTitle(raw) { + const field = this.translationField(raw); + const translated = field ? raw[field] : null; + if (!translated || !raw[this.itemTitle]) return null; + return this.isArabic ? raw[this.itemTitle] : translated; + }, + localizedItemTitle(item) { + if (item == null || typeof item !== 'object') return item; + return this.primaryTitle(item); + }, + subtitleText(raw) { + if (!this.itemSubtitle) return null; + let text = raw[this.itemSubtitle]; + if (this.isArabic && raw[this.itemSubtitle + '_ar']) { + text = raw[this.itemSubtitle + '_ar']; + } + return this.collapsePath(text); + }, + collapsePath(text) { + if (!text) return text; + const parts = String(text).split(' > '); + if (parts.length <= 3) return text; + return [parts[0], '…', parts[parts.length - 1]].join(' > '); + }, startSearch(search) { this.loading = true; this.debouncedSearch(search); @@ -148,7 +198,7 @@ const SearchField = Vue.defineComponent({ item-color="secondary" :label="label" :items="filteredItems" - :item-title="itemTitle" + :item-title="localizedItemTitle" :item-value="itemValue" prepend-inner-icon="mdi-magnify" :multiple="multiple" @@ -161,16 +211,18 @@ const SearchField = Vue.defineComponent({ :loading="loading" :rules="rules" > -