From bad92b52359cd9f33e49a5e49f5ba4893553ab76 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 12 Sep 2023 14:43:40 -0400 Subject: [PATCH 1/8] add user invite and acceptance workflow --- ...016_opportunityaccess_accepted_and_more.py | 26 ++++++++++ commcare_connect/opportunity/models.py | 5 ++ commcare_connect/opportunity/tasks.py | 6 ++- .../opportunity/tests/test_tasks.py | 9 ++-- commcare_connect/opportunity/urls.py | 2 + commcare_connect/opportunity/views.py | 11 +++++ commcare_connect/users/helpers.py | 14 ++++++ .../migrations/0010_user_phone_number.py | 18 +++++++ commcare_connect/users/models.py | 3 ++ commcare_connect/utils/sms.py | 10 ++++ config/settings/base.py | 7 +++ requirements/base.in | 2 + requirements/base.txt | 49 +++++++++++++++++-- 13 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 commcare_connect/opportunity/migrations/0016_opportunityaccess_accepted_and_more.py create mode 100644 commcare_connect/users/migrations/0010_user_phone_number.py create mode 100644 commcare_connect/utils/sms.py diff --git a/commcare_connect/opportunity/migrations/0016_opportunityaccess_accepted_and_more.py b/commcare_connect/opportunity/migrations/0016_opportunityaccess_accepted_and_more.py new file mode 100644 index 00000000..68062a4e --- /dev/null +++ b/commcare_connect/opportunity/migrations/0016_opportunityaccess_accepted_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.5 on 2023-09-12 15:11 + +from django.db import migrations, models + + +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(blank=True, max_length=50, null=True), + ), + migrations.AddIndex( + model_name="opportunityaccess", + index=models.Index(fields=["invite_id"], name="opportunity_invite__bc4919_idx"), + ), + ] diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index 42dfddd9..82daea7a 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -137,6 +137,11 @@ class OpportunityAccess(models.Model): user = models.OneToOneField(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, null=True, blank=True) + + class Meta: + indexes = [models.Index(fields=["invite_id"])] # TODO: Convert to a field and calculate this property CompletedModule is saved @property diff --git a/commcare_connect/opportunity/tasks.py b/commcare_connect/opportunity/tasks.py index 3c45fb90..8134cd27 100644 --- a/commcare_connect/opportunity/tasks.py +++ b/commcare_connect/opportunity/tasks.py @@ -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 @@ -38,8 +39,9 @@ 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(user, opportunity_access) @celery_app.task() diff --git a/commcare_connect/opportunity/tests/test_tasks.py b/commcare_connect/opportunity/tests/test_tasks.py index b660b208..c75ea9c5 100644 --- a/commcare_connect/opportunity/tests/test_tasks.py +++ b/commcare_connect/opportunity/tests/test_tasks.py @@ -15,14 +15,17 @@ def test_add_connect_user(self): with mock.patch("commcare_connect.opportunity.tasks.requests.get") as request: 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") + user_list = User.objects.filter(username="test") assert len(user) == 1 + user = user_list[0] + assert user.name == "a" + assert user.phone_number == "+15555555555" assert len(OpportunityAccess.objects.filter(user=user.first(), opportunity=opportunity)) == 1 user2 = User.objects.filter(username="test2") diff --git a/commcare_connect/opportunity/urls.py b/commcare_connect/opportunity/urls.py index 50d6dc63..0896623a 100644 --- a/commcare_connect/opportunity/urls.py +++ b/commcare_connect/opportunity/urls.py @@ -8,6 +8,7 @@ OpportunityUserLearnProgress, OpportunityUserTableView, OpportunityUserVisitTableView, + accept_invite, download_visit_export, export_user_visits, update_visit_status_import, @@ -31,4 +32,5 @@ view=OpportunityUserLearnProgress.as_view(), name="user_learn_progress", ), + path("accept_invite/", view=accept_invite, name="accept_invite"), ] diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index 70f5af3f..64fdda09 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -207,3 +207,14 @@ 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) + + +@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") diff --git a/commcare_connect/users/helpers.py b/commcare_connect/users/helpers.py index 0c2cea41..90143b63 100644 --- a/commcare_connect/users/helpers.py +++ b/commcare_connect/users/helpers.py @@ -1,7 +1,12 @@ +from uuid import uuid4 + import requests +from twilio.rest import Client + from django.conf import settings from commcare_connect.organization.models import Organization +from commcare_connect.utils.sms import send_sms def get_organization_for_request(request, view_kwargs): @@ -32,3 +37,12 @@ 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 = uuid4() + opportunity_access.invite_id = invite_id + opportunity_access.save() + url = "https://connect.dimagi.com/opportunity/accept_invite/{invite_id}" + body = f"You have been invited to a new job in Commcare Connect. Click the following link to share your information with the project and find out more {url}" + send_sms(user.phone_number, body) diff --git a/commcare_connect/users/migrations/0010_user_phone_number.py b/commcare_connect/users/migrations/0010_user_phone_number.py new file mode 100644 index 00000000..0b24047c --- /dev/null +++ b/commcare_connect/users/migrations/0010_user_phone_number.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-09-12 15:11 + +from django.db import migrations +import phonenumber_field.modelfields + + +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=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None), + ), + ] diff --git a/commcare_connect/users/models.py b/commcare_connect/users/models.py index 933290ca..b7c52e4a 100644 --- a/commcare_connect/users/models.py +++ b/commcare_connect/users/models.py @@ -1,3 +1,5 @@ +from phonenumber_field.modelfields import PhoneNumberField + from django.contrib.auth.models import AbstractUser from django.contrib.auth.validators import UnicodeUsernameValidator from django.db import models @@ -33,6 +35,7 @@ class User(AbstractUser): }, null=True, ) + phone_number = PhoneNumberField(null=True, blank=True) USERNAME_FIELD = "email" REQUIRED_FIELDS = [] diff --git a/commcare_connect/utils/sms.py b/commcare_connect/utils/sms.py new file mode 100644 index 00000000..8d8cfc4e --- /dev/null +++ b/commcare_connect/utils/sms.py @@ -0,0 +1,10 @@ +import twilio.rest import Client +from django.conf import settings + +def send_sms(to, body): + client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) + message = client.messages.create( + body=body, + to=to, + messaging_service_sid=settings.TWILIO_MESSAGING_SERVICE + ) diff --git a/config/settings/base.py b/config/settings/base.py index 78fadf86..8e47d8a7 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -65,6 +65,7 @@ "drf_spectacular", "oauth2_provider", "django_tables2", + "phonenumber_field", ] LOCAL_APPS = [ @@ -315,3 +316,9 @@ CONNECTID_CLIENT_SECRET, ), } + +BASE_ADDRESS = env("BASE_ADDRESS", default="http://localhost:8000") + +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) diff --git a/requirements/base.in b/requirements/base.in index 41e5aaa5..847269a4 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -11,6 +11,7 @@ jsonpath-ng quickcache tablib[xlsx] flatten-dict +twilio # Django # ------------------------------------------------------------------------------ @@ -20,6 +21,7 @@ django-model-utils django-allauth django-celery-beat django-crispy-forms +django-phonenumber-field[phonenumberslite] crispy-bootstrap5 django-redis # Django REST Framework diff --git a/requirements/base.txt b/requirements/base.txt index 2911e887..c8bf4213 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -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: # -# pip-compile --allow-unsafe --output-file=requirements/base.txt requirements/base.in +# pip-compile --allow-unsafe --config=pyproject.toml --output-file=requirements/base.txt requirements/base.in # +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 @@ -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 @@ -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 @@ -71,6 +86,7 @@ django==4.2.5 # django-crispy-forms # django-model-utils # django-oauth-toolkit + # django-phonenumber-field # django-redis # django-tables2 # django-timezone-field @@ -92,6 +108,8 @@ django-model-utils==4.3.1 # via -r requirements/base.in django-oauth-toolkit==2.3.0 # via -r requirements/base.in +django-phonenumber-field[phonenumberslite]==7.1.0 + # via -r requirements/base.in django-redis==5.3.0 # via -r requirements/base.in django-tables2==2.6.0 @@ -106,8 +124,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 @@ -127,6 +151,7 @@ idna==3.4 # anyio # httpx # requests + # yarl inflection==0.5.1 # via drf-spectacular jsonpath-ng==1.5.3 @@ -139,12 +164,18 @@ 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 # requests-oauthlib openpyxl==3.1.2 # via tablib +phonenumberslite==8.13.20 + # via django-phonenumber-field pillow==10.0.0 # via -r requirements/base.in ply==3.11 @@ -154,7 +185,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 @@ -169,6 +202,7 @@ pytz==2023.3 # via # django-timezone-field # djangorestframework + # twilio pyyaml==6.0.1 # via drf-spectacular quickcache==0.5.4 @@ -186,6 +220,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 @@ -209,6 +244,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 @@ -230,3 +269,5 @@ whitenoise==6.5.0 # via -r requirements/base.in wrapt==1.15.0 # via deprecated +yarl==1.9.2 + # via aiohttp From b4d2a2054d895f78ed0631ffc6b1564901ab428a Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 12 Sep 2023 21:53:15 -0400 Subject: [PATCH 2/8] lint and test fix --- commcare_connect/opportunity/tasks.py | 8 +++++--- commcare_connect/opportunity/tests/test_tasks.py | 7 +++++-- commcare_connect/opportunity/views.py | 4 +++- commcare_connect/users/helpers.py | 2 -- commcare_connect/users/models.py | 3 +-- commcare_connect/utils/sms.py | 9 +++------ 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/commcare_connect/opportunity/tasks.py b/commcare_connect/opportunity/tasks.py index 8134cd27..8d4aa044 100644 --- a/commcare_connect/opportunity/tasks.py +++ b/commcare_connect/opportunity/tasks.py @@ -39,9 +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"], phone_number=user["phone_number"], name=user["name"]) - opportunity_access = OpportunityAccess.objects.get_or_create(user=u, opportunity_id=opportunity_id) - invite_user(user, opportunity_access) + 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() diff --git a/commcare_connect/opportunity/tests/test_tasks.py b/commcare_connect/opportunity/tests/test_tasks.py index c75ea9c5..2b5dfdd1 100644 --- a/commcare_connect/opportunity/tests/test_tasks.py +++ b/commcare_connect/opportunity/tests/test_tasks.py @@ -12,7 +12,10 @@ 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") as send_sms + ): request.return_value.json.return_value = { "found_users": [ {"username": "test", "phone_number": "+15555555555", "name": "a"}, @@ -22,7 +25,7 @@ def test_add_connect_user(self): add_connect_users(["+15555555555", "+12222222222"], opportunity.id) user_list = User.objects.filter(username="test") - assert len(user) == 1 + assert len(user_list) == 1 user = user_list[0] assert user.name == "a" assert user.phone_number == "+15555555555" diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index 64fdda09..7604210f 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -217,4 +217,6 @@ def accept_invite(request, invite_id): 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") + return HttpResponse( + "Thank you for accepting the invitation. Open your CommCare Connect App to see more information about the opportunity and begin learning" + ) diff --git a/commcare_connect/users/helpers.py b/commcare_connect/users/helpers.py index 90143b63..7c3cd92d 100644 --- a/commcare_connect/users/helpers.py +++ b/commcare_connect/users/helpers.py @@ -1,8 +1,6 @@ from uuid import uuid4 import requests -from twilio.rest import Client - from django.conf import settings from commcare_connect.organization.models import Organization diff --git a/commcare_connect/users/models.py b/commcare_connect/users/models.py index b7c52e4a..ff849ea8 100644 --- a/commcare_connect/users/models.py +++ b/commcare_connect/users/models.py @@ -1,11 +1,10 @@ -from phonenumber_field.modelfields import PhoneNumberField - from django.contrib.auth.models import AbstractUser from django.contrib.auth.validators import UnicodeUsernameValidator from django.db import models from django.db.models import Q, UniqueConstraint from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from phonenumber_field.modelfields import PhoneNumberField from commcare_connect.users.managers import UserManager diff --git a/commcare_connect/utils/sms.py b/commcare_connect/utils/sms.py index 8d8cfc4e..1fc23c92 100644 --- a/commcare_connect/utils/sms.py +++ b/commcare_connect/utils/sms.py @@ -1,10 +1,7 @@ -import twilio.rest import Client from django.conf import settings +from twilio.rest import Client + def send_sms(to, body): client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) - message = client.messages.create( - body=body, - to=to, - messaging_service_sid=settings.TWILIO_MESSAGING_SERVICE - ) + client.messages.create(body=body, to=to, messaging_service_sid=settings.TWILIO_MESSAGING_SERVICE) From cbd43794d2c964f4acb5c337692ed723ce0f19c4 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 12 Sep 2023 21:57:25 -0400 Subject: [PATCH 3/8] test fix --- commcare_connect/opportunity/tests/test_tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commcare_connect/opportunity/tests/test_tasks.py b/commcare_connect/opportunity/tests/test_tasks.py index 2b5dfdd1..1f7a267c 100644 --- a/commcare_connect/opportunity/tests/test_tasks.py +++ b/commcare_connect/opportunity/tests/test_tasks.py @@ -14,7 +14,7 @@ def test_add_connect_user(self): opportunity = OpportunityFactory() with ( mock.patch("commcare_connect.opportunity.tasks.requests.get") as request, - mock.patch("commcare_connect.users.helpers.send_sms") as send_sms + mock.patch("commcare_connect.users.helpers.send_sms"), ): request.return_value.json.return_value = { "found_users": [ @@ -29,7 +29,7 @@ def test_add_connect_user(self): user = user_list[0] assert user.name == "a" assert user.phone_number == "+15555555555" - assert len(OpportunityAccess.objects.filter(user=user.first(), opportunity=opportunity)) == 1 + assert len(OpportunityAccess.objects.filter(user=user_list.first(), opportunity=opportunity)) == 1 user2 = User.objects.filter(username="test2") assert len(user2) == 1 From eee26ab16f69df112f35df84695f693810f0fb4d Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 13 Sep 2023 21:11:21 -0400 Subject: [PATCH 4/8] pr feedback --- .../0016_opportunityaccess_accepted_and_more.py | 5 +++-- commcare_connect/opportunity/models.py | 4 +++- commcare_connect/users/helpers.py | 12 ++++++------ commcare_connect/utils/sms.py | 6 ++++++ config/settings/base.py | 2 -- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/commcare_connect/opportunity/migrations/0016_opportunityaccess_accepted_and_more.py b/commcare_connect/opportunity/migrations/0016_opportunityaccess_accepted_and_more.py index 68062a4e..7a93581c 100644 --- a/commcare_connect/opportunity/migrations/0016_opportunityaccess_accepted_and_more.py +++ b/commcare_connect/opportunity/migrations/0016_opportunityaccess_accepted_and_more.py @@ -1,6 +1,7 @@ -# Generated by Django 4.2.5 on 2023-09-12 15:11 +# Generated by Django 4.2.5 on 2023-09-14 01:11 from django.db import migrations, models +import uuid class Migration(migrations.Migration): @@ -17,7 +18,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="opportunityaccess", name="invite_id", - field=models.CharField(blank=True, max_length=50, null=True), + field=models.CharField(default=uuid.uuid4, max_length=50), ), migrations.AddIndex( model_name="opportunityaccess", diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index 82daea7a..4e82279b 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from django.db import models from django.utils.translation import gettext @@ -138,7 +140,7 @@ class OpportunityAccess(models.Model): 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, null=True, blank=True) + invite_id = models.CharField(max_length=50, default=uuid4) class Meta: indexes = [models.Index(fields=["invite_id"])] diff --git a/commcare_connect/users/helpers.py b/commcare_connect/users/helpers.py index 7c3cd92d..fda41874 100644 --- a/commcare_connect/users/helpers.py +++ b/commcare_connect/users/helpers.py @@ -1,6 +1,5 @@ -from uuid import uuid4 - import requests +from allauth.utils import build_absolute_uri from django.conf import settings from commcare_connect.organization.models import Organization @@ -38,9 +37,10 @@ def create_hq_user(user, domain, api_key): def invite_user(user, opportunity_access): - invite_id = uuid4() - opportunity_access.invite_id = invite_id - opportunity_access.save() - url = "https://connect.dimagi.com/opportunity/accept_invite/{invite_id}" + invite_id = opportunity_access.invite_id + location = reverse("opportunity:accept_invite", args=(invite_id,)) + url = build_absolute_uri(None, location) body = f"You have been invited to a new job in Commcare Connect. Click the following 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) diff --git a/commcare_connect/utils/sms.py b/commcare_connect/utils/sms.py index 1fc23c92..e3948e07 100644 --- a/commcare_connect/utils/sms.py +++ b/commcare_connect/utils/sms.py @@ -2,6 +2,12 @@ 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) diff --git a/config/settings/base.py b/config/settings/base.py index 8e47d8a7..98ac2cef 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -317,8 +317,6 @@ ), } -BASE_ADDRESS = env("BASE_ADDRESS", default="http://localhost:8000") - 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) From 4c1b3ab0e74f47d74c4bcd872d07f64c55c946a1 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 19 Sep 2023 16:49:27 -0400 Subject: [PATCH 5/8] add merge migration --- .../migrations/0018_merge_20230919_2049.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 commcare_connect/opportunity/migrations/0018_merge_20230919_2049.py diff --git a/commcare_connect/opportunity/migrations/0018_merge_20230919_2049.py b/commcare_connect/opportunity/migrations/0018_merge_20230919_2049.py new file mode 100644 index 00000000..be9ccfb6 --- /dev/null +++ b/commcare_connect/opportunity/migrations/0018_merge_20230919_2049.py @@ -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 = [] From 589e52142e3992016b10a51182726444b42f7c37 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 19 Sep 2023 18:56:17 -0400 Subject: [PATCH 6/8] lint --- commcare_connect/opportunity/models.py | 4 +--- commcare_connect/opportunity/views.py | 4 +++- commcare_connect/users/helpers.py | 6 +++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index ce762920..c2837153 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -144,6 +144,7 @@ class OpportunityAccess(models.Model): 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 @@ -173,9 +174,6 @@ def last_visit_date(self): return - class Meta: - unique_together = ("user", "opportunity") - class VisitValidationStatus(models.TextChoices): pending = "pending", gettext("Pending") diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index 7c0ca6d8..149904f6 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -226,9 +226,11 @@ def accept_invite(request, invite_id): 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" + "Thank you for accepting the invitation. Open your CommCare Connect App to " + "see more information about the opportunity and begin learning" ) + @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) diff --git a/commcare_connect/users/helpers.py b/commcare_connect/users/helpers.py index fda41874..5a6b7621 100644 --- a/commcare_connect/users/helpers.py +++ b/commcare_connect/users/helpers.py @@ -1,6 +1,7 @@ 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 @@ -40,7 +41,10 @@ def invite_user(user, opportunity_access): invite_id = opportunity_access.invite_id location = reverse("opportunity:accept_invite", args=(invite_id,)) url = build_absolute_uri(None, location) - body = f"You have been invited to a new job in Commcare Connect. Click the following link to share your information with the project and find out more {url}" + 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) From a75d78ca5ad102d2848d98d944132465575d223e Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 19 Sep 2023 19:09:54 -0400 Subject: [PATCH 7/8] move invite view to users app --- commcare_connect/opportunity/urls.py | 2 -- commcare_connect/opportunity/views.py | 14 -------------- commcare_connect/users/helpers.py | 2 +- commcare_connect/users/urls.py | 2 ++ commcare_connect/users/views.py | 15 +++++++++++++++ 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/commcare_connect/opportunity/urls.py b/commcare_connect/opportunity/urls.py index 010048cc..51e65605 100644 --- a/commcare_connect/opportunity/urls.py +++ b/commcare_connect/opportunity/urls.py @@ -8,7 +8,6 @@ OpportunityUserLearnProgress, OpportunityUserTableView, OpportunityUserVisitTableView, - accept_invite, add_budget_existing_users, download_visit_export, export_user_visits, @@ -33,7 +32,6 @@ view=OpportunityUserLearnProgress.as_view(), name="user_learn_progress", ), - path("accept_invite/", view=accept_invite, name="accept_invite"), path( "/add_budget", view=add_budget_existing_users, diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index 149904f6..a16bafe4 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -217,20 +217,6 @@ def update_visit_status_import(request, org_slug=None, pk=None): return redirect("opportunity:detail", org_slug, pk) -@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" - ) - - @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) diff --git a/commcare_connect/users/helpers.py b/commcare_connect/users/helpers.py index 5a6b7621..283256df 100644 --- a/commcare_connect/users/helpers.py +++ b/commcare_connect/users/helpers.py @@ -39,7 +39,7 @@ def create_hq_user(user, domain, api_key): def invite_user(user, opportunity_access): invite_id = opportunity_access.invite_id - location = reverse("opportunity:accept_invite", args=(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 " diff --git a/commcare_connect/users/urls.py b/commcare_connect/users/urls.py index 08c6a868..68c985cd 100644 --- a/commcare_connect/users/urls.py +++ b/commcare_connect/users/urls.py @@ -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, @@ -15,4 +16,5 @@ path("/", 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/", view=accept_invite, name="accept_invite"), ] diff --git a/commcare_connect/users/views.py b/commcare_connect/users/views.py index 134cf0cb..85727310 100644 --- a/commcare_connect/users/views.py +++ b/commcare_connect/users/views.py @@ -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 @@ -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" + ) From 2fea5de53846c92a15787dc99d0e8aa7ce99b248 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Mon, 25 Sep 2023 13:42:55 -0500 Subject: [PATCH 8/8] use char field instead of phonenumber --- .../users/migrations/0010_user_phone_number.py | 7 +++---- commcare_connect/users/models.py | 3 +-- config/settings/base.py | 1 - requirements/base.in | 1 - requirements/base.txt | 5 ----- 5 files changed, 4 insertions(+), 13 deletions(-) diff --git a/commcare_connect/users/migrations/0010_user_phone_number.py b/commcare_connect/users/migrations/0010_user_phone_number.py index 0b24047c..833da2f5 100644 --- a/commcare_connect/users/migrations/0010_user_phone_number.py +++ b/commcare_connect/users/migrations/0010_user_phone_number.py @@ -1,7 +1,6 @@ -# Generated by Django 4.2.5 on 2023-09-12 15:11 +# Generated by Django 4.2.5 on 2023-09-25 18:41 -from django.db import migrations -import phonenumber_field.modelfields +from django.db import migrations, models class Migration(migrations.Migration): @@ -13,6 +12,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name="user", name="phone_number", - field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None), + field=models.CharField(blank=True, max_length=15, null=True), ), ] diff --git a/commcare_connect/users/models.py b/commcare_connect/users/models.py index ff849ea8..a15656a7 100644 --- a/commcare_connect/users/models.py +++ b/commcare_connect/users/models.py @@ -4,7 +4,6 @@ from django.db.models import Q, UniqueConstraint from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from phonenumber_field.modelfields import PhoneNumberField from commcare_connect.users.managers import UserManager @@ -34,7 +33,7 @@ class User(AbstractUser): }, null=True, ) - phone_number = PhoneNumberField(null=True, blank=True) + phone_number = models.CharField(max_length=15, null=True, blank=True) USERNAME_FIELD = "email" REQUIRED_FIELDS = [] diff --git a/config/settings/base.py b/config/settings/base.py index 98ac2cef..247e5a37 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -65,7 +65,6 @@ "drf_spectacular", "oauth2_provider", "django_tables2", - "phonenumber_field", ] LOCAL_APPS = [ diff --git a/requirements/base.in b/requirements/base.in index 847269a4..2b504a7d 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -21,7 +21,6 @@ django-model-utils django-allauth django-celery-beat django-crispy-forms -django-phonenumber-field[phonenumberslite] crispy-bootstrap5 django-redis # Django REST Framework diff --git a/requirements/base.txt b/requirements/base.txt index c8bf4213..f7aa8a95 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -86,7 +86,6 @@ django==4.2.5 # django-crispy-forms # django-model-utils # django-oauth-toolkit - # django-phonenumber-field # django-redis # django-tables2 # django-timezone-field @@ -108,8 +107,6 @@ django-model-utils==4.3.1 # via -r requirements/base.in django-oauth-toolkit==2.3.0 # via -r requirements/base.in -django-phonenumber-field[phonenumberslite]==7.1.0 - # via -r requirements/base.in django-redis==5.3.0 # via -r requirements/base.in django-tables2==2.6.0 @@ -174,8 +171,6 @@ oauthlib==3.2.2 # requests-oauthlib openpyxl==3.1.2 # via tablib -phonenumberslite==8.13.20 - # via django-phonenumber-field pillow==10.0.0 # via -r requirements/base.in ply==3.11