2 import uuid
, collections
3 from datetime
import datetime
5 from itertools
import imap
6 from operator
import itemgetter
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
.users
.settings
import FAV_FLAG
, PUBLIC_SUB_PODCAST
, SettingsMixin
23 from mygpo
.db
.couchdb
.podcast
import podcasts_by_id
, podcasts_to_dict
24 from mygpo
.db
.couchdb
.user
import user_history
, device_history
27 RE_DEVICE_UID
= re
.compile(r
'^[\w.-]+$')
30 class InvalidEpisodeActionAttributes(ValueError):
31 """ raised when the attribues of an episode action fail validation """
34 class DeviceUIDException(Exception):
38 class DeviceDoesNotExist(Exception):
42 class DeviceDeletedException(DeviceDoesNotExist
):
46 class Suggestions(Document
, RatingMixin
):
47 user
= StringProperty(required
=True)
48 user_oldid
= IntegerProperty()
49 podcasts
= StringListProperty()
50 blacklist
= StringListProperty()
53 def get_podcasts(self
, count
=None):
54 user
= User
.get(self
.user
)
55 subscriptions
= user
.get_subscribed_podcast_ids()
57 ids
= filter(lambda x
: not x
in self
.blacklist
+ subscriptions
, self
.podcasts
)
60 return filter(lambda x
: x
and x
.title
, podcasts_by_id(ids
))
65 return super(Suggestions
, self
).__repr
__()
67 return '%d Suggestions for %s (%s)' % \
68 (len(self
.podcasts
), self
.user
, self
._id
)
71 class EpisodeAction(DocumentSchema
):
73 One specific action to an episode. Must
74 always be part of a EpisodeUserState
77 action
= StringProperty(required
=True)
78 timestamp
= DateTimeProperty(required
=True, default
=datetime
.utcnow
)
79 device_oldid
= IntegerProperty(required
=False)
80 device
= StringProperty()
81 started
= IntegerProperty()
82 playmark
= IntegerProperty()
83 total
= IntegerProperty()
85 def __eq__(self
, other
):
86 if not isinstance(other
, EpisodeAction
):
88 vals
= ('action', 'timestamp', 'device', 'started', 'playmark',
90 return all([getattr(self
, v
, None) == getattr(other
, v
, None) for v
in vals
])
93 def to_history_entry(self
):
94 entry
= HistoryEntry()
95 entry
.action
= self
.action
96 entry
.timestamp
= self
.timestamp
97 entry
.device_id
= self
.device
98 entry
.started
= self
.started
99 entry
.position
= self
.playmark
100 entry
.total
= self
.total
105 def validate_time_values(self
):
106 """ Validates allowed combinations of time-values """
108 PLAY_ACTION_KEYS
= ('playmark', 'started', 'total')
110 # Key found, but must not be supplied (no play action!)
111 if self
.action
!= 'play':
112 for key
in PLAY_ACTION_KEYS
:
113 if getattr(self
, key
, None) is not None:
114 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key
)
116 # Sanity check: If started or total are given, require playmark
117 if ((self
.started
is not None) or (self
.total
is not None)) and \
118 self
.playmark
is None:
119 raise InvalidEpisodeActionAttributes('started and total require position')
121 # Sanity check: total and playmark can only appear together
122 if ((self
.total
is not None) or (self
.started
is not None)) and \
123 ((self
.total
is None) or (self
.started
is None)):
124 raise InvalidEpisodeActionAttributes('total and started can only appear together')
128 return '%s-Action on %s at %s (in %s)' % \
129 (self
.action
, self
.device
, self
.timestamp
, self
._id
)
133 return hash(frozenset([self
.action
, self
.timestamp
, self
.device
,
134 self
.started
, self
.playmark
, self
.total
]))
137 class Chapter(Document
):
138 """ A user-entered episode chapter """
140 device
= StringProperty()
141 created
= DateTimeProperty()
142 start
= IntegerProperty(required
=True)
143 end
= IntegerProperty(required
=True)
144 label
= StringProperty()
145 advertisement
= BooleanProperty()
149 return '<%s %s (%d-%d)>' % (self
.__class
__.__name
__, self
.label
,
150 self
.start
, self
.end
)
153 class EpisodeUserState(Document
, SettingsMixin
):
155 Contains everything a user has done with an Episode
158 episode
= StringProperty(required
=True)
159 actions
= SchemaListProperty(EpisodeAction
)
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
.get_wksetting(FAV_FLAG
)
181 def set_favorite(self
, set_to
=True):
182 self
.settings
[FAV_FLAG
.name
] = 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'])
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
)
208 def get_history_entries(self
):
209 return imap(EpisodeAction
.to_history_entry
, self
.actions
)
213 return 'Episode-State %s (in %s)' % \
214 (self
.episode
, self
._id
)
216 def __eq__(self
, other
):
217 if not isinstance(other
, EpisodeUserState
):
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
243 return hash(self
.action
) + hash(self
.timestamp
) + hash(self
.device
)
246 return '<SubscriptionAction %s on %s at %s>' % (
247 self
.action
, self
.device
, self
.timestamp
)
250 class PodcastUserState(Document
, SettingsMixin
):
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 actions
= SchemaListProperty(SubscriptionAction
)
260 tags
= StringListProperty()
261 ref_url
= StringProperty(required
=True)
262 disabled_devices
= StringListProperty()
263 merged_ids
= StringListProperty()
265 # TODO: a flag for enabling auto-flattring per podcast can be stored
266 # in the settings field; would be automatically accessible through
270 def remove_device(self
, device
):
272 Removes all actions from the podcast state that refer to the
275 self
.actions
= filter(lambda a
: a
.device
!= device
.id, self
.actions
)
278 def subscribe(self
, device
):
279 action
= SubscriptionAction()
280 action
.action
= 'subscribe'
281 action
.device
= device
.id
282 self
.add_actions([action
])
285 def unsubscribe(self
, device
):
286 action
= SubscriptionAction()
287 action
.action
= 'unsubscribe'
288 action
.device
= device
.id
289 self
.add_actions([action
])
292 def add_actions(self
, actions
):
293 self
.actions
= list(set(self
.actions
+ actions
))
294 self
.actions
= sorted(self
.actions
)
297 def add_tags(self
, tags
):
298 self
.tags
= list(set(self
.tags
+ tags
))
301 def set_device_state(self
, devices
):
302 disabled_devices
= [device
.id for device
in devices
if device
.deleted
]
303 self
.disabled_devices
= disabled_devices
306 def get_change_between(self
, device_id
, since
, until
):
308 Returns the change of the subscription status for the given device
309 between the two timestamps.
311 The change is given as either 'subscribe' (the podcast has been
312 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
316 device_actions
= filter(lambda x
: x
.device
== device_id
, self
.actions
)
317 before
= filter(lambda x
: x
.timestamp
<= since
, device_actions
)
318 after
= filter(lambda x
: x
.timestamp
<= until
, device_actions
)
320 # nothing happened, so there can be no change
324 then
= before
[-1] if before
else None
328 if now
.action
!= 'unsubscribe':
330 elif then
.action
!= now
.action
:
335 def get_subscribed_device_ids(self
):
336 """ device Ids on which the user subscribed to the podcast """
339 for action
in self
.actions
:
340 if action
.action
== "subscribe":
341 if not action
.device
in self
.disabled_devices
:
342 devices
.add(action
.device
)
344 if action
.device
in devices
:
345 devices
.remove(action
.device
)
352 return self
.get_wksetting(PUBLIC_SUB_PODCAST
)
355 def __eq__(self
, other
):
359 return self
.podcast
== other
.podcast
and \
360 self
.user
== other
.user
363 return 'Podcast %s for User %s (%s)' % \
364 (self
.podcast
, self
.user
, self
._id
)
367 class Device(Document
, SettingsMixin
):
368 id = StringProperty(default
=lambda: uuid
.uuid4().hex)
369 oldid
= IntegerProperty(required
=False)
370 uid
= StringProperty(required
=True)
371 name
= StringProperty(required
=True, default
='New Device')
372 type = StringProperty(required
=True, default
='other')
373 deleted
= BooleanProperty(default
=False)
374 user_agent
= StringProperty()
377 def get_subscription_changes(self
, since
, until
):
379 Returns the subscription changes for the device as two lists.
380 The first lists contains the Ids of the podcasts that have been
381 subscribed to, the second list of those that have been unsubscribed
385 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_device
388 podcast_states
= podcast_states_for_device(self
.id)
389 for p_state
in podcast_states
:
390 change
= p_state
.get_change_between(self
.id, since
, until
)
391 if change
== 'subscribe':
392 add
.append( p_state
.ref_url
)
393 elif change
== 'unsubscribe':
394 rem
.append( p_state
.ref_url
)
399 def get_latest_changes(self
):
401 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_device
403 podcast_states
= podcast_states_for_device(self
.id)
404 for p_state
in podcast_states
:
405 actions
= filter(lambda x
: x
.device
== self
.id, reversed(p_state
.actions
))
407 yield (p_state
.podcast
, actions
[0])
410 def get_subscribed_podcast_states(self
):
411 r
= PodcastUserState
.view('subscriptions/by_device',
412 startkey
= [self
.id, None],
413 endkey
= [self
.id, {}],
419 def get_subscribed_podcast_ids(self
):
420 states
= self
.get_subscribed_podcast_states()
421 return [state
.podcast
for state
in states
]
424 def get_subscribed_podcasts(self
):
425 """ Returns all subscribed podcasts for the device
427 The attribute "url" contains the URL that was used when subscribing to
430 states
= self
.get_subscribed_podcast_states()
431 podcast_ids
= [state
.podcast
for state
in states
]
432 podcasts
= podcasts_to_dict(podcast_ids
)
435 podcast
= proxy_object(podcasts
[state
.podcast
], url
=state
.ref_url
)
436 podcasts
[state
.podcast
] = podcast
438 return podcasts
.values()
442 return hash(frozenset([self
.id, self
.uid
, self
.name
, self
.type, self
.deleted
]))
445 def __eq__(self
, other
):
446 return self
.id == other
.id
450 return '<{cls} {id}>'.format(cls
=self
.__class
__.__name
__, id=self
.id)
456 def __unicode__(self
):
461 TOKEN_NAMES
= ('subscriptions_token', 'favorite_feeds_token',
462 'publisher_update_token', 'userpage_token')
465 class TokenException(Exception):
469 class User(BaseUser
, SyncedDevicesMixin
, SettingsMixin
):
470 oldid
= IntegerProperty()
471 devices
= SchemaListProperty(Device
)
472 published_objects
= StringListProperty()
473 deleted
= BooleanProperty(default
=False)
474 suggestions_up_to_date
= BooleanProperty(default
=False)
475 twitter
= StringProperty()
476 about
= StringProperty()
477 # TODO: add fields for storing flattr account info (token / enabled flag)
479 # token for accessing subscriptions of this use
480 subscriptions_token
= StringProperty(default
=None)
482 # token for accessing the favorite-episodes feed of this user
483 favorite_feeds_token
= StringProperty(default
=None)
485 # token for automatically updating feeds published by this user
486 publisher_update_token
= StringProperty(default
=None)
488 # token for accessing the userpage of this user
489 userpage_token
= StringProperty(default
=None)
495 def create_new_token(self
, token_name
, length
=32):
496 """ creates a new random token """
498 if token_name
not in TOKEN_NAMES
:
499 raise TokenException('Invalid token name %s' % token_name
)
501 token
= "".join(random
.sample(string
.letters
+string
.digits
, length
))
502 setattr(self
, token_name
, token
)
506 def get_token(self
, token_name
):
507 """ returns a token, and generate those that are still missing """
511 if token_name
not in TOKEN_NAMES
:
512 raise TokenException('Invalid token name %s' % token_name
)
514 for tn
in TOKEN_NAMES
:
515 if getattr(self
, tn
) is None:
516 self
.create_new_token(tn
)
522 return getattr(self
, token_name
)
527 def active_devices(self
):
528 not_deleted
= lambda d
: not d
.deleted
529 return filter(not_deleted
, self
.devices
)
533 def inactive_devices(self
):
534 deleted
= lambda d
: d
.deleted
535 return filter(deleted
, self
.devices
)
538 def get_devices_by_id(self
):
539 return dict( (device
.id, device
) for device
in self
.devices
)
542 def get_device(self
, id):
544 if not hasattr(self
, '__device_by_id'):
545 self
.__devices
_by
_id
= dict( (d
.id, d
) for d
in self
.devices
)
547 return self
.__devices
_by
_id
.get(id, None)
550 def get_device_by_uid(self
, uid
, only_active
=True):
552 if not hasattr(self
, '__devices_by_uio'):
553 self
.__devices
_by
_uid
= dict( (d
.uid
, d
) for d
in self
.devices
)
556 device
= self
.__devices
_by
_uid
[uid
]
558 if only_active
and device
.deleted
:
559 raise DeviceDeletedException(
560 'Device with UID %s is deleted' % uid
)
564 except KeyError as e
:
565 raise DeviceDoesNotExist('There is no device with UID %s' % uid
)
568 def update_device(self
, device
):
569 """ Sets the device and saves the user """
571 @repeat_on_conflict(['user'])
572 def _update(user
, device
):
573 user
.set_device(device
)
576 _update(user
=self
, device
=device
)
579 def set_device(self
, device
):
581 if not RE_DEVICE_UID
.match(device
.uid
):
582 raise DeviceUIDException(u
"'{uid} is not a valid device ID".format(
585 devices
= list(self
.devices
)
586 ids
= [x
.id for x
in devices
]
587 if not device
.id in ids
:
588 devices
.append(device
)
589 self
.devices
= devices
592 index
= ids
.index(device
.id)
594 devices
.insert(index
, device
)
595 self
.devices
= devices
598 def remove_device(self
, device
):
599 devices
= list(self
.devices
)
600 ids
= [x
.id for x
in devices
]
601 if not device
.id in ids
:
604 index
= ids
.index(device
.id)
606 self
.devices
= devices
608 if self
.is_synced(device
):
609 self
.unsync_device(device
)
612 def get_subscriptions_by_device(self
, public
=None):
613 from mygpo
.db
.couchdb
.podcast_state
import subscriptions_by_user
614 get_dev
= itemgetter(2)
615 groups
= collections
.defaultdict(list)
616 subscriptions
= subscriptions_by_user(self
, public
=public
)
617 subscriptions
= sorted(subscriptions
, key
=get_dev
)
619 for public
, podcast_id
, device_id
in subscriptions
:
620 groups
[device_id
].append(podcast_id
)
625 def get_subscribed_podcast_states(self
, public
=None):
627 Returns the Ids of all subscribed podcasts
630 r
= PodcastUserState
.view('subscriptions/by_user',
631 startkey
= [self
._id
, public
, None, None],
632 endkey
= [self
._id
+'ZZZ', None, None, None],
640 def get_subscribed_podcast_ids(self
, public
=None):
641 states
= self
.get_subscribed_podcast_states(public
=public
)
642 return [state
.podcast
for state
in states
]
646 def get_subscribed_podcasts(self
, public
=None):
647 """ Returns all subscribed podcasts for the user
649 The attribute "url" contains the URL that was used when subscribing to
652 states
= self
.get_subscribed_podcast_states(public
=public
)
653 podcast_ids
= [state
.podcast
for state
in states
]
654 podcasts
= podcasts_to_dict(podcast_ids
)
657 podcast
= proxy_object(podcasts
[state
.podcast
], url
=state
.ref_url
)
658 podcasts
[state
.podcast
] = podcast
660 return podcasts
.values()
664 def get_subscription_history(self
, device_id
=None, reverse
=False, public
=None):
665 """ Returns chronologically ordered subscription history entries
667 Setting device_id restricts the actions to a certain device
670 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_user
, \
671 podcast_states_for_device
673 def action_iter(state
):
674 for action
in sorted(state
.actions
, reverse
=reverse
):
675 if device_id
is not None and device_id
!= action
.device
:
678 if public
is not None and state
.is_public() != public
:
681 entry
= HistoryEntry()
682 entry
.timestamp
= action
.timestamp
683 entry
.action
= action
.action
684 entry
.podcast_id
= state
.podcast
685 entry
.device_id
= action
.device
688 if device_id
is None:
689 podcast_states
= podcast_states_for_user(self
)
691 podcast_states
= podcast_states_for_device(device_id
)
693 # create an action_iter for each PodcastUserState
694 subscription_action_lists
= [action_iter(x
) for x
in podcast_states
]
696 action_cmp_key
= lambda x
: x
.timestamp
698 # Linearize their subscription-actions
699 return linearize(action_cmp_key
, subscription_action_lists
, reverse
)
702 def get_global_subscription_history(self
, public
=None):
703 """ Actions that added/removed podcasts from the subscription list
705 Returns an iterator of all subscription actions that either
706 * added subscribed a podcast that hasn't been subscribed directly
707 before the action (but could have been subscribed) earlier
708 * removed a subscription of the podcast is not longer subscribed
712 subscriptions
= collections
.defaultdict(int)
714 for entry
in self
.get_subscription_history(public
=public
):
715 if entry
.action
== 'subscribe':
716 subscriptions
[entry
.podcast_id
] += 1
718 # a new subscription has been added
719 if subscriptions
[entry
.podcast_id
] == 1:
722 elif entry
.action
== 'unsubscribe':
723 subscriptions
[entry
.podcast_id
] -= 1
725 # the last subscription has been removed
726 if subscriptions
[entry
.podcast_id
] == 0:
731 def get_newest_episodes(self
, max_date
, max_per_podcast
=5):
732 """ Returns the newest episodes of all subscribed podcasts
734 Only max_per_podcast episodes per podcast are loaded. Episodes with
735 release dates above max_date are discarded.
737 This method returns a generator that produces the newest episodes.
739 The number of required DB queries is equal to the number of (distinct)
740 podcasts of all consumed episodes (max: number of subscribed podcasts),
741 plus a constant number of initial queries (when the first episode is
744 cmp_key
= lambda episode
: episode
.released
or datetime(2000, 01, 01)
746 podcasts
= list(self
.get_subscribed_podcasts())
747 podcasts
= filter(lambda p
: p
.latest_episode_timestamp
, podcasts
)
748 podcasts
= sorted(podcasts
, key
=lambda p
: p
.latest_episode_timestamp
,
751 podcast_dict
= dict((p
.get_id(), p
) for p
in podcasts
)
753 # contains the un-yielded episodes, newest first
756 for podcast
in podcasts
:
760 for episode
in episodes
:
761 # determine for which episodes there won't be a new episodes
762 # that is newer; those can be yielded
763 if episode
.released
> podcast
.latest_episode_timestamp
:
764 p
= podcast_dict
.get(episode
.podcast
, None)
765 yield proxy_object(episode
, podcast
=p
)
766 yielded_episodes
+= 1
770 # remove the episodes that have been yielded before
771 episodes
= episodes
[yielded_episodes
:]
773 # fetch and merge episodes for the next podcast
774 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
775 new_episodes
= episodes_for_podcast(podcast
, since
=1,
776 until
=max_date
, descending
=True, limit
=max_per_podcast
)
777 episodes
= sorted(episodes
+new_episodes
, key
=cmp_key
, reverse
=True)
780 # yield the remaining episodes
781 for episode
in episodes
:
782 podcast
= podcast_dict
.get(episode
.podcast
, None)
783 yield proxy_object(episode
, podcast
=podcast
)
788 def save(self
, *args
, **kwargs
):
790 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_user
792 super(User
, self
).save(*args
, **kwargs
)
794 podcast_states
= podcast_states_for_user(self
)
795 for state
in podcast_states
:
796 @repeat_on_conflict(['state'])
797 def _update_state(state
):
798 old_devs
= set(state
.disabled_devices
)
799 state
.set_device_state(self
.devices
)
801 if old_devs
!= set(state
.disabled_devices
):
804 _update_state(state
=state
)
809 def __eq__(self
, other
):
813 # ensure that other isn't AnonymousUser
814 return other
.is_authenticated() and self
._id
== other
._id
817 def __ne__(self
, other
):
818 return not(self
== other
)
822 return 'User %s' % self
._id
825 class History(object):
827 def __init__(self
, user
, device
):
832 def __getitem__(self
, key
):
834 if isinstance(key
, slice):
835 start
= key
.start
or 0
836 length
= key
.stop
- start
842 return device_history(self
.user
, self
.device
, start
, length
)
845 return user_history(self
.user
, start
, length
)
849 class HistoryEntry(object):
850 """ A class that can represent subscription and episode actions """
854 def from_action_dict(cls
, action
):
856 entry
= HistoryEntry()
858 if 'timestamp' in action
:
859 ts
= action
.pop('timestamp')
860 entry
.timestamp
= dateutil
.parser
.parse(ts
)
862 for key
, value
in action
.items():
863 setattr(entry
, key
, value
)
870 return getattr(self
, 'position', None)
874 def fetch_data(cls
, user
, entries
,
875 podcasts
=None, episodes
=None):
876 """ Efficiently loads additional data for a number of entries """
880 podcast_ids
= [getattr(x
, 'podcast_id', None) for x
in entries
]
881 podcast_ids
= filter(None, podcast_ids
)
882 podcasts
= podcasts_to_dict(podcast_ids
)
885 from mygpo
.db
.couchdb
.episode
import episodes_to_dict
887 episode_ids
= [getattr(x
, 'episode_id', None) for x
in entries
]
888 episode_ids
= filter(None, episode_ids
)
889 episodes
= episodes_to_dict(episode_ids
)
892 # does not need pre-populated data because no db-access is required
893 device_ids
= [getattr(x
, 'device_id', None) for x
in entries
]
894 device_ids
= filter(None, device_ids
)
895 devices
= dict([ (id, user
.get_device(id)) for id in device_ids
])
898 for entry
in entries
:
899 podcast_id
= getattr(entry
, 'podcast_id', None)
900 entry
.podcast
= podcasts
.get(podcast_id
, None)
902 episode_id
= getattr(entry
, 'episode_id', None)
903 entry
.episode
= episodes
.get(episode_id
, None)
905 if hasattr(entry
, 'user'):
908 device
= devices
.get(getattr(entry
, 'device_id', None), None)
909 entry
.device
= device