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 # 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')
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')
204 class TagsMixin(models
.Model
):
205 """ Methods for working with Tag objects """
207 tags
= GenericRelation('Tag', related_query_name
='tags')
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()
226 class PodcastGroup(UUIDModel
, TitleModel
, SlugsMixin
):
227 """ Groups multiple podcasts together """
231 """ A podcast group is always in the global scope """
235 class PodcastQuerySet(models
.QuerySet
):
236 """ Custom queries for Podcasts """
241 Excludes podcasts with missing title to guarantee some
242 minimum quality of the results """
243 return self
.exclude(title
='').order_by('?')
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 """
252 return self
.filter(license
=license_url
)
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')
263 def get_or_create_for_url(self
, url
):
264 # TODO: where to specify how uuid is created?
266 podcast
, created
= self
.get_or_create(urls__url
=url
,
268 'id': uuid
.uuid1().hex,
272 url
= URL
.objects
.create(url
=url
,
275 content_object
=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?
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
):
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,
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
):
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
330 if group1
and group2
:
331 raise ValueError('both podcasts already are in different groups')
333 elif not (group1
or group2
):
336 group
= PodcastGroup
.objects
.create(id=uuid
.uuid1(), title
=grouptitle
)
337 self
.group_member_name
= myname
341 other
.group_member_name
= othername
348 # add other to self's group
349 other
.group_member_name
= othername
355 # add self to other's group
356 self
.group_member_name
= myname
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
369 subscriptions_by_devices
= user
.get_subscriptions_by_device()
371 for group
in user
.get_grouped_devices():
375 dev
= group
.devices
[0]
377 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
378 targets
.append(group
.devices
)
381 for device
in group
.devices
:
382 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
383 targets
.append(device
)
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
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:
416 """ A podcast is always in the global scope """
420 class Episode(UUIDModel
, TitleModel
, DescriptionModel
, LinkModel
,
421 LanguageModel
, LastUpdateModel
, UpdateInfoModel
, LicenseModel
,
422 FlattrModel
, ContentTypesModel
, MergedIdsModel
, OutdatedModel
,
423 AuthorModel
, UrlsMixin
, SlugsMixin
, MergedUUIDsMixin
):
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)
436 ordering
= ['-released']
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
:
448 title
= self
.title
.replace(common_title
, '').strip()
449 title
= re
.sub(r
'^[\W\d]+', '', title
)
453 def get_episode_number(self
, common_title
):
454 """ Number of the episode """
455 if not self
.title
or not common_title
:
458 title
= self
.title
.replace(common_title
, '').strip()
459 match
= re
.search(r
'^\W*(\d+)', title
)
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,
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')
496 # a URL is unique per 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'),
505 verbose_name_plural
= 'URLs'
508 class Tag(models
.Model
):
509 """ Tags any kind of Model
511 See also :class:`TagsMixin`
520 (DELICIOUS
, 'delicious'),
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')
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')
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
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'),
567 return '{cls}(slug={slug}, order={order}, content_object={obj}'.format(
568 cls
=self
.__class
__.__name
__,
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')
589 verbose_name
= 'Merged UUID'
590 verbose_name_plural
= 'Merged UUIDs'