don't compare episode actions by their user_oldid
[mygpo.git] / mygpo / users / models.py
blob03cbf22bd2995ac98a36aaa6c5920db9eab896c1
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
29 class DeviceDoesNotExist(Exception):
30 pass
33 class DeviceDeletedException(DeviceDoesNotExist):
34 pass
37 class Suggestions(Document, RatingMixin):
38 user = StringProperty(required=True)
39 user_oldid = IntegerProperty()
40 podcasts = StringListProperty()
41 blacklist = StringListProperty()
43 @classmethod
44 def for_user(cls, user):
45 r = cls.view('suggestions/by_user', key=user._id, \
46 include_docs=True)
47 if r:
48 return r.first()
49 else:
50 s = Suggestions()
51 s.user = user._id
52 return s
55 def get_podcasts(self, count=None):
56 user = User.get(self.user)
57 subscriptions = user.get_subscribed_podcast_ids()
59 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
60 if count:
61 ids = ids[:count]
62 return filter(lambda x: x and x.title, Podcast.get_multi(ids))
65 def __repr__(self):
66 if not self._id:
67 return super(Suggestions, self).__repr__()
68 else:
69 return '%d Suggestions for %s (%s)' % \
70 (len(self.podcasts), self.user, self._id)
73 class EpisodeAction(DocumentSchema):
74 """
75 One specific action to an episode. Must
76 always be part of a EpisodeUserState
77 """
79 action = StringProperty(required=True)
80 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
81 device_oldid = IntegerProperty(required=False)
82 device = StringProperty()
83 started = IntegerProperty()
84 playmark = IntegerProperty()
85 total = IntegerProperty()
87 def __eq__(self, other):
88 if not isinstance(other, EpisodeAction):
89 return False
90 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
91 'total')
92 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
95 def to_history_entry(self):
96 entry = HistoryEntry()
97 entry.action = self.action
98 entry.timestamp = self.timestamp
99 entry.device_id = self.device
100 entry.started = self.started
101 entry.position = self.playmark
102 entry.total = self.total
103 return entry
106 @staticmethod
107 def filter(user_id, since=None, until={}, podcast_id=None,
108 device_id=None):
109 """ Returns Episode Actions for the given criteria"""
111 since_str = since.strftime('%Y-%m-%dT%H:%M:%S') if since else None
112 until_str = until.strftime('%Y-%m-%dT%H:%M:%S') if until else {}
115 if not podcast_id and not device_id:
116 view = 'episode_actions/by_user'
117 startkey = [user_id, since_str]
118 endkey = [user_id, until_str]
120 elif podcast_id and not device_id:
121 view = 'episode_actions/by_podcast'
122 startkey = [user_id, podcast_id, since_str]
123 endkey = [user_id, podcast_id, until_str]
125 elif device_id and not podcast_id:
126 view = 'episode_actions/by_device'
127 startkey = [user_id, device_id, since_str]
128 endkey = [user_id, device_id, until_str]
130 else:
131 view = 'episode_actions/by_podcast_device'
132 startkey = [user_id, podcast_id, device_id, since_str]
133 endkey = [user_id, podcast_id, device_id, until_str]
135 db = EpisodeUserState.get_db()
136 res = db.view(view,
137 startkey = startkey,
138 endkey = endkey
141 for r in res:
142 action = r['value']
143 yield action
146 def validate_time_values(self):
147 """ Validates allowed combinations of time-values """
149 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
151 # Key found, but must not be supplied (no play action!)
152 if self.action != 'play':
153 for key in PLAY_ACTION_KEYS:
154 if getattr(self, key, None) is not None:
155 raise ValueError('%s only allowed in play actions' % key)
157 # Sanity check: If started or total are given, require playmark
158 if ((self.started is not None) or (self.total is not None)) and \
159 self.playmark is None:
160 raise ValueError('started and total require position')
162 # Sanity check: total and playmark can only appear together
163 if ((self.total is not None) or (self.started is not None)) and \
164 ((self.total is None) or (self.started is None)):
165 raise ValueError('total and started can only appear together')
168 def __repr__(self):
169 return '%s-Action on %s at %s (in %s)' % \
170 (self.action, self.device, self.timestamp, self._id)
173 def __hash__(self):
174 return hash(frozenset([self.action, self.timestamp, self.device,
175 self.started, self.playmark, self.total]))
178 class Chapter(Document):
179 """ A user-entered episode chapter """
181 device = StringProperty()
182 created = DateTimeProperty()
183 start = IntegerProperty(required=True)
184 end = IntegerProperty(required=True)
185 label = StringProperty()
186 advertisement = BooleanProperty()
188 @classmethod
189 def for_episode(cls, episode_id):
190 db = cls.get_db()
191 r = db.view('chapters/by_episode',
192 startkey = [episode_id, None],
193 endkey = [episode_id, {}],
194 wrap_doc = False,
197 for res in r:
198 user = res['key'][1]
199 chapter = Chapter.wrap(res['value'])
200 yield (user, chapter)
203 def __repr__(self):
204 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
205 self.start, self.end)
208 class EpisodeUserState(Document):
210 Contains everything a user has done with an Episode
213 episode = StringProperty(required=True)
214 actions = SchemaListProperty(EpisodeAction)
215 settings = DictProperty()
216 user_oldid = IntegerProperty()
217 user = StringProperty(required=True)
218 ref_url = StringProperty(required=True)
219 podcast_ref_url = StringProperty(required=True)
220 merged_ids = StringListProperty()
221 chapters = SchemaListProperty(Chapter)
222 podcast = StringProperty(required=True)
225 @classmethod
226 def for_user_episode(cls, user, episode):
227 r = cls.view('episode_states/by_user_episode',
228 key = [user._id, episode._id],
229 include_docs = True,
230 limit = 1,
233 if r:
234 return r.first()
236 else:
237 podcast = Podcast.get(episode.podcast)
239 state = EpisodeUserState()
240 state.episode = episode._id
241 state.podcast = episode.podcast
242 state.user = user._id
243 state.ref_url = episode.url
244 state.podcast_ref_url = podcast.url
246 return state
248 @classmethod
249 def for_ref_urls(cls, user, podcast_url, episode_url):
250 res = cls.view('episode_states/by_ref_urls',
251 key = [user._id, podcast_url, episode_url], limit=1, include_docs=True)
252 if res:
253 state = res.first()
254 state.ref_url = episode_url
255 state.podcast_ref_url = podcast_url
256 return state
258 else:
259 episode = Episode.for_podcast_url(podcast_url, episode_url, create=True)
260 return episode.get_user_state(user)
263 @classmethod
264 def count(cls):
265 r = cls.view('episode_states/by_user_episode',
266 limit = 0,
267 stale = 'update_after',
269 return r.total_rows
272 def add_actions(self, actions):
273 map(EpisodeAction.validate_time_values, actions)
274 self.actions = list(self.actions) + actions
275 self.actions = list(set(self.actions))
276 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
279 def is_favorite(self):
280 return self.settings.get('is_favorite', False)
283 def set_favorite(self, set_to=True):
284 self.settings['is_favorite'] = set_to
287 def update_chapters(self, add=[], rem=[]):
288 """ Updates the Chapter list
290 * add contains the chapters to be added
292 * rem contains tuples of (start, end) times. Chapters that match
293 both endpoints will be removed
296 @repeat_on_conflict(['state'])
297 def update(state):
298 for chapter in add:
299 self.chapters = self.chapters + [chapter]
301 for start, end in rem:
302 keep = lambda c: c.start != start or c.end != end
303 self.chapters = filter(keep, self.chapters)
305 self.save()
307 update(state=self)
310 def get_history_entries(self):
311 return imap(EpisodeAction.to_history_entry, self.actions)
314 def __repr__(self):
315 return 'Episode-State %s (in %s)' % \
316 (self.episode, self._id)
318 def __eq__(self, other):
319 if not isinstance(other, EpisodeUserState):
320 return False
322 return (self.episode == other.episode and
323 self.user == other.user)
327 class SubscriptionAction(Document):
328 action = StringProperty()
329 timestamp = DateTimeProperty(default=datetime.utcnow)
330 device = StringProperty()
333 __metaclass__ = DocumentABCMeta
336 def __cmp__(self, other):
337 return cmp(self.timestamp, other.timestamp)
339 def __eq__(self, other):
340 return self.action == other.action and \
341 self.timestamp == other.timestamp and \
342 self.device == other.device
344 def __hash__(self):
345 return hash(self.action) + hash(self.timestamp) + hash(self.device)
347 def __repr__(self):
348 return '<SubscriptionAction %s on %s at %s>' % (
349 self.action, self.device, self.timestamp)
352 class PodcastUserState(Document):
354 Contains everything that a user has done
355 with a specific podcast and all its episodes
358 podcast = StringProperty(required=True)
359 user_oldid = IntegerProperty()
360 user = StringProperty(required=True)
361 settings = DictProperty()
362 actions = SchemaListProperty(SubscriptionAction)
363 tags = StringListProperty()
364 ref_url = StringProperty(required=True)
365 disabled_devices = StringListProperty()
366 merged_ids = StringListProperty()
369 @classmethod
370 def for_user_podcast(cls, user, podcast):
371 r = PodcastUserState.view('podcast_states/by_podcast', \
372 key=[podcast.get_id(), user._id], limit=1, include_docs=True)
373 if r:
374 return r.first()
375 else:
376 p = PodcastUserState()
377 p.podcast = podcast.get_id()
378 p.user = user._id
379 p.ref_url = podcast.url
380 p.settings['public_subscription'] = user.settings.get('public_subscriptions', True)
382 p.set_device_state(user.devices)
384 return p
387 @classmethod
388 def for_user(cls, user):
389 r = PodcastUserState.view('podcast_states/by_user',
390 startkey = [user._id, None],
391 endkey = [user._id, 'ZZZZ'],
392 include_docs = True,
394 return list(r)
397 @classmethod
398 def for_device(cls, device_id):
399 r = PodcastUserState.view('podcast_states/by_device',
400 startkey=[device_id, None], endkey=[device_id, {}],
401 include_docs=True)
402 return list(r)
405 def remove_device(self, device):
407 Removes all actions from the podcast state that refer to the
408 given device
410 self.actions = filter(lambda a: a.device != device.id, self.actions)
413 @classmethod
414 def count(cls):
415 r = PodcastUserState.view('podcast_states/by_user',
416 limit = 0,
417 stale = 'update_after',
419 return r.total_rows
422 def subscribe(self, device):
423 action = SubscriptionAction()
424 action.action = 'subscribe'
425 action.device = device.id
426 self.add_actions([action])
429 def unsubscribe(self, device):
430 action = SubscriptionAction()
431 action.action = 'unsubscribe'
432 action.device = device.id
433 self.add_actions([action])
436 def add_actions(self, actions):
437 self.actions = list(set(self.actions + actions))
438 self.actions = sorted(self.actions)
441 def add_tags(self, tags):
442 self.tags = list(set(self.tags + tags))
445 def set_device_state(self, devices):
446 disabled_devices = [device.id for device in devices if device.deleted]
447 self.disabled_devices = disabled_devices
450 def get_change_between(self, device_id, since, until):
452 Returns the change of the subscription status for the given device
453 between the two timestamps.
455 The change is given as either 'subscribe' (the podcast has been
456 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
457 None (no change)
460 device_actions = filter(lambda x: x.device == device_id, self.actions)
461 before = filter(lambda x: x.timestamp <= since, device_actions)
462 after = filter(lambda x: x.timestamp <= until, device_actions)
464 # nothing happened, so there can be no change
465 if not after:
466 return None
468 then = before[-1] if before else None
469 now = after[-1]
471 if then is None:
472 if now.action != 'unsubscribe':
473 return now.action
474 elif then.action != now.action:
475 return now.action
476 return None
479 def get_subscribed_device_ids(self):
480 """ device Ids on which the user subscribed to the podcast """
481 devices = set()
483 for action in self.actions:
484 if action.action == "subscribe":
485 if not action.device in self.disabled_devices:
486 devices.add(action.device)
487 else:
488 if action.device in devices:
489 devices.remove(action.device)
491 return devices
495 def is_public(self):
496 return self.settings.get('public_subscription', True)
499 def __eq__(self, other):
500 if other is None:
501 return False
503 return self.podcast == other.podcast and \
504 self.user == other.user
506 def __repr__(self):
507 return 'Podcast %s for User %s (%s)' % \
508 (self.podcast, self.user, self._id)
511 class Device(Document):
512 id = StringProperty(default=lambda: uuid.uuid4().hex)
513 oldid = IntegerProperty(required=False)
514 uid = StringProperty(required=True)
515 name = StringProperty(required=True, default='New Device')
516 type = StringProperty(required=True, default='other')
517 settings = DictProperty()
518 deleted = BooleanProperty(default=False)
519 user_agent = StringProperty()
522 def get_subscription_changes(self, since, until):
524 Returns the subscription changes for the device as two lists.
525 The first lists contains the Ids of the podcasts that have been
526 subscribed to, the second list of those that have been unsubscribed
527 from.
530 add, rem = [], []
531 podcast_states = PodcastUserState.for_device(self.id)
532 for p_state in podcast_states:
533 change = p_state.get_change_between(self.id, since, until)
534 if change == 'subscribe':
535 add.append( p_state.ref_url )
536 elif change == 'unsubscribe':
537 rem.append( p_state.ref_url )
539 return add, rem
542 def get_latest_changes(self):
543 podcast_states = PodcastUserState.for_device(self.id)
544 for p_state in podcast_states:
545 actions = filter(lambda x: x.device == self.id, reversed(p_state.actions))
546 if actions:
547 yield (p_state.podcast, actions[0])
550 def get_subscribed_podcast_ids(self):
551 r = self.view('subscriptions/by_device',
552 startkey = [self.id, None],
553 endkey = [self.id, {}]
555 return [res['key'][1] for res in r]
558 def get_subscribed_podcasts(self):
559 return set(Podcast.get_multi(self.get_subscribed_podcast_ids()))
562 def __hash__(self):
563 return hash(frozenset([self.uid, self.name, self.type, self.deleted]))
566 def __eq__(self, other):
567 return self.id == other.id
570 def __repr__(self):
571 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
574 def __str__(self):
575 return self.name
577 def __unicode__(self):
578 return self.name
581 def token_generator(length=32):
582 import random, string
583 return "".join(random.sample(string.letters+string.digits, length))
586 class User(BaseUser, SyncedDevicesMixin):
587 oldid = IntegerProperty()
588 settings = DictProperty()
589 devices = SchemaListProperty(Device)
590 published_objects = StringListProperty()
591 deleted = BooleanProperty(default=False)
592 suggestions_up_to_date = BooleanProperty(default=False)
594 # token for accessing subscriptions of this use
595 subscriptions_token = StringProperty(default=token_generator)
597 # token for accessing the favorite-episodes feed of this user
598 favorite_feeds_token = StringProperty(default=token_generator)
600 # token for automatically updating feeds published by this user
601 publisher_update_token = StringProperty(default=token_generator)
603 class Meta:
604 app_label = 'users'
607 def create_new_token(self, token_name, length=32):
608 setattr(self, token_name, token_generator(length))
611 @property
612 def active_devices(self):
613 not_deleted = lambda d: not d.deleted
614 return filter(not_deleted, self.devices)
617 @property
618 def inactive_devices(self):
619 deleted = lambda d: d.deleted
620 return filter(deleted, self.devices)
623 def get_device(self, id):
625 if not hasattr(self, '__device_by_id'):
626 self.__devices_by_id = dict( (d.id, d) for d in self.devices)
628 return self.__devices_by_id.get(id, None)
631 def get_device_by_uid(self, uid, only_active=True):
633 if not hasattr(self, '__devices_by_uio'):
634 self.__devices_by_uid = dict( (d.uid, d) for d in self.devices)
636 try:
637 device = self.__devices_by_uid[uid]
639 if only_active and device.deleted:
640 raise DeviceDeletedException(
641 'Device with UID %s is deleted' % uid)
643 return device
645 except KeyError as e:
646 raise DeviceDoesNotExist('There is no device with UID %s' % uid)
649 def update_device(self, device):
650 """ Sets the device and saves the user """
652 @repeat_on_conflict(['user'])
653 def _update(user, device):
654 user.set_device(device)
655 user.save()
657 _update(user=self, device=device)
660 def set_device(self, device):
662 if not RE_DEVICE_UID.match(device.uid):
663 raise DeviceUIDException("'{uid} is not a valid device ID".format(
664 uid=device.uid))
666 devices = list(self.devices)
667 ids = [x.id for x in devices]
668 if not device.id in ids:
669 devices.append(device)
670 self.devices = devices
671 return
673 index = ids.index(device.id)
674 devices.pop(index)
675 devices.insert(index, device)
676 self.devices = devices
679 def remove_device(self, device):
680 devices = list(self.devices)
681 ids = [x.id for x in devices]
682 if not device.id in ids:
683 return
685 index = ids.index(device.id)
686 devices.pop(index)
687 self.devices = devices
689 if self.is_synced(device):
690 self.unsync_device(device)
694 def get_subscriptions(self, public=None):
696 Returns a list of (podcast-id, device-id) tuples for all
697 of the users subscriptions
700 r = PodcastUserState.view('subscriptions/by_user',
701 startkey = [self._id, public, None, None],
702 endkey = [self._id+'ZZZ', None, None, None],
703 reduce = False,
705 return [res['key'][1:] for res in r]
708 def get_subscriptions_by_device(self, public=None):
709 get_dev = itemgetter(2)
710 groups = collections.defaultdict(list)
711 subscriptions = self.get_subscriptions(public=public)
712 subscriptions = sorted(subscriptions, key=get_dev)
714 for public, podcast_id, device_id in subscriptions:
715 groups[device_id].append(podcast_id)
717 return groups
720 def get_subscribed_podcast_ids(self, public=None):
722 Returns the Ids of all subscribed podcasts
724 return list(set(x[1] for x in self.get_subscriptions(public=public)))
727 def get_subscribed_podcasts(self, public=None):
728 return set(Podcast.get_multi(self.get_subscribed_podcast_ids(public=public)))
731 def get_subscription_history(self, device_id=None, reverse=False, public=None):
732 """ Returns chronologically ordered subscription history entries
734 Setting device_id restricts the actions to a certain device
737 def action_iter(state):
738 for action in sorted(state.actions, reverse=reverse):
739 if device_id is not None and device_id != action.device:
740 continue
742 if public is not None and state.is_public() != public:
743 continue
745 entry = HistoryEntry()
746 entry.timestamp = action.timestamp
747 entry.action = action.action
748 entry.podcast_id = state.podcast
749 entry.device_id = action.device
750 yield entry
752 if device_id is None:
753 podcast_states = PodcastUserState.for_user(self)
754 else:
755 podcast_states = PodcastUserState.for_device(device_id)
757 # create an action_iter for each PodcastUserState
758 subscription_action_lists = [action_iter(x) for x in podcast_states]
760 action_cmp_key = lambda x: x.timestamp
762 # Linearize their subscription-actions
763 return linearize(action_cmp_key, subscription_action_lists, reverse)
766 def get_global_subscription_history(self, public=None):
767 """ Actions that added/removed podcasts from the subscription list
769 Returns an iterator of all subscription actions that either
770 * added subscribed a podcast that hasn't been subscribed directly
771 before the action (but could have been subscribed) earlier
772 * removed a subscription of the podcast is not longer subscribed
773 after the action
776 subscriptions = collections.defaultdict(int)
778 for entry in self.get_subscription_history(public=public):
779 if entry.action == 'subscribe':
780 subscriptions[entry.podcast_id] += 1
782 # a new subscription has been added
783 if subscriptions[entry.podcast_id] == 1:
784 yield entry
786 elif entry.action == 'unsubscribe':
787 subscriptions[entry.podcast_id] -= 1
789 # the last subscription has been removed
790 if subscriptions[entry.podcast_id] == 0:
791 yield entry
795 def get_newest_episodes(self, max_date, max_per_podcast=5):
796 """ Returns the newest episodes of all subscribed podcasts
798 Only max_per_podcast episodes per podcast are loaded. Episodes with
799 release dates above max_date are discarded.
801 This method returns a generator that produces the newest episodes.
803 The number of required DB queries is equal to the number of (distinct)
804 podcasts of all consumed episodes (max: number of subscribed podcasts),
805 plus a constant number of initial queries (when the first episode is
806 consumed). """
808 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
810 podcasts = list(self.get_subscribed_podcasts())
811 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
812 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
813 reverse=True)
815 podcast_dict = dict((p.get_id(), p) for p in podcasts)
817 # contains the un-yielded episodes, newest first
818 episodes = []
820 for podcast in podcasts:
822 yielded_episodes = 0
824 for episode in episodes:
825 # determine for which episodes there won't be a new episodes
826 # that is newer; those can be yielded
827 if episode.released > podcast.latest_episode_timestamp:
828 p = podcast_dict.get(episode.podcast, None)
829 yield proxy_object(episode, podcast=p)
830 yielded_episodes += 1
831 else:
832 break
834 # remove the episodes that have been yielded before
835 episodes = episodes[yielded_episodes:]
837 # fetch and merge episodes for the next podcast
838 new_episodes = list(podcast.get_episodes(since=1, until=max_date,
839 descending=True, limit=max_per_podcast))
840 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
843 # yield the remaining episodes
844 for episode in episodes:
845 podcast = podcast_dict.get(episode.podcast, None)
846 yield proxy_object(episode, podcast=podcast)
850 def save(self, *args, **kwargs):
851 super(User, self).save(*args, **kwargs)
853 podcast_states = PodcastUserState.for_user(self)
854 for state in podcast_states:
855 @repeat_on_conflict(['state'])
856 def _update_state(state):
857 old_devs = set(state.disabled_devices)
858 state.set_device_state(self.devices)
860 if old_devs != set(state.disabled_devices):
861 state.save()
863 _update_state(state=state)
868 def __eq__(self, other):
869 if not other:
870 return False
872 # ensure that other isn't AnonymousUser
873 return other.is_authenticated() and self._id == other._id
876 def __repr__(self):
877 return 'User %s' % self._id
880 class History(object):
882 def __init__(self, user, device):
883 self.user = user
884 self.device = device
885 self._db = EpisodeUserState.get_db()
887 if device:
888 self._view = 'history/by_device'
889 self._startkey = [self.user._id, device.id, None]
890 self._endkey = [self.user._id, device.id, {}]
891 else:
892 self._view = 'history/by_user'
893 self._startkey = [self.user._id, None]
894 self._endkey = [self.user._id, {}]
897 def __getitem__(self, key):
899 if isinstance(key, slice):
900 start = key.start or 0
901 length = key.stop - start
902 else:
903 start = key
904 length = 1
906 res = self._db.view(self._view,
907 descending = True,
908 startkey = self._endkey,
909 endkey = self._startkey,
910 limit = length,
911 skip = start,
914 for action in res:
915 action = action['value']
916 yield HistoryEntry.from_action_dict(action)
920 class HistoryEntry(object):
921 """ A class that can represent subscription and episode actions """
924 @classmethod
925 def from_action_dict(cls, action):
927 entry = HistoryEntry()
929 if 'timestamp' in action:
930 ts = action.pop('timestamp')
931 entry.timestamp = dateutil.parser.parse(ts)
933 for key, value in action.items():
934 setattr(entry, key, value)
936 return entry
939 @property
940 def playmark(self):
941 return getattr(self, 'position', None)
944 @classmethod
945 def fetch_data(cls, user, entries,
946 podcasts=None, episodes=None):
947 """ Efficiently loads additional data for a number of entries """
949 if podcasts is None:
950 # load podcast data
951 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
952 podcast_ids = filter(None, podcast_ids)
953 podcasts = get_to_dict(Podcast, podcast_ids, get_id=Podcast.get_id)
955 if episodes is None:
956 # load episode data
957 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
958 episode_ids = filter(None, episode_ids)
959 episodes = get_to_dict(Episode, episode_ids)
961 # load device data
962 # does not need pre-populated data because no db-access is required
963 device_ids = [getattr(x, 'device_id', None) for x in entries]
964 device_ids = filter(None, device_ids)
965 devices = dict([ (id, user.get_device(id)) for id in device_ids])
968 for entry in entries:
969 podcast_id = getattr(entry, 'podcast_id', None)
970 entry.podcast = podcasts.get(podcast_id, None)
972 episode_id = getattr(entry, 'episode_id', None)
973 entry.episode = episodes.get(episode_id, None)
974 entry.user = user
976 device = devices.get(getattr(entry, 'device_id', None), None)
977 entry.device = device
980 return entries