diff --git a/commcare_connect/form_receiver/processor.py b/commcare_connect/form_receiver/processor.py index 2b837541..d927e8c4 100644 --- a/commcare_connect/form_receiver/processor.py +++ b/commcare_connect/form_receiver/processor.py @@ -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" diff --git a/commcare_connect/form_receiver/tests/test_receiver_integration.py b/commcare_connect/form_receiver/tests/test_receiver_integration.py index 7ca2f867..b21b429a 100644 --- a/commcare_connect/form_receiver/tests/test_receiver_integration.py +++ b/commcare_connect/form_receiver/tests/test_receiver_integration.py @@ -5,7 +5,8 @@ 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() @@ -13,6 +14,18 @@ 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 diff --git a/commcare_connect/opportunity/api/serializers.py b/commcare_connect/opportunity/api/serializers.py index 84a06907..04beb026 100644 --- a/commcare_connect/opportunity/api/serializers.py +++ b/commcare_connect/opportunity/api/serializers.py @@ -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): @@ -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 @@ -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): diff --git a/commcare_connect/opportunity/api/views.py b/commcare_connect/opportunity/api/views.py index c81b779e..6ffd2c2b 100644 --- a/commcare_connect/opportunity/api/views.py +++ b/commcare_connect/opportunity/api/views.py @@ -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 @@ -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): @@ -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): @@ -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) diff --git a/commcare_connect/opportunity/forms.py b/commcare_connect/opportunity/forms.py index dcef2056..c76327fa 100644 --- a/commcare_connect/opportunity/forms.py +++ b/commcare_connect/opportunity/forms.py @@ -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) diff --git a/commcare_connect/opportunity/migrations/0016_opportunityclaim.py b/commcare_connect/opportunity/migrations/0016_opportunityclaim.py new file mode 100644 index 00000000..cebb7ce5 --- /dev/null +++ b/commcare_connect/opportunity/migrations/0016_opportunityclaim.py @@ -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" + ), + ), + ], + ), + ] diff --git a/commcare_connect/opportunity/migrations/0017_alter_opportunityaccess_user_and_more.py b/commcare_connect/opportunity/migrations/0017_alter_opportunityaccess_user_and_more.py new file mode 100644 index 00000000..ba13a103 --- /dev/null +++ b/commcare_connect/opportunity/migrations/0017_alter_opportunityaccess_user_and_more.py @@ -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")}, + ), + ] diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index 42dfddd9..153f19aa 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -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) @@ -166,6 +166,9 @@ def last_visit_date(self): return + class Meta: + unique_together = ("user", "opportunity") + class VisitValidationStatus(models.TextChoices): pending = "pending", gettext("Pending") @@ -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) diff --git a/commcare_connect/opportunity/urls.py b/commcare_connect/opportunity/urls.py index 50d6dc63..51e65605 100644 --- a/commcare_connect/opportunity/urls.py +++ b/commcare_connect/opportunity/urls.py @@ -8,6 +8,7 @@ OpportunityUserLearnProgress, OpportunityUserTableView, OpportunityUserVisitTableView, + add_budget_existing_users, download_visit_export, export_user_visits, update_visit_status_import, @@ -31,4 +32,9 @@ view=OpportunityUserLearnProgress.as_view(), name="user_learn_progress", ), + path( + "/add_budget", + view=add_budget_existing_users, + name="add_budget_existing_users", + ), ] diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index 70f5af3f..a16bafe4 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -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 @@ -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, @@ -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}, + ) diff --git a/commcare_connect/static/js/vendors.js b/commcare_connect/static/js/vendors.js index d4ad2726..2f872ac5 100644 --- a/commcare_connect/static/js/vendors.js +++ b/commcare_connect/static/js/vendors.js @@ -1,3 +1,7 @@ import '@popperjs/core'; import 'bootstrap'; import 'htmx.org'; +import Alpine from 'alpinejs'; + +window.Alpine = Alpine; +Alpine.start(); diff --git a/commcare_connect/templates/opportunity/add_visits_existing_users.html b/commcare_connect/templates/opportunity/add_visits_existing_users.html new file mode 100644 index 00000000..7778620d --- /dev/null +++ b/commcare_connect/templates/opportunity/add_visits_existing_users.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% load static %} +{% load crispy_forms_tags %} + +{% block title %}{{ request.org }} - {{ opportunity.name }}{% endblock %} + +{% block content %} +
+

Edit Opportunity

+

Adding Visits for Existing Users

+
+ {% csrf_token %} + {% if form.selected_users.errors %} + + {% endif %} + {% if opportunity_claims %} + + + + + + + + + + + + {% for opp_claim in opportunity_claims %} + + + + + + + + {% endfor %} + +
NameMax VisitsUsed VisitsEnd date
+ + {{ opp_claim.opportunity_access.user.username }}{{ opp_claim.max_payments }}{{ opp_claim.opportunity_access.visit_count }}{{ opp_claim.end_date }}
+
+
+ {{ form.additional_visits|as_crispy_field }} +
+
+ {{ form.end_date|as_crispy_field }} +
+
+ + {% else %} +
+ No Opportunity Claims yet.
+ All + Opportunities +
+ {% endif %} + + + +
+
+ +{% endblock content %} diff --git a/commcare_connect/templates/opportunity/opportunity_list.html b/commcare_connect/templates/opportunity/opportunity_list.html index 8b6c207d..42d3e317 100644 --- a/commcare_connect/templates/opportunity/opportunity_list.html +++ b/commcare_connect/templates/opportunity/opportunity_list.html @@ -39,6 +39,9 @@

Opportunities

Edit + Add + Budget {% empty %} diff --git a/commcare_connect/users/tests/factories.py b/commcare_connect/users/tests/factories.py index 349f076f..1f8dceb7 100644 --- a/commcare_connect/users/tests/factories.py +++ b/commcare_connect/users/tests/factories.py @@ -53,7 +53,9 @@ class Meta: class MobileUserWithConnectIDLink(MobileUserFactory): - connectiduserlink = RelatedFactory(ConnectIdUserLinkFactory, "user", commcare_username="test") + connectiduserlink = RelatedFactory( + ConnectIdUserLinkFactory, "user", commcare_username="test@ccc-test.commcarehq.org" + ) class OrganizationFactory(DjangoModelFactory): diff --git a/config/api_router.py b/config/api_router.py index 43290cfb..cfb1d3ee 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -3,7 +3,12 @@ from rest_framework.routers import DefaultRouter, SimpleRouter from commcare_connect.form_receiver.views import FormReceiver -from commcare_connect.opportunity.api.views import OpportunityViewSet, UserLearnProgressView, UserVisitViewSet +from commcare_connect.opportunity.api.views import ( + ClaimOpportunityView, + OpportunityViewSet, + UserLearnProgressView, + UserVisitViewSet, +) from commcare_connect.users.api.views import UserViewSet if settings.DEBUG: @@ -19,5 +24,6 @@ urlpatterns = [ path("", include(router.urls)), path("receiver/", FormReceiver.as_view(), name="receiver"), - path("opportunity//learn_progress", UserLearnProgressView.as_view()), + path("opportunity//learn_progress", UserLearnProgressView.as_view(), name="learn_progress"), + path("opportunity//claim", ClaimOpportunityView.as_view()), ] diff --git a/package-lock.json b/package-lock.json index a287b654..81ad3455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "commcare_connect", "version": "0.1.0", "dependencies": { + "alpinejs": "^3.13.0", "bootstrap-icons": "^1.10.5", "htmx.org": "^1.9.4" }, @@ -2651,6 +2652,19 @@ "@types/node": "*" } }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==" + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -2932,6 +2946,14 @@ "ajv": "^8.8.2" } }, + "node_modules/alpinejs": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.0.tgz", + "integrity": "sha512-7FYR1Yz3evIjlJD1mZ3SYWSw+jlOmQGeQ1QiSufSQ6J84XMQFkzxm6OobiZ928SfqhGdoIp2SsABNsS4rXMMJw==", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", diff --git a/package.json b/package.json index b66765bc..48e11392 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "dev-watch": "webpack --mode development --config webpack/base.config.js --watch" }, "dependencies": { + "alpinejs": "^3.13.0", "bootstrap-icons": "^1.10.5", "htmx.org": "^1.9.4" }