From f6fcc634507968d64c4c3921f75cd7c41c675b3a Mon Sep 17 00:00:00 2001 From: Olaf Slomp Date: Fri, 14 Dec 2018 16:59:44 +0100 Subject: [PATCH] Song reporting and user stats --- marietje/api/urls.py | 1 + marietje/api/views.py | 11 ++ marietje/marietje/settings.py | 2 + marietje/marietje/static/js/queue.js | 23 +++- marietje/marietje/templates/base.html | 2 + marietje/queues/models.py | 5 +- marietje/queues/templates/queues/queue.html | 1 + marietje/songs/admin.py | 23 +++- .../commands/gather_reported_songs.py | 14 +++ marietje/songs/migrations/0004_reportnote.py | 25 ++++ marietje/songs/models.py | 22 ++++ .../management/commands/recache_user_stats.py | 9 ++ marietje/stats/templates/stats/stats.html | 40 +++++-- marietje/stats/templates/stats/user.html | 99 ++++++++++++++++ marietje/stats/urls.py | 1 + marietje/stats/utils.py | 110 ++++++++++++++---- marietje/stats/views.py | 40 +++++-- 17 files changed, 381 insertions(+), 47 deletions(-) create mode 100644 marietje/songs/management/commands/gather_reported_songs.py create mode 100644 marietje/songs/migrations/0004_reportnote.py create mode 100644 marietje/stats/management/commands/recache_user_stats.py create mode 100644 marietje/stats/templates/stats/user.html diff --git a/marietje/api/urls.py b/marietje/api/urls.py index 294de81..ac0ad39 100644 --- a/marietje/api/urls.py +++ b/marietje/api/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ url(r'^managesongs', views.managesongs), url(r'^queue', views.queue), url(r'^request', views.request), + url(r'^report', views.report), url(r'^skip', views.skip), url(r'^moveup', views.move_up), url(r'^movedown', views.move_down), diff --git a/marietje/api/views.py b/marietje/api/views.py index 4daa92c..81bd33b 100644 --- a/marietje/api/views.py +++ b/marietje/api/views.py @@ -232,6 +232,17 @@ def request(request): return JsonResponse({ 'success': True }) +@require_http_methods(["POST"]) +@api_auth_required +def report(request): + queue = request.user.queue + song = get_object_or_404(Song, id=request.POST.get('id'), deleted=False) + msg = request.POST.get('msg') + + err = song.report(request.user, msg) + return JsonResponse({ 'success': True }) + + @require_http_methods(["POST"]) @api_auth_required def upload(request): diff --git a/marietje/marietje/settings.py b/marietje/marietje/settings.py index a347782..1407761 100644 --- a/marietje/marietje/settings.py +++ b/marietje/marietje/settings.py @@ -114,6 +114,8 @@ USE_TZ = True # zc files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ +BASE_URL = 'https://marietje-zuid.science.ru.nl' + STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATIC_URL = '/static/' LOGIN_URL = '/login/' diff --git a/marietje/marietje/static/js/queue.js b/marietje/marietje/static/js/queue.js index a0f845e..1d6e209 100644 --- a/marietje/marietje/static/js/queue.js +++ b/marietje/marietje/static/js/queue.js @@ -45,6 +45,22 @@ $(function () { return false; }); + $(document).on('click', '[data-report-song-id]', function () { + var songId = $(this).data('report-song-id'); + var message = prompt("What is wrong with the song?"); + if (message == "") { + alert("Please enter a message."); + return false + } + $.post('/api/report', {id: songId, msg: message, csrfmiddlewaretoken: csrf_token}, function (result) { + console.log(result); + if (result.success) { + alert("Thanks for the report!"); + } + }); + return false; + }); + $('#cancel-request').click(function () { hideRequestTable(); }); @@ -267,7 +283,11 @@ function getSongs() $.each(songs, function (id, song) { var artist = song.artist.trim() === '' ? '?' : song.artist; var title = song.title.trim() === '' ? '?' : song.title; - $('#request-table tbody:last-child').append('' + artist + '' + title + '' + (song.uploader_name ? song.uploader_name : 'Marietje') + '' + song.duration.secondsToMMSS() + ''); + $('#request-table tbody:last-child').append('' + artist + + '' + title + + '' + (song.uploader_name ? song.uploader_name : 'Marietje') + + '' + song.duration.secondsToMMSS() + + ''); }); var pageNumSelect = $('.pagenum'); pageNumSelect.empty(); @@ -290,6 +310,7 @@ function getSongs() }); } + function showRequestTable() { $('#request-button').text('Close'); diff --git a/marietje/marietje/templates/base.html b/marietje/marietje/templates/base.html index 9a4d30e..d6a82b7 100644 --- a/marietje/marietje/templates/base.html +++ b/marietje/marietje/templates/base.html @@ -35,6 +35,8 @@ Manage {% url 'stats:stats' as url %} Stats + {% url 'stats:user_stats' as url %} + User Stats {% if user.is_staff %} {% url 'admin:index' as url %}
  • Admin
  • diff --git a/marietje/queues/models.py b/marietje/queues/models.py index 6ed5ab2..7f765eb 100644 --- a/marietje/queues/models.py +++ b/marietje/queues/models.py @@ -1,9 +1,12 @@ +import time + from django.db import models from django.db.models import Q, Max from django.conf import settings -from songs.models import Song from django.utils import timezone +from songs.models import Song + class Playlist(models.Model): def __str__(self): diff --git a/marietje/queues/templates/queues/queue.html b/marietje/queues/templates/queues/queue.html index 1e65aee..a9475c5 100644 --- a/marietje/queues/templates/queues/queue.html +++ b/marietje/queues/templates/queues/queue.html @@ -15,6 +15,7 @@ Title Uploader Length + Report diff --git a/marietje/songs/admin.py b/marietje/songs/admin.py index fa2e684..dbdab39 100644 --- a/marietje/songs/admin.py +++ b/marietje/songs/admin.py @@ -1,20 +1,33 @@ from django.contrib import admin -from .models import Song +from .models import ReportNote, Song +class ReportNoteInline(admin.StackedInline): + model = ReportNote + extra = 0 + @admin.register(Song) class SongAdmin(admin.ModelAdmin): - list_display = ('artist', 'title', 'user_name') + list_display = ('artist', 'title', 'user_name', 'reports') search_fields = ('artist', 'title', 'user__name') + inlines = [ReportNoteInline] + + def reports(self, song): + return ReportNote.objects.filter(song=song).count() @staticmethod - def user_name(obj): + def user_name(song): try: - return obj.user.name + return song.user.name except AttributeError: return '' @staticmethod def get_readonly_fields(request, obj=None): - return [] if request.user.is_superuser else ['hash'] \ No newline at end of file + return [] if request.user.is_superuser else ['hash'] + +@admin.register(ReportNote) +class ReportNoteAdmin(admin.ModelAdmin): + list_display = ('song', 'note', 'user') + search_fields = ('song__artist', 'song__title', 'user__name') diff --git a/marietje/songs/management/commands/gather_reported_songs.py b/marietje/songs/management/commands/gather_reported_songs.py new file mode 100644 index 0000000..c8d125b --- /dev/null +++ b/marietje/songs/management/commands/gather_reported_songs.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.core.management.base import BaseCommand + +from songs.models import ReportNote + +class Command(BaseCommand): + help = 'Gather all song reports' + def handle(self, *args, **options): + reports = ReportNote.objects.all() + for report in reports: + song = report.song + url = '<{base_url}/admin/songs/song/{r.song.id}/change/>'.format(base_url=settings.BASE_URL, r=report) + print('Song: {r.song.artist} - {r.song.title}\nMessage: {r.note}\nLink: {url}'.format(url=url, r=report)) + print('-' * 72) \ No newline at end of file diff --git a/marietje/songs/migrations/0004_reportnote.py b/marietje/songs/migrations/0004_reportnote.py new file mode 100644 index 0000000..4f085e0 --- /dev/null +++ b/marietje/songs/migrations/0004_reportnote.py @@ -0,0 +1,25 @@ +# Generated by Django 2.1.2 on 2018-12-10 14:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('songs', '0003_search_index'), + ] + + operations = [ + migrations.CreateModel( + name='ReportNote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('note', models.TextField(blank=True, help_text='reason for edit request', verbose_name='reason')), + ('song', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='songs.Song')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/marietje/songs/models.py b/marietje/songs/models.py index caa9601..7a21852 100644 --- a/marietje/songs/models.py +++ b/marietje/songs/models.py @@ -35,5 +35,27 @@ class Song(models.Model): db_index=True, help_text='hide this song from the search listings') + def report(self, user, note): + report_note = ReportNote(song=self, user=user, note=note) + report_note.save() + def __str__(self): return self.artist + ' - ' + self.title + +class ReportNote(models.Model): + song = models.ForeignKey(Song, + on_delete=models.CASCADE, + blank=False, + null=False, + db_index=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + db_index=True) + note = models.TextField(verbose_name='reason', blank=True, + help_text='reason for edit request') + + def __str__(self): + return "{song.artist} - {song.title}: '{note}'".format(song=self.song, note=self.note) diff --git a/marietje/stats/management/commands/recache_user_stats.py b/marietje/stats/management/commands/recache_user_stats.py new file mode 100644 index 0000000..5f3a9f2 --- /dev/null +++ b/marietje/stats/management/commands/recache_user_stats.py @@ -0,0 +1,9 @@ +from django.core.management.base import BaseCommand + +from stats.utils import recache_user_stats + +class Command(BaseCommand): + help = 'Update the statistics cache' + + def handle(self, *args, **options): + recache_user_stats() diff --git a/marietje/stats/templates/stats/stats.html b/marietje/stats/templates/stats/stats.html index 90042d5..4262d7e 100644 --- a/marietje/stats/templates/stats/stats.html +++ b/marietje/stats/templates/stats/stats.html @@ -26,7 +26,7 @@ # User - # Songs + # Songs @@ -51,7 +51,7 @@ # User - # Requests + # Requests @@ -67,7 +67,7 @@
    -

    Time Requested

    +

    Time requested

    Total: {{stats.total_time_requested}}

    Top {{ stats.stats_top_count }}:

    @@ -93,7 +93,7 @@

    Unique requests

    -

    Total: {{stats.total_unique_requests}}

    +

    Total: {{stats.total_unique_requests.total}}

    Top {{ stats.stats_top_count }}:

    @@ -101,7 +101,7 @@ - + @@ -109,7 +109,7 @@ - + {% endfor %} @@ -126,7 +126,7 @@ - + @@ -141,6 +141,30 @@
    # User# Requests# Unique
    {{ forloop.counter }} {{ stat.user__name }}{{ stat.total }} ({{ stat.ratio }}%){{ stat.unique_requests }} ({% widthratio stat.unique_requests stat.total_requests 100 %}%)
    # Artist Title# Requests# Requests
    +
    +
    +

    Most played uploaders

    +

    Top {{ stats.stats_top_count }}:

    +
    + + + + + + + + + + {% for stat in stats.most_requested_uploaders %} + + + + + + {% endfor %} + +
    #User# Songs
    {{ forloop.counter }}{{ stat.song__user__name }}{{ stat.total }} ({% widthratio stat.total stats.total_requests 100 %}%)
    +

    Most played songs last 14 days

    @@ -152,7 +176,7 @@ # Artist Title - # Requests + # Requests diff --git a/marietje/stats/templates/stats/user.html b/marietje/stats/templates/stats/user.html new file mode 100644 index 0000000..9f23409 --- /dev/null +++ b/marietje/stats/templates/stats/user.html @@ -0,0 +1,99 @@ +{% extends 'base.html' %} +{% load static %} +{% load tz %} + +{% block title %}User Stats{% endblock %} + +{% block content %} +

    User Statistics

    + +
    + {% if current_age_text %} +
    + {{ current_age_text }} + {% endif %} +
    +

    Total uploads: {{ stats.total_uploads }}

    +

    Total requests: {{ stats.total_requests }}

    +

    Unique requests: {{ stats.unique_requests }} ({% widthratio stats.unique_requests stats.total_requests 100 %}%)

    +

    Total requested uploads: {{stats.total_played_uploads}}

    +
    +

    Most played songs

    +

    Top {{ stats.stats_top_count }}:

    +
    + + + + + + + + + + + {% for stat in stats.most_played_songs %} + + + + + + + {% endfor %} + +
    #ArtistTitle# Requests
    {{ forloop.counter }}{{ stat.song__artist }}{{ stat.song__title }}{{ stat.total }}
    +
    +
    +
    +
    +

    Most played uploads

    +

    Top {{ stats.stats_top_count }}:

    +
    + + + + + + + + + + + {% for stat in stats.most_played_uploads %} + + + + + + + {% endfor %} + +
    #ArtistTitle# Requests
    {{ forloop.counter }}{{ stat.song__artist }}{{ stat.song__title }}{{ stat.total }}
    +
    +
    +
    +

    Most played uploaders

    +

    Top {{ stats.stats_top_count }}:

    +
    + + + + + + + + + + {% for stat in stats.most_played_uploaders %} + + + + + + {% endfor %} + +
    #Uploader# Requests
    {{ forloop.counter }}{{ stat.song__user__name }}{{ stat.total }} ({% widthratio stat.total total_requests 100 %}%)
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/marietje/stats/urls.py b/marietje/stats/urls.py index d4a86b9..2b09bbc 100644 --- a/marietje/stats/urls.py +++ b/marietje/stats/urls.py @@ -6,4 +6,5 @@ app_name = 'stats' urlpatterns = [ url(r'^$', views.stats, name='stats'), + url(r'^user$', views.user_stats, name='user_stats'), ] diff --git a/marietje/stats/utils.py b/marietje/stats/utils.py index 9c29133..3f5d542 100644 --- a/marietje/stats/utils.py +++ b/marietje/stats/utils.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, time from django.core.cache import caches from django.conf import settings @@ -8,19 +8,29 @@ from django.utils import timezone from queues.models import PlaylistSong from songs.models import Song +from marietje.models import User def recache_stats(): new_stats = compute_stats() caches['default'].delete('stats') - caches['default'].set('stats', new_stats, 7200) + caches['default'].set('stats', new_stats, 2 * 3600) + return new_stats + +def recache_user_stats(): + users = User.objects.exclude(Q(id=None) + | Q(id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).values('id') + for user in users: + new_stats = user_stats(user['id']) + cacheloc = 'userstats_{}'.format(user['id']) + caches['default'].delete(cacheloc) + caches['default'].set(cacheloc, new_stats, 48 * 3600) return new_stats def to_days(time): - for tr in time: - tr['duration'] = str(round(tr['total']/86400, 2)) + ' days' - return time - + for tr in time: + tr['duration'] = str(round(tr['total'] / 86400, 2)) + ' days' + return time def compute_stats(): # We want to grab the time now, because otherwise we would be reporting a minute too late @@ -48,13 +58,14 @@ def compute_stats(): Q(user_id=None) | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).values( 'user__id', 'user__name').annotate( - total=Count('song_id', distinct=True), - ratio=Count('song_id', distinct=True) / Count('id') * - 100).order_by('-total')[:settings.STATS_TOP_COUNT] - + total_requests=Count('id', distinct=True), + unique_requests=Count('song__id', distinct=True)).order_by( + '-total_requests')[:settings.STATS_TOP_COUNT] + total_unique_requests = PlaylistSong.objects.filter(state=2).exclude( Q(user_id=None) - | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).distinct().count() + | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).aggregate( + total=Count('song__id', distinct=True)) most_played_songs = PlaylistSong.objects.filter(state=2).exclude( Q(user_id=None) @@ -69,18 +80,23 @@ def compute_stats(): 'song__artist', 'song__title').annotate(total=Count('id')).order_by( '-total', 'song__artist')[:settings.STATS_TOP_COUNT] - + time_requested = PlaylistSong.objects.filter(state=2).exclude( - Q(user_id=None) + Q(user_id=None) | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).values( - 'user__id', 'user__name').annotate(total=Sum('song__duration')).order_by( - '-total')[:settings.STATS_TOP_COUNT] - + 'user__id', 'user__name').annotate(total=Sum('song__duration')).order_by( + '-total')[:settings.STATS_TOP_COUNT] + total_time_requested = PlaylistSong.objects.all().filter(state=2).exclude( - Q(user_id=None) + Q(user_id=None) | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).aggregate( - total=Sum('song__duration')) - + total=Sum('song__duration')) + + most_requested_uploaders = PlaylistSong.objects.filter(state=2).exclude( + Q(user_id=None) + | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).values( + 'song__user__id', 'song__user__name').annotate(total=Count( + 'song__user__id')).order_by('-total')[:settings.STATS_TOP_COUNT] return { 'last_updated': last_updated, @@ -93,7 +109,59 @@ def compute_stats(): 'most_played_songs': list(most_played_songs), 'most_played_songs_14_days': list(most_played_songs_14_days), 'time_requested': to_days(list(time_requested)), - 'total_time_requested': str(round(float(total_time_requested['total'])/86400, 2)) + ' days', + 'total_time_requested': str(round(float(total_time_requested['total']) / 86400, 2)) + ' days', 'stats_top_count': settings.STATS_TOP_COUNT, - + 'most_requested_uploaders': list(most_requested_uploaders), } + +def user_stats(request): + last_updated = datetime.now() + + total_uploads = Song.objects.filter( + user__id=request, deleted=False).count() + + total_requests = PlaylistSong.objects.filter( + user__id=request, state=2).count() + + unique_requests = PlaylistSong.objects.filter( + user__id=request, state=2, + song_id__isnull=False).values('song_id').distinct().count() + + most_played_songs = PlaylistSong.objects.filter( + user__id=request, state=2).exclude( + Q(user_id=None) + | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).values( + 'song__artist', + 'song__title').annotate(total=Count('id')).order_by( + '-total', 'song__artist', 'song__title') + + most_played_uploaders = PlaylistSong.objects.filter( + user__id=request, state=2).exclude( + Q(user_id=None) + | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).values( + 'song__user__id', 'song__user__name').annotate( + total=Count('song__user__id')).order_by('-total') + + most_played_uploads = PlaylistSong.objects.filter( + state=2, song_id__in=Song.objects.filter(user__id=request)).exclude( + Q(user_id=None) + | Q(user_id__in=settings.STATS_REQUEST_IGNORE_USER_IDS)).values( + 'pk').values( + 'song__artist', + 'song__title').annotate(total=Count('id')).order_by( + '-total', 'song__artist', 'song__title') + + total_played_uploads = most_played_uploads.aggregate(newtotal=Sum('total')) + + return { + 'last_updated': last_updated, + 'total_uploads': total_uploads, + 'total_requests': total_requests, + 'unique_requests': unique_requests, + 'most_played_songs': list(most_played_songs), + 'most_played_uploaders': list(most_played_uploaders), + 'most_played_uploads': list(most_played_uploads), + 'stats_top_count': settings.STATS_TOP_COUNT, + 'total_played_uploads': total_played_uploads['newtotal'], + } + diff --git a/marietje/stats/views.py b/marietje/stats/views.py index 7937ecc..232de2a 100644 --- a/marietje/stats/views.py +++ b/marietje/stats/views.py @@ -2,6 +2,8 @@ from datetime import datetime, timedelta from django.core.cache import caches from django.shortcuts import render +from stats.utils import user_stats +from django.http import JsonResponse, HttpResponseForbidden def stats(request): @@ -12,17 +14,33 @@ def stats(request): if stats: status = 200 - if 'last_updated' in stats: - current_age = datetime.now() - stats['last_updated'] - if current_age < timedelta(minutes=1): - current_age_text = 'Stats were updated less than a minute ago.' - elif current_age < timedelta(minutes=2): - current_age_text = 'Stats were updated one minute ago.' - elif current_age < timedelta(minutes=60): - minutes = current_age.seconds / 60 - current_age_text = 'Stats were updated {:.0f} minutes ago.'.format(minutes) - else: - current_age_text = 'Stats were updated more than an hour ago' + current_age_text = age_text(stats['last_updated']) data = {'stats': stats, 'current_age': current_age, 'current_age_text': current_age_text} return render(request, 'stats/stats.html', data, status=status) + +def user_stats(request): + stats = caches['default'].get('userstats_{}'.format(request.user.id)) + status = 503 + current_age = None + current_age_text = None + + if stats: + status = 200 + current_age_text = age_text(stats['last_updated']) + + data = {'stats': stats, 'current_age': current_age, 'current_age_text': current_age_text} + return render(request, 'stats/user.html', data, status=status) + +def age_text(last_updated): + current_age = datetime.now() - last_updated + minutes = (current_age.seconds % 3600) / 60 + hours = current_age.seconds / 3600 + minutestr = "minute" if minutes == 1 else "minutes" + hourstr = "hour" if hours == 1 else "hours" + if current_age < timedelta(hours=1): + return 'Stats were updated {:.0f} {} ago.'.format(minutes, minutestr) + else: + return 'Stats were updated {:.0f} {} and {:.0f} {} ago.'.format( + hours, hourstr, minutes, minutestr) +