added new fields to KyuDanRanking that allow to pick up the calculation from the last state of the KyuDanRanking.

last_hanchan_date: it contains the start of the latest hanchan content for this players ranking.
wins_in_row: to save the currents wins in a row

Added option to calcuclate rankings until a given datetime.
This commit is contained in:
2017-11-19 16:14:59 +01:00
parent 53ff0f1adb
commit 0a45cf1fd8
7 changed files with 189 additions and 85 deletions

View File

@@ -1,12 +1,11 @@
"""Export Mahjong Rankings as excel files.""" """Export Mahjong Rankings as excel files."""
from datetime import date
from operator import itemgetter
import openpyxl import openpyxl
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.dateparse import parse_date
from openpyxl.styles import Border from openpyxl.styles import Border
from datetime import date, time, datetime
from django.utils import timezone
from mahjong_ranking.models import SeasonRanking, KyuDanRanking from mahjong_ranking.models import SeasonRanking, KyuDanRanking
THIN_BORDER = openpyxl.styles.Side(style='thin', color="d3d7cf") THIN_BORDER = openpyxl.styles.Side(style='thin', color="d3d7cf")
@@ -34,6 +33,11 @@ FLOAT_STYLE.font = DEFAULT_STYLE.font
FLOAT_STYLE.border = DEFAULT_STYLE.border FLOAT_STYLE.border = DEFAULT_STYLE.border
FLOAT_STYLE.number_format = '#,##0.00' FLOAT_STYLE.number_format = '#,##0.00'
DATE_STYLE = openpyxl.styles.NamedStyle(name='date')
DATE_STYLE.font = DEFAULT_STYLE.font
DATE_STYLE.border = DEFAULT_STYLE.border
DATE_STYLE.number_format = 'dd.mm.yyyy'
def geneate_excel(): def geneate_excel():
"""Generate an excel .xlsx spreadsheet from json data of the kyu/dan """Generate an excel .xlsx spreadsheet from json data of the kyu/dan
@@ -45,8 +49,9 @@ def geneate_excel():
workbook.add_named_style(DEFAULT_STYLE) workbook.add_named_style(DEFAULT_STYLE)
workbook.add_named_style(INT_STYLE) workbook.add_named_style(INT_STYLE)
workbook.add_named_style(FLOAT_STYLE) workbook.add_named_style(FLOAT_STYLE)
workbook.add_named_style(DATE_STYLE)
for sheet in workbook.worksheets: for sheet in workbook.worksheets:
print(sheet)
workbook.remove(sheet) workbook.remove(sheet)
return workbook return workbook
@@ -55,6 +60,8 @@ def generate_sheet(workbook, title, columns_settings, json_data):
row = 1 row = 1
ws = workbook.create_sheet() ws = workbook.create_sheet()
ws.title = title ws.title = title
ws.syncHorizontal = True
ws.filterMode = True
# setup print orientation # setup print orientation
ws.page_setup.orientation = ws.ORIENTATION_PORTRAIT ws.page_setup.orientation = ws.ORIENTATION_PORTRAIT
@@ -90,10 +97,10 @@ def generate_sheet(workbook, title, columns_settings, json_data):
ws.column_dimensions[settings['col']].width = settings['width'] ws.column_dimensions[settings['col']].width = settings['width']
def export_season_rankings(workbook): def export_season_rankings(workbook, until):
json_data = sorted(SeasonRanking.objects.json_data(), SeasonRanking.objects.update(until=until)
key=itemgetter('placement')) json_data = SeasonRanking.objects.json_data()
title = "Mahjong Ladder - {}".format(date.today().year) title = "Mahjong Ladder - {}".format(until.year)
columns_settings = ( columns_settings = (
{'col': 'A', 'title': 'Rang', 'attr': 'placement', 'style': 'int', {'col': 'A', 'title': 'Rang', 'attr': 'placement', 'style': 'int',
'width': 8}, 'width': 8},
@@ -111,7 +118,6 @@ def export_season_rankings(workbook):
{'col': 'G', 'title': 'Gewonnen', 'attr': 'won_hanchans', {'col': 'G', 'title': 'Gewonnen', 'attr': 'won_hanchans',
'style': 'int', 'width': 10}, 'style': 'int', 'width': 10},
) )
generate_sheet( generate_sheet(
workbook=workbook, workbook=workbook,
title=title, title=title,
@@ -119,7 +125,8 @@ def export_season_rankings(workbook):
json_data=json_data) json_data=json_data)
def export_kyu_dan_rankings(workbook): def export_kyu_dan_rankings(workbook, until):
KyuDanRanking.objects.update(until=until)
json_data = KyuDanRanking.objects.json_data() json_data = KyuDanRanking.objects.json_data()
title = "Kyū & Dan Rankings" title = "Kyū & Dan Rankings"
columns_settings = ( columns_settings = (
@@ -136,7 +143,9 @@ def export_kyu_dan_rankings(workbook):
{'col': 'F', 'title': 'Gut', 'attr': 'good_hanchans', {'col': 'F', 'title': 'Gut', 'attr': 'good_hanchans',
'style': 'int', 'width': 5}, 'style': 'int', 'width': 5},
{'col': 'G', 'title': 'Gewonnen', 'attr': 'won_hanchans', {'col': 'G', 'title': 'Gewonnen', 'attr': 'won_hanchans',
'style': 'int', 'width': 10}, 'style': 'int', 'width': 8},
{'col': 'H', 'title': 'letzte Hanchan', 'attr': 'last_hanchan_date',
'style': 'date', 'width': 16},
) )
generate_sheet( generate_sheet(
workbook=workbook, workbook=workbook,
@@ -148,11 +157,16 @@ def export_kyu_dan_rankings(workbook):
class Command(BaseCommand): class Command(BaseCommand):
"""Exports the SeasonRankings""" """Exports the SeasonRankings"""
def add_arguments(self, parser):
parser.add_argument('--until', nargs='?', type=parse_date)
def handle(self, *args, **options): def handle(self, *args, **options):
"""Exports the current ladder ranking in a spreadsheet. """Exports the current ladder ranking in a spreadsheet.
This is useful as a backup in form of a hardcopy.""" This is useful as a backup in form of a hardcopy."""
until = timezone.make_aware(
datetime.combine(options['until'] or date.today(),
time(23, 59, 59)))
workbook = geneate_excel() workbook = geneate_excel()
export_season_rankings(workbook) export_season_rankings(workbook, until=until)
export_kyu_dan_rankings(workbook) export_kyu_dan_rankings(workbook, until=until)
workbook.save('sample.x') workbook.save('mahjong_rankings_{:%Y-%m-%d}.xlsx'.format(until))
workbook.save('mahjong_rankings_{}.xlsx'.format(str(date.today())))

View File

@@ -5,27 +5,29 @@ Recalculate Mahjong Rankings...
""" """
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from datetime import date, datetime, time
from mahjong_ranking import LOGGER
from mahjong_ranking import models from mahjong_ranking import models
from django.utils.dateparse import parse_date
from django.utils import timezone
class Command(BaseCommand): class Command(BaseCommand):
""" Recalculate all Kyu/Dan Rankings """ """ Recalculate all Kyu/Dan Rankings """
help = "Recalculate all Kyu/Dan Rankings" help = "Recalculate all Kyu/Dan Rankings"
def add_arguments(self, parser):
parser.add_argument('--since', nargs='?', type=parse_date)
parser.add_argument('--until', nargs='?', type=parse_date)
parser.add_argument('--forcerecalc', action='store_true')
def handle(self, *args, **options): def handle(self, *args, **options):
old_attr = {'dan': None, 'dan_points': None, since = options.get('since', None)
'kyu': None, 'kyu_points': None, 'won_hanchans': None, until = options.get('until', None)
'good_hanchans': None, 'hanchan_count': None} force_recalc = options.get('forecerecalc', False)
for ranking in models.KyuDanRanking.objects.all(): if isinstance(since, date):
old_attr = {attr: getattr(ranking, attr) for attr in old_attr.keys()} since = datetime.combine(since, time(0, 0, 0))
ranking.recalculate() since = timezone.make_aware(since)
for attr, old_value in old_attr.items(): if isinstance(until, date):
if getattr(ranking, attr) != old_value: until = datetime.combine(until, time(23, 59, 59))
LOGGER.warning( until = timezone.make_aware(until)
"%(user)s recalc shows differences in %(attr)s! old: %(old)d, new: %(new)d", models.KyuDanRanking.objects.update(since=since, until=until, force_recalc=force_recalc)
{'user': ranking.user, 'attr': attr,
'old': old_value, 'new': getattr(ranking, attr)}
)

View File

@@ -1,7 +1,8 @@
"""ObjectManagers for the Django Models used in the Mahjong-Ranking.""" """ObjectManagers for the Django Models used in the Mahjong-Ranking."""
from datetime import date from datetime import date
from . import LOGGER
from django.db import models from django.db import models
from django.conf import settings
class HanchanManager(models.Manager): class HanchanManager(models.Manager):
@@ -12,7 +13,7 @@ class HanchanManager(models.Manager):
""" """
use_for_related_fields = True use_for_related_fields = True
def confirmed_hanchans(self, user=None, **filter_args): def confirmed_hanchans(self, user=None, until=None, **filter_args):
""" Return all valid and confirmed Hanchans. """ Return all valid and confirmed Hanchans.
:param user: Only return Hanchans where this user participated. :param user: Only return Hanchans where this user participated.
@@ -20,9 +21,12 @@ class HanchanManager(models.Manager):
:return: QuerySet Object :return: QuerySet Object
""" """
if user: if user:
return self.user_hanchans(user, confirmed=True, **filter_args) return self.user_hanchans(user, confirmed=True, until=until,
else: **filter_args)
return self.filter(confirmed=True, **filter_args) hanchans = self.filter(confirmed=True, **filter_args)
if until:
hanchans = hanchans.filter(start__lte=until)
return hanchans
def dan_hanchans(self, user, **filter_args): def dan_hanchans(self, user, **filter_args):
""" Return all Hanchans where a specific user has participated and had """ Return all Hanchans where a specific user has participated and had
@@ -60,17 +64,20 @@ class HanchanManager(models.Manager):
[hanchan.get_playerdata(user) for hanchan in queryset] [hanchan.get_playerdata(user) for hanchan in queryset]
return queryset return queryset
def season_hanchans(self, user=None, season=None): def season_hanchans(self, user=None, season=None, until=None):
"""Return all Hanchans that belong to a given or the current season. """Return all Hanchans that belong to a given or the current season.
:param user: Only return Hanchans where this user participated. :param user: Only return Hanchans where this user participated.
:param season: the year of the wanted season, current year if None. :param season: the year of the wanted season, current year if None.
:return: QuerySet Object :return: QuerySet Object
""" """
season = season or date.today().year try:
return self.confirmed_hanchans(user=user, season=season) season = season or until.year
except AttributeError:
season = date.today().year
return self.confirmed_hanchans(user=user, season=season, until=until)
def user_hanchans(self, user, since=None, **filter_args): def user_hanchans(self, user, since=None, until=None, **filter_args):
"""Return all Hanchans where a specific user has participated. """Return all Hanchans where a specific user has participated.
:param user: Return Hanchans where this user participated. :param user: Return Hanchans where this user participated.
@@ -82,10 +89,11 @@ class HanchanManager(models.Manager):
models.Q(player1=user) | models.Q(player2=user) | models.Q(player1=user) | models.Q(player2=user) |
models.Q(player3=user) | models.Q(player4=user) models.Q(player3=user) | models.Q(player4=user)
) )
if since:
queryset = queryset.filter(start__gte=since, **filter_args)
else:
queryset = queryset.filter(**filter_args) queryset = queryset.filter(**filter_args)
if since:
queryset = queryset.filter(start__gte=since)
if until:
queryset = queryset.filter(start__lte=until)
queryset = queryset.select_related().order_by('-start') queryset = queryset.select_related().order_by('-start')
[hanchan.get_playerdata(user) for hanchan in queryset] [hanchan.get_playerdata(user) for hanchan in queryset]
return queryset return queryset
@@ -158,8 +166,27 @@ class SeasonRankingManager(models.Manager):
}) })
return json_data return json_data
class KyuDanRankingManager(models.Manager): def update(self, season=None, until=None, force_recalc=False):
try:
season = season or until.year
except AttributeError:
season = date.today().year
if until or force_recalc:
for ranking in self.filter(season=season):
ranking.recalculate(until=until)
for placement, ranking in enumerate(self.season_rankings(season)):
ranking.placement = placement
ranking.save(force_update=True, update_fields=['placement'])
def season_rankings(self, season=None):
season = season or date.today().year
rankings = self.filter(
season=season,
hanchan_count__gt=settings.MIN_HANCHANS_FOR_LADDER)
return rankings.order_by('avg_placement', '-avg_score')
class KyuDanRankingManager(models.Manager):
def json_data(self): def json_data(self):
""" Get all Rankings for a given Season and return them as a list of """ Get all Rankings for a given Season and return them as a list of
dict objects, suitable for JSON exports and other processings. dict objects, suitable for JSON exports and other processings.
@@ -172,9 +199,12 @@ class KyuDanRankingManager(models.Manager):
values = values.values('user_id', 'user__username', values = values.values('user_id', 'user__username',
'user__first_name', 'user__last_name', 'user__first_name', 'user__last_name',
'dan', 'dan_points', 'kyu', 'kyu_points', 'dan', 'dan_points', 'kyu', 'kyu_points',
'hanchan_count', 'won_hanchans', 'good_hanchans') 'hanchan_count', 'won_hanchans', 'good_hanchans',
'last_hanchan_date')
for user in values: for user in values:
if user['dan']: if user['hanchan_count'] == 0:
continue
elif user['dan']:
rank = '{}. Dan'.format(user['dan']) rank = '{}. Dan'.format(user['dan'])
points = user['dan_points'] points = user['dan_points']
else: else:
@@ -183,11 +213,31 @@ class KyuDanRankingManager(models.Manager):
json_data.append({ json_data.append({
'user_id': user['user_id'], 'user_id': user['user_id'],
'username': user['user__username'], 'username': user['user__username'],
'full_name': " ".join([user['user__last_name'], user['user__first_name']]), 'full_name': " ".join(
[user['user__last_name'], user['user__first_name']]),
'rank': rank, 'rank': rank,
'points': points, 'points': points,
'hanchan_count': user['hanchan_count'], 'hanchan_count': user['hanchan_count'],
'good_hanchans': user['good_hanchans'], 'good_hanchans': user['good_hanchans'],
'won_hanchans': user['won_hanchans'] 'won_hanchans': user['won_hanchans'],
'last_hanchan_date': user['last_hanchan_date']
}) })
return json_data return json_data
def update(self, since=None, until=None, force_recalc=False):
old_attr = {'dan': None, 'dan_points': None,
'kyu': None, 'kyu_points': None, 'won_hanchans': None,
'good_hanchans': None, 'hanchan_count': None}
for ranking in self.all():
old_attr = {attr: getattr(ranking, attr) for attr in
old_attr.keys()}
ranking.calculate(since=since, until=until,
force_recalc=force_recalc)
for attr, old_value in old_attr.items():
if getattr(ranking, attr) != old_value:
LOGGER.warning(
"%(user)s recalc shows differences in %(attr)s! old: %(old)d, new: %(new)d",
{'user': ranking.user, 'attr': attr,
'old': old_value, 'new': getattr(ranking, attr)}
)

View File

@@ -35,7 +35,7 @@ class DenormalizationUpdateMiddleware(object): # Ignore PyLintBear (R0903)
user_id, hanchan_start = kyu_dan_ranking_queue.pop() user_id, hanchan_start = kyu_dan_ranking_queue.pop()
kyu_dan_ranking = models.KyuDanRanking.objects.get_or_create( kyu_dan_ranking = models.KyuDanRanking.objects.get_or_create(
user_id=user_id)[0] user_id=user_id)[0]
kyu_dan_ranking.recalculate(hanchan_start) kyu_dan_ranking.calculate(since=hanchan_start)
cache.set('kyu_dan_ranking_queue', set(), 360) cache.set('kyu_dan_ranking_queue', set(), 360)
ladder_ranking_queue = cache.get('ladder_ranking_queue', set()) ladder_ranking_queue = cache.get('ladder_ranking_queue', set())
@@ -58,12 +58,5 @@ class DenormalizationUpdateMiddleware(object): # Ignore PyLintBear (R0903)
for season in season_queue: for season in season_queue:
LOGGER.info(u'Recalculate placements for Season %d', season) LOGGER.info(u'Recalculate placements for Season %d', season)
season_rankings = models.SeasonRanking.objects.filter( models.SeasonRanking.objects.update(season=season)
season=season, hanchan_count__gt=MIN_HANCHANS_FOR_LADDER
).order_by('avg_placement', '-avg_score')
placement = 1
for ranking in season_rankings:
ranking.placement = placement
ranking.save(force_update=True)
placement += 1
return response return response

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-11-15 05:53
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mahjong_ranking', '0004_auto_20170218_1947'),
]
operations = [
migrations.AlterModelOptions(
name='kyudanranking',
options={'ordering': ('-dan_points', 'dan', '-kyu_points'), 'verbose_name': 'Kyū/Dan Wertung', 'verbose_name_plural': 'Kyū/Dan Wertungen'},
),
migrations.AddField(
model_name='kyudanranking',
name='last_hanchan_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='kyudanranking',
name='wins_in_a_row',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -4,7 +4,7 @@
# werden dürfen. # werden dürfen.
from __future__ import division from __future__ import division
from datetime import datetime, time
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
@@ -345,10 +345,10 @@ class KyuDanRanking(models.Model):
legacy_hanchan_count = models.PositiveIntegerField(default=0) legacy_hanchan_count = models.PositiveIntegerField(default=0)
legacy_dan_points = models.PositiveIntegerField(default=0) legacy_dan_points = models.PositiveIntegerField(default=0)
legacy_kyu_points = models.PositiveIntegerField(default=0) legacy_kyu_points = models.PositiveIntegerField(default=0)
wins_in_a_row = 0 wins_in_a_row = models.PositiveIntegerField(default=0)
last_hanchan_date = models.DateTimeField(blank=True, null=True)
objects = managers.KyuDanRankingManager() objects = managers.KyuDanRankingManager()
class Meta(object): class Meta(object):
ordering = ('-dan_points', 'dan', '-kyu_points') ordering = ('-dan_points', 'dan', '-kyu_points')
verbose_name = _(u'Kyū/Dan Ranking') verbose_name = _(u'Kyū/Dan Ranking')
@@ -436,11 +436,19 @@ class KyuDanRanking(models.Model):
else: else:
return reverse('player-kyu-score', args=[self.user.username]) return reverse('player-kyu-score', args=[self.user.username])
def recalculate(self, hanchan_start=None): def calculate(self, since=None, until=None, force_recalc=False):
""" """
Fetches all valid Hanchans from this Player and recalculates his Fetches all valid Hanchans from this Player and recalculates his
Kyu/Dan Ranking. Kyu/Dan Ranking.
""" """
valid_hanchans = Hanchan.objects.confirmed_hanchans(user=self.user)
valid_hanchans = valid_hanchans.order_by('start')
if since and self.last_hanchan_date and since < self.last_hanchan_date:
force_recalc = True
if until and self.last_hanchan_date and until < self.last_hanchan_date:
force_recalc = True
if force_recalc:
# Setze alles auf die legacy Werte und berechne alles von neuem.
self.dan = None self.dan = None
self.dan_points = self.legacy_dan_points or 0 self.dan_points = self.legacy_dan_points or 0
self.kyu = None self.kyu = None
@@ -449,15 +457,22 @@ class KyuDanRanking(models.Model):
self.good_hanchans = 0 self.good_hanchans = 0
self.won_hanchans = 0 self.won_hanchans = 0
self.update_rank() self.update_rank()
self.last_hanchan_date = None
LOGGER.info(
"recalculating Kyu/Dan points for %s since %s...",
self.user, str(hanchan_start)
)
valid_hanchans = Hanchan.objects.confirmed_hanchans(
user=self.user).order_by('start')
if self.legacy_date: if self.legacy_date:
valid_hanchans = valid_hanchans.filter(start__gt=self.legacy_date) since = timezone.make_aware(
datetime.combine(self.legacy_date, time(0, 0, 0)))
else:
since = None
else:
since = self.last_hanchan_date or self.legacy_date
LOGGER.info(
"recalculating Kyu/Dan points for %(user)s since %(since)s...",
{'user': self.user, 'since': str(since)}
)
if since:
valid_hanchans = valid_hanchans.filter(start__gt=since)
if until:
valid_hanchans = valid_hanchans.filter(start__lte=until)
""" TODO: Hanchan Punkte nur neu berechnen wenn sie nach hachan_start """ TODO: Hanchan Punkte nur neu berechnen wenn sie nach hachan_start
lagen. Es müssen aber alle durch die Schleife rennen, damit die Punkte lagen. Es müssen aber alle durch die Schleife rennen, damit die Punkte
@@ -465,7 +480,7 @@ class KyuDanRanking(models.Model):
self.hanchan_count += valid_hanchans.count() self.hanchan_count += valid_hanchans.count()
for hanchan in valid_hanchans: for hanchan in valid_hanchans:
hanchan.get_playerdata(self.user) hanchan.get_playerdata(self.user)
if hanchan_start and hanchan_start < hanchan.start: if since and since < hanchan.start:
self.dan_points += hanchan.dan_points or 0 self.dan_points += hanchan.dan_points or 0
self.kyu_points += hanchan.kyu_points or 0 self.kyu_points += hanchan.kyu_points or 0
self.update_rank() self.update_rank()
@@ -482,6 +497,7 @@ class KyuDanRanking(models.Model):
hanchan.save(recalculate=False) hanchan.save(recalculate=False)
self.won_hanchans += 1 if hanchan.placement == 1 else 0 self.won_hanchans += 1 if hanchan.placement == 1 else 0
self.good_hanchans += 1 if hanchan.placement == 2 else 0 self.good_hanchans += 1 if hanchan.placement == 2 else 0
self.last_hanchan_date = hanchan.start
LOGGER.debug( LOGGER.debug(
'id: %(id)d, start: %(start)s, placement: %(placement)d, ' 'id: %(id)d, start: %(start)s, placement: %(placement)d, '
'score: %(score)d, kyu points: %(kyu_points)d, dan points: ' 'score: %(score)d, kyu points: %(kyu_points)d, dan points: '
@@ -593,9 +609,9 @@ class SeasonRanking(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('player-ladder-score', args=[self.user.username]) return reverse('player-ladder-score', args=[self.user.username])
def recalculate(self): def recalculate(self, until=None):
season_hanchans = Hanchan.objects.season_hanchans( season_hanchans = Hanchan.objects.season_hanchans(
user=self.user, season=self.season) user=self.user, season=self.season, until=until)
sum_placement = 0 sum_placement = 0
sum_score = 0 sum_score = 0
self.placement = None self.placement = None

View File

@@ -34,7 +34,7 @@ class KyuDanTest(TestCase):
for ranking in KyuDanRanking.objects.all(): for ranking in KyuDanRanking.objects.all():
original = {a: getattr(ranking, a) for a in self.equal_attrs} original = {a: getattr(ranking, a) for a in self.equal_attrs}
ranking.recalculate() ranking.calculate()
for attr in self.equal_attrs: for attr in self.equal_attrs:
self.assertEqual( self.assertEqual(
original[attr], original[attr],
@@ -62,7 +62,7 @@ class KyuDanTest(TestCase):
continue continue
rnd = random.randrange(confirmed_hanchans.count()) rnd = random.randrange(confirmed_hanchans.count())
since = confirmed_hanchans[rnd].start since = confirmed_hanchans[rnd].start
ranking.recalculate(hanchan_start=since) ranking.calculate(since=since)
for attr in self.equal_attrs: for attr in self.equal_attrs:
self.assertEqual( self.assertEqual(
original[attr], original[attr],