avoid error when merged episode is referenced in view
[mygpo.git] / mygpo / users / models.py
blob5c1f121c309abcf7405984a16a9f76d80e7262c7
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
7 import random
8 import string
10 from couchdbkit import ResourceNotFound
11 from couchdbkit.ext.django.schema import *
13 from django.core.cache import cache
15 from django_couchdb_utils.registration.models import User as BaseUser
17 from mygpo.core.proxy import proxy_object, DocumentABCMeta
18 from mygpo.core.models import Podcast, Episode
19 from mygpo.utils import linearize, get_to_dict, iterate_together
20 from mygpo.couchdb import get_main_database
21 from mygpo.decorators import repeat_on_conflict
22 from mygpo.users.ratings import RatingMixin
23 from mygpo.users.sync import SyncedDevicesMixin
24 from mygpo.log import log
27 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
30 class DeviceUIDException(Exception):
31 pass
34 class DeviceDoesNotExist(Exception):
35 pass
38 class DeviceDeletedException(DeviceDoesNotExist):
39 pass
42 class Suggestions(Document, RatingMixin):
43 user = StringProperty(required=True)
44 user_oldid = IntegerProperty()
45 podcasts = StringListProperty()
46 blacklist = StringListProperty()
48 @classmethod
49 def for_user(cls, user):
50 r = cls.view('suggestions/by_user', key=user._id, \
51 include_docs=True)
52 if r:
53 return r.first()
54 else:
55 s = Suggestions()
56 s.user = user._id
57 return s
60 def get_podcasts(self, count=None):
61 user = User.get(self.user)
62 subscriptions = user.get_subscribed_podcast_ids()
64 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
65 if count:
66 ids = ids[:count]
67 return filter(lambda x: x and x.title, Podcast.get_multi(ids))
70 def __repr__(self):
71 if not self._id:
72 return super(Suggestions, self).__repr__()
73 else:
74 return '%d Suggestions for %s (%s)' % \
75 (len(self.podcasts), self.user, self._id)
78 class EpisodeAction(DocumentSchema):
79 """
80 One specific action to an episode. Must
81 always be part of a EpisodeUserState
82 """
84 action = StringProperty(required=True)
85 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
86 device_oldid = IntegerProperty(required=False)
87 device = StringProperty()
88 started = IntegerProperty()
89 playmark = IntegerProperty()
90 total = IntegerProperty()
92 def __eq__(self, other):
93 if not isinstance(other, EpisodeAction):
94 return False
95 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
96 'total')
97 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
100 def to_history_entry(self):
101 entry = HistoryEntry()
102 entry.action = self.action
103 entry.timestamp = self.timestamp
104 entry.device_id = self.device
105 entry.started = self.started
106 entry.position = self.playmark
107 entry.total = self.total
108 return entry
111 @staticmethod
112 def filter(user_id, since=None, until={}, podcast_id=None,
113 device_id=None):
114 """ Returns Episode Actions for the given criteria"""
116 since_str = since.strftime('%Y-%m-%dT%H:%M:%S') if since else None
117 until_str = until.strftime('%Y-%m-%dT%H:%M:%S') if until else {}
119 if since_str >= until_str:
120 return
122 if not podcast_id and not device_id:
123 view = 'episode_actions/by_user'
124 startkey = [user_id, since_str]
125 endkey = [user_id, until_str]
127 elif podcast_id and not device_id:
128 view = 'episode_actions/by_podcast'
129 startkey = [user_id, podcast_id, since_str]
130 endkey = [user_id, podcast_id, until_str]
132 elif device_id and not podcast_id:
133 view = 'episode_actions/by_device'
134 startkey = [user_id, device_id, since_str]
135 endkey = [user_id, device_id, until_str]
137 else:
138 view = 'episode_actions/by_podcast_device'
139 startkey = [user_id, podcast_id, device_id, since_str]
140 endkey = [user_id, podcast_id, device_id, until_str]
142 db = get_main_database()
143 res = db.view(view,
144 startkey = startkey,
145 endkey = endkey
148 for r in res:
149 action = r['value']
150 yield action
153 def validate_time_values(self):
154 """ Validates allowed combinations of time-values """
156 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
158 # Key found, but must not be supplied (no play action!)
159 if self.action != 'play':
160 for key in PLAY_ACTION_KEYS:
161 if getattr(self, key, None) is not None:
162 raise ValueError('%s only allowed in play actions' % key)
164 # Sanity check: If started or total are given, require playmark
165 if ((self.started is not None) or (self.total is not None)) and \
166 self.playmark is None:
167 raise ValueError('started and total require position')
169 # Sanity check: total and playmark can only appear together
170 if ((self.total is not None) or (self.started is not None)) and \
171 ((self.total is None) or (self.started is None)):
172 raise ValueError('total and started can only appear together')
175 def __repr__(self):
176 return '%s-Action on %s at %s (in %s)' % \
177 (self.action, self.device, self.timestamp, self._id)
180 def __hash__(self):
181 return hash(frozenset([self.action, self.timestamp, self.device,
182 self.started, self.playmark, self.total]))
185 class Chapter(Document):
186 """ A user-entered episode chapter """
188 device = StringProperty()
189 created = DateTimeProperty()
190 start = IntegerProperty(required=True)
191 end = IntegerProperty(required=True)
192 label = StringProperty()
193 advertisement = BooleanProperty()
195 @classmethod
196 def for_episode(cls, episode_id):
197 r = cls.view('chapters/by_episode',
198 startkey = [episode_id, None],
199 endkey = [episode_id, {}],
200 wrap_doc = False,
203 for res in r:
204 user = res['key'][1]
205 chapter = Chapter.wrap(res['value'])
206 yield (user, chapter)
209 def __repr__(self):
210 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
211 self.start, self.end)
214 class EpisodeUserState(Document):
216 Contains everything a user has done with an Episode
219 episode = StringProperty(required=True)
220 actions = SchemaListProperty(EpisodeAction)
221 settings = DictProperty()
222 user_oldid = IntegerProperty()
223 user = StringProperty(required=True)
224 ref_url = StringProperty(required=True)
225 podcast_ref_url = StringProperty(required=True)
226 merged_ids = StringListProperty()
227 chapters = SchemaListProperty(Chapter)
228 podcast = StringProperty(required=True)
231 @classmethod
232 def for_user_episode(cls, user, episode):
233 r = cls.view('episode_states/by_user_episode',
234 key = [user._id, episode._id],
235 include_docs = True,
236 limit = 1,
239 if r:
240 return r.first()
242 else:
243 podcast = Podcast.get(episode.podcast)
245 state = EpisodeUserState()
246 state.episode = episode._id
247 state.podcast = episode.podcast
248 state.user = user._id
249 state.ref_url = episode.url
250 state.podcast_ref_url = podcast.url
252 return state
254 @classmethod
255 def for_ref_urls(cls, user, podcast_url, episode_url):
257 import hashlib
258 cache_key = 'episode-state-%s-%s-%s' % (user._id,
259 hashlib.md5(podcast_url).hexdigest(),
260 hashlib.md5(episode_url).hexdigest())
262 state = cache.get(cache_key)
263 if state:
264 return state
266 res = cls.view('episode_states/by_ref_urls',
267 key = [user._id, podcast_url, episode_url], limit=1, include_docs=True)
268 if res:
269 state = res.first()
270 state.ref_url = episode_url
271 state.podcast_ref_url = podcast_url
272 cache.set(cache_key, state, 60*60)
273 return state
275 else:
276 episode = Episode.for_podcast_url(podcast_url, episode_url, create=True)
277 return episode.get_user_state(user)
280 @classmethod
281 def count(cls):
282 r = cls.view('episode_states/by_user_episode',
283 limit = 0,
284 stale = 'update_after',
286 return r.total_rows
289 def add_actions(self, actions):
290 map(EpisodeAction.validate_time_values, actions)
291 self.actions = list(self.actions) + actions
292 self.actions = list(set(self.actions))
293 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
296 def is_favorite(self):
297 return self.settings.get('is_favorite', False)
300 def set_favorite(self, set_to=True):
301 self.settings['is_favorite'] = set_to
304 def update_chapters(self, add=[], rem=[]):
305 """ Updates the Chapter list
307 * add contains the chapters to be added
309 * rem contains tuples of (start, end) times. Chapters that match
310 both endpoints will be removed
313 @repeat_on_conflict(['state'])
314 def update(state):
315 for chapter in add:
316 self.chapters = self.chapters + [chapter]
318 for start, end in rem:
319 keep = lambda c: c.start != start or c.end != end
320 self.chapters = filter(keep, self.chapters)
322 self.save()
324 update(state=self)
327 def get_history_entries(self):
328 return imap(EpisodeAction.to_history_entry, self.actions)
331 def __repr__(self):
332 return 'Episode-State %s (in %s)' % \
333 (self.episode, self._id)
335 def __eq__(self, other):
336 if not isinstance(other, EpisodeUserState):
337 return False
339 return (self.episode == other.episode and
340 self.user == other.user)
344 class SubscriptionAction(Document):
345 action = StringProperty()
346 timestamp = DateTimeProperty(default=datetime.utcnow)
347 device = StringProperty()
350 __metaclass__ = DocumentABCMeta
353 def __cmp__(self, other):
354 return cmp(self.timestamp, other.timestamp)
356 def __eq__(self, other):
357 return self.action == other.action and \
358 self.timestamp == other.timestamp and \
359 self.device == other.device
361 def __hash__(self):
362 return hash(self.action) + hash(self.timestamp) + hash(self.device)
364 def __repr__(self):
365 return '<SubscriptionAction %s on %s at %s>' % (
366 self.action, self.device, self.timestamp)
369 class PodcastUserState(Document):
371 Contains everything that a user has done
372 with a specific podcast and all its episodes
375 podcast = StringProperty(required=True)
376 user_oldid = IntegerProperty()
377 user = StringProperty(required=True)
378 settings = DictProperty()
379 actions = SchemaListProperty(SubscriptionAction)
380 tags = StringListProperty()
381 ref_url = StringProperty(required=True)
382 disabled_devices = StringListProperty()
383 merged_ids = StringListProperty()
386 @classmethod
387 def for_user_podcast(cls, user, podcast):
388 r = PodcastUserState.view('podcast_states/by_podcast', \
389 key=[podcast.get_id(), user._id], limit=1, include_docs=True)
390 if r:
391 return r.first()
392 else:
393 p = PodcastUserState()
394 p.podcast = podcast.get_id()
395 p.user = user._id
396 p.ref_url = podcast.url
397 p.settings['public_subscription'] = user.settings.get('public_subscriptions', True)
399 p.set_device_state(user.devices)
401 return p
404 @classmethod
405 def for_user(cls, user):
406 r = PodcastUserState.view('podcast_states/by_user',
407 startkey = [user._id, None],
408 endkey = [user._id, 'ZZZZ'],
409 include_docs = True,
411 return list(r)
414 @classmethod
415 def for_device(cls, device_id):
416 r = PodcastUserState.view('podcast_states/by_device',
417 startkey=[device_id, None], endkey=[device_id, {}],
418 include_docs=True)
419 return list(r)
422 def remove_device(self, device):
424 Removes all actions from the podcast state that refer to the
425 given device
427 self.actions = filter(lambda a: a.device != device.id, self.actions)
430 @classmethod
431 def count(cls):
432 r = PodcastUserState.view('podcast_states/by_user',
433 limit = 0,
434 stale = 'update_after',
436 return r.total_rows
439 def subscribe(self, device):
440 action = SubscriptionAction()
441 action.action = 'subscribe'
442 action.device = device.id
443 self.add_actions([action])
446 def unsubscribe(self, device):
447 action = SubscriptionAction()
448 action.action = 'unsubscribe'
449 action.device = device.id
450 self.add_actions([action])
453 def add_actions(self, actions):
454 self.actions = list(set(self.actions + actions))
455 self.actions = sorted(self.actions)
458 def add_tags(self, tags):
459 self.tags = list(set(self.tags + tags))
462 def set_device_state(self, devices):
463 disabled_devices = [device.id for device in devices if device.deleted]
464 self.disabled_devices = disabled_devices
467 def get_change_between(self, device_id, since, until):
469 Returns the change of the subscription status for the given device
470 between the two timestamps.
472 The change is given as either 'subscribe' (the podcast has been
473 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
474 None (no change)
477 device_actions = filter(lambda x: x.device == device_id, self.actions)
478 before = filter(lambda x: x.timestamp <= since, device_actions)
479 after = filter(lambda x: x.timestamp <= until, device_actions)
481 # nothing happened, so there can be no change
482 if not after:
483 return None
485 then = before[-1] if before else None
486 now = after[-1]
488 if then is None:
489 if now.action != 'unsubscribe':
490 return now.action
491 elif then.action != now.action:
492 return now.action
493 return None
496 def get_subscribed_device_ids(self):
497 """ device Ids on which the user subscribed to the podcast """
498 devices = set()
500 for action in self.actions:
501 if action.action == "subscribe":
502 if not action.device in self.disabled_devices:
503 devices.add(action.device)
504 else:
505 if action.device in devices:
506 devices.remove(action.device)
508 return devices
512 def is_public(self):
513 return self.settings.get('public_subscription', True)
516 def __eq__(self, other):
517 if other is None:
518 return False
520 return self.podcast == other.podcast and \
521 self.user == other.user
523 def __repr__(self):
524 return 'Podcast %s for User %s (%s)' % \
525 (self.podcast, self.user, self._id)
528 class Device(Document):
529 id = StringProperty(default=lambda: uuid.uuid4().hex)
530 oldid = IntegerProperty(required=False)
531 uid = StringProperty(required=True)
532 name = StringProperty(required=True, default='New Device')
533 type = StringProperty(required=True, default='other')
534 settings = DictProperty()
535 deleted = BooleanProperty(default=False)
536 user_agent = StringProperty()
539 def get_subscription_changes(self, since, until):
541 Returns the subscription changes for the device as two lists.
542 The first lists contains the Ids of the podcasts that have been
543 subscribed to, the second list of those that have been unsubscribed
544 from.
547 add, rem = [], []
548 podcast_states = PodcastUserState.for_device(self.id)
549 for p_state in podcast_states:
550 change = p_state.get_change_between(self.id, since, until)
551 if change == 'subscribe':
552 add.append( p_state.ref_url )
553 elif change == 'unsubscribe':
554 rem.append( p_state.ref_url )
556 return add, rem
559 def get_latest_changes(self):
560 podcast_states = PodcastUserState.for_device(self.id)
561 for p_state in podcast_states:
562 actions = filter(lambda x: x.device == self.id, reversed(p_state.actions))
563 if actions:
564 yield (p_state.podcast, actions[0])
567 def get_subscribed_podcast_ids(self):
568 r = self.view('subscriptions/by_device',
569 startkey = [self.id, None],
570 endkey = [self.id, {}]
572 return [res['key'][1] for res in r]
575 def get_subscribed_podcasts(self):
576 return Podcast.get_multi(self.get_subscribed_podcast_ids())
579 def __hash__(self):
580 return hash(frozenset([self.uid, self.name, self.type, self.deleted]))
583 def __eq__(self, other):
584 return self.id == other.id
587 def __repr__(self):
588 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
591 def __str__(self):
592 return self.name
594 def __unicode__(self):
595 return self.name
599 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
600 'publisher_update_token', 'userpage_token')
603 class TokenException(Exception):
604 pass
607 class User(BaseUser, SyncedDevicesMixin):
608 oldid = IntegerProperty()
609 settings = DictProperty()
610 devices = SchemaListProperty(Device)
611 published_objects = StringListProperty()
612 deleted = BooleanProperty(default=False)
613 suggestions_up_to_date = BooleanProperty(default=False)
615 # token for accessing subscriptions of this use
616 subscriptions_token = StringProperty(default=None)
618 # token for accessing the favorite-episodes feed of this user
619 favorite_feeds_token = StringProperty(default=None)
621 # token for automatically updating feeds published by this user
622 publisher_update_token = StringProperty(default=None)
624 # token for accessing the userpage of this user
625 userpage_token = StringProperty(default=None)
627 class Meta:
628 app_label = 'users'
631 def create_new_token(self, token_name, length=32):
632 """ creates a new random token """
634 if token_name not in TOKEN_NAMES:
635 raise TokenException('Invalid token name %s' % token_name)
637 token = "".join(random.sample(string.letters+string.digits, length))
638 setattr(self, token_name, token)
642 def get_token(self, token_name):
643 """ returns a token, and generate those that are still missing """
645 generated = False
647 if token_name not in TOKEN_NAMES:
648 raise TokenException('Invalid token name %s' % token_name)
650 for tn in TOKEN_NAMES:
651 if getattr(self, tn) is None:
652 self.create_new_token(tn)
653 generated = True
655 if generated:
656 self.save()
658 return getattr(self, token_name)
662 @property
663 def active_devices(self):
664 not_deleted = lambda d: not d.deleted
665 return filter(not_deleted, self.devices)
668 @property
669 def inactive_devices(self):
670 deleted = lambda d: d.deleted
671 return filter(deleted, self.devices)
674 def get_devices_by_id(self):
675 return dict( (device.id, device) for device in self.devices)
678 def get_device(self, id):
680 if not hasattr(self, '__device_by_id'):
681 self.__devices_by_id = dict( (d.id, d) for d in self.devices)
683 return self.__devices_by_id.get(id, None)
686 def get_device_by_uid(self, uid, only_active=True):
688 if not hasattr(self, '__devices_by_uio'):
689 self.__devices_by_uid = dict( (d.uid, d) for d in self.devices)
691 try:
692 device = self.__devices_by_uid[uid]
694 if only_active and device.deleted:
695 raise DeviceDeletedException(
696 'Device with UID %s is deleted' % uid)
698 return device
700 except KeyError as e:
701 raise DeviceDoesNotExist('There is no device with UID %s' % uid)
704 def update_device(self, device):
705 """ Sets the device and saves the user """
707 @repeat_on_conflict(['user'])
708 def _update(user, device):
709 user.set_device(device)
710 user.save()
712 _update(user=self, device=device)
715 def set_device(self, device):
717 if not RE_DEVICE_UID.match(device.uid):
718 raise DeviceUIDException("'{uid} is not a valid device ID".format(
719 uid=device.uid))
721 devices = list(self.devices)
722 ids = [x.id for x in devices]
723 if not device.id in ids:
724 devices.append(device)
725 self.devices = devices
726 return
728 index = ids.index(device.id)
729 devices.pop(index)
730 devices.insert(index, device)
731 self.devices = devices
734 def remove_device(self, device):
735 devices = list(self.devices)
736 ids = [x.id for x in devices]
737 if not device.id in ids:
738 return
740 index = ids.index(device.id)
741 devices.pop(index)
742 self.devices = devices
744 if self.is_synced(device):
745 self.unsync_device(device)
749 def get_subscriptions(self, public=None):
751 Returns a list of (podcast-id, device-id) tuples for all
752 of the users subscriptions
755 r = PodcastUserState.view('subscriptions/by_user',
756 startkey = [self._id, public, None, None],
757 endkey = [self._id+'ZZZ', None, None, None],
758 reduce = False,
760 return [res['key'][1:] for res in r]
763 def get_subscriptions_by_device(self, public=None):
764 get_dev = itemgetter(2)
765 groups = collections.defaultdict(list)
766 subscriptions = self.get_subscriptions(public=public)
767 subscriptions = sorted(subscriptions, key=get_dev)
769 for public, podcast_id, device_id in subscriptions:
770 groups[device_id].append(podcast_id)
772 return groups
775 def get_subscribed_podcast_ids(self, public=None):
777 Returns the Ids of all subscribed podcasts
779 return list(set(x[1] for x in self.get_subscriptions(public=public)))
782 def get_subscribed_podcasts(self, public=None):
783 return list(Podcast.get_multi(self.get_subscribed_podcast_ids(public=public)))
786 def get_num_listened_episodes(self):
787 db = EpisodeUserState.get_db()
788 r = db.view('listeners/by_user_podcast',
789 startkey = [self._id, None],
790 endkey = [self._id, {}],
791 reduce = True,
792 group_level = 2,
794 for obj in r:
795 count = obj['value']
796 podcast = obj['key'][1]
797 yield (podcast, count)
800 def get_subscription_history(self, device_id=None, reverse=False, public=None):
801 """ Returns chronologically ordered subscription history entries
803 Setting device_id restricts the actions to a certain device
806 def action_iter(state):
807 for action in sorted(state.actions, reverse=reverse):
808 if device_id is not None and device_id != action.device:
809 continue
811 if public is not None and state.is_public() != public:
812 continue
814 entry = HistoryEntry()
815 entry.timestamp = action.timestamp
816 entry.action = action.action
817 entry.podcast_id = state.podcast
818 entry.device_id = action.device
819 yield entry
821 if device_id is None:
822 podcast_states = PodcastUserState.for_user(self)
823 else:
824 podcast_states = PodcastUserState.for_device(device_id)
826 # create an action_iter for each PodcastUserState
827 subscription_action_lists = [action_iter(x) for x in podcast_states]
829 action_cmp_key = lambda x: x.timestamp
831 # Linearize their subscription-actions
832 return linearize(action_cmp_key, subscription_action_lists, reverse)
835 def get_global_subscription_history(self, public=None):
836 """ Actions that added/removed podcasts from the subscription list
838 Returns an iterator of all subscription actions that either
839 * added subscribed a podcast that hasn't been subscribed directly
840 before the action (but could have been subscribed) earlier
841 * removed a subscription of the podcast is not longer subscribed
842 after the action
845 subscriptions = collections.defaultdict(int)
847 for entry in self.get_subscription_history(public=public):
848 if entry.action == 'subscribe':
849 subscriptions[entry.podcast_id] += 1
851 # a new subscription has been added
852 if subscriptions[entry.podcast_id] == 1:
853 yield entry
855 elif entry.action == 'unsubscribe':
856 subscriptions[entry.podcast_id] -= 1
858 # the last subscription has been removed
859 if subscriptions[entry.podcast_id] == 0:
860 yield entry
864 def get_newest_episodes(self, max_date, max_per_podcast=5):
865 """ Returns the newest episodes of all subscribed podcasts
867 Only max_per_podcast episodes per podcast are loaded. Episodes with
868 release dates above max_date are discarded.
870 This method returns a generator that produces the newest episodes.
872 The number of required DB queries is equal to the number of (distinct)
873 podcasts of all consumed episodes (max: number of subscribed podcasts),
874 plus a constant number of initial queries (when the first episode is
875 consumed). """
877 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
879 podcasts = list(self.get_subscribed_podcasts())
880 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
881 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
882 reverse=True)
884 podcast_dict = dict((p.get_id(), p) for p in podcasts)
886 # contains the un-yielded episodes, newest first
887 episodes = []
889 for podcast in podcasts:
891 yielded_episodes = 0
893 for episode in episodes:
894 # determine for which episodes there won't be a new episodes
895 # that is newer; those can be yielded
896 if episode.released > podcast.latest_episode_timestamp:
897 p = podcast_dict.get(episode.podcast, None)
898 yield proxy_object(episode, podcast=p)
899 yielded_episodes += 1
900 else:
901 break
903 # remove the episodes that have been yielded before
904 episodes = episodes[yielded_episodes:]
906 # fetch and merge episodes for the next podcast
907 new_episodes = list(podcast.get_episodes(since=1, until=max_date,
908 descending=True, limit=max_per_podcast))
909 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
912 # yield the remaining episodes
913 for episode in episodes:
914 podcast = podcast_dict.get(episode.podcast, None)
915 yield proxy_object(episode, podcast=podcast)
918 def get_latest_episodes(self, count=10):
919 """ Returns the latest episodes that the user has accessed """
921 startkey = [self._id, {}]
922 endkey = [self._id, None]
924 db = get_main_database()
925 res = db.view('listeners/by_user',
926 startkey = startkey,
927 endkey = endkey,
928 include_docs = True,
929 descending = True,
930 limit = count,
931 reduce = False,
934 keys = [r['value'] for r in res]
935 return Episode.get_multi(keys)
938 def get_num_played_episodes(self, since=None, until={}):
939 """ Number of played episodes in interval """
941 since_str = since.strftime('%Y-%m-%d') if since else None
942 until_str = until.strftime('%Y-%m-%d') if until else {}
944 startkey = [self._id, since_str]
945 endkey = [self._id, until_str]
947 db = EpisodeUserState.get_db()
948 res = db.view('listeners/by_user',
949 startkey = startkey,
950 endkey = endkey,
951 reduce = True,
954 val = res.one()
955 return val['value'] if val else 0
959 def get_seconds_played(self, since=None, until={}):
960 """ Returns the number of seconds that the user has listened
962 Can be selected by timespan, podcast and episode """
964 since_str = since.strftime('%Y-%m-%dT%H:%M:%S') if since else None
965 until_str = until.strftime('%Y-%m-%dT%H:%M:%S') if until else {}
967 startkey = [self._id, since_str]
968 endkey = [self._id, until_str]
970 db = EpisodeUserState.get_db()
971 res = db.view('listeners/times_played_by_user',
972 startkey = startkey,
973 endkey = endkey,
974 reduce = True,
977 val = res.one()
978 return val['value'] if val else 0
981 def save(self, *args, **kwargs):
982 super(User, self).save(*args, **kwargs)
984 podcast_states = PodcastUserState.for_user(self)
985 for state in podcast_states:
986 @repeat_on_conflict(['state'])
987 def _update_state(state):
988 old_devs = set(state.disabled_devices)
989 state.set_device_state(self.devices)
991 if old_devs != set(state.disabled_devices):
992 state.save()
994 _update_state(state=state)
999 def __eq__(self, other):
1000 if not other:
1001 return False
1003 # ensure that other isn't AnonymousUser
1004 return other.is_authenticated() and self._id == other._id
1007 def __repr__(self):
1008 return 'User %s' % self._id
1011 class History(object):
1013 def __init__(self, user, device):
1014 self.user = user
1015 self.device = device
1016 self._db = get_main_database()
1018 if device:
1019 self._view = 'history/by_device'
1020 self._startkey = [self.user._id, device.id, None]
1021 self._endkey = [self.user._id, device.id, {}]
1022 else:
1023 self._view = 'history/by_user'
1024 self._startkey = [self.user._id, None]
1025 self._endkey = [self.user._id, {}]
1028 def __getitem__(self, key):
1030 if isinstance(key, slice):
1031 start = key.start or 0
1032 length = key.stop - start
1033 else:
1034 start = key
1035 length = 1
1037 res = self._db.view(self._view,
1038 descending = True,
1039 startkey = self._endkey,
1040 endkey = self._startkey,
1041 limit = length,
1042 skip = start,
1045 for action in res:
1046 action = action['value']
1047 yield HistoryEntry.from_action_dict(action)
1051 class HistoryEntry(object):
1052 """ A class that can represent subscription and episode actions """
1055 @classmethod
1056 def from_action_dict(cls, action):
1058 entry = HistoryEntry()
1060 if 'timestamp' in action:
1061 ts = action.pop('timestamp')
1062 entry.timestamp = dateutil.parser.parse(ts)
1064 for key, value in action.items():
1065 setattr(entry, key, value)
1067 return entry
1070 @property
1071 def playmark(self):
1072 return getattr(self, 'position', None)
1075 @classmethod
1076 def fetch_data(cls, user, entries,
1077 podcasts=None, episodes=None):
1078 """ Efficiently loads additional data for a number of entries """
1080 if podcasts is None:
1081 # load podcast data
1082 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
1083 podcast_ids = filter(None, podcast_ids)
1084 podcasts = get_to_dict(Podcast, podcast_ids, get_id=Podcast.get_id)
1086 if episodes is None:
1087 # load episode data
1088 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
1089 episode_ids = filter(None, episode_ids)
1090 episodes = get_to_dict(Episode, episode_ids)
1092 # load device data
1093 # does not need pre-populated data because no db-access is required
1094 device_ids = [getattr(x, 'device_id', None) for x in entries]
1095 device_ids = filter(None, device_ids)
1096 devices = dict([ (id, user.get_device(id)) for id in device_ids])
1099 for entry in entries:
1100 podcast_id = getattr(entry, 'podcast_id', None)
1101 entry.podcast = podcasts.get(podcast_id, None)
1103 episode_id = getattr(entry, 'episode_id', None)
1104 entry.episode = episodes.get(episode_id, None)
1106 if hasattr(entry, 'user'):
1107 entry.user = user
1109 device = devices.get(getattr(entry, 'device_id', None), None)
1110 entry.device = device
1113 return entries