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 Inline Diff

flashcards/api.py View file @ ce17f96
from django.core.mail import send_mail 1 1 from rest_framework.pagination import PageNumberPagination
from django.contrib.auth import authenticate, login, logout 2
from django.contrib.auth.tokens import default_token_generator 3
from rest_framework.permissions import BasePermission 4 2 from rest_framework.permissions import BasePermission
from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED 5
from rest_framework.views import APIView 6
from rest_framework.response import Response 7
from rest_framework.exceptions import ValidationError, NotFound 8
from flashcards.serializers import * 9
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
class UserDetailPermissions(BasePermission): 12 11 class UserDetailPermissions(BasePermission):
def has_object_permission(self, request, view, obj): 13 12 def has_object_permission(self, request, view, obj):
if request.method == 'POST': 14 13 if request.method == 'POST':
return True 15 14 return True
return request.user.is_active 16 15 return request.user.is_active
17
18
class UserDetail(APIView): 19
def patch(self, request, format=None): 20
""" 21
This method checks either the email or the password passed in 22
is valid. If confirmation key is correct, it validates the 23
user. It updates the password if the new password 24
is valid. 25
26
""" 27
currentuser = request.user 28
29
if 'confirmation_key' in request.data: 30
if not currentuser.confirm_email(request.data['confirmation_key']): 31
raise ValidationError('confirmation_key is invalid') 32
33
if 'new_password' in request.data: 34
if not currentuser.check_password(request.data['old_password']): 35
raise ValidationError('Invalid old password') 36
if not request.data['new_password']: 37
raise ValidationError('Password cannot be blank') 38
currentuser.set_password(request.data['new_password']) 39
currentuser.save() 40
41
return Response(UserSerializer(request.user).data) 42
43
def get(self, request, format=None): 44
if not request.user.is_active: return Response(status=HTTP_401_UNAUTHORIZED) 45
serializer = UserSerializer(request.user) 46
return Response(serializer.data) 47
48
def post(self, request, format=None): 49
if 'email' not in request.data: 50
raise ValidationError('Email is required') 51
if 'password' not in request.data: 52
raise ValidationError('Password is required') 53
email = request.data['email'] 54
existing_users = User.objects.filter(email=email) 55
if existing_users.exists(): 56
raise ValidationError("An account with this email already exists") 57
user = User.objects.create_user(email=email, password=request.data['password']) 58
59
body = ''' 60
Visit the following link to confirm your email address: 61
http://flashy.cards/app/verify_email/%s 62
63
If you did not register for Flashy, no action is required. 64
''' 65
send_mail("Flashy email verification", 66
body % user.confirmation_key, 67
"noreply@flashy.cards", 68
[user.email]) 69
70
user = authenticate(email=email, password=request.data['password']) 71
login(request, user) 72
return Response(UserSerializer(user).data, status=HTTP_201_CREATED) 73
74
def delete(self, request): 75
request.user.delete() 76
return Response(status=HTTP_204_NO_CONTENT) 77
78
79
class UserLogin(APIView): 80
""" 81
Authenticates user and returns user data if valid. Handles invalid 82
users. 83
""" 84
85
def post(self, request, format=None): 86
""" 87
Authenticates and logs in the user and returns their data if valid. 88
""" 89
if 'email' not in request.data: 90
raise ValidationError('Email is required') 91
if 'password' not in request.data: 92
raise ValidationError('Password is required') 93
94
email = request.data['email'] 95
password = request.data['password'] 96
user = authenticate(email=email, password=password) 97
98
if user is None: 99
raise ValidationError('Invalid email or password') 100
if not user.is_active: 101
raise ValidationError('Account is disabled') 102
login(request, user) 103
return Response(UserSerializer(user).data) 104
105
106
class UserLogout(APIView): 107
""" 108
Authenticated user log out. 109
""" 110
111
def post(self, request, format=None): 112
""" 113
Logs the authenticated user out. 114
""" 115
logout(request) 116
return Response(status=HTTP_204_NO_CONTENT) 117
118
119
class PasswordReset(APIView): 120
""" 121
Allows user to reset their password. 122
System sends an email to the user's email with a token that may be verified 123
to reset their password. 124
""" 125
126
def post(self, request, format=None): 127
""" 128
Send a password reset token/link to the provided email. 129
""" 130
if 'email' not in request.data: 131
raise ValidationError('Email is required') 132
133
email = request.data['email'] 134
135
# Find the user since they are not logged in. 136
try: 137
user = User.objects.get(email=email) 138
except User.DoesNotExist: 139
# Don't leak that email does not exist. 140
flashcards/migrations/0002_auto_20150504_1327.py View file @ ce17f96
File was created 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(
flashcards/models.py View file @ ce17f96
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, AbstractUser, UserManager 1 1 from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, AbstractUser, UserManager
from django.db.models import * 2 2 from django.db.models import *
from django.utils.timezone import now 3 3 from django.utils.timezone import now
from simple_email_confirmation import SimpleEmailConfirmationUserMixin 4 4 from simple_email_confirmation import SimpleEmailConfirmationUserMixin
5 5
# Hack to fix AbstractUser before subclassing it 6 6 # Hack to fix AbstractUser before subclassing it
AbstractUser._meta.get_field('email')._unique = True 7 7 AbstractUser._meta.get_field('email')._unique = True
8 AbstractUser._meta.get_field('username')._unique = False
8 9
9 10
class EmailOnlyUserManager(UserManager): 10 11 class EmailOnlyUserManager(UserManager):
""" 11 12 """
A tiny extension of Django's UserManager which correctly creates users 12 13 A tiny extension of Django's UserManager which correctly creates users
without usernames (using emails instead). 13 14 without usernames (using emails instead).
""" 14 15 """
15 16
def _create_user(self, email, password, is_staff, is_superuser, **extra_fields): 16 17 def _create_user(self, email, password, is_staff, is_superuser, **extra_fields):
""" 17 18 """
Creates and saves a User with the given username, email and password. 18 19 Creates and saves a User with the given username, email and password.
""" 19 20 """
email = self.normalize_email(email) 20 21 email = self.normalize_email(email)
user = self.model(email=email, 21 22 user = self.model(email=email,
is_staff=is_staff, is_active=True, 22 23 is_staff=is_staff, is_active=True,
is_superuser=is_superuser, 23 24 is_superuser=is_superuser,
date_joined=now(), **extra_fields) 24 25 date_joined=now(), **extra_fields)
user.set_password(password) 25 26 user.set_password(password)
user.save(using=self._db) 26 27 user.save(using=self._db)
return user 27 28 return user
28 29
def create_user(self, email, password=None, **extra_fields): 29 30 def create_user(self, email, password=None, **extra_fields):
return self._create_user(email, password, False, False, **extra_fields) 30 31 return self._create_user(email, password, False, False, **extra_fields)
31 32
def create_superuser(self, email, password, **extra_fields): 32 33 def create_superuser(self, email, password, **extra_fields):
return self._create_user(email, password, True, True, **extra_fields) 33 34 return self._create_user(email, password, True, True, **extra_fields)
34 35
35 36
class User(AbstractUser, SimpleEmailConfirmationUserMixin): 36 37 class User(AbstractUser, SimpleEmailConfirmationUserMixin):
""" 37 38 """
An extension of Django's default user model. 38 39 An extension of Django's default user model.
We use email as the username field, and include enrolled sections here 39 40 We use email as the username field, and include enrolled sections here
""" 40 41 """
objects = EmailOnlyUserManager() 41 42 objects = EmailOnlyUserManager()
USERNAME_FIELD = 'email' 42 43 USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] 43 44 REQUIRED_FIELDS = []
sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in") 44 45 sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in")
45 46
46 47
class UserFlashcard(Model): 47 48 class UserFlashcard(Model):
""" 48 49 """
Represents the relationship between a user and a flashcard by: 49 50 Represents the relationship between a user and a flashcard by:
1. A user has a flashcard in their deck 50 51 1. A user has a flashcard in their deck
2. A user used to have a flashcard in their deck 51 52 2. A user used to have a flashcard in their deck
3. A user has a flashcard hidden from them 52 53 3. A user has a flashcard hidden from them
""" 53 54 """
user = ForeignKey('User') 54 55 user = ForeignKey('User')
mask = ForeignKey('FlashcardMask', help_text="A mask which overrides the card's mask") 55 56 mask = ForeignKey('FlashcardMask', help_text="A mask which overrides the card's mask")
pulled = DateTimeField(blank=True, null=True, help_text="When the user pulled the card") 56 57 pulled = DateTimeField(blank=True, null=True, help_text="When the user pulled the card")
flashcard = ForeignKey('Flashcard') 57 58 flashcard = ForeignKey('Flashcard')
unpulled = DateTimeField(blank=True, null=True, help_text="When the user unpulled this card") 58 59 unpulled = DateTimeField(blank=True, null=True, help_text="When the user unpulled this card")
59 60
class Meta: 60 61 class Meta:
# There can be at most one UserFlashcard for each User and Flashcard 61 62 # There can be at most one UserFlashcard for each User and Flashcard
unique_together = (('user', 'flashcard'),) 62 63 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 63 64 index_together = ["user", "flashcard"]
# By default, order by most recently pulled 64 65 # By default, order by most recently pulled
ordering = ['-pulled'] 65 66 ordering = ['-pulled']
66 67
def is_hidden(self): 67 68 def is_hidden(self):
""" 68 69 """
A card is hidden only if a user has not ever added it to their deck. 69 70 A card is hidden only if a user has not ever added it to their deck.
:return: Whether the flashcard is hidden from the user 70 71 :return: Whether the flashcard is hidden from the user
""" 71 72 """
return not self.pulled 72 73 return not self.pulled
73 74
def is_in_deck(self): 74 75 def is_in_deck(self):
""" 75 76 """
:return:Whether the flashcard is in the user's deck 76 77 :return:Whether the flashcard is in the user's deck
""" 77 78 """
return self.pulled and not self.unpulled 78 79 return self.pulled and not self.unpulled
79 80
80 81
class FlashcardMask(Model): 81 82 class FlashcardMask(Model):
""" 82 83 """
A serialized list of character ranges that can be blanked out during review. 83 84 A serialized list of character ranges that can be blanked out during review.
This is encoded as '13-145,150-195' 84 85 This is encoded as '13-145,150-195'
""" 85 86 """
ranges = CharField(max_length=255) 86 87 ranges = CharField(max_length=255)
87 88
88 89
class Flashcard(Model): 89 90 class Flashcard(Model):
text = CharField(max_length=255, help_text='The text on the card') 90 91 text = CharField(max_length=255, help_text='The text on the card')
section = ForeignKey('Section', help_text='The section with which the card is associated') 91 92 section = ForeignKey('Section', help_text='The section with which the card is associated')
pushed = DateTimeField(auto_now_add=True, help_text="When the card was first pushed") 92 93 pushed = DateTimeField(auto_now_add=True, help_text="When the card was first pushed")
material_date = DateTimeField(help_text="The date with which the card is associated") 93 94 material_date = DateTimeField(help_text="The date with which the card is associated")
previous = ForeignKey('Flashcard', null=True, blank=True, 94 95 previous = ForeignKey('Flashcard', null=True, blank=True,
help_text="The previous version of this card, if one exists") 95 96 help_text="The previous version of this card, if one exists")
author = ForeignKey(User) 96 97 author = ForeignKey(User)
is_hidden = BooleanField(default=False) 97 98 is_hidden = BooleanField(default=False)
hide_reason = CharField(blank=True, max_length=255, help_text="Reason for hiding this card") 98 99 hide_reason = CharField(blank=True, max_length=255, help_text="Reason for hiding this card")
mask = ForeignKey(FlashcardMask, blank=True, null=True, help_text="The default mask for this card") 99 100 mask = ForeignKey(FlashcardMask, blank=True, null=True, help_text="The default mask for this card")
100 101
class Meta: 101 102 class Meta:
# By default, order by most recently pushed 102 103 # By default, order by most recently pushed
ordering = ['-pushed'] 103 104 ordering = ['-pushed']
104 105
def is_hidden_from(self, user): 105 106 def is_hidden_from(self, user):
""" 106 107 """
A card can be hidden globally, but if a user has the card in their deck, 107 108 A card can be hidden globally, but if a user has the card in their deck,
this visibility overrides a global hide. 108 109 this visibility overrides a global hide.
:param user: 109 110 :param user:
:return: Whether the card is hidden from the user. 110 111 :return: Whether the card is hidden from the user.
""" 111 112 """
result = user.userflashcard_set.filter(flashcard=self) 112 113 result = user.userflashcard_set.filter(flashcard=self)
if not result.exists(): return self.is_hidden 113 114 if not result.exists(): return self.is_hidden
return result[0].is_hidden() 114 115 return result[0].is_hidden()
115 116
116 117
@classmethod 117 118 @classmethod
def cards_visible_to(cls, user): 118 119 def cards_visible_to(cls, user):
""" 119 120 """
:param user: 120 121 :param user:
:return: A queryset with all cards that should be visible to a user. 121 122 :return: A queryset with all cards that should be visible to a user.
""" 122 123 """
return cls.objects.filter(hidden=False).exclude(userflashcard=user, userflashcard__pulled=None) 123 124 return cls.objects.filter(hidden=False).exclude(userflashcard=user, userflashcard__pulled=None)
124 125
125 126
class UserFlashcardReview(Model): 126 127 class UserFlashcardReview(Model):
""" 127 128 """
An event of a user reviewing a flashcard. 128 129 An event of a user reviewing a flashcard.
""" 129 130 """
user_flashcard = ForeignKey(UserFlashcard) 130 131 user_flashcard = ForeignKey(UserFlashcard)
when = DateTimeField() 131 132 when = DateTimeField()
blanked_word = CharField(max_length=8, blank=True, help_text="The character range which was blanked") 132 133 blanked_word = CharField(max_length=8, blank=True, help_text="The character range which was blanked")
response = CharField(max_length=255, blank=True, null=True, help_text="The user's response") 133 134 response = CharField(max_length=255, blank=True, null=True, help_text="The user's response")
correct = NullBooleanField(help_text="The user's self-evaluation of their response") 134 135 correct = NullBooleanField(help_text="The user's self-evaluation of their response")
135 136
def status(self): 136 137 def status(self):
""" 137 138 """
There are three stages of a review object: 138 139 There are three stages of a review object:
1. the user has been shown the card 139 140 1. the user has been shown the card
2. the user has answered the card 140 141 2. the user has answered the card
3. the user has self-evaluated their response's correctness 141 142 3. the user has self-evaluated their response's correctness
142 143
:return: string (evaluated, answered, viewed) 143 144 :return: string (evaluated, answered, viewed)
""" 144 145 """
if self.correct is not None: return "evaluated" 145 146 if self.correct is not None: return "evaluated"
if self.response: return "answered" 146 147 if self.response: return "answered"
return "viewed" 147 148 return "viewed"
148 149
149 150
class Section(Model): 150 151 class Section(Model):
""" 151 152 """
A UCSD course taught by an instructor during a quarter. 152 153 A UCSD course taught by an instructor during a quarter.
Different sections taught by the same instructor in the same quarter are considered identical. 153 154 Different sections taught by the same instructor in the same quarter are considered identical.
We use the term "section" to avoid collision with the builtin keyword "class" 154 155 We use the term "section" to avoid collision with the builtin keyword "class"
""" 155 156 """
department = CharField(max_length=50) 156 157 department = CharField(max_length=50)
course_num = CharField(max_length=6) 157 158 course_num = CharField(max_length=6)
# section_id = CharField(max_length=10) 158 159 # section_id = CharField(max_length=10)
course_title = CharField(max_length=50) 159 160 course_title = CharField(max_length=50)
instructor = CharField(max_length=50) 160 161 instructor = CharField(max_length=50)
quarter = CharField(max_length=4) 161 162 quarter = CharField(max_length=4)
whitelist = ManyToManyField(User, related_name="whitelisted_sections") 162 163 whitelist = ManyToManyField(User, related_name="whitelisted_sections")
flashcards/serializers.py View file @ ce17f96
from flashcards.models import Section, LecturePeriod, User 1 1 from flashcards.models import Section, LecturePeriod, User
from rest_framework.fields import EmailField, BooleanField 2 2 from rest_framework import serializers
3 from rest_framework.fields import EmailField, BooleanField, CharField, IntegerField
from rest_framework.relations import HyperlinkedRelatedField 3 4 from rest_framework.relations import HyperlinkedRelatedField
from rest_framework.serializers import HyperlinkedModelSerializer, ModelSerializer 4 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
class LecturePeriodSerializer(ModelSerializer): 7 59 class LecturePeriodSerializer(ModelSerializer):
class Meta: 8 60 class Meta:
model = LecturePeriod 9 61 model = LecturePeriod
exclude = 'id', 'section' 10 62 exclude = 'id', 'section'
11 63
12 64
class SectionSerializer(HyperlinkedModelSerializer): 13 65 class SectionSerializer(HyperlinkedModelSerializer):
lectures = LecturePeriodSerializer(source='lectureperiod_set', many=True, read_only=True) 14 66 lectures = LecturePeriodSerializer(source='lectureperiod_set', many=True, read_only=True)
15 67
class Meta: 16 68 class Meta:
model = Section 17 69 model = Section
exclude = 'whitelist', 18 70 exclude = 'whitelist',
19 71
20 72
class UserSerializer(HyperlinkedModelSerializer): 21 73 class UserSerializer(HyperlinkedModelSerializer):
email = EmailField(required=False) 22 74 email = EmailField(required=False)
flashcards/tests/test_api.py View file @ ce17f96
from django.test import Client 1
from flashcards.models import User 2 1 from flashcards.models import User
from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK, HTTP_401_UNAUTHORIZED 3 2 from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_401_UNAUTHORIZED
from rest_framework.test import APITestCase, APIClient 4 3 from rest_framework.test import APITestCase
5 4
6 5
class LoginTests(APITestCase): 7 6 class LoginTests(APITestCase):
def setUp(self): 8 7 def setUp(self):
email = "test@flashy.cards" 9 8 email = "test@flashy.cards"
User.objects.create_user(email=email, password="1234") 10 9 User.objects.create_user(email=email, password="1234")
11 10
def test_login(self): 12 11 def test_login(self):
url = '/api/login' 13 12 url = '/api/login'
data = {'email': 'test@flashy.cards', 'password': '1234'} 14 13 data = {'email': 'test@flashy.cards', 'password': '1234'}
response = self.client.post(url, data, format='json') 15 14 response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, HTTP_200_OK) 16 15 self.assertEqual(response.status_code, HTTP_200_OK)
17 16
data = {'email': 'test@flashy.cards', 'password': '54321'} 18 17 data = {'email': 'test@flashy.cards', 'password': '54321'}
response = self.client.post(url, data, format='json') 19 18 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Invalid email or password', status_code=400) 20 19 self.assertContains(response, 'Invalid email or password', status_code=403)
21 20
data = {'email': 'none@flashy.cards', 'password': '54321'} 22 21 data = {'email': 'none@flashy.cards', 'password': '54321'}
response = self.client.post(url, data, format='json') 23 22 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Invalid email or password', status_code=400) 24 23 self.assertContains(response, 'Invalid email or password', status_code=403)
25 24
data = {'password': '54321'} 26 25 data = {'password': '54321'}
response = self.client.post(url, data, format='json') 27 26 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Email is required', status_code=400) 28 27 self.assertContains(response, 'email', status_code=400)
29 28
30 29
class RegistrationTest(APITestCase): 31 30 class RegistrationTest(APITestCase):
def test_create_account(self): 32 31 def test_create_account(self):
url = '/api/users/me' 33 32 url = '/api/users/me'
data = {'email': 'none@none.com', 'password': '1234'} 34 33 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post(url, data, format='json') 35 34 response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, HTTP_201_CREATED) 36 35 self.assertEqual(response.status_code, HTTP_201_CREATED)
37 36
data = {'email': 'none@none.com', 'password': '1234'} 38 37 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post('/api/login', data, format='json') 39 38 response = self.client.post('/api/login', data, format='json')
self.assertEqual(response.status_code, HTTP_200_OK) 40 39 self.assertEqual(response.status_code, HTTP_200_OK)
41 40
42
data = {'email': 'none@none.com'} 43 41 data = {'email': 'none@none.com'}
response = self.client.post(url, data, format='json') 44 42 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Password is required', status_code=400) 45 43 self.assertContains(response, 'password', status_code=400)
46 44
data = {'password': '1234'} 47 45 data = {'password': '1234'}
response = self.client.post(url, data, format='json') 48 46 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Email is required', status_code=400) 49 47 self.assertContains(response, 'email', status_code=400)
50 48
49
class ProfileViewTest(APITestCase): 51 50 class ProfileViewTest(APITestCase):
def setUp(self): 52 51 def setUp(self):
email = "profileviewtest@flashy.cards" 53 52 email = "profileviewtest@flashy.cards"
User.objects.create_user(email=email, password="1234") 54 53 User.objects.create_user(email=email, password="1234")
55 54
def test_get_me(self): 56 55 def test_get_me(self):
url = '/api/users/me' 57 56 url = '/api/users/me'
flashcards/views.py View file @ ce17f96
from flashcards.models import Section, LecturePeriod 1 1 from flashcards.api import StandardResultsSetPagination
from flashcards.serializers import SectionSerializer, LecturePeriodSerializer 2 2 from flashcards.models import Section, User
from rest_framework.permissions import IsAuthenticatedOrReadOnly 3 3 from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \
from rest_framework.viewsets import ModelViewSet 4 4 PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer
from rest_framework.pagination import PageNumberPagination 5 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
class StandardResultsSetPagination(PageNumberPagination): 8 15 class SectionViewSet(ReadOnlyModelViewSet):
page_size = 40 9
page_size_query_param = 'page_size' 10
max_page_size = 1000 11
12
13
class SectionViewSet(ModelViewSet): 14
queryset = Section.objects.all() 15 16 queryset = Section.objects.all()
serializer_class = SectionSerializer 16 17 serializer_class = SectionSerializer
permission_classes = (IsAuthenticatedOrReadOnly,) 17
pagination_class = StandardResultsSetPagination 18 18 pagination_class = StandardResultsSetPagination
19 19
20 20
class LecturePeriodViewSet(ModelViewSet): 21 21 class UserDetail(APIView):
queryset = LecturePeriod.objects.all() 22 22 def patch(self, request, format=None):
serializer_class = LecturePeriodSerializer 23 23 """
permission_classes = (IsAuthenticatedOrReadOnly,) 24 24 Updates the user's password, or verifies their email address
pagination_class = StandardResultsSetPagination 25 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)
26
27
flashy/settings.py View file @ ce17f96
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) 1 1 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os 2 2 import os
3 3
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 4 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5 5
# SECURITY WARNING: don't run with debug turned on in production! 6 6 # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True 7 7 DEBUG = True
8 8
ALLOWED_HOSTS = [] 9 9 ALLOWED_HOSTS = []
10 10
AUTH_USER_MODEL = 'flashcards.User' 11 11 AUTH_USER_MODEL = 'flashcards.User'
12 12
INSTALLED_APPS = ( 13 13 INSTALLED_APPS = (
'simple_email_confirmation', 14 14 'simple_email_confirmation',
'flashcards', 15 15 'flashcards',
'django.contrib.admin', 16 16 'django.contrib.admin',
'django.contrib.admindocs', 17 17 'django.contrib.admindocs',
'django.contrib.auth', 18 18 'django.contrib.auth',
'django.contrib.contenttypes', 19 19 'django.contrib.contenttypes',
'django.contrib.sessions', 20 20 'django.contrib.sessions',
'django.contrib.messages', 21 21 'django.contrib.messages',
'django.contrib.staticfiles', 22 22 'django.contrib.staticfiles',
'django_ses', 23 23 'django_ses',
24 'rest_framework_swagger',
'rest_framework', 24 25 'rest_framework',
) 25 26 )
26 27
REST_FRAMEWORK = { 27 28 REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.CursorPagination', 28 29 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.CursorPagination',
'PAGE_SIZE': 20 29 30 'PAGE_SIZE': 20
} 30 31 }
31 32
MIDDLEWARE_CLASSES = ( 32 33 MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware', 33 34 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 34 35 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 35 36 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 36 37 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 37 38 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 38 39 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 39 40 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 40 41 'django.middleware.security.SecurityMiddleware',
) 41 42 )
42 43
ROOT_URLCONF = 'flashy.urls' 43 44 ROOT_URLCONF = 'flashy.urls'
44 45
AUTHENTICATION_BACKENDS = ( 45 46 AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', 46 47 'django.contrib.auth.backends.ModelBackend',
) 47 48 )
48 49
TEMPLATES = [ 49 50 TEMPLATES = [
{ 50 51 {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 51 52 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['templates/'], 52 53 'DIRS': ['templates/'],
'APP_DIRS': True, 53 54 'APP_DIRS': True,
'OPTIONS': { 54 55 'OPTIONS': {
'context_processors': [ 55 56 'context_processors': [
'django.template.context_processors.debug', 56 57 'django.template.context_processors.debug',
'django.template.context_processors.request', 57 58 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 58 59 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 59 60 'django.contrib.messages.context_processors.messages',
], 60 61 ],
}, 61 62 },
}, 62 63 },
] 63 64 ]
64 65
WSGI_APPLICATION = 'flashy.wsgi.application' 65 66 WSGI_APPLICATION = 'flashy.wsgi.application'
66 67
DATABASES = { 67 68 DATABASES = {
'default': { 68 69 'default': {
'ENGINE': 'django.db.backends.sqlite3', 69 70 'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 70 71 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
} 71 72 }
} 72 73 }
73 74
LANGUAGE_CODE = 'en-us' 74 75 LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'America/Los_Angeles' 75 76 TIME_ZONE = 'America/Los_Angeles'
USE_I18N = True 76 77 USE_I18N = True
USE_L10N = True 77 78 USE_L10N = True
USE_TZ = True 78 79 USE_TZ = True
79 80
STATIC_URL = '/static/' 80 81 STATIC_URL = '/static/'
STATIC_ROOT = 'static' 81 82 STATIC_ROOT = 'static'
82 83
flashy/urls.py View file @ ce17f96
from django.conf.urls import include, url 1 1 from django.conf.urls import include, url
from django.contrib import admin 2 2 from django.contrib import admin
from flashcards.views import SectionViewSet, LecturePeriodViewSet 3 3 from flashcards.views import SectionViewSet, UserDetail, UserLogin, UserLogout, PasswordReset
from rest_framework.routers import DefaultRouter 4 4 from rest_framework.routers import DefaultRouter
from flashcards.api import * 5 5 from flashcards.api import *
6 6
router = DefaultRouter() 7 7 router = DefaultRouter()
router.register(r'sections', SectionViewSet) 8 8 router.register(r'sections', SectionViewSet)
9 9
urlpatterns = [ 10 10 urlpatterns = [
11 url(r'^api/docs/', include('rest_framework_swagger.urls')),
url(r'^api/users/me$', UserDetail.as_view()), 11 12 url(r'^api/users/me$', UserDetail.as_view()),
url(r'^api/login$', UserLogin.as_view()), 12 13 url(r'^api/login$', UserLogin.as_view()),
url(r'^api/logout$', UserLogout.as_view()), 13 14 url(r'^api/logout$', UserLogout.as_view()),
url(r'^api/reset_password$', PasswordReset.as_view()), 14 15 url(r'^api/reset_password$', PasswordReset.as_view()),
url(r'^api/', include(router.urls)), 15 16 url(r'^api/', include(router.urls)),
url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 16 17 url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
url(r'^admin/', include(admin.site.urls)), 17 18 url(r'^admin/', include(admin.site.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')) 18 19 url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework'))
] 19 20 ]
requirements.txt View file @ ce17f96
beautifulsoup4 1 1 beautifulsoup4
Django>=1.8 2 2 Django>=1.8
#django-websocket-redis==0.4.3 3 3 #django-websocket-redis==0.4.3
#gevent==1.0.1 4 4 #gevent==1.0.1
#greenlet==0.4.5 5 5 #greenlet==0.4.5
#redis==2.10.3 6 6 #redis==2.10.3
six==1.9.0 7 7 six==1.9.0
djangorestframework 8 8 djangorestframework
docutils 9 9 docutils
gunicorn 10 10 gunicorn
django-simple-email-confirmation 11 11 django-simple-email-confirmation
django-ses 12 12 django-ses
coverage 13 13 coverage
14 django-rest-swagger