4 from datetime
import datetime
6 from itertools
import imap
7 from operator
import itemgetter
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
.utils
import linearize
18 from mygpo
.core
.proxy
import DocumentABCMeta
, proxy_object
19 from mygpo
.decorators
import repeat_on_conflict
20 from mygpo
.users
.ratings
import RatingMixin
21 from mygpo
.users
.sync
import SyncedDevicesMixin
22 from mygpo
.users
.subscriptions
import subscription_changes
, podcasts_for_states
23 from mygpo
.users
.settings
import FAV_FLAG
, PUBLIC_SUB_PODCAST
, SettingsMixin
24 from mygpo
.db
.couchdb
.podcast
import podcasts_by_id
, podcasts_to_dict
25 from mygpo
.db
.couchdb
.user
import user_history
, device_history
27 # make sure this code is executed at startup
28 from mygpo
.users
.signals
import *
31 RE_DEVICE_UID
= re
.compile(r
'^[\w.-]+$')
33 # TODO: derive from ValidationException?
34 class InvalidEpisodeActionAttributes(ValueError):
35 """ raised when the attribues of an episode action fail validation """
38 class DeviceUIDException(Exception):
42 class DeviceDoesNotExist(Exception):
46 class DeviceDeletedException(DeviceDoesNotExist
):
50 class Suggestions(Document
, RatingMixin
):
51 user
= StringProperty(required
=True)
52 user_oldid
= IntegerProperty()
53 podcasts
= StringListProperty()
54 blacklist
= StringListProperty()
57 def get_podcasts(self
, count
=None):
58 user
= User
.get(self
.user
)
59 subscriptions
= user
.get_subscribed_podcast_ids()
61 ids
= filter(lambda x
: not x
in self
.blacklist
+ subscriptions
, self
.podcasts
)
64 return filter(lambda x
: x
and x
.title
, podcasts_by_id(ids
))
69 return super(Suggestions
, self
).__repr
__()
71 return '%d Suggestions for %s (%s)' % \
72 (len(self
.podcasts
), self
.user
, self
._id
)
75 class EpisodeAction(DocumentSchema
):
77 One specific action to an episode. Must
78 always be part of a EpisodeUserState
81 action
= StringProperty(required
=True)
83 # walltime of the event (assigned by the uploading client, defaults to now)
84 timestamp
= DateTimeProperty(required
=True, default
=datetime
.utcnow
)
86 # upload time of the event
87 upload_timestamp
= IntegerProperty(required
=True)
89 device_oldid
= IntegerProperty(required
=False)
90 device
= StringProperty()
91 started
= IntegerProperty()
92 playmark
= IntegerProperty()
93 total
= IntegerProperty()
95 def __eq__(self
, other
):
96 if not isinstance(other
, EpisodeAction
):
98 vals
= ('action', 'timestamp', 'device', 'started', 'playmark',
100 return all([getattr(self
, v
, None) == getattr(other
, v
, None) for v
in vals
])
103 def to_history_entry(self
):
104 entry
= HistoryEntry()
105 entry
.action
= self
.action
106 entry
.timestamp
= self
.timestamp
107 entry
.device_id
= self
.device
108 entry
.started
= self
.started
109 entry
.position
= self
.playmark
110 entry
.total
= self
.total
115 def validate_time_values(self
):
116 """ Validates allowed combinations of time-values """
118 PLAY_ACTION_KEYS
= ('playmark', 'started', 'total')
120 # Key found, but must not be supplied (no play action!)
121 if self
.action
!= 'play':
122 for key
in PLAY_ACTION_KEYS
:
123 if getattr(self
, key
, None) is not None:
124 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key
)
126 # Sanity check: If started or total are given, require playmark
127 if ((self
.started
is not None) or (self
.total
is not None)) and \
128 self
.playmark
is None:
129 raise InvalidEpisodeActionAttributes('started and total require position')
131 # Sanity check: total and playmark can only appear together
132 if ((self
.total
is not None) or (self
.started
is not None)) and \
133 ((self
.total
is None) or (self
.started
is None)):
134 raise InvalidEpisodeActionAttributes('total and started can only appear together')
138 return '%s-Action on %s at %s (in %s)' % \
139 (self
.action
, self
.device
, self
.timestamp
, self
._id
)
143 return hash(frozenset([self
.action
, self
.timestamp
, self
.device
,
144 self
.started
, self
.playmark
, self
.total
]))
147 class Chapter(Document
):
148 """ A user-entered episode chapter """
150 device
= StringProperty()
151 created
= DateTimeProperty()
152 start
= IntegerProperty(required
=True)
153 end
= IntegerProperty(required
=True)
154 label
= StringProperty()
155 advertisement
= BooleanProperty()
159 return '<%s %s (%d-%d)>' % (self
.__class
__.__name
__, self
.label
,
160 self
.start
, self
.end
)
163 class EpisodeUserState(Document
, SettingsMixin
):
165 Contains everything a user has done with an Episode
168 episode
= StringProperty(required
=True)
169 actions
= SchemaListProperty(EpisodeAction
)
170 user_oldid
= IntegerProperty()
171 user
= StringProperty(required
=True)
172 ref_url
= StringProperty(required
=True)
173 podcast_ref_url
= StringProperty(required
=True)
174 merged_ids
= StringListProperty()
175 chapters
= SchemaListProperty(Chapter
)
176 podcast
= StringProperty(required
=True)
180 def add_actions(self
, actions
):
181 map(EpisodeAction
.validate_time_values
, actions
)
182 self
.actions
= list(self
.actions
) + actions
183 self
.actions
= list(set(self
.actions
))
184 self
.actions
= sorted(self
.actions
, key
=lambda x
: x
.timestamp
)
187 def is_favorite(self
):
188 return self
.get_wksetting(FAV_FLAG
)
191 def set_favorite(self
, set_to
=True):
192 self
.settings
[FAV_FLAG
.name
] = set_to
195 def get_history_entries(self
):
196 return imap(EpisodeAction
.to_history_entry
, self
.actions
)
200 return 'Episode-State %s (in %s)' % \
201 (self
.episode
, self
._id
)
203 def __eq__(self
, other
):
204 if not isinstance(other
, EpisodeUserState
):
207 return (self
.episode
== other
.episode
and
208 self
.user
== other
.user
)
212 class SubscriptionAction(Document
):
213 action
= StringProperty()
214 timestamp
= DateTimeProperty(default
=datetime
.utcnow
)
215 device
= StringProperty()
218 __metaclass__
= DocumentABCMeta
221 def __cmp__(self
, other
):
222 return cmp(self
.timestamp
, other
.timestamp
)
224 def __eq__(self
, other
):
225 return self
.action
== other
.action
and \
226 self
.timestamp
== other
.timestamp
and \
227 self
.device
== other
.device
230 return hash(self
.action
) + hash(self
.timestamp
) + hash(self
.device
)
233 return '<SubscriptionAction %s on %s at %s>' % (
234 self
.action
, self
.device
, self
.timestamp
)
237 class PodcastUserState(Document
, SettingsMixin
):
239 Contains everything that a user has done
240 with a specific podcast and all its episodes
243 podcast
= StringProperty(required
=True)
244 user_oldid
= IntegerProperty()
245 user
= StringProperty(required
=True)
246 actions
= SchemaListProperty(SubscriptionAction
)
247 tags
= StringListProperty()
248 ref_url
= StringProperty(required
=True)
249 disabled_devices
= StringListProperty()
250 merged_ids
= StringListProperty()
253 def remove_device(self
, device
):
255 Removes all actions from the podcast state that refer to the
258 self
.actions
= filter(lambda a
: a
.device
!= device
.id, self
.actions
)
261 def subscribe(self
, device
):
262 action
= SubscriptionAction()
263 action
.action
= 'subscribe'
264 action
.device
= device
.id
265 self
.add_actions([action
])
268 def unsubscribe(self
, device
):
269 action
= SubscriptionAction()
270 action
.action
= 'unsubscribe'
271 action
.device
= device
.id
272 self
.add_actions([action
])
275 def add_actions(self
, actions
):
276 self
.actions
= list(set(self
.actions
+ actions
))
277 self
.actions
= sorted(self
.actions
)
280 def add_tags(self
, tags
):
281 self
.tags
= list(set(self
.tags
+ tags
))
284 def set_device_state(self
, devices
):
285 disabled_devices
= [device
.id for device
in devices
if device
.deleted
]
286 self
.disabled_devices
= disabled_devices
289 def get_change_between(self
, device_id
, since
, until
):
291 Returns the change of the subscription status for the given device
292 between the two timestamps.
294 The change is given as either 'subscribe' (the podcast has been
295 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
299 device_actions
= filter(lambda x
: x
.device
== device_id
, self
.actions
)
300 before
= filter(lambda x
: x
.timestamp
<= since
, device_actions
)
301 after
= filter(lambda x
: x
.timestamp
<= until
, device_actions
)
303 # nothing happened, so there can be no change
307 then
= before
[-1] if before
else None
311 if now
.action
!= 'unsubscribe':
313 elif then
.action
!= now
.action
:
318 def get_subscribed_device_ids(self
):
319 """ device Ids on which the user subscribed to the podcast """
322 for action
in self
.actions
:
323 if action
.action
== "subscribe":
324 if not action
.device
in self
.disabled_devices
:
325 devices
.add(action
.device
)
327 if action
.device
in devices
:
328 devices
.remove(action
.device
)
333 def is_subscribed_on(self
, device
):
334 """ checks if the podcast is subscribed on the given device """
336 for action
in reversed(self
.actions
):
337 if not action
.device
== device
.id:
340 # we only need to check the latest action for the device
341 return (action
.action
== 'subscribe')
343 # we haven't found any matching action
348 return self
.get_wksetting(PUBLIC_SUB_PODCAST
)
351 def __eq__(self
, other
):
355 return self
.podcast
== other
.podcast
and \
356 self
.user
== other
.user
359 return 'Podcast %s for User %s (%s)' % \
360 (self
.podcast
, self
.user
, self
._id
)
363 class Device(Document
, SettingsMixin
):
364 id = StringProperty(default
=lambda: uuid
.uuid4().hex)
365 oldid
= IntegerProperty(required
=False)
366 uid
= StringProperty(required
=True)
367 name
= StringProperty(required
=True, default
='New Device')
368 type = StringProperty(required
=True, default
='other')
369 deleted
= BooleanProperty(default
=False)
370 user_agent
= StringProperty()
373 def get_subscription_changes(self
, since
, until
):
375 Returns the subscription changes for the device as two lists.
376 The first lists contains the Ids of the podcasts that have been
377 subscribed to, the second list of those that have been unsubscribed
381 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_device
382 podcast_states
= podcast_states_for_device(self
.id)
383 return subscription_changes(self
.id, podcast_states
, since
, until
)
386 def get_latest_changes(self
):
388 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_device
390 podcast_states
= podcast_states_for_device(self
.id)
391 for p_state
in podcast_states
:
392 actions
= filter(lambda x
: x
.device
== self
.id, reversed(p_state
.actions
))
394 yield (p_state
.podcast
, actions
[0])
397 def get_subscribed_podcast_ids(self
):
398 from mygpo
.db
.couchdb
.podcast_state
import get_subscribed_podcast_states_by_device
399 states
= get_subscribed_podcast_states_by_device(self
)
400 return [state
.podcast
for state
in states
]
403 def get_subscribed_podcasts(self
):
404 """ Returns all subscribed podcasts for the device
406 The attribute "url" contains the URL that was used when subscribing to
409 from mygpo
.db
.couchdb
.podcast_state
import get_subscribed_podcast_states_by_device
410 states
= get_subscribed_podcast_states_by_device(self
)
411 return podcasts_for_states(states
)
415 return hash(frozenset([self
.id, self
.uid
, self
.name
, self
.type, self
.deleted
]))
418 def __eq__(self
, other
):
419 return self
.id == other
.id
423 return '<{cls} {id}>'.format(cls
=self
.__class
__.__name
__, id=self
.id)
429 def __unicode__(self
):
434 TOKEN_NAMES
= ('subscriptions_token', 'favorite_feeds_token',
435 'publisher_update_token', 'userpage_token')
438 class TokenException(Exception):
442 class User(BaseUser
, SyncedDevicesMixin
, SettingsMixin
):
443 oldid
= IntegerProperty()
444 devices
= SchemaListProperty(Device
)
445 published_objects
= StringListProperty()
446 deleted
= BooleanProperty(default
=False)
447 suggestions_up_to_date
= BooleanProperty(default
=False)
448 twitter
= StringProperty()
449 about
= StringProperty()
450 google_email
= StringProperty()
452 # token for accessing subscriptions of this use
453 subscriptions_token
= StringProperty(default
=None)
455 # token for accessing the favorite-episodes feed of this user
456 favorite_feeds_token
= StringProperty(default
=None)
458 # token for automatically updating feeds published by this user
459 publisher_update_token
= StringProperty(default
=None)
461 # token for accessing the userpage of this user
462 userpage_token
= StringProperty(default
=None)
468 def create_new_token(self
, token_name
, length
=32):
469 """ creates a new random token """
471 if token_name
not in TOKEN_NAMES
:
472 raise TokenException('Invalid token name %s' % token_name
)
474 token
= "".join(random
.sample(string
.letters
+string
.digits
, length
))
475 setattr(self
, token_name
, token
)
479 @repeat_on_conflict(['self'])
480 def get_token(self
, token_name
):
481 """ returns a token, and generate those that are still missing """
485 if token_name
not in TOKEN_NAMES
:
486 raise TokenException('Invalid token name %s' % token_name
)
488 for tn
in TOKEN_NAMES
:
489 if getattr(self
, tn
) is None:
490 self
.create_new_token(tn
)
496 return getattr(self
, token_name
)
501 def active_devices(self
):
502 not_deleted
= lambda d
: not d
.deleted
503 return filter(not_deleted
, self
.devices
)
507 def inactive_devices(self
):
508 deleted
= lambda d
: d
.deleted
509 return filter(deleted
, self
.devices
)
512 def get_devices_by_id(self
):
513 return dict( (device
.id, device
) for device
in self
.devices
)
516 def get_device(self
, id):
518 if not hasattr(self
, '__device_by_id'):
519 self
.__devices
_by
_id
= dict( (d
.id, d
) for d
in self
.devices
)
521 return self
.__devices
_by
_id
.get(id, None)
524 def get_device_by_uid(self
, uid
, only_active
=True):
526 if not hasattr(self
, '__devices_by_uio'):
527 self
.__devices
_by
_uid
= dict( (d
.uid
, d
) for d
in self
.devices
)
530 device
= self
.__devices
_by
_uid
[uid
]
532 if only_active
and device
.deleted
:
533 raise DeviceDeletedException(
534 'Device with UID %s is deleted' % uid
)
538 except KeyError as e
:
539 raise DeviceDoesNotExist('There is no device with UID %s' % uid
)
542 def set_device(self
, device
):
544 if not RE_DEVICE_UID
.match(device
.uid
):
545 raise DeviceUIDException(u
"'{uid} is not a valid device ID".format(
548 devices
= list(self
.devices
)
549 ids
= [x
.id for x
in devices
]
550 if not device
.id in ids
:
551 devices
.append(device
)
552 self
.devices
= devices
555 index
= ids
.index(device
.id)
557 devices
.insert(index
, device
)
558 self
.devices
= devices
561 def remove_device(self
, device
):
562 devices
= list(self
.devices
)
563 ids
= [x
.id for x
in devices
]
564 if not device
.id in ids
:
567 index
= ids
.index(device
.id)
569 self
.devices
= devices
571 if self
.is_synced(device
):
572 self
.unsync_device(device
)
575 def get_subscriptions_by_device(self
, public
=None):
576 from mygpo
.db
.couchdb
.podcast_state
import subscriptions_by_user
577 get_dev
= itemgetter(2)
578 groups
= collections
.defaultdict(list)
579 subscriptions
= subscriptions_by_user(self
, public
=public
)
580 subscriptions
= sorted(subscriptions
, key
=get_dev
)
582 for public
, podcast_id
, device_id
in subscriptions
:
583 groups
[device_id
].append(podcast_id
)
587 def get_subscribed_podcast_ids(self
, public
=None):
588 from mygpo
.db
.couchdb
.podcast_state
import get_subscribed_podcast_states_by_user
589 states
= get_subscribed_podcast_states_by_user(self
, public
)
590 return [state
.podcast
for state
in states
]
594 def get_subscribed_podcasts(self
, public
=None):
595 """ Returns all subscribed podcasts for the user
597 The attribute "url" contains the URL that was used when subscribing to
600 from mygpo
.db
.couchdb
.podcast_state
import get_subscribed_podcast_states_by_user
601 states
= get_subscribed_podcast_states_by_user(self
, public
)
602 podcast_ids
= [state
.podcast
for state
in states
]
603 podcasts
= podcasts_to_dict(podcast_ids
)
606 podcast
= proxy_object(podcasts
[state
.podcast
], url
=state
.ref_url
)
607 podcasts
[state
.podcast
] = podcast
609 return podcasts
.values()
613 def get_subscription_history(self
, device_id
=None, reverse
=False, public
=None):
614 """ Returns chronologically ordered subscription history entries
616 Setting device_id restricts the actions to a certain device
619 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_user
, \
620 podcast_states_for_device
622 def action_iter(state
):
623 for action
in sorted(state
.actions
, reverse
=reverse
):
624 if device_id
is not None and device_id
!= action
.device
:
627 if public
is not None and state
.is_public() != public
:
630 entry
= HistoryEntry()
631 entry
.timestamp
= action
.timestamp
632 entry
.action
= action
.action
633 entry
.podcast_id
= state
.podcast
634 entry
.device_id
= action
.device
637 if device_id
is None:
638 podcast_states
= podcast_states_for_user(self
)
640 podcast_states
= podcast_states_for_device(device_id
)
642 # create an action_iter for each PodcastUserState
643 subscription_action_lists
= [action_iter(x
) for x
in podcast_states
]
645 action_cmp_key
= lambda x
: x
.timestamp
647 # Linearize their subscription-actions
648 return linearize(action_cmp_key
, subscription_action_lists
, reverse
)
651 def get_global_subscription_history(self
, public
=None):
652 """ Actions that added/removed podcasts from the subscription list
654 Returns an iterator of all subscription actions that either
655 * added subscribed a podcast that hasn't been subscribed directly
656 before the action (but could have been subscribed) earlier
657 * removed a subscription of the podcast is not longer subscribed
661 subscriptions
= collections
.defaultdict(int)
663 for entry
in self
.get_subscription_history(public
=public
):
664 if entry
.action
== 'subscribe':
665 subscriptions
[entry
.podcast_id
] += 1
667 # a new subscription has been added
668 if subscriptions
[entry
.podcast_id
] == 1:
671 elif entry
.action
== 'unsubscribe':
672 subscriptions
[entry
.podcast_id
] -= 1
674 # the last subscription has been removed
675 if subscriptions
[entry
.podcast_id
] == 0:
680 def get_newest_episodes(self
, max_date
, max_per_podcast
=5):
681 """ Returns the newest episodes of all subscribed podcasts
683 Only max_per_podcast episodes per podcast are loaded. Episodes with
684 release dates above max_date are discarded.
686 This method returns a generator that produces the newest episodes.
688 The number of required DB queries is equal to the number of (distinct)
689 podcasts of all consumed episodes (max: number of subscribed podcasts),
690 plus a constant number of initial queries (when the first episode is
693 cmp_key
= lambda episode
: episode
.released
or datetime(2000, 01, 01)
695 podcasts
= list(self
.get_subscribed_podcasts())
696 podcasts
= filter(lambda p
: p
.latest_episode_timestamp
, podcasts
)
697 podcasts
= sorted(podcasts
, key
=lambda p
: p
.latest_episode_timestamp
,
700 podcast_dict
= dict((p
.get_id(), p
) for p
in podcasts
)
702 # contains the un-yielded episodes, newest first
705 for podcast
in podcasts
:
709 for episode
in episodes
:
710 # determine for which episodes there won't be a new episodes
711 # that is newer; those can be yielded
712 if episode
.released
> podcast
.latest_episode_timestamp
:
713 p
= podcast_dict
.get(episode
.podcast
, None)
714 yield proxy_object(episode
, podcast
=p
)
715 yielded_episodes
+= 1
719 # remove the episodes that have been yielded before
720 episodes
= episodes
[yielded_episodes
:]
722 # fetch and merge episodes for the next podcast
723 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
724 new_episodes
= episodes_for_podcast(podcast
, since
=1,
725 until
=max_date
, descending
=True, limit
=max_per_podcast
)
726 episodes
= sorted(episodes
+new_episodes
, key
=cmp_key
, reverse
=True)
729 # yield the remaining episodes
730 for episode
in episodes
:
731 podcast
= podcast_dict
.get(episode
.podcast
, None)
732 yield proxy_object(episode
, podcast
=podcast
)
735 def __eq__(self
, other
):
739 # ensure that other isn't AnonymousUser
740 return other
.is_authenticated() and self
._id
== other
._id
743 def __ne__(self
, other
):
744 return not(self
== other
)
748 return 'User %s' % self
._id
751 class History(object):
753 def __init__(self
, user
, device
):
758 def __getitem__(self
, key
):
760 if isinstance(key
, slice):
761 start
= key
.start
or 0
762 length
= key
.stop
- start
768 return device_history(self
.user
, self
.device
, start
, length
)
771 return user_history(self
.user
, start
, length
)
775 class HistoryEntry(object):
776 """ A class that can represent subscription and episode actions """
780 def from_action_dict(cls
, action
):
782 entry
= HistoryEntry()
784 if 'timestamp' in action
:
785 ts
= action
.pop('timestamp')
786 entry
.timestamp
= dateutil
.parser
.parse(ts
)
788 for key
, value
in action
.items():
789 setattr(entry
, key
, value
)
796 return getattr(self
, 'position', None)
800 def fetch_data(cls
, user
, entries
,
801 podcasts
=None, episodes
=None):
802 """ Efficiently loads additional data for a number of entries """
806 podcast_ids
= [getattr(x
, 'podcast_id', None) for x
in entries
]
807 podcast_ids
= filter(None, podcast_ids
)
808 podcasts
= podcasts_to_dict(podcast_ids
)
811 from mygpo
.db
.couchdb
.episode
import episodes_to_dict
813 episode_ids
= [getattr(x
, 'episode_id', None) for x
in entries
]
814 episode_ids
= filter(None, episode_ids
)
815 episodes
= episodes_to_dict(episode_ids
)
818 # does not need pre-populated data because no db-access is required
819 device_ids
= [getattr(x
, 'device_id', None) for x
in entries
]
820 device_ids
= filter(None, device_ids
)
821 devices
= dict([ (id, user
.get_device(id)) for id in device_ids
])
824 for entry
in entries
:
825 podcast_id
= getattr(entry
, 'podcast_id', None)
826 entry
.podcast
= podcasts
.get(podcast_id
, None)
828 episode_id
= getattr(entry
, 'episode_id', None)
829 entry
.episode
= episodes
.get(episode_id
, None)
831 if hasattr(entry
, 'user'):
834 device
= devices
.get(getattr(entry
, 'device_id', None), None)
835 entry
.device
= device