Skip to content

Commit

Permalink
Merge pull request #301 from dimagi/pkv/verification-flags
Browse files Browse the repository at this point in the history
Verification Flags Config
  • Loading branch information
pxwxnvermx committed May 2, 2024
2 parents 0008fc9 + 4edff0c commit 271e8b7
Show file tree
Hide file tree
Showing 10 changed files with 224 additions and 20 deletions.
5 changes: 4 additions & 1 deletion commcare_connect/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
OpportunityAccessFactory,
OpportunityClaimFactory,
OpportunityFactory,
OpportunityVerificationFlagsFactory,
PaymentUnitFactory,
)
from commcare_connect.organization.models import Organization
Expand Down Expand Up @@ -47,7 +48,9 @@ def user(db) -> User:

@pytest.fixture()
def opportunity():
return OpportunityFactory()
factory = OpportunityFactory()
OpportunityVerificationFlagsFactory(opportunity=factory)
return factory


@pytest.fixture
Expand Down
54 changes: 36 additions & 18 deletions commcare_connect/form_receiver/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
OpportunityAccess,
OpportunityClaim,
OpportunityClaimLimit,
OpportunityVerificationFlags,
UserVisit,
VisitValidationStatus,
)
Expand Down Expand Up @@ -201,27 +202,44 @@ def process_deliver_unit(user, xform: XForm, app: CommCareApp, opportunity: Oppo
user_visit.status = VisitValidationStatus.duplicate

flags = []
if user_visit.status == VisitValidationStatus.duplicate:
flags.append(["duplicate", "A beneficiary with the same identifier already exists"])
if xform.metadata.duration < datetime.timedelta(seconds=60):
opportunity_flags, _ = OpportunityVerificationFlags.objects.get_or_create(opportunity=opportunity)
if counts["entity"] > 0:
user_visit.status = VisitValidationStatus.duplicate
if opportunity_flags.duplicate:
flags.append(["duplicate", "A beneficiary with the same identifier already exists"])
if opportunity_flags.duration > 0 and xform.metadata.duration < datetime.timedelta(
minutes=opportunity_flags.duration
):
flags.append(["duration", "The form was completed too quickly."])
if xform.metadata.location is None:
flags.append(["gps", "GPS data is missing"])
if opportunity_flags.gps:
flags.append(["gps", "GPS data is missing"])
else:
user_visits = (
UserVisit.objects.filter(opportunity=opportunity, deliver_unit=deliver_unit)
.exclude(Q(status=VisitValidationStatus.trial) | Q(entity_id=user_visit.entity_id))
.values("location")
)
cur_lat, cur_lon, *_ = xform.metadata.location.split(" ")
for visit in user_visits:
if visit.get("location") is None:
continue
lat, lon, *_ = visit["location"].split(" ")
dist = distance((lat, lon), (cur_lat, cur_lon))
if dist.m <= 10:
flags.append(["location", "Visit location is too close to another visit"])
break
if opportunity_flags.location > 0:
user_visits = (
UserVisit.objects.filter(opportunity=opportunity, deliver_unit=deliver_unit)
.exclude(Q(status=VisitValidationStatus.trial) | Q(entity_id=user_visit.entity_id))
.values("location")
)
cur_lat, cur_lon, *_ = xform.metadata.location.split(" ")
for visit in user_visits:
if visit.get("location") is None:
continue
lat, lon, *_ = visit["location"].split(" ")
dist = distance((lat, lon), (cur_lat, cur_lon))
if dist.m <= 10:
flags.append(["location", "Visit location is too close to another visit"])
break
if (
opportunity_flags.form_submission_start
and opportunity_flags.form_submission_start > xform.metadata.timeStart.time()
):
flags.append(["form_submission_period", "Form was submitted before the start time"])
if (
opportunity_flags.form_submission_end
and opportunity_flags.form_submission_end < xform.metadata.timeStart.time()
):
flags.append(["form_submission_period", "Form was submitted after the end time"])
if flags:
user_visit.flagged = True
user_visit.flag_reason = {"flags": flags}
Expand Down
52 changes: 52 additions & 0 deletions commcare_connect/form_receiver/tests/test_receiver_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Opportunity,
OpportunityAccess,
OpportunityClaimLimit,
OpportunityVerificationFlags,
UserVisit,
VisitValidationStatus,
)
Expand Down Expand Up @@ -447,6 +448,57 @@ def test_auto_approve_visits_and_payments(
assert access.payment_accrued == completed_work.payment_accrued


def test_reciever_verification_flags_form_submission(
user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity
):
verification_flags = OpportunityVerificationFlags.objects.get(opportunity=opportunity)
verification_flags.form_submission_start = datetime.time(hour=10, minute=0)
verification_flags.form_submission_end = datetime.time(hour=12, minute=0)
verification_flags.save()

form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link)
time = datetime.datetime(2024, 4, 17, 10, 0, 0)
form_json["metadata"]["timeStart"] = time
form_json["metadata"]["timeEnd"] = time + datetime.timedelta(minutes=10)
make_request(api_client, form_json, user_with_connectid_link)
visit = UserVisit.objects.get(user=user_with_connectid_link)
assert not visit.flagged


def test_reciever_verification_flags_form_submission_start(
user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity
):
verification_flags = OpportunityVerificationFlags.objects.get(opportunity=opportunity)
verification_flags.form_submission_start = datetime.time(hour=10, minute=0)
verification_flags.form_submission_end = datetime.time(hour=12, minute=0)
verification_flags.save()

form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link)
time = datetime.datetime(2024, 4, 17, 9, 0, 0)
form_json["metadata"]["timeStart"] = time
make_request(api_client, form_json, user_with_connectid_link)
visit = UserVisit.objects.get(user=user_with_connectid_link)
assert visit.flagged
assert ["form_submission_period", "Form was submitted before the start time"] in visit.flag_reason.get("flags", [])


def test_reciever_verification_flags_form_submission_end(
user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity
):
verification_flags = OpportunityVerificationFlags.objects.get(opportunity=opportunity)
verification_flags.form_submission_start = datetime.time(hour=10, minute=0)
verification_flags.form_submission_end = datetime.time(hour=12, minute=0)
verification_flags.save()

form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link)
time = datetime.datetime(2024, 4, 17, 13, 0, 0)
form_json["metadata"]["timeStart"] = time
make_request(api_client, form_json, user_with_connectid_link)
visit = UserVisit.objects.get(user=user_with_connectid_link)
assert visit.flagged
assert ["form_submission_period", "Form was submitted after the end time"] in visit.flag_reason.get("flags", [])


def _get_form_json(learn_app, module_id, form_block=None):
form_json = get_form_json(
form_block=form_block or LearnModuleJsonFactory(id=module_id).json,
Expand Down
55 changes: 54 additions & 1 deletion commcare_connect/opportunity/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json

from crispy_forms.helper import FormHelper, Layout
from crispy_forms.layout import HTML, Field, Fieldset, Row, Submit
from crispy_forms.layout import HTML, Column, Field, Fieldset, Row, Submit
from dateutil.relativedelta import relativedelta
from django import forms
from django.db.models import TextChoices
Expand All @@ -15,6 +15,7 @@
HQApiKey,
Opportunity,
OpportunityAccess,
OpportunityVerificationFlags,
PaymentUnit,
VisitValidationStatus,
)
Expand Down Expand Up @@ -599,3 +600,55 @@ def __init__(self, *args, **kwargs):

choices = [(user.pk, user.username) for user in users]
self.fields["selected_users"] = forms.MultipleChoiceField(choices=choices)


class OpportunityVerificationFlagsConfigForm(forms.ModelForm):
class Meta:
model = OpportunityVerificationFlags
fields = ("duplicate", "duration", "gps", "location", "form_submission_start", "form_submission_end")
widgets = {
"form_submission_start": forms.TimeInput(attrs={"type": "time", "class": "form-control"}),
"form_submission_end": forms.TimeInput(attrs={"type": "time", "class": "form-control"}),
}
labels = {
"duplicate": "Check Duplicates",
"gps": "Check GPS",
"form_submission_start": "Start Time",
"form_submission_end": "End Time",
"location": "Location Distance",
}
help_texts = {
"duration": "Minimum time to complete form (minutes)",
"location": "Minimum distance between form locations (metres)",
"duplicate": "Flag duplicate form submissions for an entity.",
"gps": "Flag forms with no location information.",
}

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self.helper = FormHelper(self)
self.helper.layout = Layout(
Row(
Field("duplicate", css_class="form-check-input", wrapper_class="form-check form-switch"),
Field("gps", css_class="form-check-input", wrapper_class="form-check form-switch"),
),
Row(Field("duration")),
Row(Field("location")),
Fieldset(
"Form Submission Hours",
Row(
Column(Field("form_submission_start")),
Column(Field("form_submission_end")),
),
),
Submit(name="submit", value="Submit"),
)

self.fields["duplicate"].required = False
self.fields["duration"].required = False
self.fields["location"].required = False
self.fields["gps"].required = False
if self.instance:
self.fields["form_submission_start"].initial = self.instance.form_submission_start
self.fields["form_submission_end"].initial = self.instance.form_submission_end
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 4.2.5 on 2024-05-02 16:24

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("opportunity", "0043_remove_uservisit_is_trial_alter_uservisit_status"),
]

operations = [
migrations.CreateModel(
name="OpportunityVerificationFlags",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("duration", models.PositiveIntegerField(default=1)),
("gps", models.BooleanField(default=True)),
("duplicate", models.BooleanField(default=True)),
("location", models.PositiveIntegerField(default=10)),
("form_submission_start", models.TimeField(blank=True, null=True)),
("form_submission_end", models.TimeField(blank=True, null=True)),
(
"opportunity",
models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="opportunity.opportunity"),
),
],
),
]
10 changes: 10 additions & 0 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ def is_active(self):
return self.active and self.end_date and self.end_date >= now().date()


class OpportunityVerificationFlags(models.Model):
opportunity = models.OneToOneField(Opportunity, on_delete=models.CASCADE)
duration = models.PositiveIntegerField(default=1)
gps = models.BooleanField(default=True)
duplicate = models.BooleanField(default=True)
location = models.PositiveIntegerField(default=10)
form_submission_start = models.TimeField(null=True, blank=True)
form_submission_end = models.TimeField(null=True, blank=True)


class LearnModule(models.Model):
app = models.ForeignKey(
CommCareApp,
Expand Down
7 changes: 7 additions & 0 deletions commcare_connect/opportunity/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ class Meta:
model = "opportunity.Opportunity"


class OpportunityVerificationFlagsFactory(DjangoModelFactory):
opportunity = SubFactory(OpportunityFactory)

class Meta:
model = "opportunity.OpportunityVerificationFlags"


class LearnModuleFactory(DjangoModelFactory):
app = SubFactory(CommCareAppFactory)
slug = Faker("pystr")
Expand Down
2 changes: 2 additions & 0 deletions commcare_connect/opportunity/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
update_visit_status_import,
user_profile,
user_visits_list,
verification_flags_config,
visit_verification,
)

Expand Down Expand Up @@ -87,4 +88,5 @@
path("<int:pk>/completed_work_table/", view=OpportunityCompletedWorkTable.as_view(), name="completed_work_table"),
path("<int:pk>/completed_work_export/", view=export_completed_work, name="completed_work_export"),
path("<int:pk>/completed_work_import/", view=update_completed_work_status_import, name="completed_work_import"),
path("<int:pk>/verification_flags_config/", view=verification_flags_config, name="verification_flags_config"),
]
23 changes: 23 additions & 0 deletions commcare_connect/opportunity/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
OpportunityCreationForm,
OpportunityFinalizeForm,
OpportunityInitForm,
OpportunityVerificationFlagsConfigForm,
PaymentExportForm,
PaymentUnitForm,
SendMessageMobileUsersForm,
Expand All @@ -46,6 +47,7 @@
OpportunityAccess,
OpportunityClaim,
OpportunityClaimLimit,
OpportunityVerificationFlags,
Payment,
PaymentUnit,
UserVisit,
Expand Down Expand Up @@ -768,6 +770,27 @@ def fetch_attachment(self, org_slug, blob_id):
return FileResponse(attachment, filename=blob_meta.name, content_type=blob_meta.content_type)


@org_member_required
def verification_flags_config(request, org_slug=None, pk=None):
opportunity = get_object_or_404(Opportunity, pk=pk, organization=request.org)
verification_flags = OpportunityVerificationFlags.objects.filter(opportunity=opportunity).first()
form = OpportunityVerificationFlagsConfigForm(instance=verification_flags, data=request.POST or None)

if form.is_valid():
verification_flags = form.save(commit=False)
verification_flags.opportunity = opportunity
verification_flags.save()
return redirect("opportunity:detail", request.org.slug, opportunity.id)

return render(
request,
"form.html",
context=dict(
title=f"{request.org.slug} - {opportunity.name}", form_title="Verification Flags Configuration", form=form
),
)


class OpportunityCompletedWorkTable(OrganizationUserMixin, SingleTableView):
model = CompletedWork
paginate_by = 25
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ <h1 class="mb-0">{{ object.name }}</h1>
</a>
</li>
{% endif %}
<li>
<a class="dropdown-item"
href="{% url 'opportunity:verification_flags_config' org_slug=request.org.slug pk=opportunity.id %}">
<i class="bi bi-plus-circle-fill pe-2"></i>
{% translate "Configure Verification Flags" %}
</a>
</li>
<li class="dropdown-divider"></li>
<li>
<a class="dropdown-item" data-bs-toggle="modal" data-bs-target="#exportVisitModal">
Expand Down

0 comments on commit 271e8b7

Please sign in to comment.