Skip to content

Commit 098111b

Browse files
committed
simplify get/catch and add transaction
1 parent 6dd2ea4 commit 098111b

9 files changed

Lines changed: 189 additions & 49 deletions

File tree

tally_ho/apps/tally/management/commands/async_pvp_import.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import zipfile
1010

11+
from django.db import transaction
12+
1113
from tally_ho.apps.tally.models.pvp_upload_bundle import PvpUploadBundle
1214
from tally_ho.apps.tally.models.result_form import ResultForm
1315
from tally_ho.apps.tally.models.user_profile import UserProfile
@@ -28,19 +30,32 @@ def async_pvp_import(bundle_id, user_id):
2830
The orchestrator flips status to COMPLETED on success or FAILED if
2931
anything raises.
3032
33+
Re-invocations on a non-PENDING bundle short-circuit: a celery
34+
retry or duplicate enqueue must not overwrite a terminal status
35+
(which would also explode on the PvpSubmission unique constraint).
36+
3137
:param bundle_id: PvpUploadBundle.id
3238
:param user_id: UserProfile.id (the super admin who confirmed)
3339
:returns: the bundle id (round-trip for callers/monitoring)
3440
"""
35-
bundle = PvpUploadBundle.objects.get(id=bundle_id)
3641
user = UserProfile.objects.get(id=user_id)
3742

38-
bundle.status = PvpBundleStatus.IMPORTING
39-
bundle.save(update_fields=["status", "modified_date"])
43+
# Atomically claim the bundle: lock the row, verify it's PENDING,
44+
# flip to IMPORTING. A duplicate task arriving here finds the row
45+
# already IMPORTING/COMPLETED/FAILED and bails before doing work.
46+
with transaction.atomic():
47+
bundle = PvpUploadBundle.objects.select_for_update().get(
48+
id=bundle_id,
49+
)
50+
if bundle.status != PvpBundleStatus.PENDING:
51+
return bundle.id
52+
bundle.status = PvpBundleStatus.IMPORTING
53+
bundle.save(update_fields=["status", "modified_date"])
4054

41-
# Catch failures from parse/validation too — otherwise a corrupted
42-
# zip leaves the bundle stuck in IMPORTING. import_bundle handles
43-
# its own FAILED flip; this except covers everything before it.
55+
# All failure handling — parse errors, validation crashes, and
56+
# import_bundle rollbacks — is centralized in this except. The
57+
# FAILED save happens outside any atomic here (the claim above
58+
# already committed), so it sticks even after the re-raise.
4459
try:
4560
zip_path = bundle.zip_file.path
4661
parsed = parse_bundle(zip_path)
@@ -66,9 +81,7 @@ def async_pvp_import(bundle_id, user_id):
6681
zip_ref=zip_ref,
6782
)
6883
except Exception as exc:
69-
bundle.refresh_from_db(fields=["status"])
70-
if bundle.status != PvpBundleStatus.FAILED:
71-
bundle.status = PvpBundleStatus.FAILED
84+
bundle.status = PvpBundleStatus.FAILED
7285
bundle.error_message = str(exc)
7386
bundle.save(
7487
update_fields=["status", "error_message", "modified_date"],

tally_ho/apps/tally/management/commands/create_demo_pvp_bundle.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
)
2424

2525

26-
CSV_HEADERS = tuple(
27-
dict.fromkeys((*REQUIRED_COLUMNS, *RECON_COLUMNS, *IMAGE_COLUMNS))
28-
)
26+
# REQUIRED_COLUMNS is a superset — RECON_COLUMNS is already splatted in
27+
# and IMAGE_COLUMNS members are listed explicitly.
28+
CSV_HEADERS = REQUIRED_COLUMNS
2929
STUB_IMAGES = ("demo_sig.jpg", "demo_p1.jpg", "demo_p2.jpg")
3030
# Minimal valid JPEG (SOI + APP0 JFIF marker + EOI). Parser only checks
3131
# that the file exists; content does not need to be a real photo.

tally_ho/apps/tally/models/result_form.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,12 @@ class Meta:
188188
blank=True,
189189
related_name='result_forms',
190190
on_delete=models.PROTECT)
191-
# Set to the PvpSubmission whose round-2 candidate results populated
192-
# this form's DATA_ENTRY_1 results.
191+
# Set to the PvpSubmission whose candidate results populated this
192+
# form. In DE1_ONLY mode the submission's round-2 values become a
193+
# single DATA_ENTRY_1 result set (DE2 is left for a human clerk).
194+
# In DE1_AND_DE2 mode the submission populates DATA_ENTRY_1,
195+
# DATA_ENTRY_2, and FINAL — corrections is skipped because the
196+
# device guarantees round1 == round2.
193197
pvp_submission = models.ForeignKey(
194198
PvpSubmission,
195199
null=True,

tally_ho/apps/tally/tests/management/commands/test_async_pvp_import.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,30 @@ def test_async_pvp_import_marks_failed_when_parse_raises(self):
201201
self.assertIsNone(self.bundle.imported_at)
202202
self.assertEqual(PvpSubmission.objects.count(), 0)
203203

204+
def test_async_pvp_import_is_noop_when_bundle_not_pending(self):
205+
# If the task is re-queued externally (celery retry, manual
206+
# re-run, sibling worker race), the second invocation must not
207+
# overwrite a terminal status. Without the guard the task would
208+
# flip COMPLETED -> IMPORTING and then explode on the duplicate
209+
# PvpSubmission unique constraint, ending in spurious FAILED.
210+
original_imported_at = self.bundle.imported_at
211+
self.bundle.status = PvpBundleStatus.COMPLETED
212+
self.bundle.number_of_submissions = 1
213+
self.bundle.save(update_fields=[
214+
"status", "number_of_submissions", "modified_date",
215+
])
216+
217+
async_pvp_import.apply(
218+
kwargs={"bundle_id": self.bundle.id, "user_id": self.user.id},
219+
)
220+
221+
self.bundle.refresh_from_db()
222+
self.assertEqual(self.bundle.status, PvpBundleStatus.COMPLETED)
223+
self.assertEqual(self.bundle.number_of_submissions, 1)
224+
self.assertEqual(self.bundle.imported_at, original_imported_at)
225+
# No new PvpSubmission row should have been created.
226+
self.assertEqual(PvpSubmission.objects.count(), 0)
227+
204228
def test_async_pvp_import_stores_error_message_on_failure(self):
205229
# On failure the bundle must record why so the operator sees
206230
# the cause on the result page instead of a bare FAILED.

tally_ho/apps/tally/tests/views/test_pvp_views.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import csv
88
import io
99
import json
10+
import pathlib
1011
import shutil
1112
import tempfile
1213
import zipfile
@@ -193,6 +194,70 @@ def test_post_invalid_zip_shows_error_no_bundle_created(self):
193194
self.assertEqual(response.status_code, 200)
194195
self.assertEqual(PvpUploadBundle.objects.count(), 0)
195196

197+
def test_post_invalid_zip_removes_persisted_zip_from_disk(self):
198+
# On parse failure the bundle row is deleted, but Django does
199+
# not delete the underlying file when the model row goes away.
200+
# The view must explicitly clean it up to avoid an orphan in
201+
# MEDIA_ROOT.
202+
view = PvpUploadView.as_view()
203+
buf = io.BytesIO()
204+
with zipfile.ZipFile(buf, mode="w") as zf:
205+
zf.writestr("README.txt", b"oops")
206+
upload = SimpleUploadedFile(
207+
"bundle.zip", buf.getvalue(),
208+
content_type="application/zip",
209+
)
210+
request = self.factory.post("/", data={"zip_file": upload})
211+
request.user = User.objects.get(id=self.user.id)
212+
request.session = {}
213+
214+
# Snapshot the bundle directory tree before; anything new here
215+
# at the end of the test is a leak.
216+
bundles_root = pathlib.Path(self._media_root) / "pvp" / "bundles"
217+
before = (
218+
set(p.relative_to(bundles_root) for p in bundles_root.rglob("*"))
219+
if bundles_root.exists() else set()
220+
)
221+
222+
response = view(request, tally_id=self.tally.id)
223+
self.assertEqual(response.status_code, 200)
224+
self.assertEqual(PvpUploadBundle.objects.count(), 0)
225+
226+
after_files = (
227+
{
228+
p for p in bundles_root.rglob("*")
229+
if p.is_file()
230+
} if bundles_root.exists() else set()
231+
)
232+
# Compare file paths only; intermediate dirs are not interesting.
233+
new_files = {
234+
p for p in after_files
235+
if p.relative_to(bundles_root) not in before
236+
}
237+
self.assertEqual(new_files, set())
238+
239+
def test_post_to_disabled_tally_shows_error_no_bundle_created(self):
240+
# Uploading to a DISABLED tally would have every row rejected
241+
# downstream as `pvp_disabled` — short-circuit at upload so the
242+
# operator sees the actual cause instead of a useless skip list.
243+
self.tally.pvp_mode = PvpMode.DISABLED
244+
self.tally.save(update_fields=["pvp_mode"])
245+
246+
view = PvpUploadView.as_view()
247+
zip_bytes = _zip_bytes([
248+
_csv_row(instance_id="uuid:1", barcode="111",
249+
candidate_id=131301, order=1, r2=12),
250+
])
251+
upload = SimpleUploadedFile(
252+
"bundle.zip", zip_bytes, content_type="application/zip",
253+
)
254+
request = self.factory.post("/", data={"zip_file": upload})
255+
request.user = User.objects.get(id=self.user.id)
256+
request.session = {}
257+
response = view(request, tally_id=self.tally.id)
258+
self.assertEqual(response.status_code, 200)
259+
self.assertEqual(PvpUploadBundle.objects.count(), 0)
260+
196261

197262
class TestPvpConfirmView(_PvpViewTestBase, TestCase):
198263
def _make_pending_bundle(self):

tally_ho/apps/tally/views/pvp.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"""
1313

1414
from django.contrib import messages
15+
from django.db import transaction
1516
from django.http import JsonResponse
1617
from django.shortcuts import get_object_or_404, redirect
1718
from django.utils.translation import gettext_lazy as _
@@ -27,6 +28,7 @@
2728
from tally_ho.apps.tally.models.tally import Tally
2829
from tally_ho.apps.tally.models.user_profile import UserProfile
2930
from tally_ho.libs.models.enums.pvp_bundle_status import PvpBundleStatus
31+
from tally_ho.libs.models.enums.pvp_mode import PvpMode
3032
from tally_ho.libs.permissions import groups
3133
from tally_ho.libs.pvp.bundle import (
3234
DuplicateBarcodeError,
@@ -54,6 +56,17 @@ def get_context_data(self, **kwargs):
5456
def form_valid(self, form):
5557
tally_id = self.kwargs["tally_id"]
5658
tally = get_object_or_404(Tally, id=tally_id)
59+
60+
# A DISABLED tally would reject every row at confirm-time with
61+
# `pvp_disabled`. Short-circuit at upload so the operator sees
62+
# the actual cause and isn't asked to confirm an empty import.
63+
if tally.pvp_mode == PvpMode.DISABLED:
64+
form.add_error(
65+
"zip_file",
66+
_("PVP is disabled for this tally."),
67+
)
68+
return self.form_invalid(form)
69+
5770
upload = form.cleaned_data["zip_file"]
5871

5972
# request.user is a Django User; the FK targets UserProfile (the
@@ -84,7 +97,11 @@ def form_valid(self, form):
8497
RoundIntegrityError,
8598
UnsafeImageFilenameError,
8699
) as exc:
87-
bundle.delete() # zip_file deletion handled by FileField
100+
# Django's FileField does not auto-delete the on-disk file
101+
# when the model row is deleted; do it explicitly so a
102+
# rejected upload does not leak a zip in MEDIA_ROOT.
103+
bundle.zip_file.delete(save=False)
104+
bundle.delete()
88105
form.add_error("zip_file", str(exc))
89106
return self.form_invalid(form)
90107

@@ -135,13 +152,20 @@ def get_context_data(self, **kwargs):
135152
def post(self, request, *args, **kwargs):
136153
tally_id = self.kwargs["tally_id"]
137154
bundle_id = self.kwargs["bundle_id"]
138-
bundle = get_object_or_404(
155+
# Tally membership check up front; the locked row read below is
156+
# for the enqueue gate, not authorization.
157+
get_object_or_404(
139158
PvpUploadBundle, id=bundle_id, tally_id=tally_id,
140159
)
141-
# Only PENDING bundles can be enqueued. Re-confirming an already
142-
# IMPORTING/COMPLETED/FAILED bundle would otherwise duplicate the
143-
# import task and race against the in-flight one.
144-
if bundle.status != PvpBundleStatus.PENDING:
160+
# Take a row lock so two concurrent confirms can't both observe
161+
# PENDING and both enqueue. The lock is released when the
162+
# atomic block exits, which happens before we hand off to celery.
163+
with transaction.atomic():
164+
bundle = PvpUploadBundle.objects.select_for_update().get(
165+
id=bundle_id,
166+
)
167+
already_handled = bundle.status != PvpBundleStatus.PENDING
168+
if already_handled:
145169
messages.info(
146170
request,
147171
_("PVP import already %(status)s.") % {

tally_ho/libs/pvp/bundle.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,13 @@ def parse_bundle(zip_path: str | Path) -> ParsedBundle:
125125
"""Parse a PVP upload bundle into submission-level rows.
126126
127127
Raises:
128-
InvalidBundleError: zip can't open, csv missing, required columns gone
129-
DuplicateBarcodeError: same barcode appears in more than one submission
128+
InvalidBundleError: zip can't open, csv missing, required columns
129+
gone, or a value that must parse as an integer doesn't
130+
DuplicateBarcodeError: same barcode appears in more than one
131+
submission
132+
RoundIntegrityError: any candidate row has missing rounds or
133+
round1 != round2 (device guarantees they match)
134+
UnsafeImageFilenameError: an image filename contains path syntax
130135
"""
131136
path = Path(zip_path)
132137
try:

tally_ho/libs/pvp/import_submission.py

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@
6565
def import_submission(
6666
parsed_submission, *, tally, bundle, uploaded_by, zip_ref,
6767
):
68-
"""Import one validated PVP submission as DE1 results + recon."""
68+
"""Import one validated PVP submission.
69+
70+
Entry-version writes depend on ``bundle.mode`` — see the module
71+
docstring for the DE1_ONLY vs DE1_AND_DE2 breakdown. Wraps the
72+
write pass in a reversion revision for audit history.
73+
"""
6974
with reversion.create_revision():
7075
reversion.set_user(uploaded_by)
7176
reversion.set_comment(
@@ -191,37 +196,32 @@ def import_bundle(*, bundle, submissions, tally, uploaded_by, zip_ref):
191196
parse-time validation. The bundle row already exists (created at
192197
upload time); this function walks the validated subset, calls
193198
`import_submission` for each row inside a single outer atomic
194-
transaction, and flips the bundle's terminal status.
199+
transaction, and flips the bundle to COMPLETED on success.
195200
196201
All-or-nothing: if any `import_submission` raises, the outer atomic
197202
rolls back every prior submission's writes (each row's own atomic
198203
becomes a savepoint of the outer). Image `on_commit` callbacks
199204
registered by successful submissions are discarded on rollback, so
200-
no orphan files land on disk. The bundle status flip to FAILED
201-
happens after the rollback so the FAILED row sticks.
205+
no orphan files land on disk. Callers are responsible for flipping
206+
the bundle to FAILED and persisting the exception message.
202207
"""
203208
submissions = list(submissions)
204-
try:
205-
with transaction.atomic():
206-
for parsed in submissions:
207-
import_submission(
208-
parsed,
209-
tally=tally,
210-
bundle=bundle,
211-
uploaded_by=uploaded_by,
212-
zip_ref=zip_ref,
213-
)
214-
bundle.number_of_submissions = len(submissions)
215-
bundle.imported_at = timezone.now()
216-
bundle.status = PvpBundleStatus.COMPLETED
217-
bundle.save(update_fields=[
218-
"number_of_submissions", "imported_at",
219-
"status", "modified_date",
220-
])
221-
except Exception:
222-
bundle.status = PvpBundleStatus.FAILED
223-
bundle.save(update_fields=["status", "modified_date"])
224-
raise
209+
with transaction.atomic():
210+
for parsed in submissions:
211+
import_submission(
212+
parsed,
213+
tally=tally,
214+
bundle=bundle,
215+
uploaded_by=uploaded_by,
216+
zip_ref=zip_ref,
217+
)
218+
bundle.number_of_submissions = len(submissions)
219+
bundle.imported_at = timezone.now()
220+
bundle.status = PvpBundleStatus.COMPLETED
221+
bundle.save(update_fields=[
222+
"number_of_submissions", "imported_at",
223+
"status", "modified_date",
224+
])
225225
return bundle
226226

227227

tally_ho/libs/tests/pvp/test_import_submission.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,10 +524,12 @@ def test_empty_submission_list_marks_completed_with_zero(self):
524524
self.assertEqual(self.bundle.number_of_submissions, 0)
525525
self.assertIsNotNone(self.bundle.imported_at)
526526

527-
def test_failure_marks_bundle_failed_reraises_and_rolls_back_all(self):
527+
def test_failure_reraises_and_rolls_back_all(self):
528528
# Second submission references a candidate_id that doesn't exist.
529529
# Under all-or-nothing semantics the first submission must also
530530
# be rolled back so the bundle leaves no partial trace.
531+
# import_bundle re-raises for the caller to handle the FAILED
532+
# transition (see async_pvp_import for the terminal flip).
531533
bad = self._parsed_submission(
532534
odk_instance_id="uuid:rf-2", barcode="222",
533535
candidates=(
@@ -551,7 +553,10 @@ def test_failure_marks_bundle_failed_reraises_and_rolls_back_all(self):
551553
finally:
552554
zip_ref.close()
553555
self.bundle.refresh_from_db()
554-
self.assertEqual(self.bundle.status, PvpBundleStatus.FAILED)
556+
# import_bundle no longer sets FAILED — that's the caller's job.
557+
# The bundle stays at whatever status it had when import_bundle
558+
# was called (IMPORTING in production, PENDING in this test).
559+
self.assertNotEqual(self.bundle.status, PvpBundleStatus.COMPLETED)
555560
self.assertIsNone(self.bundle.imported_at)
556561
# All-or-nothing: nothing persisted.
557562
self.assertEqual(self.bundle.submissions.count(), 0)

0 commit comments

Comments
 (0)