remove unused imports
[mygpo.git] / mygpo / users / models.py
blob5cd68250e05348cc9ec6f5c14d83a180e540c952
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.core.models import Podcast
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.db.couchdb.podcast import podcasts_by_id, podcasts_to_dict
23 from mygpo.db.couchdb.user import user_history, device_history
26 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
29 class InvalidEpisodeActionAttributes(ValueError):
30 """ raised when the attribues of an episode action fail validation """
33 class DeviceUIDException(Exception):
34 pass
37 class DeviceDoesNotExist(Exception):
38 pass
41 class DeviceDeletedException(DeviceDoesNotExist):
42 pass
45 class Suggestions(Document, RatingMixin):
46 user = StringProperty(required=True)
47 user_oldid = IntegerProperty()
48 podcasts = StringListProperty()
49 blacklist = StringListProperty()
52 def get_podcasts(self, count=None):
53 user = User.get(self.user)
54 subscriptions = user.get_subscribed_podcast_ids()
56 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
57 if count:
58 ids = ids[:count]
59 return filter(lambda x: x and x.title, podcasts_by_id(ids))
62 def __repr__(self):
63 if not self._id:
64 return super(Suggestions, self).__repr__()
65 else:
66 return '%d Suggestions for %s (%s)' % \
67 (len(self.podcasts), self.user, self._id)
70 class EpisodeAction(DocumentSchema):
71 """
72 One specific action to an episode. Must
73 always be part of a EpisodeUserState
74 """
76 action = StringProperty(required=True)
77 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
78 device_oldid = IntegerProperty(required=False)
79 device = StringProperty()
80 started = IntegerProperty()
81 playmark = IntegerProperty()
82 total = IntegerProperty()
84 def __eq__(self, other):
85 if not isinstance(other, EpisodeAction):
86 return False
87 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
88 'total')
89 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
92 def to_history_entry(self):
93 entry = HistoryEntry()
94 entry.action = self.action
95 entry.timestamp = self.timestamp
96 entry.device_id = self.device
97 entry.started = self.started
98 entry.position = self.playmark
99 entry.total = self.total
100 return entry
104 def validate_time_values(self):
105 """ Validates allowed combinations of time-values """
107 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
109 # Key found, but must not be supplied (no play action!)
110 if self.action != 'play':
111 for key in PLAY_ACTION_KEYS:
112 if getattr(self, key, None) is not None:
113 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key)
115 # Sanity check: If started or total are given, require playmark
116 if ((self.started is not None) or (self.total is not None)) and \
117 self.playmark is None:
118 raise InvalidEpisodeActionAttributes('started and total require position')
120 # Sanity check: total and playmark can only appear together
121 if ((self.total is not None) or (self.started is not None)) and \
122 ((self.total is None) or (self.started is None)):
123 raise InvalidEpisodeActionAttributes('total and started can only appear together')
126 def __repr__(self):
127 return '%s-Action on %s at %s (in %s)' % \
128 (self.action, self.device, self.timestamp, self._id)
131 def __hash__(self):
132 return hash(frozenset([self.action, self.timestamp, self.device,
133 self.started, self.playmark, self.total]))
136 class Chapter(Document):
137 """ A user-entered episode chapter """
139 device = StringProperty()
140 created = DateTimeProperty()
141 start = IntegerProperty(required=True)
142 end = IntegerProperty(required=True)
143 label = StringProperty()
144 advertisement = BooleanProperty()
147 def __repr__(self):
148 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
149 self.start, self.end)
152 class EpisodeUserState(Document):
154 Contains everything a user has done with an Episode
157 episode = StringProperty(required=True)
158 actions = SchemaListProperty(EpisodeAction)
159 settings = DictProperty()
160 user_oldid = IntegerProperty()
161 user = StringProperty(required=True)
162 ref_url = StringProperty(required=True)
163 podcast_ref_url = StringProperty(required=True)
164 merged_ids = StringListProperty()
165 chapters = SchemaListProperty(Chapter)
166 podcast = StringProperty(required=True)
170 def add_actions(self, actions):
171 map(EpisodeAction.validate_time_values, actions)
172 self.actions = list(self.actions) + actions
173 self.actions = list(set(self.actions))
174 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
177 def is_favorite(self):
178 return self.settings.get('is_favorite', False)
181 def set_favorite(self, set_to=True):
182 self.settings['is_favorite'] = set_to
185 def update_chapters(self, add=[], rem=[]):
186 """ Updates the Chapter list
188 * add contains the chapters to be added
190 * rem contains tuples of (start, end) times. Chapters that match
191 both endpoints will be removed
194 @repeat_on_conflict(['state'])
195 def update(state):
196 for chapter in add:
197 self.chapters = self.chapters + [chapter]
199 for start, end in rem:
200 keep = lambda c: c.start != start or c.end != end
201 self.chapters = filter(keep, self.chapters)
203 self.save()
205 update(state=self)
208 def get_history_entries(self):
209 return imap(EpisodeAction.to_history_entry, self.actions)
212 def __repr__(self):
213 return 'Episode-State %s (in %s)' % \
214 (self.episode, self._id)
216 def __eq__(self, other):
217 if not isinstance(other, EpisodeUserState):
218 return False
220 return (self.episode == other.episode and
221 self.user == other.user)
225 class SubscriptionAction(Document):
226 action = StringProperty()
227 timestamp = DateTimeProperty(default=datetime.utcnow)
228 device = StringProperty()
231 __metaclass__ = DocumentABCMeta
234 def __cmp__(self, other):
235 return cmp(self.timestamp, other.timestamp)
237 def __eq__(self, other):
238 return self.action == other.action and \
239 self.timestamp == other.timestamp and \
240 self.device == other.device
242 def __hash__(self):
243 return hash(self.action) + hash(self.timestamp) + hash(self.device)
245 def __repr__(self):
246 return '<SubscriptionAction %s on %s at %s>' % (
247 self.action, self.device, self.timestamp)
250 class PodcastUserState(Document):
252 Contains everything that a user has done
253 with a specific podcast and all its episodes
256 podcast = StringProperty(required=True)
257 user_oldid = IntegerProperty()
258 user = StringProperty(required=True)
259 settings = DictProperty()
260 actions = SchemaListProperty(SubscriptionAction)
261 tags = StringListProperty()
262 ref_url = StringProperty(required=True)
263 disabled_devices = StringListProperty()
264 merged_ids = StringListProperty()
267 def remove_device(self, device):
269 Removes all actions from the podcast state that refer to the
270 given device
272 self.actions = filter(lambda a: a.device != device.id, self.actions)
275 def subscribe(self, device):
276 action = SubscriptionAction()
277 action.action = 'subscribe'
278 action.device = device.id
279 self.add_actions([action])
282 def unsubscribe(self, device):
283 action = SubscriptionAction()
284 action.action = 'unsubscribe'
285 action.device = device.id
286 self.add_actions([action])
289 def add_actions(self, actions):
290 self.actions = list(set(self.actions + actions))
291 self.actions = sorted(self.actions)
294 def add_tags(self, tags):
295 self.tags = list(set(self.tags + tags))
298 def set_device_state(self, devices):
299 disabled_devices = [device.id for device in devices if device.deleted]
300 self.disabled_devices = disabled_devices
303 def get_change_between(self, device_id, since, until):
305 Returns the change of the subscription status for the given device
306 between the two timestamps.
308 The change is given as either 'subscribe' (the podcast has been
309 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
310 None (no change)
313 device_actions = filter(lambda x: x.device == device_id, self.actions)
314 before = filter(lambda x: x.timestamp <= since, device_actions)
315 after = filter(lambda x: x.timestamp <= until, device_actions)
317 # nothing happened, so there can be no change
318 if not after:
319 return None
321 then = before[-1] if before else None
322 now = after[-1]
324 if then is None:
325 if now.action != 'unsubscribe':
326 return now.action
327 elif then.action != now.action:
328 return now.action
329 return None
332 def get_subscribed_device_ids(self):
333 """ device Ids on which the user subscribed to the podcast """
334 devices = set()
336 for action in self.actions:
337 if action.action == "subscribe":
338 if not action.device in self.disabled_devices:
339 devices.add(action.device)
340 else:
341 if action.device in devices:
342 devices.remove(action.device)
344 return devices
348 def is_public(self):
349 return self.settings.get('public_subscription', True)
352 def __eq__(self, other):
353 if other is None:
354 return False
356 return self.podcast == other.podcast and \
357 self.user == other.user
359 def __repr__(self):
360 return 'Podcast %s for User %s (%s)' % \
361 (self.podcast, self.user, self._id)
364 class Device(Document):
365 id = StringProperty(default=lambda: uuid.uuid4().hex)
366 oldid = IntegerProperty(required=False)
367 uid = StringProperty(required=True)
368 name = StringProperty(required=True, default='New Device')
369 type = StringProperty(required=True, default='other')
370 settings = DictProperty()
371 deleted = BooleanProperty(default=False)
372 user_agent = StringProperty()
375 def get_subscription_changes(self, since, until):
377 Returns the subscription changes for the device as two lists.
378 The first lists contains the Ids of the podcasts that have been
379 subscribed to, the second list of those that have been unsubscribed
380 from.
383 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
385 add, rem = [], []
386 podcast_states = podcast_states_for_device(self.id)
387 for p_state in podcast_states:
388 change = p_state.get_change_between(self.id, since, until)
389 if change == 'subscribe':
390 add.append( p_state.ref_url )
391 elif change == 'unsubscribe':
392 rem.append( p_state.ref_url )
394 return add, rem
397 def get_latest_changes(self):
399 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
401 podcast_states = podcast_states_for_device(self.id)
402 for p_state in podcast_states:
403 actions = filter(lambda x: x.device == self.id, reversed(p_state.actions))
404 if actions:
405 yield (p_state.podcast, actions[0])
408 def get_subscribed_podcast_states(self):
409 r = PodcastUserState.view('subscriptions/by_device',
410 startkey = [self.id, None],
411 endkey = [self.id, {}],
412 include_docs = True
414 return list(r)
417 def get_subscribed_podcast_ids(self):
418 states = self.get_subscribed_podcast_states()
419 return [state.podcast for state in states]
422 def get_subscribed_podcasts(self):
423 """ Returns all subscribed podcasts for the device
425 The attribute "url" contains the URL that was used when subscribing to
426 the podcast """
428 states = self.get_subscribed_podcast_states()
429 podcast_ids = [state.podcast for state in states]
430 podcasts = podcasts_to_dict(podcast_ids)
432 for state in states:
433 podcast = proxy_object(podcasts[state.podcast], url=state.ref_url)
434 podcasts[state.podcast] = podcast
436 return podcasts.values()
439 def __hash__(self):
440 return hash(frozenset([self.id, self.uid, self.name, self.type, self.deleted]))
443 def __eq__(self, other):
444 return self.id == other.id
447 def __repr__(self):
448 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
451 def __str__(self):
452 return self.name
454 def __unicode__(self):
455 return self.name
459 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
460 'publisher_update_token', 'userpage_token')
463 class TokenException(Exception):
464 pass
467 class User(BaseUser, SyncedDevicesMixin):
468 oldid = IntegerProperty()
469 settings = DictProperty()
470 devices = SchemaListProperty(Device)
471 published_objects = StringListProperty()
472 deleted = BooleanProperty(default=False)
473 suggestions_up_to_date = BooleanProperty(default=False)
475 # token for accessing subscriptions of this use
476 subscriptions_token = StringProperty(default=None)
478 # token for accessing the favorite-episodes feed of this user
479 favorite_feeds_token = StringProperty(default=None)
481 # token for automatically updating feeds published by this user
482 publisher_update_token = StringProperty(default=None)
484 # token for accessing the userpage of this user
485 userpage_token = StringProperty(default=None)
487 class Meta:
488 app_label = 'users'
491 def create_new_token(self, token_name, length=32):
492 """ creates a new random token """
494 if token_name not in TOKEN_NAMES:
495 raise TokenException('Invalid token name %s' % token_name)
497 token = "".join(random.sample(string.letters+string.digits, length))
498 setattr(self, token_name, token)
502 def get_token(self, token_name):
503 """ returns a token, and generate those that are still missing """
505 generated = False
507 if token_name not in TOKEN_NAMES:
508 raise TokenException('Invalid token name %s' % token_name)
510 for tn in TOKEN_NAMES:
511 if getattr(self, tn) is None:
512 self.create_new_token(tn)
513 generated = True
515 if generated:
516 self.save()
518 return getattr(self, token_name)
522 @property
523 def active_devices(self):
524 not_deleted = lambda d: not d.deleted
525 return filter(not_deleted, self.devices)
528 @property
529 def inactive_devices(self):
530 deleted = lambda d: d.deleted
531 return filter(deleted, self.devices)
534 def get_devices_by_id(self):
535 return dict( (device.id, device) for device in self.devices)
538 def get_device(self, id):
540 if not hasattr(self, '__device_by_id'):
541 self.__devices_by_id = dict( (d.id, d) for d in self.devices)
543 return self.__devices_by_id.get(id, None)
546 def get_device_by_uid(self, uid, only_active=True):
548 if not hasattr(self, '__devices_by_uio'):
549 self.__devices_by_uid = dict( (d.uid, d) for d in self.devices)
551 try:
552 device = self.__devices_by_uid[uid]
554 if only_active and device.deleted:
555 raise DeviceDeletedException(
556 'Device with UID %s is deleted' % uid)
558 return device
560 except KeyError as e:
561 raise DeviceDoesNotExist('There is no device with UID %s' % uid)
564 def update_device(self, device):
565 """ Sets the device and saves the user """
567 @repeat_on_conflict(['user'])
568 def _update(user, device):
569 user.set_device(device)
570 user.save()
572 _update(user=self, device=device)
575 def set_device(self, device):
577 if not RE_DEVICE_UID.match(device.uid):
578 raise DeviceUIDException(u"'{uid} is not a valid device ID".format(
579 uid=device.uid))
581 devices = list(self.devices)
582 ids = [x.id for x in devices]
583 if not device.id in ids:
584 devices.append(device)
585 self.devices = devices
586 return
588 index = ids.index(device.id)
589 devices.pop(index)
590 devices.insert(index, device)
591 self.devices = devices
594 def remove_device(self, device):
595 devices = list(self.devices)
596 ids = [x.id for x in devices]
597 if not device.id in ids:
598 return
600 index = ids.index(device.id)
601 devices.pop(index)
602 self.devices = devices
604 if self.is_synced(device):
605 self.unsync_device(device)
608 def get_subscriptions_by_device(self, public=None):
609 from mygpo.db.couchdb.podcast_state import subscriptions_by_user
610 get_dev = itemgetter(2)
611 groups = collections.defaultdict(list)
612 subscriptions = subscriptions_by_user(self, public=public)
613 subscriptions = sorted(subscriptions, key=get_dev)
615 for public, podcast_id, device_id in subscriptions:
616 groups[device_id].append(podcast_id)
618 return groups
621 def get_subscribed_podcast_states(self, public=None):
623 Returns the Ids of all subscribed podcasts
626 r = PodcastUserState.view('subscriptions/by_user',
627 startkey = [self._id, public, None, None],
628 endkey = [self._id+'ZZZ', None, None, None],
629 reduce = False,
630 include_docs = True
633 return set(r)
636 def get_subscribed_podcast_ids(self, public=None):
637 states = self.get_subscribed_podcast_states(public=public)
638 return [state.podcast for state in states]
642 def get_subscribed_podcasts(self, public=None):
643 """ Returns all subscribed podcasts for the user
645 The attribute "url" contains the URL that was used when subscribing to
646 the podcast """
648 states = self.get_subscribed_podcast_states(public=public)
649 podcast_ids = [state.podcast for state in states]
650 podcasts = podcasts_to_dict(podcast_ids)
652 for state in states:
653 podcast = proxy_object(podcasts[state.podcast], url=state.ref_url)
654 podcasts[state.podcast] = podcast
656 return podcasts.values()
660 def get_subscription_history(self, device_id=None, reverse=False, public=None):
661 """ Returns chronologically ordered subscription history entries
663 Setting device_id restricts the actions to a certain device
666 from mygpo.db.couchdb.podcast_state import podcast_states_for_user, \
667 podcast_states_for_device
669 def action_iter(state):
670 for action in sorted(state.actions, reverse=reverse):
671 if device_id is not None and device_id != action.device:
672 continue
674 if public is not None and state.is_public() != public:
675 continue
677 entry = HistoryEntry()
678 entry.timestamp = action.timestamp
679 entry.action = action.action
680 entry.podcast_id = state.podcast
681 entry.device_id = action.device
682 yield entry
684 if device_id is None:
685 podcast_states = podcast_states_for_user(self)
686 else:
687 podcast_states = podcast_states_for_device(device_id)
689 # create an action_iter for each PodcastUserState
690 subscription_action_lists = [action_iter(x) for x in podcast_states]
692 action_cmp_key = lambda x: x.timestamp
694 # Linearize their subscription-actions
695 return linearize(action_cmp_key, subscription_action_lists, reverse)
698 def get_global_subscription_history(self, public=None):
699 """ Actions that added/removed podcasts from the subscription list
701 Returns an iterator of all subscription actions that either
702 * added subscribed a podcast that hasn't been subscribed directly
703 before the action (but could have been subscribed) earlier
704 * removed a subscription of the podcast is not longer subscribed
705 after the action
708 subscriptions = collections.defaultdict(int)
710 for entry in self.get_subscription_history(public=public):
711 if entry.action == 'subscribe':
712 subscriptions[entry.podcast_id] += 1
714 # a new subscription has been added
715 if subscriptions[entry.podcast_id] == 1:
716 yield entry
718 elif entry.action == 'unsubscribe':
719 subscriptions[entry.podcast_id] -= 1
721 # the last subscription has been removed
722 if subscriptions[entry.podcast_id] == 0:
723 yield entry
727 def get_newest_episodes(self, max_date, max_per_podcast=5):
728 """ Returns the newest episodes of all subscribed podcasts
730 Only max_per_podcast episodes per podcast are loaded. Episodes with
731 release dates above max_date are discarded.
733 This method returns a generator that produces the newest episodes.
735 The number of required DB queries is equal to the number of (distinct)
736 podcasts of all consumed episodes (max: number of subscribed podcasts),
737 plus a constant number of initial queries (when the first episode is
738 consumed). """
740 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
742 podcasts = list(self.get_subscribed_podcasts())
743 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
744 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
745 reverse=True)
747 podcast_dict = dict((p.get_id(), p) for p in podcasts)
749 # contains the un-yielded episodes, newest first
750 episodes = []
752 for podcast in podcasts:
754 yielded_episodes = 0
756 for episode in episodes:
757 # determine for which episodes there won't be a new episodes
758 # that is newer; those can be yielded
759 if episode.released > podcast.latest_episode_timestamp:
760 p = podcast_dict.get(episode.podcast, None)
761 yield proxy_object(episode, podcast=p)
762 yielded_episodes += 1
763 else:
764 break
766 # remove the episodes that have been yielded before
767 episodes = episodes[yielded_episodes:]
769 # fetch and merge episodes for the next podcast
770 from mygpo.db.couchdb.episode import episodes_for_podcast
771 new_episodes = episodes_for_podcast(podcast, since=1,
772 until=max_date, descending=True, limit=max_per_podcast)
773 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
776 # yield the remaining episodes
777 for episode in episodes:
778 podcast = podcast_dict.get(episode.podcast, None)
779 yield proxy_object(episode, podcast=podcast)
784 def save(self, *args, **kwargs):
786 from mygpo.db.couchdb.podcast_state import podcast_states_for_user
788 super(User, self).save(*args, **kwargs)
790 podcast_states = podcast_states_for_user(self)
791 for state in podcast_states:
792 @repeat_on_conflict(['state'])
793 def _update_state(state):
794 old_devs = set(state.disabled_devices)
795 state.set_device_state(self.devices)
797 if old_devs != set(state.disabled_devices):
798 state.save()
800 _update_state(state=state)
805 def __eq__(self, other):
806 if not other:
807 return False
809 # ensure that other isn't AnonymousUser
810 return other.is_authenticated() and self._id == other._id
813 def __ne__(self, other):
814 return not(self == other)
817 def __repr__(self):
818 return 'User %s' % self._id
821 class History(object):
823 def __init__(self, user, device):
824 self.user = user
825 self.device = device
828 def __getitem__(self, key):
830 if isinstance(key, slice):
831 start = key.start or 0
832 length = key.stop - start
833 else:
834 start = key
835 length = 1
837 if self.device:
838 return device_history(self.user, self.device, start, length)
840 else:
841 return user_history(self.user, start, length)
845 class HistoryEntry(object):
846 """ A class that can represent subscription and episode actions """
849 @classmethod
850 def from_action_dict(cls, action):
852 entry = HistoryEntry()
854 if 'timestamp' in action:
855 ts = action.pop('timestamp')
856 entry.timestamp = dateutil.parser.parse(ts)
858 for key, value in action.items():
859 setattr(entry, key, value)
861 return entry
864 @property
865 def playmark(self):
866 return getattr(self, 'position', None)
869 @classmethod
870 def fetch_data(cls, user, entries,
871 podcasts=None, episodes=None):
872 """ Efficiently loads additional data for a number of entries """
874 if podcasts is None:
875 # load podcast data
876 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
877 podcast_ids = filter(None, podcast_ids)
878 podcasts = podcasts_to_dict(podcast_ids)
880 if episodes is None:
881 from mygpo.db.couchdb.episode import episodes_to_dict
882 # load episode data
883 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
884 episode_ids = filter(None, episode_ids)
885 episodes = episodes_to_dict(episode_ids)
887 # load device data
888 # does not need pre-populated data because no db-access is required
889 device_ids = [getattr(x, 'device_id', None) for x in entries]
890 device_ids = filter(None, device_ids)
891 devices = dict([ (id, user.get_device(id)) for id in device_ids])
894 for entry in entries:
895 podcast_id = getattr(entry, 'podcast_id', None)
896 entry.podcast = podcasts.get(podcast_id, None)
898 episode_id = getattr(entry, 'episode_id', None)
899 entry.episode = episodes.get(episode_id, None)
901 if hasattr(entry, 'user'):
902 entry.user = user
904 device = devices.get(getattr(entry, 'device_id', None), None)
905 entry.device = device
908 return entries