fix episode pages with existing chapters
[mygpo.git] / mygpo / users / models.py
blob339a761d2dec8efe9e627c6c33260675e67b03f3
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 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
26 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
29 class DeviceUIDException(Exception):
30 pass
33 class DeviceDoesNotExist(Exception):
34 pass
37 class DeviceDeletedException(DeviceDoesNotExist):
38 pass
41 class Suggestions(Document, RatingMixin):
42 user = StringProperty(required=True)
43 user_oldid = IntegerProperty()
44 podcasts = StringListProperty()
45 blacklist = StringListProperty()
47 @classmethod
48 def for_user(cls, user):
49 r = cls.view('suggestions/by_user', key=user._id, \
50 include_docs=True)
51 if r:
52 return r.first()
53 else:
54 s = Suggestions()
55 s.user = user._id
56 return s
59 def get_podcasts(self, count=None):
60 user = User.get(self.user)
61 subscriptions = user.get_subscribed_podcast_ids()
63 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
64 if count:
65 ids = ids[:count]
66 return filter(lambda x: x and x.title, Podcast.get_multi(ids))
69 def __repr__(self):
70 if not self._id:
71 return super(Suggestions, self).__repr__()
72 else:
73 return '%d Suggestions for %s (%s)' % \
74 (len(self.podcasts), self.user, self._id)
77 class EpisodeAction(DocumentSchema):
78 """
79 One specific action to an episode. Must
80 always be part of a EpisodeUserState
81 """
83 action = StringProperty(required=True)
84 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
85 device_oldid = IntegerProperty(required=False)
86 device = StringProperty()
87 started = IntegerProperty()
88 playmark = IntegerProperty()
89 total = IntegerProperty()
91 def __eq__(self, other):
92 if not isinstance(other, EpisodeAction):
93 return False
94 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
95 'total')
96 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
99 def to_history_entry(self):
100 entry = HistoryEntry()
101 entry.action = self.action
102 entry.timestamp = self.timestamp
103 entry.device_id = self.device
104 entry.started = self.started
105 entry.position = self.playmark
106 entry.total = self.total
107 return entry
110 @staticmethod
111 def filter(user_id, since=None, until={}, podcast_id=None,
112 device_id=None):
113 """ Returns Episode Actions for the given criteria"""
115 since_str = since.strftime('%Y-%m-%dT%H:%M:%S') if since else None
116 until_str = until.strftime('%Y-%m-%dT%H:%M:%S') if until else {}
118 if since_str >= until_str:
119 return
121 if not podcast_id and not device_id:
122 view = 'episode_actions/by_user'
123 startkey = [user_id, since_str]
124 endkey = [user_id, until_str]
126 elif podcast_id and not device_id:
127 view = 'episode_actions/by_podcast'
128 startkey = [user_id, podcast_id, since_str]
129 endkey = [user_id, podcast_id, until_str]
131 elif device_id and not podcast_id:
132 view = 'episode_actions/by_device'
133 startkey = [user_id, device_id, since_str]
134 endkey = [user_id, device_id, until_str]
136 else:
137 view = 'episode_actions/by_podcast_device'
138 startkey = [user_id, podcast_id, device_id, since_str]
139 endkey = [user_id, podcast_id, device_id, until_str]
141 db = get_main_database()
142 res = db.view(view,
143 startkey = startkey,
144 endkey = endkey
147 for r in res:
148 action = r['value']
149 yield action
152 def validate_time_values(self):
153 """ Validates allowed combinations of time-values """
155 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
157 # Key found, but must not be supplied (no play action!)
158 if self.action != 'play':
159 for key in PLAY_ACTION_KEYS:
160 if getattr(self, key, None) is not None:
161 raise ValueError('%s only allowed in play actions' % key)
163 # Sanity check: If started or total are given, require playmark
164 if ((self.started is not None) or (self.total is not None)) and \
165 self.playmark is None:
166 raise ValueError('started and total require position')
168 # Sanity check: total and playmark can only appear together
169 if ((self.total is not None) or (self.started is not None)) and \
170 ((self.total is None) or (self.started is None)):
171 raise ValueError('total and started can only appear together')
174 def __repr__(self):
175 return '%s-Action on %s at %s (in %s)' % \
176 (self.action, self.device, self.timestamp, self._id)
179 def __hash__(self):
180 return hash(frozenset([self.action, self.timestamp, self.device,
181 self.started, self.playmark, self.total]))
184 class Chapter(Document):
185 """ A user-entered episode chapter """
187 device = StringProperty()
188 created = DateTimeProperty()
189 start = IntegerProperty(required=True)
190 end = IntegerProperty(required=True)
191 label = StringProperty()
192 advertisement = BooleanProperty()
194 @classmethod
195 def for_episode(cls, episode_id):
196 db = get_main_database()
197 r = db.view('chapters/by_episode',
198 startkey = [episode_id, None],
199 endkey = [episode_id, {}],
202 for res in r:
203 user = res['key'][1]
204 chapter = Chapter.wrap(res['value'])
205 yield (user, chapter)
208 def __repr__(self):
209 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
210 self.start, self.end)
213 class EpisodeUserState(Document):
215 Contains everything a user has done with an Episode
218 episode = StringProperty(required=True)
219 actions = SchemaListProperty(EpisodeAction)
220 settings = DictProperty()
221 user_oldid = IntegerProperty()
222 user = StringProperty(required=True)
223 ref_url = StringProperty(required=True)
224 podcast_ref_url = StringProperty(required=True)
225 merged_ids = StringListProperty()
226 chapters = SchemaListProperty(Chapter)
227 podcast = StringProperty(required=True)
230 @classmethod
231 def for_user_episode(cls, user, episode):
232 r = cls.view('episode_states/by_user_episode',
233 key = [user._id, episode._id],
234 include_docs = True,
235 limit = 1,
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):
256 import hashlib
257 cache_key = 'episode-state-%s-%s-%s' % (user._id,
258 hashlib.md5(podcast_url).hexdigest(),
259 hashlib.md5(episode_url).hexdigest())
261 state = cache.get(cache_key)
262 if state:
263 return state
265 res = cls.view('episode_states/by_ref_urls',
266 key = [user._id, podcast_url, episode_url], limit=1, include_docs=True)
267 if res:
268 state = res.first()
269 state.ref_url = episode_url
270 state.podcast_ref_url = podcast_url
271 cache.set(cache_key, state, 60*60)
272 return state
274 else:
275 episode = Episode.for_podcast_url(podcast_url, episode_url, create=True)
276 return episode.get_user_state(user)
279 @classmethod
280 def count(cls):
281 r = cls.view('episode_states/by_user_episode',
282 limit = 0,
283 stale = 'update_after',
285 return r.total_rows
288 def add_actions(self, actions):
289 map(EpisodeAction.validate_time_values, actions)
290 self.actions = list(self.actions) + actions
291 self.actions = list(set(self.actions))
292 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
295 def is_favorite(self):
296 return self.settings.get('is_favorite', False)
299 def set_favorite(self, set_to=True):
300 self.settings['is_favorite'] = set_to
303 def update_chapters(self, add=[], rem=[]):
304 """ Updates the Chapter list
306 * add contains the chapters to be added
308 * rem contains tuples of (start, end) times. Chapters that match
309 both endpoints will be removed
312 @repeat_on_conflict(['state'])
313 def update(state):
314 for chapter in add:
315 self.chapters = self.chapters + [chapter]
317 for start, end in rem:
318 keep = lambda c: c.start != start or c.end != end
319 self.chapters = filter(keep, self.chapters)
321 self.save()
323 update(state=self)
326 def get_history_entries(self):
327 return imap(EpisodeAction.to_history_entry, self.actions)
330 def __repr__(self):
331 return 'Episode-State %s (in %s)' % \
332 (self.episode, self._id)
334 def __eq__(self, other):
335 if not isinstance(other, EpisodeUserState):
336 return False
338 return (self.episode == other.episode and
339 self.user == other.user)
343 class SubscriptionAction(Document):
344 action = StringProperty()
345 timestamp = DateTimeProperty(default=datetime.utcnow)
346 device = StringProperty()
349 __metaclass__ = DocumentABCMeta
352 def __cmp__(self, other):
353 return cmp(self.timestamp, other.timestamp)
355 def __eq__(self, other):
356 return self.action == other.action and \
357 self.timestamp == other.timestamp and \
358 self.device == other.device
360 def __hash__(self):
361 return hash(self.action) + hash(self.timestamp) + hash(self.device)
363 def __repr__(self):
364 return '<SubscriptionAction %s on %s at %s>' % (
365 self.action, self.device, self.timestamp)
368 class PodcastUserState(Document):
370 Contains everything that a user has done
371 with a specific podcast and all its episodes
374 podcast = StringProperty(required=True)
375 user_oldid = IntegerProperty()
376 user = StringProperty(required=True)
377 settings = DictProperty()
378 actions = SchemaListProperty(SubscriptionAction)
379 tags = StringListProperty()
380 ref_url = StringProperty(required=True)
381 disabled_devices = StringListProperty()
382 merged_ids = StringListProperty()
385 @classmethod
386 def for_user_podcast(cls, user, podcast):
387 r = PodcastUserState.view('podcast_states/by_podcast', \
388 key=[podcast.get_id(), user._id], limit=1, include_docs=True)
389 if r:
390 return r.first()
391 else:
392 p = PodcastUserState()
393 p.podcast = podcast.get_id()
394 p.user = user._id
395 p.ref_url = podcast.url
396 p.settings['public_subscription'] = user.settings.get('public_subscriptions', True)
398 p.set_device_state(user.devices)
400 return p
403 @classmethod
404 def for_user(cls, user):
405 r = PodcastUserState.view('podcast_states/by_user',
406 startkey = [user._id, None],
407 endkey = [user._id, 'ZZZZ'],
408 include_docs = True,
410 return list(r)
413 @classmethod
414 def for_device(cls, device_id):
415 r = PodcastUserState.view('podcast_states/by_device',
416 startkey=[device_id, None], endkey=[device_id, {}],
417 include_docs=True)
418 return list(r)
421 def remove_device(self, device):
423 Removes all actions from the podcast state that refer to the
424 given device
426 self.actions = filter(lambda a: a.device != device.id, self.actions)
429 @classmethod
430 def count(cls):
431 r = PodcastUserState.view('podcast_states/by_user',
432 limit = 0,
433 stale = 'update_after',
435 return r.total_rows
438 def subscribe(self, device):
439 action = SubscriptionAction()
440 action.action = 'subscribe'
441 action.device = device.id
442 self.add_actions([action])
445 def unsubscribe(self, device):
446 action = SubscriptionAction()
447 action.action = 'unsubscribe'
448 action.device = device.id
449 self.add_actions([action])
452 def add_actions(self, actions):
453 self.actions = list(set(self.actions + actions))
454 self.actions = sorted(self.actions)
457 def add_tags(self, tags):
458 self.tags = list(set(self.tags + tags))
461 def set_device_state(self, devices):
462 disabled_devices = [device.id for device in devices if device.deleted]
463 self.disabled_devices = disabled_devices
466 def get_change_between(self, device_id, since, until):
468 Returns the change of the subscription status for the given device
469 between the two timestamps.
471 The change is given as either 'subscribe' (the podcast has been
472 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
473 None (no change)
476 device_actions = filter(lambda x: x.device == device_id, self.actions)
477 before = filter(lambda x: x.timestamp <= since, device_actions)
478 after = filter(lambda x: x.timestamp <= until, device_actions)
480 # nothing happened, so there can be no change
481 if not after:
482 return None
484 then = before[-1] if before else None
485 now = after[-1]
487 if then is None:
488 if now.action != 'unsubscribe':
489 return now.action
490 elif then.action != now.action:
491 return now.action
492 return None
495 def get_subscribed_device_ids(self):
496 """ device Ids on which the user subscribed to the podcast """
497 devices = set()
499 for action in self.actions:
500 if action.action == "subscribe":
501 if not action.device in self.disabled_devices:
502 devices.add(action.device)
503 else:
504 if action.device in devices:
505 devices.remove(action.device)
507 return devices
511 def is_public(self):
512 return self.settings.get('public_subscription', True)
515 def __eq__(self, other):
516 if other is None:
517 return False
519 return self.podcast == other.podcast and \
520 self.user == other.user
522 def __repr__(self):
523 return 'Podcast %s for User %s (%s)' % \
524 (self.podcast, self.user, self._id)
527 class Device(Document):
528 id = StringProperty(default=lambda: uuid.uuid4().hex)
529 oldid = IntegerProperty(required=False)
530 uid = StringProperty(required=True)
531 name = StringProperty(required=True, default='New Device')
532 type = StringProperty(required=True, default='other')
533 settings = DictProperty()
534 deleted = BooleanProperty(default=False)
535 user_agent = StringProperty()
538 def get_subscription_changes(self, since, until):
540 Returns the subscription changes for the device as two lists.
541 The first lists contains the Ids of the podcasts that have been
542 subscribed to, the second list of those that have been unsubscribed
543 from.
546 add, rem = [], []
547 podcast_states = PodcastUserState.for_device(self.id)
548 for p_state in podcast_states:
549 change = p_state.get_change_between(self.id, since, until)
550 if change == 'subscribe':
551 add.append( p_state.ref_url )
552 elif change == 'unsubscribe':
553 rem.append( p_state.ref_url )
555 return add, rem
558 def get_latest_changes(self):
559 podcast_states = PodcastUserState.for_device(self.id)
560 for p_state in podcast_states:
561 actions = filter(lambda x: x.device == self.id, reversed(p_state.actions))
562 if actions:
563 yield (p_state.podcast, actions[0])
566 def get_subscribed_podcast_ids(self):
567 r = self.view('subscriptions/by_device',
568 startkey = [self.id, None],
569 endkey = [self.id, {}]
571 return [res['key'][1] for res in r]
574 def get_subscribed_podcasts(self):
575 return Podcast.get_multi(self.get_subscribed_podcast_ids())
578 def __hash__(self):
579 return hash(frozenset([self.id, self.uid, self.name, self.type, self.deleted]))
582 def __eq__(self, other):
583 return self.id == other.id
586 def __repr__(self):
587 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
590 def __str__(self):
591 return self.name
593 def __unicode__(self):
594 return self.name
598 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
599 'publisher_update_token', 'userpage_token')
602 class TokenException(Exception):
603 pass
606 class User(BaseUser, SyncedDevicesMixin):
607 oldid = IntegerProperty()
608 settings = DictProperty()
609 devices = SchemaListProperty(Device)
610 published_objects = StringListProperty()
611 deleted = BooleanProperty(default=False)
612 suggestions_up_to_date = BooleanProperty(default=False)
614 # token for accessing subscriptions of this use
615 subscriptions_token = StringProperty(default=None)
617 # token for accessing the favorite-episodes feed of this user
618 favorite_feeds_token = StringProperty(default=None)
620 # token for automatically updating feeds published by this user
621 publisher_update_token = StringProperty(default=None)
623 # token for accessing the userpage of this user
624 userpage_token = StringProperty(default=None)
626 class Meta:
627 app_label = 'users'
630 def create_new_token(self, token_name, length=32):
631 """ creates a new random token """
633 if token_name not in TOKEN_NAMES:
634 raise TokenException('Invalid token name %s' % token_name)
636 token = "".join(random.sample(string.letters+string.digits, length))
637 setattr(self, token_name, token)
641 def get_token(self, token_name):
642 """ returns a token, and generate those that are still missing """
644 generated = False
646 if token_name not in TOKEN_NAMES:
647 raise TokenException('Invalid token name %s' % token_name)
649 for tn in TOKEN_NAMES:
650 if getattr(self, tn) is None:
651 self.create_new_token(tn)
652 generated = True
654 if generated:
655 self.save()
657 return getattr(self, token_name)
661 @property
662 def active_devices(self):
663 not_deleted = lambda d: not d.deleted
664 return filter(not_deleted, self.devices)
667 @property
668 def inactive_devices(self):
669 deleted = lambda d: d.deleted
670 return filter(deleted, self.devices)
673 def get_devices_by_id(self):
674 return dict( (device.id, device) for device in self.devices)
677 def get_device(self, id):
679 if not hasattr(self, '__device_by_id'):
680 self.__devices_by_id = dict( (d.id, d) for d in self.devices)
682 return self.__devices_by_id.get(id, None)
685 def get_device_by_uid(self, uid, only_active=True):
687 if not hasattr(self, '__devices_by_uio'):
688 self.__devices_by_uid = dict( (d.uid, d) for d in self.devices)
690 try:
691 device = self.__devices_by_uid[uid]
693 if only_active and device.deleted:
694 raise DeviceDeletedException(
695 'Device with UID %s is deleted' % uid)
697 return device
699 except KeyError as e:
700 raise DeviceDoesNotExist('There is no device with UID %s' % uid)
703 def update_device(self, device):
704 """ Sets the device and saves the user """
706 @repeat_on_conflict(['user'])
707 def _update(user, device):
708 user.set_device(device)
709 user.save()
711 _update(user=self, device=device)
714 def set_device(self, device):
716 if not RE_DEVICE_UID.match(device.uid):
717 raise DeviceUIDException("'{uid} is not a valid device ID".format(
718 uid=device.uid))
720 devices = list(self.devices)
721 ids = [x.id for x in devices]
722 if not device.id in ids:
723 devices.append(device)
724 self.devices = devices
725 return
727 index = ids.index(device.id)
728 devices.pop(index)
729 devices.insert(index, device)
730 self.devices = devices
733 def remove_device(self, device):
734 devices = list(self.devices)
735 ids = [x.id for x in devices]
736 if not device.id in ids:
737 return
739 index = ids.index(device.id)
740 devices.pop(index)
741 self.devices = devices
743 if self.is_synced(device):
744 self.unsync_device(device)
747 def get_subscriptions(self, public=None):
749 Returns a list of (podcast-id, device-id) tuples for all
750 of the users subscriptions
753 r = PodcastUserState.view('subscriptions/by_user',
754 startkey = [self._id, public, None, None],
755 endkey = [self._id+'ZZZ', None, None, None],
756 reduce = False,
758 return [res['key'][1:] for res in r]
761 def get_subscriptions_by_device(self, public=None):
762 get_dev = itemgetter(2)
763 groups = collections.defaultdict(list)
764 subscriptions = self.get_subscriptions(public=public)
765 subscriptions = sorted(subscriptions, key=get_dev)
767 for public, podcast_id, device_id in subscriptions:
768 groups[device_id].append(podcast_id)
770 return groups
773 def get_subscribed_podcast_ids(self, public=None):
775 Returns the Ids of all subscribed podcasts
777 return list(set(x[1] for x in self.get_subscriptions(public=public)))
780 def get_subscribed_podcasts(self, public=None):
781 return list(Podcast.get_multi(self.get_subscribed_podcast_ids(public=public)))
784 def get_num_listened_episodes(self):
785 db = EpisodeUserState.get_db()
786 r = db.view('listeners/by_user_podcast',
787 startkey = [self._id, None],
788 endkey = [self._id, {}],
789 reduce = True,
790 group_level = 2,
792 for obj in r:
793 count = obj['value']
794 podcast = obj['key'][1]
795 yield (podcast, count)
798 def get_subscription_history(self, device_id=None, reverse=False, public=None):
799 """ Returns chronologically ordered subscription history entries
801 Setting device_id restricts the actions to a certain device
804 def action_iter(state):
805 for action in sorted(state.actions, reverse=reverse):
806 if device_id is not None and device_id != action.device:
807 continue
809 if public is not None and state.is_public() != public:
810 continue
812 entry = HistoryEntry()
813 entry.timestamp = action.timestamp
814 entry.action = action.action
815 entry.podcast_id = state.podcast
816 entry.device_id = action.device
817 yield entry
819 if device_id is None:
820 podcast_states = PodcastUserState.for_user(self)
821 else:
822 podcast_states = PodcastUserState.for_device(device_id)
824 # create an action_iter for each PodcastUserState
825 subscription_action_lists = [action_iter(x) for x in podcast_states]
827 action_cmp_key = lambda x: x.timestamp
829 # Linearize their subscription-actions
830 return linearize(action_cmp_key, subscription_action_lists, reverse)
833 def get_global_subscription_history(self, public=None):
834 """ Actions that added/removed podcasts from the subscription list
836 Returns an iterator of all subscription actions that either
837 * added subscribed a podcast that hasn't been subscribed directly
838 before the action (but could have been subscribed) earlier
839 * removed a subscription of the podcast is not longer subscribed
840 after the action
843 subscriptions = collections.defaultdict(int)
845 for entry in self.get_subscription_history(public=public):
846 if entry.action == 'subscribe':
847 subscriptions[entry.podcast_id] += 1
849 # a new subscription has been added
850 if subscriptions[entry.podcast_id] == 1:
851 yield entry
853 elif entry.action == 'unsubscribe':
854 subscriptions[entry.podcast_id] -= 1
856 # the last subscription has been removed
857 if subscriptions[entry.podcast_id] == 0:
858 yield entry
862 def get_newest_episodes(self, max_date, max_per_podcast=5):
863 """ Returns the newest episodes of all subscribed podcasts
865 Only max_per_podcast episodes per podcast are loaded. Episodes with
866 release dates above max_date are discarded.
868 This method returns a generator that produces the newest episodes.
870 The number of required DB queries is equal to the number of (distinct)
871 podcasts of all consumed episodes (max: number of subscribed podcasts),
872 plus a constant number of initial queries (when the first episode is
873 consumed). """
875 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
877 podcasts = list(self.get_subscribed_podcasts())
878 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
879 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
880 reverse=True)
882 podcast_dict = dict((p.get_id(), p) for p in podcasts)
884 # contains the un-yielded episodes, newest first
885 episodes = []
887 for podcast in podcasts:
889 yielded_episodes = 0
891 for episode in episodes:
892 # determine for which episodes there won't be a new episodes
893 # that is newer; those can be yielded
894 if episode.released > podcast.latest_episode_timestamp:
895 p = podcast_dict.get(episode.podcast, None)
896 yield proxy_object(episode, podcast=p)
897 yielded_episodes += 1
898 else:
899 break
901 # remove the episodes that have been yielded before
902 episodes = episodes[yielded_episodes:]
904 # fetch and merge episodes for the next podcast
905 new_episodes = list(podcast.get_episodes(since=1, until=max_date,
906 descending=True, limit=max_per_podcast))
907 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
910 # yield the remaining episodes
911 for episode in episodes:
912 podcast = podcast_dict.get(episode.podcast, None)
913 yield proxy_object(episode, podcast=podcast)
916 def get_latest_episodes(self, count=10):
917 """ Returns the latest episodes that the user has accessed """
919 startkey = [self._id, {}]
920 endkey = [self._id, None]
922 db = get_main_database()
923 res = db.view('listeners/by_user',
924 startkey = startkey,
925 endkey = endkey,
926 include_docs = True,
927 descending = True,
928 limit = count,
929 reduce = False,
932 keys = [r['value'] for r in res]
933 return list(Episode.get_multi(keys))
936 def get_num_played_episodes(self, since=None, until={}):
937 """ Number of played episodes in interval """
939 since_str = since.strftime('%Y-%m-%d') if since else None
940 until_str = until.strftime('%Y-%m-%d') if until else {}
942 startkey = [self._id, since_str]
943 endkey = [self._id, until_str]
945 db = EpisodeUserState.get_db()
946 res = db.view('listeners/by_user',
947 startkey = startkey,
948 endkey = endkey,
949 reduce = True,
952 val = res.one()
953 return val['value'] if val else 0
957 def get_seconds_played(self, since=None, until={}):
958 """ Returns the number of seconds that the user has listened
960 Can be selected by timespan, podcast and episode """
962 since_str = since.strftime('%Y-%m-%dT%H:%M:%S') if since else None
963 until_str = until.strftime('%Y-%m-%dT%H:%M:%S') if until else {}
965 startkey = [self._id, since_str]
966 endkey = [self._id, until_str]
968 db = EpisodeUserState.get_db()
969 res = db.view('listeners/times_played_by_user',
970 startkey = startkey,
971 endkey = endkey,
972 reduce = True,
975 val = res.one()
976 return val['value'] if val else 0
979 def save(self, *args, **kwargs):
980 super(User, self).save(*args, **kwargs)
982 podcast_states = PodcastUserState.for_user(self)
983 for state in podcast_states:
984 @repeat_on_conflict(['state'])
985 def _update_state(state):
986 old_devs = set(state.disabled_devices)
987 state.set_device_state(self.devices)
989 if old_devs != set(state.disabled_devices):
990 state.save()
992 _update_state(state=state)
997 def __eq__(self, other):
998 if not other:
999 return False
1001 # ensure that other isn't AnonymousUser
1002 return other.is_authenticated() and self._id == other._id
1005 def __ne__(self, other):
1006 return not(self == other)
1009 def __repr__(self):
1010 return 'User %s' % self._id
1013 class History(object):
1015 def __init__(self, user, device):
1016 self.user = user
1017 self.device = device
1018 self._db = get_main_database()
1020 if device:
1021 self._view = 'history/by_device'
1022 self._startkey = [self.user._id, device.id, None]
1023 self._endkey = [self.user._id, device.id, {}]
1024 else:
1025 self._view = 'history/by_user'
1026 self._startkey = [self.user._id, None]
1027 self._endkey = [self.user._id, {}]
1030 def __getitem__(self, key):
1032 if isinstance(key, slice):
1033 start = key.start or 0
1034 length = key.stop - start
1035 else:
1036 start = key
1037 length = 1
1039 res = self._db.view(self._view,
1040 descending = True,
1041 startkey = self._endkey,
1042 endkey = self._startkey,
1043 limit = length,
1044 skip = start,
1047 for action in res:
1048 action = action['value']
1049 yield HistoryEntry.from_action_dict(action)
1053 class HistoryEntry(object):
1054 """ A class that can represent subscription and episode actions """
1057 @classmethod
1058 def from_action_dict(cls, action):
1060 entry = HistoryEntry()
1062 if 'timestamp' in action:
1063 ts = action.pop('timestamp')
1064 entry.timestamp = dateutil.parser.parse(ts)
1066 for key, value in action.items():
1067 setattr(entry, key, value)
1069 return entry
1072 @property
1073 def playmark(self):
1074 return getattr(self, 'position', None)
1077 @classmethod
1078 def fetch_data(cls, user, entries,
1079 podcasts=None, episodes=None):
1080 """ Efficiently loads additional data for a number of entries """
1082 if podcasts is None:
1083 # load podcast data
1084 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
1085 podcast_ids = filter(None, podcast_ids)
1086 podcasts = get_to_dict(Podcast, podcast_ids, get_id=Podcast.get_id)
1088 if episodes is None:
1089 # load episode data
1090 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
1091 episode_ids = filter(None, episode_ids)
1092 episodes = get_to_dict(Episode, episode_ids)
1094 # load device data
1095 # does not need pre-populated data because no db-access is required
1096 device_ids = [getattr(x, 'device_id', None) for x in entries]
1097 device_ids = filter(None, device_ids)
1098 devices = dict([ (id, user.get_device(id)) for id in device_ids])
1101 for entry in entries:
1102 podcast_id = getattr(entry, 'podcast_id', None)
1103 entry.podcast = podcasts.get(podcast_id, None)
1105 episode_id = getattr(entry, 'episode_id', None)
1106 entry.episode = episodes.get(episode_id, None)
1108 if hasattr(entry, 'user'):
1109 entry.user = user
1111 device = devices.get(getattr(entry, 'device_id', None), None)
1112 entry.device = device
1115 return entries