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