From ce17f969fddc838e8589befcdeca0f79d71b869c Mon Sep 17 00:00:00 2001 From: Andrew Buss Date: Mon, 4 May 2015 14:02:08 -0700 Subject: [PATCH] Restructured api, moved more validation to serializers, added snazzy apidocs --- flashcards/api.py | 172 +-------------------- flashcards/migrations/0002_auto_20150504_1327.py | 46 ++++++ flashcards/models.py | 1 + flashcards/serializers.py | 56 ++++++- flashcards/tests/test_api.py | 19 ++- flashcards/views.py | 182 ++++++++++++++++++++--- flashy/settings.py | 7 +- flashy/urls.py | 3 +- requirements.txt | 1 + 9 files changed, 290 insertions(+), 197 deletions(-) create mode 100644 flashcards/migrations/0002_auto_20150504_1327.py diff --git a/flashcards/api.py b/flashcards/api.py index 7f7eea7..f51e3bb 100644 --- a/flashcards/api.py +++ b/flashcards/api.py @@ -1,12 +1,11 @@ -from django.core.mail import send_mail -from django.contrib.auth import authenticate, login, logout -from django.contrib.auth.tokens import default_token_generator +from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import BasePermission -from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.exceptions import ValidationError, NotFound -from flashcards.serializers import * + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 40 + page_size_query_param = 'page_size' + max_page_size = 1000 class UserDetailPermissions(BasePermission): @@ -16,160 +15,3 @@ class UserDetailPermissions(BasePermission): return request.user.is_active -class UserDetail(APIView): - def patch(self, request, format=None): - """ - This method checks either the email or the password passed in - is valid. If confirmation key is correct, it validates the - user. It updates the password if the new password - is valid. - - """ - currentuser = request.user - - if 'confirmation_key' in request.data: - if not currentuser.confirm_email(request.data['confirmation_key']): - raise ValidationError('confirmation_key is invalid') - - if 'new_password' in request.data: - if not currentuser.check_password(request.data['old_password']): - raise ValidationError('Invalid old password') - if not request.data['new_password']: - raise ValidationError('Password cannot be blank') - currentuser.set_password(request.data['new_password']) - currentuser.save() - - return Response(UserSerializer(request.user).data) - - def get(self, request, format=None): - if not request.user.is_active: return Response(status=HTTP_401_UNAUTHORIZED) - serializer = UserSerializer(request.user) - return Response(serializer.data) - - def post(self, request, format=None): - if 'email' not in request.data: - raise ValidationError('Email is required') - if 'password' not in request.data: - raise ValidationError('Password is required') - email = request.data['email'] - existing_users = User.objects.filter(email=email) - if existing_users.exists(): - raise ValidationError("An account with this email already exists") - user = User.objects.create_user(email=email, password=request.data['password']) - - body = ''' - Visit the following link to confirm your email address: - http://flashy.cards/app/verify_email/%s - - If you did not register for Flashy, no action is required. - ''' - send_mail("Flashy email verification", - body % user.confirmation_key, - "noreply@flashy.cards", - [user.email]) - - user = authenticate(email=email, password=request.data['password']) - login(request, user) - return Response(UserSerializer(user).data, status=HTTP_201_CREATED) - - def delete(self, request): - request.user.delete() - return Response(status=HTTP_204_NO_CONTENT) - - -class UserLogin(APIView): - """ - Authenticates user and returns user data if valid. Handles invalid - users. - """ - - def post(self, request, format=None): - """ - Authenticates and logs in the user and returns their data if valid. - """ - if 'email' not in request.data: - raise ValidationError('Email is required') - if 'password' not in request.data: - raise ValidationError('Password is required') - - email = request.data['email'] - password = request.data['password'] - user = authenticate(email=email, password=password) - - if user is None: - raise ValidationError('Invalid email or password') - if not user.is_active: - raise ValidationError('Account is disabled') - login(request, user) - return Response(UserSerializer(user).data) - - -class UserLogout(APIView): - """ - Authenticated user log out. - """ - - def post(self, request, format=None): - """ - Logs the authenticated user out. - """ - logout(request) - return Response(status=HTTP_204_NO_CONTENT) - - -class PasswordReset(APIView): - """ - Allows user to reset their password. - System sends an email to the user's email with a token that may be verified - to reset their password. - """ - - def post(self, request, format=None): - """ - Send a password reset token/link to the provided email. - """ - if 'email' not in request.data: - raise ValidationError('Email is required') - - email = request.data['email'] - - # Find the user since they are not logged in. - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - # Don't leak that email does not exist. - raise NotFound('Email does not exist') - - token = default_token_generator.make_token(user) - - body = ''' - Visit the following link to reset your password: - http://flashy.cards/app/reset_password/%d/%s - - If you did not request a password reset, no action is required. - ''' - - send_mail("Flashy password reset", - body % (user.pk, token), - "noreply@flashy.cards", - [user.email]) - - return Response(status=HTTP_204_NO_CONTENT) - - def patch(self, request, format=None): - """ - Updates user's password to new password if token is valid. - """ - if 'new_password' not in request.data: - raise ValidationError('New password is required') - if not request.data['new_password']: - raise ValidationError('Password cannot be blank') - - user = request.user - - # Check token validity. - if default_token_generator.check_token(user, request.data['token']): - user.set_password(request.data['new_password']) - user.save() - - return Response(status=HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/flashcards/migrations/0002_auto_20150504_1327.py b/flashcards/migrations/0002_auto_20150504_1327.py new file mode 100644 index 0000000..6fa57b0 --- /dev/null +++ b/flashcards/migrations/0002_auto_20150504_1327.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django.core.validators +import flashcards.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('flashcards', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='section', + options={}, + ), + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', flashcards.models.EmailOnlyUserManager()), + ], + ), + migrations.AlterField( + model_name='flashcardreport', + name='reason', + field=models.CharField(max_length=255, blank=True), + ), + migrations.AlterField( + model_name='lectureperiod', + name='week_day', + field=models.IntegerField(help_text=b'1-indexed day of week, starting at Sunday'), + ), + migrations.AlterField( + model_name='user', + name='sections', + field=models.ManyToManyField(help_text=b'The sections which the user is enrolled in', to='flashcards.Section'), + ), + migrations.AlterField( + model_name='user', + name='username', + field=models.CharField(help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=30, error_messages={'unique': 'A user with that username already exists.'}, verbose_name='username', validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')]), + ), + ] diff --git a/flashcards/models.py b/flashcards/models.py index 7918977..1f905ee 100644 --- a/flashcards/models.py +++ b/flashcards/models.py @@ -5,6 +5,7 @@ from simple_email_confirmation import SimpleEmailConfirmationUserMixin # Hack to fix AbstractUser before subclassing it AbstractUser._meta.get_field('email')._unique = True +AbstractUser._meta.get_field('username')._unique = False class EmailOnlyUserManager(UserManager): diff --git a/flashcards/serializers.py b/flashcards/serializers.py index 386fb39..cbb5058 100644 --- a/flashcards/serializers.py +++ b/flashcards/serializers.py @@ -1,7 +1,59 @@ from flashcards.models import Section, LecturePeriod, User -from rest_framework.fields import EmailField, BooleanField +from rest_framework import serializers +from rest_framework.fields import EmailField, BooleanField, CharField, IntegerField from rest_framework.relations import HyperlinkedRelatedField -from rest_framework.serializers import HyperlinkedModelSerializer, ModelSerializer +from rest_framework.serializers import HyperlinkedModelSerializer, ModelSerializer, Serializer +from rest_framework.validators import UniqueValidator + + +class EmailSerializer(Serializer): + email = EmailField(required=True) + + +class EmailPasswordSerializer(EmailSerializer): + password = CharField(required=True) + + +class RegistrationSerializer(EmailPasswordSerializer): + email = EmailField(required=True, validators=[UniqueValidator(queryset=User.objects.all())]) + + +class PasswordResetRequestSerializer(EmailSerializer): + def validate_email(self, value): + try: + User.objects.get(email=value) + return value + except User.DoesNotExist: + raise serializers.ValidationError('No user exists with that email') + + +class PasswordResetSerializer(Serializer): + new_password = CharField(required=True, allow_blank=False) + uid = IntegerField(required=True) + token = CharField(required=True) + + def validate_uid(self, value): + try: + User.objects.get(id=value) + return value + except User.DoesNotExist: + raise serializers.ValidationError('Could not verify reset token') + +class UserUpdateSerializer(Serializer): + old_password = CharField(required=False) + new_password = CharField(required=False, allow_blank=False) + confirmation_key = CharField(required=False) + # reset_token = CharField(required=False) + + def validate(self, data): + if 'new_password' in data and 'old_password' not in data: + raise serializers.ValidationError('old_password is required to set a new_password') + return data + + +class Password(Serializer): + email = EmailField(required=True) + password = CharField(required=True) class LecturePeriodSerializer(ModelSerializer): diff --git a/flashcards/tests/test_api.py b/flashcards/tests/test_api.py index 5dd8d6d..d32256b 100644 --- a/flashcards/tests/test_api.py +++ b/flashcards/tests/test_api.py @@ -1,7 +1,6 @@ -from django.test import Client from flashcards.models import User -from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK, HTTP_401_UNAUTHORIZED -from rest_framework.test import APITestCase, APIClient +from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_401_UNAUTHORIZED +from rest_framework.test import APITestCase class LoginTests(APITestCase): @@ -17,15 +16,15 @@ class LoginTests(APITestCase): data = {'email': 'test@flashy.cards', 'password': '54321'} response = self.client.post(url, data, format='json') - self.assertContains(response, 'Invalid email or password', status_code=400) + self.assertContains(response, 'Invalid email or password', status_code=403) data = {'email': 'none@flashy.cards', 'password': '54321'} response = self.client.post(url, data, format='json') - self.assertContains(response, 'Invalid email or password', status_code=400) + self.assertContains(response, 'Invalid email or password', status_code=403) data = {'password': '54321'} response = self.client.post(url, data, format='json') - self.assertContains(response, 'Email is required', status_code=400) + self.assertContains(response, 'email', status_code=400) class RegistrationTest(APITestCase): @@ -39,14 +38,14 @@ class RegistrationTest(APITestCase): response = self.client.post('/api/login', data, format='json') self.assertEqual(response.status_code, HTTP_200_OK) - data = {'email': 'none@none.com'} response = self.client.post(url, data, format='json') - self.assertContains(response, 'Password is required', status_code=400) + self.assertContains(response, 'password', status_code=400) data = {'password': '1234'} response = self.client.post(url, data, format='json') - self.assertContains(response, 'Email is required', status_code=400) + self.assertContains(response, 'email', status_code=400) + class ProfileViewTest(APITestCase): def setUp(self): @@ -56,7 +55,7 @@ class ProfileViewTest(APITestCase): def test_get_me(self): url = '/api/users/me' response = self.client.get(url, format='json') - # since we're not logged in, we shouldn't see anything here + # since we're not logged in, we shouldn't be able to see this self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) self.client.login(email='profileviewtest@flashy.cards', password='1234') diff --git a/flashcards/views.py b/flashcards/views.py index c8d1ab7..e783ed6 100644 --- a/flashcards/views.py +++ b/flashcards/views.py @@ -1,26 +1,172 @@ -from flashcards.models import Section, LecturePeriod -from flashcards.serializers import SectionSerializer, LecturePeriodSerializer -from rest_framework.permissions import IsAuthenticatedOrReadOnly -from rest_framework.viewsets import ModelViewSet -from rest_framework.pagination import PageNumberPagination +from flashcards.api import StandardResultsSetPagination +from flashcards.models import Section, User +from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \ + PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer +from rest_framework.viewsets import ReadOnlyModelViewSet +from django.core.mail import send_mail +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.tokens import default_token_generator +from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_201_CREATED +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError -class StandardResultsSetPagination(PageNumberPagination): - page_size = 40 - page_size_query_param = 'page_size' - max_page_size = 1000 - - -class SectionViewSet(ModelViewSet): +class SectionViewSet(ReadOnlyModelViewSet): queryset = Section.objects.all() serializer_class = SectionSerializer - permission_classes = (IsAuthenticatedOrReadOnly,) pagination_class = StandardResultsSetPagination -class LecturePeriodViewSet(ModelViewSet): - queryset = LecturePeriod.objects.all() - serializer_class = LecturePeriodSerializer - permission_classes = (IsAuthenticatedOrReadOnly,) - pagination_class = StandardResultsSetPagination +class UserDetail(APIView): + def patch(self, request, format=None): + """ + Updates the user's password, or verifies their email address + --- + request_serializer: UserUpdateSerializer + response_serializer: UserSerializer + """ + data = UserUpdateSerializer(data=request.data, context={'user': request.user}) + data.is_valid(raise_exception=True) + + if 'new_password' in data: + if not request.user.check_password(data['old_password']): + raise ValidationError('old_password is incorrect') + request.user.set_password(request.data['new_password']) + request.user.save() + + if 'confirmation_key' in data and not request.user.confirm_email(data['confirmation_key']): + raise ValidationError('confirmation_key is invalid') + + return Response(UserSerializer(request.user).data) + + def get(self, request, format=None): + """ + Return data about the user + --- + response_serializer: UserSerializer + """ + if not request.user.is_active: return Response(status=HTTP_401_UNAUTHORIZED) + serializer = UserSerializer(request.user) + return Response(serializer.data) + + def post(self, request, format=None): + """ + Register a new user + --- + request_serializer: EmailPasswordSerializer + response_serializer: UserSerializer + """ + data = RegistrationSerializer(data=request.data) + data.is_valid(raise_exception=True) + + User.objects.create_user(**data.validated_data) + user = authenticate(**data.validated_data) + login(request, user) + + body = ''' + Visit the following link to confirm your email address: + http://flashy.cards/app/verify_email/%s + + If you did not register for Flashy, no action is required. + ''' + + assert send_mail("Flashy email verification", + body % user.confirmation_key, + "noreply@flashy.cards", + [user.email]) + + return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED) + + def delete(self, request): + """ + Irrevocably delete the user and their data + + Yes, really + """ + request.user.delete() + return Response(status=HTTP_204_NO_CONTENT) + + +class UserLogin(APIView): + def post(self, request): + """ + Authenticates user and returns user data if valid. + --- + request_serializer: EmailPasswordSerializer + response_serializer: UserSerializer + """ + + data = EmailPasswordSerializer(data=request.data) + data.is_valid(raise_exception=True) + user = authenticate(**data.validated_data) + + if user is None: + raise AuthenticationFailed('Invalid email or password') + if not user.is_active: + raise NotAuthenticated('Account is disabled') + login(request, user) + return Response(UserSerializer(request.user).data) + + +class UserLogout(APIView): + """ + Authenticated user log out. + """ + + def post(self, request, format=None): + """ + Logs the authenticated user out. + """ + logout(request) + return Response(status=HTTP_204_NO_CONTENT) + + +class PasswordReset(APIView): + """ + Allows user to reset their password. + """ + + def post(self, request, format=None): + """ + Send a password reset token/link to the provided email. + --- + request_serializer: PasswordResetRequestSerializer + """ + data = PasswordResetRequestSerializer(data=request.data) + data.is_valid(raise_exception=True) + user = User.objects.get(email=data['email'].value) + token = default_token_generator.make_token(user) + + body = ''' + Visit the following link to reset your password: + http://flashy.cards/app/reset_password/%d/%s + + If you did not request a password reset, no action is required. + ''' + + send_mail("Flashy password reset", + body % (user.pk, token), + "noreply@flashy.cards", + [user.email]) + + return Response(status=HTTP_204_NO_CONTENT) + + def patch(self, request, format=None): + """ + Updates user's password to new password if token is valid. + --- + request_serializer: PasswordResetSerializer + """ + data = PasswordResetSerializer(data=request.data) + data.is_valid(raise_exception=True) + + user = User.objects.get(id=data['uid'].value) + # Check token validity. + if default_token_generator.check_token(user, data['token'].value): + user.set_password(data['new_password'].value) + user.save() + else: + raise ValidationError('Could not verify reset token') + return Response(status=HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/flashy/settings.py b/flashy/settings.py index 4eb1402..806b463 100644 --- a/flashy/settings.py +++ b/flashy/settings.py @@ -21,6 +21,7 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'django_ses', + 'rest_framework_swagger', 'rest_framework', ) @@ -92,4 +93,8 @@ if 'AWS_ACCESS_KEY_ID' in os.environ: AWS_SES_REGION_ENDPOINT = 'email.us-west-2.amazonaws.com' EMAIL_BACKEND = 'django_ses.SESBackend' -SECRET_KEY = os.environ.get('SECRET_KEY', 'LOL DEFAULT SECRET KEY') \ No newline at end of file +SECRET_KEY = os.environ.get('SECRET_KEY', 'LOL DEFAULT SECRET KEY') + +SWAGGER_SETTINGS = { + 'doc_expansion': 'list' +} \ No newline at end of file diff --git a/flashy/urls.py b/flashy/urls.py index be2b722..0abfc10 100644 --- a/flashy/urls.py +++ b/flashy/urls.py @@ -1,6 +1,6 @@ from django.conf.urls import include, url from django.contrib import admin -from flashcards.views import SectionViewSet, LecturePeriodViewSet +from flashcards.views import SectionViewSet, UserDetail, UserLogin, UserLogout, PasswordReset from rest_framework.routers import DefaultRouter from flashcards.api import * @@ -8,6 +8,7 @@ router = DefaultRouter() router.register(r'sections', SectionViewSet) urlpatterns = [ + url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api/users/me$', UserDetail.as_view()), url(r'^api/login$', UserLogin.as_view()), url(r'^api/logout$', UserLogout.as_view()), diff --git a/requirements.txt b/requirements.txt index 781967f..1dc44a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ gunicorn django-simple-email-confirmation django-ses coverage +django-rest-swagger \ No newline at end of file -- 1.9.1