# -*- encoding: utf-8 -*- # TODO: Rankings archiv Flag erstellen, womit sie nicht mehr neuberechnet # 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.db import models from django.urls import reverse from django.utils import timezone from django.utils.translation import ugettext as _ from events.models import Event from . import DAN_RANKS_DICT, LOGGER, set_dirty from . import managers 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, on_delete=models.PROTECT) event = models.ForeignKey(Event, on_delete=models.CASCADE) 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) class Meta(object): ordering = ('placement', 'avg_placement', '-avg_score',) def get_absolute_url(self): return reverse('event-ranking', args=[self.event_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. """ LOGGER.info( u'Recalculate EventRanking for Player %s in %s', self.user, self.event.name ) sum_placement = 0.0 sum_score = 0.0 event_hanchans = Hanchan.objects.confirmed( user=self.user_id, event=self.event_id ) self.hanchan_count = event_hanchans.count() self.won_hanchans = 0 self.good_hanchans = 0 if self.hanchan_count <= 0: self.delete() else: for hanchan in event_hanchans: hanchan.get_playerdata(user=self.user) sum_placement += hanchan.placement sum_score += hanchan.game_score self.won_hanchans += 1 if hanchan.placement == 1 else 0 self.good_hanchans += 1 if hanchan.placement == 2 else 0 self.avg_placement = sum_placement / self.hanchan_count self.avg_score = sum_score / self.hanchan_count self.save(force_update=True) 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. """ event = models.ForeignKey(Event, on_delete=models.CASCADE) start = models.DateTimeField( _('Start'), help_text=_('This is crucial to get the right Hanchans that scores') ) player1 = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='user_hanchan+', verbose_name=_('Player 1')) player1_input_score = models.IntegerField(_('Score')) player1_game_score = models.PositiveIntegerField( _('Score'), default=0, editable=False) player1_placement = models.PositiveSmallIntegerField( default=0, editable=False) player1_kyu_points = models.SmallIntegerField( blank=True, null=True, editable=False) player1_dan_points = models.SmallIntegerField( blank=True, null=True, editable=False) player1_bonus_points = models.SmallIntegerField( blank=True, null=True, editable=False) player1_comment = models.CharField( _('Comment'), blank=True, max_length=255, editable=False) player2 = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='user_hanchan+', verbose_name=_('Player 2')) player2_input_score = models.IntegerField(_('Score')) player2_game_score = models.PositiveIntegerField( _('Score'), default=0, editable=False) player2_placement = models.PositiveSmallIntegerField( default=0, editable=False) player2_kyu_points = models.SmallIntegerField( blank=True, null=True, editable=False) player2_dan_points = models.SmallIntegerField( blank=True, null=True, editable=False) player2_bonus_points = models.SmallIntegerField( blank=True, null=True, editable=False) player2_comment = models.CharField( _('Comment'), blank=True, max_length=255, editable=False) player3 = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='user_hanchan+', verbose_name=_('Player 3')) player3_input_score = models.IntegerField(_('Score')) player3_game_score = models.PositiveIntegerField( _('Score'), default=0, editable=False) player3_placement = models.PositiveSmallIntegerField( default=0, editable=False) player3_kyu_points = models.SmallIntegerField( blank=True, null=True, editable=False) player3_dan_points = models.SmallIntegerField( blank=True, null=True, editable=False) player3_bonus_points = models.SmallIntegerField( blank=True, null=True, editable=False) player3_comment = models.CharField( _('Comment'), blank=True, max_length=255, editable=False) player4 = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='user_hanchan+', verbose_name=_('Player 4')) player4_input_score = models.IntegerField(_('Score')) player4_game_score = models.PositiveIntegerField( _('Score'), default=0, editable=False) player4_placement = models.PositiveSmallIntegerField( default=0, editable=False) player4_kyu_points = models.SmallIntegerField( blank=True, null=True, editable=False) player4_dan_points = models.SmallIntegerField( blank=True, null=True, editable=False) player4_bonus_points = models.SmallIntegerField( blank=True, null=True, editable=False) player4_comment = models.CharField( _('Comment'), blank=True, max_length=255, editable=False) 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.') ) player_names = models.CharField(max_length=255, editable=False) season = models.PositiveSmallIntegerField( _('Season'), editable=False, db_index=True) objects = managers.HanchanManager() class Meta(object): ordering = ('-start',) verbose_name = _(u'Hanchan') verbose_name_plural = _(u'Hanchans') def __str__(self): return _("Hanchan from {0:%Y-%m-%d} at {0:%H:%M} with {1}").format( self.start, self.player_names ) def clean(self): """ Check if 4 different Players are attending the Game, if the Game between the opening hours and the Hanchan has a total of 100.000 Points. """ errors = {} score_sum = 0 player_set = set() try: self.clean_fields() except ValidationError as exception: exception.update_error_dict(errors) return for i in range(1, 5): player = getattr(self, 'player%d' % i) input_score = getattr(self, 'player%d_input_score' % i) score_sum += input_score game_score = input_score if input_score > 0 else 0 if player in player_set: errors['player%d' % i] = _( "%s can't attend the same game multiple times") % player else: player_set.add(player) setattr(self, 'player%d_game_score' % i, game_score) # Check if the game was played during the event if self.start > timezone.now(): errors['start'] = _( "Games in the future may not be added, Dr. Brown") elif not (self.event.start <= self.start <= self.event.end): errors['start'] = _("Only games during the event are allowed") elif score_sum < 100000: errors['player4_input_score'] = _( 'Gamescore is lower then 100.000 Pt.') elif score_sum > 100000: errors['player4_input_score'] = _('Gamescore is over 100.000 Pt.') if errors: raise ValidationError(errors) 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...") player_names = [] other_player_placement = 0 other_player_game_score = 0 placement = 1 player_nr = 1 for player in self.player_list: if player['game_score'] == other_player_game_score: player['placement'] = other_player_placement else: player['placement'] = placement setattr(self, "player%d" % player_nr, player['user']) setattr(self, "player%d_input_score" % player_nr, player['input_score']) setattr(self, "player%d_game_score" % player_nr, player['game_score']) setattr(self, "player%d_placement" % player_nr, player['placement']) setattr(self, "player%d_kyu_points" % player_nr, player['kyu_points']) setattr(self, "player%d_dan_points" % player_nr, player['dan_points']) setattr(self, "player%d_bonus_points" % player_nr, player['bonus_points']) setattr(self, "player%d_comment" % player_nr, player['comment']) other_player_placement = player['placement'] other_player_game_score = player['game_score'] player_names.append(player['user'].username) player_nr += 1 placement += 1 self.player_names = ', '.join(player_names) def get_absolute_url(self): return "{url:s}#{id:d}".format( url=reverse('event-hanchan-list', kwargs={'event': self.event_id}), id=self.pk ) def get_playerdata(self, user): """small workaround to access score, placement and points of a specific user prominent in the user templates""" for player in ('player1', 'player2', 'player3', 'player4'): if getattr(self, player) == user: self.user = user self.input_score = getattr(self, '%s_input_score' % player) self.game_score = getattr(self, '%s_game_score' % player) self.placement = getattr(self, '%s_placement' % player) self.kyu_points = getattr(self, '%s_kyu_points' % player) self.dan_points = getattr(self, '%s_dan_points' % player) self.bonus_points = getattr(self, '%s_bonus_points' % player) self.player_comment = getattr(self, '%s_comment' % player) def update_playerdata(self, user, **kwargs): """i small workaround to access score, placement of a specific user prominent from a in the user templates""" for player in ('player1', 'player2', 'player3', 'player4'): if getattr(self, player) == user: setattr(self, '%s_input_score' % player, self.input_score) setattr(self, '%s_game_score' % player, self.game_score) setattr(self, '%s_placement' % player, self.placement) setattr(self, '%s_kyu_points' % player, self.kyu_points) setattr(self, '%s_dan_points' % player, self.dan_points) setattr(self, '%s_bonus_points' % player, self.bonus_points) setattr(self, '%s_comment' % player, self.player_comment) @property def player_list(self): player_list = [] for i in range(1, 5): player_list.append({ 'user': getattr(self, 'player%d' % i), 'input_score': getattr(self, 'player%d_input_score' % i), 'game_score': getattr(self, 'player%d_game_score' % i), 'placement': getattr(self, 'player%d_placement' % i), 'kyu_points': getattr(self, 'player%d_kyu_points' % i), 'dan_points': getattr(self, 'player%d_dan_points' % i), 'bonus_points': getattr(self, 'player%d_bonus_points' % i), 'comment': getattr(self, 'player%d_comment' % i), }) # sort player by Score: return sorted(player_list, key=lambda player: player['input_score'], reverse=True) def save(self, recalculate=True, **kwargs): self.season = self.event.mahjong_season or self.start.year self.full_clean() if recalculate: self.compute_player_placements() update_ranking(sender=Hanchan, instance=self) return models.Model.save(self, **kwargs) class KyuDanRanking(models.Model): u""" Die Einstufung des Spielers im Kyu bzw. Dan System. Im Gegensatz zum Ladder Ranking ist das nicht Saison gebunden. Deswegen läuft es getrennt. """ user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) dan = models.PositiveSmallIntegerField(blank=True, null=True) dan_points = models.PositiveIntegerField(default=0) max_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) legacy_date = models.DateField(blank=True, null=True) legacy_dan = models.PositiveSmallIntegerField(blank=True, null=True) legacy_dan_points = models.PositiveIntegerField(blank=True, null=True) legacy_kyu = models.PositiveSmallIntegerField(blank=True, null=True) legacy_kyu_points = models.PositiveIntegerField(blank=True, null=True) legacy_hanchan_count = models.PositiveIntegerField(blank=True, null=True) legacy_good_hanchans = models.PositiveIntegerField(blank=True, null=True) legacy_won_hanchans = models.PositiveIntegerField(blank=True, null=True) 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') verbose_name_plural = _(u'Kyū/Dan Rankings') @property def rank(self): if self.dan is not None: return "{0:d}. Dan".format(self.dan) else: return "{0:d}. Kyū".format(self.kyu or 10) @property def points(self): return self.dan_points if self.dan is not None else self.kyu_points def __str__(self): if self.dan is not None: return u"%s - %d. Dan" % (self.user.username, self.dan or 1) else: return u"%s - %d. Kyū" % (self.user.username, self.kyu or 10) 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 not self.dan or not settings.DAN_3_WINS_IN_A_ROW: return if hanchan.placement == 1: self.wins_in_a_row += 1 else: self.wins_in_a_row = 0 return if self.wins_in_a_row >= 3 and self.dan < 9: LOGGER.info( 'adding bonuspoints for 3 wins in a row for %s', self.user) 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) LOGGER.debug("bonus points to add: %d", bonus_points) hanchan.dan_points += bonus_points hanchan.bonus_points += bonus_points hanchan.player_comment += '3 Siege in Folge: +%d Punkte auf den ' \ '%d. Dan. ' % ( bonus_points, new_dan_rank) self.dan_points += bonus_points self.wins_in_a_row = 0 self.update_rank() def append_tournament_bonuspoints(self, hanchan): """ 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 hanchan: Ein Player Objekt """ bonus_points = 0 hanchans_this_event = Hanchan.objects.user_hanchans( user=self.user, event=hanchan.event ).order_by('-start') last_hanchan_this_event = hanchans_this_event[0] # Das braucht nur am Ende eines Turnieres gemacht werden. if hanchan != last_hanchan_this_event: return False event_ranking = EventRanking.objects.get( user=self.user, event=hanchan.event ) if event_ranking.placement == 1: bonus_points += settings.TOURNAMENT_WIN_BONUSPOINTS hanchan.player_comment += u'+{0:d} Punkte Turnier gewonnen. '.format( settings.TOURNAMENT_WIN_BONUSPOINTS) if event_ranking.avg_placement == 1: bonus_points += settings.TOURNAMENT_FLAWLESS_VICTORY_BONUSPOINTS hanchan.player_comment += u'+{0:d} Pkt: alle Spiele des Turnieres gewonnen. '.format( settings.TOURNAMENT_FLAWLESS_VICTORY_BONUSPOINTS) if bonus_points and self.dan: hanchan.dan_points += bonus_points self.dan_points += bonus_points elif bonus_points: hanchan.kyu_points += bonus_points self.kyu_points += bonus_points hanchan.bonus_points += bonus_points return True def get_absolute_url(self): if self.dan or self.dan_points > 0: return reverse('player-dan-score', args=[self.user.username]) else: return reverse('player-kyu-score', args=[self.user.username]) def calculate(self, since=None, until=None, force_recalc=False): """ Fetches all valid Hanchans from this Player and recalculates his Kyu/Dan Ranking. """ valid_hanchans = Hanchan.objects.confirmed(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 = self.legacy_dan self.dan_points = self.legacy_dan_points or 0 self.max_dan_points = self.dan_points self.kyu = self.legacy_kyu self.kyu_points = self.legacy_kyu_points or 0 self.hanchan_count = self.legacy_hanchan_count or 0 self.good_hanchans = self.legacy_good_hanchans or 0 self.won_hanchans = self.legacy_won_hanchans or 0 self.last_hanchan_date = None self.update_rank() since = timezone.make_aware(datetime.combine( self.legacy_date, time(23, 59, 59))) if self.legacy_date else None elif self.last_hanchan_date: since = self.last_hanchan_date elif self.legacy_date: since = timezone.make_aware( datetime.combine(self.legacy_date, time(0, 0, 0)) ) 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) for hanchan in valid_hanchans: self.hanchan_count += 1 hanchan.get_playerdata(self.user) if since and hanchan.start < since: LOGGER.debug(hanchan, "<", since, "no recalc") self.dan_points += hanchan.dan_points or 0 self.kyu_points += hanchan.kyu_points or 0 self.update_rank() else: hanchan.bonus_points = 0 hanchan.player_comment = "" self.update_hanchan_points(hanchan) if hanchan.event.mahjong_tournament: self.append_tournament_bonuspoints(hanchan) self.update_rank() self.append_3_in_a_row_bonuspoints(hanchan) hanchan.update_playerdata(self.user) 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 self.save(force_update=True) def update_hanchan_points(self, hanchan): """ Berechne die Kyu bzw. Dan Punkte für eine Hanchan neu. :type hanchan: Hanchan :param hanchan: Das Player Objekt das neuberechnet werden soll. """ hanchan.kyu_points = None hanchan.dan_points = None if hanchan.event.mahjong_tournament and settings.TOURNAMENT_POINT_SYSTEM: """Für Turniere gelten andere Regeln zur Punktevergabe: 1. Platz 4 Punkte 2. Platz 3 Punkte 3. Platz 2 Punkte 4. Platz 1 Punkt""" tourney_points = 4 - hanchan.placement if self.dan: hanchan.dan_points = tourney_points else: hanchan.kyu_points = tourney_points elif self.dan: if hanchan.game_score >= 60000: hanchan.dan_points = 3 elif hanchan.game_score == 0: hanchan.dan_points = -3 elif hanchan.placement == 1: hanchan.dan_points = 2 elif hanchan.placement == 2: hanchan.dan_points = 1 elif hanchan.placement == 3: hanchan.dan_points = -1 elif hanchan.placement == 4: hanchan.dan_points = -2 # otherwise player must be in the kyu ranking elif hanchan.game_score >= 60000: hanchan.kyu_points = 3 elif hanchan.game_score >= 30000: hanchan.kyu_points = 1 elif hanchan.input_score < 10000: hanchan.kyu_points = -1 else: hanchan.kyu_points = 0 # Add the hanchans points to the players points if self.dan: # Only substract so much points that player has 0 Points: if self.dan_points + hanchan.dan_points < 0: hanchan.player_comment = 'Spieler unterschreitet 0 Punkte.' \ '(Original {} Punkte)'.format(hanchan.dan_points) hanchan.dan_points -= (self.dan_points + hanchan.dan_points) self.dan_points += hanchan.dan_points else: # Only substract so much points that player has 0 Points: if self.kyu_points + hanchan.kyu_points < 0: hanchan.player_comment = 'Spieler unterschreitet 0 Punkte.' \ '(Original {} Punkte)'.format(hanchan.kyu_points) hanchan.kyu_points -= (self.kyu_points + hanchan.kyu_points) self.kyu_points += hanchan.kyu_points def update_rank(self): # Update Dan ranking: if self.dan or self.dan_points > 0: if settings.DAN_ALLOW_DROP_DOWN: self.dan = max((dan for min_points, dan in settings.DAN_RANKS if self.dan_points > min_points)) else: self.max_dan_points = max(self.max_dan_points, self.dan_points) self.dan = max((dan for min_points, dan in settings.DAN_RANKS if self.max_dan_points > min_points)) # jump from Kyu to Dan elif self.kyu_points > 50: self.dan = 1 self.dan_points = 0 self.kyu = None self.kyu_points = 0 self.wins_in_a_row = 0 # update Kyu ranking_ else: self.kyu = min((kyu for min_points, kyu in settings.KYU_RANKS if self.kyu_points > min_points)) class SeasonRanking(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) season = models.PositiveSmallIntegerField(_('Season')) 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) objects = managers.SeasonRankingManager() 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, until=None): season_hanchans = Hanchan.objects.season_hanchans( user=self.user, season=self.season, until=until) sum_placement = 0 sum_score = 0 self.placement = None self.hanchan_count = season_hanchans.count() self.good_hanchans = 0 self.won_hanchans = 0 LOGGER.info( u'Recalculate LadderRanking for Player %s in Season %s', self.user, self.season) for hanchan in season_hanchans: sum_placement += hanchan.placement sum_score += hanchan.game_score self.won_hanchans += 1 if hanchan.placement == 1 else 0 self.good_hanchans += 1 if hanchan.placement == 2 else 0 try: self.avg_placement = sum_placement / self.hanchan_count self.avg_score = sum_score / self.hanchan_count except ZeroDivisionError: self.avg_placement = None self.avg_score = None self.save(force_update=True) def update_ranking(sender, instance, **kwargs): for user in ( instance.player1, instance.player2, instance.player3, instance.player4): LOGGER.debug( "marking %(user)s's kyu/dan for recalculation since %(start)s", {'user': user, 'start': str(instance.start)} ) set_dirty(user=user.id, hanchan_date=instance.start) LOGGER.debug("marking event %s for recalculation.", instance.event) set_dirty(event=instance.event_id, user=user.id) if instance.season: LOGGER.debug( "marking %s's ladder %i season for recalculation.", user, instance.season ) set_dirty(user=user.id, season=instance.season) LOGGER.debug("marking season %d for recalculation.", instance.season) set_dirty(season=instance.season) models.signals.pre_delete.connect(update_ranking, sender=Hanchan)