Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resending invites #438

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
18 changes: 15 additions & 3 deletions commcare_connect/opportunity/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
(
'<button hx-post="{}" hx-swap="none" '
'hx-confirm="Please confirm to delete the User Invite." '
'class="btn btn-danger btn-sm">Delete</button>'
"""<div class="d-flex gap-1">
<button title="Resend invitation"
hx-post="{}" hx-target="#modalBodyContent" hx-trigger="click"
hx-on::after-request="handleResendInviteResponse(event)"
class="btn btn-sm btn-success">Resend</button>
<button title="Delete invitation"
hx-post="{}" hx-swap="none" hx-confirm="Please confirm to delete the User Invite."
class="btn btn-sm btn-danger" type="button"><i class="bi bi-trash"></i>
</button>
</div>"""
),
resend_invite_url,
invite_delete_url,
)
url = reverse(
Expand Down
27 changes: 16 additions & 11 deletions commcare_connect/opportunity/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for this check?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the SID is not present, then the SMS is not sent, right? And if the SMS is not sent, we do not set the timestamp, so the user can trigger it again before 24 hours. Otherwise, if we set it to now() every time, the user won't be able to send the SMS again before 24 hours, even though the SMS was not sent earlier.

"status": UserInviteStatus.accepted if opportunity_access.accepted else UserInviteStatus.invited,
},
)
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 @@ -38,6 +38,7 @@
payment_import,
payment_report,
reject_visit,
resend_user_invite,
revoke_user_suspension,
send_message_mobile_users,
suspend_user,
Expand Down Expand Up @@ -113,4 +114,5 @@
path("<int:pk>/invoice/create/", views.invoice_create, name="invoice_create"),
path("<int:pk>/invoice/approve/", views.invoice_approve, name="invoice_approve"),
path("<int:opp_id>/user_invite_delete/<int:pk>/", views.user_invite_delete, name="user_invite_delete"),
path("<int:opp_id>/resend_invite/<int:pk>", resend_user_invite, name="resend_user_invite"),
]
27 changes: 27 additions & 0 deletions commcare_connect/opportunity/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -65,6 +66,7 @@
PaymentInvoice,
PaymentUnit,
UserInvite,
UserInviteStatus,
UserVisit,
VisitReviewStatus,
VisitValidationStatus,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.")
11 changes: 11 additions & 0 deletions commcare_connect/static/js/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion commcare_connect/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

{% endblock javascript %}
</head>
<body class="bg-light">
<body class="bg-light" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<div class="mb-1">
<nav class="navbar navbar-expand-lg bg-primary" data-bs-theme="dark">
<div class="container">
Expand Down
21 changes: 21 additions & 0 deletions commcare_connect/templates/opportunity/opportunity_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ <h2 class="accordion-header" id="headingTwo">
</div>
</div>
</div>


{% endblock content %}

{% block modal %}
Expand Down Expand Up @@ -568,4 +570,23 @@ <h1 class="modal-title fs-5 text-white">{% translate "Import Catchment Areas" %}
</div>
</div>

<div class="modal fade" id="resendInviteModal" tabindex="-1" aria-labelledby="resendInviteModalLabel" aria-hidden="true">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we change the current modal implementation to bootstrap alerts to show any responses returned from the server? This will keep the UX similar across the website.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am happy to do that. I assume it would be shown at the top level, but the problem is that when the user scrolls down, they won't see the alert unless they manually scroll back up to the top, or we trigger the scroll.

<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="resendInviteModalLabel">Alert</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="modalBodyContent">
<!-- HTMX will inject the response content here -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>



{% endblock modal %}
Loading