replace EpisodeUserState.update_chapters
[mygpo.git] / mygpo / users / models.py
blob9597f879d75b75227138d3eb683a5f4ef0633b66
1 import re
2 import uuid
3 import collections
4 from datetime import datetime
5 import dateutil.parser
6 from itertools import imap
7 from operator import itemgetter
8 import random
9 import string
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):
39 pass
42 class DeviceDoesNotExist(Exception):
43 pass
46 class DeviceDeletedException(DeviceDoesNotExist):
47 pass
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)
62 if count:
63 ids = ids[:count]
64 return filter(lambda x: x and x.title, podcasts_by_id(ids))
67 def __repr__(self):
68 if not self._id:
69 return super(Suggestions, self).__repr__()
70 else:
71 return '%d Suggestions for %s (%s)' % \
72 (len(self.podcasts), self.user, self._id)
75 class EpisodeAction(DocumentSchema):
76 """
77 One specific action to an episode. Must
78 always be part of a EpisodeUserState
79 """
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):
97 return False
98 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
99 'total')
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
111 return entry
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')
137 def __repr__(self):
138 return '%s-Action on %s at %s (in %s)' % \
139 (self.action, self.device, self.timestamp, self._id)
142 def __hash__(self):
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()
158 def __repr__(self):
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)
199 def __repr__(self):
200 return 'Episode-State %s (in %s)' % \
201 (self.episode, self._id)
203 def __eq__(self, other):
204 if not isinstance(other, EpisodeUserState):
205 return False
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
229 def __hash__(self):
230 return hash(self.action) + hash(self.timestamp) + hash(self.device)
232 def __repr__(self):
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
256 given device
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
296 None (no change)
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
304 if not after:
305 return None
307 then = before[-1] if before else None
308 now = after[-1]
310 if then is None:
311 if now.action != 'unsubscribe':
312 return now.action
313 elif then.action != now.action:
314 return now.action
315 return None
318 def get_subscribed_device_ids(self):
319 """ device Ids on which the user subscribed to the podcast """
320 devices = set()
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)
326 else:
327 if action.device in devices:
328 devices.remove(action.device)
330 return devices
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:
338 continue
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
344 return False
347 def is_public(self):
348 return self.get_wksetting(PUBLIC_SUB_PODCAST)
351 def __eq__(self, other):
352 if other is None:
353 return False
355 return self.podcast == other.podcast and \
356 self.user == other.user
358 def __repr__(self):
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
378 from.
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))
393 if 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
407 the podcast """
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)
414 def __hash__(self):
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
422 def __repr__(self):
423 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
426 def __str__(self):
427 return self.name
429 def __unicode__(self):
430 return self.name
434 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
435 'publisher_update_token', 'userpage_token')
438 class TokenException(Exception):
439 pass
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)
464 class Meta:
465 app_label = 'users'
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 """
483 generated = False
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)
491 generated = True
493 if generated:
494 self.save()
496 return getattr(self, token_name)
500 @property
501 def active_devices(self):
502 not_deleted = lambda d: not d.deleted
503 return filter(not_deleted, self.devices)
506 @property
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)
529 try:
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)
536 return device
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(
546 uid=device.uid))
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
553 return
555 index = ids.index(device.id)
556 devices.pop(index)
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:
565 return
567 index = ids.index(device.id)
568 devices.pop(index)
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)
585 return groups
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
598 the podcast """
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)
605 for state in states:
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:
625 continue
627 if public is not None and state.is_public() != public:
628 continue
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
635 yield entry
637 if device_id is None:
638 podcast_states = podcast_states_for_user(self)
639 else:
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
658 after the action
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:
669 yield entry
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:
676 yield entry
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
691 consumed). """
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,
698 reverse=True)
700 podcast_dict = dict((p.get_id(), p) for p in podcasts)
702 # contains the un-yielded episodes, newest first
703 episodes = []
705 for podcast in podcasts:
707 yielded_episodes = 0
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
716 else:
717 break
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):
736 if not other:
737 return False
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)
747 def __repr__(self):
748 return 'User %s' % self._id
751 class History(object):
753 def __init__(self, user, device):
754 self.user = user
755 self.device = device
758 def __getitem__(self, key):
760 if isinstance(key, slice):
761 start = key.start or 0
762 length = key.stop - start
763 else:
764 start = key
765 length = 1
767 if self.device:
768 return device_history(self.user, self.device, start, length)
770 else:
771 return user_history(self.user, start, length)
775 class HistoryEntry(object):
776 """ A class that can represent subscription and episode actions """
779 @classmethod
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)
791 return entry
794 @property
795 def playmark(self):
796 return getattr(self, 'position', None)
799 @classmethod
800 def fetch_data(cls, user, entries,
801 podcasts=None, episodes=None):
802 """ Efficiently loads additional data for a number of entries """
804 if podcasts is None:
805 # load podcast data
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)
810 if episodes is None:
811 from mygpo.db.couchdb.episode import episodes_to_dict
812 # load episode data
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)
817 # load device data
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'):
832 entry.user = user
834 device = devices.get(getattr(entry, 'device_id', None), None)
835 entry.device = device
838 return entries