]> git.0d.be Git - django-panik-nonstop.git/blob - nonstop/views.py
add page with list of zones, to access settings
[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 ZonesView(ListView):
100     model = Nonstop
101     template_name = 'nonstop/zones.html'
102
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)
105
106
107 class ZoneStats(object):
108     def __init__(self, zone, from_date=None, until_date=None, **kwargs):
109         self.zone = zone
110         self.qs = Track.objects.filter(nonstop_zones=self.zone, **kwargs)
111         self.from_date = from_date
112         if from_date:
113             self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__gte=from_date)
114         if until_date:
115             self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__lte=until_date)
116         self.qs = self.qs.distinct()
117
118     def total_duration(self, **kwargs):
119         try:
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
123             # track in queryset
124             return '-'
125         if total > 3600 * 2:
126             duration = _('%d hours') % (total / 3600)
127         else:
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)
131         if end < start:
132             end = end + datetime.timedelta(days=1)
133         return duration + _(', → %d days') % (total // (end - start).total_seconds())
134
135     def count(self, **kwargs):
136         return self.qs.filter(**kwargs).count()
137
138     def percentage(self, **kwargs):
139         total = self.count()
140         if total == 0:
141             return '-'
142         return '%.2f%%' % (100. * self.count(**kwargs) / total)
143
144     def instru(self):
145         return self.count(instru=True)
146
147     def instru_percentage(self):
148         return self.percentage(instru=True)
149
150     def sabam(self):
151         return self.count(sabam=True)
152
153     def sabam_percentage(self):
154         return self.percentage(sabam=True)
155
156     def cfwb(self):
157         return self.count(cfwb=True)
158
159     def cfwb_percentage(self):
160         return self.percentage(cfwb=True)
161
162     def language_set(self):
163         return self.count() - self.language_unset()
164
165     def language_unset(self):
166         return self.count(language='')
167
168     def unset_language_percentage(self):
169         return self.percentage(language='')
170
171     def french(self):
172         return self.count(language='fr')
173
174     def unset_or_na_language(self):
175         return self.qs.filter(Q(language='') | Q(language='na')).count()
176
177     def french_percentage(self):
178         considered_tracks = self.count() - self.unset_or_na_language()
179         if considered_tracks == 0:
180             return '-'
181         return '%.2f%%' % (100. * self.french() / considered_tracks)
182
183     def quota_french(self):
184         # obligation de diffuser annuellement au moins 30% d'œuvres musicales de
185         # langue française
186         considered_tracks = self.count() - self.unset_or_na_language()
187         if considered_tracks == 0:
188             return True
189         return (100. * self.french() / considered_tracks) > 30.
190
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:
196             return True
197         return (100. * self.cfwb() / considered_tracks) > 4.5
198
199     def new_files(self):
200         return self.count(nonstopfile__creation_timestamp__gte=self.from_date)
201
202     def percent_new_files(self):
203         return self.percentage(nonstopfile__creation_timestamp__gte=self.from_date)
204
205
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()
210
211
212 class StatisticsView(TemplateView):
213     template_name = 'nonstop/statistics.html'
214
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]
218         kwargs = {}
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)
228         return context
229
230
231 class UploadTracksView(FormView):
232     form_class = UploadTracksForm
233     template_name = 'nonstop/upload.html'
234     success_url = '.'
235
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 = []
244         metadatas = {}
245         for f in tracks:
246             with tempfile.NamedTemporaryFile(prefix='track-upload') as tmpfile:
247                 tmpfile.write(f.read())
248                 f.seek(0)
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)
252             else:
253                 metadatas[f.name] = metadata
254         if missing_metadata:
255             form.add_error('tracks', _('Missing metadata in: ') + ', '.join(missing_metadata))
256             return self.form_invalid(form)
257
258         for f in tracks:
259             metadata = metadatas[f.name]
260             artist_name = metadata.get('artist')[0]
261             track_title = metadata.get('title')[0]
262
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])
269
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
278                 nonstop_file.save()
279             else:
280                 # don't keep duplicated file and do not create a duplicated nonstop file object
281                 pass
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()
286
287         messages.info(self.request, '%d new track(s)' % len(tracks))
288         return self.form_valid(form)
289
290
291 class TracksMetadataView(ListView):
292     template_name = 'nonstop/tracks_metadata.html'
293
294     def get_context_data(self, **kwargs):
295         context = super().get_context_data(**kwargs)
296         context['view'] = self
297         return context
298
299     def get_queryset(self):
300         return Track.objects.exclude(nonstop_zones__isnull=True)
301
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
310             track.save()
311         return HttpResponseRedirect('.')
312
313
314 class RandomTracksMetadataView(TracksMetadataView):
315     page_title = _('Metadata of random tracks')
316
317     def get_queryset(self):
318         return super().get_queryset().filter(Q(language='') | Q(language__isnull=True)).order_by('?')[:50]
319
320
321 class RecentTracksMetadataView(TracksMetadataView):
322     page_title = _('Metadata of recent tracks')
323
324     def get_queryset(self):
325         return super().get_queryset().exclude(creation_timestamp__isnull=True).order_by('-creation_timestamp')[:50]
326
327
328 class ArtistTracksMetadataView(TracksMetadataView):
329
330     @property
331     def page_title(self):
332         return _('Metadata of tracks from %s') % Artist.objects.get(id=self.kwargs['artist_pk']).name
333
334     def get_queryset(self):
335         return Track.objects.filter(artist_id=self.kwargs['artist_pk']).order_by('title')
336
337
338 class QuickLinksView(TemplateView):
339     template_name = 'nonstop/quick_links.html'
340
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)]
345         return context
346
347
348 class SearchView(TemplateView):
349     template_name = 'nonstop/search.html'
350
351     def get_queryset(self):
352         queryset = Track.objects.all()
353
354         q = self.request.GET.get('q')
355         if q:
356             queryset = queryset.filter(Q(title__icontains=q.lower()) | Q(artist__name__icontains=q.lower()))
357
358         zone = self.request.GET.get('zone')
359         if zone:
360             from emissions.models import Nonstop
361             if zone == 'none':
362                 queryset = queryset.filter(nonstop_zones=None)
363             elif zone == 'any':
364                 queryset = queryset.filter(nonstop_zones__isnull=False).distinct()
365             else:
366                 queryset = queryset.filter(nonstop_zones=zone)
367
368         order = self.request.GET.get('order_by') or 'title'
369         if order:
370             if 'added_to_nonstop_timestamp' in order:
371                 queryset = queryset.filter(added_to_nonstop_timestamp__isnull=False)
372             queryset = queryset.order_by(order)
373         return queryset
374
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()
380         qs.pop('page', None)
381         ctx['qs'] = qs.urlencode()
382
383         tracks = Paginator(queryset.select_related(), 20)
384
385         page = self.request.GET.get('page')
386         try:
387             ctx['tracks'] = tracks.page(page)
388         except PageNotAnInteger:
389             ctx['tracks'] = tracks.page(1)
390         except EmptyPage:
391             ctx['tracks'] = tracks.page(tracks.num_pages)
392
393         return ctx
394
395
396 class SearchCsvView(SearchView):
397     def get(self, request, *args, **kwargs):
398         out = StringIO()
399         writer = csv.writer(out)
400         writer.writerow(['Title', 'Artist', 'Zones', 'Language', 'Instru', 'CFWB', 'Ajout'])
401         for track in self.get_queryset():
402             writer.writerow([
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 '',
410                 ])
411         return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
412
413
414 class CleanupView(TemplateView):
415     template_name = 'nonstop/cleanup.html'
416
417     def get_context_data(self, **kwargs):
418         ctx = super(CleanupView, self).get_context_data(**kwargs)
419         ctx['form'] = CleanupForm()
420
421         zone = self.request.GET.get('zone')
422         if 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]
428         return ctx
429
430     def post(self, request, *args, **kwargs):
431         assert self.request.user.has_perm('nonstop.add_track')
432         count = 0
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()
438                 count += 1
439         if count:
440             messages.info(self.request, 'Removed %d new track(s)' % count)
441         return HttpResponseRedirect('.')
442
443
444 class AddSomaDiffusionView(CreateView):
445     model = ScheduledDiffusion
446     fields = ['jingle', 'stream']
447     template_name = 'nonstop/streamed-diffusion.html'
448
449     @property
450     def fields(self):
451         diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
452         fields = ['jingle']
453         if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
454             fields.append('stream')
455         return fields
456
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()
463         return initial
464
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)
475         return response
476
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})
483
484
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})
494
495
496 class DiffusionPropertiesView(UpdateView):
497     model = ScheduledDiffusion
498     fields = ['jingle', 'stream']
499     template_name = 'nonstop/streamed-diffusion.html'
500
501     @property
502     def fields(self):
503         diffusion = self.get_object().diffusion
504         fields = ['jingle']
505         if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
506             fields.append('stream')
507         return fields
508
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)
518         return response
519
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})
525
526
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'))
530
531
532 class AjaxProgram(TemplateView):
533     template_name = 'nonstop/program-fragment.html'
534
535     def get_context_data(self, date, **kwargs):
536         context = super().get_context_data(**kwargs)
537         now = datetime.datetime.now()
538         if date:
539             date_start = datetime.datetime.strptime(date, '%Y-%m-%d')
540         else:
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__
548         previous_prog = None
549         for i, x in enumerate(context['day_program']):
550             if today and x.datetime > now and previous_prog:
551                 previous_prog.now = True
552                 break
553             previous_prog = x
554         return context
555
556
557 class ZoneSettings(FormView):
558     form_class = ZoneSettingsForm
559     template_name = 'nonstop/zone_settings.html'
560     success_url = reverse_lazy('nonstop-zones')
561
562     def get_context_data(self, **kwargs):
563         context = super().get_context_data(**kwargs)
564         context['zone'] = Nonstop.objects.get(slug=self.kwargs['slug'])
565         return context
566
567     def get_initial(self):
568         try:
569             zone = Nonstop.objects.get(slug=self.kwargs['slug'])
570         except Nonstop.DoesNotExist:
571             raise Http404()
572         zone_settings = zone.nonstopzonesettings_set.first()
573         if zone_settings is None:
574             zone_settings = NonstopZoneSettings(nonstop=zone)
575             zone_settings.save()
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()]
581         return initial
582
583     def form_valid(self, form):
584         zone = Nonstop.objects.get(slug=self.kwargs['slug'])
585         zone_settings = zone.nonstopzonesettings_set.first()
586         zone.start = form.cleaned_data['start']
587         zone.end = form.cleaned_data['end']
588         zone_settings.jingles.set(form.cleaned_data['jingles'])
589         zone_settings.intro_jingle_id = form.cleaned_data['intro_jingle']
590         zone.save()
591         zone_settings.save()
592         return super().form_valid(form)
593
594
595 class MuninTracks(StatisticsView):
596     template_name = 'nonstop/munin_tracks.txt'
597     content_type = 'text/plain; charset=utf-8'
598
599     def get_context_data(self, **kwargs):
600         context = super().get_context_data(**kwargs)
601         context['nonstop_general_total'] = Track.objects.count()
602         active_tracks_qs = Track.objects.filter(nonstop_zones__isnull=False).distinct()
603         context['nonstop_general_active'] = active_tracks_qs.count()
604         context['nonstop_percentages_instru'] = 100 * (
605                 active_tracks_qs.filter(instru=True).count() /
606                 context['nonstop_general_active'])
607         context['nonstop_percentages_cfwb'] = 100 * (
608                 active_tracks_qs.filter(cfwb=True).count() /
609                 context['nonstop_general_active'])
610         context['nonstop_percentages_langset'] = 100 * (
611                 active_tracks_qs.exclude(language='').count() /
612                 context['nonstop_general_active'])
613         context['nonstop_percentages_french'] = 100 * (
614                 active_tracks_qs.filter(language='fr').count() /
615                 active_tracks_qs.exclude(language__isnull=True).exclude(language__in=('', 'na')).count())
616         return context