Skip to content

Commit 16f7a2e

Browse files
Merge pull request #2564 from IFRCGo/feature/ns-initiatives-categories
NS initiatives – 4 lang categories
2 parents 197783f + 0ab922c commit 16f7a2e

File tree

5 files changed

+241
-22
lines changed

5 files changed

+241
-22
lines changed

api/management/commands/ingest_ns_initiatives.py

Lines changed: 103 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
from sentry_sdk.crons import monitor
66

77
from api.logger import logger
8-
from api.models import Country, CronJob, CronJobStatus, NSDInitiatives
8+
from api.models import (
9+
Country,
10+
CronJob,
11+
CronJobStatus,
12+
NSDInitiatives,
13+
NSDInitiativesCategory,
14+
)
915
from main.sentry import SentryMonitor
1016

1117
DEFAULT_COUNTRY_ID = 289 # IFRC
@@ -32,7 +38,6 @@ def get_defaults(element, country, funding_period, lang):
3238
"fund_type": (
3339
f"{element.get('Fund')}{element.get('FundingType')}" if element.get("FundingType") else element.get("Fund")
3440
),
35-
"categories": element.get("Categories"),
3641
"allocation": element.get("AllocationInCHF"),
3742
"funding_period": funding_period,
3843
"translation_module_original_language": lang,
@@ -89,6 +94,10 @@ def handle(self, *args, **kwargs):
8994
updated_remote_ids = set()
9095
created_ns_initiatives_pk = []
9196

97+
# In-memory alignment helpers
98+
category_by_remote_index = {} # remote_id -> list[NSDInitiativesCategory]
99+
pending_translations = {} # remote_id -> list[(lang, index, label)]
100+
92101
for lang, resp in responses:
93102
for element in resp:
94103
try:
@@ -114,24 +123,99 @@ def handle(self, *args, **kwargs):
114123
for field, value in defaults.items():
115124
setattr(ni, field, value)
116125
ni.save(update_fields=defaults.keys())
117-
updated_remote_ids.add(remote_id) # Mark as updated, only for EN entries
126+
updated_remote_ids.add(remote_id)
118127
created_ns_initiatives_pk.append(ni.pk)
119-
else:
120-
try:
121-
# We could use ISO also to identify the entry, but remote_id is more robust
122-
ni = NSDInitiatives.objects.get(remote_id=remote_id)
123-
setattr(ni, title_field, element.get("InitiativeTitle"))
124-
setattr(ni, risk_field, element.get("Risk"))
125-
ni.save(update_fields=[title_field, risk_field])
126-
except NSDInitiatives.DoesNotExist:
127-
# Should not happen – only if EN entry is missing
128-
ni = NSDInitiatives.objects.create(
129-
remote_id=remote_id,
130-
**defaults,
131-
)
132-
added += 1
133-
created_ns_initiatives_pk.append(ni.pk)
134-
logger.warning(f"Created non-EN entry: {remote_id} / {lang}")
128+
129+
# Establish baseline categories from EN by index
130+
raw_categories = element.get("Categories") or []
131+
cats_en = []
132+
cat_objs = []
133+
if isinstance(raw_categories, (list, tuple)):
134+
for idx, raw in enumerate(raw_categories):
135+
label = (raw or "").strip()
136+
if not label:
137+
cats_en.append(None)
138+
continue
139+
# Reuse/create a global category by English label
140+
cat = NSDInitiativesCategory.objects.filter(name_en__iexact=label).first()
141+
if not cat:
142+
cat = NSDInitiativesCategory.objects.create(name=label, name_en=label)
143+
else:
144+
# Ensure plain 'name' mirrors EN for convenience
145+
to_update = []
146+
if getattr(cat, "name_en", None) != label:
147+
cat.name_en = label
148+
to_update.append("name_en")
149+
if getattr(cat, "name", None) != label:
150+
cat.name = label
151+
to_update.append("name")
152+
if to_update:
153+
cat.save(update_fields=to_update)
154+
cats_en.append(cat)
155+
cat_objs.append(cat)
156+
157+
if cat_objs:
158+
ni.categories.set(cat_objs)
159+
else:
160+
ni.categories.clear()
161+
162+
# Save baseline for this remote_id
163+
category_by_remote_index[remote_id] = cats_en
164+
165+
# Apply any pending translations queued before EN
166+
for item in pending_translations.pop(remote_id, []):
167+
plang, pidx, plabel = item
168+
if 0 <= pidx < len(cats_en) and cats_en[pidx]:
169+
field = f"name_{plang}"
170+
cat = cats_en[pidx]
171+
if getattr(cat, field, None) != plabel:
172+
setattr(cat, field, plabel)
173+
cat.save(update_fields=[field])
174+
continue # Done with EN row; go next element
175+
176+
# Non-EN branch: update fields and queue/apply category translations
177+
try:
178+
ni = NSDInitiatives.objects.get(remote_id=remote_id)
179+
setattr(ni, title_field, element.get("InitiativeTitle"))
180+
setattr(ni, risk_field, element.get("Risk"))
181+
ni.save(update_fields=[title_field, risk_field])
182+
except NSDInitiatives.DoesNotExist:
183+
# Should not happen – only if EN entry is missing
184+
ni = NSDInitiatives.objects.create(
185+
remote_id=remote_id,
186+
**defaults,
187+
)
188+
added += 1
189+
created_ns_initiatives_pk.append(ni.pk)
190+
logger.warning(f"Created non-EN entry: {remote_id} / {lang}")
191+
192+
# Align categories by index to the EN baseline for this remote_id
193+
raw_categories = element.get("Categories") or []
194+
if not isinstance(raw_categories, (list, tuple)):
195+
continue
196+
197+
cats_en = category_by_remote_index.get(remote_id)
198+
if cats_en is None:
199+
# Baseline not processed yet; queue these translations
200+
q = pending_translations.setdefault(remote_id, [])
201+
for idx, raw in enumerate(raw_categories):
202+
label = (raw or "").strip()
203+
if label:
204+
q.append((lang, idx, label))
205+
continue
206+
207+
# Baseline exists: update translated fields by index
208+
field = f"name_{lang}"
209+
for idx, raw in enumerate(raw_categories):
210+
label = (raw or "").strip()
211+
if not label or idx >= len(cats_en):
212+
continue
213+
cat = cats_en[idx]
214+
if not cat:
215+
continue
216+
if getattr(cat, field, None) != label:
217+
setattr(cat, field, label)
218+
cat.save(update_fields=[field])
135219

136220
# Remove old entries not present in the latest fetch
137221
NSDInitiatives.objects.exclude(id__in=created_ns_initiatives_pk).delete()
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Generated by Django 4.2.19 on 2025-09-25 12:15
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("api", "0225_nsdinitiatives_nsia_risk_ar_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="NSDInitiativesCategory",
14+
fields=[
15+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
16+
("name", models.CharField(max_length=255, verbose_name="Name")),
17+
("name_en", models.CharField(max_length=255, null=True)),
18+
("name_es", models.CharField(max_length=255, null=True)),
19+
("name_fr", models.CharField(max_length=255, null=True)),
20+
("name_ar", models.CharField(max_length=255, null=True)),
21+
(
22+
"translation_module_original_language",
23+
models.CharField(
24+
choices=[("en", "English"), ("es", "Spanish"), ("fr", "French"), ("ar", "Arabic")],
25+
default="en",
26+
help_text="Language used to create this entity",
27+
max_length=2,
28+
verbose_name="Entity Original language",
29+
),
30+
),
31+
(
32+
"translation_module_skip_auto_translation",
33+
models.BooleanField(
34+
default=False,
35+
help_text="Skip auto translation operation for this entity?",
36+
verbose_name="Skip auto translation",
37+
),
38+
),
39+
],
40+
options={
41+
"verbose_name": "NSD initiative category",
42+
"verbose_name_plural": "NSD initiative categories",
43+
"ordering": ("name",),
44+
},
45+
),
46+
migrations.RemoveField(
47+
model_name="nsdinitiatives",
48+
name="categories", # remove old ArrayField
49+
),
50+
migrations.AddField(
51+
model_name="nsdinitiatives",
52+
name="categories",
53+
field=models.ManyToManyField(
54+
blank=True,
55+
related_name="initiatives",
56+
to="api.nsdinitiativescategory",
57+
verbose_name="Funding categories",
58+
),
59+
),
60+
migrations.AlterField(
61+
model_name='nsdinitiativescategory',
62+
name='name',
63+
field=models.CharField(max_length=255),
64+
),
65+
]

api/models.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,14 +419,31 @@ class CountryOrganizationalCapacity(models.Model):
419419
financial_capacity = models.TextField(verbose_name=_("Financial Capacity"), null=True, blank=True)
420420

421421

422+
class NSDInitiativesCategory(models.Model):
423+
name = models.CharField(max_length=255)
424+
425+
class Meta:
426+
ordering = ("name",)
427+
verbose_name = _("NSD initiative category")
428+
verbose_name_plural = _("NSD initiative categories")
429+
430+
def __str__(self):
431+
return f"{self.name}"
432+
433+
422434
class NSDInitiatives(models.Model):
423435
country = models.ForeignKey(Country, verbose_name=_("Country"), on_delete=models.CASCADE)
424436
title = models.CharField(verbose_name=_("Title"), max_length=255)
425437
fund_type = models.CharField(verbose_name=_("Fund Type"), max_length=255)
426438
allocation = models.IntegerField(verbose_name=_("Allocation in CHF"))
427439
year = models.CharField(verbose_name=_("Year"), max_length=20)
428440
funding_period = models.IntegerField(verbose_name=_("Funding Period in Month"))
429-
categories = ArrayField(models.CharField(max_length=255), verbose_name=_("Funding categories"), default=list, null=True)
441+
categories = models.ManyToManyField(
442+
NSDInitiativesCategory,
443+
blank=True,
444+
related_name="initiatives",
445+
verbose_name=_("Funding categories"),
446+
)
430447
remote_id = models.IntegerField(db_index=True, unique=True, null=True, blank=True)
431448
nsia_risk = models.CharField(verbose_name=_("NSIA Risk"), max_length=30, null=True, blank=True)
432449

api/serializers.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -657,11 +657,56 @@ class Meta:
657657
fields = ("id", "first_name", "last_name", "position")
658658

659659

660-
class NSDInitiativesSerialzier(ModelSerializer):
660+
class NSDInitiativesSerializer(ModelSerializer):
661+
# Return translated category names (current active language) – like title does.
662+
categories = serializers.SerializerMethodField()
663+
661664
class Meta:
662665
model = NSDInitiatives
663666
fields = "__all__"
664667

668+
@staticmethod
669+
def get_categories(obj):
670+
"""
671+
Return category names in the currently active language (like title does),
672+
avoiding duplicates and avoiding showing the same semantic category in
673+
multiple languages (caused by legacy per-language rows).
674+
675+
Strategy:
676+
- Active language value (name_<lang>) wins.
677+
- If missing, fall back to English ONLY if no row already provided a
678+
translated value for that semantic slot.
679+
- Ignore rows that have neither an active-language value nor an English fallback.
680+
"""
681+
from django.utils.translation import get_language
682+
683+
lang = (get_language() or "en")[:2]
684+
lang_field = f"name_{lang}"
685+
686+
cats = obj.categories.all()
687+
# First collect all explicit translations in the active language
688+
explicit_lang_values = {
689+
getattr(c, lang_field).strip() for c in cats if getattr(c, lang_field, None) and getattr(c, lang_field).strip()
690+
}
691+
692+
seen = set()
693+
result = []
694+
for c in cats:
695+
val_lang = getattr(c, lang_field, None)
696+
val_lang = val_lang.strip() if val_lang else ""
697+
if val_lang:
698+
if val_lang not in seen:
699+
seen.add(val_lang)
700+
result.append(val_lang)
701+
continue
702+
# fallback to English only if there is NO active-language version in any row
703+
val_en = getattr(c, "name_en", None)
704+
val_en = val_en.strip() if val_en else ""
705+
if val_en and not explicit_lang_values and val_en not in seen:
706+
seen.add(val_en)
707+
result.append(val_en)
708+
return result
709+
665710

666711
class CountryCapacityStrengtheningSerializer(ModelSerializer):
667712
assessment_type_display = serializers.CharField(read_only=True, source="get_assessment_type_display")
@@ -691,7 +736,7 @@ class CountryRelationSerializer(ModelSerializer):
691736
centroid = serializers.SerializerMethodField()
692737
regions_details = RegionSerializer(source="region", read_only=True)
693738
directory = CountryDirectorySerializer(source="countrydirectory_set", read_only=True, many=True)
694-
initiatives = NSDInitiativesSerialzier(source="nsdinitiatives_set", read_only=True, many=True)
739+
initiatives = NSDInitiativesSerializer(source="nsdinitiatives_set", read_only=True, many=True)
695740
capacity = CountryCapacityStrengtheningSerializer(source="countrycapacitystrengthening_set", read_only=True, many=True)
696741
organizational_capacity = CountryOrganizationalCapacitySerializer(
697742
source="countryorganizationalcapacity",
@@ -2521,6 +2566,7 @@ def create(self, validated_data):
25212566
else:
25222567
title = "Export"
25232568
user = self.context["request"].user
2569+
25242570
if export_type == Export.ExportType.PER:
25252571
validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/countries/{country_id}/{export_type}/{export_id}/export/"
25262572
else:

api/translation.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
KeyFigure,
2020
MainContact,
2121
NSDInitiatives,
22+
NSDInitiativesCategory,
2223
Region,
2324
RegionEmergencySnippet,
2425
RegionKeyFigure,
@@ -103,6 +104,12 @@ class MainContactTO(TranslationOptions):
103104
fields = ("extent",)
104105

105106

107+
@register(NSDInitiativesCategory)
108+
class NSDInitiativesCategoryTO(TranslationOptions):
109+
fields = ("name",)
110+
skip_fields = ("name",)
111+
112+
106113
@register(NSDInitiatives)
107114
class NSDInitiativesTO(TranslationOptions):
108115
fields = ("title", "nsia_risk")

0 commit comments

Comments
 (0)