diff --git a/api/funkwhale_api/instance/stats.py b/api/funkwhale_api/instance/stats.py new file mode 100644 index 0000000000000000000000000000000000000000..167b333d6d2c7f64f33055caa83cbea170569751 --- /dev/null +++ b/api/funkwhale_api/instance/stats.py @@ -0,0 +1,51 @@ +from django.db.models import Sum + +from funkwhale_api.favorites.models import TrackFavorite +from funkwhale_api.history.models import Listening +from funkwhale_api.music import models +from funkwhale_api.users.models import User + + +def get(): + return { + 'users': get_users(), + 'tracks': get_tracks(), + 'albums': get_albums(), + 'artists': get_artists(), + 'track_favorites': get_track_favorites(), + 'listenings': get_listenings(), + 'music_duration': get_music_duration(), + } + + +def get_users(): + return User.objects.count() + + +def get_listenings(): + return Listening.objects.count() + + +def get_track_favorites(): + return TrackFavorite.objects.count() + + +def get_tracks(): + return models.Track.objects.count() + + +def get_albums(): + return models.Album.objects.count() + + +def get_artists(): + return models.Artist.objects.count() + + +def get_music_duration(): + seconds = models.TrackFile.objects.aggregate( + d=Sum('duration'), + )['d'] + if seconds: + return seconds / 3600 + return 0 diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py index 2f2b46b87a4fe301387c5e134cc7a6fcdf6291b2..af23e7e08433b97c6c10f07e1b929892e5dbe32c 100644 --- a/api/funkwhale_api/instance/urls.py +++ b/api/funkwhale_api/instance/urls.py @@ -1,7 +1,11 @@ from django.conf.urls import url +from django.views.decorators.cache import cache_page + from . import views urlpatterns = [ url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'), + url(r'^stats/$', + cache_page(60 * 5)(views.InstanceStats.as_view()), name='stats'), ] diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py index 44ee228735d0ad7297dcf31d513ecc087d8c9d59..7f8f393c964e24bfc7a5ee29bf6be2e30f337188 100644 --- a/api/funkwhale_api/instance/views.py +++ b/api/funkwhale_api/instance/views.py @@ -4,6 +4,8 @@ from rest_framework.response import Response from dynamic_preferences.api import serializers from dynamic_preferences.registries import global_preferences_registry +from . import stats + class InstanceSettings(views.APIView): permission_classes = [] @@ -23,3 +25,12 @@ class InstanceSettings(views.APIView): data = serializers.GlobalPreferenceSerializer( api_preferences, many=True).data return Response(data, status=200) + + +class InstanceStats(views.APIView): + permission_classes = [] + authentication_classes = [] + + def get(self, request, *args, **kwargs): + data = stats.get() + return Response(data, status=200) diff --git a/api/tests/instance/test_stats.py b/api/tests/instance/test_stats.py new file mode 100644 index 0000000000000000000000000000000000000000..6eaad76f7f9d8292211965a462f473c8bb41745a --- /dev/null +++ b/api/tests/instance/test_stats.py @@ -0,0 +1,84 @@ +from django.urls import reverse + +from funkwhale_api.instance import stats + + +def test_can_get_stats_via_api(db, api_client, mocker): + stats = { + 'foo': 'bar' + } + mocker.patch('funkwhale_api.instance.stats.get', return_value=stats) + url = reverse('api:v1:instance:stats') + response = api_client.get(url) + assert response.data == stats + + +def test_get_users(mocker): + mocker.patch( + 'funkwhale_api.users.models.User.objects.count', return_value=42) + + assert stats.get_users() == 42 + + +def test_get_music_duration(factories): + factories['music.TrackFile'].create_batch(size=5, duration=360) + + # duration is in hours + assert stats.get_music_duration() == 0.5 + + +def test_get_listenings(mocker): + mocker.patch( + 'funkwhale_api.history.models.Listening.objects.count', + return_value=42) + assert stats.get_listenings() == 42 + + +def test_get_track_favorites(mocker): + mocker.patch( + 'funkwhale_api.favorites.models.TrackFavorite.objects.count', + return_value=42) + assert stats.get_track_favorites() == 42 + + +def test_get_tracks(mocker): + mocker.patch( + 'funkwhale_api.music.models.Track.objects.count', + return_value=42) + assert stats.get_tracks() == 42 + + +def test_get_albums(mocker): + mocker.patch( + 'funkwhale_api.music.models.Album.objects.count', + return_value=42) + assert stats.get_albums() == 42 + + +def test_get_artists(mocker): + mocker.patch( + 'funkwhale_api.music.models.Artist.objects.count', + return_value=42) + assert stats.get_artists() == 42 + + +def test_get(mocker): + keys = [ + 'users', + 'tracks', + 'albums', + 'artists', + 'track_favorites', + 'listenings', + 'music_duration', + ] + mocks = [ + mocker.patch.object(stats, 'get_{}'.format(k), return_value=i) + for i, k in enumerate(keys) + ] + + expected = { + k: i for i, k in enumerate(keys) + } + + assert stats.get() == expected diff --git a/changes/changelog.d/82.feature b/changes/changelog.d/82.feature new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/front/src/components/About.vue b/front/src/components/About.vue index 01ce6a294fdeb632d03e8eb23a4a4bbf83766223..92bafd7afddde359aa7ffef0b7a2fb1e4b531e29 100644 --- a/front/src/components/About.vue +++ b/front/src/components/About.vue @@ -6,6 +6,7 @@ <template v-if="instance.name.value">About {{ instance.name.value }}</template> <template v-else="instance.name.value">About this instance</template> </h1> + <stats></stats> </div> </div> <div class="ui vertical stripe segment"> @@ -27,8 +28,12 @@ <script> import {mapState} from 'vuex' +import Stats from '@/components/instance/Stats' export default { + components: { + Stats + }, created () { this.$store.dispatch('instance/fetchSettings') }, diff --git a/front/src/components/instance/Stats.vue b/front/src/components/instance/Stats.vue new file mode 100644 index 0000000000000000000000000000000000000000..884809f3a247515d5f2aebcc5d40ab6b14646d15 --- /dev/null +++ b/front/src/components/instance/Stats.vue @@ -0,0 +1,104 @@ +<template> + <div> + <div v-if="stats" class="ui stackable two column grid"> + <div class="column"> + <h3 class="ui left aligned header">User activity</h3> + <div class="ui mini horizontal statistics"> + <div class="statistic"> + <div class="value"> + <i class="green user icon"></i> + {{ stats.users }} + </div> + <div class="label"> + Users + </div> + </div> + <div class="statistic"> + <div class="value"> + <i class="orange sound icon"></i> {{ stats.listenings }} + </div> + <div class="label"> + tracks listened + </div> + </div> + <div class="statistic"> + <div class="value"> + <i class="pink heart icon"></i> {{ stats.track_favorites }} + </div> + <div class="label"> + Tracks favorited + </div> + </div> + </div> + </div> + <div class="column"> + <h3 class="ui left aligned header">Library</h3> + <div class="ui mini horizontal statistics"> + <div class="statistic"> + <div class="value"> + {{ parseInt(stats.music_duration) }} + </div> + <div class="label"> + hours of music + </div> + </div> + <div class="statistic"> + <div class="value"> + {{ stats.artists }} + </div> + <div class="label"> + Artists + </div> + </div> + <div class="statistic"> + <div class="value"> + {{ stats.albums }} + </div> + <div class="label"> + Albums + </div> + </div> + <div class="statistic"> + <div class="value"> + {{ stats.tracks }} + </div> + <div class="label"> + tracks + </div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import logger from '@/logging' + +export default { + data () { + return { + stats: null + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + var self = this + this.isLoading = true + logger.default.debug('Fetching instance stats...') + axios.get('instance/stats/').then((response) => { + self.stats = response.data + self.isLoading = false + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>