Skip to content

Commit

Permalink
OpenConceptLab/ocl_issues#1167 | inactive->verify->activate user feature
Browse files Browse the repository at this point in the history
  • Loading branch information
snyaggarwal committed Dec 27, 2021
1 parent 5c1788f commit 69f5421
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 22 deletions.
34 changes: 32 additions & 2 deletions core/integration_tests/tests_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ def test_login(self):
user.set_password('boogeyman')
user.save()

self.assertIsNone(user.last_login)

response = self.client.post(
'/users/login/',
dict(username='marty', password='boogeyman'),
Expand All @@ -235,6 +237,8 @@ def test_login(self):

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, dict(token=user.get_token()))
user.refresh_from_db()
self.assertIsNotNone(user.last_login)

response = self.client.post(
'/users/login/',
Expand All @@ -245,7 +249,8 @@ def test_login(self):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, dict(non_field_errors=["Unable to log in with provided credentials."]))

def test_login_inactive_user(self):
@patch('core.users.models.UserProfile.verify')
def test_login_inactive_user(self, verify_mock):
user = UserProfileFactory(username='marty', is_active=False)
user.set_password('boogeyman')
user.save()
Expand All @@ -260,10 +265,35 @@ def test_login_inactive_user(self):
self.assertEqual(
response.data,
dict(
detail='This account is deactivated. Please contact OCL Team to activate this account.',
detail='A verification email has been sent to the address on record. Verify your email address'
' to re-activate your account.',
email=user.email
)
)
verify_mock.assert_called_once()

@patch('core.users.models.UserProfile.send_verification_email')
def test_login_unverified_user(self, send_verification_email_mock):
user = UserProfileFactory(username='marty', is_active=True, verified=False)
user.set_password('boogeyman')
user.save()

response = self.client.post(
'/users/login/',
dict(username='marty', password='boogeyman'),
format='json'
)

self.assertEqual(response.status_code, 401)
self.assertEqual(
response.data,
dict(
detail='A verification email has been sent to the address on record. Verify your email address to '
'activate your account.',
email=user.email
)
)
send_verification_email_mock.assert_called_once()


class UserListViewTest(OCLAPITestCase):
Expand Down
2 changes: 2 additions & 0 deletions core/users/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
VERIFY_EMAIL_MESSAGE = 'A verification email has been sent to the address on record. Verify your email address to ' \
'activate your account.'
INACTIVE_USER_MESSAGE = 'This account is deactivated. Please contact OCL Team to activate this account.'
REACTIVATE_USER_MESSAGE = 'A verification email has been sent to the address on record. Verify your email address to ' \
're-activate your account.'
OCL_SERVERS_GROUP = 'ocl_servers'
OCL_FHIR_SERVERS_GROUP = 'ocl_fhir_servers'
HAPI_FHIR_SERVERS_GROUP = 'hapi_fhir_servers'
Expand Down
18 changes: 18 additions & 0 deletions core/users/migrations/0018_userprofile_deactivated_at.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.8 on 2021-12-27 04:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0017_remove_userprofile_internal_reference_id'),
]

operations = [
migrations.AddField(
model_name='userprofile',
name='deactivated_at',
field=models.DateTimeField(blank=True, null=True),
),
]
27 changes: 26 additions & 1 deletion core/users/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import uuid
from datetime import datetime

from django.contrib.auth.models import AbstractUser
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
Expand Down Expand Up @@ -30,6 +33,7 @@ class Meta:
verified = models.BooleanField(default=True)
verification_token = models.TextField(null=True, blank=True)
mnemonic_attr = 'username'
deactivated_at = models.DateTimeField(null=True, blank=True)

es_fields = {
'username': {'sortable': True, 'filterable': True, 'exact': True},
Expand All @@ -51,7 +55,7 @@ def status(self):
if not self.is_active:
return 'deactivated'
if not self.verified:
return 'unverified'
return 'verification_pending' if self.verification_token else 'unverified'

return 'verified'

Expand Down Expand Up @@ -145,6 +149,7 @@ def mark_verified(self, token):
if token == self.verification_token:
self.verified = True
self.verification_token = None
self.deactivated_at = None
self.save()
return True

Expand All @@ -161,3 +166,23 @@ def auth_groups(self):
@property
def organizations_count(self):
return self.organizations.count()

def deactivate(self):
self.is_active = False
self.verified = False
self.verification_token = None
self.deactivated_at = datetime.now()
self.__delete_token()
self.save()

def verify(self):
self.is_active = True
self.verified = False
self.verification_token = uuid.uuid4()

self.save()
self.token = self.get_token()
self.send_verification_email()

def soft_delete(self):
self.deactivate()
3 changes: 2 additions & 1 deletion core/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ class UserDetailSerializer(serializers.ModelSerializer):
extras = serializers.JSONField(required=False, allow_null=True)
subscribed_orgs = serializers.SerializerMethodField()
auth_groups = serializers.ListField(required=False, allow_null=True, allow_empty=True)
deactivated_at = serializers.DateTimeField(read_only=True)

class Meta:
model = UserProfile
Expand All @@ -142,7 +143,7 @@ class Meta:
'public_collections', 'public_sources', 'created_on', 'updated_on', 'created_by', 'updated_by',
'url', 'organizations_url', 'extras', 'sources_url', 'collections_url', 'website', 'last_login',
'logo_url', 'subscribed_orgs', 'is_superuser', 'is_staff', 'first_name', 'last_name', 'verified',
'verification_token', 'date_joined', 'auth_groups', 'status'
'verification_token', 'date_joined', 'auth_groups', 'status', 'deactivated_at'
)

def __init__(self, *args, **kwargs):
Expand Down
33 changes: 29 additions & 4 deletions core/users/tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from django.contrib.auth.models import Group
from mock import Mock, patch, ANY
from rest_framework.authtoken.models import Token
Expand Down Expand Up @@ -223,16 +225,39 @@ def test_is_valid_auth_group(self):
self.assertFalse(UserProfile.is_valid_auth_group('foobar'))
self.assertTrue(UserProfile.is_valid_auth_group(OCL_SERVERS_GROUP))

def test_deactivate(self):
user = UserProfileFactory(is_active=True, deactivated_at=None, verified=True)

self.assertEqual(user.status, 'verified')

user.deactivate()

self.assertEqual(user.status, 'deactivated')
self.assertFalse(user.verified)
self.assertFalse(user.is_active)

def test_verify(self):
user = UserProfileFactory(
is_active=False, deactivated_at=datetime.now(), verified=False, verification_token=None)

self.assertEqual(user.status, 'deactivated')

user.send_verification_email = Mock()

user.verify()

self.assertEqual(user.status, 'verification_pending')
self.assertFalse(user.verified)
self.assertTrue(user.is_active)
self.assertIsNotNone(user.verification_token)
user.send_verification_email.assert_called_once()


class TokenAuthenticationViewTest(OCLAPITestCase):
def test_login(self):
response = self.client.post('/users/login/', {})

self.assertEqual(response.status_code, 400)
self.assertEqual(
response.data,
dict(username=['This field is required.'], password=['This field is required.'])
)

response = self.client.post('/users/login/', dict(username='foo', password='bar'))

Expand Down
28 changes: 14 additions & 14 deletions core/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from core.common.utils import parse_updated_since_param, parse_updated_since
from core.common.views import BaseAPIView, BaseLogoView
from core.orgs.models import Organization
from core.users.constants import VERIFICATION_TOKEN_MISMATCH, VERIFY_EMAIL_MESSAGE, INACTIVE_USER_MESSAGE
from core.users.constants import VERIFICATION_TOKEN_MISMATCH, VERIFY_EMAIL_MESSAGE, REACTIVATE_USER_MESSAGE
from core.users.documents import UserProfileDocument
from core.users.search import UserProfileSearch
from core.users.serializers import UserDetailSerializer, UserCreateSerializer, UserListSerializer, UserSummarySerializer
Expand All @@ -35,21 +35,23 @@ class TokenAuthenticationView(ObtainAuthToken):
@swagger_auto_schema(request_body=AuthTokenSerializer)
def post(self, request, *args, **kwargs):
user = UserProfile.objects.filter(username=request.data.get('username')).first()
if not user:
raise Http404()
if not user or not user.check_password(request.data.get('password')):
raise Http400(dict(non_field_errors=["Unable to log in with provided credentials."]))

if not user.is_active:
user.verify()
return Response(
{'detail': REACTIVATE_USER_MESSAGE, 'email': user.email}, status=status.HTTP_401_UNAUTHORIZED
)
if not user.verified:
user.send_verification_email()
return Response(
{'detail': INACTIVE_USER_MESSAGE, 'email': user.email}, status=status.HTTP_401_UNAUTHORIZED
{'detail': VERIFY_EMAIL_MESSAGE, 'email': user.email}, status=status.HTTP_401_UNAUTHORIZED
)

result = super().post(request, *args, **kwargs)

try:
if not user.verified:
user.send_verification_email()
return Response(
{'detail': VERIFY_EMAIL_MESSAGE, 'email': user.email}, status=status.HTTP_401_UNAUTHORIZED
)
update_last_login(None, user)
except: # pylint: disable=bare-except
pass
Expand Down Expand Up @@ -233,11 +235,9 @@ def get_serializer_class(self):
return UserDetailSerializer

def get_queryset(self):
queryset = super().get_queryset()

if self.kwargs.get('user_is_self'):
return queryset.filter(username=self.request.user.username)
return queryset.filter(username=self.kwargs['user'])
return self.queryset.filter(username=self.request.user.username)
return self.queryset.filter(username=self.kwargs['user'])

def get_permissions(self):
if self.request.method == 'DELETE':
Expand Down Expand Up @@ -275,7 +275,7 @@ def delete(self, request, *args, **kwargs):
obj = self.get_object()
if self.user_is_self:
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
obj.soft_delete()
obj.deactivate()
return Response(status=status.HTTP_204_NO_CONTENT)


Expand Down

0 comments on commit 69f5421

Please sign in to comment.