Commit ce17f969fddc838e8589befcdeca0f79d71b869c

Authored by Andrew Buss
1 parent ddd9b21455
Exists in master

Restructured api, moved more validation to serializers, added snazzy apidocs

Showing 9 changed files with 290 additions and 197 deletions Side-by-side Diff

flashcards/api.py View file @ ce17f96
1   -from django.core.mail import send_mail
2   -from django.contrib.auth import authenticate, login, logout
3   -from django.contrib.auth.tokens import default_token_generator
  1 +from rest_framework.pagination import PageNumberPagination
4 2 from rest_framework.permissions import BasePermission
5   -from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED
6   -from rest_framework.views import APIView
7   -from rest_framework.response import Response
8   -from rest_framework.exceptions import ValidationError, NotFound
9   -from flashcards.serializers import *
10 3  
11 4  
  5 +class StandardResultsSetPagination(PageNumberPagination):
  6 + page_size = 40
  7 + page_size_query_param = 'page_size'
  8 + max_page_size = 1000
  9 +
  10 +
12 11 class UserDetailPermissions(BasePermission):
13 12 def has_object_permission(self, request, view, obj):
14 13 if request.method == 'POST':
15 14 return True
16 15 return request.user.is_active
17   -
18   -
19   -class UserDetail(APIView):
20   - def patch(self, request, format=None):
21   - """
22   - This method checks either the email or the password passed in
23   - is valid. If confirmation key is correct, it validates the
24   - user. It updates the password if the new password
25   - is valid.
26   -
27   - """
28   - currentuser = request.user
29   -
30   - if 'confirmation_key' in request.data:
31   - if not currentuser.confirm_email(request.data['confirmation_key']):
32   - raise ValidationError('confirmation_key is invalid')
33   -
34   - if 'new_password' in request.data:
35   - if not currentuser.check_password(request.data['old_password']):
36   - raise ValidationError('Invalid old password')
37   - if not request.data['new_password']:
38   - raise ValidationError('Password cannot be blank')
39   - currentuser.set_password(request.data['new_password'])
40   - currentuser.save()
41   -
42   - return Response(UserSerializer(request.user).data)
43   -
44   - def get(self, request, format=None):
45   - if not request.user.is_active: return Response(status=HTTP_401_UNAUTHORIZED)
46   - serializer = UserSerializer(request.user)
47   - return Response(serializer.data)
48   -
49   - def post(self, request, format=None):
50   - if 'email' not in request.data:
51   - raise ValidationError('Email is required')
52   - if 'password' not in request.data:
53   - raise ValidationError('Password is required')
54   - email = request.data['email']
55   - existing_users = User.objects.filter(email=email)
56   - if existing_users.exists():
57   - raise ValidationError("An account with this email already exists")
58   - user = User.objects.create_user(email=email, password=request.data['password'])
59   -
60   - body = '''
61   - Visit the following link to confirm your email address:
62   - http://flashy.cards/app/verify_email/%s
63   -
64   - If you did not register for Flashy, no action is required.
65   - '''
66   - send_mail("Flashy email verification",
67   - body % user.confirmation_key,
68   - "noreply@flashy.cards",
69   - [user.email])
70   -
71   - user = authenticate(email=email, password=request.data['password'])
72   - login(request, user)
73   - return Response(UserSerializer(user).data, status=HTTP_201_CREATED)
74   -
75   - def delete(self, request):
76   - request.user.delete()
77   - return Response(status=HTTP_204_NO_CONTENT)
78   -
79   -
80   -class UserLogin(APIView):
81   - """
82   - Authenticates user and returns user data if valid. Handles invalid
83   - users.
84   - """
85   -
86   - def post(self, request, format=None):
87   - """
88   - Authenticates and logs in the user and returns their data if valid.
89   - """
90   - if 'email' not in request.data:
91   - raise ValidationError('Email is required')
92   - if 'password' not in request.data:
93   - raise ValidationError('Password is required')
94   -
95   - email = request.data['email']
96   - password = request.data['password']
97   - user = authenticate(email=email, password=password)
98   -
99   - if user is None:
100   - raise ValidationError('Invalid email or password')
101   - if not user.is_active:
102   - raise ValidationError('Account is disabled')
103   - login(request, user)
104   - return Response(UserSerializer(user).data)
105   -
106   -
107   -class UserLogout(APIView):
108   - """
109   - Authenticated user log out.
110   - """
111   -
112   - def post(self, request, format=None):
113   - """
114   - Logs the authenticated user out.
115   - """
116   - logout(request)
117   - return Response(status=HTTP_204_NO_CONTENT)
118   -
119   -
120   -class PasswordReset(APIView):
121   - """
122   - Allows user to reset their password.
123   - System sends an email to the user's email with a token that may be verified
124   - to reset their password.
125   - """
126   -
127   - def post(self, request, format=None):
128   - """
129   - Send a password reset token/link to the provided email.
130   - """
131   - if 'email' not in request.data:
132   - raise ValidationError('Email is required')
133   -
134   - email = request.data['email']
135   -
136   - # Find the user since they are not logged in.
137   - try:
138   - user = User.objects.get(email=email)
139   - except User.DoesNotExist:
140   - # Don't leak that email does not exist.
141   - raise NotFound('Email does not exist')
142   -
143   - token = default_token_generator.make_token(user)
144   -
145   - body = '''
146   - Visit the following link to reset your password:
147   - http://flashy.cards/app/reset_password/%d/%s
148   -
149   - If you did not request a password reset, no action is required.
150   - '''
151   -
152   - send_mail("Flashy password reset",
153   - body % (user.pk, token),
154   - "noreply@flashy.cards",
155   - [user.email])
156   -
157   - return Response(status=HTTP_204_NO_CONTENT)
158   -
159   - def patch(self, request, format=None):
160   - """
161   - Updates user's password to new password if token is valid.
162   - """
163   - if 'new_password' not in request.data:
164   - raise ValidationError('New password is required')
165   - if not request.data['new_password']:
166   - raise ValidationError('Password cannot be blank')
167   -
168   - user = request.user
169   -
170   - # Check token validity.
171   - if default_token_generator.check_token(user, request.data['token']):
172   - user.set_password(request.data['new_password'])
173   - user.save()
174   -
175   - return Response(status=HTTP_204_NO_CONTENT)
flashcards/migrations/0002_auto_20150504_1327.py View file @ ce17f96
  1 +# -*- coding: utf-8 -*-
  2 +from __future__ import unicode_literals
  3 +
  4 +from django.db import models, migrations
  5 +import django.core.validators
  6 +import flashcards.models
  7 +
  8 +
  9 +class Migration(migrations.Migration):
  10 +
  11 + dependencies = [
  12 + ('flashcards', '0001_initial'),
  13 + ]
  14 +
  15 + operations = [
  16 + migrations.AlterModelOptions(
  17 + name='section',
  18 + options={},
  19 + ),
  20 + migrations.AlterModelManagers(
  21 + name='user',
  22 + managers=[
  23 + ('objects', flashcards.models.EmailOnlyUserManager()),
  24 + ],
  25 + ),
  26 + migrations.AlterField(
  27 + model_name='flashcardreport',
  28 + name='reason',
  29 + field=models.CharField(max_length=255, blank=True),
  30 + ),
  31 + migrations.AlterField(
  32 + model_name='lectureperiod',
  33 + name='week_day',
  34 + field=models.IntegerField(help_text=b'1-indexed day of week, starting at Sunday'),
  35 + ),
  36 + migrations.AlterField(
  37 + model_name='user',
  38 + name='sections',
  39 + field=models.ManyToManyField(help_text=b'The sections which the user is enrolled in', to='flashcards.Section'),
  40 + ),
  41 + migrations.AlterField(
  42 + model_name='user',
  43 + name='username',
  44 + 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')]),
  45 + ),
  46 + ]
flashcards/models.py View file @ ce17f96
... ... @@ -5,6 +5,7 @@
5 5  
6 6 # Hack to fix AbstractUser before subclassing it
7 7 AbstractUser._meta.get_field('email')._unique = True
  8 +AbstractUser._meta.get_field('username')._unique = False
8 9  
9 10  
10 11 class EmailOnlyUserManager(UserManager):
flashcards/serializers.py View file @ ce17f96
1 1 from flashcards.models import Section, LecturePeriod, User
2   -from rest_framework.fields import EmailField, BooleanField
  2 +from rest_framework import serializers
  3 +from rest_framework.fields import EmailField, BooleanField, CharField, IntegerField
3 4 from rest_framework.relations import HyperlinkedRelatedField
4   -from rest_framework.serializers import HyperlinkedModelSerializer, ModelSerializer
  5 +from rest_framework.serializers import HyperlinkedModelSerializer, ModelSerializer, Serializer
  6 +from rest_framework.validators import UniqueValidator
  7 +
  8 +
  9 +class EmailSerializer(Serializer):
  10 + email = EmailField(required=True)
  11 +
  12 +
  13 +class EmailPasswordSerializer(EmailSerializer):
  14 + password = CharField(required=True)
  15 +
  16 +
  17 +class RegistrationSerializer(EmailPasswordSerializer):
  18 + email = EmailField(required=True, validators=[UniqueValidator(queryset=User.objects.all())])
  19 +
  20 +
  21 +class PasswordResetRequestSerializer(EmailSerializer):
  22 + def validate_email(self, value):
  23 + try:
  24 + User.objects.get(email=value)
  25 + return value
  26 + except User.DoesNotExist:
  27 + raise serializers.ValidationError('No user exists with that email')
  28 +
  29 +
  30 +class PasswordResetSerializer(Serializer):
  31 + new_password = CharField(required=True, allow_blank=False)
  32 + uid = IntegerField(required=True)
  33 + token = CharField(required=True)
  34 +
  35 + def validate_uid(self, value):
  36 + try:
  37 + User.objects.get(id=value)
  38 + return value
  39 + except User.DoesNotExist:
  40 + raise serializers.ValidationError('Could not verify reset token')
  41 +
  42 +class UserUpdateSerializer(Serializer):
  43 + old_password = CharField(required=False)
  44 + new_password = CharField(required=False, allow_blank=False)
  45 + confirmation_key = CharField(required=False)
  46 + # reset_token = CharField(required=False)
  47 +
  48 + def validate(self, data):
  49 + if 'new_password' in data and 'old_password' not in data:
  50 + raise serializers.ValidationError('old_password is required to set a new_password')
  51 + return data
  52 +
  53 +
  54 +class Password(Serializer):
  55 + email = EmailField(required=True)
  56 + password = CharField(required=True)
5 57  
6 58  
7 59 class LecturePeriodSerializer(ModelSerializer):
flashcards/tests/test_api.py View file @ ce17f96
1   -from django.test import Client
2 1 from flashcards.models import User
3   -from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK, HTTP_401_UNAUTHORIZED
4   -from rest_framework.test import APITestCase, APIClient
  2 +from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_401_UNAUTHORIZED
  3 +from rest_framework.test import APITestCase
5 4  
6 5  
7 6 class LoginTests(APITestCase):
8 7  
9 8  
... ... @@ -17,15 +16,15 @@
17 16  
18 17 data = {'email': 'test@flashy.cards', 'password': '54321'}
19 18 response = self.client.post(url, data, format='json')
20   - self.assertContains(response, 'Invalid email or password', status_code=400)
  19 + self.assertContains(response, 'Invalid email or password', status_code=403)
21 20  
22 21 data = {'email': 'none@flashy.cards', 'password': '54321'}
23 22 response = self.client.post(url, data, format='json')
24   - self.assertContains(response, 'Invalid email or password', status_code=400)
  23 + self.assertContains(response, 'Invalid email or password', status_code=403)
25 24  
26 25 data = {'password': '54321'}
27 26 response = self.client.post(url, data, format='json')
28   - self.assertContains(response, 'Email is required', status_code=400)
  27 + self.assertContains(response, 'email', status_code=400)
29 28  
30 29  
31 30 class RegistrationTest(APITestCase):
32 31  
33 32  
34 33  
... ... @@ -39,15 +38,15 @@
39 38 response = self.client.post('/api/login', data, format='json')
40 39 self.assertEqual(response.status_code, HTTP_200_OK)
41 40  
42   -
43 41 data = {'email': 'none@none.com'}
44 42 response = self.client.post(url, data, format='json')
45   - self.assertContains(response, 'Password is required', status_code=400)
  43 + self.assertContains(response, 'password', status_code=400)
46 44  
47 45 data = {'password': '1234'}
48 46 response = self.client.post(url, data, format='json')
49   - self.assertContains(response, 'Email is required', status_code=400)
  47 + self.assertContains(response, 'email', status_code=400)
50 48  
  49 +
51 50 class ProfileViewTest(APITestCase):
52 51 def setUp(self):
53 52 email = "profileviewtest@flashy.cards"
... ... @@ -56,7 +55,7 @@
56 55 def test_get_me(self):
57 56 url = '/api/users/me'
58 57 response = self.client.get(url, format='json')
59   - # since we're not logged in, we shouldn't see anything here
  58 + # since we're not logged in, we shouldn't be able to see this
60 59 self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED)
61 60  
62 61 self.client.login(email='profileviewtest@flashy.cards', password='1234')
flashcards/views.py View file @ ce17f96
1   -from flashcards.models import Section, LecturePeriod
2   -from flashcards.serializers import SectionSerializer, LecturePeriodSerializer
3   -from rest_framework.permissions import IsAuthenticatedOrReadOnly
4   -from rest_framework.viewsets import ModelViewSet
5   -from rest_framework.pagination import PageNumberPagination
  1 +from flashcards.api import StandardResultsSetPagination
  2 +from flashcards.models import Section, User
  3 +from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \
  4 + PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer
  5 +from rest_framework.viewsets import ReadOnlyModelViewSet
  6 +from django.core.mail import send_mail
  7 +from django.contrib.auth import authenticate, login, logout
  8 +from django.contrib.auth.tokens import default_token_generator
  9 +from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_201_CREATED
  10 +from rest_framework.views import APIView
  11 +from rest_framework.response import Response
  12 +from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError
6 13  
7 14  
8   -class StandardResultsSetPagination(PageNumberPagination):
9   - page_size = 40
10   - page_size_query_param = 'page_size'
11   - max_page_size = 1000
12   -
13   -
14   -class SectionViewSet(ModelViewSet):
  15 +class SectionViewSet(ReadOnlyModelViewSet):
15 16 queryset = Section.objects.all()
16 17 serializer_class = SectionSerializer
17   - permission_classes = (IsAuthenticatedOrReadOnly,)
18 18 pagination_class = StandardResultsSetPagination
19 19  
20 20  
21   -class LecturePeriodViewSet(ModelViewSet):
22   - queryset = LecturePeriod.objects.all()
23   - serializer_class = LecturePeriodSerializer
24   - permission_classes = (IsAuthenticatedOrReadOnly,)
25   - pagination_class = StandardResultsSetPagination
  21 +class UserDetail(APIView):
  22 + def patch(self, request, format=None):
  23 + """
  24 + Updates the user's password, or verifies their email address
  25 + ---
  26 + request_serializer: UserUpdateSerializer
  27 + response_serializer: UserSerializer
  28 + """
  29 + data = UserUpdateSerializer(data=request.data, context={'user': request.user})
  30 + data.is_valid(raise_exception=True)
  31 +
  32 + if 'new_password' in data:
  33 + if not request.user.check_password(data['old_password']):
  34 + raise ValidationError('old_password is incorrect')
  35 + request.user.set_password(request.data['new_password'])
  36 + request.user.save()
  37 +
  38 + if 'confirmation_key' in data and not request.user.confirm_email(data['confirmation_key']):
  39 + raise ValidationError('confirmation_key is invalid')
  40 +
  41 + return Response(UserSerializer(request.user).data)
  42 +
  43 + def get(self, request, format=None):
  44 + """
  45 + Return data about the user
  46 + ---
  47 + response_serializer: UserSerializer
  48 + """
  49 + if not request.user.is_active: return Response(status=HTTP_401_UNAUTHORIZED)
  50 + serializer = UserSerializer(request.user)
  51 + return Response(serializer.data)
  52 +
  53 + def post(self, request, format=None):
  54 + """
  55 + Register a new user
  56 + ---
  57 + request_serializer: EmailPasswordSerializer
  58 + response_serializer: UserSerializer
  59 + """
  60 + data = RegistrationSerializer(data=request.data)
  61 + data.is_valid(raise_exception=True)
  62 +
  63 + User.objects.create_user(**data.validated_data)
  64 + user = authenticate(**data.validated_data)
  65 + login(request, user)
  66 +
  67 + body = '''
  68 + Visit the following link to confirm your email address:
  69 + http://flashy.cards/app/verify_email/%s
  70 +
  71 + If you did not register for Flashy, no action is required.
  72 + '''
  73 +
  74 + assert send_mail("Flashy email verification",
  75 + body % user.confirmation_key,
  76 + "noreply@flashy.cards",
  77 + [user.email])
  78 +
  79 + return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED)
  80 +
  81 + def delete(self, request):
  82 + """
  83 + Irrevocably delete the user and their data
  84 +
  85 + Yes, really
  86 + """
  87 + request.user.delete()
  88 + return Response(status=HTTP_204_NO_CONTENT)
  89 +
  90 +
  91 +class UserLogin(APIView):
  92 + def post(self, request):
  93 + """
  94 + Authenticates user and returns user data if valid.
  95 + ---
  96 + request_serializer: EmailPasswordSerializer
  97 + response_serializer: UserSerializer
  98 + """
  99 +
  100 + data = EmailPasswordSerializer(data=request.data)
  101 + data.is_valid(raise_exception=True)
  102 + user = authenticate(**data.validated_data)
  103 +
  104 + if user is None:
  105 + raise AuthenticationFailed('Invalid email or password')
  106 + if not user.is_active:
  107 + raise NotAuthenticated('Account is disabled')
  108 + login(request, user)
  109 + return Response(UserSerializer(request.user).data)
  110 +
  111 +
  112 +class UserLogout(APIView):
  113 + """
  114 + Authenticated user log out.
  115 + """
  116 +
  117 + def post(self, request, format=None):
  118 + """
  119 + Logs the authenticated user out.
  120 + """
  121 + logout(request)
  122 + return Response(status=HTTP_204_NO_CONTENT)
  123 +
  124 +
  125 +class PasswordReset(APIView):
  126 + """
  127 + Allows user to reset their password.
  128 + """
  129 +
  130 + def post(self, request, format=None):
  131 + """
  132 + Send a password reset token/link to the provided email.
  133 + ---
  134 + request_serializer: PasswordResetRequestSerializer
  135 + """
  136 + data = PasswordResetRequestSerializer(data=request.data)
  137 + data.is_valid(raise_exception=True)
  138 + user = User.objects.get(email=data['email'].value)
  139 + token = default_token_generator.make_token(user)
  140 +
  141 + body = '''
  142 + Visit the following link to reset your password:
  143 + http://flashy.cards/app/reset_password/%d/%s
  144 +
  145 + If you did not request a password reset, no action is required.
  146 + '''
  147 +
  148 + send_mail("Flashy password reset",
  149 + body % (user.pk, token),
  150 + "noreply@flashy.cards",
  151 + [user.email])
  152 +
  153 + return Response(status=HTTP_204_NO_CONTENT)
  154 +
  155 + def patch(self, request, format=None):
  156 + """
  157 + Updates user's password to new password if token is valid.
  158 + ---
  159 + request_serializer: PasswordResetSerializer
  160 + """
  161 + data = PasswordResetSerializer(data=request.data)
  162 + data.is_valid(raise_exception=True)
  163 +
  164 + user = User.objects.get(id=data['uid'].value)
  165 + # Check token validity.
  166 +
  167 + if default_token_generator.check_token(user, data['token'].value):
  168 + user.set_password(data['new_password'].value)
  169 + user.save()
  170 + else:
  171 + raise ValidationError('Could not verify reset token')
  172 + return Response(status=HTTP_204_NO_CONTENT)
flashy/settings.py View file @ ce17f96
... ... @@ -21,6 +21,7 @@
21 21 'django.contrib.messages',
22 22 'django.contrib.staticfiles',
23 23 'django_ses',
  24 + 'rest_framework_swagger',
24 25 'rest_framework',
25 26 )
26 27  
... ... @@ -93,4 +94,8 @@
93 94 EMAIL_BACKEND = 'django_ses.SESBackend'
94 95  
95 96 SECRET_KEY = os.environ.get('SECRET_KEY', 'LOL DEFAULT SECRET KEY')
  97 +
  98 +SWAGGER_SETTINGS = {
  99 + 'doc_expansion': 'list'
  100 +}
flashy/urls.py View file @ ce17f96
1 1 from django.conf.urls import include, url
2 2 from django.contrib import admin
3   -from flashcards.views import SectionViewSet, LecturePeriodViewSet
  3 +from flashcards.views import SectionViewSet, UserDetail, UserLogin, UserLogout, PasswordReset
4 4 from rest_framework.routers import DefaultRouter
5 5 from flashcards.api import *
6 6  
... ... @@ -8,6 +8,7 @@
8 8 router.register(r'sections', SectionViewSet)
9 9  
10 10 urlpatterns = [
  11 + url(r'^api/docs/', include('rest_framework_swagger.urls')),
11 12 url(r'^api/users/me$', UserDetail.as_view()),
12 13 url(r'^api/login$', UserLogin.as_view()),
13 14 url(r'^api/logout$', UserLogout.as_view()),
requirements.txt View file @ ce17f96
... ... @@ -11,4 +11,5 @@
11 11 django-simple-email-confirmation
12 12 django-ses
13 13 coverage
  14 +django-rest-swagger