Skip to content

Commit

Permalink
Merge branch 'master' into drf3
Browse files Browse the repository at this point in the history
Conflicts:
	user_management/api/views.py
  • Loading branch information
LilyFoote committed Apr 8, 2015
2 parents a53c0db + 852451a commit b997aa2
Show file tree
Hide file tree
Showing 12 changed files with 161 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ matrix:
- env: TOX_ENV=py34-djangopre
install:
- psql -c 'CREATE DATABASE user_management' -U postgres;
- pip install tox coveralls
- pip install tox!=1.9.1 coveralls
script:
- tox -e $TOX_ENV
after_success:
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
## v9.0.1

* Send `user_logged_in` and `user_logged_out` signals from `GetAuthToken` view.

## v9.0.0

* Replace `email_verification_required` flag with `email_verified` flag.
* Note that `email_verified == not email_verification_required`.
* A data migration will be necessary.

## v8.1.0

* Add docstrings for views.

Docstrings will be displayed in `django-rest-framework` browsable API.

## v8.0.1

* Fix translation for notifications.
Expand Down
2 changes: 1 addition & 1 deletion docs/mixins.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ mixin with a `name`, `email`, `date_joined`, `is_staff`, and `is_active`.

`user_management.models.mixins.VerifyEmailMixin` extends ActiveUserMixin to
provide functionality to verify the email. It includes an additional
`email_verification_required` field.
`email_verified` field.

By default, users will be created with `is_active = False`. A verification email
will be sent including a link to verify the email and activate the account.
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from setuptools import find_packages, setup


version = '8.0.1'
version = '9.0.1'


install_requires = (
Expand Down
2 changes: 1 addition & 1 deletion user_management/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def validate_email(self, email):
msg = _('A user with this email address does not exist.')
raise serializers.ValidationError(msg)

if not self.user.email_verification_required:
if self.user.email_verified:
msg = _('User email address is already verified.')
raise serializers.ValidationError(msg)
return email
Expand Down
2 changes: 1 addition & 1 deletion user_management/api/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ def test_user_does_not_exist(self):

def test_user_already_validated(self):
"""Assert confirmation email is not send if user was already verified."""
user = UserFactory.create(email_verification_required=False)
user = UserFactory.create(email_verified=True)
data = {'email': user.email}
serializer = serializers.ResendConfirmationEmailSerializer(data=data)
self.assertFalse(serializer.is_valid())
54 changes: 45 additions & 9 deletions user_management/api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import re
from collections import OrderedDict

from django.contrib.auth import get_user_model
from django.contrib.auth import get_user_model, signals
from django.contrib.auth.hashers import check_password
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.models import Site
Expand All @@ -12,7 +12,7 @@
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from mock import patch
from mock import MagicMock, patch
from rest_framework import status
from rest_framework.test import APIRequestFactory

Expand Down Expand Up @@ -56,6 +56,17 @@ def test_post(self):
token = self.model.objects.get()
self.assertEqual(response.data['token'], token.key)

def test_post_last_login_updates(self):
"""Authenticating updates the user's last_login."""
user = UserFactory.create(email=self.username, password=self.password)
now = timezone.now()
self.assertLess(user.last_login, now)

request = self.create_request('post', auth=False, data=self.data)
self.view_class.as_view()(request)
user = User.objects.get(pk=user.pk)
self.assertGreater(user.last_login, now)

def test_post_non_existing_user(self):
"""Assert non existing raises an error."""
request = self.create_request('post', auth=False, data=self.data)
Expand Down Expand Up @@ -119,6 +130,31 @@ def test_delete(self):
with self.assertRaises(self.model.DoesNotExist):
self.model.objects.get(pk=token.pk)

def test_delete_user_logged_out_signal(self):
"""Send the user_logged_out signal if a user deletes their Auth Token."""
handler = MagicMock()
signals.user_logged_out.connect(handler)

someday = timezone.now() + datetime.timedelta(days=1)
user = UserFactory.create()
token = AuthTokenFactory.create(user=user, expires=someday)

# Custom auth header containing token
auth = 'Token ' + token.key
request = self.create_request(
'delete',
user=user,
HTTP_AUTHORIZATION=auth,
)
response = self.view_class.as_view()(request)

handler.assert_called_once_with(
signal=signals.user_logged_out,
sender=views.GetAuthToken,
request=response.renderer_context['request'],
user=user,
)

def test_delete_no_token(self):
request = self.create_request('delete')
response = self.view_class.as_view()(request)
Expand Down Expand Up @@ -212,7 +248,7 @@ def test_unauthenticated_user_post(self):
@patch('user_management.api.serializers.User', new=BasicUser)
def test_unauthenticated_user_post_no_verify_email(self):
"""
An email should not be sent if email_verification_required is False.
An email should not be sent if email_verified is True.
"""
request = self.create_request('post', auth=False, data=self.data)

Expand Down Expand Up @@ -600,7 +636,7 @@ def test_post_authenticated(self):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)

updated_user = User.objects.get(pk=user.pk)
self.assertFalse(updated_user.email_verification_required)
self.assertTrue(updated_user.email_verified)
self.assertTrue(updated_user.is_active)

def test_post_unauthenticated(self):
Expand All @@ -614,7 +650,7 @@ def test_post_unauthenticated(self):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)

updated_user = User.objects.get(pk=user.pk)
self.assertFalse(updated_user.email_verification_required)
self.assertTrue(updated_user.email_verified)
self.assertTrue(updated_user.is_active)

def test_post_invalid_user(self):
Expand All @@ -638,7 +674,7 @@ def test_post_invalid_token(self):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_post_verified_email(self):
user = UserFactory.create(email_verification_required=False)
user = UserFactory.create(email_verified=True)
token = default_token_generator.make_token(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))

Expand All @@ -659,10 +695,10 @@ def test_post_different_user_logged_in(self):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)

updated_user = User.objects.get(pk=user.pk)
self.assertFalse(updated_user.email_verification_required)
self.assertTrue(updated_user.email_verified)

logged_in_user = User.objects.get(pk=other_user.pk)
self.assertTrue(logged_in_user.email_verification_required)
self.assertFalse(logged_in_user.email_verified)

def test_full_stack_wrong_url(self):
user = UserFactory.create()
Expand Down Expand Up @@ -952,7 +988,7 @@ def test_post_unknown_email(self):

def test_post_email_already_verified(self):
"""Assert email already verified does not trigger another email."""
user = UserFactory.create(email_verification_required=False)
user = UserFactory.create(email_verified=True)
data = {'email': user.email}
request = self.create_request('post', auth=False, data=data)
view = self.view_class.as_view()
Expand Down
87 changes: 81 additions & 6 deletions user_management/api/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.contrib.auth import get_user_model
from django.contrib.auth import get_user_model, signals
from django.contrib.auth.tokens import default_token_generator
from django.utils.encoding import force_text
from django.utils.http import urlsafe_base64_decode
Expand All @@ -16,6 +16,15 @@


class GetAuthToken(ObtainAuthToken):
"""
Obtain an authentication token.
Define a `POST` (create) method to authenticate a user using their `email` and
`password` and return a `token` if successful.
The `token` remains valid until `settings.AUTH_TOKEN_MAX_AGE` time has passed.
`DELETE` method removes the current `token` from the database.
"""
model = models.AuthToken
throttle_classes = [
throttling.UsernameLoginRateThrottle,
Expand All @@ -28,7 +37,9 @@ def post(self, request):
but not re-using them."""
serializer = AuthTokenSerializer(data=request.DATA)
if serializer.is_valid():
token = self.model.objects.create(user=serializer.validated_data['user'])
user = serializer.validated_data['user']
signals.user_logged_in.send(type(self), user=user, request=request)
token = self.model.objects.create(user=user)
token.update_expiry()
return response.Response({'token': token.key})

Expand Down Expand Up @@ -56,10 +67,21 @@ def delete(self, request, *args, **kwargs):
pass
else:
token.delete()
signals.user_logged_out.send(
type(self),
user=token.user,
request=request,
)
return response.Response(status=status.HTTP_204_NO_CONTENT)


class UserRegister(generics.CreateAPIView):
"""
Register a new `User`.
An email to validate the new account is sent if `email_verified`
is set to `False`.
"""
serializer_class = serializers.RegistrationSerializer
permission_classes = [permissions.IsNotAuthenticated]

Expand All @@ -77,7 +99,7 @@ def is_invalid(self, serializer):

def is_valid(self, serializer):
user = serializer.save()
if user.email_verification_required:
if not user.email_verified:
user.send_validation_email()
ok_message = _(
'Your account has been created and an activation link sent ' +
Expand All @@ -93,6 +115,12 @@ def is_valid(self, serializer):


class PasswordResetEmail(generics.GenericAPIView):
"""
Send a password reset email to a user on request.
A user can request a password request email by providing their email address.
If the user is not found no error is raised.
"""
permission_classes = [permissions.IsNotAuthenticated]
template_name = 'user_management/password_reset_email.html'
serializer_class = serializers.PasswordResetEmailSerializer
Expand Down Expand Up @@ -120,6 +148,11 @@ def post(self, request, *args, **kwargs):


class OneTimeUseAPIMixin(object):
"""
Use a `uid` and a `token` to allow one-time access to a view.
Set user as a class attribute or raise an `InvalidExpiredToken`.
"""
def initial(self, request, *args, **kwargs):
uidb64 = kwargs['uidb64']
uid = urlsafe_base64_decode(force_text(uidb64))
Expand All @@ -141,6 +174,17 @@ def initial(self, request, *args, **kwargs):


class PasswordReset(OneTimeUseAPIMixin, generics.UpdateAPIView):
"""
Reset a user's password.
This view is generally called when a user has followed an email link to
reset a password.
This view will check first if the `uid` and `token` are valid.
`PasswordReset` is called with an `UPDATE` containing the new password
(`new_password` and `new_password2`).
"""
permission_classes = [permissions.IsNotAuthenticated]
model = User
serializer_class = serializers.PasswordResetSerializer
Expand All @@ -150,6 +194,12 @@ def get_object(self):


class PasswordChange(generics.UpdateAPIView):
"""
Change a user's password.
Give ability to `PUT` (update) a password when authenticated by submitting current
password.
"""
model = User
permission_classes = (IsAuthenticated,)
serializer_class = serializers.PasswordChangeSerializer
Expand All @@ -159,14 +209,19 @@ def get_object(self):


class VerifyAccountView(OneTimeUseAPIMixin, views.APIView):
"""
Verify a new user's email address.
Verify a newly created account by checking the `uid` and `token` in a `POST` request.
"""
permission_classes = [AllowAny]
ok_message = _('Your account has been verified.')

def post(self, request, *args, **kwargs):
if not self.user.email_verification_required:
if self.user.email_verified:
return response.Response(status=status.HTTP_403_FORBIDDEN)

self.user.email_verification_required = False
self.user.email_verified = True
self.user.is_active = True
self.user.save()

Expand All @@ -177,6 +232,11 @@ def post(self, request, *args, **kwargs):


class ProfileDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Allow a user to view and edit their profile information.
`GET`, `UPDATE` and `DELETE` current logged-in user.
"""
model = User
permission_classes = (IsAuthenticated,)
serializer_class = serializers.ProfileSerializer
Expand All @@ -186,19 +246,34 @@ def get_object(self):


class UserList(generics.ListCreateAPIView):
"""
Return information about all users and allow creation of new users.
Allow to `GET` a list users and to `POST` new user for admin user only.
"""
queryset = User.objects.all()
permission_classes = (IsAuthenticated, permissions.IsAdminOrReadOnly)
serializer_class = serializers.UserSerializerCreate


class UserDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Display information about a user.
Allow admin users to update or delete user information.
"""
queryset = User.objects.all()
permission_classes = (IsAuthenticated, permissions.IsAdminOrReadOnly)
serializer_class = serializers.UserSerializer


class ResendConfirmationEmail(generics.GenericAPIView):
"""Resend a confirmation email."""
"""
Resend a confirmation email.
`POST` request to resend a confirmation email for existing user. Useful when
the token has expired.
"""
permission_classes = [permissions.IsNotAuthenticated]
serializer_class = serializers.ResendConfirmationEmailSerializer
throttle_classes = [throttling.ResendConfirmationEmailRateThrottle]
Expand Down
4 changes: 2 additions & 2 deletions user_management/models/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class UserAdmin(BaseUserAdmin):


class VerifyUserAdmin(UserAdmin):
readonly_fields = ('date_joined', 'email_verification_required')
readonly_fields = ('date_joined', 'email_verified')

def get_fieldsets(self, request, obj=None):
fieldsets = super(VerifyUserAdmin, self).get_fieldsets(request, obj)
Expand All @@ -62,6 +62,6 @@ def get_fieldsets(self, request, obj=None):
# removed and fieldsets will be correct so return it
return fieldsets

fields[index] = ('is_active', 'email_verification_required')
fields[index] = ('is_active', 'email_verified')
fieldsets_dict['Permissions']['fields'] = tuple(fields)
return tuple(fieldsets_dict.items())

0 comments on commit b997aa2

Please sign in to comment.