Files
kasu/mahjong_ranking/models.py
Christian Berg 86a0db050d Diverse Code Cleanups
*Code wurde PEP-8 gerecht formatiert
* Kleine Fehler die der PyCharm Inspector beanstandet wurden korrigiert
2014-11-26 16:04:52 +01:00

626 lines
24 KiB
Python

# -*- encoding: utf-8 -*-
from datetime import date, timedelta
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.db import models
from django.db.models.aggregates import Sum
from django.utils import timezone
from django.utils.translation import ugettext as _
from events.models import Event
from . import KYU_RANKS, DAN_RANKS, DAN_RANKS_DICT, MIN_HANCHANS_FOR_LADDER
from . import logger, set_dirty
kyu_dan_rankings = set()
ladder_rankings = set()
ladder_seasons = set()
class EventRanking(models.Model):
"""
Event Rankings funktionieren genauso wie Season Rankings.
Sie beschränken sich aber auf einen Event und werden nur dann angestossen,
wenn der Event als Turnier markiert wurde.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL)
event = models.ForeignKey(Event)
placement = models.PositiveIntegerField(blank=True, null=True)
avg_placement = models.FloatField(default=4)
avg_score = models.FloatField(default=0)
hanchan_count = models.PositiveIntegerField(default=0)
good_hanchans = models.PositiveIntegerField(default=0)
won_hanchans = models.PositiveIntegerField(default=0)
dirty = models.BooleanField(default=True, editable=False)
class Meta(object):
ordering = ('placement', 'avg_placement', '-avg_score',)
def get_absolute_url(self):
return reverse('event-ranking', args=[self.tourney_id])
def recalculate(self):
"""
Berechnet die durschnittliche Platzierung und Punkte, u.v.m. neu.
Diese Daten werden benötigt um die Platzierung zu erstellen. Sie
können zwar sehr leicht errechnet werden, es macht trotzdem Sinn
sie zwischen zu speichern.
Das Eigenschaft dirty ist ein altes Überbleibsel, um das Objekt
zur neuberrechnung zu markieren. Mittlerweile wird ein lokaler
Cache dafür verwendet, das ist schneller.
"""
logger.info(u'Recalculate EventRanking for Player %s in %s', self.user, self.event.name) # @IgnorePep8
event_hanchans = Player.objects.valid_hanchans(user=self.user_id, event=self.event_id) # @IgnorePep8
aggregator = event_hanchans.aggregate(
models.Avg('placement'),
models.Avg('score'),
models.Count('pk'))
self.avg_placement = aggregator['placement__avg']
self.avg_score = aggregator['score__avg']
self.hanchan_count = aggregator['pk__count']
self.good_hanchans = event_hanchans.filter(placement__lt=3).count()
self.won_hanchans = event_hanchans.filter(placement=1).count()
self.dirty = False
if self.hanchan_count <= 0:
self.delete()
else:
self.save()
class Hanchan(models.Model):
"""
Ein komplette Runde Mahjong, die aus genau 4 Spielern bestehen muss.
Es werden aber noch andere Tests durchgeführt, ob sie gültig ist.
Außerdem gehört jede Hanchan zu einer Veranstaltung.
"""
comment = models.TextField(_('Comment'), blank=True)
confirmed = models.BooleanField(_('Has been Confirmed'), default=True, help_text=_('Only valid and confirmed Hanchans will be counted in the rating.')) # @IgnorePep8
event = models.ForeignKey(Event)
player_names = models.CharField(max_length=127, editable=False)
players = models.ManyToManyField(
settings.AUTH_USER_MODEL,
through='Player',
verbose_name=_('Players')
)
season = models.ForeignKey('LadderSeason', blank=True, null=True, editable=False) # @IgnorePep8
start = models.DateTimeField(_('Start'), help_text=_('This is crucial to get the right Hanchans that scores')) # @IgnorePep8
valid = models.BooleanField(_('Is Valid'), default=False)
class Meta(object):
ordering = ('-start',)
verbose_name = _(u'Hanchan')
verbose_name_plural = _(u'Hanchans')
def __str__(self):
return "Hanchan am {0:%d.%m.%Y} um {0:%H:%M} ({1})".format(self.start, self.player_names)
def check_validity(self):
"""
Prüft ob die Hanchan gültig ist.
4 Spieler müssen genau 100.000 Punkte erreichen, mehr sind nur erlaubt
wenn midestens ein Spieler ins Minus (auf 0) geraten ist. Ansonsten
wird die Hanchan als ungültig markiert aber trotzdem abgespeichert,
außerdem wird die Begründung zurück gegeben, was nicht gestimmt hat.
"""
logger.debug("Hanchan wird geprüft ob er valide ist...")
if not self.pk:
self.valid = False
return
elif self.player_set.distinct().count() != 4:
self.valid = False
return _('For a Hanchan exactly 4 players are needed.')
score_sum = self.player_set.aggregate(Sum('score'))['score__sum']
if score_sum == 100000:
self.valid = True
return '4 Spieler, 100.000 Endpunktestand, die Hanchan ist \
korrekt!'
elif score_sum > 100000 and self.player_set.filter(score=0):
self.valid = True
return 'Endpunktestand über 100.000, aber jemand ist auf 0 \
gefallen. Die Hanchan stimmt.'
elif score_sum < 100000:
self.valid = False
return 'Endpunktestand weniger als 100.000 Punkte.'
elif score_sum > 100000 and not self.player_set.filter(score=0):
self.valid = False
return 'Endpunktestand über 100.000, aber niemand ist auf 0 \
gefallen.'
else:
self.valid = False
return 'Wir wissen nicht warum, aber das kann nicht passen...'
def clean(self):
"""
Prüft ob wichtige Vorrausetzungen gegeben sind und aktualisiert ein
paar Zwischenspeicher, bevor gespeichert wird.
Die Hanchan muss 4 (unterschiedliche) Spieler haben, muss in der
Veranstaltungzeit liegen, und darf nicht in der Zukunft liegen.
Ansonsten wird ein ValidationError ausgegeben und die Hanchan nicht
gespeichert.
Die Gültigkeit wird geprüft und die Sasion in der die Hanchan liegt
wird aktualisert.
"""
logger.debug("Hanchan clean() wurde getriggert!")
# if self.pk and self.player_set.distinct().count() != 4:
# raise ValidationError(
# _('For a Hanchan exactly 4 players are needed.'))
if self.start and self.start > timezone.now():
raise ValidationError(_("It's not allowed to enter future games."))
elif not (self.event.start <= self.start <= self.event.end):
raise ValidationError(_("Only games during the event are allowed"))
return self
def compute_player_placements(self):
u"""
Bestimmt die Platzierung eines der Spieler einer Hanchan und speichert
diese beim jeweiligen Spieler ab.
"""
logger.debug("Berechne die Platzierungen neu...")
attending_players = self.player_set.select_related('hanchan', 'user')
attending_players = attending_players.order_by('-score')
other_player_placement = 0
other_player_score = 0
placement = 1
player_list = []
logger.info("Compute player pacements for Hanchan Nr. %d", self.pk)
for player in attending_players:
player_list.append(player.user.username)
if player.score <= 0:
player.placement = 4
elif player.score == other_player_score:
player.placement = other_player_placement
else:
player.placement = placement
placement += 1
other_player_placement = player.placement
other_player_score = player.score
player.save(season_id=self.season_id, mark_dirty=True)
def get_absolute_url(self):
"""
URL zur Hanchanliste des Events wo diese Hanchan gelistet wurde.
"""
url = reverse('event-hanchan-list', kwargs={'event': self.event.pk})
return u'%(url)s#%(pk)d' % {'url': url, 'pk': self.pk}
def save(self, **kwargs):
logger.debug("Hanchan save() wurde getriggert!")
self.season = self.season or LadderSeason.objects.get_by_date(self.start)
if self.pk:
self.check_validity()
self.compute_player_placements()
self.player_names = ', '.join(self.player_set.values_list(
'user__username', flat=True).order_by('-score'))
return models.Model.save(self, **kwargs)
class KyuDanRanking(models.Model):
u"""
Die Einstufung des Spieles im Kyu bzw. Dan System.
Im Gegensatz zum Ladder Ranking ist das nicht Saison gebunden.
daher läuft es getrennt.
"""
user = models.OneToOneField(settings.AUTH_USER_MODEL)
dan = models.PositiveSmallIntegerField(blank=True, null=True)
dan_points = models.PositiveIntegerField(default=0)
kyu = models.PositiveSmallIntegerField(default=10, blank=True, null=True)
kyu_points = models.PositiveIntegerField(default=0)
won_hanchans = models.PositiveIntegerField(default=0)
good_hanchans = models.PositiveIntegerField(default=0)
hanchan_count = models.PositiveIntegerField(default=0)
dirty = models.BooleanField(default=True, editable=False)
wins_in_a_row = 0
class Meta(object):
ordering = ('-dan_points', '-kyu_points',)
verbose_name = _(u'Kyū/Dan Ranking')
verbose_name_plural = _(u'Kyū/Dan Rankings')
def __unicode__(self):
if self.dan_points:
return u"%s - %d. Dan" % (self.user.username, self.dan)
else:
return u"%s - %d. Kyu" % (self.user.username, self.kyu)
def append_3_in_a_row_bonuspoints(self, hanchan):
u"""
Wenn der Spieler 3 Siege in folge hatte, bekommt er so viele Punkte
das er einen Dan Rang aufsteigt. Dies wird als Kommentar abgespeichert,
um es besser nachvollziehen zu können.
"""
if self.dan and hanchan.placement == 1:
self.wins_in_a_row += 1
else:
self.wins_in_a_row = 0
if self.dan and self.wins_in_a_row > 2:
logger.info('adding bonuspoints for 3 wins in a row for %s', self.user) # @IgnorePep8
new_dan_rank = self.dan + 1
new_dan_points = DAN_RANKS_DICT[new_dan_rank] + 1
bonus_points = new_dan_points - self.dan_points
logger.debug("Stats for %s:", self.user)
logger.debug("current dan_points: %d", self.dan_points)
logger.debug("current dan: %d", self.dan)
logger.debug("min required points for the next dan: %d", new_dan_points) # @IgnorePep8
logger.debug("bonus points to add: %d", bonus_points)
hanchan.dan_points += bonus_points
hanchan.bonus_points += bonus_points
hanchan.comment += '3 Siege in Folge: +%d Punkte auf den \
%d. Dan. ' % (bonus_points, new_dan_rank)
self.dan_points += bonus_points
def append_tournament_bonuspoints(self, player):
"""
Prüft ob es die letzte Hanchan in einem Turnier war. Wenn ja werden
bei Bedarf Bonuspunkte vergeben, falls der Spieler das Turnier gewonnen
hat.
:param player: Ein Player Objekt
"""
bonus_points = 0
current_event = player.hanchan.event
if not current_event.is_tournament:
return False # Kein Tunrier, den rest können wir uns sparen.
hanchans_this_event = Player.objects.filter(
user=self.user, hanchan__event=current_event
)
hanchans_this_event = hanchans_this_event.order_by('-hanchan__start')
last_hanchan_this_event = hanchans_this_event[0].hanchan
if player.hanchan != last_hanchan_this_event:
return # Das bruacht nur am Ende eines Turnieres gemacht werden.
event_ranking = EventRanking.objects.get(
user=self.user,
event=current_event)
if event_ranking.placement == 1:
bonus_points += 4
player.comment += '+4 Punkte für den Sieg des Turnieres. '
if event_ranking.avg_placement == 1:
bonus_points += 8
player.comment += '+8 Pkt: alle Spiele des Turnieres gewonnen. '
player.bonus_points += bonus_points
if bonus_points and self.dan:
player.dan_points += bonus_points
self.dan_points += bonus_points
elif bonus_points:
player.kyu_points += bonus_points
self.kyu_points += bonus_points
def get_absolute_url(self):
if self.dan:
return reverse('player-dan-score', args=[self.user.username])
else:
return reverse('player-kyu-score', args=[self.user.username])
def recalculate(self):
"""
Fetches all valid Hanchans from this Player and recalculates his
Kyu/Dan Ranking.
"""
logger.debug("recalculating Kyu/Dan punkte for %s...", self.user)
valid_hanchans = Player.objects.valid_hanchans(user=self.user_id)
valid_hanchans = valid_hanchans.order_by('hanchan__start')
self.kyu_points = 0
self.dan_points = 0
self.dan = None
self.kyu = 10
self.hanchan_count = valid_hanchans.count()
self.won_hanchans = valid_hanchans.filter(placement=1).count()
self.good_hanchans = valid_hanchans.filter(placement=2).count()
logger.info("Neuberechnung der Punkte von %s", self.user)
for hanchan in valid_hanchans:
self.update_points(hanchan)
self.append_tournament_bonuspoints(hanchan)
self.update_rank()
self.append_3_in_a_row_bonuspoints(hanchan)
self.update_rank()
hanchan.save(force_update=True, mark_dirty=False)
self.save(force_update=True)
def update_points(self, player):
"""
Berechne die Kyu bzw. Dan Punkte für ein Spiel neu.
:param player: Das Player Objekt das neuberechnet werden soll.
"""
player.bonus_points = 0
player.comment = ""
player.dan_points = None
player.kyu_points = None
if player.hanchan.event.is_tournament:
tourney_points = 4 - player.placement
if self.dan:
player.dan_points = tourney_points
else:
player.kyu_points = tourney_points
elif self.dan:
if player.score >= 60000:
player.dan_points = 3
elif player.score == 0:
player.dan_points = -3
elif player.placement == 1:
player.dan_points = 2
elif player.placement == 2:
player.dan_points = 1
elif player.placement == 3:
player.dan_points = -1
elif player.placement == 4:
player.dan_points = -2
elif player.score >= 60000:
player.kyu_points = 3
elif player.score >= 30000:
player.kyu_points = 1
elif player.score < 10000:
player.kyu_points = -1
else:
player.kyu_points = 0
# Add the hanchans points to the players score
if player.dan_points:
if self.dan_points + player.dan_points < 0:
# Only substract so much points that player has 0 Points:
player.dan_points -= (self.dan_points + player.dan_points)
self.dan_points += player.dan_points
elif player.kyu_points:
if self.kyu_points + player.kyu_points < 0:
# Only substract so much points that player has 0 Points:
player.kyu_points -= (self.kyu_points + player.kyu_points)
self.kyu_points += player.kyu_points
def update_rank(self):
if self.dan and self.dan_points < 0:
self.dan_points = 0
self.dan = 1
elif self.dan:
old_dan = self.dan
for min_points, dan_rank in DAN_RANKS:
if self.dan_points > min_points:
self.dan = dan_rank
break
if self.dan > old_dan:
self.wins_in_a_row = 0
elif self.kyu_points < 1:
self.kyu_points = 0
self.kyu = 10
elif self.kyu_points > 50:
self.dan = 1
self.kyu = None
self.dan_points = 0
self.kyu_points = 0
else:
for min_points, kyu_rank in KYU_RANKS:
if self.kyu_points > min_points:
self.kyu = kyu_rank
break
class LadderRanking(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL)
season = models.ForeignKey('LadderSeason')
placement = models.PositiveIntegerField(blank=True, null=True)
avg_placement = models.FloatField(blank=True, null=True)
avg_score = models.FloatField(blank=True, null=True)
hanchan_count = models.PositiveIntegerField(default=0)
good_hanchans = models.PositiveIntegerField(default=0)
won_hanchans = models.PositiveIntegerField(default=0)
dirty = models.BooleanField(default=True, editable=False)
class Meta(object):
ordering = ('placement', 'avg_placement', '-avg_score',)
def get_absolute_url(self):
return reverse('player-ladder-score', args=[self.user.username])
def recalculate(self):
logger.info(u'Recalculate LadderRanking for Player %s in Season %s', self.user, self.season) # @IgnorePep8
ladder_hanchans = Player.objects.ladder_hanchans(
self.user_id, self.season_id)
aggregate = ladder_hanchans.aggregate(
models.Avg('placement'),
models.Avg('score'),
models.Count('pk')
)
self.dirty = False
self.placement = None
self.hanchan_count = aggregate['pk__count']
self.avg_placement = aggregate['placement__avg']
self.avg_score = aggregate['score__avg']
self.good_hanchans = ladder_hanchans.filter(placement=2).count()
self.won_hanchans = ladder_hanchans.filter(placement=1).count()
self.save(force_update=True)
class LadderSeasonManager(models.Manager):
def current(self):
"""
Returns the current season and caches the result for 12 hours
"""
current_season = cache.get('current_mahjong_season')
if not current_season:
try:
today = date.today()
current_season = self.filter(start__lte=today, end__gte=today)
current_season = current_season[0]
except IndexError:
current_season = None
cache.set('current_mahjong_season', current_season, 4320)
return current_season
def get_by_date(self, deadline):
"""
returns the season that where running on the given date.
:param deadline: the date you're intrested in
"""
try:
season = self.filter(start__lte=deadline, end__gte=deadline)
return season[0]
except IndexError:
return None
class LadderSeason(models.Model):
u"""
Eine Saison für das Kasu interne Ladder-Ranking.
"""
name = models.CharField(max_length=100)
start = models.DateField()
end = models.DateField()
objects = LadderSeasonManager()
dirty = models.BooleanField(default=True, editable=False)
class Meta(object):
ordering = ('start',)
verbose_name = _('Ladder Season')
verbose_name_plural = _('Ladder Seasons')
def __unicode__(self):
return self.name
def recalculate(self):
logger.info(u'Recalculate LadderSeason %s', self.name)
self.ladderranking_set.update(placement=None)
ladderrankings_for_placement = self.ladderranking_set.filter(
hanchan_count__gt=MIN_HANCHANS_FOR_LADDER
).order_by(
'avg_placement', '-avg_score'
)
placement = 1
for ranking in ladderrankings_for_placement:
ranking.placement = placement
ranking.save(force_update=True)
placement += 1
self.dirty = False
self.save(force_update=True)
class PlayerManager(models.Manager):
use_for_related_fields = True
def dan_hanchans(self, user=None):
dan_hanchans = self.valid_hanchans(user)
dan_hanchans = dan_hanchans.filter(dan_points__isnull=False)
return dan_hanchans.select_related()
def kyu_hanchans(self, user=None):
queryset = self.valid_hanchans(user).order_by('-hanchan__start')
queryset = queryset.filter(kyu_points__isnull=False).select_related()
return queryset
def ladder_hanchans(self, user=None, season=None, num_hanchans=None, max_age=None): # @IgnorePep8
queryset = self.valid_hanchans(user).order_by('-hanchan__start')
queryset = queryset.select_related()
season = season or LadderSeason.objects.current()
if season:
queryset = queryset.filter(hanchan__season_id=season)
if user:
queryset = queryset.filter(user=user)
if max_age:
expiration_date = date.today() - timedelta(days=max_age)
queryset = queryset.filter(start__gt=expiration_date)
if num_hanchans:
hanchan_list = queryset.values_list('id', flat=True)[:num_hanchans]
hanchan_list = set(hanchan_list)
queryset = self.valid_hanchans(user).filter(id__in=hanchan_list)
queryset = queryset.select_related().order_by('-hanchan__start')
return queryset
def hanchan_stats(self, queryset=None):
queryset = queryset or self.get_query_set()
self.num_hanchans = queryset.count()
self.won_hanchans = queryset.filter(placement=1).count()
self.good_hanchans = queryset.filter(placement__lt=3).count()
def non_counting_hanchans(self, user=None):
queryset = self.exclude(hanchan__valid=True, hanchan__confirmed=True)
if user:
queryset = queryset.filter(user=user)
queryset = queryset.select_related()
return queryset.order_by('-hanchan__start')
def valid_hanchans(self, user=None, event=None):
queryset = self.filter(hanchan__valid=True, hanchan__confirmed=True)
if user:
queryset = queryset.filter(user=user)
if event:
queryset = queryset.filter(hanchan__event_id=event)
queryset = queryset.select_related()
return queryset.order_by('-hanchan__start')
class Player(models.Model):
hanchan = models.ForeignKey(Hanchan)
user = models.ForeignKey(settings.AUTH_USER_MODEL)
score = models.PositiveIntegerField(default=0)
placement = models.PositiveSmallIntegerField(
blank=True,
null=True,
default=None
)
kyu_points = models.PositiveSmallIntegerField(
blank=True,
null=True,
default=None
)
dan_points = models.PositiveSmallIntegerField(
blank=True,
null=True,
default=None
)
bonus_points = models.PositiveSmallIntegerField(
blank=True,
null=True,
default=0
)
comment = models.TextField(_('Comment'), blank=True)
objects = PlayerManager()
class Meta(object):
unique_together = ('hanchan', 'user')
ordering = ['-score']
def __str__(self):
return "{0}'s Punkte vom {1: %d.%m.%Y um %H:%M}".format(self.user.username, self.hanchan.start)
def save(self, mark_dirty=True, season_id=None, *args, **kwargs):
season_id = season_id or self.hanchan.season_id
super(Player, self).save(*args, **kwargs)
# Set the correct Ladder Ranking to dirty, create it if necessary
if mark_dirty:
logger.debug("Marking %s's kyu/dan for recalculation", self.user)
set_dirty(user=self.user_id)
else:
return self
if season_id:
logger.debug("Marking %s's season no. %i ranking for recalculation.", self.user, season_id) # @IgnorePep8
set_dirty(season=season_id, user=self.user_id)
logger.debug("Marking season no %i for recalculation.", season_id)
set_dirty(season=season_id)
if self.hanchan.event.is_tournament:
logger.debug("Marking tournament %s for recalculation.", self.hanchan.event) # @IgnorePep8
set_dirty(event=self.hanchan.event_id, user=self.user_id)
return self
def update_ranking_delete(sender, instance, **kwargs): # @UnusedVariable
for player in instance.player_set.all():
set_dirty(user=player.user_id)
if instance.season_id:
set_dirty(season=instance.season_id, user=player.user_id)
models.signals.pre_delete.connect(update_ranking_delete, sender=Hanchan)