Skip to content

Commit 7870116

Browse files
add ! sync tasks must return an error if an exception is raised
1 parent cb69ab8 commit 7870116

File tree

6 files changed

+54
-60
lines changed

6 files changed

+54
-60
lines changed

src/country_workspace/admin/office.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class OfficeAdmin(SyncAdminMixin, BaseModelAdmin):
1818
sync_config = SyncAdminConfig(
1919
targets=[
2020
TargetConfig(target=Target.OFFICES),
21+
TargetConfig(target=Target.BENEFICIARY_GROUPS),
2122
TargetConfig(target=Target.PROGRAMS),
2223
],
2324
)
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
11
class HopePushError(Exception):
2-
pass
2+
"""Exception raised for errors during the push process."""
3+
4+
5+
class HopeSyncError(Exception):
6+
"""Exception raised for errors during the synchronization process."""
7+
8+
9+
class SkipRecordError(Exception):
10+
"""Exception raised when a record should be skipped during synchronization process."""

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

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
from contextlib import contextmanager
44
from enum import Enum
55
from io import TextIOBase
6-
from typing import Any, Final, TypedDict, NotRequired, TextIO, Literal
6+
from typing import Any, Final, TypedDict, NotRequired, Literal
77

88
from django.contrib.contenttypes.models import ContentType
99
from django.core.cache import cache
1010
from django.db import DatabaseError
1111
from django.db.models import Model
1212

1313
from country_workspace.exceptions import RemoteError
14+
from country_workspace.models import SyncLog
1415
from ..client import HopeClient
15-
from ....models import SyncLog
16+
from ..exceptions import HopeSyncError, SkipRecordError
1617

1718
logging.basicConfig()
1819

@@ -22,15 +23,11 @@
2223
"RECORD_SKIPPED": "Skipped record '{reference_id_val}': {error}",
2324
"RECORD_SYNC_FAILURE": "Failed to sync DB record '{reference_id_val}': {error}",
2425
"REMOTE_API_FAILURE": "API Error fetching '{path}': {error}",
25-
"SYNC_COMPLETE": "Sync complete for '{entity}' with result {result} with '{errors_count}' erors.",
26+
"SYNC_COMPLETE": "Sync complete for '{entity}' with result {result} with '{errors_count}' errors.",
2627
"SYNC_START": "Start fetching '{entity}' data from HOPE core...",
2728
}
2829

2930

30-
class SkipRecordError(Exception):
31-
"""Exception raised when a record should be skipped during synchronization."""
32-
33-
3431
class ParamDateName(Enum):
3532
"""Parameter names for date filtering in API requests."""
3633

@@ -48,12 +45,12 @@ class SyncConfig[T: Model](TypedDict):
4845
4946
Attributes:
5047
model: The Django model class to synchronize.
51-
delta_sync: If True, only new records will be processed; otherwise, existing records will be updated.
48+
delta_sync: If True, the API request is constrained by a date param (see build_endpoint); otherwise full fetch.
5249
endpoint: The API endpoint configuration with path and optional query parameters.
5350
reference_id: The field name used as the system reference ID for the model.
54-
prepare_defaults: Function to prepare default values for the model.
55-
should_process: Optional function to filter records before processing.
56-
post_process: Optional function to process the model instance after creation/update.
51+
prepare_defaults: Function to map an API record into model defaults (required).
52+
should_process: Optional record-level filter predicate (truthy -> process).
53+
post_process: Optional hook after create/update; may raise to fail the sync.
5754
5855
"""
5956

@@ -79,6 +76,7 @@ def log_to(
7976
level: Literal[0, 10, 20, 30, 40, 50, 60] = logging.INFO,
8077
log_format: str = "%(levelname)s %(message)s",
8178
) -> Iterator[None]:
79+
"""Temporarily redirect logs of `logger_name` to a stream."""
8280
logger = logging.getLogger(logger_name)
8381

8482
handlers_backup = tuple(logger.handlers)
@@ -108,13 +106,14 @@ def add_error(stats: Stats, error: str) -> None:
108106

109107

110108
def safe_get(client: HopeClient, endpoint: EndpointConfig, stats: Stats) -> Generator[dict[str, Any], None, None]:
111-
"""Fetch data from the remote API safely, handling errors."""
109+
"""Yield records from the remote API, converting RemoteError -> HopeSyncError."""
112110
try:
113111
yield from client.get(**endpoint)
114112
except RemoteError as e:
115113
error = format_msg("REMOTE_API_FAILURE", path=endpoint.get("path"), error=str(e))
116114
add_error(stats, error)
117115
logging.error(error)
116+
raise HopeSyncError(error) from e
118117

119118

120119
def format_msg(key: str, **kwargs: Any) -> str:
@@ -127,26 +126,27 @@ def format_msg(key: str, **kwargs: Any) -> str:
127126
raise KeyError(f"Log key '{key}' not found in MESSAGES configuration.")
128127

129128

130-
def validated_reference_id(record: dict[str, Any], out: TextIO) -> str | None:
131-
"""Validate and retrieve the system reference ID from the record."""
129+
def validated_reference_id(record: dict[str, Any]) -> str | None:
130+
"""Return record['id'] if present; warn and return None otherwise."""
132131
reference_id_val = record.get("id")
133132
if not reference_id_val:
134133
logging.warning(format_msg("RECORD_MISSING_REFERENCE_ID", record=record))
135134
return reference_id_val
136135

137136

138137
def sync_entity[T: Model](config: SyncConfig[T], client: HopeClient | None = None, stats: Stats | None = None) -> Stats:
139-
"""Synchronize an entity with the remote API.
138+
"""Synchronize a single model using the provided `SyncConfig`.
140139
141140
Args:
142-
config (SyncConfig): Configuration for the entity synchronization.
143-
out (TextIOBase): Output file to write to.
144-
client (HopeClient): HopeClient to use for synchronization.
145-
stats (dict[str, Any]): Synchronization results.
141+
config: Sync configuration (model, endpoint, mappers/hooks).
142+
client: Optional `HopeClient`, created by default.
143+
stats: Optional running stats; a new one is created if not provided.
146144
147-
Notes:
148-
- Fetches records from the API, processes them, and updates/creates model instances.
149-
- Logs synchronization start, errors, and completion.
145+
Returns:
146+
Stats: counts of added/updated records; empty `errors` on success.
147+
148+
Raises:
149+
HopeSyncError: if remote API fails or any record-level errors were collected.
150150
151151
"""
152152
should_process = config.get("should_process")
@@ -161,7 +161,7 @@ def sync_entity[T: Model](config: SyncConfig[T], client: HopeClient | None = Non
161161
with cache.lock(f"sync-{model_name}"):
162162
logging.info(format_msg("SYNC_START", entity=model_name))
163163
for record in safe_get(client, config["endpoint"], stats):
164-
if not (reference_id_val := validated_reference_id(record, stats)):
164+
if not (reference_id_val := validated_reference_id(record)):
165165
continue
166166
if should_process and not should_process(record):
167167
continue
@@ -181,21 +181,20 @@ def sync_entity[T: Model](config: SyncConfig[T], client: HopeClient | None = Non
181181
error = format_msg("RECORD_SYNC_FAILURE", reference_id_val=reference_id_val, error=str(e))
182182
add_error(stats, error)
183183
logging.error(error)
184+
if stats["errors"]:
185+
raise HopeSyncError(stats["errors"])
184186
SyncLog.objects.register_sync(model)
185-
logging.info(format_msg("SYNC_COMPLETE", entity=model_name, result=stats, errors_count=len(stats["errors"])))
186-
187+
logging.info(format_msg("SYNC_COMPLETE", entity=model_name, result=stats, errors_count=0))
187188
return stats
188189

189190

190191
def _get_last_updated_date(model: type[Model]) -> str | None:
191-
"""Get the last update date for the given model."""
192192
ct = ContentType.objects.get_for_model(model)
193193
last_sync = SyncLog.objects.filter(content_type=ct).order_by("-last_update_date").first()
194194
return last_sync.last_update_date.date().isoformat() if last_sync else None
195195

196196

197197
def build_endpoint(path: str, model: type[Model], param_date_name: ParamDateName, delta_sync: bool) -> EndpointConfig:
198-
"""Build the endpoint configuration for the API request."""
199198
params = {"format": "json"}
200199
if delta_sync and (last_date := _get_last_updated_date(model)):
201200
return EndpointConfig(path=path, params={param_date_name.value: last_date, **params})

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

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020

2121

2222
def sync_countries(delta_sync: bool = False) -> Stats:
23-
"""Fetch and process Country records from the remote API."""
2423
return sync_entity(
2524
SyncConfig(
2625
model=Country,
@@ -33,13 +32,6 @@ def sync_countries(delta_sync: bool = False) -> Stats:
3332

3433

3534
def sync_area_types(delta_sync: bool = False) -> Stats:
36-
"""Fetch and process AreaType records from the remote API.
37-
38-
Notes:
39-
Calls sync_countries first to ensure dependencies are synchronized.
40-
41-
"""
42-
4335
def _prepare_defaults(rec: dict[str, Any]) -> dict[str, Any] | None:
4436
try:
4537
country = Country.objects.get(hope_id=rec["country"])
@@ -73,13 +65,6 @@ def _prepare_defaults(rec: dict[str, Any]) -> dict[str, Any] | None:
7365

7466

7567
def sync_areas(delta_sync: bool = False) -> Stats:
76-
"""Fetch and process Area records from the remote API.
77-
78-
Notes:
79-
Calls sync_area_types first to ensure dependencies are synchronized.
80-
81-
"""
82-
8368
def _prepare_defaults(rec: dict[str, Any]) -> dict[str, Any] | None:
8469
try:
8570
area_type = AreaType.objects.get(hope_id=rec["area_type"])
@@ -113,7 +98,7 @@ def _prepare_defaults(rec: dict[str, Any]) -> dict[str, Any] | None:
11398

11499

115100
def _assign_parents(model: type[Model], parent_mapping: dict[str, str]) -> None:
116-
"""Assign parent relationships for the given model based on the parent mapping."""
101+
"""Bulk-assign parents from mapping."""
117102
updates = []
118103
for child_id, parent_id in parent_mapping.items():
119104
try:

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ def should_process_office(record: dict[str, Any]) -> bool:
6363

6464

6565
def sync_offices(delta_sync: bool = False) -> Stats:
66-
"""Fetch and process Office records from the remote API, deactivating those not present in the source."""
6766
return sync_entity(
6867
SyncConfig[Office](
6968
model=Office,
@@ -77,7 +76,6 @@ def sync_offices(delta_sync: bool = False) -> Stats:
7776

7877

7978
def sync_beneficiary_groups(delta_sync: bool = False) -> Stats:
80-
"""Fetch and process BeneficiaryGroup records from the remote API."""
8179
return sync_entity(
8280
SyncConfig[BeneficiaryGroup](
8381
model=BeneficiaryGroup,
@@ -131,12 +129,6 @@ def post_process_program(program: Program, created: bool) -> None:
131129

132130

133131
def sync_programs(delta_sync: bool = False, programs_limit_to_office: Office | None = None) -> Stats:
134-
"""Synchronize and process Program records from the remote API, applying filters and post-processing.
135-
136-
Notes:
137-
Calls sync_beneficiary_groups to ensure dependencies are synchronized.
138-
139-
"""
140132
return sync_entity(
141133
SyncConfig[Program](
142134
model=Program,

tests/contrib/hope/test_sync_base.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
from typing import Callable
1+
from collections.abc import Callable
22
from unittest.mock import Mock
33
import pytest
44
from django.db import DatabaseError
55
from pytest_mock import MockerFixture
66

77
from country_workspace.contrib.hope.client import HopeClient
8+
from country_workspace.contrib.hope.exceptions import HopeSyncError, SkipRecordError
89
from country_workspace.contrib.hope.sync.base import (
9-
SkipRecordError,
1010
SyncConfig,
1111
EndpointConfig,
1212
Stats,
@@ -39,8 +39,9 @@ def test_safe_get_errors(hope_client: HopeClient, exception: Exception, expected
3939
hope_client.get.side_effect = exception
4040
stats = Stats(add=0, upd=0, errors=[])
4141
with log_to(out := Mock()):
42-
results = list(safe_get(hope_client, EndpointConfig(path="dummy_path"), stats))
43-
assert results == []
42+
with pytest.raises(HopeSyncError) as exc:
43+
list(safe_get(hope_client, EndpointConfig(path="dummy_path"), stats))
44+
assert expected_error in str(exc.value)
4445
assert_stdout_contains(out, expected_error)
4546
assert any(expected_error in e for e in stats["errors"])
4647

@@ -54,10 +55,12 @@ def test_safe_get_errors(hope_client: HopeClient, exception: Exception, expected
5455
ids=["valid_id", "missing_id"],
5556
)
5657
def test_validated_reference_id(record: dict, expected_id: str | None, stdout_contains: str | None) -> None:
57-
with log_to(out := Mock()):
58-
assert validated_reference_id(record, out) == expected_id
5958
if stdout_contains:
59+
with log_to(out := Mock()):
60+
assert validated_reference_id(record) == expected_id
6061
assert_stdout_contains(out, stdout_contains)
62+
else:
63+
assert validated_reference_id(record) == expected_id
6164

6265

6366
def test_sync_entity_success(
@@ -121,8 +124,14 @@ def test_sync_entity_errors(
121124
expected_errors: list[str] | None,
122125
) -> None:
123126
mock_model.objects.update_or_create.side_effect = exception
124-
stats = sync_entity_context([records[0]], success_config)
125-
assert stats == {"add": 0, "upd": 0, "errors": expected_errors}
127+
if expected_errors:
128+
with pytest.raises(HopeSyncError) as exc:
129+
sync_entity_context([records[0]], success_config)
130+
for e in expected_errors:
131+
assert e in str(exc.value)
132+
else:
133+
stats = sync_entity_context([records[0]], success_config)
134+
assert stats == {"add": 0, "upd": 0, "errors": expected_errors}
126135
assert_stdout_contains(out, expected_log)
127136

128137

0 commit comments

Comments
 (0)