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
.utils
import linearize
17 from mygpo
.core
.proxy
import DocumentABCMeta
, proxy_object
18 from mygpo
.decorators
import repeat_on_conflict
19 from mygpo
.users
.ratings
import RatingMixin
20 from mygpo
.users
.sync
import SyncedDevicesMixin
21 from mygpo
.users
.settings
import FAV_FLAG
, PUBLIC_SUB_PODCAST
, SettingsMixin
22 from mygpo
.db
.couchdb
.podcast
import podcasts_by_id
, podcasts_to_dict
23 from mygpo
.db
.couchdb
.user
import user_history
, device_history
27 RE_DEVICE_UID
= re
.compile(r
'^[\w.-]+$')
29 # TODO: derive from ValidationException?
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)
79 # walltime of the event (assigned by the uploading client, defaults to now)
80 timestamp
= DateTimeProperty(required
=True, default
=datetime
.utcnow
)
82 # upload time of the event
83 upload_timestamp
= IntegerProperty(required
=True)
85 device_oldid
= IntegerProperty(required
=False)
86 device
= StringProperty()
87 started
= IntegerProperty()
88 playmark
= IntegerProperty()
89 total
= IntegerProperty()
91 def __eq__(self
, other
):
92 if not isinstance(other
, EpisodeAction
):
94 vals
= ('action', 'timestamp', 'device', 'started', 'playmark',
96 return all([getattr(self
, v
, None) == getattr(other
, v
, None) for v
in vals
])
99 def to_history_entry(self
):
100 entry
= HistoryEntry()
101 entry
.action
= self
.action
102 entry
.timestamp
= self
.timestamp
103 entry
.device_id
= self
.device
104 entry
.started
= self
.started
105 entry
.position
= self
.playmark
106 entry
.total
= self
.total
111 def validate_time_values(self
):
112 """ Validates allowed combinations of time-values """
114 PLAY_ACTION_KEYS
= ('playmark', 'started', 'total')
116 # Key found, but must not be supplied (no play action!)
117 if self
.action
!= 'play':
118 for key
in PLAY_ACTION_KEYS
:
119 if getattr(self
, key
, None) is not None:
120 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key
)
122 # Sanity check: If started or total are given, require playmark
123 if ((self
.started
is not None) or (self
.total
is not None)) and \
124 self
.playmark
is None:
125 raise InvalidEpisodeActionAttributes('started and total require position')
127 # Sanity check: total and playmark can only appear together
128 if ((self
.total
is not None) or (self
.started
is not None)) and \
129 ((self
.total
is None) or (self
.started
is None)):
130 raise InvalidEpisodeActionAttributes('total and started can only appear together')
134 return '%s-Action on %s at %s (in %s)' % \
135 (self
.action
, self
.device
, self
.timestamp
, self
._id
)
139 return hash(frozenset([self
.action
, self
.timestamp
, self
.device
,
140 self
.started
, self
.playmark
, self
.total
]))
143 class Chapter(Document
):
144 """ A user-entered episode chapter """
146 device
= StringProperty()
147 created
= DateTimeProperty()
148 start
= IntegerProperty(required
=True)
149 end
= IntegerProperty(required
=True)
150 label
= StringProperty()
151 advertisement
= BooleanProperty()
155 return '<%s %s (%d-%d)>' % (self
.__class
__.__name
__, self
.label
,
156 self
.start
, self
.end
)
159 class EpisodeUserState(Document
, SettingsMixin
):
161 Contains everything a user has done with an Episode
164 episode
= StringProperty(required
=True)
165 actions
= SchemaListProperty(EpisodeAction
)
166 user_oldid
= IntegerProperty()
167 user
= StringProperty(required
=True)
168 ref_url
= StringProperty(required
=True)
169 podcast_ref_url
= StringProperty(required
=True)
170 merged_ids
= StringListProperty()
171 chapters
= SchemaListProperty(Chapter
)
172 podcast
= StringProperty(required
=True)
176 def add_actions(self
, actions
):
177 map(EpisodeAction
.validate_time_values
, actions
)
178 self
.actions
= list(self
.actions
) + actions
179 self
.actions
= list(set(self
.actions
))
180 self
.actions
= sorted(self
.actions
, key
=lambda x
: x
.timestamp
)
183 def is_favorite(self
):
184 return self
.get_wksetting(FAV_FLAG
)
187 def set_favorite(self
, set_to
=True):
188 self
.settings
[FAV_FLAG
.name
] = set_to
191 def update_chapters(self
, add
=[], rem
=[]):
192 """ Updates the Chapter list
194 * add contains the chapters to be added
196 * rem contains tuples of (start, end) times. Chapters that match
197 both endpoints will be removed
200 @repeat_on_conflict(['state'])
203 self
.chapters
= self
.chapters
+ [chapter
]
205 for start
, end
in rem
:
206 keep
= lambda c
: c
.start
!= start
or c
.end
!= end
207 self
.chapters
= filter(keep
, self
.chapters
)
214 def get_history_entries(self
):
215 return imap(EpisodeAction
.to_history_entry
, self
.actions
)
219 return 'Episode-State %s (in %s)' % \
220 (self
.episode
, self
._id
)
222 def __eq__(self
, other
):
223 if not isinstance(other
, EpisodeUserState
):
226 return (self
.episode
== other
.episode
and
227 self
.user
== other
.user
)
231 class SubscriptionAction(Document
):
232 action
= StringProperty()
233 timestamp
= DateTimeProperty(default
=datetime
.utcnow
)
234 device
= StringProperty()
237 __metaclass__
= DocumentABCMeta
240 def __cmp__(self
, other
):
241 return cmp(self
.timestamp
, other
.timestamp
)
243 def __eq__(self
, other
):
244 return self
.action
== other
.action
and \
245 self
.timestamp
== other
.timestamp
and \
246 self
.device
== other
.device
249 return hash(self
.action
) + hash(self
.timestamp
) + hash(self
.device
)
252 return '<SubscriptionAction %s on %s at %s>' % (
253 self
.action
, self
.device
, self
.timestamp
)
256 class PodcastUserState(Document
, SettingsMixin
):
258 Contains everything that a user has done
259 with a specific podcast and all its episodes
262 podcast
= StringProperty(required
=True)
263 user_oldid
= IntegerProperty()
264 user
= StringProperty(required
=True)
265 actions
= SchemaListProperty(SubscriptionAction
)
266 tags
= StringListProperty()
267 ref_url
= StringProperty(required
=True)
268 disabled_devices
= StringListProperty()
269 merged_ids
= StringListProperty()
272 def remove_device(self
, device
):
274 Removes all actions from the podcast state that refer to the
277 self
.actions
= filter(lambda a
: a
.device
!= device
.id, self
.actions
)
280 def subscribe(self
, device
):
281 action
= SubscriptionAction()
282 action
.action
= 'subscribe'
283 action
.device
= device
.id
284 self
.add_actions([action
])
287 def unsubscribe(self
, device
):
288 action
= SubscriptionAction()
289 action
.action
= 'unsubscribe'
290 action
.device
= device
.id
291 self
.add_actions([action
])
294 def add_actions(self
, actions
):
295 self
.actions
= list(set(self
.actions
+ actions
))
296 self
.actions
= sorted(self
.actions
)
299 def add_tags(self
, tags
):
300 self
.tags
= list(set(self
.tags
+ tags
))
303 def set_device_state(self
, devices
):
304 disabled_devices
= [device
.id for device
in devices
if device
.deleted
]
305 self
.disabled_devices
= disabled_devices
308 def get_change_between(self
, device_id
, since
, until
):
310 Returns the change of the subscription status for the given device
311 between the two timestamps.
313 The change is given as either 'subscribe' (the podcast has been
314 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
318 device_actions
= filter(lambda x
: x
.device
== device_id
, self
.actions
)
319 before
= filter(lambda x
: x
.timestamp
<= since
, device_actions
)
320 after
= filter(lambda x
: x
.timestamp
<= until
, device_actions
)
322 # nothing happened, so there can be no change
326 then
= before
[-1] if before
else None
330 if now
.action
!= 'unsubscribe':
332 elif then
.action
!= now
.action
:
337 def get_subscribed_device_ids(self
):
338 """ device Ids on which the user subscribed to the podcast """
341 for action
in self
.actions
:
342 if action
.action
== "subscribe":
343 if not action
.device
in self
.disabled_devices
:
344 devices
.add(action
.device
)
346 if action
.device
in devices
:
347 devices
.remove(action
.device
)
354 return self
.get_wksetting(PUBLIC_SUB_PODCAST
)
357 def __eq__(self
, other
):
361 return self
.podcast
== other
.podcast
and \
362 self
.user
== other
.user
365 return 'Podcast %s for User %s (%s)' % \
366 (self
.podcast
, self
.user
, self
._id
)
369 class Device(Document
, SettingsMixin
):
370 id = StringProperty(default
=lambda: uuid
.uuid4().hex)
371 oldid
= IntegerProperty(required
=False)
372 uid
= StringProperty(required
=True)
373 name
= StringProperty(required
=True, default
='New Device')
374 type = StringProperty(required
=True, default
='other')
375 deleted
= BooleanProperty(default
=False)
376 user_agent
= StringProperty()
379 def get_subscription_changes(self
, since
, until
):
381 Returns the subscription changes for the device as two lists.
382 The first lists contains the Ids of the podcasts that have been
383 subscribed to, the second list of those that have been unsubscribed
387 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_device
390 podcast_states
= podcast_states_for_device(self
.id)
391 for p_state
in podcast_states
:
392 change
= p_state
.get_change_between(self
.id, since
, until
)
393 if change
== 'subscribe':
394 add
.append( p_state
.ref_url
)
395 elif change
== 'unsubscribe':
396 rem
.append( p_state
.ref_url
)
401 def get_latest_changes(self
):
403 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_device
405 podcast_states
= podcast_states_for_device(self
.id)
406 for p_state
in podcast_states
:
407 actions
= filter(lambda x
: x
.device
== self
.id, reversed(p_state
.actions
))
409 yield (p_state
.podcast
, actions
[0])
412 def get_subscribed_podcast_states(self
):
413 r
= PodcastUserState
.view('subscriptions/by_device',
414 startkey
= [self
.id, None],
415 endkey
= [self
.id, {}],
421 def get_subscribed_podcast_ids(self
):
422 states
= self
.get_subscribed_podcast_states()
423 return [state
.podcast
for state
in states
]
426 def get_subscribed_podcasts(self
):
427 """ Returns all subscribed podcasts for the device
429 The attribute "url" contains the URL that was used when subscribing to
432 states
= self
.get_subscribed_podcast_states()
433 podcast_ids
= [state
.podcast
for state
in states
]
434 podcasts
= podcasts_to_dict(podcast_ids
)
437 podcast
= proxy_object(podcasts
[state
.podcast
], url
=state
.ref_url
)
438 podcasts
[state
.podcast
] = podcast
440 return podcasts
.values()
444 return hash(frozenset([self
.id, self
.uid
, self
.name
, self
.type, self
.deleted
]))
447 def __eq__(self
, other
):
448 return self
.id == other
.id
452 return '<{cls} {id}>'.format(cls
=self
.__class
__.__name
__, id=self
.id)
458 def __unicode__(self
):
463 TOKEN_NAMES
= ('subscriptions_token', 'favorite_feeds_token',
464 'publisher_update_token', 'userpage_token')
467 class TokenException(Exception):
471 class User(BaseUser
, SyncedDevicesMixin
, SettingsMixin
):
472 oldid
= IntegerProperty()
473 devices
= SchemaListProperty(Device
)
474 published_objects
= StringListProperty()
475 deleted
= BooleanProperty(default
=False)
476 suggestions_up_to_date
= BooleanProperty(default
=False)
477 twitter
= StringProperty()
478 about
= StringProperty()
479 google_email
= StringProperty()
481 # token for accessing subscriptions of this use
482 subscriptions_token
= StringProperty(default
=None)
484 # token for accessing the favorite-episodes feed of this user
485 favorite_feeds_token
= StringProperty(default
=None)
487 # token for automatically updating feeds published by this user
488 publisher_update_token
= StringProperty(default
=None)
490 # token for accessing the userpage of this user
491 userpage_token
= StringProperty(default
=None)
497 def create_new_token(self
, token_name
, length
=32):
498 """ creates a new random token """
500 if token_name
not in TOKEN_NAMES
:
501 raise TokenException('Invalid token name %s' % token_name
)
503 token
= "".join(random
.sample(string
.letters
+string
.digits
, length
))
504 setattr(self
, token_name
, token
)
508 @repeat_on_conflict(['self'])
509 def get_token(self
, token_name
):
510 """ returns a token, and generate those that are still missing """
514 if token_name
not in TOKEN_NAMES
:
515 raise TokenException('Invalid token name %s' % token_name
)
517 for tn
in TOKEN_NAMES
:
518 if getattr(self
, tn
) is None:
519 self
.create_new_token(tn
)
525 return getattr(self
, token_name
)
530 def active_devices(self
):
531 not_deleted
= lambda d
: not d
.deleted
532 return filter(not_deleted
, self
.devices
)
536 def inactive_devices(self
):
537 deleted
= lambda d
: d
.deleted
538 return filter(deleted
, self
.devices
)
541 def get_devices_by_id(self
):
542 return dict( (device
.id, device
) for device
in self
.devices
)
545 def get_device(self
, id):
547 if not hasattr(self
, '__device_by_id'):
548 self
.__devices
_by
_id
= dict( (d
.id, d
) for d
in self
.devices
)
550 return self
.__devices
_by
_id
.get(id, None)
553 def get_device_by_uid(self
, uid
, only_active
=True):
555 if not hasattr(self
, '__devices_by_uio'):
556 self
.__devices
_by
_uid
= dict( (d
.uid
, d
) for d
in self
.devices
)
559 device
= self
.__devices
_by
_uid
[uid
]
561 if only_active
and device
.deleted
:
562 raise DeviceDeletedException(
563 'Device with UID %s is deleted' % uid
)
567 except KeyError as e
:
568 raise DeviceDoesNotExist('There is no device with UID %s' % uid
)
571 @repeat_on_conflict(['self'])
572 def update_device(self
, device
):
573 """ Sets the device and saves the user """
574 self
.set_device(device
)
578 def set_device(self
, device
):
580 if not RE_DEVICE_UID
.match(device
.uid
):
581 raise DeviceUIDException(u
"'{uid} is not a valid device ID".format(
584 devices
= list(self
.devices
)
585 ids
= [x
.id for x
in devices
]
586 if not device
.id in ids
:
587 devices
.append(device
)
588 self
.devices
= devices
591 index
= ids
.index(device
.id)
593 devices
.insert(index
, device
)
594 self
.devices
= devices
597 def remove_device(self
, device
):
598 devices
= list(self
.devices
)
599 ids
= [x
.id for x
in devices
]
600 if not device
.id in ids
:
603 index
= ids
.index(device
.id)
605 self
.devices
= devices
607 if self
.is_synced(device
):
608 self
.unsync_device(device
)
611 def get_subscriptions_by_device(self
, public
=None):
612 from mygpo
.db
.couchdb
.podcast_state
import subscriptions_by_user
613 get_dev
= itemgetter(2)
614 groups
= collections
.defaultdict(list)
615 subscriptions
= subscriptions_by_user(self
, public
=public
)
616 subscriptions
= sorted(subscriptions
, key
=get_dev
)
618 for public
, podcast_id
, device_id
in subscriptions
:
619 groups
[device_id
].append(podcast_id
)
624 def get_subscribed_podcast_states(self
, public
=None):
626 Returns the Ids of all subscribed podcasts
629 r
= PodcastUserState
.view('subscriptions/by_user',
630 startkey
= [self
._id
, public
, None, None],
631 endkey
= [self
._id
+'ZZZ', None, None, None],
639 def get_subscribed_podcast_ids(self
, public
=None):
640 states
= self
.get_subscribed_podcast_states(public
=public
)
641 return [state
.podcast
for state
in states
]
645 def get_subscribed_podcasts(self
, public
=None):
646 """ Returns all subscribed podcasts for the user
648 The attribute "url" contains the URL that was used when subscribing to
651 states
= self
.get_subscribed_podcast_states(public
=public
)
652 podcast_ids
= [state
.podcast
for state
in states
]
653 podcasts
= podcasts_to_dict(podcast_ids
)
656 podcast
= proxy_object(podcasts
[state
.podcast
], url
=state
.ref_url
)
657 podcasts
[state
.podcast
] = podcast
659 return podcasts
.values()
663 def get_subscription_history(self
, device_id
=None, reverse
=False, public
=None):
664 """ Returns chronologically ordered subscription history entries
666 Setting device_id restricts the actions to a certain device
669 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_user
, \
670 podcast_states_for_device
672 def action_iter(state
):
673 for action
in sorted(state
.actions
, reverse
=reverse
):
674 if device_id
is not None and device_id
!= action
.device
:
677 if public
is not None and state
.is_public() != public
:
680 entry
= HistoryEntry()
681 entry
.timestamp
= action
.timestamp
682 entry
.action
= action
.action
683 entry
.podcast_id
= state
.podcast
684 entry
.device_id
= action
.device
687 if device_id
is None:
688 podcast_states
= podcast_states_for_user(self
)
690 podcast_states
= podcast_states_for_device(device_id
)
692 # create an action_iter for each PodcastUserState
693 subscription_action_lists
= [action_iter(x
) for x
in podcast_states
]
695 action_cmp_key
= lambda x
: x
.timestamp
697 # Linearize their subscription-actions
698 return linearize(action_cmp_key
, subscription_action_lists
, reverse
)
701 def get_global_subscription_history(self
, public
=None):
702 """ Actions that added/removed podcasts from the subscription list
704 Returns an iterator of all subscription actions that either
705 * added subscribed a podcast that hasn't been subscribed directly
706 before the action (but could have been subscribed) earlier
707 * removed a subscription of the podcast is not longer subscribed
711 subscriptions
= collections
.defaultdict(int)
713 for entry
in self
.get_subscription_history(public
=public
):
714 if entry
.action
== 'subscribe':
715 subscriptions
[entry
.podcast_id
] += 1
717 # a new subscription has been added
718 if subscriptions
[entry
.podcast_id
] == 1:
721 elif entry
.action
== 'unsubscribe':
722 subscriptions
[entry
.podcast_id
] -= 1
724 # the last subscription has been removed
725 if subscriptions
[entry
.podcast_id
] == 0:
730 def get_newest_episodes(self
, max_date
, max_per_podcast
=5):
731 """ Returns the newest episodes of all subscribed podcasts
733 Only max_per_podcast episodes per podcast are loaded. Episodes with
734 release dates above max_date are discarded.
736 This method returns a generator that produces the newest episodes.
738 The number of required DB queries is equal to the number of (distinct)
739 podcasts of all consumed episodes (max: number of subscribed podcasts),
740 plus a constant number of initial queries (when the first episode is
743 cmp_key
= lambda episode
: episode
.released
or datetime(2000, 01, 01)
745 podcasts
= list(self
.get_subscribed_podcasts())
746 podcasts
= filter(lambda p
: p
.latest_episode_timestamp
, podcasts
)
747 podcasts
= sorted(podcasts
, key
=lambda p
: p
.latest_episode_timestamp
,
750 podcast_dict
= dict((p
.get_id(), p
) for p
in podcasts
)
752 # contains the un-yielded episodes, newest first
755 for podcast
in podcasts
:
759 for episode
in episodes
:
760 # determine for which episodes there won't be a new episodes
761 # that is newer; those can be yielded
762 if episode
.released
> podcast
.latest_episode_timestamp
:
763 p
= podcast_dict
.get(episode
.podcast
, None)
764 yield proxy_object(episode
, podcast
=p
)
765 yielded_episodes
+= 1
769 # remove the episodes that have been yielded before
770 episodes
= episodes
[yielded_episodes
:]
772 # fetch and merge episodes for the next podcast
773 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
774 new_episodes
= episodes_for_podcast(podcast
, since
=1,
775 until
=max_date
, descending
=True, limit
=max_per_podcast
)
776 episodes
= sorted(episodes
+new_episodes
, key
=cmp_key
, reverse
=True)
779 # yield the remaining episodes
780 for episode
in episodes
:
781 podcast
= podcast_dict
.get(episode
.podcast
, None)
782 yield proxy_object(episode
, podcast
=podcast
)
787 def save(self
, *args
, **kwargs
):
789 from mygpo
.db
.couchdb
.podcast_state
import podcast_states_for_user
791 super(User
, self
).save(*args
, **kwargs
)
793 podcast_states
= podcast_states_for_user(self
)
794 for state
in podcast_states
:
795 @repeat_on_conflict(['state'])
796 def _update_state(state
):
797 old_devs
= set(state
.disabled_devices
)
798 state
.set_device_state(self
.devices
)
800 if old_devs
!= set(state
.disabled_devices
):
808 def __eq__(self
, other
):
812 # ensure that other isn't AnonymousUser
813 return other
.is_authenticated() and self
._id
== other
._id
816 def __ne__(self
, other
):
817 return not(self
== other
)
821 return 'User %s' % self
._id
824 class History(object):
826 def __init__(self
, user
, device
):
831 def __getitem__(self
, key
):
833 if isinstance(key
, slice):
834 start
= key
.start
or 0
835 length
= key
.stop
- start
841 return device_history(self
.user
, self
.device
, start
, length
)
844 return user_history(self
.user
, start
, length
)
848 class HistoryEntry(object):
849 """ A class that can represent subscription and episode actions """
853 def from_action_dict(cls
, action
):
855 entry
= HistoryEntry()
857 if 'timestamp' in action
:
858 ts
= action
.pop('timestamp')
859 entry
.timestamp
= dateutil
.parser
.parse(ts
)
861 for key
, value
in action
.items():
862 setattr(entry
, key
, value
)
869 return getattr(self
, 'position', None)
873 def fetch_data(cls
, user
, entries
,
874 podcasts
=None, episodes
=None):
875 """ Efficiently loads additional data for a number of entries """
879 podcast_ids
= [getattr(x
, 'podcast_id', None) for x
in entries
]
880 podcast_ids
= filter(None, podcast_ids
)
881 podcasts
= podcasts_to_dict(podcast_ids
)
884 from mygpo
.db
.couchdb
.episode
import episodes_to_dict
886 episode_ids
= [getattr(x
, 'episode_id', None) for x
in entries
]
887 episode_ids
= filter(None, episode_ids
)
888 episodes
= episodes_to_dict(episode_ids
)
891 # does not need pre-populated data because no db-access is required
892 device_ids
= [getattr(x
, 'device_id', None) for x
in entries
]
893 device_ids
= filter(None, device_ids
)
894 devices
= dict([ (id, user
.get_device(id)) for id in device_ids
])
897 for entry
in entries
:
898 podcast_id
= getattr(entry
, 'podcast_id', None)
899 entry
.podcast
= podcasts
.get(podcast_id
, None)
901 episode_id
= getattr(entry
, 'episode_id', None)
902 entry
.episode
= episodes
.get(episode_id
, None)
904 if hasattr(entry
, 'user'):
907 device
= devices
.get(getattr(entry
, 'device_id', None), None)
908 entry
.device
= device