From b1a747a094a36c5bbbea2ba67e94f18a283fef81 Mon Sep 17 00:00:00 2001 From: Aleksey Ropan Date: Mon, 5 Feb 2024 01:07:24 +0000 Subject: [PATCH] Configure docker compose db service karelia parse_full_list get_division_problems fix PgStatTuple atcoder account renaming fast check linked accounts --- configure.py | 6 +- docker-compose.yml | 10 +- .../module/karelia.snarknews.info/index.php | 95 +++++++++++++----- src/clist/templatetags/extras.py | 25 ++++- src/clist/views.py | 6 +- src/logify/admin.py | 10 +- src/logify/apps.py | 8 ++ .../management/commands/update_pgstattuple.py | 42 ++++++++ .../0006_pgstattuple_alter_eventlog_status.py | 39 +++++++ ...e_app_name_alter_pgstattuple_table_name.py | 23 +++++ src/logify/models.py | 18 ++++ src/notification/models.py | 2 +- src/pyclist/asgi.py | 42 +++++--- src/pyclist/decorators.py | 25 +++++ src/pyclist/settings.py | 1 + .../commands/parse_accounts_infos.py | 6 ++ .../management/commands/parse_statistic.py | 10 +- .../management/commands/set_account_rank.py | 41 ++++---- src/ranking/management/modules/atcoder.py | 30 +++++- src/ranking/management/modules/codechef.py | 20 ++-- src/ranking/management/modules/opencup.py | 18 ++-- src/ranking/management/modules/tlx_toki.py | 12 ++- src/ranking/utils.py | 34 ++++++- src/ranking/views.py | 46 ++++----- src/static/img/resources/huawei_com.png | Bin 0 -> 7698 bytes src/static/js/accounts.js | 5 + src/static/js/base.js | 26 ++++- src/templates/accounts.html | 2 +- src/templates/accounts_filters.html | 4 +- src/templates/accounts_paging.html | 4 +- src/templates/coder_filter.html | 3 + src/templates/field_to_select.html | 2 +- src/templates/profile_contests_paging.html | 14 ++- src/templates/resource.html | 2 +- src/templates/resources.html | 2 +- src/true_coders/admin.py | 4 +- src/true_coders/views.py | 23 +++-- src/utils/db.py | 24 +++++ src/utils/requester/__init__.py | 3 + 39 files changed, 545 insertions(+), 142 deletions(-) create mode 100644 src/logify/management/commands/update_pgstattuple.py create mode 100644 src/logify/migrations/0006_pgstattuple_alter_eventlog_status.py create mode 100644 src/logify/migrations/0007_pgstattuple_app_name_alter_pgstattuple_table_name.py create mode 100644 src/static/img/resources/huawei_com.png create mode 100644 src/utils/db.py diff --git a/configure.py b/configure.py index e259b70e..a25cd8a0 100755 --- a/configure.py +++ b/configure.py @@ -8,7 +8,7 @@ import subprocess -def generete_password(length=40): +def random_string(length=40): return ''.join(random.choices(list(string.ascii_letters + string.digits), k=length)) @@ -25,7 +25,7 @@ def create_logger(): def enter_value(variable, old_value): if not old_value: logger.info(f'Generated new value for {variable} default') - old_value = generete_password() + old_value = random_string() if old_value == '{empty}': old_value = '' value = input(f'Enter {variable} [default "{old_value}"]: ') @@ -96,7 +96,7 @@ def main(): run_command('docker compose run dev ./manage.py migrate') username = enter_value('username', os.getlogin()) - password = enter_value('password', generete_password(10)) + password = enter_value('password', random_string(10)) email = enter_value('email', 'admin@localhost') run_command(f''' docker compose run dev ./manage.py createadmin diff --git a/docker-compose.yml b/docker-compose.yml index dde404dc..11a0e15b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,9 +74,13 @@ services: command: > postgres -c max_connections=50 - -c checkpoint_timeout=30min + -c checkpoint_timeout=60min -c track_activity_query_size=4096 - shm_size: 2gb + -c shared_buffers=2GB + -c effective_cache_size=4GB + -c work_mem=80MB + -c maintenance_work_mem=1GB + shm_size: 7gb restart: always nginx: build: @@ -93,7 +97,7 @@ services: ports: - 80:80 - 443:443 - command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + command: "/bin/sh -c 'while :; do logrotate /etc/logrotate.conf; sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" restart: always certbot: image: certbot/certbot:latest diff --git a/legacy/module/karelia.snarknews.info/index.php b/legacy/module/karelia.snarknews.info/index.php index 29e95f16..1025779e 100755 --- a/legacy/module/karelia.snarknews.info/index.php +++ b/legacy/module/karelia.snarknews.info/index.php @@ -7,7 +7,9 @@ } $base_url = $match['url']; - for ($iter = 0; ; ++$iter) { + $base_url = str_replace('&', '&', $base_url); + $n_skipped = 0; + for ($iter = 0;; ++$iter) { if ($iter) { if (!isset($_GET['parse_full_list'])) { break; @@ -17,7 +19,7 @@ function ($match) { return ($match[2] == 'w'? $match[1] - 1 . 's' : $match[1] . 'w'); }, - $url, + $base_url, ); } @@ -29,7 +31,9 @@ function ($match) { break; } - $schedule_url = "http://camp.icpc.petrsu.ru/{$query['sbname']}/schedule"; + $camp = $query['sbname']; + + $schedule_url = "http://camp.icpc.petrsu.ru/{$camp}/schedule"; if (DEBUG) { echo "schedule url = $schedule_url\n"; } @@ -37,14 +41,14 @@ function ($match) { $page = curlexec($schedule_url); preg_match_all('#(?P[^<]*)([^<]*)*\s*(?:]*>)?(?P[0-9]+:[0-9]+)([-–\s]*(?P[0-9]+:[0-9]+))?[-–\s]*(?:]*>)?[^<]*(?:Contest|[A-Za-z\s]*\sRound|[A-Za-z\s]*\scontest)\s*(?:[0-9]+\s*[<(]|[^<]*)#s', $page, $schedule, PREG_SET_ORDER); - if (!count($schedule) || !preg_match('/[a-z]*[0-9]{4}[a-z]*/', $query['sbname'], $year)) { + if (!preg_match('/[a-z]*(?P[0-9]{4})[a-z]*/', $camp, $year)) { if ($iter) { break; } continue; } - $year = $year[0]; + $year = $year['year']; foreach ($schedule as &$s) { $s['date'] = preg_replace('/\s*day\s*[0-9]+|\([^\)]*\)|,/', '', $s['date']); @@ -54,35 +58,48 @@ function ($match) { unset($s); if (parse_url($url, PHP_URL_HOST) == "") { - $url = 'http://' . parse_url($URL, PHP_URL_HOST) . "/" . $url; + $url = url_merge($URL, $url); } - $page = curlexec($url); + $camp_url = $url; + $page = curlexec($camp_url); $page = str_replace(' ', ' ', $page); - preg_match_all('#]*href="(?P[^"]*)"[^>]*>(?:\s*<[^/>]*(?:title="(?P[^"]*)">)?)*\s*Day\s*(?P<day>0[0-9]+)\s*<#s', $page, $matches); + preg_match_all('#<a[^>]*href="(?P<url>[^"]*)"[^>]*>(?:\s*<[^/>]*(?:title="(?P<title>[^"]*)">)?)*\s*Day\s*(?P<day>0[0-9]+)\s*<#s', $page, $matches, PREG_SET_ORDER); - unset($prev_date); + if (empty($schedule) && empty($matches)) { + $n_skipped += 1; + if ($n_skipped > 3) { + break; + } else { + continue; + } + } + $n_skipped = 0; $days = array(); - foreach ($matches[0] as $i => $value) + $HOUR = 60 * 60; + $DAY = 24 * $HOUR; + foreach ($matches as $i => $values) { - $url = $matches['url'][$i]; + $url = $values['url']; + $url = str_replace('&', '&', $url); $page = curlexec($url); + $data = array(); $data['url'] = $url; - if (isset($matches['title'][$i])) { - $data['title'] = $matches['title'][$i]; + if (!empty($values['title'])) { + $data['title'] = $values['title']; } - if (preg_match('#<h2>(?P<title>[^,]*)(?:, (?P<date>[0-9]+\s+[^<]*))?</h2>#', $page, $match)) { - if (!isset($data['title'])) { + if (preg_match('#<h[23]>(?P<title>[^,]+)(?:,\s*(?P<date>[^<]*\b[0-9]+\b[^<]*))?</h[23]>#', $page, $match)) { + if (!isset($data['title']) || preg_match('#Contest\s*[0-9]+#', $data['title'])) { $data['title'] = $match['title']; } if (isset($match['date'])) { - $data['date'] = preg_replace('#^.*,\s*([^,]*,[^,]*)$#', '\1', $match['date']); + $date = preg_replace('#^.*,\s*([^,]*,[^,]*)$#', '\1', $match['date']); + if (strtotime($date) !== false) { + $data['date'] = $date; + } } } - if (!isset($data['title'])) { - continue; - } if ($i < count($schedule)) { $s = $schedule[$i]; @@ -93,9 +110,18 @@ function ($match) { if (isset($s['end_time'])) { $data['end_time'] = $s['end_time']; } + } else { + $data['start_time'] = '10:00'; + if (empty($schedule) && !isset($data['date'])) { + $season = substr($camp, -1); + if ($season == 'w') { + $data['date'] = strftime('%B %d, %Y', strtotime("$year-02-27") + ($i - count($matches) + 1) * $DAY); + } else if ($season == 's') { + $data['date'] = strftime('%B %d, %Y', strtotime("$year-08-30") + ($i - count($matches) + 1) * $DAY); + } + } } - - $days[intval($matches['day'][$i])] = $data; + $days[intval($values['day'])] = $data; } foreach ($days as $day => $data) { if (!isset($data['date']) && isset($days[$day - 1]) && isset($days[$day - 1]['date'])) { @@ -108,6 +134,8 @@ function ($match) { } } + $camp_start_time = null; + $camp_end_time = null; foreach ($days as $day => $data) { if (!isset($data['date'])) { continue; @@ -124,8 +152,16 @@ function ($match) { echo $title . ' | ' . $date . "\n"; } + $key = $camp . '-day-' . $day; + + $start_time = isset($data['start_time'])? $date . ' ' . $data['start_time'] : $date; + if (empty($camp_start_time)) { + $camp_start_time = $start_time; + } + $camp_end_time = strtotime($start_time) + 2 * $DAY; + $contests[] = array( - 'start_time' => isset($data['start_time'])? $date . ' ' . $data['start_time'] : $date, + 'start_time' => $start_time, 'end_time' => isset($data['end_time'])? $date . ' ' . $data['end_time'] : '', 'duration' => isset($data['end_time'])? '' : (isset($data['start_time'])? '05:00' : '00:00'), 'title' => $title, @@ -134,8 +170,21 @@ function ($match) { 'host' => $HOST, 'rid' => $RID, 'timezone' => $TIMEZONE, - 'key' => $date, + 'key' => $key, ); } + + $contests[] = array( + 'start_time' => $camp_start_time, + 'end_time' => $camp_end_time, + 'title' => "Petrozavodsk Programming Camp $camp", + 'url' => $camp_url, + 'standings_url' => $camp_url, + 'host' => $HOST, + 'rid' => $RID, + 'timezone' => $TIMEZONE, + 'key' => $camp, + 'info' => array('series' => 'ptzcamp'), + ); } ?> diff --git a/src/clist/templatetags/extras.py b/src/clist/templatetags/extras.py index a981be09..5ebef03e 100644 --- a/src/clist/templatetags/extras.py +++ b/src/clist/templatetags/extras.py @@ -306,18 +306,38 @@ def slug(value): return slugify(unidecode(value)).strip('-') +def get_standings_divisions_order(contest): + problems = contest.info.get('problems', {}) + if 'division' in problems: + divisions_order = list(problems.get('divisions_order', sorted(contest.info['problems']['division'].keys()))) + elif 'divisions_order' in contest.info: + divisions_order = contest.info['divisions_order'] + else: + divisions_order = [] + return divisions_order + + @register.filter -def get_division_problems(problems, info): +def get_division_problems(contest, info): + problems = contest.info.get('problems', []) ret = [] + seen_keys = set() if 'division' in problems: division_addition = info.get('_division_addition') divisions = list(division_addition.keys()) if division_addition else [] division = info.get('division') if division and division not in divisions: divisions = [division] + divisions + for division in get_standings_divisions_order(contest): + if division not in divisions: + divisions.append(division) for division in divisions: if division in problems['division']: for problem in problems['division'][division]: + problem_key = get_problem_key(problem) + if problem_key in seen_keys: + continue + seen_keys.add(problem_key) ret.append(problem) return ret or problems @@ -387,8 +407,7 @@ def get_problem_solution(problem): ret = {} for contest in problem.contests.all(): for statistic in contest.statistics_set.all(): - problems = contest.info.get('problems', []) - problems = get_division_problems(problems, statistic.addition) + problems = get_division_problems(contest, statistic.addition) group_scores = defaultdict(int) for p in problems: diff --git a/src/clist/views.py b/src/clist/views.py index 044b3a94..b9c3821c 100644 --- a/src/clist/views.py +++ b/src/clist/views.py @@ -945,7 +945,7 @@ def problems(request, template='problems.html'): 'key': {'fields': ['key__iexact']}, 'contest': {'fields': ['contest__title__iregex'], 'exists': 'contests'}, 'resource': {'fields': ['resource__host__iregex']}, - 'tag': {'fields': ['problemtag__name__iregex'], 'exists': 'tags'}, + 'tag': {'fields': ['problemtag__name'], 'exists': 'tags'}, 'cid': {'fields': ['contest__pk'], 'exists': 'contests', 'func': lambda v: int(v)}, 'rid': {'fields': ['resource_id'], 'func': lambda v: int(v)}, 'pid': {'fields': ['id'], 'func': lambda v: int(v)}, @@ -1019,8 +1019,8 @@ def problems(request, template='problems.html'): tags = [r for r in request.GET.getlist('tag') if r] if tags: - problems = problems.annotate(has_tag=Exists('tags', filter=Q(problemtag__pk__in=tags))) - problems = problems.filter(has_tag=True) + for tag in tags: + problems = problems.filter(tags__pk=tag) tags = list(ProblemTag.objects.filter(pk__in=tags)) custom_fields = [f for f in request.GET.getlist('field') if f] diff --git a/src/logify/admin.py b/src/logify/admin.py index a750deaa..33edc5b0 100644 --- a/src/logify/admin.py +++ b/src/logify/admin.py @@ -2,7 +2,7 @@ from django.urls import reverse from django.utils.html import format_html -from logify.models import EventLog +from logify.models import EventLog, PgStatTuple from pyclist.admin import BaseModelAdmin, admin_register @@ -21,3 +21,11 @@ def related_object_link(self, obj): return format_html('<a href="{}">{}</a>', url, obj.related) related_object_link.short_description = 'Related Object' + + +@admin_register(PgStatTuple) +class PgStatTupleAdmin(BaseModelAdmin): + list_display = ['id', 'table_name', 'app_name', 'table_len', 'tuple_percent', 'dead_tuple_percent', 'free_percent'] + list_filter = ['app_name'] + search_fields = ['table_name'] + ordering = ['-table_len'] diff --git a/src/logify/apps.py b/src/logify/apps.py index 0f3e1442..81e99078 100644 --- a/src/logify/apps.py +++ b/src/logify/apps.py @@ -1,6 +1,14 @@ from django.apps import AppConfig +from pyclist.decorators import run_once, run_only_in_production + class LogifyConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'logify' + + @run_only_in_production + @run_once('logify_ready') + def ready(self): + from logify.models import EventLog, EventStatus + EventLog.objects.filter(status=EventStatus.IN_PROGRESS).update(status=EventStatus.INTERRUPTED) diff --git a/src/logify/management/commands/update_pgstattuple.py b/src/logify/management/commands/update_pgstattuple.py new file mode 100644 index 00000000..56355d50 --- /dev/null +++ b/src/logify/management/commands/update_pgstattuple.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +import re +from logging import getLogger + +import tqdm +from django.core.management.base import BaseCommand +from django.db import connection + +from logify.models import PgStatTuple +from utils.attrdict import AttrDict +from utils.db import dictfetchone, find_app_by_table + + +class Command(BaseCommand): + help = 'Updates the PgStatTuple table with fresh data from pgstattuple' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = getLogger('logify.update_pgstattuple') + + def add_arguments(self, parser): + parser.add_argument('-n', '--limit', type=int, help='number of tables') + parser.add_argument('-f', '--search', type=str, help='search tables') + + def handle(self, *args, **options): + self.stdout.write(str(options)) + args = AttrDict(options) + + with connection.cursor() as cursor: + cursor.execute("SELECT tablename FROM pg_tables WHERE schemaname='public'") + tables = [row[0] for row in cursor.fetchall()] + if args.limit: + tables = tables[:args.limit] + if args.search: + tables = [table for table in tables if re.search(args.search, table)] + + for table in tqdm.tqdm(tables, desc='tables'): + cursor.execute(f"SELECT * FROM pgstattuple('{table}')") + stats = dictfetchone(cursor) + defaults = {'app_name': find_app_by_table(table), **stats} + PgStatTuple.objects.update_or_create(table_name=table, defaults=defaults) diff --git a/src/logify/migrations/0006_pgstattuple_alter_eventlog_status.py b/src/logify/migrations/0006_pgstattuple_alter_eventlog_status.py new file mode 100644 index 00000000..cfc66d75 --- /dev/null +++ b/src/logify/migrations/0006_pgstattuple_alter_eventlog_status.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.3 on 2024-02-03 22:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('logify', '0005_alter_eventlog_status'), + ] + + operations = [ + migrations.CreateModel( + name='PgStatTuple', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('modified', models.DateTimeField(auto_now=True, db_index=True)), + ('table_name', models.CharField(max_length=255)), + ('table_len', models.BigIntegerField()), + ('tuple_count', models.BigIntegerField()), + ('tuple_len', models.BigIntegerField()), + ('tuple_percent', models.FloatField()), + ('dead_tuple_count', models.BigIntegerField()), + ('dead_tuple_len', models.BigIntegerField()), + ('dead_tuple_percent', models.FloatField()), + ('free_space', models.BigIntegerField()), + ('free_percent', models.FloatField()), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='eventlog', + name='status', + field=models.CharField(choices=[('default', 'Default'), ('pending', 'Pending'), ('completed', 'Completed'), ('failed', 'Failed'), ('in_progress', 'In Progress'), ('cancelled', 'Cancelled'), ('on_hold', 'On Hold'), ('initiated', 'Initiated'), ('reviewed', 'Reviewed'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('archived', 'Archived'), ('deleted', 'Deleted'), ('skipped', 'Skipped'), ('reverted', 'Reverted'), ('exception', 'Exception'), ('interrupted', 'Interrupted')], db_index=True, default='default', max_length=20), + ), + ] diff --git a/src/logify/migrations/0007_pgstattuple_app_name_alter_pgstattuple_table_name.py b/src/logify/migrations/0007_pgstattuple_app_name_alter_pgstattuple_table_name.py new file mode 100644 index 00000000..96c467a1 --- /dev/null +++ b/src/logify/migrations/0007_pgstattuple_app_name_alter_pgstattuple_table_name.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.3 on 2024-02-03 22:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('logify', '0006_pgstattuple_alter_eventlog_status'), + ] + + operations = [ + migrations.AddField( + model_name='pgstattuple', + name='app_name', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='pgstattuple', + name='table_name', + field=models.CharField(db_index=True, max_length=255, unique=True), + ), + ] diff --git a/src/logify/models.py b/src/logify/models.py index 6931d7b0..3ea2ac8f 100644 --- a/src/logify/models.py +++ b/src/logify/models.py @@ -22,6 +22,7 @@ class EventStatus(models.TextChoices): SKIPPED = 'skipped', 'Skipped' REVERTED = 'reverted', 'Reverted' EXCEPTION = 'exception', 'Exception' + INTERRUPTED = 'interrupted', 'Interrupted' class EventLogManager(BaseManager): @@ -47,3 +48,20 @@ def update_status(self, status, message=None): def update_message(self, message): self.message = message self.save(update_fields=['message', 'modified']) + + +class PgStatTuple(BaseModel): + table_name = models.CharField(max_length=255, db_index=True, unique=True) + app_name = models.CharField(max_length=255, blank=True, null=True) + table_len = models.BigIntegerField() + tuple_count = models.BigIntegerField() + tuple_len = models.BigIntegerField() + tuple_percent = models.FloatField() + dead_tuple_count = models.BigIntegerField() + dead_tuple_len = models.BigIntegerField() + dead_tuple_percent = models.FloatField() + free_space = models.BigIntegerField() + free_percent = models.FloatField() + + def __str__(self): + return f'{self.table_name} PgStatTuple#{self.id}' diff --git a/src/notification/models.py b/src/notification/models.py index fe126368..581533b8 100644 --- a/src/notification/models.py +++ b/src/notification/models.py @@ -199,7 +199,7 @@ class Meta: @staticmethod def link_accounts(to, accounts, message=None, sender=None): - if to.is_virtual: + if to.is_virtual or not accounts: return text = 'New accounts have been linked to you. Check your <a href="/coder/" class="alert-link">profile page</a>.' diff --git a/src/pyclist/asgi.py b/src/pyclist/asgi.py index e32e8a3a..63e1b229 100644 --- a/src/pyclist/asgi.py +++ b/src/pyclist/asgi.py @@ -1,19 +1,35 @@ from django.core.asgi import get_asgi_application +from django.core.cache import cache -django_asgi_app = get_asgi_application() +from pyclist.decorators import run_only_in_production -from channels.auth import AuthMiddlewareStack -from channels.routing import ProtocolTypeRouter, URLRouter -import chats.routing -import ranking.routing +@run_only_in_production +def reset_asgi_cache_values(): + cache.set('logify_ready', False) -application = ProtocolTypeRouter({ - 'http': django_asgi_app, - 'websocket': AuthMiddlewareStack( - URLRouter( - chats.routing.websocket_urlpatterns + - ranking.routing.websocket_urlpatterns + +def get_application(): + reset_asgi_cache_values() + django_asgi_app = get_asgi_application() + + from channels.auth import AuthMiddlewareStack + from channels.routing import ProtocolTypeRouter, URLRouter + + import chats.routing + import ranking.routing + + application = ProtocolTypeRouter({ + 'http': django_asgi_app, + 'websocket': AuthMiddlewareStack( + URLRouter( + chats.routing.websocket_urlpatterns + + ranking.routing.websocket_urlpatterns + ), ), - ), -}) + }) + + return application + + +application = get_application() diff --git a/src/pyclist/decorators.py b/src/pyclist/decorators.py index 070887d5..af30cd93 100644 --- a/src/pyclist/decorators.py +++ b/src/pyclist/decorators.py @@ -1,15 +1,20 @@ #!/usr/bin/env python3 import contextlib +import logging import re from functools import wraps import numpy as np +from django.conf import settings +from django.core.cache import cache from django.db import connection from django.http import HttpResponse from django.shortcuts import render from stringcolor import bold, cs +logger = logging.getLogger(__name__) + def context_pagination(): def decorator(view): @@ -70,3 +75,23 @@ def log_grouped_times(grouped_times): msg += ' ' + bold(f'{g["count"]}') + ' times' msg += ': ' + cs(g['query'], 'grey') print(msg) + + +def run_only_in_production(func): + @wraps(func) + def wrapper(*args, **kwargs): + if not settings.DEBUG: + return func(*args, **kwargs) + return wrapper + + +def run_once(key): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + if not cache.get(key): + result = func(*args, **kwargs) + cache.set(key, True) + return result + return wrapper + return decorator diff --git a/src/pyclist/settings.py b/src/pyclist/settings.py index 4343f9e2..952642b9 100644 --- a/src/pyclist/settings.py +++ b/src/pyclist/settings.py @@ -708,6 +708,7 @@ def show_toolbar_callback(request): 'logify': '<i class="fa-regular fa-file-lines"></i>', 'is_virtual': {'icon': '<i class="fa-solid fa-clock-rotate-left"></i>', 'title': False}, 'to_list': {'icon': '<i class="fa-solid fa-list-check"></i>', 'title': 'Add to list'}, + 'invert': '<i class="fa-solid fa-rotate"></i>', 'google': {'icon': '<i class="fab fa-google"></i>', 'title': None}, 'facebook': {'icon': '<i class="fab fa-facebook"></i>', 'title': None}, diff --git a/src/ranking/management/commands/parse_accounts_infos.py b/src/ranking/management/commands/parse_accounts_infos.py index 95db744b..d7b1a5ba 100644 --- a/src/ranking/management/commands/parse_accounts_infos.py +++ b/src/ranking/management/commands/parse_accounts_infos.py @@ -57,6 +57,8 @@ def add_arguments(self, parser): parser.add_argument('--min-n-contests', default=None, type=int, help='minimum number of contests') parser.add_argument('--without-new', action='store_true', help='only parsed account') parser.add_argument('--with-field', default=None, type=str, help='only parsed account which have field') + parser.add_argument('--reset-upsolving', action='store_true', help='reset upsolving') + parser.add_argument('--with-coders', action='store_true', help='with coders') def handle(self, *args, **options): self.stdout.write(str(options)) @@ -124,6 +126,8 @@ def handle(self, *args, **options): accounts = accounts.filter(**{f'info__{args.with_field}__isnull': False}) if args.contest_id: accounts = accounts.filter(statistics__contest_id=args.contest_id) + if args.with_coders: + accounts = accounts.filter(coders__isnull=False) total = accounts.count() if not total: @@ -240,6 +244,8 @@ def inf_none(): account.coders.add(c) if do_upsolve: + if args.reset_upsolving: + account.info.pop('submissions_', None) updated_info = resource.plugin.Statistic.update_submissions(account=account, resource=resource) add_dict_to_dict(updated_info, update_submissions_info) diff --git a/src/ranking/management/commands/parse_statistic.py b/src/ranking/management/commands/parse_statistic.py index b988c042..af4bd876 100644 --- a/src/ranking/management/commands/parse_statistic.py +++ b/src/ranking/management/commands/parse_statistic.py @@ -196,6 +196,7 @@ def add_arguments(self, parser): parser.add_argument('--no-update-problems', action='store_true', default=False, help='No update problems') parser.add_argument('--is-rated', action='store_true', default=False, help='Contest is rated') parser.add_argument('--after', type=str, help='Events after date') + parser.add_argument('--for-account', type=str, help='Events for account') def parse_statistic( self, @@ -224,6 +225,7 @@ def parse_statistic( without_stage=False, no_update_problems=None, is_rated=None, + for_account=None, ): channel_layer_handler = ChannelLayerHandler() formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%b-%d %H:%M:%S') @@ -297,6 +299,8 @@ def parse_statistic( contests = contests.filter(Q(parsed_time__isnull=True) | Q(parsed_time__gt=after_date)) if is_rated: contests = contests.filter(is_rated=True) + if for_account: + contests = contests.filter(statistics__account__key=for_account) if limit: contests = contests.order_by('-end_time')[:limit] @@ -408,6 +412,7 @@ def parse_statistic( has_problem_result = any('result' in p for p in row.get('problems', {}).values()) if has_problem_result: continue + row = copy.deepcopy(row) row['member'] = member row['_no_update_n_contests'] = True result[member] = row @@ -498,6 +503,7 @@ def parse_statistic( custom_fields_types = standings.pop('fields_types', {}) standings_kinds = set(Contest.STANDINGS_KINDS.keys()) has_more_solving = bool(contest.info.get('_more_solving')) + updated_statistics_ids = list() results = [] if result or users: @@ -514,7 +520,6 @@ def parse_statistic( hidden_fields = set() medals_skip = set() medals_skip_places = defaultdict(int) - updated_statistics_ids = list() additions = copy.deepcopy(contest.info.get('additions', {})) if additions: @@ -1286,6 +1291,8 @@ def link_account(statistic): timing_delta = standings.get('timing_statistic_delta', timing_delta) if now < contest.end_time: timing_delta = parse_info.get('timing_statistic_delta', timing_delta) + if updated_statistics_ids and contest.end_time < now < contest.end_time + timedelta(hours=1): + timing_delta = timing_delta or timedelta(minutes=10) if has_hidden and contest.end_time < now < contest.end_time + timedelta(days=1): timing_delta = timing_delta or timedelta(minutes=30) if wait_rating and not has_statistics and results and 'days' in wait_rating: @@ -1498,4 +1505,5 @@ def handle(self, *args, **options): without_stage=args.without_stage, no_update_problems=args.no_update_problems, is_rated=args.is_rated, + for_account=args.for_account, ) diff --git a/src/ranking/management/commands/set_account_rank.py b/src/ranking/management/commands/set_account_rank.py index b7067bc8..2a0be5b6 100644 --- a/src/ranking/management/commands/set_account_rank.py +++ b/src/ranking/management/commands/set_account_rank.py @@ -4,7 +4,6 @@ from pprint import pprint from django.core.management.base import BaseCommand -from django.db import transaction from django.db.models import F, Q, Window from django.db.models.functions import Rank from django.utils import timezone @@ -31,7 +30,11 @@ def add_arguments(self, parser): parser.add_argument('-r', '--resources', metavar='HOST', nargs='*', help='resources hosts') parser.add_argument('-bs', '--batch-size', type=int, help='batch size', default=1000) parser.add_argument('-n', '--limit', type=int, help='number of accounts') - parser.add_argument('-td', '--time-delay', help='time delay after rating last update time', default='3 hours') + parser.add_argument('--rank-update-delay', help='time delay after rank last update time', + default='1 week') + parser.add_argument('--rating-update-delay', help='time delay after rating last update time', + default='1 day') + parser.add_argument('--without-delay', action='store_true', help='do not check delay') parser.add_argument('--verbose', action='store_true', help='verbose output') def log_queryset(self, name, qs): @@ -46,16 +49,16 @@ def handle(self, *args, **options): resources = Resource.objects.filter(has_rating_history=True) now = timezone.now() - if args.time_delay: - time_delay = parse_duration(args.time_delay) - threshold_time = now - time_delay - - long_wait = Q(rank_update_time__isnull=True) | Q(rank_update_time__lt=threshold_time) + if not args.without_delay: + rank_update_delay = parse_duration(args.rank_update_delay) + rating_update_delay = parse_duration(args.rating_update_delay) need_update = ( Q(rank_update_time__isnull=True) | (Q(rating_update_time__isnull=False) & Q(rank_update_time__lt=F('rating_update_time'))) ) - resources = resources.filter(need_update & long_wait) + long_update = Q(rank_update_time__isnull=True) | Q(rank_update_time__lt=now - rank_update_delay) + short_update = Q(rating_update_time__isnull=True) | Q(rating_update_time__lt=now - rating_update_delay) + resources = resources.filter(need_update & (long_update | short_update)) if args.resources: resource_filter = Q() @@ -85,20 +88,20 @@ def handle(self, *args, **options): self.logger.info(f'resource = {resource}, {message}') event_log.update_message(message) - with transaction.atomic(): - if args.limit: - qs = qs[:args.limit] + if args.limit: + qs = qs[:args.limit] + + if args.verbose: + pprint(qs) - if args.verbose: - pprint(qs) + update_values = [Account(id=a['pk'], **{field: a['_rank']}) for a in qs] - update_values = [Account(id=a['pk'], **{field: a['_rank']}) for a in qs] + with suppress_db_logging_context(): + offsets = list(range(0, len(update_values), args.batch_size)) + for offset in tqdm(offsets, desc=f'{resource.host} batching {field}'): + update_values_batch = update_values[offset:offset + args.batch_size] + n_updated += Account.objects.bulk_update(update_values_batch, [field]) - with suppress_db_logging_context(): - offsets = list(range(0, len(update_values), args.batch_size)) - for offset in tqdm(offsets, desc=f'{resource.host} batching {field}'): - update_values_batch = update_values[offset:offset + args.batch_size] - n_updated += Account.objects.bulk_update(update_values_batch, [field]) event_log.update_status(EventStatus.COMPLETED, message=message) resource.rank_update_time = now resource.n_rating_accounts = n_rating_accounts diff --git a/src/ranking/management/modules/atcoder.py b/src/ranking/management/modules/atcoder.py index 7a48efd5..ef9d22aa 100644 --- a/src/ranking/management/modules/atcoder.py +++ b/src/ranking/management/modules/atcoder.py @@ -623,6 +623,30 @@ def fetch_profile(user): ret['avatar'] = match.group('url') if 'Rating' in ret: ret['rating'] = int(ret['Rating']) + match = re.search('>var rating_history=(?P<rating_history>[^<]*);</', page) + if match: + contest_addition_update = {} + rating_history = json.loads(match.group('rating_history')) + for hist in rating_history: + url = hist['StandingsUrl'] + match = re.search(r'/contests/(?P<contest_key>[^/]*)/', url) + if not match: + continue + contest_key = match.group('contest_key') + rank = hist['Place'] + addition_update = {'_rank': rank} + if hist.get('NewRating'): + addition_update['new_rating'] = hist['NewRating'] + if hist.get('OldRating'): + addition_update['old_rating'] = hist['OldRating'] + if 'new_rating' in addition_update and 'old_rating' in addition_update: + addition_update['rating_change'] = addition_update['new_rating'] - addition_update['old_rating'] + contest_addition_update[contest_key] = addition_update + if contest_addition_update: + ret['_contest_addition_update_params'] = { + 'update': contest_addition_update, + 'try_renaming_check': True, + } return ret with PoolExecutor(max_workers=8) as executor: @@ -637,7 +661,11 @@ def fetch_profile(user): if data.get('_delete'): yield {'delete': True} else: - yield {'info': data} + ret = {'info': data} + contest_addition_update_params = data.pop('_contest_addition_update_params', None) + if contest_addition_update_params: + ret['contest_addition_update_params'] = contest_addition_update_params + yield ret pbar.update() @staticmethod diff --git a/src/ranking/management/modules/codechef.py b/src/ranking/management/modules/codechef.py index 3f4085a5..41538cc6 100644 --- a/src/ranking/management/modules/codechef.py +++ b/src/ranking/management/modules/codechef.py @@ -21,7 +21,7 @@ from first import first from ratelimiter import RateLimiter -from clist.templatetags.extras import is_improved_solution +from clist.templatetags.extras import as_number, is_improved_solution from ranking.management.modules import conf from ranking.management.modules.common import LOG, REQ, BaseModule, FailOnGetResponse, parsed_table from ranking.management.modules.excepts import ExceptionParseStandings @@ -261,16 +261,15 @@ def get_standings(self, users=None, statistics=None): if country: d['country'] = country - rating = d.pop('rating', None) - if rating and rating != '0': - hidden_fields.add('rating') - row['rating'] = rating + rating = as_number(d.pop('rating', None), force=True) + if rating: + row['old_rating'] = rating row.update(d) row.update(contest_info) if statistics and handle in statistics: stat = statistics[handle] - for k in ('rating_change', 'new_rating'): + for k in ('old_rating', 'rating_change', 'new_rating'): if k in stat: row[k] = stat[k] hidden_fields |= set(list(d.keys())) @@ -395,7 +394,7 @@ def get_users_infos(users, resource=None, accounts=None, pbar=None): if response.status_code < 400 and response.headers.get('Content-Length', '0') != '0': info['avatar_url'] = src - contest_addition_update_params = {} + contest_addition_update_params = {'clear_rating_change': True} update = contest_addition_update_params.setdefault('update', {}) by = contest_addition_update_params.setdefault('by', ['key']) prev_rating = None @@ -410,13 +409,16 @@ def get_users_infos(users, resource=None, accounts=None, pbar=None): name = row.get('name') end_time = row.get('end_date') if code: - if re.search(r'\bdiv(ision)?[-_\s]+[ABCD1234]\b', name, re.I) \ + if re.search(r'\bdiv[ision]*[-_\s]+[ABCD1234]\b', name, re.I) \ and re.search('[ABCD]$', code): code = code[:-1] u = update.setdefault(code, OrderedDict()) - u['rating_change'] = rating - prev_rating if prev_rating is not None else None u['new_rating'] = rating + if prev_rating is not None: + u['old_rating'] = prev_rating + u['rating_change'] = rating - prev_rating + u['_group'] = row['code'] new_name = name new_name = re.sub(r'\s*\([^\)]*\brated\b[^\)]*\)$', '', new_name, flags=re.I) diff --git a/src/ranking/management/modules/opencup.py b/src/ranking/management/modules/opencup.py index 06907880..f9a0fcc2 100644 --- a/src/ranking/management/modules/opencup.py +++ b/src/ranking/management/modules/opencup.py @@ -29,6 +29,7 @@ def get_standings(self, users=None, statistics=None): problems_info = OrderedDict() page = REQ.get(self.standings_url, detect_charsets=True) + page = page.replace(' ', ' ') regex = '<table[^>]*class="standings"[^>]*>.*?</table>' html_table = re.search(regex, page, re.DOTALL).group(0) table = parsed_table.ParsedTable(html_table) @@ -106,10 +107,12 @@ def get_standings(self, users=None, statistics=None): row['member'] = v.value if ' ' not in v.value else v.value + ' ' + self.season row['name'] = v.value else: - t = as_number(v.value) - if t: + key = re.sub('[-._ ]+', '_', key) + val = v.value.strip() + if val and val != '-': + t = as_number(val) fields_types[key].add(type(t)) - other[key] = v.value + other[key] = val for k, v in other.items(): if k.lower() not in row: row[k] = v @@ -118,10 +121,7 @@ def get_standings(self, users=None, statistics=None): result[row['member']] = row for field, types in fields_types.items(): - if len(types) != 1: - continue - field_type = next(iter(types)) - if field_type not in [int, float]: + if not all(t in [int, float] for t in types): continue for row in result.values(): if field in row: @@ -133,6 +133,10 @@ def get_standings(self, users=None, statistics=None): 'problems': list(problems_info.values()), 'problems_time_format': '{H}:{m:02d}', } + + if self.info.get('series'): + standings['series'] = self.info['series'] + return standings diff --git a/src/ranking/management/modules/tlx_toki.py b/src/ranking/management/modules/tlx_toki.py index 07a9fa4f..decd0c7a 100644 --- a/src/ranking/management/modules/tlx_toki.py +++ b/src/ranking/management/modules/tlx_toki.py @@ -175,6 +175,8 @@ def get_users_infos(users, resource, accounts, pbar=None): @RateLimiter(max_calls=5, period=1) def fetch_profile(jid, handle): + if jid is None: + return {'_delete': True}, None url = Statistic.API_PROFILE_URL_FORMAT_.format(jid=jid) page = REQ.get(url) data = json.loads(page) @@ -194,20 +196,23 @@ def fetch_profile(jid, handle): page = REQ.get(Statistic.API_USER_SEARCH_, post=json.dumps(users), content_type='application/json') data = json.loads(page) - jids = [data[user] for user in users] + jids = [data.get(user) for user in users] with PoolExecutor(max_workers=8) as executor: profiles = executor.map(fetch_profile, jids, users) for user, (data, history) in zip(users, profiles): if pbar: pbar.update() - assert user == data['username'] if not data: if data is None: yield {'info': None} else: yield {'skip': True} continue + if data.get('_delete'): + yield {'delete': True} + continue + assert user == data['username'] ret = { 'info': data, @@ -234,6 +239,8 @@ def fetch_profile(jid, handle): update['old_rating'] = last_rating else: contest_addition_update.pop(url) + if contest.get('rank'): + update['_rank'] = contest['rank'] if last_rating is not None: data['rating'] = last_rating @@ -243,6 +250,7 @@ def fetch_profile(jid, handle): 'update': contest_addition_update, 'by': 'url', 'clear_rating_change': True, + 'try_renaming_check': True, } yield ret diff --git a/src/ranking/utils.py b/src/ranking/utils.py index aae99d86..849a31f5 100644 --- a/src/ranking/utils.py +++ b/src/ranking/utils.py @@ -107,16 +107,26 @@ def account_update_contest_additions( if timedelta_limit is not None and not clear_rating_change: base_qs.filter(modified__lte=timezone.now() - timedelta_limit) + grouped_contest_keys = defaultdict(list) + for contest_key, update in contest_addition_update.items(): + group = update.get('_group') + if group: + grouped_contest_keys[group].append(contest_key) + iteration = 0 while contest_keys: iteration += 1 if clear_rating_change: - qs_clear = base_qs.filter(Q(addition__rating_change__isnull=False) | Q(addition__new_rating__isnull=False)) + qs_clear = base_qs.filter(Q(addition__rating_change__isnull=False) | + Q(addition__new_rating__isnull=False) | + Q(addition__rating__isnull=False)) for s in qs_clear: + s.addition.pop('rating', None) s.addition.pop('rating_change', None) s.addition.pop('new_rating', None) s.addition.pop('old_rating', None) s.save(update_fields=['addition']) + clear_rating_change = False conditions = (Q(**{f'contest__{field}__in': contest_keys}) for field in fields) condition = functools.reduce(operator.__or__, conditions) @@ -134,6 +144,12 @@ def account_update_contest_additions( if key in contest_addition_update: ordered_dict = contest_addition_update[key] break + + group = ordered_dict.pop('_group', None) + if group: + for contest_key in grouped_contest_keys.pop(group, []): + contest_keys.discard(contest_key) + addition.update(dict(ordered_dict)) for k, v in ordered_dict.items(): if v is None: @@ -160,7 +176,8 @@ def account_update_contest_additions( if try_renaming_check and renaming_check(account, contest_keys, fields, contest_addition_update): continue if contest_keys: - LOG.warning('Not found %d contests for %s = %s', len(contest_keys), account, contest_keys) + out_contests = list(grouped_contest_keys.values()) or contest_keys + LOG.warning('Not found %d contests for %s = %s', len(out_contests), account, out_contests) break @@ -172,4 +189,17 @@ def create_upsolving_statistic(contest, account): if account.name: defaults['addition']['name'] = account.name stat, created = contest.statistics_set.get_or_create(account=account, defaults=defaults) + if stat.skip_in_stats: + return stat, created + + problems = stat.addition.get('problems', {}) + all_upsolving = True + for solution in problems.values(): + if len(solution) != 1 or 'upsolving' not in solution: + all_upsolving = False + break + if all_upsolving: + stat.skip_in_stats = True + stat.addition['_no_update_n_contests'] = True + stat.save(update_fields=['skip_in_stats', 'addition']) return stat, created diff --git a/src/ranking/views.py b/src/ranking/views.py index 08c01d58..918bda62 100644 --- a/src/ranking/views.py +++ b/src/ranking/views.py @@ -28,9 +28,9 @@ from clist.models import Contest, ContestSeries, Resource from clist.templatetags.extras import (as_number, format_time, get_country_name, get_item, get_problem_short, - get_problem_title, has_update_statistics_permission, is_ip_field, - is_private_field, is_reject, is_solved, query_transform, slug, time_in_seconds, - timestamp_to_datetime) + get_problem_title, get_standings_divisions_order, + has_update_statistics_permission, is_ip_field, is_private_field, is_reject, + is_solved, query_transform, slug, time_in_seconds, timestamp_to_datetime) from clist.templatetags.extras import timezone as set_timezone from clist.templatetags.extras import toint, url_transform from clist.views import get_group_list, get_timeformat, get_timezone @@ -573,22 +573,19 @@ def timeline_format(t): for field in contest.info.get('fields', []): types = fields_types.get(field) - if field.startswith('_') or not types: + if field.startswith('_') or not types or not fields_values[field]: continue - if len(types) != 1: - continue - field_type = next(iter(types)) - if field_type not in [int, float]: - continue - if not fields_values[field]: + if not all(t in [int, float] for t in types): continue + field_type = float if float in types else int + field_values = [field_type(v) for v in fields_values[field]] if field in mapping_fields_values: values = fields_values[mapping_fields_values[field]] bins = make_bins(min(values), max(values), n_bins=default_n_bins) - hist, bins = make_histogram(fields_values[field], bins=bins) + hist, bins = make_histogram(field_values, bins=bins) else: - hist, bins = make_histogram(fields_values[field], n_bins=default_n_bins) + hist, bins = make_histogram(field_values, n_bins=default_n_bins) chart = dict( field=field, @@ -685,17 +682,6 @@ def update_standings_socket(contest, statistics): async_to_sync(channel_layer.group_send)(contest.channel_group_name, context) -def get_standings_divisions_order(contest): - problems = contest.info.get('problems', {}) - if 'division' in problems: - divisions_order = list(problems.get('divisions_order', sorted(contest.info['problems']['division'].keys()))) - elif 'divisions_order' in contest.info: - divisions_order = contest.info['divisions_order'] - else: - divisions_order = [] - return divisions_order - - def get_standings_mod_penalty(contest, division, problems, statistics): for p in problems: if 'full_score' in p and isinstance(p['full_score'], (int, float)) and abs(p['full_score'] - 1) > 1e-9: @@ -1025,9 +1011,13 @@ def standings(request, title_slug=None, contest_id=None, contests_ids=None, else: values = None if values: - division = values[0]['addition__division'] + values = [v['addition__division'] for v in values if v['addition__division'] in divisions_order] + if values: + division = values[0] if division not in divisions_order: division = divisions_order[0] + if not values and values is not None and not inplace_division: + division = 'any' division_addition = contest.info.get('divisions_addition', {}).get(division, {}) @@ -1219,8 +1209,12 @@ def add_field_to_select(f): params['division'] = division if not inplace_division: divisions_order.append('any') - if divisions_order and division != 'any' and not inplace_division: - statistics = statistics.filter(addition__division=division) + if divisions_order and division != 'any': + if inplace_division: + field = f'addition___division_addition__{division}' + statistics = statistics.filter(**{f'{field}__isnull': False}) + else: + statistics = statistics.filter(addition__division=division) # filter by search search = request.GET.get('search') diff --git a/src/static/img/resources/huawei_com.png b/src/static/img/resources/huawei_com.png new file mode 100644 index 0000000000000000000000000000000000000000..241452ddbd642b7a6e954cfc496ad465ddea3dcc GIT binary patch literal 7698 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 literal 0 HcmV?d00001 diff --git a/src/static/js/accounts.js b/src/static/js/accounts.js index a3704907..28da929f 100644 --- a/src/static/js/accounts.js +++ b/src/static/js/accounts.js @@ -40,3 +40,8 @@ function init_clickable_has_coders() { $(this).remove() }) } + +function invert_linked_coder_accounts(e) { + e.preventDefault() + $('#accounts input[name="accounts"]').click() +} diff --git a/src/static/js/base.js b/src/static/js/base.js index 68e076de..52a1800c 100644 --- a/src/static/js/base.js +++ b/src/static/js/base.js @@ -690,5 +690,29 @@ function escape_html(str) { } function configure_pagination(paginate_on_scroll = true) { - $.endlessPaginate({paginateOnScroll: paginate_on_scroll, onCompleted: function () { toggle_tooltip(); inline_button(); $(window).trigger('resize'); }}) + $.endlessPaginate({paginateOnScroll: paginate_on_scroll, onCompleted: function () { toggle_tooltip(); inline_button(); checkbox_mouseover_toggle(); $(window).trigger('resize'); }}) } + + +/* + * checkbox mouseover toggle + */ + +var mouse_is_down = false + +function checkbox_mouseover_toggle() { + $('input.mouseover-toggle[type="checkbox"]').removeClass('mouseover-toggle').mouseover(function() { + if (mouse_is_down) { + $(this).prop('checked', !$(this).prop('checked')) + } + }) +} + +$(function() { + $(document).mousedown(function() { + mouse_is_down = true + }).mouseup(function() { + mouse_is_down = false + }) + checkbox_mouseover_toggle() +}) diff --git a/src/templates/accounts.html b/src/templates/accounts.html index 0c837250..a121a210 100644 --- a/src/templates/accounts.html +++ b/src/templates/accounts.html @@ -35,7 +35,7 @@ </th> {% endif %} <th> - <div>Linked<br/>coder</div> + <div>Linked<br/>coder{% if perms.ranking.link_account %} <a href="#" onclick="invert_linked_coder_accounts(event)" id="switch-accounts">{% icon_to 'invert' %}</a>{% endif %}</div> </th> {% if params.to_list %} <th> diff --git a/src/templates/accounts_filters.html b/src/templates/accounts_filters.html index 7793f7d7..47618c4b 100644 --- a/src/templates/accounts_filters.html +++ b/src/templates/accounts_filters.html @@ -14,7 +14,7 @@ autocomplete="off" /> - <div class="input-group-addon tooltip-help" data-toggle="tooltip" data-html="true" title="Account full match search.<br>You can search accounts after participating in a contest and after parsing statistics.<br>Use && and || and ! for difficult conditions."> + <div class="input-group-addon tooltip-help" data-toggle="tooltip" data-placement="bottom" data-html="true" title="You can search accounts after participating in a contest and after parsing statistics.<br>You can use key:{KEY} or name:{NAME} to strict search.<br>Use && and || and ! for difficult conditions."> <i class="fas fa-info"></i> </div> @@ -64,7 +64,7 @@ {% endif %} {% if perms.ranking.link_account %} - {% include "coder_filter.html" with nomultiply=True submit="link" coders=params.link_coders %} + {% include "coder_filter.html" with nomultiply=True coder_urls=True submit="link" coders=params.link_coders %} {% endif %} {% include "list_filter.html" with list_field="to_list" nomultiply=True submit="add" submit_value="add_to_list" submit_enabled=params.to_list %} diff --git a/src/templates/accounts_paging.html b/src/templates/accounts_paging.html index cf035f1d..2bdf291e 100644 --- a/src/templates/accounts_paging.html +++ b/src/templates/accounts_paging.html @@ -64,10 +64,10 @@ {% endif %} <td> {% if not account.has_coders and perms.ranking.link_account %} - <input class="scale15" type="checkbox" name="accounts" value="{{ account.pk }}"{% if account.pk|slugify in params.link_accounts %} checked{% endif %}> + <input class="scale15 mouseover-toggle" type="checkbox" name="accounts" value="{{ account.pk }}"{% if account.pk|slugify in params.link_accounts %} checked{% endif %}> {% elif perms.ranking.link_account %} <i class="has_coders fas fa-check clickable"></i> - <input class="hidden scale15" type="checkbox" name="accounts" value="{{ account.pk }}"> + <input class="hidden scale15 mouseover-toggle" type="checkbox" name="accounts" value="{{ account.pk }}"> {% else %} <i class="has_coders fas fa-{% if account.has_coders %}check{% else %}times{% endif %}"></i> {% endif %} diff --git a/src/templates/coder_filter.html b/src/templates/coder_filter.html index 32d09ebb..d24f7453 100644 --- a/src/templates/coder_filter.html +++ b/src/templates/coder_filter.html @@ -14,6 +14,9 @@ </select> {% if submit %} <span class="input-group-btn"> + {% for coder in coders %} + <a class="btn btn-default btn-xs" href="{% url 'coder:profile' coder.username %}" target="_blank">{% icon_to 'extra_url' coder.username %}</a> + {% endfor %} <button class="btn btn-default" type="submit" name="action" value="{{ submit }}"{% if not coders %} disabled{% endif %}>{{ submit|title }}</button> </span> {% endif %} diff --git a/src/templates/field_to_select.html b/src/templates/field_to_select.html index e85cc071..6a287aea 100644 --- a/src/templates/field_to_select.html +++ b/src/templates/field_to_select.html @@ -46,7 +46,7 @@ </select> {% if data.extra_url %} <div class="input-group-btn"> - <a href="{{ data.extra_url }}" target="_blank" class="btn btn-default"> + <a href="{{ data.extra_url }}" target="_blank" class="btn btn-default" rel="noopener noreferrer"> {% icon_to "extra_url" field|title|add:" extra url" %} </a> </div> diff --git a/src/templates/profile_contests_paging.html b/src/templates/profile_contests_paging.html index a11855eb..c7986ed1 100644 --- a/src/templates/profile_contests_paging.html +++ b/src/templates/profile_contests_paging.html @@ -31,13 +31,21 @@ {% endif %} {% endfor %} "> - {{ statistic.place|default:"-" }} + {% if statistic.place %} + {{ statistic.place }} + {% else %} + <small class='text-muted'>—</small> + {% endif %} </div> {% if statistic.addition.url %}</a>{% endif %} </td> <td> - {{ statistic.solving|scoreformat }} + {% if statistic.skip_in_stats and not statistic.solving %} + <small class='text-muted'>·</small> + {% else %} + {{ statistic.solving|scoreformat }} + {% endif %} </td> <td> @@ -125,7 +133,7 @@ <span class="problems"> {% if 'problems' in statistic.addition %} {% if contest.info.problems %} - {% for problem in contest.info.problems|get_division_problems:statistic.addition %} + {% for problem in contest|get_division_problems:statistic.addition %} {% with key=problem|get_problem_short %} {% with stat=statistic.addition.problems|get_item:key %} {% include 'problem_stat.html' with short=problem|get_problem_header name=problem|get_problem_name stat=stat problem_url=problem.url full_score=problem.full_score %} diff --git a/src/templates/resource.html b/src/templates/resource.html index 73704b44..240f44fa 100644 --- a/src/templates/resource.html +++ b/src/templates/resource.html @@ -13,7 +13,7 @@ <h2> <img src="{{ resource.icon|media_size:'64x64' }}" width="24" height="24"/> - <a href="{{ resource.href }}">{{ resource.host }}</a> + <a href="{{ resource.url }}">{{ resource.host }}</a> {% if perms.clist.change_resource %} <a href="{% url 'admin:clist_resource_change' resource.id %}" class="database-link invisible" target="_blank" rel="noopener"><i class="fas fa-database"></i></a> {% endif %} diff --git a/src/templates/resources.html b/src/templates/resources.html index a57a51be..97f2851a 100644 --- a/src/templates/resources.html +++ b/src/templates/resources.html @@ -42,7 +42,7 @@ {% endif %} </td> <td> - <a href="{{ resource.href }}" target="_blank" rel="noopener noreferrer"><i class="fas fa-external-link-alt"></i></a> + <a href="{{ resource.url }}" target="_blank" rel="noopener noreferrer"><i class="fas fa-external-link-alt"></i></a> </td> <td> {% if resource.uid %} diff --git a/src/true_coders/admin.py b/src/true_coders/admin.py index cd55ab8b..d17dfc3b 100644 --- a/src/true_coders/admin.py +++ b/src/true_coders/admin.py @@ -31,9 +31,9 @@ class PartySet(admin.TabularInline): @admin_register(CoderProblem) class CoderProblemAdmin(BaseModelAdmin): - search_fields = ['coder__username', 'problem__name'] - list_display = ['coder', 'problem', 'verdict'] + list_display = ['coder', 'problem', 'verdict', 'created', 'modified'] list_filter = ['verdict', 'problem__resource'] + search_fields = ['coder__username', 'problem__name'] @admin_register(Party) diff --git a/src/true_coders/views.py b/src/true_coders/views.py index cf6ede5b..985fd102 100644 --- a/src/true_coders/views.py +++ b/src/true_coders/views.py @@ -2111,18 +2111,12 @@ def accounts(request, template='accounts.html'): params['link_accounts'] = set(link_accounts) if action == 'link': if link_accounts and link_coder: - link_accounts = Account.objects.filter(pk__in=link_accounts) + linked_accounts = list(Account.objects.filter(pk__in=link_accounts).exclude(coders__pk=link_coder.pk)) + link_coder.account_set.add(*linked_accounts) coder_url = reverse('coder:profile', args=[coder.username]) message = f'Added by <a href="{coder_url}">{coder.display_name}</a>.' - linked_accounts = [] - for a in link_accounts: - if a.coders.filter(pk=link_coder.pk).exists(): - continue - a.coders.add(link_coder) - linked_accounts.append(a) - if linked_accounts and not link_coder.is_virtual: - NotificationMessage.link_accounts(link_coder, linked_accounts, message=message, sender=coder) - request.logger.success(f'Linked {len(linked_accounts)} account(s) to {link_coder.username}') + NotificationMessage.link_accounts(link_coder, linked_accounts, message=message, sender=coder) + request.logger.success(f'Linked {len(linked_accounts)} account(s) to {link_coder.username}') query = query_transform(request, with_remove=True, accounts=None, action=None) return HttpResponseRedirect(f'{request.path}?{query}') if action == 'add_to_list': @@ -2133,7 +2127,14 @@ def accounts(request, template='accounts.html'): search = request.GET.get('search') if search: - filt = get_iregex_filter(search, 'name', 'key', suffix='', logger=request.logger) + filt = get_iregex_filter( + search, 'name', 'key', suffix='__contains', + mapping={ + 'key': {'fields': ['key']}, + 'name': {'fields': ['name']}, + }, + logger=request.logger, + ) accounts = accounts.filter(filt) if request.user.has_perm('ranking.link_account'): coders_counter = Counter(accounts.filter(has_coders=True).values_list('coders__pk', flat=True)) diff --git a/src/utils/db.py b/src/utils/db.py new file mode 100644 index 00000000..e77bc983 --- /dev/null +++ b/src/utils/db.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + + +from django.apps import apps + + +def dictfetchall(cursor): + "Return all rows from a cursor as a dict" + columns = [col[0] for col in cursor.description] + return [dict(zip(columns, row)) for row in cursor.fetchall()] + + +def dictfetchone(cursor): + """Return one row from a cursor as a dict""" + columns = [col[0] for col in cursor.description] + row = cursor.fetchone() + return dict(zip(columns, row)) + + +def find_app_by_table(table_name): + for model in apps.get_models(): + if model._meta.db_table == table_name: + return model._meta.app_label + return None diff --git a/src/utils/requester/__init__.py b/src/utils/requester/__init__.py index b9e4ee72..68c0afc5 100644 --- a/src/utils/requester/__init__.py +++ b/src/utils/requester/__init__.py @@ -435,6 +435,9 @@ def init_opener(self): http_cookie_processor = urllib.request.HTTPCookieProcessor(self.cookiejar) context = ssl.create_default_context() context.set_ciphers('DEFAULT') + if strtobool(environ.get('REQUESTER_INSECURE', '0')): + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE https_handler = urllib.request.HTTPSHandler(context=context) self.opener = urllib.request.build_opener(http_cookie_processor, https_handler) self.proxer = None