diff --git a/Dockerfile b/Dockerfile index 85f18040..b2c7e56d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,9 +39,7 @@ RUN wget https://github.com/stunnel/static-curl/releases/download/8.6.0-1/curl-l rm /tmp/curl.tar.xz # psql -RUN apt install -y postgresql-client - -RUN apt update --fix-missing +RUN apt update --fix-missing && apt install -y postgresql-client ENV APPDIR=/usr/src/clist WORKDIR $APPDIR @@ -115,9 +113,9 @@ RUN apk add --no-cache --virtual .build-deps \ zlib-dev \ bash \ util-linux \ - gawk \ - && cd /tmp \ - && git clone https://github.com/reorg/pg_repack.git \ + gawk +RUN cd /tmp \ + && git clone --depth 1 --branch ver_1.5.1 https://github.com/reorg/pg_repack.git \ && cd pg_repack \ && make \ && make install \ diff --git a/docker-compose.yml b/docker-compose.yml index ec8d2eb3..8b3c6730 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - shared_files:/usr/src/clist/sharedfiles/ - ./logs/production:/usr/src/clist/logs/ - ./legacy/logs/:/usr/src/clist/logs/legacy/ + - ./logs/postgres/:/usr/src/clist/logs/postgres/ depends_on: - db - memcached diff --git a/legacy/module/codecracker.arhn.in/index.php b/legacy/module/codecracker.arhn.in/index.php index 83dbc706..0dd0bf3c 100755 --- a/legacy/module/codecracker.arhn.in/index.php +++ b/legacy/module/codecracker.arhn.in/index.php @@ -3,7 +3,7 @@ $data = curlexec($URL, null, array("http_header" => array('content-type: application/json'), "json_output" => 1)); - if (!is_array($data)) { + if (!is_array($data) || isset($data['error'])) { trigger_error('data = ' . json_encode($data), E_USER_WARNING); return; } diff --git a/legacy/module/nerc.itmo.ru_school/index.php b/legacy/module/nerc.itmo.ru_school/index.php index 4643b159..ee2dd831 100755 --- a/legacy/module/nerc.itmo.ru_school/index.php +++ b/legacy/module/nerc.itmo.ru_school/index.php @@ -88,7 +88,7 @@ function replace_months($page, $with_year) { $title = htmlspecialchars_decode($title); $title = preg_replace('#\s+#', ' ', trim($title)); - $key = "{$season}_vkoshp"; + $key = "{$season}-vkoshp"; $_contests[$key] = array( 'start_time' => $date, diff --git a/legacy/update.php b/legacy/update.php index 62cc7295..2ca7364a 100755 --- a/legacy/update.php +++ b/legacy/update.php @@ -360,7 +360,7 @@ function($m) { $db->query("DELETE FROM clist_contest WHERE $delete_update", true); } - $contest['was_auto_added'] = 1; + $contest['is_auto_added'] = 1; $contest['auto_updated'] = date("Y-m-d H:i:s"); foreach ($contest as $field => $value) @@ -423,7 +423,7 @@ function($m) { } $resources_filter .= "(resource_id = $resource_id AND $time_filter)"; } - $query = "was_auto_added = true AND ($resources_filter)"; + $query = "is_auto_added = true AND ($resources_filter)"; $to_be_removed = $db->select("clist_contest", "*", $query); if ($to_be_removed) { $filename_log = LOGREMOVEDDIR . date("Y-m-d_H-i-s", time()) . '.txt'; diff --git a/src/clist/admin.py b/src/clist/admin.py index 72951e46..48d09e28 100644 --- a/src/clist/admin.py +++ b/src/clist/admin.py @@ -58,7 +58,7 @@ def parse_statistic(self, request, queryset): 'standings_kind', 'registration_url', 'trial_standings_url']}], ['Date information', {'fields': ['start_time', 'end_time', 'duration_in_secs']}], ['Secury information', {'fields': ['key']}], - ['Addition information', {'fields': ['was_auto_added', 'auto_updated', 'n_statistics', 'has_hidden_results', + ['Addition information', {'fields': ['is_auto_added', 'auto_updated', 'n_statistics', 'has_hidden_results', 'writers', 'calculate_time', 'info', 'variables', 'invisible', 'is_rated', 'is_promoted', 'with_medals', 'related', 'merging_contests', 'series', @@ -69,7 +69,8 @@ def parse_statistic(self, request, queryset): 'rating_prediction_timing', 'created', 'modified', 'updated']}], ['Rating', {'fields': ['rating_prediction_hash', 'has_fixed_rating_prediction_field', 'rating_prediction_fields']}], - ['Problem', {'fields': ['n_problems', 'problem_rating_hash', 'problem_rating_update_required']}], + ['Problem', {'fields': ['n_problems', 'problem_rating_hash', 'problem_rating_update_required', + 'hide_unsolved_standings_problems', 'upload_solved_problems_solutions']}], ['Submission', {'fields': ['has_submissions', 'has_submissions_tests']}], ] list_display = ['title', 'host', 'start_time', 'url', 'is_rated', 'invisible', 'key', 'standings_url', diff --git a/src/clist/api/v2.py b/src/clist/api/v2.py index 37984800..92fd064d 100644 --- a/src/clist/api/v2.py +++ b/src/clist/api/v2.py @@ -2,6 +2,7 @@ import re import arrow +from django.conf import settings from django.core.exceptions import FieldDoesNotExist from django.db.models import CharField, IntegerField, JSONField, Value from django.db.models.constants import LOOKUP_SEP @@ -352,7 +353,7 @@ def dehydrate(self, *args, **kwargs): for k in list(problem.keys()): if k.startswith('_'): problem.pop(k, None) - for k in 'solution', 'external_solution': + for k in settings.PROBLEM_API_IGNORE_FIELD: problem.pop(k, None) more_fields = bundle.data['more_fields'] diff --git a/src/clist/migrations/0166_contest_promotion.py b/src/clist/migrations/0166_contest_promotion.py new file mode 100644 index 00000000..6ca0acbd --- /dev/null +++ b/src/clist/migrations/0166_contest_promotion.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.4 on 2024-12-16 01:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('clist', '0166_rename_was_auto_added_contest_is_auto_added'), ('clist', '0167_contest_hide_unsolved_standings_problems'), ('clist', '0168_contest_upload_solved_problems_solutions'), ('clist', '0169_alter_promotion_name')] + + dependencies = [ + ('clist', '0165_contest_variables'), + ] + + operations = [ + migrations.RenameField( + model_name='contest', + old_name='was_auto_added', + new_name='is_auto_added', + ), + migrations.AddField( + model_name='contest', + name='hide_unsolved_standings_problems', + field=models.BooleanField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='contest', + name='upload_solved_problems_solutions', + field=models.BooleanField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='promotion', + name='name', + field=models.CharField(max_length=200), + ), + ] diff --git a/src/clist/models.py b/src/clist/models.py index 565d0a80..521e1c8a 100644 --- a/src/clist/models.py +++ b/src/clist/models.py @@ -517,7 +517,7 @@ class Contest(BaseModel): created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True, db_index=True) - was_auto_added = models.BooleanField(default=False) + is_auto_added = models.BooleanField(default=False) auto_updated = models.DateTimeField(auto_now_add=True) event_logs = GenericRelation('logify.EventLog', related_query_name='contest') @@ -528,6 +528,9 @@ class Contest(BaseModel): is_promoted = models.BooleanField(default=None, null=True, blank=True, db_index=True) + hide_unsolved_standings_problems = models.BooleanField(default=None, null=True, blank=True) + upload_solved_problems_solutions = models.BooleanField(default=None, null=True, blank=True) + objects = BaseContestManager() visible = VisibleContestManager() significant = SignificantContestManager() @@ -1206,7 +1209,7 @@ class PromotionTimeAttribute(models.TextChoices): class Promotion(BaseModel): - name = models.CharField(max_length=50) + name = models.CharField(max_length=200) contest = models.ForeignKey(Contest, on_delete=models.CASCADE) timer_message = models.CharField(max_length=200, null=True, blank=True) time_attribute = models.CharField(max_length=50, choices=PromotionTimeAttribute.choices) diff --git a/src/clist/templatetags/extras.py b/src/clist/templatetags/extras.py index 12daa9f6..706c7295 100644 --- a/src/clist/templatetags/extras.py +++ b/src/clist/templatetags/extras.py @@ -1353,13 +1353,12 @@ def rating_from_probability(b, p, min_rating=0, max_rating=5000): @register.simple_tag -def icon_to(value, default=None, icons=None, html_class=None, **kwargs): +def icon_to(value, default=None, icons=None, html_class=None, inner='', **kwargs): icons = icons or settings.FONTAWESOME_ICONS_ if default is None: default = value.title().replace('_', ' ') if value in icons: value = icons[value] - inner = '' params = kwargs if isinstance(value, dict): params = deepcopy(value) @@ -1374,15 +1373,14 @@ def icon_to(value, default=None, icons=None, html_class=None, **kwargs): html_class = params['class'] if 'title' in params: default = params['title'] - - if default: - inner += f' title="{default}" data-toggle="tooltip"' - if html_class: - inner += f' class="{html_class}"' - ret = f'{value}' - return mark_safe(ret) else: - return default + value = title_field(value) + if default: + inner += f' title="{default}" data-toggle="tooltip"' + if html_class: + inner += f' class="{html_class}"' + ret = f'{value}' + return mark_safe(ret) @register.simple_tag(takes_context=True) @@ -1591,7 +1589,10 @@ def coder_account_filter(queryset, entity, row_number_field=None, operator=None) return [] ret = queryset.filter(pk=entity.pk).annotate(delete_on_duplicate=Value(True)) if row_number_field: - value = getattr(entity, row_number_field) + if hasattr(entity, row_number_field): + value = getattr(entity, row_number_field) + else: + value = queryset.filter(pk=entity.pk).values_list(row_number_field, flat=True).first() if value is not None: row_number = queryset.filter(**{row_number_field + operator: value}).count() + 1 ret = ret.annotate(row_number=Value(row_number)) @@ -1615,6 +1616,13 @@ def is_yes(value): return str(value).lower() in settings.YES_ +@register.filter +def is_optional_yes(value): + if value is None: + return None + return str(value).lower() in settings.YES_ + + @register.filter def get_admin_url(obj): if obj is None: diff --git a/src/clist/utils.py b/src/clist/utils.py index 445373ae..84242faa 100644 --- a/src/clist/utils.py +++ b/src/clist/utils.py @@ -76,8 +76,11 @@ def similar_contests_replacing(match): def similar_contests_queryset(contest): - title = f'^{contest.title}$' regex = get_similar_contests_regex() - title_regex = re.sub(regex, similar_contests_replacing, title) - contests_filter = Q(title__iregex=title_regex, resource_id=contest.resource_id, stage__isnull=True) + title_regex = re.sub(regex, similar_contests_replacing, f'^{contest.title}$') + contests_filter = Q(title__iregex=title_regex) + if not re.match('^[^a-zA-Z]*$', contest.key): + key_regex = re.sub(regex, similar_contests_replacing, f'^{contest.key}$') + 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) diff --git a/src/clist/views.py b/src/clist/views.py index c3834709..b4db2cef 100644 --- a/src/clist/views.py +++ b/src/clist/views.py @@ -1,3 +1,4 @@ +import logging import re from collections import OrderedDict from copy import deepcopy @@ -9,6 +10,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required, permission_required from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.management.commands import dumpdata from django.db import transaction from django.db.models import Avg, Count, F, FloatField, IntegerField, Max, Min, OuterRef, Prefetch, Q, Subquery @@ -189,33 +191,30 @@ def get_events(request): elif status == 'running': contests = contests.filter(start_time__lte=now, end_time__gte=now) - try: - result = [] - for contest in contests.filter(query): - color = contest.resource.color - if past_action not in ['show', 'hide'] and contest.end_time < now: - color = contest.resource.info.get('get_events', {}).get('colors', {}).get(past_action, color) - - start_time = (contest.start_time + timedelta(minutes=offset)).strftime("%Y-%m-%dT%H:%M:%S") - end_time = (contest.end_time + timedelta(minutes=offset)).strftime("%Y-%m-%dT%H:%M:%S") - c = { - 'id': contest.pk, - 'title': contest.title, - 'host': contest.host, - 'url': contest.actual_url, - 'start': start_time, - 'end': end_time, - 'countdown': contest.next_time_to(now), - 'hr_duration': contest.hr_duration, - 'color': color, - 'icon': contest.resource.icon, - 'allDay': contest.full_duration >= timedelta(days=1), - } - if coder: - c['favorite'] = contest.is_favorite - result.append(c) - except Exception as e: - return JsonResponse({'error': str(e)}, safe=False, status=400) + result = [] + for contest in contests.filter(query): + color = contest.resource.color + if past_action not in ['show', 'hide'] and contest.end_time < now: + color = contest.resource.info.get('get_events', {}).get('colors', {}).get(past_action, color) + + start_time = (contest.start_time + timedelta(minutes=offset)).strftime("%Y-%m-%dT%H:%M:%S") + end_time = (contest.end_time + timedelta(minutes=offset)).strftime("%Y-%m-%dT%H:%M:%S") + c = { + 'id': contest.pk, + 'title': contest.title, + 'host': contest.host, + 'url': contest.actual_url, + 'start': start_time, + 'end': end_time, + 'countdown': contest.next_time_to(now), + 'hr_duration': contest.hr_duration, + 'color': color, + 'icon': contest.resource.icon, + 'allDay': contest.full_duration >= timedelta(days=1), + } + if coder: + c['favorite'] = contest.is_favorite + result.append(c) return JsonResponse(result, safe=False) @@ -993,6 +992,16 @@ def update_problems(contest, problems=None, force=False): break old_problem_ids.remove(opt_old_problem.id) opt_old_problem.contest = opt_new_problem.contest + + # FIXME: 'GenericRelation' object has no attribute 'field' + for activity in opt_old_problem.activities.all(): + try: + activity.object_id = opt_new_problem.id + activity.validate_unique() + except ValidationError as e: + logging.warning(f'ValidationError: {e}') + activity.delete() + MergedModelInstance.create(opt_new_problem, [opt_old_problem]) opt_old_problem.delete() diff --git a/src/my_oauth/views.py b/src/my_oauth/views.py index 325b198d..3dad1d23 100644 --- a/src/my_oauth/views.py +++ b/src/my_oauth/views.py @@ -317,7 +317,7 @@ def services_dumpdata(request): def form(request, uuid): form = get_object_or_404(Form.objects, pk=uuid) - token_id = request.session.get('form_token_id', None) + token_id = request.session.pop('form_token_id', None) token = Token.objects.filter(pk=token_id).first() if token_id else None if form.is_closed(): diff --git a/src/notification/admin.py b/src/notification/admin.py index 415b8aa5..f1b7b95a 100644 --- a/src/notification/admin.py +++ b/src/notification/admin.py @@ -14,6 +14,7 @@ class NotificationAdmin(BaseModelAdmin): @admin_register(Subscription) class SubscriptionAdmin(BaseModelAdmin): list_display = ['coder', 'method', 'enable', 'n_accounts', 'n_coders', + 'top_n', '_with_first_accepted', 'resource', 'contest', 'coder_list', 'coder_chat'] list_filter = ['enable', 'method'] search_fields = ['coder__username', 'accounts__key', 'coders__username'] @@ -24,6 +25,11 @@ def n_accounts(self, obj): def n_coders(self, obj): return obj.n_coders + def _with_first_accepted(self, obj): + return bool(obj.with_first_accepted) + _with_first_accepted.boolean = True + _with_first_accepted.short_description = 'AC' + def get_queryset(self, request): ret = super().get_queryset(request) ret = ret.annotate(n_accounts=SubqueryCount('accounts')) diff --git a/src/notification/management/commands/sendout_tasks.py b/src/notification/management/commands/sendout_tasks.py index 194b6135..b0e37710 100644 --- a/src/notification/management/commands/sendout_tasks.py +++ b/src/notification/management/commands/sendout_tasks.py @@ -227,6 +227,10 @@ def handle(self, *args, **options): deleted = 0 for is_email_iteration in range(2): for task in tqdm.tqdm(qs, 'sending'): + if task.notification is None: + if task.id: + task.delete() + continue is_email = task.notification.method == settings.NOTIFICATION_CONF.EMAIL if is_email_iteration != is_email: continue diff --git a/src/pyclist/settings.py b/src/pyclist/settings.py index 68bf5e4f..c9a2eed1 100644 --- a/src/pyclist/settings.py +++ b/src/pyclist/settings.py @@ -666,6 +666,8 @@ def show_toolbar_callback(request): ('verdict', '_verdicts', 'verdicts'), ) PROBLEM_IGNORE_KINDS = {INSIVIBLE_CONTEST_KIND, STAGE_CONTEST_KIND} +PROBLEM_API_IGNORE_FIELDS = {'solution', 'external_solution', 'user_solution'} +PROBLEM_USER_SOLUTION_SIZE_LIMIT = 65536 VIRTUAL_CODER_PREFIX_ = '∨' @@ -693,6 +695,7 @@ def show_toolbar_callback(request): 'location': '', 'chat': '', 'advanced': '', + 'n_advanced': '', 'company': '', 'language': '', 'languages': '', @@ -710,6 +713,7 @@ def show_toolbar_callback(request): 'find_me': '', 'search': '', 'detail_info': '', + 'solution_info': '', 'short_info': '', 'score_in_row': '', 'luck': '', @@ -726,6 +730,7 @@ def show_toolbar_callback(request): 'coders': '', 'accounts': '', 'problems': '', + 'participants': '', 'submissions': '', 'versus': '', 'last_activity': '', @@ -827,6 +832,8 @@ def show_toolbar_callback(request): 'n_bronze_problems': '', } +STANDINGS_WITH_DETAIL_DEFAULT = True +STANDINGS_WITH_SOLUTION_DEFAULT = False STANDINGS_SMALL_N_STATISTICS = 1000 STANDINGS_FREEZE_DURATION_FACTOR_DEFAULT = 0.2 STANDINGS_UNSPECIFIED_PLACE = '-' diff --git a/src/pyclist/views.py b/src/pyclist/views.py index 8d1eb7cd..9f625846 100644 --- a/src/pyclist/views.py +++ b/src/pyclist/views.py @@ -5,7 +5,7 @@ from django.apps import apps from django.conf import settings from django.contrib.admin.views.decorators import staff_member_required -from django.db.models import F +from django.db.models import F, Q from django.http import HttpResponseBadRequest, HttpResponseRedirect from django.urls import NoReverseMatch, reverse from django.utils.timezone import now @@ -110,7 +110,10 @@ def update_context_by_source(request, context): if field_type == 'BooleanField': values = [is_yes(v) for v in values] if values: - entities = entities.filter(**{f'{field}__in': values}) + entity_filter = Q(**{f'{field}__in': values}) + if 'None' in values: + entity_filter |= Q(**{f'{field}__isnull': True}) + entities = entities.filter(entity_filter) if x_axis: x_from = request.get_filtered_value('x_from') diff --git a/src/ranking/admin.py b/src/ranking/admin.py index 319d12f7..a41e07e1 100644 --- a/src/ranking/admin.py +++ b/src/ranking/admin.py @@ -123,7 +123,7 @@ class AutoRatingAdmin(BaseModelAdmin): class StatisticsAdmin(BaseModelAdmin): list_display = ['account', 'contest', 'place', 'solving', 'upsolving', '_skip', '_adv'] search_fields = ['=account__key'] - list_filter = ['contest__host', 'skip_in_stats'] + list_filter = ['skip_in_stats'] def get_readonly_fields(self, *args, **kwargs): return ['last_activity'] + super().get_readonly_fields(*args, **kwargs) @@ -220,9 +220,9 @@ def _n_different_coders(self, obj): @admin_register(ParseStatistics) class ParseStatisticsAdmin(BaseModelAdmin): - list_display = ['contest', 'enable', 'delay', 'created', 'modified'] - search_fields = ['contest__title', 'contest__host'] - list_filter = ['contest__host'] + list_display = ['enable', 'delay', 'contest__start_time', 'contest', 'created', 'modified'] + search_fields = ['contest__title', 'contest__host', 'contest__resource__host'] + list_filter = ['contest__resource'] def get_readonly_fields(self, *args, **kwargs): return ['parse_time'] + super().get_readonly_fields(*args, **kwargs) diff --git a/src/ranking/management/commands/detect_major_contests.py b/src/ranking/management/commands/detect_major_contests.py index 26cb84d9..b54e6697 100644 --- a/src/ranking/management/commands/detect_major_contests.py +++ b/src/ranking/management/commands/detect_major_contests.py @@ -4,6 +4,7 @@ import logging from django.core.management.base import BaseCommand +from django.db import transaction from django.utils import timezone from clist.models import Contest, Promotion, Resource @@ -13,11 +14,15 @@ class Command(BaseCommand): help = 'Detect major contests' + def __init__(self, *args, **kwargs): + super(Command, self).__init__(*args, **kwargs) + self.logger = logging.getLogger('ranking.detect.major_contests') + def add_arguments(self, parser): parser.add_argument('-n', '--dryrun', action='store_true', default=False) parser.add_argument('-r', '--resources', nargs='*', default=[]) - self.logger = logging.getLogger('ranking.detect.major_contests') + @transaction.atomic() def handle(self, *args, **options): self.stdout.write(str(options)) args = AttrDict(options) diff --git a/src/ranking/management/commands/parse_statistic.py b/src/ranking/management/commands/parse_statistic.py index c3c62435..a96d6893 100644 --- a/src/ranking/management/commands/parse_statistic.py +++ b/src/ranking/management/commands/parse_statistic.py @@ -1280,6 +1280,7 @@ def update_after_update_or_create(statistic, created, addition, try_calculate_ti previous_verdict = previous_problem.get('verdict') same_result = problem.get('result') == previous_problem.get('result') same_verdict = not verdict or not previous_verdict or verdict == previous_verdict + same_verdict |= is_solved(problem) if same_result and same_verdict: continue if not same_result and problem.get('first_ac'): diff --git a/src/ranking/management/modules/adventofcode.py b/src/ranking/management/modules/adventofcode.py index c40bade6..c927ff1e 100644 --- a/src/ranking/management/modules/adventofcode.py +++ b/src/ranking/management/modules/adventofcode.py @@ -97,7 +97,7 @@ def items_sort(d): has_virtual = False divisions_order = [] - for division in 'diff', 'virtual', 'main': + for division in 'virtual', 'diff', 'main': is_main = division == 'main' is_virtual = division == 'virtual' is_diff = division == 'diff' @@ -243,11 +243,11 @@ def items_sort(d): rank = idx r['place'] = rank - if is_main: + if is_virtual and not has_virtual: + continue + if not divisions_order: for k, v in division_result.items(): result[k].update(v) - elif is_virtual and not has_virtual: - continue self._set_medals(division_result, n_medals=True) for k, v in division_result.items(): result[k].setdefault('_division_addition', {}).update({division: v}) @@ -268,7 +268,7 @@ def items_sort(d): 'problems': problems, } if len(divisions_order) > 1: - ret['divisions_order'] = divisions_order[::-1] + ret['divisions_order'] = divisions_order now = datetime.now(tz=tz) if now.year == year and now.month == 12: diff --git a/src/ranking/management/modules/nerc_itmo.py b/src/ranking/management/modules/nerc_itmo.py index 91d78066..3685a971 100644 --- a/src/ranking/management/modules/nerc_itmo.py +++ b/src/ranking/management/modules/nerc_itmo.py @@ -8,6 +8,7 @@ from django.utils.timezone import now +from clist.templatetags.extras import as_number from ranking.management.modules.common import REQ, BaseModule, FailOnGetResponse, parsed_table from ranking.management.modules.excepts import InitModuleException from ranking.management.modules.nerc_itmo_helper import parse_xml @@ -52,7 +53,7 @@ def get_standings(self, users=None, statistics=None, **kwargs): for k, v in r: k = k.split()[0] if k == 'Total' or k == '=': - row['solving'] = int(v.value) + row['solving'] = as_number(v.value) elif len(k) <= 3: problems_info[k] = {'short': k} if 'title' in v.attrs: @@ -75,7 +76,7 @@ def get_standings(self, users=None, statistics=None, **kwargs): if len(first_ac): p['first_ac'] = True elif k == 'Time': - row['penalty'] = int(v.value) + row['penalty'] = as_number(v.value) elif k.lower() in ['place', 'rank']: row['place'] = v.value.strip('.') elif 'team' in k.lower() or 'name' in k.lower(): diff --git a/src/ranking/management/modules/nerc_itmo_helper.py b/src/ranking/management/modules/nerc_itmo_helper.py index a5fd5056..951d0719 100644 --- a/src/ranking/management/modules/nerc_itmo_helper.py +++ b/src/ranking/management/modules/nerc_itmo_helper.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -import tqdm import xml.etree.ElementTree as ET +import tqdm + def parse_xml(standings_xml): root = ET.fromstring(standings_xml) @@ -25,7 +26,7 @@ def parse_xml(standings_xml): score = None language = None for run in problem.findall('run'): - v = run.attrib.get('outcome') + v = (run.attrib.get('outcome') or '').lower() if v == 'compilation-error': continue n_attempt += 1 @@ -42,11 +43,9 @@ def parse_xml(standings_xml): accepted = run.attrib['accepted'] == 'yes' time = run_time language = run.attrib.get('language-id') + verdict = v if accepted: - verdict = None break - if v: - verdict = ''.join(s[0] for s in v.upper().split('-')) if n_attempt == 0: continue @@ -57,6 +56,10 @@ def parse_xml(standings_xml): else: if accepted: result = '+' if n_attempt == 1 else f'+{n_attempt - 1}' + result = f'?{result}' + elif verdict == 'undefined': + result = f'?{n_attempt}' + verdict = None else: result = f'-{n_attempt}' @@ -65,7 +68,8 @@ def parse_xml(standings_xml): if time: p['time'] = time if verdict: - p['verdict'] = verdict + verdict = ''.join(s[0] for s in verdict.upper().split('-')) + p['verdict'] = 'AC' if accepted else verdict if language: p['language'] = language return ret diff --git a/src/ranking/management/modules/nerc_itmo_school.py b/src/ranking/management/modules/nerc_itmo_school.py index 5544018c..918a4502 100644 --- a/src/ranking/management/modules/nerc_itmo_school.py +++ b/src/ranking/management/modules/nerc_itmo_school.py @@ -6,6 +6,7 @@ import tqdm +from clist.templatetags.extras import as_number from ranking.management.modules.common import DOT, REQ, SPACE, BaseModule, FailOnGetResponse, parsed_table from ranking.management.modules.common.locator import Locator from ranking.management.modules.excepts import ExceptionParseStandings @@ -101,6 +102,8 @@ def get_standings(self, users=None, statistics=None, **kwargs): n_problem = False c = mapping_key.get(c, c).lower() row[c] = v.value.strip() + if c in {'solving', 'place', 'penalty'}: + row[c] = as_number(row[c]) if xml_result and c == 'name' and v.value in xml_result: problems.update(xml_result[v.value]) @@ -163,8 +166,6 @@ def get_standings(self, users=None, statistics=None, **kwargs): solved = [p for p in list(problems.values()) if p['result'] == '100'] row['solved'] = {'solving': len(solved)} - elif re.match('^[0-9]+$', row['penalty']): - row['penalty'] = int(row['penalty']) if self.resource.info.get('statistics', {}).get('key_as_full_name'): row['member'] = name + ' ' + season diff --git a/src/ranking/utils.py b/src/ranking/utils.py index c2858d37..d3fc5318 100644 --- a/src/ranking/utils.py +++ b/src/ranking/utils.py @@ -568,7 +568,7 @@ def update_stage(self): problems_infos[problem_info_key]['n_total'] += 1 rank = stat.place_as_int score = None - if placing: + if stage_placing: placing_scores = _get_placing(placing, stat) score_rank = 'zero' if stat.solving < eps else str(rank) score = placing_scores.get(score_rank, placing_scores.get('default')) diff --git a/src/ranking/views.py b/src/ranking/views.py index 2a5c2983..7ce40ecb 100644 --- a/src/ranking/views.py +++ b/src/ranking/views.py @@ -29,8 +29,9 @@ from clist.models import Contest, ContestSeries, Resource from clist.templatetags.extras import (allowed_redirect, as_number, format_time, get_country_name, get_item, get_problem_short, get_problem_title, get_standings_divisions_order, - has_update_statistics_permission, is_ip_field, is_private_field, is_reject, - is_solved, is_yes, redirect_login, time_in_seconds, timestamp_to_datetime) + has_update_statistics_permission, is_ip_field, is_optional_yes, is_private_field, + is_reject, is_solved, is_yes, redirect_login, time_in_seconds, + timestamp_to_datetime) from clist.templatetags.extras import timezone as set_timezone from clist.templatetags.extras import toint, url_transform from clist.views import get_group_list, get_timeformat, get_timezone @@ -293,6 +294,8 @@ def _standings_highlight(contest, statistics, options): info = participants_info.setdefault(s.id, {}) info['search'] = rf'regex:^{k}' + info['first_u_key'] = k + info['first_u_quota'] = quota n_quota[k] = n_quota.get(k, 0) + 1 if (n_quota[k] > quota or last_hl) and (not more or more['n'] >= more['n_highlight'] or more_last_hl): @@ -302,7 +305,12 @@ def _standings_highlight(contest, statistics, options): if (not p_info or more_last_hl and (-more_last_hl['solving'], more_last_hl['penalty']) > (-p_info['solving'], p_info['penalty'])): # noqa p_info = more_last_hl + if n_quota[k] <= quota: + n_highlight += 1 + info.update({ + 'n': n_highlight, + 'out_of_highlight': True, 't_solving': p_info['solving'] - solving, 't_penalty': ( p_info['penalty'] - penalty - round((p_info['solving'] - solving) * contest_penalty_time) @@ -973,16 +981,16 @@ def standings(request, contest, other_contests=None, template='standings.html', else: find_me = int(find_me) - with_detail = request.GET.get('detail', 'true') in ['true', 'on'] + with_detail = is_optional_yes(request.GET.get('detail')) + with_solution = is_optional_yes(request.GET.get('solution')) if request.user.is_authenticated: coder = request.user.coder - if 'detail' in request.GET: - coder.settings['standings_with_detail'] = with_detail - coder.save() - else: - with_detail = coder.settings.get('standings_with_detail', False) + with_detail = coder.update_or_get_setting('standings_with_detail', with_detail) + with_solution = coder.update_or_get_setting('standings_with_solution', with_solution) 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_row_num = False @@ -997,7 +1005,7 @@ def standings(request, contest, other_contests=None, template='standings.html', else: statistics = Statistics.objects.filter(contest=contest) - options = contest.info.get('standings', {}) + options = copy.deepcopy(contest.info.get('standings', {})) per_page = 50 if contests_ids else contest.standings_per_page per_page_more = per_page if find_me else 200 @@ -1094,6 +1102,8 @@ 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) n_highlight_context = _standings_highlight(contest, statistics, options) if not contests_ids else {} # field to select @@ -1552,6 +1562,7 @@ def add_field_to_select(f): find_me_stat = None my_statistics = [] + my_stat = None if groupby == 'none' and coder: statistics = statistics.annotate(my_stat=SubqueryExists('account__coders', filter=Q(coder=coder))) my_statistics = statistics.filter(account__coders=coder).extra(select={'floating': True}) @@ -1581,6 +1592,14 @@ def add_field_to_select(f): inner_scroll = not request.user_agent.is_mobile is_charts = is_yes(request.GET.get('charts')) + hide_problems = set() + if my_stat and contest.hide_unsolved_standings_problems and not contest.is_over(): + my_stat_problems = my_stat.addition.get('problems', {}) + for problem in problems: + short = get_problem_short(problem) + if not is_solved(my_stat_problems.get(short)): + hide_problems.add(short) + context.update({ 'has_versus': has_versus, 'versus_data': versus_data, @@ -1602,8 +1621,10 @@ def add_field_to_select(f): 'virtual_start_statistics': virtual_start.statistics() if with_virtual_start else None, 'with_virtual_start': with_virtual_start, 'problems': problems, + 'hide_problems': hide_problems, 'params': params, 'settings_standings_fields': settings.STANDINGS_FIELDS_, + 'problem_user_solution_size_limit': settings.PROBLEM_USER_SOLUTION_SIZE_LIMIT, 'fields': fields, 'fields_types': fields_types, 'hidden_fields': hidden_fields, @@ -1619,6 +1640,7 @@ def add_field_to_select(f): 'fields_to_select': fields_to_select, 'truncatechars_name_problem': 10 * (2 if merge_problems else 1), 'with_detail': with_detail, + 'with_solution': with_solution, 'groupby': groupby, 'pie_limit_rows_groupby': 50, 'labels_groupby': labels_groupby, @@ -1743,9 +1765,8 @@ def solutions(request, sid, problem_key): @xframe_options_exempt def standings_action(request): user = request.user - error = None message = None - + status, error = 200, None try: action = request.POST['action'] if action == 'reset_contest_statistic_timing': @@ -1756,16 +1777,16 @@ def standings_action(request): contest.statistic_timing = None contest.save() else: - error = 'Permission denied' + status, error = 403, 'Permission denied' else: - error = 'Unknown action' - except Exception as e: - error = str(e) + status, error = 400, 'Unknown action' + except Exception: + status, error = 500, 'Internal error' if error is not None: ret = {'status': 'error', 'message': error} else: ret = {'status': 'success', 'message': message} - return JsonResponse(ret) + return JsonResponse(ret, status=status) def get_versus_data(request, query, fields_to_select): diff --git a/src/static/css/base.css b/src/static/css/base.css index 664c6f2e..f32b7d69 100644 --- a/src/static/css/base.css +++ b/src/static/css/base.css @@ -1031,3 +1031,20 @@ a[data-toggle="tooltip"][disabled], table.table-border-collapse-separate { border-collapse: separate; } + +/* + * Blurred + */ + +.blurred-text { + filter: blur(5px); + cursor: default; +} + +/* + * Input + */ + +.field-to-input { + max-width: 100px; +} diff --git a/src/static/css/profile.css b/src/static/css/profile.css index a7eb073d..d43dc584 100644 --- a/src/static/css/profile.css +++ b/src/static/css/profile.css @@ -125,11 +125,15 @@ table tr.contest.honorable-medal { pointer-events: none; } + +#account-verification { + margin-top: 10px; +} + #account-verification a { font-weight: bold; } - #account-verification ol li.list-group-item { list-style: decimal inside; display: list-item; diff --git a/src/static/css/standings.css b/src/static/css/standings.css index 3f29af7e..18de06b8 100644 --- a/src/static/css/standings.css +++ b/src/static/css/standings.css @@ -76,6 +76,12 @@ tr:hover .edit-active-switcher, .edit-active-switcher:hover { background: #d0e3f7 !important; } +.standings td.blurred-text, +.standings td.blurred-text a { + color: #888 !important; + background: inherit !important; +} + .standings .gold-medal-percentage { fill: #f9d923 !important; stroke: #f9d923 !important; @@ -408,3 +414,51 @@ table.standings.table-merge-problems th { background: white; box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5); } + + +/* + * upload solution + */ + + +.drop-zone { + position: relative; +} + +.drop-zone::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 2px dashed transparent; + pointer-events: none; +} + +.drop-zone.dragover::after { + border-color: magenta; +} + +.drop-zone.uploading::after { + border-color: magenta; + animation: dash_compress 1.5s infinite linear; +} + +@keyframes dash_compress { + 0%, 100% { border-width: 2px; } + 50% { border-width: 8px; } +} + + +/* + * first_u_cell + */ + +.standings td a.out-of-quota { + color: #ccc !important; +} + +.standings.intermediate .first-u-cell .small { + display: none; +} diff --git a/src/static/css/standings_list.css b/src/static/css/standings_list.css index 08fafa1f..a8170b28 100644 --- a/src/static/css/standings_list.css +++ b/src/static/css/standings_list.css @@ -43,3 +43,6 @@ text-align: center; } +#standings_list .contest .toggled { + cursor: pointer; +} diff --git a/src/static/js/ajax-csrf.js b/src/static/js/ajax-csrf.js index 0b11cd69..cdfdc4fe 100644 --- a/src/static/js/ajax-csrf.js +++ b/src/static/js/ajax-csrf.js @@ -24,10 +24,8 @@ function csrfSafeMethod(method) { return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); } -$.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } +$(document).on("ajaxSend", function (event, xhr, settings) { + if (!csrfSafeMethod(settings.type) && !settings.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken) } -}); +}) diff --git a/src/static/js/base.js b/src/static/js/base.js index 9caf68e8..482f9ad7 100644 --- a/src/static/js/base.js +++ b/src/static/js/base.js @@ -629,23 +629,29 @@ $(function() { $('#filter-collapse').on('hidden.bs.collapse', () => { $(window).trigger('resize') }) }) +var delete_on_duplicate_lasts = {} + function delete_on_duplicate(with_starred = false) { var elements = $('[data-delete-on-duplicate]') - var lasts = {} + var lasts = delete_on_duplicate_lasts var stops = {} elements.each(function(index) { + if (!this.isConnected) { + return; + } var $el = $(this) var id = $el.attr('data-delete-on-duplicate') if (id in stops) { $el.remove() } else { - if (id in lasts) { + if (id in lasts && !lasts[id].is($el)) { lasts[id].remove() } if ($el.attr('data-delete-on-duplicate-stop')) { stops[id] = true + } else { + lasts[id] = $el } - lasts[id] = $el if (with_starred) { $el.addClass('starred') var $show_more_el = $el.next() @@ -716,8 +722,11 @@ function coders_select(id, submit) { } -function escape_html(str) { - return str.replace(/&/g, '&') // First, escape ampersands +function escape_html(val) { + if (typeof val !== 'string') { + return val + } + return val.replace(/&/g, '&') // First, escape ampersands .replace(/"/g, '"') // then double-quotes .replace(/'/g, ''') // and single quotes .replace(/'); div.append(''); diff --git a/src/static/js/settings.js b/src/static/js/settings.js index f7e72f71..5d82927f 100644 --- a/src/static/js/settings.js +++ b/src/static/js/settings.js @@ -759,16 +759,16 @@ $(function() { descriptions = JSON.parse($(this).attr('data-descriptions') || "[]") var category_select = '' - CATEGORIES.forEach(el => { category_select += '' }) + CATEGORIES.forEach(el => { category_select += '' }) var resources_select = '' - RESOURCES.forEach(el => { resources_select += '' }) + RESOURCES.forEach(el => { resources_select += '' }) var descriptions_select_after = '' var descriptions_options = {} EVENT_DESCRIPTIONS.forEach(el => { var has = $.inArray(parseInt(el['id']), descriptions) !== -1 - var option = '' + var option = '' if (has) { descriptions_options[el['id']] = option } else { @@ -1390,7 +1390,7 @@ $(function() { }, success: function(data) { if (data.message == 'add') { - window.location.replace(ACCOUNTS_TAB_URL + '?resource=' + $search_resource.val()) + window.location.replace(ACCOUNTS_TAB_URL + '?resource=' + encodeURIComponent($search_resource.val())) return } diff --git a/src/static/js/standings.js b/src/static/js/standings.js index 2c6b790a..dd92c119 100644 --- a/src/static/js/standings.js +++ b/src/static/js/standings.js @@ -582,6 +582,13 @@ function set_timeline(percent = null, duration = null, scroll_to_element = null) $('table.standings').removeClass('unfreezing') } + var intermediate = !percentage_filled || (!unfreezing && freeze_duration) || with_virtual_start + if (intermediate) { + $('table.standings').addClass('intermediate') + } else { + $('table.standings').removeClass('intermediate') + } + clear_starred_unfreezed() if (unfreeze_index != UNFREEZE_INDEX_UNDEF && scroll_to_element === null) { var target_index = Math.floor(unfreeze_index) + (percent_sign < 0? 1 : 0) @@ -779,9 +786,6 @@ function set_timeline(percent = null, duration = null, scroll_to_element = null) PROBLEM_PROGRESS_STATS = problem_progress_stats } - var first = null - var last = null - var table_inner_scroll = $('#table-inner-scroll') var scroll_object = table_inner_scroll.length? table_inner_scroll : $('html, body') var table_top = $('table.standings').parent().offset().top @@ -798,7 +802,11 @@ function set_timeline(percent = null, duration = null, scroll_to_element = null) rows.sort(cmp_row) + var first = null + var last = null var current_top = rows_top + var seen_first_u = new Map() + var n_first_u = 0 rows.each((i, r) => { if (i == 0 || cmp_row(last, r) < 0) { if (first === null) { @@ -819,6 +827,18 @@ function set_timeline(percent = null, duration = null, scroll_to_element = null) var gap = (get_row_penalty(r) - get_row_penalty(first)) + (get_row_score(first) - get_row_score(r)) * current_time $r.find('>.gap-cell').attr('data-text', Math.round(gap / 60)) + var first_u_cell = $r.find('>.first-u-cell >a[data-first-u-key]') + if (first_u_cell.length) { + var first_u_key = first_u_cell.attr('data-first-u-key') + var first_u_quota = first_u_cell.attr('data-first-u-quota') + seen_first_u.set(first_u_key, (seen_first_u.get(first_u_key) || 0) + 1) + var in_quota = seen_first_u.get(first_u_key) <= first_u_quota + n_first_u += in_quota + var class_func = in_quota && (!n_highlight || n_first_u <= n_highlight)? 'removeClass' : 'addClass' + first_u_cell[class_func]('out-of-quota') + first_u_cell.attr('data-text', n_first_u) + } + var translation = $r.attr('data-offset') - current_top $r.removeAttr('data-offset') @@ -843,14 +863,13 @@ function set_timeline(percent = null, duration = null, scroll_to_element = null) }) - var toggle_hidden_selectors = ['.first-u-cell', 'table.standings td .trophy', 'table.standings .medal-percentange'] - var with_hidden = percentage_filled && (unfreezing || !freeze_duration) && !with_virtual_start + var toggle_hidden_selectors = ['table.standings td .trophy', 'table.standings .medal-percentange'] var toggle_hidden_selector = '' toggle_hidden_selectors.forEach((selector) => { if (toggle_hidden_selector) { toggle_hidden_selector += ',' } - toggle_hidden_selector += selector + (with_hidden? '.hidden' : ':not(.hidden)') + toggle_hidden_selector += selector + (intermediate? ':not(.hidden)' : '.hidden') }) if (toggle_hidden_selector) { var toggle_hidden_elements = $(toggle_hidden_selector) @@ -861,6 +880,7 @@ function set_timeline(percent = null, duration = null, scroll_to_element = null) rows.find('>.place-cell').each((i, e) => { $(e).text($(e).attr('data-text')) }) rows.find('>.gap-cell').each((i, e) => { $(e).text($(e).attr('data-text')) }) + rows.find('>.first-u-cell >a[data-text]').each((i, e) => { $(e).text($(e).attr('data-text')) }) var delay_duration = duration / 2 clearInterval(TRANSFORM_TIMER_ID) @@ -1050,8 +1070,8 @@ function update_score_penalty_result(type) { if (type == 'switcher') { var edit_btn = '' - var edit_score = '' + score + '' - var edit_penalty = '' + penalty + '' + var edit_score = '' + escape_html(score) + '' + var edit_penalty = '' + escape_html(penalty) + '' stat.prepend('
# | @@ -61,6 +62,7 @@
---|