3
3
from contextlib import contextmanager
4
4
from enum import Enum
5
5
from io import TextIOBase
6
- from typing import Any , Final , TypedDict , NotRequired , TextIO , Literal
6
+ from typing import Any , Final , TypedDict , NotRequired , Literal
7
7
8
8
from django .contrib .contenttypes .models import ContentType
9
9
from django .core .cache import cache
10
10
from django .db import DatabaseError
11
11
from django .db .models import Model
12
12
13
13
from country_workspace .exceptions import RemoteError
14
+ from country_workspace .models import SyncLog
14
15
from ..client import HopeClient
15
- from .... models import SyncLog
16
+ from ..exceptions import HopeSyncError , SkipRecordError
16
17
17
18
logging .basicConfig ()
18
19
22
23
"RECORD_SKIPPED" : "Skipped record '{reference_id_val}': {error}" ,
23
24
"RECORD_SYNC_FAILURE" : "Failed to sync DB record '{reference_id_val}': {error}" ,
24
25
"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 ." ,
26
27
"SYNC_START" : "Start fetching '{entity}' data from HOPE core..." ,
27
28
}
28
29
29
30
30
- class SkipRecordError (Exception ):
31
- """Exception raised when a record should be skipped during synchronization."""
32
-
33
-
34
31
class ParamDateName (Enum ):
35
32
"""Parameter names for date filtering in API requests."""
36
33
@@ -48,12 +45,12 @@ class SyncConfig[T: Model](TypedDict):
48
45
49
46
Attributes:
50
47
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 .
52
49
endpoint: The API endpoint configuration with path and optional query parameters.
53
50
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 .
57
54
58
55
"""
59
56
@@ -79,6 +76,7 @@ def log_to(
79
76
level : Literal [0 , 10 , 20 , 30 , 40 , 50 , 60 ] = logging .INFO ,
80
77
log_format : str = "%(levelname)s %(message)s" ,
81
78
) -> Iterator [None ]:
79
+ """Temporarily redirect logs of `logger_name` to a stream."""
82
80
logger = logging .getLogger (logger_name )
83
81
84
82
handlers_backup = tuple (logger .handlers )
@@ -108,13 +106,14 @@ def add_error(stats: Stats, error: str) -> None:
108
106
109
107
110
108
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 ."""
112
110
try :
113
111
yield from client .get (** endpoint )
114
112
except RemoteError as e :
115
113
error = format_msg ("REMOTE_API_FAILURE" , path = endpoint .get ("path" ), error = str (e ))
116
114
add_error (stats , error )
117
115
logging .error (error )
116
+ raise HopeSyncError (error ) from e
118
117
119
118
120
119
def format_msg (key : str , ** kwargs : Any ) -> str :
@@ -127,26 +126,27 @@ def format_msg(key: str, **kwargs: Any) -> str:
127
126
raise KeyError (f"Log key '{ key } ' not found in MESSAGES configuration." )
128
127
129
128
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 ."""
132
131
reference_id_val = record .get ("id" )
133
132
if not reference_id_val :
134
133
logging .warning (format_msg ("RECORD_MISSING_REFERENCE_ID" , record = record ))
135
134
return reference_id_val
136
135
137
136
138
137
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` .
140
139
141
140
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.
146
144
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.
150
150
151
151
"""
152
152
should_process = config .get ("should_process" )
@@ -161,7 +161,7 @@ def sync_entity[T: Model](config: SyncConfig[T], client: HopeClient | None = Non
161
161
with cache .lock (f"sync-{ model_name } " ):
162
162
logging .info (format_msg ("SYNC_START" , entity = model_name ))
163
163
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 )):
165
165
continue
166
166
if should_process and not should_process (record ):
167
167
continue
@@ -181,21 +181,20 @@ def sync_entity[T: Model](config: SyncConfig[T], client: HopeClient | None = Non
181
181
error = format_msg ("RECORD_SYNC_FAILURE" , reference_id_val = reference_id_val , error = str (e ))
182
182
add_error (stats , error )
183
183
logging .error (error )
184
+ if stats ["errors" ]:
185
+ raise HopeSyncError (stats ["errors" ])
184
186
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 ))
187
188
return stats
188
189
189
190
190
191
def _get_last_updated_date (model : type [Model ]) -> str | None :
191
- """Get the last update date for the given model."""
192
192
ct = ContentType .objects .get_for_model (model )
193
193
last_sync = SyncLog .objects .filter (content_type = ct ).order_by ("-last_update_date" ).first ()
194
194
return last_sync .last_update_date .date ().isoformat () if last_sync else None
195
195
196
196
197
197
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."""
199
198
params = {"format" : "json" }
200
199
if delta_sync and (last_date := _get_last_updated_date (model )):
201
200
return EndpointConfig (path = path , params = {param_date_name .value : last_date , ** params })
0 commit comments