[Models] Add index to Podcast.released
[mygpo.git] / mygpo / podcasts / models.py
blobe8082217a54a3e726b6e928a20d7ca8def3d94e3
1 from __future__ import unicode_literals
3 from datetime import datetime
5 from django.db import models, transaction
6 from django.contrib.contenttypes.models import ContentType
7 from django.contrib.contenttypes.fields import GenericRelation
8 from django.contrib.contenttypes import generic
10 from uuidfield import UUIDField
13 # default podcast update interval in hours
14 DEFAULT_UPDATE_INTERVAL = 7 * 24
16 # minium podcast update interval in hours
17 MIN_UPDATE_INTERVAL = 5
19 # every podcast should be updated at least once a month
20 MAX_UPDATE_INTERVAL = 24 * 30
23 class UUIDModel(models.Model):
24 """ Models that have an UUID as primary key """
26 id = UUIDField(primary_key=True)
28 class Meta:
29 abstract = True
31 def get_id(self):
32 """ String representation of the ID """
33 return self.id.hex
36 class TitleModel(models.Model):
37 """ Model that has a title """
39 title = models.CharField(max_length=1000, null=False, blank=True,
40 db_index=True)
41 subtitle = models.TextField(null=False, blank=True)
43 def __str__(self):
44 return self.title.encode('ascii', errors='replace')
46 def __unicode(self):
47 return self.title
49 class Meta:
50 abstract = True
53 class DescriptionModel(models.Model):
54 """ Model that has a description """
56 description = models.TextField(null=False, blank=True)
58 class Meta:
59 abstract = True
62 class LinkModel(models.Model):
63 """ Model that has a link """
65 link = models.URLField(null=True, max_length=1000)
67 class Meta:
68 abstract = True
71 class LanguageModel(models.Model):
72 """ Model that has a language """
74 language = models.CharField(max_length=10, null=True, blank=False,
75 db_index=True)
77 class Meta:
78 abstract = True
81 class LastUpdateModel(models.Model):
82 """ Model with timestamp of last update from its source """
84 # date and time at which the model has last been updated from its source
85 # (eg a podcast feed). None means that the object has been created as a
86 # stub, without information from the source.
87 last_update = models.DateTimeField(null=True)
89 class Meta:
90 abstract = True
93 class UpdateInfoModel(models.Model):
95 # this does not use "auto_now_add=True" so that data
96 # can be migrated with its creation timestamp intact; it can be
97 # switched on after the migration is complete
98 created = models.DateTimeField(default=datetime.utcnow)
99 modified = models.DateTimeField(auto_now=True)
101 class Meta:
102 abstract = True
105 class LicenseModel(models.Model):
106 # URL to a license (usually Creative Commons)
107 license = models.CharField(max_length=100, null=True, blank=False,
108 db_index=True)
110 class Meta:
111 abstract = True
114 class FlattrModel(models.Model):
115 # A Flattr payment URL
116 flattr_url = models.URLField(null=True, blank=False, max_length=1000,
117 db_index=True)
119 class Meta:
120 abstract = True
123 class ContentTypesModel(models.Model):
124 # contains a comma-separated values of content types, eg 'audio,video'
125 content_types = models.CharField(max_length=20, null=False, blank=True)
127 class Meta:
128 abstract = True
131 class MergedIdsModel(models.Model):
133 class Meta:
134 abstract = True
137 class OutdatedModel(models.Model):
138 outdated = models.BooleanField(default=False, db_index=True)
140 class Meta:
141 abstract = True
144 class AuthorModel(models.Model):
145 author = models.CharField(max_length=350, null=True, blank=True)
147 class Meta:
148 abstract = True
151 class UrlsMixin(models.Model):
152 """ Methods for working with URL objects """
154 urls = GenericRelation('URL', related_query_name='urls')
156 class Meta:
157 abstract = True
159 @property
160 def url(self):
161 """ The main URL of the model """
162 # We could also use self.urls.first() here, but this would result in a
163 # different query and would render a .prefetch_related('urls') useless
164 # The assumption is that we will never have loads of URLS, so
165 # fetching all won't hurt
166 urls = list(self.urls.all())
167 return urls[0].url if urls else None
171 class SlugsMixin(models.Model):
172 """ Methods for working with Slug objects """
174 slugs = GenericRelation('Slug', related_query_name='slugs')
176 class Meta:
177 abstract = True
179 @property
180 def slug(self):
181 """ The main slug of the podcast
183 TODO: should be retrieved from a (materialized) view """
185 # We could also use self.slugs.first() here, but this would result in a
186 # different query and would render a .prefetch_related('slugs') useless
187 # The assumption is that we will never have loads of slugs, so
188 # fetching all won't hurt
189 slugs = list(self.slugs.all())
190 return slugs[0].slug if slugs else None
195 class MergedUUIDsMixin(models.Model):
196 """ Methods for working with MergedUUID objects """
198 merged_uuids = GenericRelation('MergedUUID',
199 related_query_name='merged_uuids')
201 class Meta:
202 abstract = True
204 class TagsMixin(models.Model):
205 """ Methods for working with Tag objects """
207 tags = GenericRelation('Tag', related_query_name='tags')
209 class Meta:
210 abstract = True
213 class OrderedModel(models.Model):
214 """ A model that can be ordered
216 The implementing Model must make sure that 'order' is sufficiently unique
219 order = models.PositiveSmallIntegerField()
221 class Meta:
222 abstract = True
223 ordering = ['order']
226 class PodcastGroup(UUIDModel, TitleModel, SlugsMixin):
227 """ Groups multiple podcasts together """
229 @property
230 def scope(self):
231 """ A podcast group is always in the global scope """
232 return ''
235 class PodcastQuerySet(models.QuerySet):
236 """ Custom queries for Podcasts """
238 def random(self):
239 """ Random podcasts
241 Excludes podcasts with missing title to guarantee some
242 minimum quality of the results """
243 return self.exclude(title='').order_by('?')
245 def flattr(self):
246 """ Podcasts providing Flattr information """
247 return self.exclude(flattr_url__isnull=True)
249 def license(self, license_url=None):
250 """ Podcasts with any / the given license """
251 if license_url:
252 return self.filter(license=license_url)
253 else:
254 return self.exclude(license__isnull=True)
256 def order_by_next_update(self):
257 """ Sort podcasts by next scheduled update """
258 NEXTUPDATE = "last_update + (update_interval || ' hours')::INTERVAL"
259 q = self.extra(select={'next_update': NEXTUPDATE})
260 return q.order_by('next_update')
262 @transaction.atomic
263 def get_or_create_for_url(self, url):
264 # TODO: where to specify how uuid is created?
265 import uuid
266 podcast, created = self.get_or_create(urls__url=url,
267 defaults={
268 'id': uuid.uuid1().hex,
271 if created:
272 url = URL.objects.create(url=url,
273 order=0,
274 scope='',
275 content_object=podcast,
277 return podcast
279 def get_by_any_id(self, id):
280 """ Find a Podcast by its own ID or by a merged ID """
281 # TODO: should this be done in the model?
282 try:
283 return self.get(id=id)
284 except self.model.DoesNotExist:
285 return self.get(merged_uuids__uuid=id)
288 class Podcast(UUIDModel, TitleModel, DescriptionModel, LinkModel,
289 LanguageModel, LastUpdateModel, UpdateInfoModel, LicenseModel,
290 FlattrModel, ContentTypesModel, MergedIdsModel, OutdatedModel,
291 AuthorModel, UrlsMixin, SlugsMixin, TagsMixin, MergedUUIDsMixin):
292 """ A Podcast """
294 logo_url = models.URLField(null=True, max_length=1000)
295 group = models.ForeignKey(PodcastGroup, null=True)
296 group_member_name = models.CharField(max_length=30, null=True, blank=False)
298 # if p1 is related to p2, p2 is also related to p1
299 related_podcasts = models.ManyToManyField('self', symmetrical=True)
301 subscribers = models.PositiveIntegerField(default=0)
302 restrictions = models.CharField(max_length=20, null=False, blank=True,
303 default='')
304 common_episode_title = models.CharField(max_length=100, null=False, blank=True)
305 new_location = models.URLField(max_length=1000, null=True, blank=False)
306 latest_episode_timestamp = models.DateTimeField(null=True)
307 episode_count = models.PositiveIntegerField(default=0)
308 hub = models.URLField(null=True)
309 twitter = models.CharField(max_length=15, null=True, blank=False)
310 update_interval = models.PositiveSmallIntegerField(null=False,
311 default=DEFAULT_UPDATE_INTERVAL)
313 objects = PodcastQuerySet.as_manager()
315 def subscriber_count(self):
316 # TODO: implement
317 return 0
319 def group_with(self, other, grouptitle, myname, othername):
320 """ Group the podcast with another one """
321 # TODO: move to PodcastGroup?
323 if bool(self.group) and (self.group == other.group):
324 # they are already grouped
325 return
327 group1 = self.group
328 group2 = other.group
330 if group1 and group2:
331 raise ValueError('both podcasts already are in different groups')
333 elif not (group1 or group2):
334 # Form a new group
335 import uuid
336 group = PodcastGroup.objects.create(id=uuid.uuid1(), title=grouptitle)
337 self.group_member_name = myname
338 self.group = group
339 self.save()
341 other.group_member_name = othername
342 other.group = group
343 other.save()
345 return group
347 elif group1:
348 # add other to self's group
349 other.group_member_name = othername
350 other.group = group1
351 other.save()
352 return group1
354 else:
355 # add self to other's group
356 self.group_member_name = myname
357 self.group = group2
358 self.save()
359 return group2
362 def subscribe_targets(self, user):
364 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
365 devices/syncgroups on which the podcast is already subscribed
367 targets = []
369 subscriptions_by_devices = user.get_subscriptions_by_device()
371 for group in user.get_grouped_devices():
373 if group.is_synced:
375 dev = group.devices[0]
377 if not self.get_id() in subscriptions_by_devices[dev.id]:
378 targets.append(group.devices)
380 else:
381 for device in group.devices:
382 if not self.get_id() in subscriptions_by_devices[device.id]:
383 targets.append(device)
385 return targets
388 def get_common_episode_title(self, num_episodes=100):
390 if self.common_episode_title:
391 return self.common_episode_title
393 episodes = self.episode_set.all()[:num_episodes]
395 # We take all non-empty titles
396 titles = filter(None, (e.title for e in episodes))
398 # there can not be a "common" title of a single title
399 if len(titles) < 2:
400 return None
402 # get the longest common substring
403 common_title = utils.longest_substr(titles)
405 # but consider only the part up to the first number. Otherwise we risk
406 # removing part of the number (eg if a feed contains episodes 100-199)
407 common_title = re.search(r'^\D*', common_title).group(0)
409 if len(common_title.strip()) < 2:
410 return None
412 return common_title
414 @property
415 def scope(self):
416 """ A podcast is always in the global scope """
417 return ''
420 class Episode(UUIDModel, TitleModel, DescriptionModel, LinkModel,
421 LanguageModel, LastUpdateModel, UpdateInfoModel, LicenseModel,
422 FlattrModel, ContentTypesModel, MergedIdsModel, OutdatedModel,
423 AuthorModel, UrlsMixin, SlugsMixin, MergedUUIDsMixin):
424 """ An episode """
426 guid = models.CharField(max_length=200, null=True)
427 content = models.TextField()
428 released = models.DateTimeField(null=True, db_index=True)
429 duration = models.PositiveIntegerField(null=True)
430 filesize = models.BigIntegerField(null=True)
431 mimetypes = models.CharField(max_length=100)
432 podcast = models.ForeignKey(Podcast)
433 listeners = models.PositiveIntegerField(null=True)
435 class Meta:
436 ordering = ['-released']
438 @property
439 def scope(self):
440 """ An episode's scope is its podcast """
441 return self.podcast_id.hex
443 def get_short_title(self, common_title):
444 """ Title when used within the podcast's context """
445 if not self.title or not common_title:
446 return None
448 title = self.title.replace(common_title, '').strip()
449 title = re.sub(r'^[\W\d]+', '', title)
450 return title
453 def get_episode_number(self, common_title):
454 """ Number of the episode """
455 if not self.title or not common_title:
456 return None
458 title = self.title.replace(common_title, '').strip()
459 match = re.search(r'^\W*(\d+)', title)
460 if not match:
461 return None
463 return int(match.group(1))
466 class ScopedModel(models.Model):
467 """ A model that belongs to some scope, usually for limited uniqueness
469 scope does not allow null values, because null is not equal to null in SQL.
470 It could therefore not be used in unique constraints. """
472 # A slug / URL is unique within a scope; no two podcasts can have the same
473 # URL (scope ''), and no two episdoes of the same podcast (scope =
474 # podcast-ID) can have the same URL
475 scope = models.CharField(max_length=32, null=False, blank=True,
476 db_index=True)
478 class Meta:
479 abstract = True
482 class URL(OrderedModel, ScopedModel):
483 """ Podcasts and Episodes can have multiple URLs
485 URLs are ordered, and the first slug is considered the canonical one """
487 url = models.URLField(max_length=2048)
489 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
490 content_type = models.ForeignKey(ContentType)
491 object_id = UUIDField()
492 content_object = generic.GenericForeignKey('content_type', 'object_id')
494 class Meta:
495 unique_together = (
496 # a URL is unique per scope
497 ('url', 'scope'),
499 # URLs of an object must be ordered, so that no two slugs of one
500 # object have the same order key
501 ('content_type', 'object_id', 'order'),
504 verbose_name = 'URL'
505 verbose_name_plural = 'URLs'
508 class Tag(models.Model):
509 """ Tags any kind of Model
511 See also :class:`TagsMixin`
514 FEED = 1
515 DELICIOUS = 2
516 USER = 4
518 SOURCE_CHOICES = (
519 (FEED, 'Feed'),
520 (DELICIOUS, 'delicious'),
521 (USER, 'User'),
524 tag = models.SlugField()
525 source = models.PositiveSmallIntegerField(choices=SOURCE_CHOICES)
526 #user = models.ForeignKey(null=True)
528 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
529 content_type = models.ForeignKey(ContentType)
530 object_id = UUIDField()
531 content_object = generic.GenericForeignKey('content_type', 'object_id')
533 class Meta:
534 unique_together = (
535 # a tag can only be assigned once from one source to one item
536 # TODO: add user to tuple
537 ('tag', 'source', 'content_type', 'object_id'),
541 class Slug(OrderedModel, ScopedModel):
542 """ Slug for any kind of Model
544 Slugs are ordered, and the first slug is considered the canonical one.
545 See also :class:`SlugsMixin`
548 slug = models.SlugField(max_length=150, db_index=True)
550 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
551 content_type = models.ForeignKey(ContentType)
552 object_id = UUIDField()
553 content_object = generic.GenericForeignKey('content_type', 'object_id')
555 class Meta:
556 unique_together = (
557 # a slug is unique per type; eg a podcast can have the same slug
558 # as an episode, but no two podcasts can have the same slug
559 ('slug', 'scope'),
561 # slugs of an object must be ordered, so that no two slugs of one
562 # object have the same order key
563 ('content_type', 'object_id', 'order'),
566 def __repr__(self):
567 return '{cls}(slug={slug}, order={order}, content_object={obj}'.format(
568 cls=self.__class__.__name__,
569 slug=self.slug,
570 order=self.order,
571 obj=self.content_object
575 class MergedUUID(models.Model):
576 """ If objects are merged their UUIDs are stored for later reference
578 see also :class:`MergedUUIDsMixin`
581 uuid = UUIDField(unique=True)
583 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
584 content_type = models.ForeignKey(ContentType)
585 object_id = UUIDField()
586 content_object = generic.GenericForeignKey('content_type', 'object_id')
588 class Meta:
589 verbose_name = 'Merged UUID'
590 verbose_name_plural = 'Merged UUIDs'