Commit dc685f1923ffcee8414dc416a53fb0cc7c2862d0

Authored by Andrew Buss
1 parent c1f4d3dea4
Exists in master

Section search works properly now

Showing 4 changed files with 56 additions and 5 deletions Inline Diff

flashcards/migrations/0010_auto_20150513_1546.py View file @ dc685f1
File was created 1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5 import django.utils.timezone
6
7
8 class Migration(migrations.Migration):
9
10 dependencies = [
11 ('flashcards', '0009_auto_20150512_0318'),
12 ]
13
14 operations = [
15 migrations.AlterField(
16 model_name='flashcard',
17 name='material_date',
18 field=models.DateTimeField(default=django.utils.timezone.now, help_text=b'The date with which the card is associated'),
19 ),
20 migrations.AlterField(
flashcards/models.py View file @ dc685f1
from django.contrib.auth.models import AbstractUser, UserManager 1 1 from django.contrib.auth.models import 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
from fields import MaskField 5 5 from fields import MaskField
6 6
# Hack to fix AbstractUser before subclassing it 7 7 # Hack to fix AbstractUser before subclassing it
AbstractUser._meta.get_field('email')._unique = True 8 8 AbstractUser._meta.get_field('email')._unique = True
AbstractUser._meta.get_field('username')._unique = False 9 9 AbstractUser._meta.get_field('username')._unique = False
10 10
11 11
class EmailOnlyUserManager(UserManager): 12 12 class EmailOnlyUserManager(UserManager):
""" 13 13 """
A tiny extension of Django's UserManager which correctly creates users 14 14 A tiny extension of Django's UserManager which correctly creates users
without usernames (using emails instead). 15 15 without usernames (using emails instead).
""" 16 16 """
17 17
def _create_user(self, email, password, is_staff, is_superuser, **extra_fields): 18 18 def _create_user(self, email, password, is_staff, is_superuser, **extra_fields):
""" 19 19 """
Creates and saves a User with the given email and password. 20 20 Creates and saves a User with the given email and password.
""" 21 21 """
email = self.normalize_email(email) 22 22 email = self.normalize_email(email)
user = self.model(email=email, 23 23 user = self.model(email=email,
is_staff=is_staff, is_active=True, 24 24 is_staff=is_staff, is_active=True,
is_superuser=is_superuser, 25 25 is_superuser=is_superuser,
date_joined=now(), **extra_fields) 26 26 date_joined=now(), **extra_fields)
user.set_password(password) 27 27 user.set_password(password)
user.save(using=self._db) 28 28 user.save(using=self._db)
return user 29 29 return user
30 30
def create_user(self, email, password=None, **extra_fields): 31 31 def create_user(self, email, password=None, **extra_fields):
return self._create_user(email, password, False, False, **extra_fields) 32 32 return self._create_user(email, password, False, False, **extra_fields)
33 33
def create_superuser(self, email, password, **extra_fields): 34 34 def create_superuser(self, email, password, **extra_fields):
return self._create_user(email, password, True, True, **extra_fields) 35 35 return self._create_user(email, password, True, True, **extra_fields)
36 36
37 37
class User(AbstractUser, SimpleEmailConfirmationUserMixin): 38 38 class User(AbstractUser, SimpleEmailConfirmationUserMixin):
""" 39 39 """
An extension of Django's default user model. 40 40 An extension of Django's default user model.
We use email as the username field, and include enrolled sections here 41 41 We use email as the username field, and include enrolled sections here
""" 42 42 """
objects = EmailOnlyUserManager() 43 43 objects = EmailOnlyUserManager()
USERNAME_FIELD = 'email' 44 44 USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] 45 45 REQUIRED_FIELDS = []
sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in") 46 46 sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in")
47 47
48 48
class UserFlashcard(Model): 49 49 class UserFlashcard(Model):
""" 50 50 """
Represents the relationship between a user and a flashcard by: 51 51 Represents the relationship between a user and a flashcard by:
1. A user has a flashcard in their deck 52 52 1. A user has a flashcard in their deck
2. A user used to have a flashcard in their deck 53 53 2. A user used to have a flashcard in their deck
3. A user has a flashcard hidden from them 54 54 3. A user has a flashcard hidden from them
""" 55 55 """
user = ForeignKey('User') 56 56 user = ForeignKey('User')
mask = MaskField(max_length=255, null=True, blank=True, help_text="The user-specific mask on the card") 57 57 mask = MaskField(max_length=255, null=True, blank=True, help_text="The user-specific mask on the card")
pulled = DateTimeField(blank=True, null=True, help_text="When the user pulled the card") 58 58 pulled = DateTimeField(blank=True, null=True, help_text="When the user pulled the card")
flashcard = ForeignKey('Flashcard') 59 59 flashcard = ForeignKey('Flashcard')
unpulled = DateTimeField(blank=True, null=True, help_text="When the user unpulled this card") 60 60 unpulled = DateTimeField(blank=True, null=True, help_text="When the user unpulled this card")
61 61
class Meta: 62 62 class Meta:
# There can be at most one UserFlashcard for each User and Flashcard 63 63 # There can be at most one UserFlashcard for each User and Flashcard
unique_together = (('user', 'flashcard'),) 64 64 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 65 65 index_together = ["user", "flashcard"]
# By default, order by most recently pulled 66 66 # By default, order by most recently pulled
ordering = ['-pulled'] 67 67 ordering = ['-pulled']
68 68
def is_hidden(self): 69 69 def is_hidden(self):
""" 70 70 """
A card is hidden only if a user has not ever added it to their deck. 71 71 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 72 72 :return: Whether the flashcard is hidden from the user
""" 73 73 """
return not self.pulled 74 74 return not self.pulled
75 75
def is_in_deck(self): 76 76 def is_in_deck(self):
""" 77 77 """
:return:Whether the flashcard is in the user's deck 78 78 :return:Whether the flashcard is in the user's deck
""" 79 79 """
return self.pulled and not self.unpulled 80 80 return self.pulled and not self.unpulled
81 81
82 82
class Flashcard(Model): 83 83 class Flashcard(Model):
text = CharField(max_length=255, help_text='The text on the card') 84 84 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') 85 85 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") 86 86 pushed = DateTimeField(auto_now_add=True, help_text="When the card was first pushed")
material_date = DateTimeField(default=now, help_text="The date with which the card is associated") 87 87 material_date = DateTimeField(default=now, help_text="The date with which the card is associated")
previous = ForeignKey('Flashcard', null=True, blank=True, 88 88 previous = ForeignKey('Flashcard', null=True, blank=True,
help_text="The previous version of this card, if one exists") 89 89 help_text="The previous version of this card, if one exists")
author = ForeignKey(User) 90 90 author = ForeignKey(User)
is_hidden = BooleanField(default=False) 91 91 is_hidden = BooleanField(default=False)
hide_reason = CharField(blank=True, max_length=255, help_text="Reason for hiding this card") 92 92 hide_reason = CharField(blank=True, max_length=255, help_text="Reason for hiding this card")
mask = MaskField(max_length=255, null=True, blank=True, help_text="The mask on the card") 93 93 mask = MaskField(max_length=255, null=True, blank=True, help_text="The mask on the card")
94 94
class Meta: 95 95 class Meta:
# By default, order by most recently pushed 96 96 # By default, order by most recently pushed
ordering = ['-pushed'] 97 97 ordering = ['-pushed']
98 98
def is_hidden_from(self, user): 99 99 def is_hidden_from(self, user):
""" 100 100 """
A card can be hidden globally, but if a user has the card in their deck, 101 101 A card can be hidden globally, but if a user has the card in their deck,
this visibility overrides a global hide. 102 102 this visibility overrides a global hide.
:param user: 103 103 :param user:
:return: Whether the card is hidden from the user. 104 104 :return: Whether the card is hidden from the user.
""" 105 105 """
result = user.userflashcard_set.filter(flashcard=self) 106 106 result = user.userflashcard_set.filter(flashcard=self)
if not result.exists(): return self.is_hidden 107 107 if not result.exists(): return self.is_hidden
return result[0].is_hidden() 108 108 return result[0].is_hidden()
109 109
@classmethod 110 110 @classmethod
def cards_visible_to(cls, user): 111 111 def cards_visible_to(cls, user):
""" 112 112 """
:param user: 113 113 :param user:
:return: A queryset with all cards that should be visible to a user. 114 114 :return: A queryset with all cards that should be visible to a user.
""" 115 115 """
return cls.objects.filter(is_hidden=False).exclude(userflashcard__user=user, userflashcard__pulled=None) 116 116 return cls.objects.filter(is_hidden=False).exclude(userflashcard__user=user, userflashcard__pulled=None)
117 117
118 118
class UserFlashcardQuiz(Model): 119 119 class UserFlashcardQuiz(Model):
""" 120 120 """
An event of a user being quizzed on a flashcard. 121 121 An event of a user being quizzed on a flashcard.
""" 122 122 """
user_flashcard = ForeignKey(UserFlashcard) 123 123 user_flashcard = ForeignKey(UserFlashcard)
when = DateTimeField(auto_now=True) 124 124 when = DateTimeField(auto_now=True)
blanked_word = CharField(max_length=8, blank=True, help_text="The character range which was blanked") 125 125 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") 126 126 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") 127 127 correct = NullBooleanField(help_text="The user's self-evaluation of their response")
128 128
def status(self): 129 129 def status(self):
""" 130 130 """
There are three stages of a quiz object: 131 131 There are three stages of a quiz object:
1. the user has been shown the card 132 132 1. the user has been shown the card
2. the user has answered the card 133 133 2. the user has answered the card
3. the user has self-evaluated their response's correctness 134 134 3. the user has self-evaluated their response's correctness
135 135
:return: string (evaluated, answered, viewed) 136 136 :return: string (evaluated, answered, viewed)
""" 137 137 """
if self.correct is not None: return "evaluated" 138 138 if self.correct is not None: return "evaluated"
if self.response: return "answered" 139 139 if self.response: return "answered"
return "viewed" 140 140 return "viewed"
141 141
142 142
class Section(Model): 143 143 class Section(Model):
""" 144 144 """
A UCSD course taught by an instructor during a quarter. 145 145 A UCSD course taught by an instructor during a quarter.
We use the term "section" to avoid collision with the builtin keyword "class" 146 146 We use the term "section" to avoid collision with the builtin keyword "class"
147 We index gratuitously to support autofill and because this is primarily read-only
""" 147 148 """
department = CharField(db_index=True, max_length=50) 148 149 department = CharField(db_index=True, max_length=50)
department_abbreviation = CharField(db_index=True, max_length=10) 149 150 department_abbreviation = CharField(db_index=True, max_length=10)
course_num = CharField(db_index=True, max_length=6) 150 151 course_num = CharField(db_index=True, max_length=6)
course_title = CharField(db_index=True, max_length=50) 151 152 course_title = CharField(db_index=True, max_length=50)
instructor = CharField(db_index=True, max_length=100) 152 153 instructor = CharField(db_index=True, max_length=100)
quarter = CharField(db_index=True, max_length=4) 153 154 quarter = CharField(db_index=True, max_length=4)
155
156 @classmethod
157 def search(cls, terms):
158 """
159 Search all fields of all sections for a particular set of terms
160 A matching section must match at least one field on each term
161 :param terms:iterable
162 :return: Matching QuerySet ordered by department and course number
163 """
164 final_q = Q()
165 for term in terms:
166 q = Q(department__icontains=term)
167 q |= Q(department_abbreviation__icontains=term)
168 q |= Q(course_title__icontains=term)
169 q |= Q(course_num__icontains=term)
170 q |= Q(instructor__icontains=term)
171 final_q &= q
172 qs = cls.objects.filter(final_q)
173 # Have the database cast the course number to an integer so it will sort properly
174 # ECE 35 should rank before ECE 135 even though '135' < '35' lexicographically
175 qs = qs.extra(select={'course_num_int': 'CAST(course_num AS INTEGER)'})
176 qs = qs.order_by('department_abbreviation', 'course_num_int')
177 return qs
154 178
def is_whitelisted(self): 155 179 def is_whitelisted(self):
""" 156 180 """
:return: whether a whitelist exists for this section 157 181 :return: whether a whitelist exists for this section
""" 158 182 """
return self.whitelist.exists() 159 183 return self.whitelist.exists()
160 184
def is_user_on_whitelist(self, user): 161 185 def is_user_on_whitelist(self, user):
""" 162 186 """
:return: whether the user is on the waitlist for this section 163 187 :return: whether the user is on the waitlist for this section
""" 164 188 """
return self.whitelist.filter(email=user.email).exists() 165 189 return self.whitelist.filter(email=user.email).exists()
166 190
class Meta: 167 191 class Meta:
ordering = ['-course_title'] 168 192 ordering = ['-course_title']
169 193
flashcards/tests/test_api.py View file @ dc685f1
from django.core import mail 1 1 from django.core import mail
from flashcards.models import User, Section, Flashcard, WhitelistedAddress 2 2 from flashcards.models import User, Section, Flashcard, WhitelistedAddress
from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED, HTTP_200_OK, HTTP_403_FORBIDDEN 3 3 from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED, HTTP_200_OK, HTTP_403_FORBIDDEN
from rest_framework.test import APITestCase 4 4 from rest_framework.test import APITestCase
from re import search 5 5 from re import search
from django.utils.timezone import now 6 6 from django.utils.timezone import now
7 7
8 8
class LoginTests(APITestCase): 9 9 class LoginTests(APITestCase):
fixtures = ['testusers'] 10 10 fixtures = ['testusers']
11 11
def test_login(self): 12 12 def test_login(self):
url = '/api/login' 13 13 url = '/api/login'
data = {'email': 'none@none.com', 'password': '1234'} 14 14 data = {'email': 'none@none.com', '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': 'none@none.com', 'password': '4321'} 18 18 data = {'email': 'none@none.com', 'password': '4321'}
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=403) 20 20 self.assertContains(response, 'Invalid email or password', status_code=403)
21 21
data = {'email': 'bad@none.com', 'password': '1234'} 22 22 data = {'email': 'bad@none.com', 'password': '1234'}
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=403) 24 24 self.assertContains(response, 'Invalid email or password', status_code=403)
25 25
data = {'password': '4321'} 26 26 data = {'password': '4321'}
response = self.client.post(url, data, format='json') 27 27 response = self.client.post(url, data, format='json')
self.assertContains(response, 'email', status_code=400) 28 28 self.assertContains(response, 'email', status_code=400)
29 29
data = {'email': 'none@none.com'} 30 30 data = {'email': 'none@none.com'}
response = self.client.post(url, data, format='json') 31 31 response = self.client.post(url, data, format='json')
self.assertContains(response, 'password', status_code=400) 32 32 self.assertContains(response, 'password', status_code=400)
33 33
user = User.objects.get(email="none@none.com") 34 34 user = User.objects.get(email="none@none.com")
user.is_active = False 35 35 user.is_active = False
user.save() 36 36 user.save()
37 37
data = {'email': 'none@none.com', 'password': '1234'} 38 38 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post(url, data, format='json') 39 39 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Account is disabled', status_code=403) 40 40 self.assertContains(response, 'Account is disabled', status_code=403)
41 41
def test_logout(self): 42 42 def test_logout(self):
self.client.login(email='none@none.com', password='1234') 43 43 self.client.login(email='none@none.com', password='1234')
response = self.client.post('/api/logout') 44 44 response = self.client.post('/api/logout')
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) 45 45 self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
46 46
# since we're not logged in, we should get a 403 response 47 47 # since we're not logged in, we should get a 403 response
response = self.client.get('/api/me', format='json') 48 48 response = self.client.get('/api/me', format='json')
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) 49 49 self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
50 50
51 51
class PasswordResetTest(APITestCase): 52 52 class PasswordResetTest(APITestCase):
fixtures = ['testusers'] 53 53 fixtures = ['testusers']
54 54
def test_reset_password(self): 55 55 def test_reset_password(self):
# submit the request to reset the password 56 56 # submit the request to reset the password
url = '/api/request_password_reset' 57 57 url = '/api/request_password_reset'
post_data = {'email': 'none@none.com'} 58 58 post_data = {'email': 'none@none.com'}
self.client.post(url, post_data, format='json') 59 59 self.client.post(url, post_data, format='json')
self.assertEqual(len(mail.outbox), 1) 60 60 self.assertEqual(len(mail.outbox), 1)
self.assertIn('reset your password', mail.outbox[0].body) 61 61 self.assertIn('reset your password', mail.outbox[0].body)
62 62
# capture the reset token from the email 63 63 # capture the reset token from the email
capture = search('https://flashy.cards/app/reset_password/(\d+)/(.*)', 64 64 capture = search('https://flashy.cards/app/reset_password/(\d+)/(.*)',
mail.outbox[0].body) 65 65 mail.outbox[0].body)
patch_data = {'new_password': '4321'} 66 66 patch_data = {'new_password': '4321'}
patch_data['uid'] = capture.group(1) 67 67 patch_data['uid'] = capture.group(1)
reset_token = capture.group(2) 68 68 reset_token = capture.group(2)
69 69
# try to reset the password with the wrong reset token 70 70 # try to reset the password with the wrong reset token
patch_data['token'] = 'wrong_token' 71 71 patch_data['token'] = 'wrong_token'
url = '/api/reset_password' 72 72 url = '/api/reset_password'
response = self.client.post(url, patch_data, format='json') 73 73 response = self.client.post(url, patch_data, format='json')
self.assertContains(response, 'Could not verify reset token', status_code=400) 74 74 self.assertContains(response, 'Could not verify reset token', status_code=400)
75 75
# try to reset the password with the correct token 76 76 # try to reset the password with the correct token
patch_data['token'] = reset_token 77 77 patch_data['token'] = reset_token
response = self.client.post(url, patch_data, format='json') 78 78 response = self.client.post(url, patch_data, format='json')
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) 79 79 self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
user = User.objects.get(id=patch_data['uid']) 80 80 user = User.objects.get(id=patch_data['uid'])
assert user.check_password(patch_data['new_password']) 81 81 assert user.check_password(patch_data['new_password'])
82 82
83 83
class RegistrationTest(APITestCase): 84 84 class RegistrationTest(APITestCase):
def test_create_account(self): 85 85 def test_create_account(self):
url = '/api/register' 86 86 url = '/api/register'
87 87
# missing password 88 88 # missing password
data = {'email': 'none@none.com'} 89 89 data = {'email': 'none@none.com'}
response = self.client.post(url, data, format='json') 90 90 response = self.client.post(url, data, format='json')
self.assertContains(response, 'password', status_code=400) 91 91 self.assertContains(response, 'password', status_code=400)
92 92
# missing email 93 93 # missing email
data = {'password': '1234'} 94 94 data = {'password': '1234'}
response = self.client.post(url, data, format='json') 95 95 response = self.client.post(url, data, format='json')
self.assertContains(response, 'email', status_code=400) 96 96 self.assertContains(response, 'email', status_code=400)
97 97
# create a user 98 98 # create a user
data = {'email': 'none@none.com', 'password': '1234'} 99 99 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post(url, data, format='json') 100 100 response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, HTTP_201_CREATED) 101 101 self.assertEqual(response.status_code, HTTP_201_CREATED)
102 102
# user should not be confirmed 103 103 # user should not be confirmed
user = User.objects.get(email="none@none.com") 104 104 user = User.objects.get(email="none@none.com")
self.assertFalse(user.is_confirmed) 105 105 self.assertFalse(user.is_confirmed)
106 106
# check that the confirmation key was sent 107 107 # check that the confirmation key was sent
self.assertEqual(len(mail.outbox), 1) 108 108 self.assertEqual(len(mail.outbox), 1)
self.assertIn(user.confirmation_key, mail.outbox[0].body) 109 109 self.assertIn(user.confirmation_key, mail.outbox[0].body)
110 110
# log the user out 111 111 # log the user out
self.client.logout() 112 112 self.client.logout()
113 113
# log the user in with their registered credentials 114 114 # log the user in with their registered credentials
self.client.login(email='none@none.com', password='1234') 115 115 self.client.login(email='none@none.com', password='1234')
116 116
# try activating with an invalid key 117 117 # try activating with an invalid key
118 118
url = '/api/me' 119 119 url = '/api/me'
response = self.client.patch(url, {'confirmation_key': 'NOT A KEY'}) 120 120 response = self.client.patch(url, {'confirmation_key': 'NOT A KEY'})
self.assertContains(response, 'confirmation_key is invalid', status_code=400) 121 121 self.assertContains(response, 'confirmation_key is invalid', status_code=400)
122 122
# try activating with the valid key 123 123 # try activating with the valid key
response = self.client.patch(url, {'confirmation_key': user.confirmation_key}) 124 124 response = self.client.patch(url, {'confirmation_key': user.confirmation_key})
self.assertTrue(response.data['is_confirmed']) 125 125 self.assertTrue(response.data['is_confirmed'])
126 126
127 127
class ProfileViewTest(APITestCase): 128 128 class ProfileViewTest(APITestCase):
fixtures = ['testusers'] 129 129 fixtures = ['testusers']
130 130
def test_get_me(self): 131 131 def test_get_me(self):
url = '/api/me' 132 132 url = '/api/me'
response = self.client.get(url, format='json') 133 133 response = self.client.get(url, format='json')
# since we're not logged in, we shouldn't be able to see this 134 134 # since we're not logged in, we shouldn't be able to see this
self.assertEqual(response.status_code, 403) 135 135 self.assertEqual(response.status_code, 403)
136 136
self.client.login(email='none@none.com', password='1234') 137 137 self.client.login(email='none@none.com', password='1234')
response = self.client.get(url, format='json') 138 138 response = self.client.get(url, format='json')
self.assertEqual(response.status_code, HTTP_200_OK) 139 139 self.assertEqual(response.status_code, HTTP_200_OK)
140 140
141 141
class PasswordChangeTest(APITestCase): 142 142 class PasswordChangeTest(APITestCase):
fixtures = ['testusers'] 143 143 fixtures = ['testusers']
144 144
def test_change_password(self): 145 145 def test_change_password(self):
url = '/api/me' 146 146 url = '/api/me'
user = User.objects.get(email='none@none.com') 147 147 user = User.objects.get(email='none@none.com')
self.assertTrue(user.check_password('1234')) 148 148 self.assertTrue(user.check_password('1234'))
149 149
response = self.client.patch(url, {'new_password': '4321', 'old_password': '1234'}, format='json') 150 150 response = self.client.patch(url, {'new_password': '4321', 'old_password': '1234'}, format='json')
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) 151 151 self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
152 152
self.client.login(email='none@none.com', password='1234') 153 153 self.client.login(email='none@none.com', password='1234')
response = self.client.patch(url, {'new_password': '4321'}, format='json') 154 154 response = self.client.patch(url, {'new_password': '4321'}, format='json')
self.assertContains(response, 'old_password is required', status_code=400) 155 155 self.assertContains(response, 'old_password is required', status_code=400)
156 156
response = self.client.patch(url, {'new_password': '4321', 'old_password': '4321'}, format='json') 157 157 response = self.client.patch(url, {'new_password': '4321', 'old_password': '4321'}, format='json')
self.assertContains(response, 'old_password is incorrect', status_code=400) 158 158 self.assertContains(response, 'old_password is incorrect', status_code=400)
159 159
response = self.client.patch(url, {'new_password': '4321', 'old_password': '1234'}, format='json') 160 160 response = self.client.patch(url, {'new_password': '4321', 'old_password': '1234'}, format='json')
self.assertEqual(response.status_code, 200) 161 161 self.assertEqual(response.status_code, 200)
user = User.objects.get(email='none@none.com') 162 162 user = User.objects.get(email='none@none.com')
163 163
self.assertFalse(user.check_password('1234')) 164 164 self.assertFalse(user.check_password('1234'))
self.assertTrue(user.check_password('4321')) 165 165 self.assertTrue(user.check_password('4321'))
166 166
167 167
class DeleteUserTest(APITestCase): 168 168 class DeleteUserTest(APITestCase):
fixtures = ['testusers'] 169 169 fixtures = ['testusers']
170 170
def test_delete_user(self): 171 171 def test_delete_user(self):
url = '/api/me' 172 172 url = '/api/me'
user = User.objects.get(email='none@none.com') 173 173 user = User.objects.get(email='none@none.com')
174 174
self.client.login(email='none@none.com', password='1234') 175 175 self.client.login(email='none@none.com', password='1234')
self.client.delete(url) 176 176 self.client.delete(url)
self.assertFalse(User.objects.filter(email='none@none.com').exists()) 177 177 self.assertFalse(User.objects.filter(email='none@none.com').exists())
178 178
179 179
class FlashcardDetailTest(APITestCase): 180 180 class FlashcardDetailTest(APITestCase):
fixtures = ['testusers', 'testsections'] 181 181 fixtures = ['testusers', 'testsections']
182 182
def setUp(self): 183 183 def setUp(self):
section = Section.objects.get(pk=1) 184 184 section = Section.objects.get(pk=1)
user = User.objects.get(email='none@none.com') 185 185 user = User.objects.get(email='none@none.com')
186 186
self.flashcard = Flashcard(text="jason", section=section, material_date=now(), author=user) 187 187 self.flashcard = Flashcard(text="jason", section=section, material_date=now(), author=user)
self.flashcard.save() 188 188 self.flashcard.save()
189 189
def test_get_flashcard(self): 190 190 def test_get_flashcard(self):
flashcards/views.py View file @ dc685f1
from django.contrib import auth 1 1 from django.contrib import auth
from django.db.models import Q 2 2 from django.db.models import Q
from flashcards.api import StandardResultsSetPagination 3 3 from flashcards.api import StandardResultsSetPagination
from flashcards.models import Section, User, Flashcard, FlashcardReport, UserFlashcard 4 4 from flashcards.models import Section, User, Flashcard, FlashcardReport, UserFlashcard
from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \ 5 5 from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \
PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer 6 6 PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer
from rest_framework.decorators import detail_route, permission_classes, api_view, list_route 7 7 from rest_framework.decorators import detail_route, permission_classes, api_view, list_route
from rest_framework.generics import ListAPIView, GenericAPIView 8 8 from rest_framework.generics import ListAPIView, GenericAPIView
from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin 9 9 from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin
from rest_framework.permissions import IsAuthenticated 10 10 from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ReadOnlyModelViewSet, GenericViewSet 11 11 from rest_framework.viewsets import ReadOnlyModelViewSet, GenericViewSet
from django.core.mail import send_mail 12 12 from django.core.mail import send_mail
from django.contrib.auth import authenticate 13 13 from django.contrib.auth import authenticate
from django.contrib.auth.tokens import default_token_generator 14 14 from django.contrib.auth.tokens import default_token_generator
from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED 15 15 from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED
from rest_framework.response import Response 16 16 from rest_framework.response import Response
from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied 17 17 from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied
from simple_email_confirmation import EmailAddress 18 18 from simple_email_confirmation import EmailAddress
19 19
20 20
class SectionViewSet(ReadOnlyModelViewSet): 21 21 class SectionViewSet(ReadOnlyModelViewSet):
queryset = Section.objects.all() 22 22 queryset = Section.objects.all()
serializer_class = SectionSerializer 23 23 serializer_class = SectionSerializer
pagination_class = StandardResultsSetPagination 24 24 pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated] 25 25 permission_classes = [IsAuthenticated]
26 26
@detail_route(methods=['get'], permission_classes=[IsAuthenticated]) 27 27 @detail_route(methods=['get'], permission_classes=[IsAuthenticated])
def flashcards(self, request, pk): 28 28 def flashcards(self, request, pk):
""" 29 29 """
Gets flashcards for a section, excluding hidden cards. 30 30 Gets flashcards for a section, excluding hidden cards.
Returned in strictly chronological order (material date). 31 31 Returned in strictly chronological order (material date).
""" 32 32 """
flashcards = Flashcard.cards_visible_to(request.user).filter( \ 33 33 flashcards = Flashcard.cards_visible_to(request.user).filter( \
section=self.get_object(), is_hidden=False).all() 34 34 section=self.get_object(), is_hidden=False).all()
35 35
return Response(FlashcardSerializer(flashcards, many=True).data) 36 36 return Response(FlashcardSerializer(flashcards, many=True).data)
37 37
@detail_route(methods=['post'], permission_classes=[IsAuthenticated]) 38 38 @detail_route(methods=['post'], permission_classes=[IsAuthenticated])
def enroll(self, request, pk): 39 39 def enroll(self, request, pk):
""" 40 40 """
Add the current user to a specified section 41 41 Add the current user to a specified section
If the class has a whitelist, but the user is not on the whitelist, the request will fail. 42 42 If the class has a whitelist, but the user is not on the whitelist, the request will fail.
--- 43 43 ---
omit_serializer: true 44 44 omit_serializer: true
parameters: 45 45 parameters:
- fake: None 46 46 - fake: None
parameters_strategy: 47 47 parameters_strategy:
form: replace 48 48 form: replace
""" 49 49 """
section = self.get_object() 50 50 section = self.get_object()
if request.user.sections.filter(pk=section.pk).exists(): 51 51 if request.user.sections.filter(pk=section.pk).exists():
raise ValidationError("You are already in this section.") 52 52 raise ValidationError("You are already in this section.")
if section.is_whitelisted() and not section.is_user_on_whitelist(request.user): 53 53 if section.is_whitelisted() and not section.is_user_on_whitelist(request.user):
raise PermissionDenied("You must be on the whitelist to add this section.") 54 54 raise PermissionDenied("You must be on the whitelist to add this section.")
request.user.sections.add(section) 55 55 request.user.sections.add(section)
return Response(status=HTTP_204_NO_CONTENT) 56 56 return Response(status=HTTP_204_NO_CONTENT)
57 57
@detail_route(methods=['post'], permission_classes=[IsAuthenticated]) 58 58 @detail_route(methods=['post'], permission_classes=[IsAuthenticated])
def drop(self, request, pk): 59 59 def drop(self, request, pk):
""" 60 60 """
Remove the current user from a specified section 61 61 Remove the current user from a specified section
If the user is not in the class, the request will fail. 62 62 If the user is not in the class, the request will fail.
--- 63 63 ---
omit_serializer: true 64 64 omit_serializer: true
parameters: 65 65 parameters:
- fake: None 66 66 - fake: None
parameters_strategy: 67 67 parameters_strategy:
form: replace 68 68 form: replace
""" 69 69 """
section = self.get_object() 70 70 section = self.get_object()
if not section.user_set.filter(pk=request.user.pk).exists(): 71 71 if not section.user_set.filter(pk=request.user.pk).exists():
raise ValidationError("You are not in the section.") 72 72 raise ValidationError("You are not in the section.")
section.user_set.remove(request.user) 73 73 section.user_set.remove(request.user)
return Response(status=HTTP_204_NO_CONTENT) 74 74 return Response(status=HTTP_204_NO_CONTENT)
75 75
@list_route(methods=['get'], permission_classes=[IsAuthenticated]) 76 76 @list_route(methods=['get'], permission_classes=[IsAuthenticated])
def search(self, request): 77 77 def search(self, request):
query = request.GET.get('q', '').split(' ') 78 78 query = request.GET.get('q',None)
q = Q() 79 79 if not query: return Response('[]')
for word in query: 80 80 qs = Section.search(query.split(' '))[:5]
q |= Q(course_title__icontains=word) 81
qs = Section.objects.filter(q).distinct() 82
serializer = SectionSerializer(qs, many=True) 83 81 serializer = SectionSerializer(qs, many=True)
return Response(serializer.data) 84 82 return Response(serializer.data)
85 83
86 84
class UserSectionListView(ListAPIView): 87 85 class UserSectionListView(ListAPIView):
serializer_class = SectionSerializer 88 86 serializer_class = SectionSerializer
permission_classes = [IsAuthenticated] 89 87 permission_classes = [IsAuthenticated]
90 88
def get_queryset(self): 91 89 def get_queryset(self):
return self.request.user.sections.all() 92 90 return self.request.user.sections.all()
93 91
def paginate_queryset(self, queryset): return None 94 92 def paginate_queryset(self, queryset): return None
95 93
96 94
class UserDetail(GenericAPIView): 97 95 class UserDetail(GenericAPIView):
serializer_class = UserSerializer 98 96 serializer_class = UserSerializer
permission_classes = [IsAuthenticated] 99 97 permission_classes = [IsAuthenticated]
100 98
def get_queryset(self): 101 99 def get_queryset(self):
return User.objects.all() 102 100 return User.objects.all()
103 101
def patch(self, request, format=None): 104 102 def patch(self, request, format=None):
""" 105 103 """
Updates the user's password, or verifies their email address 106 104 Updates the user's password, or verifies their email address
--- 107 105 ---
request_serializer: UserUpdateSerializer 108 106 request_serializer: UserUpdateSerializer
response_serializer: UserSerializer 109 107 response_serializer: UserSerializer
""" 110 108 """
data = UserUpdateSerializer(data=request.data, context={'user': request.user}) 111 109 data = UserUpdateSerializer(data=request.data, context={'user': request.user})
data.is_valid(raise_exception=True) 112 110 data.is_valid(raise_exception=True)
data = data.validated_data 113 111 data = data.validated_data
114 112
if 'new_password' in data: 115 113 if 'new_password' in data:
if not request.user.check_password(data['old_password']): 116 114 if not request.user.check_password(data['old_password']):
raise ValidationError('old_password is incorrect') 117 115 raise ValidationError('old_password is incorrect')
request.user.set_password(data['new_password']) 118 116 request.user.set_password(data['new_password'])
request.user.save() 119 117 request.user.save()
120 118
if 'confirmation_key' in data: 121 119 if 'confirmation_key' in data:
try: 122 120 try:
request.user.confirm_email(data['confirmation_key']) 123 121 request.user.confirm_email(data['confirmation_key'])
except EmailAddress.DoesNotExist: 124 122 except EmailAddress.DoesNotExist:
raise ValidationError('confirmation_key is invalid') 125 123 raise ValidationError('confirmation_key is invalid')
126 124
return Response(UserSerializer(request.user).data) 127 125 return Response(UserSerializer(request.user).data)
128 126
def get(self, request, format=None): 129 127 def get(self, request, format=None):
""" 130 128 """
Return data about the user 131 129 Return data about the user
--- 132 130 ---
response_serializer: UserSerializer 133 131 response_serializer: UserSerializer
""" 134 132 """
serializer = UserSerializer(request.user, context={'request': request}) 135 133 serializer = UserSerializer(request.user, context={'request': request})
return Response(serializer.data) 136 134 return Response(serializer.data)
137 135
def delete(self, request): 138 136 def delete(self, request):
""" 139 137 """
Irrevocably delete the user and their data 140 138 Irrevocably delete the user and their data
141 139
Yes, really 142 140 Yes, really
""" 143 141 """
request.user.delete() 144 142 request.user.delete()
return Response(status=HTTP_204_NO_CONTENT) 145 143 return Response(status=HTTP_204_NO_CONTENT)
146 144
147 145
@api_view(['POST']) 148 146 @api_view(['POST'])
def register(request, format=None): 149 147 def register(request, format=None):
""" 150 148 """
Register a new user 151 149 Register a new user
--- 152 150 ---
request_serializer: EmailPasswordSerializer 153 151 request_serializer: EmailPasswordSerializer
response_serializer: UserSerializer 154 152 response_serializer: UserSerializer
""" 155 153 """
data = RegistrationSerializer(data=request.data) 156 154 data = RegistrationSerializer(data=request.data)
data.is_valid(raise_exception=True) 157 155 data.is_valid(raise_exception=True)
158 156
User.objects.create_user(**data.validated_data) 159 157 User.objects.create_user(**data.validated_data)
user = authenticate(**data.validated_data) 160 158 user = authenticate(**data.validated_data)
auth.login(request, user) 161 159 auth.login(request, user)
162 160
body = ''' 163 161 body = '''
Visit the following link to confirm your email address: 164 162 Visit the following link to confirm your email address:
https://flashy.cards/app/verify_email/%s 165 163 https://flashy.cards/app/verify_email/%s
166 164
If you did not register for Flashy, no action is required. 167 165 If you did not register for Flashy, no action is required.
''' 168 166 '''
169 167
assert send_mail("Flashy email verification", 170 168 assert send_mail("Flashy email verification",
body % user.confirmation_key, 171 169 body % user.confirmation_key,
"noreply@flashy.cards", 172 170 "noreply@flashy.cards",
[user.email]) 173 171 [user.email])
174 172
return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED) 175 173 return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED)
176 174
177 175
@api_view(['POST']) 178 176 @api_view(['POST'])
def login(request): 179 177 def login(request):
""" 180 178 """
Authenticates user and returns user data if valid. 181 179 Authenticates user and returns user data if valid.
--- 182 180 ---
request_serializer: EmailPasswordSerializer 183 181 request_serializer: EmailPasswordSerializer
response_serializer: UserSerializer 184 182 response_serializer: UserSerializer
""" 185 183 """
186 184
data = EmailPasswordSerializer(data=request.data) 187 185 data = EmailPasswordSerializer(data=request.data)
data.is_valid(raise_exception=True) 188 186 data.is_valid(raise_exception=True)
user = authenticate(**data.validated_data) 189 187 user = authenticate(**data.validated_data)
190 188
if user is None: 191 189 if user is None:
raise AuthenticationFailed('Invalid email or password') 192 190 raise AuthenticationFailed('Invalid email or password')
if not user.is_active: 193 191 if not user.is_active:
raise NotAuthenticated('Account is disabled') 194 192 raise NotAuthenticated('Account is disabled')
auth.login(request, user) 195 193 auth.login(request, user)
return Response(UserSerializer(request.user).data) 196 194 return Response(UserSerializer(request.user).data)
197 195
198 196
@api_view(['POST']) 199 197 @api_view(['POST'])
@permission_classes((IsAuthenticated, )) 200 198 @permission_classes((IsAuthenticated, ))
def logout(request, format=None): 201 199 def logout(request, format=None):
""" 202 200 """
Logs the authenticated user out. 203 201 Logs the authenticated user out.
""" 204 202 """
auth.logout(request) 205 203 auth.logout(request)
return Response(status=HTTP_204_NO_CONTENT) 206 204 return Response(status=HTTP_204_NO_CONTENT)
207 205
208 206
@api_view(['POST']) 209 207 @api_view(['POST'])
def request_password_reset(request, format=None): 210 208 def request_password_reset(request, format=None):
""" 211 209 """
Send a password reset token/link to the provided email. 212 210 Send a password reset token/link to the provided email.
--- 213 211 ---
request_serializer: PasswordResetRequestSerializer 214 212 request_serializer: PasswordResetRequestSerializer
""" 215 213 """
data = PasswordResetRequestSerializer(data=request.data) 216 214 data = PasswordResetRequestSerializer(data=request.data)
data.is_valid(raise_exception=True) 217 215 data.is_valid(raise_exception=True)
user = User.objects.get(email=data['email'].value) 218 216 user = User.objects.get(email=data['email'].value)
token = default_token_generator.make_token(user) 219 217 token = default_token_generator.make_token(user)
220 218
body = ''' 221 219 body = '''
Visit the following link to reset your password: 222 220 Visit the following link to reset your password:
https://flashy.cards/app/reset_password/%d/%s 223 221 https://flashy.cards/app/reset_password/%d/%s
224 222
If you did not request a password reset, no action is required. 225 223 If you did not request a password reset, no action is required.
''' 226 224 '''
227 225
send_mail("Flashy password reset", 228 226 send_mail("Flashy password reset",
body % (user.pk, token), 229 227 body % (user.pk, token),
"noreply@flashy.cards", 230 228 "noreply@flashy.cards",
[user.email]) 231 229 [user.email])
232 230
return Response(status=HTTP_204_NO_CONTENT) 233 231 return Response(status=HTTP_204_NO_CONTENT)
234 232
235 233
@api_view(['POST']) 236 234 @api_view(['POST'])
def reset_password(request, format=None): 237 235 def reset_password(request, format=None):
""" 238 236 """
Updates user's password to new password if token is valid. 239 237 Updates user's password to new password if token is valid.
--- 240 238 ---
request_serializer: PasswordResetSerializer 241 239 request_serializer: PasswordResetSerializer
""" 242 240 """
data = PasswordResetSerializer(data=request.data) 243 241 data = PasswordResetSerializer(data=request.data)
data.is_valid(raise_exception=True) 244 242 data.is_valid(raise_exception=True)
245 243
user = User.objects.get(id=data['uid'].value) 246 244 user = User.objects.get(id=data['uid'].value)
# Check token validity. 247 245 # Check token validity.
248 246
if default_token_generator.check_token(user, data['token'].value): 249 247 if default_token_generator.check_token(user, data['token'].value):
user.set_password(data['new_password'].value) 250 248 user.set_password(data['new_password'].value)
user.save() 251 249 user.save()
else: 252 250 else:
raise ValidationError('Could not verify reset token') 253 251 raise ValidationError('Could not verify reset token')