9 from django.core.exceptions import PermissionDenied
10 from django.core.files.storage import default_storage
11 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
12 from django.core.urlresolvers import reverse, reverse_lazy
13 from django.contrib import messages
14 from django.db.models import Q, Sum
15 from django.http import HttpResponse, HttpResponseRedirect, FileResponse, Http404
16 from django.utils.six import StringIO
17 from django.utils.translation import ugettext_lazy as _
18 from django.views.generic.base import RedirectView, TemplateView
19 from django.views.generic.dates import DayArchiveView
20 from django.views.generic.detail import DetailView
21 from django.views.generic.edit import CreateView, FormView, UpdateView
22 from django.views.generic.list import ListView
24 from .forms import UploadTracksForm, TrackMetaForm, TrackSearchForm, CleanupForm, ZoneSettingsForm
25 from .models import (SomaLogLine, Track, Artist, NonstopFile,
26 ScheduledDiffusion, Jingle, Stream, NonstopZoneSettings)
27 from emissions.models import Nonstop, Diffusion
28 from emissions.utils import period_program
31 from .app_settings import app_settings
34 class SomaDayArchiveView(DayArchiveView):
35 queryset = SomaLogLine.objects.all()
36 date_field = "play_timestamp"
37 make_object_list = True
42 class SomaDayArchiveCsvView(SomaDayArchiveView):
43 def render_to_response(self, context, **response_kwargs):
45 writer = csv.writer(out)
46 for line in context['object_list']:
47 if line.filepath.track:
48 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
50 line.filepath.track.title,
51 line.filepath.track.artist.name,
52 line.filepath.track.language,
53 line.filepath.track.instru and 'instru' or '',
54 line.filepath.track.cfwb and 'cfwb' or '',
55 line.filepath.track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if line.filepath.track.added_to_nonstop_timestamp else '',
58 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
60 return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
63 class RedirectTodayView(RedirectView):
64 def get_redirect_url(self, *args, **kwargs):
65 today = datetime.datetime.today()
66 return reverse('archive_day', kwargs={
72 class TrackDetailView(DetailView):
75 def get_context_data(self, **kwargs):
76 ctx = super(TrackDetailView, self).get_context_data(**kwargs)
77 ctx['metadata_form'] = TrackMetaForm(instance=self.object)
80 def post(self, request, *args, **kwargs):
81 assert self.request.user.has_perm('nonstop.add_track')
82 instance = self.get_object()
83 old_nonstop_zones = copy.copy(instance.nonstop_zones.all())
84 form = TrackMetaForm(request.POST, instance=instance)
86 new_nonstop_zones = self.get_object().nonstop_zones.all()
87 if set(old_nonstop_zones) != set(new_nonstop_zones):
88 instance.sync_nonstop_zones()
89 return HttpResponseRedirect('.')
92 class ArtistDetailView(DetailView):
96 class ArtistListView(ListView):
100 class ZonesView(ListView):
102 template_name = 'nonstop/zones.html'
104 def get_queryset(self):
105 return sorted(super().get_queryset(), key=lambda x: datetime.time(23, 59) if (x.start == x.end) else x.start)
108 class ZoneStats(object):
109 def __init__(self, zone, from_date=None, until_date=None, **kwargs):
111 self.qs = Track.objects.filter(nonstop_zones=self.zone, **kwargs)
112 self.from_date = from_date
114 self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__gte=from_date)
116 self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__lte=until_date)
117 self.qs = self.qs.distinct()
119 def total_duration(self, **kwargs):
121 total = self.qs.filter(**kwargs).aggregate(Sum('duration'))['duration__sum'].total_seconds()
122 except AttributeError:
123 # 'NoneType' object has no attribute 'total_seconds', if there's no
127 duration = _('%d hours') % (total / 3600)
129 duration = _('%d minutes') % (total / 60)
130 start = datetime.datetime(2000, 1, 1, self.zone.start.hour, self.zone.start.minute)
131 end = datetime.datetime(2000, 1, 1, self.zone.end.hour, self.zone.end.minute)
133 end = end + datetime.timedelta(days=1)
134 return duration + _(', → %d days') % (total // (end - start).total_seconds())
136 def count(self, **kwargs):
137 return self.qs.filter(**kwargs).count()
139 def percentage(self, **kwargs):
143 return '%.2f%%' % (100. * self.count(**kwargs) / total)
146 return self.count(instru=True)
148 def instru_percentage(self):
149 return self.percentage(instru=True)
152 return self.count(sabam=True)
154 def sabam_percentage(self):
155 return self.percentage(sabam=True)
158 return self.count(cfwb=True)
160 def cfwb_percentage(self):
161 return self.percentage(cfwb=True)
163 def language_set(self):
164 return self.count() - self.language_unset()
166 def language_unset(self):
167 return self.count(language='')
169 def unset_language_percentage(self):
170 return self.percentage(language='')
173 return self.count(language='fr')
175 def unset_or_na_language(self):
176 return self.qs.filter(Q(language='') | Q(language='na')).count()
178 def french_percentage(self):
179 considered_tracks = self.count() - self.unset_or_na_language()
180 if considered_tracks == 0:
182 return '%.2f%%' % (100. * self.french() / considered_tracks)
184 def quota_french(self):
185 # obligation de diffuser annuellement au moins 30% d'œuvres musicales de
187 considered_tracks = self.count() - self.unset_or_na_language()
188 if considered_tracks == 0:
190 return (100. * self.french() / considered_tracks) > 30.
192 def quota_cfwb(self):
193 # obligation de diffuser annuellement au moins 4,5% d'œuvres musicales
194 # émanant de la Communauté française
195 considered_tracks = self.count()
196 if considered_tracks == 0:
198 return (100. * self.cfwb() / considered_tracks) > 4.5
201 return self.count(nonstopfile__creation_timestamp__gte=self.from_date)
203 def percent_new_files(self):
204 return self.percentage(nonstopfile__creation_timestamp__gte=self.from_date)
207 def parse_date(date):
208 if date.endswith('d'):
209 return datetime.datetime.today() + datetime.timedelta(int(date.rstrip('d')))
210 return datetime.datetime.strptime(date, '%Y-%m-%d').date()
213 class StatisticsView(TemplateView):
214 template_name = 'nonstop/statistics.html'
216 def get_context_data(self, **kwargs):
217 context = super(StatisticsView, self).get_context_data(**kwargs)
218 context['zones'] = [x for x in Nonstop.objects.all().order_by('start') if x.start != x.end]
220 if 'from' in self.request.GET:
221 kwargs['from_date'] = parse_date(self.request.GET['from'])
222 context['from_date'] = kwargs['from_date']
223 if 'until' in self.request.GET:
224 kwargs['until_date'] = parse_date(self.request.GET['until'])
225 if 'onair' in self.request.GET:
226 kwargs['nonstopfile__somalogline__on_air'] = True
227 for zone in context['zones']:
228 zone.stats = ZoneStats(zone, **kwargs)
232 class UploadTracksView(FormView):
233 form_class = UploadTracksForm
234 template_name = 'nonstop/upload.html'
237 def post(self, request, *args, **kwargs):
238 assert self.request.user.has_perm('nonstop.add_track')
239 form_class = self.get_form_class()
240 form = self.get_form(form_class)
241 tracks = request.FILES.getlist('tracks')
242 if not form.is_valid():
243 return self.form_invalid(form)
244 missing_metadata = []
247 with tempfile.NamedTemporaryFile(prefix='track-upload') as tmpfile:
248 tmpfile.write(f.read())
250 metadata = mutagen.File(tmpfile.name, easy=True)
251 if not metadata or not metadata.get('artist') or not metadata.get('title'):
252 missing_metadata.append(f.name)
254 metadatas[f.name] = metadata
256 form.add_error('tracks', _('Missing metadata in: ') + ', '.join(missing_metadata))
257 return self.form_invalid(form)
260 metadata = metadatas[f.name]
261 artist_name = metadata.get('artist')[0]
262 track_title = metadata.get('title')[0]
264 monthdir = datetime.datetime.today().strftime('%Y-%m')
265 filepath = '%s/%s - %s - %s%s' % (monthdir,
266 datetime.datetime.today().strftime('%y%m%d'),
267 artist_name[:50].replace('/', ' ').strip(),
268 track_title[:80].replace('/', ' ').strip(),
269 os.path.splitext(f.name)[-1])
271 artist, created = Artist.objects.get_or_create(name=artist_name)
272 track, created = Track.objects.get_or_create(title=track_title, artist=artist,
273 defaults={'uploader': self.request.user})
274 if created or not track.file_exists():
275 default_storage.save(os.path.join('nonstop', 'tracks', filepath), content=f)
276 nonstop_file = NonstopFile()
277 nonstop_file.set_track_filepath(filepath)
278 nonstop_file.track = track
281 # don't keep duplicated file and do not create a duplicated nonstop file object
283 if request.POST.get('nonstop_zone'):
284 track.nonstop_zones.add(
285 Nonstop.objects.get(id=request.POST.get('nonstop_zone')))
286 track.sync_nonstop_zones()
288 messages.info(self.request, '%d new track(s)' % len(tracks))
289 return self.form_valid(form)
292 class TracksMetadataView(ListView):
293 template_name = 'nonstop/tracks_metadata.html'
295 def get_context_data(self, **kwargs):
296 context = super().get_context_data(**kwargs)
297 context['view'] = self
300 def get_queryset(self):
301 return Track.objects.exclude(nonstop_zones__isnull=True)
303 def post(self, request, *args, **kwargs):
304 assert self.request.user.has_perm('nonstop.add_track')
305 for track_id in request.POST.getlist('track'):
306 track = Track.objects.get(id=track_id)
307 track.language = request.POST.get('lang-%s' % track_id, '')
308 track.instru = 'instru-%s' % track_id in request.POST
309 track.sabam = 'sabam-%s' % track_id in request.POST
310 track.cfwb = 'cfwb-%s' % track_id in request.POST
312 return HttpResponseRedirect('.')
315 class RandomTracksMetadataView(TracksMetadataView):
316 page_title = _('Metadata of random tracks')
318 def get_queryset(self):
319 return super().get_queryset().filter(Q(language='') | Q(language__isnull=True)).order_by('?')[:50]
322 class RecentTracksMetadataView(TracksMetadataView):
323 page_title = _('Metadata of recent tracks')
325 def get_queryset(self):
326 return super().get_queryset().exclude(creation_timestamp__isnull=True).order_by('-creation_timestamp')[:50]
329 class ArtistTracksMetadataView(TracksMetadataView):
332 def page_title(self):
333 return _('Metadata of tracks from %s') % Artist.objects.get(id=self.kwargs['artist_pk']).name
335 def get_queryset(self):
336 return Track.objects.filter(artist_id=self.kwargs['artist_pk']).order_by('title')
339 class QuickLinksView(TemplateView):
340 template_name = 'nonstop/quick_links.html'
342 def get_context_data(self, **kwargs):
343 context = super().get_context_data(**kwargs)
344 day = datetime.datetime.today()
345 context['days'] = [day + datetime.timedelta(days=i) for i in range(5)]
349 class SearchView(TemplateView):
350 template_name = 'nonstop/search.html'
352 def get_queryset(self):
353 queryset = Track.objects.all()
355 q = self.request.GET.get('q')
357 queryset = queryset.filter(Q(title__icontains=q.lower()) | Q(artist__name__icontains=q.lower()))
359 zone = self.request.GET.get('zone')
361 from emissions.models import Nonstop
363 queryset = queryset.filter(nonstop_zones=None)
365 queryset = queryset.filter(nonstop_zones__isnull=False).distinct()
367 queryset = queryset.filter(nonstop_zones=zone)
369 order = self.request.GET.get('order_by') or 'title'
371 if 'added_to_nonstop_timestamp' in order:
372 queryset = queryset.filter(added_to_nonstop_timestamp__isnull=False)
373 queryset = queryset.order_by(order)
376 def get_context_data(self, **kwargs):
377 ctx = super(SearchView, self).get_context_data(**kwargs)
378 ctx['form'] = TrackSearchForm(self.request.GET)
379 queryset = self.get_queryset()
380 qs = self.request.GET.copy()
382 ctx['qs'] = qs.urlencode()
384 tracks = Paginator(queryset.select_related(), 20)
386 page = self.request.GET.get('page')
388 ctx['tracks'] = tracks.page(page)
389 except PageNotAnInteger:
390 ctx['tracks'] = tracks.page(1)
392 ctx['tracks'] = tracks.page(tracks.num_pages)
397 class SearchCsvView(SearchView):
398 def get(self, request, *args, **kwargs):
400 writer = csv.writer(out)
401 writer.writerow(['Title', 'Artist', 'Zones', 'Language', 'Instru', 'CFWB', 'Ajout'])
402 for track in self.get_queryset():
404 track.title if track.title else 'Inconnu',
405 track.artist.name if (track.artist and track.artist.name) else 'Inconnu',
406 ' + '.join([x.title for x in track.nonstop_zones.all()]),
407 track.language or '',
408 track.instru and 'instru' or '',
409 track.cfwb and 'cfwb' or '',
410 track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if track.added_to_nonstop_timestamp else '',
412 return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
415 class CleanupView(TemplateView):
416 template_name = 'nonstop/cleanup.html'
418 def get_context_data(self, **kwargs):
419 ctx = super(CleanupView, self).get_context_data(**kwargs)
420 ctx['form'] = CleanupForm()
422 zone = self.request.GET.get('zone')
424 from emissions.models import Nonstop
425 ctx['zone'] = Nonstop.objects.get(id=zone)
426 ctx['count'] = Track.objects.filter(nonstop_zones=zone).count()
427 ctx['tracks'] = Track.objects.filter(nonstop_zones=zone).order_by(
428 'added_to_nonstop_timestamp').select_related()[:30]
431 def post(self, request, *args, **kwargs):
432 assert self.request.user.has_perm('nonstop.add_track')
434 for track_id in request.POST.getlist('track'):
435 if request.POST.get('remove-%s' % track_id):
436 track = Track.objects.get(id=track_id)
437 track.nonstop_zones.clear()
438 track.sync_nonstop_zones()
441 messages.info(self.request, 'Removed %d new track(s)' % count)
442 return HttpResponseRedirect('.')
445 class AddSomaDiffusionView(CreateView):
446 model = ScheduledDiffusion
447 fields = ['jingle', 'stream']
448 template_name = 'nonstop/streamed-diffusion.html'
452 diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
454 if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
455 fields.append('stream')
458 def get_initial(self):
459 initial = super(AddSomaDiffusionView, self).get_initial()
460 initial['jingle'] = None
461 if 'stream' in self.fields:
462 initial['jingle'] = Jingle.objects.filter(default_for_streams=True).first()
463 initial['stream'] = Stream.objects.all().first()
466 def form_valid(self, form):
467 form.instance.diffusion_id = self.kwargs['pk']
468 episode = form.instance.diffusion.episode
469 if 'stream' in self.fields and form.instance.stream_id is None:
470 messages.error(self.request, _('missing stream'))
471 return HttpResponseRedirect(reverse('episode-view', kwargs={
472 'emission_slug': episode.emission.slug,
473 'slug': episode.slug}))
474 response = super(AddSomaDiffusionView, self).form_valid(form)
475 messages.info(self.request, _('%s added to schedule') % episode.emission.title)
478 def get_success_url(self):
479 diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
480 episode = diffusion.episode
481 return reverse('episode-view', kwargs={
482 'emission_slug': episode.emission.slug,
483 'slug': episode.slug})
486 class DelSomaDiffusionView(RedirectView):
487 def get_redirect_url(self, pk):
488 soma_diffusion = ScheduledDiffusion.objects.filter(diffusion_id=pk).first()
489 episode = soma_diffusion.episode
490 ScheduledDiffusion.objects.filter(diffusion_id=pk).update(diffusion_id=None)
491 messages.info(self.request, _('%s removed from schedule') % episode.emission.title)
492 return reverse('episode-view', kwargs={
493 'emission_slug': episode.emission.slug,
494 'slug': episode.slug})
497 class DiffusionPropertiesView(UpdateView):
498 model = ScheduledDiffusion
499 fields = ['jingle', 'stream']
500 template_name = 'nonstop/streamed-diffusion.html'
504 diffusion = self.get_object().diffusion
506 if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
507 fields.append('stream')
510 def form_valid(self, form):
511 episode = self.get_object().diffusion.episode
512 if 'stream' in self.fields and form.instance.stream_id is None:
513 messages.error(self.request, _('missing stream'))
514 return HttpResponseRedirect(reverse('episode-view', kwargs={
515 'emission_slug': episode.emission.slug,
516 'slug': episode.slug}))
517 response = super(DiffusionPropertiesView, self).form_valid(form)
518 messages.info(self.request, _('%s diffusion properties have been updated.') % episode.emission.title)
521 def get_success_url(self):
522 episode = self.get_object().diffusion.episode
523 return reverse('episode-view', kwargs={
524 'emission_slug': episode.emission.slug,
525 'slug': episode.slug})
528 def jingle_audio_view(request, *args, **kwargs):
529 jingle = Jingle.objects.get(id=kwargs['pk'])
530 return FileResponse(open(os.path.join(app_settings.LOCAL_BASE_PATH, app_settings.JINGLES_PREFIX, jingle.filepath), 'rb'))
533 class AjaxProgram(TemplateView):
534 template_name = 'nonstop/program-fragment.html'
536 def get_context_data(self, date, **kwargs):
537 context = super().get_context_data(**kwargs)
538 now = datetime.datetime.now()
540 date_start = datetime.datetime.strptime(date, '%Y-%m-%d')
542 date_start = datetime.datetime.today()
543 date_start = date_start.replace(hour=5, minute=0, second=0, microsecond=0)
544 today = bool(date_start.timetuple()[:3] == now.timetuple()[:3])
545 date_end = date_start + datetime.timedelta(days=1)
546 context['day_program'] = period_program(date_start, date_end, prefetch_categories=False)
547 for x in context['day_program']:
548 x.klass = x.__class__.__name__
550 for i, x in enumerate(context['day_program']):
551 if today and x.datetime > now and previous_prog:
552 previous_prog.now = True
558 class ZoneSettings(FormView):
559 form_class = ZoneSettingsForm
560 template_name = 'nonstop/zone_settings.html'
561 success_url = reverse_lazy('nonstop-zones')
563 def get_context_data(self, **kwargs):
564 context = super().get_context_data(**kwargs)
565 context['zone'] = Nonstop.objects.get(slug=self.kwargs['slug'])
568 def get_initial(self):
570 zone = Nonstop.objects.get(slug=self.kwargs['slug'])
571 except Nonstop.DoesNotExist:
573 zone_settings = zone.nonstopzonesettings_set.first()
574 if zone_settings is None:
575 zone_settings = NonstopZoneSettings(nonstop=zone)
577 initial = super().get_initial()
578 initial['start'] = zone.start.strftime('%H:%M') if zone.start else None
579 initial['end'] = zone.end.strftime('%H:%M') if zone.end else None
580 initial['intro_jingle'] = zone_settings.intro_jingle_id
581 initial['jingles'] = [x.id for x in zone_settings.jingles.all()]
584 def form_valid(self, form):
585 if not self.request.user.has_perm('nonstop.change_nonstopzonesettings'):
586 raise PermissionDenied()
587 zone = Nonstop.objects.get(slug=self.kwargs['slug'])
588 zone_settings = zone.nonstopzonesettings_set.first()
589 zone.start = form.cleaned_data['start']
590 zone.end = form.cleaned_data['end']
591 zone_settings.jingles.set(form.cleaned_data['jingles'])
592 zone_settings.intro_jingle_id = form.cleaned_data['intro_jingle']
595 return super().form_valid(form)
598 class MuninTracks(StatisticsView):
599 template_name = 'nonstop/munin_tracks.txt'
600 content_type = 'text/plain; charset=utf-8'
602 def get_context_data(self, **kwargs):
603 context = super().get_context_data(**kwargs)
604 context['nonstop_general_total'] = Track.objects.count()
605 active_tracks_qs = Track.objects.filter(nonstop_zones__isnull=False).distinct()
606 context['nonstop_general_active'] = active_tracks_qs.count()
607 context['nonstop_percentages_instru'] = 100 * (
608 active_tracks_qs.filter(instru=True).count() /
609 context['nonstop_general_active'])
610 context['nonstop_percentages_cfwb'] = 100 * (
611 active_tracks_qs.filter(cfwb=True).count() /
612 context['nonstop_general_active'])
613 context['nonstop_percentages_langset'] = 100 * (
614 active_tracks_qs.exclude(language='').count() /
615 context['nonstop_general_active'])
616 context['nonstop_percentages_french'] = 100 * (
617 active_tracks_qs.filter(language='fr').count() /
618 active_tracks_qs.exclude(language__isnull=True).exclude(language__in=('', 'na')).count())