[Models] update migration files for user models
[mygpo.git] / mygpo / users / models.py
blobea23e1011a4d7ce693ddfd6d137c2c129bbfcc36
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 *
12 from uuidfield import UUIDField
14 from django.core.exceptions import ValidationError
15 from django.core.validators import RegexValidator
16 from django.db import transaction, models
17 from django.db.models import Q
18 from django.contrib.auth.models import User as DjangoUser
19 from django.contrib.auth import get_user_model
20 from django.utils.translation import ugettext_lazy as _
21 from django.conf import settings
22 from django.core.cache import cache
24 from django_couchdb_utils.registration.models import User as BaseUser
26 from mygpo.core.models import (TwitterModel, UUIDModel, SettingsModel,
27 GenericManager, )
28 from mygpo.podcasts.models import Podcast, Episode
29 from mygpo.utils import linearize, random_token
30 from mygpo.core.proxy import DocumentABCMeta, proxy_object
31 from mygpo.decorators import repeat_on_conflict
32 from mygpo.users.ratings import RatingMixin
33 from mygpo.users.subscriptions import subscription_changes, podcasts_for_states
34 from mygpo.users.settings import FAV_FLAG, PUBLIC_SUB_PODCAST, SettingsMixin
35 from mygpo.db.couchdb.user import user_history, device_history
37 # make sure this code is executed at startup
38 from mygpo.users.signals import *
40 import logging
41 logger = logging.getLogger(__name__)
44 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
46 # TODO: derive from ValidationException?
47 class InvalidEpisodeActionAttributes(ValueError):
48 """ raised when the attribues of an episode action fail validation """
51 class SubscriptionException(Exception):
52 """ raised when a subscription can not be modified """
55 class DeviceDoesNotExist(Exception):
56 pass
59 class DeviceDeletedException(DeviceDoesNotExist):
60 pass
63 GroupedDevices = collections.namedtuple('GroupedDevices', 'is_synced devices')
66 class UIDValidator(RegexValidator):
67 """ Validates that the Device UID conforms to the given regex """
68 regex = RE_DEVICE_UID
69 message = 'Invalid Device ID'
70 code='invalid-uid'
73 class UserProxyQuerySet(models.QuerySet):
75 def by_username_or_email(self, username, email):
76 """ Queries for a User by username or email """
77 q = Q()
79 if username:
80 q |= Q(username=username)
82 elif email:
83 q |= Q(email=email)
85 if q:
86 return self.get(q)
87 else:
88 return self.none()
91 class UserProxyManager(GenericManager):
92 """ Manager for the UserProxy model """
94 def get_queryset(self):
95 return UserProxyQuerySet(self.model, using=self._db)
97 def from_user(self, user):
98 """ Get the UserProxy corresponding for the given User """
99 return self.get(pk=user.pk)
102 class UserProxy(DjangoUser):
104 objects = UserProxyManager()
106 class Meta:
107 proxy = True
109 @transaction.atomic
110 def activate(self):
111 self.is_active = True
112 self.save()
114 self.profile.activation_key = None
115 self.profile.save()
117 messages.success(request, _('Your user has been activated. '
118 'You can log in now.'))
120 def get_grouped_devices(self):
121 """ Returns groups of synced devices and a unsynced group """
123 clients = Client.objects.filter(user=self, deleted=False)\
124 .order_by('-sync_group')
126 last_group = object()
127 group = None
129 for client in clients:
130 # check if we have just found a new group
131 if last_group != client.sync_group:
132 if group != None:
133 yield group
135 group = GroupedDevices(client.sync_group is not None, [])
137 last_group = client.sync_group
138 group.devices.append(client)
140 # yield remaining group
141 yield group
144 class UserProfile(TwitterModel, SettingsModel):
145 """ Additional information stored for a User """
147 # the user to which this profile belongs
148 user = models.OneToOneField(settings.AUTH_USER_MODEL,
149 related_name='profile')
151 # the CouchDB _id of the user
152 uuid = UUIDField(unique=True)
154 # if False, suggestions should be updated
155 suggestions_up_to_date = models.BooleanField(default=False)
157 # text the user entered about himeself
158 about = models.TextField(blank=True)
160 # Google email address for OAuth login
161 google_email = models.CharField(max_length=100, null=True)
163 # token for accessing subscriptions of this use
164 subscriptions_token = models.CharField(max_length=32, null=True,
165 default=random_token)
167 # token for accessing the favorite-episodes feed of this user
168 favorite_feeds_token = models.CharField(max_length=32, null=True,
169 default=random_token)
171 # token for automatically updating feeds published by this user
172 publisher_update_token = models.CharField(max_length=32, null=True,
173 default=random_token)
175 # token for accessing the userpage of this user
176 userpage_token = models.CharField(max_length=32, null=True,
177 default=random_token)
179 # key for activating the user
180 activation_key = models.CharField(max_length=40, null=True)
182 def get_token(self, token_name):
183 """ returns a token """
185 if token_name not in TOKEN_NAMES:
186 raise TokenException('Invalid token name %s' % token_name)
188 return getattr(self, token_name)
191 class Suggestions(Document, RatingMixin):
192 user = StringProperty(required=True)
193 user_oldid = IntegerProperty()
194 podcasts = StringListProperty()
195 blacklist = StringListProperty()
198 def get_podcasts(self, count=None):
199 User = get_user_model()
200 user = User.objects.get(profile__uuid=self.user)
201 subscriptions = user.get_subscribed_podcast_ids()
203 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
204 if count:
205 ids = ids[:count]
207 podcasts = Podcast.objects.filter(id__in=ids).prefetch_related('slugs')
208 return filter(lambda x: x and x.title, podcasts)
211 def __repr__(self):
212 if not self._id:
213 return super(Suggestions, self).__repr__()
214 else:
215 return '%d Suggestions for %s (%s)' % \
216 (len(self.podcasts), self.user, self._id)
219 class EpisodeAction(DocumentSchema):
221 One specific action to an episode. Must
222 always be part of a EpisodeUserState
225 action = StringProperty(required=True)
227 # walltime of the event (assigned by the uploading client, defaults to now)
228 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
230 # upload time of the event
231 upload_timestamp = IntegerProperty(required=True)
233 device_oldid = IntegerProperty(required=False)
234 device = StringProperty()
235 started = IntegerProperty()
236 playmark = IntegerProperty()
237 total = IntegerProperty()
239 def __eq__(self, other):
240 if not isinstance(other, EpisodeAction):
241 return False
242 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
243 'total')
244 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
247 def to_history_entry(self):
248 entry = HistoryEntry()
249 entry.action = self.action
250 entry.timestamp = self.timestamp
251 entry.device_id = self.device
252 entry.started = self.started
253 entry.position = self.playmark
254 entry.total = self.total
255 return entry
259 def validate_time_values(self):
260 """ Validates allowed combinations of time-values """
262 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
264 # Key found, but must not be supplied (no play action!)
265 if self.action != 'play':
266 for key in PLAY_ACTION_KEYS:
267 if getattr(self, key, None) is not None:
268 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key)
270 # Sanity check: If started or total are given, require playmark
271 if ((self.started is not None) or (self.total is not None)) and \
272 self.playmark is None:
273 raise InvalidEpisodeActionAttributes('started and total require position')
275 # Sanity check: total and playmark can only appear together
276 if ((self.total is not None) or (self.started is not None)) and \
277 ((self.total is None) or (self.started is None)):
278 raise InvalidEpisodeActionAttributes('total and started can only appear together')
281 def __repr__(self):
282 return '%s-Action on %s at %s (in %s)' % \
283 (self.action, self.device, self.timestamp, self._id)
286 def __hash__(self):
287 return hash(frozenset([self.action, self.timestamp, self.device,
288 self.started, self.playmark, self.total]))
291 class Chapter(Document):
292 """ A user-entered episode chapter """
294 device = StringProperty()
295 created = DateTimeProperty()
296 start = IntegerProperty(required=True)
297 end = IntegerProperty(required=True)
298 label = StringProperty()
299 advertisement = BooleanProperty()
302 def __repr__(self):
303 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
304 self.start, self.end)
307 class EpisodeUserState(Document, SettingsMixin):
309 Contains everything a user has done with an Episode
312 episode = StringProperty(required=True)
313 actions = SchemaListProperty(EpisodeAction)
314 user_oldid = IntegerProperty()
315 user = StringProperty(required=True)
316 ref_url = StringProperty(required=True)
317 podcast_ref_url = StringProperty(required=True)
318 merged_ids = StringListProperty()
319 chapters = SchemaListProperty(Chapter)
320 podcast = StringProperty(required=True)
324 def add_actions(self, actions):
325 map(EpisodeAction.validate_time_values, actions)
326 self.actions = list(self.actions) + actions
327 self.actions = list(set(self.actions))
328 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
331 def is_favorite(self):
332 return self.get_wksetting(FAV_FLAG)
335 def set_favorite(self, set_to=True):
336 self.settings[FAV_FLAG.name] = set_to
339 def get_history_entries(self):
340 return imap(EpisodeAction.to_history_entry, self.actions)
343 def __repr__(self):
344 return 'Episode-State %s (in %s)' % \
345 (self.episode, self._id)
347 def __eq__(self, other):
348 if not isinstance(other, EpisodeUserState):
349 return False
351 return (self.episode == other.episode and
352 self.user == other.user)
356 class SubscriptionAction(Document):
357 action = StringProperty()
358 timestamp = DateTimeProperty(default=datetime.utcnow)
359 device = StringProperty()
362 __metaclass__ = DocumentABCMeta
365 def __cmp__(self, other):
366 return cmp(self.timestamp, other.timestamp)
368 def __eq__(self, other):
369 return self.action == other.action and \
370 self.timestamp == other.timestamp and \
371 self.device == other.device
373 def __hash__(self):
374 return hash(self.action) + hash(self.timestamp) + hash(self.device)
376 def __repr__(self):
377 return '<SubscriptionAction %s on %s at %s>' % (
378 self.action, self.device, self.timestamp)
381 class PodcastUserState(Document, SettingsMixin):
383 Contains everything that a user has done
384 with a specific podcast and all its episodes
387 podcast = StringProperty(required=True)
388 user_oldid = IntegerProperty()
389 user = StringProperty(required=True)
390 actions = SchemaListProperty(SubscriptionAction)
391 tags = StringListProperty()
392 ref_url = StringProperty(required=True)
393 disabled_devices = StringListProperty()
394 merged_ids = StringListProperty()
397 def remove_device(self, device):
399 Removes all actions from the podcast state that refer to the
400 given device
402 self.actions = filter(lambda a: a.device != device.id, self.actions)
405 def subscribe(self, device):
406 action = SubscriptionAction()
407 action.action = 'subscribe'
408 action.device = device.id.hex
409 self.add_actions([action])
412 def unsubscribe(self, device):
413 action = SubscriptionAction()
414 action.action = 'unsubscribe'
415 action.device = device.id.hex
416 self.add_actions([action])
419 def add_actions(self, actions):
420 self.actions = list(set(self.actions + actions))
421 self.actions = sorted(self.actions)
424 def add_tags(self, tags):
425 self.tags = list(set(self.tags + tags))
428 def set_device_state(self, devices):
429 disabled_devices = [device.id for device in devices if device.deleted]
430 self.disabled_devices = disabled_devices
433 def get_change_between(self, device_id, since, until):
435 Returns the change of the subscription status for the given device
436 between the two timestamps.
438 The change is given as either 'subscribe' (the podcast has been
439 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
440 None (no change)
443 device_actions = filter(lambda x: x.device == device_id, self.actions)
444 before = filter(lambda x: x.timestamp <= since, device_actions)
445 after = filter(lambda x: x.timestamp <= until, device_actions)
447 # nothing happened, so there can be no change
448 if not after:
449 return None
451 then = before[-1] if before else None
452 now = after[-1]
454 if then is None:
455 if now.action != 'unsubscribe':
456 return now.action
457 elif then.action != now.action:
458 return now.action
459 return None
462 def get_subscribed_device_ids(self):
463 """ device Ids on which the user subscribed to the podcast """
464 devices = set()
466 for action in self.actions:
467 if action.action == "subscribe":
468 if not action.device in self.disabled_devices:
469 devices.add(action.device)
470 else:
471 if action.device in devices:
472 devices.remove(action.device)
474 return devices
477 def is_subscribed_on(self, device):
478 """ checks if the podcast is subscribed on the given device """
480 for action in reversed(self.actions):
481 if not action.device == device.id:
482 continue
484 # we only need to check the latest action for the device
485 return (action.action == 'subscribe')
487 # we haven't found any matching action
488 return False
491 def is_public(self):
492 return self.get_wksetting(PUBLIC_SUB_PODCAST)
495 def __eq__(self, other):
496 if other is None:
497 return False
499 return self.podcast == other.podcast and \
500 self.user == other.user
502 def __repr__(self):
503 return 'Podcast %s for User %s (%s)' % \
504 (self.podcast, self.user, self._id)
507 class SyncGroup(models.Model):
508 """ A group of Clients """
510 user = models.ForeignKey(settings.AUTH_USER_MODEL)
512 def sync(self):
513 """ Sync the group, ie bring all members up-to-date """
515 group_state = self.get_group_state()
517 for device in SyncGroup.objects.filter(sync_group=self):
518 sync_actions = self.get_sync_actions(device, group_state)
519 device.apply_sync_actions(sync_actions)
521 def get_group_state(self):
522 """ Returns the group's subscription state
524 The state is represented by the latest actions for each podcast """
526 devices = Client.objects.filter(sync_group=self)
527 state = {}
529 for d in devices:
530 actions = dict(d.get_latest_changes())
531 for podcast_id, action in actions.items():
532 if not podcast_id in state or \
533 action.timestamp > state[podcast_id].timestamp:
534 state[podcast_id] = action
536 return state
538 def get_sync_actions(self, device, group_state):
539 """ Get the actions required to bring the device to the group's state
541 After applying the actions the device reflects the group's state """
543 # Filter those that describe actual changes to the current state
544 add, rem = [], []
545 current_state = dict(device.get_latest_changes())
547 for podcast_id, action in group_state.items():
549 # Sync-Actions must be newer than current state
550 if podcast_id in current_state and \
551 action.timestamp <= current_state[podcast_id].timestamp:
552 continue
554 # subscribe only what hasn't been subscribed before
555 if action.action == 'subscribe' and \
556 (podcast_id not in current_state or \
557 current_state[podcast_id].action == 'unsubscribe'):
558 add.append(podcast_id)
560 # unsubscribe only what has been subscribed before
561 elif action.action == 'unsubscribe' and \
562 podcast_id in current_state and \
563 current_state[podcast_id].action == 'subscribe':
564 rem.append(podcast_id)
566 return add, rem
570 class Client(UUIDModel):
571 """ A client application """
573 DESKTOP = 'desktop'
574 LAPTOP = 'laptop'
575 MOBILE = 'mobile'
576 SERVER = 'server'
577 TABLET = 'tablet'
578 OTHER = 'other'
580 TYPES = (
581 (DESKTOP, _('Desktop')),
582 (LAPTOP, _('Laptop')),
583 (MOBILE, _('Cell phone')),
584 (SERVER, _('Server')),
585 (TABLET, _('Tablet')),
586 (OTHER, _('Other')),
589 # User-assigned ID; must be unique for the user
590 uid = models.CharField(max_length=64, validators=[UIDValidator()])
592 # the user to which the Client belongs
593 user = models.ForeignKey(settings.AUTH_USER_MODEL)
595 # User-assigned name
596 name = models.CharField(max_length=100, default='New Device')
598 # one of several predefined types
599 type = models.CharField(max_length=max(len(k) for k, v in TYPES),
600 choices=TYPES, default=OTHER)
602 # indicates if the user has deleted the client
603 deleted = models.BooleanField(default=False)
605 # user-agent string from which the Client was last accessed (for writing)
606 user_agent = models.CharField(max_length=300, null=True, blank=True)
608 sync_group = models.ForeignKey(SyncGroup, null=True)
610 class Meta:
611 unique_together = (
612 ('user', 'uid'),
615 @transaction.atomic
616 def sync_with(self, other):
617 """ Puts two devices in a common sync group"""
619 if self.user != other.user:
620 raise ValueError('the devices do not belong to the user')
622 if self.sync_group is not None and \
623 other.sync_group is not None and \
624 self.sync_group != other.sync_group:
625 # merge sync_groups
626 ogroup = other.sync_group
627 Client.objects.filter(sync_group=ogroup)\
628 .update(sync_group=self.sync_group)
629 ogroup.delete()
631 elif self.sync_group is None and \
632 other.sync_group is None:
633 sg = SyncGroup.objects.create(user=self.user)
634 other.sync_group = sg
635 other.save()
636 self.sync_group = sg
637 self.save()
639 elif self.sync_group is not None:
640 self.sync_group = other.sync_group
641 self.save()
643 elif other.sync_group is not None:
644 other.sync_group = self.sync_group
645 other.save()
647 def stop_sync():
648 """ Stop synchronisation with other clients """
649 sg = self.sync_group
651 logger.info('Stopping synchronisation of %r', self)
652 self.sync_group = None
653 self.save()
655 clients = Client.objects.filter(sync_group=sg)
656 logger.info('%d other clients remaining in sync group', len(clients))
658 if len(clients) < 2:
659 logger.info('Deleting sync group %r', sg)
660 for client in clients:
661 client.sync_group = None
662 client.save()
664 sg.delete()
666 def get_sync_targets(self):
667 """ Returns the devices and groups with which the device can be synced
669 Groups are represented as lists of devices """
671 sg = self.sync_group
673 user = UserProxy.objects.from_user(self.user)
674 for group in user.get_grouped_devices():
676 if self in group.devices:
677 # the device's group can't be a sync-target
678 continue
680 elif group.is_synced:
681 yield group.devices
683 else:
684 # every unsynced device is a sync-target
685 for dev in group.devices:
686 if not dev == self:
687 yield dev
689 def apply_sync_actions(self, sync_actions):
690 """ Applies the sync-actions to the client """
692 from mygpo.db.couchdb.podcast_state import subscribe, unsubscribe
693 from mygpo.users.models import SubscriptionException
694 add, rem = sync_actions
696 podcasts = Podcast.objects.filter(id__in=(add+rem))
697 podcasts = {podcast.id: podcast for podcast in podcasts}
699 for podcast_id in add:
700 podcast = podcasts.get(podcast_id, None)
701 if podcast is None:
702 continue
703 try:
704 subscribe(podcast, self.user, self)
705 except SubscriptionException as e:
706 logger.warn('Web: %(username)s: cannot sync device: %(error)s' %
707 dict(username=self.user.username, error=repr(e)))
709 for podcast_id in rem:
710 podcast = podcasts.get(podcast_id, None)
711 if not podcast:
712 continue
714 try:
715 unsubscribe(podcast, self.user, self)
716 except SubscriptionException as e:
717 logger.warn('Web: %(username)s: cannot sync device: %(error)s' %
718 dict(username=self.user.username, error=repr(e)))
720 def get_subscription_changes(self, since, until):
722 Returns the subscription changes for the device as two lists.
723 The first lists contains the Ids of the podcasts that have been
724 subscribed to, the second list of those that have been unsubscribed
725 from.
728 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
729 podcast_states = podcast_states_for_device(self.id.hex)
730 return subscription_changes(self.id.hex, podcast_states, since, until)
732 def get_latest_changes(self):
733 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
734 podcast_states = podcast_states_for_device(self.id.hex)
735 for p_state in podcast_states:
736 actions = filter(lambda x: x.device == self.id.hex, reversed(p_state.actions))
737 if actions:
738 yield (p_state.podcast, actions[0])
740 def get_subscribed_podcast_ids(self):
741 from mygpo.db.couchdb.podcast_state import get_subscribed_podcast_states_by_device
742 states = get_subscribed_podcast_states_by_device(self)
743 return [state.podcast for state in states]
745 def get_subscribed_podcasts(self):
746 """ Returns all subscribed podcasts for the device
748 The attribute "url" contains the URL that was used when subscribing to
749 the podcast """
750 from mygpo.db.couchdb.podcast_state import get_subscribed_podcast_states_by_device
751 states = get_subscribed_podcast_states_by_device(self)
752 return podcasts_for_states(states)
756 def __str__(self):
757 return '{} ({})'.format(self.name.encode('ascii', errors='replace'),
758 self.uid.encode('ascii', errors='replace'))
760 def __unicode__(self):
761 return u'{} ({})'.format(self.name, self.uid)
764 class Device(Document, SettingsMixin):
765 id = StringProperty(default=lambda: uuid.uuid4().hex)
766 oldid = IntegerProperty(required=False)
767 uid = StringProperty(required=True)
768 name = StringProperty(required=True, default='New Device')
769 type = StringProperty(required=True, default='other')
770 deleted = BooleanProperty(default=False)
771 user_agent = StringProperty()
775 def __hash__(self):
776 return hash(frozenset([self.id, self.uid, self.name, self.type, self.deleted]))
779 def __eq__(self, other):
780 return self.id == other.id
783 def __repr__(self):
784 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
788 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
789 'publisher_update_token', 'userpage_token')
792 class TokenException(Exception):
793 pass
796 class User(BaseUser, SettingsMixin):
797 oldid = IntegerProperty()
798 devices = SchemaListProperty(Device)
799 published_objects = StringListProperty()
800 deleted = BooleanProperty(default=False)
801 suggestions_up_to_date = BooleanProperty(default=False)
802 twitter = StringProperty()
803 about = StringProperty()
804 google_email = StringProperty()
806 # token for accessing subscriptions of this use
807 subscriptions_token = StringProperty(default=None)
809 # token for accessing the favorite-episodes feed of this user
810 favorite_feeds_token = StringProperty(default=None)
812 # token for automatically updating feeds published by this user
813 publisher_update_token = StringProperty(default=None)
815 # token for accessing the userpage of this user
816 userpage_token = StringProperty(default=None)
818 class Meta:
819 app_label = 'users'
822 def create_new_token(self, token_name, length=32):
823 """ creates a new random token """
825 if token_name not in TOKEN_NAMES:
826 raise TokenException('Invalid token name %s' % token_name)
828 token = "".join(random.sample(string.letters+string.digits, length))
829 setattr(self, token_name, token)
831 @property
832 def active_devices(self):
833 not_deleted = lambda d: not d.deleted
834 return filter(not_deleted, self.devices)
837 @property
838 def inactive_devices(self):
839 deleted = lambda d: d.deleted
840 return filter(deleted, self.devices)
843 def get_devices_by_id(self, device_ids=None):
844 """ Returns a dict of {devices_id: device} """
845 if device_ids is None:
846 # return all devices
847 devices = self.devices
848 else:
849 devices = self.get_devices(device_ids)
851 return {device.id: device for device in devices}
854 def get_device(self, id):
856 if not hasattr(self, '__device_by_id'):
857 self.__devices_by_id = self.get_devices_by_id()
859 return self.__devices_by_id.get(id, None)
862 def get_devices(self, ids):
863 return filter(None, (self.get_device(dev_id) for dev_id in ids))
866 def get_device_by_uid(self, uid, only_active=True):
868 if not hasattr(self, '__devices_by_uio'):
869 self.__devices_by_uid = dict( (d.uid, d) for d in self.devices)
871 try:
872 device = self.__devices_by_uid[uid]
874 if only_active and device.deleted:
875 raise DeviceDeletedException(
876 'Device with UID %s is deleted' % uid)
878 return device
880 except KeyError as e:
881 raise DeviceDoesNotExist('There is no device with UID %s' % uid)
884 def remove_device(self, device):
885 devices = list(self.devices)
886 ids = [x.id for x in devices]
887 if not device.id in ids:
888 return
890 index = ids.index(device.id)
891 devices.pop(index)
892 self.devices = devices
894 if self.is_synced(device):
895 device.stop_sync()
897 def get_subscriptions_by_device(self, public=None):
898 from mygpo.db.couchdb.podcast_state import subscriptions_by_user
899 get_dev = itemgetter(2)
900 groups = collections.defaultdict(list)
901 subscriptions = subscriptions_by_user(self, public=public)
902 subscriptions = sorted(subscriptions, key=get_dev)
904 for public, podcast_id, device_id in subscriptions:
905 groups[device_id].append(podcast_id)
907 return groups
909 def get_subscribed_podcast_ids(self, public=None):
910 from mygpo.db.couchdb.podcast_state import get_subscribed_podcast_states_by_user
911 states = get_subscribed_podcast_states_by_user(self, public)
912 return [state.podcast for state in states]
916 def get_subscription_history(self, device_id=None, reverse=False, public=None):
917 """ Returns chronologically ordered subscription history entries
919 Setting device_id restricts the actions to a certain device
922 from mygpo.db.couchdb.podcast_state import podcast_states_for_user, \
923 podcast_states_for_device
925 def action_iter(state):
926 for action in sorted(state.actions, reverse=reverse):
927 if device_id is not None and device_id != action.device:
928 continue
930 if public is not None and state.is_public() != public:
931 continue
933 entry = HistoryEntry()
934 entry.timestamp = action.timestamp
935 entry.action = action.action
936 entry.podcast_id = state.podcast
937 entry.device_id = action.device
938 yield entry
940 if device_id is None:
941 podcast_states = podcast_states_for_user(self)
942 else:
943 podcast_states = podcast_states_for_device(device_id)
945 # create an action_iter for each PodcastUserState
946 subscription_action_lists = [action_iter(x) for x in podcast_states]
948 action_cmp_key = lambda x: x.timestamp
950 # Linearize their subscription-actions
951 return linearize(action_cmp_key, subscription_action_lists, reverse)
954 def get_global_subscription_history(self, public=None):
955 """ Actions that added/removed podcasts from the subscription list
957 Returns an iterator of all subscription actions that either
958 * added subscribed a podcast that hasn't been subscribed directly
959 before the action (but could have been subscribed) earlier
960 * removed a subscription of the podcast is not longer subscribed
961 after the action
964 subscriptions = collections.defaultdict(int)
966 for entry in self.get_subscription_history(public=public):
967 if entry.action == 'subscribe':
968 subscriptions[entry.podcast_id] += 1
970 # a new subscription has been added
971 if subscriptions[entry.podcast_id] == 1:
972 yield entry
974 elif entry.action == 'unsubscribe':
975 subscriptions[entry.podcast_id] -= 1
977 # the last subscription has been removed
978 if subscriptions[entry.podcast_id] == 0:
979 yield entry
983 def get_newest_episodes(self, max_date, max_per_podcast=5):
984 """ Returns the newest episodes of all subscribed podcasts
986 Only max_per_podcast episodes per podcast are loaded. Episodes with
987 release dates above max_date are discarded.
989 This method returns a generator that produces the newest episodes.
991 The number of required DB queries is equal to the number of (distinct)
992 podcasts of all consumed episodes (max: number of subscribed podcasts),
993 plus a constant number of initial queries (when the first episode is
994 consumed). """
996 cmp_key = lambda episode: episode.released or datetime(2000, 01, 01)
998 podcasts = list(self.get_subscribed_podcasts())
999 podcasts = filter(lambda p: p.latest_episode_timestamp, podcasts)
1000 podcasts = sorted(podcasts, key=lambda p: p.latest_episode_timestamp,
1001 reverse=True)
1003 podcast_dict = dict((p.get_id(), p) for p in podcasts)
1005 # contains the un-yielded episodes, newest first
1006 episodes = []
1008 for podcast in podcasts:
1010 yielded_episodes = 0
1012 for episode in episodes:
1013 # determine for which episodes there won't be a new episodes
1014 # that is newer; those can be yielded
1015 if episode.released > podcast.latest_episode_timestamp:
1016 p = podcast_dict.get(episode.podcast, None)
1017 yield proxy_object(episode, podcast=p)
1018 yielded_episodes += 1
1019 else:
1020 break
1022 # remove the episodes that have been yielded before
1023 episodes = episodes[yielded_episodes:]
1025 # fetch and merge episodes for the next podcast
1026 # TODO: max_per_podcast
1027 new_episodes = podcast.episode_set.filter(release__isnull=False,
1028 released__lt=max_date)
1029 new_episodes = new_episodes[:max_per_podcast]
1030 episodes = sorted(episodes+new_episodes, key=cmp_key, reverse=True)
1033 # yield the remaining episodes
1034 for episode in episodes:
1035 podcast = podcast_dict.get(episode.podcast, None)
1036 yield proxy_object(episode, podcast=podcast)
1039 def __eq__(self, other):
1040 if not other:
1041 return False
1043 # ensure that other isn't AnonymousUser
1044 return other.is_authenticated() and self._id == other._id
1047 def __ne__(self, other):
1048 return not(self == other)
1051 def __repr__(self):
1052 return 'User %s' % self._id
1055 class History(object):
1057 def __init__(self, user, device):
1058 self.user = user
1059 self.device = device
1062 def __getitem__(self, key):
1064 if isinstance(key, slice):
1065 start = key.start or 0
1066 length = key.stop - start
1067 else:
1068 start = key
1069 length = 1
1071 if self.device:
1072 return device_history(self.user, self.device, start, length)
1074 else:
1075 return user_history(self.user, start, length)
1079 class HistoryEntry(object):
1080 """ A class that can represent subscription and episode actions """
1083 @classmethod
1084 def from_action_dict(cls, action):
1086 entry = HistoryEntry()
1088 if 'timestamp' in action:
1089 ts = action.pop('timestamp')
1090 entry.timestamp = dateutil.parser.parse(ts)
1092 for key, value in action.items():
1093 setattr(entry, key, value)
1095 return entry
1098 @property
1099 def playmark(self):
1100 return getattr(self, 'position', None)
1103 @classmethod
1104 def fetch_data(cls, user, entries,
1105 podcasts=None, episodes=None):
1106 """ Efficiently loads additional data for a number of entries """
1108 if podcasts is None:
1109 # load podcast data
1110 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
1111 podcast_ids = filter(None, podcast_ids)
1112 podcasts = Podcast.objects.filter(id__in=podcast_ids)\
1113 .prefetch_related('slugs')
1114 podcasts = {podcast.id.hex: podcast for podcast in podcasts}
1116 if episodes is None:
1117 # load episode data
1118 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
1119 episode_ids = filter(None, episode_ids)
1120 episodes = Episode.objects.filter(id__in=episode_ids)\
1121 .select_related('podcast')\
1122 .prefetch_related('slugs',
1123 'podcast__slugs')
1124 episodes = {episode.id.hex: episode for episode in episodes}
1126 # load device data
1127 # does not need pre-populated data because no db-access is required
1128 device_ids = [getattr(x, 'device_id', None) for x in entries]
1129 device_ids = filter(None, device_ids)
1130 devices = {client.id.hex: client for client in user.client_set.all()}
1133 for entry in entries:
1134 podcast_id = getattr(entry, 'podcast_id', None)
1135 entry.podcast = podcasts.get(podcast_id, None)
1137 episode_id = getattr(entry, 'episode_id', None)
1138 entry.episode = episodes.get(episode_id, None)
1140 if hasattr(entry, 'user'):
1141 entry.user = user
1143 device = devices.get(getattr(entry, 'device_id', None), None)
1144 entry.device = device
1147 return entries
1150 def create_missing_profile(sender, **kwargs):
1151 """ Creates a UserProfile if a User doesn't have one """
1152 user = kwargs['instance']
1154 if not hasattr(user, 'profile'):
1155 # TODO: remove uuid column once migration from CouchDB is complete
1156 import uuid
1157 profile = UserProfile.objects.create(user=user, uuid=uuid.uuid1())
1158 user.profile = profile