[Subscriptions] add Subscription, HistoryEntry
[mygpo.git] / mygpo / podcasts / models.py
blob7ef3ed64643ba14fae2ace7a34cd23c8f83c1f01
1 from __future__ import unicode_literals
3 import re
4 from datetime import datetime
6 from django.conf import settings
7 from django.db import models, transaction, IntegrityError
8 from django.contrib.contenttypes.models import ContentType
9 from django.contrib.contenttypes.fields import GenericRelation
10 from django.contrib.contenttypes import generic
12 from uuidfield import UUIDField
14 from mygpo import utils
15 from mygpo.core.models import (TwitterModel, UUIDModel, GenericManager,
16 UpdateInfoModel)
18 import logging
19 logger = logging.getLogger(__name__)
22 # default podcast update interval in hours
23 DEFAULT_UPDATE_INTERVAL = 7 * 24
25 # minium podcast update interval in hours
26 MIN_UPDATE_INTERVAL = 5
28 # every podcast should be updated at least once a month
29 MAX_UPDATE_INTERVAL = 24 * 30
32 class TitleModel(models.Model):
33 """ Model that has a title """
35 title = models.CharField(max_length=1000, null=False, blank=True,
36 db_index=True)
37 subtitle = models.TextField(null=False, blank=True)
39 def __str__(self):
40 return self.title.encode('ascii', errors='replace')
42 def __unicode(self):
43 return self.title
45 class Meta:
46 abstract = True
49 class DescriptionModel(models.Model):
50 """ Model that has a description """
52 description = models.TextField(null=False, blank=True)
54 class Meta:
55 abstract = True
58 class LinkModel(models.Model):
59 """ Model that has a link """
61 link = models.URLField(null=True, max_length=1000)
63 class Meta:
64 abstract = True
67 class LanguageModel(models.Model):
68 """ Model that has a language """
70 language = models.CharField(max_length=10, null=True, blank=False,
71 db_index=True)
73 class Meta:
74 abstract = True
77 class LastUpdateModel(models.Model):
78 """ Model with timestamp of last update from its source """
80 # date and time at which the model has last been updated from its source
81 # (eg a podcast feed). None means that the object has been created as a
82 # stub, without information from the source.
83 last_update = models.DateTimeField(null=True)
85 class Meta:
86 abstract = True
89 class LicenseModel(models.Model):
90 # URL to a license (usually Creative Commons)
91 license = models.CharField(max_length=100, null=True, blank=False,
92 db_index=True)
94 class Meta:
95 abstract = True
98 class FlattrModel(models.Model):
99 # A Flattr payment URL
100 flattr_url = models.URLField(null=True, blank=False, max_length=1000,
101 db_index=True)
103 class Meta:
104 abstract = True
107 class ContentTypesModel(models.Model):
108 # contains a comma-separated values of content types, eg 'audio,video'
109 content_types = models.CharField(max_length=20, null=False, blank=True)
111 class Meta:
112 abstract = True
115 class MergedIdsModel(models.Model):
117 class Meta:
118 abstract = True
121 class OutdatedModel(models.Model):
122 outdated = models.BooleanField(default=False, db_index=True)
124 class Meta:
125 abstract = True
128 class AuthorModel(models.Model):
129 author = models.CharField(max_length=350, null=True, blank=True)
131 class Meta:
132 abstract = True
135 class UrlsMixin(models.Model):
136 """ Methods for working with URL objects """
138 urls = GenericRelation('URL', related_query_name='urls')
140 class Meta:
141 abstract = True
143 @property
144 def url(self):
145 """ The main URL of the model """
146 # We could also use self.urls.first() here, but this would result in a
147 # different query and would render a .prefetch_related('urls') useless
148 # The assumption is that we will never have loads of URLS, so
149 # fetching all won't hurt
150 urls = list(self.urls.all())
151 return urls[0].url if urls else None
153 def add_missing_urls(self, new_urls):
154 """ Adds missing URLS from new_urls
156 The order of existing URLs is not changed """
157 existing_urls = self.urls.all()
158 next_order = max([-1] + [u.order for u in existing_urls]) + 1
159 existing_urls = [u.url for u in existing_urls]
161 for url in new_urls:
162 if url in existing_urls:
163 continue
165 URL.objects.create(url=url,
166 order=next_order,
167 scope=self.scope,
168 content_object=self,
171 next_order += 1
174 class SlugsMixin(models.Model):
175 """ Methods for working with Slug objects """
177 slugs = GenericRelation('Slug', related_query_name='slugs')
179 class Meta:
180 abstract = True
182 @property
183 def slug(self):
184 """ The main slug of the podcast
186 TODO: should be retrieved from a (materialized) view """
188 # We could also use self.slugs.first() here, but this would result in a
189 # different query and would render a .prefetch_related('slugs') useless
190 # The assumption is that we will never have loads of slugs, so
191 # fetching all won't hurt
192 slugs = list(self.slugs.all())
193 slug = slugs[0].slug if slugs else None
194 logger.debug('Found slugs %r, picking %r', slugs, slug)
195 return slug
198 def add_slug(self, slug):
199 """ Adds a (non-cannonical) slug """
201 if not slug:
202 raise ValueError("'%s' is not a valid slug" % slug)
204 existing_slugs = self.slugs.all()
206 # check if slug already exists
207 if slug in [s.slug for s in existing_slugs]:
208 return
210 max_order = max([-1] + [s.order for s in existing_slugs])
211 next_order = max_order + 1
212 Slug.objects.create(scope=self.scope,
213 slug=slug,
214 content_object=self,
215 order=next_order,
218 def set_slug(self, slug):
219 """ Sets the canonical slug """
221 slugs = [s.slug for s in self.slugs.all()]
222 if slug in slugs:
223 slugs.remove(slug)
225 slugs.insert(0, slug)
226 self.set_slugs(slugs)
229 def remove_slug(self, slug):
230 """ Removes a slug """
231 Slug.objects.filter(
232 slug=slug,
233 content_type=ContentType.objects.get_for_model(self),
234 object_id=self.id,
235 ).delete()
238 def set_slugs(self, slugs):
239 """ Update the object's slugs to the given list
241 'slugs' should be a list of strings. Slugs that do not exist are
242 created. Existing slugs that are not in the 'slugs' list are
243 deleted. """
244 existing = {s.slug: s for s in self.slugs.all()}
245 logger.info('%d existing slugs', len(existing))
247 logger.info('%d new slugs', len(slugs))
249 with transaction.atomic():
250 max_order = max([s.order for s in existing.values()] + [len(slugs)])
251 logger.info('Renumbering slugs starting from %d', max_order+1)
252 for n, slug in enumerate(existing.values(), max_order+1):
253 slug.order = n
254 slug.save()
256 logger.info('%d existing slugs', len(existing))
258 for n, slug in enumerate(slugs):
259 try:
260 s = existing.pop(slug)
261 logger.info('Updating new slug %d: %s', n, slug)
262 s.order = n
263 s.save()
264 except KeyError:
265 logger.info('Creating new slug %d: %s', n, slug)
266 try:
267 Slug.objects.create(slug=slug,
268 content_object=self,
269 order=n,
270 scope=self.scope,
272 except IntegrityError as ie:
273 logger.warn('Could not create Slug for %s: %s', self, ie)
275 with transaction.atomic():
276 delete = [s.pk for s in existing.values()]
277 logger.info('Deleting %d slugs', len(delete))
278 Slug.objects.filter(id__in=delete).delete()
282 class MergedUUIDsMixin(models.Model):
283 """ Methods for working with MergedUUID objects """
285 merged_uuids = GenericRelation('MergedUUID',
286 related_query_name='merged_uuids')
288 class Meta:
289 abstract = True
292 class MergedUUIDQuerySet(models.QuerySet):
293 """ QuerySet for Models inheriting from MergedUUID """
295 def get_by_any_id(self, id):
296 """ Find am Episode by its own ID or by a merged ID """
297 # TODO: should this be done in the model?
298 try:
299 return self.get(id=id)
300 except self.model.DoesNotExist:
301 return self.get(merged_uuids__uuid=id)
304 class TagsMixin(models.Model):
305 """ Methods for working with Tag objects """
307 tags = GenericRelation('Tag', related_query_name='tags')
309 class Meta:
310 abstract = True
313 class OrderedModel(models.Model):
314 """ A model that can be ordered
316 The implementing Model must make sure that 'order' is sufficiently unique
319 order = models.PositiveSmallIntegerField()
321 class Meta:
322 abstract = True
323 ordering = ['order']
326 class PodcastGroup(UUIDModel, TitleModel, SlugsMixin):
327 """ Groups multiple podcasts together """
329 @property
330 def scope(self):
331 """ A podcast group is always in the global scope """
332 return ''
334 def subscriber_count(self):
335 # this could be done directly in the DB
336 return sum([p.subscriber_count() for p in self.podcast_set.all()] + [0])
338 class PodcastQuerySet(MergedUUIDQuerySet):
339 """ Custom queries for Podcasts """
341 def random(self):
342 """ Random podcasts
344 Excludes podcasts with missing title to guarantee some
345 minimum quality of the results """
347 # Using PostgreSQL's RANDOM() is very expensive, so we're generating a
348 # random uuid and query podcasts with a higher ID
349 # This returns podcasts in order of their ID, but the assumption is
350 # that usually only one podcast will be required anyway
351 import uuid
352 ruuid = uuid.uuid1()
353 return self.exclude(title='').filter(id__gt=ruuid)
355 def flattr(self):
356 """ Podcasts providing Flattr information """
357 return self.exclude(flattr_url__isnull=True)
359 def license(self, license_url=None):
360 """ Podcasts with any / the given license """
361 if license_url:
362 return self.filter(license=license_url)
363 else:
364 return self.exclude(license__isnull=True)
366 def order_by_next_update(self):
367 """ Sort podcasts by next scheduled update """
368 NEXTUPDATE = "last_update + (update_interval || ' hours')::INTERVAL"
369 q = self.extra(select={'next_update': NEXTUPDATE})
370 return q.order_by('next_update')
372 def toplist(self, language=None):
373 toplist = self
374 if language:
375 toplist = toplist.filter(language=language)
377 return toplist.order_by('-subscribers')
380 class PodcastManager(GenericManager):
381 """ Manager for the Podcast model """
383 def get_queryset(self):
384 return PodcastQuerySet(self.model, using=self._db)
386 @transaction.atomic
387 def get_or_create_for_url(self, url, defaults={}):
388 # TODO: where to specify how uuid is created?
389 import uuid
390 defaults.update({
391 'id': uuid.uuid1().hex,
394 url = utils.to_maxlength(URL, 'url', url)
395 podcast, created = self.get_or_create(urls__url=url, defaults=defaults)
397 if created:
398 url = URL.objects.create(url=url,
399 order=0,
400 scope='',
401 content_object=podcast,
403 return podcast
406 class Podcast(UUIDModel, TitleModel, DescriptionModel, LinkModel,
407 LanguageModel, LastUpdateModel, UpdateInfoModel, LicenseModel,
408 FlattrModel, ContentTypesModel, MergedIdsModel, OutdatedModel,
409 AuthorModel, UrlsMixin, SlugsMixin, TagsMixin, MergedUUIDsMixin,
410 TwitterModel, ):
411 """ A Podcast """
413 logo_url = models.URLField(null=True, max_length=1000)
414 group = models.ForeignKey(PodcastGroup, null=True,
415 on_delete=models.PROTECT)
416 group_member_name = models.CharField(max_length=30, null=True, blank=False)
418 # if p1 is related to p2, p2 is also related to p1
419 related_podcasts = models.ManyToManyField('self', symmetrical=True)
421 subscribers = models.PositiveIntegerField(default=0)
422 restrictions = models.CharField(max_length=20, null=False, blank=True,
423 default='')
424 common_episode_title = models.CharField(max_length=100, null=False, blank=True)
425 new_location = models.URLField(max_length=1000, null=True, blank=False)
426 latest_episode_timestamp = models.DateTimeField(null=True)
427 episode_count = models.PositiveIntegerField(default=0)
428 hub = models.URLField(null=True)
429 update_interval = models.PositiveSmallIntegerField(null=False,
430 default=DEFAULT_UPDATE_INTERVAL)
432 objects = PodcastManager()
434 def subscriber_count(self):
435 # TODO: implement
436 return self.subscribers
438 def group_with(self, other, grouptitle, myname, othername):
439 """ Group the podcast with another one """
440 # TODO: move to PodcastGroup?
442 if bool(self.group) and (self.group == other.group):
443 # they are already grouped
444 return
446 group1 = self.group
447 group2 = other.group
449 if group1 and group2:
450 raise ValueError('both podcasts already are in different groups')
452 elif not (group1 or group2):
453 # Form a new group
454 import uuid
455 group = PodcastGroup.objects.create(id=uuid.uuid1(), title=grouptitle)
456 self.group_member_name = myname
457 self.group = group
458 self.save()
460 other.group_member_name = othername
461 other.group = group
462 other.save()
464 return group
466 elif group1:
467 # add other to self's group
468 other.group_member_name = othername
469 other.group = group1
470 other.save()
471 return group1
473 else:
474 # add self to other's group
475 self.group_member_name = myname
476 self.group = group2
477 self.save()
478 return group2
481 def subscribe_targets(self, user):
483 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
484 devices/syncgroups on which the podcast is already subscribed
486 targets = []
488 from mygpo.users.subscriptions import get_subscriptions_by_device
489 from mygpo.users.models import UserProxy
490 subscriptions_by_devices = get_subscriptions_by_device(user)
492 user = UserProxy.objects.from_user(user)
493 for group in user.get_grouped_devices():
495 if group.is_synced:
497 dev = group.devices[0]
499 if not self.get_id() in subscriptions_by_devices[dev.id]:
500 targets.append(group.devices)
502 else:
503 for device in group.devices:
504 if not self.get_id() in subscriptions_by_devices[device.id]:
505 targets.append(device)
507 return targets
510 def get_common_episode_title(self, num_episodes=100):
512 if self.common_episode_title:
513 return self.common_episode_title
515 episodes = self.episode_set.all()[:num_episodes]
517 # We take all non-empty titles
518 titles = filter(None, (e.title for e in episodes))
520 # there can not be a "common" title of a single title
521 if len(titles) < 2:
522 return None
524 # get the longest common substring
525 common_title = utils.longest_substr(titles)
527 # but consider only the part up to the first number. Otherwise we risk
528 # removing part of the number (eg if a feed contains episodes 100-199)
529 common_title = re.search(r'^\D*', common_title).group(0)
531 if len(common_title.strip()) < 2:
532 return None
534 return common_title
537 def get_episode_before(self, episode):
538 if not episode.released:
539 return None
540 return self.episode_set.filter(released__lt=episode.released).latest()
542 def get_episode_after(self, episode):
543 if not episode.released:
544 return None
545 return self.episode_set.filter(released__gt=episode.released).first()
547 @property
548 def scope(self):
549 """ A podcast is always in the global scope """
550 return ''
552 @property
553 def display_title(self):
554 # TODO
555 return self.title
558 class EpisodeQuerySet(MergedUUIDQuerySet):
559 """ QuerySet for Episodes """
561 def toplist(self, language=None):
562 toplist = self
563 if language:
564 toplist = toplist.filter(language=language)
566 return toplist.order_by('-listeners')
568 def by_released(self):
569 """ Sorts by release date, sorting missing release date last
571 When sorting by release date, we want to list those with the most
572 revent release date first. At the end the episodes without release date
573 should be sorted. """
574 return self.extra(select={
575 'has_released': 'released IS NOT NULL',
576 }).\
577 order_by('-has_released', '-released')
580 class EpisodeManager(GenericManager):
581 """ Custom queries for Episodes """
583 def get_queryset(self):
584 return EpisodeQuerySet(self.model, using=self._db)
586 @transaction.atomic
587 def get_or_create_for_url(self, podcast, url, defaults={}):
588 # TODO: where to specify how uuid is created?
589 import uuid
590 defaults.update({
591 'id': uuid.uuid1().hex,
593 episode, created = self.get_or_create(podcast=podcast,
594 urls__url=url,
595 defaults=defaults,
598 if created:
599 url = URL.objects.create(url=url,
600 order=0,
601 scope=podcast.get_id(),
602 content_object=episode,
604 return episode
606 class Episode(UUIDModel, TitleModel, DescriptionModel, LinkModel,
607 LanguageModel, LastUpdateModel, UpdateInfoModel, LicenseModel,
608 FlattrModel, ContentTypesModel, MergedIdsModel, OutdatedModel,
609 AuthorModel, UrlsMixin, SlugsMixin, MergedUUIDsMixin):
610 """ An episode """
612 guid = models.CharField(max_length=200, null=True)
613 content = models.TextField()
614 released = models.DateTimeField(null=True, db_index=True)
615 duration = models.PositiveIntegerField(null=True)
616 filesize = models.BigIntegerField(null=True)
617 mimetypes = models.CharField(max_length=200)
618 podcast = models.ForeignKey(Podcast, on_delete=models.PROTECT)
619 listeners = models.PositiveIntegerField(null=True, db_index=True)
621 objects = EpisodeManager()
623 class Meta:
624 ordering = ['-released']
626 @property
627 def scope(self):
628 """ An episode's scope is its podcast """
629 return self.podcast_id.hex
631 def get_short_title(self, common_title):
632 """ Title when used within the podcast's context """
633 if not self.title or not common_title:
634 return None
636 title = self.title.replace(common_title, '').strip()
637 title = re.sub(r'^[\W\d]+', '', title)
638 return title
641 def get_episode_number(self, common_title):
642 """ Number of the episode """
643 if not self.title or not common_title:
644 return None
646 title = self.title.replace(common_title, '').strip()
647 match = re.search(r'^\W*(\d+)', title)
648 if not match:
649 return None
651 return int(match.group(1))
654 class ScopedModel(models.Model):
655 """ A model that belongs to some scope, usually for limited uniqueness
657 scope does not allow null values, because null is not equal to null in SQL.
658 It could therefore not be used in unique constraints. """
660 # A slug / URL is unique within a scope; no two podcasts can have the same
661 # URL (scope ''), and no two episdoes of the same podcast (scope =
662 # podcast-ID) can have the same URL
663 scope = models.CharField(max_length=32, null=False, blank=True,
664 db_index=True)
666 class Meta:
667 abstract = True
670 class URL(OrderedModel, ScopedModel):
671 """ Podcasts and Episodes can have multiple URLs
673 URLs are ordered, and the first slug is considered the canonical one """
675 url = models.URLField(max_length=2048)
677 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
678 content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT)
679 object_id = UUIDField()
680 content_object = generic.GenericForeignKey('content_type', 'object_id')
682 class Meta(OrderedModel.Meta):
683 unique_together = (
684 # a URL is unique per scope
685 ('url', 'scope'),
687 # URLs of an object must be ordered, so that no two slugs of one
688 # object have the same order key
689 ('content_type', 'object_id', 'order'),
692 verbose_name = 'URL'
693 verbose_name_plural = 'URLs'
696 class Tag(models.Model):
697 """ Tags any kind of Model
699 See also :class:`TagsMixin`
702 FEED = 1
703 DELICIOUS = 2
704 USER = 4
706 SOURCE_CHOICES = (
707 (FEED, 'Feed'),
708 (DELICIOUS, 'delicious'),
709 (USER, 'User'),
712 tag = models.SlugField()
714 # indicates where the tag came from
715 source = models.PositiveSmallIntegerField(choices=SOURCE_CHOICES)
717 # the user that created the tag (if it was created by a user,
718 # null otherwise)
719 user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
720 on_delete=models.CASCADE)
722 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
723 content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT)
724 object_id = UUIDField()
725 content_object = generic.GenericForeignKey('content_type', 'object_id')
727 class Meta:
728 unique_together = (
729 # a tag can only be assigned once from one source to one item
730 ('tag', 'source', 'user', 'content_type', 'object_id'),
734 class Slug(OrderedModel, ScopedModel):
735 """ Slug for any kind of Model
737 Slugs are ordered, and the first slug is considered the canonical one.
738 See also :class:`SlugsMixin`
741 slug = models.SlugField(max_length=150, db_index=True)
743 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
744 content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT)
745 object_id = UUIDField()
746 content_object = generic.GenericForeignKey('content_type', 'object_id')
748 class Meta(OrderedModel.Meta):
749 unique_together = (
750 # a slug is unique per type; eg a podcast can have the same slug
751 # as an episode, but no two podcasts can have the same slug
752 ('slug', 'scope'),
754 # slugs of an object must be ordered, so that no two slugs of one
755 # object have the same order key
756 ('content_type', 'object_id', 'order'),
759 def __repr__(self):
760 return '{cls}(slug={slug}, order={order}, content_object={obj}'.format(
761 cls=self.__class__.__name__,
762 slug=self.slug,
763 order=self.order,
764 obj=self.content_object
768 class MergedUUID(models.Model):
769 """ If objects are merged their UUIDs are stored for later reference
771 see also :class:`MergedUUIDsMixin`
774 uuid = UUIDField(unique=True)
776 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
777 content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT)
778 object_id = UUIDField()
779 content_object = generic.GenericForeignKey('content_type', 'object_id')
781 class Meta:
782 verbose_name = 'Merged UUID'
783 verbose_name_plural = 'Merged UUIDs'