2 import uuid
, collections
3 from datetime
import datetime
5 from itertools
import imap
6 from operator
import itemgetter
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):
34 class DeviceDoesNotExist(Exception):
38 class DeviceDeletedException(DeviceDoesNotExist
):
42 class Suggestions(Document
, RatingMixin
):
43 user
= StringProperty(required
=True)
44 user_oldid
= IntegerProperty()
45 podcasts
= StringListProperty()
46 blacklist
= StringListProperty()
49 def for_user(cls
, user
):
50 r
= cls
.view('suggestions/by_user', key
=user
._id
, \
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
)
67 return filter(lambda x
: x
and x
.title
, Podcast
.get_multi(ids
))
72 return super(Suggestions
, self
).__repr
__()
74 return '%d Suggestions for %s (%s)' % \
75 (len(self
.podcasts
), self
.user
, self
._id
)
78 class EpisodeAction(DocumentSchema
):
80 One specific action to an episode. Must
81 always be part of a EpisodeUserState
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
):
95 vals
= ('action', 'timestamp', 'device', 'started', 'playmark',
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
112 def filter(user_id
, since
=None, until
={}, podcast_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
:
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
]
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()
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')
176 return '%s-Action on %s at %s (in %s)' % \
177 (self
.action
, self
.device
, self
.timestamp
, self
._id
)
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()
196 def for_episode(cls
, episode_id
):
197 r
= cls
.view('chapters/by_episode',
198 startkey
= [episode_id
, None],
199 endkey
= [episode_id
, {}],
205 chapter
= Chapter
.wrap(res
['value'])
206 yield (user
, chapter
)
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)
232 def for_user_episode(cls
, user
, episode
):
233 r
= cls
.view('episode_states/by_user_episode',
234 key
= [user
._id
, episode
._id
],
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
255 def for_ref_urls(cls
, user
, podcast_url
, episode_url
):
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
)
266 res
= cls
.view('episode_states/by_ref_urls',
267 key
= [user
._id
, podcast_url
, episode_url
], limit
=1, include_docs
=True)
270 state
.ref_url
= episode_url
271 state
.podcast_ref_url
= podcast_url
272 cache
.set(cache_key
, state
, 60*60)
276 episode
= Episode
.for_podcast_url(podcast_url
, episode_url
, create
=True)
277 return episode
.get_user_state(user
)
282 r
= cls
.view('episode_states/by_user_episode',
284 stale
= 'update_after',
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'])
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
)
327 def get_history_entries(self
):
328 return imap(EpisodeAction
.to_history_entry
, self
.actions
)
332 return 'Episode-State %s (in %s)' % \
333 (self
.episode
, self
._id
)
335 def __eq__(self
, other
):
336 if not isinstance(other
, EpisodeUserState
):
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
362 return hash(self
.action
) + hash(self
.timestamp
) + hash(self
.device
)
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()
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)
393 p
= PodcastUserState()
394 p
.podcast
= podcast
.get_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
)
405 def for_user(cls
, user
):
406 r
= PodcastUserState
.view('podcast_states/by_user',
407 startkey
= [user
._id
, None],
408 endkey
= [user
._id
, 'ZZZZ'],
415 def for_device(cls
, device_id
):
416 r
= PodcastUserState
.view('podcast_states/by_device',
417 startkey
=[device_id
, None], endkey
=[device_id
, {}],
422 def remove_device(self
, device
):
424 Removes all actions from the podcast state that refer to the
427 self
.actions
= filter(lambda a
: a
.device
!= device
.id, self
.actions
)
432 r
= PodcastUserState
.view('podcast_states/by_user',
434 stale
= 'update_after',
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
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
485 then
= before
[-1] if before
else None
489 if now
.action
!= 'unsubscribe':
491 elif then
.action
!= now
.action
:
496 def get_subscribed_device_ids(self
):
497 """ device Ids on which the user subscribed to the podcast """
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
)
505 if action
.device
in devices
:
506 devices
.remove(action
.device
)
513 return self
.settings
.get('public_subscription', True)
516 def __eq__(self
, other
):
520 return self
.podcast
== other
.podcast
and \
521 self
.user
== other
.user
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
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
)
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
))
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())
580 return hash(frozenset([self
.uid
, self
.name
, self
.type, self
.deleted
]))
583 def __eq__(self
, other
):
584 return self
.id == other
.id
588 return '<{cls} {id}>'.format(cls
=self
.__class
__.__name
__, id=self
.id)
594 def __unicode__(self
):
599 TOKEN_NAMES
= ('subscriptions_token', 'favorite_feeds_token',
600 'publisher_update_token', 'userpage_token')
603 class TokenException(Exception):
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)
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 """
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
)
658 return getattr(self
, token_name
)
663 def active_devices(self
):
664 not_deleted
= lambda d
: not d
.deleted
665 return filter(not_deleted
, self
.devices
)
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
)
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
)
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
)
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(
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
728 index
= ids
.index(device
.id)
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
:
740 index
= ids
.index(device
.id)
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],
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
)
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
, {}],
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
:
811 if public
is not None and state
.is_public() != public
:
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
821 if device_id
is None:
822 podcast_states
= PodcastUserState
.for_user(self
)
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
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:
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:
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
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
,
884 podcast_dict
= dict((p
.get_id(), p
) for p
in podcasts
)
886 # contains the un-yielded episodes, newest first
889 for podcast
in podcasts
:
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
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',
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',
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',
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
):
994 _update_state(state
=state
)
999 def __eq__(self
, other
):
1003 # ensure that other isn't AnonymousUser
1004 return other
.is_authenticated() and self
._id
== other
._id
1008 return 'User %s' % self
._id
1011 class History(object):
1013 def __init__(self
, user
, device
):
1015 self
.device
= device
1016 self
._db
= get_main_database()
1019 self
._view
= 'history/by_device'
1020 self
._startkey
= [self
.user
._id
, device
.id, None]
1021 self
._endkey
= [self
.user
._id
, device
.id, {}]
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
1037 res
= self
._db
.view(self
._view
,
1039 startkey
= self
._endkey
,
1040 endkey
= self
._startkey
,
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 """
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
)
1072 return getattr(self
, 'position', None)
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:
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:
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
)
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'):
1109 device
= devices
.get(getattr(entry
, 'device_id', None), None)
1110 entry
.device
= device