diff --git a/src/mahjong_ranking/management/commands/export_ranking.py b/src/mahjong_ranking/management/commands/export_ranking.py index 164ced4..4092565 100644 --- a/src/mahjong_ranking/management/commands/export_ranking.py +++ b/src/mahjong_ranking/management/commands/export_ranking.py @@ -1,12 +1,11 @@ """Export Mahjong Rankings as excel files.""" -from datetime import date -from operator import itemgetter - import openpyxl from django.core.management.base import BaseCommand +from django.utils.dateparse import parse_date from openpyxl.styles import Border - +from datetime import date, time, datetime +from django.utils import timezone from mahjong_ranking.models import SeasonRanking, KyuDanRanking 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.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(): """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(INT_STYLE) workbook.add_named_style(FLOAT_STYLE) + workbook.add_named_style(DATE_STYLE) + for sheet in workbook.worksheets: - print(sheet) workbook.remove(sheet) return workbook @@ -55,6 +60,8 @@ def generate_sheet(workbook, title, columns_settings, json_data): row = 1 ws = workbook.create_sheet() ws.title = title + ws.syncHorizontal = True + ws.filterMode = True # setup print orientation 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'] -def export_season_rankings(workbook): - json_data = sorted(SeasonRanking.objects.json_data(), - key=itemgetter('placement')) - title = "Mahjong Ladder - {}".format(date.today().year) +def export_season_rankings(workbook, until): + SeasonRanking.objects.update(until=until) + json_data = SeasonRanking.objects.json_data() + title = "Mahjong Ladder - {}".format(until.year) columns_settings = ( {'col': 'A', 'title': 'Rang', 'attr': 'placement', 'style': 'int', 'width': 8}, @@ -111,7 +118,6 @@ def export_season_rankings(workbook): {'col': 'G', 'title': 'Gewonnen', 'attr': 'won_hanchans', 'style': 'int', 'width': 10}, ) - generate_sheet( workbook=workbook, title=title, @@ -119,7 +125,8 @@ def export_season_rankings(workbook): 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() title = "Kyū & Dan Rankings" columns_settings = ( @@ -136,7 +143,9 @@ def export_kyu_dan_rankings(workbook): {'col': 'F', 'title': 'Gut', 'attr': 'good_hanchans', 'style': 'int', 'width': 5}, {'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( workbook=workbook, @@ -148,11 +157,16 @@ def export_kyu_dan_rankings(workbook): class Command(BaseCommand): """Exports the SeasonRankings""" + def add_arguments(self, parser): + parser.add_argument('--until', nargs='?', type=parse_date) + def handle(self, *args, **options): """Exports the current ladder ranking in a spreadsheet. 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() - export_season_rankings(workbook) - export_kyu_dan_rankings(workbook) - workbook.save('sample.x') - workbook.save('mahjong_rankings_{}.xlsx'.format(str(date.today()))) + export_season_rankings(workbook, until=until) + export_kyu_dan_rankings(workbook, until=until) + workbook.save('mahjong_rankings_{:%Y-%m-%d}.xlsx'.format(until)) diff --git a/src/mahjong_ranking/management/commands/update_ranking.py b/src/mahjong_ranking/management/commands/update_ranking.py index 692a8e1..d7a8fd7 100644 --- a/src/mahjong_ranking/management/commands/update_ranking.py +++ b/src/mahjong_ranking/management/commands/update_ranking.py @@ -5,27 +5,29 @@ Recalculate Mahjong Rankings... """ from django.core.management.base import BaseCommand - -from mahjong_ranking import LOGGER +from datetime import date, datetime, time from mahjong_ranking import models - +from django.utils.dateparse import parse_date +from django.utils import timezone class Command(BaseCommand): """ 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): - old_attr = {'dan': None, 'dan_points': None, - 'kyu': None, 'kyu_points': None, 'won_hanchans': None, - 'good_hanchans': None, 'hanchan_count': None} - for ranking in models.KyuDanRanking.objects.all(): - old_attr = {attr: getattr(ranking, attr) for attr in old_attr.keys()} - ranking.recalculate() - 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)} - ) + since = options.get('since', None) + until = options.get('until', None) + force_recalc = options.get('forecerecalc', False) + if isinstance(since, date): + since = datetime.combine(since, time(0, 0, 0)) + since = timezone.make_aware(since) + if isinstance(until, date): + until = datetime.combine(until, time(23, 59, 59)) + until = timezone.make_aware(until) + models.KyuDanRanking.objects.update(since=since, until=until, force_recalc=force_recalc) \ No newline at end of file diff --git a/src/mahjong_ranking/managers.py b/src/mahjong_ranking/managers.py index aa0421a..43b3a33 100644 --- a/src/mahjong_ranking/managers.py +++ b/src/mahjong_ranking/managers.py @@ -1,7 +1,8 @@ """ObjectManagers for the Django Models used in the Mahjong-Ranking.""" from datetime import date - +from . import LOGGER from django.db import models +from django.conf import settings class HanchanManager(models.Manager): @@ -12,7 +13,7 @@ class HanchanManager(models.Manager): """ 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. :param user: Only return Hanchans where this user participated. @@ -20,9 +21,12 @@ class HanchanManager(models.Manager): :return: QuerySet Object """ if user: - return self.user_hanchans(user, confirmed=True, **filter_args) - else: - return self.filter(confirmed=True, **filter_args) + return self.user_hanchans(user, confirmed=True, until=until, + **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): """ Return all Hanchans where a specific user has participated and had @@ -39,7 +43,7 @@ class HanchanManager(models.Manager): models.Q(player4=user, player4_dan_points__isnull=False) ).filter(confirmed=True, **filter_args) 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 def kyu_hanchans(self, user, **filter_args): @@ -57,20 +61,23 @@ class HanchanManager(models.Manager): models.Q(player4=user, player4_kyu_points__isnull=False) ).filter(confirmed=True, **filter_args) 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 - 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. :param user: Only return Hanchans where this user participated. :param season: the year of the wanted season, current year if None. :return: QuerySet Object """ - season = season or date.today().year - return self.confirmed_hanchans(user=user, season=season) + try: + 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. :param user: Return Hanchans where this user participated. @@ -82,12 +89,13 @@ class HanchanManager(models.Manager): models.Q(player1=user) | models.Q(player2=user) | models.Q(player3=user) | models.Q(player4=user) ) + queryset = queryset.filter(**filter_args) if since: - queryset = queryset.filter(start__gte=since, **filter_args) - else: - queryset = queryset.filter(**filter_args) + queryset = queryset.filter(start__gte=since) + if until: + queryset = queryset.filter(start__lte=until) 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 def unconfirmed_hanchans(self, user=None, **filter_args): @@ -158,8 +166,27 @@ class SeasonRankingManager(models.Manager): }) 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): """ Get all Rankings for a given Season and return them as a list of dict objects, suitable for JSON exports and other processings. @@ -172,9 +199,12 @@ class KyuDanRankingManager(models.Manager): values = values.values('user_id', 'user__username', 'user__first_name', 'user__last_name', '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: - if user['dan']: + if user['hanchan_count'] == 0: + continue + elif user['dan']: rank = '{}. Dan'.format(user['dan']) points = user['dan_points'] else: @@ -183,11 +213,31 @@ class KyuDanRankingManager(models.Manager): json_data.append({ 'user_id': user['user_id'], '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, 'points': points, 'hanchan_count': user['hanchan_count'], '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 + + 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)} + ) diff --git a/src/mahjong_ranking/middleware.py b/src/mahjong_ranking/middleware.py index 299edc1..c13853b 100644 --- a/src/mahjong_ranking/middleware.py +++ b/src/mahjong_ranking/middleware.py @@ -35,7 +35,7 @@ class DenormalizationUpdateMiddleware(object): # Ignore PyLintBear (R0903) user_id, hanchan_start = kyu_dan_ranking_queue.pop() kyu_dan_ranking = models.KyuDanRanking.objects.get_or_create( 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) ladder_ranking_queue = cache.get('ladder_ranking_queue', set()) @@ -58,12 +58,5 @@ class DenormalizationUpdateMiddleware(object): # Ignore PyLintBear (R0903) for season in season_queue: LOGGER.info(u'Recalculate placements for Season %d', season) - season_rankings = models.SeasonRanking.objects.filter( - 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 + models.SeasonRanking.objects.update(season=season) return response diff --git a/src/mahjong_ranking/migrations/0005_auto_20171115_0653.py b/src/mahjong_ranking/migrations/0005_auto_20171115_0653.py new file mode 100644 index 0000000..50b21a8 --- /dev/null +++ b/src/mahjong_ranking/migrations/0005_auto_20171115_0653.py @@ -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), + ), + ] diff --git a/src/mahjong_ranking/models.py b/src/mahjong_ranking/models.py index 191a727..832b5d4 100644 --- a/src/mahjong_ranking/models.py +++ b/src/mahjong_ranking/models.py @@ -4,7 +4,7 @@ # werden dürfen. from __future__ import division - +from datetime import datetime, time from django.conf import settings from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse @@ -345,10 +345,10 @@ class KyuDanRanking(models.Model): legacy_hanchan_count = models.PositiveIntegerField(default=0) legacy_dan_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() - class Meta(object): ordering = ('-dan_points', 'dan', '-kyu_points') verbose_name = _(u'Kyū/Dan Ranking') @@ -436,28 +436,43 @@ class KyuDanRanking(models.Model): else: 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 Kyu/Dan Ranking. """ - self.dan = None - self.dan_points = self.legacy_dan_points or 0 - self.kyu = None - self.kyu_points = self.legacy_kyu_points or 0 - self.hanchan_count = self.legacy_hanchan_count or 0 - self.good_hanchans = 0 - self.won_hanchans = 0 - self.update_rank() - + 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_points = self.legacy_dan_points or 0 + self.kyu = None + self.kyu_points = self.legacy_kyu_points or 0 + self.hanchan_count = self.legacy_hanchan_count or 0 + self.good_hanchans = 0 + self.won_hanchans = 0 + self.update_rank() + self.last_hanchan_date = None + if 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 %s since %s...", - self.user, str(hanchan_start) + "recalculating Kyu/Dan points for %(user)s since %(since)s...", + {'user': self.user, 'since': str(since)} ) - valid_hanchans = Hanchan.objects.confirmed_hanchans( - user=self.user).order_by('start') - if self.legacy_date: - valid_hanchans = valid_hanchans.filter(start__gt=self.legacy_date) + 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 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() for hanchan in valid_hanchans: 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.kyu_points += hanchan.kyu_points or 0 self.update_rank() @@ -482,6 +497,7 @@ class KyuDanRanking(models.Model): hanchan.save(recalculate=False) self.won_hanchans += 1 if hanchan.placement == 1 else 0 self.good_hanchans += 1 if hanchan.placement == 2 else 0 + self.last_hanchan_date = hanchan.start LOGGER.debug( 'id: %(id)d, start: %(start)s, placement: %(placement)d, ' 'score: %(score)d, kyu points: %(kyu_points)d, dan points: ' @@ -593,9 +609,9 @@ class SeasonRanking(models.Model): def get_absolute_url(self): return reverse('player-ladder-score', args=[self.user.username]) - def recalculate(self): + def recalculate(self, until=None): season_hanchans = Hanchan.objects.season_hanchans( - user=self.user, season=self.season) + user=self.user, season=self.season, until=until) sum_placement = 0 sum_score = 0 self.placement = None diff --git a/src/mahjong_ranking/tests.py b/src/mahjong_ranking/tests.py index 63f1a86..22c2d56 100644 --- a/src/mahjong_ranking/tests.py +++ b/src/mahjong_ranking/tests.py @@ -34,7 +34,7 @@ class KyuDanTest(TestCase): for ranking in KyuDanRanking.objects.all(): original = {a: getattr(ranking, a) for a in self.equal_attrs} - ranking.recalculate() + ranking.calculate() for attr in self.equal_attrs: self.assertEqual( original[attr], @@ -62,7 +62,7 @@ class KyuDanTest(TestCase): continue rnd = random.randrange(confirmed_hanchans.count()) since = confirmed_hanchans[rnd].start - ranking.recalculate(hanchan_start=since) + ranking.calculate(since=since) for attr in self.equal_attrs: self.assertEqual( original[attr],