include User-Id in View subscriptions_by_podcast
[mygpo.git] / mygpo / users / models.py
blobbedede649b65f1a8862a670bdac1dd7c2049bfdf
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)
23 @classmethod
24 def for_user_oldid(cls, oldid):
25 r = cls.view('users/suggestions_by_user_oldid', key=oldid, \
26 include_docs=True)
27 if r:
28 return r.first()
29 else:
30 s = Suggestions()
31 s.user_oldid = oldid
32 return s
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)
40 if count:
41 ids = ids[:count]
42 return filter(lambda x: x.title, Podcast.get_multi(ids))
45 def __repr__(self):
46 if not self._id:
47 return super(Suggestions, self).__repr__()
48 else:
49 return '%d Suggestions for %s (%s)' % \
50 (len(self.podcasts),
51 self.user[:10] if self.user else self.user_oldid,
52 self._id[:10])
55 class EpisodeAction(DocumentSchema):
56 """
57 One specific action to an episode. Must
58 always be part of a EpisodeUserState
59 """
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):
70 return False
71 vals = ('action', 'timestamp', 'device_oldid', 'started', 'playmark',
72 'total')
73 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
76 def __repr__(self):
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()
91 @classmethod
92 def for_episode(cls, episode_id):
93 db = cls.get_db()
94 r = db.view('users/chapters_by_episode',
95 startkey = [episode_id, None],
96 endkey = [episode_id, {}],
97 wrap_doc = False,
100 for res in r:
101 user = res['key'][1]
102 chapter = Chapter.wrap(res['value'])
103 yield (user, chapter)
106 def __repr__(self):
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)
127 @classmethod
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)
132 if r:
133 return r.first()
135 else:
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
147 return state
149 @classmethod
150 def count(cls):
151 r = cls.view('users/episode_states_by_user_episode',
152 limit=0)
153 return r.total_rows
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'])
180 def update(state):
181 for chapter in add:
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)
189 self.save()
191 update(state=self)
194 def __repr__(self):
195 return 'Episode-State %s (in %s)' % \
196 (self.episode, self._id)
198 def __eq__(self, other):
199 if not isinstance(other, EpisodeUserState):
200 return False
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
222 def __hash__(self):
223 return hash(self.action) + hash(self.timestamp) + hash(self.device)
225 def __repr__(self):
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()
247 @classmethod
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)
251 if r:
252 return r.first()
253 else:
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)
265 return p
268 @classmethod
269 def for_user(cls, user):
270 return cls.for_user_oldid(user.id)
273 @classmethod
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'],
277 include_docs=True)
278 return list(r)
281 @classmethod
282 def for_device(cls, device_id):
283 r = PodcastUserState.view('users/podcast_states_by_device',
284 startkey=[device_id, None], endkey=[device_id, {}],
285 include_docs=True)
286 return list(r)
289 def remove_device(self, device):
291 Removes all actions from the podcast state that refer to the
292 given device
294 self.actions = filter(lambda a: a.device != device.id, self.actions)
297 @classmethod
298 def count(cls):
299 r = PodcastUserState.view('users/podcast_states_by_user',
300 limit=0)
301 return r.total_rows
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):
328 if device.deleted:
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
341 None (no change)
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
349 now = after[-1]
351 if then is None:
352 if now.action != 'unsubscribe':
353 return now.action
354 elif then.action != now.action:
355 return now.action
356 return None
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)
366 def is_public(self):
367 return self.settings.get('public_subscription', True)
370 def __eq__(self, other):
371 if other is None:
372 return False
374 return self.podcast == other.podcast and \
375 self.user_oldid == other.user_oldid
377 def __repr__(self):
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()
391 @classmethod
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
402 from.
405 add, rem = [], []
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 )
414 return add, rem
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))
421 if 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)
428 d.sync()
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)
461 @classmethod
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:
473 if device.id == id:
474 return device
476 return None
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)
484 return
486 index = ids.index(device.id)
487 devices.pop(index)
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:
496 return
498 index = ids.index(device.id)
499 devices.pop(index)
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:
535 continue
537 if public is not None and state.is_public() != public:
538 continue
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
545 yield entry
547 if device_id is None:
548 podcast_states = PodcastUserState.for_user_oldid(self.oldid)
549 else:
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
568 after the action
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:
579 yield entry
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:
586 yield entry
589 def __repr__(self):
590 return 'User %s' % self._id
593 class HistoryEntry(object):
595 @classmethod
596 def fetch_data(cls, user, entries):
597 """ Efficiently loads additional data for a number of entries """
599 # load podcast data
600 podcast_ids = [x.podcast_id for x in entries]
601 podcasts = get_to_dict(Podcast, podcast_ids)
603 # load device data
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]
610 entry.user = user
612 return entries