move all User and EpisodetUserState db queries into separate module
[mygpo.git] / mygpo / core / models.py
blobc9cc28ee93f5be4b510aa382fe23d56925253ec5
1 from __future__ import division
3 import hashlib
4 import os.path
5 import re
6 from datetime import datetime
7 from dateutil import parser
8 from random import randint, random
10 from couchdbkit.ext.django.schema import *
11 from restkit.errors import Unauthorized
13 from django.conf import settings
14 from django.core.urlresolvers import reverse
16 from mygpo.decorators import repeat_on_conflict
17 from mygpo import utils
18 from mygpo.cache import cache_result
19 from mygpo.couch import get_main_database
20 from mygpo.core.proxy import DocumentABCMeta
21 from mygpo.core.slugs import SlugMixin
22 from mygpo.core.oldid import OldIdMixin
23 from mygpo.web.logo import CoverArt
26 class SubscriptionException(Exception):
27 pass
30 class MergedIdException(Exception):
31 """ raised when an object is accessed through one of its merged_ids """
33 def __init__(self, obj, current_id):
34 self.obj = obj
35 self.current_id = current_id
38 class Episode(Document, SlugMixin, OldIdMixin):
39 """
40 Represents an Episode. Can only be part of a Podcast
41 """
43 __metaclass__ = DocumentABCMeta
45 title = StringProperty()
46 description = StringProperty()
47 link = StringProperty()
48 released = DateTimeProperty()
49 author = StringProperty()
50 duration = IntegerProperty()
51 filesize = IntegerProperty()
52 language = StringProperty()
53 last_update = DateTimeProperty()
54 outdated = BooleanProperty(default=False)
55 mimetypes = StringListProperty()
56 merged_ids = StringListProperty()
57 urls = StringListProperty()
58 podcast = StringProperty(required=True)
59 listeners = IntegerProperty()
60 content_types = StringListProperty()
64 @property
65 def url(self):
66 return self.urls[0]
68 def __repr__(self):
69 return 'Episode %s' % self._id
73 def get_short_title(self, common_title):
74 if not self.title or not common_title:
75 return None
77 title = self.title.replace(common_title, '').strip()
78 title = re.sub(r'^[\W\d]+', '', title)
79 return title
82 def get_episode_number(self, common_title):
83 if not self.title or not common_title:
84 return None
86 title = self.title.replace(common_title, '').strip()
87 match = re.search(r'^\W*(\d+)', title)
88 if not match:
89 return None
91 return int(match.group(1))
94 def get_ids(self):
95 return set([self._id] + self.merged_ids)
98 @classmethod
99 def all(cls):
100 return utils.multi_request_view(cls, 'episodes/by_podcast',
101 reduce = False,
102 include_docs = True,
103 stale = 'update_after',
106 def __eq__(self, other):
107 if other == None:
108 return False
109 return self._id == other._id
112 def __hash__(self):
113 return hash(self._id)
116 def __str__(self):
117 return '<{cls} {title} ({id})>'.format(cls=self.__class__.__name__,
118 title=self.title, id=self._id)
120 __repr__ = __str__
123 class SubscriberData(DocumentSchema):
124 timestamp = DateTimeProperty()
125 subscriber_count = IntegerProperty()
127 def __eq__(self, other):
128 if not isinstance(other, SubscriberData):
129 return False
131 return (self.timestamp == other.timestamp) and \
132 (self.subscriber_count == other.subscriber_count)
134 def __hash__(self):
135 return hash(frozenset([self.timestamp, self.subscriber_count]))
138 class PodcastSubscriberData(Document):
139 podcast = StringProperty()
140 subscribers = SchemaListProperty(SubscriberData)
142 @classmethod
143 def for_podcast(cls, id):
144 r = cls.view('podcasts/subscriber_data', key=id, include_docs=True)
145 if r:
146 return r.first()
148 data = PodcastSubscriberData()
149 data.podcast = id
150 return data
152 def __repr__(self):
153 return 'PodcastSubscriberData for Podcast %s (%s)' % (self.podcast, self._id)
156 class Podcast(Document, SlugMixin, OldIdMixin):
158 __metaclass__ = DocumentABCMeta
160 id = StringProperty()
161 title = StringProperty()
162 urls = StringListProperty()
163 description = StringProperty()
164 link = StringProperty()
165 last_update = DateTimeProperty()
166 logo_url = StringProperty()
167 author = StringProperty()
168 merged_ids = StringListProperty()
169 group = StringProperty()
170 group_member_name = StringProperty()
171 related_podcasts = StringListProperty()
172 subscribers = SchemaListProperty(SubscriberData)
173 language = StringProperty()
174 content_types = StringListProperty()
175 tags = DictProperty()
176 restrictions = StringListProperty()
177 common_episode_title = StringProperty()
178 new_location = StringProperty()
179 latest_episode_timestamp = DateTimeProperty()
180 episode_count = IntegerProperty()
181 random_key = FloatProperty(default=random)
185 def get_podcast_by_id(self, id, current_id=False):
186 if current_id and id != self.get_id():
187 raise MergedIdException(self, self.get_id())
189 return self
192 get_podcast_by_oldid = get_podcast_by_id
193 get_podcast_by_url = get_podcast_by_id
196 def get_id(self):
197 return self.id or self._id
199 def get_ids(self):
200 return set([self.get_id()] + self.merged_ids)
202 @property
203 def display_title(self):
204 return self.title or self.url
207 def group_with(self, other, grouptitle, myname, othername):
209 if self.group and (self.group == other.group):
210 # they are already grouped
211 return
213 group1 = PodcastGroup.get(self.group) if self.group else None
214 group2 = PodcastGroup.get(other.group) if other.group else None
216 if group1 and group2:
217 raise ValueError('both podcasts already are in different groups')
219 elif not (group1 or group2):
220 group = PodcastGroup(title=grouptitle)
221 group.save()
222 group.add_podcast(self, myname)
223 group.add_podcast(other, othername)
224 return group
226 elif group1:
227 group1.add_podcast(other, othername)
228 return group1
230 else:
231 group2.add_podcast(self, myname)
232 return group2
236 def get_episode_count(self, since=None, until={}, **kwargs):
238 # use stored episode count for better performance
239 if getattr(self, 'episode_count', None) is not None:
240 return self.episode_count
242 from mygpo.db.couchdb import episode_count_for_podcast
243 return episode_count_for_podcast(self, since, until, **kwargs)
246 def get_common_episode_title(self, num_episodes=100):
248 if self.common_episode_title:
249 return self.common_episode_title
251 from mygpo.db.couchdb.episode import episodes_for_podcast
252 episodes = episodes_for_podcast(self, descending=True, limit=num_episodes)
254 # We take all non-empty titles
255 titles = filter(None, (e.title for e in episodes))
256 # get the longest common substring
257 common_title = utils.longest_substr(titles)
259 # but consider only the part up to the first number. Otherwise we risk
260 # removing part of the number (eg if a feed contains episodes 100-199)
261 common_title = re.search(r'^\D*', common_title).group(0)
263 if len(common_title.strip()) < 2:
264 return None
266 return common_title
269 @cache_result(timeout=60*60)
270 def get_latest_episode(self):
271 # since = 1 ==> has a timestamp
273 from mygpo.db.couchdb.episode import episodes_for_podcast
274 episodes = episodes_for_podcast(self, since=1, descending=True, limit=1)
275 return next(iter(episodes), None)
278 def get_episode_before(self, episode):
279 if not episode.released:
280 return None
282 from mygpo.db.couchdb.episode import episodes_for_podcast
283 prevs = episodes_for_podcast(self, until=episode.released,
284 descending=True, limit=1)
286 return next(iter(prevs), None)
289 def get_episode_after(self, episode):
290 if not episode.released:
291 return None
293 from mygpo.db.couchdb.episode import episodes_for_podcast
294 nexts = episodes_for_podcast(self, since=episode.released, limit=1)
296 return next(iter(nexts), None)
299 @property
300 def url(self):
301 return self.urls[0]
304 def get_podcast(self):
305 return self
308 def get_logo_url(self, size):
309 if self.logo_url:
310 filename = hashlib.sha1(self.logo_url).hexdigest()
311 else:
312 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
314 prefix = CoverArt.get_prefix(filename)
316 return reverse('logo', args=[size, prefix, filename])
319 def subscriber_change(self):
320 prev = self.prev_subscriber_count()
321 if prev <= 0:
322 return 0
324 return self.subscriber_count() / prev
327 def subscriber_count(self):
328 if not self.subscribers:
329 return 0
330 return self.subscribers[-1].subscriber_count
333 def prev_subscriber_count(self):
334 if len(self.subscribers) < 2:
335 return 0
336 return self.subscribers[-2].subscriber_count
339 def get_all_subscriber_data(self):
340 subdata = PodcastSubscriberData.for_podcast(self.get_id())
341 return sorted(self.subscribers + subdata.subscribers,
342 key=lambda s: s.timestamp)
345 @repeat_on_conflict()
346 def subscribe(self, user, device):
347 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
348 state = podcast_state_for_user_podcast(user, self)
349 state.subscribe(device)
350 try:
351 state.save()
352 except Unauthorized as ex:
353 raise SubscriptionException(ex)
356 @repeat_on_conflict()
357 def unsubscribe(self, user, device):
358 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
359 state = podcast_state_for_user_podcast(user, self)
360 state.unsubscribe(device)
361 try:
362 state.save()
363 except Unauthorized as ex:
364 raise SubscriptionException(ex)
367 def subscribe_targets(self, user):
369 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
370 devices/syncgroups on which the podcast is already subscribed
372 targets = []
374 subscriptions_by_devices = user.get_subscriptions_by_device()
376 for group in user.get_grouped_devices():
378 if group.is_synced:
380 dev = group.devices[0]
382 if not self.get_id() in subscriptions_by_devices[dev.id]:
383 targets.append(group.devices)
385 else:
386 for device in group.devices:
387 if not self.get_id() in subscriptions_by_devices[device.id]:
388 targets.append(device)
390 return targets
393 def __hash__(self):
394 return hash(self.get_id())
397 def __repr__(self):
398 if not self._id:
399 return super(Podcast, self).__repr__()
400 elif self.oldid:
401 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
402 else:
403 return '%s %s' % (self.__class__.__name__, self.get_id())
406 def save(self):
407 group = getattr(self, 'group', None)
408 if group: #we are part of a PodcastGroup
409 group = PodcastGroup.get(group)
410 podcasts = list(group.podcasts)
412 if not self in podcasts:
413 # the podcast has not been added to the group correctly
414 group.add_podcast(self)
416 else:
417 i = podcasts.index(self)
418 podcasts[i] = self
419 group.podcasts = podcasts
420 group.save()
422 i = podcasts.index(self)
423 podcasts[i] = self
424 group.podcasts = podcasts
425 group.save()
427 else:
428 super(Podcast, self).save()
431 def delete(self):
432 group = getattr(self, 'group', None)
433 if group:
434 group = PodcastGroup.get(group)
435 podcasts = list(group.podcasts)
437 if self in podcasts:
438 i = podcasts.index(self)
439 del podcasts[i]
440 group.podcasts = podcasts
441 group.save()
443 else:
444 super(Podcast, self).delete()
447 def __eq__(self, other):
448 if not self.get_id():
449 return self == other
451 if other == None:
452 return False
454 return self.get_id() == other.get_id()
458 class PodcastGroup(Document, SlugMixin, OldIdMixin):
459 title = StringProperty()
460 podcasts = SchemaListProperty(Podcast)
462 def get_id(self):
463 return self._id
466 @classmethod
467 def for_slug_id(cls, slug_id):
468 """ Returns the Podcast for either an CouchDB-ID for a Slug """
470 if utils.is_couchdb_id(slug_id):
471 return cls.get(slug_id)
472 else:
473 #TODO: implement
474 return cls.for_slug(slug_id)
477 def get_podcast_by_id(self, id, current_id=False):
478 for podcast in self.podcasts:
479 if podcast.get_id() == id:
480 return podcast
482 if id in podcast.merged_ids:
483 if current_id:
484 raise MergedIdException(podcast, podcast.get_id())
486 return podcast
489 def get_podcast_by_oldid(self, oldid):
490 for podcast in list(self.podcasts):
491 if podcast.oldid == oldid:
492 return podcast
495 def get_podcast_by_url(self, url):
496 for podcast in self.podcasts:
497 if url in list(podcast.urls):
498 return podcast
501 def subscriber_change(self):
502 prev = self.prev_subscriber_count()
503 if not prev:
504 return 0
506 return self.subscriber_count() / prev
509 def subscriber_count(self):
510 return sum([p.subscriber_count() for p in self.podcasts])
513 def prev_subscriber_count(self):
514 return sum([p.prev_subscriber_count() for p in self.podcasts])
516 @property
517 def display_title(self):
518 return self.title
521 def get_podcast(self):
522 # return podcast with most subscribers (bug 1390)
523 return sorted(self.podcasts, key=Podcast.subscriber_count,
524 reverse=True)[0]
527 @property
528 def logo_url(self):
529 return utils.first(p.logo_url for p in self.podcasts)
532 def get_logo_url(self, size):
533 if self.logo_url:
534 filename = hashlib.sha1(self.logo_url).hexdigest()
535 else:
536 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
538 prefix = CoverArt.get_prefix(filename)
540 return reverse('logo', args=[size, prefix, filename])
543 def add_podcast(self, podcast, member_name):
545 if not self._id:
546 raise ValueError('group has to have an _id first')
548 if not podcast._id:
549 raise ValueError('podcast needs to have an _id first')
551 if not podcast.id:
552 podcast.id = podcast._id
554 podcast.delete()
555 podcast.group = self._id
556 podcast.group_member_name = member_name
557 self.podcasts = sorted(self.podcasts + [podcast],
558 key=Podcast.subscriber_count, reverse=True)
559 self.save()
562 def __repr__(self):
563 if not self._id:
564 return super(PodcastGroup, self).__repr__()
565 elif self.oldid:
566 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
567 else:
568 return '%s %s' % (self.__class__.__name__, self._id[:10])
571 class SanitizingRuleStub(object):
572 pass
574 class SanitizingRule(Document):
575 slug = StringProperty()
576 applies_to = StringListProperty()
577 search = StringProperty()
578 replace = StringProperty()
579 priority = IntegerProperty()
580 description = StringProperty()
583 @classmethod
584 def for_obj_type(cls, obj_type):
585 r = cls.view('sanitizing_rules/by_target', include_docs=True,
586 startkey=[obj_type, None], endkey=[obj_type, {}])
588 for rule in r:
589 obj = SanitizingRuleStub()
590 obj.slug = rule.slug
591 obj.applies_to = list(rule.applies_to)
592 obj.search = rule.search
593 obj.replace = rule.replace
594 obj.priority = rule.priority
595 obj.description = rule.description
596 yield obj
599 @classmethod
600 def for_slug(cls, slug):
601 r = cls.view('sanitizing_rules/by_slug', include_docs=True,
602 key=slug)
603 return r.one() if r else None
606 def __repr__(self):
607 return 'SanitizingRule %s' % self._id