[Migration] Store podcast subscriber count
[mygpo.git] / mygpo / podcasts / models.py
blob657a65f95f56a16a3b3f4ccd2503b582a8c8b9cc
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 url = self.urls.first()
163 if url is None:
164 return None
165 return url.url
168 class SlugsMixin(models.Model):
169 """ Methods for working with Slug objects """
171 slugs = GenericRelation('Slug', related_query_name='slugs')
173 class Meta:
174 abstract = True
176 @property
177 def slug(self):
178 """ The main slug of the podcast
180 TODO: should be retrieved from a (materialized) view """
181 slug = self.slugs.first()
182 if slug is None:
183 return None
184 return slug.slug
187 class MergedUUIDsMixin(models.Model):
188 """ Methods for working with MergedUUID objects """
190 merged_uuids = GenericRelation('MergedUUID',
191 related_query_name='merged_uuids')
193 class Meta:
194 abstract = True
196 class TagsMixin(models.Model):
197 """ Methods for working with Tag objects """
199 tags = GenericRelation('Tag', related_query_name='tags')
201 class Meta:
202 abstract = True
205 class OrderedModel(models.Model):
206 """ A model that can be ordered
208 The implementing Model must make sure that 'order' is sufficiently unique
211 order = models.PositiveSmallIntegerField()
213 class Meta:
214 abstract = True
215 ordering = ['order']
218 class PodcastGroup(UUIDModel, TitleModel, SlugsMixin):
219 """ Groups multiple podcasts together """
221 @property
222 def scope(self):
223 """ A podcast group is always in the global scope """
224 return ''
227 class PodcastQuerySet(models.QuerySet):
228 """ Custom queries for Podcasts """
230 def random(self):
231 """ Random podcasts
233 Excludes podcasts with missing title to guarantee some
234 minimum quality of the results """
235 return self.exclude(title='').order_by('?')
237 def flattr(self):
238 """ Podcasts providing Flattr information """
239 return self.exclude(flattr_url__isnull=True)
241 def license(self, license_url=None):
242 """ Podcasts with any / the given license """
243 if license_url:
244 return self.filter(license=license_url)
245 else:
246 return self.exclude(license__isnull=True)
248 def order_by_next_update(self):
249 """ Sort podcasts by next scheduled update """
250 NEXTUPDATE = "last_update + (update_interval || ' hours')::INTERVAL"
251 q = self.extra(select={'next_update': NEXTUPDATE})
252 return q.order_by('next_update')
254 @transaction.atomic
255 def get_or_create_for_url(self, url):
256 # TODO: where to specify how uuid is created?
257 import uuid
258 podcast, created = self.get_or_create(urls__url=url,
259 defaults={
260 'id': uuid.uuid1().hex,
263 if created:
264 url = URL.objects.create(url=url,
265 order=0,
266 scope='',
267 content_object=podcast,
269 return podcast
271 def get_by_any_id(self, id):
272 """ Find a Podcast by its own ID or by a merged ID """
273 # TODO: should this be done in the model?
274 try:
275 return self.get(id=id)
276 except self.model.DoesNotExist:
277 return self.get(merged_uuids__uuid=id)
280 class Podcast(UUIDModel, TitleModel, DescriptionModel, LinkModel,
281 LanguageModel, LastUpdateModel, UpdateInfoModel, LicenseModel,
282 FlattrModel, ContentTypesModel, MergedIdsModel, OutdatedModel,
283 AuthorModel, UrlsMixin, SlugsMixin, TagsMixin, MergedUUIDsMixin):
284 """ A Podcast """
286 logo_url = models.URLField(null=True, max_length=1000)
287 group = models.ForeignKey(PodcastGroup, null=True)
288 group_member_name = models.CharField(max_length=30, null=True, blank=False)
290 # if p1 is related to p2, p2 is also related to p1
291 related_podcasts = models.ManyToManyField('self', symmetrical=True)
293 subscribers = models.PositiveIntegerField(default=0)
294 restrictions = models.CharField(max_length=20, null=False, blank=True,
295 default='')
296 common_episode_title = models.CharField(max_length=100, null=False, blank=True)
297 new_location = models.URLField(max_length=1000, null=True, blank=False)
298 latest_episode_timestamp = models.DateTimeField(null=True)
299 episode_count = models.PositiveIntegerField(default=0)
300 hub = models.URLField(null=True)
301 twitter = models.CharField(max_length=15, null=True, blank=False)
302 update_interval = models.PositiveSmallIntegerField(null=False,
303 default=DEFAULT_UPDATE_INTERVAL)
305 objects = PodcastQuerySet.as_manager()
307 def subscriber_count(self):
308 # TODO: implement
309 return 0
311 def group_with(self, other, grouptitle, myname, othername):
312 """ Group the podcast with another one """
313 # TODO: move to PodcastGroup?
315 if bool(self.group) and (self.group == other.group):
316 # they are already grouped
317 return
319 group1 = self.group
320 group2 = other.group
322 if group1 and group2:
323 raise ValueError('both podcasts already are in different groups')
325 elif not (group1 or group2):
326 # Form a new group
327 import uuid
328 group = PodcastGroup.objects.create(id=uuid.uuid1(), title=grouptitle)
329 self.group_member_name = myname
330 self.group = group
331 self.save()
333 other.group_member_name = othername
334 other.group = group
335 other.save()
337 return group
339 elif group1:
340 # add other to self's group
341 other.group_member_name = othername
342 other.group = group1
343 other.save()
344 return group1
346 else:
347 # add self to other's group
348 self.group_member_name = myname
349 self.group = group2
350 self.save()
351 return group2
354 def subscribe_targets(self, user):
356 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
357 devices/syncgroups on which the podcast is already subscribed
359 targets = []
361 subscriptions_by_devices = user.get_subscriptions_by_device()
363 for group in user.get_grouped_devices():
365 if group.is_synced:
367 dev = group.devices[0]
369 if not self.get_id() in subscriptions_by_devices[dev.id]:
370 targets.append(group.devices)
372 else:
373 for device in group.devices:
374 if not self.get_id() in subscriptions_by_devices[device.id]:
375 targets.append(device)
377 return targets
380 def get_common_episode_title(self, num_episodes=100):
382 if self.common_episode_title:
383 return self.common_episode_title
385 episodes = self.episode_set.all()[:num_episodes]
387 # We take all non-empty titles
388 titles = filter(None, (e.title for e in episodes))
390 # there can not be a "common" title of a single title
391 if len(titles) < 2:
392 return None
394 # get the longest common substring
395 common_title = utils.longest_substr(titles)
397 # but consider only the part up to the first number. Otherwise we risk
398 # removing part of the number (eg if a feed contains episodes 100-199)
399 common_title = re.search(r'^\D*', common_title).group(0)
401 if len(common_title.strip()) < 2:
402 return None
404 return common_title
406 @property
407 def scope(self):
408 """ A podcast is always in the global scope """
409 return ''
412 class Episode(UUIDModel, TitleModel, DescriptionModel, LinkModel,
413 LanguageModel, LastUpdateModel, UpdateInfoModel, LicenseModel,
414 FlattrModel, ContentTypesModel, MergedIdsModel, OutdatedModel,
415 AuthorModel, UrlsMixin, SlugsMixin, MergedUUIDsMixin):
416 """ An episode """
418 guid = models.CharField(max_length=200, null=True)
419 content = models.TextField()
420 released = models.DateTimeField(null=True)
421 duration = models.PositiveIntegerField(null=True)
422 filesize = models.BigIntegerField(null=True)
423 mimetypes = models.CharField(max_length=100)
424 podcast = models.ForeignKey(Podcast)
425 listeners = models.PositiveIntegerField(null=True)
427 class Meta:
428 ordering = ['-released']
430 @property
431 def scope(self):
432 """ An episode's scope is its podcast """
433 return self.podcast_id.hex
435 def get_short_title(self, common_title):
436 """ Title when used within the podcast's context """
437 if not self.title or not common_title:
438 return None
440 title = self.title.replace(common_title, '').strip()
441 title = re.sub(r'^[\W\d]+', '', title)
442 return title
445 def get_episode_number(self, common_title):
446 """ Number of the episode """
447 if not self.title or not common_title:
448 return None
450 title = self.title.replace(common_title, '').strip()
451 match = re.search(r'^\W*(\d+)', title)
452 if not match:
453 return None
455 return int(match.group(1))
458 class ScopedModel(models.Model):
459 """ A model that belongs to some scope, usually for limited uniqueness
461 scope does not allow null values, because null is not equal to null in SQL.
462 It could therefore not be used in unique constraints. """
464 # A slug / URL is unique within a scope; no two podcasts can have the same
465 # URL (scope ''), and no two episdoes of the same podcast (scope =
466 # podcast-ID) can have the same URL
467 scope = models.CharField(max_length=32, null=False, blank=True,
468 db_index=True)
470 class Meta:
471 abstract = True
474 class URL(OrderedModel, ScopedModel):
475 """ Podcasts and Episodes can have multiple URLs
477 URLs are ordered, and the first slug is considered the canonical one """
479 url = models.URLField(max_length=2048)
481 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
482 content_type = models.ForeignKey(ContentType)
483 object_id = UUIDField()
484 content_object = generic.GenericForeignKey('content_type', 'object_id')
486 class Meta:
487 unique_together = (
488 # a URL is unique per scope
489 ('url', 'scope'),
491 # URLs of an object must be ordered, so that no two slugs of one
492 # object have the same order key
493 ('content_type', 'object_id', 'order'),
496 verbose_name = 'URL'
497 verbose_name_plural = 'URLs'
500 class Tag(models.Model):
501 """ Tags any kind of Model
503 See also :class:`TagsMixin`
506 FEED = 1
507 DELICIOUS = 2
508 USER = 4
510 SOURCE_CHOICES = (
511 (FEED, 'Feed'),
512 (DELICIOUS, 'delicious'),
513 (USER, 'User'),
516 tag = models.SlugField()
517 source = models.PositiveSmallIntegerField(choices=SOURCE_CHOICES)
518 #user = models.ForeignKey(null=True)
520 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
521 content_type = models.ForeignKey(ContentType)
522 object_id = UUIDField()
523 content_object = generic.GenericForeignKey('content_type', 'object_id')
525 class Meta:
526 unique_together = (
527 # a tag can only be assigned once from one source to one item
528 # TODO: add user to tuple
529 ('tag', 'source', 'content_type', 'object_id'),
533 class Slug(OrderedModel, ScopedModel):
534 """ Slug for any kind of Model
536 Slugs are ordered, and the first slug is considered the canonical one.
537 See also :class:`SlugsMixin`
540 slug = models.SlugField(max_length=150, db_index=True)
542 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
543 content_type = models.ForeignKey(ContentType)
544 object_id = UUIDField()
545 content_object = generic.GenericForeignKey('content_type', 'object_id')
547 class Meta:
548 unique_together = (
549 # a slug is unique per type; eg a podcast can have the same slug
550 # as an episode, but no two podcasts can have the same slug
551 ('slug', 'scope'),
553 # slugs of an object must be ordered, so that no two slugs of one
554 # object have the same order key
555 ('content_type', 'object_id', 'order'),
558 def __repr__(self):
559 return '{cls}(slug={slug}, order={order}, content_object={obj}'.format(
560 cls=self.__class__.__name__,
561 slug=self.slug,
562 order=self.order,
563 obj=self.content_object
567 class MergedUUID(models.Model):
568 """ If objects are merged their UUIDs are stored for later reference
570 see also :class:`MergedUUIDsMixin`
573 uuid = UUIDField(unique=True)
575 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
576 content_type = models.ForeignKey(ContentType)
577 object_id = UUIDField()
578 content_object = generic.GenericForeignKey('content_type', 'object_id')
580 class Meta:
581 verbose_name = 'Merged UUID'
582 verbose_name_plural = 'Merged UUIDs'