]> git.0d.be Git - django-panik-nonstop.git/blob - nonstop/views.py
add view to generate munin data
[django-panik-nonstop.git] / nonstop / views.py
1 import copy
2 import csv
3 import datetime
4 import os
5 import tempfile
6
7 import mutagen
8
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
22
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
28
29 from . import utils
30 from .app_settings import app_settings
31
32
33 class SomaDayArchiveView(DayArchiveView):
34     queryset = SomaLogLine.objects.all()
35     date_field = "play_timestamp"
36     make_object_list = True
37     allow_future = False
38     month_format = '%m'
39
40
41 class SomaDayArchiveCsvView(SomaDayArchiveView):
42     def render_to_response(self, context, **response_kwargs):
43         out = StringIO()
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'),
48                     line.filepath.short,
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 '',
55                     ])
56             else:
57                 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
58                                 line.filepath.short])
59         return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
60
61
62 class RedirectTodayView(RedirectView):
63     def get_redirect_url(self, *args, **kwargs):
64         today = datetime.datetime.today()
65         return reverse('archive_day', kwargs={
66                         'year': today.year,
67                         'month': today.month,
68                         'day': today.day})
69
70
71 class TrackDetailView(DetailView):
72     model = Track
73
74     def get_context_data(self, **kwargs):
75         ctx = super(TrackDetailView, self).get_context_data(**kwargs)
76         ctx['metadata_form'] = TrackMetaForm(instance=self.object)
77         return ctx
78
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)
84         form.save()
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('.')
89
90
91 class ArtistDetailView(DetailView):
92     model = Artist
93
94
95 class ArtistListView(ListView):
96     model = Artist
97
98
99 class ZoneStats(object):
100     def __init__(self, zone, from_date=None, until_date=None, **kwargs):
101         self.zone = zone
102         self.qs = Track.objects.filter(nonstop_zones=self.zone, **kwargs)
103         self.from_date = from_date
104         if from_date:
105             self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__gte=from_date)
106         if until_date:
107             self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__lte=until_date)
108         self.qs = self.qs.distinct()
109
110     def total_duration(self, **kwargs):
111         try:
112             total = self.qs.filter(**kwargs).aggregate(Sum('duration'))['duration__sum'].total_seconds()
113         except AttributeError:
114             # 'NoneType' object has no attribute 'total_seconds', if there's no
115             # track in queryset
116             return '-'
117         if total > 3600 * 2:
118             duration = _('%d hours') % (total / 3600)
119         else:
120             duration = _('%d minutes') % (total / 60)
121         start = datetime.datetime(2000, 1, 1, self.zone.start.hour, self.zone.start.minute)
122         end = datetime.datetime(2000, 1, 1, self.zone.end.hour, self.zone.end.minute)
123         if end < start:
124             end = end + datetime.timedelta(days=1)
125         return duration + _(', → %d days') % (total // (end - start).total_seconds())
126
127     def count(self, **kwargs):
128         return self.qs.filter(**kwargs).count()
129
130     def percentage(self, **kwargs):
131         total = self.count()
132         if total == 0:
133             return '-'
134         return '%.2f%%' % (100. * self.count(**kwargs) / total)
135
136     def instru(self):
137         return self.count(instru=True)
138
139     def instru_percentage(self):
140         return self.percentage(instru=True)
141
142     def sabam(self):
143         return self.count(sabam=True)
144
145     def sabam_percentage(self):
146         return self.percentage(sabam=True)
147
148     def cfwb(self):
149         return self.count(cfwb=True)
150
151     def cfwb_percentage(self):
152         return self.percentage(cfwb=True)
153
154     def language_set(self):
155         return self.count() - self.language_unset()
156
157     def language_unset(self):
158         return self.count(language='')
159
160     def unset_language_percentage(self):
161         return self.percentage(language='')
162
163     def french(self):
164         return self.count(language='fr')
165
166     def unset_or_na_language(self):
167         return self.qs.filter(Q(language='') | Q(language='na')).count()
168
169     def french_percentage(self):
170         considered_tracks = self.count() - self.unset_or_na_language()
171         if considered_tracks == 0:
172             return '-'
173         return '%.2f%%' % (100. * self.french() / considered_tracks)
174
175     def quota_french(self):
176         # obligation de diffuser annuellement au moins 30% d'œuvres musicales de
177         # langue française
178         considered_tracks = self.count() - self.unset_or_na_language()
179         if considered_tracks == 0:
180             return True
181         return (100. * self.french() / considered_tracks) > 30.
182
183     def quota_cfwb(self):
184         # obligation de diffuser annuellement au moins 4,5% d'œuvres musicales
185         # émanant de la Communauté française
186         considered_tracks = self.count()
187         if considered_tracks == 0:
188             return True
189         return (100. * self.cfwb() / considered_tracks) > 4.5
190
191     def new_files(self):
192         return self.count(nonstopfile__creation_timestamp__gte=self.from_date)
193
194     def percent_new_files(self):
195         return self.percentage(nonstopfile__creation_timestamp__gte=self.from_date)
196
197
198 def parse_date(date):
199     if date.endswith('d'):
200         return datetime.datetime.today() + datetime.timedelta(int(date.rstrip('d')))
201     return datetime.datetime.strptime(date, '%Y-%m-%d').date()
202
203
204 class StatisticsView(TemplateView):
205     template_name = 'nonstop/statistics.html'
206
207     def get_context_data(self, **kwargs):
208         context = super(StatisticsView, self).get_context_data(**kwargs)
209         context['zones'] = [x for x in Nonstop.objects.all().order_by('start') if x.start != x.end]
210         kwargs = {}
211         if 'from' in self.request.GET:
212             kwargs['from_date'] = parse_date(self.request.GET['from'])
213             context['from_date'] = kwargs['from_date']
214         if 'until' in self.request.GET:
215             kwargs['until_date'] = parse_date(self.request.GET['until'])
216         if 'onair' in self.request.GET:
217             kwargs['nonstopfile__somalogline__on_air'] = True
218         for zone in context['zones']:
219             zone.stats = ZoneStats(zone, **kwargs)
220         return context
221
222
223 class UploadTracksView(FormView):
224     form_class = UploadTracksForm
225     template_name = 'nonstop/upload.html'
226     success_url = '.'
227
228     def post(self, request, *args, **kwargs):
229         assert self.request.user.has_perm('nonstop.add_track')
230         form_class = self.get_form_class()
231         form = self.get_form(form_class)
232         tracks = request.FILES.getlist('tracks')
233         if not form.is_valid():
234             return self.form_invalid(form)
235         missing_metadata = []
236         metadatas = {}
237         for f in tracks:
238             with tempfile.NamedTemporaryFile(prefix='track-upload') as tmpfile:
239                 tmpfile.write(f.read())
240                 f.seek(0)
241                 metadata = mutagen.File(tmpfile.name, easy=True)
242             if not metadata or not metadata.get('artist') or not metadata.get('title'):
243                 missing_metadata.append(f.name)
244             else:
245                 metadatas[f.name] = metadata
246         if missing_metadata:
247             form.add_error('tracks', _('Missing metadata in: ') + ', '.join(missing_metadata))
248             return self.form_invalid(form)
249
250         for f in tracks:
251             metadata = metadatas[f.name]
252             artist_name = metadata.get('artist')[0]
253             track_title = metadata.get('title')[0]
254
255             monthdir = datetime.datetime.today().strftime('%Y-%m')
256             filepath = '%s/%s - %s - %s%s' % (monthdir,
257                 datetime.datetime.today().strftime('%y%m%d'),
258                 artist_name[:50].replace('/', ' ').strip(),
259                 track_title[:80].replace('/', ' ').strip(),
260                 os.path.splitext(f.name)[-1])
261
262             artist, created = Artist.objects.get_or_create(name=artist_name)
263             track, created = Track.objects.get_or_create(title=track_title, artist=artist,
264                     defaults={'uploader': self.request.user})
265             if created or not track.file_exists():
266                 default_storage.save(os.path.join('nonstop', 'tracks', filepath), content=f)
267                 nonstop_file = NonstopFile()
268                 nonstop_file.set_track_filepath(filepath)
269                 nonstop_file.track = track
270                 nonstop_file.save()
271             else:
272                 # don't keep duplicated file and do not create a duplicated nonstop file object
273                 pass
274             if request.POST.get('nonstop_zone'):
275                 track.nonstop_zones.add(
276                         Nonstop.objects.get(id=request.POST.get('nonstop_zone')))
277             track.sync_nonstop_zones()
278
279         messages.info(self.request, '%d new track(s)' % len(tracks))
280         return self.form_valid(form)
281
282
283 class TracksMetadataView(ListView):
284     template_name = 'nonstop/tracks_metadata.html'
285
286     def get_context_data(self, **kwargs):
287         context = super().get_context_data(**kwargs)
288         context['view'] = self
289         return context
290
291     def get_queryset(self):
292         return Track.objects.exclude(nonstop_zones__isnull=True)
293
294     def post(self, request, *args, **kwargs):
295         assert self.request.user.has_perm('nonstop.add_track')
296         for track_id in request.POST.getlist('track'):
297             track = Track.objects.get(id=track_id)
298             track.language = request.POST.get('lang-%s' % track_id, '')
299             track.instru = 'instru-%s' % track_id in request.POST
300             track.sabam = 'sabam-%s' % track_id in request.POST
301             track.cfwb = 'cfwb-%s' % track_id in request.POST
302             track.save()
303         return HttpResponseRedirect('.')
304
305
306 class RandomTracksMetadataView(TracksMetadataView):
307     page_title = _('Metadata of random tracks')
308
309     def get_queryset(self):
310         return super().get_queryset().filter(Q(language='') | Q(language__isnull=True)).order_by('?')[:50]
311
312
313 class RecentTracksMetadataView(TracksMetadataView):
314     page_title = _('Metadata of recent tracks')
315
316     def get_queryset(self):
317         return super().get_queryset().exclude(creation_timestamp__isnull=True).order_by('-creation_timestamp')[:50]
318
319
320 class ArtistTracksMetadataView(TracksMetadataView):
321
322     @property
323     def page_title(self):
324         return _('Metadata of tracks from %s') % Artist.objects.get(id=self.kwargs['artist_pk']).name
325
326     def get_queryset(self):
327         return Track.objects.filter(artist_id=self.kwargs['artist_pk']).order_by('title')
328
329
330 class QuickLinksView(TemplateView):
331     template_name = 'nonstop/quick_links.html'
332
333     def get_context_data(self, **kwargs):
334         context = super().get_context_data(**kwargs)
335         day = datetime.datetime.today()
336         context['days'] = [day + datetime.timedelta(days=i) for i in range(5)]
337         return context
338
339
340 class SearchView(TemplateView):
341     template_name = 'nonstop/search.html'
342
343     def get_queryset(self):
344         queryset = Track.objects.all()
345
346         q = self.request.GET.get('q')
347         if q:
348             queryset = queryset.filter(Q(title__icontains=q.lower()) | Q(artist__name__icontains=q.lower()))
349
350         zone = self.request.GET.get('zone')
351         if zone:
352             from emissions.models import Nonstop
353             if zone == 'none':
354                 queryset = queryset.filter(nonstop_zones=None)
355             elif zone == 'any':
356                 queryset = queryset.filter(nonstop_zones__isnull=False).distinct()
357             else:
358                 queryset = queryset.filter(nonstop_zones=zone)
359
360         order = self.request.GET.get('order_by') or 'title'
361         if order:
362             if 'added_to_nonstop_timestamp' in order:
363                 queryset = queryset.filter(added_to_nonstop_timestamp__isnull=False)
364             queryset = queryset.order_by(order)
365         return queryset
366
367     def get_context_data(self, **kwargs):
368         ctx = super(SearchView, self).get_context_data(**kwargs)
369         ctx['form'] = TrackSearchForm(self.request.GET)
370         queryset = self.get_queryset()
371         qs = self.request.GET.copy()
372         qs.pop('page', None)
373         ctx['qs'] = qs.urlencode()
374
375         tracks = Paginator(queryset.select_related(), 20)
376
377         page = self.request.GET.get('page')
378         try:
379             ctx['tracks'] = tracks.page(page)
380         except PageNotAnInteger:
381             ctx['tracks'] = tracks.page(1)
382         except EmptyPage:
383             ctx['tracks'] = tracks.page(tracks.num_pages)
384
385         return ctx
386
387
388 class SearchCsvView(SearchView):
389     def get(self, request, *args, **kwargs):
390         out = StringIO()
391         writer = csv.writer(out)
392         writer.writerow(['Title', 'Artist', 'Zones', 'Language', 'Instru', 'CFWB', 'Ajout'])
393         for track in self.get_queryset():
394             writer.writerow([
395                 track.title if track.title else 'Inconnu',
396                 track.artist.name if (track.artist and track.artist.name) else 'Inconnu',
397                 ' + '.join([x.title for x in track.nonstop_zones.all()]),
398                 track.language or '',
399                 track.instru and 'instru' or '',
400                 track.cfwb and 'cfwb' or '',
401                 track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if track.added_to_nonstop_timestamp else '',
402                 ])
403         return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
404
405
406 class CleanupView(TemplateView):
407     template_name = 'nonstop/cleanup.html'
408
409     def get_context_data(self, **kwargs):
410         ctx = super(CleanupView, self).get_context_data(**kwargs)
411         ctx['form'] = CleanupForm()
412
413         zone = self.request.GET.get('zone')
414         if zone:
415             from emissions.models import Nonstop
416             ctx['zone'] = Nonstop.objects.get(id=zone)
417             ctx['count'] = Track.objects.filter(nonstop_zones=zone).count()
418             ctx['tracks'] = Track.objects.filter(nonstop_zones=zone).order_by(
419                             'added_to_nonstop_timestamp').select_related()[:30]
420         return ctx
421
422     def post(self, request, *args, **kwargs):
423         assert self.request.user.has_perm('nonstop.add_track')
424         count = 0
425         for track_id in request.POST.getlist('track'):
426             if request.POST.get('remove-%s' % track_id):
427                 track = Track.objects.get(id=track_id)
428                 track.nonstop_zones.clear()
429                 track.sync_nonstop_zones()
430                 count += 1
431         if count:
432             messages.info(self.request, 'Removed %d new track(s)' % count)
433         return HttpResponseRedirect('.')
434
435
436 class AddSomaDiffusionView(CreateView):
437     model = ScheduledDiffusion
438     fields = ['jingle', 'stream']
439     template_name = 'nonstop/streamed-diffusion.html'
440
441     @property
442     def fields(self):
443         diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
444         fields = ['jingle']
445         if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
446             fields.append('stream')
447         return fields
448
449     def get_initial(self):
450         initial = super(AddSomaDiffusionView, self).get_initial()
451         initial['jingle'] = None
452         if 'stream' in self.fields:
453             initial['jingle'] = Jingle.objects.filter(default_for_streams=True).first()
454         initial['stream'] = Stream.objects.all().first()
455         return initial
456
457     def form_valid(self, form):
458         form.instance.diffusion_id = self.kwargs['pk']
459         episode = form.instance.diffusion.episode
460         if 'stream' in self.fields and form.instance.stream_id is None:
461             messages.error(self.request, _('missing stream'))
462             return HttpResponseRedirect(reverse('episode-view', kwargs={
463                 'emission_slug': episode.emission.slug,
464                 'slug': episode.slug}))
465         response = super(AddSomaDiffusionView, self).form_valid(form)
466         messages.info(self.request, _('%s added to schedule') % episode.emission.title)
467         return response
468
469     def get_success_url(self):
470         diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
471         episode = diffusion.episode
472         return reverse('episode-view', kwargs={
473             'emission_slug': episode.emission.slug,
474             'slug': episode.slug})
475
476
477 class DelSomaDiffusionView(RedirectView):
478     def get_redirect_url(self, pk):
479         soma_diffusion = ScheduledDiffusion.objects.filter(diffusion_id=pk).first()
480         episode = soma_diffusion.episode
481         ScheduledDiffusion.objects.filter(diffusion_id=pk).update(diffusion_id=None)
482         messages.info(self.request, _('%s removed from schedule') % episode.emission.title)
483         return reverse('episode-view', kwargs={
484             'emission_slug': episode.emission.slug,
485             'slug': episode.slug})
486
487
488 class DiffusionPropertiesView(UpdateView):
489     model = ScheduledDiffusion
490     fields = ['jingle', 'stream']
491     template_name = 'nonstop/streamed-diffusion.html'
492
493     @property
494     def fields(self):
495         diffusion = self.get_object().diffusion
496         fields = ['jingle']
497         if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
498             fields.append('stream')
499         return fields
500
501     def form_valid(self, form):
502         episode = self.get_object().diffusion.episode
503         if 'stream' in self.fields and form.instance.stream_id is None:
504             messages.error(self.request, _('missing stream'))
505             return HttpResponseRedirect(reverse('episode-view', kwargs={
506                 'emission_slug': episode.emission.slug,
507                 'slug': episode.slug}))
508         response = super(DiffusionPropertiesView, self).form_valid(form)
509         messages.info(self.request, _('%s diffusion properties have been updated.') % episode.emission.title)
510         return response
511
512     def get_success_url(self):
513         episode = self.get_object().diffusion.episode
514         return reverse('episode-view', kwargs={
515             'emission_slug': episode.emission.slug,
516             'slug': episode.slug})
517
518
519 def jingle_audio_view(request, *args, **kwargs):
520     jingle = Jingle.objects.get(id=kwargs['pk'])
521     return FileResponse(open(os.path.join(app_settings.LOCAL_BASE_PATH, app_settings.JINGLES_PREFIX, jingle.filepath), 'rb'))
522
523
524 class AjaxProgram(TemplateView):
525     template_name = 'nonstop/program-fragment.html'
526
527     def get_context_data(self, date, **kwargs):
528         context = super().get_context_data(**kwargs)
529         now = datetime.datetime.now()
530         if date:
531             date_start = datetime.datetime.strptime(date, '%Y-%m-%d')
532         else:
533             date_start = datetime.datetime.today()
534         date_start = date_start.replace(hour=5, minute=0, second=0, microsecond=0)
535         today = bool(date_start.timetuple()[:3] == now.timetuple()[:3])
536         date_end = date_start + datetime.timedelta(days=1)
537         context['day_program'] = period_program(date_start, date_end, prefetch_categories=False)
538         for x in context['day_program']:
539             x.klass = x.__class__.__name__
540         previous_prog = None
541         for i, x in enumerate(context['day_program']):
542             if today and x.datetime > now and previous_prog:
543                 previous_prog.now = True
544                 break
545             previous_prog = x
546         return context
547
548
549 class ZoneSettings(FormView):
550     form_class = ZoneSettingsForm
551     template_name = 'nonstop/zone_settings.html'
552     success_url = reverse_lazy('nonstop-quick-links')
553
554     def get_context_data(self, **kwargs):
555         context = super().get_context_data(**kwargs)
556         context['zone'] = Nonstop.objects.get(slug=self.kwargs['slug'])
557         return context
558
559     def get_initial(self):
560         try:
561             zone = Nonstop.objects.get(slug=self.kwargs['slug'])
562         except Nonstop.DoesNotExist:
563             raise Http404()
564         zone_settings = zone.nonstopzonesettings_set.first()
565         if zone_settings is None:
566             zone_settings = NonstopZoneSettings(nonstop=zone)
567             zone_settings.save()
568         initial = super().get_initial()
569         initial['start'] = zone.start.strftime('%H:%M') if zone.start else None
570         initial['end'] = zone.end.strftime('%H:%M') if zone.end else None
571         initial['intro_jingle'] = zone_settings.intro_jingle_id
572         initial['jingles'] = [x.id for x in zone_settings.jingles.all()]
573         return initial
574
575     def form_valid(self, form):
576         zone = Nonstop.objects.get(slug=self.kwargs['slug'])
577         zone_settings = zone.nonstopzonesettings_set.first()
578         zone.start = form.cleaned_data['start']
579         zone.end = form.cleaned_data['end']
580         zone_settings.jingles.set(form.cleaned_data['jingles'])
581         zone_settings.intro_jingle_id = form.cleaned_data['intro_jingle']
582         zone.save()
583         zone_settings.save()
584         return super().form_valid(form)
585
586
587 class MuninTracks(StatisticsView):
588     template_name = 'nonstop/munin_tracks.txt'
589     content_type = 'text/plain; charset=utf-8'
590
591     def get_context_data(self, **kwargs):
592         context = super().get_context_data(**kwargs)
593         context['nonstop_general_total'] = Track.objects.count()
594         active_tracks_qs = Track.objects.filter(nonstop_zones__isnull=False).distinct()
595         context['nonstop_general_active'] = active_tracks_qs.count()
596         context['nonstop_percentages_instru'] = 100 * (
597                 active_tracks_qs.filter(instru=True).count() /
598                 context['nonstop_general_active'])
599         context['nonstop_percentages_cfwb'] = 100 * (
600                 active_tracks_qs.filter(cfwb=True).count() /
601                 context['nonstop_general_active'])
602         context['nonstop_percentages_langset'] = 100 * (
603                 active_tracks_qs.exclude(language='').count() /
604                 context['nonstop_general_active'])
605         context['nonstop_percentages_french'] = 100 * (
606                 active_tracks_qs.filter(language='fr').count() /
607                 active_tracks_qs.exclude(language__isnull=True).exclude(language__in=('', 'na')).count())
608         return context