1 import uuid
, collections
2 from datetime
import datetime
3 from couchdbkit
import ResourceNotFound
4 from couchdbkit
.ext
.django
.schema
import *
6 from mygpo
.core
.models
import Podcast
7 from mygpo
.utils
import linearize
, get_to_dict
8 from mygpo
.decorators
import repeat_on_conflict
11 class Rating(DocumentSchema
):
12 rating
= IntegerProperty()
13 timestamp
= DateTimeProperty(default
=datetime
.utcnow
)
16 class Suggestions(Document
):
17 user
= StringProperty()
18 user_oldid
= IntegerProperty()
19 podcasts
= StringListProperty()
20 blacklist
= StringListProperty()
21 ratings
= SchemaListProperty(Rating
)
24 def for_user_oldid(cls
, oldid
):
25 r
= cls
.view('users/suggestions_by_user_oldid', key
=oldid
, \
35 def get_podcasts(self
, count
=None):
36 user
= User
.for_oldid(self
.user_oldid
)
37 subscriptions
= user
.get_subscribed_podcast_ids()
39 ids
= filter(lambda x
: not x
in self
.blacklist
+ subscriptions
, self
.podcasts
)
42 return filter(lambda x
: x
.title
, Podcast
.get_multi(ids
))
47 return super(Suggestions
, self
).__repr
__()
49 return '%d Suggestions for %s (%s)' % \
51 self
.user
[:10] if self
.user
else self
.user_oldid
,
55 class EpisodeAction(DocumentSchema
):
57 One specific action to an episode. Must
58 always be part of a EpisodeUserState
61 action
= StringProperty(required
=True)
62 timestamp
= DateTimeProperty(required
=True)
63 device_oldid
= IntegerProperty()
64 started
= IntegerProperty()
65 playmark
= IntegerProperty()
66 total
= IntegerProperty()
68 def __eq__(self
, other
):
69 if not isinstance(other
, EpisodeAction
):
71 vals
= ('action', 'timestamp', 'device_oldid', 'started', 'playmark',
73 return all([getattr(self
, v
, None) == getattr(other
, v
, None) for v
in vals
])
77 return '%s-Action on %s at %s (in %s)' % \
78 (self
.action
, self
.device_oldid
, self
.timestamp
, self
._id
)
81 class Chapter(Document
):
82 """ A user-entered episode chapter """
84 device
= StringProperty()
85 created
= DateTimeProperty()
86 start
= IntegerProperty(required
=True)
87 end
= IntegerProperty(required
=True)
88 label
= StringProperty()
89 advertisement
= BooleanProperty()
92 def for_episode(cls
, episode_id
):
94 r
= db
.view('users/chapters_by_episode',
95 startkey
= [episode_id
, None],
96 endkey
= [episode_id
, {}],
102 chapter
= Chapter
.wrap(res
['value'])
103 yield (user
, chapter
)
107 return '<%s %s (%d-%d)>' % (self
.__class
__.__name
__, self
.label
,
108 self
.start
, self
.end
)
111 class EpisodeUserState(Document
):
113 Contains everything a user has done with an Episode
116 episode_oldid
= IntegerProperty()
117 episode
= StringProperty(required
=True)
118 actions
= SchemaListProperty(EpisodeAction
)
119 settings
= DictProperty()
120 user_oldid
= IntegerProperty()
121 ref_url
= StringProperty(required
=True)
122 podcast_ref_url
= StringProperty(required
=True)
123 merged_ids
= StringListProperty()
124 chapters
= SchemaListProperty(Chapter
)
128 def for_user_episode(cls
, user
, episode
):
129 r
= cls
.view('users/episode_states_by_user_episode',
130 key
=[user
.id, episode
._id
], include_docs
=True)
136 from mygpo
import migrate
137 new_user
= migrate
.get_or_migrate_user(user
)
138 podcast
= Podcast
.get(episode
.podcast
)
140 state
= EpisodeUserState()
141 state
.episode
= podcast
.get_id()
142 state
.podcast
= episode
.podcast
143 state
.user_oldid
= user
.id
144 state
.ref_url
= episode
.url
145 state
.podcast_ref_url
= podcast
.url
151 r
= cls
.view('users/episode_states_by_user_episode',
156 def add_actions(self
, actions
):
157 self
.actions
+= actions
158 self
.actions
= list(set(self
.actions
))
159 self
.actions
.sort(key
=lambda x
: x
.timestamp
)
162 def is_favorite(self
):
163 return self
.settings
.get('is_favorite', False)
166 def set_favorite(self
, set_to
=True):
167 self
.settings
['is_favorite'] = set_to
170 def update_chapters(self
, add
=[], rem
=[]):
171 """ Updates the Chapter list
173 * add contains the chapters to be added
175 * rem contains tuples of (start, end) times. Chapters that match
176 both endpoints will be removed
179 @repeat_on_conflict(['state'])
182 self
.chapters
.append(chapter
)
184 for start
, end
in rem
:
185 print 'remove: start %d, end %d' % (start
, end
)
186 keep
= lambda c
: c
.start
!= start
or c
.end
!= end
187 self
.chapters
= filter(keep
, self
.chapters
)
195 return 'Episode-State %s (in %s)' % \
196 (self
.episode
, self
._id
)
198 def __eq__(self
, other
):
199 if not isinstance(other
, EpisodeUserState
):
202 return (self
.episode_oldid
== other
.episode_oldid
and \
203 self
.episode
== other
.episode
and
204 self
.actions
== other
.actions
)
208 class SubscriptionAction(Document
):
209 action
= StringProperty()
210 timestamp
= DateTimeProperty(default
=datetime
.utcnow
)
211 device
= StringProperty()
214 def __cmp__(self
, other
):
215 return cmp(self
.timestamp
, other
.timestamp
)
217 def __eq__(self
, other
):
218 return self
.action
== other
.action
and \
219 self
.timestamp
== other
.timestamp
and \
220 self
.device
== other
.device
223 return hash(self
.action
) + hash(self
.timestamp
) + hash(self
.device
)
226 return '<SubscriptionAction %s on %s at %s>' % (
227 self
.action
, self
.device
, self
.timestamp
)
230 class PodcastUserState(Document
):
232 Contains everything that a user has done
233 with a specific podcast and all its episodes
236 podcast
= StringProperty(required
=True)
237 episodes
= SchemaDictProperty(EpisodeUserState
)
238 user_oldid
= IntegerProperty()
239 settings
= DictProperty()
240 actions
= SchemaListProperty(SubscriptionAction
)
241 tags
= StringListProperty()
242 ref_url
= StringProperty(required
=True)
243 disabled_devices
= StringListProperty()
244 merged_ids
= StringListProperty()
248 def for_user_podcast(cls
, user
, podcast
):
249 r
= PodcastUserState
.view('users/podcast_states_by_podcast', \
250 key
=[podcast
.get_id(), user
.id], limit
=1, include_docs
=True)
254 from mygpo
import migrate
255 new_user
= migrate
.get_or_migrate_user(user
)
256 p
= PodcastUserState()
257 p
.podcast
= podcast
.get_id()
258 p
.user_oldid
= user
.id
259 p
.ref_url
= podcast
.url
260 p
.settings
['public_subscription'] = new_user
.settings
.get('public_subscriptions', True)
262 for device
in migrate
.get_devices(user
):
263 p
.set_device_state(device
)
269 def for_user(cls
, user
):
270 return cls
.for_user_oldid(user
.id)
274 def for_user_oldid(cls
, user_oldid
):
275 r
= PodcastUserState
.view('users/podcast_states_by_user',
276 startkey
=[user_oldid
, None], endkey
=[user_oldid
, 'ZZZZ'],
282 def for_device(cls
, device_id
):
283 r
= PodcastUserState
.view('users/podcast_states_by_device',
284 startkey
=[device_id
, None], endkey
=[device_id
, {}],
289 def remove_device(self
, device
):
291 Removes all actions from the podcast state that refer to the
294 self
.actions
= filter(lambda a
: a
.device
!= device
.id, self
.actions
)
299 r
= PodcastUserState
.view('users/podcast_states_by_user',
304 def subscribe(self
, device
):
305 action
= SubscriptionAction()
306 action
.action
= 'subscribe'
307 action
.device
= device
.id
308 self
.add_actions([action
])
311 def unsubscribe(self
, device
):
312 action
= SubscriptionAction()
313 action
.action
= 'unsubscribe'
314 action
.device
= device
.id
315 self
.add_actions([action
])
318 def add_actions(self
, actions
):
319 self
.actions
= list(set(self
.actions
+ actions
))
320 self
.actions
= sorted(self
.actions
)
323 def add_tags(self
, tags
):
324 self
.tags
= list(set(self
.tags
+ tags
))
327 def set_device_state(self
, device
):
329 self
.disabled_devices
= list(set(self
.disabled_devices
+ [device
.id]))
330 elif not device
.deleted
and device
.id in self
.disabled_devices
:
331 self
.disabled_devices
.remove(device
.id)
334 def get_change_between(self
, device_id
, since
, until
):
336 Returns the change of the subscription status for the given device
337 between the two timestamps.
339 The change is given as either 'subscribe' (the podcast has been
340 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
344 device_actions
= filter(lambda x
: x
.device
== device_id
, self
.actions
)
345 before
= filter(lambda x
: x
.timestamp
<= since
, device_actions
)
346 after
= filter(lambda x
: x
.timestamp
<= until
, device_actions
)
348 then
= before
[-1] if before
else None
352 if now
.action
!= 'unsubscribe':
354 elif then
.action
!= now
.action
:
359 def get_subscribed_device_ids(self
):
360 r
= PodcastUserState
.view('users/subscriptions_by_podcast',
361 startkey
=[self
.podcast
, self
.user_oldid
, None],
362 endkey
=[self
.podcast
, self
.user_oldid
, {}])
363 return (res
['key'][2] for res
in r
)
367 return self
.settings
.get('public_subscription', True)
370 def __eq__(self
, other
):
374 return self
.podcast
== other
.podcast
and \
375 self
.user_oldid
== other
.user_oldid
378 return 'Podcast %s for User %s (%s)' % \
379 (self
.podcast
, self
.user_oldid
, self
._id
)
382 class Device(Document
):
383 id = StringProperty(default
=lambda: uuid
.uuid4().hex)
384 oldid
= IntegerProperty()
385 uid
= StringProperty()
386 name
= StringProperty()
387 type = StringProperty()
388 settings
= DictProperty()
389 deleted
= BooleanProperty()
392 def for_oldid(cls
, oldid
):
393 r
= cls
.view('users/devices_by_oldid', key
=oldid
)
394 return r
.first() if r
else None
397 def get_subscription_changes(self
, since
, until
):
399 Returns the subscription changes for the device as two lists.
400 The first lists contains the Ids of the podcasts that have been
401 subscribed to, the second list of those that have been unsubscribed
406 podcast_states
= PodcastUserState
.for_device(self
.id)
407 for p_state
in podcast_states
:
408 change
= p_state
.get_change_between(self
.id, since
, until
)
409 if change
== 'subscribe':
410 add
.append( p_state
.podcast
)
411 elif change
== 'unsubscribe':
412 rem
.append( p_state
.podcast
)
417 def get_latest_changes(self
):
418 podcast_states
= PodcastUserState
.for_device(self
.id)
419 for p_state
in podcast_states
:
420 actions
= filter(lambda x
: x
.device
== self
.id, reversed(p_state
.actions
))
422 yield (p_state
.podcast
, actions
[0])
425 def get_subscribed_podcast_ids(self
):
426 from mygpo
.api
.models
import Device
427 d
= Device
.objects
.get(id=self
.oldid
)
429 r
= self
.view('users/subscribed_podcasts_by_device',
430 startkey
=[self
.id, None],
431 endkey
=[self
.id, {}])
432 return [res
['key'][1] for res
in r
]
435 def get_subscribed_podcasts(self
):
436 return Podcast
.get_multi(self
.get_subscribed_podcast_ids())
440 def token_generator(length
=32):
441 import random
, string
442 return "".join(random
.sample(string
.letters
+string
.digits
, length
))
445 class User(Document
):
446 oldid
= IntegerProperty()
447 settings
= DictProperty()
448 devices
= SchemaListProperty(Device
)
449 published_objects
= StringListProperty()
451 # token for accessing subscriptions of this use
452 subscriptions_token
= StringProperty(default
=token_generator
)
454 # token for accessing the favorite-episodes feed of this user
455 favorite_feeds_token
= StringProperty(default
=token_generator
)
457 # token for automatically updating feeds published by this user
458 publisher_update_token
= StringProperty(default
=token_generator
)
462 def for_oldid(cls
, oldid
):
463 r
= cls
.view('users/users_by_oldid', key
=oldid
, limit
=1, include_docs
=True)
464 return r
.one() if r
else None
467 def create_new_token(self
, token_name
, length
=32):
468 setattr(self
, token_name
, token_generator(length
))
471 def get_device(self
, id):
472 for device
in self
.devices
:
479 def set_device(self
, device
):
480 devices
= list(self
.devices
)
481 ids
= [x
.id for x
in devices
]
482 if not device
.id in ids
:
483 devices
.append(device
)
486 index
= ids
.index(device
.id)
488 devices
.insert(index
, device
)
489 self
.devices
= devices
492 def remove_device(self
, device
):
493 devices
= list(self
.devices
)
494 ids
= [x
.id for x
in devices
]
495 if not device
.id in ids
:
498 index
= ids
.index(device
.id)
500 self
.devices
= devices
503 def get_subscriptions(self
, public
=None):
505 Returns a list of (podcast-id, device-id) tuples for all
506 of the users subscriptions
509 r
= PodcastUserState
.view('users/subscribed_podcasts_by_user',
510 startkey
=[self
.oldid
, public
, None, None],
511 endkey
=[self
.oldid
+1, None, None, None])
512 return [res
['key'][1:] for res
in r
]
515 def get_subscribed_podcast_ids(self
, public
=None):
517 Returns the Ids of all subscribed podcasts
519 return list(set(x
[1] for x
in self
.get_subscriptions(public
=public
)))
522 def get_subscribed_podcasts(self
, public
=None):
523 return Podcast
.get_multi(self
.get_subscribed_podcast_ids(public
=public
))
526 def get_subscription_history(self
, device_id
=None, reverse
=False, public
=None):
527 """ Returns chronologically ordered subscription history entries
529 Setting device_id restricts the actions to a certain device
532 def action_iter(state
):
533 for action
in sorted(state
.actions
, reverse
=reverse
):
534 if device_id
is not None and device_id
!= action
.device
:
537 if public
is not None and state
.is_public() != public
:
540 entry
= HistoryEntry()
541 entry
.timestamp
= action
.timestamp
542 entry
.action
= action
.action
543 entry
.podcast_id
= state
.podcast
544 entry
.device_id
= action
.device
547 if device_id
is None:
548 podcast_states
= PodcastUserState
.for_user_oldid(self
.oldid
)
550 podcast_states
= PodcastUserState
.for_device(device_id
)
552 # create an action_iter for each PodcastUserState
553 subscription_action_lists
= [action_iter(x
) for x
in podcast_states
]
555 action_cmp_key
= lambda x
: x
.timestamp
557 # Linearize their subscription-actions
558 return linearize(action_cmp_key
, subscription_action_lists
, reverse
)
561 def get_global_subscription_history(self
, public
=None):
562 """ Actions that added/removed podcasts from the subscription list
564 Returns an iterator of all subscription actions that either
565 * added subscribed a podcast that hasn't been subscribed directly
566 before the action (but could have been subscribed) earlier
567 * removed a subscription of the podcast is not longer subscribed
571 subscriptions
= collections
.defaultdict(int)
573 for entry
in self
.get_subscription_history(public
=public
):
574 if entry
.action
== 'subscribe':
575 subscriptions
[entry
.podcast_id
] += 1
577 # a new subscription has been added
578 if subscriptions
[entry
.podcast_id
] == 1:
581 elif entry
.action
== 'unsubscribe':
582 subscriptions
[entry
.podcast_id
] -= 1
584 # the last subscription has been removed
585 if subscriptions
[entry
.podcast_id
] == 0:
590 return 'User %s' % self
._id
593 class HistoryEntry(object):
596 def fetch_data(cls
, user
, entries
):
597 """ Efficiently loads additional data for a number of entries """
600 podcast_ids
= [x
.podcast_id
for x
in entries
]
601 podcasts
= get_to_dict(Podcast
, podcast_ids
)
604 device_ids
= [x
.device_id
for x
in entries
]
605 devices
= dict([ (id, user
.get_device(id)) for id in device_ids
])
607 for entry
in entries
:
608 entry
.podcast
= podcasts
[entry
.podcast_id
]
609 entry
.device
= devices
[entry
.device_id
]