[Publisher] move add_published_objs to PublishedPodcast
[mygpo.git] / mygpo / users / models.py
blobd45bd7a90e1569f2aa70ba3adc3b729b7a40791d
1 import re
2 import uuid
3 import collections
4 from datetime import datetime
5 import dateutil.parser
6 from itertools import imap
7 from operator import itemgetter
8 import random
9 import string
11 from couchdbkit.ext.django.schema import *
12 from uuidfield import UUIDField
14 from django.db import transaction, models
15 from django.db.models import Q
16 from django.contrib.auth.models import User as DjangoUser
17 from django.contrib.auth import get_user_model
18 from django.utils.translation import ugettext_lazy as _
19 from django.conf import settings
20 from django.core.cache import cache
22 from django_couchdb_utils.registration.models import User as BaseUser
24 from mygpo.core.models import (TwitterModel, UUIDModel, SettingsModel,
25 GenericManager, )
26 from mygpo.podcasts.models import Podcast, Episode
27 from mygpo.utils import linearize
28 from mygpo.core.proxy import DocumentABCMeta, proxy_object
29 from mygpo.decorators import repeat_on_conflict
30 from mygpo.users.ratings import RatingMixin
31 from mygpo.users.sync import SyncedDevicesMixin, get_grouped_devices
32 from mygpo.users.subscriptions import subscription_changes, podcasts_for_states
33 from mygpo.users.settings import FAV_FLAG, PUBLIC_SUB_PODCAST, SettingsMixin
34 from mygpo.db.couchdb.user import user_history, device_history, \
35 create_missing_user_tokens
37 # make sure this code is executed at startup
38 from mygpo.users.signals import *
41 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
43 # TODO: derive from ValidationException?
44 class InvalidEpisodeActionAttributes(ValueError):
45 """ raised when the attribues of an episode action fail validation """
48 class SubscriptionException(Exception):
49 """ raised when a subscription can not be modified """
52 class DeviceUIDException(Exception):
53 pass
56 class DeviceDoesNotExist(Exception):
57 pass
60 class DeviceDeletedException(DeviceDoesNotExist):
61 pass
65 class UserProxyQuerySet(models.QuerySet):
67 def by_username_or_email(self, username, email):
68 """ Queries for a User by username or email """
69 q = Q()
71 if username:
72 q |= Q(username=username)
74 elif email:
75 q |= Q(email=email)
77 if q:
78 return self.get(q)
79 else:
80 return self.none()
83 class UserProxyManager(GenericManager):
84 """ Manager for the UserProxy model """
86 def get_queryset(self):
87 return UserProxyQuerySet(self.model, using=self._db)
91 class UserProxy(DjangoUser):
93 objects = UserProxyManager()
95 class Meta:
96 proxy = True
99 class UserProfile(TwitterModel, SettingsModel):
100 """ Additional information stored for a User """
102 # the user to which this profile belongs
103 user = models.OneToOneField(settings.AUTH_USER_MODEL,
104 related_name='profile')
106 # the CouchDB _id of the user
107 uuid = UUIDField(unique=True)
109 # if False, suggestions should be updated
110 suggestions_up_to_date = models.BooleanField(default=False)
112 # text the user entered about himeself
113 about = models.TextField(blank=True)
115 # Google email address for OAuth login
116 google_email = models.CharField(max_length=100, null=True)
118 # token for accessing subscriptions of this use
119 subscriptions_token = models.CharField(max_length=32, null=True)
121 # token for accessing the favorite-episodes feed of this user
122 favorite_feeds_token = models.CharField(max_length=32, null=True)
124 # token for automatically updating feeds published by this user
125 publisher_update_token = models.CharField(max_length=32, null=True)
127 # token for accessing the userpage of this user
128 userpage_token = models.CharField(max_length=32, null=True)
130 # key for activating the user
131 activation_key = models.CharField(max_length=40, null=True)
133 def get_token(self, token_name):
134 """ returns a token, and generate those that are still missing """
136 generated = False
138 if token_name not in TOKEN_NAMES:
139 raise TokenException('Invalid token name %s' % token_name)
141 create_missing_user_tokens(self)
143 return getattr(self, token_name)
146 class Suggestions(Document, RatingMixin):
147 user = StringProperty(required=True)
148 user_oldid = IntegerProperty()
149 podcasts = StringListProperty()
150 blacklist = StringListProperty()
153 def get_podcasts(self, count=None):
154 User = get_user_model()
155 user = User.objects.get(profile__uuid=self.user)
156 subscriptions = user.get_subscribed_podcast_ids()
158 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
159 if count:
160 ids = ids[:count]
162 podcasts = Podcast.objects.filter(id__in=ids).prefetch_related('slugs')
163 return filter(lambda x: x and x.title, podcasts)
166 def __repr__(self):
167 if not self._id:
168 return super(Suggestions, self).__repr__()
169 else:
170 return '%d Suggestions for %s (%s)' % \
171 (len(self.podcasts), self.user, self._id)
174 class EpisodeAction(DocumentSchema):
176 One specific action to an episode. Must
177 always be part of a EpisodeUserState
180 action = StringProperty(required=True)
182 # walltime of the event (assigned by the uploading client, defaults to now)
183 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
185 # upload time of the event
186 upload_timestamp = IntegerProperty(required=True)
188 device_oldid = IntegerProperty(required=False)
189 device = StringProperty()
190 started = IntegerProperty()
191 playmark = IntegerProperty()
192 total = IntegerProperty()
194 def __eq__(self, other):
195 if not isinstance(other, EpisodeAction):
196 return False
197 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
198 'total')
199 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
202 def to_history_entry(self):
203 entry = HistoryEntry()
204 entry.action = self.action
205 entry.timestamp = self.timestamp
206 entry.device_id = self.device
207 entry.started = self.started
208 entry.position = self.playmark
209 entry.total = self.total
210 return entry
214 def validate_time_values(self):
215 """ Validates allowed combinations of time-values """
217 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
219 # Key found, but must not be supplied (no play action!)
220 if self.action != 'play':
221 for key in PLAY_ACTION_KEYS:
222 if getattr(self, key, None) is not None:
223 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key)
225 # Sanity check: If started or total are given, require playmark
226 if ((self.started is not None) or (self.total is not None)) and \
227 self.playmark is None:
228 raise InvalidEpisodeActionAttributes('started and total require position')
230 # Sanity check: total and playmark can only appear together
231 if ((self.total is not None) or (self.started is not None)) and \
232 ((self.total is None) or (self.started is None)):
233 raise InvalidEpisodeActionAttributes('total and started can only appear together')
236 def __repr__(self):
237 return '%s-Action on %s at %s (in %s)' % \
238 (self.action, self.device, self.timestamp, self._id)
241 def __hash__(self):
242 return hash(frozenset([self.action, self.timestamp, self.device,
243 self.started, self.playmark, self.total]))
246 class Chapter(Document):
247 """ A user-entered episode chapter """
249 device = StringProperty()
250 created = DateTimeProperty()
251 start = IntegerProperty(required=True)
252 end = IntegerProperty(required=True)
253 label = StringProperty()
254 advertisement = BooleanProperty()
257 def __repr__(self):
258 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
259 self.start, self.end)
262 class EpisodeUserState(Document, SettingsMixin):
264 Contains everything a user has done with an Episode
267 episode = StringProperty(required=True)
268 actions = SchemaListProperty(EpisodeAction)
269 user_oldid = IntegerProperty()
270 user = StringProperty(required=True)
271 ref_url = StringProperty(required=True)
272 podcast_ref_url = StringProperty(required=True)
273 merged_ids = StringListProperty()
274 chapters = SchemaListProperty(Chapter)
275 podcast = StringProperty(required=True)
279 def add_actions(self, actions):
280 map(EpisodeAction.validate_time_values, actions)
281 self.actions = list(self.actions) + actions
282 self.actions = list(set(self.actions))
283 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
286 def is_favorite(self):
287 return self.get_wksetting(FAV_FLAG)
290 def set_favorite(self, set_to=True):
291 self.settings[FAV_FLAG.name] = set_to
294 def get_history_entries(self):
295 return imap(EpisodeAction.to_history_entry, self.actions)
298 def __repr__(self):
299 return 'Episode-State %s (in %s)' % \
300 (self.episode, self._id)
302 def __eq__(self, other):
303 if not isinstance(other, EpisodeUserState):
304 return False
306 return (self.episode == other.episode and
307 self.user == other.user)
311 class SubscriptionAction(Document):
312 action = StringProperty()
313 timestamp = DateTimeProperty(default=datetime.utcnow)
314 device = StringProperty()
317 __metaclass__ = DocumentABCMeta
320 def __cmp__(self, other):
321 return cmp(self.timestamp, other.timestamp)
323 def __eq__(self, other):
324 return self.action == other.action and \
325 self.timestamp == other.timestamp and \
326 self.device == other.device
328 def __hash__(self):
329 return hash(self.action) + hash(self.timestamp) + hash(self.device)
331 def __repr__(self):
332 return '<SubscriptionAction %s on %s at %s>' % (
333 self.action, self.device, self.timestamp)
336 class PodcastUserState(Document, SettingsMixin):
338 Contains everything that a user has done
339 with a specific podcast and all its episodes
342 podcast = StringProperty(required=True)
343 user_oldid = IntegerProperty()
344 user = StringProperty(required=True)
345 actions = SchemaListProperty(SubscriptionAction)
346 tags = StringListProperty()
347 ref_url = StringProperty(required=True)
348 disabled_devices = StringListProperty()
349 merged_ids = StringListProperty()
352 def remove_device(self, device):
354 Removes all actions from the podcast state that refer to the
355 given device
357 self.actions = filter(lambda a: a.device != device.id, self.actions)
360 def subscribe(self, device):
361 action = SubscriptionAction()
362 action.action = 'subscribe'
363 action.device = device.id.hex
364 self.add_actions([action])
367 def unsubscribe(self, device):
368 action = SubscriptionAction()
369 action.action = 'unsubscribe'
370 action.device = device.id.hex
371 self.add_actions([action])
374 def add_actions(self, actions):
375 self.actions = list(set(self.actions + actions))
376 self.actions = sorted(self.actions)
379 def add_tags(self, tags):
380 self.tags = list(set(self.tags + tags))
383 def set_device_state(self, devices):
384 disabled_devices = [device.id for device in devices if device.deleted]
385 self.disabled_devices = disabled_devices
388 def get_change_between(self, device_id, since, until):
390 Returns the change of the subscription status for the given device
391 between the two timestamps.
393 The change is given as either 'subscribe' (the podcast has been
394 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
395 None (no change)
398 device_actions = filter(lambda x: x.device == device_id, self.actions)
399 before = filter(lambda x: x.timestamp <= since, device_actions)
400 after = filter(lambda x: x.timestamp <= until, device_actions)
402 # nothing happened, so there can be no change
403 if not after:
404 return None
406 then = before[-1] if before else None
407 now = after[-1]
409 if then is None:
410 if now.action != 'unsubscribe':
411 return now.action
412 elif then.action != now.action:
413 return now.action
414 return None
417 def get_subscribed_device_ids(self):
418 """ device Ids on which the user subscribed to the podcast """
419 devices = set()
421 for action in self.actions:
422 if action.action == "subscribe":
423 if not action.device in self.disabled_devices:
424 devices.add(action.device)
425 else:
426 if action.device in devices:
427 devices.remove(action.device)
429 return devices
432 def is_subscribed_on(self, device):
433 """ checks if the podcast is subscribed on the given device """
435 for action in reversed(self.actions):
436 if not action.device == device.id:
437 continue
439 # we only need to check the latest action for the device
440 return (action.action == 'subscribe')
442 # we haven't found any matching action
443 return False
446 def is_public(self):
447 return self.get_wksetting(PUBLIC_SUB_PODCAST)
450 def __eq__(self, other):
451 if other is None:
452 return False
454 return self.podcast == other.podcast and \
455 self.user == other.user
457 def __repr__(self):
458 return 'Podcast %s for User %s (%s)' % \
459 (self.podcast, self.user, self._id)
462 class SyncGroup(models.Model):
463 """ A group of Clients """
465 user = models.ForeignKey(settings.AUTH_USER_MODEL)
468 class Client(UUIDModel):
469 """ A client application """
471 DESKTOP = 'desktop'
472 LAPTOP = 'laptop'
473 MOBILE = 'mobile'
474 SERVER = 'server'
475 TABLET = 'tablet'
476 OTHER = 'other'
478 TYPES = (
479 (DESKTOP, _('Desktop')),
480 (LAPTOP, _('Laptop')),
481 (MOBILE, _('Cell phone')),
482 (SERVER, _('Server')),
483 (TABLET, _('Tablet')),
484 (OTHER, _('Other')),
487 # User-assigned ID; must be unique for the user
488 uid = models.CharField(max_length=64)
490 # the user to which the Client belongs
491 user = models.ForeignKey(settings.AUTH_USER_MODEL)
493 # User-assigned name
494 name = models.CharField(max_length=100, default='New Device')
496 # one of several predefined types
497 type = models.CharField(max_length=max(len(k) for k, v in TYPES),
498 choices=TYPES, default=OTHER)
500 # indicates if the user has deleted the client
501 deleted = models.BooleanField(default=False)
503 # user-agent string from which the Client was last accessed (for writing)
504 user_agent = models.CharField(max_length=300, null=True, blank=True)
506 sync_group = models.ForeignKey(SyncGroup, null=True)
508 class Meta:
509 unique_together = (
510 ('user', 'uid'),
513 @transaction.atomic
514 def sync_with(self, other):
515 """ Puts two devices in a common sync group"""
517 if self.user != other.user:
518 raise ValueError('the devices do not belong to the user')
520 if self.sync_group is not None and \
521 other.sync_group is not None and \
522 self.sync_group != other.sync_group:
523 # merge sync_groups
524 ogroup = other.sync_group
525 Client.objects.filter(sync_group=ogroup)\
526 .update(sync_group=self.sync_group)
527 ogroup.delete()
529 elif self.sync_group is None and \
530 other.sync_group is None:
531 sg = SyncGroup.objects.create(user=self.user)
532 other.sync_group = sg
533 other.save()
534 self.sync_group = sg
535 self.save()
537 elif self.sync_group is not None:
538 self.sync_group = other.sync_group
539 self.save()
541 elif other.sync_group is not None:
542 other.sync_group = self.sync_group
543 other.save()
545 def get_sync_targets(self):
546 """ Returns the devices and groups with which the device can be synced
548 Groups are represented as lists of devices """
550 sg = self.sync_group
552 for group in get_grouped_devices(self.user):
554 if self in group.devices:
555 # the device's group can't be a sync-target
556 continue
558 elif group.is_synced:
559 yield group.devices
561 else:
562 # every unsynced device is a sync-target
563 for dev in group.devices:
564 if not dev == self:
565 yield dev
567 def get_subscription_changes(self, since, until):
569 Returns the subscription changes for the device as two lists.
570 The first lists contains the Ids of the podcasts that have been
571 subscribed to, the second list of those that have been unsubscribed
572 from.
575 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
576 podcast_states = podcast_states_for_device(self.id.hex)
577 return subscription_changes(self.id.hex, podcast_states, since, until)
579 def get_latest_changes(self):
580 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
581 podcast_states = podcast_states_for_device(self.id.hex)
582 for p_state in podcast_states:
583 actions = filter(lambda x: x.device == self.id.hex, reversed(p_state.actions))
584 if actions:
585 yield (p_state.podcast, actions[0])
587 def get_subscribed_podcast_ids(self):
588 from mygpo.db.couchdb.podcast_state import get_subscribed_podcast_states_by_device
589 states = get_subscribed_podcast_states_by_device(self)
590 return [state.podcast for state in states]
592 def get_subscribed_podcasts(self):
593 """ Returns all subscribed podcasts for the device
595 The attribute "url" contains the URL that was used when subscribing to
596 the podcast """
597 from mygpo.db.couchdb.podcast_state import get_subscribed_podcast_states_by_device
598 states = get_subscribed_podcast_states_by_device(self)
599 return podcasts_for_states(states)
603 def __str__(self):
604 return '{} ({})'.format(self.name.encode('ascii', errors='replace'),
605 self.uid.encode('ascii', errors='replace'))
607 def __unicode__(self):
608 return u'{} ({})'.format(self.name, self.uid)
611 class Device(Document, SettingsMixin):
612 id = StringProperty(default=lambda: uuid.uuid4().hex)
613 oldid = IntegerProperty(required=False)
614 uid = StringProperty(required=True)
615 name = StringProperty(required=True, default='New Device')
616 type = StringProperty(required=True, default='other')
617 deleted = BooleanProperty(default=False)
618 user_agent = StringProperty()
622 def __hash__(self):
623 return hash(frozenset([self.id, self.uid, self.name, self.type, self.deleted]))
626 def __eq__(self, other):
627 return self.id == other.id
630 def __repr__(self):
631 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
635 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
636 'publisher_update_token', 'userpage_token')
639 class TokenException(Exception):
640 pass
643 class User(BaseUser, SyncedDevicesMixin, SettingsMixin):
644 oldid = IntegerProperty()
645 devices = SchemaListProperty(Device)
646 published_objects = StringListProperty()
647 deleted = BooleanProperty(default=False)
648 suggestions_up_to_date = BooleanProperty(default=False)
649 twitter = StringProperty()
650 about = StringProperty()
651 google_email = StringProperty()
653 # token for accessing subscriptions of this use
654 subscriptions_token = StringProperty(default=None)
656 # token for accessing the favorite-episodes feed of this user
657 favorite_feeds_token = StringProperty(default=None)
659 # token for automatically updating feeds published by this user
660 publisher_update_token = StringProperty(default=None)
662 # token for accessing the userpage of this user
663 userpage_token = StringProperty(default=None)
665 class Meta:
666 app_label = 'users'
669 def create_new_token(self, token_name, length=32):
670 """ creates a new random token """
672 if token_name not in TOKEN_NAMES:
673 raise TokenException('Invalid token name %s' % token_name)
675 token = "".join(random.sample(string.letters+string.digits, length))
676 setattr(self, token_name, token)
678 @property
679 def active_devices(self):
680 not_deleted = lambda d: not d.deleted
681 return filter(not_deleted, self.devices)
684 @property
685 def inactive_devices(self):
686 deleted = lambda d: d.deleted
687 return filter(deleted, self.devices)
690 def get_devices_by_id(self, device_ids=None):
691 """ Returns a dict of {devices_id: device} """
692 if device_ids is None:
693 # return all devices
694 devices = self.devices
695 else:
696 devices = self.get_devices(device_ids)
698 return {device.id: device for device in devices}
701 def get_device(self, id):
703 if not hasattr(self, '__device_by_id'):
704 self.__devices_by_id = self.get_devices_by_id()
706 return self.__devices_by_id.get(id, None)
709 def get_devices(self, ids):
710 return filter(None, (self.get_device(dev_id) for dev_id in ids))
713 def get_device_by_uid(self, uid, only_active=True):
715 if not hasattr(self, '__devices_by_uio'):
716 self.__devices_by_uid = dict( (d.uid, d) for d in self.devices)
718 try:
719 device = self.__devices_by_uid[uid]
721 if only_active and device.deleted:
722 raise DeviceDeletedException(
723 'Device with UID %s is deleted' % uid)
725 return device
727 except KeyError as e:
728 raise DeviceDoesNotExist('There is no device with UID %s' % uid)
731 def set_device(self, device):
733 if not RE_DEVICE_UID.match(device.uid):
734 raise DeviceUIDException(u"'{uid} is not a valid device ID".format(
735 uid=device.uid))
737 devices = list(self.devices)
738 ids = [x.id for x in devices]
739 if not device.id in ids:
740 devices.append(device)
741 self.devices = devices
742 return
744 index = ids.index(device.id)
745 devices.pop(index)
746 devices.insert(index, device)
747 self.devices = devices
750 def remove_device(self, device):
751 devices = list(self.devices)
752 ids = [x.id for x in devices]
753 if not device.id in ids:
754 return
756 index = ids.index(device.id)
757 devices.pop(index)
758 self.devices = devices
760 if self.is_synced(device):
761 self.unsync_device(device)
764 def get_subscriptions_by_device(self, public=None):
765 from mygpo.db.couchdb.podcast_state import subscriptions_by_user
766 get_dev = itemgetter(2)
767 groups = collections.defaultdict(list)
768 subscriptions = subscriptions_by_user(self, public=public)
769 subscriptions = sorted(subscriptions, key=get_dev)
771 for public, podcast_id, device_id in subscriptions:
772 groups[device_id].append(podcast_id)
774 return groups
776 def get_subscribed_podcast_ids(self, public=None):
777 from mygpo.db.couchdb.podcast_state import get_subscribed_podcast_states_by_user
778 states = get_subscribed_podcast_states_by_user(self, public)
779 return [state.podcast for state in states]
783 def get_subscription_history(self, device_id=None, reverse=False, public=None):
784 """ Returns chronologically ordered subscription history entries
786 Setting device_id restricts the actions to a certain device
789 from mygpo.db.couchdb.podcast_state import podcast_states_for_user, \
790 podcast_states_for_device
792 def action_iter(state):
793 for action in sorted(state.actions, reverse=reverse):
794 if device_id is not None and device_id != action.device:
795 continue
797 if public is not None and state.is_public() != public:
798 continue
800 entry = HistoryEntry()
801 entry.timestamp = action.timestamp
802 entry.action = action.action
803 entry.podcast_id = state.podcast
804 entry.device_id = action.device
805 yield entry
807 if device_id is None:
808 podcast_states = podcast_states_for_user(self)
809 else:
810 podcast_states = podcast_states_for_device(device_id)
812 # create an action_iter for each PodcastUserState
813 subscription_action_lists = [action_iter(x) for x in podcast_states]
815 action_cmp_key = lambda x: x.timestamp
817 # Linearize their subscription-actions
818 return linearize(action_cmp_key, subscription_action_lists, reverse)
821 def get_global_subscription_history(self, public=None):
822 """ Actions that added/removed podcasts from the subscription list
824 Returns an iterator of all subscription actions that either
825 * added subscribed a podcast that hasn't been subscribed directly
826 before the action (but could have been subscribed) earlier
827 * removed a subscription of the podcast is not longer subscribed
828 after the action
831 subscriptions = collections.defaultdict(int)
833 for entry in self.get_subscription_history(public=public):
834 if entry.action == 'subscribe':
835 subscriptions[entry.podcast_id] += 1
837 # a new subscription has been added
838 if subscriptions[entry.podcast_id] == 1:
839 yield entry
841 elif entry.action == 'unsubscribe':
842 subscriptions[entry.podcast_id] -= 1
844 # the last subscription has been removed
845 if subscriptions[entry.podcast_id] == 0:
846 yield entry
850 def get_newest_episodes(self, max_date, max_per_podcast=5):
851 """ Returns the newest episodes of all subscribed podcasts
853 Only max_per_podcast episodes per podcast are loaded. Episodes with
854 release dates above max_date are discarded.
856 This method returns a generator that produces the newest episodes.
858 The number of required DB queries is equal to the number of (distinct)
859 podcasts of all consumed episodes (max: number of subscribed podcasts),
860 plus a constant number of initial queries (when the first episode is
861 consumed). """
863 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
865 podcasts = list(self.get_subscribed_podcasts())
866 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
867 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
868 reverse=True)
870 podcast_dict = dict((p.get_id(), p) for p in podcasts)
872 # contains the un-yielded episodes, newest first
873 episodes = []
875 for podcast in podcasts:
877 yielded_episodes = 0
879 for episode in episodes:
880 # determine for which episodes there won't be a new episodes
881 # that is newer; those can be yielded
882 if episode.released > podcast.latest_episode_timestamp:
883 p = podcast_dict.get(episode.podcast, None)
884 yield proxy_object(episode, podcast=p)
885 yielded_episodes += 1
886 else:
887 break
889 # remove the episodes that have been yielded before
890 episodes = episodes[yielded_episodes:]
892 # fetch and merge episodes for the next podcast
893 # TODO: max_per_podcast
894 new_episodes = podcast.episode_set.filter(release__isnull=False,
895 released__lt=max_date)
896 new_episodes = new_episodes[:max_per_podcast]
897 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
900 # yield the remaining episodes
901 for episode in episodes:
902 podcast = podcast_dict.get(episode.podcast, None)
903 yield proxy_object(episode, podcast=podcast)
906 def __eq__(self, other):
907 if not other:
908 return False
910 # ensure that other isn't AnonymousUser
911 return other.is_authenticated() and self._id == other._id
914 def __ne__(self, other):
915 return not(self == other)
918 def __repr__(self):
919 return 'User %s' % self._id
922 class History(object):
924 def __init__(self, user, device):
925 self.user = user
926 self.device = device
929 def __getitem__(self, key):
931 if isinstance(key, slice):
932 start = key.start or 0
933 length = key.stop - start
934 else:
935 start = key
936 length = 1
938 if self.device:
939 return device_history(self.user, self.device, start, length)
941 else:
942 return user_history(self.user, start, length)
946 class HistoryEntry(object):
947 """ A class that can represent subscription and episode actions """
950 @classmethod
951 def from_action_dict(cls, action):
953 entry = HistoryEntry()
955 if 'timestamp' in action:
956 ts = action.pop('timestamp')
957 entry.timestamp = dateutil.parser.parse(ts)
959 for key, value in action.items():
960 setattr(entry, key, value)
962 return entry
965 @property
966 def playmark(self):
967 return getattr(self, 'position', None)
970 @classmethod
971 def fetch_data(cls, user, entries,
972 podcasts=None, episodes=None):
973 """ Efficiently loads additional data for a number of entries """
975 if podcasts is None:
976 # load podcast data
977 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
978 podcast_ids = filter(None, podcast_ids)
979 podcasts = Podcast.objects.filter(id__in=podcast_ids)\
980 .prefetch_related('slugs')
981 podcasts = {podcast.id.hex: podcast for podcast in podcasts}
983 if episodes is None:
984 # load episode data
985 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
986 episode_ids = filter(None, episode_ids)
987 episodes = Episode.objects.filter(id__in=episode_ids)\
988 .select_related('podcast')\
989 .prefetch_related('slugs',
990 'podcast__slugs')
991 episodes = {episode.id.hex: episode for episode in episodes}
993 # load device data
994 # does not need pre-populated data because no db-access is required
995 device_ids = [getattr(x, 'device_id', None) for x in entries]
996 device_ids = filter(None, device_ids)
997 devices = {client.id.hex: client for client in user.client_set.all()}
1000 for entry in entries:
1001 podcast_id = getattr(entry, 'podcast_id', None)
1002 entry.podcast = podcasts.get(podcast_id, None)
1004 episode_id = getattr(entry, 'episode_id', None)
1005 entry.episode = episodes.get(episode_id, None)
1007 if hasattr(entry, 'user'):
1008 entry.user = user
1010 device = devices.get(getattr(entry, 'device_id', None), None)
1011 entry.device = device
1014 return entries
1017 def create_missing_profile(sender, **kwargs):
1018 """ Creates a UserProfile if a User doesn't have one """
1019 user = kwargs['instance']
1021 if not hasattr(user, 'profile'):
1022 # TODO: remove uuid column once migration from CouchDB is complete
1023 import uuid
1024 profile = UserProfile.objects.create(user=user, uuid=uuid.uuid1())
1025 user.profile = profile