Commit 901ee2fa94a11d428f3963e58ad1ddd35c1ff670

Authored by Rohan Rangray
1 parent 6967144326
Exists in master

Added pagination for the feed.

Showing 3 changed files with 21 additions and 7 deletions Inline Diff

flashcards/models.py View file @ 901ee2f
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.core.validators import MinLengthValidator 10 10 from django.core.validators import MinLengthValidator
from django.db import IntegrityError 11 11 from django.db import IntegrityError
from django.db.models import * 12 12 from django.db.models import *
from django.utils.log import getLogger 13 13 from django.utils.log import getLogger
from django.utils.timezone import now, make_aware 14 14 from django.utils.timezone import now, make_aware
from flashy.settings import QUARTER_START, QUARTER_END 15 15 from flashy.settings import QUARTER_START, QUARTER_END
from simple_email_confirmation import SimpleEmailConfirmationUserMixin 16 16 from simple_email_confirmation import SimpleEmailConfirmationUserMixin
from fields import MaskField 17 17 from fields import MaskField
from cached_property import cached_property 18 18 from cached_property import cached_property
from flashy.settings import IN_PRODUCTION 19 19 from flashy.settings import IN_PRODUCTION
from math import e 20 20 from math import e
21 21
# Hack to fix AbstractUser before subclassing it 22 22 # Hack to fix AbstractUser before subclassing it
23 23
AbstractUser._meta.get_field('email')._unique = True 24 24 AbstractUser._meta.get_field('email')._unique = True
AbstractUser._meta.get_field('username')._unique = False 25 25 AbstractUser._meta.get_field('username')._unique = False
26 26
27 27
class EmailOnlyUserManager(UserManager): 28 28 class EmailOnlyUserManager(UserManager):
""" 29 29 """
A tiny extension of Django's UserManager which correctly creates users 30 30 A tiny extension of Django's UserManager which correctly creates users
without usernames (using emails instead). 31 31 without usernames (using emails instead).
""" 32 32 """
33 33
def _create_user(self, email, password, is_staff, is_superuser, **extra_fields): 34 34 def _create_user(self, email, password, is_staff, is_superuser, **extra_fields):
""" 35 35 """
Creates and saves a User with the given email and password. 36 36 Creates and saves a User with the given email and password.
""" 37 37 """
email = self.normalize_email(email) 38 38 email = self.normalize_email(email)
user = self.model(email=email, 39 39 user = self.model(email=email,
is_staff=is_staff, is_active=True, 40 40 is_staff=is_staff, is_active=True,
is_superuser=is_superuser, 41 41 is_superuser=is_superuser,
date_joined=now(), **extra_fields) 42 42 date_joined=now(), **extra_fields)
user.set_password(password) 43 43 user.set_password(password)
user.save(using=self._db) 44 44 user.save(using=self._db)
return user 45 45 return user
46 46
def create_user(self, email, password=None, **extra_fields): 47 47 def create_user(self, email, password=None, **extra_fields):
user = self._create_user(email, password, False, False, **extra_fields) 48 48 user = self._create_user(email, password, False, False, **extra_fields)
body = ''' 49 49 body = '''
Visit the following link to confirm your email address: 50 50 Visit the following link to confirm your email address:
https://flashy.cards/app/verifyemail/%s 51 51 https://flashy.cards/app/verifyemail/%s
52 52
If you did not register for Flashy, no action is required. 53 53 If you did not register for Flashy, no action is required.
''' 54 54 '''
55 55
assert send_mail("Flashy email verification", 56 56 assert send_mail("Flashy email verification",
body % user.confirmation_key, 57 57 body % user.confirmation_key,
"noreply@flashy.cards", 58 58 "noreply@flashy.cards",
[user.email]) 59 59 [user.email])
return user 60 60 return user
61 61
def create_superuser(self, email, password, **extra_fields): 62 62 def create_superuser(self, email, password, **extra_fields):
return self._create_user(email, password, True, True, **extra_fields) 63 63 return self._create_user(email, password, True, True, **extra_fields)
64 64
65 65
class FlashcardAlreadyPulledException(Exception): 66 66 class FlashcardAlreadyPulledException(Exception):
pass 67 67 pass
68 68
69 69
70 70
class FlashcardNotInDeckException(Exception): 71 71 class FlashcardNotInDeckException(Exception):
pass 72 72 pass
73 73
74 74
class User(AbstractUser, SimpleEmailConfirmationUserMixin): 75 75 class User(AbstractUser, SimpleEmailConfirmationUserMixin):
""" 76 76 """
An extension of Django's default user model. 77 77 An extension of Django's default user model.
We use email as the username field, and include enrolled sections here 78 78 We use email as the username field, and include enrolled sections here
""" 79 79 """
objects = EmailOnlyUserManager() 80 80 objects = EmailOnlyUserManager()
USERNAME_FIELD = 'email' 81 81 USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] 82 82 REQUIRED_FIELDS = []
sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in") 83 83 sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in")
confirmed_email = BooleanField(default=False) 84 84 confirmed_email = BooleanField(default=False)
85 85
def is_in_section(self, section): 86 86 def is_in_section(self, section):
return self.sections.filter(pk=section.pk).exists() 87 87 return self.sections.filter(pk=section.pk).exists()
88 88
def pull(self, flashcard): 89 89 def pull(self, flashcard):
if not self.is_in_section(flashcard.section): 90 90 if not self.is_in_section(flashcard.section):
raise ValueError("User not in the section this flashcard belongs to") 91 91 raise ValueError("User not in the section this flashcard belongs to")
92 92
try: 93 93 try:
user_card = UserFlashcard.objects.create(user=self, flashcard=flashcard) 94 94 user_card = UserFlashcard.objects.create(user=self, flashcard=flashcard)
except IntegrityError: 95 95 except IntegrityError:
raise FlashcardAlreadyPulledException() 96 96 raise FlashcardAlreadyPulledException()
user_card.save() 97 97 user_card.save()
98 98
import flashcards.notifications 99 99 import flashcards.notifications
100 100
flashcards.notifications.notify_score_change(flashcard) 101 101 flashcards.notifications.notify_score_change(flashcard)
flashcards.notifications.notify_pull(flashcard) 102 102 flashcards.notifications.notify_pull(flashcard)
103 103
def unpull(self, flashcard): 104 104 def unpull(self, flashcard):
if not self.is_in_section(flashcard.section): 105 105 if not self.is_in_section(flashcard.section):
raise ValueError("User not in the section this flashcard belongs to") 106 106 raise ValueError("User not in the section this flashcard belongs to")
107 107
try: 108 108 try:
import flashcards.notifications 109 109 import flashcards.notifications
user_card = UserFlashcard.objects.get(user=self, flashcard=flashcard) 110 110 user_card = UserFlashcard.objects.get(user=self, flashcard=flashcard)
user_card.delete() 111 111 user_card.delete()
flashcards.notifications.notify_score_change(flashcard) 112 112 flashcards.notifications.notify_score_change(flashcard)
except UserFlashcard.DoesNotExist: 113 113 except UserFlashcard.DoesNotExist:
raise FlashcardNotInDeckException() 114 114 raise FlashcardNotInDeckException()
115 115
def get_deck(self, section): 116 116 def get_deck(self, section):
if not self.is_in_section(section): 117 117 if not self.is_in_section(section):
raise ObjectDoesNotExist("User not enrolled in section") 118 118 raise ObjectDoesNotExist("User not enrolled in section")
return Flashcard.objects.all().filter(userflashcard__user=self).filter(section=section) 119 119 return Flashcard.objects.all().filter(userflashcard__user=self).filter(section=section)
120 120
def request_password_reset(self): 121 121 def request_password_reset(self):
token = default_token_generator.make_token(self) 122 122 token = default_token_generator.make_token(self)
123 123
body = ''' 124 124 body = '''
Visit the following link to reset your password: 125 125 Visit the following link to reset your password:
https://flashy.cards/app/resetpassword/%d/%s 126 126 https://flashy.cards/app/resetpassword/%d/%s
127 127
If you did not request a password reset, no action is required. 128 128 If you did not request a password reset, no action is required.
''' 129 129 '''
130 130
send_mail("Flashy password reset", 131 131 send_mail("Flashy password reset",
body % (self.pk, token), 132 132 body % (self.pk, token),
"noreply@flashy.cards", 133 133 "noreply@flashy.cards",
[self.email]) 134 134 [self.email])
135 135
def confirm_email(self, confirmation_key): 136 136 def confirm_email(self, confirmation_key):
# This will raise an exception if the email address is invalid 137 137 # This will raise an exception if the email address is invalid
self.email_address_set.confirm(confirmation_key, save=True) 138 138 self.email_address_set.confirm(confirmation_key, save=True)
self.confirmed_email = True 139 139 self.confirmed_email = True
self.save() 140 140 self.save()
141 141
def by_retention(self, sections, material_date_begin, material_date_end): 142 142 def by_retention(self, sections, material_date_begin, material_date_end):
section_pks = map(lambda i: i['pk'], sections.values('pk')) 143 143 section_pks = map(lambda i: i['pk'], sections.values('pk'))
user_flashcard_filter = UserFlashcard.objects.filter( 144 144 user_flashcard_filter = UserFlashcard.objects.filter(
user=self, flashcard__section__pk__in=section_pks, 145 145 user=self, flashcard__section__pk__in=section_pks,
flashcard__material_date__gte=material_date_begin, 146 146 flashcard__material_date__gte=material_date_begin,
flashcard__material_date__lte=material_date_end 147 147 flashcard__material_date__lte=material_date_end
) 148 148 )
149 149
if not user_flashcard_filter.exists(): 150 150 if not user_flashcard_filter.exists():
raise ValidationError("No matching flashcard found in your decks") 151 151 raise ValidationError("No matching flashcard found in your decks")
152 152
return user_flashcard_filter.prefetch_related('userflashcardquiz_set').annotate( 153 153 return user_flashcard_filter.prefetch_related('userflashcardquiz_set').annotate(
study_count=Count('pk'), 154 154 study_count=Count('pk'),
days_since=Case( 155 155 days_since=Case(
When(userflashcardquiz__when=None, then=interval_days(Now(), F('pulled'))), 156 156 When(userflashcardquiz__when=None, then=interval_days(Now(), F('pulled'))),
default=interval_days(Now(), Max('userflashcardquiz__when')), 157 157 default=interval_days(Now(), Max('userflashcardquiz__when')),
output_field=FloatField() 158 158 output_field=FloatField()
), 159 159 ),
retention_score=Case( 160 160 retention_score=Case(
default=Value(e, output_field=FloatField()) ** (F('days_since')*(-0.1/(F('study_count')+1))), 161 161 default=Value(e, output_field=FloatField()) ** (F('days_since')*(-0.1/(F('study_count')+1))),
output_field=FloatField() 162 162 output_field=FloatField()
) 163 163 )
).order_by('retention_score') 164 164 ).order_by('retention_score')
165 165
166 166
class UserFlashcard(Model): 167 167 class UserFlashcard(Model):
""" 168 168 """
Represents the relationship between a user and a flashcard by: 169 169 Represents the relationship between a user and a flashcard by:
1. A user has a flashcard in their deck 170 170 1. A user has a flashcard in their deck
2. A user used to have a flashcard in their deck 171 171 2. A user used to have a flashcard in their deck
3. A user has a flashcard hidden from them 172 172 3. A user has a flashcard hidden from them
""" 173 173 """
user = ForeignKey('User') 174 174 user = ForeignKey('User')
mask = MaskField(null=True, blank=True, default=None, help_text="The user-specific mask on the card") 175 175 mask = MaskField(null=True, blank=True, default=None, help_text="The user-specific mask on the card")
pulled = DateTimeField(auto_now_add=True, help_text="When the user pulled the card") 176 176 pulled = DateTimeField(auto_now_add=True, help_text="When the user pulled the card")
flashcard = ForeignKey('Flashcard') 177 177 flashcard = ForeignKey('Flashcard')
178 178
def get_mask(self): 179 179 def get_mask(self):
if self.mask is None: 180 180 if self.mask is None:
return self.flashcard.mask 181 181 return self.flashcard.mask
return self.mask 182 182 return self.mask
183 183
class Meta: 184 184 class Meta:
# There can be at most one UserFlashcard for each User and Flashcard 185 185 # There can be at most one UserFlashcard for each User and Flashcard
unique_together = (('user', 'flashcard'),) 186 186 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 187 187 index_together = ["user", "flashcard"]
# By default, order by most recently pulled 188 188 # By default, order by most recently pulled
ordering = ['-pulled'] 189 189 ordering = ['-pulled']
190 190
def __unicode__(self): 191 191 def __unicode__(self):
return '%s has %s' % (str(self.user), str(self.flashcard)) 192 192 return '%s has %s' % (str(self.user), str(self.flashcard))
193 193
194 194
class FlashcardHide(Model): 195 195 class FlashcardHide(Model):
""" 196 196 """
Represents the property of a flashcard being hidden by a user. 197 197 Represents the property of a flashcard being hidden by a user.
Each instance of this class represents a single user hiding a single flashcard. 198 198 Each instance of this class represents a single user hiding a single flashcard.
If reason is null, the flashcard was just hidden. 199 199 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. 200 200 If reason is not null, the flashcard was reported, and reason is the reason why it was reported.
""" 201 201 """
user = ForeignKey('User') 202 202 user = ForeignKey('User')
flashcard = ForeignKey('Flashcard') 203 203 flashcard = ForeignKey('Flashcard')
reason = CharField(max_length=255, blank=True, null=True) 204 204 reason = CharField(max_length=255, blank=True, null=True)
hidden = DateTimeField(auto_now_add=True) 205 205 hidden = DateTimeField(auto_now_add=True)
206 206
class Meta: 207 207 class Meta:
# There can only be one FlashcardHide object for each User and Flashcard 208 208 # There can only be one FlashcardHide object for each User and Flashcard
unique_together = (('user', 'flashcard'),) 209 209 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 210 210 index_together = ["user", "flashcard"]
211 211
def __unicode__(self): 212 212 def __unicode__(self):
return '%s hid %s' % (str(self.user), str(self.flashcard)) 213 213 return '%s hid %s' % (str(self.user), str(self.flashcard))
214 214
215 215
class Flashcard(Model): 216 216 class Flashcard(Model):
text = CharField(max_length=180, help_text='The text on the card', validators=[MinLengthValidator(5)]) 217 217 text = CharField(max_length=180, help_text='The text on the card', validators=[MinLengthValidator(5)])
section = ForeignKey('Section', help_text='The section with which the card is associated') 218 218 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") 219 219 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") 220 220 material_date = DateTimeField(default=now, help_text="The date with which the card is associated")
previous = ForeignKey('Flashcard', null=True, blank=True, default=None, 221 221 previous = ForeignKey('Flashcard', null=True, blank=True, default=None,
help_text="The previous version of this card, if one exists") 222 222 help_text="The previous version of this card, if one exists")
author = ForeignKey(User) 223 223 author = ForeignKey(User)
is_hidden = BooleanField(default=False) 224 224 is_hidden = BooleanField(default=False)
hide_reason = CharField(blank=True, null=True, max_length=255, default='', 225 225 hide_reason = CharField(blank=True, null=True, max_length=255, default='',
help_text="Reason for hiding this card") 226 226 help_text="Reason for hiding this card")
mask = MaskField(null=True, blank=True, help_text="The mask on the card") 227 227 mask = MaskField(null=True, blank=True, help_text="The mask on the card")
228 228
class Meta: 229 229 class Meta:
# By default, order by most recently pushed 230 230 # By default, order by most recently pushed
ordering = ['-pushed'] 231 231 ordering = ['-pushed']
232 232
def __unicode__(self): 233 233 def __unicode__(self):
return '<flashcard: %s>' % self.text 234 234 return '<flashcard: %s>' % self.text
235 235
@property 236 236 @property
def material_week_num(self): 237 237 def material_week_num(self):
return (self.material_date - QUARTER_START).days / 7 + 1 238 238 return (self.material_date - QUARTER_START).days / 7 + 1
239 239
def is_hidden_from(self, user): 240 240 def is_hidden_from(self, user):
""" 241 241 """
A card can be hidden globally, but if a user has the card in their deck, 242 242 A card can be hidden globally, but if a user has the card in their deck,
this visibility overrides a global hide. 243 243 this visibility overrides a global hide.
:param user: 244 244 :param user:
:return: Whether the card is hidden from the user. 245 245 :return: Whether the card is hidden from the user.
""" 246 246 """
return self.is_hidden or self.flashcardhide_set.filter(user=user).exists() 247 247 return self.is_hidden or self.flashcardhide_set.filter(user=user).exists()
248 248
def hide_from(self, user, reason=None): 249 249 def hide_from(self, user, reason=None):
if self.is_in_deck(user): user.unpull(self) 250 250 if self.is_in_deck(user): user.unpull(self)
obj, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self, reason=None) 251 251 obj, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self, reason=None)
if not created: 252 252 if not created:
raise ValidationError("The card has already been hidden.") 253 253 raise ValidationError("The card has already been hidden.")
obj.save() 254 254 obj.save()
255 255
def is_in_deck(self, user): 256 256 def is_in_deck(self, user):
return self.userflashcard_set.filter(user=user).exists() 257 257 return self.userflashcard_set.filter(user=user).exists()
258 258
def add_to_deck(self, user): 259 259 def add_to_deck(self, user):
if not user.is_in_section(self.section): 260 260 if not user.is_in_section(self.section):
raise PermissionDenied("You don't have the permission to add this card") 261 261 raise PermissionDenied("You don't have the permission to add this card")
try: 262 262 try:
user_flashcard = UserFlashcard.objects.create(user=user, flashcard=self, mask=self.mask) 263 263 user_flashcard = UserFlashcard.objects.create(user=user, flashcard=self, mask=self.mask)
except IntegrityError: 264 264 except IntegrityError:
raise SuspiciousOperation("The flashcard is already in the user's deck") 265 265 raise SuspiciousOperation("The flashcard is already in the user's deck")
user_flashcard.save() 266 266 user_flashcard.save()
return user_flashcard 267 267 return user_flashcard
268 268
def edit(self, user, new_data): 269 269 def edit(self, user, new_data):
""" 270 270 """
Creates a new flashcard if a new flashcard should be created when the given user edits this flashcard. 271 271 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. 272 272 Sets up everything correctly so this object, when saved, will result in the appropriate changes.
:param user: The user editing this card. 273 273 :param user: The user editing this card.
:param new_data: The new information, namely a dict containg 'material_date', 'text', and 'mask' keys. 274 274 :param new_data: The new information, namely a dict containg 'material_date', 'text', and 'mask' keys.
""" 275 275 """
276 276
# content_changed is True iff either material_date or text were changed 277 277 # content_changed is True iff either material_date or text were changed
content_changed = False 278 278 content_changed = False
# create_new is True iff the user editing this card is the author of this card 279 279 # 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 280 280 # and there are no other users with this card in their decks
create_new = user != self.author or \ 281 281 create_new = user != self.author or \
UserFlashcard.objects.filter(flashcard=self).exclude(user=user).exists() 282 282 UserFlashcard.objects.filter(flashcard=self).exclude(user=user).exists()
if 'material_date' in new_data and self.material_date != new_data['material_date']: 283 283 if 'material_date' in new_data and self.material_date != new_data['material_date']:
content_changed = True 284 284 content_changed = True
self.material_date = new_data['material_date'] 285 285 self.material_date = new_data['material_date']
if 'text' in new_data and self.text != new_data['text']: 286 286 if 'text' in new_data and self.text != new_data['text']:
content_changed = True 287 287 content_changed = True
self.text = new_data['text'] 288 288 self.text = new_data['text']
if create_new and content_changed: 289 289 if create_new and content_changed:
if self.is_in_deck(user): user.unpull(self) 290 290 if self.is_in_deck(user): user.unpull(self)
self.previous_id = self.pk 291 291 self.previous_id = self.pk
self.pk = None 292 292 self.pk = None
self.mask = new_data.get('mask', self.mask) 293 293 self.mask = new_data.get('mask', self.mask)
self.save() 294 294 self.save()
self.add_to_deck(user) 295 295 self.add_to_deck(user)
else: 296 296 else:
user_card, created = UserFlashcard.objects.get_or_create(user=user, flashcard=self) 297 297 user_card, created = UserFlashcard.objects.get_or_create(user=user, flashcard=self)
user_card.mask = new_data.get('mask', user_card.mask) 298 298 user_card.mask = new_data.get('mask', user_card.mask)
user_card.save() 299 299 user_card.save()
return self 300 300 return self
301 301
def report(self, user, reason=None): 302 302 def report(self, user, reason=None):
if self.is_in_deck(user): user.unpull(self) 303 303 if self.is_in_deck(user): user.unpull(self)
obj, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self) 304 304 obj, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self)
obj.reason = reason 305 305 obj.reason = reason
obj.save() 306 306 obj.save()
307 307
@cached_property 308 308 @cached_property
def score(self): 309 309 def score(self):
def seconds_since_epoch(dt): 310 310 def seconds_since_epoch(dt):
from datetime import datetime 311 311 from datetime import datetime
312 312
epoch = make_aware(datetime.utcfromtimestamp(0)) 313 313 epoch = make_aware(datetime.utcfromtimestamp(0))
delta = dt - epoch 314 314 delta = dt - epoch
return delta.total_seconds() 315 315 return delta.total_seconds()
316 316
z = 0 317 317 z = 0
rate = 1.0 / 3600 318 318 rate = 1.0 / 3600
for vote in self.userflashcard_set.iterator(): 319 319 for vote in self.userflashcard_set.iterator():
t = seconds_since_epoch(vote.pulled) 320 320 t = seconds_since_epoch(vote.pulled)
u = max(z, rate * t) 321 321 u = max(z, rate * t)
v = min(z, rate * t) 322 322 v = min(z, rate * t)
z = u + log1p(exp(v - u)) 323 323 z = u + log1p(exp(v - u))
return z 324 324 return z
325 325
@classmethod 326 326 @classmethod
def cards_visible_to(cls, user): 327 327 def cards_visible_to(cls, user):
""" 328 328 """
:param user: 329 329 :param user:
:return: A queryset with all cards that should be visible to a user. 330 330 :return: A queryset with all cards that should be visible to a user.
""" 331 331 """
return cls.objects.filter(Q(author__confirmed_email=True) | Q(author=user) 332 332 return cls.objects.filter(Q(author__confirmed_email=True) | Q(author=user)
).exclude(Q(is_hidden=True) | Q(flashcardhide__user=user)) 333 333 ).exclude(Q(is_hidden=True) | Q(flashcardhide__user=user))
334 334
@classmethod 335 335 @classmethod
def cards_hidden_by(cls, user): 336 336 def cards_hidden_by(cls, user):
return cls.objects.filter(flashcardhide__user=user) 337 337 return cls.objects.filter(flashcardhide__user=user)
338 338
339 339
class UserFlashcardQuiz(Model): 340 340 class UserFlashcardQuiz(Model):
""" 341 341 """
An event of a user being quizzed on a flashcard. 342 342 An event of a user being quizzed on a flashcard.
""" 343 343 """
user_flashcard = ForeignKey(UserFlashcard) 344 344 user_flashcard = ForeignKey(UserFlashcard)
when = DateTimeField(auto_now=True) 345 345 when = DateTimeField(auto_now=True)
blanked_word = CharField(max_length=8, blank=True, help_text="The character range which was blanked") 346 346 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") 347 347 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") 348 348 correct = NullBooleanField(help_text="The user's self-evaluation of their response")
349 349
def __unicode__(self): 350 350 def __unicode__(self):
return '%s reviewed %s' % (str(self.user_flashcard.user), str(self.user_flashcard.flashcard)) 351 351 return '%s reviewed %s' % (str(self.user_flashcard.user), str(self.user_flashcard.flashcard))
352 352
def status(self): 353 353 def status(self):
""" 354 354 """
There are three stages of a quiz object: 355 355 There are three stages of a quiz object:
1. the user has been shown the card 356 356 1. the user has been shown the card
2. the user has answered the card 357 357 2. the user has answered the card
3. the user has self-evaluated their response's correctness 358 358 3. the user has self-evaluated their response's correctness
359 359
:return: string (evaluated, answered, viewed) 360 360 :return: string (evaluated, answered, viewed)
""" 361 361 """
if self.correct is not None: return "evaluated" 362 362 if self.correct is not None: return "evaluated"
if self.response: return "answered" 363 363 if self.response: return "answered"
return "viewed" 364 364 return "viewed"
365 365
366 366
class Section(Model): 367 367 class Section(Model):
""" 368 368 """
A UCSD course taught by an instructor during a quarter. 369 369 A UCSD course taught by an instructor during a quarter.
We use the term "section" to avoid collision with the builtin keyword "class" 370 370 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 371 371 We index gratuitously to support autofill and because this is primarily read-only
""" 372 372 """
department = CharField(db_index=True, max_length=50) 373 373 department = CharField(db_index=True, max_length=50)
department_abbreviation = CharField(db_index=True, max_length=10) 374 374 department_abbreviation = CharField(db_index=True, max_length=10)
course_num = CharField(db_index=True, max_length=6) 375 375 course_num = CharField(db_index=True, max_length=6)
course_title = CharField(db_index=True, max_length=50) 376 376 course_title = CharField(db_index=True, max_length=50)
instructor = CharField(db_index=True, max_length=100) 377 377 instructor = CharField(db_index=True, max_length=100)
quarter = CharField(db_index=True, max_length=4) 378 378 quarter = CharField(db_index=True, max_length=4)
379 PAGE_SIZE = 40
379 380
@classmethod 380 381 @classmethod
def search(cls, terms): 381 382 def search(cls, terms):
""" 382 383 """
Search all fields of all sections for a particular set of terms 383 384 Search all fields of all sections for a particular set of terms
A matching section must match at least one field on each term 384 385 A matching section must match at least one field on each term
:param terms:iterable 385 386 :param terms:iterable
:return: Matching QuerySet ordered by department and course number 386 387 :return: Matching QuerySet ordered by department and course number
""" 387 388 """
final_q = Q() 388 389 final_q = Q()
for term in terms: 389 390 for term in terms:
q = Q(department__icontains=term) 390 391 q = Q(department__icontains=term)
q |= Q(department_abbreviation__icontains=term) 391 392 q |= Q(department_abbreviation__icontains=term)
q |= Q(course_title__icontains=term) 392 393 q |= Q(course_title__icontains=term)
q |= Q(course_num__icontains=term) 393 394 q |= Q(course_num__icontains=term)
q |= Q(instructor__icontains=term) 394 395 q |= Q(instructor__icontains=term)
final_q &= q 395 396 final_q &= q
qs = cls.objects.filter(final_q) 396 397 qs = cls.objects.filter(final_q)
# Have the database cast the course number to an integer so it will sort properly 397 398 # 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 398 399 # 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)"}) 399 400 qs = qs.extra(select={'course_num_int': "CAST(rtrim(course_num, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') AS INTEGER)"})
qs = qs.order_by('department_abbreviation', 'course_num_int') 400 401 qs = qs.order_by('department_abbreviation', 'course_num_int')
return qs 401 402 return qs
402 403
@property 403 404 @property
def is_whitelisted(self): 404 405 def is_whitelisted(self):
""" 405 406 """
:return: whether a whitelist exists for this section 406 407 :return: whether a whitelist exists for this section
""" 407 408 """
return self.whitelist.exists() 408 409 return self.whitelist.exists()
409 410
def is_user_on_whitelist(self, user): 410 411 def is_user_on_whitelist(self, user):
""" 411 412 """
:return: whether the user is on the waitlist for this section 412 413 :return: whether the user is on the waitlist for this section
""" 413 414 """
return self.whitelist.filter(email=user.email).exists() 414 415 return self.whitelist.filter(email=user.email).exists()
415 416
def enroll(self, user): 416 417 def enroll(self, user):
if user.sections.filter(pk=self.pk).exists(): 417 418 if user.sections.filter(pk=self.pk).exists():
raise ValidationError('User is already enrolled in this section') 418 419 raise ValidationError('User is already enrolled in this section')
if self.is_whitelisted and not self.is_user_on_whitelist(user): 419 420 if self.is_whitelisted and not self.is_user_on_whitelist(user):
raise PermissionDenied("User must be on the whitelist to add this section.") 420 421 raise PermissionDenied("User must be on the whitelist to add this section.")
self.user_set.add(user) 421 422 self.user_set.add(user)
422 423
def drop(self, user): 423 424 def drop(self, user):
if not user.sections.filter(pk=self.pk).exists(): 424 425 if not user.sections.filter(pk=self.pk).exists():
raise ValidationError("User is not enrolled in the section.") 425 426 raise ValidationError("User is not enrolled in the section.")
self.user_set.remove(user) 426 427 self.user_set.remove(user)
427 428
class Meta: 428 429 class Meta:
ordering = ['department_abbreviation', 'course_num'] 429 430 ordering = ['department_abbreviation', 'course_num']
430 431
@property 431 432 @property
def lecture_times(self): 432 433 def lecture_times(self):
data = cache.get("section_%d_lecture_times" % self.pk) 433 434 data = cache.get("section_%d_lecture_times" % self.pk)
if not data: 434 435 if not data:
lecture_periods = self.lectureperiod_set.all() 435 436 lecture_periods = self.lectureperiod_set.all()
if lecture_periods.exists(): 436 437 if lecture_periods.exists():
data = ''.join(map(lambda x: x.weekday_letter, lecture_periods)) + ' ' + lecture_periods[ 437 438 data = ''.join(map(lambda x: x.weekday_letter, lecture_periods)) + ' ' + lecture_periods[
0].short_start_time 438 439 0].short_start_time
else: 439 440 else:
flashcards/serializers.py View file @ 901ee2f
from json import dumps, loads 1 1 from json import dumps, loads
2 2
from django.utils.datetime_safe import datetime 3 3 from django.utils.datetime_safe import datetime
from django.utils.timezone import now 4 4 from django.utils.timezone import now
from flashcards.models import Section, LecturePeriod, User, Flashcard, UserFlashcard, UserFlashcardQuiz 5 5 from flashcards.models import Section, LecturePeriod, User, Flashcard, UserFlashcard, UserFlashcardQuiz
from flashcards.validators import FlashcardMask, OverlapIntervalException 6 6 from flashcards.validators import FlashcardMask, OverlapIntervalException
from rest_framework import serializers 7 7 from rest_framework import serializers
from rest_framework.fields import EmailField, BooleanField, CharField, IntegerField, DateTimeField, empty, \ 8 8 from rest_framework.fields import EmailField, BooleanField, CharField, IntegerField, DateTimeField, empty, \
SerializerMethodField, FloatField 9 9 SerializerMethodField, FloatField
from rest_framework.serializers import ModelSerializer, Serializer, PrimaryKeyRelatedField, ListField 10 10 from rest_framework.serializers import ModelSerializer, Serializer, PrimaryKeyRelatedField, ListField
from rest_framework.validators import UniqueValidator 11 11 from rest_framework.validators import UniqueValidator
from flashy.settings import QUARTER_END, QUARTER_START 12 12 from flashy.settings import QUARTER_END, QUARTER_START
from collections import Iterable 13 13 from collections import Iterable
14 14
15 15
class EmailSerializer(Serializer): 16 16 class EmailSerializer(Serializer):
email = EmailField(required=True) 17 17 email = EmailField(required=True)
18 18
19 19
class EmailPasswordSerializer(EmailSerializer): 20 20 class EmailPasswordSerializer(EmailSerializer):
password = CharField(required=True) 21 21 password = CharField(required=True)
22 22
23 23
class RegistrationSerializer(EmailPasswordSerializer): 24 24 class RegistrationSerializer(EmailPasswordSerializer):
email = EmailField(required=True, validators=[UniqueValidator(queryset=User.objects.all())]) 25 25 email = EmailField(required=True, validators=[UniqueValidator(queryset=User.objects.all())])
26 26
27 27
class PasswordResetRequestSerializer(EmailSerializer): 28 28 class PasswordResetRequestSerializer(EmailSerializer):
def validate_email(self, value): 29 29 def validate_email(self, value):
try: 30 30 try:
User.objects.get(email=value) 31 31 User.objects.get(email=value)
return value 32 32 return value
except User.DoesNotExist: 33 33 except User.DoesNotExist:
raise serializers.ValidationError('No user exists with that email') 34 34 raise serializers.ValidationError('No user exists with that email')
35 35
36 36
class PasswordResetSerializer(Serializer): 37 37 class PasswordResetSerializer(Serializer):
new_password = CharField(required=True, allow_blank=False) 38 38 new_password = CharField(required=True, allow_blank=False)
uid = IntegerField(required=True) 39 39 uid = IntegerField(required=True)
token = CharField(required=True) 40 40 token = CharField(required=True)
41 41
def validate_uid(self, value): 42 42 def validate_uid(self, value):
try: 43 43 try:
User.objects.get(id=value) 44 44 User.objects.get(id=value)
return value 45 45 return value
except User.DoesNotExist: 46 46 except User.DoesNotExist:
raise serializers.ValidationError('Could not verify reset token') 47 47 raise serializers.ValidationError('Could not verify reset token')
48 48
49 49
class UserUpdateSerializer(Serializer): 50 50 class UserUpdateSerializer(Serializer):
old_password = CharField(required=False) 51 51 old_password = CharField(required=False)
new_password = CharField(required=False, allow_blank=False) 52 52 new_password = CharField(required=False, allow_blank=False)
confirmation_key = CharField(required=False) 53 53 confirmation_key = CharField(required=False)
# reset_token = CharField(required=False) 54 54 # reset_token = CharField(required=False)
55 55
def validate(self, data): 56 56 def validate(self, data):
if 'new_password' in data and 'old_password' not in data: 57 57 if 'new_password' in data and 'old_password' not in data:
raise serializers.ValidationError('old_password is required to set a new_password') 58 58 raise serializers.ValidationError('old_password is required to set a new_password')
return data 59 59 return data
60 60
61 61
class Password(Serializer): 62 62 class Password(Serializer):
email = EmailField(required=True) 63 63 email = EmailField(required=True)
password = CharField(required=True) 64 64 password = CharField(required=True)
65 65
66 66
class LecturePeriodSerializer(ModelSerializer): 67 67 class LecturePeriodSerializer(ModelSerializer):
class Meta: 68 68 class Meta:
model = LecturePeriod 69 69 model = LecturePeriod
exclude = 'id', 'section' 70 70 exclude = 'id', 'section'
71 71
72 72
class SectionSerializer(ModelSerializer): 73 73 class SectionSerializer(ModelSerializer):
lecture_times = CharField() 74 74 lecture_times = CharField()
short_name = CharField() 75 75 short_name = CharField()
long_name = CharField() 76 76 long_name = CharField()
77 77
class Meta: 78 78 class Meta:
model = Section 79 79 model = Section
80 80
81 81
class DeepSectionSerializer(SectionSerializer): 82 82 class DeepSectionSerializer(SectionSerializer):
lectures = LecturePeriodSerializer(source='lectureperiod_set', many=True, read_only=True) 83 83 lectures = LecturePeriodSerializer(source='lectureperiod_set', many=True, read_only=True)
84 84
85 85
86 class FeedRequestSerializer(Serializer):
87 page = IntegerField(min_value=1, default=1, required=False)
88
89 def validate(self, attrs):
90 if not isinstance(attrs['page'], int):
91 raise serializers.ValidationError("Invalid page number")
92 return attrs
93
94
class UserSerializer(ModelSerializer): 86 95 class UserSerializer(ModelSerializer):
email = EmailField(required=False) 87 96 email = EmailField(required=False)
sections = SectionSerializer(many=True) 88 97 sections = SectionSerializer(many=True)
is_confirmed = BooleanField() 89 98 is_confirmed = BooleanField()
90 99
class Meta: 91 100 class Meta:
model = User 92 101 model = User
fields = ("sections", "email", "is_confirmed", "last_login", "date_joined") 93 102 fields = ("sections", "email", "is_confirmed", "last_login", "date_joined")
94 103
95 104
class MaskFieldSerializer(serializers.Field): 96 105 class MaskFieldSerializer(serializers.Field):
default_error_messages = { 97 106 default_error_messages = {
'max_length': 'Ensure this field has no more than {max_length} characters.', 98 107 'max_length': 'Ensure this field has no more than {max_length} characters.',
'interval': 'Ensure this field has valid intervals.', 99 108 'interval': 'Ensure this field has valid intervals.',
'overlap': 'Ensure this field does not have overlapping intervals.' 100 109 'overlap': 'Ensure this field does not have overlapping intervals.'
} 101 110 }
102 111
def to_representation(self, value): 103 112 def to_representation(self, value):
return map(list, self._make_mask(value)) 104 113 return map(list, self._make_mask(value))
105 114
def to_internal_value(self, value): 106 115 def to_internal_value(self, value):
if not isinstance(value, list): 107 116 if not isinstance(value, list):
value = loads(value) 108 117 value = loads(value)
return self._make_mask(value) 109 118 return self._make_mask(value)
110 119
def _make_mask(self, data): 111 120 def _make_mask(self, data):
try: 112 121 try:
mask = FlashcardMask(data) 113 122 mask = FlashcardMask(data)
except ValueError: 114 123 except ValueError:
raise serializers.ValidationError("Invalid JSON for MaskField") 115 124 raise serializers.ValidationError("Invalid JSON for MaskField")
except TypeError: 116 125 except TypeError:
raise serializers.ValidationError("Invalid data for MaskField.") 117 126 raise serializers.ValidationError("Invalid data for MaskField.")
except OverlapIntervalException: 118 127 except OverlapIntervalException:
raise serializers.ValidationError("Invalid intervals for MaskField data.") 119 128 raise serializers.ValidationError("Invalid intervals for MaskField data.")
if len(mask) > 32: 120 129 if len(mask) > 32:
raise serializers.ValidationError("Too many intervals in the mask.") 121 130 raise serializers.ValidationError("Too many intervals in the mask.")
return mask 122 131 return mask
123 132
124 133
class FlashcardSerializer(ModelSerializer): 125 134 class FlashcardSerializer(ModelSerializer):
is_hidden = SerializerMethodField() 126 135 is_hidden = SerializerMethodField()
is_in_deck = SerializerMethodField() 127 136 is_in_deck = SerializerMethodField()
# hide_reason = CharField(read_only=True) 128 137 # hide_reason = CharField(read_only=True)
material_week_num = IntegerField(read_only=True) 129 138 material_week_num = IntegerField(read_only=True)
material_date = DateTimeField(default=now) 130 139 material_date = DateTimeField(default=now)
mask = MaskFieldSerializer(allow_null=True) 131 140 mask = MaskFieldSerializer(allow_null=True)
score = FloatField(read_only=True) 132 141 score = FloatField(read_only=True)
133 142
def validate_material_date(self, value): 134 143 def validate_material_date(self, value):
# TODO: make this dynamic 135 144 # TODO: make this dynamic
if QUARTER_START <= value <= QUARTER_END: 136 145 if QUARTER_START <= value <= QUARTER_END:
return value 137 146 return value
else: 138 147 else:
raise serializers.ValidationError("Material date is outside allowed range for this quarter") 139 148 raise serializers.ValidationError("Material date is outside allowed range for this quarter")
140 149
def validate_pushed(self, value): 141 150 def validate_pushed(self, value):
if value > datetime.now(): 142 151 if value > datetime.now():
raise serializers.ValidationError("Invalid creation date for the Flashcard") 143 152 raise serializers.ValidationError("Invalid creation date for the Flashcard")
return value 144 153 return value
145 154
def validate_mask(self, value): 146 155 def validate_mask(self, value):
if value is None: 147 156 if value is None:
return None 148 157 return None
if len(self.initial_data['text']) < value.max_offset(): 149 158 if len(self.initial_data['text']) < value.max_offset():
raise serializers.ValidationError("Mask out of bounds") 150 159 raise serializers.ValidationError("Mask out of bounds")
return value 151 160 return value
152 161
def get_is_hidden(self, obj): 153 162 def get_is_hidden(self, obj):
if 'user' not in self.context: return False 154 163 if 'user' not in self.context: return False
return obj.is_hidden_from(self.context['user']) 155 164 return obj.is_hidden_from(self.context['user'])
156 165
def get_is_in_deck(self, obj): 157 166 def get_is_in_deck(self, obj):
if 'user' not in self.context: return False 158 167 if 'user' not in self.context: return False
return obj.is_in_deck(self.context['user']) 159 168 return obj.is_in_deck(self.context['user'])
160 169
class Meta: 161 170 class Meta:
model = Flashcard 162 171 model = Flashcard
exclude = 'author', 'previous' 163 172 exclude = 'author', 'previous'
164 173
165 174
class FlashcardUpdateSerializer(serializers.Serializer): 166 175 class FlashcardUpdateSerializer(serializers.Serializer):
text = CharField(max_length=255, required=False) 167 176 text = CharField(max_length=255, required=False)
material_date = DateTimeField(required=False) 168 177 material_date = DateTimeField(required=False)
mask = MaskFieldSerializer(required=False) 169 178 mask = MaskFieldSerializer(required=False)
170 179
def validate_material_date(self, date): 171 180 def validate_material_date(self, date):
if date > QUARTER_END: 172 181 if date > QUARTER_END:
raise serializers.ValidationError("Invalid material_date for the flashcard") 173 182 raise serializers.ValidationError("Invalid material_date for the flashcard")
return date 174 183 return date
175 184
def validate(self, attrs): 176 185 def validate(self, attrs):
# Make sure that at least one of the attributes was passed in 177 186 # Make sure that at least one of the attributes was passed in
if not any(i in attrs for i in ['material_date', 'text', 'mask']): 178 187 if not any(i in attrs for i in ['material_date', 'text', 'mask']):
raise serializers.ValidationError("No new value passed in") 179 188 raise serializers.ValidationError("No new value passed in")
return attrs 180 189 return attrs
181 190
182 191
class QuizRequestSerializer(serializers.Serializer): 183 192 class QuizRequestSerializer(serializers.Serializer):
sections = ListField(child=IntegerField(min_value=1), required=False) 184 193 sections = ListField(child=IntegerField(min_value=1), required=False)
material_date_begin = DateTimeField(default=QUARTER_START) 185 194 material_date_begin = DateTimeField(default=QUARTER_START)
material_date_end = DateTimeField(default=QUARTER_END) 186 195 material_date_end = DateTimeField(default=QUARTER_END)
187 196
def update(self, instance, validated_data): 188 197 def update(self, instance, validated_data):
pass 189 198 pass
190 199
def create(self, validated_data): 191 200 def create(self, validated_data):
return validated_data 192 201 return validated_data
193 202
def validate_material_date_begin(self, value): 194 203 def validate_material_date_begin(self, value):
if QUARTER_START <= value <= QUARTER_END: 195 204 if QUARTER_START <= value <= QUARTER_END:
return value 196 205 return value
raise serializers.ValidationError("Invalid begin date for the flashcard range") 197 206 raise serializers.ValidationError("Invalid begin date for the flashcard range")
198 207
def validate_material_date_end(self, value): 199 208 def validate_material_date_end(self, value):
if QUARTER_START <= value <= QUARTER_END: 200 209 if QUARTER_START <= value <= QUARTER_END:
return value 201 210 return value
raise serializers.ValidationError("Invalid end date for the flashcard range") 202 211 raise serializers.ValidationError("Invalid end date for the flashcard range")
203 212
def validate_sections(self, value): 204 213 def validate_sections(self, value):
if value is not None and not isinstance(value, Iterable): 205 214 if value is not None and not isinstance(value, Iterable):
raise serializers.ValidationError("Invalid section format. Expecting a list or no value.") 206 215 raise serializers.ValidationError("Invalid section format. Expecting a list or no value.")
if value is None or len(value) == 0: 207 216 if value is None or len(value) == 0:
return Section.objects.all() 208 217 return Section.objects.all()
section_filter = Section.objects.filter(pk__in=value) 209 218 section_filter = Section.objects.filter(pk__in=value)
if not section_filter.exists(): 210 219 if not section_filter.exists():
raise serializers.ValidationError("Those aren't valid sections") 211 220 raise serializers.ValidationError("Those aren't valid sections")
return value 212 221 return value
213 222
def validate(self, attrs): 214 223 def validate(self, attrs):
if attrs['material_date_begin'] > attrs['material_date_end']: 215 224 if attrs['material_date_begin'] > attrs['material_date_end']:
raise serializers.ValidationError("Invalid range") 216 225 raise serializers.ValidationError("Invalid range")
flashcards/views.py View file @ 901ee2f
import django 1 1 import django
from django.contrib import auth 2 2 from django.contrib import auth
from django.shortcuts import get_object_or_404 3 3 from django.shortcuts import get_object_or_404
from django.utils.log import getLogger 4 4 from django.utils.log import getLogger
from flashcards.api import StandardResultsSetPagination, IsEnrolledInAssociatedSection, IsFlashcardReviewer, \ 5 5 from flashcards.api import StandardResultsSetPagination, IsEnrolledInAssociatedSection, IsFlashcardReviewer, \
IsAuthenticatedAndConfirmed 6 6 IsAuthenticatedAndConfirmed
from flashcards.models import Section, User, Flashcard, FlashcardHide, UserFlashcard, UserFlashcardQuiz, \ 7 7 from flashcards.models import Section, User, Flashcard, FlashcardHide, UserFlashcard, UserFlashcardQuiz, \
FlashcardAlreadyPulledException, FlashcardNotInDeckException, Now, interval_days 8 8 FlashcardAlreadyPulledException, FlashcardNotInDeckException, Now, interval_days
from flashcards.notifications import notify_new_card 9 9 from flashcards.notifications import notify_new_card
from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \ 10 10 from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \
PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer, \ 11 11 PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer, \
FlashcardUpdateSerializer, QuizRequestSerializer, QuizResponseSerializer, \ 12 12 FlashcardUpdateSerializer, QuizRequestSerializer, QuizResponseSerializer, \
QuizAnswerRequestSerializer, DeepSectionSerializer 13 13 QuizAnswerRequestSerializer, DeepSectionSerializer, FeedRequestSerializer
from rest_framework.decorators import detail_route, permission_classes, api_view, list_route 14 14 from rest_framework.decorators import detail_route, permission_classes, api_view, list_route
from rest_framework.generics import ListAPIView, GenericAPIView 15 15 from rest_framework.generics import ListAPIView, GenericAPIView
from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin 16 16 from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin
from rest_framework.permissions import IsAuthenticated 17 17 from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ReadOnlyModelViewSet, GenericViewSet 18 18 from rest_framework.viewsets import ReadOnlyModelViewSet, GenericViewSet
from django.core.mail import send_mail 19 19 from django.core.mail import send_mail
from django.contrib.auth import authenticate 20 20 from django.contrib.auth import authenticate
from django.contrib.auth.tokens import default_token_generator 21 21 from django.contrib.auth.tokens import default_token_generator
from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED, HTTP_200_OK 22 22 from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED, HTTP_200_OK
from rest_framework.response import Response 23 23 from rest_framework.response import Response
from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied 24 24 from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied
from simple_email_confirmation import EmailAddress 25 25 from simple_email_confirmation import EmailAddress
26 26
27 27
def log_event(request, event=''): 28 28 def log_event(request, event=''):
getLogger('flashy.events').info( 29 29 getLogger('flashy.events').info(
'%s %s %s %s' % (request.META['REMOTE_ADDR'], str(request.user), request.path, event)) 30 30 '%s %s %s %s' % (request.META['REMOTE_ADDR'], str(request.user), request.path, event))
31 31
32 32
class SectionViewSet(ReadOnlyModelViewSet): 33 33 class SectionViewSet(ReadOnlyModelViewSet):
queryset = Section.objects.all() 34 34 queryset = Section.objects.all()
serializer_class = DeepSectionSerializer 35 35 serializer_class = DeepSectionSerializer
pagination_class = StandardResultsSetPagination 36 36 pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticatedAndConfirmed] 37 37 permission_classes = [IsAuthenticatedAndConfirmed]
38 38
@detail_route(methods=['GET']) 39 39 @detail_route(methods=['GET'])
def flashcards(self, request, pk): 40 40 def flashcards(self, request, pk):
""" 41 41 """
Gets flashcards for a section, excluding hidden cards. 42 42 Gets flashcards for a section, excluding hidden cards.
Returned in strictly chronological order (material date). 43 43 Returned in strictly chronological order (material date).
""" 44 44 """
flashcards = Flashcard.cards_visible_to(request.user) 45 45 flashcards = Flashcard.cards_visible_to(request.user)
if 'hidden' in request.GET: 46 46 if 'hidden' in request.GET:
if request.GET['hidden'] == 'only': 47 47 if request.GET['hidden'] == 'only':
flashcards = Flashcard.cards_hidden_by(request.user) 48 48 flashcards = Flashcard.cards_hidden_by(request.user)
else: 49 49 else:
flashcards |= Flashcard.cards_hidden_by(request.user) 50 50 flashcards |= Flashcard.cards_hidden_by(request.user)
flashcards = flashcards.filter(section=self.get_object()).order_by('material_date').all() 51 51 flashcards = flashcards.filter(section=self.get_object()).order_by('material_date').all()
log_event(request, str(self.get_object())) 52 52 log_event(request, str(self.get_object()))
return Response(FlashcardSerializer(flashcards, context={"user": request.user}, many=True).data) 53 53 return Response(FlashcardSerializer(flashcards, context={"user": request.user}, many=True).data)
54 54
@detail_route(methods=['POST']) 55 55 @detail_route(methods=['POST'])
def enroll(self, request, pk): 56 56 def enroll(self, request, pk):
57 57
""" 58 58 """
Add the current user to a specified section 59 59 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. 60 60 If the class has a whitelist, but the user is not on the whitelist, the request will fail.
--- 61 61 ---
view_mocker: flashcards.api.mock_no_params 62 62 view_mocker: flashcards.api.mock_no_params
""" 63 63 """
try: 64 64 try:
self.get_object().enroll(request.user) 65 65 self.get_object().enroll(request.user)
log_event(request, str(self.get_object())) 66 66 log_event(request, str(self.get_object()))
except django.core.exceptions.PermissionDenied as e: 67 67 except django.core.exceptions.PermissionDenied as e:
raise PermissionDenied(e) 68 68 raise PermissionDenied(e)
except django.core.exceptions.ValidationError as e: 69 69 except django.core.exceptions.ValidationError as e:
raise ValidationError(e) 70 70 raise ValidationError(e)
return Response(status=HTTP_204_NO_CONTENT) 71 71 return Response(status=HTTP_204_NO_CONTENT)
72 72
@detail_route(methods=['POST']) 73 73 @detail_route(methods=['POST'])
def drop(self, request, pk): 74 74 def drop(self, request, pk):
""" 75 75 """
Remove the current user from a specified section 76 76 Remove the current user from a specified section
If the user is not in the class, the request will fail. 77 77 If the user is not in the class, the request will fail.
--- 78 78 ---
view_mocker: flashcards.api.mock_no_params 79 79 view_mocker: flashcards.api.mock_no_params
""" 80 80 """
try: 81 81 try:
self.get_object().drop(request.user) 82 82 self.get_object().drop(request.user)
log_event(request, str(self.get_object())) 83 83 log_event(request, str(self.get_object()))
except django.core.exceptions.PermissionDenied as e: 84 84 except django.core.exceptions.PermissionDenied as e:
raise PermissionDenied(e) 85 85 raise PermissionDenied(e)
except django.core.exceptions.ValidationError as e: 86 86 except django.core.exceptions.ValidationError as e:
raise ValidationError(e) 87 87 raise ValidationError(e)
return Response(status=HTTP_204_NO_CONTENT) 88 88 return Response(status=HTTP_204_NO_CONTENT)
89 89
@list_route(methods=['GET']) 90 90 @list_route(methods=['GET'])
def search(self, request): 91 91 def search(self, request):
""" 92 92 """
Returns a list of sections which match a user's query 93 93 Returns a list of sections which match a user's query
--- 94 94 ---
parameters: 95 95 parameters:
- name: q 96 96 - name: q
description: space-separated list of terms 97 97 description: space-separated list of terms
required: true 98 98 required: true
type: form 99 99 type: form
response_serializer: SectionSerializer 100 100 response_serializer: SectionSerializer
""" 101 101 """
query = request.GET.get('q', None) 102 102 query = request.GET.get('q', None)
if not query: return Response('[]') 103 103 if not query: return Response('[]')
qs = Section.search(query.split(' '))[:20] 104 104 qs = Section.search(query.split(' '))[:20]
data = SectionSerializer(qs, many=True).data 105 105 data = SectionSerializer(qs, many=True).data
log_event(request, query) 106 106 log_event(request, query)
return Response(data) 107 107 return Response(data)
108 108
@detail_route(methods=['GET']) 109 109 @detail_route(methods=['GET'])
def deck(self, request, pk): 110 110 def deck(self, request, pk):
""" 111 111 """
Gets the contents of a user's deck for a given section. 112 112 Gets the contents of a user's deck for a given section.
""" 113 113 """
qs = request.user.get_deck(self.get_object()) 114 114 qs = request.user.get_deck(self.get_object())
serializer = FlashcardSerializer(qs, many=True) 115 115 serializer = FlashcardSerializer(qs, many=True)
log_event(request, str(self.get_object())) 116 116 log_event(request, str(self.get_object()))
return Response(serializer.data) 117 117 return Response(serializer.data)
118 118
@detail_route(methods=['GET']) 119 119 @detail_route(methods=['GET'])
def feed(self, request, pk): 120 120 def feed(self, request, pk):
""" 121 121 """
Gets the contents of a user's feed for a section. 122 122 Gets the contents of a user's feed for a section.
Exclude cards that are already in the user's deck 123 123 Exclude cards that are already in the user's deck
124 request_serializer: FeedRequestSerializer
125 response_serializer: FlashcardSerializer
""" 124 126 """
serializer = FlashcardSerializer(self.get_object().get_feed_for_user(request.user), many=True, 125 127 feed_serializer = FeedRequestSerializer(data=request.data)
context={'user': request.user}) 126 128 feed_serializer.is_valid(raise_exception=True)
129 page = feed_serializer.validated_data['page']
130 serializer = FlashcardSerializer(self.get_object().get_feed_for_user(request.user, page=page),
131 many=True, context={'user': request.user})
log_event(request, str(self.get_object())) 127 132 log_event(request, str(self.get_object()))
return Response(serializer.data) 128 133 return Response(serializer.data)
129 134
130 135
class UserSectionListView(ListAPIView): 131 136 class UserSectionListView(ListAPIView):
serializer_class = DeepSectionSerializer 132 137 serializer_class = DeepSectionSerializer
permission_classes = [IsAuthenticatedAndConfirmed] 133 138 permission_classes = [IsAuthenticatedAndConfirmed]
134 139
def get_queryset(self): 135 140 def get_queryset(self):
return self.request.user.sections.all() 136 141 return self.request.user.sections.all()
137 142
def paginate_queryset(self, queryset): return None 138 143 def paginate_queryset(self, queryset): return None
139 144
140 145
class UserDetail(GenericAPIView): 141 146 class UserDetail(GenericAPIView):
serializer_class = UserSerializer 142 147 serializer_class = UserSerializer
permission_classes = [IsAuthenticatedAndConfirmed] 143 148 permission_classes = [IsAuthenticatedAndConfirmed]
144 149
def patch(self, request, format=None): 145 150 def patch(self, request, format=None):
""" 146 151 """
Updates the user's password, or verifies their email address 147 152 Updates the user's password, or verifies their email address
--- 148 153 ---
request_serializer: UserUpdateSerializer 149 154 request_serializer: UserUpdateSerializer
response_serializer: UserSerializer 150 155 response_serializer: UserSerializer
""" 151 156 """
data = UserUpdateSerializer(data=request.data, context={'user': request.user}) 152 157 data = UserUpdateSerializer(data=request.data, context={'user': request.user})
data.is_valid(raise_exception=True) 153 158 data.is_valid(raise_exception=True)
data = data.validated_data 154 159 data = data.validated_data
155 160
if 'new_password' in data: 156 161 if 'new_password' in data:
if not request.user.check_password(data['old_password']): 157 162 if not request.user.check_password(data['old_password']):
raise ValidationError('old_password is incorrect') 158 163 raise ValidationError('old_password is incorrect')
request.user.set_password(data['new_password']) 159 164 request.user.set_password(data['new_password'])
request.user.save() 160 165 request.user.save()
log_event(request, 'change password') 161 166 log_event(request, 'change password')
162 167
if 'confirmation_key' in data: 163 168 if 'confirmation_key' in data:
try: 164 169 try:
request.user.confirm_email(data['confirmation_key']) 165 170 request.user.confirm_email(data['confirmation_key'])
log_event(request, 'confirm email') 166 171 log_event(request, 'confirm email')
except EmailAddress.DoesNotExist: 167 172 except EmailAddress.DoesNotExist:
raise ValidationError('confirmation_key is invalid') 168 173 raise ValidationError('confirmation_key is invalid')
169 174
return Response(UserSerializer(request.user).data) 170 175 return Response(UserSerializer(request.user).data)
171 176
def get(self, request, format=None): 172 177 def get(self, request, format=None):
""" 173 178 """
Return data about the user 174 179 Return data about the user
--- 175 180 ---
response_serializer: UserSerializer 176 181 response_serializer: UserSerializer
""" 177 182 """
serializer = UserSerializer(request.user, context={'request': request}) 178 183 serializer = UserSerializer(request.user, context={'request': request})
return Response(serializer.data) 179 184 return Response(serializer.data)
180 185
def delete(self, request): 181 186 def delete(self, request):
""" 182 187 """
Irrevocably delete the user and their data 183 188 Irrevocably delete the user and their data
184 189
Yes, really 185 190 Yes, really
""" 186 191 """
request.user.delete() 187 192 request.user.delete()
log_event(request) 188 193 log_event(request)
return Response(status=HTTP_204_NO_CONTENT) 189 194 return Response(status=HTTP_204_NO_CONTENT)
190 195
191 196
@api_view(['POST']) 192 197 @api_view(['POST'])
def register(request, format=None): 193 198 def register(request, format=None):
""" 194 199 """
Register a new user 195 200 Register a new user
--- 196 201 ---
request_serializer: EmailPasswordSerializer 197 202 request_serializer: EmailPasswordSerializer
response_serializer: UserSerializer 198 203 response_serializer: UserSerializer
""" 199 204 """
data = RegistrationSerializer(data=request.data) 200 205 data = RegistrationSerializer(data=request.data)
data.is_valid(raise_exception=True) 201 206 data.is_valid(raise_exception=True)
202 207
User.objects.create_user(**data.validated_data) 203 208 User.objects.create_user(**data.validated_data)
user = authenticate(**data.validated_data) 204 209 user = authenticate(**data.validated_data)
auth.login(request, user) 205 210 auth.login(request, user)
log_event(request) 206 211 log_event(request)
return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED) 207 212 return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED)
208 213
209 214
@api_view(['POST']) 210 215 @api_view(['POST'])
def login(request): 211 216 def login(request):
""" 212 217 """
Authenticates user and returns user data if valid. 213 218 Authenticates user and returns user data if valid.
--- 214 219 ---
request_serializer: EmailPasswordSerializer 215 220 request_serializer: EmailPasswordSerializer
response_serializer: UserSerializer 216 221 response_serializer: UserSerializer
""" 217 222 """
218 223
data = EmailPasswordSerializer(data=request.data) 219 224 data = EmailPasswordSerializer(data=request.data)
data.is_valid(raise_exception=True) 220 225 data.is_valid(raise_exception=True)
user = authenticate(**data.validated_data) 221 226 user = authenticate(**data.validated_data)
222 227
if user is None: 223 228 if user is None:
raise AuthenticationFailed('Invalid email or password') 224 229 raise AuthenticationFailed('Invalid email or password')
if not user.is_active: 225 230 if not user.is_active:
raise NotAuthenticated('Account is disabled') 226 231 raise NotAuthenticated('Account is disabled')
auth.login(request, user) 227 232 auth.login(request, user)
log_event(request) 228 233 log_event(request)
return Response(UserSerializer(request.user).data) 229 234 return Response(UserSerializer(request.user).data)
230 235
231 236
@api_view(['POST']) 232 237 @api_view(['POST'])
@permission_classes((IsAuthenticated,)) 233 238 @permission_classes((IsAuthenticated,))
def logout(request, format=None): 234 239 def logout(request, format=None):
""" 235 240 """
Logs the authenticated user out. 236 241 Logs the authenticated user out.
""" 237 242 """
auth.logout(request) 238 243 auth.logout(request)
log_event(request) 239 244 log_event(request)
return Response(status=HTTP_204_NO_CONTENT) 240 245 return Response(status=HTTP_204_NO_CONTENT)
241 246
242 247
@api_view(['POST']) 243 248 @api_view(['POST'])
def request_password_reset(request, format=None): 244 249 def request_password_reset(request, format=None):
""" 245 250 """
Send a password reset token/link to the provided email. 246 251 Send a password reset token/link to the provided email.
--- 247 252 ---
request_serializer: PasswordResetRequestSerializer 248 253 request_serializer: PasswordResetRequestSerializer
""" 249 254 """
data = PasswordResetRequestSerializer(data=request.data) 250 255 data = PasswordResetRequestSerializer(data=request.data)
data.is_valid(raise_exception=True) 251 256 data.is_valid(raise_exception=True)
log_event(request, 'email: ' + str(data['email'])) 252 257 log_event(request, 'email: ' + str(data['email']))
get_object_or_404(User, email=data['email'].value).request_password_reset() 253 258 get_object_or_404(User, email=data['email'].value).request_password_reset()
return Response(status=HTTP_204_NO_CONTENT) 254 259 return Response(status=HTTP_204_NO_CONTENT)
255 260
256 261
@api_view(['POST']) 257 262 @api_view(['POST'])
def reset_password(request, format=None): 258 263 def reset_password(request, format=None):
""" 259 264 """
Updates user's password to new password if token is valid. 260 265 Updates user's password to new password if token is valid.
--- 261 266 ---
request_serializer: PasswordResetSerializer 262 267 request_serializer: PasswordResetSerializer
""" 263 268 """
data = PasswordResetSerializer(data=request.data) 264 269 data = PasswordResetSerializer(data=request.data)
data.is_valid(raise_exception=True) 265 270 data.is_valid(raise_exception=True)
266 271
user = User.objects.get(id=data['uid'].value) 267 272 user = User.objects.get(id=data['uid'].value)
# Check token validity. 268 273 # Check token validity.
269 274
if default_token_generator.check_token(user, data['token'].value): 270 275 if default_token_generator.check_token(user, data['token'].value):
user.set_password(data['new_password'].value) 271 276 user.set_password(data['new_password'].value)
user.save() 272 277 user.save()
log_event(request) 273 278 log_event(request)
else: 274 279 else:
raise ValidationError('Could not verify reset token') 275 280 raise ValidationError('Could not verify reset token')
return Response(status=HTTP_204_NO_CONTENT) 276 281 return Response(status=HTTP_204_NO_CONTENT)
277 282
278 283
class FlashcardViewSet(GenericViewSet, CreateModelMixin, RetrieveModelMixin): 279 284 class FlashcardViewSet(GenericViewSet, CreateModelMixin, RetrieveModelMixin):
queryset = Flashcard.objects.all() 280 285 queryset = Flashcard.objects.all()
serializer_class = FlashcardSerializer 281 286 serializer_class = FlashcardSerializer
permission_classes = [IsAuthenticatedAndConfirmed, IsEnrolledInAssociatedSection] 282 287 permission_classes = [IsAuthenticatedAndConfirmed, IsEnrolledInAssociatedSection]
283 288
# Override create in CreateModelMixin 284 289 # Override create in CreateModelMixin
def create(self, request, *args, **kwargs): 285 290 def create(self, request, *args, **kwargs):
serializer = FlashcardSerializer(data=request.data) 286 291 serializer = FlashcardSerializer(data=request.data)
serializer.is_valid(raise_exception=True) 287 292 serializer.is_valid(raise_exception=True)
data = serializer.validated_data 288 293 data = serializer.validated_data
if not request.user.is_in_section(data['section']): 289 294 if not request.user.is_in_section(data['section']):
raise PermissionDenied('The user is not enrolled in that section') 290 295 raise PermissionDenied('The user is not enrolled in that section')
data['author'] = request.user 291 296 data['author'] = request.user
flashcard = Flashcard.objects.create(**data) 292 297 flashcard = Flashcard.objects.create(**data)
self.perform_create(flashcard) 293 298 self.perform_create(flashcard)
notify_new_card(flashcard) 294 299 notify_new_card(flashcard)
headers = self.get_success_headers(data) 295 300 headers = self.get_success_headers(data)
request.user.pull(flashcard) 296 301 request.user.pull(flashcard)
response_data = FlashcardSerializer(flashcard).data 297 302 response_data = FlashcardSerializer(flashcard).data
log_event(request, response_data) 298 303 log_event(request, response_data)
return Response(response_data, status=HTTP_201_CREATED, headers=headers) 299 304 return Response(response_data, status=HTTP_201_CREATED, headers=headers)
300 305
@detail_route(methods=['POST']) 301 306 @detail_route(methods=['POST'])
def unhide(self, request, pk): 302 307 def unhide(self, request, pk):
""" 303 308 """
Unhide the given card 304 309 Unhide the given card
--- 305 310 ---
view_mocker: flashcards.api.mock_no_params 306 311 view_mocker: flashcards.api.mock_no_params
""" 307 312 """
hide = get_object_or_404(FlashcardHide, user=request.user, flashcard=self.get_object()) 308 313 hide = get_object_or_404(FlashcardHide, user=request.user, flashcard=self.get_object())
hide.delete() 309 314 hide.delete()
log_event(request, str(self.get_object())) 310 315 log_event(request, str(self.get_object()))
return Response(status=HTTP_204_NO_CONTENT) 311 316 return Response(status=HTTP_204_NO_CONTENT)
312 317
@detail_route(methods=['POST']) 313 318 @detail_route(methods=['POST'])
def report(self, request, pk): 314 319 def report(self, request, pk):
""" 315 320 """
Hide the given card 316 321 Hide the given card
--- 317 322 ---
view_mocker: flashcards.api.mock_no_params 318 323 view_mocker: flashcards.api.mock_no_params
""" 319 324 """
self.get_object().report(request.user) 320 325 self.get_object().report(request.user)
log_event(request, str(self.get_object())) 321 326 log_event(request, str(self.get_object()))
return Response(status=HTTP_204_NO_CONTENT) 322 327 return Response(status=HTTP_204_NO_CONTENT)
323 328
hide = report 324 329 hide = report
325 330
@detail_route(methods=['POST']) 326 331 @detail_route(methods=['POST'])
def pull(self, request, pk): 327 332 def pull(self, request, pk):
""" 328 333 """
Pull a card from the live feed into the user's deck. 329 334 Pull a card from the live feed into the user's deck.
--- 330 335 ---
view_mocker: flashcards.api.mock_no_params 331 336 view_mocker: flashcards.api.mock_no_params
""" 332 337 """
try: 333 338 try:
request.user.pull(self.get_object()) 334 339 request.user.pull(self.get_object())
log_event(request, str(self.get_object())) 335 340 log_event(request, str(self.get_object()))
return Response(status=HTTP_204_NO_CONTENT) 336 341 return Response(status=HTTP_204_NO_CONTENT)
except FlashcardAlreadyPulledException: 337 342 except FlashcardAlreadyPulledException:
raise ValidationError('Cannot pull a card already in deck') 338 343 raise ValidationError('Cannot pull a card already in deck')
339 344
@detail_route(methods=['POST']) 340 345 @detail_route(methods=['POST'])
def unpull(self, request, pk): 341 346 def unpull(self, request, pk):
""" 342 347 """
Unpull a card from the user's deck 343 348 Unpull a card from the user's deck
--- 344 349 ---
view_mocker: flashcards.api.mock_no_params 345 350 view_mocker: flashcards.api.mock_no_params
""" 346 351 """
user = request.user 347 352 user = request.user
flashcard = self.get_object() 348 353 flashcard = self.get_object()
try: 349 354 try:
user.unpull(flashcard) 350 355 user.unpull(flashcard)
log_event(request, str(self.get_object())) 351 356 log_event(request, str(self.get_object()))
return Response(status=HTTP_204_NO_CONTENT) 352 357 return Response(status=HTTP_204_NO_CONTENT)
except FlashcardNotInDeckException: 353 358 except FlashcardNotInDeckException:
raise ValidationError('Cannot unpull a card not in deck') 354 359 raise ValidationError('Cannot unpull a card not in deck')
355
356 360
def partial_update(self, request, *args, **kwargs): 357 361 def partial_update(self, request, *args, **kwargs):
""" 358 362 """
Edit settings related to a card for the user. 359 363 Edit settings related to a card for the user.
--- 360 364 ---
request_serializer: FlashcardUpdateSerializer 361 365 request_serializer: FlashcardUpdateSerializer
""" 362 366 """
user = request.user 363 367 user = request.user
flashcard = self.get_object() 364 368 flashcard = self.get_object()
data = FlashcardUpdateSerializer(data=request.data) 365 369 data = FlashcardUpdateSerializer(data=request.data)
data.is_valid(raise_exception=True) 366 370 data.is_valid(raise_exception=True)
new_flashcard = data.validated_data 367 371 new_flashcard = data.validated_data