10 from django.conf import settings
11 from django.core.exceptions import PermissionDenied
12 from django.core.files.storage import default_storage
13 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
14 from django.core.urlresolvers import reverse, reverse_lazy
15 from django.contrib import messages
16 from django.db.models import Q, Sum
17 from django.http import HttpResponse, HttpResponseRedirect, FileResponse, Http404
18 from django.utils.six import StringIO
19 from django.utils.translation import ugettext_lazy as _
20 from django.views.generic.base import RedirectView, TemplateView
21 from django.views.generic.dates import DayArchiveView
22 from django.views.generic.detail import DetailView
23 from django.views.generic.edit import CreateView, FormView, UpdateView
24 from django.views.generic.list import ListView
26 from .forms import UploadTracksForm, TrackMetaForm, TrackSearchForm, CleanupForm, ZoneSettingsForm
27 from .models import (SomaLogLine, Track, Artist, NonstopFile,
28 ScheduledDiffusion, Jingle, Stream, NonstopZoneSettings)
29 from emissions.models import Nonstop, Diffusion
30 from emissions.utils import period_program
33 from .app_settings import app_settings
36 class SomaDayArchiveView(DayArchiveView):
37 queryset = SomaLogLine.objects.all()
38 date_field = "play_timestamp"
39 make_object_list = True
44 class SomaDayArchiveCsvView(SomaDayArchiveView):
45 def render_to_response(self, context, **response_kwargs):
47 writer = csv.writer(out)
48 for line in context['object_list']:
49 if line.filepath.track:
50 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
52 line.filepath.track.title,
53 line.filepath.track.artist.name,
54 line.filepath.track.language,
55 line.filepath.track.instru and 'instru' or '',
56 line.filepath.track.cfwb and 'cfwb' or '',
57 line.filepath.track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if line.filepath.track.added_to_nonstop_timestamp else '',
60 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
62 return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
65 class RedirectTodayView(RedirectView):
66 def get_redirect_url(self, *args, **kwargs):
67 today = datetime.datetime.today()
68 return reverse('archive_day', kwargs={
74 class TrackDetailView(DetailView):
77 def get_context_data(self, **kwargs):
78 ctx = super(TrackDetailView, self).get_context_data(**kwargs)
79 ctx['metadata_form'] = TrackMetaForm(instance=self.object)
82 def post(self, request, *args, **kwargs):
83 assert self.request.user.has_perm('nonstop.add_track')
84 instance = self.get_object()
85 old_nonstop_zones = copy.copy(instance.nonstop_zones.all())
86 form = TrackMetaForm(request.POST, instance=instance)
88 new_nonstop_zones = self.get_object().nonstop_zones.all()
89 if set(old_nonstop_zones) != set(new_nonstop_zones):
90 instance.sync_nonstop_zones()
91 return HttpResponseRedirect('.')
94 def track_sound(request, pk):
96 track = Track.objects.get(id=pk)
97 except Track.DoesNotExist:
99 if not track.file_exists():
101 file_path = track.file_path()
102 remote_ip = (request.META.get('HTTP_X_FORWARDED_FOR') or
103 request.META.get('HTTP_X_REAL_IP') or
104 request.META.get('REMOTE_ADDR'))
105 if remote_ip in settings.INTERNAL_IPS:
107 return FileResponse(open(file_path, 'rb'))
108 # remote user, transcode and serve first minute
111 '-loglevel', 'quiet',
112 '-t', '60', # 60 seconds
117 '-', # send to stdout
119 if track.duration and track.duration.total_seconds() > 60:
120 cmdline[1:1] = ['-ss', '60']
121 cmd = subprocess.run(cmdline, capture_output=True)
122 return HttpResponse(cmd.stdout, content_type='audio/opus')
125 class ArtistDetailView(DetailView):
129 class ArtistListView(ListView):
133 class ZonesView(ListView):
135 template_name = 'nonstop/zones.html'
137 def get_queryset(self):
138 return sorted(super().get_queryset(), key=lambda x: datetime.time(23, 59) if (x.start == x.end) else x.start)
141 class ZoneStats(object):
142 def __init__(self, zone, from_date=None, until_date=None, **kwargs):
144 self.qs = Track.objects.filter(nonstop_zones=self.zone, **kwargs)
145 self.from_date = from_date
147 self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__gte=from_date)
149 self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__lte=until_date)
150 self.qs = self.qs.distinct()
152 def total_duration(self, **kwargs):
154 total = self.qs.filter(**kwargs).aggregate(Sum('duration'))['duration__sum'].total_seconds()
155 except AttributeError:
156 # 'NoneType' object has no attribute 'total_seconds', if there's no
160 duration = _('%d hours') % (total / 3600)
162 duration = _('%d minutes') % (total / 60)
163 start = datetime.datetime(2000, 1, 1, self.zone.start.hour, self.zone.start.minute)
164 end = datetime.datetime(2000, 1, 1, self.zone.end.hour, self.zone.end.minute)
166 end = end + datetime.timedelta(days=1)
167 return duration + _(', → %d days') % (total // (end - start).total_seconds())
169 def count(self, **kwargs):
170 return self.qs.filter(**kwargs).count()
172 def percentage(self, **kwargs):
176 return '%.2f%%' % (100. * self.count(**kwargs) / total)
179 return self.count(instru=True)
181 def instru_percentage(self):
182 return self.percentage(instru=True)
185 return self.count(sabam=True)
187 def sabam_percentage(self):
188 return self.percentage(sabam=True)
191 return self.count(cfwb=True)
193 def cfwb_percentage(self):
194 return self.percentage(cfwb=True)
196 def language_set(self):
197 return self.count() - self.language_unset()
199 def language_unset(self):
200 return self.count(language='')
202 def unset_language_percentage(self):
203 return self.percentage(language='')
206 return self.count(language='fr')
208 def unset_or_na_language(self):
209 return self.qs.filter(Q(language='') | Q(language='na')).count()
211 def french_percentage(self):
212 considered_tracks = self.count() - self.unset_or_na_language()
213 if considered_tracks == 0:
215 return '%.2f%%' % (100. * self.french() / considered_tracks)
217 def quota_french(self):
218 # obligation de diffuser annuellement au moins 30% d'œuvres musicales de
220 considered_tracks = self.count() - self.unset_or_na_language()
221 if considered_tracks == 0:
223 return (100. * self.french() / considered_tracks) > 30.
225 def quota_cfwb(self):
226 # obligation de diffuser annuellement au moins 4,5% d'œuvres musicales
227 # émanant de la Communauté française
228 considered_tracks = self.count()
229 if considered_tracks == 0:
231 return (100. * self.cfwb() / considered_tracks) > 4.5
234 return self.count(nonstopfile__creation_timestamp__gte=self.from_date)
236 def percent_new_files(self):
237 return self.percentage(nonstopfile__creation_timestamp__gte=self.from_date)
240 def parse_date(date):
241 if date.endswith('d'):
242 return datetime.datetime.today() + datetime.timedelta(int(date.rstrip('d')))
243 return datetime.datetime.strptime(date, '%Y-%m-%d').date()
246 class StatisticsView(TemplateView):
247 template_name = 'nonstop/statistics.html'
249 def get_context_data(self, **kwargs):
250 context = super(StatisticsView, self).get_context_data(**kwargs)
251 context['zones'] = [x for x in Nonstop.objects.all().order_by('start') if x.start != x.end]
253 if 'from' in self.request.GET:
254 kwargs['from_date'] = parse_date(self.request.GET['from'])
255 context['from_date'] = kwargs['from_date']
256 if 'until' in self.request.GET:
257 kwargs['until_date'] = parse_date(self.request.GET['until'])
258 if 'onair' in self.request.GET:
259 kwargs['nonstopfile__somalogline__on_air'] = True
260 for zone in context['zones']:
261 zone.stats = ZoneStats(zone, **kwargs)
265 class UploadTracksView(FormView):
266 form_class = UploadTracksForm
267 template_name = 'nonstop/upload.html'
270 def post(self, request, *args, **kwargs):
271 assert self.request.user.has_perm('nonstop.add_track')
272 form_class = self.get_form_class()
273 form = self.get_form(form_class)
274 tracks = request.FILES.getlist('tracks')
275 if not form.is_valid():
276 return self.form_invalid(form)
277 missing_metadata = []
280 with tempfile.NamedTemporaryFile(prefix='track-upload') as tmpfile:
281 tmpfile.write(f.read())
283 metadata = mutagen.File(tmpfile.name, easy=True)
284 if not metadata or not metadata.get('artist') or not metadata.get('title'):
285 missing_metadata.append(f.name)
287 metadatas[f.name] = metadata
289 form.add_error('tracks', _('Missing metadata in: ') + ', '.join(missing_metadata))
290 return self.form_invalid(form)
293 metadata = metadatas[f.name]
294 artist_name = metadata.get('artist')[0]
295 track_title = metadata.get('title')[0]
297 monthdir = datetime.datetime.today().strftime('%Y-%m')
298 filepath = '%s/%s - %s - %s%s' % (monthdir,
299 datetime.datetime.today().strftime('%y%m%d'),
300 artist_name[:50].replace('/', ' ').strip(),
301 track_title[:80].replace('/', ' ').strip(),
302 os.path.splitext(f.name)[-1])
304 artist, created = Artist.objects.get_or_create(name=artist_name)
305 track, created = Track.objects.get_or_create(title=track_title, artist=artist,
306 defaults={'uploader': self.request.user})
307 if created or not track.file_exists():
308 default_storage.save(os.path.join('nonstop', 'tracks', filepath), content=f)
309 nonstop_file = NonstopFile()
310 nonstop_file.set_track_filepath(filepath)
311 nonstop_file.track = track
314 # don't keep duplicated file and do not create a duplicated nonstop file object
316 if request.POST.get('nonstop_zone'):
317 track.nonstop_zones.add(
318 Nonstop.objects.get(id=request.POST.get('nonstop_zone')))
319 track.sync_nonstop_zones()
321 messages.info(self.request, '%d new track(s)' % len(tracks))
322 return self.form_valid(form)
325 class TracksMetadataView(ListView):
326 template_name = 'nonstop/tracks_metadata.html'
328 def get_context_data(self, **kwargs):
329 context = super().get_context_data(**kwargs)
330 context['view'] = self
333 def get_queryset(self):
334 return Track.objects.exclude(nonstop_zones__isnull=True)
336 def post(self, request, *args, **kwargs):
337 assert self.request.user.has_perm('nonstop.add_track')
338 for track_id in request.POST.getlist('track'):
339 track = Track.objects.get(id=track_id)
340 track.language = request.POST.get('lang-%s' % track_id, '')
341 track.instru = 'instru-%s' % track_id in request.POST
342 track.sabam = 'sabam-%s' % track_id in request.POST
343 track.cfwb = 'cfwb-%s' % track_id in request.POST
345 return HttpResponseRedirect('.')
348 class RandomTracksMetadataView(TracksMetadataView):
349 page_title = _('Metadata of random tracks')
351 def get_queryset(self):
352 return super().get_queryset().filter(Q(language='') | Q(language__isnull=True)).order_by('?')[:50]
355 class RecentTracksMetadataView(TracksMetadataView):
356 page_title = _('Metadata of recent tracks')
358 def get_queryset(self):
359 return super().get_queryset().exclude(creation_timestamp__isnull=True).order_by('-creation_timestamp')[:50]
362 class ArtistTracksMetadataView(TracksMetadataView):
365 def page_title(self):
366 return _('Metadata of tracks from %s') % Artist.objects.get(id=self.kwargs['artist_pk']).name
368 def get_queryset(self):
369 return Track.objects.filter(artist_id=self.kwargs['artist_pk']).order_by('title')
372 class QuickLinksView(TemplateView):
373 template_name = 'nonstop/quick_links.html'
375 def get_context_data(self, **kwargs):
376 context = super().get_context_data(**kwargs)
377 day = datetime.datetime.today()
378 context['days'] = [day + datetime.timedelta(days=i) for i in range(5)]
382 class SearchView(TemplateView):
383 template_name = 'nonstop/search.html'
385 def get_queryset(self):
386 queryset = Track.objects.all()
388 q = self.request.GET.get('q')
390 queryset = queryset.filter(Q(title__icontains=q.lower()) | Q(artist__name__icontains=q.lower()))
392 zone = self.request.GET.get('zone')
394 from emissions.models import Nonstop
396 queryset = queryset.filter(nonstop_zones=None)
398 queryset = queryset.filter(nonstop_zones__isnull=False).distinct()
400 queryset = queryset.filter(nonstop_zones=zone)
402 order = self.request.GET.get('order_by') or 'title'
404 if 'added_to_nonstop_timestamp' in order:
405 queryset = queryset.filter(added_to_nonstop_timestamp__isnull=False)
406 queryset = queryset.order_by(order)
409 def get_context_data(self, **kwargs):
410 ctx = super(SearchView, self).get_context_data(**kwargs)
411 ctx['form'] = TrackSearchForm(self.request.GET)
412 queryset = self.get_queryset()
413 qs = self.request.GET.copy()
415 ctx['qs'] = qs.urlencode()
417 tracks = Paginator(queryset.select_related(), 20)
419 page = self.request.GET.get('page')
421 ctx['tracks'] = tracks.page(page)
422 except PageNotAnInteger:
423 ctx['tracks'] = tracks.page(1)
425 ctx['tracks'] = tracks.page(tracks.num_pages)
430 class SearchCsvView(SearchView):
431 def get(self, request, *args, **kwargs):
433 writer = csv.writer(out)
434 writer.writerow(['Title', 'Artist', 'Zones', 'Language', 'Instru', 'CFWB', 'Ajout'])
435 for track in self.get_queryset():
437 track.title if track.title else 'Inconnu',
438 track.artist.name if (track.artist and track.artist.name) else 'Inconnu',
439 ' + '.join([x.title for x in track.nonstop_zones.all()]),
440 track.language or '',
441 track.instru and 'instru' or '',
442 track.cfwb and 'cfwb' or '',
443 track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if track.added_to_nonstop_timestamp else '',
445 return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
448 class CleanupView(TemplateView):
449 template_name = 'nonstop/cleanup.html'
451 def get_context_data(self, **kwargs):
452 ctx = super(CleanupView, self).get_context_data(**kwargs)
453 ctx['form'] = CleanupForm()
455 zone = self.request.GET.get('zone')
457 from emissions.models import Nonstop
458 ctx['zone'] = Nonstop.objects.get(id=zone)
459 ctx['count'] = Track.objects.filter(nonstop_zones=zone).count()
460 ctx['tracks'] = Track.objects.filter(nonstop_zones=zone).order_by(
461 'added_to_nonstop_timestamp').select_related()[:30]
464 def post(self, request, *args, **kwargs):
465 assert self.request.user.has_perm('nonstop.add_track')
467 for track_id in request.POST.getlist('track'):
468 if request.POST.get('remove-%s' % track_id):
469 track = Track.objects.get(id=track_id)
470 track.nonstop_zones.clear()
471 track.sync_nonstop_zones()
474 messages.info(self.request, 'Removed %d new track(s)' % count)
475 return HttpResponseRedirect('.')
478 class AddSomaDiffusionView(CreateView):
479 model = ScheduledDiffusion
480 fields = ['jingle', 'stream']
481 template_name = 'nonstop/streamed-diffusion.html'
485 diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
487 if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
488 fields.append('stream')
491 def get_initial(self):
492 initial = super(AddSomaDiffusionView, self).get_initial()
493 initial['jingle'] = None
494 if 'stream' in self.fields:
495 initial['jingle'] = Jingle.objects.filter(default_for_streams=True).first()
496 initial['stream'] = Stream.objects.all().first()
499 def form_valid(self, form):
500 form.instance.diffusion_id = self.kwargs['pk']
501 episode = form.instance.diffusion.episode
502 if 'stream' in self.fields and form.instance.stream_id is None:
503 messages.error(self.request, _('missing stream'))
504 return HttpResponseRedirect(reverse('episode-view', kwargs={
505 'emission_slug': episode.emission.slug,
506 'slug': episode.slug}))
507 response = super(AddSomaDiffusionView, self).form_valid(form)
508 messages.info(self.request, _('%s added to schedule') % episode.emission.title)
511 def get_success_url(self):
512 diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
513 episode = diffusion.episode
514 return reverse('episode-view', kwargs={
515 'emission_slug': episode.emission.slug,
516 'slug': episode.slug})
519 class DelSomaDiffusionView(RedirectView):
520 def get_redirect_url(self, pk):
521 soma_diffusion = ScheduledDiffusion.objects.filter(diffusion_id=pk).first()
522 episode = soma_diffusion.episode
523 ScheduledDiffusion.objects.filter(diffusion_id=pk).update(diffusion_id=None)
524 messages.info(self.request, _('%s removed from schedule') % episode.emission.title)
525 return reverse('episode-view', kwargs={
526 'emission_slug': episode.emission.slug,
527 'slug': episode.slug})
530 class DiffusionPropertiesView(UpdateView):
531 model = ScheduledDiffusion
532 fields = ['jingle', 'stream']
533 template_name = 'nonstop/streamed-diffusion.html'
537 diffusion = self.get_object().diffusion
539 if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
540 fields.append('stream')
543 def form_valid(self, form):
544 episode = self.get_object().diffusion.episode
545 if 'stream' in self.fields and form.instance.stream_id is None:
546 messages.error(self.request, _('missing stream'))
547 return HttpResponseRedirect(reverse('episode-view', kwargs={
548 'emission_slug': episode.emission.slug,
549 'slug': episode.slug}))
550 response = super(DiffusionPropertiesView, self).form_valid(form)
551 messages.info(self.request, _('%s diffusion properties have been updated.') % episode.emission.title)
554 def get_success_url(self):
555 episode = self.get_object().diffusion.episode
556 return reverse('episode-view', kwargs={
557 'emission_slug': episode.emission.slug,
558 'slug': episode.slug})
561 def jingle_audio_view(request, *args, **kwargs):
562 jingle = Jingle.objects.get(id=kwargs['pk'])
563 return FileResponse(open(os.path.join(app_settings.LOCAL_BASE_PATH, app_settings.JINGLES_PREFIX, jingle.filepath), 'rb'))
566 class AjaxProgram(TemplateView):
567 template_name = 'nonstop/program-fragment.html'
569 def get_context_data(self, date, **kwargs):
570 context = super().get_context_data(**kwargs)
571 now = datetime.datetime.now()
573 date_start = datetime.datetime.strptime(date, '%Y-%m-%d')
575 date_start = datetime.datetime.today()
576 date_start = date_start.replace(hour=5, minute=0, second=0, microsecond=0)
577 today = bool(date_start.timetuple()[:3] == now.timetuple()[:3])
578 date_end = date_start + datetime.timedelta(days=1)
579 context['day_program'] = period_program(date_start, date_end, prefetch_categories=False)
580 for x in context['day_program']:
581 x.klass = x.__class__.__name__
583 for i, x in enumerate(context['day_program']):
584 if today and x.datetime > now and previous_prog:
585 previous_prog.now = True
591 class ZoneSettings(FormView):
592 form_class = ZoneSettingsForm
593 template_name = 'nonstop/zone_settings.html'
594 success_url = reverse_lazy('nonstop-zones')
596 def get_context_data(self, **kwargs):
597 context = super().get_context_data(**kwargs)
598 context['zone'] = Nonstop.objects.get(slug=self.kwargs['slug'])
601 def get_initial(self):
603 zone = Nonstop.objects.get(slug=self.kwargs['slug'])
604 except Nonstop.DoesNotExist:
606 zone_settings = zone.nonstopzonesettings_set.first()
607 if zone_settings is None:
608 zone_settings = NonstopZoneSettings(nonstop=zone)
610 initial = super().get_initial()
611 initial['start'] = zone.start.strftime('%H:%M') if zone.start else None
612 initial['end'] = zone.end.strftime('%H:%M') if zone.end else None
613 initial['intro_jingle'] = zone_settings.intro_jingle_id
614 initial['jingles'] = [x.id for x in zone_settings.jingles.all()]
617 def form_valid(self, form):
618 if not self.request.user.has_perm('nonstop.change_nonstopzonesettings'):
619 raise PermissionDenied()
620 zone = Nonstop.objects.get(slug=self.kwargs['slug'])
621 zone_settings = zone.nonstopzonesettings_set.first()
622 zone.start = form.cleaned_data['start']
623 zone.end = form.cleaned_data['end']
624 zone_settings.jingles.set(form.cleaned_data['jingles'])
625 zone_settings.intro_jingle_id = form.cleaned_data['intro_jingle']
628 return super().form_valid(form)
631 class MuninTracks(StatisticsView):
632 template_name = 'nonstop/munin_tracks.txt'
633 content_type = 'text/plain; charset=utf-8'
635 def get_context_data(self, **kwargs):
636 context = super().get_context_data(**kwargs)
637 context['nonstop_general_total'] = Track.objects.count()
638 active_tracks_qs = Track.objects.filter(nonstop_zones__isnull=False).distinct()
639 context['nonstop_general_active'] = active_tracks_qs.count()
640 context['nonstop_percentages_instru'] = 100 * (
641 active_tracks_qs.filter(instru=True).count() /
642 context['nonstop_general_active'])
643 context['nonstop_percentages_cfwb'] = 100 * (
644 active_tracks_qs.filter(cfwb=True).count() /
645 context['nonstop_general_active'])
646 context['nonstop_percentages_langset'] = 100 * (
647 active_tracks_qs.exclude(language='').count() /
648 context['nonstop_general_active'])
649 context['nonstop_percentages_french'] = 100 * (
650 active_tracks_qs.filter(language='fr').count() /
651 active_tracks_qs.exclude(language__isnull=True).exclude(language__in=('', 'na')).count())