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
.db
import transaction
, models
15 from django
.db
.models
import Q
16 from django
.contrib
.auth
.models
import User
as DjangoUser
17 from django
.contrib
.auth
import get_user_model
18 from django
.utils
.translation
import ugettext_lazy
as _
19 from django
.conf
import settings
20 from django
.core
.cache
import cache
22 from django_couchdb_utils
.registration
.models
import User
as BaseUser
24 from mygpo
.core
.models
import (TwitterModel
, UUIDModel
, SettingsModel
,
26 from mygpo
.podcasts
.models
import Podcast
, Episode
27 from mygpo
.utils
import linearize
28 from mygpo
.core
.proxy
import DocumentABCMeta
, proxy_object
29 from mygpo
.decorators
import repeat_on_conflict
30 from mygpo
.users
.ratings
import RatingMixin
31 from mygpo
.users
.sync
import SyncedDevicesMixin
, get_grouped_devices
32 from mygpo
.users
.subscriptions
import subscription_changes
, podcasts_for_states
33 from mygpo
.users
.settings
import FAV_FLAG
, PUBLIC_SUB_PODCAST
, SettingsMixin
34 from mygpo
.db
.couchdb
.user
import user_history
, device_history
, \
35 create_missing_user_tokens
37 # make sure this code is executed at startup
38 from mygpo
.users
.signals
import *
41 RE_DEVICE_UID
= re
.compile(r
'^[\w.-]+$')
43 # TODO: derive from ValidationException?
44 class InvalidEpisodeActionAttributes(ValueError):
45 """ raised when the attribues of an episode action fail validation """
48 class SubscriptionException(Exception):
49 """ raised when a subscription can not be modified """
52 class DeviceUIDException(Exception):
56 class DeviceDoesNotExist(Exception):
60 class DeviceDeletedException(DeviceDoesNotExist
):
65 class UserProxyQuerySet(models
.QuerySet
):
67 def by_username_or_email(self
, username
, email
):
68 """ Queries for a User by username or email """
72 q |
= Q(username
=username
)
83 class UserProxyManager(GenericManager
):
84 """ Manager for the UserProxy model """
86 def get_queryset(self
):
87 return UserProxyQuerySet(self
.model
, using
=self
._db
)
91 class UserProxy(DjangoUser
):
93 objects
= UserProxyManager()
99 class UserProfile(TwitterModel
, SettingsModel
):
100 """ Additional information stored for a User """
102 # the user to which this profile belongs
103 user
= models
.OneToOneField(settings
.AUTH_USER_MODEL
,
104 related_name
='profile')
106 # the CouchDB _id of the user
107 uuid
= UUIDField(unique
=True)
109 # if False, suggestions should be updated
110 suggestions_up_to_date
= models
.BooleanField(default
=False)
112 # text the user entered about himeself
113 about
= models
.TextField(blank
=True)
115 # Google email address for OAuth login
116 google_email
= models
.CharField(max_length
=100, null
=True)
118 # token for accessing subscriptions of this use
119 subscriptions_token
= models
.CharField(max_length
=32, null
=True)
121 # token for accessing the favorite-episodes feed of this user
122 favorite_feeds_token
= models
.CharField(max_length
=32, null
=True)
124 # token for automatically updating feeds published by this user
125 publisher_update_token
= models
.CharField(max_length
=32, null
=True)
127 # token for accessing the userpage of this user
128 userpage_token
= models
.CharField(max_length
=32, null
=True)
130 # key for activating the user
131 activation_key
= models
.CharField(max_length
=40, null
=True)
133 def get_token(self
, token_name
):
134 """ returns a token, and generate those that are still missing """
138 if token_name
not in TOKEN_NAMES
:
139 raise TokenException('Invalid token name %s' % token_name
)
141 create_missing_user_tokens(self
)
143 return getattr(self
, token_name
)
146 class Suggestions(Document
, RatingMixin
):
147 user
= StringProperty(required
=True)
148 user_oldid
= IntegerProperty()
149 podcasts
= StringListProperty()
150 blacklist
= StringListProperty()
153 def get_podcasts(self
, count
=None):
154 User
= get_user_model()
155 user
= User
.objects
.get(profile__uuid
=self
.user
)
156 subscriptions
= user
.get_subscribed_podcast_ids()
158 ids
= filter(lambda x
: not x
in self
.blacklist
+ subscriptions
, self
.podcasts
)
162 podcasts
= Podcast
.objects
.filter(id__in
=ids
).prefetch_related('slugs')
163 return filter(lambda x
: x
and x
.title
, podcasts
)
168 return super(Suggestions
, self
).__repr
__()
170 return '%d Suggestions for %s (%s)' % \
171 (len(self
.podcasts
), self
.user
, self
._id
)
174 class EpisodeAction(DocumentSchema
):
176 One specific action to an episode. Must
177 always be part of a EpisodeUserState
180 action
= StringProperty(required
=True)
182 # walltime of the event (assigned by the uploading client, defaults to now)
183 timestamp
= DateTimeProperty(required
=True, default
=datetime
.utcnow
)
185 # upload time of the event
186 upload_timestamp
= IntegerProperty(required
=True)
188 device_oldid
= IntegerProperty(required
=False)
189 device
= StringProperty()
190 started
= IntegerProperty()
191 playmark
= IntegerProperty()
192 total
= IntegerProperty()
194 def __eq__(self
, other
):
195 if not isinstance(other
, EpisodeAction
):
197 vals
= ('action', 'timestamp', 'device', 'started', 'playmark',
199 return all([getattr(self
, v
, None) == getattr(other
, v
, None) for v
in vals
])
202 def to_history_entry(self
):
203 entry
= HistoryEntry()
204 entry
.action
= self
.action
205 entry
.timestamp
= self
.timestamp
206 entry
.device_id
= self
.device
207 entry
.started
= self
.started
208 entry
.position
= self
.playmark
209 entry
.total
= self
.total
214 def validate_time_values(self
):
215 """ Validates allowed combinations of time-values """
217 PLAY_ACTION_KEYS
= ('playmark', 'started', 'total')
219 # Key found, but must not be supplied (no play action!)
220 if self
.action
!= 'play':
221 for key
in PLAY_ACTION_KEYS
:
222 if getattr(self
, key
, None) is not None:
223 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key
)
225 # Sanity check: If started or total are given, require playmark
226 if ((self
.started
is not None) or (self
.total
is not None)) and \
227 self
.playmark
is None:
228 raise InvalidEpisodeActionAttributes('started and total require position')
230 # Sanity check: total and playmark can only appear together
231 if ((self
.total
is not None) or (self
.started
is not None)) and \
232 ((self
.total
is None) or (self
.started
is None)):
233 raise InvalidEpisodeActionAttributes('total and started can only appear together')
237 return '%s-Action on %s at %s (in %s)' % \
238 (self
.action
, self
.device
, self
.timestamp
, self
._id
)
242 return hash(frozenset([self
.action
, self
.timestamp
, self
.device
,
243 self
.started
, self
.playmark
, self
.total
]))
246 class Chapter(Document
):
247 """ A user-entered episode chapter """
249 device
= StringProperty()
250 created
= DateTimeProperty()
251 start
= IntegerProperty(required
=True)
252 end
= IntegerProperty(required
=True)
253 label
= StringProperty()
254 advertisement
= BooleanProperty()
258 return '<%s %s (%d-%d)>' % (self
.__class
__.__name
__, self
.label
,
259 self
.start
, self
.end
)
262 class EpisodeUserState(Document
, SettingsMixin
):
264 Contains everything a user has done with an Episode
267 episode
= StringProperty(required
=True)
268 actions
= SchemaListProperty(EpisodeAction
)
269 user_oldid
= IntegerProperty()
270 user
= StringProperty(required
=True)
271 ref_url
= StringProperty(required
=True)
272 podcast_ref_url
= StringProperty(required
=True)
273 merged_ids
= StringListProperty()
274 chapters
= SchemaListProperty(Chapter
)
275 podcast
= StringProperty(required
=True)
279 def add_actions(self
, actions
):
280 map(EpisodeAction
.validate_time_values
, actions
)
281 self
.actions
= list(self
.actions
) + actions
282 self
.actions
= list(set(self
.actions
))
283 self
.actions
= sorted(self
.actions
, key
=lambda x
: x
.timestamp
)
286 def is_favorite(self
):
287 return self
.get_wksetting(FAV_FLAG
)
290 def set_favorite(self
, set_to
=True):
291 self
.settings
[FAV_FLAG
.name
] = set_to
294 def get_history_entries(self
):
295 return imap(EpisodeAction
.to_history_entry
, self
.actions
)
299 return 'Episode-State %s (in %s)' % \
300 (self
.episode
, self
._id
)
302 def __eq__(self
, other
):
303 if not isinstance(other
, EpisodeUserState
):
306 return (self
.episode
== other
.episode
and
307 self
.user
== other
.user
)
311 class SubscriptionAction(Document
):
312 action
= StringProperty()
313 timestamp
= DateTimeProperty(default
=datetime
.utcnow
)
314 device
= StringProperty()
317 __metaclass__
= DocumentABCMeta
320 def __cmp__(self
, other
):
321 return cmp(self
.timestamp
, other
.timestamp
)
323 def __eq__(self
, other
):
324 return self
.action
== other
.action
and \
325 self
.timestamp
== other
.timestamp
and \
326 self
.device
== other
.device
329 return hash(self
.action
) + hash(self
.timestamp
) + hash(self
.device
)
332 return '<SubscriptionAction %s on %s at %s>' % (
333 self
.action
, self
.device
, self
.timestamp
)
336 class PodcastUserState(Document
, SettingsMixin
):
338 Contains everything that a user has done
339 with a specific podcast and all its episodes
342 podcast
= StringProperty(required
=True)
343 user_oldid
= IntegerProperty()
344 user
= StringProperty(required
=True)
345 actions
= SchemaListProperty(SubscriptionAction
)
346 tags
= StringListProperty()
347 ref_url
= StringProperty(required
=True)
348 disabled_devices
= StringListProperty()
349 merged_ids
= StringListProperty()
352 def remove_device(self
, device
):
354 Removes all actions from the podcast state that refer to the
357 self
.actions
= filter(lambda a
: a
.device
!= device
.id, self
.actions
)
360 def subscribe(self
, device
):
361 action
= SubscriptionAction()
362 action
.action
= 'subscribe'
363 action
.device
= device
.id.hex
364 self
.add_actions([action
])
367 def unsubscribe(self
, device
):
368 action
= SubscriptionAction()
369 action
.action
= 'unsubscribe'
370 action
.device
= device
.id.hex
371 self
.add_actions([action
])
374 def add_actions(self
, actions
):
375 self
.actions
= list(set(self
.actions
+ actions
))
376 self
.actions
= sorted(self
.actions
)
379 def add_tags(self
, tags
):
380 self
.tags
= list(set(self
.tags
+ tags
))
383 def set_device_state(self
, devices
):
384 disabled_devices
= [device
.id for device
in devices
if device
.deleted
]
385 self
.disabled_devices
= disabled_devices
388 def get_change_between(self
, device_id
, since
, until
):
390 Returns the change of the subscription status for the given device
391 between the two timestamps.
393 The change is given as either 'subscribe' (the podcast has been
394 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
398 device_actions
= filter(lambda x
: x
.device
== device_id
, self
.actions
)
399 before
= filter(lambda x
: x
.timestamp
<= since
, device_actions
)
400 after
= filter(lambda x
: x
.timestamp
<= until
, device_actions
)
402 # nothing happened, so there can be no change
406 then
= before
[-1] if before
else None
410 if now
.action
!= 'unsubscribe':
412 elif then
.action
!= now
.action
:
417 def get_subscribed_device_ids(self
):
418 """ device Ids on which the user subscribed to the podcast """
421 for action
in self
.actions
:
422 if action
.action
== "subscribe":
423 if not action
.device
in self
.disabled_devices
:
424 devices
.add(action
.device
)
426 if action
.device
in devices
:
427 devices
.remove(action
.device
)
432 def is_subscribed_on(self
, device
):
433 """ checks if the podcast is subscribed on the given device """
435 for action
in reversed(self
.actions
):
436 if not action
.device
== device
.id:
439 # we only need to check the latest action for the device
440 return (action
.action
== 'subscribe')
442 # we haven't found any matching action
447 return self
.get_wksetting(PUBLIC_SUB_PODCAST
)
450 def __eq__(self
, other
):
454 return self
.podcast
== other
.podcast
and \
455 self
.user
== other
.user
458 return 'Podcast %s for User %s (%s)' % \
459 (self
.podcast
, self
.user
, self
._id
)
462 class SyncGroup(models
.Model
):
463 """ A group of Clients """
465 user
= models
.ForeignKey(settings
.AUTH_USER_MODEL
)
468 class Client(UUIDModel
):
469 """ A client application """
479 (DESKTOP
, _('Desktop')),
480 (LAPTOP
, _('Laptop')),
481 (MOBILE
, _('Cell phone')),
482 (SERVER
, _('Server')),
483 (TABLET
, _('Tablet')),
487 # User-assigned ID; must be unique for the user
488 uid
= models
.CharField(max_length
=64)
490 # the user to which the Client belongs
491 user
= models
.ForeignKey(settings
.AUTH_USER_MODEL
)
494 name
= models
.CharField(max_length
=100, default
='New Device')
496 # one of several predefined types
497 type = models
.CharField(max_length
=max(len(k
) for k
, v
in TYPES
),
498 choices
=TYPES
, default
=OTHER
)
500 # indicates if the user has deleted the client
501 deleted
= models
.BooleanField(default
=False)
503 # user-agent string from which the Client was last accessed (for writing)
504 user_agent
= models
.CharField(max_length
=300, null
=True, blank
=True)
506 sync_group
= models
.ForeignKey(SyncGroup
, null
=True)
514 def sync_with(self
, other
):
515 """ Puts two devices in a common sync group"""
517 if self
.user
!= other
.user
:
518 raise ValueError('the devices do not belong to the user')
520 if self
.sync_group
is not None and \
521 other
.sync_group
is not None and \
522 self
.sync_group
!= other
.sync_group
:
524 ogroup
= other
.sync_group
525 Client
.objects
.filter(sync_group
=ogroup
)\
526 .update(sync_group
=self
.sync_group
)
529 elif self
.sync_group
is None and \
530 other
.sync_group
is None:
531 sg
= SyncGroup
.objects
.create(user
=self
.user
)
532 other
.sync_group
= sg
537 elif self
.sync_group
is not None:
538 self
.sync_group
= other
.sync_group
541 elif other
.sync_group
is not None:
542 other
.sync_group
= self
.sync_group
545 def get_sync_targets(self
):
546 """ Returns the devices and groups with which the device can be synced
548 Groups are represented as lists of devices """
552 for group
in get_grouped_devices(self
.user
):
554 if self
in group
.devices
:
555 # the device's group can't be a sync-target
558 elif group
.is_synced
:
562 # every unsynced device is a sync-target
563 for dev
in group
.devices
:
567 def get_subscription_changes(self
, since
, until
):
569 Returns the subscription changes for the device as two lists.
570 The first lists contains the Ids of the podcasts that have been
571 subscribed to, the second list of those that have been unsubscribed
575 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_device
576 podcast_states
= podcast_states_for_device(self
.id.hex)
577 return subscription_changes(self
.id.hex, podcast_states
, since
, until
)
579 def get_latest_changes(self
):
580 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_device
581 podcast_states
= podcast_states_for_device(self
.id.hex)
582 for p_state
in podcast_states
:
583 actions
= filter(lambda x
: x
.device
== self
.id.hex, reversed(p_state
.actions
))
585 yield (p_state
.podcast
, actions
[0])
587 def get_subscribed_podcast_ids(self
):
588 from mygpo
.db
.couchdb
.podcast_state
import get_subscribed_podcast_states_by_device
589 states
= get_subscribed_podcast_states_by_device(self
)
590 return [state
.podcast
for state
in states
]
592 def get_subscribed_podcasts(self
):
593 """ Returns all subscribed podcasts for the device
595 The attribute "url" contains the URL that was used when subscribing to
597 from mygpo
.db
.couchdb
.podcast_state
import get_subscribed_podcast_states_by_device
598 states
= get_subscribed_podcast_states_by_device(self
)
599 return podcasts_for_states(states
)
604 return '{} ({})'.format(self
.name
.encode('ascii', errors
='replace'),
605 self
.uid
.encode('ascii', errors
='replace'))
607 def __unicode__(self
):
608 return u
'{} ({})'.format(self
.name
, self
.uid
)
611 class Device(Document
, SettingsMixin
):
612 id = StringProperty(default
=lambda: uuid
.uuid4().hex)
613 oldid
= IntegerProperty(required
=False)
614 uid
= StringProperty(required
=True)
615 name
= StringProperty(required
=True, default
='New Device')
616 type = StringProperty(required
=True, default
='other')
617 deleted
= BooleanProperty(default
=False)
618 user_agent
= StringProperty()
623 return hash(frozenset([self
.id, self
.uid
, self
.name
, self
.type, self
.deleted
]))
626 def __eq__(self
, other
):
627 return self
.id == other
.id
631 return '<{cls} {id}>'.format(cls
=self
.__class
__.__name
__, id=self
.id)
635 TOKEN_NAMES
= ('subscriptions_token', 'favorite_feeds_token',
636 'publisher_update_token', 'userpage_token')
639 class TokenException(Exception):
643 class User(BaseUser
, SyncedDevicesMixin
, SettingsMixin
):
644 oldid
= IntegerProperty()
645 devices
= SchemaListProperty(Device
)
646 published_objects
= StringListProperty()
647 deleted
= BooleanProperty(default
=False)
648 suggestions_up_to_date
= BooleanProperty(default
=False)
649 twitter
= StringProperty()
650 about
= StringProperty()
651 google_email
= StringProperty()
653 # token for accessing subscriptions of this use
654 subscriptions_token
= StringProperty(default
=None)
656 # token for accessing the favorite-episodes feed of this user
657 favorite_feeds_token
= StringProperty(default
=None)
659 # token for automatically updating feeds published by this user
660 publisher_update_token
= StringProperty(default
=None)
662 # token for accessing the userpage of this user
663 userpage_token
= StringProperty(default
=None)
669 def create_new_token(self
, token_name
, length
=32):
670 """ creates a new random token """
672 if token_name
not in TOKEN_NAMES
:
673 raise TokenException('Invalid token name %s' % token_name
)
675 token
= "".join(random
.sample(string
.letters
+string
.digits
, length
))
676 setattr(self
, token_name
, token
)
679 def active_devices(self
):
680 not_deleted
= lambda d
: not d
.deleted
681 return filter(not_deleted
, self
.devices
)
685 def inactive_devices(self
):
686 deleted
= lambda d
: d
.deleted
687 return filter(deleted
, self
.devices
)
690 def get_devices_by_id(self
, device_ids
=None):
691 """ Returns a dict of {devices_id: device} """
692 if device_ids
is None:
694 devices
= self
.devices
696 devices
= self
.get_devices(device_ids
)
698 return {device
.id: device
for device
in devices
}
701 def get_device(self
, id):
703 if not hasattr(self
, '__device_by_id'):
704 self
.__devices
_by
_id
= self
.get_devices_by_id()
706 return self
.__devices
_by
_id
.get(id, None)
709 def get_devices(self
, ids
):
710 return filter(None, (self
.get_device(dev_id
) for dev_id
in ids
))
713 def get_device_by_uid(self
, uid
, only_active
=True):
715 if not hasattr(self
, '__devices_by_uio'):
716 self
.__devices
_by
_uid
= dict( (d
.uid
, d
) for d
in self
.devices
)
719 device
= self
.__devices
_by
_uid
[uid
]
721 if only_active
and device
.deleted
:
722 raise DeviceDeletedException(
723 'Device with UID %s is deleted' % uid
)
727 except KeyError as e
:
728 raise DeviceDoesNotExist('There is no device with UID %s' % uid
)
731 def set_device(self
, device
):
733 if not RE_DEVICE_UID
.match(device
.uid
):
734 raise DeviceUIDException(u
"'{uid} is not a valid device ID".format(
737 devices
= list(self
.devices
)
738 ids
= [x
.id for x
in devices
]
739 if not device
.id in ids
:
740 devices
.append(device
)
741 self
.devices
= devices
744 index
= ids
.index(device
.id)
746 devices
.insert(index
, device
)
747 self
.devices
= devices
750 def remove_device(self
, device
):
751 devices
= list(self
.devices
)
752 ids
= [x
.id for x
in devices
]
753 if not device
.id in ids
:
756 index
= ids
.index(device
.id)
758 self
.devices
= devices
760 if self
.is_synced(device
):
761 self
.unsync_device(device
)
764 def get_subscriptions_by_device(self
, public
=None):
765 from mygpo
.db
.couchdb
.podcast_state
import subscriptions_by_user
766 get_dev
= itemgetter(2)
767 groups
= collections
.defaultdict(list)
768 subscriptions
= subscriptions_by_user(self
, public
=public
)
769 subscriptions
= sorted(subscriptions
, key
=get_dev
)
771 for public
, podcast_id
, device_id
in subscriptions
:
772 groups
[device_id
].append(podcast_id
)
776 def get_subscribed_podcast_ids(self
, public
=None):
777 from mygpo
.db
.couchdb
.podcast_state
import get_subscribed_podcast_states_by_user
778 states
= get_subscribed_podcast_states_by_user(self
, public
)
779 return [state
.podcast
for state
in states
]
783 def get_subscription_history(self
, device_id
=None, reverse
=False, public
=None):
784 """ Returns chronologically ordered subscription history entries
786 Setting device_id restricts the actions to a certain device
789 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_user
, \
790 podcast_states_for_device
792 def action_iter(state
):
793 for action
in sorted(state
.actions
, reverse
=reverse
):
794 if device_id
is not None and device_id
!= action
.device
:
797 if public
is not None and state
.is_public() != public
:
800 entry
= HistoryEntry()
801 entry
.timestamp
= action
.timestamp
802 entry
.action
= action
.action
803 entry
.podcast_id
= state
.podcast
804 entry
.device_id
= action
.device
807 if device_id
is None:
808 podcast_states
= podcast_states_for_user(self
)
810 podcast_states
= podcast_states_for_device(device_id
)
812 # create an action_iter for each PodcastUserState
813 subscription_action_lists
= [action_iter(x
) for x
in podcast_states
]
815 action_cmp_key
= lambda x
: x
.timestamp
817 # Linearize their subscription-actions
818 return linearize(action_cmp_key
, subscription_action_lists
, reverse
)
821 def get_global_subscription_history(self
, public
=None):
822 """ Actions that added/removed podcasts from the subscription list
824 Returns an iterator of all subscription actions that either
825 * added subscribed a podcast that hasn't been subscribed directly
826 before the action (but could have been subscribed) earlier
827 * removed a subscription of the podcast is not longer subscribed
831 subscriptions
= collections
.defaultdict(int)
833 for entry
in self
.get_subscription_history(public
=public
):
834 if entry
.action
== 'subscribe':
835 subscriptions
[entry
.podcast_id
] += 1
837 # a new subscription has been added
838 if subscriptions
[entry
.podcast_id
] == 1:
841 elif entry
.action
== 'unsubscribe':
842 subscriptions
[entry
.podcast_id
] -= 1
844 # the last subscription has been removed
845 if subscriptions
[entry
.podcast_id
] == 0:
850 def get_newest_episodes(self
, max_date
, max_per_podcast
=5):
851 """ Returns the newest episodes of all subscribed podcasts
853 Only max_per_podcast episodes per podcast are loaded. Episodes with
854 release dates above max_date are discarded.
856 This method returns a generator that produces the newest episodes.
858 The number of required DB queries is equal to the number of (distinct)
859 podcasts of all consumed episodes (max: number of subscribed podcasts),
860 plus a constant number of initial queries (when the first episode is
863 cmp_key
= lambda episode
: episode
.released
or datetime(2000, 01, 01)
865 podcasts
= list(self
.get_subscribed_podcasts())
866 podcasts
= filter(lambda p
: p
.latest_episode_timestamp
, podcasts
)
867 podcasts
= sorted(podcasts
, key
=lambda p
: p
.latest_episode_timestamp
,
870 podcast_dict
= dict((p
.get_id(), p
) for p
in podcasts
)
872 # contains the un-yielded episodes, newest first
875 for podcast
in podcasts
:
879 for episode
in episodes
:
880 # determine for which episodes there won't be a new episodes
881 # that is newer; those can be yielded
882 if episode
.released
> podcast
.latest_episode_timestamp
:
883 p
= podcast_dict
.get(episode
.podcast
, None)
884 yield proxy_object(episode
, podcast
=p
)
885 yielded_episodes
+= 1
889 # remove the episodes that have been yielded before
890 episodes
= episodes
[yielded_episodes
:]
892 # fetch and merge episodes for the next podcast
893 # TODO: max_per_podcast
894 new_episodes
= podcast
.episode_set
.filter(release__isnull
=False,
895 released__lt
=max_date
)
896 new_episodes
= new_episodes
[:max_per_podcast
]
897 episodes
= sorted(episodes
+new_episodes
, key
=cmp_key
, reverse
=True)
900 # yield the remaining episodes
901 for episode
in episodes
:
902 podcast
= podcast_dict
.get(episode
.podcast
, None)
903 yield proxy_object(episode
, podcast
=podcast
)
906 def __eq__(self
, other
):
910 # ensure that other isn't AnonymousUser
911 return other
.is_authenticated() and self
._id
== other
._id
914 def __ne__(self
, other
):
915 return not(self
== other
)
919 return 'User %s' % self
._id
922 class History(object):
924 def __init__(self
, user
, device
):
929 def __getitem__(self
, key
):
931 if isinstance(key
, slice):
932 start
= key
.start
or 0
933 length
= key
.stop
- start
939 return device_history(self
.user
, self
.device
, start
, length
)
942 return user_history(self
.user
, start
, length
)
946 class HistoryEntry(object):
947 """ A class that can represent subscription and episode actions """
951 def from_action_dict(cls
, action
):
953 entry
= HistoryEntry()
955 if 'timestamp' in action
:
956 ts
= action
.pop('timestamp')
957 entry
.timestamp
= dateutil
.parser
.parse(ts
)
959 for key
, value
in action
.items():
960 setattr(entry
, key
, value
)
967 return getattr(self
, 'position', None)
971 def fetch_data(cls
, user
, entries
,
972 podcasts
=None, episodes
=None):
973 """ Efficiently loads additional data for a number of entries """
977 podcast_ids
= [getattr(x
, 'podcast_id', None) for x
in entries
]
978 podcast_ids
= filter(None, podcast_ids
)
979 podcasts
= Podcast
.objects
.filter(id__in
=podcast_ids
)\
980 .prefetch_related('slugs')
981 podcasts
= {podcast
.id.hex: podcast
for podcast
in podcasts
}
985 episode_ids
= [getattr(x
, 'episode_id', None) for x
in entries
]
986 episode_ids
= filter(None, episode_ids
)
987 episodes
= Episode
.objects
.filter(id__in
=episode_ids
)\
988 .select_related('podcast')\
989 .prefetch_related('slugs',
991 episodes
= {episode
.id.hex: episode
for episode
in episodes
}
994 # does not need pre-populated data because no db-access is required
995 device_ids
= [getattr(x
, 'device_id', None) for x
in entries
]
996 device_ids
= filter(None, device_ids
)
997 devices
= {client
.id.hex: client
for client
in user
.client_set
.all()}
1000 for entry
in entries
:
1001 podcast_id
= getattr(entry
, 'podcast_id', None)
1002 entry
.podcast
= podcasts
.get(podcast_id
, None)
1004 episode_id
= getattr(entry
, 'episode_id', None)
1005 entry
.episode
= episodes
.get(episode_id
, None)
1007 if hasattr(entry
, 'user'):
1010 device
= devices
.get(getattr(entry
, 'device_id', None), None)
1011 entry
.device
= device
1017 def create_missing_profile(sender
, **kwargs
):
1018 """ Creates a UserProfile if a User doesn't have one """
1019 user
= kwargs
['instance']
1021 if not hasattr(user
, 'profile'):
1022 # TODO: remove uuid column once migration from CouchDB is complete
1024 profile
= UserProfile
.objects
.create(user
=user
, uuid
=uuid
.uuid1())
1025 user
.profile
= profile