Commit 28a4bd2e76e8a9fb4a37532064f8be85a8b0783c

Authored by Rohan Rangray
1 parent 90b3c45f13
Exists in master

Refactored code in accordance with the code review

Showing 2 changed files with 7 additions and 2 deletions Inline Diff

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