Skip to content

Commit 89532b7

Browse files
chg ! sync models with HOPE core
1 parent 8fe71e7 commit 89532b7

File tree

15 files changed

+865
-158
lines changed

15 files changed

+865
-158
lines changed

src/country_workspace/admin/office.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from admin_extra_buttons.buttons import LinkButton
22
from admin_extra_buttons.decorators import button, link
3-
from django.contrib import admin
3+
from django.contrib import admin, messages
44
from django.http import HttpRequest
55
from django.urls import reverse
66

@@ -24,7 +24,12 @@ 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.context_programs import SyncStep, sync_context_programs
2828

29-
totals = sync_offices()
30-
self.message_user(request, f"{totals['add']} created - {totals['upd']} updated")
29+
totals = sync_context_programs(step=SyncStep.OFFICES)
30+
31+
if errors := totals.get("errors"):
32+
self.message_user(request, "; ".join(errors), level=messages.ERROR)
33+
else:
34+
info = totals[Office._meta.model_name]
35+
self.message_user(request, f"{info['add']} created - {info['upd']} updated", level=messages.SUCCESS)

src/country_workspace/admin/program.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from admin_extra_buttons.api import button, link
44
from adminfilters.autocomplete import AutoCompleteFilter
5-
from django.contrib import admin
5+
from django.contrib import admin, messages
66
from django.http import HttpRequest, HttpResponse
77
from django.urls import reverse
88

@@ -76,7 +76,12 @@ 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.context_programs import SyncStep, sync_context_programs
8080

81-
totals = sync_programs()
82-
self.message_user(request, f"{totals['add']} created - {totals['upd']} updated - {totals['skip']} skipped")
81+
totals = sync_context_programs(step=SyncStep.PROGRAMS)
82+
83+
if errors := totals.get("errors"):
84+
self.message_user(request, "; ".join(errors), level=messages.ERROR)
85+
else:
86+
info = totals[Program._meta.model_name]
87+
self.message_user(request, f"{info['add']} created - {info['upd']} updated", level=messages.SUCCESS)

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.context_programs import SyncStep, sync_context_programs
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_context_programs(step=SyncStep.PROGRAMS),
2727
"projects": sync_projects(client),
2828
"registrations": sync_registrations(client),
2929
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
from typing import Any, Final, TypedDict, NotRequired, TypeVar
2+
from collections.abc import Generator, Callable
3+
from io import TextIOBase
4+
5+
from dataclasses import dataclass, field
6+
from enum import Enum, auto
7+
8+
from django.core.cache import cache
9+
from django.db import DatabaseError
10+
from django.db.models import Model, Q
11+
from country_workspace.exceptions import RemoteError
12+
13+
from ....models import SyncLog
14+
from ..client import HopeClient
15+
16+
17+
T = TypeVar("T", bound="BaseSync")
18+
19+
20+
MESSAGES: Final[dict[str, str]] = {
21+
"DEACTIVATION_FAILURE": "Failed during deactivation for '{entity}' : {error}",
22+
"RECORDS_DEACTIVATED": "Deactivated '{count}' records '{entity}' (obsolete or marked inactive in source).",
23+
"RECORD_MISSING_ID": "Skipping record due to missing 'id': {record}",
24+
"RECORD_SKIPPED": "Skipped record '{hope_id}': {error}",
25+
"RECORD_SYNC_FAILURE": "Failed to sync DB record '{hope_id}': {error}",
26+
"REMOTE_API_FAILURE": "API Error fetching '{path}': {error}",
27+
"SYNC_COMPLETE": "Sync complete for '{entity}' with result {result} with '{errors_count}' erors.",
28+
"SYNC_START": "Start fetching '{entity}' data from HOPE core...",
29+
}
30+
31+
32+
class SkipRecordError(Exception):
33+
"""Exception raised when a record should be skipped during synchronization."""
34+
35+
36+
class LogLevel(Enum):
37+
"""Log levels for synchronization messages."""
38+
39+
INFO = auto()
40+
ERROR = auto()
41+
42+
43+
class SyncConfig(TypedDict):
44+
"""Configuration for synchronizing an entity.
45+
46+
Attributes:
47+
model: The Django model class to synchronize.
48+
path: The API path to fetch data from.
49+
prepare_defaults: Function to prepare default values for the model.
50+
should_process: Optional function to filter records before processing.
51+
post_process: Optional function to process the model instance after creation/update.
52+
should_deactivate: Optional function to determine if a record should be deactivated.
53+
54+
"""
55+
56+
model: type[Model]
57+
path: str
58+
should_process: NotRequired[Callable[[dict[str, Any]], bool] | None]
59+
prepare_defaults: Callable[[dict[str, Any]], dict[str, Any] | None]
60+
post_process: NotRequired[Callable[[Model, bool], None] | None]
61+
should_deactivate: NotRequired[Callable[["dict[str, Any]"], bool] | None]
62+
63+
64+
class BaseSyncStep(Enum):
65+
"""Base class for synchronization steps.
66+
67+
Attributes:
68+
value: The enumeration value.
69+
sync_method: The method to execute for this step.
70+
71+
"""
72+
73+
def __init__(self, value: Any, sync_method: Callable[["BaseSync"], None]) -> None:
74+
self._value_ = value
75+
self._sync_method = sync_method
76+
77+
@property
78+
def func(self) -> Callable[["BaseSync"], None]:
79+
return self._sync_method
80+
81+
82+
@dataclass
83+
class BaseSync:
84+
"""Base class for synchronization operations.
85+
86+
Attributes:
87+
client: The client for fetching data from the remote API.
88+
stdout: Optional output stream for logging messages.
89+
total: Accumulated results of synchronization (e.g., counts of added/updated records).
90+
91+
"""
92+
93+
client: HopeClient = field(default_factory=HopeClient)
94+
stdout: TextIOBase | None = None
95+
total: dict[str, Any] = field(default_factory=dict)
96+
97+
def safe_get(self, path: str) -> Generator[dict[str, Any], None, None]:
98+
"""Fetch data from the remote API safely, handling errors."""
99+
try:
100+
yield from self.client.get(path)
101+
except RemoteError as e:
102+
self.emit_log("REMOTE_API_FAILURE", LogLevel.ERROR, path=path, error=e)
103+
104+
def emit_log(self, key: str, level: LogLevel = LogLevel.INFO, **kwargs: Any) -> None:
105+
"""Emit a log message with the specified key and level."""
106+
if template := MESSAGES.get(key):
107+
try:
108+
msg = template.format(**kwargs).rstrip("\n")
109+
except KeyError as e:
110+
raise ValueError(
111+
f"Log format error for key '{key}': missing placeholder '{e}'. Provided args: {kwargs}"
112+
)
113+
if self.stdout:
114+
self.stdout.write(f"[{level.name}] {msg}\n")
115+
self.stdout.flush()
116+
if level == LogLevel.ERROR:
117+
self.total.setdefault("errors", []).append(msg)
118+
else:
119+
raise KeyError(f"Log key '{key}' not found in MESSAGES configuration.")
120+
121+
def validated_hope_id(self, record: dict[str, Any]) -> str | None:
122+
"""Validate and retrieve the HOPE core ID from the record."""
123+
hope_id = record.get("id")
124+
if not hope_id:
125+
self.emit_log("RECORD_MISSING_ID", record=record)
126+
return hope_id
127+
128+
def sync_entity(self, config: SyncConfig) -> None:
129+
"""Synchronize an entity with the remote API.
130+
131+
Args:
132+
config (SyncConfig): Configuration for the entity synchronization.
133+
134+
Notes:
135+
- Fetches records from the API, processes them, and updates/creates model instances.
136+
- Logs synchronization start, errors, and completion.
137+
- Deactivates records based on the should_deactivate function, if specified.
138+
139+
"""
140+
model, model_name = config["model"], config["model"]._meta.model_name
141+
self.total.setdefault(model_name, {"add": 0, "upd": 0})
142+
should_process, prepare_defaults, post_process, should_deactivate = (
143+
config.get(k, v)
144+
for k, v in (
145+
("should_process", None),
146+
("prepare_defaults", lambda _: {}),
147+
("post_process", None),
148+
("should_deactivate", None),
149+
)
150+
)
151+
152+
with cache.lock(f"sync-{model_name.lower()}"):
153+
self.emit_log("SYNC_START", entity=model_name)
154+
all_processed, inactive = set(), set()
155+
for record in self.safe_get(config["path"]):
156+
if not (hope_id := self.validated_hope_id(record)):
157+
continue
158+
all_processed.add(hope_id)
159+
if should_deactivate and should_deactivate(record):
160+
inactive.add(hope_id)
161+
continue
162+
if should_process and not should_process(record):
163+
continue
164+
try:
165+
defaults = prepare_defaults(record)
166+
if defaults is None or not defaults:
167+
continue
168+
instance, created = model.objects.update_or_create(hope_id=hope_id, defaults=defaults)
169+
if post_process:
170+
post_process(instance, created)
171+
self.total[model_name]["add" if created else "upd"] += 1
172+
except SkipRecordError as e:
173+
self.emit_log("RECORD_SKIPPED", hope_id=hope_id, error=str(e))
174+
except (DatabaseError, KeyError, AttributeError) as e:
175+
self.emit_log("RECORD_SYNC_FAILURE", LogLevel.ERROR, hope_id=hope_id, error=str(e))
176+
if should_deactivate:
177+
self._deactivate_records(model, model_name, all_processed, inactive)
178+
SyncLog.objects.register_sync(model)
179+
self.emit_log(
180+
"SYNC_COMPLETE",
181+
entity=model_name,
182+
result=self.total[model_name],
183+
errors_count=len(self.total.get("errors", [])),
184+
)
185+
186+
def _deactivate_records(self, model: type[Model], model_name: str, processed: set[str], inactive: set[str]) -> None:
187+
"""Deactivate existed records in the database that are inactive or not present in the source."""
188+
self.total.setdefault(model_name, {})
189+
try:
190+
deactivated_count = model.objects.filter(
191+
Q(active=True) & (~Q(hope_id__in=processed) | Q(hope_id__in=inactive))
192+
).update(active=False)
193+
if deactivated_count:
194+
self.total[model_name]["deactivated"] = deactivated_count
195+
self.emit_log("RECORDS_DEACTIVATED", count=deactivated_count, entity=model_name)
196+
except DatabaseError as e:
197+
self.emit_log("DEACTIVATION_FAILURE", LogLevel.ERROR, entity=model_name, error=str(e))
198+
199+
200+
def sync_context(
201+
context_class: type[T],
202+
step: BaseSyncStep | None,
203+
stdout: TextIOBase | None = None,
204+
**context_kwargs: Any,
205+
) -> dict[str, Any]:
206+
"""Run synchronization steps for a given context.
207+
208+
Args:
209+
context_class (type[T]): The synchronization context class (inheriting from BaseSync).
210+
step (BaseSyncStep | None): Specific step to execute. If None, all steps are run.
211+
stdout (TextIOBase | None): Optional output stream for logging.
212+
**context_kwargs (Any): Additional keyword arguments to pass to the context class.
213+
214+
Returns:
215+
dict[str, Any]: Synchronization results, including counts and errors.
216+
217+
Notes:
218+
Executes the specified step or all steps defined in the context's SyncStep and refreshes SyncLog.
219+
Stops on errors.
220+
221+
"""
222+
sync = context_class(stdout=stdout, **context_kwargs)
223+
steps = (step,) if step else tuple(sync.__class__.SyncStep)
224+
for current_step in steps:
225+
current_step.func(sync)()
226+
if sync.total.get("errors"):
227+
return sync.total
228+
return sync.total

0 commit comments

Comments
 (0)