Advanced API: performance optimizations
[mygpo.git] / mygpo / users / models.py
blob374f882dbab9aa1e7f264ae21055f102a9f111f0
1 import re
2 import uuid, collections
3 from datetime import datetime
4 import dateutil.parser
5 from itertools import imap
6 from operator import itemgetter
8 from couchdbkit import ResourceNotFound
9 from couchdbkit.ext.django.schema import *
11 from django_couchdb_utils.registration.models import User as BaseUser
13 from mygpo.core.proxy import proxy_object, DocumentABCMeta
14 from mygpo.core.models import Podcast, Episode
15 from mygpo.utils import linearize, get_to_dict, iterate_together
16 from mygpo.decorators import repeat_on_conflict
17 from mygpo.users.ratings import RatingMixin
18 from mygpo.users.sync import SyncedDevicesMixin
19 from mygpo.log import log
22 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
25 class DeviceUIDException(Exception):
26 pass
30 class Suggestions(Document, RatingMixin):
31 user = StringProperty(required=True)
32 user_oldid = IntegerProperty()
33 podcasts = StringListProperty()
34 blacklist = StringListProperty()
36 @classmethod
37 def for_user(cls, user):
38 r = cls.view('users/suggestions_by_user', key=user._id, \
39 include_docs=True)
40 if r:
41 return r.first()
42 else:
43 s = Suggestions()
44 s.user = user._id
45 return s
48 def get_podcasts(self, count=None):
49 user = User.get(self.user)
50 subscriptions = user.get_subscribed_podcast_ids()
52 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
53 if count:
54 ids = ids[:count]
55 return filter(lambda x: x and x.title, Podcast.get_multi(ids))
58 def __repr__(self):
59 if not self._id:
60 return super(Suggestions, self).__repr__()
61 else:
62 return '%d Suggestions for %s (%s)' % \
63 (len(self.podcasts), self.user, self._id)
66 class EpisodeAction(DocumentSchema):
67 """
68 One specific action to an episode. Must
69 always be part of a EpisodeUserState
70 """
72 action = StringProperty(required=True)
73 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
74 device_oldid = IntegerProperty(required=False)
75 device = StringProperty()
76 started = IntegerProperty()
77 playmark = IntegerProperty()
78 total = IntegerProperty()
80 def __eq__(self, other):
81 if not isinstance(other, EpisodeAction):
82 return False
83 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
84 'total')
85 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
88 def to_history_entry(self):
89 entry = HistoryEntry()
90 entry.action = self.action
91 entry.timestamp = self.timestamp
92 entry.device_id = self.device
93 entry.started = self.started
94 entry.position = self.playmark
95 entry.total = self.total
96 return entry
99 @staticmethod
100 def filter(user_id, since=None, until={}, podcast_id=None,
101 device_id=None):
102 """ Returns Episode Actions for the given criteria"""
104 since_str = since.strftime('%Y-%m-%dT%H:%M:%S') if since else None
105 until_str = until.strftime('%Y-%m-%dT%H:%M:%S') if until else {}
107 # further parts of the key are filled in below
108 startkey = [user_id, since_str, None, None]
109 endkey = [user_id, until_str, {}, {}]
111 # additional filter that are carried out by the
112 # application, not by the database
113 add_filters = []
115 if isinstance(podcast_id, basestring):
116 if until is not None: # filter in database
117 startkey[2] = podcast_id
118 endkey[2] = podcast_id
120 add_filters.append( lambda x: x['podcast_id'] == podcast_id )
122 elif isinstance(podcast_id, list):
123 add_filters.append( lambda x: x['podcast_id'] in podcast_id )
125 elif podcast_id is not None:
126 raise ValueError('podcast_id can be either None, basestring '
127 'or a list of basestrings')
130 if device_id:
131 if None not in (until, podcast_id): # filter in database
132 startkey[3] = device_id
133 endkey[3] = device_id
134 else:
135 dev_filter = lambda x: x.get('device_id', None) == device_id
136 add_filters.append(dev_filter)
139 db = EpisodeUserState.get_db()
140 res = db.view('users/episode_actions',
141 startkey = startkey,
142 endkey = endkey,
143 include_docs = True,
146 for r in res:
147 state = EpisodeUserState.wrap(r['doc'])
148 index = int(r['value'])
149 action = HistoryEntry.from_action_dict(state, index)
150 if all( f(action) for f in add_filters):
151 yield action
154 def validate_time_values(self):
155 """ Validates allowed combinations of time-values """
157 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
159 # Key found, but must not be supplied (no play action!)
160 if self.action != 'play':
161 for key in PLAY_ACTION_KEYS:
162 if getattr(self, key, None) is not None:
163 raise ValueError('%s only allowed in play actions' % key)
165 # Sanity check: If started or total are given, require playmark
166 if ((self.started is not None) or (self.total is not None)) and \
167 self.playmark is None:
168 raise ValueError('started and total require position')
170 # Sanity check: total and playmark can only appear together
171 if ((self.total is not None) or (self.started is not None)) and \
172 ((self.total is None) or (self.started is None)):
173 raise ValueError('total and started can only appear together')
176 def __repr__(self):
177 return '%s-Action on %s at %s (in %s)' % \
178 (self.action, self.device, self.timestamp, self._id)
181 def __hash__(self):
182 return hash(frozenset([self.action, self.timestamp, self.device,
183 self.started, self.playmark, self.total]))
186 class Chapter(Document):
187 """ A user-entered episode chapter """
189 device = StringProperty()
190 created = DateTimeProperty()
191 start = IntegerProperty(required=True)
192 end = IntegerProperty(required=True)
193 label = StringProperty()
194 advertisement = BooleanProperty()
196 @classmethod
197 def for_episode(cls, episode_id):
198 db = cls.get_db()
199 r = db.view('users/chapters_by_episode',
200 startkey = [episode_id, None],
201 endkey = [episode_id, {}],
202 wrap_doc = False,
205 for res in r:
206 user = res['key'][1]
207 chapter = Chapter.wrap(res['value'])
208 yield (user, chapter)
211 def __repr__(self):
212 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
213 self.start, self.end)
216 class EpisodeUserState(Document):
218 Contains everything a user has done with an Episode
221 episode = StringProperty(required=True)
222 actions = SchemaListProperty(EpisodeAction)
223 settings = DictProperty()
224 user_oldid = IntegerProperty()
225 user = StringProperty(required=True)
226 ref_url = StringProperty(required=True)
227 podcast_ref_url = StringProperty(required=True)
228 merged_ids = StringListProperty()
229 chapters = SchemaListProperty(Chapter)
230 podcast = StringProperty(required=True)
233 @classmethod
234 def for_user_episode(cls, user, episode):
235 r = cls.view('users/episode_states_by_user_episode',
236 key=[user.id, episode._id], include_docs=True)
238 if r:
239 return r.first()
241 else:
242 podcast = Podcast.get(episode.podcast)
244 state = EpisodeUserState()
245 state.episode = episode._id
246 state.podcast = episode.podcast
247 state.user = user._id
248 state.ref_url = episode.url
249 state.podcast_ref_url = podcast.url
251 return state
253 @classmethod
254 def for_ref_urls(cls, user, podcast_url, episode_url):
255 res = cls.view('users/episode_states_by_ref_urls',
256 key = [user.id, podcast_url, episode_url], limit=1, include_docs=True)
257 if res:
258 state = res.first()
259 state.ref_url = episode_url
260 state.podcast_ref_url = podcast_url
261 return state
263 else:
264 podcast = Podcast.for_url(podcast_url, create=True)
265 episode = Episode.for_podcast_id_url(podcast.get_id(), episode_url,
266 create=True)
268 return episode.get_user_state(user)
271 @classmethod
272 def count(cls):
273 r = cls.view('users/episode_states_by_user_episode',
274 limit=0)
275 return r.total_rows
278 def add_actions(self, actions):
279 map(EpisodeAction.validate_time_values, actions)
280 self.actions = list(self.actions) + actions
281 self.actions = list(set(self.actions))
282 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
285 def is_favorite(self):
286 return self.settings.get('is_favorite', False)
289 def set_favorite(self, set_to=True):
290 self.settings['is_favorite'] = set_to
293 def update_chapters(self, add=[], rem=[]):
294 """ Updates the Chapter list
296 * add contains the chapters to be added
298 * rem contains tuples of (start, end) times. Chapters that match
299 both endpoints will be removed
302 @repeat_on_conflict(['state'])
303 def update(state):
304 for chapter in add:
305 self.chapters = self.chapters + [chapter]
307 for start, end in rem:
308 keep = lambda c: c.start != start or c.end != end
309 self.chapters = filter(keep, self.chapters)
311 self.save()
313 update(state=self)
316 def get_history_entries(self):
317 return imap(EpisodeAction.to_history_entry, self.actions)
320 def __repr__(self):
321 return 'Episode-State %s (in %s)' % \
322 (self.episode, self._id)
324 def __eq__(self, other):
325 if not isinstance(other, EpisodeUserState):
326 return False
328 return (self.episode == other.episode and
329 self.user_oldid == other.user_oldid)
333 class SubscriptionAction(Document):
334 action = StringProperty()
335 timestamp = DateTimeProperty(default=datetime.utcnow)
336 device = StringProperty()
339 __metaclass__ = DocumentABCMeta
342 def __cmp__(self, other):
343 return cmp(self.timestamp, other.timestamp)
345 def __eq__(self, other):
346 return self.action == other.action and \
347 self.timestamp == other.timestamp and \
348 self.device == other.device
350 def __hash__(self):
351 return hash(self.action) + hash(self.timestamp) + hash(self.device)
353 def __repr__(self):
354 return '<SubscriptionAction %s on %s at %s>' % (
355 self.action, self.device, self.timestamp)
358 class PodcastUserState(Document):
360 Contains everything that a user has done
361 with a specific podcast and all its episodes
364 podcast = StringProperty(required=True)
365 user_oldid = IntegerProperty()
366 user = StringProperty(required=True)
367 settings = DictProperty()
368 actions = SchemaListProperty(SubscriptionAction)
369 tags = StringListProperty()
370 ref_url = StringProperty(required=True)
371 disabled_devices = StringListProperty()
372 merged_ids = StringListProperty()
375 @classmethod
376 def for_user_podcast(cls, user, podcast):
377 r = PodcastUserState.view('users/podcast_states_by_podcast', \
378 key=[podcast.get_id(), user.id], limit=1, include_docs=True)
379 if r:
380 return r.first()
381 else:
382 p = PodcastUserState()
383 p.podcast = podcast.get_id()
384 p.user = user._id
385 p.ref_url = podcast.url
386 p.settings['public_subscription'] = user.settings.get('public_subscriptions', True)
388 p.set_device_state(user.devices)
390 return p
393 @classmethod
394 def for_user(cls, user):
395 r = PodcastUserState.view('users/podcast_states_by_user',
396 startkey = [user._id, None],
397 endkey = [user._id, 'ZZZZ'],
398 include_docs = True,
400 return list(r)
403 @classmethod
404 def for_device(cls, device_id):
405 r = PodcastUserState.view('users/podcast_states_by_device',
406 startkey=[device_id, None], endkey=[device_id, {}],
407 include_docs=True)
408 return list(r)
411 def remove_device(self, device):
413 Removes all actions from the podcast state that refer to the
414 given device
416 self.actions = filter(lambda a: a.device != device.id, self.actions)
419 @classmethod
420 def count(cls):
421 r = PodcastUserState.view('users/podcast_states_by_user',
422 limit=0)
423 return r.total_rows
426 def subscribe(self, device):
427 action = SubscriptionAction()
428 action.action = 'subscribe'
429 action.device = device.id
430 self.add_actions([action])
433 def unsubscribe(self, device):
434 action = SubscriptionAction()
435 action.action = 'unsubscribe'
436 action.device = device.id
437 self.add_actions([action])
440 def add_actions(self, actions):
441 self.actions = list(set(self.actions + actions))
442 self.actions = sorted(self.actions)
445 def add_tags(self, tags):
446 self.tags = list(set(self.tags + tags))
449 def set_device_state(self, devices):
450 disabled_devices = [device.id for device in devices if device.deleted]
451 self.disabled_devices = disabled_devices
454 def get_change_between(self, device_id, since, until):
456 Returns the change of the subscription status for the given device
457 between the two timestamps.
459 The change is given as either 'subscribe' (the podcast has been
460 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
461 None (no change)
464 device_actions = filter(lambda x: x.device == device_id, self.actions)
465 before = filter(lambda x: x.timestamp <= since, device_actions)
466 after = filter(lambda x: x.timestamp <= until, device_actions)
468 # nothing happened, so there can be no change
469 if not after:
470 return None
472 then = before[-1] if before else None
473 now = after[-1]
475 if then is None:
476 if now.action != 'unsubscribe':
477 return now.action
478 elif then.action != now.action:
479 return now.action
480 return None
483 def get_subscribed_device_ids(self):
484 r = PodcastUserState.view('users/subscriptions_by_podcast',
485 startkey = [self.podcast, self.user, None],
486 endkey = [self.podcast, self.user, {}],
487 reduce = False,
489 return (res['key'][2] for res in r)
492 def is_public(self):
493 return self.settings.get('public_subscription', True)
496 def __eq__(self, other):
497 if other is None:
498 return False
500 return self.podcast == other.podcast and \
501 self.user == other.user
503 def __repr__(self):
504 return 'Podcast %s for User %s (%s)' % \
505 (self.podcast, self.user, self._id)
508 class Device(Document):
509 id = StringProperty(default=lambda: uuid.uuid4().hex)
510 oldid = IntegerProperty(required=False)
511 uid = StringProperty(required=True)
512 name = StringProperty(required=True, default='New Device')
513 type = StringProperty(required=True, default='other')
514 settings = DictProperty()
515 deleted = BooleanProperty(default=False)
517 @classmethod
518 def for_oldid(cls, oldid):
519 r = cls.view('users/devices_by_oldid', key=oldid)
520 return r.first() if r else None
523 def get_subscription_changes(self, since, until):
525 Returns the subscription changes for the device as two lists.
526 The first lists contains the Ids of the podcasts that have been
527 subscribed to, the second list of those that have been unsubscribed
528 from.
531 add, rem = [], []
532 podcast_states = PodcastUserState.for_device(self.id)
533 for p_state in podcast_states:
534 change = p_state.get_change_between(self.id, since, until)
535 if change == 'subscribe':
536 add.append( p_state.podcast )
537 elif change == 'unsubscribe':
538 rem.append( p_state.podcast )
540 return add, rem
543 def get_latest_changes(self):
544 podcast_states = PodcastUserState.for_device(self.id)
545 for p_state in podcast_states:
546 actions = filter(lambda x: x.device == self.id, reversed(p_state.actions))
547 if actions:
548 yield (p_state.podcast, actions[0])
551 def get_subscribed_podcast_ids(self):
552 r = self.view('users/subscribed_podcasts_by_device',
553 startkey = [self.id, None],
554 endkey = [self.id, {}]
556 return [res['key'][1] for res in r]
559 def get_subscribed_podcasts(self):
560 return Podcast.get_multi(self.get_subscribed_podcast_ids())
563 def __hash__(self):
564 return hash(frozenset([self.uid, self.name, self.type, self.deleted]))
567 def __eq__(self, other):
568 return self.id == other.id
571 def __repr__(self):
572 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
575 def __str__(self):
576 return self.name
579 def token_generator(length=32):
580 import random, string
581 return "".join(random.sample(string.letters+string.digits, length))
584 class User(BaseUser, SyncedDevicesMixin):
585 oldid = IntegerProperty()
586 settings = DictProperty()
587 devices = SchemaListProperty(Device)
588 published_objects = StringListProperty()
589 deleted = BooleanProperty(default=False)
590 suggestions_up_to_date = BooleanProperty(default=False)
592 # token for accessing subscriptions of this use
593 subscriptions_token = StringProperty(default=token_generator)
595 # token for accessing the favorite-episodes feed of this user
596 favorite_feeds_token = StringProperty(default=token_generator)
598 # token for automatically updating feeds published by this user
599 publisher_update_token = StringProperty(default=token_generator)
601 class Meta:
602 app_label = 'users'
604 @classmethod
605 def for_oldid(cls, oldid):
606 r = cls.view('users/users_by_oldid', key=oldid, limit=1, include_docs=True)
607 return r.one() if r else None
610 def create_new_token(self, token_name, length=32):
611 setattr(self, token_name, token_generator(length))
614 @property
615 def active_devices(self):
616 not_deleted = lambda d: not d.deleted
617 return filter(not_deleted, self.devices)
620 @property
621 def inactive_devices(self):
622 deleted = lambda d: d.deleted
623 return filter(deleted, self.devices)
626 def get_device(self, id):
627 for device in self.devices:
628 if device.id == id:
629 return device
631 return None
634 def get_device_by_uid(self, uid):
635 for device in self.devices:
636 if device.uid == uid:
637 return device
640 def get_device_by_oldid(self, oldid):
641 for device in self.devices:
642 if device.oldid == oldid:
643 return device
646 @repeat_on_conflict(['self'])
647 def update_device(self, device):
648 """ Sets the device and saves the user """
649 self.set_device(device)
650 self.save()
653 def set_device(self, device):
655 if not RE_DEVICE_UID.match(device.uid):
656 raise DeviceUIDException("'{uid} is not a valid device ID".format(
657 uid=device.uid))
659 devices = list(self.devices)
660 ids = [x.id for x in devices]
661 if not device.id in ids:
662 devices.append(device)
663 self.devices = devices
664 return
666 index = ids.index(device.id)
667 devices.pop(index)
668 devices.insert(index, device)
669 self.devices = devices
672 def remove_device(self, device):
673 devices = list(self.devices)
674 ids = [x.id for x in devices]
675 if not device.id in ids:
676 return
678 index = ids.index(device.id)
679 devices.pop(index)
680 self.devices = devices
682 if self.is_synced(device):
683 self.unsync_device(device)
687 def get_subscriptions(self, public=None):
689 Returns a list of (podcast-id, device-id) tuples for all
690 of the users subscriptions
693 r = PodcastUserState.view('users/subscribed_podcasts_by_user',
694 startkey = [self._id, public, None, None],
695 endkey = [self._id+'ZZZ', None, None, None],
696 reduce = False,
698 return [res['key'][1:] for res in r]
701 def get_subscriptions_by_device(self, public=None):
702 get_dev = itemgetter(2)
703 groups = collections.defaultdict(list)
704 subscriptions = self.get_subscriptions(public=public)
705 subscriptions = sorted(subscriptions, key=get_dev)
707 for public, podcast_id, device_id in subscriptions:
708 groups[device_id].append(podcast_id)
710 return groups
713 def get_subscribed_podcast_ids(self, public=None):
715 Returns the Ids of all subscribed podcasts
717 return list(set(x[1] for x in self.get_subscriptions(public=public)))
720 def get_subscribed_podcasts(self, public=None):
721 return Podcast.get_multi(self.get_subscribed_podcast_ids(public=public))
724 def get_subscription_history(self, device_id=None, reverse=False, public=None):
725 """ Returns chronologically ordered subscription history entries
727 Setting device_id restricts the actions to a certain device
730 def action_iter(state):
731 for action in sorted(state.actions, reverse=reverse):
732 if device_id is not None and device_id != action.device:
733 continue
735 if public is not None and state.is_public() != public:
736 continue
738 entry = HistoryEntry()
739 entry.timestamp = action.timestamp
740 entry.action = action.action
741 entry.podcast_id = state.podcast
742 entry.device_id = action.device
743 yield entry
745 if device_id is None:
746 podcast_states = PodcastUserState.for_user(self)
747 else:
748 podcast_states = PodcastUserState.for_device(device_id)
750 # create an action_iter for each PodcastUserState
751 subscription_action_lists = [action_iter(x) for x in podcast_states]
753 action_cmp_key = lambda x: x.timestamp
755 # Linearize their subscription-actions
756 return linearize(action_cmp_key, subscription_action_lists, reverse)
759 def get_global_subscription_history(self, public=None):
760 """ Actions that added/removed podcasts from the subscription list
762 Returns an iterator of all subscription actions that either
763 * added subscribed a podcast that hasn't been subscribed directly
764 before the action (but could have been subscribed) earlier
765 * removed a subscription of the podcast is not longer subscribed
766 after the action
769 subscriptions = collections.defaultdict(int)
771 for entry in self.get_subscription_history(public=public):
772 if entry.action == 'subscribe':
773 subscriptions[entry.podcast_id] += 1
775 # a new subscription has been added
776 if subscriptions[entry.podcast_id] == 1:
777 yield entry
779 elif entry.action == 'unsubscribe':
780 subscriptions[entry.podcast_id] -= 1
782 # the last subscription has been removed
783 if subscriptions[entry.podcast_id] == 0:
784 yield entry
788 def get_newest_episodes(self, max_date, max_per_podcast=5):
789 """ Returns the newest episodes of all subscribed podcasts
791 Only max_per_podcast episodes per podcast are loaded. Episodes with
792 release dates above max_date are discarded.
794 This method returns a generator that produces the newest episodes.
796 The number of required DB queries is equal to the number of (distinct)
797 podcasts of all consumed episodes (max: number of subscribed podcasts),
798 plus a constant number of initial queries (when the first episode is
799 consumed). """
801 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
803 podcasts = list(self.get_subscribed_podcasts())
804 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
805 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
806 reverse=True)
808 podcast_dict = dict((p.get_id(), p) for p in podcasts)
810 # contains the un-yielded episodes, newest first
811 episodes = []
813 for podcast in podcasts:
815 yielded_episodes = 0
817 for episode in episodes:
818 # determine for which episodes there won't be a new episodes
819 # that is newer; those can be yielded
820 if episode.released > podcast.latest_episode_timestamp:
821 p = podcast_dict.get(episode.podcast, None)
822 yield proxy_object(episode, podcast=p)
823 yielded_episodes += 1
824 else:
825 break
827 # remove the episodes that have been yielded before
828 episodes = episodes[yielded_episodes:]
830 # fetch and merge episodes for the next podcast
831 new_episodes = list(podcast.get_episodes(since=1, until=max_date,
832 descending=True, limit=max_per_podcast))
833 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
836 # yield the remaining episodes
837 for episode in episodes:
838 podcast = podcast_dict.get(episode.podcast, None)
839 yield proxy_object(episode, podcast=podcast)
843 def save(self, *args, **kwargs):
844 super(User, self).save(*args, **kwargs)
846 podcast_states = PodcastUserState.for_user(self)
847 for state in podcast_states:
848 @repeat_on_conflict(['state'])
849 def _update_state(state):
850 old_devs = set(state.disabled_devices)
851 state.set_device_state(self.devices)
853 if old_devs != set(state.disabled_devices):
854 state.save()
856 _update_state(state=state)
861 def __eq__(self, other):
862 if not other:
863 return False
865 return self._id == other._id
868 def __repr__(self):
869 return 'User %s' % self._id
872 class History(object):
874 def __init__(self, user, device):
875 self.user = user
876 self.device = device
877 self._db = EpisodeUserState.get_db()
879 if device:
880 self._view = 'users/device_history'
881 self._startkey = [self.user._id, device.id, None]
882 self._endkey = [self.user._id, device.id, {}]
883 else:
884 self._view = 'users/history'
885 self._startkey = [self.user._id, None]
886 self._endkey = [self.user._id, {}]
889 def __getitem__(self, key):
891 if isinstance(key, slice):
892 start = key.start or 0
893 length = key.stop - start
894 else:
895 start = key
896 length = 1
898 res = self._db.view(self._view,
899 descending = True,
900 startkey = self._endkey,
901 endkey = self._startkey,
902 limit = length,
903 skip = start,
904 include_docs = True,
907 for action in res:
908 state_doc = action['doc']
909 index = int(action['value'])
911 if state_doc['doc_type'] == 'EpisodeUserState':
912 state = EpisodeUserState.wrap(state_doc)
913 else:
914 state = PodcastUserState.wrap(state_doc)
916 yield HistoryEntry.from_action_dict(state, index)
920 class HistoryEntry(object):
921 """ A class that can represent subscription and episode actions """
924 @classmethod
925 def from_action_dict(cls, state, index):
927 entry = HistoryEntry()
928 action = state.actions[index]
930 if isinstance(state, EpisodeUserState):
931 entry.type = 'Episode'
932 entry.podcast_url = state.podcast_ref_url
933 entry.episode_url = state.ref_url
934 entry.podcast_id = state.podcast
935 entry.episode_id = state.episode
936 if action.device:
937 entry.device_id = action.device
938 if action.started:
939 entry.started = action.started
940 if action.playmark:
941 entry.position = action.playmark
942 if action.total:
943 entry.total = action.total
945 else:
946 entry.type = 'Subscription'
947 entry.podcast_url = state.ref_url
948 entry.podcast_id = state.podcast
951 entry.action = action.action
952 entry.timestamp = action.timestamp
954 return entry
957 @property
958 def playmark(self):
959 return getattr(self, 'position', None)
962 @classmethod
963 def fetch_data(cls, user, entries,
964 podcasts=None, episodes=None):
965 """ Efficiently loads additional data for a number of entries """
967 if podcasts is None:
968 # load podcast data
969 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
970 podcast_ids = filter(None, podcast_ids)
971 podcasts = get_to_dict(Podcast, podcast_ids, get_id=Podcast.get_id)
973 if episodes is None:
974 # load episode data
975 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
976 episode_ids = filter(None, episode_ids)
977 episodes = get_to_dict(Episode, episode_ids)
979 # load device data
980 # does not need pre-populated data because no db-access is required
981 device_ids = [getattr(x, 'device_id', None) for x in entries]
982 device_ids = filter(None, device_ids)
983 devices = dict([ (id, user.get_device(id)) for id in device_ids])
986 for entry in entries:
987 podcast_id = getattr(entry, 'podcast_id', None)
988 entry.podcast = podcasts.get(podcast_id, None)
990 episode_id = getattr(entry, 'episode_id', None)
991 entry.episode = episodes.get(episode_id, None)
992 entry.user = user
994 device = devices.get(getattr(entry, 'device_id', None), None)
995 entry.device = device
998 return entries