Commit d3e33141 authored by Markus Shepherd's avatar Markus Shepherd

Merge branch 'stats' into 'master'

Resolve "More user stats"

Closes #193

See merge request !69
parents 8e84029a 6703ddfa
......@@ -97,6 +97,7 @@
<script src="/js/nav.js" type="text/javascript"></script>
<script src="/js/list.js" type="text/javascript"></script>
<script src="/js/detail.js" type="text/javascript"></script>
<script src="/js/stats.js" type="text/javascript"></script>
<script src="/js/news.js" type="text/javascript"></script>
<script src="/js/about.js" type="text/javascript"></script>
<script src="/js/faq.js" type="text/javascript"></script>
......@@ -176,13 +177,19 @@
<li class="nav-item {{ path === '/news' ? 'active' : '' }} text-nowrap">
<a href="/#/news"
class="nav-link">
News aggregator
News
<span ng-if="newsCount"
class="badge badge-light">
{{ newsCount &gt; 9 ? '9+' : newsCount }}
</span>
</a>
</li>
<li class="nav-item {{ path === '/stats' ? 'active' : '' }} text-nowrap">
<a href="/#/stats"
class="nav-link">
Stats
</a>
</li>
<li class="nav-item {{ path === '/about' ? 'active' : '' }} text-nowrap">
<a href="/#/about"
class="nav-link">
......
......@@ -41,6 +41,9 @@ ludojApp.config(function (
}).when('/news', {
templateUrl: '/partials/news.html',
controller: 'NewsController'
}).when('/stats', {
templateUrl: '/partials/stats.html',
controller: 'StatsController'
}).when('/about', {
templateUrl: '/partials/about.html',
controller: 'AboutController'
......
......@@ -538,8 +538,8 @@ ludojApp.controller('ListController', function ListController(
usersService.getUserStats(params.for, true)
.then(function (stats) {
$scope.userUpdatedAt = stats.updated_at_str;
userStats.rg = stats.rg_top_100;
userStats.bgg = stats.bgg_top_100;
userStats.rg = stats.rg_top;
userStats.bgg = stats.bgg_top;
return updateStats('rg');
})
.catch($log.error);
......
......@@ -377,21 +377,38 @@ ludojApp.factory('gamesService', function gamesService(
});
};
function processDate(data, field) {
field = field || 'updated_at';
if (_.isEmpty(data)) {
data = {};
data[field] = null;
data[field + '_str'] = null;
return data;
}
var date = moment(_.get(data, field));
data[field + '_str'] = date.isValid() ? date.calendar() : null;
return data;
}
service.getModelUpdatedAt = function getModelUpdatedAt(noblock) {
if (!_.isEmpty($sessionStorage.model_updated_at)) {
return $q.resolve($sessionStorage.model_updated_at);
}
if (!_.isEmpty(_.get($sessionStorage, 'games_stats.updated_at_str'))) {
return $q.resolve($sessionStorage.games_stats.updated_at_str);
}
return $http.get(API_URL + 'games/updated_at/', {'noblock': !!noblock})
.then(function (response) {
var updatedAt = moment(_.get(response, 'data.updated_at')),
updatedAtStr;
if (!updatedAt.isValid()) {
var data = processDate(response.data, 'updated_at');
if (!data.updated_at_str) {
return $q.reject('Unable to retrieve last update.');
}
updatedAtStr = updatedAt.calendar();
$sessionStorage.model_updated_at = updatedAtStr;
return updatedAtStr;
$sessionStorage.model_updated_at = data.updated_at_str;
return data.updated_at_str;
})
.catch(function (reason) {
$log.error('There has been an error', reason);
......@@ -401,6 +418,47 @@ ludojApp.factory('gamesService', function gamesService(
});
};
function addRanks(items, field) {
field = field || 'count';
_.forEach(items, function (item, i) {
item.rank = i === 0 || item[field] !== items[i - 1][field] ? i + 1 : items[i - 1].rank;
});
return items;
}
function processStats(stats) {
stats = processDate(stats, 'updated_at');
_.forEach(['rg', 'bgg'], function (site) {
_.forEach(['artist', 'category', 'designer', 'game_type', 'mechanic'], function (field) {
stats[site + '_top'][field] = addRanks(stats[site + '_top'][field]);
});
});
return stats;
}
service.getGamesStats = function getGamesStats(noblock) {
if (!_.isEmpty($sessionStorage.games_stats)) {
return $q.resolve($sessionStorage.games_stats);
}
return $http.get(API_URL + 'games/stats/', {'noblock': !!noblock})
.then(function (response) {
var stats = response.data;
if (_.isEmpty(stats)) {
return $q.reject('Unable to load games stats.');
}
stats = processStats(stats);
$sessionStorage.games_stats = stats;
return stats;
})
.catch(function (reason) {
$log.error('There has been an error', reason);
var response = _.get(reason, 'data.detail') || reason;
response = _.isString(response) ? response : 'Unable to load games stats.';
return $q.reject(response);
});
};
service.jsonLD = function jsonLD(game) {
if (_.isArray(game)) {
return {
......@@ -551,6 +609,13 @@ ludojApp.factory('usersService', function usersService(
stats.updated_at = null;
stats.updated_at_str = null;
}
_.forEach(['rg_top', 'bgg_top'], function (site) {
var total = _.get(stats, site + '.total', 0);
_.forEach(['owned', 'played', 'rated'], function (item) {
var value = _.get(stats, site + '.' + item, 0);
stats[site][item + '_pct'] = total ? 100 * value / total : 0;
});
});
return stats;
}
......
/*jslint browser: true, nomen: true, stupid: true, todo: true */
/*jshint -W097 */
/*global ludojApp, _ */
'use strict';
ludojApp.controller('StatsController', function StatsController(
$location,
$log,
$scope,
gamesService
) {
gamesService.getGamesStats()
.then(function (response) {
$scope.data = response;
})
.catch($log.error);
gamesService.setTitle('Statistics');
gamesService.setDescription('TODO');
gamesService.setCanonicalUrl($location.path());
gamesService.setImage();
});
......@@ -830,6 +830,7 @@
target="_blank">{{ currUser }} <i class="fas fa-external-link-alt"></i></a>
</strong>
</h2>
<p>More <a href="/#/stats">stats</a>...</p>
<div class="card mb-3">
<div class="card-header">
<nav class="nav nav-tabs nav-justified card-header-tabs">
......@@ -853,33 +854,33 @@
</div>
<div class="card-body">
<div class="progress bg-secondary mb-1">
<div id="progress-bar-played"
<div id="progress-bar-played_pct"
class="progress-bar bg-success"
role="progressbar"
aria-valuenow="{{ userStats.played }}"
aria-valuenow="{{ userStats.played_pct }}"
aria-valuemin="0"
aria-valuemax="100">
<strong>&nbsp;Played: {{ userStats.played }}%&nbsp;</strong>
<strong>&nbsp;Played: {{ userStats.played_pct | number:0 }}%&nbsp;</strong>
</div>
</div>
<div class="progress bg-secondary mb-1">
<div id="progress-bar-owned"
<div id="progress-bar-owned_pct"
class="progress-bar bg-success"
role="progressbar"
aria-valuenow="{{ userStats.owned }}"
aria-valuenow="{{ userStats.owned_pct }}"
aria-valuemin="0"
aria-valuemax="100">
<strong>&nbsp;Owned: {{ userStats.owned }}%&nbsp;</strong>
<strong>&nbsp;Owned: {{ userStats.owned_pct | number:0 }}%&nbsp;</strong>
</div>
</div>
<div class="progress bg-secondary">
<div id="progress-bar-rated"
<div id="progress-bar-rated_pct"
class="progress-bar bg-success"
role="progressbar"
aria-valuenow="{{ userStats.rated }}"
aria-valuenow="{{ userStats.rated_pct }}"
aria-valuemin="0"
aria-valuemax="100">
<strong>&nbsp;Rated: {{ userStats.rated }}%&nbsp;</strong>
<strong>&nbsp;Rated: {{ userStats.rated_pct | number:0 }}%&nbsp;</strong>
</div>
</div>
</div>
......
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml"
id="ludoj-stats">
<h1>Statistics</h1>
<p class="lead">
Analyses of the <span class="recommend-games">Recommend.Games</span> and BoardGameGeek top 100 games.
</p>
<div class="card mb-3">
<div class="card-header">
<h4>Top Designers</h4>
<nav class="nav nav-tabs nav-justified card-header-tabs">
<a id="designers-rg-tab"
data-toggle="tab"
data-target="#designers-rg"
href=""
role="tab"
aria-controls="designers-rg"
aria-selected="true"
class="nav-item nav-link active">
<abbr title="Recommend.Games">R.G</abbr> top 100
</a>
<a id="designers-bgg-tab"
data-toggle="tab"
data-target="#designers-bgg"
href=""
role="tab"
aria-controls="designers-bgg"
aria-selected="false"
class="nav-item nav-link">
<abbr title="BoardGameGeek">BGG</abbr> top 100
</a>
</nav>
</div>
<div class="card-body tab-content">
<div id="designers-rg"
class="tab-pane fade show active"
role="tabpanel"
aria-labelledby="designers-rg-tab">
<ol class="mb-0">
<li ng-repeat="designer in data.rg_top.designer"
value="{{ designer.rank }}">
<a ng-href="https://boardgamegeek.com/boardgamedesigner/{{ designer.bgg_id }}"
target="_blank">
<strong>
{{ designer.name }}
<i class="fas fa-external-link-alt"></i>
</strong>
</a>
({{ designer.count }} game{{ designer.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
<div id="designers-bgg"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="designers-bgg-tab">
<ol class="mb-0">
<li ng-repeat="designer in data.bgg_top.designer"
value="{{ designer.rank }}">
<a ng-href="https://boardgamegeek.com/boardgamedesigner/{{ designer.bgg_id }}"
target="_blank">
<strong>
{{ designer.name }}
<i class="fas fa-external-link-alt"></i>
</strong>
</a>
({{ designer.count }} game{{ designer.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h4>Top Artists</h4>
<nav class="nav nav-tabs nav-justified card-header-tabs">
<a id="artists-rg-tab"
data-toggle="tab"
data-target="#artists-rg"
href=""
role="tab"
aria-controls="artists-rg"
aria-selected="true"
class="nav-item nav-link active">
<abbr title="Recommend.Games">R.G</abbr> top 100
</a>
<a id="artists-bgg-tab"
data-toggle="tab"
data-target="#artists-bgg"
href=""
role="tab"
aria-controls="artists-bgg"
aria-selected="false"
class="nav-item nav-link">
<abbr title="BoardGameGeek">BGG</abbr> top 100
</a>
</nav>
</div>
<div class="card-body tab-content">
<div id="artists-rg"
class="tab-pane fade show active"
role="tabpanel"
aria-labelledby="artists-rg-tab">
<ol class="mb-0">
<li ng-repeat="artist in data.rg_top.artist"
value="{{ artist.rank }}">
<a ng-href="https://boardgamegeek.com/boardgameartist/{{ artist.bgg_id }}"
target="_blank">
<strong>
{{ artist.name }}
<i class="fas fa-external-link-alt"></i>
</strong>
</a>
({{ artist.count }} game{{ artist.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
<div id="artists-bgg"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="artists-bgg-tab">
<ol class="mb-0">
<li ng-repeat="artist in data.bgg_top.artist"
value="{{ artist.rank }}">
<a ng-href="https://boardgamegeek.com/boardgameartist/{{ artist.bgg_id }}"
target="_blank">
<strong>
{{ artist.name }}
<i class="fas fa-external-link-alt"></i>
</strong>
</a>
({{ artist.count }} game{{ artist.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h4>Top Game Types</h4>
<nav class="nav nav-tabs nav-justified card-header-tabs">
<a id="types-rg-tab"
data-toggle="tab"
data-target="#types-rg"
href=""
role="tab"
aria-controls="types-rg"
aria-selected="true"
class="nav-item nav-link active">
<abbr title="Recommend.Games">R.G</abbr> top 100
</a>
<a id="types-bgg-tab"
data-toggle="tab"
data-target="#types-bgg"
href=""
role="tab"
aria-controls="types-bgg"
aria-selected="false"
class="nav-item nav-link">
<abbr title="BoardGameGeek">BGG</abbr> top 100
</a>
</nav>
</div>
<div class="card-body tab-content">
<div id="types-rg"
class="tab-pane fade show active"
role="tabpanel"
aria-labelledby="types-rg-tab">
<ol class="mb-0">
<li ng-repeat="type in data.rg_top.game_type"
value="{{ type.rank }}">
<a ng-href="/#/?gameType={{ type.bgg_id }}">
<strong>
{{ type.name }}
</strong>
</a>
({{ type.count }} game{{ type.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
<div id="types-bgg"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="types-bgg-tab">
<ol class="mb-0">
<li ng-repeat="type in data.bgg_top.game_type"
value="{{ type.rank }}">
<a ng-href="/#/?gameType={{ type.bgg_id }}">
<strong>
{{ type.name }}
</strong>
</a>
({{ type.count }} game{{ type.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h4>Top Categories</h4>
<nav class="nav nav-tabs nav-justified card-header-tabs">
<a id="categories-rg-tab"
data-toggle="tab"
data-target="#categories-rg"
href=""
role="tab"
aria-controls="categories-rg"
aria-selected="true"
class="nav-item nav-link active">
<abbr title="Recommend.Games">R.G</abbr> top 100
</a>
<a id="categories-bgg-tab"
data-toggle="tab"
data-target="#categories-bgg"
href=""
role="tab"
aria-controls="categories-bgg"
aria-selected="false"
class="nav-item nav-link">
<abbr title="BoardGameGeek">BGG</abbr> top 100
</a>
</nav>
</div>
<div class="card-body tab-content">
<div id="categories-rg"
class="tab-pane fade show active"
role="tabpanel"
aria-labelledby="categories-rg-tab">
<ol class="mb-0">
<li ng-repeat="category in data.rg_top.category"
value="{{ category.rank }}">
<a ng-href="/#/?category={{ category.bgg_id }}">
<strong>
{{ category.name }}
</strong>
</a>
({{ category.count }} game{{ category.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
<div id="categories-bgg"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="categories-bgg-tab">
<ol class="mb-0">
<li ng-repeat="category in data.bgg_top.category"
value="{{ category.rank }}">
<a ng-href="/#/?category={{ category.bgg_id }}">
<strong>
{{ category.name }}
</strong>
</a>
({{ category.count }} game{{ category.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h4>Top Mechanics</h4>
<nav class="nav nav-tabs nav-justified card-header-tabs">
<a id="mechanics-rg-tab"
data-toggle="tab"
data-target="#mechanics-rg"
href=""
role="tab"
aria-controls="mechanics-rg"
aria-selected="true"
class="nav-item nav-link active">
<abbr title="Recommend.Games">R.G</abbr> top 100
</a>
<a id="mechanics-bgg-tab"
data-toggle="tab"
data-target="#mechanics-bgg"
href=""
role="tab"
aria-controls="mechanics-bgg"
aria-selected="false"
class="nav-item nav-link">
<abbr title="BoardGameGeek">BGG</abbr> top 100
</a>
</nav>
</div>
<div class="card-body tab-content">
<div id="mechanics-rg"
class="tab-pane fade show active"
role="tabpanel"
aria-labelledby="mechanics-rg-tab">
<ol class="mb-0">
<li ng-repeat="mechanic in data.rg_top.mechanic"
value="{{ mechanic.rank }}">
<a ng-href="/#/?mechanic={{ mechanic.bgg_id }}">
<strong>
{{ mechanic.name }}
</strong>
</a>
({{ mechanic.count }} game{{ mechanic.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
<div id="mechanics-bgg"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="mechanics-bgg-tab">
<ol class="mb-0">
<li ng-repeat="mechanic in data.bgg_top.mechanic"
value="{{ mechanic.rank }}">
<a ng-href="/#/?mechanic={{ mechanic.bgg_id }}">
<strong>
{{ mechanic.name }}
</strong>
</a>
({{ mechanic.count }} game{{ mechanic.count === 1 ? '' : 's' }})
</li>
</ol>
</div>
</div>
</div>
</section>
......@@ -193,6 +193,19 @@ class GameViewSet(PermissionsModelViewSet):
'owned',
)
stats_sites = {
'rg_top': 'rec_rank',
'bgg_top': 'bgg_rank',
}
stats_models = {
'designer': (Person.objects.exclude(bgg_id=3), 'designer_of', PersonSerializer),
'artist': (Person.objects.exclude(bgg_id=3), 'artist_of', PersonSerializer),
'game_type': (GameType.objects.all(), 'games', GameTypeSerializer),
'category': (Category.objects.all(), 'games', CategorySerializer),
'mechanic': (Mechanic.objects.all(), 'games', MechanicSerializer),
}
def _excluded_games(self, user, params):
params = params or {}
params.setdefault('exclude_known', True)
......@@ -370,6 +383,41 @@ class GameViewSet(PermissionsModelViewSet):
raise NotFound('unable to retrieve latest update')
return Response({'updated_at': updated_at})
@action(detail=False)
def stats(self, request):
''' get games stats '''
result = {'updated_at': model_updated_at()}
top_games = next(_parse_ints(request.query_params.get('top_games')), 100)
top_items = next(_parse_ints(request.query_params.get('top_items')), 10)
for site_key, site_rank in self.stats_sites.items():
site_filters = {f'{site_rank}__isnull': False}
games = frozenset(
self.filter_queryset(self.get_queryset())
.filter(**site_filters)
.order_by(site_rank)[:top_games]
.values_list('bgg_id', flat=True))
total = len(games)
site_result = {'total': total}
result[site_key] = site_result
for key, (queryset, field, serializer_class) in self.stats_models.items():
filters = {f'{field}__in': games}
objs = (
queryset.annotate(top=Count(field, filter=Q(**filters)))
.filter(top__gt=0)
.order_by('-top')[:top_items])
serializer = serializer_class(
objs, many=True, context=self.get_serializer_context())
for d, obj in zip(serializer.data, objs):
d['count'] = obj.top
d['pct'] = 100 * obj.top / total if total else 0
site_result[key] = serializer.data
return Response(result)
class PersonViewSet(PermissionsModelViewSet):
''' person view set '''
......@@ -438,6 +486,7 @@ class UserViewSet(PermissionsModelViewSet):
serializer_class = UserSerializer
lookup_field = 'name__iexact'
lookup_url_kwarg = 'pk'
stats_sites = GameViewSet.stats_sites
# pylint: disable=unused-argument,invalid-name
@action(detail=True)
......@@ -445,24 +494,29 @@ class UserViewSet(PermissionsModelViewSet):
''' get user stats '''
user = self.get_object()
rg_top_100 = user.collection_set.filter(game__rec_rank__lte=100)
bgg_top_100 = user.collection_set.filter(game__bgg_rank__lte=100)
data = {
'user': user.name,
'updated_at': user.updated_at,
'rg_top_100': {
'owned': rg_top_100.filter(owned=True).count(),
'played': rg_top_100.filter(play_count__gt=0).count(),
'rated': rg_top_100.filter(rating__isnull=False).count(),
},
'bgg_top_100': {
'owned': bgg_top_100.filter(owned=True).count(),
'played': bgg_top_100.filter(play_count__gt=0).count(),
'rated': bgg_top_100.filter(rating__isnull=False).count(),
},
}
top_games = next(_parse_ints(request.query_params.get('top_games')), 100)
for key, rank in self.stats_sites.items():
games = frozenset(
Game.objects
.filter(**{f'{rank}__lte': top_games})
.order_by()
.values_list('bgg_id', flat=True))
filters = {f'game__in': games}
collection = user.collection_set.filter(**filters)
result = {
'total': len(games),
'owned': collection.filter(owned=True).count(),
'played': collection.filter(play_count__gt=0).count(),
'rated': collection.filter(rating__isnull=False).count(),
}
data[key] = result
return Response(data)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment