4 from datetime
import datetime
6 from itertools
import imap
7 from operator
import itemgetter
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
,
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 *
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):
59 class DeviceDeletedException(DeviceDoesNotExist
):
63 GroupedDevices
= collections
.namedtuple('GroupedDevices', 'is_synced devices')
66 class UIDValidator(RegexValidator
):
67 """ Validates that the Device UID conforms to the given regex """
69 message
= 'Invalid Device ID'
73 class UserProxyQuerySet(models
.QuerySet
):
75 def by_username_or_email(self
, username
, email
):
76 """ Queries for a User by username or email """
80 q |
= Q(username
=username
)
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()
111 self
.is_active
= True
114 self
.profile
.activation_key
= None
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()
129 for client
in clients
:
130 # check if we have just found a new group
131 if last_group
!= client
.sync_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
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
)
207 podcasts
= Podcast
.objects
.filter(id__in
=ids
).prefetch_related('slugs')
208 return filter(lambda x
: x
and x
.title
, podcasts
)
213 return super(Suggestions
, self
).__repr
__()
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
):
242 vals
= ('action', 'timestamp', 'device', 'started', 'playmark',
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
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')
282 return '%s-Action on %s at %s (in %s)' % \
283 (self
.action
, self
.device
, self
.timestamp
, self
._id
)
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()
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
)
344 return 'Episode-State %s (in %s)' % \
345 (self
.episode
, self
._id
)
347 def __eq__(self
, other
):
348 if not isinstance(other
, EpisodeUserState
):
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
374 return hash(self
.action
) + hash(self
.timestamp
) + hash(self
.device
)
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
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
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
451 then
= before
[-1] if before
else None
455 if now
.action
!= 'unsubscribe':
457 elif then
.action
!= now
.action
:
462 def get_subscribed_device_ids(self
):
463 """ device Ids on which the user subscribed to the podcast """
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
)
471 if action
.device
in devices
:
472 devices
.remove(action
.device
)
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:
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
492 return self
.get_wksetting(PUBLIC_SUB_PODCAST
)
495 def __eq__(self
, other
):
499 return self
.podcast
== other
.podcast
and \
500 self
.user
== other
.user
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
)
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
)
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
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
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
:
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
)
570 class Client(UUIDModel
):
571 """ A client application """
581 (DESKTOP
, _('Desktop')),
582 (LAPTOP
, _('Laptop')),
583 (MOBILE
, _('Cell phone')),
584 (SERVER
, _('Server')),
585 (TABLET
, _('Tablet')),
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
)
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)
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
:
626 ogroup
= other
.sync_group
627 Client
.objects
.filter(sync_group
=ogroup
)\
628 .update(sync_group
=self
.sync_group
)
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
639 elif self
.sync_group
is not None:
640 self
.sync_group
= other
.sync_group
643 elif other
.sync_group
is not None:
644 other
.sync_group
= self
.sync_group
648 """ Stop synchronisation with other clients """
651 logger
.info('Stopping synchronisation of %r', self
)
652 self
.sync_group
= None
655 clients
= Client
.objects
.filter(sync_group
=sg
)
656 logger
.info('%d other clients remaining in sync group', len(clients
))
659 logger
.info('Deleting sync group %r', sg
)
660 for client
in clients
:
661 client
.sync_group
= None
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 """
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
680 elif group
.is_synced
:
684 # every unsynced device is a sync-target
685 for dev
in group
.devices
:
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)
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)
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
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
))
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
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
)
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()
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
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):
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)
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
)
832 def active_devices(self
):
833 not_deleted
= lambda d
: not d
.deleted
834 return filter(not_deleted
, self
.devices
)
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:
847 devices
= self
.devices
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
)
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
)
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
:
890 index
= ids
.index(device
.id)
892 self
.devices
= devices
894 if self
.is_synced(device
):
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
)
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
:
930 if public
is not None and state
.is_public() != public
:
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
940 if device_id
is None:
941 podcast_states
= podcast_states_for_user(self
)
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
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:
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:
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
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
,
1003 podcast_dict
= dict((p
.get_id(), p
) for p
in podcasts
)
1005 # contains the un-yielded episodes, newest first
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
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
):
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
)
1052 return 'User %s' % self
._id
1055 class History(object):
1057 def __init__(self
, user
, device
):
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
1072 return device_history(self
.user
, self
.device
, start
, length
)
1075 return user_history(self
.user
, start
, length
)
1079 class HistoryEntry(object):
1080 """ A class that can represent subscription and episode actions """
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
)
1100 return getattr(self
, 'position', None)
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:
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:
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',
1124 episodes
= {episode
.id.hex: episode
for episode
in episodes
}
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'):
1143 device
= devices
.get(getattr(entry
, 'device_id', None), None)
1144 entry
.device
= device
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
1157 profile
= UserProfile
.objects
.create(user
=user
, uuid
=uuid
.uuid1())
1158 user
.profile
= profile