fix tests for various utils methods
[mygpo.git] / mygpo / users / models.py
blob3138fd64a8e75fb6088bf1599c960e6f0ddd8836
1 import re
2 import uuid, collections
3 from datetime import datetime
4 import dateutil.parser
5 from itertools import imap
6 from operator import itemgetter
7 import random
8 import string
10 from couchdbkit.ext.django.schema import *
12 from django.core.cache import cache
14 from django_couchdb_utils.registration.models import User as BaseUser
16 from mygpo.utils import linearize
17 from mygpo.core.proxy import DocumentABCMeta, proxy_object
18 from mygpo.decorators import repeat_on_conflict
19 from mygpo.users.ratings import RatingMixin
20 from mygpo.users.sync import SyncedDevicesMixin
21 from mygpo.users.settings import FAV_FLAG, PUBLIC_SUB_PODCAST, SettingsMixin
22 from mygpo.db.couchdb.podcast import podcasts_by_id, podcasts_to_dict
23 from mygpo.db.couchdb.user import user_history, device_history
27 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
29 # TODO: derive from ValidationException?
30 class InvalidEpisodeActionAttributes(ValueError):
31 """ raised when the attribues of an episode action fail validation """
34 class DeviceUIDException(Exception):
35 pass
38 class DeviceDoesNotExist(Exception):
39 pass
42 class DeviceDeletedException(DeviceDoesNotExist):
43 pass
46 class Suggestions(Document, RatingMixin):
47 user = StringProperty(required=True)
48 user_oldid = IntegerProperty()
49 podcasts = StringListProperty()
50 blacklist = StringListProperty()
53 def get_podcasts(self, count=None):
54 user = User.get(self.user)
55 subscriptions = user.get_subscribed_podcast_ids()
57 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
58 if count:
59 ids = ids[:count]
60 return filter(lambda x: x and x.title, podcasts_by_id(ids))
63 def __repr__(self):
64 if not self._id:
65 return super(Suggestions, self).__repr__()
66 else:
67 return '%d Suggestions for %s (%s)' % \
68 (len(self.podcasts), self.user, self._id)
71 class EpisodeAction(DocumentSchema):
72 """
73 One specific action to an episode. Must
74 always be part of a EpisodeUserState
75 """
77 action = StringProperty(required=True)
79 # walltime of the event (assigned by the uploading client, defaults to now)
80 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
82 # upload time of the event
83 upload_timestamp = IntegerProperty(required=True)
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):
93 return False
94 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
95 'total')
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
107 return entry
111 def validate_time_values(self):
112 """ Validates allowed combinations of time-values """
114 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
116 # Key found, but must not be supplied (no play action!)
117 if self.action != 'play':
118 for key in PLAY_ACTION_KEYS:
119 if getattr(self, key, None) is not None:
120 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key)
122 # Sanity check: If started or total are given, require playmark
123 if ((self.started is not None) or (self.total is not None)) and \
124 self.playmark is None:
125 raise InvalidEpisodeActionAttributes('started and total require position')
127 # Sanity check: total and playmark can only appear together
128 if ((self.total is not None) or (self.started is not None)) and \
129 ((self.total is None) or (self.started is None)):
130 raise InvalidEpisodeActionAttributes('total and started can only appear together')
133 def __repr__(self):
134 return '%s-Action on %s at %s (in %s)' % \
135 (self.action, self.device, self.timestamp, self._id)
138 def __hash__(self):
139 return hash(frozenset([self.action, self.timestamp, self.device,
140 self.started, self.playmark, self.total]))
143 class Chapter(Document):
144 """ A user-entered episode chapter """
146 device = StringProperty()
147 created = DateTimeProperty()
148 start = IntegerProperty(required=True)
149 end = IntegerProperty(required=True)
150 label = StringProperty()
151 advertisement = BooleanProperty()
154 def __repr__(self):
155 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
156 self.start, self.end)
159 class EpisodeUserState(Document, SettingsMixin):
161 Contains everything a user has done with an Episode
164 episode = StringProperty(required=True)
165 actions = SchemaListProperty(EpisodeAction)
166 user_oldid = IntegerProperty()
167 user = StringProperty(required=True)
168 ref_url = StringProperty(required=True)
169 podcast_ref_url = StringProperty(required=True)
170 merged_ids = StringListProperty()
171 chapters = SchemaListProperty(Chapter)
172 podcast = StringProperty(required=True)
176 def add_actions(self, actions):
177 map(EpisodeAction.validate_time_values, actions)
178 self.actions = list(self.actions) + actions
179 self.actions = list(set(self.actions))
180 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
183 def is_favorite(self):
184 return self.get_wksetting(FAV_FLAG)
187 def set_favorite(self, set_to=True):
188 self.settings[FAV_FLAG.name] = set_to
191 def update_chapters(self, add=[], rem=[]):
192 """ Updates the Chapter list
194 * add contains the chapters to be added
196 * rem contains tuples of (start, end) times. Chapters that match
197 both endpoints will be removed
200 @repeat_on_conflict(['state'])
201 def update(state):
202 for chapter in add:
203 self.chapters = self.chapters + [chapter]
205 for start, end in rem:
206 keep = lambda c: c.start != start or c.end != end
207 self.chapters = filter(keep, self.chapters)
209 self.save()
211 update(state=self)
214 def get_history_entries(self):
215 return imap(EpisodeAction.to_history_entry, self.actions)
218 def __repr__(self):
219 return 'Episode-State %s (in %s)' % \
220 (self.episode, self._id)
222 def __eq__(self, other):
223 if not isinstance(other, EpisodeUserState):
224 return False
226 return (self.episode == other.episode and
227 self.user == other.user)
231 class SubscriptionAction(Document):
232 action = StringProperty()
233 timestamp = DateTimeProperty(default=datetime.utcnow)
234 device = StringProperty()
237 __metaclass__ = DocumentABCMeta
240 def __cmp__(self, other):
241 return cmp(self.timestamp, other.timestamp)
243 def __eq__(self, other):
244 return self.action == other.action and \
245 self.timestamp == other.timestamp and \
246 self.device == other.device
248 def __hash__(self):
249 return hash(self.action) + hash(self.timestamp) + hash(self.device)
251 def __repr__(self):
252 return '<SubscriptionAction %s on %s at %s>' % (
253 self.action, self.device, self.timestamp)
256 class PodcastUserState(Document, SettingsMixin):
258 Contains everything that a user has done
259 with a specific podcast and all its episodes
262 podcast = StringProperty(required=True)
263 user_oldid = IntegerProperty()
264 user = StringProperty(required=True)
265 actions = SchemaListProperty(SubscriptionAction)
266 tags = StringListProperty()
267 ref_url = StringProperty(required=True)
268 disabled_devices = StringListProperty()
269 merged_ids = StringListProperty()
272 def remove_device(self, device):
274 Removes all actions from the podcast state that refer to the
275 given device
277 self.actions = filter(lambda a: a.device != device.id, self.actions)
280 def subscribe(self, device):
281 action = SubscriptionAction()
282 action.action = 'subscribe'
283 action.device = device.id
284 self.add_actions([action])
287 def unsubscribe(self, device):
288 action = SubscriptionAction()
289 action.action = 'unsubscribe'
290 action.device = device.id
291 self.add_actions([action])
294 def add_actions(self, actions):
295 self.actions = list(set(self.actions + actions))
296 self.actions = sorted(self.actions)
299 def add_tags(self, tags):
300 self.tags = list(set(self.tags + tags))
303 def set_device_state(self, devices):
304 disabled_devices = [device.id for device in devices if device.deleted]
305 self.disabled_devices = disabled_devices
308 def get_change_between(self, device_id, since, until):
310 Returns the change of the subscription status for the given device
311 between the two timestamps.
313 The change is given as either 'subscribe' (the podcast has been
314 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
315 None (no change)
318 device_actions = filter(lambda x: x.device == device_id, self.actions)
319 before = filter(lambda x: x.timestamp <= since, device_actions)
320 after = filter(lambda x: x.timestamp <= until, device_actions)
322 # nothing happened, so there can be no change
323 if not after:
324 return None
326 then = before[-1] if before else None
327 now = after[-1]
329 if then is None:
330 if now.action != 'unsubscribe':
331 return now.action
332 elif then.action != now.action:
333 return now.action
334 return None
337 def get_subscribed_device_ids(self):
338 """ device Ids on which the user subscribed to the podcast """
339 devices = set()
341 for action in self.actions:
342 if action.action == "subscribe":
343 if not action.device in self.disabled_devices:
344 devices.add(action.device)
345 else:
346 if action.device in devices:
347 devices.remove(action.device)
349 return devices
353 def is_public(self):
354 return self.get_wksetting(PUBLIC_SUB_PODCAST)
357 def __eq__(self, other):
358 if other is None:
359 return False
361 return self.podcast == other.podcast and \
362 self.user == other.user
364 def __repr__(self):
365 return 'Podcast %s for User %s (%s)' % \
366 (self.podcast, self.user, self._id)
369 class Device(Document, SettingsMixin):
370 id = StringProperty(default=lambda: uuid.uuid4().hex)
371 oldid = IntegerProperty(required=False)
372 uid = StringProperty(required=True)
373 name = StringProperty(required=True, default='New Device')
374 type = StringProperty(required=True, default='other')
375 deleted = BooleanProperty(default=False)
376 user_agent = StringProperty()
379 def get_subscription_changes(self, since, until):
381 Returns the subscription changes for the device as two lists.
382 The first lists contains the Ids of the podcasts that have been
383 subscribed to, the second list of those that have been unsubscribed
384 from.
387 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
389 add, rem = [], []
390 podcast_states = podcast_states_for_device(self.id)
391 for p_state in podcast_states:
392 change = p_state.get_change_between(self.id, since, until)
393 if change == 'subscribe':
394 add.append( p_state.ref_url )
395 elif change == 'unsubscribe':
396 rem.append( p_state.ref_url )
398 return add, rem
401 def get_latest_changes(self):
403 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
405 podcast_states = podcast_states_for_device(self.id)
406 for p_state in podcast_states:
407 actions = filter(lambda x: x.device == self.id, reversed(p_state.actions))
408 if actions:
409 yield (p_state.podcast, actions[0])
412 def get_subscribed_podcast_states(self):
413 r = PodcastUserState.view('subscriptions/by_device',
414 startkey = [self.id, None],
415 endkey = [self.id, {}],
416 include_docs = True
418 return list(r)
421 def get_subscribed_podcast_ids(self):
422 states = self.get_subscribed_podcast_states()
423 return [state.podcast for state in states]
426 def get_subscribed_podcasts(self):
427 """ Returns all subscribed podcasts for the device
429 The attribute "url" contains the URL that was used when subscribing to
430 the podcast """
432 states = self.get_subscribed_podcast_states()
433 podcast_ids = [state.podcast for state in states]
434 podcasts = podcasts_to_dict(podcast_ids)
436 for state in states:
437 podcast = proxy_object(podcasts[state.podcast], url=state.ref_url)
438 podcasts[state.podcast] = podcast
440 return podcasts.values()
443 def __hash__(self):
444 return hash(frozenset([self.id, self.uid, self.name, self.type, self.deleted]))
447 def __eq__(self, other):
448 return self.id == other.id
451 def __repr__(self):
452 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
455 def __str__(self):
456 return self.name
458 def __unicode__(self):
459 return self.name
463 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
464 'publisher_update_token', 'userpage_token')
467 class TokenException(Exception):
468 pass
471 class User(BaseUser, SyncedDevicesMixin, SettingsMixin):
472 oldid = IntegerProperty()
473 devices = SchemaListProperty(Device)
474 published_objects = StringListProperty()
475 deleted = BooleanProperty(default=False)
476 suggestions_up_to_date = BooleanProperty(default=False)
477 twitter = StringProperty()
478 about = StringProperty()
480 # token for accessing subscriptions of this use
481 subscriptions_token = StringProperty(default=None)
483 # token for accessing the favorite-episodes feed of this user
484 favorite_feeds_token = StringProperty(default=None)
486 # token for automatically updating feeds published by this user
487 publisher_update_token = StringProperty(default=None)
489 # token for accessing the userpage of this user
490 userpage_token = StringProperty(default=None)
492 class Meta:
493 app_label = 'users'
496 def create_new_token(self, token_name, length=32):
497 """ creates a new random token """
499 if token_name not in TOKEN_NAMES:
500 raise TokenException('Invalid token name %s' % token_name)
502 token = "".join(random.sample(string.letters+string.digits, length))
503 setattr(self, token_name, token)
507 @repeat_on_conflict(['self'])
508 def get_token(self, token_name):
509 """ returns a token, and generate those that are still missing """
511 generated = False
513 if token_name not in TOKEN_NAMES:
514 raise TokenException('Invalid token name %s' % token_name)
516 for tn in TOKEN_NAMES:
517 if getattr(self, tn) is None:
518 self.create_new_token(tn)
519 generated = True
521 if generated:
522 self.save()
524 return getattr(self, token_name)
528 @property
529 def active_devices(self):
530 not_deleted = lambda d: not d.deleted
531 return filter(not_deleted, self.devices)
534 @property
535 def inactive_devices(self):
536 deleted = lambda d: d.deleted
537 return filter(deleted, self.devices)
540 def get_devices_by_id(self):
541 return dict( (device.id, device) for device in self.devices)
544 def get_device(self, id):
546 if not hasattr(self, '__device_by_id'):
547 self.__devices_by_id = dict( (d.id, d) for d in self.devices)
549 return self.__devices_by_id.get(id, None)
552 def get_device_by_uid(self, uid, only_active=True):
554 if not hasattr(self, '__devices_by_uio'):
555 self.__devices_by_uid = dict( (d.uid, d) for d in self.devices)
557 try:
558 device = self.__devices_by_uid[uid]
560 if only_active and device.deleted:
561 raise DeviceDeletedException(
562 'Device with UID %s is deleted' % uid)
564 return device
566 except KeyError as e:
567 raise DeviceDoesNotExist('There is no device with UID %s' % uid)
570 @repeat_on_conflict(['self'])
571 def update_device(self, device):
572 """ Sets the device and saves the user """
573 self.set_device(device)
574 self.save()
577 def set_device(self, device):
579 if not RE_DEVICE_UID.match(device.uid):
580 raise DeviceUIDException(u"'{uid} is not a valid device ID".format(
581 uid=device.uid))
583 devices = list(self.devices)
584 ids = [x.id for x in devices]
585 if not device.id in ids:
586 devices.append(device)
587 self.devices = devices
588 return
590 index = ids.index(device.id)
591 devices.pop(index)
592 devices.insert(index, device)
593 self.devices = devices
596 def remove_device(self, device):
597 devices = list(self.devices)
598 ids = [x.id for x in devices]
599 if not device.id in ids:
600 return
602 index = ids.index(device.id)
603 devices.pop(index)
604 self.devices = devices
606 if self.is_synced(device):
607 self.unsync_device(device)
610 def get_subscriptions_by_device(self, public=None):
611 from mygpo.db.couchdb.podcast_state import subscriptions_by_user
612 get_dev = itemgetter(2)
613 groups = collections.defaultdict(list)
614 subscriptions = subscriptions_by_user(self, public=public)
615 subscriptions = sorted(subscriptions, key=get_dev)
617 for public, podcast_id, device_id in subscriptions:
618 groups[device_id].append(podcast_id)
620 return groups
623 def get_subscribed_podcast_states(self, public=None):
625 Returns the Ids of all subscribed podcasts
628 r = PodcastUserState.view('subscriptions/by_user',
629 startkey = [self._id, public, None, None],
630 endkey = [self._id+'ZZZ', None, None, None],
631 reduce = False,
632 include_docs = True
635 return set(r)
638 def get_subscribed_podcast_ids(self, public=None):
639 states = self.get_subscribed_podcast_states(public=public)
640 return [state.podcast for state in states]
644 def get_subscribed_podcasts(self, public=None):
645 """ Returns all subscribed podcasts for the user
647 The attribute "url" contains the URL that was used when subscribing to
648 the podcast """
650 states = self.get_subscribed_podcast_states(public=public)
651 podcast_ids = [state.podcast for state in states]
652 podcasts = podcasts_to_dict(podcast_ids)
654 for state in states:
655 podcast = proxy_object(podcasts[state.podcast], url=state.ref_url)
656 podcasts[state.podcast] = podcast
658 return podcasts.values()
662 def get_subscription_history(self, device_id=None, reverse=False, public=None):
663 """ Returns chronologically ordered subscription history entries
665 Setting device_id restricts the actions to a certain device
668 from mygpo.db.couchdb.podcast_state import podcast_states_for_user, \
669 podcast_states_for_device
671 def action_iter(state):
672 for action in sorted(state.actions, reverse=reverse):
673 if device_id is not None and device_id != action.device:
674 continue
676 if public is not None and state.is_public() != public:
677 continue
679 entry = HistoryEntry()
680 entry.timestamp = action.timestamp
681 entry.action = action.action
682 entry.podcast_id = state.podcast
683 entry.device_id = action.device
684 yield entry
686 if device_id is None:
687 podcast_states = podcast_states_for_user(self)
688 else:
689 podcast_states = podcast_states_for_device(device_id)
691 # create an action_iter for each PodcastUserState
692 subscription_action_lists = [action_iter(x) for x in podcast_states]
694 action_cmp_key = lambda x: x.timestamp
696 # Linearize their subscription-actions
697 return linearize(action_cmp_key, subscription_action_lists, reverse)
700 def get_global_subscription_history(self, public=None):
701 """ Actions that added/removed podcasts from the subscription list
703 Returns an iterator of all subscription actions that either
704 * added subscribed a podcast that hasn't been subscribed directly
705 before the action (but could have been subscribed) earlier
706 * removed a subscription of the podcast is not longer subscribed
707 after the action
710 subscriptions = collections.defaultdict(int)
712 for entry in self.get_subscription_history(public=public):
713 if entry.action == 'subscribe':
714 subscriptions[entry.podcast_id] += 1
716 # a new subscription has been added
717 if subscriptions[entry.podcast_id] == 1:
718 yield entry
720 elif entry.action == 'unsubscribe':
721 subscriptions[entry.podcast_id] -= 1
723 # the last subscription has been removed
724 if subscriptions[entry.podcast_id] == 0:
725 yield entry
729 def get_newest_episodes(self, max_date, max_per_podcast=5):
730 """ Returns the newest episodes of all subscribed podcasts
732 Only max_per_podcast episodes per podcast are loaded. Episodes with
733 release dates above max_date are discarded.
735 This method returns a generator that produces the newest episodes.
737 The number of required DB queries is equal to the number of (distinct)
738 podcasts of all consumed episodes (max: number of subscribed podcasts),
739 plus a constant number of initial queries (when the first episode is
740 consumed). """
742 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
744 podcasts = list(self.get_subscribed_podcasts())
745 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
746 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
747 reverse=True)
749 podcast_dict = dict((p.get_id(), p) for p in podcasts)
751 # contains the un-yielded episodes, newest first
752 episodes = []
754 for podcast in podcasts:
756 yielded_episodes = 0
758 for episode in episodes:
759 # determine for which episodes there won't be a new episodes
760 # that is newer; those can be yielded
761 if episode.released > podcast.latest_episode_timestamp:
762 p = podcast_dict.get(episode.podcast, None)
763 yield proxy_object(episode, podcast=p)
764 yielded_episodes += 1
765 else:
766 break
768 # remove the episodes that have been yielded before
769 episodes = episodes[yielded_episodes:]
771 # fetch and merge episodes for the next podcast
772 from mygpo.db.couchdb.episode import episodes_for_podcast
773 new_episodes = episodes_for_podcast(podcast, since=1,
774 until=max_date, descending=True, limit=max_per_podcast)
775 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
778 # yield the remaining episodes
779 for episode in episodes:
780 podcast = podcast_dict.get(episode.podcast, None)
781 yield proxy_object(episode, podcast=podcast)
786 def save(self, *args, **kwargs):
788 from mygpo.db.couchdb.podcast_state import podcast_states_for_user
790 super(User, self).save(*args, **kwargs)
792 podcast_states = podcast_states_for_user(self)
793 for state in podcast_states:
794 @repeat_on_conflict(['state'])
795 def _update_state(state):
796 old_devs = set(state.disabled_devices)
797 state.set_device_state(self.devices)
799 if old_devs != set(state.disabled_devices):
800 state.save()
802 _update_state(state)
807 def __eq__(self, other):
808 if not other:
809 return False
811 # ensure that other isn't AnonymousUser
812 return other.is_authenticated() and self._id == other._id
815 def __ne__(self, other):
816 return not(self == other)
819 def __repr__(self):
820 return 'User %s' % self._id
823 class History(object):
825 def __init__(self, user, device):
826 self.user = user
827 self.device = device
830 def __getitem__(self, key):
832 if isinstance(key, slice):
833 start = key.start or 0
834 length = key.stop - start
835 else:
836 start = key
837 length = 1
839 if self.device:
840 return device_history(self.user, self.device, start, length)
842 else:
843 return user_history(self.user, start, length)
847 class HistoryEntry(object):
848 """ A class that can represent subscription and episode actions """
851 @classmethod
852 def from_action_dict(cls, action):
854 entry = HistoryEntry()
856 if 'timestamp' in action:
857 ts = action.pop('timestamp')
858 entry.timestamp = dateutil.parser.parse(ts)
860 for key, value in action.items():
861 setattr(entry, key, value)
863 return entry
866 @property
867 def playmark(self):
868 return getattr(self, 'position', None)
871 @classmethod
872 def fetch_data(cls, user, entries,
873 podcasts=None, episodes=None):
874 """ Efficiently loads additional data for a number of entries """
876 if podcasts is None:
877 # load podcast data
878 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
879 podcast_ids = filter(None, podcast_ids)
880 podcasts = podcasts_to_dict(podcast_ids)
882 if episodes is None:
883 from mygpo.db.couchdb.episode import episodes_to_dict
884 # load episode data
885 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
886 episode_ids = filter(None, episode_ids)
887 episodes = episodes_to_dict(episode_ids)
889 # load device data
890 # does not need pre-populated data because no db-access is required
891 device_ids = [getattr(x, 'device_id', None) for x in entries]
892 device_ids = filter(None, device_ids)
893 devices = dict([ (id, user.get_device(id)) for id in device_ids])
896 for entry in entries:
897 podcast_id = getattr(entry, 'podcast_id', None)
898 entry.podcast = podcasts.get(podcast_id, None)
900 episode_id = getattr(entry, 'episode_id', None)
901 entry.episode = episodes.get(episode_id, None)
903 if hasattr(entry, 'user'):
904 entry.user = user
906 device = devices.get(getattr(entry, 'device_id', None), None)
907 entry.device = device
910 return entries