Skip to content

Commit e2bd5ab

Browse files
chg ! sync models with HOPE core
1 parent 8b2e504 commit e2bd5ab

File tree

10 files changed

+263
-143
lines changed

10 files changed

+263
-143
lines changed

src/country_workspace/admin/office.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def programmes(self, btn: LinkButton) -> None:
2424

2525
@button()
2626
def sync(self, request: HttpRequest) -> None:
27-
from country_workspace.contrib.hope.sync.office import sync_offices
27+
from country_workspace.contrib.hope.sync.data import SyncStep, sync_hope_data
2828

29-
totals = sync_offices()
29+
totals = sync_hope_data(step=SyncStep.OFFICES)["offices"]
3030
self.message_user(request, f"{totals['add']} created - {totals['upd']} updated")

src/country_workspace/admin/program.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def _action(request: HttpRequest) -> HttpResponse:
7676

7777
@button()
7878
def sync(self, request: HttpRequest) -> None:
79-
from country_workspace.contrib.hope.sync.office import sync_programs
79+
from country_workspace.contrib.hope.sync.data import SyncStep, sync_hope_data
8080

81-
totals = sync_programs()
82-
self.message_user(request, f"{totals['add']} created - {totals['upd']} updated - {totals['skip']} skipped")
81+
totals = sync_hope_data(step=SyncStep.PROGRAMS)["programs"]
82+
self.message_user(request, f"{totals['add']} created - {totals['upd']} updated")

src/country_workspace/contrib/aurora/sync.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from country_workspace.contrib.aurora.client import AuroraClient
77
from country_workspace.contrib.aurora.models import Project, Registration
8-
from country_workspace.contrib.hope.sync.office import sync_programs
8+
from country_workspace.contrib.hope.sync.data import SyncStep, sync_hope_data
99
from country_workspace.models import AsyncJob, SyncLog
1010

1111

@@ -23,7 +23,7 @@ def sync_all(job: AsyncJob) -> dict[str, Any]:
2323
client = AuroraClient()
2424
with cache.lock("sync-aurora"):
2525
return {
26-
"programs": sync_programs(),
26+
"programs": sync_hope_data(step=SyncStep.PROGRAMS),
2727
"projects": sync_projects(client),
2828
"registrations": sync_registrations(client),
2929
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
from io import TextIOBase
2+
from collections.abc import Generator, Callable
3+
from typing import Any, Final
4+
from dataclasses import dataclass, field
5+
from enum import Enum, auto
6+
7+
from django.core.cache import cache
8+
from django.db import DatabaseError
9+
from django.db.models import Q
10+
from hope_flex_fields.models import DataChecker
11+
from country_workspace.exceptions import RemoteError
12+
13+
from country_workspace.models import BeneficiaryGroup, Office, Program, SyncLog
14+
15+
from .. import constants
16+
from ..client import HopeClient
17+
18+
19+
MESSAGES: Final[dict[str, str]] = {
20+
"DEACTIVATION_FAILURE": "Failed during deactivation: {error}",
21+
"RECORDS_DEACTIVATED": "Deactivated '{count}' records (obsolete or marked inactive in source).",
22+
"RECORD_MISSING_ID": "Skipping record due to missing 'id': {record}",
23+
"RECORD_SYNC_FAILURE": "Failed to sync DB record '{hope_id}': {error}",
24+
"REMOTE_API_FAILURE": "API Error fetching '{path}': {error}",
25+
"SYNC_COMPLETE": "Sync complete for {entity} with result {result}.",
26+
"SYNC_START": "Start fetching '{entity}' data from HOPE...",
27+
}
28+
29+
30+
class LogLevel(Enum):
31+
INFO = auto()
32+
ERROR = auto()
33+
34+
35+
@dataclass
36+
class BaseSync:
37+
client: HopeClient = field(default_factory=HopeClient)
38+
stdout: TextIOBase | None = None
39+
total: dict[str, dict[str, int] | list[str]] = field(default_factory=dict)
40+
41+
def safe_get(self, path: str) -> Generator[dict[str, Any], None, None]:
42+
try:
43+
yield from self.client.get(path)
44+
except RemoteError as e:
45+
self.emit_log("REMOTE_API_FAILURE", LogLevel.ERROR, path=path, error=e)
46+
47+
def emit_log(self, key: str, tag: LogLevel = LogLevel.INFO, **kwargs: Any) -> None:
48+
"""Emit a log message with the specified key and tag."""
49+
template = MESSAGES.get(key)
50+
if template is None:
51+
raise KeyError(f"Log key '{key}' not found in MESSAGES configuration.")
52+
try:
53+
msg = template.format(**kwargs)
54+
except KeyError as e:
55+
raise ValueError(
56+
f"Log format error for key '{key}': incorrect arguments provided. "
57+
f"Missing placeholder: {e}. Provided args: {kwargs}"
58+
)
59+
60+
if self.stdout:
61+
self.stdout.write(f"[{tag.name}] {msg}\n")
62+
self.stdout.flush()
63+
if tag == LogLevel.ERROR:
64+
self.total.setdefault("errors", []).append(msg)
65+
66+
67+
@dataclass
68+
class SyncAll(BaseSync):
69+
programs_limit_to_office: Office | None = None
70+
total: dict[str, dict[str, int] | list[str]] = field(
71+
default_factory=lambda: {
72+
**{_: {"add": 0, "upd": 0} for _ in ("offices", "programs", "beneficiary_groups")},
73+
"errors": [],
74+
}
75+
)
76+
default_checkers: dict[str, DataChecker] = field(
77+
default_factory=lambda: {
78+
"hh": DataChecker.objects.filter(name=constants.HOUSEHOLD_CHECKER_NAME).first(),
79+
"ind": DataChecker.objects.filter(name=constants.INDIVIDUAL_CHECKER_NAME).first(),
80+
"ppl": DataChecker.objects.filter(name=constants.PEOPLE_CHECKER_NAME).first(),
81+
}
82+
)
83+
84+
def sync_offices(self) -> None:
85+
with cache.lock("sync-offices"):
86+
self.emit_log("SYNC_START", entity="Office")
87+
all_processed_hope_ids, inactive_hope_ids_from_source = set(), set()
88+
for record in self.safe_get("business_areas"):
89+
if not (hope_id := record.get("id")):
90+
self.emit_log("RECORD_MISSING_ID", LogLevel.ERROR, record=record)
91+
continue
92+
all_processed_hope_ids.add(hope_id)
93+
if record.get("active"):
94+
try:
95+
__, created = Office.objects.update_or_create(
96+
hope_id=hope_id,
97+
defaults={k: record.get(k) for k in ("name", "slug", "code", "long_name", "active")},
98+
)
99+
self.total["offices"]["add" if created else "upd"] += 1
100+
except DatabaseError as e:
101+
self.emit_log("RECORD_SYNC_FAILURE", LogLevel.ERROR, hope_id=hope_id, error=e)
102+
else:
103+
inactive_hope_ids_from_source.add(hope_id)
104+
# Deactivate records in the database that are not present in the source or are inactive.
105+
try:
106+
deactivated_count = Office.objects.filter(
107+
Q(active=True)
108+
& (~Q(hope_id__in=all_processed_hope_ids) | Q(hope_id__in=inactive_hope_ids_from_source))
109+
).update(active=False)
110+
if deactivated_count > 0:
111+
self.total["offices"]["deactivated"] = deactivated_count
112+
self.emit_log("RECORDS_DEACTIVATED", count=deactivated_count)
113+
except DatabaseError as e:
114+
self.emit_log("DEACTIVATION_FAILURE", LogLevel.ERROR, error=e)
115+
116+
SyncLog.objects.register_sync(Office)
117+
self.emit_log("SYNC_COMPLETE", entity="Office", result=self.total["offices"])
118+
119+
def sync_beneficiary_groups(self) -> None:
120+
with cache.lock("sync-beneficiary_groups"):
121+
self.emit_log("SYNC_START", entity="Beneficiary Group")
122+
for record in self.safe_get("beneficiary-groups"):
123+
if not (hope_id := record.get("id")):
124+
self.emit_log("RECORD_MISSING_ID", LogLevel.ERROR, record=record)
125+
continue
126+
try:
127+
__, created = BeneficiaryGroup.objects.update_or_create(
128+
hope_id=hope_id,
129+
defaults={
130+
k: record.get(k)
131+
for k in (
132+
"name",
133+
"group_label",
134+
"group_label_plural",
135+
"member_label",
136+
"member_label_plural",
137+
"master_detail",
138+
)
139+
},
140+
)
141+
self.total["beneficiary_groups"]["add" if created else "upd"] += 1
142+
except DatabaseError as e:
143+
self.emit_log("RECORD_SYNC_FAILURE", LogLevel.ERROR, hope_id=hope_id, error=e)
144+
145+
SyncLog.objects.register_sync(BeneficiaryGroup)
146+
self.emit_log("SYNC_COMPLETE", entity="Beneficiary Group", result=self.total["beneficiary_groups"])
147+
148+
def sync_programs(self) -> None:
149+
def is_valid() -> bool:
150+
if not hope_id:
151+
self.emit_log("RECORD_MISSING_ID", LogLevel.ERROR, record=record)
152+
return False
153+
return record["status"] in [Program.ACTIVE, Program.DRAFT]
154+
155+
self.sync_beneficiary_groups()
156+
with cache.lock("sync-programs"):
157+
self.emit_log("SYNC_START", entity="Program")
158+
office = self.programs_limit_to_office if self.programs_limit_to_office else None
159+
for record in self.safe_get("programs"):
160+
try:
161+
if (hope_id := record.get("id")) and is_valid():
162+
office = Office.objects.get(code=record["business_area_code"])
163+
bg = BeneficiaryGroup.objects.get(hope_id=record["beneficiary_group"])
164+
p, created = Program.objects.update_or_create(
165+
hope_id=hope_id,
166+
defaults={
167+
"name": record["name"],
168+
"code": record["programme_code"],
169+
"status": record["status"],
170+
"sector": record["sector"],
171+
"country_office": office,
172+
"beneficiary_group": bg,
173+
},
174+
)
175+
if created:
176+
p.household_checker = self.default_checkers["hh"]
177+
p.individual_checker = (
178+
self.default_checkers["ind"] if bg.master_detail else self.default_checkers["ppl"]
179+
)
180+
p.save(update_fields=("household_checker", "individual_checker"))
181+
self.total["programs"]["add" if created else "upd"] += 1
182+
except Office.DoesNotExist:
183+
continue
184+
except BeneficiaryGroup.DoesNotExist as e:
185+
self.emit_log(
186+
"RECORD_SYNC_FAILURE", LogLevel.ERROR, hope_id=hope_id, error=f"BeneficiaryGroup not found: {e}"
187+
)
188+
except DatabaseError as e:
189+
self.emit_log("RECORD_SYNC_FAILURE", LogLevel.ERROR, hope_id=hope_id, error=e)
190+
191+
SyncLog.objects.register_sync(Program)
192+
self.emit_log("SYNC_COMPLETE", entity="Program", result=self.total["programs"])
193+
194+
195+
class SyncStep(Enum):
196+
OFFICES = auto()
197+
PROGRAMS = auto()
198+
199+
@property
200+
def func(self) -> Callable[[SyncAll], None]:
201+
func_map = {
202+
SyncStep.OFFICES: SyncAll.sync_offices,
203+
SyncStep.PROGRAMS: SyncAll.sync_programs,
204+
}
205+
return func_map[self]
206+
207+
208+
def sync_hope_data(
209+
step: SyncStep | None = None, programs_limit_to_office: Office | None = None, stdout: TextIOBase | None = None
210+
) -> dict[str, Any]:
211+
sync = SyncAll(stdout=stdout)
212+
steps = tuple(SyncStep) if step is None else (step,)
213+
214+
for current_step in steps:
215+
current_step.func(sync)
216+
if sync.total["errors"]:
217+
SyncLog.objects.refresh()
218+
return sync.total
219+
SyncLog.objects.refresh()
220+
return sync.total

src/country_workspace/contrib/hope/sync/office.py

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

0 commit comments

Comments
 (0)