9 from django.core.files.storage import default_storage
10 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
11 from django.core.urlresolvers import reverse, reverse_lazy
12 from django.contrib import messages
13 from django.db.models import Q, Sum
14 from django.http import HttpResponse, HttpResponseRedirect, FileResponse, Http404
15 from django.utils.six import StringIO
16 from django.utils.translation import ugettext_lazy as _
17 from django.views.generic.base import RedirectView, TemplateView
18 from django.views.generic.dates import DayArchiveView
19 from django.views.generic.detail import DetailView
20 from django.views.generic.edit import CreateView, FormView, UpdateView
21 from django.views.generic.list import ListView
23 from .forms import UploadTracksForm, TrackMetaForm, TrackSearchForm, CleanupForm, ZoneSettingsForm
24 from .models import (SomaLogLine, Track, Artist, NonstopFile,
25 ScheduledDiffusion, Jingle, Stream, NonstopZoneSettings)
26 from emissions.models import Nonstop, Diffusion
27 from emissions.utils import period_program
30 from .app_settings import app_settings
33 class SomaDayArchiveView(DayArchiveView):
34 queryset = SomaLogLine.objects.all()
35 date_field = "play_timestamp"
36 make_object_list = True
41 class SomaDayArchiveCsvView(SomaDayArchiveView):
42 def render_to_response(self, context, **response_kwargs):
44 writer = csv.writer(out)
45 for line in context['object_list']:
46 if line.filepath.track:
47 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
49 line.filepath.track.title,
50 line.filepath.track.artist.name,
51 line.filepath.track.language,
52 line.filepath.track.instru and 'instru' or '',
53 line.filepath.track.cfwb and 'cfwb' or '',
54 line.filepath.track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if line.filepath.track.added_to_nonstop_timestamp else '',
57 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
59 return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
62 class RedirectTodayView(RedirectView):
63 def get_redirect_url(self, *args, **kwargs):
64 today = datetime.datetime.today()
65 return reverse('archive_day', kwargs={
71 class TrackDetailView(DetailView):
74 def get_context_data(self, **kwargs):
75 ctx = super(TrackDetailView, self).get_context_data(**kwargs)
76 ctx['metadata_form'] = TrackMetaForm(instance=self.object)
79 def post(self, request, *args, **kwargs):
80 assert self.request.user.has_perm('nonstop.add_track')
81 instance = self.get_object()
82 old_nonstop_zones = copy.copy(instance.nonstop_zones.all())
83 form = TrackMetaForm(request.POST, instance=instance)
85 new_nonstop_zones = self.get_object().nonstop_zones.all()
86 if set(old_nonstop_zones) != set(new_nonstop_zones):
87 instance.sync_nonstop_zones()
88 return HttpResponseRedirect('.')
91 class ArtistDetailView(DetailView):
95 class ArtistListView(ListView):
99 class ZonesView(ListView):
101 template_name = 'nonstop/zones.html'
103 def get_queryset(self):
104 return sorted(super().get_queryset(), key=lambda x: datetime.time(23, 59) if (x.start == x.end) else x.start)
107 class ZoneStats(object):
108 def __init__(self, zone, from_date=None, until_date=None, **kwargs):
110 self.qs = Track.objects.filter(nonstop_zones=self.zone, **kwargs)
111 self.from_date = from_date
113 self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__gte=from_date)
115 self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__lte=until_date)
116 self.qs = self.qs.distinct()
118 def total_duration(self, **kwargs):
120 total = self.qs.filter(**kwargs).aggregate(Sum('duration'))['duration__sum'].total_seconds()
121 except AttributeError:
122 # 'NoneType' object has no attribute 'total_seconds', if there's no
126 duration = _('%d hours') % (total / 3600)
128 duration = _('%d minutes') % (total / 60)
129 start = datetime.datetime(2000, 1, 1, self.zone.start.hour, self.zone.start.minute)
130 end = datetime.datetime(2000, 1, 1, self.zone.end.hour, self.zone.end.minute)
132 end = end + datetime.timedelta(days=1)
133 return duration + _(', → %d days') % (total // (end - start).total_seconds())
135 def count(self, **kwargs):
136 return self.qs.filter(**kwargs).count()
138 def percentage(self, **kwargs):
142 return '%.2f%%' % (100. * self.count(**kwargs) / total)
145 return self.count(instru=True)
147 def instru_percentage(self):
148 return self.percentage(instru=True)
151 return self.count(sabam=True)
153 def sabam_percentage(self):
154 return self.percentage(sabam=True)
157 return self.count(cfwb=True)
159 def cfwb_percentage(self):
160 return self.percentage(cfwb=True)
162 def language_set(self):
163 return self.count() - self.language_unset()
165 def language_unset(self):
166 return self.count(language='')
168 def unset_language_percentage(self):
169 return self.percentage(language='')
172 return self.count(language='fr')
174 def unset_or_na_language(self):
175 return self.qs.filter(Q(language='') | Q(language='na')).count()
177 def french_percentage(self):
178 considered_tracks = self.count() - self.unset_or_na_language()
179 if considered_tracks == 0:
181 return '%.2f%%' % (100. * self.french() / considered_tracks)
183 def quota_french(self):
184 # obligation de diffuser annuellement au moins 30% d'œuvres musicales de
186 considered_tracks = self.count() - self.unset_or_na_language()
187 if considered_tracks == 0:
189 return (100. * self.french() / considered_tracks) > 30.
191 def quota_cfwb(self):
192 # obligation de diffuser annuellement au moins 4,5% d'œuvres musicales
193 # émanant de la Communauté française
194 considered_tracks = self.count()
195 if considered_tracks == 0:
197 return (100. * self.cfwb() / considered_tracks) > 4.5
200 return self.count(nonstopfile__creation_timestamp__gte=self.from_date)
202 def percent_new_files(self):
203 return self.percentage(nonstopfile__creation_timestamp__gte=self.from_date)
206 def parse_date(date):
207 if date.endswith('d'):
208 return datetime.datetime.today() + datetime.timedelta(int(date.rstrip('d')))
209 return datetime.datetime.strptime(date, '%Y-%m-%d').date()
212 class StatisticsView(TemplateView):
213 template_name = 'nonstop/statistics.html'
215 def get_context_data(self, **kwargs):
216 context = super(StatisticsView, self).get_context_data(**kwargs)
217 context['zones'] = [x for x in Nonstop.objects.all().order_by('start') if x.start != x.end]
219 if 'from' in self.request.GET:
220 kwargs['from_date'] = parse_date(self.request.GET['from'])
221 context['from_date'] = kwargs['from_date']
222 if 'until' in self.request.GET:
223 kwargs['until_date'] = parse_date(self.request.GET['until'])
224 if 'onair' in self.request.GET:
225 kwargs['nonstopfile__somalogline__on_air'] = True
226 for zone in context['zones']:
227 zone.stats = ZoneStats(zone, **kwargs)
231 class UploadTracksView(FormView):
232 form_class = UploadTracksForm
233 template_name = 'nonstop/upload.html'
236 def post(self, request, *args, **kwargs):
237 assert self.request.user.has_perm('nonstop.add_track')
238 form_class = self.get_form_class()
239 form = self.get_form(form_class)
240 tracks = request.FILES.getlist('tracks')
241 if not form.is_valid():
242 return self.form_invalid(form)
243 missing_metadata = []
246 with tempfile.NamedTemporaryFile(prefix='track-upload') as tmpfile:
247 tmpfile.write(f.read())
249 metadata = mutagen.File(tmpfile.name, easy=True)
250 if not metadata or not metadata.get('artist') or not metadata.get('title'):
251 missing_metadata.append(f.name)
253 metadatas[f.name] = metadata
255 form.add_error('tracks', _('Missing metadata in: ') + ', '.join(missing_metadata))
256 return self.form_invalid(form)
259 metadata = metadatas[f.name]
260 artist_name = metadata.get('artist')[0]
261 track_title = metadata.get('title')[0]
263 monthdir = datetime.datetime.today().strftime('%Y-%m')
264 filepath = '%s/%s - %s - %s%s' % (monthdir,
265 datetime.datetime.today().strftime('%y%m%d'),
266 artist_name[:50].replace('/', ' ').strip(),
267 track_title[:80].replace('/', ' ').strip(),
268 os.path.splitext(f.name)[-1])
270 artist, created = Artist.objects.get_or_create(name=artist_name)
271 track, created = Track.objects.get_or_create(title=track_title, artist=artist,
272 defaults={'uploader': self.request.user})
273 if created or not track.file_exists():
274 default_storage.save(os.path.join('nonstop', 'tracks', filepath), content=f)
275 nonstop_file = NonstopFile()
276 nonstop_file.set_track_filepath(filepath)
277 nonstop_file.track = track
280 # don't keep duplicated file and do not create a duplicated nonstop file object
282 if request.POST.get('nonstop_zone'):
283 track.nonstop_zones.add(
284 Nonstop.objects.get(id=request.POST.get('nonstop_zone')))
285 track.sync_nonstop_zones()
287 messages.info(self.request, '%d new track(s)' % len(tracks))
288 return self.form_valid(form)
291 class TracksMetadataView(ListView):
292 template_name = 'nonstop/tracks_metadata.html'
294 def get_context_data(self, **kwargs):
295 context = super().get_context_data(**kwargs)
296 context['view'] = self
299 def get_queryset(self):
300 return Track.objects.exclude(nonstop_zones__isnull=True)
302 def post(self, request, *args, **kwargs):
303 assert self.request.user.has_perm('nonstop.add_track')
304 for track_id in request.POST.getlist('track'):
305 track = Track.objects.get(id=track_id)
306 track.language = request.POST.get('lang-%s' % track_id, '')
307 track.instru = 'instru-%s' % track_id in request.POST
308 track.sabam = 'sabam-%s' % track_id in request.POST
309 track.cfwb = 'cfwb-%s' % track_id in request.POST
311 return HttpResponseRedirect('.')
314 class RandomTracksMetadataView(TracksMetadataView):
315 page_title = _('Metadata of random tracks')
317 def get_queryset(self):
318 return super().get_queryset().filter(Q(language='') | Q(language__isnull=True)).order_by('?')[:50]
321 class RecentTracksMetadataView(TracksMetadataView):
322 page_title = _('Metadata of recent tracks')
324 def get_queryset(self):
325 return super().get_queryset().exclude(creation_timestamp__isnull=True).order_by('-creation_timestamp')[:50]
328 class ArtistTracksMetadataView(TracksMetadataView):
331 def page_title(self):
332 return _('Metadata of tracks from %s') % Artist.objects.get(id=self.kwargs['artist_pk']).name
334 def get_queryset(self):
335 return Track.objects.filter(artist_id=self.kwargs['artist_pk']).order_by('title')
338 class QuickLinksView(TemplateView):
339 template_name = 'nonstop/quick_links.html'
341 def get_context_data(self, **kwargs):
342 context = super().get_context_data(**kwargs)
343 day = datetime.datetime.today()
344 context['days'] = [day + datetime.timedelta(days=i) for i in range(5)]
348 class SearchView(TemplateView):
349 template_name = 'nonstop/search.html'
351 def get_queryset(self):
352 queryset = Track.objects.all()
354 q = self.request.GET.get('q')
356 queryset = queryset.filter(Q(title__icontains=q.lower()) | Q(artist__name__icontains=q.lower()))
358 zone = self.request.GET.get('zone')
360 from emissions.models import Nonstop
362 queryset = queryset.filter(nonstop_zones=None)
364 queryset = queryset.filter(nonstop_zones__isnull=False).distinct()
366 queryset = queryset.filter(nonstop_zones=zone)
368 order = self.request.GET.get('order_by') or 'title'
370 if 'added_to_nonstop_timestamp' in order:
371 queryset = queryset.filter(added_to_nonstop_timestamp__isnull=False)
372 queryset = queryset.order_by(order)
375 def get_context_data(self, **kwargs):
376 ctx = super(SearchView, self).get_context_data(**kwargs)
377 ctx['form'] = TrackSearchForm(self.request.GET)
378 queryset = self.get_queryset()
379 qs = self.request.GET.copy()
381 ctx['qs'] = qs.urlencode()
383 tracks = Paginator(queryset.select_related(), 20)
385 page = self.request.GET.get('page')
387 ctx['tracks'] = tracks.page(page)
388 except PageNotAnInteger:
389 ctx['tracks'] = tracks.page(1)
391 ctx['tracks'] = tracks.page(tracks.num_pages)
396 class SearchCsvView(SearchView):
397 def get(self, request, *args, **kwargs):
399 writer = csv.writer(out)
400 writer.writerow(['Title', 'Artist', 'Zones', 'Language', 'Instru', 'CFWB', 'Ajout'])
401 for track in self.get_queryset():
403 track.title if track.title else 'Inconnu',
404 track.artist.name if (track.artist and track.artist.name) else 'Inconnu',
405 ' + '.join([x.title for x in track.nonstop_zones.all()]),
406 track.language or '',
407 track.instru and 'instru' or '',
408 track.cfwb and 'cfwb' or '',
409 track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if track.added_to_nonstop_timestamp else '',
411 return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
414 class CleanupView(TemplateView):
415 template_name = 'nonstop/cleanup.html'
417 def get_context_data(self, **kwargs):
418 ctx = super(CleanupView, self).get_context_data(**kwargs)
419 ctx['form'] = CleanupForm()
421 zone = self.request.GET.get('zone')
423 from emissions.models import Nonstop
424 ctx['zone'] = Nonstop.objects.get(id=zone)
425 ctx['count'] = Track.objects.filter(nonstop_zones=zone).count()
426 ctx['tracks'] = Track.objects.filter(nonstop_zones=zone).order_by(
427 'added_to_nonstop_timestamp').select_related()[:30]
430 def post(self, request, *args, **kwargs):
431 assert self.request.user.has_perm('nonstop.add_track')
433 for track_id in request.POST.getlist('track'):
434 if request.POST.get('remove-%s' % track_id):
435 track = Track.objects.get(id=track_id)
436 track.nonstop_zones.clear()
437 track.sync_nonstop_zones()
440 messages.info(self.request, 'Removed %d new track(s)' % count)
441 return HttpResponseRedirect('.')
444 class AddSomaDiffusionView(CreateView):
445 model = ScheduledDiffusion
446 fields = ['jingle', 'stream']
447 template_name = 'nonstop/streamed-diffusion.html'
451 diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
453 if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
454 fields.append('stream')
457 def get_initial(self):
458 initial = super(AddSomaDiffusionView, self).get_initial()
459 initial['jingle'] = None
460 if 'stream' in self.fields:
461 initial['jingle'] = Jingle.objects.filter(default_for_streams=True).first()
462 initial['stream'] = Stream.objects.all().first()
465 def form_valid(self, form):
466 form.instance.diffusion_id = self.kwargs['pk']
467 episode = form.instance.diffusion.episode
468 if 'stream' in self.fields and form.instance.stream_id is None:
469 messages.error(self.request, _('missing stream'))
470 return HttpResponseRedirect(reverse('episode-view', kwargs={
471 'emission_slug': episode.emission.slug,
472 'slug': episode.slug}))
473 response = super(AddSomaDiffusionView, self).form_valid(form)
474 messages.info(self.request, _('%s added to schedule') % episode.emission.title)
477 def get_success_url(self):
478 diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
479 episode = diffusion.episode
480 return reverse('episode-view', kwargs={
481 'emission_slug': episode.emission.slug,
482 'slug': episode.slug})
485 class DelSomaDiffusionView(RedirectView):
486 def get_redirect_url(self, pk):
487 soma_diffusion = ScheduledDiffusion.objects.filter(diffusion_id=pk).first()
488 episode = soma_diffusion.episode
489 ScheduledDiffusion.objects.filter(diffusion_id=pk).update(diffusion_id=None)
490 messages.info(self.request, _('%s removed from schedule') % episode.emission.title)
491 return reverse('episode-view', kwargs={
492 'emission_slug': episode.emission.slug,
493 'slug': episode.slug})
496 class DiffusionPropertiesView(UpdateView):
497 model = ScheduledDiffusion
498 fields = ['jingle', 'stream']
499 template_name = 'nonstop/streamed-diffusion.html'
503 diffusion = self.get_object().diffusion
505 if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
506 fields.append('stream')
509 def form_valid(self, form):
510 episode = self.get_object().diffusion.episode
511 if 'stream' in self.fields and form.instance.stream_id is None:
512 messages.error(self.request, _('missing stream'))
513 return HttpResponseRedirect(reverse('episode-view', kwargs={
514 'emission_slug': episode.emission.slug,
515 'slug': episode.slug}))
516 response = super(DiffusionPropertiesView, self).form_valid(form)
517 messages.info(self.request, _('%s diffusion properties have been updated.') % episode.emission.title)
520 def get_success_url(self):
521 episode = self.get_object().diffusion.episode
522 return reverse('episode-view', kwargs={
523 'emission_slug': episode.emission.slug,
524 'slug': episode.slug})
527 def jingle_audio_view(request, *args, **kwargs):
528 jingle = Jingle.objects.get(id=kwargs['pk'])
529 return FileResponse(open(os.path.join(app_settings.LOCAL_BASE_PATH, app_settings.JINGLES_PREFIX, jingle.filepath), 'rb'))
532 class AjaxProgram(TemplateView):
533 template_name = 'nonstop/program-fragment.html'
535 def get_context_data(self, date, **kwargs):
536 context = super().get_context_data(**kwargs)
537 now = datetime.datetime.now()
539 date_start = datetime.datetime.strptime(date, '%Y-%m-%d')
541 date_start = datetime.datetime.today()
542 date_start = date_start.replace(hour=5, minute=0, second=0, microsecond=0)
543 today = bool(date_start.timetuple()[:3] == now.timetuple()[:3])
544 date_end = date_start + datetime.timedelta(days=1)
545 context['day_program'] = period_program(date_start, date_end, prefetch_categories=False)
546 for x in context['day_program']:
547 x.klass = x.__class__.__name__
549 for i, x in enumerate(context['day_program']):
550 if today and x.datetime > now and previous_prog:
551 previous_prog.now = True
557 class ZoneSettings(FormView):
558 form_class = ZoneSettingsForm
559 template_name = 'nonstop/zone_settings.html'
560 success_url = reverse_lazy('nonstop-zones')
562 def get_context_data(self, **kwargs):
563 context = super().get_context_data(**kwargs)
564 context['zone'] = Nonstop.objects.get(slug=self.kwargs['slug'])
567 def get_initial(self):
569 zone = Nonstop.objects.get(slug=self.kwargs['slug'])
570 except Nonstop.DoesNotExist:
572 zone_settings = zone.nonstopzonesettings_set.first()
573 if zone_settings is None:
574 zone_settings = NonstopZoneSettings(nonstop=zone)
576 initial = super().get_initial()
577 initial['start'] = zone.start.strftime('%H:%M') if zone.start else None
578 initial['end'] = zone.end.strftime('%H:%M') if zone.end else None
579 initial['intro_jingle'] = zone_settings.intro_jingle_id
580 initial['jingles'] = [x.id for x in zone_settings.jingles.all()]
583 def form_valid(self, form):
584 assert self.request.user.has_perm('nonstop.add_track')
585 zone = Nonstop.objects.get(slug=self.kwargs['slug'])
586 zone_settings = zone.nonstopzonesettings_set.first()
587 zone.start = form.cleaned_data['start']
588 zone.end = form.cleaned_data['end']
589 zone_settings.jingles.set(form.cleaned_data['jingles'])
590 zone_settings.intro_jingle_id = form.cleaned_data['intro_jingle']
593 return super().form_valid(form)
596 class MuninTracks(StatisticsView):
597 template_name = 'nonstop/munin_tracks.txt'
598 content_type = 'text/plain; charset=utf-8'
600 def get_context_data(self, **kwargs):
601 context = super().get_context_data(**kwargs)
602 context['nonstop_general_total'] = Track.objects.count()
603 active_tracks_qs = Track.objects.filter(nonstop_zones__isnull=False).distinct()
604 context['nonstop_general_active'] = active_tracks_qs.count()
605 context['nonstop_percentages_instru'] = 100 * (
606 active_tracks_qs.filter(instru=True).count() /
607 context['nonstop_general_active'])
608 context['nonstop_percentages_cfwb'] = 100 * (
609 active_tracks_qs.filter(cfwb=True).count() /
610 context['nonstop_general_active'])
611 context['nonstop_percentages_langset'] = 100 * (
612 active_tracks_qs.exclude(language='').count() /
613 context['nonstop_general_active'])
614 context['nonstop_percentages_french'] = 100 * (
615 active_tracks_qs.filter(language='fr').count() /
616 active_tracks_qs.exclude(language__isnull=True).exclude(language__in=('', 'na')).count())