Commit 8e66c8186f7e31f52c7bec7207a641aba4a73a17

Authored by Andrew Buss
1 parent cec534fd3c
Exists in master

moved still more business logic into the model layer

Showing 2 changed files with 39 additions and 31 deletions Inline Diff

flashcards/models.py View file @ 8e66c81
from django.contrib.auth.models import AbstractUser, UserManager 1 1 from django.contrib.auth.models import AbstractUser, UserManager
2 from django.contrib.auth.tokens import default_token_generator
from django.core.exceptions import ValidationError 2 3 from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied, SuspiciousOperation 3 4 from django.core.exceptions import PermissionDenied, SuspiciousOperation
5 from django.core.mail import send_mail
from django.db import IntegrityError 4 6 from django.db import IntegrityError
from django.db.models import * 5 7 from django.db.models import *
from django.utils.timezone import now 6 8 from django.utils.timezone import now
from simple_email_confirmation import SimpleEmailConfirmationUserMixin 7 9 from simple_email_confirmation import SimpleEmailConfirmationUserMixin
from fields import MaskField 8 10 from fields import MaskField
9 11
10 12
# Hack to fix AbstractUser before subclassing it 11 13 # Hack to fix AbstractUser before subclassing it
AbstractUser._meta.get_field('email')._unique = True 12 14 AbstractUser._meta.get_field('email')._unique = True
AbstractUser._meta.get_field('username')._unique = False 13 15 AbstractUser._meta.get_field('username')._unique = False
14 16
15 17
class EmailOnlyUserManager(UserManager): 16 18 class EmailOnlyUserManager(UserManager):
""" 17 19 """
A tiny extension of Django's UserManager which correctly creates users 18 20 A tiny extension of Django's UserManager which correctly creates users
without usernames (using emails instead). 19 21 without usernames (using emails instead).
""" 20 22 """
21 23
def _create_user(self, email, password, is_staff, is_superuser, **extra_fields): 22 24 def _create_user(self, email, password, is_staff, is_superuser, **extra_fields):
""" 23 25 """
Creates and saves a User with the given email and password. 24 26 Creates and saves a User with the given email and password.
""" 25 27 """
email = self.normalize_email(email) 26 28 email = self.normalize_email(email)
user = self.model(email=email, 27 29 user = self.model(email=email,
is_staff=is_staff, is_active=True, 28 30 is_staff=is_staff, is_active=True,
is_superuser=is_superuser, 29 31 is_superuser=is_superuser,
date_joined=now(), **extra_fields) 30 32 date_joined=now(), **extra_fields)
user.set_password(password) 31 33 user.set_password(password)
user.save(using=self._db) 32 34 user.save(using=self._db)
return user 33 35 return user
34 36
def create_user(self, email, password=None, **extra_fields): 35 37 def create_user(self, email, password=None, **extra_fields):
return self._create_user(email, password, False, False, **extra_fields) 36 38 user = self._create_user(email, password, False, False, **extra_fields)
39 body = '''
40 Visit the following link to confirm your email address:
41 https://flashy.cards/app/verifyemail/%s
37 42
43 If you did not register for Flashy, no action is required.
44 '''
45
46 assert send_mail("Flashy email verification",
47 body % user.confirmation_key,
48 "noreply@flashy.cards",
49 [user.email])
50 return user
51
def create_superuser(self, email, password, **extra_fields): 38 52 def create_superuser(self, email, password, **extra_fields):
return self._create_user(email, password, True, True, **extra_fields) 39 53 return self._create_user(email, password, True, True, **extra_fields)
40 54
41 55
56
57
class User(AbstractUser, SimpleEmailConfirmationUserMixin): 42 58 class User(AbstractUser, SimpleEmailConfirmationUserMixin):
""" 43 59 """
An extension of Django's default user model. 44 60 An extension of Django's default user model.
We use email as the username field, and include enrolled sections here 45 61 We use email as the username field, and include enrolled sections here
""" 46 62 """
objects = EmailOnlyUserManager() 47 63 objects = EmailOnlyUserManager()
USERNAME_FIELD = 'email' 48 64 USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] 49 65 REQUIRED_FIELDS = []
sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in") 50 66 sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in")
51 67
def is_in_section(self, section): 52 68 def is_in_section(self, section):
return self.sections.filter(pk=section.pk).exists() 53 69 return self.sections.filter(pk=section.pk).exists()
54 70
def pull(self, flashcard): 55 71 def pull(self, flashcard):
if not self.is_in_section(flashcard.section): 56 72 if not self.is_in_section(flashcard.section):
raise ValueError("User not in the section this flashcard belongs to") 57 73 raise ValueError("User not in the section this flashcard belongs to")
user_card = UserFlashcard.objects.create(user=self, flashcard=flashcard) 58 74 user_card = UserFlashcard.objects.create(user=self, flashcard=flashcard)
user_card.pulled = now() 59 75 user_card.pulled = now()
user_card.save() 60 76 user_card.save()
61 77
def unpull(self, flashcard): 62 78 def unpull(self, flashcard):
if not self.is_in_section(flashcard.section): 63 79 if not self.is_in_section(flashcard.section):
raise ValueError("User not in the section this flashcard belongs to") 64 80 raise ValueError("User not in the section this flashcard belongs to")
65 81
try: 66 82 try:
user_card = UserFlashcard.objects.get(user=self, flashcard=flashcard) 67 83 user_card = UserFlashcard.objects.get(user=self, flashcard=flashcard)
except UserFlashcard.DoesNotExist: 68 84 except UserFlashcard.DoesNotExist:
raise ValueError('Cannot unpull card that is not pulled.') 69 85 raise ValueError('Cannot unpull card that is not pulled.')
70 86
user_card.delete() 71 87 user_card.delete()
72 88
def get_deck(self, section): 73 89 def get_deck(self, section):
if not self.is_in_section(section): 74 90 if not self.is_in_section(section):
raise ObjectDoesNotExist("User not enrolled in section") 75 91 raise ObjectDoesNotExist("User not enrolled in section")
return Flashcard.objects.all().filter(userflashcard__user=self).filter(section=section) 76 92 return Flashcard.objects.all().filter(userflashcard__user=self).filter(section=section)
77 93
94 def request_password_reset(self):
95 token = default_token_generator.make_token(self)
78 96
97 body = '''
98 Visit the following link to reset your password:
99 https://flashy.cards/app/resetpassword/%d/%s
100
101 If you did not request a password reset, no action is required.
102 '''
103
104 send_mail("Flashy password reset",
105 body % (self.pk, token),
106 "noreply@flashy.cards",
107 [self.email])
108
109
class UserFlashcard(Model): 79 110 class UserFlashcard(Model):
""" 80 111 """
Represents the relationship between a user and a flashcard by: 81 112 Represents the relationship between a user and a flashcard by:
1. A user has a flashcard in their deck 82 113 1. A user has a flashcard in their deck
2. A user used to have a flashcard in their deck 83 114 2. A user used to have a flashcard in their deck
3. A user has a flashcard hidden from them 84 115 3. A user has a flashcard hidden from them
""" 85 116 """
user = ForeignKey('User') 86 117 user = ForeignKey('User')
mask = MaskField(max_length=255, null=True, blank=True, default=None, 87 118 mask = MaskField(max_length=255, null=True, blank=True, default=None,
help_text="The user-specific mask on the card") 88 119 help_text="The user-specific mask on the card")
pulled = DateTimeField(blank=True, null=True, default=None, help_text="When the user pulled the card") 89 120 pulled = DateTimeField(blank=True, null=True, default=None, help_text="When the user pulled the card")
flashcard = ForeignKey('Flashcard') 90 121 flashcard = ForeignKey('Flashcard')
91 122
class Meta: 92 123 class Meta:
# There can be at most one UserFlashcard for each User and Flashcard 93 124 # There can be at most one UserFlashcard for each User and Flashcard
unique_together = (('user', 'flashcard'),) 94 125 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 95 126 index_together = ["user", "flashcard"]
# By default, order by most recently pulled 96 127 # By default, order by most recently pulled
ordering = ['-pulled'] 97 128 ordering = ['-pulled']
98 129
99 130
class FlashcardHide(Model): 100 131 class FlashcardHide(Model):
""" 101 132 """
Represents the property of a flashcard being hidden by a user. 102 133 Represents the property of a flashcard being hidden by a user.
Each instance of this class represents a single user hiding a single flashcard. 103 134 Each instance of this class represents a single user hiding a single flashcard.
If reason is null, the flashcard was just hidden. 104 135 If reason is null, the flashcard was just hidden.
If reason is not null, the flashcard was reported, and reason is the reason why it was reported. 105 136 If reason is not null, the flashcard was reported, and reason is the reason why it was reported.
""" 106 137 """
user = ForeignKey('User') 107 138 user = ForeignKey('User')
flashcard = ForeignKey('Flashcard') 108 139 flashcard = ForeignKey('Flashcard')
reason = CharField(max_length=255, blank=True, null=True) 109 140 reason = CharField(max_length=255, blank=True, null=True)
hidden = DateTimeField(auto_now_add=True) 110 141 hidden = DateTimeField(auto_now_add=True)
111 142
class Meta: 112 143 class Meta:
# There can only be one FlashcardHide object for each User and Flashcard 113 144 # There can only be one FlashcardHide object for each User and Flashcard
unique_together = (('user', 'flashcard'),) 114 145 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 115 146 index_together = ["user", "flashcard"]
116 147
117 148
class Flashcard(Model): 118 149 class Flashcard(Model):
text = CharField(max_length=255, help_text='The text on the card') 119 150 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') 120 151 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") 121 152 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") 122 153 material_date = DateTimeField(default=now, help_text="The date with which the card is associated")
previous = ForeignKey('Flashcard', null=True, blank=True, default=None, 123 154 previous = ForeignKey('Flashcard', null=True, blank=True, default=None,
help_text="The previous version of this card, if one exists") 124 155 help_text="The previous version of this card, if one exists")
author = ForeignKey(User) 125 156 author = ForeignKey(User)
is_hidden = BooleanField(default=False) 126 157 is_hidden = BooleanField(default=False)
hide_reason = CharField(blank=True, null=True, max_length=255, default='', 127 158 hide_reason = CharField(blank=True, null=True, max_length=255, default='',
help_text="Reason for hiding this card") 128 159 help_text="Reason for hiding this card")
mask = MaskField(max_length=255, null=True, blank=True, help_text="The mask on the card") 129 160 mask = MaskField(max_length=255, null=True, blank=True, help_text="The mask on the card")
130 161
class Meta: 131 162 class Meta:
# By default, order by most recently pushed 132 163 # By default, order by most recently pushed
ordering = ['-pushed'] 133 164 ordering = ['-pushed']
134 165
def is_hidden_from(self, user): 135 166 def is_hidden_from(self, user):
""" 136 167 """
A card can be hidden globally, but if a user has the card in their deck, 137 168 A card can be hidden globally, but if a user has the card in their deck,
this visibility overrides a global hide. 138 169 this visibility overrides a global hide.
:param user: 139 170 :param user:
:return: Whether the card is hidden from the user. 140 171 :return: Whether the card is hidden from the user.
""" 141 172 """
if self.userflashcard_set.filter(user=user).exists(): return False 142 173 if self.userflashcard_set.filter(user=user).exists(): return False
if self.is_hidden or self.flashcardhide_set.filter(user=user).exists(): return True 143 174 if self.is_hidden or self.flashcardhide_set.filter(user=user).exists(): return True
return False 144 175 return False
145 176
def hide_from(self, user, reason=None): 146 177 def hide_from(self, user, reason=None):
if self.is_in_deck(user): user.unpull(self) 147 178 if self.is_in_deck(user): user.unpull(self)
obj, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self, reason=None) 148 179 obj, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self, reason=None)
if not created: 149 180 if not created:
raise ValidationError("The card has already been hidden.") 150 181 raise ValidationError("The card has already been hidden.")
obj.save() 151 182 obj.save()
152 183
def is_in_deck(self, user): 153 184 def is_in_deck(self, user):
return self.userflashcard_set.filter(user=user).exists() 154 185 return self.userflashcard_set.filter(user=user).exists()
155 186
def add_to_deck(self, user): 156 187 def add_to_deck(self, user):
if not user.is_in_section(self.section): 157 188 if not user.is_in_section(self.section):
raise PermissionDenied("You don't have the permission to add this card") 158 189 raise PermissionDenied("You don't have the permission to add this card")
try: 159 190 try:
user_flashcard = UserFlashcard.objects.create(user=user, flashcard=self, mask=self.mask) 160 191 user_flashcard = UserFlashcard.objects.create(user=user, flashcard=self, mask=self.mask)
except IntegrityError: 161 192 except IntegrityError:
raise SuspiciousOperation("The flashcard is already in the user's deck") 162 193 raise SuspiciousOperation("The flashcard is already in the user's deck")
user_flashcard.save() 163 194 user_flashcard.save()
return user_flashcard 164 195 return user_flashcard
165 196
def edit(self, user, new_data): 166 197 def edit(self, user, new_data):
""" 167 198 """
Creates a new flashcard if a new flashcard should be created when the given user edits this flashcard. 168 199 Creates a new flashcard if a new flashcard should be created when the given user edits this flashcard.
Sets up everything correctly so this object, when saved, will result in the appropriate changes. 169 200 Sets up everything correctly so this object, when saved, will result in the appropriate changes.
:param user: The user editing this card. 170 201 :param user: The user editing this card.
:param new_data: The new information, namely a dict containg 'material_date', 'text', and 'mask' keys. 171 202 :param new_data: The new information, namely a dict containg 'material_date', 'text', and 'mask' keys.
""" 172 203 """
173 204
# content_changed is True iff either material_date or text were changed 174 205 # content_changed is True iff either material_date or text were changed
content_changed = False 175 206 content_changed = False
# create_new is True iff the user editing this card is the author of this card 176 207 # create_new is True iff the user editing this card is the author of this card
# and there are no other users with this card in their decks 177 208 # and there are no other users with this card in their decks
create_new = user != self.author or \ 178 209 create_new = user != self.author or \
UserFlashcard.objects.filter(flashcard=self).exclude(user=user).exists() 179 210 UserFlashcard.objects.filter(flashcard=self).exclude(user=user).exists()
if 'material_date' in new_data and self.material_date != new_data['material_date']: 180 211 if 'material_date' in new_data and self.material_date != new_data['material_date']:
content_changed = True 181 212 content_changed = True
self.material_date = new_data['material_date'] 182 213 self.material_date = new_data['material_date']
if 'text' in new_data and self.text != new_data['text']: 183 214 if 'text' in new_data and self.text != new_data['text']:
content_changed = True 184 215 content_changed = True
self.text = new_data['text'] 185 216 self.text = new_data['text']
if create_new and content_changed: 186 217 if create_new and content_changed:
if self.is_in_deck(user): user.unpull(self) 187 218 if self.is_in_deck(user): user.unpull(self)
self.previous_id = self.pk 188 219 self.previous_id = self.pk
self.pk = None 189 220 self.pk = None
self.mask = new_data.get('mask', self.mask) 190 221 self.mask = new_data.get('mask', self.mask)
self.save() 191 222 self.save()
self.add_to_deck(user) 192 223 self.add_to_deck(user)
else: 193 224 else:
user_card, created = UserFlashcard.objects.get_or_create(user=user, flashcard=self) 194 225 user_card, created = UserFlashcard.objects.get_or_create(user=user, flashcard=self)
user_card.mask = new_data.get('mask', user_card.mask) 195 226 user_card.mask = new_data.get('mask', user_card.mask)
user_card.save() 196 227 user_card.save()
return self 197 228 return self
229
230 def report(self, user, reason=None):
231 obj, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self)
232 obj.reason = reason
233 obj.save()
198 234
@classmethod 199 235 @classmethod
def cards_visible_to(cls, user): 200 236 def cards_visible_to(cls, user):
""" 201 237 """
:param user: 202 238 :param user:
:return: A queryset with all cards that should be visible to a user. 203 239 :return: A queryset with all cards that should be visible to a user.
""" 204 240 """
return cls.objects.filter(is_hidden=False).exclude(flashcardhide__user=user) 205 241 return cls.objects.filter(is_hidden=False).exclude(flashcardhide__user=user)
206 242
207 243
class UserFlashcardQuiz(Model): 208 244 class UserFlashcardQuiz(Model):
""" 209 245 """
An event of a user being quizzed on a flashcard. 210 246 An event of a user being quizzed on a flashcard.
""" 211 247 """
user_flashcard = ForeignKey(UserFlashcard) 212 248 user_flashcard = ForeignKey(UserFlashcard)
when = DateTimeField(auto_now=True) 213 249 when = DateTimeField(auto_now=True)
blanked_word = CharField(max_length=8, blank=True, help_text="The character range which was blanked") 214 250 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") 215 251 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") 216 252 correct = NullBooleanField(help_text="The user's self-evaluation of their response")
217 253
def status(self): 218 254 def status(self):
""" 219 255 """
There are three stages of a quiz object: 220 256 There are three stages of a quiz object:
1. the user has been shown the card 221 257 1. the user has been shown the card
2. the user has answered the card 222 258 2. the user has answered the card
3. the user has self-evaluated their response's correctness 223 259 3. the user has self-evaluated their response's correctness
224 260
:return: string (evaluated, answered, viewed) 225 261 :return: string (evaluated, answered, viewed)
""" 226 262 """
if self.correct is not None: return "evaluated" 227 263 if self.correct is not None: return "evaluated"
if self.response: return "answered" 228 264 if self.response: return "answered"
return "viewed" 229 265 return "viewed"
230 266
231 267
class Section(Model): 232 268 class Section(Model):
""" 233 269 """
A UCSD course taught by an instructor during a quarter. 234 270 A UCSD course taught by an instructor during a quarter.
We use the term "section" to avoid collision with the builtin keyword "class" 235 271 We use the term "section" to avoid collision with the builtin keyword "class"
We index gratuitously to support autofill and because this is primarily read-only 236 272 We index gratuitously to support autofill and because this is primarily read-only
""" 237 273 """
department = CharField(db_index=True, max_length=50) 238 274 department = CharField(db_index=True, max_length=50)
department_abbreviation = CharField(db_index=True, max_length=10) 239 275 department_abbreviation = CharField(db_index=True, max_length=10)
course_num = CharField(db_index=True, max_length=6) 240 276 course_num = CharField(db_index=True, max_length=6)
course_title = CharField(db_index=True, max_length=50) 241 277 course_title = CharField(db_index=True, max_length=50)
instructor = CharField(db_index=True, max_length=100) 242 278 instructor = CharField(db_index=True, max_length=100)
quarter = CharField(db_index=True, max_length=4) 243 279 quarter = CharField(db_index=True, max_length=4)
244 280
@classmethod 245 281 @classmethod
def search(cls, terms): 246 282 def search(cls, terms):
""" 247 283 """
Search all fields of all sections for a particular set of terms 248 284 Search all fields of all sections for a particular set of terms
A matching section must match at least one field on each term 249 285 A matching section must match at least one field on each term
:param terms:iterable 250 286 :param terms:iterable
:return: Matching QuerySet ordered by department and course number 251 287 :return: Matching QuerySet ordered by department and course number
""" 252 288 """
final_q = Q() 253 289 final_q = Q()
for term in terms: 254 290 for term in terms:
q = Q(department__icontains=term) 255 291 q = Q(department__icontains=term)
q |= Q(department_abbreviation__icontains=term) 256 292 q |= Q(department_abbreviation__icontains=term)
q |= Q(course_title__icontains=term) 257 293 q |= Q(course_title__icontains=term)
q |= Q(course_num__icontains=term) 258 294 q |= Q(course_num__icontains=term)
q |= Q(instructor__icontains=term) 259 295 q |= Q(instructor__icontains=term)
final_q &= q 260 296 final_q &= q
qs = cls.objects.filter(final_q) 261 297 qs = cls.objects.filter(final_q)
# Have the database cast the course number to an integer so it will sort properly 262 298 # Have the database cast the course number to an integer so it will sort properly
# ECE 35 should rank before ECE 135 even though '135' < '35' lexicographically 263 299 # ECE 35 should rank before ECE 135 even though '135' < '35' lexicographically
qs = qs.extra(select={'course_num_int': "CAST(rtrim(course_num, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') AS INTEGER)"}) 264 300 qs = qs.extra(select={'course_num_int': "CAST(rtrim(course_num, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') AS INTEGER)"})
qs = qs.order_by('department_abbreviation', 'course_num_int') 265 301 qs = qs.order_by('department_abbreviation', 'course_num_int')
return qs 266 302 return qs
267 303
@property 268 304 @property
def is_whitelisted(self): 269 305 def is_whitelisted(self):
""" 270 306 """
:return: whether a whitelist exists for this section 271 307 :return: whether a whitelist exists for this section
""" 272 308 """
return self.whitelist.exists() 273 309 return self.whitelist.exists()
274 310
def is_user_on_whitelist(self, user): 275 311 def is_user_on_whitelist(self, user):
""" 276 312 """
:return: whether the user is on the waitlist for this section 277 313 :return: whether the user is on the waitlist for this section
""" 278 314 """
return self.whitelist.filter(email=user.email).exists() 279 315 return self.whitelist.filter(email=user.email).exists()
280 316
281 317
def enroll(self, user): 282 318 def enroll(self, user):
if user.sections.filter(pk=self.pk).exists(): 283 319 if user.sections.filter(pk=self.pk).exists():
raise ValidationError('User is already enrolled in this section') 284 320 raise ValidationError('User is already enrolled in this section')
if self.is_whitelisted and not self.is_user_on_whitelist(user): 285 321 if self.is_whitelisted and not self.is_user_on_whitelist(user):
raise PermissionDenied("User must be on the whitelist to add this section.") 286 322 raise PermissionDenied("User must be on the whitelist to add this section.")
self.user_set.add(user) 287 323 self.user_set.add(user)
288 324
def drop(self, user): 289 325 def drop(self, user):
if not user.sections.filter(pk=self.pk).exists(): 290 326 if not user.sections.filter(pk=self.pk).exists():
raise ValidationError("User is not enrolled in the section.") 291 327 raise ValidationError("User is not enrolled in the section.")
self.user_set.remove(user) 292 328 self.user_set.remove(user)
293 329
class Meta: 294 330 class Meta:
ordering = ['-course_title'] 295 331 ordering = ['-course_title']
flashcards/views.py View file @ 8e66c81
import django 1 1 import django
2 2
from django.contrib import auth 3 3 from django.contrib import auth
from django.shortcuts import get_object_or_404 4 4 from django.shortcuts import get_object_or_404
from flashcards.api import StandardResultsSetPagination, IsEnrolledInAssociatedSection 5 5 from flashcards.api import StandardResultsSetPagination, IsEnrolledInAssociatedSection
from flashcards.models import Section, User, Flashcard, FlashcardHide, UserFlashcard 6 6 from flashcards.models import Section, User, Flashcard, FlashcardHide, UserFlashcard
from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \ 7 7 from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \
PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer, \ 8 8 PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer, \
FlashcardUpdateSerializer 9 9 FlashcardUpdateSerializer
from rest_framework.decorators import detail_route, permission_classes, api_view, list_route 10 10 from rest_framework.decorators import detail_route, permission_classes, api_view, list_route
from rest_framework.generics import ListAPIView, GenericAPIView 11 11 from rest_framework.generics import ListAPIView, GenericAPIView
from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin 12 12 from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin
from rest_framework.permissions import IsAuthenticated 13 13 from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ReadOnlyModelViewSet, GenericViewSet 14 14 from rest_framework.viewsets import ReadOnlyModelViewSet, GenericViewSet
from django.core.mail import send_mail 15 15 from django.core.mail import send_mail
from django.contrib.auth import authenticate 16 16 from django.contrib.auth import authenticate
from django.contrib.auth.tokens import default_token_generator 17 17 from django.contrib.auth.tokens import default_token_generator
from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED, HTTP_200_OK 18 18 from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED, HTTP_200_OK
from rest_framework.response import Response 19 19 from rest_framework.response import Response
from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied 20 20 from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied
from simple_email_confirmation import EmailAddress 21 21 from simple_email_confirmation import EmailAddress
22 22
23 23
class SectionViewSet(ReadOnlyModelViewSet): 24 24 class SectionViewSet(ReadOnlyModelViewSet):
queryset = Section.objects.all() 25 25 queryset = Section.objects.all()
serializer_class = SectionSerializer 26 26 serializer_class = SectionSerializer
pagination_class = StandardResultsSetPagination 27 27 pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated] 28 28 permission_classes = [IsAuthenticated]
29 29
@detail_route(methods=['GET']) 30 30 @detail_route(methods=['GET'])
def flashcards(self, request, pk): 31 31 def flashcards(self, request, pk):
""" 32 32 """
Gets flashcards for a section, excluding hidden cards. 33 33 Gets flashcards for a section, excluding hidden cards.
Returned in strictly chronological order (material date). 34 34 Returned in strictly chronological order (material date).
""" 35 35 """
flashcards = Flashcard.cards_visible_to(request.user).filter(section=self.get_object()).all() 36 36 flashcards = Flashcard.cards_visible_to(request.user).filter(section=self.get_object()).all()
return Response(FlashcardSerializer(flashcards, many=True).data) 37 37 return Response(FlashcardSerializer(flashcards, many=True).data)
38 38
@detail_route(methods=['post']) 39 39 @detail_route(methods=['post'])
def enroll(self, request, pk): 40 40 def enroll(self, request, pk):
""" 41 41 """
Add the current user to a specified section 42 42 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. 43 43 If the class has a whitelist, but the user is not on the whitelist, the request will fail.
--- 44 44 ---
view_mocker: flashcards.api.mock_no_params 45 45 view_mocker: flashcards.api.mock_no_params
""" 46 46 """
47 47
self.get_object().enroll(request.user) 48 48 self.get_object().enroll(request.user)
return Response(status=HTTP_204_NO_CONTENT) 49 49 return Response(status=HTTP_204_NO_CONTENT)
50 50
@detail_route(methods=['post']) 51 51 @detail_route(methods=['post'])
def drop(self, request, pk): 52 52 def drop(self, request, pk):
""" 53 53 """
Remove the current user from a specified section 54 54 Remove the current user from a specified section
If the user is not in the class, the request will fail. 55 55 If the user is not in the class, the request will fail.
--- 56 56 ---
view_mocker: flashcards.api.mock_no_params 57 57 view_mocker: flashcards.api.mock_no_params
""" 58 58 """
try: 59 59 try:
self.get_object().drop(request.user) 60 60 self.get_object().drop(request.user)
except django.core.exceptions.PermissionDenied as e: 61 61 except django.core.exceptions.PermissionDenied as e:
raise PermissionDenied(e) 62 62 raise PermissionDenied(e)
except django.core.exceptions.ValidationError as e: 63 63 except django.core.exceptions.ValidationError as e:
raise ValidationError(e) 64 64 raise ValidationError(e)
return Response(status=HTTP_204_NO_CONTENT) 65 65 return Response(status=HTTP_204_NO_CONTENT)
66 66
@list_route(methods=['GET']) 67 67 @list_route(methods=['GET'])
def search(self, request): 68 68 def search(self, request):
""" 69 69 """
Returns a list of sections which match a user's query 70 70 Returns a list of sections which match a user's query
--- 71 71 ---
parameters: 72 72 parameters:
- name: q 73 73 - name: q
description: space-separated list of terms 74 74 description: space-separated list of terms
required: true 75 75 required: true
type: form 76 76 type: form
""" 77 77 """
query = request.GET.get('q', None) 78 78 query = request.GET.get('q', None)
if not query: return Response('[]') 79 79 if not query: return Response('[]')
qs = Section.search(query.split(' '))[:20] 80 80 qs = Section.search(query.split(' '))[:20]
serializer = SectionSerializer(qs, many=True) 81 81 serializer = SectionSerializer(qs, many=True)
return Response(serializer.data) 82 82 return Response(serializer.data)
83 83
@detail_route(methods=['GET']) 84 84 @detail_route(methods=['GET'])
def deck(self, request, pk): 85 85 def deck(self, request, pk):
""" 86 86 """
Gets the contents of a user's deck for a given section. 87 87 Gets the contents of a user's deck for a given section.
""" 88 88 """
qs = request.user.get_deck(self.get_object()) 89 89 qs = request.user.get_deck(self.get_object())
serializer = FlashcardSerializer(qs, many=True) 90 90 serializer = FlashcardSerializer(qs, many=True)
return Response(serializer.data) 91 91 return Response(serializer.data)
92 92
@detail_route(methods=['get'], permission_classes=[IsAuthenticated]) 93 93 @detail_route(methods=['get'], permission_classes=[IsAuthenticated])
def ordered_deck(self, request, pk): 94 94 def ordered_deck(self, request, pk):
""" 95 95 """
Get a chronological order by material_date of flashcards for a section. 96 96 Get a chronological order by material_date of flashcards for a section.
This excludes hidden card. 97 97 This excludes hidden card.
""" 98 98 """
qs = request.user.get_deck(self.get_object()).order_by('-material_date') 99 99 qs = request.user.get_deck(self.get_object()).order_by('-material_date')
serializer = FlashcardSerializer(qs, many=True) 100 100 serializer = FlashcardSerializer(qs, many=True)
return Response(serializer.data) 101 101 return Response(serializer.data)
102 102
@detail_route(methods=['GET']) 103 103 @detail_route(methods=['GET'])
def feed(self, request, pk): 104 104 def feed(self, request, pk):
""" 105 105 """
Gets the contents of a user's feed for a section. 106 106 Gets the contents of a user's feed for a section.
Exclude cards that are already in the user's deck 107 107 Exclude cards that are already in the user's deck
""" 108 108 """
serializer = FlashcardSerializer(self.get_object().get_feed_for_user(request.user), many=True) 109 109 serializer = FlashcardSerializer(self.get_object().get_feed_for_user(request.user), many=True)
return Response(serializer.data) 110 110 return Response(serializer.data)
111 111
112 112
class UserSectionListView(ListAPIView): 113 113 class UserSectionListView(ListAPIView):
serializer_class = SectionSerializer 114 114 serializer_class = SectionSerializer
permission_classes = [IsAuthenticated] 115 115 permission_classes = [IsAuthenticated]
116 116
def get_queryset(self): 117 117 def get_queryset(self):
return self.request.user.sections.all() 118 118 return self.request.user.sections.all()
119 119
def paginate_queryset(self, queryset): return None 120 120 def paginate_queryset(self, queryset): return None
121 121
122 122
class UserDetail(GenericAPIView): 123 123 class UserDetail(GenericAPIView):
serializer_class = UserSerializer 124 124 serializer_class = UserSerializer
permission_classes = [IsAuthenticated] 125 125 permission_classes = [IsAuthenticated]
126 126
def patch(self, request, format=None): 127 127 def patch(self, request, format=None):
""" 128 128 """
Updates the user's password, or verifies their email address 129 129 Updates the user's password, or verifies their email address
--- 130 130 ---
request_serializer: UserUpdateSerializer 131 131 request_serializer: UserUpdateSerializer
response_serializer: UserSerializer 132 132 response_serializer: UserSerializer
""" 133 133 """
data = UserUpdateSerializer(data=request.data, context={'user': request.user}) 134 134 data = UserUpdateSerializer(data=request.data, context={'user': request.user})
data.is_valid(raise_exception=True) 135 135 data.is_valid(raise_exception=True)
data = data.validated_data 136 136 data = data.validated_data
137 137
if 'new_password' in data: 138 138 if 'new_password' in data:
if not request.user.check_password(data['old_password']): 139 139 if not request.user.check_password(data['old_password']):
raise ValidationError('old_password is incorrect') 140 140 raise ValidationError('old_password is incorrect')
request.user.set_password(data['new_password']) 141 141 request.user.set_password(data['new_password'])
request.user.save() 142 142 request.user.save()
143 143
if 'confirmation_key' in data: 144 144 if 'confirmation_key' in data:
try: 145 145 try:
request.user.confirm_email(data['confirmation_key']) 146 146 request.user.confirm_email(data['confirmation_key'])
except EmailAddress.DoesNotExist: 147 147 except EmailAddress.DoesNotExist:
raise ValidationError('confirmation_key is invalid') 148 148 raise ValidationError('confirmation_key is invalid')
149 149
return Response(UserSerializer(request.user).data) 150 150 return Response(UserSerializer(request.user).data)
151 151
def get(self, request, format=None): 152 152 def get(self, request, format=None):
""" 153 153 """
Return data about the user 154 154 Return data about the user
--- 155 155 ---
response_serializer: UserSerializer 156 156 response_serializer: UserSerializer
""" 157 157 """
serializer = UserSerializer(request.user, context={'request': request}) 158 158 serializer = UserSerializer(request.user, context={'request': request})
return Response(serializer.data) 159 159 return Response(serializer.data)
160 160
def delete(self, request): 161 161 def delete(self, request):
""" 162 162 """
Irrevocably delete the user and their data 163 163 Irrevocably delete the user and their data
164 164
Yes, really 165 165 Yes, really
""" 166 166 """
request.user.delete() 167 167 request.user.delete()
return Response(status=HTTP_204_NO_CONTENT) 168 168 return Response(status=HTTP_204_NO_CONTENT)
169 169
170 170
@api_view(['POST']) 171 171 @api_view(['POST'])
def register(request, format=None): 172 172 def register(request, format=None):
""" 173 173 """
Register a new user 174 174 Register a new user
--- 175 175 ---
request_serializer: EmailPasswordSerializer 176 176 request_serializer: EmailPasswordSerializer
response_serializer: UserSerializer 177 177 response_serializer: UserSerializer
""" 178 178 """
data = RegistrationSerializer(data=request.data) 179 179 data = RegistrationSerializer(data=request.data)
data.is_valid(raise_exception=True) 180 180 data.is_valid(raise_exception=True)
181 181
User.objects.create_user(**data.validated_data) 182 182 User.objects.create_user(**data.validated_data)
user = authenticate(**data.validated_data) 183 183 user = authenticate(**data.validated_data)
auth.login(request, user) 184 184 auth.login(request, user)
185 185
body = ''' 186
Visit the following link to confirm your email address: 187
https://flashy.cards/app/verifyemail/%s 188
189
If you did not register for Flashy, no action is required. 190
''' 191
192
assert send_mail("Flashy email verification", 193
body % user.confirmation_key, 194
"noreply@flashy.cards", 195
[user.email]) 196
197
return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED) 198 186 return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED)
199 187
200 188
@api_view(['POST']) 201 189 @api_view(['POST'])
def login(request): 202 190 def login(request):
""" 203 191 """
Authenticates user and returns user data if valid. 204 192 Authenticates user and returns user data if valid.
--- 205 193 ---
request_serializer: EmailPasswordSerializer 206 194 request_serializer: EmailPasswordSerializer
response_serializer: UserSerializer 207 195 response_serializer: UserSerializer
""" 208 196 """
209 197
data = EmailPasswordSerializer(data=request.data) 210 198 data = EmailPasswordSerializer(data=request.data)
data.is_valid(raise_exception=True) 211 199 data.is_valid(raise_exception=True)
user = authenticate(**data.validated_data) 212 200 user = authenticate(**data.validated_data)
213 201
if user is None: 214 202 if user is None:
raise AuthenticationFailed('Invalid email or password') 215 203 raise AuthenticationFailed('Invalid email or password')
if not user.is_active: 216 204 if not user.is_active:
raise NotAuthenticated('Account is disabled') 217 205 raise NotAuthenticated('Account is disabled')
auth.login(request, user) 218 206 auth.login(request, user)
return Response(UserSerializer(request.user).data) 219 207 return Response(UserSerializer(request.user).data)
220 208
221 209
@api_view(['POST']) 222 210 @api_view(['POST'])
@permission_classes((IsAuthenticated, )) 223 211 @permission_classes((IsAuthenticated, ))
def logout(request, format=None): 224 212 def logout(request, format=None):
""" 225 213 """
Logs the authenticated user out. 226 214 Logs the authenticated user out.
""" 227 215 """
auth.logout(request) 228 216 auth.logout(request)
return Response(status=HTTP_204_NO_CONTENT) 229 217 return Response(status=HTTP_204_NO_CONTENT)
230 218
231 219
@api_view(['POST']) 232 220 @api_view(['POST'])
def request_password_reset(request, format=None): 233 221 def request_password_reset(request, format=None):
""" 234 222 """
Send a password reset token/link to the provided email. 235 223 Send a password reset token/link to the provided email.
--- 236 224 ---
request_serializer: PasswordResetRequestSerializer 237 225 request_serializer: PasswordResetRequestSerializer
""" 238 226 """
data = PasswordResetRequestSerializer(data=request.data) 239 227 data = PasswordResetRequestSerializer(data=request.data)
data.is_valid(raise_exception=True) 240 228 data.is_valid(raise_exception=True)
user = User.objects.get(email=data['email'].value) 241 229 get_object_or_404(User, email=data['email'].value).request_password_reset()
token = default_token_generator.make_token(user) 242
243
body = ''' 244
Visit the following link to reset your password: 245
https://flashy.cards/app/resetpassword/%d/%s 246
247
If you did not request a password reset, no action is required. 248
''' 249
250
send_mail("Flashy password reset", 251
body % (user.pk, token), 252
"noreply@flashy.cards", 253
[user.email]) 254
255
return Response(status=HTTP_204_NO_CONTENT) 256 230 return Response(status=HTTP_204_NO_CONTENT)
257 231
258 232
@api_view(['POST']) 259 233 @api_view(['POST'])
def reset_password(request, format=None): 260 234 def reset_password(request, format=None):
""" 261 235 """
Updates user's password to new password if token is valid. 262 236 Updates user's password to new password if token is valid.
--- 263 237 ---
request_serializer: PasswordResetSerializer 264 238 request_serializer: PasswordResetSerializer
""" 265 239 """
data = PasswordResetSerializer(data=request.data) 266 240 data = PasswordResetSerializer(data=request.data)
data.is_valid(raise_exception=True) 267 241 data.is_valid(raise_exception=True)
268 242
user = User.objects.get(id=data['uid'].value) 269 243 user = User.objects.get(id=data['uid'].value)
# Check token validity. 270 244 # Check token validity.
271 245
if default_token_generator.check_token(user, data['token'].value): 272 246 if default_token_generator.check_token(user, data['token'].value):
user.set_password(data['new_password'].value) 273 247 user.set_password(data['new_password'].value)
user.save() 274 248 user.save()
else: 275 249 else:
raise ValidationError('Could not verify reset token') 276 250 raise ValidationError('Could not verify reset token')
return Response(status=HTTP_204_NO_CONTENT) 277 251 return Response(status=HTTP_204_NO_CONTENT)
278 252
279 253
class FlashcardViewSet(GenericViewSet, CreateModelMixin, RetrieveModelMixin): 280 254 class FlashcardViewSet(GenericViewSet, CreateModelMixin, RetrieveModelMixin):
queryset = Flashcard.objects.all() 281 255 queryset = Flashcard.objects.all()
serializer_class = FlashcardSerializer 282 256 serializer_class = FlashcardSerializer
permission_classes = [IsAuthenticated, IsEnrolledInAssociatedSection] 283 257 permission_classes = [IsAuthenticated, IsEnrolledInAssociatedSection]
284 258
# Override create in CreateModelMixin 285 259 # Override create in CreateModelMixin
def create(self, request, *args, **kwargs): 286 260 def create(self, request, *args, **kwargs):
serializer = FlashcardSerializer(data=request.data) 287 261 serializer = FlashcardSerializer(data=request.data)
serializer.is_valid(raise_exception=True) 288 262 serializer.is_valid(raise_exception=True)
data = serializer.validated_data 289 263 data = serializer.validated_data
if not request.user.is_in_section(data['section']): 290 264 if not request.user.is_in_section(data['section']):
raise PermissionDenied('The user is not enrolled in that section') 291 265 raise PermissionDenied('The user is not enrolled in that section')
data['author'] = request.user 292 266 data['author'] = request.user
flashcard = Flashcard.objects.create(**data) 293 267 flashcard = Flashcard.objects.create(**data)
self.perform_create(flashcard) 294 268 self.perform_create(flashcard)
headers = self.get_success_headers(data) 295 269 headers = self.get_success_headers(data)
response_data = FlashcardSerializer(flashcard) 296 270 response_data = FlashcardSerializer(flashcard)
return Response(response_data.data, status=HTTP_201_CREATED, headers=headers) 297 271 return Response(response_data.data, status=HTTP_201_CREATED, headers=headers)
298 272
299 273
@detail_route(methods=['post']) 300 274 @detail_route(methods=['post'])
def unhide(self, request, pk): 301 275 def unhide(self, request, pk):
""" 302 276 """
Unhide the given card 303 277 Unhide the given card
--- 304 278 ---
view_mocker: flashcards.api.mock_no_params 305 279 view_mocker: flashcards.api.mock_no_params
""" 306 280 """