Skip to content

Commit a1718f7

Browse files
Refactor
1 parent 9afb265 commit a1718f7

File tree

18 files changed

+530
-704
lines changed

18 files changed

+530
-704
lines changed

src/country_workspace/admin/locations.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
from django.forms import FileField, FileInput, Form
1111

1212
from country_workspace.models.locations import Area, AreaType, Country
13-
from country_workspace.admin.sync import SyncAdminMixin, SyncAdminConfig, StepConfig
14-
13+
from country_workspace.admin.sync import SyncAdminMixin, SyncAdminConfig, TargetConfig, Target
1514

1615
if TYPE_CHECKING:
1716
from django.http import HttpRequest
@@ -35,10 +34,7 @@ class CountryAdmin(SyncAdminMixin, AdminFiltersMixin, admin.ModelAdmin):
3534
"iso_code2",
3635
"iso_code3",
3736
)
38-
sync_config = SyncAdminConfig(
39-
step_handler=StepConfig(path="country_workspace.contrib.hope.sync.context_geo.SyncStep", name="COUNTRIES"),
40-
sync_handler="country_workspace.contrib.hope.sync.context_geo.sync_context_geo",
41-
)
37+
sync_config = SyncAdminConfig(targets=[TargetConfig(target=Target.COUNTRIES, delta_sync=False)])
4238

4339

4440
@admin.register(AreaType)
@@ -48,10 +44,7 @@ class AreaTypeAdmin(SyncAdminMixin, AdminFiltersMixin, admin.ModelAdmin):
4844
search_fields = ("name",)
4945
autocomplete_fields = ("country",)
5046
raw_id_fields = ("country", "parent")
51-
sync_config = SyncAdminConfig(
52-
step_handler=StepConfig(path="country_workspace.contrib.hope.sync.context_geo.SyncStep", name="AREATYPES"),
53-
sync_handler="country_workspace.contrib.hope.sync.context_geo.sync_context_geo",
54-
)
47+
sync_config = SyncAdminConfig(targets=[TargetConfig(target=Target.AREA_TYPES, delta_sync=False)])
5548

5649

5750
class AreaTypeFilter(RelatedFieldListFilter):
@@ -74,7 +67,4 @@ class AreaAdmin(SyncAdminMixin, AdminFiltersMixin, admin.ModelAdmin):
7467
)
7568
search_fields = ("name", "p_code")
7669
raw_id_fields = ("area_type", "parent")
77-
sync_config = SyncAdminConfig(
78-
step_handler=StepConfig(path="country_workspace.contrib.hope.sync.context_geo.SyncStep", name="AREAS"),
79-
sync_handler="country_workspace.contrib.hope.sync.context_geo.sync_context_geo",
80-
)
70+
sync_config = SyncAdminConfig(targets=[TargetConfig(target=Target.AREAS, delta_sync=False)])

src/country_workspace/admin/office.py

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

66
from country_workspace.models import Office
77
from country_workspace.admin.base import BaseModelAdmin
8-
from country_workspace.admin.sync import SyncAdminMixin, SyncAdminConfig, StepConfig
8+
from country_workspace.admin.sync import SyncAdminMixin, SyncAdminConfig, TargetConfig, Target
99

1010

1111
@admin.register(Office)
@@ -16,8 +16,10 @@ class OfficeAdmin(SyncAdminMixin, BaseModelAdmin):
1616
readonly_fields = ("hope_id", "slug")
1717
ordering = ("name",)
1818
sync_config = SyncAdminConfig(
19-
step_handler=StepConfig(path="country_workspace.contrib.hope.sync.context_programs.SyncStep", name="OFFICES"),
20-
sync_handler="country_workspace.contrib.hope.sync.context_programs.sync_context_programs",
19+
targets=[
20+
TargetConfig(target=Target.OFFICES, delta_sync=False),
21+
TargetConfig(target=Target.PROGRAMS, delta_sync=False),
22+
]
2123
)
2224

2325
@link(change_list=False)

src/country_workspace/admin/program.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
from ..compat.admin_extra_buttons import confirm_action
1111
from ..models import Program
1212
from .base import BaseModelAdmin
13-
from country_workspace.admin.sync import SyncAdminMixin, SyncAdminConfig, StepConfig
14-
13+
from country_workspace.admin.sync import SyncAdminMixin, SyncAdminConfig, TargetConfig, Target
1514

1615
if TYPE_CHECKING:
1716
from admin_extra_buttons.buttons import LinkButton
@@ -42,10 +41,7 @@ class ProgramAdmin(SyncAdminMixin, BaseModelAdmin):
4241
)
4342
ordering = ("name",)
4443
autocomplete_fields = ("country_office",)
45-
sync_config = SyncAdminConfig(
46-
step_handler=StepConfig(path="country_workspace.contrib.hope.sync.context_programs.SyncStep", name="PROGRAMS"),
47-
sync_handler="country_workspace.contrib.hope.sync.context_programs.sync_context_programs",
48-
)
44+
sync_config = SyncAdminConfig(targets=[TargetConfig(target=Target.PROGRAMS, delta_sync=False)])
4945

5046
@button()
5147
def invalidate_cache(self, request: HttpRequest, pk: str) -> None:
@@ -68,7 +64,7 @@ def population(self, btn: "LinkButton") -> None:
6864
def zap(self, request: HttpRequest, pk: str) -> None:
6965
obj: Program = self.get_object(request, pk)
7066

71-
def _action(request: HttpRequest) -> HttpResponse:
67+
def _action(_: HttpRequest) -> HttpResponse:
7268
obj.households.all().delete()
7369

7470
return confirm_action(
Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,43 @@
1-
from typing import TypedDict, Any
1+
from enum import StrEnum, auto
2+
from functools import reduce
3+
from itertools import chain
4+
from operator import add
5+
from typing import TypedDict, Mapping, Final
6+
7+
from admin_extra_buttons.api import button
8+
from admin_extra_buttons.mixins import ExtraButtonsMixin
29
from django.contrib import messages
310
from django.http import HttpRequest
4-
from django.utils.translation import gettext as _
511
from django.utils.module_loading import import_string
12+
from django.utils.translation import gettext as _
613
from strategy_field.utils import fqn
7-
from admin_extra_buttons.api import button
8-
from admin_extra_buttons.mixins import ExtraButtonsMixin
914

1015
from country_workspace.models import AsyncJob
1116

1217

13-
class StepConfig(TypedDict):
14-
path: str
15-
name: str
18+
class Target(StrEnum):
19+
AREAS = auto()
20+
AREA_TYPES = auto()
21+
COUNTRIES = auto()
22+
OFFICES = auto()
23+
PROGRAMS = auto()
24+
PROJECTS = auto()
25+
REGISTRATIONS = auto()
26+
27+
28+
class TargetConfig(TypedDict):
29+
target: Target
30+
delta_sync: bool
1631

1732

1833
class SyncAdminConfig(TypedDict):
19-
step_handler: StepConfig
20-
sync_handler: str
34+
targets: list[TargetConfig]
35+
36+
37+
class Stats(TypedDict):
38+
errors: list[str]
39+
add: int
40+
upd: int
2141

2242

2343
class SyncAdminMixin(ExtraButtonsMixin):
@@ -39,25 +59,40 @@ def sync(self, request: HttpRequest) -> None:
3959

4060
@button()
4161
def sync_delta(self, request: HttpRequest) -> None:
42-
totals = run_sync(config={**self.sync_config, "delta_sync": True})
43-
if errors := totals.get("errors"):
62+
config = SyncAdminConfig(
63+
targets=[TargetConfig(**config, delta_sync=True) for config in self.sync_config["targets"]]
64+
)
65+
totals = run_sync(config=config)
66+
chain.from_iterable(totals.values())
67+
if errors := reduce(add, (t["errors"] for t in totals.values())):
4468
self.message_user(request, " | ".join(errors), level=messages.ERROR)
4569
else:
46-
summary = " | ".join(
47-
f"{model_name.upper()}: {counts.get('add', 0)} created - {counts.get('upd', 0)} updated"
48-
for model_name, counts in totals.items()
49-
if isinstance(counts, dict)
50-
)
70+
summary = " | ".join(f"{t}: {s['add']} created - {s['upd']} updated" for t, s in totals.items())
5171
self.message_user(request, summary, level=messages.SUCCESS)
5272

5373

54-
def task(job: AsyncJob) -> dict[str, Any]:
55-
return run_sync(config={**job.config, "delta_sync": False})
74+
TARGET_TO_HANDLER_PATH_MAPPING: Final[Mapping[Target, str]] = {
75+
Target.AREAS: "country_workspace.contrib.hope.sync.context_geo.sync_areas",
76+
Target.AREA_TYPES: "country_workspace.contrib.hope.sync.context_geo.sync_area_types",
77+
Target.COUNTRIES: "country_workspace.contrib.hope.sync.context_geo.sync_countries",
78+
# TODO: add beneficiary groups
79+
Target.OFFICES: "country_workspace.contrib.hope.sync.context_programs.sync_offices",
80+
Target.PROGRAMS: "country_workspace.contrib.hope.sync.context_programs.sync_programs",
81+
Target.PROJECTS: "country_workspace.contrib.aurora.context_aurora.sync_projects",
82+
Target.REGISTRATIONS: "country_workspace.contrib.aurora.context_aurora.sync_registrations",
83+
}
84+
85+
86+
def run_sync(config: SyncAdminConfig) -> Mapping[Target, Stats]:
87+
stats = {}
88+
for target_config in config["targets"]:
89+
delta_sync = target_config["delta_sync"]
90+
target = target_config["target"]
91+
handler_path = TARGET_TO_HANDLER_PATH_MAPPING[target]
92+
handler = import_string(handler_path)
93+
stats[target] = handler(delta_sync=delta_sync)
94+
return stats
5695

5796

58-
def run_sync(config: SyncAdminConfig) -> dict[str, Any]:
59-
step_class = import_string(config["step_handler"]["path"])
60-
return import_string(config["sync_handler"])(
61-
delta_sync=config["delta_sync"],
62-
step=step_class[config["step_handler"]["name"]],
63-
)
97+
def task(job: AsyncJob) -> Mapping[Target, Stats]:
98+
return run_sync(config=job.config)

src/country_workspace/contrib/aurora/admin/registration.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from country_workspace.admin.base import BaseModelAdmin
66
from country_workspace.contrib.aurora.models import Registration
7-
from country_workspace.admin.sync import SyncAdminMixin, SyncAdminConfig, StepConfig
7+
from country_workspace.admin.sync import SyncAdminMixin, SyncAdminConfig, TargetConfig, Target
88

99

1010
@admin.register(Registration)
@@ -18,8 +18,10 @@ class RegistrationAdmin(SyncAdminMixin, BaseModelAdmin):
1818
ordering = ("name",)
1919
autocomplete_fields = ("project",)
2020
sync_config = SyncAdminConfig(
21-
step_handler=StepConfig(path="country_workspace.contrib.aurora.context_aurora.SyncStep", name="REGISTRATIONS"),
22-
sync_handler="country_workspace.contrib.aurora.context_aurora.sync_context_aurora",
21+
targets=[
22+
TargetConfig(target=Target.PROJECTS, delta_sync=False),
23+
TargetConfig(target=Target.REGISTRATIONS, delta_sync=False),
24+
]
2325
)
2426

2527
@admin.display(ordering="last_modified")
Lines changed: 56 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,77 @@
11
from typing import Any, Final
2-
from enum import auto
3-
from dataclasses import dataclass, field
4-
from io import TextIOBase
5-
from django.db.models import Model
62
from urllib.parse import urlparse
73

4+
from django.db.models import Model
5+
86
from country_workspace.contrib.aurora.models import Project, Registration
97
from country_workspace.contrib.hope.sync.base import (
10-
BaseSync,
11-
BaseSyncStep,
128
ParamDateName,
139
SyncConfig,
1410
SkipRecordError,
15-
sync_context,
11+
sync_entity,
12+
build_endpoint,
1613
)
17-
from country_workspace.contrib.aurora.client import AuroraClient
18-
1914

2015
MODELS: Final[tuple[type[Model], ...]] = (Project, Registration)
2116
"""List of models to synchronize."""
2217

2318

24-
class SyncStep(BaseSyncStep):
25-
"""Synchronization steps for aurora-related models."""
26-
27-
PROJECTS = (auto(), lambda self: self.sync_projects)
28-
REGISTRATIONS = (auto(), lambda self: self.sync_registrations)
29-
30-
31-
@dataclass
32-
class SyncContextAurora(BaseSync):
33-
"""Context for synchronizing Aurora-related models."""
34-
35-
SyncStep = SyncStep
36-
client: AuroraClient = field(default_factory=AuroraClient)
37-
38-
def sync_projects(self) -> None:
39-
"""Fetch and process Project records from the Aurora system."""
40-
self.sync_entity(
41-
SyncConfig(
42-
model=Project,
43-
reference_id="reference_pk",
44-
endpoint=self._build_endpoint("project", Project, ParamDateName.MODIFIED),
45-
prepare_defaults=lambda r: {"name": r["name"]},
46-
),
47-
)
48-
49-
def sync_registrations(self) -> None:
50-
"""Fetch and process Registration records from the Aurora system."""
51-
52-
def _prepare_defaults(rec: dict[str, Any]) -> dict[str, Any] | None:
53-
if (extracted_id := self._extract_related_id(rec["project"])) is None:
54-
raise SkipRecordError("Invalid project URL format.")
55-
try:
56-
project = Project.objects.get(reference_pk=extracted_id)
57-
except Project.DoesNotExist as e:
58-
raise SkipRecordError("Project not found.") from e
59-
return {
60-
"name": rec["name"],
61-
"project": project,
62-
"reference_pk": rec["id"],
63-
}
64-
65-
self.sync_projects()
66-
self.sync_entity(
67-
SyncConfig(
68-
model=Registration,
69-
reference_id="reference_pk",
70-
endpoint=self._build_endpoint("registration", Registration, ParamDateName.MODIFIED),
71-
prepare_defaults=_prepare_defaults,
72-
),
73-
)
74-
75-
def _extract_related_id(self, url: str) -> int | None:
76-
"""Extract the related object ID from the given URL.
77-
78-
Args:
79-
url (str): A URL string that is expected to end with the object's ID as its last path segment.
80-
81-
Returns:
82-
int | None: The extracted ID if successful, otherwise None.
83-
84-
"""
85-
parsed_url = urlparse(url)
86-
try:
87-
related_id = parsed_url.path.rstrip("/").split("/")[-1]
88-
return int(related_id)
89-
except (ValueError, IndexError):
90-
return None
91-
92-
93-
def sync_context_aurora(
94-
*,
95-
delta_sync: bool = False,
96-
step: SyncStep | None = None,
97-
stdout: TextIOBase | None = None,
98-
) -> dict[str, Any]:
99-
"""Run synchronization for geo-related models.
19+
def _extract_related_id(url: str) -> int | None:
20+
"""Extract the related object ID from the given URL.
10021
10122
Args:
102-
delta_sync (bool): If True, only synchronize records updated after the last sync,
103-
otherwise synchronize all records.
104-
step (SyncStep | None): Specific step to execute (e.g., SyncStep.REGISTRATIONS). If None, all steps are run.
105-
stdout (TextIOBase | None): Optional output stream for logging.
23+
url (str): A URL string that is expected to end with the object's ID as its last path segment.
10624
10725
Returns:
108-
dict[str, Any]: Synchronization results, including counts and errors.
26+
int | None: The extracted ID if successful, otherwise None.
10927
11028
"""
111-
return sync_context(
112-
SyncContextAurora,
113-
delta_sync=delta_sync,
114-
step=step,
115-
stdout=stdout,
29+
parsed_url = urlparse(url)
30+
try:
31+
related_id = parsed_url.path.rstrip("/").split("/")[-1]
32+
return int(related_id)
33+
except (ValueError, IndexError):
34+
return None
35+
36+
37+
# client: AuroraClient = field(default_factory=AuroraClient)
38+
39+
40+
def sync_projects(delta_sync: bool) -> None:
41+
"""Fetch and process Project records from the Aurora system."""
42+
sync_entity(
43+
SyncConfig(
44+
model=Project,
45+
reference_id="reference_pk",
46+
endpoint=build_endpoint("project", Project, ParamDateName.MODIFIED, delta_sync),
47+
prepare_defaults=lambda r: {"name": r["name"]},
48+
delta_sync=delta_sync,
49+
),
50+
)
51+
52+
53+
def sync_registrations(delta_sync: bool) -> None:
54+
"""Fetch and process Registration records from the Aurora system."""
55+
56+
def _prepare_defaults(rec: dict[str, Any]) -> dict[str, Any] | None:
57+
if (extracted_id := _extract_related_id(rec["project"])) is None:
58+
raise SkipRecordError("Invalid project URL format.")
59+
try:
60+
project = Project.objects.get(reference_pk=extracted_id)
61+
except Project.DoesNotExist as e:
62+
raise SkipRecordError("Project not found.") from e
63+
return {
64+
"name": rec["name"],
65+
"project": project,
66+
"reference_pk": rec["id"],
67+
}
68+
69+
sync_entity(
70+
SyncConfig(
71+
model=Registration,
72+
reference_id="reference_pk",
73+
endpoint=build_endpoint("registration", Registration, ParamDateName.MODIFIED),
74+
prepare_defaults=_prepare_defaults,
75+
delta_sync=delta_sync,
76+
),
11677
)

0 commit comments

Comments
 (0)