diff --git a/commcare_connect/opportunity/migrations/0063_userinvite_notification_date.py b/commcare_connect/opportunity/migrations/0063_userinvite_notification_date.py new file mode 100644 index 00000000..b6a3bfe8 --- /dev/null +++ b/commcare_connect/opportunity/migrations/0063_userinvite_notification_date.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2024-12-10 06:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("opportunity", "0062_opportunityaccess_invited_date"), + ] + + operations = [ + migrations.AddField( + model_name="userinvite", + name="notification_date", + field=models.DateTimeField(null=True), + ), + ] diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index 6214d3fc..0ed25cf0 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -664,6 +664,7 @@ class UserInvite(models.Model): opportunity_access = models.OneToOneField(OpportunityAccess, on_delete=models.CASCADE, null=True, blank=True) message_sid = models.CharField(max_length=50, null=True, blank=True) status = models.CharField(max_length=50, choices=UserInviteStatus.choices, default=UserInviteStatus.invited) + notification_date = models.DateTimeField(null=True) class FormJsonValidationRules(models.Model): diff --git a/commcare_connect/opportunity/tables.py b/commcare_connect/opportunity/tables.py index c4f19f2e..eec4d74e 100644 --- a/commcare_connect/opportunity/tables.py +++ b/commcare_connect/opportunity/tables.py @@ -183,12 +183,24 @@ def render_view_profile(self, record): "opportunity:user_invite_delete", args=(self.org_slug, record.opportunity.id, record.id), ) + resend_invite_url = reverse( + "opportunity:resend_user_invite", + args=(self.org_slug, record.opportunity.id, record.id), + ) return format_html( ( - '' + """
+ + +
""" ), + resend_invite_url, invite_delete_url, ) url = reverse( diff --git a/commcare_connect/opportunity/tasks.py b/commcare_connect/opportunity/tasks.py index b5450cc1..6b5efafc 100644 --- a/commcare_connect/opportunity/tasks.py +++ b/commcare_connect/opportunity/tasks.py @@ -13,7 +13,7 @@ from tablib import Dataset from commcare_connect.connect_id_client import fetch_users, filter_users, send_message, send_message_bulk -from commcare_connect.connect_id_client.models import Message +from commcare_connect.connect_id_client.models import ConnectIdUser, Message from commcare_connect.opportunity.app_xml import get_connect_blocks_for_app, get_deliver_units_for_app from commcare_connect.opportunity.export import ( export_catchment_area_table, @@ -85,16 +85,20 @@ def add_connect_users( status=UserInviteStatus.not_found, ) for user in found_users: - u, _ = User.objects.update_or_create( - username=user.username, defaults={"phone_number": user.phone_number, "name": user.name} - ) - opportunity_access, _ = OpportunityAccess.objects.get_or_create(user=u, opportunity_id=opportunity_id) - UserInvite.objects.update_or_create( - opportunity_id=opportunity_id, - phone_number=user.phone_number, - defaults={"opportunity_access": opportunity_access}, - ) - invite_user.delay(u.pk, opportunity_access.pk) + update_user_and_send_invite(user, opportunity_id) + + +def update_user_and_send_invite(user: ConnectIdUser, opp_id): + u, _ = User.objects.update_or_create( + username=user.username, defaults={"phone_number": user.phone_number, "name": user.name} + ) + opportunity_access, _ = OpportunityAccess.objects.get_or_create(user=u, opportunity_id=opp_id) + UserInvite.objects.update_or_create( + opportunity_id=opp_id, + phone_number=user.phone_number, + defaults={"opportunity_access": opportunity_access}, + ) + invite_user.delay(u.pk, opportunity_access.pk) @celery_app.task() @@ -115,6 +119,7 @@ def invite_user(user_id, opportunity_access_id): opportunity_access=opportunity_access, defaults={ "message_sid": sms_status.sid, + "notification_date": now() if sms_status.sid else None, "status": UserInviteStatus.accepted if opportunity_access.accepted else UserInviteStatus.invited, }, ) diff --git a/commcare_connect/opportunity/urls.py b/commcare_connect/opportunity/urls.py index ef5cae31..15a988f2 100644 --- a/commcare_connect/opportunity/urls.py +++ b/commcare_connect/opportunity/urls.py @@ -38,6 +38,7 @@ payment_import, payment_report, reject_visit, + resend_user_invite, revoke_user_suspension, send_message_mobile_users, suspend_user, @@ -113,4 +114,5 @@ path("/invoice/create/", views.invoice_create, name="invoice_create"), path("/invoice/approve/", views.invoice_approve, name="invoice_approve"), path("/user_invite_delete//", views.user_invite_delete, name="user_invite_delete"), + path("/resend_invite/", resend_user_invite, name="resend_user_invite"), ] diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index fa8e425d..d3297f94 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -24,6 +24,7 @@ from django_tables2.export import TableExport from geopy import distance +from commcare_connect.connect_id_client import fetch_users from commcare_connect.form_receiver.serializers import XFormSerializer from commcare_connect.opportunity.api.serializers import remove_opportunity_access_cache from commcare_connect.opportunity.forms import ( @@ -65,6 +66,7 @@ PaymentInvoice, PaymentUnit, UserInvite, + UserInviteStatus, UserVisit, VisitReviewStatus, VisitValidationStatus, @@ -92,8 +94,10 @@ generate_user_status_export, generate_visit_export, generate_work_status_export, + invite_user, send_push_notification_task, send_sms_task, + update_user_and_send_invite, ) from commcare_connect.opportunity.visit_import import ( ImportException, @@ -1233,3 +1237,26 @@ def user_invite_delete(request, org_slug, opp_id, pk): invite = get_object_or_404(UserInvite, pk=pk, opportunity=opportunity) invite.delete() return HttpResponse(status=200, headers={"HX-Trigger": "userStatusReload"}) + + +@org_admin_required +@require_POST +def resend_user_invite(request, org_slug, opp_id, pk): + user_invite = get_object_or_404(UserInvite, id=pk) + + if user_invite.notification_date and (now() - user_invite.notification_date) < datetime.timedelta(days=1): + return HttpResponse("You can only send one invitation per user every 24 hours. Please try again later.") + + if user_invite.status == UserInviteStatus.not_found: + found_user_list = fetch_users([user_invite.phone_number]) + if not found_user_list: + return HttpResponse("The user is not registered on Connect ID yet. Please ask them to sign up first.") + + connect_user = found_user_list[0] + update_user_and_send_invite(connect_user, opp_id=pk) + else: + user = User.objects.get(phone_number=user_invite.phone_number) + access, _ = OpportunityAccess.objects.get_or_create(user=user, opportunity_id=opp_id) + invite_user.delay(user.id, access.pk) + + return HttpResponse("The invitation has been successfully resent to the user.") diff --git a/commcare_connect/static/js/project.js b/commcare_connect/static/js/project.js index ef6e4946..d59b3090 100644 --- a/commcare_connect/static/js/project.js +++ b/commcare_connect/static/js/project.js @@ -15,6 +15,17 @@ function refreshTooltips() { } window.refreshTooltips = refreshTooltips; +function handleResendInviteResponse(event) { + if (event.detail.successful) { + const response = event.detail.elt; + const resendModal = new bootstrap.Modal( + document.getElementById('resendInviteModal'), + ); + resendModal.show(); + } +} +window.handleResendInviteResponse = handleResendInviteResponse; + window.mapboxgl = mapboxgl; window.circle = circle; diff --git a/commcare_connect/templates/base.html b/commcare_connect/templates/base.html index f417ddab..2c28e58e 100644 --- a/commcare_connect/templates/base.html +++ b/commcare_connect/templates/base.html @@ -33,7 +33,7 @@ {% endblock javascript %} - +
+ + {% endblock content %} {% block modal %} @@ -568,4 +570,23 @@

{% translate "Import Catchment Areas" %} + + + + {% endblock modal %}