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
=350, 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
= models
.PositiveIntegerField(default
=0)
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
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
361 subscriptions_by_devices
= user
.get_subscriptions_by_device()
363 for group
in user
.get_grouped_devices():
367 dev
= group
.devices
[0]
369 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
370 targets
.append(group
.devices
)
373 for device
in group
.devices
:
374 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
375 targets
.append(device
)
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
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:
408 """ A podcast is always in the global scope """
412 class Episode(UUIDModel
, TitleModel
, DescriptionModel
, LinkModel
,
413 LanguageModel
, LastUpdateModel
, UpdateInfoModel
, LicenseModel
,
414 FlattrModel
, ContentTypesModel
, MergedIdsModel
, OutdatedModel
,
415 AuthorModel
, UrlsMixin
, SlugsMixin
, MergedUUIDsMixin
):
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)
428 ordering
= ['-released']
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
:
440 title
= self
.title
.replace(common_title
, '').strip()
441 title
= re
.sub(r
'^[\W\d]+', '', title
)
445 def get_episode_number(self
, common_title
):
446 """ Number of the episode """
447 if not self
.title
or not common_title
:
450 title
= self
.title
.replace(common_title
, '').strip()
451 match
= re
.search(r
'^\W*(\d+)', title
)
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,
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')
488 # a URL is unique per 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'),
497 verbose_name_plural
= 'URLs'
500 class Tag(models
.Model
):
501 """ Tags any kind of Model
503 See also :class:`TagsMixin`
512 (DELICIOUS
, 'delicious'),
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')
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')
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
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'),
559 return '{cls}(slug={slug}, order={order}, content_object={obj}'.format(
560 cls
=self
.__class
__.__name
__,
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')
581 verbose_name
= 'Merged UUID'
582 verbose_name_plural
= 'Merged UUIDs'