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 %}