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 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):
33 class DeviceDoesNotExist(Exception):
37 class DeviceDeletedException(DeviceDoesNotExist
):
41 class Suggestions(Document
, RatingMixin
):
42 user
= StringProperty(required
=True)
43 user_oldid
= IntegerProperty()
44 podcasts
= StringListProperty()
45 blacklist
= StringListProperty()
48 def for_user(cls
, user
):
49 r
= cls
.view('suggestions/by_user', key
=user
._id
, \
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
)
66 return filter(lambda x
: x
and x
.title
, Podcast
.get_multi(ids
))
71 return super(Suggestions
, self
).__repr
__()
73 return '%d Suggestions for %s (%s)' % \
74 (len(self
.podcasts
), self
.user
, self
._id
)
77 class EpisodeAction(DocumentSchema
):
79 One specific action to an episode. Must
80 always be part of a EpisodeUserState
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
):
94 vals
= ('action', 'timestamp', 'device', 'started', 'playmark',
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
111 def filter(user_id
, since
=None, until
={}, podcast_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
:
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
]
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()
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')
175 return '%s-Action on %s at %s (in %s)' % \
176 (self
.action
, self
.device
, self
.timestamp
, self
._id
)
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()
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
, {}],
204 chapter
= Chapter
.wrap(res
['value'])
205 yield (user
, chapter
)
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)
231 def for_user_episode(cls
, user
, episode
):
232 r
= cls
.view('episode_states/by_user_episode',
233 key
= [user
._id
, episode
._id
],
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
254 def for_ref_urls(cls
, user
, podcast_url
, episode_url
):
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
)
265 res
= cls
.view('episode_states/by_ref_urls',
266 key
= [user
._id
, podcast_url
, episode_url
], limit
=1, include_docs
=True)
269 state
.ref_url
= episode_url
270 state
.podcast_ref_url
= podcast_url
271 cache
.set(cache_key
, state
, 60*60)
275 episode
= Episode
.for_podcast_url(podcast_url
, episode_url
, create
=True)
276 return episode
.get_user_state(user
)
281 r
= cls
.view('episode_states/by_user_episode',
283 stale
= 'update_after',
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'])
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
)
326 def get_history_entries(self
):
327 return imap(EpisodeAction
.to_history_entry
, self
.actions
)
331 return 'Episode-State %s (in %s)' % \
332 (self
.episode
, self
._id
)
334 def __eq__(self
, other
):
335 if not isinstance(other
, EpisodeUserState
):
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
361 return hash(self
.action
) + hash(self
.timestamp
) + hash(self
.device
)
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()
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)
392 p
= PodcastUserState()
393 p
.podcast
= podcast
.get_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
)
404 def for_user(cls
, user
):
405 r
= PodcastUserState
.view('podcast_states/by_user',
406 startkey
= [user
._id
, None],
407 endkey
= [user
._id
, 'ZZZZ'],
414 def for_device(cls
, device_id
):
415 r
= PodcastUserState
.view('podcast_states/by_device',
416 startkey
=[device_id
, None], endkey
=[device_id
, {}],
421 def remove_device(self
, device
):
423 Removes all actions from the podcast state that refer to the
426 self
.actions
= filter(lambda a
: a
.device
!= device
.id, self
.actions
)
431 r
= PodcastUserState
.view('podcast_states/by_user',
433 stale
= 'update_after',
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
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
484 then
= before
[-1] if before
else None
488 if now
.action
!= 'unsubscribe':
490 elif then
.action
!= now
.action
:
495 def get_subscribed_device_ids(self
):
496 """ device Ids on which the user subscribed to the podcast """
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
)
504 if action
.device
in devices
:
505 devices
.remove(action
.device
)
512 return self
.settings
.get('public_subscription', True)
515 def __eq__(self
, other
):
519 return self
.podcast
== other
.podcast
and \
520 self
.user
== other
.user
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
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
)
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
))
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())
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
587 return '<{cls} {id}>'.format(cls
=self
.__class
__.__name
__, id=self
.id)
593 def __unicode__(self
):
598 TOKEN_NAMES
= ('subscriptions_token', 'favorite_feeds_token',
599 'publisher_update_token', 'userpage_token')
602 class TokenException(Exception):
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)
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 """
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
)
657 return getattr(self
, token_name
)
662 def active_devices(self
):
663 not_deleted
= lambda d
: not d
.deleted
664 return filter(not_deleted
, self
.devices
)
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
)
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
)
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
)
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(
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
727 index
= ids
.index(device
.id)
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
:
739 index
= ids
.index(device
.id)
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],
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
)
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
, {}],
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
:
809 if public
is not None and state
.is_public() != public
:
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
819 if device_id
is None:
820 podcast_states
= PodcastUserState
.for_user(self
)
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
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:
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:
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
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
,
882 podcast_dict
= dict((p
.get_id(), p
) for p
in podcasts
)
884 # contains the un-yielded episodes, newest first
887 for podcast
in podcasts
:
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
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',
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',
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',
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
):
992 _update_state(state
=state
)
997 def __eq__(self
, other
):
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
)
1010 return 'User %s' % self
._id
1013 class History(object):
1015 def __init__(self
, user
, device
):
1017 self
.device
= device
1018 self
._db
= get_main_database()
1021 self
._view
= 'history/by_device'
1022 self
._startkey
= [self
.user
._id
, device
.id, None]
1023 self
._endkey
= [self
.user
._id
, device
.id, {}]
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
1039 res
= self
._db
.view(self
._view
,
1041 startkey
= self
._endkey
,
1042 endkey
= self
._startkey
,
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 """
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
)
1074 return getattr(self
, 'position', None)
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:
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:
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
)
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'):
1111 device
= devices
.get(getattr(entry
, 'device_id', None), None)
1112 entry
.device
= device