Commit ddd9b214550605969ecaded850aec381dfee1673

Authored by Andrew Buss
1 parent 56c04ca5b8
Exists in master

Fixed usermanager more completely

Showing 3 changed files with 41 additions and 10 deletions Inline Diff

flashcards/api.py View file @ ddd9b21
from django.core.mail import send_mail 1 1 from django.core.mail import send_mail
from django.contrib.auth import authenticate, login, logout 2 2 from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.tokens import default_token_generator 3 3 from django.contrib.auth.tokens import default_token_generator
from rest_framework.permissions import BasePermission 4 4 from rest_framework.permissions import BasePermission
from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED 5 5 from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED
from rest_framework.views import APIView 6 6 from rest_framework.views import APIView
from rest_framework.response import Response 7 7 from rest_framework.response import Response
from rest_framework.exceptions import ValidationError, NotFound 8 8 from rest_framework.exceptions import ValidationError, NotFound
from flashcards.serializers import * 9 9 from flashcards.serializers import *
10 10
11 11
class UserDetailPermissions(BasePermission): 12 12 class UserDetailPermissions(BasePermission):
def has_object_permission(self, request, view, obj): 13 13 def has_object_permission(self, request, view, obj):
if request.method == 'POST': 14 14 if request.method == 'POST':
return True 15 15 return True
return request.user.is_active 16 16 return request.user.is_active
17 17
18 18
class UserDetail(APIView): 19 19 class UserDetail(APIView):
def patch(self, request, format=None): 20 20 def patch(self, request, format=None):
""" 21 21 """
This method checks either the email or the password passed in 22 22 This method checks either the email or the password passed in
is valid. If confirmation key is correct, it validates the 23 23 is valid. If confirmation key is correct, it validates the
user. It updates the password if the new password 24 24 user. It updates the password if the new password
is valid. 25 25 is valid.
26 26
""" 27 27 """
currentuser = request.user 28 28 currentuser = request.user
29 29
if 'confirmation_key' in request.data: 30 30 if 'confirmation_key' in request.data:
if not currentuser.confirm_email(request.data['confirmation_key']): 31 31 if not currentuser.confirm_email(request.data['confirmation_key']):
raise ValidationError('confirmation_key is invalid') 32 32 raise ValidationError('confirmation_key is invalid')
33 33
if 'new_password' in request.data: 34 34 if 'new_password' in request.data:
if not currentuser.check_password(request.data['old_password']): 35 35 if not currentuser.check_password(request.data['old_password']):
raise ValidationError('Invalid old password') 36 36 raise ValidationError('Invalid old password')
if not request.data['new_password']: 37 37 if not request.data['new_password']:
raise ValidationError('Password cannot be blank') 38 38 raise ValidationError('Password cannot be blank')
currentuser.set_password(request.data['new_password']) 39 39 currentuser.set_password(request.data['new_password'])
currentuser.save() 40 40 currentuser.save()
41 41
return Response(UserSerializer(request.user).data) 42 42 return Response(UserSerializer(request.user).data)
43 43
def get(self, request, format=None): 44 44 def get(self, request, format=None):
if not request.user.is_active: return Response(status=HTTP_401_UNAUTHORIZED) 45 45 if not request.user.is_active: return Response(status=HTTP_401_UNAUTHORIZED)
serializer = UserSerializer(request.user) 46 46 serializer = UserSerializer(request.user)
return Response(serializer.data) 47 47 return Response(serializer.data)
48 48
def post(self, request, format=None): 49 49 def post(self, request, format=None):
if 'email' not in request.data: 50 50 if 'email' not in request.data:
raise ValidationError('Email is required') 51 51 raise ValidationError('Email is required')
if 'password' not in request.data: 52 52 if 'password' not in request.data:
raise ValidationError('Password is required') 53 53 raise ValidationError('Password is required')
email = request.data['email'] 54 54 email = request.data['email']
existing_users = User.objects.filter(email=email) 55 55 existing_users = User.objects.filter(email=email)
if existing_users.exists(): 56 56 if existing_users.exists():
raise ValidationError("An account with this email already exists") 57 57 raise ValidationError("An account with this email already exists")
user = User.objects.create_user(email, email=email, password=request.data['password']) 58 58 user = User.objects.create_user(email=email, password=request.data['password'])
59 59
body = ''' 60 60 body = '''
Visit the following link to confirm your email address: 61 61 Visit the following link to confirm your email address:
http://flashy.cards/app/verify_email/%s 62 62 http://flashy.cards/app/verify_email/%s
63 63
If you did not register for Flashy, no action is required. 64 64 If you did not register for Flashy, no action is required.
''' 65 65 '''
send_mail("Flashy email verification", 66 66 send_mail("Flashy email verification",
body % user.confirmation_key, 67 67 body % user.confirmation_key,
"noreply@flashy.cards", 68 68 "noreply@flashy.cards",
[user.email]) 69 69 [user.email])
70 70
user = authenticate(email=email, password=request.data['password']) 71 71 user = authenticate(email=email, password=request.data['password'])
login(request, user) 72 72 login(request, user)
return Response(UserSerializer(user).data, status=HTTP_201_CREATED) 73 73 return Response(UserSerializer(user).data, status=HTTP_201_CREATED)
74 74
def delete(self, request): 75 75 def delete(self, request):
request.user.delete() 76 76 request.user.delete()
return Response(status=HTTP_204_NO_CONTENT) 77 77 return Response(status=HTTP_204_NO_CONTENT)
78 78
79 79
class UserLogin(APIView): 80 80 class UserLogin(APIView):
""" 81 81 """
Authenticates user and returns user data if valid. Handles invalid 82 82 Authenticates user and returns user data if valid. Handles invalid
users. 83 83 users.
""" 84 84 """
85 85
def post(self, request, format=None): 86 86 def post(self, request, format=None):
""" 87 87 """
Authenticates and logs in the user and returns their data if valid. 88 88 Authenticates and logs in the user and returns their data if valid.
""" 89 89 """
if 'email' not in request.data: 90 90 if 'email' not in request.data:
raise ValidationError('Email is required') 91 91 raise ValidationError('Email is required')
if 'password' not in request.data: 92 92 if 'password' not in request.data:
raise ValidationError('Password is required') 93 93 raise ValidationError('Password is required')
94 94
email = request.data['email'] 95 95 email = request.data['email']
password = request.data['password'] 96 96 password = request.data['password']
user = authenticate(email=email, password=password) 97 97 user = authenticate(email=email, password=password)
98 98
if user is None: 99 99 if user is None:
raise ValidationError('Invalid email or password') 100 100 raise ValidationError('Invalid email or password')
if not user.is_active: 101 101 if not user.is_active:
raise ValidationError('Account is disabled') 102 102 raise ValidationError('Account is disabled')
login(request, user) 103 103 login(request, user)
return Response(UserSerializer(user).data) 104 104 return Response(UserSerializer(user).data)
105 105
106 106
class UserLogout(APIView): 107 107 class UserLogout(APIView):
""" 108 108 """
Authenticated user log out. 109 109 Authenticated user log out.
""" 110 110 """
111 111
def post(self, request, format=None): 112 112 def post(self, request, format=None):
""" 113 113 """
Logs the authenticated user out. 114 114 Logs the authenticated user out.
""" 115 115 """
logout(request) 116 116 logout(request)
return Response(status=HTTP_204_NO_CONTENT) 117 117 return Response(status=HTTP_204_NO_CONTENT)
118 118
119 119
class PasswordReset(APIView): 120 120 class PasswordReset(APIView):
""" 121 121 """
Allows user to reset their password. 122 122 Allows user to reset their password.
System sends an email to the user's email with a token that may be verified 123 123 System sends an email to the user's email with a token that may be verified
to reset their password. 124 124 to reset their password.
""" 125 125 """
126 126
def post(self, request, format=None): 127 127 def post(self, request, format=None):
""" 128 128 """
Send a password reset token/link to the provided email. 129 129 Send a password reset token/link to the provided email.
""" 130 130 """
if 'email' not in request.data: 131 131 if 'email' not in request.data:
raise ValidationError('Email is required') 132 132 raise ValidationError('Email is required')
133 133
email = request.data['email'] 134 134 email = request.data['email']
135 135
# Find the user since they are not logged in. 136 136 # Find the user since they are not logged in.
try: 137 137 try:
user = User.objects.get(email=email) 138 138 user = User.objects.get(email=email)
except User.DoesNotExist: 139 139 except User.DoesNotExist:
# Don't leak that email does not exist. 140 140 # Don't leak that email does not exist.
flashcards/models.py View file @ ddd9b21
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 *
3 from django.utils.timezone import now
from simple_email_confirmation import SimpleEmailConfirmationUserMixin 3 4 from simple_email_confirmation import SimpleEmailConfirmationUserMixin
4 5
# Hack to fix AbstractUser before subclassing it 5 6 # Hack to fix AbstractUser before subclassing it
AbstractUser._meta.get_field('email')._unique = True 6 7 AbstractUser._meta.get_field('email')._unique = True
7 8
8 9
class UserManager(UserManager): 9 10 class EmailOnlyUserManager(UserManager):
11 """
12 A tiny extension of Django's UserManager which correctly creates users
13 without usernames (using emails instead).
14 """
15
16 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.
19 """
20 email = self.normalize_email(email)
21 user = self.model(email=email,
22 is_staff=is_staff, is_active=True,
23 is_superuser=is_superuser,
24 date_joined=now(), **extra_fields)
25 user.set_password(password)
26 user.save(using=self._db)
27 return user
28
29 def create_user(self, email, password=None, **extra_fields):
30 return self._create_user(email, password, False, False, **extra_fields)
31
def create_superuser(self, email, password, **extra_fields): 10 32 def create_superuser(self, email, password, **extra_fields):
return self._create_user(email, email, password, True, True, **extra_fields) 11 33 return self._create_user(email, password, True, True, **extra_fields)
12 34
13 35
class User(AbstractUser, SimpleEmailConfirmationUserMixin): 14 36 class User(AbstractUser, SimpleEmailConfirmationUserMixin):
objects = UserManager() 15 37 """
38 An extension of Django's default user model.
39 We use email as the username field, and include enrolled sections here
40 """
41 objects = EmailOnlyUserManager()
USERNAME_FIELD = 'email' 16 42 USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] 17 43 REQUIRED_FIELDS = []
sections = ManyToManyField('Section') 18 44 sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in")
19 45
20 46
class UserFlashcard(Model): 21 47 class UserFlashcard(Model):
""" 22 48 """
Represents the relationship between a user and a flashcard by: 23 49 Represents the relationship between a user and a flashcard by:
1. A user has a flashcard in their deck 24 50 1. A user has a flashcard in their deck
2. A user used to have a flashcard in their deck 25 51 2. A user used to have a flashcard in their deck
3. A user has a flashcard hidden from them 26 52 3. A user has a flashcard hidden from them
""" 27 53 """
user = ForeignKey('User') 28 54 user = ForeignKey('User')
mask = ForeignKey('FlashcardMask', help_text="A mask which overrides the card's mask") 29 55 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") 30 56 pulled = DateTimeField(blank=True, null=True, help_text="When the user pulled the card")
flashcard = ForeignKey('Flashcard') 31 57 flashcard = ForeignKey('Flashcard')
unpulled = DateTimeField(blank=True, null=True, help_text="When the user unpulled this card") 32 58 unpulled = DateTimeField(blank=True, null=True, help_text="When the user unpulled this card")
33 59
class Meta: 34 60 class Meta:
# There can be at most one UserFlashcard for each User and Flashcard 35 61 # There can be at most one UserFlashcard for each User and Flashcard
unique_together = (('user', 'flashcard'),) 36 62 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 37 63 index_together = ["user", "flashcard"]
# By default, order by most recently pulled 38 64 # By default, order by most recently pulled
ordering = ['-pulled'] 39 65 ordering = ['-pulled']
40 66
def is_hidden(self): 41 67 def is_hidden(self):
""" 42 68 """
A card is hidden only if a user has not ever added it to their deck. 43 69 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 44 70 :return: Whether the flashcard is hidden from the user
""" 45 71 """
return not self.pulled 46 72 return not self.pulled
47 73
def is_in_deck(self): 48 74 def is_in_deck(self):
""" 49 75 """
:return:Whether the flashcard is in the user's deck 50 76 :return:Whether the flashcard is in the user's deck
""" 51 77 """
return self.pulled and not self.unpulled 52 78 return self.pulled and not self.unpulled
53 79
54 80
class FlashcardMask(Model): 55 81 class FlashcardMask(Model):
""" 56 82 """
A serialized list of character ranges that can be blanked out during review. 57 83 A serialized list of character ranges that can be blanked out during review.
This is encoded as '13-145,150-195' 58 84 This is encoded as '13-145,150-195'
""" 59 85 """
ranges = CharField(max_length=255) 60 86 ranges = CharField(max_length=255)
61 87
62 88
class Flashcard(Model): 63 89 class Flashcard(Model):
text = CharField(max_length=255, help_text='The text on the card') 64 90 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') 65 91 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") 66 92 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") 67 93 material_date = DateTimeField(help_text="The date with which the card is associated")
previous = ForeignKey('Flashcard', null=True, blank=True, 68 94 previous = ForeignKey('Flashcard', null=True, blank=True,
help_text="The previous version of this card, if one exists") 69 95 help_text="The previous version of this card, if one exists")
author = ForeignKey(User) 70 96 author = ForeignKey(User)
is_hidden = BooleanField(default=False) 71 97 is_hidden = BooleanField(default=False)
hide_reason = CharField(blank=True, max_length=255, help_text="Reason for hiding this card") 72 98 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") 73 99 mask = ForeignKey(FlashcardMask, blank=True, null=True, help_text="The default mask for this card")
74 100
class Meta: 75 101 class Meta:
# By default, order by most recently pushed 76 102 # By default, order by most recently pushed
ordering = ['-pushed'] 77 103 ordering = ['-pushed']
78 104
def is_hidden_from(self, user): 79 105 def is_hidden_from(self, user):
""" 80 106 """
A card can be hidden globally, but if a user has the card in their deck, 81 107 A card can be hidden globally, but if a user has the card in their deck,
this visibility overrides a global hide. 82 108 this visibility overrides a global hide.
:param user: 83 109 :param user:
:return: Whether the card is hidden from the user. 84 110 :return: Whether the card is hidden from the user.
""" 85 111 """
result = user.userflashcard_set.filter(flashcard=self) 86 112 result = user.userflashcard_set.filter(flashcard=self)
if not result.exists(): return self.is_hidden 87 113 if not result.exists(): return self.is_hidden
return result[0].is_hidden() 88 114 return result[0].is_hidden()
89 115
90 116
@classmethod 91 117 @classmethod
def cards_visible_to(cls, user): 92 118 def cards_visible_to(cls, user):
""" 93 119 """
:param user: 94 120 :param user:
:return: A queryset with all cards that should be visible to a user. 95 121 :return: A queryset with all cards that should be visible to a user.
""" 96 122 """
return cls.objects.filter(hidden=False).exclude(userflashcard=user, userflashcard__pulled=None) 97 123 return cls.objects.filter(hidden=False).exclude(userflashcard=user, userflashcard__pulled=None)
98 124
99 125
class UserFlashcardReview(Model): 100 126 class UserFlashcardReview(Model):
""" 101 127 """
An event of a user reviewing a flashcard. 102 128 An event of a user reviewing a flashcard.
""" 103 129 """
user_flashcard = ForeignKey(UserFlashcard) 104 130 user_flashcard = ForeignKey(UserFlashcard)
when = DateTimeField() 105 131 when = DateTimeField()
blanked_word = CharField(max_length=8, blank=True, help_text="The character range which was blanked") 106 132 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") 107 133 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") 108 134 correct = NullBooleanField(help_text="The user's self-evaluation of their response")
109 135
def status(self): 110 136 def status(self):
""" 111 137 """
There are three stages of a review object: 112 138 There are three stages of a review object:
1. the user has been shown the card 113 139 1. the user has been shown the card
2. the user has answered the card 114 140 2. the user has answered the card
3. the user has self-evaluated their response's correctness 115 141 3. the user has self-evaluated their response's correctness
116 142
:return: string (evaluated, answered, viewed) 117 143 :return: string (evaluated, answered, viewed)
""" 118 144 """
if self.correct is not None: return "evaluated" 119 145 if self.correct is not None: return "evaluated"
if self.response: return "answered" 120 146 if self.response: return "answered"
return "viewed" 121 147 return "viewed"
122 148
123 149
class Section(Model): 124 150 class Section(Model):
""" 125 151 """
A UCSD course taught by an instructor during a quarter. 126 152 A UCSD course taught by an instructor during a quarter.
Different sections taught by the same instructor in the same quarter are considered identical. 127 153 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" 128 154 We use the term "section" to avoid collision with the builtin keyword "class"
""" 129 155 """
department = CharField(max_length=50) 130 156 department = CharField(max_length=50)
course_num = CharField(max_length=6) 131 157 course_num = CharField(max_length=6)
# section_id = CharField(max_length=10) 132 158 # section_id = CharField(max_length=10)
course_title = CharField(max_length=50) 133 159 course_title = CharField(max_length=50)
instructor = CharField(max_length=50) 134 160 instructor = CharField(max_length=50)
quarter = CharField(max_length=4) 135 161 quarter = CharField(max_length=4)
whitelist = ManyToManyField(User, related_name="whitelisted_sections") 136 162 whitelist = ManyToManyField(User, related_name="whitelisted_sections")
137 163
class Meta: 138 164 class Meta:
unique_together = (('department', 'course_num', 'quarter', 'instructor'),) 139 165 unique_together = (('department', 'course_num', 'quarter', 'instructor'),)
ordering = ['-quarter'] 140
141 166
142 167
class LecturePeriod(Model): 143 168 class LecturePeriod(Model):
""" 144 169 """
A lecture period for a section 145 170 A lecture period for a section
""" 146 171 """
flashcards/tests/test_api.py View file @ ddd9b21
from django.test import Client 1 1 from django.test import Client
from flashcards.models import User 2 2 from flashcards.models import User
from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK, HTTP_401_UNAUTHORIZED 3 3 from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK, HTTP_401_UNAUTHORIZED
from rest_framework.test import APITestCase, APIClient 4 4 from rest_framework.test import APITestCase, APIClient
5 5
6 6
class LoginTests(APITestCase): 7 7 class LoginTests(APITestCase):
def setUp(self): 8 8 def setUp(self):
email = "test@flashy.cards" 9 9 email = "test@flashy.cards"
User.objects.create_user(email, email=email, password="1234") 10 10 User.objects.create_user(email=email, password="1234")
11 11
def test_login(self): 12 12 def test_login(self):
url = '/api/login' 13 13 url = '/api/login'
data = {'email': 'test@flashy.cards', 'password': '1234'} 14 14 data = {'email': 'test@flashy.cards', 'password': '1234'}
response = self.client.post(url, data, format='json') 15 15 response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, HTTP_200_OK) 16 16 self.assertEqual(response.status_code, HTTP_200_OK)
17 17
data = {'email': 'test@flashy.cards', 'password': '54321'} 18 18 data = {'email': 'test@flashy.cards', 'password': '54321'}
response = self.client.post(url, data, format='json') 19 19 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Invalid email or password', status_code=400) 20 20 self.assertContains(response, 'Invalid email or password', status_code=400)
21 21
data = {'email': 'none@flashy.cards', 'password': '54321'} 22 22 data = {'email': 'none@flashy.cards', 'password': '54321'}
response = self.client.post(url, data, format='json') 23 23 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Invalid email or password', status_code=400) 24 24 self.assertContains(response, 'Invalid email or password', status_code=400)
25 25
data = {'password': '54321'} 26 26 data = {'password': '54321'}
response = self.client.post(url, data, format='json') 27 27 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Email is required', status_code=400) 28 28 self.assertContains(response, 'Email is required', status_code=400)
29 29
30 30
class RegistrationTest(APITestCase): 31 31 class RegistrationTest(APITestCase):
def test_create_account(self): 32 32 def test_create_account(self):
url = '/api/users/me' 33 33 url = '/api/users/me'
data = {'email': 'none@none.com', 'password': '1234'} 34 34 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post(url, data, format='json') 35 35 response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, HTTP_201_CREATED) 36 36 self.assertEqual(response.status_code, HTTP_201_CREATED)
37 37
data = {'email': 'none@none.com', 'password': '1234'} 38 38 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post('/api/login', data, format='json') 39 39 response = self.client.post('/api/login', data, format='json')
self.assertEqual(response.status_code, HTTP_200_OK) 40 40 self.assertEqual(response.status_code, HTTP_200_OK)
41 41
42 42
data = {'email': 'none@none.com'} 43 43 data = {'email': 'none@none.com'}
response = self.client.post(url, data, format='json') 44 44 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Password is required', status_code=400) 45 45 self.assertContains(response, 'Password is required', status_code=400)
46 46
data = {'password': '1234'} 47 47 data = {'password': '1234'}
response = self.client.post(url, data, format='json') 48 48 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Email is required', status_code=400) 49 49 self.assertContains(response, 'Email is required', status_code=400)
50 50
class ProfileViewTest(APITestCase): 51 51 class ProfileViewTest(APITestCase):