]> git.0d.be Git - django-panik-emissions.git/blob - emissions/models.py
a17b445b7b11938c6f24d843318f765f20622e20
[django-panik-emissions.git] / emissions / models.py
1 import datetime
2 import json
3 import os
4 import statistics
5 import urllib.parse
6
7 from ckeditor.fields import RichTextField
8 from django.conf import settings
9 from django.core.exceptions import ObjectDoesNotExist
10 from django.core.validators import URLValidator
11 from django.db import models
12 from django.db.models.expressions import RawSQL
13 from django.db.models.signals import post_delete, pre_save
14 from django.dispatch.dispatcher import receiver
15 from django.forms import fields
16 from django.urls import reverse
17 from django.utils.text import slugify
18 from django.utils.translation import gettext
19 from django.utils.translation import gettext_lazy as _
20 from django.utils.translation import pgettext_lazy
21 from taggit.managers import TaggableManager
22
23 from .app_settings import app_settings
24 from .utils import get_duration, maybe_resize
25
26 LICENSES = (
27     ('', _('Unspecified')),
28     ('cc by', _('Creative Commons Attribution')),
29     ('cc by-sa', _('Creative Commons Attribution ShareAlike')),
30     ('cc by-nc', _('Creative Commons Attribution NonCommercial')),
31     ('cc by-nd', _('Creative Commons Attribution NoDerivs')),
32     ('cc by-nc-sa', _('Creative Commons Attribution NonCommercial ShareAlike')),
33     ('cc by-nc-nd', _('Creative Commons Attribution NonCommercial NoDerivs')),
34     ('cc0 / pd', _('Creative Commons Zero / Public Domain')),
35     ('artlibre', _('Art Libre')),
36 )
37
38 PODCAST_SOUND_QUALITY_LIST = (
39     ('standard', _('Standard')),
40     ('high', _('High')),
41     ('highest', _('Highest')),
42 )
43
44
45 def get_first_url_from_multi(value):
46     if not value:
47         return None
48     return value.splitlines()[0]
49
50
51 def get_url_kind(url):
52     netloc = urllib.parse.urlparse(url).netloc
53     if netloc in ('facebook.com', 'www.facebook.com'):
54         return 'facebook'
55     if netloc in ('t.co', 'twitter.com'):
56         return 'twitter'
57     if netloc in ('instagram.com', 'www.instagram.com'):
58         return 'instagram'
59     if netloc in ('twitch.com', 'twitch.tv', 'www.twitch.com', 'www.twitch.tv'):
60         return 'twitch'
61     if netloc == 'www.youtube.com':
62         return 'youtube'
63     if netloc.endswith('.bandcamp.com'):
64         return 'bandcamp'
65     if netloc == 'soundcloud.com':
66         return 'soundcloud'
67     if netloc == 'www.mixcloud.com':
68         return 'mixcloud'
69     return None
70
71
72 def get_urls_and_kind(value):
73     for url in (value or '').splitlines():
74         yield (get_url_kind(url), url)
75
76
77 def get_urls_by_kind(value):
78     urls = []
79     for url in (value or '').splitlines():
80         kind = get_url_kind(url) or '_website'
81         urls.append((kind, url))
82     urls.sort()
83     urls = [(x.strip('_'), y) for x, y in urls]
84     return urls
85
86
87 def generate_slug(instance, **query_filters):
88     base_slug = instance.base_slug
89     slug = base_slug
90     i = 1
91
92     # no optimization: check slug in DB each time
93     while instance._meta.model.objects.filter(slug=slug, **query_filters).exists():
94         slug = '%s-%s' % (base_slug, i)
95         i += 1
96     return slug
97
98
99 class WeekdayMixin:
100     def get_weekday(self):
101         weekday = self.datetime.weekday() + 7
102         if self.datetime.time() < datetime.time(app_settings.DAY_HOUR_START, app_settings.DAY_MINUTE_START):
103             weekday -= 1
104         weekday %= 7
105         return weekday
106
107     def is_on_weekday(self, day):  # day is [1..7]
108         week_day = self.datetime.weekday()
109         if (self.datetime.hour, self.datetime.minute) < (
110             app_settings.DAY_HOUR_START,
111             app_settings.DAY_MINUTE_START,
112         ):
113             week_day -= 1
114         week_day = (week_day % 7) + 1
115         if hasattr(self, 'episode'):
116             if (self.datetime.hour, self.datetime.minute) < (
117                 app_settings.DAY_HOUR_START,
118                 app_settings.DAY_MINUTE_START,
119             ) and (
120                 self.end_datetime.hour,
121                 self.end_datetime.minute,
122             ) >= (
123                 app_settings.DAY_HOUR_START,
124                 app_settings.DAY_MINUTE_START,
125             ):
126                 if (self.end_datetime.weekday() + 1) == day:
127                     return True
128         return week_day == day
129
130
131 class Category(models.Model):
132     class Meta:
133         verbose_name = _('Category')
134         verbose_name_plural = _('Categories')
135         ordering = ['title']
136
137     title = models.CharField(_('Title'), max_length=50)
138     slug = models.SlugField(null=True)
139     itunes_category = models.CharField(_('iTunes Category Name'), max_length=100, null=True, blank=True)
140     archived = models.BooleanField(pgettext_lazy('category', 'Archived'), default=False)
141
142     def sorted_emission(self):
143         return self.emission_set.order_by('title')
144
145     def __str__(self):
146         return self.title
147
148
149 class Format(models.Model):
150     class Meta:
151         verbose_name = _('Format')
152         verbose_name_plural = _('Formats')
153         ordering = ['title']
154
155     title = models.CharField(_('Title'), max_length=50)
156     slug = models.SlugField(null=True)
157     archived = models.BooleanField(pgettext_lazy('format', 'Archived'), default=False)
158
159     def __str__(self):
160         return self.title
161
162
163 def get_image_path(instance, filename):
164     if isinstance(instance, Emission):
165         return os.path.join('images', instance.slug, os.path.basename(filename))
166     if isinstance(instance, Nonstop):
167         return os.path.join('images', 'nonstop', instance.slug, os.path.basename(filename))
168     if isinstance(instance, Episode):
169         return os.path.join('images', instance.emission.slug, os.path.basename(filename))
170     if isinstance(instance, NewsItem):
171         return os.path.join('images', 'news', instance.slug, os.path.basename(filename))
172     if isinstance(instance, Picture):
173         return get_image_path(instance.get_content_object(), filename)
174
175
176 class MultiURLField(models.TextField):
177     def validate(self, value, model_instance):
178         super().validate(value, model_instance)
179         url_validator = URLValidator(schemes=('http', 'https'))
180         for line in (value or '').splitlines():
181             url_validator(line)
182
183
184 class Emission(models.Model):
185     class Meta:
186         verbose_name = _('Emission')
187         verbose_name_plural = _('Emissions')
188         ordering = ['title']
189
190     title = models.CharField(_('Title'), max_length=200)
191     slug = models.SlugField(max_length=200)
192     subtitle = models.CharField(_('Subtitle'), max_length=80, null=True, blank=True)
193     text = RichTextField(_('Description'), null=True)
194     archived = models.BooleanField(pgettext_lazy('emission', 'Archived'), default=False)
195     categories = models.ManyToManyField(Category, verbose_name=_('Categories'), blank=True)
196
197     # XXX: languages (models.ManyToManyField(Language))
198
199     duration = models.IntegerField(_('Duration'), default=60, help_text=_('In minutes'))
200
201     default_license = models.CharField(
202         _('Default license for podcasts'), max_length=20, blank=True, default='', choices=LICENSES
203     )
204     podcast_sound_quality = models.CharField(
205         _('Podcast sound quality'), max_length=20, default='standard', choices=PODCAST_SOUND_QUALITY_LIST
206     )
207     email = models.EmailField(_('Email'), max_length=254, null=True, blank=True)
208     website = MultiURLField(_('Website'), null=True, blank=True)
209
210     image_usage_ok = models.BooleanField(_('Include image'), default=False)
211     image = models.ImageField(_('Image'), upload_to=get_image_path, max_length=250, null=True, blank=True)
212     image_attribution_text = models.CharField(
213         _('Text for image attribution'), max_length=250, null=True, blank=True
214     )
215     image_attribution_url = models.URLField(_('URL for image attribution'), null=True, blank=True)
216     tags = TaggableManager(_('Tags'), blank=True)
217
218     chat_open = models.DateTimeField(null=True, blank=True)
219
220     # denormalized from Focus
221     got_focus = models.DateTimeField(default=None, null=True, blank=True)
222     has_focus = models.BooleanField(default=False)
223
224     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
225     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
226
227     def get_absolute_url(self):
228         return reverse('emission-view', kwargs={'slug': str(self.slug)})
229
230     def __str__(self):
231         return self.title
232
233     @property
234     def base_slug(self):
235         return slugify(self.title).strip('-') or 'untitled'
236
237     def save(self, *args, **kwargs):
238         if not self.slug:
239             self.slug = generate_slug(self)
240         super().save(*args, **kwargs)
241         if self.id is not None and self.image:
242             maybe_resize(self.image.path)
243
244     def get_recurring_content(self):
245         from nonstop.models import RecurringPlaylistDiffusion, RecurringStreamDiffusion
246
247         recurring_stream = RecurringStreamDiffusion.objects.filter(schedule__in=self.schedule_set.all())
248         if recurring_stream.exists():
249             return recurring_stream
250         recurring_playlist = RecurringPlaylistDiffusion.objects.filter(schedule__in=self.schedule_set.all())
251         if recurring_playlist.exists():
252             return recurring_playlist
253
254     def get_week_compacted_schedules(self, rerun=None):
255         schedules = [
256             x
257             for x in self.schedule_set.all().order_by('datetime')
258             if (rerun is None and True) or x.rerun is rerun
259         ]
260         if len(schedules) > 1 and len({x.datetime for x in schedules}) == 1:
261             # multiple schedules for same day/hour, must be a custom week combination,
262             # alter first schedule week bits
263             for schedule in schedules[1:]:
264                 schedules[0].weeks |= schedule.weeks
265             return [schedules[0]]
266         return schedules
267
268     def get_compacted_schedules(self, rerun=None):
269         schedules = self.get_week_compacted_schedules(rerun=rerun)
270         if len(schedules) > 1 and len({x.datetime.time() for x in schedules}) == 1:
271             # multiple schedules for same hour, join consecutive weekdays
272             sequences = [[schedules[0].datetime]]
273             for schedule in schedules[1:]:
274                 if schedule.datetime == sequences[-1][-1] + datetime.timedelta(days=1):
275                     sequences[-1].append(schedule.datetime)
276                 else:
277                     sequences.append([schedule.datetime])
278             for i, sequence in enumerate(sequences):
279                 # alter existing schedules
280                 schedules[i].multiple_days = bool(len(sequence) > 1)
281                 schedules[i].first_weekday_datetime = sequence[0]
282                 schedules[i].last_weekday_datetime = sequence[-1]
283                 yield schedules[i]
284         else:
285             yield from schedules
286
287     def get_schedules(self):
288         return list(self.get_compacted_schedules())
289
290     def get_schedules_no_reruns(self):
291         return list(self.get_compacted_schedules(rerun=False))
292
293     def get_schedules_reruns(self):
294         return list(self.get_compacted_schedules(rerun=True))
295
296     def get_sorted_episodes(self):
297         return (
298             self.episode_set.select_related()
299             .annotate(
300                 first_diffusion=RawSQL(
301                     '''SELECT MIN(datetime)
302                          FROM emissions_diffusion
303                         WHERE episode_id = emissions_episode.id
304                           AND emissions_episode.emission_id = emissions_emission.id''',
305                     (),
306                 )
307             )
308             .annotate(
309                 latest_soundfile_timestamp=RawSQL(
310                     '''SELECT MAX(emissions_soundfile.creation_timestamp)
311                          FROM emissions_soundfile
312                         WHERE emissions_soundfile.episode_id = emissions_episode.id''',
313                     (),
314                 )
315             )
316             .exclude(first_diffusion__isnull=True)
317             .order_by('-first_diffusion')
318         )
319
320     def get_sorted_newsitems(self):
321         return self.newsitem_set.select_related().order_by('-date')
322
323     def get_next_planned_date_and_schedule(self, since=None, include_rerun=False):
324         if include_rerun:
325             schedules = self.schedule_set.all()
326         else:
327             schedules = self.schedule_set.filter(rerun=False)
328         if not schedules:
329             return None
330         if since is None:
331             since = datetime.datetime.today()
332         possible_dates = []
333         for schedule in schedules:
334             possible_dates.append((schedule.get_next_planned_date(since), schedule))
335         possible_dates.sort(key=lambda x: x[0])
336         return possible_dates[0]
337
338     def get_next_planned_date(self, since=None, include_rerun=False):
339         result = self.get_next_planned_date_and_schedule(since=since, include_rerun=include_rerun)
340         return result[0] if result else None
341
342     def get_next_planned_duration(self, since=None, include_rerun=False):
343         result = self.get_next_planned_date_and_schedule(since=since, include_rerun=include_rerun)
344         if not result:
345             return None
346         return result[1].duration or self.duration
347
348     def get_website_url(self):
349         return get_first_url_from_multi(self.website)
350
351     def get_website_urls(self):
352         return get_urls_by_kind(self.website)
353
354
355 class Schedule(models.Model, WeekdayMixin):
356     class Meta:
357         verbose_name = _('Schedule')
358         verbose_name_plural = _('Schedules')
359         ordering = ['datetime']
360
361     WEEK_CHOICES = (
362         (0b1111, _('Every week')),
363         (0b0001, _('First week')),
364         (0b0010, _('Second week')),
365         (0b0100, _('Third week')),
366         (0b1000, _('Fourth week')),
367         (0b0101, _('First and third week')),
368         (0b1010, _('Second and fourth week')),
369     )
370     emission = models.ForeignKey('Emission', verbose_name='Emission', on_delete=models.CASCADE)
371     datetime = models.DateTimeField(_('Day/time'))
372     weeks = models.IntegerField(_('Weeks'), default=15, choices=WEEK_CHOICES)
373     rerun = models.BooleanField(_('Rerun'), default=False)
374     duration = models.IntegerField(_('Duration'), null=True, blank=True, help_text=_('In minutes'))
375
376     @property
377     def weeks_string(self):
378         week_ordinals = []
379         if self.weeks & 0b0001:
380             week_ordinals.append(gettext('1st'))
381         if self.weeks & 0b0010:
382             week_ordinals.append(gettext('2nd'))
383         if self.weeks & 0b0100:
384             week_ordinals.append(gettext('3rd'))
385         if self.weeks & 0b1000:
386             week_ordinals.append(gettext('4th'))
387         if not week_ordinals or len(week_ordinals) == 4:
388             return
389         if len(week_ordinals) == 1:
390             return gettext('%s of the month') % week_ordinals[0]
391         if len(week_ordinals) == 2:
392             return gettext(' and ').join(week_ordinals)
393         return gettext(' and ').join([', '.join(week_ordinals[:-1]), week_ordinals[-1]])
394
395     def week_sort_key(self):
396         order = [
397             0b1111,  # Every week
398             0b0001,  # First week
399             0b0101,  # First and third week
400             0b0010,  # Second week
401             0b1010,  # Second and fourth week
402             0b0100,  # Third week
403             0b1000,  # Fourth week
404         ]
405         if self.weeks in order:
406             return order.index(self.weeks)
407         return -1
408
409     def get_duration(self):
410         if self.duration:
411             return self.duration
412         else:
413             return self.emission.duration
414
415     @property
416     def end_datetime(self):
417         return self.datetime + datetime.timedelta(minutes=self.get_duration())
418
419     def match_week(self, week_no):
420         if week_no == 4:
421             # this is the fifth week of the month, only return True for
422             # emissions scheduled every week.
423             return self.weeks == 0b1111
424         if self.weeks & (0b0001 << (week_no)) == 0:
425             return False
426         return True
427
428     def matches(self, dt):
429         weekday = dt.weekday()
430         if (dt.hour, dt.minute) < (app_settings.DAY_HOUR_START, app_settings.DAY_MINUTE_START):
431             weekday -= 1
432         if weekday != self.get_weekday():
433             return False
434         if self.weeks != 0b1111:
435             week_no = (dt.day - 1) // 7
436             if self.match_week(week_no) is False:
437                 return False
438         if (
439             dt.time() >= self.datetime.time()
440             and dt.time() <= (self.datetime + datetime.timedelta(minutes=self.get_duration())).time()
441         ):
442             return True
443         return False
444
445     def get_next_planned_date(self, since):
446         if since is None:
447             since = datetime.datetime.today()
448         possible_dates = []
449         monday_since_date = since - datetime.timedelta(days=since.weekday())
450         start_week_date = self.datetime.replace(
451             year=monday_since_date.year, month=monday_since_date.month, day=monday_since_date.day
452         )
453         start_week_date -= datetime.timedelta(days=start_week_date.weekday())
454         start_week_date += datetime.timedelta(days=self.datetime.weekday())
455         for i in range(6):
456             week_date = start_week_date + datetime.timedelta(days=i * 7)
457             if week_date < since:
458                 continue
459             if self.match_week((week_date.day - 1) // 7):
460                 possible_dates.append(week_date)
461         possible_dates.sort()
462         if self.emission.absence_set.filter(datetime=possible_dates[0]).exists():
463             return self.get_next_planned_date(since=possible_dates[0] + datetime.timedelta(minutes=10))
464         return possible_dates[0]
465
466     def __str__(self):
467         return '%s at %s' % (self.emission.title, self.datetime.strftime('%a %H:%M'))
468
469
470 class Episode(models.Model):
471     class Meta:
472         verbose_name = _('Episode')
473         verbose_name_plural = _('Episodes')
474         ordering = ['title']
475
476     emission = models.ForeignKey('Emission', verbose_name=_('Emission'), on_delete=models.CASCADE)
477     title = models.CharField(_('Title'), max_length=200)
478     slug = models.SlugField(max_length=200)
479     subtitle = models.CharField(_('Subtitle'), max_length=150, null=True, blank=True)
480     text = RichTextField(_('Description'), null=True)
481     extra_links = MultiURLField(_('Extra links'), null=True, blank=True)
482     tags = TaggableManager(_('Tags'), blank=True)
483     duration = models.IntegerField(_('Duration'), null=True, blank=True, help_text=_('In minutes'))
484
485     image_usage_ok = models.BooleanField(_('Include image'), default=False)
486     image = models.ImageField(_('Image'), upload_to=get_image_path, max_length=250, null=True, blank=True)
487     image_attribution_text = models.CharField(
488         _('Text for image attribution'), max_length=250, null=True, blank=True
489     )
490     image_attribution_url = models.URLField(_('URL for image attribution'), null=True, blank=True)
491
492     agenda_only = models.BooleanField(_('Only include in agenda'), default=False)
493
494     effective_start = models.DateTimeField(null=True, blank=True)
495     effective_end = models.DateTimeField(null=True, blank=True)
496
497     # denormalized from Focus
498     got_focus = models.DateTimeField(default=None, null=True, blank=True)
499     has_focus = models.BooleanField(default=False)
500
501     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
502     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
503
504     # XXX: languages (models.ManyToManyField(Language))
505
506     def __str__(self):
507         return self.title
508
509     @property
510     def base_slug(self):
511         return slugify(self.title).strip('-') or 'untitled'
512
513     def save(self, *args, **kwargs):
514         if not self.slug:
515             self.slug = generate_slug(self)
516         super().save(*args, **kwargs)
517         if self.id is not None and self.image:
518             maybe_resize(self.image.path)
519
520     def get_duration(self):
521         if self.duration:
522             return self.duration
523         else:
524             return self.emission.duration
525
526     def get_absolute_url(self):
527         return reverse(
528             'episode-view', kwargs={'emission_slug': str(self.emission.slug), 'slug': str(self.slug)}
529         )
530
531     def get_site_url(self):
532         return urllib.parse.urljoin(settings.WEBSITE_BASE_URL, self.get_absolute_url())
533
534     def has_sound(self):
535         if hasattr(self, 'latest_soundfile_timestamp'):
536             return bool(self.latest_soundfile_timestamp)
537         return self.soundfile_set.count() > 0
538
539     def get_pige_download_url(self):
540         return '%s/%s-%s-%s.wav' % (
541             settings.PIGE_DOWNLOAD_BASE_URL,
542             self.effective_start.strftime('%Y%m%d'),
543             self.effective_start.strftime('%Hh%Mm%S.%f'),
544             self.effective_end.strftime('%Hh%Mm%S.%f'),
545         )
546
547     _soundfiles = {}
548
549     @classmethod
550     def set_prefetched_soundfiles(cls, soundfiles):
551         cls._soundfiles.update(soundfiles)
552
553     def has_prefetched_soundfile(self):
554         return self.id in self._soundfiles
555
556     def get_prefetched_soundfile(self):
557         return self._soundfiles.get(self.id)
558
559     _main_sound = False
560
561     @property
562     def main_sound(self):
563         if self._main_sound is not False:
564             return self._main_sound
565
566         if self.has_prefetched_soundfile():
567             self._main_sound = self.get_prefetched_soundfile()
568             return self._main_sound
569
570         t = self.soundfile_set.exclude(podcastable=False).exclude(fragment=True)
571         if t:
572             self._main_sound = t[0]
573         else:
574             self._main_sound = None
575
576         return self._main_sound
577
578     @main_sound.setter
579     def main_sound(self, value):
580         self._main_sound = value
581
582     def podcastable_sounds(self):
583         return self.soundfile_set.exclude(podcastable=False)
584
585     def fragment_sounds(self):
586         return self.soundfile_set.exclude(podcastable=False).exclude(fragment=False)
587
588     def main_sounds(self):
589         return self.soundfile_set.exclude(fragment=True)
590
591     def diffusions(self):
592         return Diffusion.objects.filter(episode=self.id).order_by('datetime')
593
594     def get_extra_links_and_kind(self):
595         yield from get_urls_and_kind(self.extra_links)
596
597
598 class Diffusion(models.Model, WeekdayMixin):
599     class Meta:
600         verbose_name = _('Diffusion')
601         verbose_name_plural = _('Diffusions')
602         ordering = ['datetime']
603
604     episode = models.ForeignKey('Episode', verbose_name=_('Episode'), on_delete=models.CASCADE)
605     datetime = models.DateTimeField(verbose_name=_('Date/time'), db_index=True)
606
607     def __str__(self):
608         return '%s at %02d:%02d' % (self.episode.title, self.datetime.hour, self.datetime.minute)
609
610     def get_duration(self):
611         return self.episode.get_duration()
612
613     @property
614     def end_datetime(self):
615         return self.datetime + datetime.timedelta(minutes=self.get_duration())
616
617     @property
618     def emission(self):
619         return self.episode.emission
620
621
622 class Absence(models.Model):
623     class Meta:
624         verbose_name = _('Absence')
625         verbose_name_plural = _('Absences')
626
627     emission = models.ForeignKey('Emission', verbose_name='Emission', on_delete=models.CASCADE)
628     datetime = models.DateTimeField(_('Date/time'), db_index=True)
629
630     def __str__(self):
631         return 'Absence for %s on %s' % (self.emission.title, self.datetime)
632
633
634 def get_sound_path(instance, filename):
635     return os.path.join('sounds.orig', instance.episode.emission.slug, os.path.basename(filename))
636
637
638 class SoundFile(models.Model):
639     class Meta:
640         verbose_name = _('Sound file')
641         verbose_name_plural = _('Sound files')
642         ordering = ['order', 'creation_timestamp']
643
644     episode = models.ForeignKey('Episode', verbose_name=_('Episode'), on_delete=models.CASCADE)
645     file = models.FileField(_('File'), upload_to=get_sound_path, max_length=250, blank=True, null=True)
646     external_url = models.URLField(_('URL'), null=True, blank=True)
647     podcastable = models.BooleanField(
648         _('Published'),
649         default=True,
650         db_index=True,
651         help_text=_('Get the sound published on the website'),
652     )
653     fragment = models.BooleanField(
654         _('Fragment'),
655         default=False,
656         db_index=True,
657         help_text=_('The file is some segment or extra content, not the complete recording.'),
658     )
659     title = models.CharField(_('Title'), max_length=200)
660     duration = models.IntegerField(_('Duration'), null=True, help_text=_('In seconds'))
661     mp3_file_size = models.IntegerField(null=True)  # used in rss feeds
662
663     format = models.ForeignKey(
664         'Format', verbose_name=_('Format'), null=True, blank=True, on_delete=models.SET_NULL
665     )
666     license = models.CharField(_('License'), max_length=20, blank=True, default='', choices=LICENSES)
667
668     order = models.PositiveIntegerField(default=0)
669
670     # denormalized from Focus
671     got_focus = models.DateTimeField(default=None, null=True, blank=True)
672     has_focus = models.BooleanField(default=False)
673
674     # basic statistics
675     download_count = models.IntegerField(_('Download Count'), default=0)
676
677     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
678     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
679
680     def compute_duration(self):
681         if not self.file:
682             return
683         for path in (self.get_format_path('ogg'), self.get_format_path('mp3'), self.file.path):
684             self.duration = get_duration(path)
685             if self.duration:
686                 return
687
688     def get_url(self):
689         if self.file:
690             return self.file.url
691         if self.external_url:
692             return self.external_url
693
694     def get_external_host(self):
695         if not self.external_url:
696             return
697         parts = urllib.parse.urlparse(self.external_url)
698         if parts.netloc == 'www.mixcloud.com':
699             return 'mixcloud'
700         if parts.netloc == 'api.soundcloud.com':
701             return 'soundcloud'
702
703     def get_external_embed_url(self):
704         if not self.external_url:
705             return
706         parts = urllib.parse.urlparse(self.external_url)
707         if parts.netloc == 'www.mixcloud.com':
708             return 'https://www.mixcloud.com/widget/iframe/?feed=%s' % urllib.parse.quote_plus(parts.path)
709         if parts.netloc == 'api.soundcloud.com':
710             return 'https://w.soundcloud.com/player/?url=%s' % urllib.parse.quote_plus(self.external_url)
711
712     def get_format_filename(self, format):
713         return '%s_%05d__%s.%s' % (self.episode.slug, self.id, (self.fragment and '0' or '1'), format)
714
715     def get_format_path(self, format):
716         if not self.file:
717             return None
718         return '%s/%s' % (
719             os.path.dirname(self.file.path).replace('.orig', ''),
720             self.get_format_filename(format),
721         )
722
723     def get_format_url(self, format):
724         if not self.file:
725             return None
726         return '%s/%s' % (
727             os.path.dirname(self.file.url).replace('.orig', ''),
728             self.get_format_filename(format),
729         )
730
731     def get_duration_string(self):
732         if not self.duration:
733             return ''
734         return '%d:%02d' % (self.duration / 60, self.duration % 60)
735
736     def get_durations(self):
737         durations = [self.episode.emission.duration * 60, self.episode.get_duration() * 60] + [
738             x.get_duration() * 60 for x in self.episode.diffusion_set.all()
739         ]
740         return durations
741
742     def is_too_long(self):
743         if self.fragment or self.external_url:
744             return False
745         return bool(self.duration > max(self.get_durations()) * 1.2)
746
747     def is_too_short(self):
748         if self.fragment or self.external_url:
749             return False
750         return bool(self.duration < min(self.get_durations()) * 0.5)
751
752     def has_low_volume(self):
753         waveform_json = self.get_format_path('waveform.json')
754         if waveform_json and os.path.exists(waveform_json):
755             with open(waveform_json) as wavefile_fd:
756                 median_volume = statistics.median(json.load(wavefile_fd))
757                 return bool(median_volume < 10)
758         return False
759
760     def __str__(self):
761         return '%s - %s' % (self.title or self.id, self.episode.title)
762
763
764 class NewsCategory(models.Model):
765     class Meta:
766         verbose_name = _('News Category')
767         verbose_name_plural = _('News Categories')
768         ordering = ['title']
769
770     title = models.CharField(_('Title'), max_length=50)
771     slug = models.SlugField(null=True)
772     archived = models.BooleanField(pgettext_lazy('category', 'Archived'), default=False)
773
774     def __str__(self):
775         return self.title
776
777     def get_sorted_newsitems(self):
778         return self.newsitem_set.select_related().order_by('-date')
779
780
781 class NewsItem(models.Model):
782     class Meta:
783         verbose_name = _('News Item')
784         verbose_name_plural = _('News Items')
785         ordering = ['title']
786
787     title = models.CharField(_('Title'), max_length=200)
788     subtitle = models.CharField(_('Subtitle'), max_length=80, null=True, blank=True)
789     slug = models.SlugField(max_length=200)
790     text = RichTextField(_('Description'))
791     date = models.DateField(
792         _('Publication Date'), help_text=_('The news won\'t appear on the website before this date.')
793     )
794
795     image_usage_ok = models.BooleanField(_('Include image'), default=False)
796     image = models.ImageField(_('Image'), upload_to=get_image_path, max_length=250, null=True, blank=True)
797     image_attribution_text = models.CharField(
798         _('Text for image attribution'), max_length=250, null=True, blank=True
799     )
800     image_attribution_url = models.URLField(_('URL for image attribution'), null=True, blank=True)
801
802     tags = TaggableManager(_('Tags'), blank=True)
803     category = models.ForeignKey(
804         'NewsCategory', verbose_name=_('Category'), null=True, blank=True, on_delete=models.SET_NULL
805     )
806     emission = models.ForeignKey(
807         'Emission', verbose_name=_('Emission'), null=True, blank=True, on_delete=models.CASCADE
808     )
809
810     expiration_date = models.DateField(_('Expiration Date'), null=True, blank=True)
811     event_date = models.DateField(
812         _('Event Date'),
813         null=True,
814         blank=True,
815         help_text=_('If this is an event, set the date here so it appears in the agenda.'),
816     )
817
818     # denormalized from Focus
819     got_focus = models.DateTimeField(default=None, null=True, blank=True)
820     has_focus = models.BooleanField(default=False)
821
822     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
823     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
824
825     def __str__(self):
826         return self.title
827
828     @property
829     def base_slug(self):
830         return slugify(self.title).strip('-') or 'untitled'
831
832     def save(self, *args, **kwargs):
833         if not self.slug:
834             self.slug = generate_slug(self)
835         super().save(*args, **kwargs)
836
837     def get_absolute_url(self):
838         return reverse('newsitem-view', kwargs={'slug': str(self.slug)})
839
840
841 class Nonstop(models.Model):
842     class Meta:
843         verbose_name = _('Nonstop zone')
844         verbose_name_plural = _('Nonstop zones')
845         ordering = ['title']
846
847     title = models.CharField(_('Title'), max_length=50)
848     slug = models.SlugField()
849
850     start = models.TimeField(_('Start'))
851     end = models.TimeField(_('End'))
852
853     subtitle = models.CharField(_('Subtitle'), max_length=80, null=True, blank=True)
854     text = RichTextField(_('Description'), null=True, blank=True)
855
856     image_usage_ok = models.BooleanField(_('Include image'), default=False)
857     image = models.ImageField(_('Image'), upload_to=get_image_path, max_length=250, null=True, blank=True)
858     image_attribution_text = models.CharField(
859         _('Text for image attribution'), max_length=250, null=True, blank=True
860     )
861     image_attribution_url = models.URLField(_('URL for image attribution'), null=True, blank=True)
862
863     redirect_path = models.CharField(_('Redirect Path'), max_length=200, blank=True)
864
865     def __str__(self):
866         return self.title
867
868     @property
869     def base_slug(self):
870         return slugify(self.title).strip('-') or 'untitled'
871
872     def save(self, *args, **kwargs):
873         if not self.slug:
874             self.slug = generate_slug(self)
875         super().save(*args, **kwargs)
876
877     def get_public_label(self):
878         return self.title.split('(')[0].strip()
879
880     def get_playlist_emissions(self):
881         emissions = {}
882         for recurring_playlist in self.recurring_playlist_zones.all().select_related(
883             'schedule', 'schedule__emission'
884         ):
885             emissions[recurring_playlist.schedule.emission_id] = recurring_playlist.schedule.emission
886         return emissions.values()
887
888
889 class Focus(models.Model):
890     title = models.CharField(_('Alternate Title'), max_length=50, null=True, blank=True)
891     newsitem = models.ForeignKey(
892         'NewsItem', verbose_name=_('News Item'), null=True, blank=True, on_delete=models.CASCADE
893     )
894     emission = models.ForeignKey(
895         'Emission', verbose_name=_('Emission'), null=True, blank=True, on_delete=models.CASCADE
896     )
897     episode = models.ForeignKey(
898         'Episode', verbose_name=_('Episode'), null=True, blank=True, on_delete=models.CASCADE
899     )
900     soundfile = models.ForeignKey(
901         'SoundFile', verbose_name=_('Sound file'), null=True, blank=True, on_delete=models.CASCADE
902     )
903     page = models.ForeignKey(
904         'data.Page', verbose_name=_('Page'), null=True, blank=True, on_delete=models.CASCADE
905     )
906     current = models.BooleanField('Current', default=True)
907
908     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
909     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
910
911     def __str__(self):
912         if self.newsitem:
913             return 'Newsitem: %s' % self.newsitem.title
914         if self.emission:
915             return 'Emission: %s' % self.emission.title
916         if self.episode:
917             return 'Episode: %s' % self.episode.title
918         if self.soundfile:
919             return 'Soundfile: %s' % (self.soundfile.title or self.soundfile.episode.title)
920         if self.page:
921             return 'Page: %s' % self.page.title
922         return '%s' % self.id
923
924     def focus_title(self):
925         if self.title:
926             return self.title
927         if self.newsitem:
928             return self.newsitem.title
929         if self.emission:
930             return self.emission.title
931         if self.page:
932             return self.page.title
933
934         episode = None
935         if self.soundfile:
936             if self.soundfile.fragment and self.soundfile.title:
937                 return self.soundfile.title
938             episode = self.soundfile.episode
939         elif self.episode:
940             episode = self.episode
941
942         if episode:
943             if episode.title:
944                 return episode.title
945             else:
946                 return episode.emission.title
947
948         return None
949
950     def content_image(self):
951         if self.newsitem:
952             return self.newsitem.image
953         if self.emission:
954             return self.emission.image
955         if self.page:
956             return self.page.picture
957
958         episode = None
959         if self.soundfile:
960             episode = self.soundfile.episode
961         elif self.episode:
962             episode = self.episode
963
964         if episode:
965             if episode.image:
966                 return episode.image
967             else:
968                 return episode.emission.image
969
970     def content_category_title(self):
971         if self.newsitem:
972             if self.newsitem.category:
973                 return self.newsitem.category.title
974             return _('News')
975         if self.emission:
976             return _('Emission')
977         if self.episode:
978             return self.episode.emission.title
979         if self.soundfile:
980             return self.soundfile.episode.emission.title
981         if self.page:
982             return 'Topik'
983
984     def get_related_object(self):
985         try:
986             if self.newsitem:
987                 return self.newsitem
988             if self.emission:
989                 return self.emission
990             if self.episode:
991                 return self.episode
992             if self.soundfile:
993                 return self.soundfile
994             if self.page:
995                 return self.page
996         except ObjectDoesNotExist:
997             return None
998
999         return None
1000
1001
1002 class Picture(models.Model):
1003     class Meta:
1004         ordering = ['order', 'creation_timestamp']
1005
1006     title = models.CharField(_('Title'), max_length=150, null=True, blank=True)
1007     alt_text = models.CharField(_('Alternative Text'), max_length=500, null=True, blank=True)
1008
1009     image_usage_ok = models.BooleanField(_('Include image'), default=False)
1010     image = models.ImageField(_('Picture'), upload_to=get_image_path, max_length=250, null=False, blank=False)
1011     image_attribution_text = models.CharField(
1012         _('Text for picture attribution'), max_length=250, null=True, blank=True
1013     )
1014     image_attribution_url = models.URLField(_('URL for picture attribution'), null=True, blank=True)
1015
1016     newsitem = models.ForeignKey(
1017         'NewsItem', verbose_name=_('News Item'), null=True, blank=True, on_delete=models.CASCADE
1018     )
1019     emission = models.ForeignKey(
1020         'Emission', verbose_name=_('Emission'), null=True, blank=True, on_delete=models.CASCADE
1021     )
1022     episode = models.ForeignKey(
1023         'Episode', verbose_name=_('Episode'), null=True, blank=True, on_delete=models.CASCADE
1024     )
1025
1026     order = models.PositiveIntegerField(default=999)
1027
1028     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
1029     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
1030
1031     def get_content_object(self):
1032         return self.newsitem or self.emission or self.episode
1033
1034
1035 def get_playlist_sound_path(instance, filename):
1036     return os.path.join(
1037         'playlists', instance.episode.emission.slug, instance.episode.slug, os.path.basename(filename)
1038     )
1039
1040
1041 class PlaylistElement(models.Model):
1042     episode = models.ForeignKey('Episode', null=True, on_delete=models.CASCADE)
1043     title = models.CharField(_('Title'), max_length=200)
1044     notes = models.CharField(_('Notes'), max_length=200, blank=True, default='')
1045     sound = models.FileField(_('Sound'), upload_to=get_playlist_sound_path, max_length=250)
1046     order = models.PositiveIntegerField()
1047
1048     class Meta:
1049         verbose_name = _('Playlist Element')
1050         verbose_name_plural = _('Playlist Elements')
1051         ordering = ['order']
1052
1053     def shortcut(self):
1054         return chr(ord('a') + self.order - 1)
1055
1056
1057 @receiver(pre_save, sender=Focus, dispatch_uid='focus_pre_save')
1058 def set_focus_on_save(sender, instance, **kwargs):
1059     object = instance.get_related_object()
1060     if not hasattr(object, 'has_focus'):
1061         return
1062     must_save = False
1063     if instance.current != object.has_focus:
1064         object.has_focus = instance.current
1065         must_save = True
1066     if object and not object.got_focus and instance.current:
1067         object.got_focus = datetime.datetime.now()
1068         must_save = True
1069     if must_save:
1070         object.save()
1071
1072
1073 @receiver(post_delete, sender=Focus, dispatch_uid='focus_post_delete')
1074 def remove_focus_on_delete(sender, instance, **kwargs):
1075     object = instance.get_related_object()
1076     if object and (object.got_focus or object.has_focus):
1077         object.got_focus = None
1078         object.has_focus = False
1079         object.save()