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
23 from .app_settings import app_settings
24 from .utils import get_duration, maybe_resize
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')),
38 PODCAST_SOUND_QUALITY_LIST = (
39 ('standard', _('Standard')),
41 ('highest', _('Highest')),
45 def get_first_url_from_multi(value):
48 return value.splitlines()[0]
51 def get_url_kind(url):
52 netloc = urllib.parse.urlparse(url).netloc
53 if netloc in ('facebook.com', 'www.facebook.com'):
55 if netloc in ('t.co', 'twitter.com'):
57 if netloc in ('instagram.com', 'www.instagram.com'):
59 if netloc in ('twitch.com', 'twitch.tv', 'www.twitch.com', 'www.twitch.tv'):
61 if netloc == 'www.youtube.com':
63 if netloc.endswith('.bandcamp.com'):
65 if netloc == 'soundcloud.com':
67 if netloc == 'www.mixcloud.com':
72 def get_urls_and_kind(value):
73 for url in (value or '').splitlines():
74 yield (get_url_kind(url), url)
77 def get_urls_by_kind(value):
79 for url in (value or '').splitlines():
80 kind = get_url_kind(url) or '_website'
81 urls.append((kind, url))
83 urls = [(x.strip('_'), y) for x, y in urls]
87 def generate_slug(instance, **query_filters):
88 base_slug = instance.base_slug
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)
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):
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,
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,
120 self.end_datetime.hour,
121 self.end_datetime.minute,
123 app_settings.DAY_HOUR_START,
124 app_settings.DAY_MINUTE_START,
126 if (self.end_datetime.weekday() + 1) == day:
128 return week_day == day
131 class Category(models.Model):
133 verbose_name = _('Category')
134 verbose_name_plural = _('Categories')
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)
142 def sorted_emission(self):
143 return self.emission_set.order_by('title')
149 class Format(models.Model):
151 verbose_name = _('Format')
152 verbose_name_plural = _('Formats')
155 title = models.CharField(_('Title'), max_length=50)
156 slug = models.SlugField(null=True)
157 archived = models.BooleanField(pgettext_lazy('format', 'Archived'), default=False)
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)
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():
184 class Emission(models.Model):
186 verbose_name = _('Emission')
187 verbose_name_plural = _('Emissions')
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)
197 # XXX: languages (models.ManyToManyField(Language))
199 duration = models.IntegerField(_('Duration'), default=60, help_text=_('In minutes'))
201 default_license = models.CharField(
202 _('Default license for podcasts'), max_length=20, blank=True, default='', choices=LICENSES
204 podcast_sound_quality = models.CharField(
205 _('Podcast sound quality'), max_length=20, default='standard', choices=PODCAST_SOUND_QUALITY_LIST
207 email = models.EmailField(_('Email'), max_length=254, null=True, blank=True)
208 website = MultiURLField(_('Website'), null=True, blank=True)
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
215 image_attribution_url = models.URLField(_('URL for image attribution'), null=True, blank=True)
216 tags = TaggableManager(_('Tags'), blank=True)
218 chat_open = models.DateTimeField(null=True, blank=True)
220 # denormalized from Focus
221 got_focus = models.DateTimeField(default=None, null=True, blank=True)
222 has_focus = models.BooleanField(default=False)
224 creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
225 last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
227 def get_absolute_url(self):
228 return reverse('emission-view', kwargs={'slug': str(self.slug)})
235 return slugify(self.title).strip('-') or 'untitled'
237 def save(self, *args, **kwargs):
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)
244 def get_recurring_content(self):
245 from nonstop.models import RecurringPlaylistDiffusion, RecurringStreamDiffusion
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
254 def get_week_compacted_schedules(self, rerun=None):
257 for x in self.schedule_set.all().order_by('datetime')
258 if (rerun is None and True) or x.rerun is rerun
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]]
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)
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]
287 def get_schedules(self):
288 return list(self.get_compacted_schedules())
290 def get_schedules_no_reruns(self):
291 return list(self.get_compacted_schedules(rerun=False))
293 def get_schedules_reruns(self):
294 return list(self.get_compacted_schedules(rerun=True))
296 def get_sorted_episodes(self):
298 self.episode_set.select_related()
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''',
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''',
316 .exclude(first_diffusion__isnull=True)
317 .order_by('-first_diffusion')
320 def get_sorted_newsitems(self):
321 return self.newsitem_set.select_related().order_by('-date')
323 def get_next_planned_date_and_schedule(self, since=None, include_rerun=False):
325 schedules = self.schedule_set.all()
327 schedules = self.schedule_set.filter(rerun=False)
331 since = datetime.datetime.today()
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]
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
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)
346 return result[1].duration or self.duration
348 def get_website_url(self):
349 return get_first_url_from_multi(self.website)
351 def get_website_urls(self):
352 return get_urls_by_kind(self.website)
355 class Schedule(models.Model, WeekdayMixin):
357 verbose_name = _('Schedule')
358 verbose_name_plural = _('Schedules')
359 ordering = ['datetime']
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')),
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'))
377 def weeks_string(self):
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:
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]])
395 def week_sort_key(self):
399 0b0101, # First and third week
400 0b0010, # Second week
401 0b1010, # Second and fourth week
403 0b1000, # Fourth week
405 if self.weeks in order:
406 return order.index(self.weeks)
409 def get_duration(self):
413 return self.emission.duration
416 def end_datetime(self):
417 return self.datetime + datetime.timedelta(minutes=self.get_duration())
419 def match_week(self, week_no):
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:
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):
432 if weekday != self.get_weekday():
434 if self.weeks != 0b1111:
435 week_no = (dt.day - 1) // 7
436 if self.match_week(week_no) is False:
439 dt.time() >= self.datetime.time()
440 and dt.time() <= (self.datetime + datetime.timedelta(minutes=self.get_duration())).time()
445 def get_next_planned_date(self, since):
447 since = datetime.datetime.today()
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
453 start_week_date -= datetime.timedelta(days=start_week_date.weekday())
454 start_week_date += datetime.timedelta(days=self.datetime.weekday())
456 week_date = start_week_date + datetime.timedelta(days=i * 7)
457 if week_date < since:
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]
467 return '%s at %s' % (self.emission.title, self.datetime.strftime('%a %H:%M'))
470 class Episode(models.Model):
472 verbose_name = _('Episode')
473 verbose_name_plural = _('Episodes')
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'))
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
490 image_attribution_url = models.URLField(_('URL for image attribution'), null=True, blank=True)
492 agenda_only = models.BooleanField(_('Only include in agenda'), default=False)
494 effective_start = models.DateTimeField(null=True, blank=True)
495 effective_end = models.DateTimeField(null=True, blank=True)
497 # denormalized from Focus
498 got_focus = models.DateTimeField(default=None, null=True, blank=True)
499 has_focus = models.BooleanField(default=False)
501 creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
502 last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
504 # XXX: languages (models.ManyToManyField(Language))
511 return slugify(self.title).strip('-') or 'untitled'
513 def save(self, *args, **kwargs):
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)
520 def get_duration(self):
524 return self.emission.duration
526 def get_absolute_url(self):
528 'episode-view', kwargs={'emission_slug': str(self.emission.slug), 'slug': str(self.slug)}
531 def get_site_url(self):
532 return urllib.parse.urljoin(settings.WEBSITE_BASE_URL, self.get_absolute_url())
535 if hasattr(self, 'latest_soundfile_timestamp'):
536 return bool(self.latest_soundfile_timestamp)
537 return self.soundfile_set.count() > 0
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'),
550 def set_prefetched_soundfiles(cls, soundfiles):
551 cls._soundfiles.update(soundfiles)
553 def has_prefetched_soundfile(self):
554 return self.id in self._soundfiles
556 def get_prefetched_soundfile(self):
557 return self._soundfiles.get(self.id)
562 def main_sound(self):
563 if self._main_sound is not False:
564 return self._main_sound
566 if self.has_prefetched_soundfile():
567 self._main_sound = self.get_prefetched_soundfile()
568 return self._main_sound
570 t = self.soundfile_set.exclude(podcastable=False).exclude(fragment=True)
572 self._main_sound = t[0]
574 self._main_sound = None
576 return self._main_sound
579 def main_sound(self, value):
580 self._main_sound = value
582 def podcastable_sounds(self):
583 return self.soundfile_set.exclude(podcastable=False)
585 def fragment_sounds(self):
586 return self.soundfile_set.exclude(podcastable=False).exclude(fragment=False)
588 def main_sounds(self):
589 return self.soundfile_set.exclude(fragment=True)
591 def diffusions(self):
592 return Diffusion.objects.filter(episode=self.id).order_by('datetime')
594 def get_extra_links_and_kind(self):
595 yield from get_urls_and_kind(self.extra_links)
598 class Diffusion(models.Model, WeekdayMixin):
600 verbose_name = _('Diffusion')
601 verbose_name_plural = _('Diffusions')
602 ordering = ['datetime']
604 episode = models.ForeignKey('Episode', verbose_name=_('Episode'), on_delete=models.CASCADE)
605 datetime = models.DateTimeField(verbose_name=_('Date/time'), db_index=True)
608 return '%s at %02d:%02d' % (self.episode.title, self.datetime.hour, self.datetime.minute)
610 def get_duration(self):
611 return self.episode.get_duration()
614 def end_datetime(self):
615 return self.datetime + datetime.timedelta(minutes=self.get_duration())
619 return self.episode.emission
622 class Absence(models.Model):
624 verbose_name = _('Absence')
625 verbose_name_plural = _('Absences')
627 emission = models.ForeignKey('Emission', verbose_name='Emission', on_delete=models.CASCADE)
628 datetime = models.DateTimeField(_('Date/time'), db_index=True)
631 return 'Absence for %s on %s' % (self.emission.title, self.datetime)
634 def get_sound_path(instance, filename):
635 return os.path.join('sounds.orig', instance.episode.emission.slug, os.path.basename(filename))
638 class SoundFile(models.Model):
640 verbose_name = _('Sound file')
641 verbose_name_plural = _('Sound files')
642 ordering = ['order', 'creation_timestamp']
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(
651 help_text=_('Get the sound published on the website'),
653 fragment = models.BooleanField(
657 help_text=_('The file is some segment or extra content, not the complete recording.'),
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
663 format = models.ForeignKey(
664 'Format', verbose_name=_('Format'), null=True, blank=True, on_delete=models.SET_NULL
666 license = models.CharField(_('License'), max_length=20, blank=True, default='', choices=LICENSES)
668 order = models.PositiveIntegerField(default=0)
670 # denormalized from Focus
671 got_focus = models.DateTimeField(default=None, null=True, blank=True)
672 has_focus = models.BooleanField(default=False)
675 download_count = models.IntegerField(_('Download Count'), default=0)
677 creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
678 last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
680 def compute_duration(self):
683 for path in (self.get_format_path('ogg'), self.get_format_path('mp3'), self.file.path):
684 self.duration = get_duration(path)
691 if self.external_url:
692 return self.external_url
694 def get_external_host(self):
695 if not self.external_url:
697 parts = urllib.parse.urlparse(self.external_url)
698 if parts.netloc == 'www.mixcloud.com':
700 if parts.netloc == 'api.soundcloud.com':
703 def get_external_embed_url(self):
704 if not self.external_url:
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)
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)
715 def get_format_path(self, format):
719 os.path.dirname(self.file.path).replace('.orig', ''),
720 self.get_format_filename(format),
723 def get_format_url(self, format):
727 os.path.dirname(self.file.url).replace('.orig', ''),
728 self.get_format_filename(format),
731 def get_duration_string(self):
732 if not self.duration:
734 return '%d:%02d' % (self.duration / 60, self.duration % 60)
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()
742 def is_too_long(self):
743 if self.fragment or self.external_url:
745 return bool(self.duration > max(self.get_durations()) * 1.2)
747 def is_too_short(self):
748 if self.fragment or self.external_url:
750 return bool(self.duration < min(self.get_durations()) * 0.5)
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)
761 return '%s - %s' % (self.title or self.id, self.episode.title)
764 class NewsCategory(models.Model):
766 verbose_name = _('News Category')
767 verbose_name_plural = _('News Categories')
770 title = models.CharField(_('Title'), max_length=50)
771 slug = models.SlugField(null=True)
772 archived = models.BooleanField(pgettext_lazy('category', 'Archived'), default=False)
777 def get_sorted_newsitems(self):
778 return self.newsitem_set.select_related().order_by('-date')
781 class NewsItem(models.Model):
783 verbose_name = _('News Item')
784 verbose_name_plural = _('News Items')
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.')
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
800 image_attribution_url = models.URLField(_('URL for image attribution'), null=True, blank=True)
802 tags = TaggableManager(_('Tags'), blank=True)
803 category = models.ForeignKey(
804 'NewsCategory', verbose_name=_('Category'), null=True, blank=True, on_delete=models.SET_NULL
806 emission = models.ForeignKey(
807 'Emission', verbose_name=_('Emission'), null=True, blank=True, on_delete=models.CASCADE
810 expiration_date = models.DateField(_('Expiration Date'), null=True, blank=True)
811 event_date = models.DateField(
815 help_text=_('If this is an event, set the date here so it appears in the agenda.'),
818 # denormalized from Focus
819 got_focus = models.DateTimeField(default=None, null=True, blank=True)
820 has_focus = models.BooleanField(default=False)
822 creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
823 last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
830 return slugify(self.title).strip('-') or 'untitled'
832 def save(self, *args, **kwargs):
834 self.slug = generate_slug(self)
835 super().save(*args, **kwargs)
837 def get_absolute_url(self):
838 return reverse('newsitem-view', kwargs={'slug': str(self.slug)})
841 class Nonstop(models.Model):
843 verbose_name = _('Nonstop zone')
844 verbose_name_plural = _('Nonstop zones')
847 title = models.CharField(_('Title'), max_length=50)
848 slug = models.SlugField()
850 start = models.TimeField(_('Start'))
851 end = models.TimeField(_('End'))
853 subtitle = models.CharField(_('Subtitle'), max_length=80, null=True, blank=True)
854 text = RichTextField(_('Description'), null=True, blank=True)
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
861 image_attribution_url = models.URLField(_('URL for image attribution'), null=True, blank=True)
863 redirect_path = models.CharField(_('Redirect Path'), max_length=200, blank=True)
870 return slugify(self.title).strip('-') or 'untitled'
872 def save(self, *args, **kwargs):
874 self.slug = generate_slug(self)
875 super().save(*args, **kwargs)
877 def get_public_label(self):
878 return self.title.split('(')[0].strip()
880 def get_playlist_emissions(self):
882 for recurring_playlist in self.recurring_playlist_zones.all().select_related(
883 'schedule', 'schedule__emission'
885 emissions[recurring_playlist.schedule.emission_id] = recurring_playlist.schedule.emission
886 return emissions.values()
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
894 emission = models.ForeignKey(
895 'Emission', verbose_name=_('Emission'), null=True, blank=True, on_delete=models.CASCADE
897 episode = models.ForeignKey(
898 'Episode', verbose_name=_('Episode'), null=True, blank=True, on_delete=models.CASCADE
900 soundfile = models.ForeignKey(
901 'SoundFile', verbose_name=_('Sound file'), null=True, blank=True, on_delete=models.CASCADE
903 page = models.ForeignKey(
904 'data.Page', verbose_name=_('Page'), null=True, blank=True, on_delete=models.CASCADE
906 current = models.BooleanField('Current', default=True)
908 creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
909 last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
913 return 'Newsitem: %s' % self.newsitem.title
915 return 'Emission: %s' % self.emission.title
917 return 'Episode: %s' % self.episode.title
919 return 'Soundfile: %s' % (self.soundfile.title or self.soundfile.episode.title)
921 return 'Page: %s' % self.page.title
922 return '%s' % self.id
924 def focus_title(self):
928 return self.newsitem.title
930 return self.emission.title
932 return self.page.title
936 if self.soundfile.fragment and self.soundfile.title:
937 return self.soundfile.title
938 episode = self.soundfile.episode
940 episode = self.episode
946 return episode.emission.title
950 def content_image(self):
952 return self.newsitem.image
954 return self.emission.image
956 return self.page.picture
960 episode = self.soundfile.episode
962 episode = self.episode
968 return episode.emission.image
970 def content_category_title(self):
972 if self.newsitem.category:
973 return self.newsitem.category.title
978 return self.episode.emission.title
980 return self.soundfile.episode.emission.title
984 def get_related_object(self):
993 return self.soundfile
996 except ObjectDoesNotExist:
1002 class Picture(models.Model):
1004 ordering = ['order', 'creation_timestamp']
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)
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
1014 image_attribution_url = models.URLField(_('URL for picture attribution'), null=True, blank=True)
1016 newsitem = models.ForeignKey(
1017 'NewsItem', verbose_name=_('News Item'), null=True, blank=True, on_delete=models.CASCADE
1019 emission = models.ForeignKey(
1020 'Emission', verbose_name=_('Emission'), null=True, blank=True, on_delete=models.CASCADE
1022 episode = models.ForeignKey(
1023 'Episode', verbose_name=_('Episode'), null=True, blank=True, on_delete=models.CASCADE
1026 order = models.PositiveIntegerField(default=999)
1028 creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
1029 last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
1031 def get_content_object(self):
1032 return self.newsitem or self.emission or self.episode
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)
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()
1049 verbose_name = _('Playlist Element')
1050 verbose_name_plural = _('Playlist Elements')
1051 ordering = ['order']
1054 return chr(ord('a') + self.order - 1)
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'):
1063 if instance.current != object.has_focus:
1064 object.has_focus = instance.current
1066 if object and not object.got_focus and instance.current:
1067 object.got_focus = datetime.datetime.now()
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