Commit c72fc64b8b0ab012145888678b2e431d1093dfea

Authored by Andrew Buss
1 parent 4e105f55eb
Exists in master

allow a user to hide a card twice - the second reason overwrites the original

Showing 1 changed file with 2 additions and 1 deletions Inline Diff

flashcards/models.py View file @ c72fc64
from math import log1p 1 1 from math import log1p
from math import exp 2 2 from math import exp
from datetime import timedelta 3 3 from datetime import timedelta
4 4
from gcm import GCM 5 5 from gcm import GCM
from django.contrib.auth.models import AbstractUser, UserManager 6 6 from django.contrib.auth.models import AbstractUser, UserManager
from django.contrib.auth.tokens import default_token_generator 7 7 from django.contrib.auth.tokens import default_token_generator
from django.core.cache import cache 8 8 from django.core.cache import cache
from django.core.exceptions import ValidationError 9 9 from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied 10 10 from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail 11 11 from django.core.mail import send_mail
from django.core.validators import MinLengthValidator 12 12 from django.core.validators import MinLengthValidator
from django.db import IntegrityError 13 13 from django.db import IntegrityError
from django.db.models import * 14 14 from django.db.models import *
from django.utils.timezone import now, make_aware 15 15 from django.utils.timezone import now, make_aware
from flashy.settings import QUARTER_START, ABSOLUTE_URL_ROOT, IN_PRODUCTION, GCM_API_KEY 16 16 from flashy.settings import QUARTER_START, ABSOLUTE_URL_ROOT, IN_PRODUCTION, GCM_API_KEY
from simple_email_confirmation import SimpleEmailConfirmationUserMixin, EmailAddress 17 17 from simple_email_confirmation import SimpleEmailConfirmationUserMixin, EmailAddress
from fields import MaskField 18 18 from fields import MaskField
from cached_property import cached_property 19 19 from cached_property import cached_property
20 20
# Hack to fix AbstractUser before subclassing it 21 21 # Hack to fix AbstractUser before subclassing it
22 22
AbstractUser._meta.get_field('email')._unique = True 23 23 AbstractUser._meta.get_field('email')._unique = True
AbstractUser._meta.get_field('username')._unique = False 24 24 AbstractUser._meta.get_field('username')._unique = False
25 25
26 26
class EmailOnlyUserManager(UserManager): 27 27 class EmailOnlyUserManager(UserManager):
""" 28 28 """
A tiny extension of Django's UserManager which correctly creates users 29 29 A tiny extension of Django's UserManager which correctly creates users
without usernames (using emails instead). 30 30 without usernames (using emails instead).
""" 31 31 """
32 32
def _create_user(self, email, password, is_staff, is_superuser, **extra_fields): 33 33 def _create_user(self, email, password, is_staff, is_superuser, **extra_fields):
""" 34 34 """
Creates and saves a User with the given email and password. 35 35 Creates and saves a User with the given email and password.
""" 36 36 """
email = self.normalize_email(email) 37 37 email = self.normalize_email(email)
user = self.model(email=email, 38 38 user = self.model(email=email,
is_staff=is_staff, is_active=True, 39 39 is_staff=is_staff, is_active=True,
is_superuser=is_superuser, 40 40 is_superuser=is_superuser,
date_joined=now(), **extra_fields) 41 41 date_joined=now(), **extra_fields)
user.set_password(password) 42 42 user.set_password(password)
user.save(using=self._db) 43 43 user.save(using=self._db)
user.send_confirmation_email() 44 44 user.send_confirmation_email()
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)
49 49
return user 50 50 return user
51 51
def create_superuser(self, email, password, **extra_fields): 52 52 def create_superuser(self, email, password, **extra_fields):
return self._create_user(email, password, True, True, **extra_fields) 53 53 return self._create_user(email, password, True, True, **extra_fields)
54 54
55 55
class FlashcardAlreadyPulledException(Exception): 56 56 class FlashcardAlreadyPulledException(Exception):
pass 57 57 pass
58 58
59 59
class FlashcardNotInDeckException(Exception): 60 60 class FlashcardNotInDeckException(Exception):
pass 61 61 pass
62 62
63 63
class FlashcardNotHiddenException(Exception): 64 64 class FlashcardNotHiddenException(Exception):
pass 65 65 pass
66 66
67 67
class FlashcardAlreadyHiddenException(Exception): 68 68 class FlashcardAlreadyHiddenException(Exception):
pass 69 69 pass
70 70
71 71
class User(AbstractUser, SimpleEmailConfirmationUserMixin): 72 72 class User(AbstractUser, SimpleEmailConfirmationUserMixin):
""" 73 73 """
An extension of Django's default user model. 74 74 An extension of Django's default user model.
We use email as the username field, and include enrolled sections here 75 75 We use email as the username field, and include enrolled sections here
""" 76 76 """
objects = EmailOnlyUserManager() 77 77 objects = EmailOnlyUserManager()
USERNAME_FIELD = 'email' 78 78 USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] 79 79 REQUIRED_FIELDS = []
sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in") 80 80 sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in")
confirmed_email = BooleanField(default=False) 81 81 confirmed_email = BooleanField(default=False)
registration_id = CharField(null=True, default=None, max_length=4096) 82 82 registration_id = CharField(null=True, default=None, max_length=4096)
last_notified = DateTimeField(null=True, default=None) 83 83 last_notified = DateTimeField(null=True, default=None)
84 84
@property 85 85 @property
def locked(self): 86 86 def locked(self):
if self.confirmed_email: return False 87 87 if self.confirmed_email: return False
return (now() - self.date_joined).days > 0 88 88 return (now() - self.date_joined).days > 0
89 89
def send_confirmation_email(self): 90 90 def send_confirmation_email(self):
body = ''' 91 91 body = '''
Visit the following link to confirm your email address: 92 92 Visit the following link to confirm your email address:
%sapp/verifyemail/%s 93 93 %sapp/verifyemail/%s
94 94
If you did not register for Flashy, no action is required. 95 95 If you did not register for Flashy, no action is required.
''' 96 96 '''
send_mail("Flashy email verification", body % (ABSOLUTE_URL_ROOT, self.confirmation_key), 97 97 send_mail("Flashy email verification", body % (ABSOLUTE_URL_ROOT, self.confirmation_key),
"noreply@flashy.cards", [self.email]) 98 98 "noreply@flashy.cards", [self.email])
99 99
def notify(self): 100 100 def notify(self):
gcm = GCM(GCM_API_KEY) 101 101 gcm = GCM(GCM_API_KEY)
gcm.plaintext_request( 102 102 gcm.plaintext_request(
registration_id=self.registration_id, 103 103 registration_id=self.registration_id,
data={'poop': 'data'} 104 104 data={'poop': 'data'}
) 105 105 )
self.last_notified = now() 106 106 self.last_notified = now()
self.save() 107 107 self.save()
108 108
def set_registration_id(self, token): 109 109 def set_registration_id(self, token):
self.registration_id = token 110 110 self.registration_id = token
self.save() 111 111 self.save()
112 112
def is_in_section(self, section): 113 113 def is_in_section(self, section):
return self.sections.filter(pk=section.pk).exists() 114 114 return self.sections.filter(pk=section.pk).exists()
115 115
def pull(self, flashcard): 116 116 def pull(self, flashcard):
if not self.is_in_section(flashcard.section): 117 117 if not self.is_in_section(flashcard.section):
raise ValueError("User not in the section this flashcard belongs to") 118 118 raise ValueError("User not in the section this flashcard belongs to")
119 119
try: 120 120 try:
user_card = UserFlashcard.objects.create(user=self, flashcard=flashcard) 121 121 user_card = UserFlashcard.objects.create(user=self, flashcard=flashcard)
except IntegrityError: 122 122 except IntegrityError:
raise FlashcardAlreadyPulledException() 123 123 raise FlashcardAlreadyPulledException()
124 124
flashcard.refresh_score() 125 125 flashcard.refresh_score()
126 126
import flashcards.pushes 127 127 import flashcards.pushes
128 128
flashcards.pushes.push_feed_event('score_change', flashcard) 129 129 flashcards.pushes.push_feed_event('score_change', flashcard)
flashcards.pushes.push_deck_event('card_pulled', flashcard, self) 130 130 flashcards.pushes.push_deck_event('card_pulled', flashcard, self)
131 131
def unpull(self, flashcard): 132 132 def unpull(self, flashcard):
if not self.is_in_section(flashcard.section): 133 133 if not self.is_in_section(flashcard.section):
raise ValueError("User not in the section this flashcard belongs to") 134 134 raise ValueError("User not in the section this flashcard belongs to")
try: 135 135 try:
user_card = UserFlashcard.objects.get(user=self, flashcard=flashcard) 136 136 user_card = UserFlashcard.objects.get(user=self, flashcard=flashcard)
except UserFlashcard.DoesNotExist: 137 137 except UserFlashcard.DoesNotExist:
raise FlashcardNotInDeckException() 138 138 raise FlashcardNotInDeckException()
user_card.delete() 139 139 user_card.delete()
140 140
flashcard.refresh_score() 141 141 flashcard.refresh_score()
142 142
import flashcards.pushes 143 143 import flashcards.pushes
144 144
flashcards.pushes.push_feed_event('score_change', flashcard) 145 145 flashcards.pushes.push_feed_event('score_change', flashcard)
flashcards.pushes.push_deck_event('card_unpulled', flashcard, self) 146 146 flashcards.pushes.push_deck_event('card_unpulled', flashcard, self)
147 147
def get_deck(self, section): 148 148 def get_deck(self, section):
if not self.is_in_section(section): 149 149 if not self.is_in_section(section):
raise ObjectDoesNotExist("User not enrolled in section") 150 150 raise ObjectDoesNotExist("User not enrolled in section")
return Flashcard.objects.all().filter(userflashcard__user=self).filter(section=section) 151 151 return Flashcard.objects.all().filter(userflashcard__user=self).filter(section=section)
152 152
def request_password_reset(self): 153 153 def request_password_reset(self):
token = default_token_generator.make_token(self) 154 154 token = default_token_generator.make_token(self)
155 155
body = ''' 156 156 body = '''
Visit the following link to reset your password: 157 157 Visit the following link to reset your password:
%sapp/resetpassword/%d/%s 158 158 %sapp/resetpassword/%d/%s
159 159
If you did not request a password reset, no action is required. 160 160 If you did not request a password reset, no action is required.
''' 161 161 '''
162 162
send_mail("Flashy password reset", 163 163 send_mail("Flashy password reset",
body % (ABSOLUTE_URL_ROOT, self.pk, token), 164 164 body % (ABSOLUTE_URL_ROOT, self.pk, token),
"noreply@flashy.cards", 165 165 "noreply@flashy.cards",
[self.email]) 166 166 [self.email])
167 167
@classmethod 168 168 @classmethod
def confirm_email(cls, confirmation_key): 169 169 def confirm_email(cls, confirmation_key):
# This will raise an exception if the email address is invalid 170 170 # This will raise an exception if the email address is invalid
address = EmailAddress.objects.confirm(confirmation_key, save=True).email 171 171 address = EmailAddress.objects.confirm(confirmation_key, save=True).email
user = cls.objects.get(email=address) 172 172 user = cls.objects.get(email=address)
user.confirmed_email = True 173 173 user.confirmed_email = True
user.save() 174 174 user.save()
return address 175 175 return address
176 176
def by_retention(self, sections, material_date_begin, material_date_end): 177 177 def by_retention(self, sections, material_date_begin, material_date_end):
section_pks = sections.values_list('pk') 178 178 section_pks = sections.values_list('pk')
user_flashcard_filter = UserFlashcard.objects.filter( 179 179 user_flashcard_filter = UserFlashcard.objects.filter(
user=self, flashcard__section__pk__in=section_pks, 180 180 user=self, flashcard__section__pk__in=section_pks,
flashcard__material_date__gte=material_date_begin, 181 181 flashcard__material_date__gte=material_date_begin,
flashcard__material_date__lte=material_date_end 182 182 flashcard__material_date__lte=material_date_end
) 183 183 )
184 184
if not user_flashcard_filter.exists(): 185 185 if not user_flashcard_filter.exists():
raise ValidationError("No matching flashcard found in your decks") 186 186 raise ValidationError("No matching flashcard found in your decks")
187 187
return user_flashcard_filter.prefetch_related('userflashcardquiz_set').annotate( 188 188 return user_flashcard_filter.prefetch_related('userflashcardquiz_set').annotate(
study_count=Count('pk'), 189 189 study_count=Count('pk'),
).order_by('next_review') 190 190 ).order_by('next_review')
191 191
192 192
class UserFlashcard(Model): 193 193 class UserFlashcard(Model):
""" 194 194 """
Represents the relationship between a user and a flashcard by: 195 195 Represents the relationship between a user and a flashcard by:
1. A user has a flashcard in their deck 196 196 1. A user has a flashcard in their deck
2. A user used to have a flashcard in their deck 197 197 2. A user used to have a flashcard in their deck
3. A user has a flashcard hidden from them 198 198 3. A user has a flashcard hidden from them
""" 199 199 """
user = ForeignKey('User') 200 200 user = ForeignKey('User')
mask = MaskField(null=True, blank=True, default=None, help_text="The user-specific mask on the card") 201 201 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") 202 202 pulled = DateTimeField(auto_now_add=True, help_text="When the user pulled the card")
flashcard = ForeignKey('Flashcard') 203 203 flashcard = ForeignKey('Flashcard')
next_review = DateTimeField(null=True) 204 204 next_review = DateTimeField(null=True)
last_interval = IntegerField(default=1) 205 205 last_interval = IntegerField(default=1)
last_response_factor = FloatField(default=2.5) 206 206 last_response_factor = FloatField(default=2.5)
207 207
q_dict = {(False, False): 0, (False, True): 1, (False, None): 2, 208 208 q_dict = {(False, False): 0, (False, True): 1, (False, None): 2,
(True, False): 3, (True, None): 4, (True, True): 5} 209 209 (True, False): 3, (True, None): 4, (True, True): 5}
210 210
def get_mask(self): 211 211 def get_mask(self):
if self.mask is None: 212 212 if self.mask is None:
return self.flashcard.mask 213 213 return self.flashcard.mask
return self.mask 214 214 return self.mask
215 215
def save(self, force_insert=False, force_update=False, using=None, 216 216 def save(self, force_insert=False, force_update=False, using=None,
update_fields=None): 217 217 update_fields=None):
if self.pk is None: 218 218 if self.pk is None:
self.next_review = now() + timedelta(days=1) 219 219 self.next_review = now() + timedelta(days=1)
super(UserFlashcard, self).save(force_insert=force_insert, force_update=force_update, 220 220 super(UserFlashcard, self).save(force_insert=force_insert, force_update=force_update,
using=using, update_fields=update_fields) 221 221 using=using, update_fields=update_fields)
222 222
def review(self, user_flashcard_quiz): 223 223 def review(self, user_flashcard_quiz):
q = self.q_dict[(user_flashcard_quiz.response == user_flashcard_quiz.blanked_word), user_flashcard_quiz.correct] 224 224 q = self.q_dict[(user_flashcard_quiz.response == user_flashcard_quiz.blanked_word), user_flashcard_quiz.correct]
if self.last_interval == 1: 225 225 if self.last_interval == 1:
self.last_interval = 6 226 226 self.last_interval = 6
else: 227 227 else:
self.last_response_factor = min(1.3, self.last_response_factor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))) 228 228 self.last_response_factor = min(1.3, self.last_response_factor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)))
self.last_interval = int(round(self.last_interval * self.last_response_factor)) 229 229 self.last_interval = int(round(self.last_interval * self.last_response_factor))
self.next_review = user_flashcard_quiz.when + timedelta(days=self.last_interval) 230 230 self.next_review = user_flashcard_quiz.when + timedelta(days=self.last_interval)
self.save() 231 231 self.save()
232 232
class Meta: 233 233 class Meta:
# There can be at most one UserFlashcard for each User and Flashcard 234 234 # There can be at most one UserFlashcard for each User and Flashcard
unique_together = (('user', 'flashcard'),) 235 235 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 236 236 index_together = ["user", "flashcard"]
# By default, order by most recently pulled 237 237 # By default, order by most recently pulled
ordering = ['-pulled'] 238 238 ordering = ['-pulled']
239 239
def __unicode__(self): 240 240 def __unicode__(self):
return '%s has %s' % (str(self.user), str(self.flashcard)) 241 241 return '%s has %s' % (str(self.user), str(self.flashcard))
242 242
243 243
class FlashcardHide(Model): 244 244 class FlashcardHide(Model):
""" 245 245 """
Represents the property of a flashcard being hidden by a user. 246 246 Represents the property of a flashcard being hidden by a user.
Each instance of this class represents a single user hiding a single flashcard. 247 247 Each instance of this class represents a single user hiding a single flashcard.
If reason is null, the flashcard was just hidden. 248 248 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. 249 249 If reason is not null, the flashcard was reported, and reason is the reason why it was reported.
""" 250 250 """
user = ForeignKey('User') 251 251 user = ForeignKey('User')
flashcard = ForeignKey('Flashcard') 252 252 flashcard = ForeignKey('Flashcard')
reason = CharField(max_length=255, blank=True, null=True) 253 253 reason = CharField(max_length=255, blank=True, null=True)
hidden = DateTimeField(auto_now_add=True) 254 254 hidden = DateTimeField(auto_now_add=True)
255 255
class Meta: 256 256 class Meta:
# There can only be one FlashcardHide object for each User and Flashcard 257 257 # There can only be one FlashcardHide object for each User and Flashcard
unique_together = (('user', 'flashcard'),) 258 258 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 259 259 index_together = ["user", "flashcard"]
260 260
def __unicode__(self): 261 261 def __unicode__(self):
return u'%s hid %s' % (unicode(self.user), unicode(self.flashcard)) 262 262 return u'%s hid %s' % (unicode(self.user), unicode(self.flashcard))
263 263
264 264
class Flashcard(Model): 265 265 class Flashcard(Model):
text = CharField(max_length=180, help_text='The text on the card', validators=[MinLengthValidator(5)]) 266 266 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') 267 267 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") 268 268 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") 269 269 material_date = DateTimeField(default=now, help_text="The date with which the card is associated")
previous = ForeignKey('Flashcard', null=True, blank=True, default=None, 270 270 previous = ForeignKey('Flashcard', null=True, blank=True, default=None,
help_text="The previous version of this card, if one exists") 271 271 help_text="The previous version of this card, if one exists")
score = FloatField(default=0) 272 272 score = FloatField(default=0)
author = ForeignKey(User) 273 273 author = ForeignKey(User)
is_hidden = BooleanField(default=False) 274 274 is_hidden = BooleanField(default=False)
hide_reason = CharField(blank=True, null=True, max_length=255, default='', 275 275 hide_reason = CharField(blank=True, null=True, max_length=255, default='',
help_text="Reason for hiding this card") 276 276 help_text="Reason for hiding this card")
mask = MaskField(null=True, blank=True, help_text="The mask on the card") 277 277 mask = MaskField(null=True, blank=True, help_text="The mask on the card")
278 278
class Meta: 279 279 class Meta:
# By default, order by most recently pushed 280 280 # By default, order by most recently pushed
ordering = ['-pushed'] 281 281 ordering = ['-pushed']
282 282
def __unicode__(self): 283 283 def __unicode__(self):
return u'<flashcard: %s>' % self.text 284 284 return u'<flashcard: %s>' % self.text
285 285
def refresh_score(self): 286 286 def refresh_score(self):
self.score = self.calculate_score 287 287 self.score = self.calculate_score
self.save() 288 288 self.save()
289 289
@classmethod 290 290 @classmethod
def push(cls, **kwargs): 291 291 def push(cls, **kwargs):
card = cls(**kwargs) 292 292 card = cls(**kwargs)
card.save() 293 293 card.save()
card.author.pull(card) 294 294 card.author.pull(card)
import flashcards.pushes 295 295 import flashcards.pushes
296 296
flashcards.pushes.push_feed_event('new_card', card) 297 297 flashcards.pushes.push_feed_event('new_card', card)
298 298
return card 299 299 return card
300 300
@property 301 301 @property
def material_week_num(self): 302 302 def material_week_num(self):
return (self.material_date - QUARTER_START).days / 7 + 1 303 303 return (self.material_date - QUARTER_START).days / 7 + 1
304 304
def is_hidden_from(self, user): 305 305 def is_hidden_from(self, user):
""" 306 306 """
A card can be hidden globally, but if a user has the card in their deck, 307 307 A card can be hidden globally, but if a user has the card in their deck,
this visibility overrides a global hide. 308 308 this visibility overrides a global hide.
:param user: 309 309 :param user:
:return: Whether the card is hidden from the user. 310 310 :return: Whether the card is hidden from the user.
""" 311 311 """
if hasattr(self, 'is_not_hidden') and self.is_not_hidden: return False 312 312 if hasattr(self, 'is_not_hidden') and self.is_not_hidden: return False
return self.is_hidden or self.flashcardhide_set.filter(user=user).exists() 313 313 return self.is_hidden or self.flashcardhide_set.filter(user=user).exists()
314 314
def is_in_deck(self, user): 315 315 def is_in_deck(self, user):
if hasattr(self, 'userflashcard_id'): return self.userflashcard_id 316 316 if hasattr(self, 'userflashcard_id'): return self.userflashcard_id
return self.userflashcard_set.filter(user=user).exists() 317 317 return self.userflashcard_set.filter(user=user).exists()
318 318
def edit(self, user, new_data): 319 319 def edit(self, user, new_data):
""" 320 320 """
Creates a new flashcard if a new flashcard should be created when the given user edits this flashcard. 321 321 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. 322 322 Sets up everything correctly so this object, when saved, will result in the appropriate changes.
:param user: The user editing this card. 323 323 :param user: The user editing this card.
:param new_data: The new information, namely a dict containg 'material_date', 'text', and 'mask' keys. 324 324 :param new_data: The new information, namely a dict containg 'material_date', 'text', and 'mask' keys.
""" 325 325 """
326 326
# content_changed is True iff either material_date or text were changed 327 327 # content_changed is True iff either material_date or text were changed
content_changed = False 328 328 content_changed = False
# create_new is True iff the user editing this card is the author of this card 329 329 # 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 330 330 # and there are no other users with this card in their decks
create_new = user != self.author or \ 331 331 create_new = user != self.author or \
UserFlashcard.objects.filter(flashcard=self).exclude(user=user).exists() 332 332 UserFlashcard.objects.filter(flashcard=self).exclude(user=user).exists()
if 'material_date' in new_data and self.material_date != new_data['material_date']: 333 333 if 'material_date' in new_data and self.material_date != new_data['material_date']:
content_changed = True 334 334 content_changed = True
self.material_date = new_data['material_date'] 335 335 self.material_date = new_data['material_date']
if 'text' in new_data and self.text != new_data['text']: 336 336 if 'text' in new_data and self.text != new_data['text']:
content_changed = True 337 337 content_changed = True
self.text = new_data['text'] 338 338 self.text = new_data['text']
if create_new and content_changed: 339 339 if create_new and content_changed:
if self.is_in_deck(user): user.unpull(self) 340 340 if self.is_in_deck(user): user.unpull(self)
self.previous_id = self.pk 341 341 self.previous_id = self.pk
self.pk = None 342 342 self.pk = None
self.mask = new_data.get('mask', self.mask) 343 343 self.mask = new_data.get('mask', self.mask)
self.save() 344 344 self.save()
user.pull(self) 345 345 user.pull(self)
else: 346 346 else:
user_card, created = UserFlashcard.objects.get_or_create(user=user, flashcard=self) 347 347 user_card, created = UserFlashcard.objects.get_or_create(user=user, flashcard=self)
user_card.mask = new_data.get('mask', user_card.mask) 348 348 user_card.mask = new_data.get('mask', user_card.mask)
user_card.save() 349 349 user_card.save()
return self 350 350 return self
351 351
def hide_by_user(self, user, reason=None): 352 352 def hide_by_user(self, user, reason=None):
import flashcards.pushes 353 353 import flashcards.pushes
354 354
flashcards.pushes.push_deck_event('card_hidden', self, user) 355 355 flashcards.pushes.push_deck_event('card_hidden', self, user)
if self.is_in_deck(user): user.unpull(self) 356 356 if self.is_in_deck(user): user.unpull(self)
hide, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self, reason=reason) 357 357 hide, created = FlashcardHide.objects.get_or_create(user=user, flashcard=self)
358 hide.reason = reason
hide.save() 358 359 hide.save()
359 360
def unhide_by_user(self, user, reason=None): 360 361 def unhide_by_user(self, user, reason=None):
import flashcards.pushes 361 362 import flashcards.pushes
362 363
flashcards.pushes.push_deck_event('card_unhidden', self, user) 363 364 flashcards.pushes.push_deck_event('card_unhidden', self, user)
hide = self.flashcardhide_set.get(user=user) 364 365 hide = self.flashcardhide_set.get(user=user)
hide.delete() 365 366 hide.delete()
366 367
@cached_property 367 368 @cached_property
def calculate_score(self): 368 369 def calculate_score(self):
def seconds_since_epoch(dt): 369 370 def seconds_since_epoch(dt):
from datetime import datetime 370 371 from datetime import datetime
371 372
epoch = make_aware(datetime.utcfromtimestamp(0)) 372 373 epoch = make_aware(datetime.utcfromtimestamp(0))
delta = dt - epoch 373 374 delta = dt - epoch
return delta.total_seconds() 374 375 return delta.total_seconds()
375 376
z = 0 376 377 z = 0
rate = 1.0 / 3600 377 378 rate = 1.0 / 3600
for vote in self.userflashcard_set.iterator(): 378 379 for vote in self.userflashcard_set.iterator():
t = seconds_since_epoch(vote.pulled) 379 380 t = seconds_since_epoch(vote.pulled)
u = max(z, rate * t) 380 381 u = max(z, rate * t)
v = min(z, rate * t) 381 382 v = min(z, rate * t)
z = u + log1p(exp(v - u)) 382 383 z = u + log1p(exp(v - u))
return z 383 384 return z
384 385
@classmethod 385 386 @classmethod
def cards_visible_to(cls, user): 386 387 def cards_visible_to(cls, user):
""" 387 388 """
:param user: 388 389 :param user:
:return: A queryset with all cards that should be visible to a user. 389 390 :return: A queryset with all cards that should be visible to a user.
""" 390 391 """
# All flashcards where the author is either confirmed, or the user 391 392 # All flashcards where the author is either confirmed, or the user
rqs = cls.objects.filter(Q(author__confirmed_email=True) | Q(author=user)) 392 393 rqs = cls.objects.filter(Q(author__confirmed_email=True) | Q(author=user))
# Exclude hidden cards 393 394 # Exclude hidden cards
rqs = rqs.exclude(Q(is_hidden=True) | Q(flashcardhide__user=user)) 394 395 rqs = rqs.exclude(Q(is_hidden=True) | Q(flashcardhide__user=user))
# rqs = rqs.prefetch_related('userflashcard_set') 395 396 # rqs = rqs.prefetch_related('userflashcard_set')
# rqs = rqs.aggregate(Count(userflashcard__user=user)) 396 397 # rqs = rqs.aggregate(Count(userflashcard__user=user))
# Annotate the cards so we don't have to check if they're hidden in the future 397 398 # Annotate the cards so we don't have to check if they're hidden in the future
return rqs.annotate(is_not_hidden=Value(True, output_field=BooleanField())) 398 399 return rqs.annotate(is_not_hidden=Value(True, output_field=BooleanField()))
399 400
@classmethod 400 401 @classmethod
def cards_hidden_by(cls, user): 401 402 def cards_hidden_by(cls, user):
return cls.objects.filter(flashcardhide__user=user) 402 403 return cls.objects.filter(flashcardhide__user=user)
403 404
404 405
class UserFlashcardQuiz(Model): 405 406 class UserFlashcardQuiz(Model):
""" 406 407 """
An event of a user being quizzed on a flashcard. 407 408 An event of a user being quizzed on a flashcard.
""" 408 409 """
user_flashcard = ForeignKey(UserFlashcard) 409 410 user_flashcard = ForeignKey(UserFlashcard)
when = DateTimeField(auto_now=True) 410 411 when = DateTimeField(auto_now=True)
blanked_word = CharField(max_length=40, blank=True, help_text="The character range which was blanked") 411 412 blanked_word = CharField(max_length=40, 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") 412 413 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") 413 414 correct = NullBooleanField(help_text="The user's self-evaluation of their response")
414 415
def __unicode__(self): 415 416 def __unicode__(self):
return '%s reviewed %s' % (str(self.user_flashcard.user), str(self.user_flashcard.flashcard)) 416 417 return '%s reviewed %s' % (str(self.user_flashcard.user), str(self.user_flashcard.flashcard))
417 418
def save(self, force_insert=False, force_update=False, using=None, 418 419 def save(self, force_insert=False, force_update=False, using=None,
update_fields=None): 419 420 update_fields=None):
super(UserFlashcardQuiz, self).save(force_insert=force_insert, force_update=force_update, 420 421 super(UserFlashcardQuiz, self).save(force_insert=force_insert, force_update=force_update,
using=using, update_fields=update_fields) 421 422 using=using, update_fields=update_fields)
self.user_flashcard.review(self) 422 423 self.user_flashcard.review(self)
423 424
def status(self): 424 425 def status(self):
""" 425 426 """
There are three stages of a quiz object: 426 427 There are three stages of a quiz object:
1. the user has been shown the card 427 428 1. the user has been shown the card
2. the user has answered the card 428 429 2. the user has answered the card
3. the user has self-evaluated their response's correctness 429 430 3. the user has self-evaluated their response's correctness
430 431
:return: string (evaluated, answered, viewed) 431 432 :return: string (evaluated, answered, viewed)
""" 432 433 """
if self.correct is not None: return "evaluated" 433 434 if self.correct is not None: return "evaluated"
if self.response: return "answered" 434 435 if self.response: return "answered"
return "viewed" 435 436 return "viewed"
436 437
437 438
class Section(Model): 438 439 class Section(Model):
""" 439 440 """
A UCSD course taught by an instructor during a quarter. 440 441 A UCSD course taught by an instructor during a quarter.
We use the term "section" to avoid collision with the builtin keyword "class" 441 442 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 442 443 We index gratuitously to support autofill and because this is primarily read-only
""" 443 444 """
department = CharField(db_index=True, max_length=50) 444 445 department = CharField(db_index=True, max_length=50)
department_abbreviation = CharField(db_index=True, max_length=10) 445 446 department_abbreviation = CharField(db_index=True, max_length=10)
course_num = CharField(db_index=True, max_length=6) 446 447 course_num = CharField(db_index=True, max_length=6)
course_title = CharField(db_index=True, max_length=50) 447 448 course_title = CharField(db_index=True, max_length=50)
instructor = CharField(db_index=True, max_length=100) 448 449 instructor = CharField(db_index=True, max_length=100)
quarter = CharField(db_index=True, max_length=4) 449 450 quarter = CharField(db_index=True, max_length=4)
PAGE_SIZE = 40 450 451 PAGE_SIZE = 40
451 452
@classmethod 452 453 @classmethod
def search(cls, terms): 453 454 def search(cls, terms):
""" 454 455 """
Search all fields of all sections for a particular set of terms 455 456 Search all fields of all sections for a particular set of terms
A matching section must match at least one field on each term 456 457 A matching section must match at least one field on each term
:param terms:iterable 457 458 :param terms:iterable
:return: Matching QuerySet ordered by department and course number 458 459 :return: Matching QuerySet ordered by department and course number
""" 459 460 """
final_q = Q() 460 461 final_q = Q()
for term in terms: 461 462 for term in terms:
q = Q(department__icontains=term) 462 463 q = Q(department__icontains=term)
q |= Q(department_abbreviation__icontains=term) 463 464 q |= Q(department_abbreviation__icontains=term)
q |= Q(course_title__icontains=term) 464 465 q |= Q(course_title__icontains=term)
q |= Q(course_num__icontains=term) 465 466 q |= Q(course_num__icontains=term)
q |= Q(instructor__icontains=term) 466 467 q |= Q(instructor__icontains=term)
final_q &= q 467 468 final_q &= q
qs = cls.objects.filter(final_q) 468 469 qs = cls.objects.filter(final_q)
# Have the database cast the course number to an integer so it will sort properly 469 470 # 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 470 471 # 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)"}) 471 472 qs = qs.extra(select={'course_num_int': "CAST(rtrim(course_num, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') AS INTEGER)"})
qs = qs.order_by('department_abbreviation', 'course_num_int') 472 473 qs = qs.order_by('department_abbreviation', 'course_num_int')
return qs 473 474 return qs
474 475
@property 475 476 @property
def is_whitelisted(self): 476 477 def is_whitelisted(self):
""" 477 478 """
:return: whether a whitelist exists for this section 478 479 :return: whether a whitelist exists for this section
""" 479 480 """
return self.whitelist.exists() 480 481 return self.whitelist.exists()
481 482
def is_user_on_whitelist(self, user): 482 483 def is_user_on_whitelist(self, user):
""" 483 484 """
:return: whether the user is on the waitlist for this section 484 485 :return: whether the user is on the waitlist for this section
""" 485 486 """
return self.whitelist.filter(email=user.email).exists() 486 487 return self.whitelist.filter(email=user.email).exists()
487 488
def is_user_enrolled(self, user): 488 489 def is_user_enrolled(self, user):
return self.user_set.filter(pk=user.pk).exists() 489 490 return self.user_set.filter(pk=user.pk).exists()
490 491
def enroll(self, user): 491 492 def enroll(self, user):
if user.sections.filter(pk=self.pk).exists(): 492 493 if user.sections.filter(pk=self.pk).exists():