# -*- 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. """ super(Hanchan, self).clean() # if self.pk and self.player_set.distinct().count() != 4: # raise ValidationError( # _('For a Hanchan exactly 4 players are needed.')) if not self.event_id: raise ValidationError(_("Hanchan has no event")) elif 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 get_absolute_url(self): """ URL zur Hanchanliste des Events wo diese Hanchan gelistet wurde. """ return reverse('mahjong-ladder', kwargs={'season': self.pk}) 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)