Skip to content

Commit 2c9c069

Browse files
authored
Merge pull request #463 from onaio/444-extract-forms-from-archive
[WIP] 444 Extract forms from archive
2 parents ed36366 + 0541b98 commit 2c9c069

38 files changed

+3036
-588
lines changed

.github/workflows/config.yml

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ name: Django CI
33
on: [push]
44

55
jobs:
6-
container-job:
7-
runs-on: ubuntu-20.04
6+
build-amd64:
7+
runs-on: ubuntu-latest
88

99
services:
1010
# Label used to access the service container
@@ -28,10 +28,10 @@ jobs:
2828

2929
steps:
3030
- uses: actions/checkout@v2
31-
- name: Set up Python 3.9.0
31+
- name: Set up Python 3.9
3232
uses: actions/setup-python@v2
3333
with:
34-
python-version: 3.9.0
34+
python-version: '3.9'
3535

3636
- name: Cache Dependencies
3737
uses: actions/cache@v3
@@ -51,14 +51,18 @@ jobs:
5151
- name: Start Redis
5252
run: sudo service redis-server start
5353

54+
- name: Install uv
55+
run: curl -LsSf https://astral.sh/uv/install.sh | sh
56+
# Add uv to the PATH
57+
- run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
58+
5459
- name: Generate Report
5560
run: |
56-
python3 -m venv venv
57-
. venv/bin/activate
58-
python -m pip install --upgrade pip
59-
pip install -r requirements/dev.pip
60-
ruff check . --exit-non-zero-on-fix
61-
pytest tally_ho --doctest-modules --junitxml=coverage.xml --cov=tally_ho --cov-report=xml --cov-report=html
61+
uv venv
62+
source .venv/bin/activate
63+
uv pip install -r requirements/dev.pip
64+
python -m ruff check . --exit-non-zero-on-fix
65+
python -m pytest tally_ho --doctest-modules --junitxml=coverage.xml --cov=tally_ho --cov-report=xml --cov-report=html
6266
coverage xml
6367
ls -al
6468
env:
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from django import forms
2+
from django.utils.translation import gettext_lazy as _
3+
4+
from tally_ho.apps.tally.models import WorkflowRequest
5+
6+
7+
class RequestRecallForm(forms.ModelForm):
8+
class Meta:
9+
model = WorkflowRequest
10+
fields = ['request_reason', 'request_comment']
11+
widgets = {
12+
'request_reason': forms.Select(attrs={'class': 'form-control'}),
13+
'request_comment': forms.Textarea(
14+
attrs={'rows': 3, 'class': 'form-control'}),
15+
}
16+
labels = {
17+
'request_reason': _("Reason for Recall"),
18+
'request_comment': _("Comment (Required)"),
19+
}
20+
21+
def __init__(self, *args, **kwargs):
22+
super().__init__(*args, **kwargs)
23+
self.fields['request_comment'].required = True
24+
25+
26+
class ApprovalForm(forms.ModelForm):
27+
class Meta:
28+
model = WorkflowRequest
29+
fields = ['approval_comment']
30+
widgets = {
31+
'approval_comment': forms.Textarea(
32+
attrs={'rows': 3, 'class': 'form-control'}),
33+
}
34+
labels = {
35+
'approval_comment': _("Approval/Rejection Comment"),
36+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 4.2.2 on 2025-04-15 14:20
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import enumfields.fields
6+
import tally_ho.libs.models.enums.request_reason
7+
import tally_ho.libs.models.enums.request_status
8+
import tally_ho.libs.models.enums.request_type
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
dependencies = [
14+
('tally', '0057_alter_reconciliationform_ballot_number_from_and_more'),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='WorkflowRequest',
20+
fields=[
21+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('created_date', models.DateTimeField(auto_now_add=True)),
23+
('modified_date', models.DateTimeField(auto_now=True)),
24+
('request_type', enumfields.fields.EnumIntegerField(enum=tally_ho.libs.models.enums.request_type.RequestType, verbose_name='Request Type')),
25+
('status', enumfields.fields.EnumIntegerField(default=0, enum=tally_ho.libs.models.enums.request_status.RequestStatus, verbose_name='Request Status')),
26+
('request_reason', enumfields.fields.EnumIntegerField(enum=tally_ho.libs.models.enums.request_reason.RequestReason, verbose_name='Reason')),
27+
('request_comment', models.TextField(verbose_name='Request Comment')),
28+
('approval_comment', models.TextField(blank=True, null=True, verbose_name='Approval/Rejection Comment')),
29+
('resolved_date', models.DateTimeField(blank=True, null=True)),
30+
('approver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='approved_rejected_requests', to='tally.userprofile')),
31+
('requester', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_requests', to='tally.userprofile')),
32+
('result_form', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='workflow_requests', to='tally.resultform')),
33+
],
34+
options={
35+
'indexes': [models.Index(fields=['result_form', 'request_type', 'status'], name='tally_workf_result__92f4e2_idx')],
36+
},
37+
),
38+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 4.2.2 on 2025-04-16 12:13
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('tally', '0058_workflowrequest'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='reconciliationform',
16+
name='deactivated_by_request',
17+
field=models.ForeignKey(blank=True, help_text='The workflow request that triggered the deactivation of this reconciliation record.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deactivated_recons', to='tally.workflowrequest'),
18+
),
19+
migrations.AddField(
20+
model_name='result',
21+
name='deactivated_by_request',
22+
field=models.ForeignKey(blank=True, help_text='The workflow request that triggered the deactivation of this result record.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deactivated_results', to='tally.workflowrequest'),
23+
),
24+
]

tally_ho/apps/tally/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@
2020
from tally_ho.apps.tally.models.region import Region
2121
from tally_ho.apps.tally.models.constituency import Constituency
2222
from tally_ho.apps.tally.models.electrol_race import ElectrolRace
23+
from tally_ho.apps.tally.models.workflow_request import WorkflowRequest

tally_ho/apps/tally/models/reconciliation_form.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from tally_ho.apps.tally.models.user_profile import UserProfile
1010
from tally_ho.libs.models.base_model import BaseModel
1111
from tally_ho.libs.models.enums.entry_version import EntryVersion
12+
from tally_ho.apps.tally.models.workflow_request import WorkflowRequest
1213

1314

1415
class ReconciliationFormSet(models.QuerySet):
@@ -84,6 +85,15 @@ class Meta:
8485
signature_dated = models.BooleanField(_('Is the form dated?'))
8586
notes = models.TextField(null=True, blank=True)
8687
objects = ReconciliationFormSet.as_manager()
88+
deactivated_by_request = models.ForeignKey(
89+
WorkflowRequest,
90+
null=True,
91+
blank=True,
92+
on_delete=models.SET_NULL,
93+
related_name='deactivated_recons',
94+
help_text=_(str("The workflow request that triggered the "
95+
"deactivation of this reconciliation record."))
96+
)
8797

8898
@property
8999
def number_ballots_used(self):

tally_ho/apps/tally/models/result.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from django.db import models
22
from enumfields import EnumIntegerField
33
import reversion
4+
from django.utils.translation import gettext_lazy as _
45

56
from tally_ho.apps.tally.models.candidate import Candidate
67
from tally_ho.apps.tally.models.result_form import ResultForm
78
from tally_ho.apps.tally.models.user_profile import UserProfile
89
from tally_ho.libs.models.base_model import BaseModel
910
from tally_ho.libs.models.enums.entry_version import EntryVersion
11+
from tally_ho.apps.tally.models.workflow_request import WorkflowRequest
1012

1113

1214
class Result(BaseModel):
@@ -26,6 +28,15 @@ class Meta:
2628
active = models.BooleanField(default=True)
2729
entry_version = EnumIntegerField(EntryVersion)
2830
votes = models.PositiveIntegerField()
31+
deactivated_by_request = models.ForeignKey(
32+
WorkflowRequest,
33+
null=True,
34+
blank=True,
35+
on_delete=models.SET_NULL,
36+
related_name='deactivated_results',
37+
help_text=_(str("The workflow request that triggered the "
38+
"deactivation of this result record."))
39+
)
2940

3041

3142
reversion.register(Result)

tally_ho/apps/tally/models/result_form.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,18 +389,29 @@ def corrections_passed(self):
389389
(not self.reconciliationform_exists or
390390
self.reconciliation_match))
391391

392-
def reject(self, new_state=FormState.DATA_ENTRY_1, reject_reason=None):
392+
def reject(
393+
self,
394+
new_state=FormState.DATA_ENTRY_1,
395+
reject_reason=None,
396+
workflow_request=None):
393397
"""Deactivate existing results and reconciliation forms for this result
394398
form, change the state, and increment the rejected count.
395399
396400
:param new_state: The state to set the form to.
401+
:param reject_reason: Optional reason text for the rejection.
402+
:param workflow_request: Optional WorkflowRequest instance that
403+
triggered this rejection.
397404
"""
398405
for result in self.results.all():
399406
result.active = False
407+
if workflow_request:
408+
result.deactivated_by_request = workflow_request
400409
result.save()
401410

402411
for recon in self.reconciliationform_set.all():
403412
recon.active = False
413+
if workflow_request:
414+
recon.deactivated_by_request = workflow_request
404415
recon.save()
405416

406417
self.rejected_count += 1
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# -*- coding: utf-8 -*-
2+
from django.db import models
3+
from django.utils.translation import gettext_lazy as _
4+
from enumfields import EnumIntegerField
5+
import reversion
6+
7+
from tally_ho.apps.tally.models.result_form import ResultForm
8+
from tally_ho.apps.tally.models.user_profile import UserProfile
9+
from tally_ho.libs.models.base_model import BaseModel
10+
from tally_ho.libs.models.enums.request_reason import RequestReason
11+
from tally_ho.libs.models.enums.request_status import RequestStatus
12+
from tally_ho.libs.models.enums.request_type import RequestType
13+
14+
15+
@reversion.register()
16+
class WorkflowRequest(BaseModel):
17+
class Meta:
18+
app_label = 'tally'
19+
indexes = [
20+
models.Index(fields=['result_form', 'request_type', 'status'])
21+
]
22+
23+
request_type = EnumIntegerField(
24+
RequestType, verbose_name=_("Request Type"))
25+
status = EnumIntegerField(
26+
RequestStatus,
27+
default=RequestStatus.PENDING,
28+
verbose_name=_("Request Status"))
29+
result_form = models.ForeignKey(
30+
ResultForm,
31+
on_delete=models.PROTECT,
32+
related_name='workflow_requests')
33+
requester = models.ForeignKey(
34+
UserProfile,
35+
on_delete=models.PROTECT,
36+
related_name='initiated_requests')
37+
request_reason = EnumIntegerField(RequestReason, verbose_name=_("Reason"))
38+
request_comment = models.TextField(verbose_name=_("Request Comment"))
39+
approver = models.ForeignKey(
40+
UserProfile,
41+
on_delete=models.PROTECT,
42+
related_name='approved_rejected_requests',
43+
null=True,
44+
blank=True)
45+
approval_comment = models.TextField(
46+
verbose_name=_("Approval/Rejection Comment"), null=True, blank=True)
47+
resolved_date = models.DateTimeField(null=True, blank=True)
48+
49+
def __str__(self):
50+
return str(
51+
f"{self.get_request_type_display()} request for "
52+
f"{self.result_form.barcode} - Status: "
53+
f"{self.get_status_display()}")
54+
55+
def is_pending(self):
56+
return self.status == RequestStatus.PENDING
57+
58+
def is_approved(self):
59+
return self.status == RequestStatus.APPROVED
60+
61+
def is_rejected(self):
62+
return self.status == RequestStatus.REJECTED
63+
64+
def can_be_actioned_by(self, user):
65+
"""
66+
Logic to determine if the user (Tally Manager/Super Admin)
67+
can approve/reject
68+
:param user: Logged in user
69+
:return: bool
70+
"""
71+
from tally_ho.libs.permissions import groups
72+
return user.is_authenticated and\
73+
groups.is_tally_manager(user) or\
74+
groups.is_super_administrator(user)
75+
76+
def can_be_viewed_by(self, user):
77+
"""
78+
Logic to determine who can view the request
79+
Audit Clerks/Supervisors, or any
80+
Tally Manager/Super Admin
81+
:param user: Logged in user
82+
:return: bool
83+
"""
84+
from tally_ho.libs.permissions import groups
85+
return (
86+
user.is_authenticated and (
87+
groups.is_audit_clerk(user) or
88+
groups.is_audit_supervisor(user) or
89+
groups.is_tally_manager(user) or
90+
groups.is_super_administrator(user)
91+
)
92+
)

0 commit comments

Comments
 (0)