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)
32 """ String representation of the ID """
36 class TitleModel(models
.Model
):
37 """ Model that has a title """
39 title
= models
.CharField(max_length
=1000, null
=False, blank
=True,
41 subtitle
= models
.TextField(null
=False, blank
=True)
44 return self
.title
.encode('ascii', errors
='replace')
53 class DescriptionModel(models
.Model
):
54 """ Model that has a description """
56 description
= models
.TextField(null
=False, blank
=True)
62 class LinkModel(models
.Model
):
63 """ Model that has a link """
65 link
= models
.URLField(null
=True, max_length
=1000)
71 class LanguageModel(models
.Model
):
72 """ Model that has a language """
74 language
= models
.CharField(max_length
=10, null
=True, blank
=False,
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)
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)
105 class LicenseModel(models
.Model
):
106 # URL to a license (usually Creative Commons)
107 license
= models
.CharField(max_length
=100, null
=True, blank
=False,
114 class FlattrModel(models
.Model
):
115 # A Flattr payment URL
116 flattr_url
= models
.URLField(null
=True, blank
=False, max_length
=1000,
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)
131 class MergedIdsModel(models
.Model
):
137 class OutdatedModel(models
.Model
):
138 outdated
= models
.BooleanField(default
=False, db_index
=True)
144 class AuthorModel(models
.Model
):
145 author
= models
.CharField(max_length
=250, null
=True, blank
=True)
151 class UrlsMixin(models
.Model
):
152 """ Methods for working with URL objects """
154 urls
= GenericRelation('URL', related_query_name
='urls')
161 """ The main URL of the model """
162 url
= self
.urls
.first()
168 class SlugsMixin(models
.Model
):
169 """ Methods for working with Slug objects """
171 slugs
= GenericRelation('Slug', related_query_name
='slugs')
178 """ The main slug of the podcast
180 TODO: should be retrieved from a (materialized) view """
181 slug
= self
.slugs
.first()
187 class MergedUUIDsMixin(models
.Model
):
188 """ Methods for working with MergedUUID objects """
190 merged_uuids
= GenericRelation('MergedUUID',
191 related_query_name
='merged_uuids')
196 class TagsMixin(models
.Model
):
197 """ Methods for working with Tag objects """
199 tags
= GenericRelation('Tag', related_query_name
='tags')
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()
218 class PodcastGroup(UUIDModel
, TitleModel
, SlugsMixin
):
219 """ Groups multiple podcasts together """
223 """ A podcast group is always in the global scope """
227 class PodcastQuerySet(models
.QuerySet
):
228 """ Custom queries for Podcasts """
233 Excludes podcasts with missing title to guarantee some
234 minimum quality of the results """
235 return self
.exclude(title
='').order_by('?')
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 """
244 return self
.filter(license
=license_url
)
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')
255 def get_or_create_for_url(self
, url
):
256 # TODO: where to specify how uuid is created?
258 podcast
, created
= self
.get_or_create(urls__url
=url
,
260 'id': uuid
.uuid1().hex,
264 url
= URL
.objects
.create(url
=url
,
267 content_object
=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?
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
):
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,
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
):
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
322 if group1
and group2
:
323 raise ValueError('both podcasts already are in different groups')
325 elif not (group1
or group2
):
328 group
= PodcastGroup
.objects
.create(id=uuid
.uuid1(), title
=grouptitle
)
329 self
.group_member_name
= myname
333 other
.group_member_name
= othername
340 # add other to self's group
341 other
.group_member_name
= othername
347 # add self to other's group
348 self
.group_member_name
= myname
356 """ A podcast is always in the global scope """
360 class Episode(UUIDModel
, TitleModel
, DescriptionModel
, LinkModel
,
361 LanguageModel
, LastUpdateModel
, UpdateInfoModel
, LicenseModel
,
362 FlattrModel
, ContentTypesModel
, MergedIdsModel
, OutdatedModel
,
363 AuthorModel
, UrlsMixin
, SlugsMixin
, MergedUUIDsMixin
):
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)
376 ordering
= ['-released']
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
:
388 title
= self
.title
.replace(common_title
, '').strip()
389 title
= re
.sub(r
'^[\W\d]+', '', title
)
393 def get_episode_number(self
, common_title
):
394 """ Number of the episode """
395 if not self
.title
or not common_title
:
398 title
= self
.title
.replace(common_title
, '').strip()
399 match
= re
.search(r
'^\W*(\d+)', title
)
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,
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')
436 # a URL is unique per 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'),
445 verbose_name_plural
= 'URLs'
448 class Tag(models
.Model
):
449 """ Tags any kind of Model
451 See also :class:`TagsMixin`
460 (DELICIOUS
, 'delicious'),
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')
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')
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
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'),
507 return '{cls}(slug={slug}, order={order}, content_object={obj}'.format(
508 cls
=self
.__class
__.__name
__,
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')
529 verbose_name
= 'Merged UUID'
530 verbose_name_plural
= 'Merged UUIDs'