Skip to content

Commit 575c5b8

Browse files
authored
Feature/284491 kobo watermark (#265)
* Implement kobo watermark feature * Add unit tests * Use synclog instead of kobosubmission Fix failing unit tests * fix failing unit test add field name to the synclog admin listing * fix failing unit test add field name to the synclog admin listing * Remove unnecessary mocks from unit tests * Improve error message on import failure
1 parent 14bd066 commit 575c5b8

File tree

19 files changed

+329
-52
lines changed

19 files changed

+329
-52
lines changed

src/country_workspace/admin/sync_log.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
@admin.register(SyncLog)
1010
class SyncLogAdmin(BaseModelAdmin):
11-
list_display = ("content_type", "content_object", "last_update_date", "last_id")
11+
list_display = ("content_type", "name", "content_object", "last_update_date", "last_id")
12+
search_fields = ("name", "content_type__model", "content_type__app_label")
1213

1314
@button()
1415
def sync_flex_fields(self, request: HttpRequest) -> "HttpResponse":

src/country_workspace/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
# these should be optional in the future
5858
"country_workspace.contrib.hope.apps.Config",
5959
"country_workspace.contrib.aurora.apps.Config",
60+
"country_workspace.contrib.kobo.apps.Config",
6061
*env("EXTRA_APPS"),
6162
)
6263

src/country_workspace/contrib/kobo/api/client/helpers.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,21 @@ def get_asset_list(data_getter: DataGetter, url: str) -> Generator[Asset, None,
7474
return handle_paginated_response(data_getter, url, get_raw_asset_list, partial(get_asset, data_getter))
7575

7676

77-
def get_submission_list(data_getter: DataGetter, url: str) -> Iterable[Submission]:
78-
url_with_start_and_limit = change_url(
79-
url, query={START_PARAMETER_NAME: START_PARAMETER_VALUE, LIMIT_PARAMETER_NAME: LIMIT_PARAMETER_VALUE}
80-
)
77+
def get_submission_list(data_getter: DataGetter, url: str, min_id: int | None = None) -> Iterable[Submission]:
78+
import json
79+
80+
query_params = {
81+
START_PARAMETER_NAME: START_PARAMETER_VALUE,
82+
LIMIT_PARAMETER_NAME: LIMIT_PARAMETER_VALUE,
83+
}
84+
85+
if min_id is not None:
86+
query_params["query"] = json.dumps({"_id": {"$gt": min_id}})
87+
88+
url_with_params = change_url(url, query=query_params)
8189
return map(
8290
partial(download_attachments, data_getter),
83-
handle_paginated_response(data_getter, url_with_start_and_limit, get_raw_submission_list, Submission),
91+
handle_paginated_response(data_getter, url_with_params, get_raw_submission_list, Submission),
8492
)
8593

8694

src/country_workspace/contrib/kobo/api/data/asset.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77

88
class Asset(Raw[raw_asset.Asset]):
9-
def __init__(self, raw: raw_asset.Asset, submissions: Callable[[], Generator[Submission, None, None]]) -> None:
9+
def __init__(
10+
self, raw: raw_asset.Asset, submissions: Callable[[int | None], Generator[Submission, None, None]]
11+
) -> None:
1012
super().__init__(raw)
1113
self._submissions = submissions
1214

@@ -18,9 +20,8 @@ def uid(self) -> str:
1820
def name(self) -> str:
1921
return self._raw["name"]
2022

21-
@property
22-
def submissions(self) -> Generator[Submission, None, None]:
23-
yield from self._submissions()
23+
def submissions(self, min_id: int | None = None) -> Generator[Submission, None, None]:
24+
yield from self._submissions(min_id)
2425

2526
def __str__(self) -> str:
2627
return f"Asset: {self.name}"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class Config(AppConfig):
5+
name = __name__.rpartition(".")[0]
6+
verbose_name = "Contributed Service | Kobo"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.2.8 on 2025-11-11 00:47
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
initial = True
8+
9+
dependencies = []
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="KoboSubmission",
14+
fields=[
15+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
16+
("asset_uid", models.CharField(editable=False, max_length=32, unique=True)),
17+
("last_submission_id", models.IntegerField(editable=False)),
18+
],
19+
),
20+
]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Generated by Django 5.2.8 on 2025-11-11 13:42
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("kobo", "0001_initial"),
9+
]
10+
11+
operations = [
12+
migrations.DeleteModel(
13+
name="KoboSubmission",
14+
),
15+
]

src/country_workspace/contrib/kobo/migrations/__init__.py

Whitespace-only changes.

src/country_workspace/contrib/kobo/models.py

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

src/country_workspace/contrib/kobo/sync.py

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,22 @@
33
from functools import partial
44
from typing import Any, Final, TypedDict, cast
55
from constance import config as constance_config
6+
from django.utils import timezone
67
from requests import Session
78
from requests.adapters import HTTPAdapter
89

10+
from django.contrib.contenttypes.models import ContentType
11+
912
from country_workspace.contrib.kobo.api.client.auth import Auth
1013
from country_workspace.contrib.kobo.api.client.main import Client
1114
from country_workspace.contrib.kobo.api.common import DataGetter
1215
from country_workspace.contrib.kobo.api.data.asset import Asset
1316
from country_workspace.contrib.kobo.api.data.submission import Submission
14-
from country_workspace.contrib.kobo.models import KoboSubmission
15-
from country_workspace.models import AsyncJob, Batch, Household, Individual
17+
from country_workspace.models import AsyncJob, Batch, Household, Individual, Program, SyncLog
1618
from country_workspace.utils.config import BatchNameConfig, ValidateModeConfig
1719
from country_workspace.utils.fields import clean_field_names, TO_UPPERCASE_FIELDS
1820
from country_workspace.utils.functional import compose
21+
from country_workspace.utils.sync_log import get_kobo_sync_log_name
1922

2023

2124
class Config(BatchNameConfig, ValidateModeConfig):
@@ -149,18 +152,51 @@ def set_roles_and_relationships(household: Household, individuals: list[Individu
149152

150153

151154
def import_asset(batch: Batch, asset: Asset, config: Config, id_generator: Callable[[], int]) -> ImportResult:
155+
from django.db import transaction
156+
152157
household_counter = 0
153158
individual_counter = 0
159+
sync_log_name = get_kobo_sync_log_name(asset.uid)
160+
161+
program_ct = ContentType.objects.get_for_model(Program)
162+
sync_log = SyncLog.objects.filter(name=sync_log_name, content_type=program_ct, object_id=batch.program.id).first()
163+
last_id = int(sync_log.last_id) if sync_log and sync_log.last_id else 0
164+
165+
last_successful_id = last_id
166+
current_submission = None
167+
168+
try:
169+
for submission in asset.submissions(min_id=last_id):
170+
current_submission = submission
154171

155-
submission_ids = set(KoboSubmission.objects.filter(asset_uid=asset.uid).values_list("submission_id", flat=True))
156-
for submission in asset.submissions:
157-
if submission.id in submission_ids:
158-
continue
159-
household = create_household(batch, submission, config, id_generator)
160-
household_counter += 1
161-
individuals = create_individuals(batch, household, submission, config)
162-
individual_counter += len(individuals)
163-
set_roles_and_relationships(household, individuals)
172+
with transaction.atomic():
173+
household = create_household(batch, submission, config, id_generator)
174+
individuals = create_individuals(batch, household, submission, config)
175+
set_roles_and_relationships(household, individuals)
176+
177+
household_counter += 1
178+
individual_counter += len(individuals)
179+
180+
last_successful_id = submission.id
181+
182+
except Exception as e:
183+
failed_id = current_submission.id if current_submission else "unknown (before first submission)"
184+
185+
error_msg = (
186+
f"Successfully imported {household_counter} households, before stopping at submission {failed_id} due to:"
187+
f"Error: {e}"
188+
f"Last successful submission ID: {last_successful_id}."
189+
)
190+
raise ImportError(error_msg) from e
191+
192+
finally:
193+
if last_successful_id > last_id:
194+
SyncLog.objects.update_or_create(
195+
name=sync_log_name,
196+
content_type=program_ct,
197+
object_id=batch.program.id,
198+
defaults={"last_id": str(last_successful_id), "last_update_date": timezone.now()},
199+
)
164200

165201
return ImportResult(households=household_counter, individuals=individual_counter)
166202

0 commit comments

Comments
 (0)