import datetime from itertools import chain from django.db import models from django.db.models import Avg, Max, Min, Count from apps.manabi_redis.models import redis from model_utils.managers import manager_from from flashcards.models.constants import ( GRADE_NONE, GRADE_HARD, GRADE_GOOD, GRADE_EASY, MAX_NEW_CARD_ORDINAL, EASE_FACTOR_MODIFIERS, YOUNG_FAILURE_INTERVAL, MATURE_FAILURE_INTERVAL, MATURE_INTERVAL_MIN, GRADE_EASY_BONUS_FACTOR, DEFAULT_EASE_FACTOR, INTERVAL_FUZZ_MAX) class SchedulerMixin(object): ''' Contains the functions for retrieving the next cards that are ready to be reviewed. ''' def _space_cards(self, card_query, count, review_time, excluded_ids=[], early_review=False): ''' Check if any of these are from the same fact, or if other cards from their facts have been reviewed recently. If so, push their due date up. `excluded_ids` is included for avoiding showing sibling cards of cards which the user is already currently reviewing. if `early_review` == True: Doesn't actually delay cards. If all cards in query end up being "spaced", then it will return the spaced cards, since early review shouldn't ever run out of cards. ''' # Keep track of new cards we want to skip, # since we shouldn't set their due_at (via delay()) delayed_cards = [] # REDIS WIP #while True: # cards = card_query.exclude( # id__in=[card.id for card in delayed_cards]) # card_ids = cards[:count].values_list('id', flat=True) # if early_review and len(cards) == 0: # return delayed_cards[:count] # # get cards to delay. # # get fact IDs # # get zrange of score > # for card in cards: # min_space = card.sibling_spacing() # fact_id = card.fact_id # if redis.zrangebyscore() while True: cards_delayed = 0 cards = card_query.exclude( id__in=[card.id for card in delayed_cards]).select_related() cards = cards[:count] if early_review and len(cards) == 0: return delayed_cards[:count] for card in cards: min_space = card.sibling_spacing() for sibling in card.siblings: if sibling.is_due(review_time) \ or sibling.id in excluded_ids \ or (sibling.last_reviewed_at \ and abs(card.due_at - sibling.last_reviewed_at) <= min_space): # Delay the card. It's already sorted by priority, # so we delay this one instead of its sibling. if card.is_new() or early_review: delayed_cards.append(card) else: card.delay(min_space) card.save() cards_delayed += 1 break if not cards_delayed: break return cards def _next_failed_due_cards(self, user, initial_query, count, review_time, excluded_ids=[], early_review=False, deck=None, tags=None, **kwargs): if not count: return [] cards = initial_query.filter( last_review_grade=GRADE_NONE, due_at__isnull=False, due_at__lte=review_time).order_by('due_at') # Don't space these #self._space_cards(cards, count, review_time) return cards[:count] def _next_not_failed_due_cards(self, user, initial_query, count, review_time, excluded_ids=[], early_review=False, deck=None, tags=None, **kwargs): ''' Returns the first [count] cards from initial_query which are due, weren't failed the last review, and taking spacing of cards from the same fact into account. review_time should be datetime.datetime.utcnow() ''' if not count: return [] due_cards = initial_query.exclude( last_review_grade=GRADE_NONE).filter( due_at__isnull=False, due_at__lte=review_time).order_by('-interval') #TODO Also get cards that aren't quite due yet, but will be soon, # and depending on their maturity # (i.e. only mature cards due soon). # Figure out some kind of way to prioritize these too. return self._space_cards(due_cards, count, review_time) def _next_failed_not_due_cards(self, user, initial_query, count, review_time, excluded_ids=[], early_review=False, deck=None, tags=None, **kwargs): if not count: return [] #TODO prioritize certain failed cards, not just by due date # We'll show failed cards even if they've been reviewed recently. # This is because failed cards are set to be shown 'soon' and not # just in 10 minutes. Special rules. #TODO we shouldn't show mature failed cards so soon though! #TODO randomize the order (once we fix the Undo) card_query = initial_query.filter(last_review_grade=GRADE_NONE, \ due_at__gt=review_time).order_by('due_at') return card_query[:count] def _next_new_cards(self, user, initial_query, count, review_time, excluded_ids=[], early_review=False, learn_more=False, deck=None, tags=None, add_upstream_facts_as_needed=True, **kwargs): ''' Gets the next new cards for this user or deck. ''' from flashcards.models.facts import Fact if not count: return [] new_card_query = initial_query.filter( due_at__isnull=True).order_by('new_card_ordinal') def _next_new_cards2(): new_cards = [] for card in new_card_query.iterator(): min_space = card.sibling_spacing() for sibling in card.siblings: # sibling is already included as a new card to be shown or # sibling is currently in the client-side review queue or # sibling is due or # sibling was reviewed recently or # sibling is failed. Either it's due, or it's not due and it's shown before new cards. if (sibling in new_cards or sibling.id in excluded_ids or sibling.is_due(review_time) or (sibling.last_reviewed_at and abs(review_time - sibling.last_reviewed_at) <= min_space) or sibling.last_review_grade == GRADE_NONE): break else: # Only add the card if none of the # conditions above matched for its siblings. new_cards.append(card) # Got enough cards? if len(new_cards) == count: #or \ #(new_count_left_for_today is not None and not early_review and len(new_cards) == new_count_left_for_today): break return new_cards new_cards = _next_new_cards2() if add_upstream_facts_as_needed and len(new_cards) < count: # Still have fewer cards than requested. # See if we can get new cards from synchronized decks. facts_added = Fact.objects.add_new_facts_from_synchronized_decks( user, count - len(new_cards), deck=deck, tags=tags) if len(facts_added): # Got new facts from a synchronized deck. # Get cards from them by re-getting new cards new_cards = _next_new_cards2() eligible_ids = [card.id for card in new_cards] if (early_review or learn_more) and len(eligible_ids) < count: # Queue up spaced cards if needed for early review. eligible_ids.extend([card.id for card in new_card_query.exclude( id__in=eligible_ids).select_related() [:count - len(eligible_ids)]]) # Return a query containing the eligible cards. ret = self.filter(id__in=eligible_ids).order_by('new_card_ordinal') ret = ret[:count] return ret def _next_due_soon_cards(self, user, initial_query, count, review_time, excluded_ids=[], early_review=False, deck=None, tags=None, **kwargs): ''' Used for early review. Ordered by due date. ''' if not count: return [] priority_cutoff = review_time - datetime.timedelta(minutes=60) cards = initial_query.exclude( last_review_grade=GRADE_NONE).filter( due_at__gt=review_time).order_by('due_at') staler_cards = cards.filter( last_reviewed_at__gt=priority_cutoff).order_by('due_at') return self._space_cards( staler_cards, count, review_time, early_review=True) def _next_due_soon_cards2(self, user, initial_query, count, review_time, excluded_ids=[], early_review=False, deck=None, tags=None, **kwargs): if not count: return [] priority_cutoff = review_time - datetime.timedelta(minutes=60) cards = initial_query.exclude( last_review_grade=GRADE_NONE).filter( due_at__gt=review_time).order_by('due_at') fresher_cards = cards.filter( last_reviewed_at__isnull=False, last_reviewed_at__lte=priority_cutoff).order_by('due_at') return self._space_cards( fresher_cards, count, review_time, early_review=True) def _next_cards(self, early_review=False, learn_more=False): card_funcs = [ self._next_failed_due_cards, # due, failed self._next_not_failed_due_cards, # due, not failed self._next_failed_not_due_cards] # failed, not due if early_review and learn_more: raise Exception( 'Cannot set both early_review and learn_more together.') if early_review: card_funcs.extend([ self._next_due_soon_cards, # due soon, not yet, but next in the future self._next_due_soon_cards2]) elif learn_more: # Only new cards, and ignore spacing. card_funcs = [self._next_new_cards] else: card_funcs.extend([self._next_new_cards]) # new cards at end return card_funcs def next_cards(self, user, count, excluded_ids=[], session_start=False, deck=None, tags=None, early_review=False, learn_more=False): ''' Returns `count` cards to be reviewed, in order. count should not be any more than a short session of cards set `early_review` to True for reviewing cards early (following any due cards) If learn_more is True, only new cards will be chosen, even if they were spaced due to sibling reviews. "Due soon" cards won't be chosen in this case, contrary to early_review's normal behavior. (#TODO consider changing this to have a separate option) ''' #TODO somehow spread some new cards into the early review # cards if early_review==True #TODO use args instead, like *kwargs etc for these funcs now = datetime.datetime.utcnow() card_funcs = self._next_cards( early_review=early_review, learn_more=learn_more) user_cards = self.common_filters(user, deck=deck, excluded_ids=excluded_ids, tags=tags) cards_left = count card_queries = [] for card_func in card_funcs: if not cards_left: break cards = card_func( user, user_cards, cards_left, now, excluded_ids, early_review=early_review, learn_more=learn_more, deck=deck, tags=tags) cards_left -= len(cards) if len(cards): card_queries.append(cards) #TODO decide what to do with this #if session_start: #FIXME add new cards into the mix when there's a defined # new card per day limit #for now, we'll add new ones to the end return chain(*card_queries) class CommonFiltersMixin(object): ''' Provides filters for decks, tags, maturity level, etc. This is particularly useful with view URLs which take query params for these things. ''' def available(self): ''' Cards which are active and unsuspended. ''' return self.filter(active=True, suspended=False) def of_deck(self, deck, with_upstream=False): deck_key = 'cards:deck:{0}'.format(deck.id) if with_upstream and deck.synchronized_with: sync_deck_key = 'cards:deck:{0}'.format(deck.synchronized_with_id) card_ids = redis.sunion(deck_key, sync_deck_key) else: card_ids = redis.smembers(deck_key) return self.filter(id__in=card_ids) def of_user(self, user, with_upstream=False): from flashcards.models.facts import Fact #TODO this is probably really slow facts = Fact.objects.with_upstream(user) user_cards = self.filter(fact__in=facts) if not with_upstream: user_cards = user_cards.without_upstream(user) return user_cards def without_upstream(self, user): ''' Excludes cards that the subscribed user doesn't yet own, but which are in a synchronized deck this user owns. ''' #card_ids = redis.smembers('cards:owner:%s' % user.id) #return self.filter(id__in=card_ids) return self.filter(fact__deck__owner=user) def of_upstream(self, user): ''' Filters to include only upstream cards (which the user hasn't copied yet). ''' #card_ids = redis.smembers('cards:owner:%s' % user.id) #return self.exclude(id__in=card_ids) return self.exclude(fact__deck__owner=user) def with_tags(self, tags): from flashcards.models.facts import Fact from usertagging.models import UserTaggedItem facts = UserTaggedItem.objects.get_by_model(Fact, tags) return self.filter(fact__in=facts) def exclude_ids(self, excluded_ids): return self.exclude(id__in=excluded_ids) def unsuspended(self): ''' Returns unsuspended cards. ''' return self.filter(suspended=False) def common_filters(self, user, with_upstream=False, deck=None, tags=None, excluded_ids=None): cards = self.of_user(user, with_upstream=with_upstream).unsuspended().filter(active=True) if deck: cards = cards.of_deck(deck, with_upstream=with_upstream) else: cards = cards.exclude(fact__deck__owner=user, fact__deck__suspended=True) if excluded_ids: cards = cards.exclude_ids(excluded_ids) if tags: cards = cards.with_tags(tags) return cards def new(self, user): return self.filter( last_reviewed_at__isnull=True).without_upstream(user) def new_count(self, user): ''' Use this rather than `new(user).count()`, since this will count upstream cards properly. Any uncopied upstream cards are to be considered "new". ''' return self.new(user).count() + self.of_upstream(user).count() def approx_new_count(self, user=None, deck=None): ''' Approximates how many new cards are actually available to review. Will be between what new_count and unspaced_new_count return, but much faster than the latter. ''' cards = self.available() if deck: cards = cards.of_deck(deck) return (cards.new(user).values_list('fact_id').distinct().count() + cards.of_upstream(user).values_list('fact_id').distinct().count()) def unspaced_new_count(self, user): ''' Same as `new_count`, except it subtracts new cards that will be delayed due to sibling spacing (cards which haven't been spaced.) Works properly with upstream cards. ''' local_query = self.new(user) desired_count = 999999 #TODO use more elegant solution. now = datetime.datetime.utcnow() local = self._next_new_cards(user, local_query, desired_count, now, add_upstream_facts_as_needed=False).count() # Count the number of upstream cards -- they are all "new". # But only count one card per distinct fact, to simulate # the effect of sibling spacing. upstream = self.of_upstream(user).values('fact_id').distinct().count() return local + upstream def young(self, user): return self.filter( last_reviewed_at__isnull=False, interval__isnull=False, interval__lt=MATURE_INTERVAL_MIN ).without_upstream(user) def mature(self, user): return self.filter( interval__gte=MATURE_INTERVAL_MIN ).without_upstream(user) def due(self, user, _space_cards=True): ''' `_space_cards` is whether to space out due cards before returning them (which can result in fewer being returned). Excludes upstream cards that the user doesn't own. ''' now = datetime.datetime.utcnow() due_cards = self.filter( due_at__isnull=False, due_at__lte=now).without_upstream(user) if _space_cards: self._space_cards(due_cards, due_cards.count(), now) # Re-get them since some may have been spaced due_cards = due_cards.filter( due_at__lte=now) return due_cards.order_by('-interval') def count_of_cards_due_tomorrow(self, user): ''' Returns the number of cards due by tomorrow at the same time as now. Doesn't take future spacing into account though, so it's a somewhat rough estimate. No longer includes new cards in its count. ''' #from flashcards.models.facts import Fact #cards = self.of_user(user) #if deck: # cards = cards.filter(fact__deck=deck) #if tags: # facts = usertagging.models.UserTaggedItem.objects.get_by_model( # Fact, tags) # cards = cards.filter(fact__in=facts) this_time_tomorrow = (datetime.datetime.utcnow() + datetime.timedelta(days=1)) cards = self.filter( due_at__isnull=False, due_at__lt=this_time_tomorrow).without_upstream(user) due_count = cards.count() #new_count = self.new().count() #new_count = self.common_filters( #user, deck=deck, tags=tags, with_upstream=True).new().count() #new_count = min( #NEW_CARDS_PER_DAY, #self.new_cards_count(user, [], deck=deck, tags=tags)) #return due_count + new_count return due_count def next_card_due_at(self): ''' Returns the due date of the next due card. If one is already due, this will be in the past. ''' return self.aggregate(Min('due_at'))['due_at__min'] #def spaced_cards_new_count(self, user, deck=None): #threshold_at = datetime.datetime.utcnow() - datetime.timedelta(minutes=30) #recently_reviewed = self.filter(fact__deck__owner=user, fact__deck=deck, last_reviewed_at__lte=threshold_at) #facts = Fact.objects.filter(id__in=recently_reviewed.values_list('fact', flat=True)) #new_cards_count = self.new_cards(user, deck).exclude(fact__in=facts).count() #return new_cards_count class CardStatsMixin(object): '''Stats data methods, primarily used for graphs and things.''' def with_due_dates(self): ''' Adds a `due_on` DateField-like value. Same as `due_at` minus its time information -- so just the day. ''' return self.extra(select={'due_on': 'date(due_at)'}) def due_counts(self): '''Number of cards due per day in the future.''' return self.with_due_dates().values('due_on').annotate( due_count=Count('id')) def due_today_count(self): '''The # of cards already due right now or later today.''' return self.filter( due_at__isnull=False, due_at__lte=datetime.datetime.today()).count() def future_due_counts(self): '''Same as `due_counts` but only for future, after today.''' return self.filter( due_at__gt=datetime.datetime.today()).with_due_dates().values( 'due_on').annotate(due_count=Count('id')) CardManager = lambda: manager_from( CommonFiltersMixin, SchedulerMixin, CardStatsMixin)