Skip to content

Commit

Permalink
Merge branch 'main' into ce/username
Browse files Browse the repository at this point in the history
  • Loading branch information
calellowitz committed Sep 19, 2023
2 parents 1d26622 + 3f3fa9f commit 0b9c214
Show file tree
Hide file tree
Showing 17 changed files with 338 additions and 25 deletions.
12 changes: 10 additions & 2 deletions commcare_connect/form_receiver/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,15 @@ def get_app(domain, app_id):


def get_user(xform: XForm):
user = User.objects.filter(connectiduserlink__commcare_username=xform.metadata.username).first()
cc_username = _get_commcare_username(xform)
user = User.objects.filter(connectiduserlink__commcare_username=cc_username).first()
if not user:
raise ProcessingError(f"Commcare User {xform.metadata.username} not found")
raise ProcessingError(f"Commcare User {cc_username} not found")
return user


def _get_commcare_username(xform: XForm):
username = xform.metadata.username
if "@" in username:
return username
return f"{username}@{xform.domain}.commcarehq.org"
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,27 @@
from commcare_connect.form_receiver.tests.xforms import AssessmentStubFactory, LearnModuleJsonFactory, get_form_json
from commcare_connect.opportunity.models import Assessment, CompletedModule, LearnModule, Opportunity, UserVisit
from commcare_connect.opportunity.tests.factories import LearnModuleFactory, OpportunityFactory
from commcare_connect.users.models import User
from commcare_connect.users.models import ConnectIDUserLink, User
from commcare_connect.users.tests.factories import MobileUserFactory


@pytest.fixture()
def opportunity():
return OpportunityFactory()


@pytest.fixture
def mobile_user_with_connect_link(db, opportunity: Opportunity) -> User:
user = MobileUserFactory()
links = [ConnectIDUserLink(user=user, commcare_username=f"test@{opportunity.learn_app.cc_domain}.commcarehq.org")]
if opportunity.learn_app.cc_domain != opportunity.deliver_app.cc_domain:
links.append(
ConnectIDUserLink(user=user, commcare_username=f"test@{opportunity.deliver_app.cc_domain}.commcarehq.org")
)
ConnectIDUserLink.objects.bulk_create(links)
return user


@pytest.mark.django_db
def test_form_receiver_learn_module(
mobile_user_with_connect_link: User, api_client: APIClient, opportunity: Opportunity
Expand Down
16 changes: 12 additions & 4 deletions commcare_connect/opportunity/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from rest_framework import serializers

from commcare_connect.opportunity.models import CommCareApp, Opportunity, UserVisit
from commcare_connect.opportunity.models import CommCareApp, CompletedModule, Opportunity, OpportunityClaim, UserVisit


class CommCareAppSerializer(serializers.ModelSerializer):
Expand All @@ -15,6 +15,7 @@ class OpportunitySerializer(serializers.ModelSerializer):
organization = serializers.SlugRelatedField(read_only=True, slug_field="slug")
learn_app = CommCareAppSerializer()
deliver_app = CommCareAppSerializer()
claim = serializers.SerializerMethodField()

class Meta:
model = Opportunity
Expand All @@ -32,12 +33,19 @@ class Meta:
"daily_max_visits_per_user",
"budget_per_visit",
"total_budget",
"claim",
]

def get_claim(self, obj):
opp_access_qs = self.context.get("opportunity_access")
opp_access = opp_access_qs.filter(opportunity=obj).first()
return OpportunityClaim.objects.filter(opportunity_access=opp_access).first()

class UserLearnProgressSerializer(serializers.Serializer):
completed_modules = serializers.IntegerField()
total_modules = serializers.IntegerField()

class UserLearnProgressSerializer(serializers.ModelSerializer):
class Meta:
model = CompletedModule
fields = ["module", "date", "duration"]


class UserVisitSerializer(serializers.ModelSerializer):
Expand Down
57 changes: 44 additions & 13 deletions commcare_connect/opportunity/api/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from rest_framework import viewsets
from rest_framework.generics import get_object_or_404
from rest_framework.generics import ListAPIView, get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
Expand All @@ -9,7 +9,14 @@
UserLearnProgressSerializer,
UserVisitSerializer,
)
from commcare_connect.opportunity.models import CompletedModule, LearnModule, Opportunity, OpportunityAccess, UserVisit
from commcare_connect.opportunity.models import (
CompletedModule,
Opportunity,
OpportunityAccess,
OpportunityClaim,
UserVisit,
)
from commcare_connect.users.helpers import create_hq_user


class OpportunityViewSet(viewsets.ReadOnlyModelViewSet):
Expand All @@ -19,22 +26,22 @@ class OpportunityViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return Opportunity.objects.filter(opportunityaccess__user=self.request.user)

def get_serializer_context(self):
context = super().get_serializer_context()
opportunity_access = OpportunityAccess.objects.filter(user=self.request.user)
context.update({"opportunity_access": opportunity_access})
return context


class UserLearnProgressView(APIView):
class UserLearnProgressView(ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = UserLearnProgressSerializer

def get(self, *args, **kwargs):
opportunity_access = get_object_or_404(OpportunityAccess, user=self.request.user, opportunity=kwargs.get("pk"))
completed_modules = CompletedModule.objects.filter(
user=self.request.user, opportunity=opportunity_access.opportunity
def get_queryset(self):
opportunity_access = get_object_or_404(
OpportunityAccess, user=self.request.user, opportunity=self.kwargs.get("pk")
)
total_modules = LearnModule.objects.filter(app=opportunity_access.opportunity.learn_app)
ret = {
"completed_modules": completed_modules.count(),
"total_modules": total_modules.count(),
}
return Response(UserLearnProgressSerializer(ret).data)
return CompletedModule.objects.filter(user=self.request.user, opportunity=opportunity_access.opportunity)


class UserVisitViewSet(viewsets.GenericViewSet, viewsets.mixins.ListModelMixin):
Expand All @@ -43,3 +50,27 @@ class UserVisitViewSet(viewsets.GenericViewSet, viewsets.mixins.ListModelMixin):

def get_queryset(self):
return UserVisit.objects.filter(opportunity=self.kwargs.get("opportunity_id"), user=self.request.user)


class ClaimOpportunityView(APIView):
permission_classes = [IsAuthenticated]

def post(self, *args, **kwargs):
opportunity_access = get_object_or_404(OpportunityAccess, user=self.request.user, opportunity=kwargs.get("pk"))
opportunity = opportunity_access.opportunity

claim, created = OpportunityClaim.objects.get_or_create(
opportunity_access=opportunity_access,
defaults={
"max_payments": opportunity.daily_max_visits_per_user,
"end_date": opportunity.end_date,
},
)

if not created:
return Response(status=200, data="Opportunity is already claimed")

if opportunity.learn_app.cc_domain != opportunity.deliver_app.cc_domain:
create_hq_user(self.request.user, opportunity.deliver_app.cc_domain, opportunity.api_key)

return Response(status=201)
18 changes: 18 additions & 0 deletions commcare_connect/opportunity/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,21 @@ class OpportunityAccessCreationForm(forms.ModelForm):
class Meta:
model = OpportunityAccess
fields = "__all__"


class AddBudgetExistingUsersForm(forms.Form):
additional_visits = forms.IntegerField(
min_value=1,
widget=forms.NumberInput(attrs={"x-model": "additionalVisits"}),
)
end_date = forms.DateField(
widget=forms.DateInput(attrs={"type": "date", "class": "form-input"}),
label="Extended Opportunity End date",
)

def __init__(self, *args, **kwargs):
opportunity_claims = kwargs.pop("opportunity_claims", [])
super().__init__(*args, **kwargs)

choices = [(opp_claim.id, opp_claim.id) for opp_claim in opportunity_claims]
self.fields["selected_users"] = forms.MultipleChoiceField(choices=choices, widget=forms.CheckboxSelectMultiple)
28 changes: 28 additions & 0 deletions commcare_connect/opportunity/migrations/0016_opportunityclaim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 4.2.3 on 2023-09-08 07:18

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


class Migration(migrations.Migration):
dependencies = [
("opportunity", "0015_remove_opportunityaccess_date_claimed"),
]

operations = [
migrations.CreateModel(
name="OpportunityClaim",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("date_claimed", models.DateField(auto_created=True)),
("max_payments", models.IntegerField()),
("end_date", models.DateField()),
(
"opportunity_access",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to="opportunity.opportunityaccess"
),
),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.3 on 2023-09-13 05:04

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


class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("opportunity", "0016_opportunityclaim"),
]

operations = [
migrations.AlterField(
model_name="opportunityaccess",
name="user",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AlterUniqueTogether(
name="opportunityaccess",
unique_together={("user", "opportunity")},
),
]
12 changes: 11 additions & 1 deletion commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class Assessment(XFormBaseModel):


class OpportunityAccess(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
opportunity = models.ForeignKey(Opportunity, on_delete=models.CASCADE)
date_learn_started = models.DateTimeField(null=True)

Expand Down Expand Up @@ -166,6 +166,9 @@ def last_visit_date(self):

return

class Meta:
unique_together = ("user", "opportunity")


class VisitValidationStatus(models.TextChoices):
pending = "pending", gettext("Pending")
Expand All @@ -191,3 +194,10 @@ class UserVisit(XFormBaseModel):
max_length=50, choices=VisitValidationStatus.choices, default=VisitValidationStatus.pending
)
form_json = models.JSONField()


class OpportunityClaim(models.Model):
opportunity_access = models.OneToOneField(OpportunityAccess, on_delete=models.CASCADE)
max_payments = models.IntegerField()
end_date = models.DateField()
date_claimed = models.DateField(auto_created=True)
6 changes: 6 additions & 0 deletions commcare_connect/opportunity/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
OpportunityUserLearnProgress,
OpportunityUserTableView,
OpportunityUserVisitTableView,
add_budget_existing_users,
download_visit_export,
export_user_visits,
update_visit_status_import,
Expand All @@ -31,4 +32,9 @@
view=OpportunityUserLearnProgress.as_view(),
name="user_learn_progress",
),
path(
"<int:pk>/add_budget",
view=add_budget_existing_users,
name="add_budget_existing_users",
),
]
35 changes: 34 additions & 1 deletion commcare_connect/opportunity/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.files.storage import storages
from django.db.models import F
from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
Expand All @@ -14,12 +15,19 @@
from django_tables2.export import TableExport

from commcare_connect.opportunity.forms import (
AddBudgetExistingUsersForm,
DateRanges,
OpportunityChangeForm,
OpportunityCreationForm,
VisitExportForm,
)
from commcare_connect.opportunity.models import CompletedModule, Opportunity, OpportunityAccess, UserVisit
from commcare_connect.opportunity.models import (
CompletedModule,
Opportunity,
OpportunityAccess,
OpportunityClaim,
UserVisit,
)
from commcare_connect.opportunity.tables import OpportunityAccessTable, UserVisitTable
from commcare_connect.opportunity.tasks import (
add_connect_users,
Expand Down Expand Up @@ -207,3 +215,28 @@ def update_visit_status_import(request, org_slug=None, pk=None):
message += status.get_missing_message()
messages.success(request, mark_safe(message))
return redirect("opportunity:detail", org_slug, pk)


@org_member_required
def add_budget_existing_users(request, org_slug=None, pk=None):
opportunity = get_object_or_404(Opportunity, organization=request.org, id=pk)
opportunity_access = OpportunityAccess.objects.filter(opportunity=opportunity)
opportunity_claims = OpportunityClaim.objects.filter(opportunity_access__in=opportunity_access)
form = AddBudgetExistingUsersForm(opportunity_claims=opportunity_claims)

if request.method == "POST":
form = AddBudgetExistingUsersForm(opportunity_claims=opportunity_claims, data=request.POST)
if form.is_valid():
selected_users = form.cleaned_data["selected_users"]
additional_visits = form.cleaned_data["additional_visits"]
end_date = form.cleaned_data["end_date"]
OpportunityClaim.objects.filter(pk__in=selected_users).update(
max_payments=F("max_payments") + additional_visits, end_date=end_date
)
return redirect("opportunity:detail", org_slug, pk)

return render(
request,
"opportunity/add_visits_existing_users.html",
{"form": form, "opportunity_claims": opportunity_claims, "budget_per_visit": opportunity.budget_per_visit},
)
4 changes: 4 additions & 0 deletions commcare_connect/static/js/vendors.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import '@popperjs/core';
import 'bootstrap';
import 'htmx.org';
import Alpine from 'alpinejs';

window.Alpine = Alpine;
Alpine.start();
Loading

0 comments on commit 0b9c214

Please sign in to comment.