Squashed commit of the following:
commitbb5081a78bAuthor: Xeniac <xeniac@posteo.at> Date: Thu Nov 23 22:02:40 2017 +0100 Added a setting where the exported excel files should be stored. Added a option to send the exported excel as mail attachment. commit854fd38740Author: Xeniac <xeniac@posteo.at> Date: Thu Nov 23 22:01:38 2017 +0100 Fixed: enumerate the Seasonrankings starting with 1 Fixed: Logging error when a value changed from/to None commit6de1ecb102Author: Christian Berg <xeniac@posteo.at> Date: Thu Nov 23 14:15:36 2017 +0100 add a latest method to query the latest x events commitbf12060c3bAuthor: Christian Berg <xeniac@posteo.at> Date: Thu Nov 23 14:15:12 2017 +0100 add a latest method to query the latest x events commit5ad628f33aAuthor: Christian Berg <xeniac@posteo.at> Date: Mon Nov 20 07:47:47 2017 +0100 Changed PlayerDanScore to only list non-legacy hanchans commit36272c60d6Author: Christian Berg <xeniac@posteo.at> Date: Mon Nov 20 07:42:44 2017 +0100 fixed import of MIN_HANCHANS_FOR_LADDER that moved to settings commitc428f6ed1fAuthor: Christian Berg <xeniac@posteo.at> Date: Mon Nov 20 07:41:04 2017 +0100 Updated docstrings for new since and until kwargs commit9276e97c36Author: Christian Berg <xeniac@posteo.at> Date: Mon Nov 20 07:33:54 2017 +0100 added a since parameter to the hanchan queries to return only hanchans since the give date and time commitfd244f10e8Author: Christian Berg <xeniac@posteo.at> Date: Sun Nov 19 16:55:10 2017 +0100 new command: resetdanranking YYYY-MM-DD, sets every dan player to 1st dan with zero dan_points at the given date. commit0a45cf1fd8Author: Christian Berg <xeniac@posteo.at> Date: Sun Nov 19 16:14:59 2017 +0100 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:
91
src/content/migrations/0006_auto_20171115_0653.py
Normal file
91
src/content/migrations/0006_auto_20171115_0653.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.7 on 2017-11-15 05:53
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import ckeditor_uploader.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('content', '0005_auto_20161012_2236'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='article',
|
||||||
|
name='content_de',
|
||||||
|
field=ckeditor_uploader.fields.RichTextUploadingField(verbose_name='Inhalt'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='article',
|
||||||
|
name='content_en',
|
||||||
|
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True, verbose_name='Content'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='article',
|
||||||
|
name='headline_en',
|
||||||
|
field=models.CharField(blank=True, max_length=255, verbose_name='Headline'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='article',
|
||||||
|
name='image',
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to='news/', verbose_name='Bild'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='article',
|
||||||
|
name='slug',
|
||||||
|
field=models.SlugField(unique_for_month='date_created', verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='category',
|
||||||
|
name='image',
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to='news/categories/', verbose_name='Bild'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='content_de',
|
||||||
|
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True, verbose_name='Inhalt'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='content_en',
|
||||||
|
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True, verbose_name='Content'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='menu_name_de',
|
||||||
|
field=models.CharField(help_text='Ein kurzer Name für den Menüeintrag', max_length=255, verbose_name='Menü Name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='menu_name_en',
|
||||||
|
field=models.CharField(blank=True, help_text='Ein kurzer Name für den Menüeintrag', max_length=255, verbose_name='Menu Name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='pdf_de',
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to='pdf/de/'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='pdf_en',
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to='pdf/en/'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='template',
|
||||||
|
field=models.CharField(default='content/page.html', max_length=255, verbose_name='Vorlage'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='title_de',
|
||||||
|
field=models.CharField(help_text="The page title as you'd like it to be seen by the public", max_length=255, verbose_name='Titel'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='page',
|
||||||
|
name='title_en',
|
||||||
|
field=models.CharField(blank=True, help_text="The page title as you'd like it to be seen by the public", max_length=255, verbose_name='Title'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -31,6 +31,10 @@ class EventManager(models.Manager):
|
|||||||
"""Returns all past events."""
|
"""Returns all past events."""
|
||||||
return self.filter(start__lt=now())
|
return self.filter(start__lt=now())
|
||||||
|
|
||||||
|
def latest(self, limit=None):
|
||||||
|
result = self.filter(start__lt=now()).order_by('-start', '-end')
|
||||||
|
return result[0:limit] if limit else result
|
||||||
|
|
||||||
def upcoming(self, limit=None):
|
def upcoming(self, limit=None):
|
||||||
"""Returns the next 'limit' upcoming events.
|
"""Returns the next 'limit' upcoming events.
|
||||||
|
|
||||||
|
|||||||
20
src/events/migrations/0008_auto_20171115_0653.py
Normal file
20
src/events/migrations/0008_auto_20171115_0653.py
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,6 @@
|
|||||||
"""Mixins for Events."""
|
"""Mixins for Events."""
|
||||||
|
from django.http import Http404
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
@@ -9,7 +11,6 @@ class EventArchiveMixin(object):
|
|||||||
date_field = 'start'
|
date_field = 'start'
|
||||||
make_object_list = True
|
make_object_list = True
|
||||||
model = models.Event
|
model = models.Event
|
||||||
ordering = ('start', 'end')
|
|
||||||
paginate_by = 15
|
paginate_by = 15
|
||||||
template_name = 'events/event_archive.html'
|
template_name = 'events/event_archive.html'
|
||||||
|
|
||||||
@@ -40,3 +41,16 @@ class EventDetailMixin(object):
|
|||||||
elif hasattr(self, 'object') and hasattr(self.object, 'event'):
|
elif hasattr(self, 'object') and hasattr(self.object, 'event'):
|
||||||
context['event'] = self.object.event
|
context['event'] = self.object.event
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""set event attribute from the URL kwarg event and
|
||||||
|
load all related objects from the set model.
|
||||||
|
|
||||||
|
:return: a django QuerySets
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.event = models.Event.objects.get(pk=self.kwargs['event'])
|
||||||
|
queryset = self.model.objects.filter(event=self.event)
|
||||||
|
except models.Event.DoesNotExist:
|
||||||
|
raise Http404(_('Event does not exist'))
|
||||||
|
return queryset.prefetch_related()
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class DeleteEventPhoto(PermissionRequiredMixin, mixins.EventDetailMixin,
|
|||||||
class EventArchiveIndex(mixins.EventArchiveMixin, generic.ArchiveIndexView):
|
class EventArchiveIndex(mixins.EventArchiveMixin, generic.ArchiveIndexView):
|
||||||
"""Index of the event archive, displays the upcoming events first."""
|
"""Index of the event archive, displays the upcoming events first."""
|
||||||
allow_empty = True
|
allow_empty = True
|
||||||
ordering = ('-start', '-end')
|
|
||||||
|
|
||||||
|
|
||||||
class EventArchiveMonth(mixins.EventArchiveMixin, generic.MonthArchiveView):
|
class EventArchiveMonth(mixins.EventArchiveMixin, generic.MonthArchiveView):
|
||||||
@@ -73,7 +72,7 @@ class EventForm(PermissionRequiredMixin, mixins.EventDetailMixin,
|
|||||||
if self.kwargs.get('pk') else models.Event()
|
if self.kwargs.get('pk') else models.Event()
|
||||||
|
|
||||||
|
|
||||||
class EventGallery(mixins.EventDetailMixin, generic.ListView):
|
class EventGallery(generic.ListView):
|
||||||
"""Display a overview of all event photo albums."""
|
"""Display a overview of all event photo albums."""
|
||||||
template_name = 'events/photo_gallery.html'
|
template_name = 'events/photo_gallery.html'
|
||||||
queryset = models.Event.objects.filter(
|
queryset = models.Event.objects.filter(
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ KYU_RANKS = (
|
|||||||
|
|
||||||
DAN_ALLOW_DROP_DOWN = True
|
DAN_ALLOW_DROP_DOWN = True
|
||||||
MIN_HANCHANS_FOR_LADDER = 5
|
MIN_HANCHANS_FOR_LADDER = 5
|
||||||
|
RANKING_EXPORT_PATH = path.join(PROJECT_PATH, 'backup', 'mahjong_ranking')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .local_settings import * # Ignore PyLintBear (W0401, W0614)
|
from .local_settings import * # Ignore PyLintBear (W0401, W0614)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Export Mahjong Rankings as excel files."""
|
"""Export Mahjong Rankings as excel files."""
|
||||||
|
|
||||||
from datetime import date
|
import os
|
||||||
from operator import itemgetter
|
from datetime import date, time, datetime
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from openpyxl.styles import Border
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_date
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
|
|
||||||
from mahjong_ranking.models import SeasonRanking, KyuDanRanking
|
from mahjong_ranking.models import SeasonRanking, KyuDanRanking
|
||||||
|
|
||||||
@@ -34,7 +37,24 @@ 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'
|
||||||
|
|
||||||
|
MAIL_BODY = """
|
||||||
|
Hallo! Ich bin's dein Server.
|
||||||
|
|
||||||
|
Ich habe gerade die Mahjong Rankings als Excel exportiert und dachte mir das
|
||||||
|
ich sie dir am besten gleich schicke.
|
||||||
|
|
||||||
|
Bitte versuche nicht auf diese E-Mail zu antworten.
|
||||||
|
Ich bin nur ein dummes Programm.
|
||||||
|
|
||||||
|
mit lieben Grüßen
|
||||||
|
|
||||||
|
Der Kasu Server
|
||||||
|
"""
|
||||||
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
|
||||||
rankings.
|
rankings.
|
||||||
@@ -45,8 +65,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 +76,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 +113,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},
|
||||||
@@ -101,7 +124,7 @@ def export_season_rankings(workbook):
|
|||||||
'style': 'content',
|
'style': 'content',
|
||||||
'width': 25},
|
'width': 25},
|
||||||
{'col': 'C', 'title': '⌀ Platz', 'attr': 'avg_placement',
|
{'col': 'C', 'title': '⌀ Platz', 'attr': 'avg_placement',
|
||||||
'style': 'int', 'width': 8},
|
'style': 'float', 'width': 8},
|
||||||
{'col': 'D', 'title': '⌀ Punkte', 'attr': 'avg_score',
|
{'col': 'D', 'title': '⌀ Punkte', 'attr': 'avg_score',
|
||||||
'style': 'float', 'width': 12},
|
'style': 'float', 'width': 12},
|
||||||
{'col': 'E', 'title': 'Hanchans', 'attr': 'hanchan_count',
|
{'col': 'E', 'title': 'Hanchans', 'attr': 'hanchan_count',
|
||||||
@@ -111,7 +134,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 +141,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 +159,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,
|
||||||
@@ -147,12 +172,43 @@ def export_kyu_dan_rankings(workbook):
|
|||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""Exports the SeasonRankings"""
|
"""Exports the SeasonRankings"""
|
||||||
|
filename = str()
|
||||||
|
until = datetime
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--until', nargs='?', type=parse_date,
|
||||||
|
default=date.today(), metavar='YYYY-MM-DD',
|
||||||
|
help='Calculate and export rankings until the given date.')
|
||||||
|
parser.add_argument(
|
||||||
|
'--mail', nargs='*', type=str, metavar='user@example.com',
|
||||||
|
help='Send the spreadsheet via eMail to the given recipient.')
|
||||||
|
|
||||||
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."""
|
||||||
|
self.until = timezone.make_aware(datetime.combine(
|
||||||
|
options['until'], time(23, 59, 59)
|
||||||
|
))
|
||||||
|
|
||||||
|
self.filename = os.path.join(
|
||||||
|
settings.RANKING_EXPORT_PATH,
|
||||||
|
'mahjong_rankings_{:%Y-%m-%d}.xlsx'.format(self.until)
|
||||||
|
)
|
||||||
workbook = geneate_excel()
|
workbook = geneate_excel()
|
||||||
export_season_rankings(workbook)
|
export_season_rankings(workbook, until=self.until)
|
||||||
export_kyu_dan_rankings(workbook)
|
export_kyu_dan_rankings(workbook, until=self.until)
|
||||||
workbook.save('sample.x')
|
os.makedirs(settings.RANKING_EXPORT_PATH, exist_ok=True)
|
||||||
workbook.save('mahjong_rankings_{}.xlsx'.format(str(date.today())))
|
workbook.save(self.filename)
|
||||||
|
if options['mail']:
|
||||||
|
self.send_mail(options['mail'])
|
||||||
|
|
||||||
|
def send_mail(self, recipients):
|
||||||
|
mail = EmailMessage(
|
||||||
|
subject='Mahjong Rankings vom {:%d.%m.%Y}'.format(self.until),
|
||||||
|
body=MAIL_BODY,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=recipients)
|
||||||
|
mail.attach_file(self.filename)
|
||||||
|
mail.send()
|
||||||
|
|
||||||
|
|||||||
32
src/mahjong_ranking/management/commands/resetdanranking.py
Normal file
32
src/mahjong_ranking/management/commands/resetdanranking.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Rest all dan points to 0 at a given date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
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 = "reset every dan player to 1st dan with 0 points."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('reset_date', type=parse_date)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
reset_date = timezone.make_aware(datetime.combine(options.get('reset_date'), time(23, 59, 59)))
|
||||||
|
# models.KyuDanRanking.objects.update(until=reset_date, force_recalc=True)
|
||||||
|
dan_rankigns = models.KyuDanRanking.objects.filter(dan__isnull=False)
|
||||||
|
for ranking in dan_rankigns:
|
||||||
|
ranking.dan = 1
|
||||||
|
ranking.dan_points = 0
|
||||||
|
ranking.legacy_date = reset_date.date()
|
||||||
|
ranking.legacy_hanchan_count = ranking.hanchan_count
|
||||||
|
ranking.legacy_dan_points = ranking.dan_points
|
||||||
|
ranking.legacy_kyu_points = ranking.kyu_points
|
||||||
|
ranking.save()
|
||||||
|
|
||||||
|
|
||||||
@@ -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)}
|
|
||||||
)
|
|
||||||
@@ -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,23 +13,31 @@ class HanchanManager(models.Manager):
|
|||||||
"""
|
"""
|
||||||
use_for_related_fields = True
|
use_for_related_fields = True
|
||||||
|
|
||||||
def confirmed_hanchans(self, user=None, **filter_args):
|
def confirmed(self, user=None, since=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.
|
||||||
|
:param since: only return Hanchans played since the given datetime
|
||||||
|
:param until: only return Hanchans played until the given datetime
|
||||||
:param filter_args: To add specific arguments to the Django filter.
|
:param filter_args: To add specific arguments to the Django filter.
|
||||||
: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 since:
|
||||||
|
hanchans = hanchans.filter(start__gt=since)
|
||||||
|
if until:
|
||||||
|
hanchans = hanchans.filter(start__lte=until)
|
||||||
|
return hanchans
|
||||||
|
|
||||||
def dan_hanchans(self, user, **filter_args):
|
def dan_hanchans(self, user, since = None, **filter_args):
|
||||||
""" Return all Hanchans where a specific user has participated and had
|
""" Return all Hanchans where a specific user has participated and had
|
||||||
gain dan points and make his gamestats availabale.
|
gain dan points and make his gamestats availabale.
|
||||||
|
|
||||||
:param user: Only return Hanchans where this user participated.
|
:param user: Only return Hanchans where this user participated.
|
||||||
|
:param since: only return Hanchans played since the given datetime
|
||||||
:param filter_args: To add specific arguments to the Django filter.
|
:param filter_args: To add specific arguments to the Django filter.
|
||||||
:return: QuerySet Object
|
:return: QuerySet Object
|
||||||
"""
|
"""
|
||||||
@@ -38,15 +47,18 @@ class HanchanManager(models.Manager):
|
|||||||
models.Q(player3=user, player3_dan_points__isnull=False) |
|
models.Q(player3=user, player3_dan_points__isnull=False) |
|
||||||
models.Q(player4=user, player4_dan_points__isnull=False)
|
models.Q(player4=user, player4_dan_points__isnull=False)
|
||||||
).filter(confirmed=True, **filter_args)
|
).filter(confirmed=True, **filter_args)
|
||||||
|
if since:
|
||||||
|
queryset = queryset.filter(start__gt=since)
|
||||||
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
|
||||||
|
|
||||||
def kyu_hanchans(self, user, **filter_args):
|
def kyu_hanchans(self, user, since = None, **filter_args):
|
||||||
""" Return all Hanchans where a specific user has participated and had
|
""" Return all Hanchans where a specific user has participated and had
|
||||||
gain kyū points and make his gamestats availabale.
|
gain kyū points and make his gamestats availabale.
|
||||||
|
|
||||||
:param user: Only return Hanchans where this user participated.
|
:param user: Only return Hanchans where this user participated.
|
||||||
|
:param since: only return Hanchans played since the given datetime
|
||||||
:param filter_args: To add specific arguments to the Django filter.
|
:param filter_args: To add specific arguments to the Django filter.
|
||||||
:return: QuerySet Object
|
:return: QuerySet Object
|
||||||
"""
|
"""
|
||||||
@@ -56,25 +68,30 @@ class HanchanManager(models.Manager):
|
|||||||
models.Q(player3=user, player3_kyu_points__isnull=False) |
|
models.Q(player3=user, player3_kyu_points__isnull=False) |
|
||||||
models.Q(player4=user, player4_kyu_points__isnull=False)
|
models.Q(player4=user, player4_kyu_points__isnull=False)
|
||||||
).filter(confirmed=True, **filter_args)
|
).filter(confirmed=True, **filter_args)
|
||||||
|
if since:
|
||||||
|
queryset = queryset.filter(start__gt=since)
|
||||||
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
|
||||||
|
|
||||||
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(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.
|
||||||
:param since: optional a date value since when you want to hanchans
|
:param since: only return Hanchans played since the given datetime
|
||||||
:param filter_args: To add specific arguments to the Django filter.
|
:param filter_args: To add specific arguments to the Django filter.
|
||||||
:return: a QuerySet Object
|
:return: a QuerySet Object
|
||||||
"""
|
"""
|
||||||
@@ -82,15 +99,16 @@ 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)
|
||||||
)
|
)
|
||||||
|
queryset = queryset.filter(**filter_args)
|
||||||
if since:
|
if since:
|
||||||
queryset = queryset.filter(start__gte=since, **filter_args)
|
queryset = queryset.filter(start__gte=since)
|
||||||
else:
|
if until:
|
||||||
queryset = queryset.filter(**filter_args)
|
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
|
||||||
|
|
||||||
def unconfirmed_hanchans(self, user=None, **filter_args):
|
def unconfirmed(self, user=None, **filter_args):
|
||||||
""" Return all Hanchans that have been set to unconfirmed.
|
""" Return all Hanchans that have been set to unconfirmed.
|
||||||
|
|
||||||
:param user: Only return Hanchans where this user participated.
|
:param user: Only return Hanchans where this user participated.
|
||||||
@@ -158,8 +176,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), start=1):
|
||||||
|
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 +209,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 +223,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 or 0, 'new': getattr(ranking, attr) or 0}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Middleware to defer slow denormalization at the end of a request."""
|
"""Middleware to defer slow denormalization at the end of a request."""
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from mahjong_ranking import models
|
from mahjong_ranking import models
|
||||||
from . import LOGGER, MIN_HANCHANS_FOR_LADDER
|
from . import LOGGER
|
||||||
|
|
||||||
|
|
||||||
class DenormalizationUpdateMiddleware(object): # Ignore PyLintBear (R0903)
|
class DenormalizationUpdateMiddleware(object): # Ignore PyLintBear (R0903)
|
||||||
@@ -35,7 +34,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 +57,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
|
||||||
|
|||||||
29
src/mahjong_ranking/migrations/0005_auto_20171115_0653.py
Normal file
29
src/mahjong_ranking/migrations/0005_auto_20171115_0653.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/mahjong_ranking/mixins.py
Normal file
18
src/mahjong_ranking/mixins.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from datetime import date
|
||||||
|
from .models import Hanchan, SeasonRanking
|
||||||
|
from events.models import Event
|
||||||
|
|
||||||
|
|
||||||
|
class MahjongMixin(object):
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(MahjongMixin, self).get_context_data(**kwargs)
|
||||||
|
try:
|
||||||
|
context['season'] = self.season
|
||||||
|
context['season_start'] = date(year=self.season, month=1, day=1)
|
||||||
|
context['season_end'] = date(year=self.season, month=12, day=31)
|
||||||
|
context['season_list'] = SeasonRanking.objects.season_list
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
context['latest_hanchan_list'] = Hanchan.objects.confirmed()[:3]
|
||||||
|
context['latest_event_list'] = Event.objects.latest(limit=3)
|
||||||
|
return context
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -56,7 +58,7 @@ class EventRanking(models.Model):
|
|||||||
)
|
)
|
||||||
sum_placement = 0.0
|
sum_placement = 0.0
|
||||||
sum_score = 0.0
|
sum_score = 0.0
|
||||||
event_hanchans = Hanchan.objects.confirmed_hanchans(
|
event_hanchans = Hanchan.objects.confirmed(
|
||||||
user=self.user_id,
|
user=self.user_id,
|
||||||
event=self.event_id
|
event=self.event_id
|
||||||
)
|
)
|
||||||
@@ -345,10 +347,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,36 +438,48 @@ 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.
|
||||||
"""
|
"""
|
||||||
self.dan = None
|
valid_hanchans = Hanchan.objects.confirmed(user=self.user)
|
||||||
self.dan_points = self.legacy_dan_points or 0
|
valid_hanchans = valid_hanchans.order_by('start')
|
||||||
self.kyu = None
|
if since and self.last_hanchan_date and since < self.last_hanchan_date:
|
||||||
self.kyu_points = self.legacy_kyu_points or 0
|
force_recalc = True
|
||||||
self.hanchan_count = self.legacy_hanchan_count or 0
|
if until and self.last_hanchan_date and until < self.last_hanchan_date:
|
||||||
self.good_hanchans = 0
|
force_recalc = True
|
||||||
self.won_hanchans = 0
|
if force_recalc:
|
||||||
self.update_rank()
|
# 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
|
||||||
|
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(
|
LOGGER.info(
|
||||||
"recalculating Kyu/Dan points for %s since %s...",
|
"recalculating Kyu/Dan points for %(user)s since %(since)s...",
|
||||||
self.user, str(hanchan_start)
|
{'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__date__gte=self.legacy_date)
|
|
||||||
|
|
||||||
""" TODO: Hanchan Punkte nur neu berechnen wenn sie nach hachan_start
|
|
||||||
lagen. Es müssen aber alle durch die Schleife rennen, damit die Punkte
|
|
||||||
richtig gezählt werden."""
|
|
||||||
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 hanchan.start < since:
|
||||||
|
print(hanchan, "<", since, "no recalc")
|
||||||
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()
|
||||||
@@ -480,20 +494,22 @@ class KyuDanRanking(models.Model):
|
|||||||
self.update_rank()
|
self.update_rank()
|
||||||
hanchan.update_playerdata(self.user)
|
hanchan.update_playerdata(self.user)
|
||||||
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
|
||||||
LOGGER.debug(
|
self.last_hanchan_date = hanchan.start
|
||||||
'id: %(id)d, start: %(start)s, placement: %(placement)d, '
|
LOGGER.debug(
|
||||||
'score: %(score)d, kyu points: %(kyu_points)d, dan points: '
|
'id: %(id)d, start: %(start)s, placement: %(placement)d, '
|
||||||
'%(dan_points)d, bonus points: %(bonus_points)d',
|
'score: %(score)d, kyu points: %(kyu_points)d, dan points: '
|
||||||
{'id': hanchan.pk, 'start': hanchan.start,
|
'%(dan_points)d, bonus points: %(bonus_points)d',
|
||||||
'placement': hanchan.placement, 'score': hanchan.game_score,
|
{'id': hanchan.pk, 'start': hanchan.start,
|
||||||
'kyu_points': hanchan.kyu_points or 0,
|
'placement': hanchan.placement, 'score': hanchan.game_score,
|
||||||
'dan_points': hanchan.dan_points or 0,
|
'kyu_points': hanchan.kyu_points or 0,
|
||||||
'bonus_points': hanchan.bonus_points or 0}
|
'dan_points': hanchan.dan_points or 0,
|
||||||
)
|
'bonus_points': hanchan.bonus_points or 0}
|
||||||
|
)
|
||||||
self.save(force_update=True)
|
self.save(force_update=True)
|
||||||
|
|
||||||
|
|
||||||
def update_hanchan_points(self, hanchan):
|
def update_hanchan_points(self, hanchan):
|
||||||
"""
|
"""
|
||||||
Berechne die Kyu bzw. Dan Punkte für eine Hanchan neu.
|
Berechne die Kyu bzw. Dan Punkte für eine Hanchan neu.
|
||||||
@@ -547,6 +563,7 @@ class KyuDanRanking(models.Model):
|
|||||||
hanchan.kyu_points -= (self.kyu_points + hanchan.kyu_points)
|
hanchan.kyu_points -= (self.kyu_points + hanchan.kyu_points)
|
||||||
self.kyu_points += hanchan.kyu_points
|
self.kyu_points += hanchan.kyu_points
|
||||||
|
|
||||||
|
|
||||||
# TODO: Merkwürdige Methode die zwar funktioniert aber nicht sehr
|
# TODO: Merkwürdige Methode die zwar funktioniert aber nicht sehr
|
||||||
# aussagekräfig ist. Überarbeiten?
|
# aussagekräfig ist. Überarbeiten?
|
||||||
def update_rank(self):
|
def update_rank(self):
|
||||||
@@ -593,9 +610,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
|
||||||
|
|||||||
@@ -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],
|
||||||
@@ -54,7 +54,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}
|
||||||
confirmed_hanchans = Hanchan.objects.confirmed_hanchans(
|
confirmed_hanchans = Hanchan.objects.confirmed(
|
||||||
user=ranking.user,
|
user=ranking.user,
|
||||||
since=ranking.legacy_date
|
since=ranking.legacy_date
|
||||||
)
|
)
|
||||||
@@ -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],
|
||||||
@@ -86,7 +86,7 @@ class KyuDanTest(TestCase):
|
|||||||
'dan_points': ranking.legacy_dan_points or 0,
|
'dan_points': ranking.legacy_dan_points or 0,
|
||||||
'kyu_points': ranking.legacy_kyu_points or 0
|
'kyu_points': ranking.legacy_kyu_points or 0
|
||||||
}
|
}
|
||||||
confirmed_hanchans = Hanchan.objects.confirmed_hanchans(
|
confirmed_hanchans = Hanchan.objects.confirmed(
|
||||||
user=ranking.user,
|
user=ranking.user,
|
||||||
since=ranking.legacy_date
|
since=ranking.legacy_date
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ from django.core.urlresolvers import reverse
|
|||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.views import generic
|
from django.views import generic
|
||||||
|
|
||||||
from events.models import Event
|
|
||||||
from events.mixins import EventDetailMixin
|
from events.mixins import EventDetailMixin
|
||||||
from . import forms, models
|
from . import forms, models
|
||||||
|
from .mixins import MahjongMixin
|
||||||
|
|
||||||
kyu_dan_order = {
|
KYU_DAN_ORDER = { # map sort URL args to Django ORM order_by args
|
||||||
'+full_name': ('user__last_name', 'user__first_name'),
|
'+full_name': ('user__last_name', 'user__first_name'),
|
||||||
'-full_name': ('-user__last_name', '-user__first_name'),
|
'-full_name': ('-user__last_name', '-user__first_name'),
|
||||||
'+hanchan_count': ('hanchan_count',),
|
'+hanchan_count': ('hanchan_count',),
|
||||||
@@ -31,16 +31,17 @@ kyu_dan_order = {
|
|||||||
|
|
||||||
class DeleteHanchan(EventDetailMixin, PermissionRequiredMixin,
|
class DeleteHanchan(EventDetailMixin, PermissionRequiredMixin,
|
||||||
generic.DeleteView):
|
generic.DeleteView):
|
||||||
"""
|
"""Deletes a Hanchan if confimration has been answerd with 'yes'."""
|
||||||
Fragt zuerst nach, ob die Hanchan wirklich gelöscht werden soll.
|
|
||||||
Wir die Frage mit "Ja" beantwortet, wird die die Hanchan gelöscht.
|
|
||||||
"""
|
|
||||||
form_class = forms.HanchanForm
|
form_class = forms.HanchanForm
|
||||||
model = models.Hanchan
|
model = models.Hanchan
|
||||||
permission_required = 'mahjong_ranking.delete_hanchan'
|
permission_required = 'mahjong_ranking.delete_hanchan'
|
||||||
pk_url_kwarg = 'hanchan'
|
pk_url_kwarg = 'hanchan'
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
|
"""
|
||||||
|
Return to the HachanList of the event form the deleted hanchan.
|
||||||
|
:return: URL of the EventHanchanList for the event
|
||||||
|
"""
|
||||||
return reverse('event-hanchan-list',
|
return reverse('event-hanchan-list',
|
||||||
kwargs={'event': self.object.event.pk})
|
kwargs={'event': self.object.event.pk})
|
||||||
|
|
||||||
@@ -48,32 +49,30 @@ class DeleteHanchan(EventDetailMixin, PermissionRequiredMixin,
|
|||||||
class HanchanForm(SuccessMessageMixin, EventDetailMixin,
|
class HanchanForm(SuccessMessageMixin, EventDetailMixin,
|
||||||
PermissionRequiredMixin, generic.UpdateView):
|
PermissionRequiredMixin, generic.UpdateView):
|
||||||
"""
|
"""
|
||||||
Ein Formular um neue Hanchans anzulegen, bzw. eine bestehende zu
|
A Form to add a new or edit an existing Hanchan.
|
||||||
bearbeitsen
|
|
||||||
"""
|
"""
|
||||||
form_class = forms.HanchanForm
|
form_class = forms.HanchanForm
|
||||||
model = models.Hanchan
|
model = models.Hanchan
|
||||||
permission_required = 'mahjong_ranking.add_hanchan'
|
permission_required = 'mahjong_ranking.add_hanchan'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = generic.UpdateView.get_context_data(self, **kwargs)
|
|
||||||
context['event'] = self.event
|
|
||||||
return context
|
|
||||||
|
|
||||||
def get_form_class(self):
|
def get_form_class(self):
|
||||||
"""
|
"""
|
||||||
Users with edit Persmission will see the AdminForm to confirm
|
Users with hanchan edit persmission can also un-/confirm hanchans.
|
||||||
unconfirmed Hanchans.
|
:return: forms.HanchanForm, or forms.HanchanAdminForm
|
||||||
"""
|
"""
|
||||||
if self.request.user.has_perm('mahjong_ranking.change_hanchan'):
|
return forms.HanchanAdminForm if self.request.user.has_perm(
|
||||||
return forms.HanchanAdminForm
|
'mahjong_ranking.change_hanchan') else forms.HanchanForm
|
||||||
else:
|
|
||||||
return forms.HanchanForm
|
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
|
"""
|
||||||
|
load the hanchan form the db, or create a new one with the event set.
|
||||||
|
Also sets the event attribute.
|
||||||
|
:param queryset:
|
||||||
|
:return: models.Hanchan object
|
||||||
|
"""
|
||||||
if self.kwargs.get('hanchan') and self.request.user.has_perm(
|
if self.kwargs.get('hanchan') and self.request.user.has_perm(
|
||||||
'mahjong_ranking.change_hanchan'):
|
'mahjong_ranking.change_hanchan'):
|
||||||
hanchan = models.Hanchan.objects.get(id=self.kwargs['hanchan'])
|
hanchan = self.model.objects.get(id=self.kwargs['hanchan'])
|
||||||
self.event = hanchan.event
|
self.event = hanchan.event
|
||||||
elif self.kwargs.get('event'):
|
elif self.kwargs.get('event'):
|
||||||
self.event = models.Event.objects.get(id=self.kwargs['event'])
|
self.event = models.Event.objects.get(id=self.kwargs['event'])
|
||||||
@@ -94,6 +93,11 @@ class HanchanForm(SuccessMessageMixin, EventDetailMixin,
|
|||||||
return reverse('add-hanchan-form', kwargs={'event': self.event.pk})
|
return reverse('add-hanchan-form', kwargs={'event': self.event.pk})
|
||||||
|
|
||||||
def get_success_message(self, cleaned_data):
|
def get_success_message(self, cleaned_data):
|
||||||
|
"""
|
||||||
|
Get the right sucsess message for the django notification subsystem.
|
||||||
|
:param cleaned_data:
|
||||||
|
:return: Sucsess message
|
||||||
|
"""
|
||||||
if self.kwargs.get('hanchan'):
|
if self.kwargs.get('hanchan'):
|
||||||
return _('%s has been updated successfully.') % self.object
|
return _('%s has been updated successfully.') % self.object
|
||||||
else:
|
else:
|
||||||
@@ -103,72 +107,36 @@ class HanchanForm(SuccessMessageMixin, EventDetailMixin,
|
|||||||
|
|
||||||
|
|
||||||
class EventHanchanList(EventDetailMixin, generic.ListView):
|
class EventHanchanList(EventDetailMixin, generic.ListView):
|
||||||
"""
|
"List all hanchans played on a given event."
|
||||||
Auflistung aller Hanchan die während der Veranstaltung gespielt wurden.
|
|
||||||
"""
|
|
||||||
model = models.Hanchan
|
model = models.Hanchan
|
||||||
template_name = 'mahjong_ranking/eventhanchan_list.html'
|
template_name = 'mahjong_ranking/eventhanchan_list.html'
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
try:
|
|
||||||
self.event = models.Event.objects.get(pk=self.kwargs['event'])
|
|
||||||
queryset = models.Hanchan.objects.filter(event=self.event)
|
|
||||||
queryset = queryset.order_by('start')
|
|
||||||
return queryset
|
|
||||||
except models.Event.DoesNotExist:
|
|
||||||
raise django.http.Http404(_('Event does not exist'))
|
|
||||||
|
|
||||||
|
|
||||||
class EventRankingList(EventDetailMixin, generic.ListView):
|
class EventRankingList(EventDetailMixin, generic.ListView):
|
||||||
"""
|
"""Display the event ranking for the given event."""
|
||||||
Anzeige des Eventrankings, daß erstellt wurde falls der Termin als internes
|
|
||||||
Turnier markiert wurde.
|
|
||||||
"""
|
|
||||||
model = models.EventRanking
|
model = models.EventRanking
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
try:
|
|
||||||
self.event = models.Event.objects.get(pk=self.kwargs['event'])
|
|
||||||
queryset = models.EventRanking.objects.filter(event=self.event)
|
|
||||||
return queryset.prefetch_related()
|
|
||||||
except models.Event.DoesNotExist:
|
|
||||||
raise django.http.Http404(_('Event does not exist'))
|
|
||||||
|
|
||||||
|
|
||||||
class MahjongMixin(object):
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super(MahjongMixin, self).get_context_data(**kwargs)
|
|
||||||
try:
|
|
||||||
context['season'] = self.season
|
|
||||||
context['season_start'] = date(year=self.season, month=1, day=1)
|
|
||||||
context['season_end'] = date(year=self.season, month=12, day=31)
|
|
||||||
context['season_list'] = models.SeasonRanking.objects.season_list
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
context[
|
|
||||||
'latest_hanchan_list'] = \
|
|
||||||
models.Hanchan.objects.confirmed_hanchans()[
|
|
||||||
:3]
|
|
||||||
context['latest_event_list'] = Event.objects.upcoming(limit=3)
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
class KyuDanRankingList(MahjongMixin, generic.ListView):
|
class KyuDanRankingList(MahjongMixin, generic.ListView):
|
||||||
"""
|
"""List all Players with an Kyu or Dan score. """
|
||||||
Anzeige aller Spiele mit ihrem Kyu bzw Dan Grad.
|
|
||||||
"""
|
|
||||||
default_order = '-score'
|
default_order = '-score'
|
||||||
order_by = ''
|
order_by = ''
|
||||||
paginate_by = 25
|
paginate_by = 25
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
self.order_by = kyu_dan_order[
|
"""Set the order_by settings, revert to default_order if necessary."""
|
||||||
|
self.order_by = KYU_DAN_ORDER[
|
||||||
kwargs.get('order_by', self.default_order)
|
kwargs.get('order_by', self.default_order)
|
||||||
]
|
]
|
||||||
return generic.ListView.dispatch(self, request, *args, **kwargs)
|
return super(KyuDanRankingList, self).dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = models.KyuDanRanking.objects.all().order_by(*self.order_by)
|
queryset = models.KyuDanRanking.objects.filter(
|
||||||
|
hanchan_count__gt=0).order_by(*self.order_by)
|
||||||
return queryset.select_related()
|
return queryset.select_related()
|
||||||
|
|
||||||
|
|
||||||
@@ -223,14 +191,16 @@ class PlayerDanScore(PlayerScore):
|
|||||||
template_name = 'mahjong_ranking/player_dan_score.html'
|
template_name = 'mahjong_ranking/player_dan_score.html'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return models.Hanchan.objects.dan_hanchans(user=self.user)
|
kyu_dan_ranking = models.KyuDanRanking.objects.get(user=self.user)
|
||||||
|
return models.Hanchan.objects.dan_hanchans(user=self.user,
|
||||||
|
since=kyu_dan_ranking.legacy_date)
|
||||||
|
|
||||||
|
|
||||||
class PlayerInvalidScore(PlayerScore):
|
class PlayerInvalidScore(PlayerScore):
|
||||||
template_name = 'mahjong_ranking/player_invalid_score.html'
|
template_name = 'mahjong_ranking/player_invalid_score.html'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return models.Hanchan.objects.unconfirmed_hanchans(user=self.user)
|
return models.Hanchan.objects.unconfirmed(user=self.user)
|
||||||
|
|
||||||
|
|
||||||
class PlayerKyuScore(PlayerScore):
|
class PlayerKyuScore(PlayerScore):
|
||||||
|
|||||||
19
src/maistar_ranking/migrations/0006_auto_20171115_0653.py
Normal file
19
src/maistar_ranking/migrations/0006_auto_20171115_0653.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# -*- 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
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('maistar_ranking', '0005_auto_20170218_1947'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='game',
|
||||||
|
options={'ordering': ('-event__start', '-id')},
|
||||||
|
),
|
||||||
|
]
|
||||||
26
src/membership/migrations/0007_auto_20171115_0653.py
Normal file
26
src/membership/migrations/0007_auto_20171115_0653.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.7 on 2017-11-15 05:53
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('membership', '0006_auto_20160916_1759'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='membership',
|
||||||
|
name='gender',
|
||||||
|
field=models.CharField(blank=True, choices=[('m', 'Male'), ('f', 'Female')], max_length=1, null=True, verbose_name='Geschlecht'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='membership',
|
||||||
|
name='username',
|
||||||
|
field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'),
|
||||||
|
),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user