|
12 | 12 | """ |
13 | 13 |
|
14 | 14 | from django.contrib import messages |
| 15 | +from django.db import transaction |
15 | 16 | from django.http import JsonResponse |
16 | 17 | from django.shortcuts import get_object_or_404, redirect |
17 | 18 | from django.utils.translation import gettext_lazy as _ |
|
27 | 28 | from tally_ho.apps.tally.models.tally import Tally |
28 | 29 | from tally_ho.apps.tally.models.user_profile import UserProfile |
29 | 30 | from tally_ho.libs.models.enums.pvp_bundle_status import PvpBundleStatus |
| 31 | +from tally_ho.libs.models.enums.pvp_mode import PvpMode |
30 | 32 | from tally_ho.libs.permissions import groups |
31 | 33 | from tally_ho.libs.pvp.bundle import ( |
32 | 34 | DuplicateBarcodeError, |
@@ -54,6 +56,17 @@ def get_context_data(self, **kwargs): |
54 | 56 | def form_valid(self, form): |
55 | 57 | tally_id = self.kwargs["tally_id"] |
56 | 58 | 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 | + |
57 | 70 | upload = form.cleaned_data["zip_file"] |
58 | 71 |
|
59 | 72 | # request.user is a Django User; the FK targets UserProfile (the |
@@ -84,7 +97,11 @@ def form_valid(self, form): |
84 | 97 | RoundIntegrityError, |
85 | 98 | UnsafeImageFilenameError, |
86 | 99 | ) 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() |
88 | 105 | form.add_error("zip_file", str(exc)) |
89 | 106 | return self.form_invalid(form) |
90 | 107 |
|
@@ -135,13 +152,20 @@ def get_context_data(self, **kwargs): |
135 | 152 | def post(self, request, *args, **kwargs): |
136 | 153 | tally_id = self.kwargs["tally_id"] |
137 | 154 | 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( |
139 | 158 | PvpUploadBundle, id=bundle_id, tally_id=tally_id, |
140 | 159 | ) |
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: |
145 | 169 | messages.info( |
146 | 170 | request, |
147 | 171 | _("PVP import already %(status)s.") % { |
|
0 commit comments