[Migration] Get Podcasts / Episodes from PostgreSQL
[mygpo.git] / mygpo / podcasts / models.py
blobe4a80e7ef74d5eb08349ea8fe817085656c6ec0a
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=250, 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 = SchemaListProperty(SubscriberData)
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 @property
355 def scope(self):
356 """ A podcast is always in the global scope """
357 return ''
360 class Episode(UUIDModel, TitleModel, DescriptionModel, LinkModel,
361 LanguageModel, LastUpdateModel, UpdateInfoModel, LicenseModel,
362 FlattrModel, ContentTypesModel, MergedIdsModel, OutdatedModel,
363 AuthorModel, UrlsMixin, SlugsMixin, MergedUUIDsMixin):
364 """ An episode """
366 guid = models.CharField(max_length=200, null=True)
367 content = models.TextField()
368 released = models.DateTimeField(null=True)
369 duration = models.PositiveIntegerField(null=True)
370 filesize = models.BigIntegerField(null=True)
371 mimetypes = models.CharField(max_length=100)
372 podcast = models.ForeignKey(Podcast)
373 listeners = models.PositiveIntegerField(null=True)
375 class Meta:
376 ordering = ['-released']
378 @property
379 def scope(self):
380 """ An episode's scope is its podcast """
381 return self.podcast_id.hex
383 def get_short_title(self, common_title):
384 """ Title when used within the podcast's context """
385 if not self.title or not common_title:
386 return None
388 title = self.title.replace(common_title, '').strip()
389 title = re.sub(r'^[\W\d]+', '', title)
390 return title
393 def get_episode_number(self, common_title):
394 """ Number of the episode """
395 if not self.title or not common_title:
396 return None
398 title = self.title.replace(common_title, '').strip()
399 match = re.search(r'^\W*(\d+)', title)
400 if not match:
401 return None
403 return int(match.group(1))
406 class ScopedModel(models.Model):
407 """ A model that belongs to some scope, usually for limited uniqueness
409 scope does not allow null values, because null is not equal to null in SQL.
410 It could therefore not be used in unique constraints. """
412 # A slug / URL is unique within a scope; no two podcasts can have the same
413 # URL (scope ''), and no two episdoes of the same podcast (scope =
414 # podcast-ID) can have the same URL
415 scope = models.CharField(max_length=32, null=False, blank=True,
416 db_index=True)
418 class Meta:
419 abstract = True
422 class URL(OrderedModel, ScopedModel):
423 """ Podcasts and Episodes can have multiple URLs
425 URLs are ordered, and the first slug is considered the canonical one """
427 url = models.URLField(max_length=2048)
429 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
430 content_type = models.ForeignKey(ContentType)
431 object_id = UUIDField()
432 content_object = generic.GenericForeignKey('content_type', 'object_id')
434 class Meta:
435 unique_together = (
436 # a URL is unique per scope
437 ('url', 'scope'),
439 # URLs of an object must be ordered, so that no two slugs of one
440 # object have the same order key
441 ('content_type', 'object_id', 'order'),
444 verbose_name = 'URL'
445 verbose_name_plural = 'URLs'
448 class Tag(models.Model):
449 """ Tags any kind of Model
451 See also :class:`TagsMixin`
454 FEED = 1
455 DELICIOUS = 2
456 USER = 4
458 SOURCE_CHOICES = (
459 (FEED, 'Feed'),
460 (DELICIOUS, 'delicious'),
461 (USER, 'User'),
464 tag = models.SlugField()
465 source = models.PositiveSmallIntegerField(choices=SOURCE_CHOICES)
466 #user = models.ForeignKey(null=True)
468 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
469 content_type = models.ForeignKey(ContentType)
470 object_id = UUIDField()
471 content_object = generic.GenericForeignKey('content_type', 'object_id')
473 class Meta:
474 unique_together = (
475 # a tag can only be assigned once from one source to one item
476 # TODO: add user to tuple
477 ('tag', 'source', 'content_type', 'object_id'),
481 class Slug(OrderedModel, ScopedModel):
482 """ Slug for any kind of Model
484 Slugs are ordered, and the first slug is considered the canonical one.
485 See also :class:`SlugsMixin`
488 slug = models.SlugField(max_length=150, db_index=True)
490 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
491 content_type = models.ForeignKey(ContentType)
492 object_id = UUIDField()
493 content_object = generic.GenericForeignKey('content_type', 'object_id')
495 class Meta:
496 unique_together = (
497 # a slug is unique per type; eg a podcast can have the same slug
498 # as an episode, but no two podcasts can have the same slug
499 ('slug', 'scope'),
501 # slugs of an object must be ordered, so that no two slugs of one
502 # object have the same order key
503 ('content_type', 'object_id', 'order'),
506 def __repr__(self):
507 return '{cls}(slug={slug}, order={order}, content_object={obj}'.format(
508 cls=self.__class__.__name__,
509 slug=self.slug,
510 order=self.order,
511 obj=self.content_object
515 class MergedUUID(models.Model):
516 """ If objects are merged their UUIDs are stored for later reference
518 see also :class:`MergedUUIDsMixin`
521 uuid = UUIDField(unique=True)
523 # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations
524 content_type = models.ForeignKey(ContentType)
525 object_id = UUIDField()
526 content_object = generic.GenericForeignKey('content_type', 'object_id')
528 class Meta:
529 verbose_name = 'Merged UUID'
530 verbose_name_plural = 'Merged UUIDs'