]> git.0d.be Git - django-panik-emissions.git/blob - emissions/models.py
c6b7f99b967c8b83d87c18415ce03d363e22eb6e
[django-panik-emissions.git] / emissions / models.py
1 import datetime
2 import os
3
4 from django.conf import settings
5 from django.core.exceptions import ObjectDoesNotExist
6 from django.forms import fields
7 from django.core.urlresolvers import reverse
8
9 from django.db import models
10 from django.utils.encoding import python_2_unicode_compatible
11 from django.utils.translation import ugettext
12 from django.utils.translation import ugettext_lazy as _
13
14 from django.db.models.signals import pre_save, post_delete
15 from django.dispatch.dispatcher import receiver
16
17 from ckeditor.fields import RichTextField
18 from taggit.managers import TaggableManager
19
20 from .utils import maybe_resize, get_duration
21
22
23 LICENSES = (
24     ('', _('Unspecified')),
25     ('cc by', _('Creative Commons Attribution')),
26     ('cc by-sa', _('Creative Commons Attribution ShareAlike')),
27     ('cc by-nc', _('Creative Commons Attribution NonCommercial')),
28     ('cc by-nd', _('Creative Commons Attribution NoDerivs')),
29     ('cc by-nc-sa', _('Creative Commons Attribution NonCommercial ShareAlike')),
30     ('cc by-nc-nd', _('Creative Commons Attribution NonCommercial NoDerivs')),
31     ('cc0 / pd', _('Creative Commons Zero / Public Domain')),
32     ('artlibre', _('Art Libre')),
33 )
34
35 PODCAST_SOUND_QUALITY_LIST = (
36     ('standard', _('Standard')),
37     ('high', _('High')),
38     ('highest', _('Highest')),
39 )
40
41
42 class WeekdayMixin(object):
43     DAY_HOUR_START = 5
44
45     def get_weekday(self):
46         weekday = self.datetime.weekday() + 7
47         if self.datetime.time() < datetime.time(self.DAY_HOUR_START, 0):
48             weekday -= 1
49         weekday %= 7
50         return weekday
51
52     def is_on_weekday(self, day): # day is [1..7]
53         week_day = self.datetime.weekday()
54         if self.datetime.hour < self.DAY_HOUR_START:
55             week_day -= 1
56         week_day = (week_day % 7) + 1
57         if hasattr(self, 'episode'):
58             if self.datetime.hour < self.DAY_HOUR_START and self.end_datetime.hour > self.DAY_HOUR_START:
59                 if (self.end_datetime.weekday()+1) == day:
60                     return True
61         return week_day == day
62
63
64 @python_2_unicode_compatible
65 class Category(models.Model):
66
67     class Meta:
68         verbose_name = _('Category')
69         verbose_name_plural = _('Categories')
70         ordering = ['title']
71
72     title = models.CharField(_('Title'), max_length=50)
73     slug = models.SlugField(null=True)
74     itunes_category = models.CharField(_('iTunes Category Name'), max_length=100, null=True)
75
76     def sorted_emission(self):
77         return self.emission_set.order_by('title')
78
79     def __str__(self):
80         return self.title
81
82
83 @python_2_unicode_compatible
84 class Colour(models.Model):
85
86     class Meta:
87         verbose_name = _('Colour')
88         verbose_name_plural = _('Colours')
89         ordering = ['title']
90
91     title = models.CharField(_('Title'), max_length=50)
92     slug = models.SlugField(null=True)
93
94     def sorted_emission(self):
95         return self.emission_set.order_by('title')
96
97     def __str__(self):
98         return self.title
99
100
101 @python_2_unicode_compatible
102 class Format(models.Model):
103
104     class Meta:
105         verbose_name = _('Format')
106         verbose_name_plural = _('Formats')
107         ordering = ['title']
108
109     title = models.CharField(_('Title'), max_length=50)
110     slug = models.SlugField(null=True)
111
112     def __str__(self):
113         return self.title
114
115
116 def get_emission_image_path(instance, filename):
117     return os.path.join('images', instance.slug,
118             os.path.basename(filename))
119
120
121 @python_2_unicode_compatible
122 class Emission(models.Model):
123
124     class Meta:
125         verbose_name = _('Emission')
126         verbose_name_plural = _('Emissions')
127         ordering = ['title']
128
129     title = models.CharField(_('Title'), max_length=200)
130     slug = models.SlugField(max_length=200)
131     subtitle = models.CharField(_('Subtitle'), max_length=80, null=True, blank=True)
132     text = RichTextField(_('Description'), null=True)
133     archived = models.BooleanField(_('Archived'), default=False)
134     categories = models.ManyToManyField(Category, verbose_name=_('Categories'), blank=True)
135     colours = models.ManyToManyField(Colour, verbose_name=_('Colours'), blank=True)
136
137     # XXX: languages (models.ManyToManyField(Language))
138
139     duration = models.IntegerField(_('Duration'), default=60,
140             help_text=_('In minutes'))
141
142     default_license = models.CharField(_('Default license for podcasts'),
143             max_length=20, blank=True, default='', choices=LICENSES)
144     podcast_sound_quality = models.CharField(_('Podcast sound quality'),
145             max_length=20, default='standard', choices=PODCAST_SOUND_QUALITY_LIST)
146     email = models.EmailField(_('Email'), max_length=254, null=True, blank=True)
147     website = models.URLField(_('Website'), null=True, blank=True)
148
149     image = models.ImageField(_('Image'),
150             upload_to=get_emission_image_path, max_length=250, null=True, blank=True)
151
152     chat_open = models.DateTimeField(null=True, blank=True)
153
154     # denormalized from Focus
155     got_focus = models.DateTimeField(default=None, null=True, blank=True)
156     has_focus = models.BooleanField(default=False)
157
158     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
159     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
160
161     def get_absolute_url(self):
162         return reverse('emission-view', kwargs={'slug': str(self.slug)})
163
164     def __str__(self):
165         return self.title
166
167     def save(self, *args, **kwargs):
168         super(Emission, self).save(*args, **kwargs)
169         if self.id is not None and self.image:
170             maybe_resize(self.image.path)
171
172     def get_absolute_url(self):
173         return reverse('emission-view',
174                         kwargs={'slug':  str(self.slug)})
175
176     def get_schedules(self):
177         return Schedule.objects.filter(emission=self).order_by('datetime')
178
179     def get_sorted_episodes(self):
180         return self.episode_set.select_related().extra(select={
181                 'first_diffusion': 'emissions_diffusion.datetime',
182                 },
183                 select_params=(False, True),
184                 where=['''datetime = (SELECT MIN(datetime)
185                                         FROM emissions_diffusion
186                                        WHERE episode_id = emissions_episode.id)'''],
187                 tables=['emissions_diffusion'],
188             ).order_by('-first_diffusion')
189
190     def get_sorted_newsitems(self):
191         return self.newsitem_set.select_related().order_by('-date')
192
193     def get_next_planned_date_and_schedule(self, since=None):
194         schedules = self.schedule_set.filter(rerun=False)
195         if not schedules:
196             return None
197         if since is None:
198             since = datetime.datetime.today()
199         possible_dates = []
200         for schedule in schedules:
201             possible_dates.append((schedule.get_next_planned_date(since), schedule))
202         possible_dates.sort(key=lambda x: x[0])
203         return possible_dates[0]
204
205     def get_next_planned_date(self, since=None):
206         result = self.get_next_planned_date_and_schedule(since=since)
207         return result[0] if result else None
208
209     def get_next_planned_duration(self, since=None):
210         result = self.get_next_planned_date_and_schedule(since=since)
211         if not result:
212             return None
213         return result[1].duration or self.duration
214
215 @python_2_unicode_compatible
216 class Schedule(models.Model, WeekdayMixin):
217
218     class Meta:
219         verbose_name = _('Schedule')
220         verbose_name_plural = _('Schedules')
221         ordering = ['datetime']
222
223     WEEK_CHOICES = (
224         (0b1111, _('Every week')),
225         (0b0001, _('First week')),
226         (0b0010, _('Second week')),
227         (0b0100, _('Third week')),
228         (0b1000, _('Fourth week')),
229         (0b0101, _('First and third week')),
230         (0b1010, _('Second and fourth week'))
231     )
232     emission = models.ForeignKey('Emission', verbose_name=u'Emission')
233     datetime = models.DateTimeField()
234     weeks = models.IntegerField(_('Weeks'), default=15, choices=WEEK_CHOICES)
235     rerun = models.BooleanField(_('Rerun'), default=False)
236     duration = models.IntegerField(_('Duration'), null=True, blank=True,
237             help_text=_('In minutes'))
238
239     @property
240     def weeks_string(self):
241         if self.weeks == 0b0001:
242             return ugettext('1st of the month')
243         elif self.weeks == 0b0010:
244             return ugettext('2nd of the month')
245         elif self.weeks == 0b0100:
246             return ugettext('3rd of the month')
247         elif self.weeks == 0b1000:
248             return ugettext('4th of the month')
249         elif self.weeks == 0b0101:
250             return ugettext('1st and 3rd')
251         elif self.weeks == 0b1010:
252             return ugettext('2nd and 4th')
253         return None
254
255     def week_sort_key(self):
256         order = [
257             0b1111, # Every week
258             0b0001, # First week
259             0b0101, # First and third week
260             0b0010, # Second week
261             0b1010, # Second and fourth week
262             0b0100, # Third week
263             0b1000, # Fourth week
264         ]
265         if self.weeks in order:
266             return order.index(self.weeks)
267         return -1
268
269     def get_duration(self):
270         if self.duration:
271             return self.duration
272         else:
273             return self.emission.duration
274
275     @property
276     def end_datetime(self):
277         return self.datetime + datetime.timedelta(minutes=self.get_duration())
278
279     def match_week(self, week_no):
280         if week_no == 4:
281             # this is the fifth week of the month, only return True for
282             # emissions scheduled every week.
283             return (self.weeks == 0b1111)
284         if (self.weeks & (0b0001<<(week_no)) == 0):
285             return False
286         return True
287
288     def matches(self, dt):
289         weekday = dt.weekday()
290         if dt.hour < self.DAY_HOUR_START:
291             weekday -= 1
292         if weekday != self.get_weekday():
293             return False
294         if self.weeks != 0b1111:
295             week_no = (dt.day-1) // 7
296             if self.match_week(week_no) is False:
297                 return False
298         if dt.time() >= self.datetime.time() and \
299                 dt.time() <= (self.datetime + datetime.timedelta(minutes=self.get_duration())).time():
300             return True
301         return False
302
303     def get_next_planned_date(self, since):
304         possible_dates = []
305         monday_since_date = since - datetime.timedelta(days=since.weekday())
306         start_week_date = self.datetime.replace(
307                 year=monday_since_date.year,
308                 month=monday_since_date.month,
309                 day=monday_since_date.day)
310         start_week_date -= datetime.timedelta(days=start_week_date.weekday())
311         start_week_date += datetime.timedelta(days=self.datetime.weekday())
312         for i in range(6):
313             week_date = start_week_date + datetime.timedelta(days=i*7)
314             if week_date < since:
315                 continue
316             if self.match_week((week_date.day-1)//7):
317                 possible_dates.append(week_date)
318         possible_dates.sort()
319         return possible_dates[0]
320
321
322     def __str__(self):
323         return u'%s at %s' % (self.emission.title,
324                 self.datetime.strftime('%a %H:%M'))
325
326
327 def get_episode_image_path(instance, filename):
328     return os.path.join('images', instance.emission.slug,
329             os.path.basename(filename))
330
331
332 @python_2_unicode_compatible
333 class Episode(models.Model):
334
335     class Meta:
336         verbose_name = _('Episode')
337         verbose_name_plural = _('Episodes')
338         ordering = ['title']
339
340     emission = models.ForeignKey('Emission', verbose_name=_('Emission'))
341     title = models.CharField(_('Title'), max_length=200)
342     slug = models.SlugField(max_length=200)
343     subtitle = models.CharField(_('Subtitle'), max_length=150, null=True, blank=True)
344     text = RichTextField(_('Description'), null=True)
345     tags = TaggableManager(_('Tags'), blank=True)
346     duration = models.IntegerField(_('Duration'), null=True, blank=True,
347             help_text=_('In minutes'))
348
349     image = models.ImageField(_('Image'),
350             upload_to=get_episode_image_path, max_length=250, null=True, blank=True)
351
352     effective_start = models.DateTimeField(null=True, blank=True)
353     effective_end = models.DateTimeField(null=True, blank=True)
354
355     # denormalized from Focus
356     got_focus = models.DateTimeField(default=None, null=True, blank=True)
357     has_focus = models.BooleanField(default=False)
358
359     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
360     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
361
362     # XXX: languages (models.ManyToManyField(Language))
363
364     def __str__(self):
365         return self.title
366
367     def save(self, *args, **kwargs):
368         super(Episode, self).save(*args, **kwargs)
369         if self.id is not None and self.image:
370             maybe_resize(self.image.path)
371
372     def get_duration(self):
373         if self.duration:
374             return self.duration
375         else:
376             return self.emission.duration
377
378     def get_absolute_url(self):
379         return reverse('episode-view',
380                         kwargs={'emission_slug': str(self.emission.slug),
381                                 'slug':  str(self.slug)})
382
383     def has_sound(self):
384         return (self.soundfile_set.count() > 0)
385
386     def get_pige_download_url(self):
387         return '%s/%s-%s-%s.wav' % (
388                 settings.PIGE_DOWNLOAD_BASE_URL,
389                 self.effective_start.strftime('%Y%m%d'),
390                 self.effective_start.strftime('%Hh%Mm%S.%f'),
391                 self.effective_end.strftime('%Hh%Mm%S.%f'))
392
393     _soundfiles = {}
394
395     @classmethod
396     def set_prefetched_soundfiles(cls, soundfiles):
397         cls._soundfiles.update(soundfiles)
398
399     def has_prefetched_soundfile(self):
400         return self.id in self._soundfiles
401
402     def get_prefetched_soundfile(self):
403         return self._soundfiles.get(self.id)
404
405     _main_sound = False
406
407     @property
408     def main_sound(self):
409         if self._main_sound is not False:
410             return self._main_sound
411
412         if self.has_prefetched_soundfile():
413             self._main_sound = self.get_prefetched_soundfile()
414             return self._main_sound
415
416         t = self.soundfile_set.exclude(podcastable=False).exclude(fragment=True)
417         if t:
418             self._main_sound = t[0]
419         else:
420             self._main_sound = None
421
422         return self._main_sound
423
424     @main_sound.setter
425     def main_sound(self, value):
426         self._main_sound = value
427
428     def fragment_sounds(self):
429         return self.soundfile_set.exclude(podcastable=False).exclude(fragment=False)
430
431     def diffusions(self):
432         return Diffusion.objects.filter(episode=self.id).order_by('datetime')
433
434
435 @python_2_unicode_compatible
436 class Diffusion(models.Model, WeekdayMixin):
437
438     class Meta:
439         verbose_name = _('Diffusion')
440         verbose_name_plural = _('Diffusions')
441         ordering = ['datetime']
442
443     episode = models.ForeignKey('Episode', verbose_name=_('Episode'))
444     datetime = models.DateTimeField(_('Date/time'), db_index=True)
445
446     def __str__(self):
447         return u'%s at %02d:%02d' % (self.episode.title, self.datetime.hour,
448                 self.datetime.minute)
449
450     def get_duration(self):
451         return self.episode.get_duration()
452
453     @property
454     def end_datetime(self):
455         return self.datetime + datetime.timedelta(minutes=self.get_duration())
456
457
458 @python_2_unicode_compatible
459 class Absence(models.Model):
460
461     class Meta:
462         verbose_name = _('Absence')
463         verbose_name_plural = _('Absences')
464
465     emission = models.ForeignKey('Emission', verbose_name=u'Emission')
466     datetime = models.DateTimeField(_('Date/time'), db_index=True)
467
468     def __str__(self):
469         return u'Absence for %s on %s' % (self.emission.title, self.datetime)
470
471
472 def get_sound_path(instance, filename):
473     return os.path.join('sounds.orig', instance.episode.emission.slug,
474             os.path.basename(filename))
475
476
477 @python_2_unicode_compatible
478 class SoundFile(models.Model):
479
480     class Meta:
481         verbose_name = _('Sound file')
482         verbose_name_plural = _('Sound files')
483         ordering = ['creation_timestamp']
484
485     episode = models.ForeignKey('Episode', verbose_name=_('Episode'))
486     file = models.FileField(_('File'), upload_to=get_sound_path, max_length=250)
487     podcastable = models.BooleanField(_('Podcastable'), default=False,
488             db_index=True,
489             help_text=_('The file can be published online according to SABAM rules.'))
490     fragment = models.BooleanField(_('Fragment'), default=False, db_index=True,
491             help_text=_('The file is some segment or extra content, not the complete recording.'))
492     title = models.CharField(_('Title'), max_length=200)
493     duration = models.IntegerField(_('Duration'), null=True, help_text=_('In seconds'))
494
495     format = models.ForeignKey('Format', verbose_name=_('Format'), null=True, blank=True)
496     license = models.CharField(_('License'), max_length=20, blank=True, default='', choices=LICENSES)
497
498     # denormalized from Focus
499     got_focus = models.DateTimeField(default=None, null=True, blank=True)
500     has_focus = models.BooleanField(default=False)
501
502     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
503     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
504
505     def compute_duration(self):
506         for path in (self.get_format_path('ogg'), self.get_format_path('mp3'), self.file.path):
507             self.duration = get_duration(path)
508             if self.duration:
509                 return
510
511     def get_format_filename(self, format):
512         return '%s_%05d__%s.%s' % (
513                 self.episode.slug,
514                 self.id,
515                 (self.fragment and '0' or '1'),
516                 format)
517
518     def get_format_path(self, format):
519         if not self.file:
520             return None
521         return '%s/%s' % (os.path.dirname(self.file.path).replace('.orig', ''), self.get_format_filename(format))
522
523     def get_format_url(self, format):
524         if not self.file:
525             return None
526         return '%s/%s' % (os.path.dirname(self.file.url).replace('.orig', ''), self.get_format_filename(format))
527
528     def get_duration_string(self):
529         if not self.duration:
530             return ''
531         return '%d:%02d' % (self.duration/60, self.duration%60)
532
533     def __str__(self):
534         return '%s - %s' % (self.title or self.id, self.episode.title)
535
536
537 @python_2_unicode_compatible
538 class NewsCategory(models.Model):
539
540     class Meta:
541         verbose_name = _('News Category')
542         verbose_name_plural = _('News Categories')
543         ordering = ['title']
544
545     title = models.CharField(_('Title'), max_length=50)
546     slug = models.SlugField(null=True)
547
548     def __str__(self):
549         return self.title
550
551     def get_sorted_newsitems(self):
552         return self.newsitem_set.select_related().order_by('-date')
553
554
555 def get_newsitem_image_path(instance, filename):
556     return os.path.join('images', 'news', instance.slug,
557             os.path.basename(filename))
558
559
560 @python_2_unicode_compatible
561 class NewsItem(models.Model):
562
563     class Meta:
564         verbose_name = _('News Item')
565         verbose_name_plural = _('News Items')
566         ordering = ['title']
567
568     title = models.CharField(_('Title'), max_length=200)
569     slug = models.SlugField(max_length=200)
570     text = RichTextField(_('Description'))
571     date = models.DateField(_('Publication Date'),
572             help_text=_('The news won\'t appear on the website before this date.'))
573     image = models.ImageField(_('Image'),
574             upload_to=get_newsitem_image_path, max_length=250, null=True, blank=True)
575
576     tags = TaggableManager(_('Tags'), blank=True)
577     category = models.ForeignKey('NewsCategory', verbose_name=_('Category'), null=True, blank=True)
578     emission = models.ForeignKey('Emission', verbose_name=_('Emission'), null=True, blank=True)
579
580     expiration_date = models.DateField(_('Expiration Date'), null=True, blank=True)
581     event_date = models.DateField(_('Event Date'), null=True, blank=True,
582             help_text=_('If this is an event, set the date here so it appears in the agenda.'))
583
584     # denormalized from Focus
585     got_focus = models.DateTimeField(default=None, null=True, blank=True)
586     has_focus = models.BooleanField(default=False)
587
588     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
589     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
590
591     def __str__(self):
592         return self.title
593
594     def get_absolute_url(self):
595         return reverse('newsitem-view', kwargs={'slug': str(self.slug)})
596
597
598 @python_2_unicode_compatible
599 class Nonstop(models.Model):
600
601     class Meta:
602         verbose_name = _('Nonstop zone')
603         verbose_name_plural = _('Nonstop zones')
604         ordering = ['title']
605
606     title = models.CharField(_('Title'), max_length=50)
607     slug = models.SlugField()
608
609     start = models.TimeField(_('Start'))
610     end = models.TimeField(_('End'))
611     text = RichTextField(_('Description'), null=True, blank=True)
612     redirect_path = models.CharField(_('Redirect Path'), max_length=200, blank=True)
613
614     def __str__(self):
615         return self.title
616
617
618 @python_2_unicode_compatible
619 class Focus(models.Model):
620     title = models.CharField(_('Alternate Title'), max_length=50,
621             null=True, blank=True)
622     newsitem = models.ForeignKey('NewsItem', verbose_name=_('News Item'),
623             null=True, blank=True)
624     emission = models.ForeignKey('Emission', verbose_name=_('Emission'),
625             null=True, blank=True)
626     episode = models.ForeignKey('Episode', verbose_name=_('Episode'),
627             null=True, blank=True)
628     soundfile = models.ForeignKey('SoundFile', verbose_name=_('Sound file'),
629             null=True, blank=True)
630     page = models.ForeignKey('data.Page', verbose_name=_('Page'),
631             null=True, blank=True)
632     current = models.BooleanField('Current', default=True)
633
634     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
635     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
636
637     def __str__(self):
638         if self.newsitem:
639             return u'Newsitem: %s' % self.newsitem.title
640         if self.emission:
641             return u'Emission: %s' % self.emission.title
642         if self.episode:
643             return u'Episode: %s' % self.episode.title
644         if self.soundfile:
645             return u'Soundfile: %s' % (self.soundfile.title or self.soundfile.episode.title)
646         if self.page:
647             return u'Page: %s' % self.page.title
648         return u'%s' % self.id
649
650     def focus_title(self):
651         if self.title:
652             return self.title
653         if self.newsitem:
654             return self.newsitem.title
655         if self.emission:
656             return self.emission.title
657         if self.page:
658             return self.page.title
659
660         episode = None
661         if self.soundfile:
662             if self.soundfile.fragment and self.soundfile.title:
663                 return self.soundfile.title
664             episode = self.soundfile.episode
665         elif self.episode:
666             episode = self.episode
667
668         if episode:
669             if episode.title:
670                 return episode.title
671             else:
672                 return episode.emission.title
673
674         return None
675
676     def content_image(self):
677         if self.newsitem:
678             return self.newsitem.image
679         if self.emission:
680             return self.emission.image
681         if self.page:
682             return self.page.picture
683
684         episode = None
685         if self.soundfile:
686             episode = self.soundfile.episode
687         elif self.episode:
688             episode = self.episode
689
690         if episode:
691             if episode.image:
692                 return episode.image
693             else:
694                 return episode.emission.image
695
696     def content_category_title(self):
697         if self.newsitem:
698             if self.newsitem.category:
699                 return self.newsitem.category.title
700             return _('News')
701         if self.emission:
702             return _('Emission')
703         if self.episode:
704             return self.episode.emission.title
705         if self.soundfile:
706             return self.soundfile.episode.emission.title
707         if self.page:
708             return 'Topik'
709
710     def get_related_object(self):
711         try:
712             if self.newsitem:
713                 return self.newsitem
714             if self.emission:
715                 return self.emission
716             if self.episode:
717                 return self.episode
718             if self.soundfile:
719                 return self.soundfile
720             if self.page:
721                 from panikombo.models import Topik
722                 return Topik.objects.get(page=self.page)
723         except ObjectDoesNotExist:
724             return None
725
726         return None
727
728
729 def get_playlist_sound_path(instance, filename):
730     return os.path.join('playlists', instance.episode.emission.slug,
731             instance.episode.slug, os.path.basename(filename))
732
733
734 class PlaylistElement(models.Model):
735     episode = models.ForeignKey('Episode', null=True)
736     title = models.CharField(_('Title'), max_length=200)
737     notes = models.CharField(_('Notes'), max_length=200, blank=True, default='')
738     sound = models.FileField(_('Sound'), upload_to=get_playlist_sound_path, max_length=250)
739     order = models.PositiveIntegerField()
740
741     class Meta:
742         verbose_name = _('Playlist Element')
743         verbose_name_plural = _('Playlist Elements')
744         ordering = ['order']
745
746     def shortcut(self):
747         return chr(ord('a')+self.order-1)
748
749
750 @receiver(pre_save, sender=Focus, dispatch_uid='focus_pre_save')
751 def set_focus_on_save(sender, instance, **kwargs):
752     object = instance.get_related_object()
753     must_save = False
754     if instance.current != object.has_focus:
755         object.has_focus = instance.current
756         must_save = True
757     if object and not object.got_focus and instance.current:
758         object.got_focus = datetime.datetime.now()
759         must_save = True
760     if must_save:
761         object.save()
762
763 @receiver(post_delete, sender=Focus, dispatch_uid='focus_post_delete')
764 def remove_focus_on_delete(sender, instance, **kwargs):
765     object = instance.get_related_object()
766     if object and (object.got_focus or object.has_focus):
767         object.got_focus = None
768         object.has_focus = False
769         object.save()