From 907a7f7d53a6fc60fefef06cc4a17dc7a79baf5c Mon Sep 17 00:00:00 2001 From: Aleksey Ropan Date: Wed, 25 Dec 2024 22:53:27 +0000 Subject: [PATCH] Minor parsing fixes Discussion chats and topics Toastify to new UI notifications Custom names in CoderList --- .github/workflows/codeql-analysis.yml | 5 +- legacy/db.class.php | 2 +- legacy/helper.php | 3 + legacy/js/countdown.js | 3 - legacy/module/codingame.com/index.php | 4 +- legacy/module/olympiads.ru/index.php | 39 +- legacy/module/usaco.org/index.php | 17 +- src/clist/admin.py | 8 +- src/clist/api/v2.py | 2 +- .../migrations/0167_create_discussion.py | 45 ++ src/clist/models.py | 72 +- src/clist/templatetags/extras.py | 67 +- src/clist/utils.py | 45 ++ src/clist/views.py | 22 +- .../0043_subscription_with_custom_names.py | 18 + src/notification/models.py | 1 + src/notification/utils.py | 29 +- src/pyclist/settings.py | 3 + src/ranking/admin.py | 3 +- .../commands/parse_accounts_infos.py | 24 +- .../management/commands/parse_statistic.py | 48 +- .../management/modules/adventofcode.py | 64 +- src/ranking/management/modules/codeforces.py | 22 + src/ranking/management/modules/nerc_itmo.py | 34 +- src/ranking/management/modules/ucup.py | 35 +- src/ranking/management/modules/usaco.py | 30 +- .../0136_rating_update_time_field.py | 34 + src/ranking/models.py | 13 +- src/ranking/views.py | 43 +- src/static/css/base.css | 47 +- src/static/css/toastify.min.css | 15 + src/static/flags/flags.css | 4 +- src/static/js/accounts.js | 6 +- src/static/js/base.js | 64 +- src/static/js/chart-helper.js | 2 +- src/static/js/coder_list.js | 26 +- src/static/js/contest/calendar.js | 9 +- src/static/js/countdown.js | 38 +- src/static/js/notify-config.js | 3 - src/static/js/notify.js | 616 ------------------ src/static/js/profile.js | 4 +- src/static/js/settings.js | 6 +- src/static/js/standings.js | 76 ++- src/static/js/toastify.js | 15 + src/templates/account_table_cell.html | 6 +- src/templates/accounts.html | 6 +- src/templates/accounts_filters.html | 2 + src/templates/accounts_paging.html | 6 +- src/templates/base.html | 6 +- src/templates/check_timezone.html | 4 +- src/templates/coder_list.html | 57 +- src/templates/field_to_input.html | 8 +- src/templates/messages.html | 2 +- src/templates/settings_subscription.html | 10 +- src/templates/standings.html | 12 +- src/templates/standings_account.html | 7 +- src/templates/standings_filters.html | 26 +- src/templates/standings_list_paging.html | 4 +- src/templates/standings_paging_detail.html | 2 + src/templates/trophy.html | 6 +- src/tg/bot.py | 12 +- src/tg/models.py | 8 + src/true_coders/admin.py | 23 +- .../management/commands/set_coder_problems.py | 40 +- .../migrations/0071_edit_coder_list.py | 124 ++++ src/true_coders/models.py | 42 +- src/true_coders/views.py | 182 ++++-- 67 files changed, 1324 insertions(+), 937 deletions(-) create mode 100644 src/clist/migrations/0167_create_discussion.py create mode 100644 src/notification/migrations/0043_subscription_with_custom_names.py create mode 100644 src/ranking/migrations/0136_rating_update_time_field.py create mode 100644 src/static/css/toastify.min.css delete mode 100644 src/static/js/notify-config.js delete mode 100644 src/static/js/notify.js create mode 100644 src/static/js/toastify.js create mode 100644 src/true_coders/migrations/0071_edit_coder_list.py diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a0285782..11ff0da7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -12,8 +12,9 @@ name: "CodeQL" on: - schedule: - - cron: '37 16 * * 3' + push: + branches: + - master jobs: analyze: diff --git a/legacy/db.class.php b/legacy/db.class.php index cc486bf3..7bd3b3a4 100755 --- a/legacy/db.class.php +++ b/legacy/db.class.php @@ -70,7 +70,7 @@ function select($table, $fields = '*', $where = '1 = 1') function escapeString($data) { - //return mysqli_real_escape_string($this->link, $data); + if ($data === null) return null; return pg_escape_string($this->link, $data); } diff --git a/legacy/helper.php b/legacy/helper.php index b2ba5a34..3d503a9f 100755 --- a/legacy/helper.php +++ b/legacy/helper.php @@ -219,6 +219,9 @@ function curlexec(&$url, $postfields = NULL, $params = array()) if (isset($params["cookie_file"])) { $command .= " -b " . escapeshellarg($params["cookie_file"]) . " -c " . escapeshellarg($params["cookie_file"]); } + if (isset($params["curl_args_file"])) { + $command .= " " . file_get_contents($params["curl_args_file"]); + } $page = shell_exec($command); } else { $page = curl_exec($CID); diff --git a/legacy/js/countdown.js b/legacy/js/countdown.js index a5797992..4e3321cb 100755 --- a/legacy/js/countdown.js +++ b/legacy/js/countdown.js @@ -98,7 +98,6 @@ function addValueChange(change) for (var key in atable) { var table = atable[key]; -// console.log(table); var afields = new Array(); var avalues = new Array(); @@ -116,11 +115,9 @@ function addValueChange(change) afields.push('`' + field + '`'); avalues.push(value); } - var obj = document.getElementById('aAdd_' + table); if (obj == null) return; obj.href = encodeURI('/?action=query&query=INSERT INTO `' + "clist_" + table + '` (' + afields.join(',') + ') VALUES (' + avalues.join(',') +')'); -// console.log(obj.href); } } diff --git a/legacy/module/codingame.com/index.php b/legacy/module/codingame.com/index.php index 87e3853d..4ac1fd5f 100755 --- a/legacy/module/codingame.com/index.php +++ b/legacy/module/codingame.com/index.php @@ -113,8 +113,8 @@ } $contests[] = array( - 'start_time' => $data['date'] / 1000, - 'end_time' => $end_time, + 'start_time' => floor($data['date'] / 1000), + 'end_time' => floor($end_time), 'duration' => $duration, 'title' => $data['title'], 'url' => $prefix_contest . $data['publicId'], diff --git a/legacy/module/olympiads.ru/index.php b/legacy/module/olympiads.ru/index.php index 4fb0503d..ecf0c4aa 100755 --- a/legacy/module/olympiads.ru/index.php +++ b/legacy/module/olympiads.ru/index.php @@ -257,6 +257,24 @@ $schedule_page = curlexec($url); $schedule_page = $replace_months($schedule_page); + $standings = array(); + preg_match_all('#]*href="(?P[^"]*standing[^"]*)"[^>]*>[^<]*результат[^<]*#', $schedule_page, $matches); + $seen = array(); + foreach ($matches['url'] as $standings_url) { + $standings_url = url_merge($url, $standings_url); + if (in_array($standings_url, $seen)) { + continue; + } + $seen[] = $standings_url; + $standings_page = curlexec($standings_url); + preg_match('#]*>(?P[^<]*)#s', $standings_page, $match); + $title = html_entity_decode($match['title']); + $standings[] = array( + "title" => $title, + "url" => $standings_url, + ); + } + preg_match_all('#<h4[^>]*id="[^"]*"[^>]*>(?P<title>[^<]*)</h4>.*?<strong>(?P<date>[^<]*[0-9]{4})\sгода</strong>#s', $schedule_page, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $title = html_entity_decode($match['title']); @@ -282,14 +300,31 @@ $year--; } $season = $year . '-' . ($year + 1); + $standings_season = $year . '-' . (($year + 1) % 100); + + $standings_url = null; + foreach ($standings as $ind => $s) { + if ( + !preg_match("/[Оо]тборочный/i", $title) || + !preg_match("/[Зз]аочный/i", $s['title']) || + strpos($s['url'] . ' ' . $s['title'], $standings_season) === false + ) { + continue; + } + $standings_url = $s['url']; + unset($standings[$ind]); + break; + } + $contests[] = array( "start_time" => $start_time, "end_time" => $end_time, - "title" => $title . '. ' . $main_title, + "title" => "$title. $main_title", "url" => $url, + "standings_url" => $standings_url, "host" => $HOST, - "key" => $season . '. ' . $title, + "key" => "$season. $title", "rid" => $RID, "timezone" => $TIMEZONE, ); diff --git a/legacy/module/usaco.org/index.php b/legacy/module/usaco.org/index.php index f07e1075..c6ed8c89 100755 --- a/legacy/module/usaco.org/index.php +++ b/legacy/module/usaco.org/index.php @@ -2,18 +2,24 @@ require_once dirname(__FILE__) . "/../../config.php"; $url = 'http://usaco.org/index.php?page=contests'; - $page = curlexec($url); + $curl_params = ['with_curl' => true, 'curl_args_file' => '/sharedfiles/resource/usaco/curl.args']; + $page = curlexec($url, NULL, $curl_params); - preg_match_all('#<a[^>]*href="(?<url>[^"]*)"[^>]*>(?<name>[^<]*[0-9]{4}[^<]*Results)</a>#', $page, $matches, PREG_SET_ORDER); $results = array(); + preg_match_all('#<a[^>]*href="(?<url>[^"]*result[^"]*)"[^>]*>[^<]*(?<name>[0-9]{4}[^<]*Results)</a>#', $page, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $k = implode(' ', array_slice(explode(' ', $match['name']), 0, 3)); - $results[$k] = url_merge($url, $match['url']); + $results[strtolower($k)] = url_merge($url, $match['url']); + } + preg_match_all('#<p>[^<]*(?<name>[0-9]{4}[^<]*)<a[^>]*href="(?<url>[^"]*result[^"]*)"[^>]*>here</a>#', $page, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + $k = implode(' ', array_slice(explode(' ', $match['name']), 0, 3)); + $results[strtolower($k)] = url_merge($url, $match['url']); } - $page = curlexec($URL); + $page = curlexec($URL, NULL, $curl_params); - if (!preg_match('#(\d{4})-(\d{4}) Schedule#', $page, $match)) return; + if (!preg_match('#>\s*(\d{4})-(\d{4})[^<]*Schedule#', $page, $match)) return; list(, $start_year, $end_year) = $match; preg_match_all("#(?<start_time>[^\s]+\s\d+)-(?<end_time>(?:[^\s]+\s)?\d+):(?<title>[^<]*)#", $page, $matches, PREG_SET_ORDER); @@ -57,6 +63,7 @@ date('Y', strtotime($start_time)) . ' ' . $title, ); foreach ($keys as $k) { + $k = strtolower($k); if (isset($results[$k])) { $c['standings_url'] = $results[$k]; unset($results[$k]); diff --git a/src/clist/admin.py b/src/clist/admin.py index 48d09e28..4b062ef9 100644 --- a/src/clist/admin.py +++ b/src/clist/admin.py @@ -3,7 +3,7 @@ from django.utils import timezone from sql_util.utils import SubqueryCount -from clist.models import Banner, Contest, ContestSeries, Problem, ProblemTag, PromoLink, Promotion, Resource +from clist.models import Banner, Contest, ContestSeries, Discussion, Problem, ProblemTag, PromoLink, Promotion, Resource from pyclist.admin import BaseModelAdmin, admin_register from ranking.management.commands.parse_statistic import Command as parse_stat from ranking.models import Module, Rating @@ -249,3 +249,9 @@ class PromotionAdmin(BaseModelAdmin): class PromoLinkAdmin(BaseModelAdmin): list_display = ['name', 'enable', 'desc', 'url'] search_fields = ['name'] + + +@admin_register(Discussion) +class DiscussionAdmin(BaseModelAdmin): + list_display = ['name', 'resource', 'contest', 'problem', 'what', 'where', 'created', 'modified'] + search_fields = ['name', 'resource', 'contest', 'problem'] diff --git a/src/clist/api/v2.py b/src/clist/api/v2.py index 92fd064d..7055f7b6 100644 --- a/src/clist/api/v2.py +++ b/src/clist/api/v2.py @@ -353,7 +353,7 @@ def dehydrate(self, *args, **kwargs): for k in list(problem.keys()): if k.startswith('_'): problem.pop(k, None) - for k in settings.PROBLEM_API_IGNORE_FIELD: + for k in settings.PROBLEM_API_IGNORE_FIELDS: problem.pop(k, None) more_fields = bundle.data['more_fields'] diff --git a/src/clist/migrations/0167_create_discussion.py b/src/clist/migrations/0167_create_discussion.py new file mode 100644 index 00000000..6bf6b3e2 --- /dev/null +++ b/src/clist/migrations/0167_create_discussion.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.4 on 2024-12-25 22:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('clist', '0167_discussion'), ('clist', '0168_discussion_info_discussion_with_problem_discussions_and_more'), ('clist', '0169_alter_problem_index')] + + dependencies = [ + ('clist', '0166_contest_promotion'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='Discussion', + fields=[ + ('id', models.AutoField(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)), + ('name', models.CharField(max_length=255)), + ('url', models.URLField()), + ('what_id', models.PositiveIntegerField()), + ('where_id', models.PositiveIntegerField()), + ('contest', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='clist.contest')), + ('problem', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='clist.problem')), + ('resource', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='clist.resource')), + ('what_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discussion_what', to='contenttypes.contenttype')), + ('where_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discussion_where', to='contenttypes.contenttype')), + ('info', models.JSONField(blank=True, default=dict)), + ('with_problem_discussions', models.BooleanField(default=False)), + ], + options={ + 'unique_together': {('what_type', 'what_id', 'where_type', 'where_id')}, + 'indexes': [models.Index(fields=['what_type', 'what_id', 'with_problem_discussions'], name='clist_discu_what_ty_760c53_idx')], + }, + ), + migrations.AlterField( + model_name='problem', + name='index', + field=models.SmallIntegerField(blank=True, null=True), + ), + ] diff --git a/src/clist/models.py b/src/clist/models.py index 521e1c8a..75018a47 100644 --- a/src/clist/models.py +++ b/src/clist/models.py @@ -16,7 +16,8 @@ import requests import xgboost as xgb from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.db import models, transaction from django.db.models import Case, F, Max, Q, When @@ -522,6 +523,12 @@ class Contest(BaseModel): event_logs = GenericRelation('logify.EventLog', related_query_name='contest') virtual_starts = GenericRelation('ranking.VirtualStart', related_query_name='contest') + discussions = GenericRelation( + 'clist.Discussion', + content_type_field='what_type', + object_id_field='what_id', + related_query_name='what_contest', + ) has_submissions = models.BooleanField(default=None, null=True, blank=True, db_index=True) has_submissions_tests = models.BooleanField(default=None, null=True, blank=True, db_index=True) @@ -683,21 +690,20 @@ def month_regex(cls): return cls._month_regex @staticmethod - def title_neighbors_(title, deep, viewed): + def _title_neighbors(title, deep, viewed): viewed.add(title) if deep == 0: return - for match in re.finditer(rf'([0-9]+|[A-Z]\b|{Contest.month_regex()})', title): + for match in re.finditer(rf'(?P<number>\b[0-9]+\b(?:[\W\S]\b[0-9]+\b)*)|(?P<letter>[A-Z]\b)|(?P<month>{Contest.month_regex()})', title): for delta in (-1, 1): base_title = title - value = match.group(0) values = [] - if value.isdigit(): - value = str(int(value) + delta) - elif len(value) == 1: + if value := match.group('number'): + value = re.sub('[0-9]+', lambda x: str(int(x.group()) + delta), value) + elif value := match.group('letter'): value = chr(ord(value) + delta) - else: + elif value := match.group('month'): mformat = '%b' if len(value) == 3 else '%B' index = datetime.strptime(value.title(), mformat).month mformats = ['%b', '%B'] if index == 5 else [mformat] @@ -714,14 +720,14 @@ def title_neighbors_(title, deep, viewed): new_title = base_title[:match.start()] + value + base_title[match.end():] if new_title in viewed: continue - Contest.title_neighbors_(new_title, deep=deep - 1, viewed=viewed) + Contest._title_neighbors(new_title, deep=deep - 1, viewed=viewed) def similar_contests(self): return similar_contests_queryset(self) def neighbors(self): viewed = set() - Contest.title_neighbors_(self.title, deep=1, viewed=viewed) + Contest._title_neighbors(self.title, deep=1, viewed=viewed) qs = None @@ -932,16 +938,18 @@ def problem_rating_update_done(self): def get_statistics_order(self): options = self.info.get('standings', {}) - contest_fields = self.info.get('fields', []).copy() + fields = self.info.get('fields', []) resource_standings = self.resource.info.get('standings', {}) order = copy.copy(options.get('order', resource_standings.get('order'))) if order: for f in order: - if f.startswith('addition__') and f.split('__', 1)[1] not in contest_fields: + if f.startswith('addition__') and f.split('__', 1)[1] not in fields: order = None break if order is None: order = ['place_as_int', '-solving'] + if 'penalty' in fields: + order.append('penalty') return order @transaction.atomic @@ -1046,7 +1054,7 @@ class Problem(BaseModel): time = models.DateTimeField(default=None, null=True, blank=True) start_time = models.DateTimeField(default=None, null=True, blank=True) end_time = models.DateTimeField(default=None, null=True, blank=True) - index = models.SmallIntegerField(null=True) + index = models.SmallIntegerField(null=True, blank=True) key = models.TextField() name = models.TextField() slug = models.TextField(default=None, null=True, blank=True) @@ -1070,6 +1078,12 @@ class Problem(BaseModel): activities = GenericRelation('favorites.Activity', related_query_name='problem') notes = GenericRelation('notes.Note', related_query_name='problem') + discussions = GenericRelation( + 'clist.Discussion', + content_type_field='what_type', + object_id_field='what_id', + related_query_name='what_problem', + ) objects = BaseManager() visible_objects = VisibleProblemManager() @@ -1158,6 +1172,13 @@ def update_tags(self, tags, replace): for tag in old_tags: self.tags.remove(tag) + @property + def full_name(self): + short_or_key = self.short or self.key + if short_or_key == self.name: + return short_or_key + return f'{short_or_key}. {self.name}' + class ProblemTag(BaseModel): name = models.TextField(unique=True, db_index=True, null=False) @@ -1281,3 +1302,28 @@ def save(self, *args, **kwargs): def __str__(self): return f'{self.name} PromoLink#{self.id}' + + +class Discussion(BaseModel): + name = models.CharField(max_length=255) + url = models.URLField() + resource = models.ForeignKey(Resource, null=True, blank=True, on_delete=models.CASCADE) + contest = models.ForeignKey(Contest, null=True, blank=True, on_delete=models.CASCADE) + problem = models.ForeignKey(Problem, null=True, blank=True, on_delete=models.CASCADE) + what_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='discussion_what') + what_id = models.PositiveIntegerField() + what = GenericForeignKey('what_type', 'what_id') + where_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='discussion_where') + where_id = models.PositiveIntegerField() + where = GenericForeignKey('where_type', 'where_id') + info = models.JSONField(default=dict, blank=True) + with_problem_discussions = models.BooleanField(default=False) + + def __str__(self): + return f'{self.name} Discussion#{self.id}' + + class Meta: + unique_together = ('what_type', 'what_id', 'where_type', 'where_id') + indexes = [ + models.Index(fields=['what_type', 'what_id', 'with_problem_discussions']), + ] diff --git a/src/clist/templatetags/extras.py b/src/clist/templatetags/extras.py index 706c7295..c59d6aec 100644 --- a/src/clist/templatetags/extras.py +++ b/src/clist/templatetags/extras.py @@ -209,8 +209,10 @@ def countdown(timer): c = 0 if d > 2: return "%d days" % d + if h > 5: + return "%d hours" % h if m + h > 0: - return "%02d:%02d:%02d" % (h, m, s) + return "%d:%02d:%02d" % (h, m, s) return "%d.%d" % (s, c) @@ -493,7 +495,12 @@ def get_problem_solution(problem): group_scores[p['group']] += score result['result'] = group_scores[p['group']] - res = {'statistic': statistic, 'result': result, 'key': short} + res = { + 'contest': contest, + 'statistic': statistic, + 'result': result, + 'key': short, + } if ( not ret or ret['result'] is None or result is not None and is_improved_solution(result, ret['result']) @@ -906,8 +913,6 @@ def next_time_to(obj, now): @register.filter def is_solved(value, with_upsolving=False): - if not value: - return False if isinstance(value, dict): if with_upsolving and is_solved(value.get('upsolving')): return True @@ -915,12 +920,12 @@ def is_solved(value, with_upsolving=False): return False if value.get('binary') is True: return True - value = value.get('result', 0) + value = value.get('result') + if value is None: + return False if isinstance(value, str): if value.startswith('+'): return True - if value.startswith('-'): - return False try: value = float(value) except ValueError: @@ -930,22 +935,21 @@ def is_solved(value, with_upsolving=False): @register.filter def is_reject(value, with_upsolving=False): - if with_upsolving and is_solved(value, with_upsolving=with_upsolving): - return False if isinstance(value, dict): if with_upsolving and is_reject(value.get('upsolving')): return True if value.get('binary') is False: return True value = value.get('result') - if not value: - return False - if str(value).startswith('-'): - return True - try: - value = float(value) - except ValueError: + if value is None: return False + if isinstance(value, str): + if value.startswith('-'): + return True + try: + value = float(value) + except ValueError: + return False return value <= 0 @@ -953,27 +957,25 @@ def is_reject(value, with_upsolving=False): def is_upsolved(value): if not value: return False - return is_solved(value.get('upsolving')) + return isinstance(value, dict) and is_solved(value.get('upsolving')) @register.filter def is_hidden(value, with_upsolving=False): - if with_upsolving and is_solved(value, with_upsolving=with_upsolving): - return False if isinstance(value, dict): if with_upsolving and is_hidden(value.get('upsolving')): return True value = value.get('result') - if not value: - return False - return str(value).startswith('?') + return isinstance(value, str) and value.startswith('?') @register.filter def is_partial(value, with_upsolving=False): - if with_upsolving and is_solved(value, with_upsolving=with_upsolving): + if is_solved(value, with_upsolving=with_upsolving): return False - if not value: + if is_reject(value, with_upsolving=with_upsolving): + return False + if not value or not isinstance(value, dict): return False return value.get('partial') or with_upsolving and is_partial(value.get('upsolving')) @@ -1053,6 +1055,11 @@ def timestamp_to_datetime(value): return None +@register.filter +def has_passed_since_timestamp(value): + return now().timestamp() - value + + @register.filter def highlight_class(lang): lang = lang.lower() @@ -1620,7 +1627,7 @@ def is_yes(value): def is_optional_yes(value): if value is None: return None - return str(value).lower() in settings.YES_ + return is_yes(value) @register.filter @@ -1892,3 +1899,13 @@ def field_to_select_option(context, value): if 'value_option' in context['data']: return get_item(value, context['data']['value_option']) return value + + +@register.filter +def ifor(value, arg): + return value or arg + + +@register.filter +def ifand(value, arg): + return value and arg diff --git a/src/clist/utils.py b/src/clist/utils.py index 84242faa..6e8e208b 100644 --- a/src/clist/utils.py +++ b/src/clist/utils.py @@ -1,8 +1,14 @@ #!/usr/bin/env python3 +import logging import re from functools import cache +from importlib import import_module +from urllib.parse import urljoin +from django.apps import apps +from django.contrib.contenttypes.models import ContentType +from django.db import transaction from django.db.models import Q from django.urls import reverse @@ -84,3 +90,42 @@ def similar_contests_queryset(contest): contests_filter |= Q(key__iregex=key_regex) contests_filter &= Q(resource_id=contest.resource_id, stage__isnull=True) return contest._meta.model.objects.filter(contests_filter) + + +class CreateContestProblemDiscussionError(Exception): + pass + + +def create_contest_problem_discussions(contest): + for problem in contest.problem_set.all(): + create_contest_problem_discussion(contest, problem) + + +def create_contest_problem_discussion(contest, problem): + Discussion = apps.get_model('clist.Discussion') + contest_discussions = Discussion.objects.filter( + contest=contest, + what_contest=contest, + with_problem_discussions=True, + where_telegram_chat__isnull=False, + ) + TelegramBot = import_module('tg.bot').Bot() + for discussion in contest_discussions: + telegram_chat = discussion.where + if Discussion.objects.filter(what_problem=problem, where_telegram_chat=telegram_chat).exists(): + continue + with transaction.atomic(): + topic = None + try: + discussion.id = None + discussion.problem = problem + discussion.what = problem + discussion.save() + topic = TelegramBot.create_topic(telegram_chat.chat_id, problem.full_name) + discussion.url = urljoin(discussion.url, str(topic.message_thread_id)) + discussion.info = {'topic': topic.to_dict()} + discussion.save() + except Exception as e: + if topic is not None: + TelegramBot.delete_topic(telegram_chat.chat_id, topic.message_thread_id) + raise CreateContestProblemDiscussionError(e) diff --git a/src/clist/views.py b/src/clist/views.py index b4db2cef..b7dbaef3 100644 --- a/src/clist/views.py +++ b/src/clist/views.py @@ -28,6 +28,7 @@ from clist.templatetags.extras import (as_number, canonize, get_item, get_problem_key, get_problem_name, get_problem_short, get_timezone_offset, is_yes, rating_from_probability, win_probability) +from clist.utils import create_contest_problem_discussion from favorites.models import Activity from favorites.templatetags.favorites_extras import activity_icon from notification.management.commands import sendout_tasks @@ -834,6 +835,16 @@ def update_problems(contest, problems=None, force=False): old_problem_ids |= set(contest.individual_problem_set.values_list('id', flat=True)) added_problems = dict() + def link_problem_to_contest(problem, contest): + ret = not problem.contests.filter(pk=contest.pk).exists() + if ret: + create_contest_problem_discussion(contest, problem) + problem.contests.add(contest) + if problem.id in old_problem_ids: + old_problem_ids.remove(problem.id) + new_problem_ids.add(problem.id) + return ret + while not contests_queue.empty(): current_contest = contests_queue.get() problem_sets = current_contest.division_problems @@ -867,9 +878,7 @@ def update_problems(contest, problems=None, force=False): key=key, ).first() if problem: - problem.contests.add(contest) - if problem.id in old_problem_ids: - old_problem_ids.remove(problem.id) + link_problem_to_contest(problem, contest) continue url = info.pop('url', None) @@ -950,16 +959,13 @@ def update_problems(contest, problems=None, force=False): key=key, defaults=defaults, ) - problem.contests.add(contest) + + link_problem_to_contest(problem, contest) problem.update_tags(problem_info.get('tags'), replace=not added_problem) added_problems[key] = problem - if problem.id in old_problem_ids: - old_problem_ids.remove(problem.id) - new_problem_ids.add(problem.id) - for c in problem.contests.all(): if c.pk in contests_set: continue diff --git a/src/notification/migrations/0043_subscription_with_custom_names.py b/src/notification/migrations/0043_subscription_with_custom_names.py new file mode 100644 index 00000000..949ffae5 --- /dev/null +++ b/src/notification/migrations/0043_subscription_with_custom_names.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2024-12-25 08:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notification', '0042_edit_subscription'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='with_custom_names', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/notification/models.py b/src/notification/models.py index 8fdb1b1b..834c0f40 100644 --- a/src/notification/models.py +++ b/src/notification/models.py @@ -133,6 +133,7 @@ class Subscription(TaskNotification): last_update = models.DateTimeField(null=True, blank=True) with_first_accepted = models.BooleanField(default=False) top_n = models.IntegerField(null=True, blank=True) + with_custom_names = models.BooleanField(default=False) tasks = GenericRelation( 'Task', diff --git a/src/notification/utils.py b/src/notification/utils.py index 67a843b4..c4ac7ccc 100644 --- a/src/notification/utils.py +++ b/src/notification/utils.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.core.management import call_command +from django.db.models import Q from django.urls import reverse from clist.templatetags.extras import (as_number, get_division_problems, get_problem_name, get_problem_short, is_hidden, @@ -12,7 +13,23 @@ solution_time_compare) -def compose_message_by_problems(problem_shorts, statistic, previous_addition, contest_or_problems): +def compose_message_by_problems( + problem_shorts, + statistic, + previous_addition, + contest_or_problems, + subscription=None, + general_message=None, +): + with_subscription_names = ( + subscription + and subscription.with_custom_names + and subscription.coder_list + and subscription.coder_list.with_names + ) + if general_message is not None and not with_subscription_names: + return general_message + problems = statistic.addition.get('problems', {}) previous_problems = previous_addition.get('problems', {}) @@ -79,7 +96,15 @@ def compose_message_by_problems(problem_shorts, statistic, previous_addition, co place_message = '%s->%s' % (previous_place, statistic.place) standings_url = reverse('ranking:standings_by_id', args=[statistic.contest_id]) + f'?find_me={statistic.pk}' - account_message = '[%s](%s)' % (md_url_text(statistic.account_name), md_url(standings_url)) + account_name = statistic.account_name + if with_subscription_names: + groups = subscription.coder_list.groups.filter(name__isnull=False) + groups = groups.filter(Q(values__account=statistic.account) | + Q(values__coder__account=statistic.account)) + group = groups.first() + if group: + account_name = group.name + account_message = '[%s](%s)' % (md_url_text(account_name), md_url(standings_url)) if statistic.account.country: account_message = flag.flag(statistic.account.country.code) + account_message diff --git a/src/pyclist/settings.py b/src/pyclist/settings.py index c9a2eed1..b10ae458 100644 --- a/src/pyclist/settings.py +++ b/src/pyclist/settings.py @@ -807,11 +807,13 @@ def show_toolbar_callback(request): 'rating_prediction': '<i class="fa-solid fa-calculator"></i>', 'https': '<i class="fa-regular fa-square-check"></i>', 'http': '<i class="fa-regular fa-rectangle-xmark"></i>', + 'add': '<i class="fas fa-plus"></i>', 'edit': '<i class="fa-regular fa-pen-to-square"></i>', 'login': '<i class="fa-solid fa-right-to-bracket"></i>', 'logout': '<i class="fa-solid fa-right-from-bracket"></i>', 'expires': '<i class="fa-solid fa-clock-rotate-left"></i>', 'subscription': '<i class="fa-regular fa-newspaper"></i>', + 'field_instead_key': '<i class="fa-solid fa-pencil"></i>', 'google': {'icon': '<i class="fab fa-google"></i>', 'title': None}, 'facebook': {'icon': '<i class="fab fa-facebook"></i>', 'title': None}, @@ -834,6 +836,7 @@ def show_toolbar_callback(request): STANDINGS_WITH_DETAIL_DEFAULT = True STANDINGS_WITH_SOLUTION_DEFAULT = False +STANDINGS_WITH_AUTORELOAD_DEFAULT = True STANDINGS_SMALL_N_STATISTICS = 1000 STANDINGS_FREEZE_DURATION_FACTOR_DEFAULT = 0.2 STANDINGS_UNSPECIFIED_PLACE = '-' diff --git a/src/ranking/admin.py b/src/ranking/admin.py index a41e07e1..e0ae82aa 100644 --- a/src/ranking/admin.py +++ b/src/ranking/admin.py @@ -61,7 +61,8 @@ def _has_coder(self, obj): def get_readonly_fields(self, request, obj=None): return ( ['updated', 'n_contests', 'n_writers', 'n_subscribers', - 'last_activity', 'last_submission', 'last_rating_activity'] + 'last_activity', 'last_submission', 'last_rating_activity', + 'rating_update_time'] + super().get_readonly_fields(request, obj) ) diff --git a/src/ranking/management/commands/parse_accounts_infos.py b/src/ranking/management/commands/parse_accounts_infos.py index b66937d5..8a25d3c4 100644 --- a/src/ranking/management/commands/parse_accounts_infos.py +++ b/src/ranking/management/commands/parse_accounts_infos.py @@ -19,7 +19,7 @@ from ranking.management.modules.excepts import ExceptionParseAccounts from ranking.models import Account from ranking.utils import account_update_contest_additions, rename_account -from true_coders.models import Coder +from true_coders.models import Coder, CoderList from utils.attrdict import AttrDict from utils.countrier import Countrier from utils.traceback_with_vars import colored_format_exc @@ -60,6 +60,8 @@ def add_arguments(self, parser): 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') + parser.add_argument('--coder-list', type=str, help='account from coder list') + def handle(self, *args, **options): self.stdout.write(str(options)) @@ -132,6 +134,9 @@ def handle(self, *args, **options): accounts = accounts.filter(statistics__contest_id=args.contest_id) if args.with_coders: accounts = accounts.filter(coders__isnull=False) + if args.coder_list: + accounts_filter = CoderList.accounts_filter(uuids=[args.coder_list]) + accounts = accounts.filter(accounts_filter) total = accounts.count() if not total: @@ -268,8 +273,6 @@ def inf_none(): if 'name' in info: name = info['name'] account.name = name if name and name != account.key else None - if 'rating' in info and account.info.get('rating') != info['rating']: - info['_rating_time'] = int(now.timestamp()) delta = timedelta(**resource_info.get('delta', {'days': 365})) delta = info.pop('delta', delta) @@ -279,15 +282,16 @@ def inf_none(): if k not in info and not Account.is_special_info_field(k): info[k] = v + outdated = account.info.pop('outdated_', {}) + special_info_fields = data.get('special_info_fields') or set() for k, v in account.info.items(): - if args.all or k not in info and Account.is_special_info_field(k): + if ( + args.all or + k not in info and (Account.is_special_info_field(k) or k in special_info_fields) + ): info[k] = v - - outdated = account.info.pop('outdated_', {}) - outdated.update(account.info) - for k in info.keys(): - if k in outdated: - outdated.pop(k) + elif k not in info: + outdated[k] = v info['outdated_'] = outdated account.info = info diff --git a/src/ranking/management/commands/parse_statistic.py b/src/ranking/management/commands/parse_statistic.py index a96d6893..62fa2be2 100644 --- a/src/ranking/management/commands/parse_statistic.py +++ b/src/ranking/management/commands/parse_statistic.py @@ -32,6 +32,7 @@ from clist.templatetags.extras import (as_number, canonize, get_item, get_number_from_str, get_problem_key, get_problem_short, is_hidden, is_solved, normalize_field, time_in_seconds, time_in_seconds_format) +from clist.utils import create_contest_problem_discussions from clist.views import update_problems, update_writers from logify.models import EventLog, EventStatus from notification.models import NotificationMessage, Subscription @@ -1069,11 +1070,8 @@ def update_account_info(): account_info = r.pop('info', {}) if account_info: update_fields = ['info'] - if 'rating' in account_info: - if is_major_kind: - account_info['_rating_time'] = int(now.timestamp()) - else: - account_info.pop('rating') + if 'rating' in account_info and not is_major_kind: + account_info.pop('rating') if 'name' in account_info: name = account_info.pop('name') name = name if name and name != account.key else None @@ -1170,6 +1168,7 @@ def get_addition(): 'place_as_int': place if place == UNCHANGED else get_number_from_str(place), 'solving': r.pop('solving', 0), 'upsolving': r.pop('upsolving', 0), + 'penalty': as_number(r.get('penalty'), force=True), 'skip_in_stats': skip_result, 'advanced': bool(r.get('advanced')), 'last_activity': r.pop('last_activity', None), @@ -1216,16 +1215,19 @@ def get_addition(): if not skip_result: fields_preceding[f] |= previous_fields - rating_time = int(min(contest.end_time, now).timestamp()) + rating_time = min(contest.end_time, now) if ( is_major_kind and 'new_rating' in addition - and ('rating' not in account.info - or account.info.get('_rating_time', -1) <= rating_time) + and ( + 'rating' not in account.info + or account.rating_update_time is None + or account.rating_update_time < rating_time + ) ): - account.info['_rating_time'] = rating_time account.info['rating'] = addition['new_rating'] - account.save(update_fields=['info']) + account.rating_update_time = rating_time + account.save(update_fields=['info', 'rating_update_time']) try_calculate_time = contest.calculate_time or ( contest.start_time <= now < contest.end_time and @@ -1292,7 +1294,7 @@ def update_after_update_or_create(statistic, created, addition, try_calculate_ti updated_statistics_ids.append(statistic.pk) for field, lhs, rhs in ( - ('place', statistic.place, stat.get('place')), + ('place', str(statistic.place), str(stat.get('place'))), ('score', statistic.solving, stat.get('score')), ): if lhs != rhs and statistic.pk not in updated_statistics_ids: @@ -1507,12 +1509,13 @@ def process_subscriptions(statistic, updates): ): return - subscription_message = compose_message_by_problems( - updates['problems'], - statistic=statistic, - previous_addition=stat, - contest_or_problems=standings_problems, - ) + kwargs = { + 'problem_shorts': updates['problems'], + 'statistic': statistic, + 'previous_addition': stat, + 'contest_or_problems': standings_problems, + } + subscription_message = compose_message_by_problems(**kwargs) subscriptions_filter = Q() if account.n_subscribers: @@ -1534,7 +1537,13 @@ def process_subscriptions(statistic, updates): if subscription.notification_key in already_sent: continue already_sent.add(subscription.notification_key) - subscription.send(message=subscription_message, contest=contest) + + message = compose_message_by_problems( + subscription=subscription, + general_message=subscription_message, + **kwargs, + ) + subscription.send(message=message, contest=contest) contest_log_counter['statistics_subscription'] += 1 update_addition_fields() @@ -1684,6 +1693,9 @@ def process_subscriptions(statistic, updates): if not no_update_problems: update_problems(contest, problems=standings_problems, force=force_problems) + if force_problems: + create_contest_problem_discussions(contest) + contest.save() if to_update_socket: diff --git a/src/ranking/management/modules/adventofcode.py b/src/ranking/management/modules/adventofcode.py index c927ff1e..ae7c4d73 100644 --- a/src/ranking/management/modules/adventofcode.py +++ b/src/ranking/management/modules/adventofcode.py @@ -12,9 +12,10 @@ import tqdm from clist.models import Contest +from clist.templatetags.extras import is_hidden from ranking.management.modules import conf from ranking.management.modules.common import REQ, BaseModule -from ranking.models import Account, VirtualStart +from ranking.models import Account, Statistics, VirtualStart class Statistic(BaseModule): @@ -54,6 +55,8 @@ def _get_problem_stats(year, day='[0-9]+'): def _set_medals(result, n_medals=False): for row in result.values(): for problem in row.get('problems', {}).values(): + if 'rank' not in problem: + continue rank = problem['rank'] if rank == 1: problem['first_ac'] = True @@ -98,7 +101,6 @@ def items_sort(d): has_virtual = False divisions_order = [] for division in 'virtual', 'diff', 'main': - is_main = division == 'main' is_virtual = division == 'virtual' is_diff = division == 'diff' times = defaultdict(list) @@ -123,15 +125,25 @@ def items_sort(d): if not solutions: division_result.pop(handle) continue + solutions = {str(day): solution for day, solution in solutions.items()} if is_virtual and handle in account_coders: virtual_starts = VirtualStart.filter_by_content_type(Contest) virtual_starts = virtual_starts.filter(object_id__in={c.pk for c in contests.values()}, coder__in=account_coders[handle]) - virtual_starts = {vs.entity.start_time: vs.start_time for vs in virtual_starts} + virtual_starts = {vs.entity.start_time: vs.start_time for vs in virtual_starts + if vs.start_time >= vs.entity.start_time} else: virtual_starts = {} + for start_time, virtual_start_time in virtual_starts.items(): + day = str(start_time.day) + for star in ['1', '2']: + solution = solutions.setdefault(day, {}) + if star not in solution: + solution[star] = {'hidden': True, 'virtual_start': virtual_start_time.timestamp()} + break + problems = row.setdefault('problems', OrderedDict()) for day, solution in items_sort(solutions): if not solution: @@ -157,10 +169,18 @@ def items_sort(d): if star == '1': problems_infos[k]['skip_for_divisions'] = ['diff'] - time = datetime.fromtimestamp(res['get_star_ts'], tz=timezone.utc) + if res.get('hidden'): + problem = { + 'result': '?', + 'is_virtual': True, + 'virtual_start_ts': res['virtual_start'], + } + problems[k] = problem + continue + time = datetime.fromtimestamp(res['get_star_ts'], tz=timezone.utc) virtual_start = virtual_starts.get(contest.start_time) - if is_virtual and virtual_start and day_start_time < virtual_start < time: + if is_virtual and virtual_start and virtual_start < time: time -= virtual_start - day_start_time has_virtual = True problem_is_virtual = True @@ -198,26 +218,30 @@ def items_sort(d): division_result.pop(handle) global_times = deepcopy(times) - for contest in contests.values(): - day = contest.key.split()[-1] - for stat in contest.statistics_set.values('addition__problems', 'account__key'): - account = stat['account__key'] - for star, p in stat['addition__problems'].items(): - star = 3 - int(star) - k = f'{day}.{star}' - if account in division_result and k in division_result[account]['problems']: - division_result[account]['problems'][k].update({ - 'global_rank': p['rank'], - 'global_score': p['result'], - }) - elif 'time_in_seconds' in p: - global_times[k].append((p['time_in_seconds'], -1)) + stats = Statistics.objects.filter(contest__in=contests.values()) + stats = stats.values('contest__key', 'addition__problems', 'account__key') + for stat in stats: + day = stat['contest__key'].split()[-1] + account = stat['account__key'] + for star, p in stat['addition__problems'].items(): + star = 3 - int(star) + k = f'{day}.{star}' + if account in division_result and k in division_result[account]['problems']: + division_result[account]['problems'][k].update({ + 'global_rank': p['rank'], + 'global_score': p['result'], + }) + elif 'time_in_seconds' in p: + global_times[k].append((p['time_in_seconds'], -1)) + for t in (times, global_times): for v in t.values(): v.sort() for row in division_result.values(): problems = row.setdefault('problems', {}) for k, p in row['problems'].items(): + if is_hidden(p): + continue time_value = (p['time_in_seconds'], p['time_index']) rank = times[k].index(time_value) + 1 score = max(local_best_score - rank + 1, 0) @@ -234,7 +258,7 @@ def items_sort(d): row['global_score'] += p.get('global_score', 0) for row in division_result.values(): - row['solving'] = sum(p['result'] for p in row['problems'].values()) + row['solving'] = sum(p['result'] for p in row['problems'].values() if not is_hidden(p)) last = None for idx, r in enumerate(sorted(division_result.values(), key=lambda r: -r['solving']), start=1): diff --git a/src/ranking/management/modules/codeforces.py b/src/ranking/management/modules/codeforces.py index ee159db5..47473474 100644 --- a/src/ranking/management/modules/codeforces.py +++ b/src/ranking/management/modules/codeforces.py @@ -2,6 +2,7 @@ import html import json +import os import re from collections import OrderedDict from copy import deepcopy @@ -226,6 +227,9 @@ def process_submission(submission, result, upsolve, contest, with_binary=False): if 'programmingLanguage' in submission: info['language'] = submission['programmingLanguage'] + if 'creationTimeSeconds' in submission: + info['submission_time'] = submission['creationTimeSeconds'] + is_accepted = info.get('verdict') == 'OK' if not is_accepted and 'passedTestCount' in submission: info['test'] = submission['passedTestCount'] + 1 @@ -672,6 +676,8 @@ def get_users_infos(users, resource=None, accounts=None, pbar=None): users.insert(index, user) assert len(infos) == len(users) + + parse_russian_name = 'CODEFORCES_PARSE_RUSSIAN_NAME' in os.environ for data, user, orig in zip(infos, users, orig_users): if data: if data['handle'].lower() != user.lower(): @@ -682,9 +688,25 @@ def get_users_infos(users, resource=None, accounts=None, pbar=None): data.pop('titlePhoto') data['name'] = ' '.join([data[f] for f in ['firstName', 'lastName'] if data.get(f)]) info = {'info': data} + + if parse_russian_name: + page = REQ.get(f'https://{SUBDOMAIN}codeforces.com/profile/{user}?locale=ru') + match = re.search( + r'''<div style="margin-top: 0.5em;">\s*''' + r'''<div style="font-size: 0.8em; color: #777;">(?P<name>[^<,]*)''', + page, + ) + if match: + data['name_ru'] = match.group('name').strip() + else: + info['special_info_fields'] = {'name_ru'} + if data and data['handle'] != orig: info['rename'] = data['handle'] yield info + if parse_russian_name: + REQ.get(f'https://{SUBDOMAIN}codeforces.com/?locale=en') + @staticmethod def get_source_code(contest, problem): diff --git a/src/ranking/management/modules/nerc_itmo.py b/src/ranking/management/modules/nerc_itmo.py index 3685a971..eca5fdd9 100644 --- a/src/ranking/management/modules/nerc_itmo.py +++ b/src/ranking/management/modules/nerc_itmo.py @@ -87,21 +87,31 @@ def get_standings(self, users=None, statistics=None, **kwargs): else: row[k] = v.value for f in 'diploma', 'medal', 'qual': - medal = row.pop(f, None) or row.pop(f.title(), None) - if medal: - if medal in ['З', 'G']: + value = row.pop(f, None) or row.pop(f.title(), None) + if not value: + continue + words = value.split() + skipped = [] + for w in words: + if w in ['З', 'G']: row['medal'] = 'gold' - elif medal in ['С', 'S']: + elif w in ['С', 'S']: row['medal'] = 'silver' - elif medal in ['Б', 'B']: + elif w in ['Б', 'B']: row['medal'] = 'bronze' + elif w in ['I', 'II', 'III']: + row['diploma'] = w + elif w in ['Q']: + row['advanced'] = True else: - row.update({ - "medal": "honorable", - "_honorable": medal, - "_medal_title_field": "_honorable" - }) - break + skipped.append(w) + if 'diploma' in row and 'medal' not in row: + row.update({ + "medal": "Diploma", + "_medal_title_field": "diploma" + }) + if skipped: + row['_{f}'] = ' '.join(skipped) if university_regex: match = re.search(university_regex, row['name']) if match: @@ -168,6 +178,6 @@ def get_region(team_name): 'url': self.standings_url, 'problems': list(problems_info.values()), 'problems_time_format': '{M}:{s:02d}', - 'hidden_fields': ['university', 'region', 'medal'], + 'hidden_fields': ['university', 'region', 'medal', 'diploma'], } return standings diff --git a/src/ranking/management/modules/ucup.py b/src/ranking/management/modules/ucup.py index b5971767..e5a3cfa7 100644 --- a/src/ranking/management/modules/ucup.py +++ b/src/ranking/management/modules/ucup.py @@ -10,9 +10,10 @@ import yaml from django.db.models import Q -from clist.templatetags.extras import as_number, is_yes +from clist.templatetags.extras import as_number, is_yes, slug from ranking.management.modules.common import LOG, REQ, BaseModule, FailOnGetResponse, parsed_table from ranking.management.modules.excepts import ExceptionParseStandings +from utils.strings import string_iou def extract_team_name(name): @@ -24,9 +25,38 @@ def extract_team_name(name): class Statistic(BaseModule): + def _detect_standings(self): + contest = self.resource.contest_set.filter(end_time__lt=self.start_time) + contest = contest.filter(standings_url__isnull=False, stage__isnull=True) + contest = contest.latest('end_time') + matches = list(re.finditer('[0-9]+', contest.standings_url)) + if not matches: + raise ExceptionParseStandings('No standings url, not found contest id in previous contest') + + match = matches[-1] + prefix = contest.standings_url[:match.start()] + suffix = contest.standings_url[match.end():] + contest_id = int(match.group()) + for delta in range(1, 30): + url = f'{prefix}{contest_id + delta}{suffix}' + page, code = REQ.get(url, return_code=True, ignore_codes={403, 404}) + if code == 403: + continue + if code == 404: + break + match = re.search('<div[^>]*class="text-center"[^>]*>\s*<h1[^>]*>([^<]+)</h1>', page) + if not match: + continue + title = match.group(1).strip() + + if string_iou(slug(title), slug(self.name)) > 0.95: + return url + + raise ExceptionParseStandings('No standings url, not found title matching') + def get_standings(self, users=None, statistics=None, **kwargs): if not self.standings_url: - raise ExceptionParseStandings('No standings url') + self.standings_url = self._detect_standings() season = self.info.get('parse', {}).get('season') season_ratings = {} @@ -279,6 +309,7 @@ def process_submission_page(page): pass standings = { + 'url': self.standings_url, 'result': result, 'problems': problems_infos, 'hidden_fields': ['total_rating', 'original_handle', 'affiliation', 'rating', 'standings_rating', diff --git a/src/ranking/management/modules/usaco.py b/src/ranking/management/modules/usaco.py index 9216413e..5866c012 100644 --- a/src/ranking/management/modules/usaco.py +++ b/src/ranking/management/modules/usaco.py @@ -1,24 +1,40 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import os import re import urllib.parse from collections import OrderedDict from copy import deepcopy -from datetime import datetime -from pprint import pprint from ranking.management.modules.common import REQ, BaseModule, parsed_table from ranking.management.modules.excepts import InitModuleException +def get_curl_args(): + if not hasattr('get_curl_args', '_curl_args'): + curl_args_filepath = 'sharedfiles/resource/usaco/curl.args' + if os.path.exists(curl_args_filepath): + with open(curl_args_filepath) as file: + get_curl_args._curl_args = file.read().strip() + else: + get_curl_args._curl_args = None + return get_curl_args._curl_args + + +def req_get(*args, **kwargs): + kwargs['with_curl'] = True + kwargs['curl_args'] = get_curl_args() + return REQ.get(*args, **kwargs) + + class Statistic(BaseModule): def __init__(self, **kwargs): super(Statistic, self).__init__(**kwargs) if not self.standings_url: url = 'http://usaco.org/index.php?page=contests' - page = REQ.get(url) + page = req_get(url) matches = re.finditer('<a[^>]*href="(?P<url>[^"]*)"[^>]*>(?P<name>[^<]*[0-9]{4}[^<]*Results)</a>', page) month = self.start_time.strftime('%B').lower() prev_standings_url = None @@ -33,7 +49,7 @@ def __init__(self, **kwargs): if prev_standings_url is not None: pred_standings_url = re.sub('[0-9]+', lambda m: str(int(m.group(0)) + 1), prev_standings_url) url = 'http://usaco.org/' - page = REQ.get(url) + page = req_get(url) matches = re.finditer('<a[^>]*href="?(?P<url>[^"]*)"?[^>]*>here</a>', page) for match in matches: standings_url = urllib.parse.urljoin(url, match.group('url')) @@ -83,7 +99,7 @@ def parse_problems(page, full=False): return problemsets if full else problems - page = REQ.get(self.standings_url) + page = req_get(self.standings_url) divisions = list(re.finditer('<a[^>]*href="(?P<url>[^"]*data[^"]*_(?P<name>[^_]*)_results.html)"[^>]*>', page)) descriptions = [] prev_span = None @@ -99,7 +115,7 @@ def parse_problems(page, full=False): match = re.search('''<a[^>]*href=["'](?P<href>[^"']*page=[a-z0-9]+problems)["'][^>]*>''', page) if match: url = urllib.parse.urljoin(self.standings_url, match.group('href')) - page = REQ.get(url) + page = req_get(url) problemsets = parse_problems(page, full=True) assert len(divisions) == len(problemsets) else: @@ -121,7 +137,7 @@ def parse_problems(page, full=False): p['short'] = d0 + p['short'] url = urllib.parse.urljoin(self.standings_url, division_match.group('url')) - page = REQ.get(url) + page = req_get(url) tables = re.finditer(r'>(?P<title>[^<]*)</[^>]*>\s*(?P<html><table[^>]*>.*?</table>)', page, re.DOTALL) for table_match in tables: diff --git a/src/ranking/migrations/0136_rating_update_time_field.py b/src/ranking/migrations/0136_rating_update_time_field.py new file mode 100644 index 00000000..95709116 --- /dev/null +++ b/src/ranking/migrations/0136_rating_update_time_field.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.4 on 2024-12-25 22:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('ranking', '0136_statistics_penalty_and_more'), ('ranking', '0137_account_rating_update_time')] + + dependencies = [ + ('clist', '0166_contest_promotion'), + ('ranking', '0135_alter_parsestatistics_contest'), + ] + + operations = [ + migrations.AddField( + model_name='statistics', + name='penalty', + field=models.FloatField(blank=True, default=None, null=True), + ), + migrations.AddIndex( + model_name='statistics', + index=models.Index(fields=['place_as_int', '-solving', 'penalty'], name='ranking_sta_place_a_280c4a_idx'), + ), + migrations.AddIndex( + model_name='statistics', + index=models.Index(fields=['contest', 'place_as_int', '-solving', 'penalty', 'id'], name='ranking_sta_contest_5962eb_idx'), + ), + migrations.AddField( + model_name='account', + name='rating_update_time', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + ] diff --git a/src/ranking/models.py b/src/ranking/models.py index ca8bbc05..d5e0f067 100644 --- a/src/ranking/models.py +++ b/src/ranking/models.py @@ -10,7 +10,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.management import call_command from django.db import models -from django.db.models import F, OuterRef, Q, Sum +from django.db.models import F, OuterRef, Prefetch, Q, Sum from django.db.models.functions import Coalesce, Upper from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save from django.dispatch import receiver @@ -50,6 +50,7 @@ class Account(BaseModel): last_rating_activity = models.DateTimeField(default=None, null=True, blank=True, db_index=True) rating = models.IntegerField(default=None, null=True, blank=True, db_index=True) rating50 = models.SmallIntegerField(default=None, null=True, blank=True, db_index=True) + rating_update_time = models.DateTimeField(default=None, null=True, blank=True) resource_rank = models.IntegerField(null=True, blank=True, default=None, db_index=True) info = models.JSONField(default=dict, blank=True) updated = models.DateTimeField(auto_now_add=True) @@ -436,6 +437,7 @@ class Statistics(BaseModel): place_as_int = models.IntegerField(default=None, null=True, blank=True) solving = models.FloatField(default=0, blank=True) upsolving = models.FloatField(default=0, blank=True) + penalty = models.FloatField(default=None, null=True, blank=True) addition = models.JSONField(default=dict, blank=True) url = models.TextField(null=True, blank=True) new_global_rating = models.IntegerField(null=True, blank=True, default=None, db_index=True) @@ -498,8 +500,10 @@ class Meta: indexes = [ models.Index(fields=['place_as_int', 'created']), models.Index(fields=['place_as_int', '-solving']), + models.Index(fields=['place_as_int', '-solving', 'penalty']), models.Index(fields=['place_as_int', '-created']), models.Index(fields=['contest', 'place_as_int', '-solving', 'id']), + models.Index(fields=['contest', 'place_as_int', '-solving', 'penalty', 'id']), models.Index(fields=['contest', 'account']), models.Index(fields=['contest', 'advanced', 'place_as_int']), models.Index(fields=['contest', 'account', 'advanced', 'place_as_int']), @@ -581,9 +585,12 @@ class Meta: indexes = [models.Index(fields=['coder', 'content_type', 'object_id'])] @classmethod - def filter_by_content_type(cls, model_class): + def filter_by_content_type(cls, model_class, prefetch=True): content_type = ContentType.objects.get_for_model(model_class) - return cls.objects.filter(content_type=content_type) + qs = cls.objects.filter(content_type=content_type) + if prefetch: + qs = qs.prefetch_related(Prefetch('entity', queryset=model_class.objects.all())) + return qs @staticmethod def contests_filter(coder): diff --git a/src/ranking/views.py b/src/ranking/views.py index 7ce40ecb..234e4c00 100644 --- a/src/ranking/views.py +++ b/src/ranking/views.py @@ -12,7 +12,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.db import models -from django.db.models import Avg, Case, Count, Exists, F, OuterRef, Prefetch, Q, Value, When +from django.db.models import Avg, Case, Count, Exists, F, OuterRef, Prefetch, Q, Subquery, Value, When from django.db.models.expressions import RawSQL from django.db.models.functions import Cast, window from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, JsonResponse @@ -41,7 +41,7 @@ from ranking.management.modules.excepts import ExceptionParseStandings from ranking.models import Account, AccountRenaming, Module, Stage, Statistics, VirtualStart from tg.models import Chat -from true_coders.models import Coder, CoderList, Party +from true_coders.models import Coder, CoderList, ListGroup, Party from true_coders.views import get_ratings_data from utils.chart import make_bins, make_histogram from utils.colors import get_n_colors @@ -712,6 +712,8 @@ def timeline_format(t): def render_standings_paging(contest, statistics, with_detail=True): + contest_fields = contest.info.get('fields', []) + n_total = None if isinstance(statistics, list): per_page = contest.standings_per_page @@ -720,13 +722,18 @@ def render_standings_paging(contest, statistics, with_detail=True): statistics = statistics[:per_page] statistics = Statistics.objects.filter(pk__in=statistics) - order = contest.get_statistics_order() + ['pk'] + order = contest.get_statistics_order() statistics = statistics.order_by(*order) + inplace_division = '_division_addition' in contest_fields divisions_order = get_standings_divisions_order(contest) division = divisions_order[0] if divisions_order else None if division: - statistics = statistics.filter(addition__division=division) + if inplace_division: + field = f'addition___division_addition__{division}' + statistics = statistics.filter(**{f'{field}__isnull': False}) + else: + statistics = statistics.filter(addition__division=division) problems = get_standings_problems(contest, division) @@ -737,7 +744,6 @@ def render_standings_paging(contest, statistics, with_detail=True): mod_penalty = get_standings_mod_penalty(contest, division, problems, statistics) colored_by_group_score = contest.info.get('standings', {}).get('colored_by_group_score') - contest_fields = contest.info.get('fields', []) has_country = ( 'country' in contest_fields or '_countries' in contest_fields or @@ -857,7 +863,7 @@ def get_standings_fields(contest, division, with_detail, hidden_fields=None, hid fixed_fields = ( 'penalty', ('total_time', 'Time'), - ('advanced', 'Advance'), + ('advanced', 'Adv'), ) fixed_fields += tuple(options.get('fixed_fields', [])) if not with_detail: @@ -983,14 +989,17 @@ def standings(request, contest, other_contests=None, template='standings.html', with_detail = is_optional_yes(request.GET.get('detail')) with_solution = is_optional_yes(request.GET.get('solution')) + with_autoreload = is_optional_yes(request.GET.get('autoreload')) if request.user.is_authenticated: coder = request.user.coder with_detail = coder.update_or_get_setting('standings_with_detail', with_detail) with_solution = coder.update_or_get_setting('standings_with_solution', with_solution) + with_autoreload = coder.update_or_get_setting('standings_with_autoreload', with_autoreload) else: coder = None - with_detail = with_detail if with_detail is not None else settings.STANDINGS_WITH_DETAIL_DEFAULT - with_solution = with_solution if with_solution is not None else settings.STANDINGS_WITH_SOLUTION_DEFAULT + with_detail = with_detail if with_detail is not None else settings.STANDINGS_WITH_DETAIL_DEFAULT + with_solution = with_solution if with_solution is not None else settings.STANDINGS_WITH_SOLUTION_DEFAULT + with_autoreload = with_autoreload if with_autoreload is not None else settings.STANDINGS_WITH_AUTORELOAD_DEFAULT with_row_num = False @@ -1102,8 +1111,9 @@ def standings(request, contest, other_contests=None, template='standings.html', fields[v] = v hidden_fields.append(v) - if (n_advanced := request.GET.get('n_advanced')) and n_advanced.isdigit() and 'n_highlight' in options: - options['n_highlight'] = int(n_advanced) + if n_advanced := request.GET.get('n_advanced'): + if n_advanced.isdigit() and int(n_advanced) and 'n_highlight' in options: + options['n_highlight'] = int(n_advanced) n_highlight_context = _standings_highlight(contest, statistics, options) if not contests_ids else {} # field to select @@ -1379,6 +1389,11 @@ def add_field_to_select(f): # subquery = Chat.objects.filter(coder=OuterRef('account__coders'), is_group=False).values('name')[:1] # statistics = statistics.annotate(chat_name=Subquery(subquery)) elif field == 'list': + if values: + groups = ListGroup.objects.filter(coder_list__uuid__in=values, name__isnull=False) + groups = groups.filter(Q(values__account=OuterRef('account')) | + Q(values__coder__account=OuterRef('account'))) + statistics = statistics.annotate(value_instead_key=Subquery(groups.values('name')[:1])) coders, accounts = CoderList.coders_and_accounts_ids(uuids=values, coder=coder) filt |= Q(account__coders__in=coders) | Q(account__in=accounts) else: @@ -1576,6 +1591,11 @@ def add_field_to_select(f): ): context['my_statistics_rev'] = True + # field_instead_key + if field_instead_key := request.GET.get('field_instead_key'): + if field_instead_key in contest_fields: + context['field_instead_key'] = f'addition__{field_instead_key}' + relative_problem_time = contest.resource.info.get('standings', {}).get('relative_problem_time') relative_problem_time = contest.info.get('standings', {}).get('relative_problem_time', relative_problem_time) context['relative_problem_time'] = relative_problem_time @@ -1641,6 +1661,7 @@ def add_field_to_select(f): 'truncatechars_name_problem': 10 * (2 if merge_problems else 1), 'with_detail': with_detail, 'with_solution': with_solution, + 'with_autoreload': with_autoreload, 'groupby': groupby, 'pie_limit_rows_groupby': 50, 'labels_groupby': labels_groupby, @@ -1709,7 +1730,7 @@ def add_field_to_select(f): return render(request, template, context) -@ratelimit(key='user', rate='1000/h', block=True) +@ratelimit(key='user', rate='20/m', block=True) def solutions(request, sid, problem_key): is_modal = request.is_ajax() if not request.user.is_authenticated: diff --git a/src/static/css/base.css b/src/static/css/base.css index f32b7d69..00d53db1 100644 --- a/src/static/css/base.css +++ b/src/static/css/base.css @@ -88,7 +88,6 @@ input[type="search"]::-webkit-search-cancel-button { object-fit: cover; border-radius: 50%; vertical-align: top; - margin-right: 5px; } .avatar-width-fixed { @@ -97,7 +96,6 @@ input[type="search"]::-webkit-search-cancel-button { display: inline-block; text-align: center; vertical-align: top; - margin-right: 5px; } /* Space out content a bit */ @@ -837,6 +835,10 @@ a[disabled] { padding: 0px; } +#filter-collapse .form-group { + margin-bottom: 0px; +} + #filter-collapse .input-group { display: inline-table; vertical-align: middle; @@ -848,6 +850,7 @@ a[disabled] { } #filter-toggle { + position: relative; margin-bottom: 5px; font-size: 10px; line-height: 1.0; @@ -994,6 +997,10 @@ a[data-toggle="tooltip"][disabled], * Table inner scroll */ +#table-inner-scroll.firefox table tr.starred td.sticky-column { + z-index: 21; +} + #table-inner-scroll table thead tr, #table-inner-scroll:not(.firefox) table tr.starred, #table-inner-scroll.firefox table tr.starred td /* https://bugzilla.mozilla.org/show_bug.cgi?id=1745323 */ @@ -1048,3 +1055,39 @@ table.table-border-collapse-separate { .field-to-input { max-width: 100px; } + + +/* + * Countdown + */ + +.countdown { + white-space: nowrap !important; +} + + +/* + * toastify notifications + */ + +.toastify-bootstrap { + border-radius: 3px 3px 0px 0px !important; + background-image: initial !important; + margin: 0px !important; + padding: 10px 15px !important; +} + +.alert-undefined { + background-color: #f0f0f0 !important; + border-color: #ccc !important; + color: #333 !important; +} + +.progress-bar-undefined { + background-color: #333 !important; +} + +@keyframes progress-animation { + from { width: 100% } + to { width: 0% } +} diff --git a/src/static/css/toastify.min.css b/src/static/css/toastify.min.css new file mode 100644 index 00000000..01174dcf --- /dev/null +++ b/src/static/css/toastify.min.css @@ -0,0 +1,15 @@ +/** + * Minified by jsDelivr using clean-css v5.3.2. + * Original file: /npm/toastify-js@1.12.0/src/toastify.css + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Toastify js 1.12.0 + * https://github.com/apvarun/toastify-js + * @license MIT licensed + * + * Copyright (C) 2018 Varun A P + */ +.toastify{padding:12px 20px;color:#fff;display:inline-block;box-shadow:0 3px 6px -1px rgba(0,0,0,.12),0 10px 36px -4px rgba(77,96,232,.3);background:-webkit-linear-gradient(315deg,#73a5ff,#5477f5);background:linear-gradient(135deg,#73a5ff,#5477f5);position:fixed;opacity:0;transition:all .4s cubic-bezier(.215, .61, .355, 1);border-radius:2px;cursor:pointer;text-decoration:none;max-width:calc(50% - 20px);z-index:2147483647}.toastify.on{opacity:1}.toast-close{background:0 0;border:0;color:#fff;cursor:pointer;font-family:inherit;font-size:1em;opacity:.4;padding:0 5px}.toastify-right{right:15px}.toastify-left{left:15px}.toastify-top{top:-150px}.toastify-bottom{bottom:-150px}.toastify-rounded{border-radius:25px}.toastify-avatar{width:1.5em;height:1.5em;margin:-7px 5px;border-radius:2px}.toastify-center{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content;max-width:-moz-fit-content}@media only screen and (max-width:360px){.toastify-left,.toastify-right{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content}} +/*# sourceMappingURL=/sm/cb4335d1b03e933ed85cb59fffa60cf51f07567ed09831438c60f59afd166464.map */ \ No newline at end of file diff --git a/src/static/flags/flags.css b/src/static/flags/flags.css index b48e2b29..56a0f70b 100644 --- a/src/static/flags/flags.css +++ b/src/static/flags/flags.css @@ -4,6 +4,7 @@ display: inline-block; text-align: center; vertical-align: top; + margin-right: 7px; } .select2-container div.flag { @@ -21,7 +22,8 @@ filter: drop-shadow(0px 0px 1px rgb(0 0 0 / 0.5)) opacity(80%); background-image: url('../flags/4x3/xx.svg'); transform: scale(1.3); - margin-right: 5px; + margin-left: 5px; + margin-right: 4px; } .flag:before { diff --git a/src/static/js/accounts.js b/src/static/js/accounts.js index 28da929f..a9a8584b 100644 --- a/src/static/js/accounts.js +++ b/src/static/js/accounts.js @@ -17,7 +17,7 @@ function init_account_buttons() { $btn.parent().children().toggleClass('hidden') $btn.toggleClass('hidden') $btn.closest('tr').toggleClass('info') - $.notify($btn.attr('data-message'), 'success') + notify($btn.attr('data-message'), 'success') }, error: function(response) { if (response.responseJSON && response.responseJSON.message == 'redirect') { @@ -41,7 +41,7 @@ function init_clickable_has_coders() { }) } -function invert_linked_coder_accounts(e) { +function invert_accounts(e, s) { e.preventDefault() - $('#accounts input[name="accounts"]').click() + $('#accounts input[name="' + $.escapeSelector(s) + '"]').click() } diff --git a/src/static/js/base.js b/src/static/js/base.js index 482f9ad7..5da00be9 100644 --- a/src/static/js/base.js +++ b/src/static/js/base.js @@ -157,7 +157,7 @@ function inline_button() { action: 'reset_contest_statistic_timing', cid: btn.attr('data-contest-id') }).done(function(data) { btn.attr('data-original-title', data.message).tooltip('show') - $.notify(data.message, data.status) + notify(data.message, data.status) }).fail(log_ajax_error_callback) }) @@ -225,7 +225,7 @@ function log_ajax_error(response, element = null) { element.text(message) element.removeClass('hidden') } else { - $.notify(message, 'error') + notify(message, 'error') } $('.bootbox').effect('shake') } @@ -297,7 +297,7 @@ function copyElementToClipboard(event, element) { copyTextToClipboard(text) el.attr('title', 'copied') el.tooltip('show') - $.notify('Copied "' + text + '" to clipboard', 'success') + notify('Copied "' + text + '" to clipboard', 'success') setTimeout(function() { el.attr('title', ''); el.tooltip('destroy'); }, 1000) return false } @@ -888,3 +888,61 @@ function restarred() { offset_height += el.height() }).css('z-index', '') } + +/* + * toastify notifications + */ + +Toastify.defaults.style = {} +Toastify.defaults.position = 'right' +Toastify.defaults.gravity= 'bottom' +Toastify.defaults.stopOnFocus = true +Toastify.defaults.duration = 4000 +Toastify.defaults.escapeMarkup = true + +function notify(message, type = 'success', duration = Toastify.defaults.duration) { + var escapeHTML = Toastify.defaults.escapeMarkup + if (typeof type == 'object') { + var options = type + type = options.type ?? 'success' + duration = options.duration ?? duration + escapeHTML = options.escapeHTML ?? escapeHTML + } + type = {error: 'danger', warn: 'warning'}[type] || type + if (!['danger', 'warning', 'success', 'info'].includes(type)) { + type = 'undefined' + } + + var toastify = Toastify({ + text: message, + duration: duration, + escapeMarkup: escapeHTML, + className: `toastify-bootstrap alert alert-${type}`, + }) + toastify.showToast() + const toastElement = $(toastify.toastElement) + + const progressBar = $('<div>', { + class: `progress-bar-${type}`, + style: ` + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 3px; + margin: -3px -1px; + animation: progress-animation ${Math.floor(duration / 1000)}s linear; + `, + }).on('animationend', () => { progressBar.css('width', '0%') }) + toastElement.append(progressBar) + + if (Toastify.defaults.stopOnFocus) { + toastElement.on('mouseenter', () => { + progressBar.css('animation', 'none') + progressBar.toggleClass('hidden') + }).on('mouseleave', () => { + progressBar.css('animation', `progress-animation ${Math.floor(duration / 1000)}s linear`) + progressBar.toggleClass('hidden') + }) + } +} diff --git a/src/static/js/chart-helper.js b/src/static/js/chart-helper.js index ffc9ae02..acb30d5e 100644 --- a/src/static/js/chart-helper.js +++ b/src/static/js/chart-helper.js @@ -4,7 +4,7 @@ function create_chart_config(resource_info, dates, y_field = 'new_rating', is_ad var min_field = Math.min(...values) var max_field = Math.max(...values) if (min_field === undefined || max_field === undefined || min_field == max_field) { - $.notify( + notify( 'Skip ' + y_field + ' field, min value = ' + min_field + ', max value = ' + max_field, {position: 'bottom right', className: 'warn'}, ) diff --git a/src/static/js/coder_list.js b/src/static/js/coder_list.js index 35a34f7c..6049498d 100644 --- a/src/static/js/coder_list.js +++ b/src/static/js/coder_list.js @@ -1,19 +1,37 @@ $(function() { $('#coder-list .add-account').click(function() { + clear_tooltip() event.preventDefault() $('<input>').attr({ type: 'hidden', - name: 'gid', - value: $(this).attr('data-gid'), - }).appendTo($('#coder-list').closest('form')) + name: 'group_id', + value: $(this).attr('data-group-id'), + }).appendTo( + $('#coder-list').closest('form') + ) $(this).closest('td').append($('#add-list-value')) $('#add-list-value-coder').remove() - $('#coder-list .add-account').remove() + $(this).remove() + }) + + $('#coder-list .edit-group').click(function() { + clear_tooltip() + event.preventDefault() + $(this).closest('td').find('[name="delete_value_id"]').toggleClass('hidden') + $(this).closest('td').find('[name="delete_group_id"]').toggleClass('hidden') + $(this).remove() }) $('#raw-value').on('change keyup paste', function() { $('#raw-submit').attr('disabled', !$(this).val()) }) + + $('.edit-name').editable({ + params: function(params) { + params.group_id = $(this).data('group-id') + return params + } + }) }) diff --git a/src/static/js/contest/calendar.js b/src/static/js/contest/calendar.js index 149753fb..1b86317e 100644 --- a/src/static/js/contest/calendar.js +++ b/src/static/js/contest/calendar.js @@ -11,15 +11,16 @@ $(function() { } $(window).resize(function() { - var height = get_calendar_height() - calendar.setOption('height', height) - calendar.setOption('contentHeight', height) + var calendar_height = get_calendar_height() + calendar.setOption('height', calendar_height) + calendar.setOption('contentHeight', calendar_height) $('.fc-timegrid').each(function() { var el = $(this).find('.fc-scroller:has(.fc-timegrid-body)') var height = el.height() var tr = $(this).find('.fc-timegrid-slots tr') - tr.height(height / tr.length) + var h = Math.round(height / tr.length) + tr.height(h) }) }) diff --git a/src/static/js/countdown.js b/src/static/js/countdown.js index 3c064623..571c7756 100644 --- a/src/static/js/countdown.js +++ b/src/static/js/countdown.js @@ -15,20 +15,28 @@ function getFormatTime(timer) var s = parseInt(timer % 60); var c = parseInt(timer % 1 * 10); var d = parseInt((h + 12) / 24); - if (h < 10) h = '0' + h; if (m < 10) m = '0' + m; if (s < 10 && h + m > 0) s = '0' + s; time_update = Math.min(time_update, HOUR); if (d > 2) return d + ' days'; + time_update = Math.min(time_update, MINUTE); + if (h > 5) return h + ' hours'; time_update = Math.min(time_update, SECOND); if (m + h > 0) return h + ':' + m + ':' + s; time_update = Math.min(time_update, SECOND / 10); return s + '.' + c; } +var countdown_timeout = null; + function countdown() { + if (countdown_timeout) { + clearTimeout(countdown_timeout); + } + countdown_timeout = null; + var need_reload = false; var now = $.now(); time_update = MINUTE; @@ -37,17 +45,26 @@ function countdown() $(".countdown").each(function () { var el = $(this) var timer + var timestamp if (el.is('[data-timestamp]')) { - timer = parseInt(el.attr('data-timestamp')) - timer = timer - now / 1000 + timestamp = parseInt(el.attr('data-timestamp')) + timer = timestamp - now / 1000 + } else if (el.is('[data-timestamp-up]')) { + timestamp = parseInt(el.attr('data-timestamp-up')) + timer = now / 1000 - timestamp } else { + var delta = (now - page_load) / 1000 if (el.is('[data-countdown]')) { timer = parseInt(el.attr('data-countdown')) + timer = timer - delta + } else if (el.is('[data-countdown-up]')) { + timer = parseInt(el.attr('data-countdown-up')) + timer = timer + delta } else { timer = parseInt(el.find('.countdown-timestamp').html()) + timer = timer - delta el = $(el.find('.countdown-format')[0]) } - timer = timer - (now - page_load) / 1000 } var value; if (timer < 0) { @@ -55,9 +72,12 @@ function countdown() if (!countdown_reload_time || parseInt(countdown_reload_time) + COUNTDOWN_RELOAD_DELAY < now) { need_reload = true; } - value = '--:--:--'; + value = '0'; } else { - value = getFormatTime(timer); + value = getFormatTime(timer) + if (el.is('[data-timeago="true"]')) { + value = $.timeago(timestamp * 1000) + } } el.html(value); }); @@ -66,10 +86,8 @@ function countdown() Cookies.set(reload_time_cookie_name, now) setTimeout("location.reload()", 1990); } else if (typeof(time_update) != "undefined") { - setTimeout(countdown, time_update); + countdown_timeout = setTimeout(countdown, time_update); } } -$(function () { - setTimeout(countdown, 0); -}); +$(countdown) diff --git a/src/static/js/notify-config.js b/src/static/js/notify-config.js deleted file mode 100644 index 9a2a7da6..00000000 --- a/src/static/js/notify-config.js +++ /dev/null @@ -1,3 +0,0 @@ -$.notify.defaults({ - position: 'right bottom', -}) diff --git a/src/static/js/notify.js b/src/static/js/notify.js deleted file mode 100644 index 7b3736cb..00000000 --- a/src/static/js/notify.js +++ /dev/null @@ -1,616 +0,0 @@ -/* Notify.js - http://notifyjs.com/ Copyright (c) 2015 MIT */ -(function (factory) { - // UMD start - // https://github.com/umdjs/umd/blob/master/jqueryPluginCommonjs.js - if (typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(['jquery'], factory); - } else if (typeof module === 'object' && module.exports) { - // Node/CommonJS - module.exports = function( root, jQuery ) { - if ( jQuery === undefined ) { - // require('jQuery') returns a factory that requires window to - // build a jQuery instance, we normalize how we use modules - // that require this pattern but the window provided is a noop - // if it's defined (how jquery works) - if ( typeof window !== 'undefined' ) { - jQuery = require('jquery'); - } - else { - jQuery = require('jquery')(root); - } - } - factory(jQuery); - return jQuery; - }; - } else { - // Browser globals - factory(jQuery); - } -}(function ($) { - //IE8 indexOf polyfill - var indexOf = [].indexOf || function(item) { - for (var i = 0, l = this.length; i < l; i++) { - if (i in this && this[i] === item) { - return i; - } - } - return -1; - }; - - var pluginName = "notify"; - var pluginClassName = pluginName + "js"; - var blankFieldName = pluginName + "!blank"; - - var positions = { - t: "top", - m: "middle", - b: "bottom", - l: "left", - c: "center", - r: "right" - }; - var hAligns = ["l", "c", "r"]; - var vAligns = ["t", "m", "b"]; - var mainPositions = ["t", "b", "l", "r"]; - var opposites = { - t: "b", - m: null, - b: "t", - l: "r", - c: null, - r: "l" - }; - - var parsePosition = function(str) { - var pos; - pos = []; - $.each(str.split(/\W+/), function(i, word) { - var w; - w = word.toLowerCase().charAt(0); - if (positions[w]) { - return pos.push(w); - } - }); - return pos; - }; - - var styles = {}; - - var coreStyle = { - name: "core", - html: "<div class=\"" + pluginClassName + "-wrapper\">\n <div class=\"" + pluginClassName + "-arrow\"></div>\n <div class=\"" + pluginClassName + "-container\"></div>\n</div>", - css: "." + pluginClassName + "-corner {\n position: fixed;\n margin: 5px;\n z-index: 1050;\n}\n\n." + pluginClassName + "-corner ." + pluginClassName + "-wrapper,\n." + pluginClassName + "-corner ." + pluginClassName + "-container {\n position: relative;\n display: block;\n height: inherit;\n width: inherit;\n margin: 3px;\n}\n\n." + pluginClassName + "-wrapper {\n z-index: 1;\n position: absolute;\n display: inline-block;\n height: 0;\n width: 0;\n}\n\n." + pluginClassName + "-container {\n display: none;\n z-index: 1;\n position: absolute;\n}\n\n." + pluginClassName + "-hidable {\n cursor: pointer;\n}\n\n[data-notify-text],[data-notify-html] {\n position: relative;\n}\n\n." + pluginClassName + "-arrow {\n position: absolute;\n z-index: 2;\n width: 0;\n height: 0;\n}" - }; - - var stylePrefixes = { - "border-radius": ["-webkit-", "-moz-"] - }; - - var getStyle = function(name) { - return styles[name]; - }; - - var addStyle = function(name, def) { - if (!name) { - throw "Missing Style name"; - } - if (!def) { - throw "Missing Style definition"; - } - if (!def.html) { - throw "Missing Style HTML"; - } - //remove existing style - var existing = styles[name]; - if (existing && existing.cssElem) { - if (window.console) { - console.warn(pluginName + ": overwriting style '" + name + "'"); - } - styles[name].cssElem.remove(); - } - def.name = name; - styles[name] = def; - var cssText = ""; - if (def.classes) { - $.each(def.classes, function(className, props) { - cssText += "." + pluginClassName + "-" + def.name + "-" + className + " {\n"; - $.each(props, function(name, val) { - if (stylePrefixes[name]) { - $.each(stylePrefixes[name], function(i, prefix) { - return cssText += " " + prefix + name + ": " + val + ";\n"; - }); - } - return cssText += " " + name + ": " + val + ";\n"; - }); - return cssText += "}\n"; - }); - } - if (def.css) { - cssText += "/* styles for " + def.name + " */\n" + def.css; - } - if (cssText) { - def.cssElem = insertCSS(cssText); - def.cssElem.attr("id", "notify-" + def.name); - } - var fields = {}; - var elem = $(def.html); - findFields("html", elem, fields); - findFields("text", elem, fields); - def.fields = fields; - }; - - var insertCSS = function(cssText) { - var e, elem, error; - elem = createElem("style"); - elem.attr("type", 'text/css'); - $("head").append(elem); - try { - elem.html(cssText); - } catch (_) { - elem[0].styleSheet.cssText = cssText; - } - return elem; - }; - - var findFields = function(type, elem, fields) { - var attr; - if (type !== "html") { - type = "text"; - } - attr = "data-notify-" + type; - return find(elem, "[" + attr + "]").each(function() { - var name; - name = $(this).attr(attr); - if (!name) { - name = blankFieldName; - } - fields[name] = type; - }); - }; - - var find = function(elem, selector) { - if (elem.is(selector)) { - return elem; - } else { - return elem.find(selector); - } - }; - - var pluginOptions = { - clickToHide: true, - autoHide: true, - autoHideDelay: 5000, - arrowShow: true, - arrowSize: 5, - breakNewLines: true, - elementPosition: "bottom", - globalPosition: "top right", - style: "bootstrap", - className: "error", - showAnimation: "slideDown", - showDuration: 400, - hideAnimation: "slideUp", - hideDuration: 200, - gap: 5 - }; - - var inherit = function(a, b) { - var F; - F = function() {}; - F.prototype = a; - return $.extend(true, new F(), b); - }; - - var defaults = function(opts) { - return $.extend(pluginOptions, opts); - }; - - var createElem = function(tag) { - return $("<" + tag + "></" + tag + ">"); - }; - - var globalAnchors = {}; - - var getAnchorElement = function(element) { - var radios; - if (element.is('[type=radio]')) { - radios = element.parents('form:first').find('[type=radio]').filter(function(i, e) { - return $(e).attr("name") === element.attr("name"); - }); - element = radios.first(); - } - return element; - }; - - var incr = function(obj, pos, val) { - var opp, temp; - if (typeof val === "string") { - val = parseInt(val, 10); - } else if (typeof val !== "number") { - return; - } - if (isNaN(val)) { - return; - } - opp = positions[opposites[pos.charAt(0)]]; - temp = pos; - if (obj[opp] !== undefined) { - pos = positions[opp.charAt(0)]; - val = -val; - } - if (obj[pos] === undefined) { - obj[pos] = val; - } else { - obj[pos] += val; - } - return null; - }; - - var realign = function(alignment, inner, outer) { - if (alignment === "l" || alignment === "t") { - return 0; - } else if (alignment === "c" || alignment === "m") { - return outer / 2 - inner / 2; - } else if (alignment === "r" || alignment === "b") { - return outer - inner; - } - throw "Invalid alignment"; - }; - - var encode = function(text) { - encode.e = encode.e || createElem("div"); - return encode.e.text(text).html(); - }; - - function Notification(elem, data, options) { - if (typeof options === "string") { - options = { - className: options - }; - } - this.options = inherit(pluginOptions, $.isPlainObject(options) ? options : {}); - this.loadHTML(); - this.wrapper = $(coreStyle.html); - if (this.options.clickToHide) { - this.wrapper.addClass(pluginClassName + "-hidable"); - } - this.wrapper.data(pluginClassName, this); - this.arrow = this.wrapper.find("." + pluginClassName + "-arrow"); - this.container = this.wrapper.find("." + pluginClassName + "-container"); - this.container.append(this.userContainer); - if (elem && elem.length) { - this.elementType = elem.attr("type"); - this.originalElement = elem; - this.elem = getAnchorElement(elem); - this.elem.data(pluginClassName, this); - this.elem.before(this.wrapper); - } - this.container.hide(); - this.run(data); - } - - Notification.prototype.loadHTML = function() { - var style; - style = this.getStyle(); - this.userContainer = $(style.html); - this.userFields = style.fields; - }; - - Notification.prototype.show = function(show, userCallback) { - var args, callback, elems, fn, hidden; - callback = (function(_this) { - return function() { - if (!show && !_this.elem) { - _this.destroy(); - } - if (userCallback) { - return userCallback(); - } - }; - })(this); - hidden = this.container.parent().parents(':hidden').length > 0; - elems = this.container.add(this.arrow); - args = []; - if (hidden && show) { - fn = "show"; - } else if (hidden && !show) { - fn = "hide"; - } else if (!hidden && show) { - fn = this.options.showAnimation; - args.push(this.options.showDuration); - } else if (!hidden && !show) { - fn = this.options.hideAnimation; - args.push(this.options.hideDuration); - } else { - return callback(); - } - args.push(callback); - return elems[fn].apply(elems, args); - }; - - Notification.prototype.setGlobalPosition = function() { - var p = this.getPosition(); - var pMain = p[0]; - var pAlign = p[1]; - var main = positions[pMain]; - var align = positions[pAlign]; - var key = pMain + "|" + pAlign; - var anchor = globalAnchors[key]; - if (!anchor) { - anchor = globalAnchors[key] = createElem("div"); - var css = {}; - css[main] = 0; - if (align === "middle") { - css.top = '45%'; - } else if (align === "center") { - css.left = '45%'; - } else { - css[align] = 0; - } - anchor.css(css).addClass(pluginClassName + "-corner"); - $("body").append(anchor); - } - return anchor.prepend(this.wrapper); - }; - - Notification.prototype.setElementPosition = function() { - var arrowColor, arrowCss, arrowSize, color, contH, contW, css, elemH, elemIH, elemIW, elemPos, elemW, gap, j, k, len, len1, mainFull, margin, opp, oppFull, pAlign, pArrow, pMain, pos, posFull, position, ref, wrapPos; - position = this.getPosition(); - pMain = position[0]; - pAlign = position[1]; - pArrow = position[2]; - elemPos = this.elem.position(); - elemH = this.elem.outerHeight(); - elemW = this.elem.outerWidth(); - elemIH = this.elem.innerHeight(); - elemIW = this.elem.innerWidth(); - wrapPos = this.wrapper.position(); - contH = this.container.height(); - contW = this.container.width(); - mainFull = positions[pMain]; - opp = opposites[pMain]; - oppFull = positions[opp]; - css = {}; - css[oppFull] = pMain === "b" ? elemH : pMain === "r" ? elemW : 0; - incr(css, "top", elemPos.top - wrapPos.top); - incr(css, "left", elemPos.left - wrapPos.left); - ref = ["top", "left"]; - for (j = 0, len = ref.length; j < len; j++) { - pos = ref[j]; - margin = parseInt(this.elem.css("margin-" + pos), 10); - if (margin) { - incr(css, pos, margin); - } - } - gap = Math.max(0, this.options.gap - (this.options.arrowShow ? arrowSize : 0)); - incr(css, oppFull, gap); - if (!this.options.arrowShow) { - this.arrow.hide(); - } else { - arrowSize = this.options.arrowSize; - arrowCss = $.extend({}, css); - arrowColor = this.userContainer.css("border-color") || this.userContainer.css("background-color") || "white"; - for (k = 0, len1 = mainPositions.length; k < len1; k++) { - pos = mainPositions[k]; - posFull = positions[pos]; - if (pos === opp) { - continue; - } - color = posFull === mainFull ? arrowColor : "transparent"; - arrowCss["border-" + posFull] = arrowSize + "px solid " + color; - } - incr(css, positions[opp], arrowSize); - if (indexOf.call(mainPositions, pAlign) >= 0) { - incr(arrowCss, positions[pAlign], arrowSize * 2); - } - } - if (indexOf.call(vAligns, pMain) >= 0) { - incr(css, "left", realign(pAlign, contW, elemW)); - if (arrowCss) { - incr(arrowCss, "left", realign(pAlign, arrowSize, elemIW)); - } - } else if (indexOf.call(hAligns, pMain) >= 0) { - incr(css, "top", realign(pAlign, contH, elemH)); - if (arrowCss) { - incr(arrowCss, "top", realign(pAlign, arrowSize, elemIH)); - } - } - if (this.container.is(":visible")) { - css.display = "block"; - } - this.container.removeAttr("style").css(css); - if (arrowCss) { - return this.arrow.removeAttr("style").css(arrowCss); - } - }; - - Notification.prototype.getPosition = function() { - var pos, ref, ref1, ref2, ref3, ref4, ref5, text; - text = this.options.position || (this.elem ? this.options.elementPosition : this.options.globalPosition); - pos = parsePosition(text); - if (pos.length === 0) { - pos[0] = "b"; - } - if (ref = pos[0], indexOf.call(mainPositions, ref) < 0) { - throw "Must be one of [" + mainPositions + "]"; - } - if (pos.length === 1 || ((ref1 = pos[0], indexOf.call(vAligns, ref1) >= 0) && (ref2 = pos[1], indexOf.call(hAligns, ref2) < 0)) || ((ref3 = pos[0], indexOf.call(hAligns, ref3) >= 0) && (ref4 = pos[1], indexOf.call(vAligns, ref4) < 0))) { - pos[1] = (ref5 = pos[0], indexOf.call(hAligns, ref5) >= 0) ? "m" : "l"; - } - if (pos.length === 2) { - pos[2] = pos[1]; - } - return pos; - }; - - Notification.prototype.getStyle = function(name) { - var style; - if (!name) { - name = this.options.style; - } - if (!name) { - name = "default"; - } - style = styles[name]; - if (!style) { - throw "Missing style: " + name; - } - return style; - }; - - Notification.prototype.updateClasses = function() { - var classes, style; - classes = ["base"]; - if ($.isArray(this.options.className)) { - classes = classes.concat(this.options.className); - } else if (this.options.className) { - classes.push(this.options.className); - } - style = this.getStyle(); - classes = $.map(classes, function(n) { - return pluginClassName + "-" + style.name + "-" + n; - }).join(" "); - return this.userContainer.attr("class", classes); - }; - - Notification.prototype.run = function(data, options) { - var d, datas, name, type, value; - if ($.isPlainObject(options)) { - $.extend(this.options, options); - } else if ($.type(options) === "string") { - this.options.className = options; - } - if (this.container && !data) { - this.show(false); - return; - } else if (!this.container && !data) { - return; - } - datas = {}; - if ($.isPlainObject(data)) { - datas = data; - } else { - datas[blankFieldName] = data; - } - for (name in datas) { - d = datas[name]; - type = this.userFields[name]; - if (!type) { - continue; - } - if (type === "text") { - d = encode(d); - if (this.options.breakNewLines) { - d = d.replace(/\n/g, '<br/>'); - } - } - value = name === blankFieldName ? '' : '=' + name; - find(this.userContainer, "[data-notify-" + type + value + "]").html(d); - } - this.updateClasses(); - if (this.elem) { - this.setElementPosition(); - } else { - this.setGlobalPosition(); - } - this.show(true); - if (this.options.autoHide) { - clearTimeout(this.autohideTimer); - this.autohideTimer = setTimeout(this.show.bind(this, false), this.options.autoHideDelay); - } - }; - - Notification.prototype.destroy = function() { - return this.wrapper.remove(); - }; - - $[pluginName] = function(elem, data, options) { - if ((elem && elem.nodeName) || elem.jquery) { - $(elem)[pluginName](data, options); - } else { - options = data; - data = elem; - new Notification(null, data, options); - } - return elem; - }; - - $.fn[pluginName] = function(data, options) { - $(this).each(function() { - var inst; - inst = getAnchorElement($(this)).data(pluginClassName); - if (inst) { - return inst.run(data, options); - } else { - return new Notification($(this), data, options); - } - }); - return this; - }; - - $.extend($[pluginName], { - defaults: defaults, - addStyle: addStyle, - pluginOptions: pluginOptions, - getStyle: getStyle, - insertCSS: insertCSS - }); - - //always include the default bootstrap style - addStyle("bootstrap", { - html: "<div>\n<span data-notify-text></span>\n</div>", - classes: { - base: { - "font-weight": "bold", - "padding": "8px 15px 8px 14px", - "text-shadow": "0 1px 0 rgba(255, 255, 255, 0.5)", - "background-color": "#fcf8e3", - "border": "1px solid #fbeed5", - "border-radius": "4px", - "white-space": "nowrap", - "padding-left": "25px", - "background-repeat": "no-repeat", - "background-position": "3px 7px" - }, - error: { - "color": "#B94A48", - "background-color": "#F2DEDE", - "border-color": "#EED3D7", - "background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAtRJREFUeNqkVc1u00AQHq+dOD+0poIQfkIjalW0SEGqRMuRnHos3DjwAH0ArlyQeANOOSMeAA5VjyBxKBQhgSpVUKKQNGloFdw4cWw2jtfMOna6JOUArDTazXi/b3dm55socPqQhFka++aHBsI8GsopRJERNFlY88FCEk9Yiwf8RhgRyaHFQpPHCDmZG5oX2ui2yilkcTT1AcDsbYC1NMAyOi7zTX2Agx7A9luAl88BauiiQ/cJaZQfIpAlngDcvZZMrl8vFPK5+XktrWlx3/ehZ5r9+t6e+WVnp1pxnNIjgBe4/6dAysQc8dsmHwPcW9C0h3fW1hans1ltwJhy0GxK7XZbUlMp5Ww2eyan6+ft/f2FAqXGK4CvQk5HueFz7D6GOZtIrK+srupdx1GRBBqNBtzc2AiMr7nPplRdKhb1q6q6zjFhrklEFOUutoQ50xcX86ZlqaZpQrfbBdu2R6/G19zX6XSgh6RX5ubyHCM8nqSID6ICrGiZjGYYxojEsiw4PDwMSL5VKsC8Yf4VRYFzMzMaxwjlJSlCyAQ9l0CW44PBADzXhe7xMdi9HtTrdYjFYkDQL0cn4Xdq2/EAE+InCnvADTf2eah4Sx9vExQjkqXT6aAERICMewd/UAp/IeYANM2joxt+q5VI+ieq2i0Wg3l6DNzHwTERPgo1ko7XBXj3vdlsT2F+UuhIhYkp7u7CarkcrFOCtR3H5JiwbAIeImjT/YQKKBtGjRFCU5IUgFRe7fF4cCNVIPMYo3VKqxwjyNAXNepuopyqnld602qVsfRpEkkz+GFL1wPj6ySXBpJtWVa5xlhpcyhBNwpZHmtX8AGgfIExo0ZpzkWVTBGiXCSEaHh62/PoR0p/vHaczxXGnj4bSo+G78lELU80h1uogBwWLf5YlsPmgDEd4M236xjm+8nm4IuE/9u+/PH2JXZfbwz4zw1WbO+SQPpXfwG/BBgAhCNZiSb/pOQAAAAASUVORK5CYII=)" - }, - success: { - "color": "#468847", - "background-color": "#DFF0D8", - "border-color": "#D6E9C6", - "background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAutJREFUeNq0lctPE0Ecx38zu/RFS1EryqtgJFA08YCiMZIAQQ4eRG8eDGdPJiYeTIwHTfwPiAcvXIwXLwoXPaDxkWgQ6islKlJLSQWLUraPLTv7Gme32zoF9KSTfLO7v53vZ3d/M7/fIth+IO6INt2jjoA7bjHCJoAlzCRw59YwHYjBnfMPqAKWQYKjGkfCJqAF0xwZjipQtA3MxeSG87VhOOYegVrUCy7UZM9S6TLIdAamySTclZdYhFhRHloGYg7mgZv1Zzztvgud7V1tbQ2twYA34LJmF4p5dXF1KTufnE+SxeJtuCZNsLDCQU0+RyKTF27Unw101l8e6hns3u0PBalORVVVkcaEKBJDgV3+cGM4tKKmI+ohlIGnygKX00rSBfszz/n2uXv81wd6+rt1orsZCHRdr1Imk2F2Kob3hutSxW8thsd8AXNaln9D7CTfA6O+0UgkMuwVvEFFUbbAcrkcTA8+AtOk8E6KiQiDmMFSDqZItAzEVQviRkdDdaFgPp8HSZKAEAL5Qh7Sq2lIJBJwv2scUqkUnKoZgNhcDKhKg5aH+1IkcouCAdFGAQsuWZYhOjwFHQ96oagWgRoUov1T9kRBEODAwxM2QtEUl+Wp+Ln9VRo6BcMw4ErHRYjH4/B26AlQoQQTRdHWwcd9AH57+UAXddvDD37DmrBBV34WfqiXPl61g+vr6xA9zsGeM9gOdsNXkgpEtTwVvwOklXLKm6+/p5ezwk4B+j6droBs2CsGa/gNs6RIxazl4Tc25mpTgw/apPR1LYlNRFAzgsOxkyXYLIM1V8NMwyAkJSctD1eGVKiq5wWjSPdjmeTkiKvVW4f2YPHWl3GAVq6ymcyCTgovM3FzyRiDe2TaKcEKsLpJvNHjZgPNqEtyi6mZIm4SRFyLMUsONSSdkPeFtY1n0mczoY3BHTLhwPRy9/lzcziCw9ACI+yql0VLzcGAZbYSM5CCSZg1/9oc/nn7+i8N9p/8An4JMADxhH+xHfuiKwAAAABJRU5ErkJggg==)" - }, - info: { - "color": "#3A87AD", - "background-color": "#D9EDF7", - "border-color": "#BCE8F1", - "background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QYFAhkSsdes/QAAA8dJREFUOMvVlGtMW2UYx//POaWHXg6lLaW0ypAtw1UCgbniNOLcVOLmAjHZolOYlxmTGXVZdAnRfXQm+7SoU4mXaOaiZsEpC9FkiQs6Z6bdCnNYruM6KNBw6YWewzl9z+sHImEWv+vz7XmT95f/+3/+7wP814v+efDOV3/SoX3lHAA+6ODeUFfMfjOWMADgdk+eEKz0pF7aQdMAcOKLLjrcVMVX3xdWN29/GhYP7SvnP0cWfS8caSkfHZsPE9Fgnt02JNutQ0QYHB2dDz9/pKX8QjjuO9xUxd/66HdxTeCHZ3rojQObGQBcuNjfplkD3b19Y/6MrimSaKgSMmpGU5WevmE/swa6Oy73tQHA0Rdr2Mmv/6A1n9w9suQ7097Z9lM4FlTgTDrzZTu4StXVfpiI48rVcUDM5cmEksrFnHxfpTtU/3BFQzCQF/2bYVoNbH7zmItbSoMj40JSzmMyX5qDvriA7QdrIIpA+3cdsMpu0nXI8cV0MtKXCPZev+gCEM1S2NHPvWfP/hL+7FSr3+0p5RBEyhEN5JCKYr8XnASMT0xBNyzQGQeI8fjsGD39RMPk7se2bd5ZtTyoFYXftF6y37gx7NeUtJJOTFlAHDZLDuILU3j3+H5oOrD3yWbIztugaAzgnBKJuBLpGfQrS8wO4FZgV+c1IxaLgWVU0tMLEETCos4xMzEIv9cJXQcyagIwigDGwJgOAtHAwAhisQUjy0ORGERiELgG4iakkzo4MYAxcM5hAMi1WWG1yYCJIcMUaBkVRLdGeSU2995TLWzcUAzONJ7J6FBVBYIggMzmFbvdBV44Corg8vjhzC+EJEl8U1kJtgYrhCzgc/vvTwXKSib1paRFVRVORDAJAsw5FuTaJEhWM2SHB3mOAlhkNxwuLzeJsGwqWzf5TFNdKgtY5qHp6ZFf67Y/sAVadCaVY5YACDDb3Oi4NIjLnWMw2QthCBIsVhsUTU9tvXsjeq9+X1d75/KEs4LNOfcdf/+HthMnvwxOD0wmHaXr7ZItn2wuH2SnBzbZAbPJwpPx+VQuzcm7dgRCB57a1uBzUDRL4bfnI0RE0eaXd9W89mpjqHZnUI5Hh2l2dkZZUhOqpi2qSmpOmZ64Tuu9qlz/SEXo6MEHa3wOip46F1n7633eekV8ds8Wxjn37Wl63VVa+ej5oeEZ/82ZBETJjpJ1Rbij2D3Z/1trXUvLsblCK0XfOx0SX2kMsn9dX+d+7Kf6h8o4AIykuffjT8L20LU+w4AZd5VvEPY+XpWqLV327HR7DzXuDnD8r+ovkBehJ8i+y8YAAAAASUVORK5CYII=)" - }, - warn: { - "color": "#C09853", - "background-color": "#FCF8E3", - "border-color": "#FBEED5", - "background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAMAAAC6V+0/AAABJlBMVEXr6eb/2oD/wi7/xjr/0mP/ykf/tQD/vBj/3o7/uQ//vyL/twebhgD/4pzX1K3z8e349vK6tHCilCWbiQymn0jGworr6dXQza3HxcKkn1vWvV/5uRfk4dXZ1bD18+/52YebiAmyr5S9mhCzrWq5t6ufjRH54aLs0oS+qD751XqPhAybhwXsujG3sm+Zk0PTwG6Shg+PhhObhwOPgQL4zV2nlyrf27uLfgCPhRHu7OmLgAafkyiWkD3l49ibiAfTs0C+lgCniwD4sgDJxqOilzDWowWFfAH08uebig6qpFHBvH/aw26FfQTQzsvy8OyEfz20r3jAvaKbhgG9q0nc2LbZxXanoUu/u5WSggCtp1anpJKdmFz/zlX/1nGJiYmuq5Dx7+sAAADoPUZSAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfdBgUBGhh4aah5AAAAlklEQVQY02NgoBIIE8EUcwn1FkIXM1Tj5dDUQhPU502Mi7XXQxGz5uVIjGOJUUUW81HnYEyMi2HVcUOICQZzMMYmxrEyMylJwgUt5BljWRLjmJm4pI1hYp5SQLGYxDgmLnZOVxuooClIDKgXKMbN5ggV1ACLJcaBxNgcoiGCBiZwdWxOETBDrTyEFey0jYJ4eHjMGWgEAIpRFRCUt08qAAAAAElFTkSuQmCC)" - } - } - }); - - $(function() { - insertCSS(coreStyle.css).attr("id", "core-notify"); - $(document).on("click", "." + pluginClassName + "-hidable", function(e) { - return $(this).trigger("notify-hide"); - }); - return $(document).on("notify-hide", "." + pluginClassName + "-wrapper", function(e) { - var elem = $(this).data(pluginClassName); - if(elem) { - elem.show(false); - } - }); - }); - -})); diff --git a/src/static/js/profile.js b/src/static/js/profile.js index 92b69e53..93cff326 100644 --- a/src/static/js/profile.js +++ b/src/static/js/profile.js @@ -67,7 +67,7 @@ function load_rating_history() { } }, error: function(data) { - $.notify('{status} {statusText}'.format(data), 'error') + notify('{status} {statusText}'.format(data), 'error') }, complete: function() { $(loading_selector).parent().remove() @@ -130,7 +130,7 @@ $(function() { btn.text('Verified') btn.show() loading.addClass('hidden') - $.notify('Verified', 'success') + notify('Verified', 'success') window.history.replaceState(null, null, account_url) }, error: function(response) { diff --git a/src/static/js/settings.js b/src/static/js/settings.js index 5d82927f..5d153e40 100644 --- a/src/static/js/settings.js +++ b/src/static/js/settings.js @@ -226,7 +226,7 @@ $(function() { $('#custom_countries_loading').addClass('hidden') }, error: function(data) { - $.notify(data.responseText, 'error') + notify(data.responseText, 'error') $('#custom_countries_loading').addClass('hidden') }, }) @@ -1478,7 +1478,7 @@ $(function() { document.location.href = "/" }, error: function(data) { - $.notify(data.responseText, "error") + notify(data.responseText, "error") }, }) } @@ -1503,7 +1503,7 @@ $(function() { }) }, error: function(data) { - $.notify(data.responseText, "error") + notify(data.responseText, "error") }, }) }) diff --git a/src/static/js/standings.js b/src/static/js/standings.js index dd92c119..891eb7a8 100644 --- a/src/static/js/standings.js +++ b/src/static/js/standings.js @@ -18,6 +18,13 @@ function update_sticky() { }) } +function reload_standings(delay) { + var timestamp = ($.now() + delay) / 1000 + notify(`The page will be reloaded after <span class="countdown" data-timestamp="${timestamp}">${delay / 1000}</span>`, {type: 'info', duration: delay, escapeHTML: false}) + setTimeout(() => { location.reload() }, delay) + countdown() +} + function color_by_group_score(attr = 'data-result') { var prev = null var idx = 0 @@ -1042,7 +1049,7 @@ function switcher_updated(stat) { penalty: tr.data('current-penalty'), solving: tr.data('current-solving'), }, - success: function(data) { $.notify(data.message, data.status) }, + success: function(data) { notify(data.message, data.status) }, error: log_ajax_error_callback, }) } @@ -1217,6 +1224,11 @@ function show_timeline() { $('#timeline').show() $('.standings .endless_container').remove() + $('.standings-auto-reload').prop('disabled', true) + if ($('.standings-auto-reload.hidden[data-value="off"]').length) { + $('.standings-auto-reload').toggleClass('hidden') + } + clear_extra_info_timeline() update_timeline_text(CURRENT_PERCENT) @@ -1537,9 +1549,12 @@ $(function() { apply_starring() clear_extra_info_timeline() set_timeline() - $.notify('updated ' + n_rows_msg + ' row(s)', 'success') + notify('updated ' + n_rows_msg + ' row(s)', 'info') + } else if (with_autoreload) { + notify('updated ' + n_rows_msg + ' row(s)', 'info') + reload_standings(5000) } else { - $.notify('updated ' + n_rows_msg + ' row(s), reload page to see', 'warn') + notify('updated ' + n_rows_msg + ' row(s), reload page to see', 'info') } } @@ -1554,15 +1569,18 @@ $(function() { } standings_socket.onclose = function(e) { - if (n_messages) { - $.notify('Socket closed unexpectedly', 'warn') - $.notify('The page will be reloaded in 10 seconds', 'warn') - setTimeout(() => { location.reload() }, 10000) + if (n_messages || with_autoreload) { + setTimeout(() => { + if (document.hidden) { + return + } + notify('Socket closed unexpectedly', 'warn') + reload_standings(5000) + }, 20000) } } }) - /* * Update statistics */ @@ -1607,7 +1625,7 @@ function update_statistics(e) { }, error: log_ajax_error_callback, success: function() { - $.notify('Queued update', 'success') + notify('Queued update', 'success') }, complete: function(jqXHR, textStatus) { icon.removeClass('fa-spin') @@ -1794,6 +1812,40 @@ $(() => { visible_standings() } + + $('.standings-auto-reload').click(function(event) { + event.preventDefault() + + var $btn = $(this) + var autoreload_new_value = $btn.data('value') + update_urls_params({'autoreload': autoreload_new_value}) + + function success() { + $('.standings-auto-reload').toggleClass('hidden') + with_autoreload = !with_autoreload + } + + if (coder_pk === undefined) { + success() + return + } + + $btn.prop('disabled', true) + $.ajax({ + type: 'POST', + url: change_url, + data: { + pk: coder_pk, + name: 'standings-auto-reload', + value: autoreload_new_value, + }, + error: log_ajax_error_callback, + success: success, + complete: function() { + $btn.prop('disabled', false) + }, + }) + }) }) /* @@ -1820,7 +1872,7 @@ $(() => { const files = e.originalEvent.dataTransfer.files if (files.length > 1) { - $.notify('Only one file can be uploaded', 'warn') + notify('Only one file can be uploaded', 'warn') return } @@ -1832,7 +1884,7 @@ $(() => { function standings_upload_solution(file, problem_cell) { if (file.size > problem_user_solution_size_limit) { - $.notify('File is too large', 'warn') + notify('File is too large', 'warn') return } @@ -1849,7 +1901,7 @@ $(() => { contentType: false, data: form_data, beforeSend: function() { problem_cell.addClass('uploading') }, - success: function(data) { $.notify(data.message, data.status), location.reload() }, + success: function(data) { notify(data.message, data.status), location.reload() }, error: log_ajax_error_callback, complete: function() { problem_cell.removeClass('uploading') }, }) diff --git a/src/static/js/toastify.js b/src/static/js/toastify.js new file mode 100644 index 00000000..51bc09f1 --- /dev/null +++ b/src/static/js/toastify.js @@ -0,0 +1,15 @@ +/** + * Minified by jsDelivr using Terser v5.19.2. + * Original file: /npm/toastify-js@1.12.0/src/toastify.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Toastify js 1.12.0 + * https://github.com/apvarun/toastify-js + * @license MIT licensed + * + * Copyright (C) 2018 Varun A P + */ +!function(t,o){"object"==typeof module&&module.exports?module.exports=o():t.Toastify=o()}(this,(function(t){var o=function(t){return new o.lib.init(t)};function i(t,o){return o.offset[t]?isNaN(o.offset[t])?o.offset[t]:o.offset[t]+"px":"0px"}function s(t,o){return!(!t||"string"!=typeof o)&&!!(t.className&&t.className.trim().split(/\s+/gi).indexOf(o)>-1)}return o.defaults={oldestFirst:!0,text:"Toastify is awesome!",node:void 0,duration:3e3,selector:void 0,callback:function(){},destination:void 0,newWindow:!1,close:!1,gravity:"toastify-top",positionLeft:!1,position:"",backgroundColor:"",avatar:"",className:"",stopOnFocus:!0,onClick:function(){},offset:{x:0,y:0},escapeMarkup:!0,ariaLive:"polite",style:{background:""}},o.lib=o.prototype={toastify:"1.12.0",constructor:o,init:function(t){return t||(t={}),this.options={},this.toastElement=null,this.options.text=t.text||o.defaults.text,this.options.node=t.node||o.defaults.node,this.options.duration=0===t.duration?0:t.duration||o.defaults.duration,this.options.selector=t.selector||o.defaults.selector,this.options.callback=t.callback||o.defaults.callback,this.options.destination=t.destination||o.defaults.destination,this.options.newWindow=t.newWindow||o.defaults.newWindow,this.options.close=t.close||o.defaults.close,this.options.gravity="bottom"===t.gravity?"toastify-bottom":o.defaults.gravity,this.options.positionLeft=t.positionLeft||o.defaults.positionLeft,this.options.position=t.position||o.defaults.position,this.options.backgroundColor=t.backgroundColor||o.defaults.backgroundColor,this.options.avatar=t.avatar||o.defaults.avatar,this.options.className=t.className||o.defaults.className,this.options.stopOnFocus=void 0===t.stopOnFocus?o.defaults.stopOnFocus:t.stopOnFocus,this.options.onClick=t.onClick||o.defaults.onClick,this.options.offset=t.offset||o.defaults.offset,this.options.escapeMarkup=void 0!==t.escapeMarkup?t.escapeMarkup:o.defaults.escapeMarkup,this.options.ariaLive=t.ariaLive||o.defaults.ariaLive,this.options.style=t.style||o.defaults.style,t.backgroundColor&&(this.options.style.background=t.backgroundColor),this},buildToast:function(){if(!this.options)throw"Toastify is not initialized";var t=document.createElement("div");for(var o in t.className="toastify on "+this.options.className,this.options.position?t.className+=" toastify-"+this.options.position:!0===this.options.positionLeft?(t.className+=" toastify-left",console.warn("Property `positionLeft` will be depreciated in further versions. Please use `position` instead.")):t.className+=" toastify-right",t.className+=" "+this.options.gravity,this.options.backgroundColor&&console.warn('DEPRECATION NOTICE: "backgroundColor" is being deprecated. Please use the "style.background" property.'),this.options.style)t.style[o]=this.options.style[o];if(this.options.ariaLive&&t.setAttribute("aria-live",this.options.ariaLive),this.options.node&&this.options.node.nodeType===Node.ELEMENT_NODE)t.appendChild(this.options.node);else if(this.options.escapeMarkup?t.innerText=this.options.text:t.innerHTML=this.options.text,""!==this.options.avatar){var s=document.createElement("img");s.src=this.options.avatar,s.className="toastify-avatar","left"==this.options.position||!0===this.options.positionLeft?t.appendChild(s):t.insertAdjacentElement("afterbegin",s)}if(!0===this.options.close){var e=document.createElement("button");e.type="button",e.setAttribute("aria-label","Close"),e.className="toast-close",e.innerHTML="✖",e.addEventListener("click",function(t){t.stopPropagation(),this.removeElement(this.toastElement),window.clearTimeout(this.toastElement.timeOutValue)}.bind(this));var n=window.innerWidth>0?window.innerWidth:screen.width;("left"==this.options.position||!0===this.options.positionLeft)&&n>360?t.insertAdjacentElement("afterbegin",e):t.appendChild(e)}if(this.options.stopOnFocus&&this.options.duration>0){var a=this;t.addEventListener("mouseover",(function(o){window.clearTimeout(t.timeOutValue)})),t.addEventListener("mouseleave",(function(){t.timeOutValue=window.setTimeout((function(){a.removeElement(t)}),a.options.duration)}))}if(void 0!==this.options.destination&&t.addEventListener("click",function(t){t.stopPropagation(),!0===this.options.newWindow?window.open(this.options.destination,"_blank"):window.location=this.options.destination}.bind(this)),"function"==typeof this.options.onClick&&void 0===this.options.destination&&t.addEventListener("click",function(t){t.stopPropagation(),this.options.onClick()}.bind(this)),"object"==typeof this.options.offset){var l=i("x",this.options),r=i("y",this.options),p="left"==this.options.position?l:"-"+l,d="toastify-top"==this.options.gravity?r:"-"+r;t.style.transform="translate("+p+","+d+")"}return t},showToast:function(){var t;if(this.toastElement=this.buildToast(),!(t="string"==typeof this.options.selector?document.getElementById(this.options.selector):this.options.selector instanceof HTMLElement||"undefined"!=typeof ShadowRoot&&this.options.selector instanceof ShadowRoot?this.options.selector:document.body))throw"Root element is not defined";var i=o.defaults.oldestFirst?t.firstChild:t.lastChild;return t.insertBefore(this.toastElement,i),o.reposition(),this.options.duration>0&&(this.toastElement.timeOutValue=window.setTimeout(function(){this.removeElement(this.toastElement)}.bind(this),this.options.duration)),this},hideToast:function(){this.toastElement.timeOutValue&&clearTimeout(this.toastElement.timeOutValue),this.removeElement(this.toastElement)},removeElement:function(t){t.className=t.className.replace(" on",""),window.setTimeout(function(){this.options.node&&this.options.node.parentNode&&this.options.node.parentNode.removeChild(this.options.node),t.parentNode&&t.parentNode.removeChild(t),this.options.callback.call(t),o.reposition()}.bind(this),400)}},o.reposition=function(){for(var t,o={top:15,bottom:15},i={top:15,bottom:15},e={top:15,bottom:15},n=document.getElementsByClassName("toastify"),a=0;a<n.length;a++){t=!0===s(n[a],"toastify-top")?"toastify-top":"toastify-bottom";var l=n[a].offsetHeight;t=t.substr(9,t.length-1);(window.innerWidth>0?window.innerWidth:screen.width)<=360?(n[a].style[t]=e[t]+"px",e[t]+=l+15):!0===s(n[a],"toastify-left")?(n[a].style[t]=o[t]+"px",o[t]+=l+15):(n[a].style[t]=i[t]+"px",i[t]+=l+15)}return this},o.lib.init.prototype=o.lib,o})); +//# sourceMappingURL=/sm/e1ebbfe1bf0b0061f0726ebc83434e1c2f8308e6354c415fd05ecccdaad47617.map \ No newline at end of file diff --git a/src/templates/account_table_cell.html b/src/templates/account_table_cell.html index 8b4521b9..923d8c71 100644 --- a/src/templates/account_table_cell.html +++ b/src/templates/account_table_cell.html @@ -39,7 +39,11 @@ <span class="{% coder_color_class resource account.info %}"> {% endif %} -{% if account.name and account.key|has_season:account.name %} +{% if account.value_instead_key %} +<span><b>{% trim_to account.value_instead_key trim_length %}</b></span> +{% elif field_instead_key and account|get_item:field_instead_key %} +<span>{% trim_to account|get_item:field_instead_key trim_length %}</span> +{% elif account.name and account.key|has_season:account.name %} <span>{% trim_to account.name trim_length %}</span> {% elif account.name and resource.info.standings.name_instead_key %} <span>{% trim_to account.name trim_length %}</span> diff --git a/src/templates/accounts.html b/src/templates/accounts.html index e4fcecf4..0e91454f 100644 --- a/src/templates/accounts.html +++ b/src/templates/accounts.html @@ -36,11 +36,11 @@ </th> {% endif %} <th> - <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> + <div>Linked<br/>coder{% if perms.ranking.link_account %} <a href="#" onclick="invert_accounts(event, 'accounts')">{% icon_to 'invert' %}</a>{% endif %}</div> </th> {% if params.to_list %} <th> - <div>Add<br/>to list</div> + <div>Add to<br/>list <a href="#" onclick="invert_accounts(event, 'to_list_accounts')">{% icon_to 'invert' %}</a></div> </th> {% endif %} <th> @@ -71,7 +71,7 @@ {% if custom_fields %} {% for field in custom_fields.values %} <th> - {% if field not in skip_actions_columns %} + {% if field not in skip_actions_columns and fields_types|get_item:field != 'str' %} <div class="chart-column" data-field="{{ field }}"></div> {% endif %} <div>{{ field|title_field|split:" "|join:"<br>" }}</div> diff --git a/src/templates/accounts_filters.html b/src/templates/accounts_filters.html index 97633e18..13e5226e 100644 --- a/src/templates/accounts_filters.html +++ b/src/templates/accounts_filters.html @@ -68,4 +68,6 @@ {% endif %} {% include "list_filter.html" with list_field="to_list" nomultiply=True submit="add" submit_value="add_to_list" submit_enabled=params.to_list %} + + {% include "field_to_input.html" with field="field_instead_key" default=request.GET|get_item:"field_instead_key" force_collapse=True %} </div> diff --git a/src/templates/accounts_paging.html b/src/templates/accounts_paging.html index dc566b3d..64e4caf3 100644 --- a/src/templates/accounts_paging.html +++ b/src/templates/accounts_paging.html @@ -95,13 +95,12 @@ {% if account.to_list %} <i class="fas fa-check"></i> {% else %} - <input class="scale15" type="checkbox" name="to_list_accounts" value="{{ account.pk }}"> + <input class="scale15 mouseover-toggle" type="checkbox" name="to_list_accounts" value="{{ account.pk }}"> {% endif %} </td> {% endif %} <td> - {% with rating_time=account.info|get_item:"_rating_time" %} - {% if rating_time and account.rating_prediction.time and rating_time < account.rating_prediction.time or not rating_time and account.rating_prediction.time %} + {% if account.rating_update_time and account.rating_prediction.time and account.rating_update_time < account.rating_prediction.time or not account.rating_update_time and account.rating_prediction.time %} <span class="rating-prediction-label" title="{{ account.rating_prediction.new_rating }}" data-toggle="tooltip">{% icon_to "rating_prediction" False %}</span> {% endif %} {% if account.rating is not None %} @@ -113,7 +112,6 @@ {% else %} — {% endif %} - {% endwith %} </td> <td>{% if account.resource_rank %}{{ account.resource_rank }}{% else %}—{% endif %}</td> <td>{{ account.n_contests }}</td> diff --git a/src/templates/base.html b/src/templates/base.html index 6a73e7c8..0ecaa324 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -43,6 +43,9 @@ <link href="{% static_ts 'css/spacing.css' %}" rel="stylesheet"> <link href="{% static_ts 'css/print.css' %}" media="print" rel="stylesheet"> + <script src="{% static_ts 'js/toastify.js' %}"></script> + <link href="{% static_ts 'css/toastify.min.css' %}" rel="stylesheet"> + <script src="{% static_ts 'js/base.js' %}"></script> {% if user.is_authenticated and user.coder.settings.theme %} @@ -63,9 +66,6 @@ <link href="{% static_ts 'fontawesomefree/css/all.min.css' %}" rel="stylesheet" type="text/css"> - <script src="{% static_ts 'js/notify.js' %}"></script> - <script src="{% static_ts 'js/notify-config.js' %}"></script> - <script src="{% static_ts 'el-pagination/js/el-pagination.js' %}"></script> <link href="{% static_ts 'css/select2.min.css' %}" rel="stylesheet"> diff --git a/src/templates/check_timezone.html b/src/templates/check_timezone.html index fded30a1..cd7b96c4 100644 --- a/src/templates/check_timezone.html +++ b/src/templates/check_timezone.html @@ -11,9 +11,9 @@ if (data == "reload") { location.reload() } else if (data == "accepted") { - $.notify("Warning! Timezone is set incorrectly. Please reload page.", "warn") + notify("Warning! Timezone is set incorrectly. Please reload page.", "warn") } else { - $.notify(data, "error") + notify(data, "error") } } }); diff --git a/src/templates/coder_list.html b/src/templates/coder_list.html index 6158ba2e..b73aeab1 100644 --- a/src/templates/coder_list.html +++ b/src/templates/coder_list.html @@ -3,12 +3,24 @@ {% block ogtitle %}{% block title %}{{ coder_list.name }} - List{% endblock %}{% endblock %} {% block end-head %} +<link href="{% static_ts 'css/bootstrap-editable.css' %}" rel="stylesheet"> +<script src="{% static_ts 'js/bootstrap-editable.min.js' %}"></script> + <link href="{% static_ts 'css/coder_list.css' %}" rel="stylesheet"> <script src="{% static_ts 'js/coder_list.js' %}"></script> + {% endblock %} {% block content %} +{% if coder_list.with_names %} +<script> + $.fn.editable.defaults.mode = 'popup' + $.fn.editable.defaults.url = change_url + $.fn.editable.defaults.pk = {{ coder.id }} +</script> +{% endif %} + <h2> <i class="fas fa-list"></i> {{ coder_list.name }} @@ -49,9 +61,12 @@ <h2> <th class="no-stretch">#</th> <th class="no-stretch"><i class="fas fa-external-link-alt"></i></th> <th class="no-stretch">VS</th> + {% if coder_list.with_names %} + <th class="no-stretch">Name</th> + {% endif %} <th class="value"> Coder/Accounts - <span class="small text-muted">({{ coder_values|length }} of {{ coder_list_n_values_limit }})</span> + <span class="small text-muted">({{ coder_list_groups|length }} of {{ coder_list_n_values_limit }})</span> </th> {% for resource in params.resources %} <th> @@ -63,38 +78,54 @@ <h2> </tr> </thead> <tbody> - {% for group_id, data in coder_values.items %} + {% for group in coder_list_groups %} + {% with list_values=group.values %} + {% with has_list_values=list_values.count %} <tr> <td class="no-stretch">{{ forloop.counter }}</td> - <td class="no-stretch"><a href="{% url 'coder:mixed_profile' data.versus %}"><i class="fa fa-user"></i></a></td> + <td class="no-stretch">{% if has_list_values %}<a href="{% url 'coder:mixed_profile' group.profile_str %}"><i class="fa fa-user"></i></a>{% endif %}</td> <td class="no-stretch"> + {% if has_list_values %} {% if request.user.is_authenticated %} - <a href="{% url 'ranking:versus' data.versus|add:"/vs/"|add:coder.username %}"><i class="fas fa-people-arrows"></i></a> + <a href="{% url 'ranking:versus' group.profile_str|add:"/vs/"|add:coder.username %}"><i class="fas fa-people-arrows"></i></a> {% else %} <a href="{% url 'auth:login' %}?next={{ request.path }}"><i class="fa fa-sign-in-alt"></i></a> {% endif %} + {% endif %} + </td> + {% if coder_list.with_names %} + <td class="no-stretch"> + <a class="edit-name" href="#" data-name="group-name" data-type="text" data-group-id="{{ group.id }}" data-value="{{ group.name|default:'' }}"></a> </td> + {% endif %} <td class="value"> - {% for v in data.list_values %} + {% for v in list_values.all %} {% if forloop.counter0 %}|{% endif %} {% if v.coder %} - {% include "coder.html" with coder=v.coder with_fixed_width=True %} + {% include "coder.html" with coder=v.coder with_fixed_width=False %} {% elif v.account %} - {% include "account_table_cell.html" with resource=v.account.resource account=v.account with_resource=True with_fixed_width=True without_inline_button=True %} + {% include "account_table_cell.html" with resource=v.account.resource account=v.account with_resource=True with_fixed_width=False without_inline_button=True %} {% else %} — {% endif %} + <button class="btn btn-danger btn-xs hidden" name="delete_value_id" value="{{ v.id }}"><i class="far fa-trash-alt"></i></button> {% endfor %} - {% if can_modify %} - {% with v=data.list_values.0 %} <span class="inline-button"> - <button href="#" class="add-account btn btn-primary btn-xs" data-gid="{{ v.group_id }}"><i class="fas fa-plus"></i></button> - <button class="btn btn-danger btn-xs" name="delete_gid" value="{{ v.group_id }}"><i class="far fa-trash-alt"></i></button> + {% if can_modify %} + <button href="#" class="add-account btn btn-primary btn-xs" data-group-id="{{ group.id }}">{% icon_to "add" %}</button> + {% if list_values|length > 1 %} + <button href="#" class="edit-group btn btn-primary btn-xs">{% icon_to "edit" %}</button> + {% endif %} + <button class="btn btn-danger btn-xs" name="delete_group_id" value="{{ group.id }}">{% icon_to "delete" %}</button> + {% endif %} + {% if perms.true_coders.change_coderlist %} + <a href="{% url 'admin:true_coders_listgroup_change' group.id %}" target="_blank" rel="noopener">{% icon_to "database" group %}</a> + {% endif %} </span> - {% endwith %} - {% endif %} </td> </tr> + {% endwith %} + {% endwith %} {% endfor %} </tbody> </table> diff --git a/src/templates/field_to_input.html b/src/templates/field_to_input.html index 317eb3d6..af5b8af5 100644 --- a/src/templates/field_to_input.html +++ b/src/templates/field_to_input.html @@ -1,6 +1,8 @@ +{% if default %} {% with capitalized_field=field|capitalize_field %} {% with value=request.GET|get_item:field %} -{% with collapse=value|iffalse %} +{% with force_collapse=force_collapse|default:False %} +{% with collapse=value|iffalse|ifor:force_collapse %} {% if collapse %} <div class="input-group{% if not nosmall %} input-group-sm{% endif %} field-to-input"> @@ -12,9 +14,11 @@ <div class="input-group{% if not nosmall %} input-group-sm{% endif %} field-to-input{% if collapse %} hidden{% endif %}"> <span class="input-group-addon">{% icon_to field capitalized_field %}</span> - <input placeholder="{{ default }}" class="form-control" name="{{ field }}" id="{{ field }}" type="{{ type|default:"text" }}"{% if value %} value="{{ value }}"{% endif %}{% if collapse %} disabled{% endif %}> + <input placeholder="{{ default }}" class="form-control" name="{{ field }}" id="{{ field }}" type="{{ type|default:"text" }}"{% if value %} value="{{ value }}"{% endif %}{% if not value %} disabled{% endif %}{% if min %} min="{{ min }}"{% endif %}> </div> {% endwith %} {% endwith %} {% endwith %} +{% endwith %} +{% endif %} diff --git a/src/templates/messages.html b/src/templates/messages.html index 2254e531..3528e8ff 100644 --- a/src/templates/messages.html +++ b/src/templates/messages.html @@ -2,7 +2,7 @@ <script> $(function() { {% for message in messages %} - $.notify( + notify( "{{ message|escapejs }}", { className: "{% if message.level == DEFAULT_MESSAGE_LEVELS.WARNING %}warn{% else %}{{ message.level_tag }}{% endif %}", diff --git a/src/templates/settings_subscription.html b/src/templates/settings_subscription.html index dfd1d6d1..6377eadf 100644 --- a/src/templates/settings_subscription.html +++ b/src/templates/settings_subscription.html @@ -1,4 +1,4 @@ -<div class="subscription col-sm-6 col-md-4 col-lg-3{% if not subscription.enable %} text-muted{% endif %}"> +<div class="subscription col-sm-6 col-lg-4{% if not subscription.enable %} text-muted{% endif %}"> <div class="panel panel-default table-responsive"> <table class="table"> <tr> @@ -59,9 +59,15 @@ </td> </tr> {% endif %} + {% if subscription.with_custom_names %} + <tr> + <td class="no-stretch key-column">Namings</td> + <td>{{ subscription.with_custom_names }}</td> + </tr> + {% endif %} {% if subscription.accounts.all %} <tr> - {% with n_split=3 %} + {% with n_split=3 with_resource=subscription.resource|ifor:subscription.contest|iffalse %} <td class="no-stretch key-column">Accounts</td> <td> {% for account in subscription.accounts.all %} diff --git a/src/templates/standings.html b/src/templates/standings.html index 8fb9cd4d..90887e39 100644 --- a/src/templates/standings.html +++ b/src/templates/standings.html @@ -14,6 +14,7 @@ <script src="{% static_ts 'js/addon-chart.js' %}"></script> {% endif %} +<script src="{% static_ts 'js/jquery.timeago.js' %}"></script> <script src="{% static_ts 'js/countdown.js' %}"></script> <script> contest_duration = {{ contest.duration_in_secs }} @@ -48,6 +49,7 @@ n_highlight = {{ standings_options.n_highlight|default:"undefined" }} problem_user_solution_size_limit = {{ problem_user_solution_size_limit|default:"undefined" }} + with_autoreload = {{ with_autoreload|lower }} </script> {% endblock %} @@ -213,16 +215,6 @@ <h4 class="text-center"> {% elif statistics.exists %} {% if not groupby or groupby == 'none' %} - {% if contest.parsed_time %} - <div id="parsed-time"> - <small class="text-muted pull-right"> - updated - {% if contest.parsed_percentage and contest.parsed_percentage < 100 %}{{ contest.parsed_percentage|floatformat:1 }}%{% endif %} - <span title="{{ contest.parsed_time|timezone:timezone|format_time:timeformat }}" data-placement="top" data-toggle="tooltip">{{ contest.parsed_time|timezone:timezone|naturaltime }}</span> - </small> - </div> - {% endif %} - {% if contest.resource.info.standings.style %} <style> {{ contest.resource.info.standings.style|safe }} diff --git a/src/templates/standings_account.html b/src/templates/standings_account.html index 929cde47..3e2f552c 100644 --- a/src/templates/standings_account.html +++ b/src/templates/standings_account.html @@ -45,7 +45,12 @@ {% define "span" as account_tag %} {% endif %} {% define False as subnames %} - {% if resource.info.standings.subname and account.key|split_account_key:resource.info.standings.subname %} + + {% if statistic.value_instead_key %} + <{{ account_tag }}><b>{% trim_to statistic.value_instead_key 50 %}</b></{{ account_tag }}> + {% elif field_instead_key and statistic|get_item:field_instead_key %} + <{{ account_tag }}>{% trim_to statistic|get_item:field_instead_key 50 %}</{{ account_tag }}> + {% elif resource.info.standings.subname and account.key|split_account_key:resource.info.standings.subname %} {% define account.key|split_account_key:resource.info.standings.subname as subnames %} <{{ account_tag }}>{% trim_to subnames.0 40 %}</{{ account_tag }}> {% elif statistic.addition.name and account.key|has_season:statistic.addition.name or members or statistic.addition.name and resource.info.standings.name_instead_key or statistic.addition.name and statistic.addition|get_item:"_name_instead_key" %} diff --git a/src/templates/standings_filters.html b/src/templates/standings_filters.html index 32103be7..37cf3194 100644 --- a/src/templates/standings_filters.html +++ b/src/templates/standings_filters.html @@ -44,9 +44,9 @@ {% include "resource_filter.html" with groupby=False disabled=disable_switches %} - {% include "field_to_input.html" with field="n_advanced" default=standings_options.n_highlight type="number" %} + {% include "field_to_input.html" with field="n_advanced" default=standings_options.n_highlight type="number" min="1" %} - {% if not request.user.is_authenticated %} + {% if not request.user.is_authenticated and request.GET.detail %} <input type="hidden" name="detail" value="{% if with_detail %}true{% else %}false{% endif %}"/> {% endif %} <div class="input-group input-group-sm"> @@ -56,7 +56,7 @@ </div> {% if contest.upload_solved_problems_solutions %} - {% if not request.user.is_authenticated %} + {% if not request.user.is_authenticated and request.GET.solution %} <input type="hidden" name="solution" value="{% if with_solution %}true{% else %}false{% endif %}"/> {% endif %} <div class="input-group input-group-sm"> @@ -110,6 +110,16 @@ <button id="toggle-fullscreen" class="btn btn-default btn-sm{% if fullscreen %} active{% endif %}" name="fullscreen"{% if not fullscreen %} value=""{% else %} value="off"{% endif %}>{% icon_to 'fullscreen' default='Fullscreen (F)' %}</button> </div> + <div class="input-group input-group-sm"> + {% if not request.user.is_authenticated and request.GET.autoreload %} + <input type="hidden" name="autoreload" value="{{ request.GET.autoreload }}"/> + {% endif %} + <button href="#" class="standings-auto-reload btn btn-default btn-sm{% if not with_autoreload%} hidden{% endif %}" data-value="off">{% icon_to "on" "Disable auto reload" %}</button> + <button href="#" class="standings-auto-reload btn btn-default btn-sm{% if with_autoreload%} hidden{% endif %}" data-value="on">{% icon_to "off" "Enable auto reload" %}</button> + </div> + + {% include "field_to_input.html" with field="field_instead_key" default=request.GET|get_item:"field_instead_key" force_collapse=True %} + {% if enable_timeline and groupby == 'none' %} <div class="input-group input-group-sm"> @@ -243,4 +253,14 @@ </script> {% endif %} </div> + + {% if contest.parsed_time %} + <div id="parsed-time"> + <small class="text-muted pull-right"> + updated + {% if contest.parsed_percentage and contest.parsed_percentage < 100 %}{{ contest.parsed_percentage|floatformat:1 }}%{% endif %} + <span title="{{ contest.parsed_time|timezone:timezone|format_time:timeformat }}" data-placement="top" data-toggle="tooltip" class="countdown" data-timestamp-up="{{ contest.parsed_time.timestamp }}" data-timeago="true">{{ contest.parsed_time|timezone:timezone|naturaltime }}</span> + </small> + </div> + {% endif %} </form> diff --git a/src/templates/standings_list_paging.html b/src/templates/standings_list_paging.html index 0052f69d..619bd7d7 100644 --- a/src/templates/standings_list_paging.html +++ b/src/templates/standings_list_paging.html @@ -40,9 +40,7 @@ <span class="toggle" data-group=".group-resource-{{ resource.pk }}"><i class="fa fa-caret-down"></i></span> {% endif %} - <a href="{{ contest.actual_url }}" class="{% if status %} text-{{ status }}{% endif %}"> - {% trim_to contest.title 60 %} - </a> + <a href="{{ contest.actual_url }}" class="{% if status %} text-{{ status }}{% endif %}">{% trim_to contest.title 60 %}</a> {% if contest.has_submissions %} <a href="{% url 'submissions:submissions' contest.title|slug contest.pk %}" class="submissions">{% icon_to 'submissions' %}</a> diff --git a/src/templates/standings_paging_detail.html b/src/templates/standings_paging_detail.html index cf920236..4f7ca380 100644 --- a/src/templates/standings_paging_detail.html +++ b/src/templates/standings_paging_detail.html @@ -62,6 +62,8 @@ {% elif has_failed_verdict %} <div class="rej">{{ stat.verdict }}{% if stat.test %}({{ stat.test }}){% endif %}</div> +{% elif stat.virtual_start_ts %} + <div class="countdown" data-timestamp-up="{{ stat.virtual_start_ts }}">{{ stat.virtual_start_ts|has_passed_since_timestamp|countdown }}</div> {% endif %} </div> diff --git a/src/templates/trophy.html b/src/templates/trophy.html index 4af12982..b5d2b336 100644 --- a/src/templates/trophy.html +++ b/src/templates/trophy.html @@ -1,11 +1,11 @@ {% if with_color %}<span class="trophy {{ statistic.addition.medal|lower }}-trophy no-user-select">{% endif %} {% if statistic.place_as_int == 1 %} -<i class="fas fa-trophy"></i> +<i class="fas fa-fw fa-trophy"></i> {% elif statistic.addition.medal == "gold" or statistic.addition.medal == "silver" or statistic.addition.medal == "bronze" %} -<i class="fas fa-medal"></i> +<i class="fas fa-fw fa-medal"></i> {% else %} -<i class="fas fa-award"></i> +<i class="fas fa-fw fa-award"></i> {% endif %} {% if with_color %}</span>{% endif %} diff --git a/src/tg/bot.py b/src/tg/bot.py index 8296bed5..833970e7 100644 --- a/src/tg/bot.py +++ b/src/tg/bot.py @@ -262,10 +262,14 @@ def iamadmin(self, args): if self.group is False: msg = 'This command should be used in chat rooms.' else: - if self.group is None: + admins = self.getChatAdministrators(self.chat_id) + if not any(str(admin.user.id) == self.from_id for admin in admins): + msg = 'You are not admin in "%s" chat.' % self.message['chat']['title'] + elif self.group is None: title = self.message['chat']['title'] if self.thread_id: title += f' # {self.thread_name}' + chat, created = Chat.objects.get_or_create( chat_id=self.chat_id, thread_id=self.thread_id, @@ -829,3 +833,9 @@ def webhook(self): def unwebhook(self): self.logger.info('unwebhook') return self.setWebhook(None) + + def create_topic(self, chat_id, title): + return self.createForumTopic(chat_id=chat_id, name=title) + + def delete_topic(self, chat_id, thread_id): + return self.delete_forum_topic(chat_id=chat_id, message_thread_id=thread_id) diff --git a/src/tg/models.py b/src/tg/models.py index bf545130..d2a4e685 100644 --- a/src/tg/models.py +++ b/src/tg/models.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models.signals import m2m_changed from django.dispatch import receiver @@ -20,6 +21,13 @@ class Chat(BaseModel): accounts = models.ManyToManyField(Account, blank=True, related_name='chats') settings = models.JSONField(default=dict, blank=True) + discussions = GenericRelation( + 'clist.Discussion', + content_type_field='where_type', + object_id_field='where_id', + related_query_name='where_telegram_chat', + ) + def __str__(self): return "%s Chat#%s" % (self.title or self.name or self.chat_id, self.id) diff --git a/src/true_coders/admin.py b/src/true_coders/admin.py index e3a0ec58..fe77dd7a 100644 --- a/src/true_coders/admin.py +++ b/src/true_coders/admin.py @@ -2,7 +2,7 @@ from events.models import Participant from pyclist.admin import BaseModelAdmin, admin_register -from true_coders.models import Coder, CoderList, CoderProblem, Filter, ListValue, Organization, Party +from true_coders.models import Coder, CoderList, CoderProblem, Filter, ListGroup, ListValue, Organization, Party @admin_register(Coder) @@ -102,8 +102,27 @@ class CoderListAdmin(BaseModelAdmin): list_display = ['name', 'owner', 'access_level', 'uuid'] search_fields = ['name', 'owner__username', 'uuid'] + def get_readonly_fields(self, request, obj=None): + return ['uuid'] + super().get_readonly_fields(request, obj) + + +@admin_register(ListGroup) +class ListGroupAdmin(BaseModelAdmin): + list_display = ['id', 'coder_list'] + search_fields = ['coder_list__name', 'coder_list__uuid'] + + class ListValueInline(admin.StackedInline): + model = ListValue + fields = ['coder', 'account'] + readonly_fields = ['coder', 'account'] + show_change_link = True + can_delete = False + extra = 0 + + inlines = [ListValueInline] + @admin_register(ListValue) class ListValueAdmin(BaseModelAdmin): - list_display = ['id', 'coder_list', 'coder', 'account', 'group_id'] + list_display = ['id', 'coder_list', 'group', 'coder', 'account'] search_fields = ['coder_list__name', 'coder_list__uuid', 'coder__username', 'account__key', 'account__name'] diff --git a/src/true_coders/management/commands/set_coder_problems.py b/src/true_coders/management/commands/set_coder_problems.py index a78cece6..9c518da4 100644 --- a/src/true_coders/management/commands/set_coder_problems.py +++ b/src/true_coders/management/commands/set_coder_problems.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +from datetime import datetime, timedelta, timezone from logging import getLogger from django.core.management.base import BaseCommand @@ -111,23 +112,44 @@ def process_problem(problems, desc): if 'result' not in solution: continue result = solution['result'] - if is_solved(result, with_upsolving=True): - verdict = ProblemVerdict.SOLVED - elif is_reject(result, with_upsolving=True): - verdict = ProblemVerdict.REJECT - elif is_hidden(result, with_upsolving=True): - verdict = ProblemVerdict.HIDDEN - elif is_partial(result, with_upsolving=True): - verdict = ProblemVerdict.PARTIAL + upsolving = False + for func, verdict in ( + (is_solved, ProblemVerdict.SOLVED), + (is_reject, ProblemVerdict.REJECT), + (is_partial, ProblemVerdict.PARTIAL), + (is_hidden, ProblemVerdict.HIDDEN), + ): + if func(result, with_upsolving=True): + if not func(result): + result = result['upsolving'] + upsolving = True + break else: continue + + contest = solution['contest'] + if 'time_in_seconds' in result: + submission_time = contest.start_time + timedelta(seconds=result['time_in_seconds']) + elif 'submission_time' in result: + submission_time = datetime.fromtimestamp(result['submission_time'], tz=timezone.utc) + else: + submission_time = contest.end_time + status, created = CoderProblem.objects.update_or_create( coder=coder, problem=problem, - defaults={'verdict': verdict}, + defaults={ + 'contest': contest, + 'statistic': solution['statistic'], + 'problem_key': solution['key'], + 'verdict': verdict, + 'upsolving': upsolving, + 'submission_time': submission_time, + }, ) n_created += created n_total += 1 + old_problem_ids.discard(status.id) n_deleted += len(old_problem_ids) coder.verdicts.filter(id__in=old_problem_ids).delete() diff --git a/src/true_coders/migrations/0071_edit_coder_list.py b/src/true_coders/migrations/0071_edit_coder_list.py new file mode 100644 index 00000000..4463b979 --- /dev/null +++ b/src/true_coders/migrations/0071_edit_coder_list.py @@ -0,0 +1,124 @@ +# Generated by Django 5.1.4 on 2024-12-25 22:42 + +import django.db.models.deletion +from django.db import migrations, models + + +def set_new_group(apps, schema_editor): + ListValue = apps.get_model('true_coders', 'ListValue') + ListGroup = apps.get_model('true_coders', 'ListGroup') + + new_groups = {} + for lv in ListValue.objects.filter(new_group__isnull=False): + key = (lv.coder_list_id, lv.group_id) + new_groups[key] = lv.new_group + + for lv in ListValue.objects.filter(new_group__isnull=True): + key = (lv.coder_list_id, lv.group_id) + if key not in new_groups: + new_groups[key] = ListGroup.objects.create(coder_list_id=lv.coder_list_id) + lv.new_group = new_groups[key] + lv.save(update_fields=['new_group']) + + +class Migration(migrations.Migration): + + replaces = [('true_coders', '0071_listgroup_listvalue_new_group'), ('true_coders', '0072_remove_listvalue_unique_account_and_more'), ('true_coders', '0073_alter_listvalue_group'), ('true_coders', '0074_listgroup_name'), ('true_coders', '0075_coderlist_with_names'), ('true_coders', '0076_coderproblem_time_coderproblem_upsolving_and_more'), ('true_coders', '0077_remove_coderproblem_true_coders_coder_i_33e18c_idx_and_more'), ('true_coders', '0078_coderproblem_contest_coderproblem_statistic'), ('true_coders', '0079_coderproblem_problem_key')] + + dependencies = [ + ('clist', '0169_alter_problem_index'), + ('ranking', '0136_statistics_penalty_and_more'), + ('true_coders', '0070_coder_n_subscribers'), + ] + + operations = [ + migrations.CreateModel( + name='ListGroup', + fields=[ + ('id', models.AutoField(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)), + ('coder_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='true_coders.coderlist')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='listvalue', + name='new_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='true_coders.listgroup'), + ), + migrations.RunPython( + code=set_new_group, + ), + migrations.RemoveConstraint( + model_name='listvalue', + name='unique_account', + ), + migrations.RemoveIndex( + model_name='listvalue', + name='true_coders_coder_l_1cff61_idx', + ), + migrations.RemoveField( + model_name='listvalue', + name='group_id', + ), + migrations.RenameField( + model_name='listvalue', + old_name='new_group', + new_name='group', + ), + migrations.AddIndex( + model_name='listvalue', + index=models.Index(fields=['coder_list', 'group'], name='true_coders_coder_l_1cff61_idx'), + ), + migrations.AddConstraint( + model_name='listvalue', + constraint=models.UniqueConstraint(condition=models.Q(('account__isnull', False)), fields=('coder_list', 'account', 'group'), name='unique_account'), + ), + migrations.AlterField( + model_name='listvalue', + name='group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='true_coders.listgroup'), + ), + migrations.AddField( + model_name='listgroup', + name='name', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name='coderlist', + name='with_names', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='coderproblem', + name='upsolving', + field=models.BooleanField(blank=True, db_index=True, null=True), + ), + migrations.AddField( + model_name='coderproblem', + name='submission_time', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AddIndex( + model_name='coderproblem', + index=models.Index(fields=['coder', 'submission_time'], name='true_coders_coder_i_05932c_idx'), + ), + migrations.AddField( + model_name='coderproblem', + name='contest', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='clist.contest'), + ), + migrations.AddField( + model_name='coderproblem', + name='statistic', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='ranking.statistics'), + ), + migrations.AddField( + model_name='coderproblem', + name='problem_key', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/src/true_coders/models.py b/src/true_coders/models.py index e206b61f..56fdb1d9 100644 --- a/src/true_coders/models.py +++ b/src/true_coders/models.py @@ -276,7 +276,12 @@ def update_or_get_setting(self, field, value): class CoderProblem(BaseModel): coder = models.ForeignKey(Coder, on_delete=models.CASCADE, related_name='verdicts') problem = models.ForeignKey(Problem, on_delete=models.CASCADE, related_name='verdicts') + contest = models.ForeignKey(Contest, null=True, blank=True, on_delete=models.CASCADE) + statistic = models.ForeignKey('ranking.Statistics', null=True, blank=True, on_delete=models.CASCADE) + problem_key = models.CharField(max_length=255, null=True, blank=True) verdict = models.CharField(max_length=2, choices=ProblemVerdict.choices, db_index=True) + upsolving = models.BooleanField(null=True, blank=True, db_index=True) + submission_time = models.DateTimeField(null=True, blank=True, db_index=True) class Meta: unique_together = ('coder', 'problem') @@ -284,6 +289,7 @@ class Meta: indexes = [ models.Index(fields=['coder', 'verdict']), models.Index(fields=['problem', 'verdict']), + models.Index(fields=['coder', 'submission_time']), ] @@ -438,6 +444,7 @@ class CoderList(BaseModel): uuid = models.UUIDField(default=uuid.uuid4, unique=True, db_index=True, editable=False) access_level = models.CharField(max_length=10, choices=AccessLevel.choices, default=AccessLevel.PRIVATE) shared_with_coders = models.ManyToManyField(Coder, related_name='shared_list_set', blank=True) + with_names = models.BooleanField(default=False) def __str__(self): return f'{self.name} CoderList#{self.id}' @@ -511,6 +518,10 @@ def coders_filter(uuids, coder=None, logger=None): ret = Q(id=0) return ret + @property + def related_groups(self): + return self.groups.prefetch_related('values__coder__user', 'values__account__resource').order_by('pk') + @property def related_values(self): return self.values.select_related('coder__user', 'account__resource') @@ -522,11 +533,36 @@ def update_coders_or_accounts(self): subscription.accounts.set(accounts) +class ListGroup(BaseModel): + name = models.CharField(max_length=200, null=True, blank=True) + coder_list = models.ForeignKey(CoderList, related_name='groups', on_delete=models.CASCADE) + + def __str__(self): + return f'{self.name} ListGroup#{self.id}' + + def profile_str(self): + ret = [] + for value in self.values.all(): + if value.coder: + ret.append(value.coder.username) + elif value.account: + prefix = value.account.resource.short_host or value.account.resource.host + ret.append(f'{prefix}:{value.account.key}') + return ','.join(ret) + + class ListValue(BaseModel): coder = models.ForeignKey(Coder, null=True, blank=True, on_delete=models.CASCADE) account = models.ForeignKey('ranking.Account', null=True, blank=True, on_delete=models.CASCADE) - group_id = models.PositiveIntegerField() coder_list = models.ForeignKey(CoderList, related_name='values', on_delete=models.CASCADE) + group = models.ForeignKey(ListGroup, related_name='values', on_delete=models.CASCADE) + + def __str__(self): + if self.coder: + return f'Coder#{self.coder_id} ListValue#{self.id}' + if self.account: + return f'Account#{self.account_id} ListValue#{self.id}' + return f'{self.name} ListValue#{self.id}' class Meta: constraints = [ @@ -536,14 +572,14 @@ class Meta: name='unique_coder', ), models.UniqueConstraint( - fields=['coder_list', 'account', 'group_id'], + fields=['coder_list', 'account', 'group'], condition=Q(account__isnull=False), name='unique_account', ), ] indexes = [ - models.Index(fields=['coder_list', 'group_id']), + models.Index(fields=['coder_list', 'group']), ] diff --git a/src/true_coders/views.py b/src/true_coders/views.py index eb6ad34f..7e820235 100644 --- a/src/true_coders/views.py +++ b/src/true_coders/views.py @@ -30,6 +30,7 @@ from django.views.decorators.http import require_http_methods from django_countries import countries from django_ratelimit.core import get_usage +from django_super_deduper.merge import MergedModelInstance from el_pagination.decorators import page_template, page_templates from sql_util.utils import Exists, SubqueryCount, SubqueryMax, SubquerySum from tastypie.models import ApiKey @@ -54,7 +55,7 @@ from ranking.models import (Account, AccountRenaming, AccountVerification, Module, Rating, Statistics, VerifiedAccount, VirtualStart) from tg.models import Chat -from true_coders.models import AccessLevel, Coder, CoderList, Filter, ListValue, Organization, Party +from true_coders.models import AccessLevel, Coder, CoderList, Filter, ListGroup, ListValue, Organization, Party from true_coders.utils import add_query_to_list, get_or_set_upsolving_filter from utils.chart import make_chart from utils.json_field import JSONF, IntegerJSONF, JsonJSONF @@ -475,18 +476,15 @@ def account_context(request, key, host): context['set_variables'] = set_variables wait_rating = account.resource.info.get('statistics', {}).get('wait_rating', {}) + wait_rating_days = timedelta(days=wait_rating.get('days', 7)) context['show_add_account_message'] = ( wait_rating.get('has_coder') and account.resource.has_rating_history and not account.coders.all() and ( account.last_activity is None - or '_rating_time' not in account.info - or ( - account.last_activity - - make_aware(datetime.fromtimestamp(account.info['_rating_time'])) - > timedelta(days=wait_rating.get('days', 7)) - ) + or account.rating_update_time is None + or account.last_activity - account.rating_update_time > wait_rating_days ) ) @@ -1022,7 +1020,7 @@ def change(request): else: coder.settings["theme"] = value coder.save() - if name == "highlight": + elif name == "highlight": if value not in django_settings.HIGHLIGHT_STYLES: return HttpResponseBadRequest("invalid highlight name") if value == "default": @@ -1266,6 +1264,11 @@ def change(request): coder_list.delete() except Exception: return HttpResponseBadRequest('invalid list id') + elif name == "group-name": + group_id = request.POST.get("group_id") + group = get_object_or_404(ListGroup, pk=group_id, coder_list__owner=coder) + group.name = value or None + group.save(update_fields=['name']) elif name in ["add-calendar", "edit-calendar"]: try: pk = int(request.POST.get("id") or -1) @@ -1439,7 +1442,13 @@ def view_statistic_by_filter(query): continue sent_statistics.add(statistic.pk) view_contest = contest or statistic.contest - message = compose_message_by_problems('all', statistic, {}, view_contest) + message = compose_message_by_problems( + problem_shorts='all', + statistic=statistic, + previous_addition={}, + contest_or_problems=view_contest, + subscription=subscription, + ) subscription.send(message=message, contest=view_contest) if subscription.top_n: @@ -1765,6 +1774,10 @@ def get_field(name): problems[problem_short]['user_solution'] = file_content statistic.save(update_fields=['addition']) return JsonResponse({'status': 'success', 'message': 'Solution was uploaded'}) + elif name == 'standings-auto-reload': + value = is_yes(request.POST.get('value')) + coder.settings['standings_with_autoreload'] = value + coder.save(update_fields=['settings']) else: return HttpResponseBadRequest(f'unknown name = {escape(name)}') @@ -2340,40 +2353,49 @@ def view_list(request, uuid): return HttpResponseBadRequest('Only the owner can change the list') if 'uuid' in request_post and request_post['uuid'] != uuid: return HttpResponseBadRequest('Wrong uuid value') - group_id = toint(request_post.get('gid')) - if not group_id: - group_id = (coder_list.values.aggregate(val=Max('group_id')).get('val') or 0) + 1 + group_id, group = request_post.get('group_id'), None + existing_groups = set() + + def reset_group_id(): + nonlocal group_id, group + if group is not None and not group.values.exists(): + group.delete() + group_id, group = None, None + existing_groups = set() + + def get_group_id(): + nonlocal group_id, group + if group is None: + if group_id is None: + group = coder_list.groups.create() + else: + group = get_object_or_404(coder_list.groups, pk=group_id) + group_id = group.pk + return group_id def add_coder(c, log_value=None): log_value = log_value or c.username - if ListValue.objects.filter(coder_list=coder_list).count() >= django_settings.CODER_LIST_N_VALUES_LIMIT_: + if coder_list.values.count() >= django_settings.CODER_LIST_N_VALUES_LIMIT_: request.logger.warning(f'Limit reached. Coder {log_value} not added') return - try: - ListValue.objects.create(coder_list=coder_list, coder=c, group_id=group_id) - request.logger.success(f'Added {log_value} coder to list') - except IntegrityError: + if qs := coder_list.groups.filter(values__coder=c): + existing_groups.update(qs) request.logger.warning(f'Coder {log_value} has already been added') + return + ListValue.objects.create(coder_list=coder_list, coder=c, group_id=get_group_id()) + request.logger.success(f'Added {log_value} coder to list') def add_account(a, log_value=None): log_value = log_value or a.key - if ListValue.objects.filter(coder_list=coder_list).count() >= django_settings.CODER_LIST_N_VALUES_LIMIT_: + if coder_list.values.count() >= django_settings.CODER_LIST_N_VALUES_LIMIT_: request.logger.warning(f'Limit reached. Account {log_value} not added') return - - added = False - try: - accounts_filter = CoderList.accounts_filter(uuids=[uuid], coder=coder, logger=request.logger) - if not Account.objects.filter(accounts_filter, pk=a.pk).exists(): - ListValue.objects.create(coder_list=coder_list, account=a, group_id=group_id) - added = True - except IntegrityError: - pass - - if added: - request.logger.success(f'Added {log_value} account to list') - else: + if qs := coder_list.groups.filter(Q(values__account=a) | Q(values__coder__account=a)): + existing_groups.update(qs) request.logger.warning(f'Account {log_value} has already been added') + return + ListValue.objects.create(coder_list=coder_list, account=a, group_id=get_group_id()) + request.logger.success(f'Added {log_value} account to list') if request_post.get('coder'): c = get_object_or_404(Coder, pk=request_post.get('coder')) @@ -2381,9 +2403,8 @@ def add_account(a, log_value=None): elif request_post.get('account'): a = get_object_or_404(Account, pk=request_post.get('account')) add_account(a) - elif request_post.get('delete_gid'): - group_id = request_post.get('delete_gid') - deleted, *_ = coder_list.values.filter(group_id=group_id).delete() + elif delete_group_id := request_post.get('delete_group_id'): + deleted, *_ = coder_list.groups.filter(pk=delete_group_id).delete() if deleted: request.logger.success('Deleted from list') else: @@ -2392,15 +2413,17 @@ def add_account(a, log_value=None): raw = request_post.get('raw') lines = raw.strip().splitlines() - if request_post.get('gid'): - if len(lines) > 1: - request.logger.warning(f'Ignore {len(lines) - 1} line(s)') + if group_id and len(lines) > 1: + request.logger.warning(f'Ignore {len(lines) - 1} line(s)') lines = lines[:1] - for line in lines: + for index, line in enumerate(lines): + if index: + reset_group_id() values = accounts_split(line.strip()) n_coders = 0 n_accounts = 0 + group_name = None for value in values: try: if ':' not in value: @@ -2418,6 +2441,11 @@ def add_account(a, log_value=None): n_coders += 1 else: host, account = value.split(':', 1) + if host == 'NAME': + if qs := coder_list.groups.filter(name=group_name): + existing_groups.add(qs) + group_name = account + continue resources = list(Resource.objects.filter(Q(host=host) | Q(short_host=host))) if not resources: request.logger.warning(f'Not found host = "{host}", value = "{value}"') @@ -2426,7 +2454,14 @@ def add_account(a, log_value=None): request.logger.warning(f'Too many resources found = "{resources}", value = "{value}"') continue resource = resources[0] - accounts = list(Account.objects.filter(resource=resource, key=account)) + renamings = resource.accountrenaming_set + for suffix in '__exact', '__iexact': + last_account = account + while (next_renaming := renamings.filter(**{f'old_key{suffix}': last_account}).first()): + last_account = next_renaming.new_key + accounts = list(resource.account_set.filter(**{f'key{suffix}': last_account})) + if accounts: + break if not accounts: request.logger.warning(f'Not found account = "{account}", value = "{value}"') continue @@ -2438,34 +2473,50 @@ def add_account(a, log_value=None): except Exception as e: logger.error(f'Error while adding raw to coder list: {e}') request.logger.error(f'Some problem with value = "{value}"') - group_id += 1 + + if group_id: + get_group_id() + if not group and existing_groups: + group = existing_groups.pop() + if group and existing_groups: + group = MergedModelInstance.create(group, existing_groups) + for g in existing_groups: + g.delete() + request.logger.info(f'Merged {len(existing_groups) + 1} groups') + if group and group_name: + group.name = group_name + group.save(update_fields=['name']) + elif value_id := request_post.get('delete_value_id'): + list_value = get_object_or_404(coder_list.values, pk=value_id) + list_group = list_value.group + deleted, *_ = list_value.delete() + if deleted: + if not list_group.values.exists(): + deleted, *_ = list_group.delete() + if deleted: + request.logger.success('Deleted group from list') + else: + request.logger.error('Group has not been deleted') + request.logger.success('Deleted from list') + else: + request.logger.warning('Nothing has been deleted') + else: + request.logger.warning('No action specified') + reset_group_id() return allowed_redirect(request.path) - coder_values = {} - for v in coder_list.related_values.order_by('group_id').all(): - data = coder_values.setdefault(v.group_id, {}) - data.setdefault('list_values', []).append(v) - - for data in coder_values.values(): - vs = [] - for v in data['list_values']: - if v.coder: - vs.append(v.coder.username) - elif v.account: - prefix = v.account.resource.short_host or v.account.resource.host - vs.append(f'{prefix}:{v.account.key}') - data['versus'] = ','.join(vs) + coder_list_groups = coder_list.related_groups context = { 'coder_list': coder_list, - 'coder_values': coder_values, + 'coder_list_groups': coder_list_groups, 'is_owner': is_owner, 'can_modify': can_modify, 'coder': coder, } - if len(coder_values) > 1: - context['versus'] = '/vs/'.join([d['versus'] for d in coder_values.values()]) + if len(coder_list_groups) > 1: + context['versus'] = '/vs/'.join([group.profile_str() for group in coder_list_groups]) return render(request, 'coder_list.html', context) @@ -2591,6 +2642,11 @@ def accounts(request, template='accounts.html'): list_uuids = [v for v in request.GET.getlist('list') if v] if list_uuids: + if list_uuids: + groups = ListGroup.objects.filter(coder_list__uuid__in=list_uuids, name__isnull=False) + groups = groups.filter(Q(values__account=OuterRef('pk')) | + Q(values__coder__account=OuterRef('pk'))) + accounts = accounts.annotate(value_instead_key=Subquery(groups.values('name')[:1])) accounts_filter = CoderList.accounts_filter(list_uuids, coder=coder, logger=request.logger) accounts = accounts.filter(accounts_filter) @@ -2622,7 +2678,7 @@ def accounts(request, template='accounts.html'): params['to_list'] = to_list context = {'params': params} - addition_table_fields = ('modified', 'updated', 'created', 'key', 'last_rating_activity') + addition_table_fields = ('modified', 'updated', 'created', 'name', 'key', 'last_rating_activity') table_fields = ('rating', 'resource_rank', 'n_contests', 'n_writers', 'last_activity') + addition_table_fields chart_field = request.GET.get('chart_column') @@ -2644,6 +2700,8 @@ def accounts(request, template='accounts.html'): field_type = 'float' elif v == {'int'}: field_type = 'int' + elif v == {'str'}: + field_type = 'str' else: continue fields_types[k] = field_type @@ -2732,6 +2790,14 @@ def accounts(request, template='accounts.html'): primary_account = Account.priority_objects.filter(resource=resource, coders=coder).first() context['primary_account'] = primary_account + # field_instead_key + if field_instead_key := request.GET.get('field_instead_key'): + if field_instead_key in custom_fields: + field_instead_key = f'info__{field_instead_key}' + elif field_instead_key not in table_fields: + field_instead_key = None + context['field_instead_key'] = field_instead_key + return template, context