fed743daafc4898d8ad6364cae34671a26d096d7
[mygpo.git] / mygpo / users / models.py
blobfed743daafc4898d8ad6364cae34671a26d096d7
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.settings import FAV_FLAG, PUBLIC_SUB_PODCAST, SettingsMixin
23 from mygpo.db.couchdb.podcast import podcasts_by_id, podcasts_to_dict
24 from mygpo.db.couchdb.user import user_history, device_history
28 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
30 # TODO: derive from ValidationException?
31 class InvalidEpisodeActionAttributes(ValueError):
32 """ raised when the attribues of an episode action fail validation """
35 class DeviceUIDException(Exception):
36 pass
39 class DeviceDoesNotExist(Exception):
40 pass
43 class DeviceDeletedException(DeviceDoesNotExist):
44 pass
47 class Suggestions(Document, RatingMixin):
48 user = StringProperty(required=True)
49 user_oldid = IntegerProperty()
50 podcasts = StringListProperty()
51 blacklist = StringListProperty()
54 def get_podcasts(self, count=None):
55 user = User.get(self.user)
56 subscriptions = user.get_subscribed_podcast_ids()
58 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
59 if count:
60 ids = ids[:count]
61 return filter(lambda x: x and x.title, podcasts_by_id(ids))
64 def __repr__(self):
65 if not self._id:
66 return super(Suggestions, self).__repr__()
67 else:
68 return '%d Suggestions for %s (%s)' % \
69 (len(self.podcasts), self.user, self._id)
72 class EpisodeAction(DocumentSchema):
73 """
74 One specific action to an episode. Must
75 always be part of a EpisodeUserState
76 """
78 action = StringProperty(required=True)
80 # walltime of the event (assigned by the uploading client, defaults to now)
81 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
83 # upload time of the event
84 upload_timestamp = IntegerProperty(required=True)
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):
94 return False
95 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
96 'total')
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
108 return entry
112 def validate_time_values(self):
113 """ Validates allowed combinations of time-values """
115 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
117 # Key found, but must not be supplied (no play action!)
118 if self.action != 'play':
119 for key in PLAY_ACTION_KEYS:
120 if getattr(self, key, None) is not None:
121 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key)
123 # Sanity check: If started or total are given, require playmark
124 if ((self.started is not None) or (self.total is not None)) and \
125 self.playmark is None:
126 raise InvalidEpisodeActionAttributes('started and total require position')
128 # Sanity check: total and playmark can only appear together
129 if ((self.total is not None) or (self.started is not None)) and \
130 ((self.total is None) or (self.started is None)):
131 raise InvalidEpisodeActionAttributes('total and started can only appear together')
134 def __repr__(self):
135 return '%s-Action on %s at %s (in %s)' % \
136 (self.action, self.device, self.timestamp, self._id)
139 def __hash__(self):
140 return hash(frozenset([self.action, self.timestamp, self.device,
141 self.started, self.playmark, self.total]))
144 class Chapter(Document):
145 """ A user-entered episode chapter """
147 device = StringProperty()
148 created = DateTimeProperty()
149 start = IntegerProperty(required=True)
150 end = IntegerProperty(required=True)
151 label = StringProperty()
152 advertisement = BooleanProperty()
155 def __repr__(self):
156 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
157 self.start, self.end)
160 class EpisodeUserState(Document, SettingsMixin):
162 Contains everything a user has done with an Episode
165 episode = StringProperty(required=True)
166 actions = SchemaListProperty(EpisodeAction)
167 user_oldid = IntegerProperty()
168 user = StringProperty(required=True)
169 ref_url = StringProperty(required=True)
170 podcast_ref_url = StringProperty(required=True)
171 merged_ids = StringListProperty()
172 chapters = SchemaListProperty(Chapter)
173 podcast = StringProperty(required=True)
177 def add_actions(self, actions):
178 map(EpisodeAction.validate_time_values, actions)
179 self.actions = list(self.actions) + actions
180 self.actions = list(set(self.actions))
181 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
184 def is_favorite(self):
185 return self.get_wksetting(FAV_FLAG)
188 def set_favorite(self, set_to=True):
189 self.settings[FAV_FLAG.name] = set_to
192 def update_chapters(self, add=[], rem=[]):
193 """ Updates the Chapter list
195 * add contains the chapters to be added
197 * rem contains tuples of (start, end) times. Chapters that match
198 both endpoints will be removed
201 @repeat_on_conflict(['state'])
202 def update(state):
203 for chapter in add:
204 self.chapters = self.chapters + [chapter]
206 for start, end in rem:
207 keep = lambda c: c.start != start or c.end != end
208 self.chapters = filter(keep, self.chapters)
210 self.save()
212 update(state=self)
215 def get_history_entries(self):
216 return imap(EpisodeAction.to_history_entry, self.actions)
219 def __repr__(self):
220 return 'Episode-State %s (in %s)' % \
221 (self.episode, self._id)
223 def __eq__(self, other):
224 if not isinstance(other, EpisodeUserState):
225 return False
227 return (self.episode == other.episode and
228 self.user == other.user)
232 class SubscriptionAction(Document):
233 action = StringProperty()
234 timestamp = DateTimeProperty(default=datetime.utcnow)
235 device = StringProperty()
238 __metaclass__ = DocumentABCMeta
241 def __cmp__(self, other):
242 return cmp(self.timestamp, other.timestamp)
244 def __eq__(self, other):
245 return self.action == other.action and \
246 self.timestamp == other.timestamp and \
247 self.device == other.device
249 def __hash__(self):
250 return hash(self.action) + hash(self.timestamp) + hash(self.device)
252 def __repr__(self):
253 return '<SubscriptionAction %s on %s at %s>' % (
254 self.action, self.device, self.timestamp)
257 class PodcastUserState(Document, SettingsMixin):
259 Contains everything that a user has done
260 with a specific podcast and all its episodes
263 podcast = StringProperty(required=True)
264 user_oldid = IntegerProperty()
265 user = StringProperty(required=True)
266 actions = SchemaListProperty(SubscriptionAction)
267 tags = StringListProperty()
268 ref_url = StringProperty(required=True)
269 disabled_devices = StringListProperty()
270 merged_ids = StringListProperty()
273 def remove_device(self, device):
275 Removes all actions from the podcast state that refer to the
276 given device
278 self.actions = filter(lambda a: a.device != device.id, self.actions)
281 def subscribe(self, device):
282 action = SubscriptionAction()
283 action.action = 'subscribe'
284 action.device = device.id
285 self.add_actions([action])
288 def unsubscribe(self, device):
289 action = SubscriptionAction()
290 action.action = 'unsubscribe'
291 action.device = device.id
292 self.add_actions([action])
295 def add_actions(self, actions):
296 self.actions = list(set(self.actions + actions))
297 self.actions = sorted(self.actions)
300 def add_tags(self, tags):
301 self.tags = list(set(self.tags + tags))
304 def set_device_state(self, devices):
305 disabled_devices = [device.id for device in devices if device.deleted]
306 self.disabled_devices = disabled_devices
309 def get_change_between(self, device_id, since, until):
311 Returns the change of the subscription status for the given device
312 between the two timestamps.
314 The change is given as either 'subscribe' (the podcast has been
315 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
316 None (no change)
319 device_actions = filter(lambda x: x.device == device_id, self.actions)
320 before = filter(lambda x: x.timestamp <= since, device_actions)
321 after = filter(lambda x: x.timestamp <= until, device_actions)
323 # nothing happened, so there can be no change
324 if not after:
325 return None
327 then = before[-1] if before else None
328 now = after[-1]
330 if then is None:
331 if now.action != 'unsubscribe':
332 return now.action
333 elif then.action != now.action:
334 return now.action
335 return None
338 def get_subscribed_device_ids(self):
339 """ device Ids on which the user subscribed to the podcast """
340 devices = set()
342 for action in self.actions:
343 if action.action == "subscribe":
344 if not action.device in self.disabled_devices:
345 devices.add(action.device)
346 else:
347 if action.device in devices:
348 devices.remove(action.device)
350 return devices
354 def is_public(self):
355 return self.get_wksetting(PUBLIC_SUB_PODCAST)
358 def __eq__(self, other):
359 if other is None:
360 return False
362 return self.podcast == other.podcast and \
363 self.user == other.user
365 def __repr__(self):
366 return 'Podcast %s for User %s (%s)' % \
367 (self.podcast, self.user, self._id)
370 class Device(Document, SettingsMixin):
371 id = StringProperty(default=lambda: uuid.uuid4().hex)
372 oldid = IntegerProperty(required=False)
373 uid = StringProperty(required=True)
374 name = StringProperty(required=True, default='New Device')
375 type = StringProperty(required=True, default='other')
376 deleted = BooleanProperty(default=False)
377 user_agent = StringProperty()
380 def get_subscription_changes(self, since, until):
382 Returns the subscription changes for the device as two lists.
383 The first lists contains the Ids of the podcasts that have been
384 subscribed to, the second list of those that have been unsubscribed
385 from.
388 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
390 add, rem = [], []
391 podcast_states = podcast_states_for_device(self.id)
392 for p_state in podcast_states:
393 change = p_state.get_change_between(self.id, since, until)
394 if change == 'subscribe':
395 add.append( p_state.ref_url )
396 elif change == 'unsubscribe':
397 rem.append( p_state.ref_url )
399 return add, rem
402 def get_latest_changes(self):
404 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
406 podcast_states = podcast_states_for_device(self.id)
407 for p_state in podcast_states:
408 actions = filter(lambda x: x.device == self.id, reversed(p_state.actions))
409 if actions:
410 yield (p_state.podcast, actions[0])
413 def get_subscribed_podcast_states(self):
414 r = PodcastUserState.view('subscriptions/by_device',
415 startkey = [self.id, None],
416 endkey = [self.id, {}],
417 include_docs = True
419 return list(r)
422 def get_subscribed_podcast_ids(self):
423 states = self.get_subscribed_podcast_states()
424 return [state.podcast for state in states]
427 def get_subscribed_podcasts(self):
428 """ Returns all subscribed podcasts for the device
430 The attribute "url" contains the URL that was used when subscribing to
431 the podcast """
433 states = self.get_subscribed_podcast_states()
434 podcast_ids = [state.podcast for state in states]
435 podcasts = podcasts_to_dict(podcast_ids)
437 for state in states:
438 podcast = proxy_object(podcasts[state.podcast], url=state.ref_url)
439 podcasts[state.podcast] = podcast
441 return podcasts.values()
444 def __hash__(self):
445 return hash(frozenset([self.id, self.uid, self.name, self.type, self.deleted]))
448 def __eq__(self, other):
449 return self.id == other.id
452 def __repr__(self):
453 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
456 def __str__(self):
457 return self.name
459 def __unicode__(self):
460 return self.name
464 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
465 'publisher_update_token', 'userpage_token')
468 class TokenException(Exception):
469 pass
472 class User(BaseUser, SyncedDevicesMixin, SettingsMixin):
473 oldid = IntegerProperty()
474 devices = SchemaListProperty(Device)
475 published_objects = StringListProperty()
476 deleted = BooleanProperty(default=False)
477 suggestions_up_to_date = BooleanProperty(default=False)
478 twitter = StringProperty()
479 about = StringProperty()
480 google_email = StringProperty()
482 # token for accessing subscriptions of this use
483 subscriptions_token = StringProperty(default=None)
485 # token for accessing the favorite-episodes feed of this user
486 favorite_feeds_token = StringProperty(default=None)
488 # token for automatically updating feeds published by this user
489 publisher_update_token = StringProperty(default=None)
491 # token for accessing the userpage of this user
492 userpage_token = StringProperty(default=None)
494 class Meta:
495 app_label = 'users'
498 def create_new_token(self, token_name, length=32):
499 """ creates a new random token """
501 if token_name not in TOKEN_NAMES:
502 raise TokenException('Invalid token name %s' % token_name)
504 token = "".join(random.sample(string.letters+string.digits, length))
505 setattr(self, token_name, token)
509 @repeat_on_conflict(['self'])
510 def get_token(self, token_name):
511 """ returns a token, and generate those that are still missing """
513 generated = False
515 if token_name not in TOKEN_NAMES:
516 raise TokenException('Invalid token name %s' % token_name)
518 for tn in TOKEN_NAMES:
519 if getattr(self, tn) is None:
520 self.create_new_token(tn)
521 generated = True
523 if generated:
524 self.save()
526 return getattr(self, token_name)
530 @property
531 def active_devices(self):
532 not_deleted = lambda d: not d.deleted
533 return filter(not_deleted, self.devices)
536 @property
537 def inactive_devices(self):
538 deleted = lambda d: d.deleted
539 return filter(deleted, self.devices)
542 def get_devices_by_id(self):
543 return dict( (device.id, device) for device in self.devices)
546 def get_device(self, id):
548 if not hasattr(self, '__device_by_id'):
549 self.__devices_by_id = dict( (d.id, d) for d in self.devices)
551 return self.__devices_by_id.get(id, None)
554 def get_device_by_uid(self, uid, only_active=True):
556 if not hasattr(self, '__devices_by_uio'):
557 self.__devices_by_uid = dict( (d.uid, d) for d in self.devices)
559 try:
560 device = self.__devices_by_uid[uid]
562 if only_active and device.deleted:
563 raise DeviceDeletedException(
564 'Device with UID %s is deleted' % uid)
566 return device
568 except KeyError as e:
569 raise DeviceDoesNotExist('There is no device with UID %s' % uid)
572 @repeat_on_conflict(['self'])
573 def update_device(self, device):
574 """ Sets the device and saves the user """
575 self.set_device(device)
576 self.save()
579 def set_device(self, device):
581 if not RE_DEVICE_UID.match(device.uid):
582 raise DeviceUIDException(u"'{uid} is not a valid device ID".format(
583 uid=device.uid))
585 devices = list(self.devices)
586 ids = [x.id for x in devices]
587 if not device.id in ids:
588 devices.append(device)
589 self.devices = devices
590 return
592 index = ids.index(device.id)
593 devices.pop(index)
594 devices.insert(index, device)
595 self.devices = devices
598 def remove_device(self, device):
599 devices = list(self.devices)
600 ids = [x.id for x in devices]
601 if not device.id in ids:
602 return
604 index = ids.index(device.id)
605 devices.pop(index)
606 self.devices = devices
608 if self.is_synced(device):
609 self.unsync_device(device)
612 def get_subscriptions_by_device(self, public=None):
613 from mygpo.db.couchdb.podcast_state import subscriptions_by_user
614 get_dev = itemgetter(2)
615 groups = collections.defaultdict(list)
616 subscriptions = subscriptions_by_user(self, public=public)
617 subscriptions = sorted(subscriptions, key=get_dev)
619 for public, podcast_id, device_id in subscriptions:
620 groups[device_id].append(podcast_id)
622 return groups
625 def get_subscribed_podcast_states(self, public=None):
627 Returns the Ids of all subscribed podcasts
630 r = PodcastUserState.view('subscriptions/by_user',
631 startkey = [self._id, public, None, None],
632 endkey = [self._id+'ZZZ', None, None, None],
633 reduce = False,
634 include_docs = True
637 return set(r)
640 def get_subscribed_podcast_ids(self, public=None):
641 states = self.get_subscribed_podcast_states(public=public)
642 return [state.podcast for state in states]
646 def get_subscribed_podcasts(self, public=None):
647 """ Returns all subscribed podcasts for the user
649 The attribute "url" contains the URL that was used when subscribing to
650 the podcast """
652 states = self.get_subscribed_podcast_states(public=public)
653 podcast_ids = [state.podcast for state in states]
654 podcasts = podcasts_to_dict(podcast_ids)
656 for state in states:
657 podcast = proxy_object(podcasts[state.podcast], url=state.ref_url)
658 podcasts[state.podcast] = podcast
660 return podcasts.values()
664 def get_subscription_history(self, device_id=None, reverse=False, public=None):
665 """ Returns chronologically ordered subscription history entries
667 Setting device_id restricts the actions to a certain device
670 from mygpo.db.couchdb.podcast_state import podcast_states_for_user, \
671 podcast_states_for_device
673 def action_iter(state):
674 for action in sorted(state.actions, reverse=reverse):
675 if device_id is not None and device_id != action.device:
676 continue
678 if public is not None and state.is_public() != public:
679 continue
681 entry = HistoryEntry()
682 entry.timestamp = action.timestamp
683 entry.action = action.action
684 entry.podcast_id = state.podcast
685 entry.device_id = action.device
686 yield entry
688 if device_id is None:
689 podcast_states = podcast_states_for_user(self)
690 else:
691 podcast_states = podcast_states_for_device(device_id)
693 # create an action_iter for each PodcastUserState
694 subscription_action_lists = [action_iter(x) for x in podcast_states]
696 action_cmp_key = lambda x: x.timestamp
698 # Linearize their subscription-actions
699 return linearize(action_cmp_key, subscription_action_lists, reverse)
702 def get_global_subscription_history(self, public=None):
703 """ Actions that added/removed podcasts from the subscription list
705 Returns an iterator of all subscription actions that either
706 * added subscribed a podcast that hasn't been subscribed directly
707 before the action (but could have been subscribed) earlier
708 * removed a subscription of the podcast is not longer subscribed
709 after the action
712 subscriptions = collections.defaultdict(int)
714 for entry in self.get_subscription_history(public=public):
715 if entry.action == 'subscribe':
716 subscriptions[entry.podcast_id] += 1
718 # a new subscription has been added
719 if subscriptions[entry.podcast_id] == 1:
720 yield entry
722 elif entry.action == 'unsubscribe':
723 subscriptions[entry.podcast_id] -= 1
725 # the last subscription has been removed
726 if subscriptions[entry.podcast_id] == 0:
727 yield entry
731 def get_newest_episodes(self, max_date, max_per_podcast=5):
732 """ Returns the newest episodes of all subscribed podcasts
734 Only max_per_podcast episodes per podcast are loaded. Episodes with
735 release dates above max_date are discarded.
737 This method returns a generator that produces the newest episodes.
739 The number of required DB queries is equal to the number of (distinct)
740 podcasts of all consumed episodes (max: number of subscribed podcasts),
741 plus a constant number of initial queries (when the first episode is
742 consumed). """
744 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
746 podcasts = list(self.get_subscribed_podcasts())
747 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
748 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
749 reverse=True)
751 podcast_dict = dict((p.get_id(), p) for p in podcasts)
753 # contains the un-yielded episodes, newest first
754 episodes = []
756 for podcast in podcasts:
758 yielded_episodes = 0
760 for episode in episodes:
761 # determine for which episodes there won't be a new episodes
762 # that is newer; those can be yielded
763 if episode.released > podcast.latest_episode_timestamp:
764 p = podcast_dict.get(episode.podcast, None)
765 yield proxy_object(episode, podcast=p)
766 yielded_episodes += 1
767 else:
768 break
770 # remove the episodes that have been yielded before
771 episodes = episodes[yielded_episodes:]
773 # fetch and merge episodes for the next podcast
774 from mygpo.db.couchdb.episode import episodes_for_podcast
775 new_episodes = episodes_for_podcast(podcast, since=1,
776 until=max_date, descending=True, limit=max_per_podcast)
777 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
780 # yield the remaining episodes
781 for episode in episodes:
782 podcast = podcast_dict.get(episode.podcast, None)
783 yield proxy_object(episode, podcast=podcast)
788 def save(self, *args, **kwargs):
790 from mygpo.db.couchdb.podcast_state import podcast_states_for_user
792 super(User, self).save(*args, **kwargs)
794 podcast_states = podcast_states_for_user(self)
795 for state in podcast_states:
796 @repeat_on_conflict(['state'])
797 def _update_state(state):
798 old_devs = set(state.disabled_devices)
799 state.set_device_state(self.devices)
801 if old_devs != set(state.disabled_devices):
802 state.save()
804 _update_state(state)
809 def __eq__(self, other):
810 if not other:
811 return False
813 # ensure that other isn't AnonymousUser
814 return other.is_authenticated() and self._id == other._id
817 def __ne__(self, other):
818 return not(self == other)
821 def __repr__(self):
822 return 'User %s' % self._id
825 class History(object):
827 def __init__(self, user, device):
828 self.user = user
829 self.device = device
832 def __getitem__(self, key):
834 if isinstance(key, slice):
835 start = key.start or 0
836 length = key.stop - start
837 else:
838 start = key
839 length = 1
841 if self.device:
842 return device_history(self.user, self.device, start, length)
844 else:
845 return user_history(self.user, start, length)
849 class HistoryEntry(object):
850 """ A class that can represent subscription and episode actions """
853 @classmethod
854 def from_action_dict(cls, action):
856 entry = HistoryEntry()
858 if 'timestamp' in action:
859 ts = action.pop('timestamp')
860 entry.timestamp = dateutil.parser.parse(ts)
862 for key, value in action.items():
863 setattr(entry, key, value)
865 return entry
868 @property
869 def playmark(self):
870 return getattr(self, 'position', None)
873 @classmethod
874 def fetch_data(cls, user, entries,
875 podcasts=None, episodes=None):
876 """ Efficiently loads additional data for a number of entries """
878 if podcasts is None:
879 # load podcast data
880 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
881 podcast_ids = filter(None, podcast_ids)
882 podcasts = podcasts_to_dict(podcast_ids)
884 if episodes is None:
885 from mygpo.db.couchdb.episode import episodes_to_dict
886 # load episode data
887 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
888 episode_ids = filter(None, episode_ids)
889 episodes = episodes_to_dict(episode_ids)
891 # load device data
892 # does not need pre-populated data because no db-access is required
893 device_ids = [getattr(x, 'device_id', None) for x in entries]
894 device_ids = filter(None, device_ids)
895 devices = dict([ (id, user.get_device(id)) for id in device_ids])
898 for entry in entries:
899 podcast_id = getattr(entry, 'podcast_id', None)
900 entry.podcast = podcasts.get(podcast_id, None)
902 episode_id = getattr(entry, 'episode_id', None)
903 entry.episode = episodes.get(episode_id, None)
905 if hasattr(entry, 'user'):
906 entry.user = user
908 device = devices.get(getattr(entry, 'device_id', None), None)
909 entry.device = device
912 return entries