Skip to content

Commit

Permalink
Merge pull request #113 from dimagi/ce/user-invites
Browse files Browse the repository at this point in the history
add user invite and acceptance workflow
  • Loading branch information
calellowitz authored Sep 26, 2023
2 parents bd3f5b1 + 68d19e5 commit d092413
Show file tree
Hide file tree
Showing 14 changed files with 173 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.2.5 on 2023-09-14 01:11

from django.db import migrations, models
import uuid


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

operations = [
migrations.AddField(
model_name="opportunityaccess",
name="accepted",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="opportunityaccess",
name="invite_id",
field=models.CharField(default=uuid.uuid4, max_length=50),
),
migrations.AddIndex(
model_name="opportunityaccess",
index=models.Index(fields=["invite_id"], name="opportunity_invite__bc4919_idx"),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Generated by Django 4.2.5 on 2023-09-19 20:49

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("opportunity", "0016_opportunityaccess_accepted_and_more"),
("opportunity", "0017_alter_opportunityaccess_user_and_more"),
]

operations = []
11 changes: 8 additions & 3 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from uuid import uuid4

from django.db import models
from django.utils.translation import gettext

Expand Down Expand Up @@ -137,6 +139,12 @@ class OpportunityAccess(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
opportunity = models.ForeignKey(Opportunity, on_delete=models.CASCADE)
date_learn_started = models.DateTimeField(null=True)
accepted = models.BooleanField(default=False)
invite_id = models.CharField(max_length=50, default=uuid4)

class Meta:
indexes = [models.Index(fields=["invite_id"])]
unique_together = ("user", "opportunity")

# TODO: Convert to a field and calculate this property CompletedModule is saved
@property
Expand Down Expand Up @@ -166,9 +174,6 @@ def last_visit_date(self):

return

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


class VisitValidationStatus(models.TextChoices):
pending = "pending", gettext("Pending")
Expand Down
8 changes: 6 additions & 2 deletions commcare_connect/opportunity/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from commcare_connect.opportunity.export import export_user_visit_data
from commcare_connect.opportunity.forms import DateRanges
from commcare_connect.opportunity.models import LearnModule, Opportunity, OpportunityAccess, VisitValidationStatus
from commcare_connect.users.helpers import invite_user
from commcare_connect.users.models import User
from config import celery_app

Expand Down Expand Up @@ -38,8 +39,11 @@ def add_connect_users(user_list: list[str], opportunity_id: str):
)
data = result.json()
for user in data["found_users"]:
u, _ = User.objects.get_or_create(username=user["username"])
OpportunityAccess.objects.get_or_create(user=u, opportunity_id=opportunity_id)
u, _ = User.objects.get_or_create(
username=user["username"], phone_number=user["phone_number"], name=user["name"]
)
opportunity_access, _ = OpportunityAccess.objects.get_or_create(user=u, opportunity_id=opportunity_id)
invite_user(u, opportunity_access)


@celery_app.task()
Expand Down
18 changes: 12 additions & 6 deletions commcare_connect/opportunity/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,24 @@ class TestConnectUserCreation:
@pytest.mark.django_db
def test_add_connect_user(self):
opportunity = OpportunityFactory()
with mock.patch("commcare_connect.opportunity.tasks.requests.get") as request:
with (
mock.patch("commcare_connect.opportunity.tasks.requests.get") as request,
mock.patch("commcare_connect.users.helpers.send_sms"),
):
request.return_value.json.return_value = {
"found_users": [
{"username": "test", "phone_number": "+15555555555"},
{"username": "test2", "phone_number": "+12222222222"},
{"username": "test", "phone_number": "+15555555555", "name": "a"},
{"username": "test2", "phone_number": "+12222222222", "name": "b"},
]
}
add_connect_users(["+15555555555", "+12222222222"], opportunity.id)

user = User.objects.filter(username="test")
assert len(user) == 1
assert len(OpportunityAccess.objects.filter(user=user.first(), opportunity=opportunity)) == 1
user_list = User.objects.filter(username="test")
assert len(user_list) == 1
user = user_list[0]
assert user.name == "a"
assert user.phone_number == "+15555555555"
assert len(OpportunityAccess.objects.filter(user=user_list.first(), opportunity=opportunity)) == 1

user2 = User.objects.filter(username="test2")
assert len(user2) == 1
Expand Down
16 changes: 16 additions & 0 deletions commcare_connect/users/helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import requests
from allauth.utils import build_absolute_uri
from django.conf import settings
from django.urls import reverse

from commcare_connect.organization.models import Organization
from commcare_connect.utils.sms import send_sms


def get_organization_for_request(request, view_kwargs):
Expand Down Expand Up @@ -32,3 +35,16 @@ def create_hq_user(user, domain, api_key):
if hq_request.status_code == 201:
return True
return False


def invite_user(user, opportunity_access):
invite_id = opportunity_access.invite_id
location = reverse("users:accept_invite", args=(invite_id,))
url = build_absolute_uri(None, location)
body = (
"You have been invited to a new job in Commcare Connect. Click the following "
f"link to share your information with the project and find out more {url}"
)
if not user.phone_number:
return
send_sms(user.phone_number, body)
17 changes: 17 additions & 0 deletions commcare_connect/users/migrations/0010_user_phone_number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.5 on 2023-09-25 18:41

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0009_alter_user_options_alter_user_email_and_more"),
]

operations = [
migrations.AddField(
model_name="user",
name="phone_number",
field=models.CharField(blank=True, max_length=15, null=True),
),
]
1 change: 1 addition & 0 deletions commcare_connect/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class User(AbstractUser):
},
null=True,
)
phone_number = models.CharField(max_length=15, null=True, blank=True)

USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
Expand Down
2 changes: 2 additions & 0 deletions commcare_connect/users/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import path

from commcare_connect.users.views import (
accept_invite,
create_user_link_view,
start_learn_app,
user_detail_view,
Expand All @@ -15,4 +16,5 @@
path("<int:pk>/", view=user_detail_view, name="detail"),
path("create_user_link/", view=create_user_link_view, name="create_user_link"),
path("start_learn_app/", view=start_learn_app, name="start_learn_app"),
path("accept_invite/<slug:invite_id>", view=accept_invite, name="accept_invite"),
]
15 changes: 15 additions & 0 deletions commcare_connect/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from django.views.generic import DetailView, RedirectView, UpdateView, View
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from oauth2_provider.views.mixins import ClientProtectedResourceMixin
Expand Down Expand Up @@ -104,3 +105,17 @@ def start_learn_app(request):
access_object.date_learn_started = datetime.utcnow()
access_object.save()
return HttpResponse(status=200)


@require_GET
def accept_invite(request, invite_id):
try:
o = OpportunityAccess.objects.get(invite_id=invite_id)
except OpportunityAccess.DoesNotExist:
return HttpResponse("This link is invalid. Please try again", status=404)
o.accepted = True
o.save()
return HttpResponse(
"Thank you for accepting the invitation. Open your CommCare Connect App to "
"see more information about the opportunity and begin learning"
)
13 changes: 13 additions & 0 deletions commcare_connect/utils/sms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.conf import settings
from twilio.rest import Client


class SMSException(Exception):
pass


def send_sms(to, body):
if not (settings.TWILIO_ACCOUNT_SID and settings.TWILIO_AUTH_TOKEN and settings.TWILIO_MESSAGING_SERVICE):
raise SMSException("Twilio credentials not provided")
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
client.messages.create(body=body, to=to, messaging_service_sid=settings.TWILIO_MESSAGING_SERVICE)
4 changes: 4 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,7 @@
CONNECTID_CLIENT_SECRET,
),
}

TWILIO_ACCOUNT_SID = env("TWILIO_SID", default=None)
TWILIO_AUTH_TOKEN = env("TWILIO_TOKEN", default=None)
TWILIO_MESSAGING_SERVICE = env("TWILIO_MESSAGING_SERVICE", default=None)
1 change: 1 addition & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jsonpath-ng
quickcache
tablib[xlsx]
flatten-dict
twilio

# Django
# ------------------------------------------------------------------------------
Expand Down
42 changes: 39 additions & 3 deletions requirements/base.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# inv requirements
#
aiohttp==3.8.5
# via
# aiohttp-retry
# twilio
aiohttp-retry==2.8.3
# via twilio
aiosignal==1.3.1
# via aiohttp
amqp==5.1.1
# via kombu
anyio==3.7.1
Expand All @@ -14,8 +22,13 @@ argon2-cffi-bindings==21.2.0
# via argon2-cffi
asgiref==3.7.2
# via django
async-timeout==4.0.3
# via
# aiohttp
# redis
attrs==23.1.0
# via
# aiohttp
# jsonschema
# referencing
billiard==4.1.0
Expand All @@ -34,7 +47,9 @@ cffi==1.15.1
# argon2-cffi-bindings
# cryptography
charset-normalizer==3.2.0
# via requests
# via
# aiohttp
# requests
click==8.1.6
# via
# celery
Expand Down Expand Up @@ -106,8 +121,14 @@ drf-spectacular==0.26.4
# via -r requirements/base.in
et-xmlfile==1.1.0
# via openpyxl
exceptiongroup==1.1.3
# via anyio
flatten-dict==0.4.2
# via -r requirements/base.in
frozenlist==1.4.0
# via
# aiohttp
# aiosignal
h11==0.14.0
# via httpcore
h2==4.1.0
Expand All @@ -127,6 +148,7 @@ idna==3.4
# anyio
# httpx
# requests
# yarl
inflection==0.5.1
# via drf-spectacular
jsonpath-ng==1.5.3
Expand All @@ -139,6 +161,10 @@ jwcrypto==1.5.0
# via django-oauth-toolkit
kombu==5.3.2
# via celery
multidict==6.0.4
# via
# aiohttp
# yarl
oauthlib==3.2.2
# via
# django-oauth-toolkit
Expand All @@ -154,7 +180,9 @@ prompt-toolkit==3.0.39
pycparser==2.21
# via cffi
pyjwt[crypto]==2.8.0
# via django-allauth
# via
# django-allauth
# twilio
python-crontab==3.0.0
# via django-celery-beat
python-dateutil==2.8.2
Expand All @@ -169,6 +197,7 @@ pytz==2023.3
# via
# django-timezone-field
# djangorestframework
# twilio
pyyaml==6.0.1
# via drf-spectacular
quickcache==0.5.4
Expand All @@ -186,6 +215,7 @@ requests==2.31.0
# django-allauth
# django-oauth-toolkit
# requests-oauthlib
# twilio
requests-oauthlib==1.3.1
# via django-allauth
rpds-py==0.9.2
Expand All @@ -209,6 +239,10 @@ tablib[xlsx]==3.5.0
# via -r requirements/base.in
text-unidecode==1.3
# via python-slugify
twilio==8.8.0
# via -r requirements/base.in
typing-extensions==4.7.1
# via asgiref
tzdata==2023.3
# via
# celery
Expand All @@ -230,3 +264,5 @@ whitenoise==6.5.0
# via -r requirements/base.in
wrapt==1.15.0
# via deprecated
yarl==1.9.2
# via aiohttp

0 comments on commit d092413

Please sign in to comment.