]> git.0d.be Git - django-panik-nonstop.git/blob - nonstop/views.py
misc: check nonstop.change_nonstopzonesetting permission for settings changes
[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.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
23
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
29
30 from . import utils
31 from .app_settings import app_settings
32
33
34 class SomaDayArchiveView(DayArchiveView):
35     queryset = SomaLogLine.objects.all()
36     date_field = "play_timestamp"
37     make_object_list = True
38     allow_future = False
39     month_format = '%m'
40
41
42 class SomaDayArchiveCsvView(SomaDayArchiveView):
43     def render_to_response(self, context, **response_kwargs):
44         out = StringIO()
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'),
49                     line.filepath.short,
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 '',
56                     ])
57             else:
58                 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
59                                 line.filepath.short])
60         return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
61
62
63 class RedirectTodayView(RedirectView):
64     def get_redirect_url(self, *args, **kwargs):
65         today = datetime.datetime.today()
66         return reverse('archive_day', kwargs={
67                         'year': today.year,
68                         'month': today.month,
69                         'day': today.day})
70
71
72 class TrackDetailView(DetailView):
73     model = Track
74
75     def get_context_data(self, **kwargs):
76         ctx = super(TrackDetailView, self).get_context_data(**kwargs)
77         ctx['metadata_form'] = TrackMetaForm(instance=self.object)
78         return ctx
79
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)
85         form.save()
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('.')
90
91
92 class ArtistDetailView(DetailView):
93     model = Artist
94
95
96 class ArtistListView(ListView):
97     model = Artist
98
99
100 class ZonesView(ListView):
101     model = Nonstop
102     template_name = 'nonstop/zones.html'
103
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)
106
107
108 class ZoneStats(object):
109     def __init__(self, zone, from_date=None, until_date=None, **kwargs):
110         self.zone = zone
111         self.qs = Track.objects.filter(nonstop_zones=self.zone, **kwargs)
112         self.from_date = from_date
113         if from_date:
114             self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__gte=from_date)
115         if until_date:
116             self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__lte=until_date)
117         self.qs = self.qs.distinct()
118
119     def total_duration(self, **kwargs):
120         try:
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
124             # track in queryset
125             return '-'
126         if total > 3600 * 2:
127             duration = _('%d hours') % (total / 3600)
128         else:
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)
132         if end < start:
133             end = end + datetime.timedelta(days=1)
134         return duration + _(', → %d days') % (total // (end - start).total_seconds())
135
136     def count(self, **kwargs):
137         return self.qs.filter(**kwargs).count()
138
139     def percentage(self, **kwargs):
140         total = self.count()
141         if total == 0:
142             return '-'
143         return '%.2f%%' % (100. * self.count(**kwargs) / total)
144
145     def instru(self):
146         return self.count(instru=True)
147
148     def instru_percentage(self):
149         return self.percentage(instru=True)
150
151     def sabam(self):
152         return self.count(sabam=True)
153
154     def sabam_percentage(self):
155         return self.percentage(sabam=True)
156
157     def cfwb(self):
158         return self.count(cfwb=True)
159
160     def cfwb_percentage(self):
161         return self.percentage(cfwb=True)
162
163     def language_set(self):
164         return self.count() - self.language_unset()
165
166     def language_unset(self):
167         return self.count(language='')
168
169     def unset_language_percentage(self):
170         return self.percentage(language='')
171
172     def french(self):
173         return self.count(language='fr')
174
175     def unset_or_na_language(self):
176         return self.qs.filter(Q(language='') | Q(language='na')).count()
177
178     def french_percentage(self):
179         considered_tracks = self.count() - self.unset_or_na_language()
180         if considered_tracks == 0:
181             return '-'
182         return '%.2f%%' % (100. * self.french() / considered_tracks)
183
184     def quota_french(self):
185         # obligation de diffuser annuellement au moins 30% d'œuvres musicales de
186         # langue française
187         considered_tracks = self.count() - self.unset_or_na_language()
188         if considered_tracks == 0:
189             return True
190         return (100. * self.french() / considered_tracks) > 30.
191
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:
197             return True
198         return (100. * self.cfwb() / considered_tracks) > 4.5
199
200     def new_files(self):
201         return self.count(nonstopfile__creation_timestamp__gte=self.from_date)
202
203     def percent_new_files(self):
204         return self.percentage(nonstopfile__creation_timestamp__gte=self.from_date)
205
206
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()
211
212
213 class StatisticsView(TemplateView):
214     template_name = 'nonstop/statistics.html'
215
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]
219         kwargs = {}
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)
229         return context
230
231
232 class UploadTracksView(FormView):
233     form_class = UploadTracksForm
234     template_name = 'nonstop/upload.html'
235     success_url = '.'
236
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 = []
245         metadatas = {}
246         for f in tracks:
247             with tempfile.NamedTemporaryFile(prefix='track-upload') as tmpfile:
248                 tmpfile.write(f.read())
249                 f.seek(0)
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)
253             else:
254                 metadatas[f.name] = metadata
255         if missing_metadata:
256             form.add_error('tracks', _('Missing metadata in: ') + ', '.join(missing_metadata))
257             return self.form_invalid(form)
258
259         for f in tracks:
260             metadata = metadatas[f.name]
261             artist_name = metadata.get('artist')[0]
262             track_title = metadata.get('title')[0]
263
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])
270
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
279                 nonstop_file.save()
280             else:
281                 # don't keep duplicated file and do not create a duplicated nonstop file object
282                 pass
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()
287
288         messages.info(self.request, '%d new track(s)' % len(tracks))
289         return self.form_valid(form)
290
291
292 class TracksMetadataView(ListView):
293     template_name = 'nonstop/tracks_metadata.html'
294
295     def get_context_data(self, **kwargs):
296         context = super().get_context_data(**kwargs)
297         context['view'] = self
298         return context
299
300     def get_queryset(self):
301         return Track.objects.exclude(nonstop_zones__isnull=True)
302
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
311             track.save()
312         return HttpResponseRedirect('.')
313
314
315 class RandomTracksMetadataView(TracksMetadataView):
316     page_title = _('Metadata of random tracks')
317
318     def get_queryset(self):
319         return super().get_queryset().filter(Q(language='') | Q(language__isnull=True)).order_by('?')[:50]
320
321
322 class RecentTracksMetadataView(TracksMetadataView):
323     page_title = _('Metadata of recent tracks')
324
325     def get_queryset(self):
326         return super().get_queryset().exclude(creation_timestamp__isnull=True).order_by('-creation_timestamp')[:50]
327
328
329 class ArtistTracksMetadataView(TracksMetadataView):
330
331     @property
332     def page_title(self):
333         return _('Metadata of tracks from %s') % Artist.objects.get(id=self.kwargs['artist_pk']).name
334
335     def get_queryset(self):
336         return Track.objects.filter(artist_id=self.kwargs['artist_pk']).order_by('title')
337
338
339 class QuickLinksView(TemplateView):
340     template_name = 'nonstop/quick_links.html'
341
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)]
346         return context
347
348
349 class SearchView(TemplateView):
350     template_name = 'nonstop/search.html'
351
352     def get_queryset(self):
353         queryset = Track.objects.all()
354
355         q = self.request.GET.get('q')
356         if q:
357             queryset = queryset.filter(Q(title__icontains=q.lower()) | Q(artist__name__icontains=q.lower()))
358
359         zone = self.request.GET.get('zone')
360         if zone:
361             from emissions.models import Nonstop
362             if zone == 'none':
363                 queryset = queryset.filter(nonstop_zones=None)
364             elif zone == 'any':
365                 queryset = queryset.filter(nonstop_zones__isnull=False).distinct()
366             else:
367                 queryset = queryset.filter(nonstop_zones=zone)
368
369         order = self.request.GET.get('order_by') or 'title'
370         if order:
371             if 'added_to_nonstop_timestamp' in order:
372                 queryset = queryset.filter(added_to_nonstop_timestamp__isnull=False)
373             queryset = queryset.order_by(order)
374         return queryset
375
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()
381         qs.pop('page', None)
382         ctx['qs'] = qs.urlencode()
383
384         tracks = Paginator(queryset.select_related(), 20)
385
386         page = self.request.GET.get('page')
387         try:
388             ctx['tracks'] = tracks.page(page)
389         except PageNotAnInteger:
390             ctx['tracks'] = tracks.page(1)
391         except EmptyPage:
392             ctx['tracks'] = tracks.page(tracks.num_pages)
393
394         return ctx
395
396
397 class SearchCsvView(SearchView):
398     def get(self, request, *args, **kwargs):
399         out = StringIO()
400         writer = csv.writer(out)
401         writer.writerow(['Title', 'Artist', 'Zones', 'Language', 'Instru', 'CFWB', 'Ajout'])
402         for track in self.get_queryset():
403             writer.writerow([
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 '',
411                 ])
412         return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
413
414
415 class CleanupView(TemplateView):
416     template_name = 'nonstop/cleanup.html'
417
418     def get_context_data(self, **kwargs):
419         ctx = super(CleanupView, self).get_context_data(**kwargs)
420         ctx['form'] = CleanupForm()
421
422         zone = self.request.GET.get('zone')
423         if 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]
429         return ctx
430
431     def post(self, request, *args, **kwargs):
432         assert self.request.user.has_perm('nonstop.add_track')
433         count = 0
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()
439                 count += 1
440         if count:
441             messages.info(self.request, 'Removed %d new track(s)' % count)
442         return HttpResponseRedirect('.')
443
444
445 class AddSomaDiffusionView(CreateView):
446     model = ScheduledDiffusion
447     fields = ['jingle', 'stream']
448     template_name = 'nonstop/streamed-diffusion.html'
449
450     @property
451     def fields(self):
452         diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
453         fields = ['jingle']
454         if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
455             fields.append('stream')
456         return fields
457
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()
464         return initial
465
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)
476         return response
477
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})
484
485
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})
495
496
497 class DiffusionPropertiesView(UpdateView):
498     model = ScheduledDiffusion
499     fields = ['jingle', 'stream']
500     template_name = 'nonstop/streamed-diffusion.html'
501
502     @property
503     def fields(self):
504         diffusion = self.get_object().diffusion
505         fields = ['jingle']
506         if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
507             fields.append('stream')
508         return fields
509
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)
519         return response
520
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})
526
527
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'))
531
532
533 class AjaxProgram(TemplateView):
534     template_name = 'nonstop/program-fragment.html'
535
536     def get_context_data(self, date, **kwargs):
537         context = super().get_context_data(**kwargs)
538         now = datetime.datetime.now()
539         if date:
540             date_start = datetime.datetime.strptime(date, '%Y-%m-%d')
541         else:
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__
549         previous_prog = None
550         for i, x in enumerate(context['day_program']):
551             if today and x.datetime > now and previous_prog:
552                 previous_prog.now = True
553                 break
554             previous_prog = x
555         return context
556
557
558 class ZoneSettings(FormView):
559     form_class = ZoneSettingsForm
560     template_name = 'nonstop/zone_settings.html'
561     success_url = reverse_lazy('nonstop-zones')
562
563     def get_context_data(self, **kwargs):
564         context = super().get_context_data(**kwargs)
565         context['zone'] = Nonstop.objects.get(slug=self.kwargs['slug'])
566         return context
567
568     def get_initial(self):
569         try:
570             zone = Nonstop.objects.get(slug=self.kwargs['slug'])
571         except Nonstop.DoesNotExist:
572             raise Http404()
573         zone_settings = zone.nonstopzonesettings_set.first()
574         if zone_settings is None:
575             zone_settings = NonstopZoneSettings(nonstop=zone)
576             zone_settings.save()
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()]
582         return initial
583
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']
593         zone.save()
594         zone_settings.save()
595         return super().form_valid(form)
596
597
598 class MuninTracks(StatisticsView):
599     template_name = 'nonstop/munin_tracks.txt'
600     content_type = 'text/plain; charset=utf-8'
601
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())
619         return context