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('
' + edit_score + edit_btn + '
' + edit_penalty + '
') } else if (type == 'submission') { if (is_hidden(score)) { @@ -1061,7 +1081,7 @@ function update_score_penalty_result(type) { } else { stat_class = 'rej' } - stat.prepend('
' + score + '
' + penalty + '
') + stat.prepend('
' + escape_html(score) + '
' + escape_html(penalty) + '
') } if (!stat.hasClass('problem-cell-stat')) { @@ -1070,6 +1090,10 @@ function update_score_penalty_result(type) { } } +function n_switchers() { + return $(ERASE_SWITCHER_SELECTOR).length +} + function switcher_click(event) { const hidden_regex = /^[-+0-9]+$/ var stat = $(this) @@ -1160,7 +1184,7 @@ function switcher_click(event) { } } - $('#erase-switchers-timeline').prop('disabled', $(ERASE_SWITCHER_SELECTOR).length == 0) + $('#erase-switchers-timeline').prop('disabled', n_switchers() == 0) switcher_updated(stat) clear_tooltip() @@ -1771,3 +1795,63 @@ $(() => { visible_standings() } }) + +/* + * Upload solution + */ + +$(() => { + $('.drop-zone').on('dragover', function (e) { + e.preventDefault() + e.stopPropagation() + $(this).addClass('dragover') + }) + + $('.drop-zone').on('dragleave', function (e) { + e.preventDefault() + e.stopPropagation() + $(this).removeClass('dragover') + }) + + $('.drop-zone').on('drop', function (e) { + e.preventDefault() + e.stopPropagation() + $(this).removeClass('dragover') + + const files = e.originalEvent.dataTransfer.files + if (files.length > 1) { + $.notify('Only one file can be uploaded', 'warn') + return + } + + if (!files.length) { + return + } + standings_upload_solution(files[0], $(this)) + }) + + function standings_upload_solution(file, problem_cell) { + if (file.size > problem_user_solution_size_limit) { + $.notify('File is too large', 'warn') + return + } + + const form_data = new FormData() + form_data.append('file', file) + form_data.append('pk', coder_pk) + form_data.append('problem-short', problem_cell.closest('.problem-cell').data('problem-key')) + form_data.append('statistic-id', problem_cell.closest('.stat-cell').data('statistic-id')) + form_data.append('name', 'standings-upload-solution') + $.ajax({ + type: 'POST', + url: change_url, + processData: false, + contentType: false, + data: form_data, + beforeSend: function() { problem_cell.addClass('uploading') }, + 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/standings_list.js b/src/static/js/standings_list.js index 318e90ea..9db1ca3c 100644 --- a/src/static/js/standings_list.js +++ b/src/static/js/standings_list.js @@ -1,10 +1,10 @@ function set_toggle_contest_groups() { $('#standings_list .contest .toggle').click(function() { - var selector = $(this).attr('data-group') + var selector = escape_html($(this).attr('data-group')) $(selector).slideToggle(200, 'linear').css('display', 'table-row'); $('[data-group="' + selector + '"] i').toggleClass('fa-caret-up').toggleClass('fa-caret-down') event.preventDefault() - }).removeClass('toggle') + }).removeClass('toggle').addClass('toggled') } $(set_toggle_contest_groups) diff --git a/src/templates/account_verification.html b/src/templates/account_verification.html index bccb1e6a..d0c39ee4 100644 --- a/src/templates/account_verification.html +++ b/src/templates/account_verification.html @@ -7,6 +7,7 @@
+{% if account.resource.account_verification_fields %}
Verification
    @@ -29,5 +30,11 @@
+{% else %} + +{% endif %}
+ {% endblock %} diff --git a/src/templates/accounts_paging.html b/src/templates/accounts_paging.html index 17153d41..dc566b3d 100644 --- a/src/templates/accounts_paging.html +++ b/src/templates/accounts_paging.html @@ -1,12 +1,12 @@ {% load preload_statistics %} -{% coder_account_filter accounts primary_account row_number_field=row_number_field operator=row_number_operator as coder_account %} +{% coder_account_filter accounts primary_account row_number_field=row_number_field operator=row_number_operator as filtred_account %} {% lazy_paginate 50,200 accounts %}{% get_pages %} {% preload_statistics accounts 'resource_id' attr='selected_stats' as preload_statistics_data %} -{% for account in accounts|chain:coder_account %} +{% for account in accounts|chain:filtred_account %} {% if account.row_number %}{{ account.row_number }}{% else %}{{ forloop.counter0|add:pages.current_start_index }}{% endif %} diff --git a/src/templates/coders.html b/src/templates/coders.html index e0ea9bcf..6100a19c 100644 --- a/src/templates/coders.html +++ b/src/templates/coders.html @@ -12,9 +12,10 @@
{% include "filter_collapse.html" with include_filter="coders_filters.html" %} - -
- + +
+ +
@@ -61,6 +62,7 @@
#
+
{% endblock %} diff --git a/src/templates/coders_paging.html b/src/templates/coders_paging.html index 38f7016d..c173899a 100644 --- a/src/templates/coders_paging.html +++ b/src/templates/coders_paging.html @@ -1,9 +1,13 @@ +{% coder_account_filter coders primary_coder row_number_field=row_number_field operator=row_number_operator as filtered_coder %} {% lazy_paginate 50,200 coders %}{% get_pages %} -{% for coder in coders %} - - {{ forloop.counter0|add:pages.current_start_index }} +{% for coder in coders|chain:filtered_coder %} + + {% if coder.row_number %}{{ coder.row_number }}{% else %}{{ forloop.counter0|add:pages.current_start_index }}{% endif %} {% get_country_from_coder coder as country %}
@@ -19,7 +23,7 @@ {% if perms.true_coders.change_coder %} - + {% endif %} {% if enable_global_rating %} @@ -71,3 +75,4 @@ {% endfor %} {% show_more_table %} + diff --git a/src/templates/delete_on_duplicate_attrs.html b/src/templates/delete_on_duplicate_attrs.html index a3aa9bd2..8730acee 100644 --- a/src/templates/delete_on_duplicate_attrs.html +++ b/src/templates/delete_on_duplicate_attrs.html @@ -1,4 +1,4 @@ {% if entry.delete_on_duplicate or entry == primary %} {% if not entry.delete_on_duplicate %}data-delete-on-duplicate-stop="true"{% endif %} -data-delete-on-duplicate="{{ name }}-{{ entry.pk }}" +data-delete-on-duplicate="delete-on-duplicate-{{ name }}-{{ entry.pk }}" {% endif %} diff --git a/src/templates/field_to_input.html b/src/templates/field_to_input.html new file mode 100644 index 00000000..317eb3d6 --- /dev/null +++ b/src/templates/field_to_input.html @@ -0,0 +1,20 @@ +{% with capitalized_field=field|capitalize_field %} +{% with value=request.GET|get_item:field %} +{% with collapse=value|iffalse %} + +{% if collapse %} +
+ +
+{% endif %} + +
+ {% icon_to field capitalized_field %} + +
+ +{% endwith %} +{% endwith %} +{% endwith %} diff --git a/src/templates/field_to_select.html b/src/templates/field_to_select.html index 244ca55e..c04adc96 100644 --- a/src/templates/field_to_select.html +++ b/src/templates/field_to_select.html @@ -9,10 +9,11 @@ {% endwith %} {% endif %} +{% with capitalized_field=data.title|default:field|capitalize_field %} {% if collapse %}
{% endif %} @@ -33,24 +34,17 @@
{% elif data.nogroupby %} - {% if data.icon is False %} - {{ data.title|default:field|capitalize_field }} - {% else %} - {% icon_to data.icon|default:field data.title|default:field|capitalize_field %} - {% endif %} + {% icon_to data.icon|default:field capitalized_field %} {% else %}
{% endif %} {% endif %} +{% endwith %} {% if data.options is not None %} {% if not data.noempty %}{% endif %} diff --git a/src/templates/main.html b/src/templates/main.html index 8002601d..a50e9826 100644 --- a/src/templates/main.html +++ b/src/templates/main.html @@ -147,7 +147,7 @@ {% endif %} - {{ contest.title }} + {% trim_to contest.title 60 %} {% if favorite_contests %}{% activity_action "fav" contest %}{% endif %} {% if contest.state != "past" and add_to_calendar != "disable" %} diff --git a/src/templates/standings.html b/src/templates/standings.html index 5d038504..8fb9cd4d 100644 --- a/src/templates/standings.html +++ b/src/templates/standings.html @@ -45,6 +45,9 @@ score_precision = {{ contest.info.standings.score_precision|default:"undefined" }} unfreezing = false unspecified_place = '{{ unspecified_place }}' + + n_highlight = {{ standings_options.n_highlight|default:"undefined" }} + problem_user_solution_size_limit = {{ problem_user_solution_size_limit|default:"undefined" }} {% endblock %} @@ -115,6 +118,10 @@

{% icon_to 'problems' %} {% endif %} + {% if contest.n_statistics %} + {% icon_to 'participants' %} + {% endif %} + {% call_method contest.merging_contests 'values_list' 'id' flat=True as merging_contests_ids %} {% if merging_contests_ids %} {% icon_to "merged_standings" %} diff --git a/src/templates/standings_filters.html b/src/templates/standings_filters.html index aaafc290..32103be7 100644 --- a/src/templates/standings_filters.html +++ b/src/templates/standings_filters.html @@ -44,6 +44,8 @@ {% 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" %} + {% if not request.user.is_authenticated %} {% endif %} @@ -53,6 +55,17 @@ + {% if contest.upload_solved_problems_solutions %} + {% if not request.user.is_authenticated %} + + {% endif %} +
+ +
+ {% endif %} + {% with find_me=request.GET.find_me|toint %} {% if find_me or params.find_me %} {% if find_me %} diff --git a/src/templates/standings_paging.html b/src/templates/standings_paging.html index 7b7b26cb..22b8e1e6 100644 --- a/src/templates/standings_paging.html +++ b/src/templates/standings_paging.html @@ -46,27 +46,31 @@ {% with info=participants_info|get_item:statistic.id %} <{{ tag }} class="first-u-cell sticky-column" data-sticky-column="first-u-cell"> {% if info.search %} - {% if data_1st_u.field %} - - {% else %} - - {% endif %} + {% if info.n %}
{% if info.prefix %}{{ info.prefix }}{% endif %}{{ info.n }}{% if info.q %} ({{ info.q }}){% endif %}
{% endif %} - {% if with_detail or not info.n %}{% if info.t_solving is not None %} - +
+ {% if with_detail or not info.n %}{% if info.out_of_highlight %} +
{{ info.t_solving|scoreformat }}{% if info.t_penalty is not None %}{% if info.t_penalty >= 0 %}+{% endif %}{{ info.t_penalty }}{% endif %} - +
{% endif %}{% endif %} - {% endif %} {% endwith %} {% endif %} <{{ tag }} class="handle-cell sticky-column {% with info=participants_info|get_item:statistic.id %} - {% if info and info.n and info.n <= standings_options.n_highlight or info and info.highlight %}bg-success{% endif %} + {% if info and info.n and not info.out_of_highlight and info.n <= standings_options.n_highlight or info and info.highlight %}bg-success{% endif %} {% endwith %} " data-sticky-column="handle-cell">
diff --git a/src/templates/standings_statistic_problems.html b/src/templates/standings_statistic_problems.html index 5c880d78..0d3834b6 100644 --- a/src/templates/standings_statistic_problems.html +++ b/src/templates/standings_statistic_problems.html @@ -3,7 +3,7 @@ {% with stat=addition.problems|get_item:key %} {% with with_first=params.division|pass_arg:problem|allow_first:stat %} <{{ tag }} - class="problem-cell{% if stat %} problem-cell-stat{% if '_class' in stat %} {{ stat|get_item:"_class" }}{% elif with_first and stat.first_ac_of_all %} first-ac-of-all{% elif with_first and stat.first_ac %} first-ac{% elif stat.max_score %} max-score{% endif %}{% endif %}" + class="problem-cell{% if stat %} problem-cell-stat{% if '_class' in stat %} {{ stat|get_item:"_class" }}{% elif with_first and stat.first_ac_of_all %} first-ac-of-all{% elif with_first and stat.first_ac %} first-ac{% elif stat.max_score %} max-score{% endif %}{% endif %}{% if hide_problems|contains:key and stat and not statistic.my_stat %} blurred-text{% endif %}{% if statistic.my_stat and with_solution and stat %} drop-zone{% endif %}" {% if stat %} data-score="{% if not problem.full_score or not stat.binary and not stat.result|slice:":1" == "+" %}{{ stat.result }}{% else %}{{ problem.full_score }}{% endif %}" data-result="{{ stat.result }}" diff --git a/src/templates/table_inner_scroll.html b/src/templates/table_inner_scroll.html index af4c0b02..3d286981 100644 --- a/src/templates/table_inner_scroll.html +++ b/src/templates/table_inner_scroll.html @@ -4,7 +4,7 @@ var default_height = table_inner_scroll.height() $(window).resize(function() { if (table_inner_scroll.length) { - var val = $(window).height() - table_inner_scroll.offset().top - {% if fullscreen %}0{% else %}50{% endif %} + var val = $(window).height() - table_inner_scroll.offset().top - {% if fullscreen %}0{% else %}20{% endif %} if (val < default_height) { table_inner_scroll.height(val) } else { diff --git a/src/true_coders/models.py b/src/true_coders/models.py index 913a0a43..e206b61f 100644 --- a/src/true_coders/models.py +++ b/src/true_coders/models.py @@ -264,6 +264,14 @@ def subscription_top_n_limit(self): def subscription_n_limit(self): return self.get_limit('subscription_n', django_settings.CODER_SUBSCRIPTION_N_LIMIT_) + def update_or_get_setting(self, field, value): + if value is not None: + self.settings[field] = value + self.save(update_fields=['settings']) + else: + value = self.settings.get(field) + return value + class CoderProblem(BaseModel): coder = models.ForeignKey(Coder, on_delete=models.CASCADE, related_name='verdicts') diff --git a/src/true_coders/views.py b/src/true_coders/views.py index 5f01f938..eb6ad34f 100644 --- a/src/true_coders/views.py +++ b/src/true_coders/views.py @@ -34,7 +34,7 @@ from sql_util.utils import Exists, SubqueryCount, SubqueryMax, SubquerySum from tastypie.models import ApiKey -from clist.models import Contest, ContestSeries, ProblemTag, Resource +from clist.models import Contest, ContestSeries, ProblemTag, Promotion, Resource from clist.templatetags.extras import (accounts_split, allowed_redirect, as_number, asfloat, format_time, get_item, get_problem_short, get_timezones, has_update_statistics_permission, is_rating_prediction_field, is_yes, query_transform, quote_url, relative_url) @@ -375,6 +375,7 @@ def coders(request, template='coders.html'): # ordering orderby = request.GET.get('sort_column') or [] + order = request.GET.get('sort_order') if orderby else None if orderby in ['username', 'global_rating', 'created', 'n_accounts', 'n_contests']: pass elif orderby and orderby.startswith('resource_'): @@ -387,7 +388,12 @@ def coders(request, template='coders.html'): request.logger.error(f'Not found `{orderby}` column for sorting') orderby = [] orderby = orderby if not orderby or isinstance(orderby, list) else [orderby] - order = request.GET.get('sort_order') + + context = {} + if orderby: + context['row_number_field'] = orderby[0] + context['row_number_operator'] = '__gt' if order == 'desc' else '__lt' + if order in ['asc', 'desc']: orderby = [getattr(F(o), order)(nulls_last=True) for o in orderby] elif order: @@ -396,14 +402,16 @@ def coders(request, template='coders.html'): orderby = orderby or [F(main_field).desc(nulls_last=True), '-created'] coders = coders.order_by(*orderby) - context = { + context.update({ 'coders': coders, + 'primary_coder': coder, 'params': params, 'virtual_field': virtual_field, 'chat_fields': chat_fields, 'custom_fields': custom_fields, 'view_coder_chat': view_coder_chat, - } + 'with_table_inner_scroll': not request.user_agent.is_mobile, + }) return template, context @@ -1731,6 +1739,32 @@ def get_field(name): virtual_start.save(update_fields=['addition']) message = f'{problem_short}: {message}' return JsonResponse({'status': 'success', 'message': message}) + elif name == 'standings-upload-solution': + statistic_id = request.POST.get('statistic-id') + if not statistic_id: + return HttpResponseBadRequest('empty statistic id') + statistic = get_object_or_404(Statistics, pk=statistic_id) + account = statistic.account + if not VerifiedAccount.objects.filter(coder=coder, account=account).exists(): + return HttpResponseBadRequest('account is not verified') + problem_short = request.POST.get('problem-short') + if not problem_short: + return HttpResponseBadRequest('empty problem short') + problems = statistic.addition.get('problems', {}) + if problem_short not in problems: + return HttpResponseBadRequest('problem not found') + file_size = request.FILES.get('file').size + if file_size > django_settings.PROBLEM_USER_SOLUTION_SIZE_LIMIT: + return HttpResponseBadRequest('file is too large') + try: + file_content = request.FILES.get('file').read().decode('utf-8') + except UnicodeDecodeError: + return HttpResponseBadRequest('file is not utf-8') + if not re.match(r'^[\x00-\x7F]*$', file_content): + return HttpResponseBadRequest('file is not text') + problems[problem_short]['user_solution'] = file_content + statistic.save(update_fields=['addition']) + return JsonResponse({'status': 'success', 'message': 'Solution was uploaded'}) else: return HttpResponseBadRequest(f'unknown name = {escape(name)}') @@ -2706,10 +2740,11 @@ def skip_promotion(request): promotion_id = request.POST.get('id') if not promotion_id: return HttpResponseBadRequest('No promotion id') + promotion = get_object_or_404(Promotion, pk=promotion_id) if request.user.is_authenticated: coder = request.user.coder - coder.settings['skip_promotion_id'] = promotion_id + coder.settings['skip_promotion_id'] = promotion.id coder.save() response = HttpResponse('ok') - response.set_cookie('_skip_promotion_id', promotion_id) + response.set_security_cookie('_skip_promotion_id', promotion.id) return response diff --git a/src/utils/custom_request.py b/src/utils/custom_request.py index da55ff87..324d1dd4 100644 --- a/src/utils/custom_request.py +++ b/src/utils/custom_request.py @@ -70,6 +70,13 @@ def has_contest_perm(self, perm, contest): return self.user.has_perm(perm, contest.resource) or self.user.has_perm(perm, contest) +def set_security_cookie(request, *args, **kwargs): + kwargs.setdefault('secure', True) + kwargs.setdefault('httponly', True) + kwargs.setdefault('samesite', 'Strict') + request.set_cookie(*args, **kwargs) + + def CustomRequest(request): setattr(request, 'logger', RequestLogger(request)) setattr(request, 'get_resource', partial(get_resource, request)) @@ -78,4 +85,5 @@ def CustomRequest(request): setattr(request, 'canonical_url', None) setattr(request, 'set_canonical', partial(set_canonical, request)) setattr(request, 'has_contest_perm', partial(has_contest_perm, request)) + setattr(request, 'set_security_cookie', partial(set_security_cookie, request)) return request