Commit a3f17ed32e7575fbe914a1cfcda13f2b198f7ee9

Authored by Andrew Buss
1 parent 93e9f2896e
Exists in master

only save if necessary

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

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