From 9ae2d34231c4badb8235912ccc2098571866930e Mon Sep 17 00:00:00 2001 From: Aleksey Ropan Date: Sun, 17 Nov 2024 15:53:35 +0000 Subject: [PATCH] added subscriptions added forms live parse statistics repack database parsing fixes minor improvements --- .env.sentry.template | 1 + Dockerfile | 51 +- config/cron | 22 +- config/loggly/60-loggly.conf | 4 +- config/nginx/cron | 2 + config/postgres/cron | 1 + config/postgres/supervisord.conf | 28 + config/supervisord.conf | 4 +- docker-compose.yml | 34 +- legacy/helper.php | 51 +- legacy/module/acmp.ru/index.php | 28 +- legacy/module/algotester.com/index.php | 41 +- legacy/module/azspcs.com/index.php | 86 +++ legacy/module/beecrowd.com.br/index.php | 11 +- legacy/module/facebook.com/index.php | 71 +- legacy/module/icpc.baylor.edu/index.php | 5 +- legacy/module/kattis.com/index.php | 31 + legacy/module/lightoj.com/index.php | 9 +- legacy/module/open.kattis.com/index.php | 57 +- legacy/module/yandex.com_cup/index.php | 167 +++-- legacy/update.php | 7 +- requirements.txt | 7 +- src/clist/admin.py | 12 +- src/clist/api/v1.py | 6 +- src/clist/api/v2.py | 54 +- src/clist/api/v3.py | 2 +- src/clist/api/v4.py | 49 +- src/clist/migrations/0164_edit_contest.py | 25 + src/clist/models.py | 101 ++- src/clist/templatetags/extras.py | 130 +++- src/clist/views.py | 53 +- src/logify/admin.py | 16 +- .../management/commands/update_pgstat.py | 78 ++ .../management/commands/update_pgstattuple.py | 42 -- src/logify/migrations/0010_pretty_pgstat.py | 64 ++ src/logify/models.py | 15 +- src/my_oauth/admin.py | 18 +- .../migrations/0013_alter_token_email_form.py | 40 + src/my_oauth/migrations/0014_forms.py | 60 ++ src/my_oauth/models.py | 23 +- src/my_oauth/urls.py | 2 + src/my_oauth/utils.py | 28 + src/my_oauth/views.py | 106 ++- src/notification/admin.py | 25 +- src/notification/forms.py | 1 - .../management/commands/sendout_tasks.py | 8 +- .../migrations/0042_edit_subscription.py | 102 +++ src/notification/models.py | 134 +++- src/notification/utils.py | 100 +++ src/pyclist/decorators.py | 78 +- src/pyclist/middleware.py | 9 +- src/pyclist/models.py | 12 +- src/pyclist/settings.py | 33 +- src/ranking/admin.py | 25 +- src/ranking/management/commands/informer.py | 52 +- .../commands/parse_accounts_infos.py | 4 +- .../commands/parse_live_statistics.py | 392 ++++++++++ .../management/commands/parse_statistic.py | 342 ++++++--- src/ranking/management/modules/acm_bsu.py | 8 +- .../management/modules/algorithm_yandex.py | 2 + src/ranking/management/modules/algotester.py | 2 + src/ranking/management/modules/atcoder.py | 17 +- src/ranking/management/modules/codechef.py | 2 +- src/ranking/management/modules/codeforces.py | 12 +- .../management/modules/common/__init__.py | 5 +- src/ranking/management/modules/cphof.py | 2 +- src/ranking/management/modules/facebook.py | 147 ++-- src/ranking/management/modules/icpc_baylor.py | 78 +- src/ranking/management/modules/leetcode.py | 47 +- src/ranking/management/modules/open_kattis.py | 253 ++++--- src/ranking/management/modules/topcoder.py | 39 +- src/ranking/management/modules/yandex.py | 4 +- .../0133_subsriptions_and_parsestatistics.py | 45 ++ src/ranking/models.py | 709 ++---------------- src/ranking/utils.py | 665 +++++++++++++++- src/ranking/views.py | 53 +- src/scripts/repack-database.bash | 62 ++ src/scripts/up.bash | 17 +- src/static/css/base.css | 34 + src/static/css/form.css | 25 + src/static/css/settings.css | 18 + src/static/css/standings.css | 4 + src/static/img/resources/azspcs_com.png | Bin 0 -> 12999 bytes .../img/resources/calico_cs_berkeley_edu.png | Bin 0 -> 30550 bytes src/static/img/resources/coliseum_ai.png | Bin 0 -> 4764 bytes src/static/img/resources/kattis_com.png | Bin 0 -> 29794 bytes src/static/js/base.js | 26 +- src/static/js/contest/calendar.js | 8 +- src/static/js/contest/main.js | 5 +- src/static/js/settings.js | 291 +++++-- src/static/js/standings.js | 28 +- src/templates/account_table_cell.html | 2 +- src/templates/base.html | 8 +- src/templates/chart.html | 59 +- src/templates/coder.html | 12 + src/templates/coder_list.html | 4 +- src/templates/event.html | 2 - src/templates/field_value.html | 2 + src/templates/form.html | 40 + src/templates/main.html | 21 +- src/templates/main_notification.html | 4 +- src/templates/message/telegram | 2 +- src/templates/navbar.html | 1 + src/templates/party.html | 26 +- src/templates/profile.html | 2 +- src/templates/profile_account.html | 7 +- src/templates/profile_contests_paging.html | 2 +- src/templates/robots.txt | 1 + src/templates/series_filter.html | 5 +- src/templates/settings.html | 84 ++- src/templates/settings_chats.html | 30 + src/templates/settings_subscription.html | 70 +- src/templates/standings.html | 1 + src/templates/standings_account.html | 43 +- src/templates/standings_account_members.html | 7 +- src/templates/standings_list_filters.html | 2 +- src/templates/standings_problem_progress.html | 2 +- src/tg/bot.py | 285 ++++++- src/tg/models.py | 20 + src/true_coders/admin.py | 6 + ...oder_problems.py => set_coder_problems.py} | 20 +- .../migrations/0070_coder_n_subscribers.py | 18 + src/true_coders/models.py | 63 +- src/true_coders/views.py | 299 +++++--- src/utils/custom_request.py | 27 +- src/utils/db.py | 9 + src/utils/requester/__init__.py | 63 +- src/utils/strings.py | 24 + src/utils/urlutils.py | 10 + 129 files changed, 4987 insertions(+), 1863 deletions(-) create mode 100644 config/nginx/cron create mode 100644 config/postgres/cron create mode 100644 config/postgres/supervisord.conf create mode 100644 legacy/module/azspcs.com/index.php create mode 100644 legacy/module/kattis.com/index.php create mode 100644 src/clist/migrations/0164_edit_contest.py create mode 100644 src/logify/management/commands/update_pgstat.py delete mode 100644 src/logify/management/commands/update_pgstattuple.py create mode 100644 src/logify/migrations/0010_pretty_pgstat.py create mode 100644 src/my_oauth/migrations/0013_alter_token_email_form.py create mode 100644 src/my_oauth/migrations/0014_forms.py create mode 100644 src/my_oauth/utils.py create mode 100644 src/notification/migrations/0042_edit_subscription.py create mode 100644 src/notification/utils.py create mode 100644 src/ranking/management/commands/parse_live_statistics.py create mode 100644 src/ranking/migrations/0133_subsriptions_and_parsestatistics.py create mode 100755 src/scripts/repack-database.bash create mode 100644 src/static/css/form.css create mode 100644 src/static/img/resources/azspcs_com.png create mode 100644 src/static/img/resources/calico_cs_berkeley_edu.png create mode 100644 src/static/img/resources/coliseum_ai.png create mode 100644 src/static/img/resources/kattis_com.png create mode 100644 src/templates/coder.html create mode 100644 src/templates/form.html create mode 100644 src/templates/settings_chats.html rename src/true_coders/management/commands/{fill_coder_problems.py => set_coder_problems.py} (91%) create mode 100644 src/true_coders/migrations/0070_coder_n_subscribers.py create mode 100644 src/utils/urlutils.py diff --git a/.env.sentry.template b/.env.sentry.template index 720db38a..adc82df0 100644 --- a/.env.sentry.template +++ b/.env.sentry.template @@ -11,3 +11,4 @@ SENTRY_CRON_MONITOR_CHECKING_LOGS= SENTRY_CRON_MONITOR_REINDEX= SENTRY_CRON_MONITOR_SET_ACCOUNT_RANK= SENTRY_CRON_MONITOR_UPDATE_AUTO_RATING= +SENTRY_CRON_MONITOR_PARSE_ARCHIVE_PROBLEMS= diff --git a/Dockerfile b/Dockerfile index f91c8cd0..b31fe275 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,9 @@ RUN wget https://github.com/stunnel/static-curl/releases/download/8.6.0-1/curl-l mv /tmp/curl /usr/local/bin/curl && \ rm /tmp/curl.tar.xz +# psql +RUN apt install -y postgresql-client + RUN apt update --fix-missing ENV APPDIR=/usr/src/clist @@ -45,7 +48,7 @@ WORKDIR $APPDIR FROM base as dev ENV DJANGO_ENV_FILE .env.dev RUN apt install -y redis-server -CMD sh -c 'redis-server --daemonize yes; scripts/watchdog.bash "python manage.py rqworker" "*.py"; python manage.py runserver 0.0.0.0:10042' +CMD sh -c 'redis-server --daemonize yes; scripts/watchdog.bash "python manage.py rqworker system default" "*.py"; python manage.py runserver 0.0.0.0:10042' COPY config/ipython_config.py . RUN ipython profile create @@ -71,6 +74,7 @@ RUN mkdir /run/daphne COPY config/redis.conf /etc/redis/redis.conf COPY config/supervisord.conf /etc/supervisord.conf + CMD supervisord -c /etc/supervisord.conf @@ -82,7 +86,50 @@ COPY config/loggly/60-loggly.conf /etc/rsyslog.d/60-loggly.conf ENTRYPOINT /entrypoint.sh -FROM nginx:alpine as nginx +FROM nginx:stable-alpine as nginx +# logrotate RUN apk add --no-cache logrotate COPY config/nginx/logrotate.d/nginx /etc/logrotate.d/nginx RUN chmod 0644 /etc/logrotate.d/nginx +# cron +RUN apk add --no-cache logrotate dcron +COPY config/nginx/cron /etc/cron.d/nginx +RUN chmod 0644 /etc/cron.d/nginx +RUN crontab /etc/cron.d/nginx + +CMD crond && nginx -g "daemon off;" + + +FROM postgres:14.3-alpine as postgres +# pg_repack +RUN apk add --no-cache --virtual .build-deps \ + gcc \ + g++ \ + make \ + musl-dev \ + postgresql-dev \ + git \ + lz4-dev \ + zlib-dev \ + bash \ + util-linux \ + gawk \ + && cd /tmp \ + && git clone https://github.com/reorg/pg_repack.git \ + && cd pg_repack \ + && make \ + && make install \ + && apk del .build-deps \ + && rm -rf /tmp/pg_repack +# numfmt +RUN apk add --no-cache coreutils +# cron +RUN apk add --no-cache dcron +COPY config/postgres/cron /etc/cron.d/postgres +RUN chmod 0644 /etc/cron.d/postgres +RUN crontab /etc/cron.d/postgres +# supervisord +RUN apk add --no-cache supervisor +COPY config/postgres/supervisord.conf /etc/supervisord.conf + +CMD supervisord -c /etc/supervisord.conf diff --git a/config/cron b/config/cron index 0e4d0218..a15cc986 100644 --- a/config/cron +++ b/config/cron @@ -1,18 +1,18 @@ PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DJANGO_ENV_FILE=.env.prod -20,35,55 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_CALENDAR_UPDATE /usr/src/clist/run-manage.bash update_google_calendars -*/1 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_CREATING_NOTIFICATIONS /usr/src/clist/run-manage.bash notification_to_task -*/1 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_SENDING_NOTIFICATIONS /usr/src/clist/run-manage.bash sendout_tasks -*/1 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_PARSING_STATISTICS /usr/src/clist/run-manage.bash parse_statistic -*/3 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_PARSING_ACCOUNTS /usr/src/clist/run-manage.bash parse_accounts_infos -30 * * * * /usr/src/clist/run-manage.bash parse_archive_problems -*/1 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_CHECKING_LOGS /usr/src/clist/run-manage.bash check_logs -*/15 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_SET_ACCOUNT_RANK /usr/src/clist/run-manage.bash set_account_rank -*/20 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_SET_COUNTRY_RANK /usr/src/clist/run-manage.bash set_country_rank -15 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_UPDATE_AUTO_RATING /usr/src/clist/run-manage.bash update_auto_rating +20,35,55 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_CALENDAR_UPDATE /usr/src/clist/run-manage.bash update_google_calendars +*/1 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_CREATING_NOTIFICATIONS /usr/src/clist/run-manage.bash notification_to_task +*/1 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_SENDING_NOTIFICATIONS /usr/src/clist/run-manage.bash sendout_tasks +*/1 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_PARSING_STATISTICS /usr/src/clist/run-manage.bash parse_statistic +*/1 * * * * /usr/src/clist/run-manage.bash parse_live_statistics +*/3 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_PARSING_ACCOUNTS /usr/src/clist/run-manage.bash parse_accounts_infos +30 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_PARSE_ARCHIVE_PROBLEMS /usr/src/clist/run-manage.bash parse_archive_problems +*/1 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_CHECKING_LOGS /usr/src/clist/run-manage.bash check_logs +*/15 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_SET_ACCOUNT_RANK /usr/src/clist/run-manage.bash set_account_rank +*/20 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_SET_COUNTRY_RANK /usr/src/clist/run-manage.bash set_country_rank +15 * * * * env MONITOR_NAME=SENTRY_CRON_MONITOR_UPDATE_AUTO_RATING /usr/src/clist/run-manage.bash update_auto_rating -# 32 1 * * wed env MONITOR_NAME=SENTRY_CRON_MONITOR_REINDEX /usr/src/clist/run-manage.bash reindex # # 58 3 14-20 * * [ "$(date '+\%u')" -eq 4 ] && cd $PROJECT_DIR && run-one ./manage.py runscript calculate_account_contests >logs/command/calculate_account_contests.log 2>&1 # 58 4 * * 4 cd $PROJECT_DIR && run-one ./manage.py runscript calculate_coder_n_accounts_and_coder_n_contests >logs/command/calculate_coder_n_accounts_and_coder_n_contests.log 2>&1 # 58 5 * * 4 cd $PROJECT_DIR && run-one ./manage.py runscript calculate_resource_contests >logs/command/calculate_resource_contests.log 2>&1 diff --git a/config/loggly/60-loggly.conf b/config/loggly/60-loggly.conf index 0a27780e..431a6fa8 100644 --- a/config/loggly/60-loggly.conf +++ b/config/loggly/60-loggly.conf @@ -1,14 +1,14 @@ $ModLoad imfile $InputFilePollInterval 5 -$InputFileName /logs/nginx/clist-prod-access.log +$InputFileName /logs/nginx/nginx/clist-prod-access.log $InputFileTag nginx-prod-access: $InputFileStateFile /logs/loggly/nginx-prod-access $InputFileSeverity info $InputFileReadMode 0 $InputRunFileMonitor -$InputFileName /logs/nginx/clist-prod-error.log +$InputFileName /logs/nginx/nginx/clist-prod-error.log $InputFileTag nginx-prod-error: $InputFileStateFile /logs/loggly/nginx-prod-error $InputFileSeverity error diff --git a/config/nginx/cron b/config/nginx/cron new file mode 100644 index 00000000..2ce92484 --- /dev/null +++ b/config/nginx/cron @@ -0,0 +1,2 @@ +0 */6 * * * /usr/sbin/logrotate /etc/logrotate.conf >/var/log/logrotate.log 2>&1 +0 0 */9 * * nginx -s reload >/var/log/nginx-reload.log 2>&1 diff --git a/config/postgres/cron b/config/postgres/cron new file mode 100644 index 00000000..e604aede --- /dev/null +++ b/config/postgres/cron @@ -0,0 +1 @@ +5 0 * * thu /usr/src/clist/scripts/repack-database.bash >/var/log/repack_database.log 2>&1 diff --git a/config/postgres/supervisord.conf b/config/postgres/supervisord.conf new file mode 100644 index 00000000..f8a8cb6a --- /dev/null +++ b/config/postgres/supervisord.conf @@ -0,0 +1,28 @@ +[supervisord] +logfile=/var/log/supervisord.log +loglevel=info +nodaemon=true +logfile_maxbytes=1MB +logfile_backups=5 + +[program:postgres] +command=/usr/local/bin/docker-entrypoint.sh postgres -c max_connections=50 -c checkpoint_timeout=60min -c track_activity_query_size=4096 -c shared_buffers=1GB -c effective_cache_size=3GB -c work_mem=64MB -c maintenance_work_mem=500MB +autostart=true +autorestart=true +stdout_logfile=/var/log/postgres_stdout.log +stderr_logfile=/var/log/postgres_stderr.log +stdout_logfile_maxbytes=10MB +stderr_logfile_maxbytes=10MB +stdout_logfile_backups=5 +stderr_logfile_backups=5 + +[program:cron] +command=/usr/sbin/crond -f +autostart=true +autorestart=true +stdout_logfile=/var/log/cron_stdout.log +stderr_logfile=/var/log/cron_stderr.log +stdout_logfile_maxbytes=10MB +stderr_logfile_maxbytes=10MB +stdout_logfile_backups=5 +stderr_logfile_backups=5 diff --git a/config/supervisord.conf b/config/supervisord.conf index 99476e98..e54cdbc4 100644 --- a/config/supervisord.conf +++ b/config/supervisord.conf @@ -49,9 +49,9 @@ stdout_logfile=logs/redis.log stderr_logfile=logs/redis.err [program:rqworker] -command=python manage.py rqworker +command=python manage.py rqworker system default directory=/usr/src/clist/ -numprocs=3 +numprocs=4 process_name=%(program_name)s%(process_num)d user=root autostart=true diff --git a/docker-compose.yml b/docker-compose.yml index 8ce3aeb6..ec8d2eb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,8 @@ services: context: . target: prod networks: - - clist_network + clist_network: + ipv4_address: 172.42.0.102 volumes: - static_files:/usr/src/clist/staticfiles/ - media_files:/usr/src/clist/mediafiles/ @@ -25,7 +26,8 @@ services: context: . target: dev networks: - - clist_network + clist_network: + ipv4_address: 172.42.0.103 volumes: - ./src/:/usr/src/clist/ - ./legacy/api/:/usr/src/clist/legacy/api/ @@ -50,9 +52,11 @@ services: legacy: build: ./legacy networks: - - clist_network + clist_network: + ipv4_address: 172.42.0.104 volumes: - ./legacy:/usr/src/legacy/ + - shared_files:/sharedfiles/ depends_on: - db secrets: @@ -71,22 +75,17 @@ services: - db restart: unless-stopped db: - image: postgres:14.3-alpine + build: + context: . + target: postgres networks: - clist_network volumes: - postgres_data:/var/lib/postgresql/data/ + - ./src/scripts/:/usr/src/clist/scripts/ + - ./logs/postgres/:/var/log/ env_file: - ./.env.db - command: > - postgres - -c max_connections=50 - -c checkpoint_timeout=60min - -c track_activity_query_size=4096 - -c shared_buffers=1GB - -c effective_cache_size=3GB - -c work_mem=64MB - -c maintenance_work_mem=500MB shm_size: 4GB ports: - ${CLIST_DB_PORT:-5432}:5432 @@ -96,7 +95,8 @@ services: context: . target: nginx networks: - - clist_network + clist_network: + ipv4_address: 172.42.0.101 volumes: - static_files:/staticfiles/ - media_files:/mediafiles/ @@ -104,11 +104,10 @@ services: - ./config/nginx/conf.d:/etc/nginx/conf.d/ - certbot_www:/var/www/certbot/ - certbot_conf:/etc/letsencrypt/ - - ./logs/nginx:/var/log/nginx/ + - ./logs/nginx:/var/log/ ports: - 80:80 - 443:443 - command: "/bin/sh -c 'while :; do logrotate /etc/logrotate.conf; sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" restart: unless-stopped certbot: image: certbot/certbot:latest @@ -166,6 +165,9 @@ networks: driver_opts: com.docker.network.bridge.name: br-clist name: clist + ipam: + config: + - subnet: 172.42.0.0/24 secrets: db_conf: diff --git a/legacy/helper.php b/legacy/helper.php index 53691a82..b2ba5a34 100755 --- a/legacy/helper.php +++ b/legacy/helper.php @@ -184,6 +184,12 @@ function curlexec(&$url, $postfields = NULL, $params = array()) curl_setopt($CID, CURLOPT_HEADER, true); } + if (isset($params["no_body"])) { + curl_setopt($CID, CURLOPT_NOBODY, true); + } else { + curl_setopt($CID, CURLOPT_NOBODY, false); + } + $cachefile = CACHEDIR . "/" . parse_url($url, PHP_URL_HOST) . "-" . md5(preg_replace("#/?timeMin=[^&]*#", "", $url)) . ".html"; if ($postfields !== NULL) { @@ -217,7 +223,7 @@ function curlexec(&$url, $postfields = NULL, $params = array()) } else { $page = curl_exec($CID); } - if (preg_match('#charset=["\']?([-a-z0-9]+)#i', $page, $match)) + if (preg_match('#charset=["\']?([-a-z0-9]+)#i', $page, $match) && !isset($params['no_convert_charset'])) { $charset = $match[1]; if (!preg_match('#^utf.*8$#i', $charset)) @@ -686,4 +692,47 @@ function pop_item(&$array, $path, $default = null) { unset($last_result[$key]); return $result; } + + function parsed_table($table_html) { + $dom = new DOMDocument(); + $dom->loadHTML($table_html); + + $cols = $dom->getElementsByTagName('th'); + $header = array(); + foreach ($cols as $col) { + $header[] = slugify($col->nodeValue); + } + + $rows = $dom->getElementsByTagName('tr'); + $data = array(); + foreach ($rows as $row) { + $cols = $row->getElementsByTagName('td'); + if ($cols->length == 0) { + continue; + } + if ($cols->length != count($header)) { + continue; + } + $headered_cols = array_combine($header, iterator_to_array($cols)); + $row_data = array(); + foreach ($headered_cols as $field => $col) { + $row_data[$field] = trim($col->nodeValue); + $a = $col->getElementsByTagName('a'); + if ($a->length > 0) { + $row_data[$field . ':url'] = $a[0]->getAttribute('href'); + } + } + $data[] = $row_data; + } + return $data; + } + + function current_season_year() { + $year = date('Y'); + $month = date('n'); + if ($month <= 8) { + return $year - 1; + } + return $year; + } ?> diff --git a/legacy/module/acmp.ru/index.php b/legacy/module/acmp.ru/index.php index 8716abe2..033dd6f9 100644 --- a/legacy/module/acmp.ru/index.php +++ b/legacy/module/acmp.ru/index.php @@ -14,7 +14,10 @@ $url = $change_url; $page = curlexec($url, "period=$year", array('http_header' => array("Content-Type: application/x-www-form-urlencoded", "Referer: $referer"))); - preg_match('#

(?P[^<]*)</h1>#', $page, $match); + if (!preg_match('#<h1>(?P<title>[^<]*)</h1>#', $page, $match)) { + trigger_error("Can't find title", E_USER_WARNING); + break; + } if (strpos($match['title'], "$year") === false) { break; } @@ -32,14 +35,29 @@ $page = str_replace(" ", " ", $page); $page = replace_russian_moths_to_number($page); - preg_match('#<h1>Содержание олимпиады "(?P<title>.*?)"</h1>#', $page, $m); + if (!preg_match('#<h1>Содержание олимпиады "(?P<title>.*?)"</h1>#', $page, $m)) { + trigger_error("Can't find title", E_USER_WARNING); + continue; + } $title = $m['title']; - preg_match('#<b[^>]*>Начало олимпиады:</b>(?P<start_time>[^<]*)<#', $page, $m); + + if (!preg_match('#<b[^>]*>Начало олимпиады:</b>(?P<start_time>[^<]*)<#', $page, $m)) { + trigger_error("Can't find start time", E_USER_WARNING); + continue; + } $start_time = $m['start_time']; $start_time = preg_replace('#\s*г\.\s*#', ' ', $start_time); - preg_match('#<b[^>]*>Продолжительность:</b>(?P<duration>[^<]*)<#', $page, $m); + + if (!preg_match('#<b[^>]*>Продолжительность:</b>(?P<duration>[^<]*)<#', $page, $m)) { + trigger_error("Can't find duration", E_USER_WARNING); + continue; + } $duration = $m['duration']; - preg_match('#id_stage=(?P<id>[0-9]+)#', $url, $m); + + if (!preg_match('#id_stage=(?P<id>[0-9]+)#', $url, $m)) { + trigger_error("Can't find id", E_USER_WARNING); + continue; + } $key = $m['id']; $contests[] = array( diff --git a/legacy/module/algotester.com/index.php b/legacy/module/algotester.com/index.php index 297709d7..f29dc518 100755 --- a/legacy/module/algotester.com/index.php +++ b/legacy/module/algotester.com/index.php @@ -33,8 +33,11 @@ $seen = array(); $tournament_ids = array(); + $tournament_urls = array(); foreach ($scoreboard_urls as $scoreboard_url) { $page = curlexec($scoreboard_url, null, array('no_logmsg' => true)); + preg_match('#<title>(?P<title>[^<]*)#', $page, $match); + preg_match_all('#]*href="(?P[^"]*/Tournament/Display/(?P[0-9]+))[^"]*"#', $page, $matches, PREG_SET_ORDER); foreach ($matches as $match) { @@ -44,6 +47,28 @@ $seen[$match['id']] = true; $tournament_ids[] = $match['id']; } + + preg_match_all('#
  • \s*]*href="(?P/?[^"/]+)"[^>]*>[^<]*\s*
  • #', $page, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + $url = url_merge($scoreboard_url, $match['href']); + if (in_array($url, $tournament_urls)) { + continue; + } + $tournament_urls[] = $url; + } + } + + foreach ($tournament_urls as $tournament_url) { + $page = curlexec($tournament_url, null, array('no_logmsg' => true)); + preg_match_all('#/TournamentList/(?P[0-9]+)#', $page, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + if (isset($seen[$match['id']])) { + continue; + } + $seen[$match['id']] = true; + $tournament_ids[] = $match['id']; + } } if (empty($tournament_ids)) { @@ -51,12 +76,20 @@ return; } - $ids = range(1, 100500); + $seen = array(); + $ids = array_merge($tournament_ids, range(1, 100500)); $n_success = 0; foreach ($ids as $tournament_id) { - if (1.1 * $n_success < $tournament_id - 10) { + if (isset($seen[$tournament_id])) { + continue; + } + $seen[$tournament_id] = true; + + $in_tournament = in_array($tournament_id, $tournament_ids); + if (!$in_tournament && 1.1 * $n_success < $tournament_id - 10) { break; } + $offset = 0; $limit = 100; do { @@ -89,7 +122,7 @@ $standings_url = 'https://algotester.com/en/Contest/ViewScoreboard/'. $c['Id']; } $title = $c['Name']['Text']; - $invisible = in_array($tournament_id, $tournament_ids)? 'false' : 'true'; + $contest = array( 'start_time' => $c['ContestStart'], 'end_time' => $c['ContestEnd'], @@ -99,7 +132,7 @@ 'rid' => $RID, 'timezone' => $TIMEZONE, 'key' => $c['Id'], - 'invisible' => $invisible, + 'invisible' => $in_tournament? "false" : "true", 'standings_url' => $standings_url, ); $contests[] = $contest; diff --git a/legacy/module/azspcs.com/index.php b/legacy/module/azspcs.com/index.php new file mode 100644 index 00000000..572bb735 --- /dev/null +++ b/legacy/module/azspcs.com/index.php @@ -0,0 +1,86 @@ +(?P[^/]*)/index.php)">(?P[^<]*)</a>#', $page, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $url = url_merge($main_url, $match['url']); + $page = curlexec($url); + $key = $match['key']; + $title = $match['title']; + $title = preg_replace('#\s*contest\s*$#i', '', $title); + $title = preg_replace("#\s*Al\s*Zimmermann's\s*#i", '', $title); + + unset($start_time, $end_time); + + if (preg_match('#<a[^>]*href="(?P<url>[^"]*)"[^>]*>[^<]*description[^<]*</a>#i', $page, $match)) { + $description_url = url_merge($url, $match['url']); + $description_page = curlexec($description_url, NULL, ['no_convert_charset' => true, 'no_header' => true]); + } else { + $description_page = $page; + } + + $start_regex = '#(?:start|begin)\w*:(?:\s*|<[^>]*>| )*(?P<start_time>[^<]+[^;])<#i'; + if (preg_match($start_regex, $page, $match) || preg_match($start_regex, $description_page, $match)) { + $start_time = preg_replace('#\s*,\s*#', ' ', $match['start_time']); + $start_time = strtotime($start_time); + } + $end_regex = '#(?:end)\w*:(?:\s*|<[^>]*>| )*(?P<end_time>[^<]+[^;])<#i'; + if (preg_match($end_regex, $page, $match) || preg_match($end_regex, $description_page, $match)) { + $end_time = preg_replace('#\s*,\s*#', ' ', $match['end_time']); + $end_time = strtotime($end_time); + } + + + if (!preg_match('#<a[^>]*href="(?P<url>[^"]*)"[^>]*>[^<]*standings[^<]*</a>#i', $page, $match)) { + preg_match('#<a[^>]*href="(?P<url>[^"]*)"[^>]*>[^<]*final\s*results[^<]*</a>#i', $page, $match); + } + $standings_url = url_merge($url, $match['url']); + + if (!isset($start_time) && !isset($end_time)) { + $standings_page = curlexec($standings_url, NULL, ['no_convert_charset' => true, 'no_header' => true]); + $date_regex = '#(?:\d+-\d+-\d+ \d+:\d+:\d+|\d+ \w+ \d+\s*(?:<[^>]*>|,\s*)?\d+:\d+:\d+|\w+ \d+, \d{4})#i'; + preg_match_all($date_regex, $standings_page, $matches1, PREG_SET_ORDER); + preg_match_all($date_regex, $description_page, $matches2, PREG_SET_ORDER); + $matches = array_merge($matches1, $matches2); + + foreach ($matches as $match) { + $time = $match[0]; + $time = preg_replace('#<[^>]*>#', ' ', $time); + $time = preg_replace('#\s*,\s*#', ' ', $time); + $time = strtotime($time); + if (!$time) { + continue; + } + if (!isset($start_time) || $time < $start_time) { + $start_time = $time; + } + if (!isset($end_time) || $time > $end_time) { + $end_time = $time; + } + } + } else if (!isset($start_time)) { + $start_time = $end_time; + } else if (!isset($end_time)) { + $end_time = $start_time; + } + + $contests[] = array( + "start_time" => $start_time, + "end_time" => $end_time, + "title" => $title, + "url" => $url, + "key" => $key, + "rid" => $RID, + "host" => $HOST, + "standings_url" => $standings_url, + ); + } + // print_r($matches); +?> diff --git a/legacy/module/beecrowd.com.br/index.php b/legacy/module/beecrowd.com.br/index.php index a4b303ce..c73aaef3 100644 --- a/legacy/module/beecrowd.com.br/index.php +++ b/legacy/module/beecrowd.com.br/index.php @@ -30,12 +30,12 @@ preg_match_all('# <tr[^>]*>\s* <td[^>]*>\s*<a[^>]*href="(?P<url>[^"]*)"[^>]*>\s*(?P<key>[0-9]+)\s*</a>\s*</td>\s* - <td[^>]*>.*?</td>\s* - <td[^>]*>.*?</td>\s* - <td[^>]*>\s*<a[^>]*>(?P<title>[^<]*)</a>\s*</td>\s* + <td[^>]*>(?:\s*<[^/][^>]*>)*\s*</td>\s* + <td[^>]*>(?:\s*<[^/][^>]*>)*\s*</td>\s* + <td[^>]*>\s*(?:<[^/][^>]*>\s*)*<a[^>]*>(?P<title>[^<]*)</a>\s*(?:<[^>]*>\s*)*</td>\s* <td[^>]*class="[^"]*date[^"]*"[^>]*>(?P<start>[^<]*)</td>\s* <td[^>]*>(?P<duration>[^<]*)</td>\s* - </tr>#xs', + </tr>#x', $page, $matches, PREG_SET_ORDER, @@ -43,6 +43,9 @@ $nothing = true; foreach ($matches as $c) { + foreach ($c as $k => $v) { + $c[$k] = trim($v); + } $url = url_merge($URL, $c['url']); $title = $c['title']; if (substr($title, -3) == '...') { diff --git a/legacy/module/facebook.com/index.php b/legacy/module/facebook.com/index.php index 002ddad8..09cdd7a4 100755 --- a/legacy/module/facebook.com/index.php +++ b/legacy/module/facebook.com/index.php @@ -7,12 +7,14 @@ function get_ids($page) { global $required_urls; preg_match_all('#<link[^>]*href="(?P<href>[^"]*rsrc[^"]*\.js\b[^"]*)"[^>]*>#', $page, $matches); $urls = $matches['href']; - // preg_match_all('#{"type":"js","src":"(?P<href>[^"]*rsrc[^"]*)"#', $page, $matches); - // foreach ($matches['href'] as $u) { - // $u = str_replace('\/', '/', $u); - // $urls[] = $u; - // } - // $urls = array_unique($urls); + + preg_match_all('#{"type":"js","src":"(?P<href>[^"]*rsrc[^"]*)"#', $page, $matches); + foreach ($matches['href'] as $u) { + $u = str_replace('\/', '/', $u); + $urls[] = $u; + } + + $urls = array_unique($urls); $urls_ = array_fill(0, count($urls), null); $offset = 0; @@ -32,72 +34,43 @@ function get_ids($page) { "CodingCompetitionsContestScoreboardQuery", "CCEScoreboardQuery", ); + $required_ids = array_fill_keys($required_ids, true); foreach ($urls as $u) { - if (DEBUG) { - echo "get id url = $u\n"; - } $url = $u; $p = curlexec($u, null, array('no_logmsg' => true)); + $new_ids = array(); if (preg_match_all('#{id:"(?P<id>[^"]*)"(?:[^{}]*(?:{[^}]*})?)*}#', $p, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { if (preg_match('#,name:"(?P<name>[^"]*)"#', $match[0], $m)) { $ids[$m['name']] = $match['id']; + $new_ids[] = $m['name']; } } } if (preg_match_all('#__d\("(?P<name>[^_]*)_facebookRelayOperation"[^_]*exports="(?P<id>[^"]*)"#', $p, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $ids[$match['name']] = $match['id']; + $new_ids[] = $match['name']; } } - $required_ids_ = array(); - foreach ($required_ids as $id) { - if (!isset($ids[$id])) { - $required_ids_[] = $id; + foreach ($new_ids as $k) { + if (isset($required_ids[$k])) { + unset($required_ids[$k]); } } - if (count($required_ids) != count($required_ids_)) { - $required_urls[$url] = true; - } - $required_ids = $required_ids_; - - if (!count($required_ids)) { + if (empty($required_ids)) { break; } } return $ids; } - $url = "https://www.facebook.com/?_fb_noscript=1"; - $page = curlexec($url); - - if (preg_match('#<form[^>]*action="(?P<url>[^"]*/login/[^"]*)"#', $page, $match)) { - $url = $match['url']; - preg_match_all('#<input[^>]*name="(?P<name>[^"]*)"[^>]*value="(?P<value>[^"]*)"#', $page, $matches, PREG_SET_ORDER); - $data = array(); - foreach ($matches as $match) { - $data[$match['name']] = $match['value']; - } - require_once dirname(__FILE__) . "/secret.php"; - $data['email'] = $FACEBOOK_USERNAME; - $data['pass'] = $FACEBOOK_PASSWORD; - unset($FACEBOOK_EMAIL); - unset($FACEBOOK_USERNAME); - unset($FACEBOOK_PASSWORD); - $page = curlexec($url, $data); - } - - $headers = array( - 'pragma: no-cache', - 'cache-control: no-cache', - 'upgrade-insecure-requests: 1', - 'sec-fetch-site: same-origin', - 'sec-fetch-mode: navigate', - 'sec-fetch-user: ?1', - 'sec-fetch-dest: document', - ); + $headers = json_decode(file_get_contents('sharedfiles/resource/facebook/headers.json')); + $headers = array_map(function($k, $v) { return "$k: $v"; }, array_keys((array)$headers), (array)$headers); + $cookie_file = 'sharedfiles/resource/facebook/cookies.txt'; + $curlexec_params = array('http_header' => $headers, 'with_curl' => true, 'cookie_file' => $cookie_file); unset($year); for (;;) { @@ -108,7 +81,7 @@ function get_ids($page) { if (DEBUG) { echo "url = $url\n"; } - $page = curlexec($url, null, array("http_header" => $headers)); + $page = curlexec($url, null, $curlexec_params); unset($fb_dtsg); if (preg_match('#\["DTSGInitialData",\[\],{"token":"(?P<token>[^"]*)"#', $page, $match)) { @@ -179,7 +152,7 @@ function get_ids($page) { $url = rtrim($URL, '/') . "/$year/{$node['contest_vanity']}"; $scoreboard_url = rtrim($url) . '/scoreboard'; $url_ = $scoreboard_url; - $scoreboard_page = curlexec($url_); + $scoreboard_page = curlexec($url_, NULL, ['with_curl' => true]); $scoreboard_ids = get_ids($scoreboard_page); if ($scoreboard_ids) { $info['_scoreboard_ids'] = $scoreboard_ids; diff --git a/legacy/module/icpc.baylor.edu/index.php b/legacy/module/icpc.baylor.edu/index.php index 75e353e4..bca74287 100755 --- a/legacy/module/icpc.baylor.edu/index.php +++ b/legacy/module/icpc.baylor.edu/index.php @@ -23,7 +23,7 @@ $year = $match['year']; $title = trim($match['title']); - if (!preg_match("#>hosted by(?:[^,<]*,)?\s*(?P<where>[^<]*?)\s*<#i", $page, $match)) { + if (!preg_match("#>hosted by\s+(?:the\s+)(?:[^,<]*,)?\s*(?P<where>[^<]*?)\s*<#i", $page, $match)) { trigger_error('Not found where', E_USER_WARNING); return; } @@ -63,6 +63,9 @@ $title .= ". $where, Egypt"; $timezone = 'Africa/Cairo'; $start_date = str_replace($year, $year + 1, $start_date); + } else if (starts_with($where, 'Kazakhstan')) { + $title .= ". $where"; + $timezone = 'Asia/Almaty'; } else { $title .= ". $where"; $timezone = $TIMEZONE; diff --git a/legacy/module/kattis.com/index.php b/legacy/module/kattis.com/index.php new file mode 100644 index 00000000..252bb089 --- /dev/null +++ b/legacy/module/kattis.com/index.php @@ -0,0 +1,31 @@ +<?php + require_once dirname(__FILE__) . '/../../config.php'; + + $_contests = $contests; + $contests = array(); + + $subdomains = $INFO['update']['subdomains']; + foreach ($subdomains as $subdomain_format) { + $n_skip = 0; + for ($year = current_season_year() + 1, $iter = 0; $n_skip < 3; $year--, $iter++) { + $subdomain = strtr($subdomain_format, array('{YY}' => substr($year, 2, 2), '{YYYY}' => $year)); + $HOST = "$subdomain.kattis.com"; + $URL = "https://$HOST/contests/"; + $n_contests = -count($contests); + include './module/open.kattis.com/index.php'; + $n_contests += count($contests); + $n_skip = $n_contests ? 0 : $n_skip + 1; + if ($iter >= 2 && !isset($_GET['parse_full_list'])) { + break; + } + } + } + + foreach ($contests as $contest) { + if (preg_match('/\b(practice|warmup|test|hidden)\b/i', $contest['title'])) { + continue; + } + $_contests[] = $contest; + } + $contests = $_contests; +?> diff --git a/legacy/module/lightoj.com/index.php b/legacy/module/lightoj.com/index.php index 5dd85332..5e1c366c 100755 --- a/legacy/module/lightoj.com/index.php +++ b/legacy/module/lightoj.com/index.php @@ -16,10 +16,12 @@ } foreach ($response['data']['contests']['data'] as $c) { - if ($c['contestVisibilityStr'] == 'private') { - continue; + $visibility = $c['contestVisibilityStr']; + if ($c['isPasswordProtectedBool']) { + $visibility = 'protected'; } - $title = $c['contestTitleStr'] . ' [' . $c['contestVisibilityStr'] . ', ' . $c['contestTypeStr'] . ', ' . $c['contestParticipationTypeStr'] . ']'; + $title = $c['contestTitleStr'] . ' [' . $visibility . ', ' . $c['contestTypeStr'] . ', ' . $c['contestParticipationTypeStr'] . ']'; + $info = array('parse' => $c); $contests[] = array( 'start_time' => $c['contestStartTimestamp'], 'end_time' => $c['contestEndTimestamp'], @@ -29,6 +31,7 @@ 'rid' => $RID, 'timezone' => $TIMEZONE, 'key' => $c['contestId'], + 'info' => $info, ); } diff --git a/legacy/module/open.kattis.com/index.php b/legacy/module/open.kattis.com/index.php index 2114d5e9..95d51569 100755 --- a/legacy/module/open.kattis.com/index.php +++ b/legacy/module/open.kattis.com/index.php @@ -1,8 +1,12 @@ <?php require_once dirname(__FILE__) . '/../../config.php'; + $host = parse_url($URL, PHP_URL_HOST); + $host_parts = explode('.', $host); + $subdomain = count($host_parts) == 3 && $host_parts[0] != 'open'? $host_parts[0] : false; + $urls = array($URL); - if (isset($_GET['parse_full_list'])) { + if (!$subdomain && isset($_GET['parse_full_list'])) { $url_scheme_host = parse_url($URL, PHP_URL_SCHEME) . "://" . parse_url($URL, PHP_URL_HOST); $urls[] = $url_scheme_host . '/past-contests?user_created=off'; } @@ -14,44 +18,43 @@ 'Ongoing' => 'start_time', 'Upcoming' => 'start_time', 'Past' => 'end_time' - ) as $t => $v + ) as $table_title => $date_field ) { - if (!preg_match("#<h2[^>]*>$t</h2>([^<]*<[^/][^>]*>)*\s*<table[^>]*>.*?</table>#s", $page, $match)) { + if (!preg_match("#<h2[^>]*>$table_title</h2>([^<]*<[^/][^>]*>)*\s*<table[^>]*>.*?</table>#s", $page, $match)) { continue; } - preg_match_all('# - <tr[^>]*>\s* - <td[^>]*>\s* - (?:<[^>]*>\s*)+ - <a[^>]*href="(?P<url>[^"]*)"[^>]*>(?P<title>[^<]*)</a>\s* - (?:</[^>]*>\s*)+ - </td>\s* - (?:<td[^>]*>[0-9:\s]+</td>\s*)?? - <td[^>]*>(?P<duration>[^<]*)</td>\s* - <td[^>]*>(?P<date>[^<]*)</td>\s* - (?:<[^>]*>\s*|<button[^>]*>[^<]*</button>)* - </tr> - #msx', - $match[0], - $matches, - PREG_SET_ORDER - ); - foreach ($matches as $data) { - $key = explode('/', $data['url']); + + $table = parsed_table($match[0]); + + foreach ($table as $data) { + $title = $data['name']; + $title = preg_replace('/[^0-9a-z]*â[^0-9a-z]*/i', ' - ', $title); + + $url = url_merge($clean_url, $data['name:url']); + $key = explode('/', $url); $key = end($key); - $date = trim($data['date']); + if ($subdomain) { + $key = "$subdomain.$key"; + } + + $date = trim($data[str_replace('_', '-', $date_field)]); if (substr_count($date, ' ') == 1) { $date = strtotime($date); $day = 24 * 60 * 60; if ($date + $day < time()) { $date += $day; + } else if ($date_field == 'end_time' && $date > time()) { + $date -= $day; } } + + $duration = preg_replace('#^([0-9]+:[0-9]+):[0-9]+$#', '$1', $data['length']); + $contests[] = array( - $v => $date, - 'title' => $data['title'], - 'duration' => preg_replace('#^([0-9]+:[0-9]+):[0-9]+$#', '$1', $data['duration']), - 'url' => url_merge($clean_url, $data['url']), + $date_field => $date, + 'title' => $title, + 'duration' => $duration, + 'url' => $url, 'host' => $HOST, 'rid' => $RID, 'timezone' => $TIMEZONE, diff --git a/legacy/module/yandex.com_cup/index.php b/legacy/module/yandex.com_cup/index.php index ed6413f3..ad003c83 100755 --- a/legacy/module/yandex.com_cup/index.php +++ b/legacy/module/yandex.com_cup/index.php @@ -1,79 +1,37 @@ <?php require_once dirname(__FILE__) . "/../../config.php"; - - $url = $URL; - + $url = 'https://contest.yandex.com/yacup/schedule'; $page = curlexec($url); - $year = date('Y'); - preg_match_all('#<a[^>]*home-hero__link[^>]*home-hero__link_(?P<type>[a-z]+)[^>]*href="(?P<href>[^"]*)"[^>]*>.*?<[^>]*home-hero__title[^>]*>(?P<name>[^<]*)(</[^>]*>)*</a>#', $page, $matches, PREG_SET_ORDER); + preg_match_all('#<h1[^>]*>(?P<name>[^<]*)</h1>\s*(?P<table><table[^>]*>.*?</table>)#s', $page, $matches, PREG_SET_ORDER); foreach ($matches as $category) { - $url = url_merge($URL, $category['href']); - $page = curlexec($url); + $stages = parsed_table($category['table']); - preg_match_all('#<[^>]*home-stages__date[^>]*>(?<date>[^<]*)<[^>]*>\s*<[^>]*home-stages__title[^>]*>(?<title>[^<]*)<[^>]*>#', $page, $matches, PREG_SET_ORDER); - foreach ($matches as $stage) { - $title = $category['name'] . '. ' . $stage['title']; + foreach ($stages as $stage) { + $start_time = $stage['start-time']; + $start_time = preg_replace('#[\(\)]#', '', $start_time); - $sep = '–'; - $start_time = trim($stage['date']); - if (empty($start_time)) { - continue; - } - $start_time = preg_replace("#[^0-9a-z$sep]+#i", " ", $start_time); - $start_time = preg_replace("#\s*$sep\s*#i", $sep, $start_time); - $end_time = null; - if (strpos($start_time, $sep) !== false) { - list($start_time, $end_time) = explode($sep, $start_time); - $start_time = trim($start_time); - $end_time = trim($end_time); - if (preg_match('#^[0-9]+$#', $end_time)) { - $end_time = preg_replace('#[0-9]+#', $end_time, $start_time); - } - } - if (strpos($start_time, $year) === false) { - $start_time .= " $year"; - } - $start_time = '12:00 ' . $start_time; + $end_time = $stage['end-time']; + $end_time = preg_replace('#[\(\)]#', '', $end_time); - if ($end_time) { - if (strpos($end_time, $year) === false) { - $end_time .= " $year"; - } - $end_time = strtotime($end_time) + 24 * 60 * 60; - } + $duration = $stage['duration']; + $duration = preg_replace('#\s*\(.*?\)$#', '', $duration); + $name = $stage['stage']; + $name = preg_replace('#\s+-\s+.*$#', '', $name); + + $title = $category['name'] . '. ' . $name; + + $year = date('Y', strtotime($start_time)); $month = date('n', strtotime($start_time)); if ($month >= 9) { $season = ($year + 0) . "-" . ($year + 1); } else { $season = ($year - 1) . "-" . ($year + 0); } - - $duration = 0; - if (preg_match('#algorithm#i', $category['name'])) { - if (preg_match('#marathon#i', $stage['title'])) { - $duration = 7 * 24 * 60; // 7 days - } else if (preg_match('#sprint#i', $stage['title'])) { - $duration = 120; // 120 minutes - } else if (preg_match('#final#i', $stage['title'])) { - $duration = 120; - } - } else if (preg_match('#back-?end#i', $category['name'])) { - $duration = 300; - } else if (preg_match('#front-?end#i', $category['name'])) { - $duration = 300; - } else if (preg_match('#analytics#i', $category['name'])) { - $duration = 180; - } else if (preg_match('#mobile#i', $category['name'])) { - if (preg_match('#qualifying#i', $stage['title'])) { - $duration = 120; - } - } - - $key = $category['type'] . ' ' . strtolower($stage['title']) . ' ' . $season; + $key = slugify($category['name']) . ' ' . slugify($name) . ' ' . $season; $contests[] = array( 'start_time' => $start_time, @@ -89,7 +47,92 @@ } } - if ($RID == -1) { - print_r($contests); - } + // $url = $URL; + + // $page = curlexec($url); + // $year = date('Y'); + + // preg_match_all('#<a[^>]*home-hero__link[^>]*home-hero__link_(?P<type>[a-z]+)[^>]*href="(?P<href>[^"]*)"[^>]*>.*?<[^>]*home-hero__title[^>]*>(?P<name>[^<]*)(<[^>/]*>[^<]*)*(</[^>]*>\s*)*</a>#', $page, $matches, PREG_SET_ORDER); + + // foreach ($matches as $category) { + // $url = url_merge($URL, $category['href']); + // $page = curlexec($url); + + // preg_match_all('#<[^>]*home-stages__date[^>]*>(?<date>[^<]*)(?:<em>(?P<tag>[^<]*)</em>)?<[^>]*>\s*<[^>]*home-stages__title[^>]*>(?<title>[^<]*)<[^>]*>#', $page, $matches, PREG_SET_ORDER); + // foreach ($matches as $stage) { + // $title = $category['name'] . '. ' . $stage['title']; + + // $sep = '–'; + // $start_time = trim($stage['date']); + // if (empty($start_time)) { + // continue; + // } + // $start_time = preg_replace("#[^0-9a-z$sep]+#i", " ", $start_time); + // $start_time = preg_replace("#\s*$sep\s*#i", $sep, $start_time); + // $end_time = null; + // if (strpos($start_time, $sep) !== false) { + // list($start_time, $end_time) = explode($sep, $start_time); + // $start_time = trim($start_time); + // $end_time = trim($end_time); + // if (preg_match('#^[0-9]+$#', $end_time)) { + // $end_time = preg_replace('#[0-9]+#', $end_time, $start_time); + // } + // } + // if (strpos($start_time, $year) === false) { + // $start_time .= " $year"; + // } + // $start_time = '12:00 ' . $start_time; + + // if ($end_time) { + // if (strpos($end_time, $year) === false) { + // $end_time .= " $year"; + // } + // $end_time = strtotime($end_time) + 24 * 60 * 60; + // } + + // $month = date('n', strtotime($start_time)); + // if ($month >= 9) { + // $season = ($year + 0) . "-" . ($year + 1); + // } else { + // $season = ($year - 1) . "-" . ($year + 0); + // } + + // $duration = 0; + // if (preg_match('#algorithm#i', $category['name'])) { + // if (preg_match('#marathon#i', $stage['title'])) { + // $duration = 7 * 24 * 60; // 7 days + // } else if (preg_match('#sprint#i', $stage['title'])) { + // $duration = 120; // 120 minutes + // } else if (preg_match('#final#i', $stage['title'])) { + // $duration = 120; + // } + // } else if (preg_match('#back-?end#i', $category['name'])) { + // $duration = 300; + // } else if (preg_match('#front-?end#i', $category['name'])) { + // $duration = 300; + // } else if (preg_match('#analytics#i', $category['name'])) { + // $duration = 180; + // } else if (preg_match('#mobile#i', $category['name'])) { + // if (preg_match('#qualifying#i', $stage['title'])) { + // $duration = 120; + // } + // } else if (preg_match('#juniors#i', $category['name'])) { + // $duration = 120; + // } + + // $key = $category['type'] . ' ' . strtolower($stage['title']) . ' ' . $season; + + // $contests[] = array( + // 'start_time' => $start_time, + // 'end_time' => $end_time, + // 'duration' => $duration, + // 'title' => $title, + // 'url' => $url, + // 'host' => $HOST, + // 'rid' => $RID, + // 'timezone' => $TIMEZONE, + // 'key' => $key, + // ); + // } + // } ?> diff --git a/legacy/update.php b/legacy/update.php index 7b86c98c..8daeb422 100755 --- a/legacy/update.php +++ b/legacy/update.php @@ -91,10 +91,6 @@ preg_match_all($resource['regexp'], $page, $matches, PREG_SET_ORDER); - if (DEBUG) { - print_r($matches); - } - $timezone_offset = timezone_offset_get(new DateTimeZone($resource['timezone']), new DateTime("now")); $registration = NULL; @@ -190,9 +186,10 @@ $updated_resources[$contest['rid']] = true; foreach (array('start_time', 'end_time') as $k) { if (isset($contest[$k]) && !is_numeric($contest[$k]) && $contest[$k]) { - if (!preg_match('/(?:[\-\+][0-9]+:[0-9]+|\s[A-Z]{3,}|Z)$/', $contest[$k]) and !empty($contest['timezone']) and strpos($contest[$k], $contest['timezone']) === false) { + if (!preg_match('/(?:[\-\+][0-9]+:[0-9]+|\s[A-Z]{3,}|Z|UTC\+[0-9]+)$/', $contest[$k]) and !empty($contest['timezone']) and strpos($contest[$k], $contest['timezone']) === false) { $contest[$k] .= " " . $contest['timezone']; } + $contest[$k] = preg_replace('/\bUTC\b([+-][0-9]+)/i', '\1', $contest[$k]); $contest[$k] = preg_replace_callback( '/\s([A-Z]{3,})$/', function ($match) { diff --git a/requirements.txt b/requirements.txt index 1029637e..eba7ec9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ Django==5.1 django-add-default-value==0.10.0 +django-autoslug==1.9.9 django-brotli==0.2.1 django-cache-machine==1.2.0 django-cors-headers==4.3.1 @@ -15,13 +16,13 @@ django-ical==1.9.2 django-ipware==7.0.1 django-jsonify==0.3.0 django-json-widget==2.0.1 -django-ltree==0.5.3 +django-ltree-2==0.1.8 django-oauth-toolkit==2.4.0 django-phonenumber-field==7.3.0 django-pivot==1.10.0 django-print-sql==2018.3.6 django-ratelimit==4.1.0 -django-rq==2.10.2 +django-rq==3.0.0 django-sql-utils==0.7.0 django-static-compress==2.0.0 django-static-fontawesome==6.5.2.0 @@ -108,3 +109,5 @@ geoip2==4.8.0 xgboost==2.1.1 scikit-learn==1.5.1 pandas==2.2.2 +beautifulsoup4==4.12.3 +markdown==3.7 diff --git a/src/clist/admin.py b/src/clist/admin.py index 3bc10e51..99cda1f7 100644 --- a/src/clist/admin.py +++ b/src/clist/admin.py @@ -63,8 +63,8 @@ def parse_statistic(self, request, queryset): 'with_medals', 'related', 'merging_contests', 'series', 'allow_updating_statistics_for_participants', 'set_matched_coders_to_members']}], - ['Timing', {'fields': ['statistics_update_required', 'parsed_time', 'wait_for_successful_update_timing', - 'statistic_timing', 'notification_timing', + ['Timing', {'fields': ['statistics_update_required', 'parsed_time', 'parsed_percentage', + 'wait_for_successful_update_timing', 'statistic_timing', 'notification_timing', 'rating_prediction_timing', 'created', 'modified', 'updated']}], ['Rating', {'fields': ['rating_prediction_hash', 'has_fixed_rating_prediction_field', 'rating_prediction_fields']}], @@ -79,8 +79,9 @@ def parse_statistic(self, request, queryset): actions = [parse_statistic] def get_readonly_fields(self, request, obj=None): - ret = ['auto_updated', 'updated', 'parsed_time', 'wait_for_successful_update_timing', - 'statistic_timing', 'notification_timing', 'rating_prediction_timing'] + ret = ['auto_updated', 'updated', 'parsed_time', 'parsed_percentage', 'wait_for_successful_update_timing', + 'statistic_timing', 'notification_timing', 'rating_prediction_timing', + 'slug', 'title_path'] ret += list(super().get_readonly_fields(request, obj)) return ret @@ -132,7 +133,8 @@ def queryset(self, request, queryset): fieldsets = [ [None, {'fields': ['host', 'short_host', 'enable', 'url', 'profile_url', 'avatar_url', 'problem_url', 'icon', 'n_accounts', 'n_contests']}], - ['Parse information', {'fields': ['regexp', 'path', 'parse_url', 'timezone', 'auto_remove_started']}], + ['Parse information', {'fields': ['regexp', 'path', 'parse_url', 'timezone', 'auto_remove_started', + 'has_inherit_medals_to_related']}], ['Calendar information', {'fields': ['color', 'uid']}], ['Rating information', {'fields': ['has_rating_history', 'has_country_rating', 'avg_rating', 'n_rating_accounts', diff --git a/src/clist/api/v1.py b/src/clist/api/v1.py index 9e959e8c..c5d84af0 100644 --- a/src/clist/api/v1.py +++ b/src/clist/api/v1.py @@ -2,9 +2,9 @@ from tastypie import fields from tastypie.resources import ALL_WITH_RELATIONS -from clist.models import Resource, Contest -from true_coders.models import Filter from clist.api.common import BaseModelResource +from clist.models import Contest, Resource +from true_coders.models import Filter class ResourceResource(BaseModelResource): @@ -17,7 +17,7 @@ class Meta(BaseModelResource.Meta): resource_name = 'resource' filtering = { 'id': ['exact', 'in'], - 'name': ['exact', 'iregex', 'regex', 'in'], + 'name': ['exact', 'in'], } ordering = ['id', 'name', ] diff --git a/src/clist/api/v2.py b/src/clist/api/v2.py index a0559ada..37984800 100644 --- a/src/clist/api/v2.py +++ b/src/clist/api/v2.py @@ -2,7 +2,9 @@ import re import arrow +from django.core.exceptions import FieldDoesNotExist from django.db.models import CharField, IntegerField, JSONField, Value +from django.db.models.constants import LOOKUP_SEP from django.db.models.expressions import F from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Cast @@ -33,7 +35,31 @@ class Meta(CommmonBaseModuelResource.Meta): def build_filters(self, filters=None, *args, **kwargs): filters = filters or {} filters.pop('total_count', None) - return super().build_filters(filters, *args, **kwargs) + + custom_filters = {} + for filter_expr in list(filters): + filter_bits = filter_expr.split(LOOKUP_SEP) + field_name = filter_bits.pop(0) + if field_name not in self.fields: + continue + try: + django_field_name = self.fields[field_name].attribute + self._meta.object_class._meta.get_field(django_field_name) + continue + except FieldDoesNotExist: + pass + filter_type = filter_bits.pop() if filter_bits else 'exact' + lookup_bits = self.check_filtering(field_name, filter_type, filter_bits) + value = self.filter_value_to_python(filters[filter_expr], field_name, filters, filter_expr, filter_type) + db_field_name = LOOKUP_SEP.join(lookup_bits) + qs_filter = "%s%s%s" % (db_field_name, LOOKUP_SEP, filter_type) + custom_filters[qs_filter] = value + filters.pop(filter_expr) + + filters = super().build_filters(filters, *args, **kwargs) + + filters.update(custom_filters) + return filters def dehydrate(self, *args, **kwargs): bundle = super().dehydrate(*args, **kwargs) @@ -73,8 +99,8 @@ class Meta(BaseModelResource.Meta): filtering = { 'total_count': ['exact'], 'id': ['exact', 'in'], - 'name': ['exact', 'iregex', 'regex', 'in'], - 'short': ['exact', 'iregex', 'regex', 'in'], + 'name': ['exact', 'in'], + 'short': ['exact', 'in'], 'n_accounts': ['exact', 'gt', 'lt', 'gte', 'lte'], 'n_contests': ['exact', 'gt', 'lt', 'gte', 'lte'], } @@ -87,8 +113,7 @@ def dehydrate(self, *args, **kwargs): class ContestResource(BaseModelResource): - resource = fields.CharField('resource__host', - help_text='Unicode string data. Use comma to filter multiple resources') + resource = fields.CharField('resource__host') resource_id = fields.IntegerField('resource_id') host = fields.CharField('host') event = fields.CharField('title') @@ -127,7 +152,7 @@ class Meta(BaseModelResource.Meta): 'end_time__during': ['exact'], 'id': ['exact', 'in'], 'resource_id': ['exact', 'in'], - 'resource': ['exact'], + 'resource': ['exact', 'iregex', 'regex', 'in'], 'host': ['exact', 'iregex', 'regex'], 'event': ['exact', 'iregex', 'regex'], 'start': ['exact', 'gt', 'lt', 'gte', 'lte', 'week_day'], @@ -173,7 +198,6 @@ def build_filters(self, filters=None, *args, **kwargs): upcoming = filters.pop('upcoming', None) filtered = filters.pop('filtered', None) category = filters.pop('category', ['api']) - resource = filters.pop('resource', None) with_problems = filters.pop('with_problems', None) format_time = filters.pop('format_time', None) start_time__during = filters.pop('start_time__during', None) @@ -182,8 +206,6 @@ def build_filters(self, filters=None, *args, **kwargs): if filtered is not None: filters['filtered'] = filtered[-1] filters['category'] = category[-1] - if resource: - filters['resource__host__in'] = ','.join(resource).split(',') if upcoming: filters['upcoming'] = upcoming[-1] if with_problems: @@ -362,21 +384,13 @@ class Meta(BaseModelResource.Meta): 'total_count': ['exact'], 'id': ['exact', 'in'], 'resource_id': ['exact', 'in'], - 'resource': ['exact'], - 'handle': ['exact', 'iregex', 'regex'], + 'resource': ['exact', 'iregex', 'regex', 'in'], + 'handle': ['exact', 'in'], 'rating': ['exact', 'gt', 'lt', 'gte', 'lte', 'isnull'], 'overall_rank': ['exact', 'gt', 'lt', 'gte', 'lte', 'isnull'], } ordering = ['id', 'handle', 'rating', 'overall_rank', 'n_contests'] - def build_filters(self, filters=None, *args, **kwargs): - filters = filters or {} - resource = filters.pop('resource', None) - filters = super().build_filters(filters, *args, **kwargs) - if resource: - filters['resource__host'] = resource[-1] - return filters - def apply_filters(self, request, applicable_filters): qs = super().apply_filters(request, applicable_filters) qs = qs.select_related('resource') @@ -430,7 +444,7 @@ class Meta(BaseModelResource.Meta): 'with_accounts': ['exact'], 'country': ['exact'], 'id': ['exact', 'in'], - 'username': ['exact', 'iregex', 'regex', 'in'], + 'username': ['exact', 'in'], } extra_actions = [ { diff --git a/src/clist/api/v3.py b/src/clist/api/v3.py index f3ce1cb6..66bc799f 100644 --- a/src/clist/api/v3.py +++ b/src/clist/api/v3.py @@ -49,7 +49,7 @@ class Meta(BaseModelResource.Meta): 'with_accounts': ['exact'], 'country': ['exact'], 'id': ['exact', 'in'], - 'handle': ['exact', 'iregex', 'regex', 'in'], + 'handle': ['exact', 'in'], 'is_virtual': ['exact'], } extra_actions = [ diff --git a/src/clist/api/v4.py b/src/clist/api/v4.py index d386d33b..fdef86f7 100644 --- a/src/clist/api/v4.py +++ b/src/clist/api/v4.py @@ -10,7 +10,6 @@ from tastypie import fields from clist.api import v3 -from clist.api.common import is_true_value from clist.api.v3 import (BaseModelResource, ContestResource, ResourceResource, StatisticsResource, # noqa use_for_is_real, use_for_is_virtual, use_in_detail_only, use_in_me_only) from clist.models import Contest, Problem, ProblemVerdict @@ -34,8 +33,8 @@ class Meta(v3.AccountResource.Meta): 'total_count': ['exact'], 'id': ['exact', 'in'], 'resource_id': ['exact', 'in'], - 'resource': ['exact'], - 'handle': ['exact', 'iregex', 'regex'], + 'resource': ['exact', 'regex', 'iregex', 'in'], + 'handle': ['exact', 'in'], 'rating': ['exact', 'gt', 'lt', 'gte', 'lte', 'isnull'], 'resource_rank': ['exact', 'gt', 'lt', 'gte', 'lte', 'isnull'], 'last_activity': ['exact', 'gt', 'lt', 'gte', 'lte', 'week_day'], @@ -69,11 +68,11 @@ class Meta(v3.CoderResource.Meta): class ProblemResource(BaseModelResource): name = fields.CharField('name') - contests_ids = fields.ListField('contests_ids', null=True) + contest_ids = fields.ListField('contest_ids', null=True, + help_text="A list of data. Ex: {'abc', 26.73, 8}") divisions = fields.ListField('divisions', null=True) kinds = fields.ListField('kinds', null=True) - resource = fields.CharField('resource__host', - help_text='Unicode string data. Use comma to filter multiple resources') + resource = fields.CharField('resource__host') resource_id = fields.IntegerField('resource_id') slug = fields.CharField('slug', null=True) short = fields.CharField('short', null=True) @@ -87,8 +86,8 @@ class ProblemResource(BaseModelResource): rating = fields.IntegerField('rating', null=True, help_text='Resource rating') favorite = fields.BooleanField('is_favorite', null=True, help_text='User-marked as favorite') note = fields.CharField('note_text', null=True, help_text='User-specified note') - solved = fields.BooleanField(help_text='Solved in resource system or user-marked as solved') - reject = fields.BooleanField(help_text='Rejected in resource system or user-marked as reject') + solved = fields.BooleanField('solved', help_text='Solved in resource system or user-marked as solved') + reject = fields.BooleanField('reject', help_text='Rejected in resource system or user-marked as reject') system_solved = fields.BooleanField('system_solved', help_text='Solved in resource system') system_reject = fields.BooleanField('system_reject', help_text='Rejected in resource system') user_solved = fields.BooleanField('user_solved', help_text='User-marked as solved') @@ -102,12 +101,12 @@ class Meta(BaseModelResource.Meta): excludes = ('total_count', 'solved', 'reject') filtering = { 'total_count': ['exact'], - 'name': ['exact', 'iregex', 'regex'], - 'contests_ids': ['exact', 'contains'], - 'resource': ['exact', 'iregex', 'regex'], + 'name': ['exact', 'in'], + 'contest_ids': ['exact', 'contains'], + 'resource': ['exact', 'iregex', 'regex', 'in'], 'resource_id': ['exact', 'in'], - 'slug': ['exact', 'regex'], - 'short': ['exact', 'iregex', 'regex'], + 'slug': ['exact', 'in'], + 'short': ['exact', 'in'], 'url': ['exact', 'regex'], 'archive_url': ['exact', 'regex'], 'n_attempts': ['exact', 'gt', 'lt', 'gte', 'lte'], @@ -117,7 +116,7 @@ class Meta(BaseModelResource.Meta): 'n_total': ['exact', 'gt', 'lt', 'gte', 'lte'], 'rating': ['exact', 'gt', 'lt', 'gte', 'lte'], 'favorite': ['exact'], - 'note': ['exact', 'iregex', 'regex'], + 'note': ['exact', 'in'], 'solved': ['exact'], 'reject': ['exact'], 'system_solved': ['exact'], @@ -137,7 +136,7 @@ def get_object_list(self, request): contests = Contest.objects.filter(problem_set__id=OuterRef('pk')) contests = contests.values('problem_set__id').annotate(ids=ArrayAgg('id')).values('ids') - problems = problems.annotate(contests_ids=Subquery(contests, output_field=ArrayField(models.IntegerField()))) + problems = problems.annotate(contest_ids=Subquery(contests, output_field=ArrayField(models.IntegerField()))) content_type = ContentType.objects.get_for_model(Problem) coder = request.user.coder if request.user.is_authenticated else None @@ -156,23 +155,3 @@ def get_object_list(self, request): output_field=models.BooleanField())) return problems - - def build_filters(self, filters=None, *args, **kwargs): - filters = filters or {} - - custom_filters = {} - for k in list(filters.keys()): - if k.startswith('note'): - custom_filters[k.replace('note', 'note_text')] = filters.pop(k)[-1] - elif k.startswith('resource'): - custom_filters[k.replace('resource', 'resource__host')] = filters.pop(k)[-1] - elif k.startswith('favorite'): - custom_filters['is_' + k] = is_true_value(filters.pop(k)[-1]) - elif k in ['solved', 'reject', 'system_solved', 'system_reject', 'user_solved', 'user_todo', 'user_reject']: - custom_filters[k] = is_true_value(filters.pop(k)[-1]) - elif k.startswith('contests_ids'): - custom_filters[k] = filters.pop(k) - - filters = super().build_filters(filters, *args, **kwargs) - filters.update(custom_filters) - return filters diff --git a/src/clist/migrations/0164_edit_contest.py b/src/clist/migrations/0164_edit_contest.py new file mode 100644 index 00000000..97f6e7b1 --- /dev/null +++ b/src/clist/migrations/0164_edit_contest.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1 on 2024-11-17 15:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('clist', '0164_contest_parsed_percentage'), ('clist', '0165_contest_inherit_medals_to_related'), ('clist', '0166_remove_contest_inherit_medals_to_related_and_more'), ('clist', '0167_rename_inherit_medals_to_related_resource_has_inherit_medals_to_related')] + + dependencies = [ + ('clist', '0163_update_resource_problems_fields'), + ] + + operations = [ + migrations.AddField( + model_name='contest', + name='parsed_percentage', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='resource', + name='has_inherit_medals_to_related', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/clist/models.py b/src/clist/models.py index 374902f1..6936c5f3 100644 --- a/src/clist/models.py +++ b/src/clist/models.py @@ -1,9 +1,11 @@ import calendar +import copy import itertools import logging import math import os import re +from collections import defaultdict from collections.abc import Iterable from datetime import datetime, timedelta, timezone from typing import Optional @@ -16,7 +18,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import ArrayField -from django.db import models +from django.db import models, transaction from django.db.models import Case, F, Max, Q, When from django.db.models.expressions import Exists, OuterRef from django.db.models.functions import Cast, Ln @@ -109,6 +111,7 @@ class Resource(BaseModel): statistics_fields = models.JSONField(default=dict, blank=True) skip_for_contests_chart = models.BooleanField(default=False) problem_rating_predictor = models.JSONField(default=dict, blank=True) + has_inherit_medals_to_related = models.BooleanField(default=False) RATING_FIELDS = ( 'old_rating', 'new_rating', 'rating', 'rating_perf', 'performance', 'raw_rating', @@ -389,6 +392,17 @@ def load_problem_rating_predictor(self): model.load_model(model_path) return model + def latest_parsed_contest(self): + return self.contest_set.filter(parsed_time__isnull=False).order_by('-end_time').first() + + @staticmethod + def get(value) -> Optional['Resource']: + if isinstance(value, int) or isinstance(value, str) and value.isdigit(): + return Resource.objects.filter(pk=value).first() + if isinstance(value, str): + return Resource.objects.filter(Q(host=value) | Q(short_host=value)).first() + return None + @property def problems_fields_types(self): return self.problems_fields.get('types', {}) @@ -454,6 +468,7 @@ class Contest(BaseModel): n_statistics = models.IntegerField(null=True, blank=True, db_index=True) n_problems = models.IntegerField(null=True, blank=True, db_index=True) parsed_time = models.DateTimeField(null=True, blank=True) + parsed_percentage = models.FloatField(null=True, blank=True) has_hidden_results = models.BooleanField(null=True, blank=True) related = models.ForeignKey('Contest', null=True, blank=True, on_delete=models.SET_NULL, related_name='related_set') merging_contests = models.ManyToManyField('Contest', blank=True, related_name='merged_set') @@ -545,7 +560,8 @@ def save(self, *args, **kwargs): stats.update(new_global_rating=None, global_rating_change=None) if self.is_over(): - self.with_medals = bool(get_item(self.info, 'standings.medals')) or 'medal' in fields + standings_medals = bool(get_item(self.info, 'standings.medals')) + self.with_medals = standings_medals and not self.has_hidden_results or 'medal' in fields self.with_advance = 'advanced' in fields or '_advance' in fields if not self.kind: @@ -591,6 +607,9 @@ def is_running(self): def is_coming(self): return timezone_now() < self.start_time + def with_virtual_start(self): + return self.duration_in_secs and self.duration != self.full_duration + @property def next_time(self): return self.next_time_to(None) @@ -764,7 +783,7 @@ def standings_per_page(self): def full_problems_list(self): problems = self.info.get('problems') if not problems: - return + return [] if isinstance(problems, dict): division_problems = list(problems.get('division', {}).values()) problems = [] @@ -776,7 +795,7 @@ def full_problems_list(self): def problems_list(self): problems = self.info.get('problems') if not problems: - return + return [] if isinstance(problems, dict): division_problems = list(problems.get('division', {}).values()) problems = [] @@ -869,6 +888,80 @@ def problem_rating_update_done(self): self.problem_rating_update_required = False self.save(update_fields=['problem_rating_update_required']) + def get_statistics_order(self): + options = self.info.get('standings', {}) + contest_fields = self.info.get('fields', []).copy() + resource_standings = self.resource.info.get('standings', {}) + order = copy.copy(options.get('order', resource_standings.get('order'))) + if order: + for f in order: + if f.startswith('addition__') and f.split('__', 1)[1] not in contest_fields: + order = None + break + if order is None: + order = ['place_as_int', '-solving'] + return order + + @transaction.atomic + def inherit_medals(self, other): + if self.with_medals: + raise ValueError('already has medals') + if not other.with_medals: + raise ValueError('other contest has no medals') + if self.n_statistics != other.n_statistics: + raise ValueError('different number of statistics') + + rank_medals = {} + rank_n_medals = defaultdict(int) + seen_teams = set() + for stat in other.statistics_set.filter(addition__medal__isnull=False): + if (team := stat.addition.get('team_id')): + if team in seen_teams: + continue + seen_teams.add(team) + rank = stat.place_as_int + medal = stat.addition['medal'] + if rank in rank_medals and rank_medals[rank] != medal: + raise ValueError('multiple medals for the same place') + rank_medals[rank] = medal + rank_n_medals[rank] += 1 + + for stat in self.statistics_set.all(): + rank = stat.place_as_int + if rank not in rank_medals: + continue + if not rank_n_medals[rank]: + raise ValueError('no medals left for the place') + stat.addition['medal'] = rank_medals[rank] + stat.save(update_fields=['addition']) + rank_n_medals[rank] -= 1 + + if any(rank_n_medals.values()): + raise ValueError('not all medals were used') + + if 'medal' not in self.info.get('fields', []): + self.info['fields'].append('medal') + self.save(update_fields=['info']) + self.with_medals = True + self.save(update_fields=['with_medals']) + + def is_finalized(self): + return ( + self.is_over() and + not self.has_hidden_results and + not self.info.get('_timing_statistic_delta_seconds') + ) + + @staticmethod + def get(value) -> Optional['Contest']: + if isinstance(value, int) or isinstance(value, str) and value.isdigit(): + return Contest.objects.filter(pk=value).first() + if isinstance(value, str): + qs = Contest.objects.filter(Q(title=value) | Q(resource__host=value) | + Q(series__name=value) | Q(series__slug=slug(value))) + return qs.order_by('-end_time').first() + return None + class ContestSeries(BaseModel): name = models.TextField(unique=True, db_index=True, null=False) diff --git a/src/clist/templatetags/extras.py b/src/clist/templatetags/extras.py index f266f6fe..c949faa6 100644 --- a/src/clist/templatetags/extras.py +++ b/src/clist/templatetags/extras.py @@ -8,6 +8,7 @@ from collections.abc import Iterable from copy import deepcopy from datetime import datetime, timedelta +from numbers import Number from sys import float_info from urllib.parse import quote_plus, urlparse @@ -34,6 +35,8 @@ from ipware import get_client_ip from unidecode import unidecode +from utils.urlutils import absolute_url as utils_absolute_url + register = template.Library() @@ -143,30 +146,40 @@ def format_time(time, fmt): @register.filter def hr_timedelta(delta, n_significant=2): - if isinstance(delta, timedelta): + if isinstance(delta, datetime): + delta = (delta - now()).total_seconds() + elif isinstance(delta, timedelta): delta = delta.total_seconds() if delta <= 0: return 'past' - ret = [] - n_used = 0 - for c, s in ( + units = [ (364 * 24 * 60 * 60, 'year'), (7 * 24 * 60 * 60, 'week'), (24 * 60 * 60, 'day'), (60 * 60, 'hour'), (60, 'minute'), (1, 'second'), - ): - if c <= delta: - val = delta // c - delta %= c - ret.append('%d %s%s' % (val, s, 's' if val > 1 else '')) - n_used += 1 - elif ret: - n_used += 1 - if n_significant and n_significant == n_used: + ] + + rounded_delta = 0 + for seconds_per_unit, unit_name in units: + if delta >= seconds_per_unit: + n_significant -= 1 + val = round(delta / seconds_per_unit) if n_significant == 0 else delta // seconds_per_unit + delta %= seconds_per_unit + rounded_delta += val * seconds_per_unit + elif rounded_delta: + n_significant -= 1 + if n_significant == 0: break + + ret = [] + for seconds_per_unit, unit_name in units: + if rounded_delta >= seconds_per_unit: + val = rounded_delta // seconds_per_unit + rounded_delta %= seconds_per_unit + ret.append('%d %s%s' % (val, unit_name, 's' if val > 1 else '')) ret = ' '.join(ret) return ret.strip() @@ -281,13 +294,13 @@ def md_escape(value, clear=False): @register.filter -def md_url(value, clear=False): - return value.replace('(', '%28').replace(')', '%29') +def md_url_text(value): + return value.replace('[', '(').replace(']', ')') @register.filter -def md_italic_escape(value): - return value.replace('_', '_\\__') +def md_url(url): + return utils_absolute_url(url.replace('(', '%28').replace(')', '%29')) @register.filter(name='sort') @@ -334,24 +347,38 @@ def calc_mod_penalty(info, contest, solving, penalty): @register.filter -def slug(value): - return slugify(unidecode(value)).strip('-') +def slug(value, sep=None): + ret = slugify(unidecode(value)).strip('-') + if sep: + ret = ret.replace('-', sep) + return ret + + +def is_problems_(contest_or_problems): + return isinstance(contest_or_problems, (list, dict)) + +def get_problems_(contest_or_problems): + if is_problems_(contest_or_problems): + return contest_or_problems + return contest_or_problems.info.get('problems', []) -def get_standings_divisions_order(contest): - problems = contest.info.get('problems', {}) + +def get_standings_divisions_order(contest_or_problems): + problems = get_problems_(contest_or_problems) if 'division' in problems: - divisions_order = list(problems.get('divisions_order', sorted(contest.info['problems']['division'].keys()))) - elif 'divisions_order' in contest.info: - divisions_order = contest.info['divisions_order'] + divisions_order = list(problems.get('divisions_order', sorted(problems['division'].keys()))) + elif not is_problems_(contest_or_problems) and 'divisions_order' in contest_or_problems.info: + divisions_order = contest_or_problems.info['divisions_order'] else: divisions_order = [] return divisions_order @register.filter -def get_division_problems(contest, info): - problems = contest.info.get('problems', []) +def get_division_problems(contest_or_problems, info): + problems = get_problems_(contest_or_problems) + ret = [] seen_keys = set() if 'division' in problems: @@ -360,7 +387,7 @@ def get_division_problems(contest, info): division = info.get('division') if division and division not in divisions: divisions = [division] + divisions - for division in get_standings_divisions_order(contest): + for division in get_standings_divisions_order(contest_or_problems): if division not in divisions: divisions.append(division) for division in divisions: @@ -763,7 +790,7 @@ def divide(value, arg): @register.filter -def substract(value, arg): +def subtract(value, arg): return value - arg @@ -975,6 +1002,25 @@ def is_improved_solution(curr, prev): return False +def time_compare_value(val): + if isinstance(val, Number): + val = [val] + else: + val = list(map(int, val.split(':'))) + return len(val), val + + +def solution_time_compare(lhs, rhs): + for k in 'time_in_seconds', 'time': + if k not in lhs or k not in rhs: + continue + l_val = time_compare_value(lhs[k]) + r_val = time_compare_value(rhs[k]) + if l_val != r_val: + return -1 if l_val < r_val else 1 + return 0 + + @register.filter def timestamp_to_datetime(value): try: @@ -1035,7 +1081,7 @@ def to_dict(**kwargs): @register.filter -def as_number(value, force=False): +def as_number(value, force=False, default=None): valf = str(value).replace(',', '.') retf = asfloat(valf) if retf is not None: @@ -1047,6 +1093,8 @@ def as_number(value, force=False): percentf = asfloat(valf[:-1]) if percentf is not None: return percentf / 100 + if default is not None: + return default if force: return None return value @@ -1199,7 +1247,7 @@ def get_country_from_coder(context, coder): def get_country_from_members(accounts, members): counter = defaultdict(int) for member in members: - if 'account' not in member or member.get('without_country'): + if not member or 'account' not in member: continue account = accounts.get(member['account']) if account is None or not account.country: @@ -1554,6 +1602,13 @@ def get_admin_url(obj): return +@register.simple_tag +def admin_url(obj): + url = get_admin_url(obj) + icon = icon_to('database', '') + return mark_safe(f'<a href="{url}" class="database-link invisible" target="_blank" rel="noopener">{icon}</a>') + + @register.simple_tag def stat_has_failed_verdict(stat, small): return small and not is_solved(stat) and stat.get('verdict') and not stat.get('binary') and not stat.get('icon') @@ -1726,3 +1781,18 @@ def create_dict(**kwargs): @register.simple_tag def create_list(*args): return list(args) + + +@register.filter +def ne(value, arg): + return value != arg + + +@register.simple_tag(takes_context=True) +def absolute_url(context, viewname, *args, **kwargs): + return context['request'].build_absolute_uri(reverse(viewname, args=args, kwargs=kwargs)) + + +@register.filter +def get_id(value): + return id(value) diff --git a/src/clist/views.py b/src/clist/views.py index 2dc04391..7414d1db 100644 --- a/src/clist/views.py +++ b/src/clist/views.py @@ -33,9 +33,8 @@ from ranking.models import Account, CountryAccount, Rating, Statistics from true_coders.models import Coder, CoderList, CoderProblem, Filter, Party from utils.chart import make_bins, make_chart -from utils.custom_request import get_filtered_list from utils.json_field import JSONF -from utils.regex import get_iregex_filter, verify_regex +from utils.regex import get_iregex_filter from utils.timetools import get_timeformat, get_timezone @@ -77,11 +76,16 @@ def get_view_contests(request, coder): resources = [r for r in request.GET.getlist('resource') if r] if resources: base_contests = base_contests.filter(resource_id__in=resources) + search_query = request.GET.get('search_query', None) + if search_query: + base_contests = base_contests.filter(get_iregex_filter(search_query, 'host', 'title')) now = timezone.now() result = [] status = request.GET.get('status') + past_days = int(request.GET.get('past_days', 1)) for group, query, order in ( + ("past", Q(start_time__gt=now - timedelta(days=past_days), end_time__lte=now), "end_time"), ("running", Q(start_time__lte=now, end_time__gte=now), "end_time"), ("coming", Q(start_time__gt=now), "start_time"), ): @@ -152,10 +156,9 @@ def get_events(request): end_time = arrow.get(request.POST.get('end', now + timedelta(days=31))).datetime query = query & Q(end_time__gte=start_time) & Q(start_time__lte=end_time) - search_query = request.POST.get('search_query', None) + search_query = request.POST.get('search_query') if search_query: - search_query_re = verify_regex(search_query) - query &= Q(host__iregex=search_query_re) | Q(title__iregex=search_query_re) + query &= get_iregex_filter(search_query, 'host', 'title') favorite_value = request.POST.get('favorite') if favorite_value == 'on': @@ -193,13 +196,15 @@ def get_events(request): 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': (contest.start_time + timedelta(minutes=offset)).strftime("%Y-%m-%dT%H:%M:%S"), - 'end': (contest.end_time + timedelta(minutes=offset)).strftime("%Y-%m-%dT%H:%M:%S"), + 'start': start_time, + 'end': end_time, 'countdown': contest.next_time_to(now), 'hr_duration': contest.hr_duration, 'color': color, @@ -210,25 +215,35 @@ def get_events(request): c['favorite'] = contest.is_favorite result.append(c) except Exception as e: - return JsonResponse({'message': f'query = `{search_query}`, error = {e}'}, safe=False, status=400) + return JsonResponse({'error': str(e)}, safe=False, status=400) return JsonResponse(result, safe=False) @login_required def send_event_notification(request): - method = request.POST['method'] + methods = request.POST.getlist('method') + methods = set(methods) contest_id = request.POST['contest_id'] message = request.POST['message'] coder = request.user.coder - sendout_tasks.Command().send_message( - coder=coder, - method=method, - data={'contests': [int(contest_id)]}, - message=message.strip() + '\n', - ) - + if len(methods) > 10: + return HttpResponseBadRequest('Too many methods') + + notifications = coder.get_notifications() + notifications = {k for k, v in notifications} + for method in methods: + if method not in notifications: + return HttpResponseBadRequest('Invalid method') + + for method in methods: + sendout_tasks.Command().send_message( + coder=coder, + method=method, + data={'contests': [int(contest_id)]}, + message=message.strip() + '\n', + ) return HttpResponse('ok') @@ -316,6 +331,9 @@ def main(request, party=None): offset = get_timezone_offset(tzname) + more_fields = request.user.has_perm('clist.view_more_fields') + more_fields = more_fields and [f for f in request.GET.getlist('more') if f] or [] + context.update({ "offset": offset, "now": now, @@ -328,6 +346,7 @@ def main(request, party=None): "add_to_calendar": get_add_to_calendar(request), "banners": banners, "promotion": promotion, + "more_fields": more_fields, }) return render(request, "main.html", context) @@ -1152,7 +1171,7 @@ def problems(request, template='problems.html'): field_types = fields_types.get(field) if not field_types or any(t in field_types for t in ('int', 'float', 'dict')): continue - values_list = get_filtered_list(request, field) + values_list = request.get_filtered_list(field) if values_list: values_filter = Q() for value in values_list: diff --git a/src/logify/admin.py b/src/logify/admin.py index 95020156..b9853f0a 100644 --- a/src/logify/admin.py +++ b/src/logify/admin.py @@ -1,7 +1,7 @@ from django.urls import reverse from django.utils.html import format_html -from logify.models import EventLog, PgStatTuple +from logify.models import EventLog, PgStat from pyclist.admin import BaseModelAdmin, admin_register @@ -19,9 +19,15 @@ def related_object_link(self, obj): related_object_link.short_description = 'Related Object' -@admin_register(PgStatTuple) -class PgStatTupleAdmin(BaseModelAdmin): - list_display = ['id', 'table_name', 'app_name', 'table_len', 'tuple_percent', 'dead_tuple_percent', 'free_percent'] +@admin_register(PgStat) +class PgStatAdmin(BaseModelAdmin): + list_display = ['id', 'table_name', 'app_name', 'pretty_table_size', 'pretty_diff_size', 'table_len', + 'tuple_percent', 'dead_tuple_percent', 'free_percent', + 'last_vacuum', 'last_autovacuum', 'last_analyze', 'last_autoanalyze', + 'created', 'modified'] list_filter = ['app_name'] search_fields = ['table_name'] - ordering = ['-table_len'] + ordering = ['-table_size'] + + def get_readonly_fields(self, request, obj=None): + return [f.name for f in self.model._meta.fields] diff --git a/src/logify/management/commands/update_pgstat.py b/src/logify/management/commands/update_pgstat.py new file mode 100644 index 00000000..4b46199f --- /dev/null +++ b/src/logify/management/commands/update_pgstat.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +import re +from logging import getLogger + +import tqdm +from django.core.management.base import BaseCommand +from django.db import connection + +from logify.models import PgStat +from utils.attrdict import AttrDict +from utils.db import dictfetchone, find_app_by_table + + +class Command(BaseCommand): + help = 'Updates the PgStat table' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = getLogger('logify.update_pgstat') + + def add_arguments(self, parser): + parser.add_argument('-n', '--limit', type=int, help='number of tables') + parser.add_argument('-f', '--search', type=str, help='search tables') + parser.add_argument('--reset', action='store_true', help='reset initial_table_size') + + def handle(self, *args, **options): + self.stdout.write(str(options)) + args = AttrDict(options) + + with connection.cursor() as cursor: + cursor.execute("SELECT tablename FROM pg_tables WHERE schemaname='public'") + tables = [row[0] for row in cursor.fetchall()] + if args.limit: + tables = tables[:args.limit] + if args.search: + tables = [table for table in tables if re.search(args.search, table)] + + for table in tqdm.tqdm(tables, desc='tables'): + cursor.execute(f"SELECT * FROM pgstattuple('{table}')") + tuple_stats = dictfetchone(cursor) + + fields = [ + 'pg_total_relation_size(relid) AS table_size', + 'pg_size_pretty(pg_total_relation_size(relid)) AS pretty_table_size', + ''' + CASE + WHEN initial_table_size IS NULL THEN 0 + ELSE pg_total_relation_size(relid) - initial_table_size + END AS diff_size + ''', + ''' + CASE + WHEN initial_table_size IS NULL THEN '0' + ELSE pg_size_pretty(pg_total_relation_size(relid) - initial_table_size) + END AS pretty_diff_size + ''', + 'pg_stat_user_tables.last_vacuum', + 'pg_stat_user_tables.last_autovacuum', + 'pg_stat_user_tables.last_analyze', + 'pg_stat_user_tables.last_autoanalyze', + ] + cursor.execute(f''' + SELECT {', '.join(fields)} + FROM pg_stat_user_tables + LEFT JOIN logify_pgstat ON logify_pgstat.table_name = '{table}' + WHERE relname = '{table}' + ''') + table_stats = dictfetchone(cursor) + + defaults = {'app_name': find_app_by_table(table), **tuple_stats, **table_stats} + + pg_stat, created = PgStat.objects.update_or_create(table_name=table, defaults=defaults) + if pg_stat.initial_table_size is None or args.reset: + pg_stat.initial_table_size = pg_stat.table_size + pg_stat.diff_size = 0 + pg_stat.pretty_diff_size = '0' + pg_stat.save(update_fields=['initial_table_size']) diff --git a/src/logify/management/commands/update_pgstattuple.py b/src/logify/management/commands/update_pgstattuple.py deleted file mode 100644 index 56355d50..00000000 --- a/src/logify/management/commands/update_pgstattuple.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 - -import re -from logging import getLogger - -import tqdm -from django.core.management.base import BaseCommand -from django.db import connection - -from logify.models import PgStatTuple -from utils.attrdict import AttrDict -from utils.db import dictfetchone, find_app_by_table - - -class Command(BaseCommand): - help = 'Updates the PgStatTuple table with fresh data from pgstattuple' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.logger = getLogger('logify.update_pgstattuple') - - def add_arguments(self, parser): - parser.add_argument('-n', '--limit', type=int, help='number of tables') - parser.add_argument('-f', '--search', type=str, help='search tables') - - def handle(self, *args, **options): - self.stdout.write(str(options)) - args = AttrDict(options) - - with connection.cursor() as cursor: - cursor.execute("SELECT tablename FROM pg_tables WHERE schemaname='public'") - tables = [row[0] for row in cursor.fetchall()] - if args.limit: - tables = tables[:args.limit] - if args.search: - tables = [table for table in tables if re.search(args.search, table)] - - for table in tqdm.tqdm(tables, desc='tables'): - cursor.execute(f"SELECT * FROM pgstattuple('{table}')") - stats = dictfetchone(cursor) - defaults = {'app_name': find_app_by_table(table), **stats} - PgStatTuple.objects.update_or_create(table_name=table, defaults=defaults) diff --git a/src/logify/migrations/0010_pretty_pgstat.py b/src/logify/migrations/0010_pretty_pgstat.py new file mode 100644 index 00000000..ccbc4e50 --- /dev/null +++ b/src/logify/migrations/0010_pretty_pgstat.py @@ -0,0 +1,64 @@ +# Generated by Django 5.1 on 2024-11-17 15:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('logify', '0010_rename_pgstattuple_pgstat'), ('logify', '0011_pgstat_last_analyze_pgstat_last_autoanalyze_and_more'), ('logify', '0012_pgstat_table_size'), ('logify', '0013_pgstat_pretty_table_size'), ('logify', '0014_pgstat_diff_size_pgstat_pretty_diff_size'), ('logify', '0015_pgstat_initial_table_size')] + + dependencies = [ + ('logify', '0009_eventlog_environment'), + ] + + operations = [ + migrations.RenameModel( + old_name='PgStatTuple', + new_name='PgStat', + ), + migrations.AddField( + model_name='pgstat', + name='last_analyze', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='pgstat', + name='last_autoanalyze', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='pgstat', + name='last_autovacuum', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='pgstat', + name='last_vacuum', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='pgstat', + name='table_size', + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='pgstat', + name='pretty_table_size', + field=models.CharField(blank=True, max_length=20, null=True), + ), + migrations.AddField( + model_name='pgstat', + name='diff_size', + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='pgstat', + name='pretty_diff_size', + field=models.CharField(blank=True, max_length=20, null=True), + ), + migrations.AddField( + model_name='pgstat', + name='initial_table_size', + field=models.BigIntegerField(blank=True, null=True), + ), + ] diff --git a/src/logify/models.py b/src/logify/models.py index 7b4c22ff..0f48cf01 100644 --- a/src/logify/models.py +++ b/src/logify/models.py @@ -65,7 +65,7 @@ def update_message(self, message): self.save(update_fields=['message', 'modified', 'elapsed']) -class PgStatTuple(BaseModel): +class PgStat(BaseModel): table_name = models.CharField(max_length=255, db_index=True, unique=True) app_name = models.CharField(max_length=255, blank=True, null=True) table_len = models.BigIntegerField() @@ -78,5 +78,16 @@ class PgStatTuple(BaseModel): free_space = models.BigIntegerField() free_percent = models.FloatField() + last_vacuum = models.DateTimeField(blank=True, null=True) + last_autovacuum = models.DateTimeField(blank=True, null=True) + last_analyze = models.DateTimeField(blank=True, null=True) + last_autoanalyze = models.DateTimeField(blank=True, null=True) + + table_size = models.BigIntegerField(blank=True, null=True) + pretty_table_size = models.CharField(max_length=20, blank=True, null=True) + initial_table_size = models.BigIntegerField(blank=True, null=True) + diff_size = models.BigIntegerField(blank=True, null=True) + pretty_diff_size = models.CharField(max_length=20, blank=True, null=True) + def __str__(self): - return f'{self.table_name} PgStatTuple#{self.id}' + return f'{self.table_name} PgStat#{self.id}' diff --git a/src/my_oauth/admin.py b/src/my_oauth/admin.py index b271beeb..ed64ce20 100644 --- a/src/my_oauth/admin.py +++ b/src/my_oauth/admin.py @@ -1,15 +1,27 @@ +from my_oauth.models import Form, Service, Token from pyclist.admin import BaseModelAdmin, admin_register -from my_oauth.models import Service, Token @admin_register(Service) class ServiceAdmin(BaseModelAdmin): - list_display = ['name', 'title', 'disable'] + list_display = ['name', 'title', '_has_refresh_token', 'disable'] search_fields = ['name', 'title'] + def _has_refresh_token(self, obj): + return bool(obj.refresh_token_uri) + _has_refresh_token.boolean = True + _has_refresh_token.short_description = 'RToken' + @admin_register(Token) class TokenAdmin(BaseModelAdmin): list_display = ['service', 'coder', 'user_id', 'email', 'modified'] - search_fields = ['coder__user__username', 'email'] + search_fields = ['coder__user__username', 'email', 'data'] + list_filter = ['service'] + + +@admin_register(Form) +class FormAdmin(BaseModelAdmin): + list_display = ['name', 'service'] + search_fields = ['name'] list_filter = ['service'] diff --git a/src/my_oauth/migrations/0013_alter_token_email_form.py b/src/my_oauth/migrations/0013_alter_token_email_form.py new file mode 100644 index 00000000..4fec1370 --- /dev/null +++ b/src/my_oauth/migrations/0013_alter_token_email_form.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1 on 2024-10-27 12:44 + +import uuid + +import autoslug.fields +import django.db.models.deletion +from django.db import migrations, models + +import my_oauth.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('my_oauth', '0012_auto_20220211_2316'), + ] + + operations = [ + migrations.AlterField( + model_name='token', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.CreateModel( + name='Form', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('modified', models.DateTimeField(auto_now=True, db_index=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='name', unique=True)), + ('code', models.TextField()), + ('secret', models.CharField(default=my_oauth.models.generate_secret_64, max_length=64, unique=True)), + ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='my_oauth.service')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/my_oauth/migrations/0014_forms.py b/src/my_oauth/migrations/0014_forms.py new file mode 100644 index 00000000..7f22e13c --- /dev/null +++ b/src/my_oauth/migrations/0014_forms.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1 on 2024-11-17 15:34 + +import utils.strings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('my_oauth', '0014_form_register_headers_form_register_url'), ('my_oauth', '0015_remove_form_slug'), ('my_oauth', '0016_alter_form_secret'), ('my_oauth', '0017_token_expires_at'), ('my_oauth', '0018_service_refresh_token_post_service_refresh_token_uri'), ('my_oauth', '0019_form_end_time_form_start_time')] + + dependencies = [ + ('my_oauth', '0013_alter_token_email_form'), + ] + + operations = [ + migrations.AddField( + model_name='form', + name='register_headers', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='form', + name='register_url', + field=models.URLField(blank=True, null=True), + ), + migrations.RemoveField( + model_name='form', + name='slug', + ), + migrations.AlterField( + model_name='form', + name='secret', + field=models.CharField(blank=True, default=utils.strings.generate_secret_64, max_length=64, unique=True), + ), + migrations.AddField( + model_name='token', + name='expires_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='service', + name='refresh_token_post', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='service', + name='refresh_token_uri', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='form', + name='end_time', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='form', + name='start_time', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/my_oauth/models.py b/src/my_oauth/models.py index 68b78000..7c484080 100644 --- a/src/my_oauth/models.py +++ b/src/my_oauth/models.py @@ -1,9 +1,12 @@ import re +import uuid from django.db import models +from django.utils import timezone from pyclist.models import BaseManager, BaseModel from true_coders.models import Coder +from utils.strings import generate_secret_64 class ActiveServiceManager(BaseManager): @@ -19,6 +22,8 @@ class Service(BaseModel): code_uri = models.TextField() token_uri = models.TextField() token_post = models.TextField(blank=True) + refresh_token_uri = models.TextField(null=True, blank=True) + refresh_token_post = models.TextField(null=True, blank=True) state_field = models.CharField(max_length=255, default='state') email_field = models.CharField(max_length=255, default='email') user_id_field = models.CharField(max_length=255) @@ -38,8 +43,9 @@ class Token(BaseModel): service = models.ForeignKey(Service, on_delete=models.CASCADE) coder = models.ForeignKey(Coder, null=True, on_delete=models.CASCADE, blank=True) user_id = models.CharField(max_length=255) - email = models.EmailField() + email = models.EmailField(null=True, blank=True) access_token = models.JSONField(default=dict, blank=True) + expires_at = models.DateTimeField(null=True, blank=True) data = models.JSONField(default=dict, blank=True) tokens_view_time = models.DateTimeField(null=True, default=None, blank=True) @@ -61,3 +67,18 @@ def hint(s): return re.sub(regex, '.', s) return f'{hint(login)}@{hint(domain)}' + + +class Form(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=255) + service = models.ForeignKey(Service, on_delete=models.CASCADE) + code = models.TextField() + secret = models.CharField(max_length=64, default=generate_secret_64, blank=True, unique=True) + register_url = models.URLField(null=True, blank=True) + register_headers = models.TextField(null=True, blank=True) + start_time = models.DateTimeField(null=True, blank=True) + end_time = models.DateTimeField(null=True, blank=True) + + def is_closed(self): + return self.end_time is not None and self.end_time < timezone.now() diff --git a/src/my_oauth/urls.py b/src/my_oauth/urls.py index 7403d383..e6aac1b8 100644 --- a/src/my_oauth/urls.py +++ b/src/my_oauth/urls.py @@ -12,4 +12,6 @@ re_path(r'^oauth/([-a-z]+)/$', views.query, name='query'), re_path(r'^oauth/([-a-z]+)/unlink/$', views.unlink, name='unlink'), re_path(r'^oauth/([-a-z]+)/response/$', views.response, name='response'), + re_path(r'^oauth/([-a-z]+)/refresh/$', views.refresh, name='refresh'), + re_path(r'^form/(?P<uuid>[-\w\d]+)/$', views.form, name='form'), ] diff --git a/src/my_oauth/utils.py b/src/my_oauth/utils.py new file mode 100644 index 00000000..b6f97a73 --- /dev/null +++ b/src/my_oauth/utils.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +import json +from urllib.parse import parse_qsl + +import requests +from django.forms.models import model_to_dict + + +def access_token_from_response(response): + if response.status_code != requests.codes.ok: + raise Exception('Response status code not equal ok.') + try: + access_token = json.loads(response.text) + except Exception: + access_token = dict(parse_qsl(response.text)) + return access_token + + +def refresh_acccess_token(token): + service = token.service + args = model_to_dict(service) + args.update(token.access_token) + refresh_token_uri = service.refresh_token_uri % args + refresh_token_post = json.loads(service.refresh_token_post % args) + response = requests.post(refresh_token_uri, data=refresh_token_post) + access_token = access_token_from_response(response) + return access_token diff --git a/src/my_oauth/views.py b/src/my_oauth/views.py index 4c75df23..8cbefad8 100644 --- a/src/my_oauth/views.py +++ b/src/my_oauth/views.py @@ -6,7 +6,7 @@ from copy import deepcopy from datetime import timedelta from io import StringIO -from urllib.parse import parse_qsl +from urllib.parse import quote import requests from django.conf import settings @@ -17,14 +17,15 @@ from django.db import transaction from django.db.models import Count, Q from django.forms.models import model_to_dict -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone from flatten_dict import flatten -from clist.templatetags.extras import allowed_redirect, relative_url -from my_oauth.models import Service, Token +from clist.templatetags.extras import allowed_redirect, as_number, relative_url +from my_oauth.models import Form, Service, Token +from my_oauth.utils import access_token_from_response, refresh_acccess_token from true_coders.models import Coder @@ -49,7 +50,19 @@ def unlink(request, name): messages.error(request, 'Not enough services') else: coder.token_set.filter(service__name=name).delete() - return allowed_redirect(reverse('coder:settings', kwargs=dict(tab='social'))) + return allowed_redirect(reverse('coder:settings', kwargs={'tab': 'social'})) + + +@login_required +def refresh(request, name): + token = get_object_or_404(request.user.coder.token_set, + service__name=name, + service__refresh_token_uri__isnull=False) + access_token = refresh_acccess_token(token) + request.session['token_url'] = reverse('coder:settings', kwargs={'tab': 'social'}) + ret = process_access_token(request, token.service, access_token) + request.logger.success(f'Token {token.service.title} refreshed') + return ret def process_data(request, service, access_token, data): @@ -57,28 +70,34 @@ def process_data(request, service, access_token, data): d.update(access_token) d = flatten(d, reducer='underscore') - email = d.get(service.email_field, None) user_id = d.get(service.user_id_field, None) - if not email or not user_id: - raise Exception('Email or User ID not found.') + email = d.get(service.email_field, None) + redirect_url = request.session.pop('token_url', None) + if not user_id: + raise Exception('User ID not found.') + if not email and not redirect_url: + raise Exception('Email not found.') token, created = Token.objects.get_or_create(service=service, user_id=user_id) token.access_token = access_token token.data = data token.email = email + expires_in = as_number(d.get('expires_in'), force=True) + if expires_in: + token.expires_at = timezone.now() + timedelta(seconds=expires_in) token.save() + if redirect_url: + token_id_field = request.session.pop('token_id_field', None) + if token_id_field: + request.session[token_id_field] = token.id + return allowed_redirect(redirect_url) + request.session['token_id'] = token.id return redirect('auth:signup') -def process_access_token(request, service, response): - if response.status_code != requests.codes.ok: - raise Exception('Response status code not equal ok.') - try: - access_token = json.loads(response.text) - except Exception: - access_token = dict(parse_qsl(response.text)) +def process_access_token(request, service, access_token): if service.data_header: args = model_to_dict(service) @@ -132,13 +151,16 @@ def response(request, name): else: url = re.sub('[\n\r]', '', service.token_uri % args) response = requests.get(url) - return process_access_token(request, service, response) + access_token = access_token_from_response(response) + return process_access_token(request, service, access_token) except Exception as e: messages.error(request, "ERROR: {}".format(str(e).strip("'"))) return signup(request) def login(request): + request.session.pop('token_url', None) + redirect_url = request.GET.get('next') if not redirect_url or not redirect_url.startswith('/'): redirect_url = relative_url(request.META.get('HTTP_REFERER')) or reverse('clist:main') @@ -287,3 +309,55 @@ def services_dumpdata(request): service['fields']['secret'] = None service['fields']['app_id'] = None return HttpResponse(json.dumps(services), content_type="application/json") + + +def form(request, uuid): + form = get_object_or_404(Form.objects, pk=uuid) + token_id = request.session.get('form_token_id', None) + token = Token.objects.filter(pk=token_id).first() if token_id else None + + if form.is_closed(): + token = None + code = None + elif token: + data = {k: quote(str(v)) for k, v in flatten(token.data, reducer='underscore').items()} + code = form.code.format(**data) + else: + code = None + + action = request.GET.get('action') + if action: + if form.is_closed(): + return HttpResponseBadRequest('Form is closed') + form_url = reverse('auth:form', args=(uuid, )) + if action == 'login': + request.session['token_id_field'] = 'form_token_id' + request.session['token_url'] = form_url + return redirect(reverse('auth:query', args=(form.service.name, ))) + elif action == 'logout': + request.session.pop('form_token_id', None) + elif action == 'register': + if request.headers.get('X-Secret') != form.secret: + return HttpResponseBadRequest('Unauthorized') + register_url = form.register_url.format(**request.GET.dict()) + register_headers = form.register_headers.format(**request.headers) + response = requests.post(register_url, headers=json.loads(register_headers)) + return JsonResponse( + { + 'status': 'ok', + 'code': response.status_code, + 'text': response.text, + }, + status=response.status_code, + ) + else: + return HttpResponseBadRequest('Unknown action') + return allowed_redirect(form_url) + + return render(request, 'form.html', { + 'form': form, + 'code': code, + 'token': token, + 'nofavicon': True, + 'nocounter': True, + }) diff --git a/src/notification/admin.py b/src/notification/admin.py index 805bc403..415b8aa5 100644 --- a/src/notification/admin.py +++ b/src/notification/admin.py @@ -1,3 +1,5 @@ +from sql_util.utils import SubqueryCount + from notification.models import Calendar, Notification, NotificationMessage, Subscription, Task from pyclist.admin import BaseModelAdmin, admin_register @@ -8,15 +10,28 @@ class NotificationAdmin(BaseModelAdmin): list_filter = ['method'] search_fields = ['coder__user__username', 'method', 'period'] - def get_readonly_fields(self, request, obj=None): - return super().get_readonly_fields(request, obj) - @admin_register(Subscription) class SubscriptionAdmin(BaseModelAdmin): - list_display = ['coder', 'method', 'resource', 'contest', 'account', 'coder_list', 'coder_chat', 'enable'] + list_display = ['coder', 'method', 'enable', 'n_accounts', 'n_coders', + 'resource', 'contest', 'coder_list', 'coder_chat'] list_filter = ['enable', 'method'] - search_fields = ['coder__username', 'account__key'] + search_fields = ['coder__username', 'accounts__key', 'coders__username'] + + def n_accounts(self, obj): + return obj.n_accounts + + def n_coders(self, obj): + return obj.n_coders + + def get_queryset(self, request): + ret = super().get_queryset(request) + ret = ret.annotate(n_accounts=SubqueryCount('accounts')) + ret = ret.annotate(n_coders=SubqueryCount('coders')) + return ret + + def get_readonly_fields(self, *args, **kwargs): + return ['last_contest', 'last_update'] + super().get_readonly_fields(*args, **kwargs) @admin_register(Task) diff --git a/src/notification/forms.py b/src/notification/forms.py index ede1f51a..10c1d0c2 100644 --- a/src/notification/forms.py +++ b/src/notification/forms.py @@ -29,7 +29,6 @@ class Meta: def __init__(self, coder, *args, **kwargs): super(NotificationForm, self).__init__(*args, **kwargs) - methods = coder.get_notifications() self.fields['method'] = ChoiceField(choices=methods) diff --git a/src/notification/management/commands/sendout_tasks.py b/src/notification/management/commands/sendout_tasks.py index fd1954d2..194b6135 100644 --- a/src/notification/management/commands/sendout_tasks.py +++ b/src/notification/management/commands/sendout_tasks.py @@ -32,6 +32,7 @@ from utils.traceback_with_vars import colored_format_exc logger = getLogger('notification.sendout.tasks') +lock = FileLock('sharedfiles/lock/sendout_tasks.lock') class Command(BaseCommand): @@ -185,6 +186,7 @@ def save_config(self): yaml.dump(self.config, fo, indent=2) @print_sql_decorator() + @lock def handle(self, *args, **options): self.load_config() dryrun = options.get('dryrun') @@ -206,7 +208,11 @@ def handle(self, *args, **options): qs = Task.objects.all() if coders and options.get('force') else Task.unsent.all() if coders: - qs = qs.filter(periodical_notification__coder__username__in=coders) + qs = qs.filter( + Q(periodical_notification__coder__username__in=coders) | + Q(subscription__coder__username__in=coders) + ) + qs = qs.prefetch_related( Prefetch( 'notification__coder__chat_set', diff --git a/src/notification/migrations/0042_edit_subscription.py b/src/notification/migrations/0042_edit_subscription.py new file mode 100644 index 00000000..d592501b --- /dev/null +++ b/src/notification/migrations/0042_edit_subscription.py @@ -0,0 +1,102 @@ +# Generated by Django 5.1 on 2024-11-17 15:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('notification', '0042_remove_subscription_notificatio_contest_559967_idx_and_more'), ('notification', '0043_alter_subscription_accounts'), ('notification', '0044_alter_notification_coder_alter_subscription_coder'), ('notification', '0045_alter_notification_coder_alter_subscription_coder'), ('notification', '0046_subscription_coders_alter_subscription_accounts'), ('notification', '0047_subscription_notificatio_enable_2370eb_idx'), ('notification', '0048_subscription_last_contest_subscription_last_update'), ('notification', '0049_subscription_top_n_subscription_with_first_accepted'), ('notification', '0050_subscription_notificatio_resourc_b8cd18_idx_and_more')] + + dependencies = [ + ('clist', '0163_update_resource_problems_fields'), + ('clist', '0167_rename_inherit_medals_to_related_resource_has_inherit_medals_to_related'), + ('notification', '0041_alter_subscription_account_and_more'), + ('ranking', '0132_account_rating_prediction'), + ('ranking', '0135_alter_account_n_subscriptions'), + ('ranking', '0136_rename_n_subscriptions_account_n_subscribers'), + ('ranking', '0140_parsestatistics_parse_time'), + ('tg', '0013_chat_accounts'), + ('true_coders', '0069_listvalue_true_coders_coder_l_1cff61_idx'), + ('true_coders', '0070_coder_n_subscribers'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='subscription', + name='notificatio_contest_559967_idx', + ), + migrations.RemoveField( + model_name='subscription', + name='account', + ), + migrations.AddField( + model_name='subscription', + name='accounts', + field=models.ManyToManyField(to='ranking.account'), + ), + migrations.AddIndex( + model_name='subscription', + index=models.Index(fields=['contest'], name='notificatio_contest_089a04_idx'), + ), + migrations.AddIndex( + model_name='subscription', + index=models.Index(fields=['resource'], name='notificatio_resourc_b72d99_idx'), + ), + migrations.AlterField( + model_name='notification', + name='coder', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='true_coders.coder'), + ), + migrations.AlterField( + model_name='subscription', + name='coder', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='true_coders.coder'), + ), + migrations.AddField( + model_name='subscription', + name='coders', + field=models.ManyToManyField(blank=True, related_name='subscribers', to='true_coders.coder'), + ), + migrations.AlterField( + model_name='subscription', + name='accounts', + field=models.ManyToManyField(blank=True, related_name='subscribers', to='ranking.account'), + ), + migrations.AddIndex( + model_name='subscription', + index=models.Index(fields=['enable', 'resource', 'contest'], name='notificatio_enable_2370eb_idx'), + ), + migrations.AddField( + model_name='subscription', + name='last_contest', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='last_contest_subscriptions', to='clist.contest'), + ), + migrations.AddField( + model_name='subscription', + name='last_update', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='subscription', + name='top_n', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='subscription', + name='with_first_accepted', + field=models.BooleanField(default=False), + ), + migrations.AddIndex( + model_name='subscription', + index=models.Index(fields=['resource', 'contest'], name='notificatio_resourc_b8cd18_idx'), + ), + migrations.AddIndex( + model_name='subscription', + index=models.Index(fields=['resource', 'contest', 'with_first_accepted'], name='notificatio_resourc_90d51f_idx'), + ), + migrations.AddIndex( + model_name='subscription', + index=models.Index(fields=['resource', 'contest', 'top_n'], name='notificatio_resourc_b039f3_idx'), + ), + ] diff --git a/src/notification/models.py b/src/notification/models.py index 581533b8..8fdb1b1b 100644 --- a/src/notification/models.py +++ b/src/notification/models.py @@ -11,20 +11,21 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models.signals import pre_delete +from django.db.models.signals import m2m_changed, pre_delete from django.dispatch import receiver from django.template.loader import render_to_string from django.utils import timezone from clist.models import Contest, Resource -from pyclist.models import BaseModel +from pyclist.models import BaseManager, BaseModel from ranking.models import Account from tg.models import Chat as CoderChat from true_coders.models import Coder, CoderList +from utils.strings import markdown_to_html, markdown_to_text class TaskNotification(BaseModel): - coder = models.ForeignKey(Coder, on_delete=models.CASCADE) + coder = models.ForeignKey(Coder, on_delete=models.CASCADE, null=True, blank=True) method = models.CharField(max_length=256, null=False) enable = models.BooleanField(default=True) @@ -33,6 +34,23 @@ def method_type(self): ret, *_ = self.method.split(':', 1) return ret + def send(self, message=None, markdown=True, contest=None, **kwargs): + if markdown and message: + if self.method_type == django_settings.NOTIFICATION_CONF.WEBBROWSER: + message = markdown_to_text(message) + elif self.method_type == django_settings.NOTIFICATION_CONF.EMAIL: + message = markdown_to_html(message) + if contest is not None and self.last_contest != contest: + Task.create_contest_notification(notification=self, contest=contest) + self.last_contest = contest + self.last_update = timezone.now() + self.save(update_fields=['last_contest', 'last_update']) + Task.objects.create(notification=self, message=message, **kwargs) + + @property + def notification_key(self): + return f'{self.coder_id}:{self.method}' + class Meta: abstract = True @@ -78,7 +96,7 @@ class Notification(TaskNotification): ) def __str__(self): - return '{0.method}@{0.coder}: {0.before} {0.period}'.format(self) + return f'{self.method}@{self.coder}: {self.before} {self.period} Notification#{self.id}' def save(self, *args, **kwargs): if not self.id: @@ -98,12 +116,23 @@ def clean(self): raise ValidationError('WebBrowser method must have Event period.') +class EnabledSubscriptionManager(BaseManager): + def get_queryset(self): + return super().get_queryset().filter(enable=True) + + class Subscription(TaskNotification): resource = models.ForeignKey(Resource, null=True, blank=True, default=None, on_delete=models.CASCADE) contest = models.ForeignKey(Contest, null=True, blank=True, default=None, on_delete=models.CASCADE) - account = models.ForeignKey(Account, null=True, blank=True, default=None, on_delete=models.CASCADE) + coders = models.ManyToManyField(Coder, blank=True, related_name='subscribers') + accounts = models.ManyToManyField(Account, blank=True, related_name='subscribers') coder_list = models.ForeignKey(CoderList, null=True, blank=True, default=None, on_delete=models.CASCADE) coder_chat = models.ForeignKey(CoderChat, null=True, blank=True, default=None, on_delete=models.CASCADE) + last_contest = models.ForeignKey(Contest, null=True, blank=True, default=None, on_delete=models.CASCADE, + related_name='last_contest_subscriptions') + last_update = models.DateTimeField(null=True, blank=True) + with_first_accepted = models.BooleanField(default=False) + top_n = models.IntegerField(null=True, blank=True) tasks = GenericRelation( 'Task', @@ -112,8 +141,93 @@ class Subscription(TaskNotification): related_query_name='subscription', ) + objects = BaseManager() + enabled = EnabledSubscriptionManager() + class Meta: - indexes = [models.Index(fields=['contest', 'account'])] + indexes = [ + models.Index(fields=['contest']), + models.Index(fields=['resource']), + models.Index(fields=['enable', 'resource', 'contest']), + models.Index(fields=['resource', 'contest']), + models.Index(fields=['resource', 'contest', 'with_first_accepted']), + models.Index(fields=['resource', 'contest', 'top_n']), + ] + + def __str__(self): + ret = f'{self.method}@{self.coder}:' + if self.resource_id: + ret += f' {self.resource}' + if self.contest_id: + ret += f' {self.contest}' + return f'{ret} Subscription#{self.id}' + + def form_data(self): + ret = {} + ret['id'] = self.id + ret['method'] = {'id': self.method, 'text': self.method} + if self.resource: + ret['resource'] = {'id': self.resource.id, 'text': self.resource.host} + if self.contest: + ret['contest'] = {'id': self.contest.id, 'text': self.contest.title} + accounts = ret.setdefault('accounts', []) + for account in self.accounts.all(): + accounts.append({'id': account.id, 'text': account.display()}) + coders = ret.setdefault('coders', []) + for coder in self.coders.all(): + coders.append({'id': coder.id, 'text': coder.detailed_name}) + if self.coder_list_id: + ret['coder_list'] = {'id': self.coder_list.id, 'text': self.coder_list.name} + if self.coder_chat_id: + ret['coder_chat'] = {'id': self.coder_chat.id, 'text': self.coder_chat.title} + ret['with_first_accepted'] = self.with_first_accepted + ret['top_n'] = self.top_n + return ret + + def is_empty(self): + return not (self.accounts.exists() or self.coders.exists() or self.with_first_accepted or self.top_n) + + +@receiver(m2m_changed, sender=Subscription.accounts.through) +def update_account_n_subscribers_on_change(sender, instance, action, reverse, pk_set, **kwargs): + when, action = action.split('_', 1) + if when != 'post': + return + if action == 'add': + delta = 1 + elif action == 'remove': + delta = -1 + else: + return + + if reverse: + Account.objects.filter(pk=instance.pk).update(n_subscribers=models.F('n_subscribers') + delta) + elif pk_set: + Account.objects.filter(pk__in=pk_set).update(n_subscribers=models.F('n_subscribers') + delta) + + +@receiver(m2m_changed, sender=Subscription.coders.through) +def update_coder_n_subscribers_on_change(sender, instance, action, reverse, pk_set, **kwargs): + when, action = action.split('_', 1) + if when != 'post': + return + if action == 'add': + delta = 1 + elif action == 'remove': + delta = -1 + else: + return + + if reverse: + Coder.objects.filter(pk=instance.pk).update(n_subscribers=models.F('n_subscribers') + delta) + elif pk_set: + Coder.objects.filter(pk__in=pk_set).update(n_subscribers=models.F('n_subscribers') + delta) + + +@receiver(pre_delete, sender=Subscription) +def update_n_subscribers_on_delete(sender, instance, **kwargs): + Account.objects.filter(subscribers=instance).update(n_subscribers=models.F('n_subscribers') - 1) + Coder.objects.filter(subscribers=instance).update(n_subscribers=models.F('n_subscribers') - 1) class Task(BaseModel): @@ -138,8 +252,14 @@ def get_queryset(self): objects = ObjectsManager() unsent = UnsentManager() + @classmethod + def create_contest_notification(cls, notification, contest, **kwargs): + addition = kwargs.setdefault('addition', {}) + addition['contests'] = [contest.pk] + Task.objects.create(notification=notification, **kwargs) + def __str__(self): - return 'task of {0.notification}'.format(self) + return 'Task#{0.id} {0.notification}'.format(self) @receiver(pre_delete, sender=Task) diff --git a/src/notification/utils.py b/src/notification/utils.py new file mode 100644 index 00000000..ae17687d --- /dev/null +++ b/src/notification/utils.py @@ -0,0 +1,100 @@ +import re + +import django_rq +import flag +from django.conf import settings +from django.contrib.auth.models import User +from django.core.management import call_command +from django.urls import reverse + +from clist.templatetags.extras import (as_number, get_division_problems, get_problem_name, get_problem_short, is_hidden, + is_partial, is_solved, md_escape, md_url, md_url_text, scoreformat, + solution_time_compare) + + +def compose_message_by_problems(problem_shorts, statistic, previous_addition, contest_or_problems): + problems = statistic.addition.get('problems', {}) + previous_problems = previous_addition.get('problems', {}) + + contest_problems = get_division_problems(contest_or_problems, statistic.addition) + contest_problems = {get_problem_short(problem): problem for problem in contest_problems} + + problem_messages = [] + max_time_solution = None + is_improving = False + + if problem_shorts == 'all': + problem_shorts = list(problems.keys()) + else: + for short, solution in problems.items(): + if short in problem_shorts or not is_hidden(solution): + continue + problem_shorts.append(short) + + for short in problem_shorts: + solution = problems.get(short, {}) + previous_problem = previous_problems.get(short, {}) + + result = solution.get('result') + verdict = solution.get('verdict') + previous_result = previous_problem.get('result') + + is_solved_result = is_solved(solution) + is_improving |= is_solved_result + + contest_problem = contest_problems.get(short, {}) + problem_message = short if 'short' in contest_problem else get_problem_name(contest_problem) + problem_message = md_escape(problem_message) + if re.match(r'^[\w\d_]+$', problem_message): + problem_message = f'#{problem_message}' + if 'name' in solution: + problem_message = '%s. %s' % (problem_message, md_escape(solution['name'])) + problem_message = '%s `%s`' % (problem_message, scoreformat(result, with_shorten=False)) + if verdict: + problem_message = '%s %s' % (problem_message, md_escape(verdict)) + + if previous_result and is_partial(solution): + delta = as_number(result, default=0) - as_number(previous_result, default=0) + is_improving |= delta > 0 + problem_message += " `%s%s`" % ('+' if delta >= 0 else '', delta) + + if max_time_solution is None or solution_time_compare(max_time_solution, solution) < 0: + max_time_solution = solution + + if solution.get('first_ac'): + problem_message += ' FIRST ACCEPTED' + if solution.get('is_max_score'): + problem_message += ' MAX SCORE' + if solution.get('try_first_ac'): + problem_message += ' TRY FIRST AC' + problem_messages.append(problem_message) + problem_message = '(%s)' % ', '.join(problem_messages) if problem_messages else '' + time_message = '`[%s]`' % max_time_solution["time"] if max_time_solution and 'time' in max_time_solution else '' + + previous_place = previous_addition.get('place') + previous_solving = previous_addition.get('score') + has_solving_diff = previous_solving is None or previous_solving != statistic.solving + place_message = statistic.place + if previous_place and has_solving_diff: + place_message = '%s->%s' % (previous_place, statistic.place) + + standings_url = reverse('ranking:standings_by_id', args=[statistic.contest_id]) + f'?find_me={statistic.pk}' + account_message = '[%s](%s)' % (md_url_text(statistic.account_name), md_url(standings_url)) + if statistic.account.country: + account_message = flag.flag(statistic.account.country.code) + account_message + + suffix_message = '' + if has_solving_diff: + suffix_message += f'= `{scoreformat(statistic.solving, with_shorten=False)}`' + if 'penalty' in statistic.addition: + suffix_message += rf' `[{statistic.addition["penalty"]}]`' + message = f'{time_message} `{place_message}`. {account_message} {suffix_message} {problem_message}'.strip() + message = re.sub(r'(\s)\s+', r'\1', message) + return message + + +def send_messages(**kwargs): + if settings.DEBUG: + admin_coders = list(User.objects.filter(is_superuser=True).values_list('username', flat=True)) + kwargs.setdefault('coders', []).extend(admin_coders) + django_rq.get_queue('system').enqueue(call_command, 'sendout_tasks', **kwargs) diff --git a/src/pyclist/decorators.py b/src/pyclist/decorators.py index 12a2679d..f84d91c5 100644 --- a/src/pyclist/decorators.py +++ b/src/pyclist/decorators.py @@ -14,42 +14,76 @@ from stringcolor import bold, cs from clist.models import Contest -from clist.templatetags.extras import query_transform, slug, toint +from clist.templatetags.extras import slug, toint, trim_to +from utils.strings import slug_string_iou logger = logging.getLogger(__name__) +def get_contest_by_id_and_slug(contests, contest_id, title_slug) -> None | Contest: + contest = contests.filter(pk=contest_id).first() + if contest is None: + return + if slug_string_iou(title_slug, slug(contest.title)) < 0.3: + return + return contest + + +def get_contest_by_series(contests, series) -> None | Contest: + contest = contests.filter(series__aliases__contains=slug(series)).order_by('-end_time').first() + if contest is None: + return + return contest + + +def guess_contest(contests, contest_id, title_slug): + contests_iterator = contests.filter(slug=title_slug).iterator() + + try: + contest = None + contest = next(contests_iterator) + another = next(contests_iterator) + except StopIteration: + another = None + + if another is not None: + return redirect(reverse('ranking:standings_list') + f'?search=slug:{title_slug}') + + if contest is not None: + return contest + + contest = get_contest_by_id_and_slug(contests, contest_id, title_slug) + if contest is not None: + return contest + + contest = get_contest_by_series(contests, title_slug) + if contest is not None: + return contest + + return HttpResponseNotFound() + + def inject_contest(): def decorator(view): @wraps(view) def decorated(request, title_slug=None, contest_id=None, contests_ids=None, *args, **kwargs): contests = Contest.objects.annotate_favorite(request.user) - to_redirect = False + to_canonical = False contest = None if contest_id is not None: contest = contests.filter(pk=contest_id).first() if title_slug is None: - to_redirect = True + to_canonical = True else: if contest is None or slug(contest.title) != title_slug: contest = None title_slug += f'-{contest_id}' if contest is None and title_slug is not None: - contests_iterator = contests.filter(slug=title_slug).iterator() - - contest = None - try: - contest = next(contests_iterator) - another = next(contests_iterator) - except StopIteration: - another = None - if contest is None: - return HttpResponseNotFound() - if another is None: - to_redirect = True - else: - return redirect(reverse('ranking:standings_list') + f'?search=slug:{title_slug}') + contest = guess_contest(contests, contest_id, title_slug) + if not isinstance(contest, Contest): + return contest + to_canonical = True if contests_ids is not None: cids, contests_ids = list(map(toint, contests_ids.split(','))), [] @@ -58,18 +92,16 @@ def decorated(request, title_slug=None, contest_id=None, contests_ids=None, *arg contests_ids.append(cid) contest = contests.filter(pk=contests_ids[0]).first() kwargs['other_contests'] = list(contests.filter(pk__in=contests_ids[1:])) + if contest is None: return HttpResponseNotFound() - if to_redirect: + if to_canonical: resolved = resolve(request.path) viewname = resolved.app_name + ':' + resolved.url_name.split('_')[0] - query = query_transform(request) url = reverse(viewname, kwargs={'title_slug': slug(contest.title), 'contest_id': str(contest.pk)}) - if query: - query = '?' + query - return redirect(url + query) + request.set_canonical(url) response = view(request, *args, contest=contest, **kwargs) return response @@ -136,7 +168,7 @@ def log_grouped_times(grouped_times): msg = bold(f'{g["sum"]:.3f}') + ' ms' msg += ' (avg ' + bold(f'{g["avg"]:.3f}') + ' ms)' msg += ' ' + bold(f'{g["count"]}') + ' times' - msg += ': ' + cs(g['query'], 'grey') + msg += ': ' + cs(trim_to(g['query'], 100, raw_text=True), 'grey') print(msg) diff --git a/src/pyclist/middleware.py b/src/pyclist/middleware.py index c0fd3ce3..8717f2f6 100644 --- a/src/pyclist/middleware.py +++ b/src/pyclist/middleware.py @@ -6,15 +6,14 @@ from django.conf import settings from django.db import connection from django.db.models import Q -from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseForbidden from django.middleware import csrf -from django.urls import reverse from django.utils import timezone from pytz import timezone as pytz_timezone from clist.templatetags.extras import redirect_login from true_coders.models import Coder -from utils.custom_request import custom_request +from utils.custom_request import CustomRequest def DebugPermissionOnlyMiddleware(get_response): @@ -23,7 +22,7 @@ def middleware(request): first_path = request.path.split('/')[1] if first_path not in settings.DEBUG_PERMISSION_EXCLUDE_PATHS: if not request.user.is_authenticated: - if first_path not in ('login', 'signup', 'oauth', 'calendar', 'telegram'): + if first_path not in ('login', 'signup', 'oauth', 'calendar', 'telegram', 'form'): return redirect_login(request) elif not request.user.has_perm('auth.view_debug'): return HttpResponseForbidden() @@ -43,7 +42,7 @@ def middleware(request): def CustomRequestMiddleware(get_response): def middleware(request): - request = custom_request(request) + request = CustomRequest(request) response = get_response(request) return response diff --git a/src/pyclist/models.py b/src/pyclist/models.py index 37c4e463..7a6929c8 100644 --- a/src/pyclist/models.py +++ b/src/pyclist/models.py @@ -1,3 +1,5 @@ +from typing import Any, Optional + from django.apps import apps from django.contrib.auth.models import AnonymousUser, User from django.contrib.contenttypes.models import ContentType @@ -25,6 +27,15 @@ class BaseModel(models.Model): created = models.DateTimeField(auto_now_add=True, db_index=True) modified = models.DateTimeField(auto_now=True, db_index=True) + def fetched_field(self, field) -> Optional[Any]: + fields = field.split('__') + obj = self + for field in fields: + if field not in obj._state.fields_cache: + return + obj = getattr(obj, field) + return obj + class Meta: abstract = True @@ -67,7 +78,6 @@ def annotate_note(self, instance): content_type = ContentType.objects.get_for_model(self.model) qs = Note.objects.filter(coder=coder, content_type=content_type, object_id=OuterRef('pk')) ret = self.annotate(is_note=Exists(qs)) - ret = ret.annotate(note_text=Subquery(qs.values('text')[:1])) return ret diff --git a/src/pyclist/settings.py b/src/pyclist/settings.py index 60a11001..073ac19e 100644 --- a/src/pyclist/settings.py +++ b/src/pyclist/settings.py @@ -180,6 +180,7 @@ 'favorites.templatetags.favorites_extras', 'django.templatetags.cache', 'el_pagination.templatetags.el_pagination_tags', + 'jsonify.templatetags.jsonify', ], 'loaders': [ ('django.template.loaders.cached.Loader', [ @@ -212,11 +213,16 @@ # django_rq RQ_QUEUES = { + 'system': { + 'HOST': 'localhost', + 'PORT': 6379, + 'DEFAULT_TIMEOUT': 60, + }, 'default': { 'HOST': 'localhost', 'PORT': 6379, - 'DEFAULT_TIMEOUT': 3600, - } + 'DEFAULT_TIMEOUT': 300, + }, } RQ_SHOW_ADMIN_LINK = True @@ -282,7 +288,7 @@ RESOURCES_ICONS_SIZES = [32, 64] STORAGES = { - "default": {"BACKEND": "django.core.files.storage.FileystemStorage"}, + 'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'}, 'staticfiles': {'BACKEND': 'static_compress.CompressedStaticFilesStorage'}, } @@ -592,6 +598,9 @@ def show_toolbar_callback(request): CSP_IMG_SRC += ('https://www.google-analytics.com', ) CSP_CONNECT_SRC += ('https://www.google-analytics.com', ) +# CSP Yandex form +CSP_SCRIPT_SRC += ('https://forms.yandex.ru', ) + # X-XSS-Protection SECURE_BROWSER_XSS_FILTER = True @@ -657,6 +666,9 @@ def show_toolbar_callback(request): DEFAULT_API_THROTTLE_AT_ = 10 CODER_LIST_N_VALUES_LIMIT_ = 100 +CODER_N_SUBSCRIPTIONS_LIMIT_ = 10 +CODER_SUBSCRIPTION_N_LIMIT_ = CODER_LIST_N_VALUES_LIMIT_ +CODER_SUBSCRIPTION_TOP_N_LIMIT_ = 50 ENABLE_GLOBAL_RATING_ = False @@ -780,6 +792,13 @@ def show_toolbar_callback(request): 'loading': '<i class="fas fa-circle-notch fa-spin"></i>', 'profile': dict(icon='<i class="fa-regular fa-address-card"></i>', title=False), 'rating_prediction': '<i class="fa-solid fa-calculator"></i>', + 'https': '<i class="fa-regular fa-square-check"></i>', + 'http': '<i class="fa-regular fa-rectangle-xmark"></i>', + 'edit': '<i class="fa-regular fa-pen-to-square"></i>', + 'login': '<i class="fa-solid fa-right-to-bracket"></i>', + 'logout': '<i class="fa-solid fa-right-from-bracket"></i>', + 'expires': '<i class="fa-solid fa-clock-rotate-left"></i>', + 'subscription': '<i class="fa-regular fa-newspaper"></i>', 'google': {'icon': '<i class="fab fa-google"></i>', 'title': None}, 'facebook': {'icon': '<i class="fab fa-facebook"></i>', 'title': None}, @@ -790,7 +809,7 @@ def show_toolbar_callback(request): 'discord': {'icon': '<i class="fab fa-discord"></i>', 'title': None}, 'vk': {'icon': '<i class="fab fa-vk"></i>', 'title': None}, 'patreon': {'icon': '<i class="fab fa-patreon"></i>', 'title': None}, - 'competitive-hustle': {'icon': '<i class="fas fa-tools"></i>'}, + 'yandex-contest': {'icon': '<i class="fas fa-tools"></i>'}, } @@ -822,9 +841,9 @@ class NOTIFICATION_CONF: WEBBROWSER = 'webbrowser' METHODS_CHOICES = ( - (EMAIL, 'Email'), (TELEGRAM, 'Telegram'), (WEBBROWSER, 'WebBrowser'), + # (EMAIL, 'Email'), ) @@ -843,8 +862,8 @@ class NOTIFICATION_CONF: DjangoIntegration(), LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), ], - traces_sample_rate=0.01, - profiles_sample_rate=0.01, + traces_sample_rate=0.005, + profiles_sample_rate=0.005, send_default_pii=True, environment='development' if DEBUG else 'production', ) diff --git a/src/ranking/admin.py b/src/ranking/admin.py index 5a2777e3..319d12f7 100644 --- a/src/ranking/admin.py +++ b/src/ranking/admin.py @@ -6,7 +6,8 @@ from pyclist.admin import BaseModelAdmin, admin_register from ranking.management.commands.parse_statistic import Command as parse_stat from ranking.models import (Account, AccountMatching, AccountRenaming, AccountVerification, AutoRating, CountryAccount, - Module, Rating, Stage, StageContest, Statistics, VerifiedAccount, VirtualStart) + Module, ParseStatistics, Rating, Stage, StageContest, Statistics, VerifiedAccount, + VirtualStart) class HasCoders(admin.SimpleListFilter): @@ -59,8 +60,9 @@ def _has_coder(self, obj): def get_readonly_fields(self, request, obj=None): return ( - ['updated', 'n_contests', 'n_writers', 'last_activity', 'last_submission', 'last_rating_activity'] + - super().get_readonly_fields(request, obj) + ['updated', 'n_contests', 'n_writers', 'n_subscribers', + 'last_activity', 'last_submission', 'last_rating_activity'] + + super().get_readonly_fields(request, obj) ) def get_queryset(self, request): @@ -144,13 +146,6 @@ class StageAdmin(BaseModelAdmin): list_filter = ['contest__host'] ordering = ['-contest__start_time'] - def parse_stage(self, request, queryset): - for stage in queryset: - stage.update() - parse_stage.short_description = 'Parse stages' - - actions = [parse_stage] - class StageContestInline(admin.TabularInline): model = StageContest raw_id_fields = ['contest'] @@ -221,3 +216,13 @@ def _n_different_coders(self, obj): return obj.n_different_coders _n_different_coders.admin_order_field = 'n_different_coders' _n_different_coders.short_description = 'NDC' + + +@admin_register(ParseStatistics) +class ParseStatisticsAdmin(BaseModelAdmin): + list_display = ['contest', 'enable', 'delay', 'created', 'modified'] + search_fields = ['contest__title', 'contest__host'] + list_filter = ['contest__host'] + + def get_readonly_fields(self, *args, **kwargs): + return ['parse_time'] + super().get_readonly_fields(*args, **kwargs) diff --git a/src/ranking/management/commands/informer.py b/src/ranking/management/commands/informer.py index 213a6347..908026e5 100644 --- a/src/ranking/management/commands/informer.py +++ b/src/ranking/management/commands/informer.py @@ -8,7 +8,6 @@ import subprocess import time from datetime import timedelta -from numbers import Number from pprint import pprint # noqa import coloredlogs @@ -17,6 +16,7 @@ from django.conf import settings from django.core.management import call_command from django.core.management.base import BaseCommand +from django.db.models import Q from django.urls import reverse from django.utils import timezone from rich.console import Console @@ -24,8 +24,9 @@ from rich.table import Table from clist.models import Contest -from clist.templatetags.extras import (as_number, get_problem_name, get_problem_short, has_season, md_escape, - md_italic_escape, md_url, scoreformat) +from clist.templatetags.extras import (as_number, get_problem_name, get_problem_short, has_season, md_escape, md_url, + scoreformat, solution_time_compare) +from notification.models import Subscription from ranking.models import Statistics from tg.bot import MAX_MESSAGE_LENGTH, Bot, telegram from tg.models import Chat @@ -76,6 +77,8 @@ def add_arguments(self, parser): parser.add_argument('--dryrun', action='store_true', help='Do not send to bot message', default=False) parser.add_argument('--dump', help='Dump and restore log file', default=None) parser.add_argument('--force-iterations', type=int, help='Minimum number of iterations before stop', default=0) + parser.add_argument('--with-history', action='store_true', help='Show history', default=False) + parser.add_argument('--with-subscriptions', action='store_true', help='Send to subscriptions', default=False) parser.add_argument('--no-parse-statistic', action='store_true', help='Do not call command to parse statistics', default=False) @@ -110,7 +113,7 @@ def handle(self, *args, **options): print(now) if not args.no_parse_statistic: - call_command('parse_statistic', contest_id=args.cid, without_fill_coder_problems=True) + call_command('parse_statistic', contest_id=args.cid, without_set_coder_problems=True) contest = Contest.objects.get(pk=args.cid) resource = contest.resource qs = Statistics.objects.filter(contest=contest) @@ -204,25 +207,6 @@ def delete_message(): in_time = None - def time_compare(lhs, rhs): - - def get_value(val): - if isinstance(val, Number): - val = [val] - else: - val = list(val.split(':')) - return len(val), val - - for k in ('time_in_seconds', 'time'): - if k not in lhs or k not in rhs: - continue - l_val = get_value(lhs[k]) - r_val = get_value(rhs[k]) - if l_val != r_val: - return -1 if l_val < r_val else 1 - - return 0 - for k, v in stat_problems.items(): if 'result' not in v: continue @@ -261,14 +245,14 @@ def get_value(val): m = '%s%s `%s`' % (short, ('. ' + v['name']) if 'name' in v else '', scoreformat(result)) if verdict: - m += ' ' + md_italic_escape(verdict) + m += ' ' + md_escape(verdict) if has_change: if p_result is not None and v.get('partial'): delta = as_number(result) - (as_number(p_result) or 0) m += " `%s%s`" % ('+' if delta >= 0 else '', delta) - if in_time is None or time_compare(in_time, v) < 0: + if in_time is None or solution_time_compare(in_time, v) < 0: in_time = v if verdict not in ['U']: @@ -343,8 +327,9 @@ def get_value(val): if filtered: table.add_row(str(stat.place), str(stat.solving), Markdown(msg), str(stat.pk)) if not args.dryrun and (filtered and has_update or has_first_ac or has_try_first_ac or has_max_score): - delete_message() - msg = combine_messages(msg, message_text) + if args.with_history: + delete_message() + msg = combine_messages(msg, message_text) n_attempts = 5 delay_on_timeout = 3 for it in range(n_attempts): @@ -357,10 +342,21 @@ def get_value(val): except telegram.error.BadRequest as e: logger.error(str(e)) wait('send message', it * delay_on_timeout) - continue if not skip_in_message_text: message_text = combine_messages(history_msg, message_text) + if args.with_subscriptions: + has_subscription_update = stat.account.n_subscribers and has_update + if has_subscription_update: + subscriptions_filter = ( + Q(accounts=stat.account) + & (Q(resource__isnull=True) | Q(resource=resource)) + & (Q(contest__isnull=True) | Q(contest=contest)) + ) + subscriptions = Subscription.objects.filter(subscriptions_filter) + for subscription in subscriptions: + subscription.send(message=msg) + data = { 'solving': stat.solving, 'place': stat.place, diff --git a/src/ranking/management/commands/parse_accounts_infos.py b/src/ranking/management/commands/parse_accounts_infos.py index de3e6018..feca06f6 100644 --- a/src/ranking/management/commands/parse_accounts_infos.py +++ b/src/ranking/management/commands/parse_accounts_infos.py @@ -258,7 +258,7 @@ def inf_none(): coders = list(account.coders.values_list('username', flat=True)) if coders and updated_info.get('n_updated'): - call_command('fill_coder_problems', coders=coders, resources=[resource.host]) + call_command('set_coder_problems', coders=coders, resources=[resource.host]) if info.get('country'): account.country = countrier.get(info['country']) @@ -310,7 +310,7 @@ def inf_none(): try: updated = arrow.get(now + timedelta(days=1)).ceil('day').datetime for a in tqdm(accounts, desc='changing update time'): - if a.key not in seen: + if a.pk and a.key not in seen: a.updated = updated a.save(update_fields=['updated']) n_counter['reupdated'] += 1 diff --git a/src/ranking/management/commands/parse_live_statistics.py b/src/ranking/management/commands/parse_live_statistics.py new file mode 100644 index 00000000..bd57384d --- /dev/null +++ b/src/ranking/management/commands/parse_live_statistics.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import logging +# import os +# import re +# import subprocess +import time + +# import coloredlogs +# import flag +import humanize +# from django.conf import settings +from django.core.management import call_command +from django.core.management.base import BaseCommand +# from django.db.models import Q +# from django.urls import reverse +from django.utils import timezone + +from ranking.models import ParseStatistics +# from clist.models import Contest +# from clist.templatetags.extras import (as_number, get_problem_name, get_problem_short, has_season, md_escape, md_url, +# scoreformat, solution_time_compare) +# from notification.models import Subscription +# from ranking.models import Statistics +# from tg.bot import MAX_MESSAGE_LENGTH, Bot, telegram +# from tg.models import Chat +# from true_coders.models import CoderList +from utils.attrdict import AttrDict + +# from datetime import timedelta +# from pprint import pprint # noqa + +# from rich.console import Console +# from rich.markdown import Markdown +# from rich.table import Table + +# from utils.strings import trim_on_newline + + +class Command(BaseCommand): + help = 'Parse live statistics' + + def add_arguments(self, parser): + parser.add_argument('--dryrun', action='store_true', default=False) + self.logger = logging.getLogger('ranking.parse.live_statistics') + + def handle(self, *args, **options): + self.stdout.write(str(options)) + args = AttrDict(options) + logging.disable(logging.DEBUG) + + while True: + now = timezone.now() + parse_statistics = ParseStatistics.objects.filter( + enable=True, + contest__start_time__lte=now, + contest__end_time__gte=now, + ) + parse_statistics = parse_statistics.select_related('contest') + + for parse_stat in parse_statistics: + now = timezone.now() + if parse_stat.parse_time and parse_stat.parse_time > now: + self.logger.info(f'Skip {parse_stat.contest}, delay = {parse_stat.parse_time - now}') + continue + self.logger.info(f'Parse statistic for {parse_stat.contest}') + parse_stat.parse_time = now + parse_stat.delay + parse_stat.save(update_fields=['parse_time']) + if args.dryrun: + continue + call_command( + 'parse_statistic', + contest_id=parse_stat.contest.pk, + without_set_coder_problems=parse_stat.without_set_coder_problems, + without_stage=parse_stat.without_stage, + without_subscriptions=parse_stat.without_subscriptions, + ) + + parse_times = [p.parse_time for p in parse_statistics] + if not parse_times: + break + + parse_time = min(parse_times) + delay = (parse_time - now).total_seconds() + self.logger.info(f'Delay = {humanize.naturaldelta(delay)}, next parse time = {parse_time}') + time.sleep(delay) + + # if not re.match(r'^-?\d+$', args.tid): + # tg_chat_id = Chat.objects.get(title=args.tid).chat_id + # else: + # tg_chat_id = args.tid + + # bot = Bot() + + # if args.dump is not None and os.path.exists(args.dump): + # with open(args.dump, 'r') as fo: + # standings = json.load(fo) + # else: + # standings = {} + + # problems_info = standings.setdefault('__problems_info', {}) + + # iteration = 1 if args.dump else 0 + # forced_iterations = args.force_iterations + + # console = Console() + + # while True: + # subprocess.call('clear', shell=True) + # now = timezone.now() + # print(now) + + # if not args.no_parse_statistic: + # call_command('parse_statistic', contest_id=args.cid, without_set_coder_problems=True) + # contest = Contest.objects.get(pk=args.cid) + # resource = contest.resource + # qs = Statistics.objects.filter(contest=contest) + # qs = qs.prefetch_related('account') + # statistics = list(qs) + + # for p in problems_info.values(): + # if p.get('accepted') or not p.get('n_hidden'): + # p.pop('show_hidden', None) + # p['n_hidden'] = 0 + + # updated = False + # has_hidden = contest.has_hidden_results + # numbered = 0 + # table = Table() + # table.add_column('Rank') + # table.add_column('Score') + # table.add_column('Text') + # table.add_column('Pk') + + # def statistics_sort_key(stat): + # return ( + # stat.place_as_int if stat.place_as_int is not None else float('inf'), + # -stat.solving, + # ) + + # if args.coder_list: + # accounts_filter = CoderList.accounts_filter(uuids=[args.coder_list]) + # qs = resource.account_set.filter(accounts_filter) # TODO filter by contest + # coder_list_accounts = set(qs.values_list('pk', flat=True)) + + # for stat in sorted(statistics, key=statistics_sort_key): + # name_instead_key = resource.info.get('standings', {}).get('name_instead_key') + # name_instead_key = stat.account.info.get('_name_instead_key', name_instead_key) + + # if name_instead_key: + # name = stat.account.name + # else: + # name = stat.addition.get('name') + # if not name or not has_season(stat.account.key, name): + # name = stat.account.key + + # stat_problems = stat.addition.get('problems', {}) + + # filtered = False + # if args.query is not None and re.search(args.query, name, re.I): + # filtered = True + # if args.top and stat.place_as_int and stat.place_as_int <= args.top and stat_problems: + # filtered = True + # if args.coder_list and stat.account.pk in coder_list_accounts: + # filtered = True + + # contest_problems = contest.info.get('problems') + # division = stat.addition.get('division') + # if division and 'division' in contest_problems: + # contest_problems = contest_problems['division'][division] + # contest_problems = {get_problem_short(p): p for p in contest_problems} + + # key = str(stat.account.id) + # standings_key = standings.get(key, {}) + # problems = standings_key.get('problems', {}) + # message_id = standings_key.get('message_id') + # message_text = standings_key.get('message_text', '') + # skip_in_message_text = True + + # def delete_message(): + # nonlocal message_id + # if message_id: + # for it in range(3): + # try: + # bot.delete_message(chat_id=tg_chat_id, message_id=message_id) + # message_id = None + # break + # except telegram.error.BadRequest as e: + # logger.warning(str(e)) + # if 'Message to delete not found' in str(e): + # break + # raise e + # except telegram.error.TimedOut as e: + # logger.warning(str(e)) + # time.sleep(it) + # continue + + # p = [] + # has_update = False + # has_first_ac = False + # has_max_score = False + # has_try_first_ac = False + # has_top = False + # has_solving_diff = ( + # 'solving' not in standings_key or + # abs(standings_key['solving'] - stat.solving) > 1e-9 + # ) + + # in_time = None + + # for k, v in stat_problems.items(): + # if 'result' not in v: + # continue + + # p_info = problems_info.setdefault(k, {}) + # p_result = problems.get(k, {}).get('result') + # p_verdict = problems.get(k, {}).get('verdict') + # result = v['result'] + # verdict = v.get('verdict') + + # is_hidden = str(result).startswith('?') + # is_accepted = str(result).startswith('+') or v.get('binary', False) + # try: + # is_accepted = is_accepted or float(result) > 0 and not v.get('partial') + # except Exception: + # pass + # is_max_score = False + # try: + # is_max_score = float(result) > p_info.get('max_score', 0) + # except Exception: + # pass + + # if is_hidden: + # p_info['n_hidden'] = p_info.get('n_hidden', 0) + 1 + + # has_change = p_result != result or (p_verdict and verdict and p_verdict != verdict) + # if has_change or is_hidden: + # short = k + # contest_problem = contest_problems.get(k, {}) + # if contest_problem and ('short' not in contest_problem or short != get_problem_short(contest_problem)): # noqa + # short = get_problem_name(contest_problems[k]) + # short = md_escape(short) + # if 'url' in contest_problem: + # short = '[%s](%s)' % (short, contest_problem['url']) + + # m = '%s%s `%s`' % (short, ('. ' + v['name']) if 'name' in v else '', scoreformat(result)) + + # if verdict: + # m += ' ' + md_escape(verdict) + + # if has_change: + # if p_result is not None and v.get('partial'): + # delta = as_number(result) - (as_number(p_result) or 0) + # m += " `%s%s`" % ('+' if delta >= 0 else '', delta) + + # if in_time is None or solution_time_compare(in_time, v) < 0: + # in_time = v + + # if verdict not in ['U']: + # skip_in_message_text = False + + # has_update = True + # if iteration: + # if p_info.get('show_hidden') == key: + # delete_message() + # if not is_hidden: + # p_info.pop('show_hidden') + # if not p_info.get('accepted'): + # if is_accepted: + # m += ' FIRST ACCEPTED' + # has_first_ac = True + # elif is_max_score: + # m += ' MAX SCORE' + # has_max_score = True + # elif is_hidden and not p_info.get('show_hidden'): + # p_info['show_hidden'] = key + # m += ' TRY FIRST AC' + # has_try_first_ac = True + # if args.top and stat.place_as_int and stat.place_as_int <= args.top: + # has_top = True + # p.append(m) + # if is_accepted: + # p_info['accepted'] = True + # if is_max_score: + # p_info['max_score'] = float(result) + # has_hidden = has_hidden or is_hidden + + # prev_place_as_int = standings_key.get('place_as_int') + # place_as_int = stat.place_as_int + # if prev_place_as_int and place_as_int and prev_place_as_int > place_as_int: + # has_update = True + # prev_place = standings_key.get('place') + # place = stat.place + # if has_solving_diff and prev_place: + # place = '%s->%s' % (prev_place, place) + # if args.numbered is not None and re.search(args.numbered, stat.account.key, re.I): + # numbered += 1 + # place = '%s (%s)' % (place, numbered) + + # prefix_msg = '' + # if place is not None: + # prefix_msg += '`%s`. ' % place + # if in_time and 'time' in in_time: + # prefix_msg = '`[%s]` %s' % (in_time['time'], prefix_msg) + + # account_url = reverse('coder:account', kwargs={'key': stat.account.key, 'host': resource.host}) + # account_url = settings.MAIN_HOST_URL_ + md_url(account_url) + # account_msg = '[%s](%s)' % (name, account_url) + # if stat.account.country: + # account_msg = flag.flag(stat.account.country.code) + account_msg + + # suffix_msg = '' + # if has_solving_diff: + # suffix_msg += ' = `%d`' % stat.solving + # if 'penalty' in stat.addition: + # suffix_msg += rf' `[{stat.addition["penalty"]}]`' + # if p: + # suffix_msg += ' (%s)' % ', '.join(p) + # if has_top: + # suffix_msg += f' TOP{args.top}' + + # if has_update or has_first_ac or has_try_first_ac or has_max_score: + # updated = True + + # msg = prefix_msg + account_msg + suffix_msg + # history_msg = prefix_msg.rstrip() + suffix_msg + + # if filtered: + # table.add_row(str(stat.place), str(stat.solving), Markdown(msg), str(stat.pk)) + # if not args.dryrun and (filtered and has_update or has_first_ac or has_try_first_ac or has_max_score): + # if args.with_history: + # delete_message() + # msg = combine_messages(msg, message_text) + # n_attempts = 5 + # delay_on_timeout = 3 + # for it in range(n_attempts): + # try: + # message = bot.send_message(msg=msg, chat_id=tg_chat_id) + # message_id = message.message_id + # break + # except telegram.error.TimedOut as e: + # logger.warning(str(e)) + # except telegram.error.BadRequest as e: + # logger.error(str(e)) + # wait('send message', it * delay_on_timeout) + # if not skip_in_message_text: + # message_text = combine_messages(history_msg, message_text) + + # if args.with_subscriptions: + # has_subscription_update = stat.account.n_subscribers and has_update + # if has_subscription_update: + # subscriptions_filter = ( + # Q(accounts=stat.account) + # & (Q(resource__isnull=True) | Q(resource=resource)) + # & (Q(contest__isnull=True) | Q(contest=contest)) + # ) + # subscriptions = Subscription.objects.filter(subscriptions_filter) + # for subscription in subscriptions: + # subscription.send(message=msg) + + # data = { + # 'solving': stat.solving, + # 'place': stat.place, + # 'place_as_int': stat.place_as_int, + # 'problems': stat.addition.get('problems', {}), + # 'message_id': message_id, + # 'message_text': message_text, + # } + # standings[key] = data + + # console.print(table) + + # if args.dump is not None and (updated or not os.path.exists(args.dump)): + # standings_dump = json.dumps(standings, indent=2) + # with open(args.dump, 'w') as fo: + # fo.write(standings_dump) + + # if iteration: + # is_over = contest.end_time < now or contest.time_percentage >= 1 + # if args.no_parse_statistic or forced_iterations <= 0 and is_over and not has_hidden: + # break + # is_coming = now < contest.standings_start_time + # if is_coming: + # limit = contest.standings_start_time + # else: + # tick = args.delay * 10 if is_over else args.delay + # limit = now + timedelta(seconds=tick) + # wait('parsing iteration', limit) + + # iteration += 1 + # forced_iterations -= 1 diff --git a/src/ranking/management/commands/parse_statistic.py b/src/ranking/management/commands/parse_statistic.py index 122fc58a..e9b04a53 100644 --- a/src/ranking/management/commands/parse_statistic.py +++ b/src/ranking/management/commands/parse_statistic.py @@ -22,7 +22,7 @@ from django.core.management import call_command from django.core.management.base import BaseCommand from django.db import transaction -from django.db.models import Exists, F, OuterRef, Q +from django.db.models import Exists, F, Max, OuterRef, Prefetch, Q from django.utils.timezone import now as timezone_now from django_print_sql import print_sql_decorator @@ -30,17 +30,18 @@ from clist.models import Contest, Problem, Resource from clist.templatetags.extras import (as_number, canonize, get_item, get_number_from_str, get_problem_key, - get_problem_short, is_solved, normalize_field, time_in_seconds, + get_problem_short, is_hidden, is_solved, normalize_field, time_in_seconds, time_in_seconds_format) from clist.views import update_problems, update_writers from logify.models import EventLog, EventStatus from notification.models import NotificationMessage, Subscription +from notification.utils import compose_message_by_problems, send_messages from pyclist.decorators import analyze_db_queries from ranking.management.commands.parse_accounts_infos import rename_account from ranking.management.modules.common import REQ, UNCHANGED, ProxyLimitReached from ranking.management.modules.excepts import ExceptionParseStandings, InitModuleException from ranking.models import Account, AccountRenaming, Module, Stage, Statistics -from ranking.utils import account_update_contest_additions +from ranking.utils import account_update_contest_additions, update_stage from ranking.views import update_standings_socket from true_coders.models import Coder from utils.attrdict import AttrDict @@ -197,10 +198,11 @@ def add_arguments(self, parser): parser.add_argument('--before-date', default=False, help='Update contests that have been updated to date') parser.add_argument('--after-date', default=False, help='Update contests that have been updated after date') parser.add_argument('--with-medals', action='store_true', default=False, help='Contest with medals') - parser.add_argument('--without-fill-coder-problems', action='store_true', default=False) + parser.add_argument('--without-set-coder-problems', action='store_true', default=False) parser.add_argument('--without-calculate-problem-rating', action='store_true', default=False) parser.add_argument('--without-calculate-rating-prediction', action='store_true', default=False) parser.add_argument('--without-stage', action='store_true', default=False, help='Without update stage contests') + parser.add_argument('--without-subscriptions', action='store_true', default=False, help='Without subscriptions') parser.add_argument('--contest-id', '-cid', help='Contest id') parser.add_argument('--no-update-problems', action='store_true', default=False, help='No update problems') parser.add_argument('--is-rated', action='store_true', default=False, help='Contest is rated') @@ -208,6 +210,8 @@ def add_arguments(self, parser): parser.add_argument('--for-account', type=str, help='Events for account') parser.add_argument('--ignore-stage', action='store_true', default=False, help='Ignore stage') parser.add_argument('--disabled', action='store_true', default=False, help='Disabled module only') + parser.add_argument('--without-delete-statistics', action='store_true') + parser.add_argument('--allow-delete-statistics', action='store_true') def parse_statistic( self, @@ -223,7 +227,7 @@ def parse_statistic( random_order=False, no_update_results=False, title_regex=None, - users=None, + specific_users=None, with_stats=True, update_without_new_rating=None, force_problems=False, @@ -231,16 +235,19 @@ def parse_statistic( without_n_statistics=False, contest_id=None, query=None, - without_fill_coder_problems=False, + without_set_coder_problems=False, without_calculate_problem_rating=False, without_calculate_rating_prediction=False, without_stage=False, + without_subscriptions=False, no_update_problems=None, is_rated=None, for_account=None, ignore_stage=None, with_reparse=None, enabled=True, + without_delete_statistics=None, + allow_delete_statistics=None, ): channel_layer_handler = ChannelLayerHandler() formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%b-%d %H:%M:%S') @@ -342,6 +349,7 @@ def parse_statistic( n_statistics_created = 0 n_calculated_rating_prediction = 0 n_calculated_problem_rating = 0 + n_inherited_medals = 0 progress_bar = tqdm(contests) stages_ids = [] @@ -409,7 +417,6 @@ def parse_statistic( exception_error = None user_info_has_rating = {} to_update_socket = contest.is_running() or contest.has_hidden_results or force_socket - to_calculate_problem_rating = False event_log = EventLog.objects.create(name='parse_statistic', related=contest, status=EventStatus.IN_PROGRESS) @@ -418,25 +425,40 @@ def parse_statistic( try: now = timezone_now() is_coming = now < contest.start_time + is_archive_contest = now > contest.end_time + timedelta(days=30) and contest.parsed_time + + with_subscription = not without_subscriptions and not is_archive_contest and with_stats + prefetch_subscribed_coders = Prefetch('coders', queryset=Coder.objects.filter(n_subscribers__gt=0), + to_attr='subscribed_coders') + subscription_top_n = None + subscription_first_ac = None + if with_subscription: + subscriptions = Subscription.enabled.filter( + (Q(resource__isnull=True) | Q(resource=resource)) & + (Q(contest__isnull=True) | Q(contest=contest)) + ) + subscription_top_n = subscriptions.aggregate(n=Max('top_n'))['n'] + subscription_first_ac = subscriptions.filter(with_first_accepted=True).exists() + plugin = resource.plugin.Statistic(contest=contest) with transaction.atomic(): _ = Contest.objects.select_for_update().get(pk=contest.pk) - statistics_users = copy.deepcopy(users) - if resource.has_standings_renamed_account and users: - renamings = AccountRenaming.objects.filter(resource=resource, old_key__in=users) + statistics_users = copy.deepcopy(specific_users) + if resource.has_standings_renamed_account and specific_users: + renamings = AccountRenaming.objects.filter(resource=resource, old_key__in=specific_users) renamings = renamings.values_list('new_key', flat=True) statistics_users.extend(renamings) with REQ: statistics_by_key = {} more_statistics_by_key = {} - statistics_ids = set() + statistics_to_delete = set() has_statistics = False if not no_update_results: statistics = Statistics.objects.filter(contest=contest).select_related('account') - if users: + if specific_users: statistics = statistics.filter(account__key__in=statistics_users) for s in statistics.iterator(): addition = s.addition or {} @@ -449,8 +471,8 @@ def parse_statistic( '_no_update_n_contests': s.skip_in_stats, } has_statistics = True - statistics_ids.add(s.pk) - standings = plugin.get_standings(users=copy.deepcopy(users), + statistics_to_delete.add(s.pk) + standings = plugin.get_standings(users=copy.deepcopy(specific_users), statistics=statistics_by_key, more_statistics=more_statistics_by_key) has_standings_result = bool(standings.get('result')) @@ -465,7 +487,7 @@ def parse_statistic( if has_problem_result: continue if row.get('_no_update_n_contests') and stat['_no_update_n_contests']: - statistics_ids.remove(stat['pk']) + statistics_to_delete.remove(stat['pk']) contest_log_counter['skip_no_update'] += 1 continue row = copy.deepcopy(row) @@ -479,24 +501,32 @@ def parse_statistic( ('place' not in result_row or str(result_row['place']) == str(stat['place'])) ): if row.get('_no_update_n_contests') and stat['_no_update_n_contests']: - statistics_ids.remove(stat['pk']) + statistics_to_delete.remove(stat['pk']) contest_log_counter['skip_no_update'] += 1 result.pop(member) keep_results = standings.pop('keep_results', False) + parsed_percentage = standings.pop('parsed_percentage', None) if keep_results: + reset_place = parsed_percentage and not contest.parsed_percentage + reset_place_ids = set() result = standings.setdefault('result', {}) for member, row in statistics_by_key.items(): if member in result: continue pk = more_statistics_by_key[member]['pk'] - if pk in statistics_ids: - statistics_ids.remove(pk) + if pk in statistics_to_delete: + statistics_to_delete.remove(pk) row = copy.deepcopy(row) row['member'] = member row['_skip_update'] = True contest_log_counter['skip_update'] += 1 result[member] = row + if reset_place: + row.pop('place', None) + reset_place_ids.add(pk) + if reset_place_ids: + Statistics.objects.filter(pk__in=reset_place_ids).update(place=None, place_as_int=None) if get_item(resource, 'info.standings.skip_not_solving'): result = standings.setdefault('result', {}) @@ -509,6 +539,7 @@ def parse_statistic( for key, more_stat in more_statistics_by_key.items(): statistics_by_key[key].update(more_stat) + update_fields = [] for field, attr in ( ('url', 'standings_url'), ('contest_url', 'url'), @@ -522,7 +553,12 @@ def parse_statistic( ): if field in standings and standings[field] != getattr(contest, attr): setattr(contest, attr, standings[field]) - contest.save(update_fields=[attr]) + update_fields.append(attr) + if parsed_percentage is not None: + contest.parsed_percentage = parsed_percentage if parsed_percentage < 100 else None + update_fields.append('parsed_percentage') + if update_fields: + contest.save(update_fields=update_fields) if 'series' in standings: contest.set_series(standings.pop('series')) @@ -611,7 +647,7 @@ def parse_statistic( has_problem_stats = False results = [] - if result or users: + if result or specific_users: fields_set = set() fields_types = defaultdict(set) fields_preceding = defaultdict(set) @@ -652,7 +688,7 @@ def parse_statistic( total_problems_solving = 0 def update_problems_info(): - nonlocal last_activity, total_problems_solving, has_problem_stats + nonlocal last_activity, total_problems_solving, has_problem_stats, has_hidden problems = r.get('problems', {}) @@ -680,7 +716,8 @@ def update_problems_info(): result_str = str(v['result']) is_accepted = result_str.startswith('+') - is_hidden = result_str.startswith('?') + is_hidden_result = '?' in result_str + has_hidden |= is_hidden_result is_score = result_str and result_str[0].isdigit() result_num = as_number(v['result'], force=True) @@ -774,7 +811,7 @@ def update_problems_info(): p['n_accepted'] = p.get('n_accepted', 0) + 1 elif scored and v.get('partial'): p['n_partial'] = p.get('n_partial', 0) + 1 - elif is_hidden: + elif is_hidden_result: p['n_hidden'] = p.get('n_hidden', 0) + 1 has_problem_stats = True @@ -801,9 +838,18 @@ def update_problems_info(): contest.standings_kind = standings_kind contest.save(update_fields=['standings_kind']) + if has_hidden and not contest.has_hidden_results and is_archive_contest: + raise ExceptionParseStandings(f'archive contest = {contest} has hidden results') + if has_hidden != contest.has_hidden_results and 'has_hidden_results' not in standings: + contest.has_hidden_results = has_hidden + contest.save(update_fields=['has_hidden_results']) + if not lazy_fetch_accounts: members = [r['member'] for r in results] - accounts = resource.account_set.filter(key__in=members) + accounts = resource.account_set + if with_subscription: + accounts = accounts.prefetch_related(prefetch_subscribed_coders) + accounts = accounts.filter(key__in=members) accounts = {a.key: a for a in accounts} if contest.set_matched_coders_to_members: @@ -824,7 +870,10 @@ def update_problems_info(): skip_result = bool(r.get('_no_update_n_contests')) if lazy_fetch_accounts: - account, account_created = Account.objects.get_or_create(resource=resource, key=member) + account = Account.objects + if with_subscription: + account = account.prefetch_related(prefetch_subscribed_coders) + account, account_created = account.get_or_create(resource=resource, key=member) elif member not in accounts: account = resource.account_set.create(key=member) accounts[member] = account @@ -885,7 +934,7 @@ def update_account_time(): contest.info.get('_no_update_account_time') or skip_result or contest.end_time > now or - users + specific_users ): return @@ -940,12 +989,12 @@ def update_account_time(): user_info = next(generator) params = user_info.get('contest_addition_update_params', {}) field = user_info.get('contest_addition_update_by') or params.get('by') or 'key' # noqa - updates = user_info.get('contest_addition_update') or params.get('update') or {} # noqa + addition_update = user_info.get('contest_addition_update') or params.get('update') or {} # noqa if not isinstance(field, (list, tuple)): field = [field] user_info_has_rating[division] = False for f in field: - if getattr(contest, f) in updates: + if getattr(contest, f) in addition_update: user_info_has_rating[division] = True break except Exception: @@ -1009,6 +1058,9 @@ def update_account_info(): if account.name != name: account.name = name update_fields.append('name') + if 'deleted' in account_info: + account.deleted = account_info.pop('deleted') + update_fields.append('deleted') account.info.update(account_info) account.save(update_fields=update_fields) @@ -1079,12 +1131,15 @@ def update_problems_first_ac(): if not r.get('division'): continue p = p.setdefault(r['division'], {}) - p = p.setdefault(k, {}) - if 'first_ac' not in p: + if k not in p: continue - - if member in p['first_ac']['accounts']: + p = p.get(k, {}) + if 'first_ac' in p and member in p['first_ac']['accounts']: v['first_ac'] = True + if p.get('n_teams') and not p.get('n_accepted') and is_hidden(v): + v['try_first_ac'] = True + else: + v.pop('try_first_ac', None) def get_addition(): place = r.pop('place', None) @@ -1163,11 +1218,14 @@ def get_addition(): return defaults, addition, try_calculate_time def update_after_update_or_create(statistic, created, addition, try_calculate_time): + updates = {} + updates['has_first_ac'] = False + updated_problems = updates.setdefault('problems', []) problems = r.get('problems', {}) if not created: nonlocal calculate_time - statistics_ids.discard(statistic.pk) + statistics_to_delete.discard(statistic.pk) if contest_timeline and try_calculate_time: p_problems = statistic.addition.get('problems', {}) @@ -1189,32 +1247,38 @@ def update_after_update_or_create(statistic, created, addition, try_calculate_ti else: v['time'] = time - for p in problems.values(): - p_result = p.get('result', '') - if isinstance(p_result, str) and '?' in p_result: - nonlocal has_hidden - has_hidden = True + if force_socket: + updated_statistics_ids.append(statistic.pk) previous_problems = stat.get('problems', {}) for k, problem in problems.items(): - if statistic.pk in updated_statistics_ids: - break + verdict = problem.get('verdict') previous_problem = previous_problems.get(k, {}) - if ( - problem.get('result') != previous_problem.get('result') or - problem.get('time') != previous_problem.get('time') or - force_socket - ): - contest_log_counter['updated_statistics_problem'] += 1 - updated_statistics_ids.append(statistic.pk) - - if str(statistic.place) != str(stat.get('place')): - contest_log_counter['updated_statistics_place'] += 1 + 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 + if same_result and same_verdict: + continue + if not same_result and problem.get('first_ac'): + updates['has_first_ac'] = True + updated_problems.append(k) + if statistic.pk in updated_statistics_ids: + continue + contest_log_counter['updated_statistics_problem'] += 1 updated_statistics_ids.append(statistic.pk) + for field, lhs, rhs in ( + ('place', statistic.place, stat.get('place')), + ('score', statistic.solving, stat.get('score')), + ): + if lhs != rhs and statistic.pk not in updated_statistics_ids: + contest_log_counter[f'updated_statistics_{field}'] += 1 + updated_statistics_ids.append(statistic.pk) + if try_calculate_time: statistic.addition = addition statistic.save() + return updates @suppress_db_logging_context() def update_submissions(statistic, result_submissions): @@ -1226,7 +1290,7 @@ def update_submissions(statistic, result_submissions): contest.save(update_fields=['has_submissions']) statistic_problems = statistic.addition.get('problems', {}) - updated_problems = False + updated_submission_problems = False submission_ids = set() for result_submission in result_submissions: language = Language.cached_get(result_submission['language']) @@ -1337,10 +1401,10 @@ def update_submissions(statistic, result_submissions): for field, value in fields_values: if value is not None and statistic_problem.get(field) != value: statistic_problem[field] = value - updated_problems = True + updated_submission_problems = True if update_problems_values(statistic.addition): - updated_problems = True - if updated_problems: + updated_submission_problems = True + if updated_submission_problems: statistic.save(update_fields=['addition']) if os.environ.get('DELETE_SUBMISSIONS'): extra = Submission.objects.filter(statistic=statistic) @@ -1401,12 +1465,60 @@ def set_matched_coders_to_members(statistic): if to_update: statistic.save(update_fields=['addition']) + def process_subscriptions(statistic, updates): + if skip_result or not with_subscription: + return + if addition.get('_skip_subscription') or not updates['problems']: + return + + with_top_n = (subscription_top_n and statistic.place_as_int and + statistic.place_as_int <= subscription_top_n) + with_first_ac = updates['has_first_ac'] and subscription_first_ac + subscribed_coders = getattr(account, 'subscribed_coders', None) + if ( + not account.n_subscribers and + not subscribed_coders and + not with_top_n and + not with_first_ac + ): + return + + subscription_message = compose_message_by_problems( + updates['problems'], + statistic=statistic, + previous_addition=stat, + contest_or_problems=standings_problems, + ) + + subscriptions_filter = Q() + if account.n_subscribers: + subscriptions_filter |= Q(accounts=account) + if subscribed_coders: + subscriptions_filter |= Q(coders__in=subscribed_coders) + if with_top_n: + subscriptions_filter |= Q(top_n__gte=statistic.place_as_int) + if with_first_ac: + subscriptions_filter |= Q(with_first_accepted=True) + subscriptions_filter = ( + subscriptions_filter + & (Q(resource__isnull=True) | Q(resource=resource)) + & (Q(contest__isnull=True) | Q(contest=contest)) + ) + subscriptions = Subscription.enabled.filter(subscriptions_filter) + already_sent = set() + for subscription in subscriptions: + if subscription.notification_key in already_sent: + continue + already_sent.add(subscription.notification_key) + subscription.send(message=subscription_message, contest=contest) + contest_log_counter['statistics_subscription'] += 1 + update_addition_fields() update_account_time() update_account_info() update_stat_info() update_problems_values(r) - if not users: + if not specific_users: update_problems_first_ac() defaults, addition, try_calculate_time = get_addition() @@ -1421,7 +1533,8 @@ def set_matched_coders_to_members(statistic): if statistic_created: contest_log_counter['statistics_created'] += 1 - update_after_update_or_create(statistic, statistic_created, addition, try_calculate_time) + updates = update_after_update_or_create(statistic, statistic_created, addition, + try_calculate_time) update_submissions(statistic, result_submissions) @@ -1431,33 +1544,9 @@ def set_matched_coders_to_members(statistic): if contest.set_matched_coders_to_members: set_matched_coders_to_members(statistic) - has_subscription_update = ( - account.is_subscribed - and not skip_result - and not addition.get('_skip_subscription') - and ( - statistic_created - or stat.get('score', 0) < statistic.solving + EPS - ) - ) - if has_subscription_update: - subscriptions_filter = Q(resource__isnull=True) | Q(resource=resource) - subscriptions_filter &= Q(contest__isnull=True) | Q(contest=contest) - subscriptions_filter &= ( - Q(account=account) - | Q(coder_chat__coders__account=account) - | Q(coder_list__values__account=account) - | Q(coder_list__values__coder__account=account) - ) - subscriptions = Subscription.objects.filter(subscriptions_filter) - for subscription in subscriptions: - pass - - if not users: - if has_hidden != contest.has_hidden_results and 'has_hidden_results' not in standings: - contest.has_hidden_results = has_hidden - contest.save(update_fields=['has_hidden_results']) + process_subscriptions(statistic, updates) + if not specific_users: for field, values in problems_values.items(): if values: field = field.strip('_') @@ -1478,11 +1567,14 @@ def set_matched_coders_to_members(statistic): fields.remove(rating_field) fields.append(rating_field) - if statistics_ids: - first = Statistics.objects.filter(pk__in=statistics_ids).first() + if statistics_to_delete and not without_delete_statistics: + if is_archive_contest and not allow_delete_statistics: + raise ExceptionParseStandings(f'archive contest = {contest} ' + f'has statistics to delete') + first = Statistics.objects.filter(pk__in=statistics_to_delete).first() if first: self.logger.info(f'First deleted: {first}, account = {first.account}') - delete_info = Statistics.objects.filter(pk__in=statistics_ids).delete() + delete_info = Statistics.objects.filter(pk__in=statistics_to_delete).delete() self.logger.info(f'Delete info: {delete_info}') progress_bar.set_postfix(deleted=str(delete_info)) n_deleted, _ = delete_info @@ -1577,20 +1669,16 @@ def set_matched_coders_to_members(statistic): if to_update_socket: update_standings_socket(contest, updated_statistics_ids) - to_calculate_problem_rating = ( - resource.has_problem_rating and - contest.end_time < now and - not contest.has_hidden_results and - not standings.get('timing_statistic_delta') - ) - progress_bar.set_postfix(n_fields=len(fields), n_updated=len(updated_statistics_ids)) else: if standings_problems is not None and standings_problems and not no_update_problems: standings_problems = plugin.merge_dict(standings_problems, contest.info.get('problems')) - update_problems(contest, problems=standings_problems, force=force_problems or not users) + update_problems(contest, problems=standings_problems, force=force_problems) - if not users: + if contest_log_counter.get('statistics_subscription'): + send_messages() + + if not specific_users: timing_delta = None if contest.full_duration < resource.module.long_contest_idle: timing_delta = standings.get('timing_statistic_delta', timing_delta) @@ -1598,7 +1686,7 @@ def set_matched_coders_to_members(statistic): timing_delta = parse_info.get('timing_statistic_delta', timing_delta) if updated_statistics_ids and contest.end_time < now < contest.end_time + timedelta(hours=1): timing_delta = timing_delta or timedelta(minutes=20) - if has_hidden and contest.end_time < now < contest.end_time + timedelta(days=1): + if contest.has_hidden_results and contest.end_time < now < contest.end_time + timedelta(days=1): timing_delta = timing_delta or timedelta(minutes=60) if wait_rating and not has_statistics and results and 'days' in wait_rating: timing_delta = timing_delta or timedelta(days=wait_rating['days']) / 10 @@ -1613,7 +1701,6 @@ def set_matched_coders_to_members(statistic): contest.save() else: without_calculate_rating_prediction = True - to_calculate_problem_rating = False action = standings.get('action') if action is not None: @@ -1635,31 +1722,53 @@ def set_matched_coders_to_members(statistic): contest.url = args[0] contest.save() - if not contest.statistics_update_required: - if resource.rating_prediction and not without_calculate_rating_prediction and contest.pk: + if not contest.statistics_update_required and contest.pk: + if resource.rating_prediction and not without_calculate_rating_prediction and not specific_users: self.logger.info(f'Calculate rating prediction for contest = {contest}') call_command('calculate_rating_prediction', contest=contest.pk) contest.refresh_from_db() n_calculated_rating_prediction += 1 - if to_calculate_problem_rating and not without_calculate_problem_rating and contest.pk: + if ( + resource.has_problem_rating and contest.is_finalized() and not without_calculate_problem_rating + and not specific_users + ): self.logger.info(f'Calculate problem rating for contest = {contest}') call_command('calculate_problem_rating', contest=contest.pk, force=force_problems) contest.refresh_from_db() n_calculated_problem_rating += 1 - if not without_fill_coder_problems and contest.pk: - if users: - users_qs = Account.objects.filter(resource=resource, key__in=users) + if contest.is_finalized(): + related_contests = [contest] + for related in contest.related_set.select_related('resource', 'related').all(): + related_contests.append(related) + for orig_contest in related_contests: + related_contest = orig_contest.related + if not related_contest: + continue + if not orig_contest.resource.has_inherit_medals_to_related: + continue + if not orig_contest.is_finalized(): + continue + if not orig_contest.with_medals or related_contest.with_medals: + continue + self.logger.info(f'Inherit medals to related contest = {related_contest}' + f', from contest = {orig_contest}') + related_contest.inherit_medals(orig_contest) + n_inherited_medals += 1 + + if not without_set_coder_problems: + if specific_users: + users_qs = Account.objects.filter(resource=resource, key__in=specific_users) users_coders = Coder.objects.filter(Exists(users_qs.filter(pk=OuterRef('account')))) users_coders = users_coders.values_list('username', flat=True) if users_coders: - self.logger.info(f'Fill coder problems for contest = {contest}' + self.logger.info(f'Set coder problems for contest = {contest}' f', coders = {users_coders}') - call_command('fill_coder_problems', contest=contest.pk, coders=users_coders) + call_command('set_coder_problems', contest=contest.pk, coders=users_coders) else: - self.logger.info(f'Fill coder problems for contest = {contest}') - call_command('fill_coder_problems', contest=contest.pk) + self.logger.info(f'Set coder problems for contest = {contest}') + call_command('set_coder_problems', contest=contest.pk) if has_standings_result: count += 1 @@ -1678,7 +1787,7 @@ def set_matched_coders_to_members(statistic): if stop_on_error: print(colored_format_exc()) - if not users: + if not specific_users: module = resource.module delay = module.max_delay_after_end if contest.statistics_update_required or contest.end_time < now or not module.long_contest_divider: @@ -1715,7 +1824,7 @@ def set_matched_coders_to_members(statistic): if contest_log_counter: messages += [f'log_counter = {dict(contest_log_counter)}'] event_log.update_status(status=status, message='\n\n'.join(messages)) - if users: + if specific_users: event_log.delete() self.logger.info(f'log_counter = {dict(contest_log_counter)}') if error_counter: @@ -1723,12 +1832,12 @@ def set_matched_coders_to_members(statistic): channel_layer_handler.send_done(done=parsed) @lru_cache(maxsize=None) - def update_stage(stage): + def advanced_update_stage(stage): exclude_stages = stage.score_params.get('advances', {}).get('exclude_stages', []) ret = stage.pk in stages_ids if exclude_stages: for s in Stage.objects.filter(pk__in=exclude_stages): - if update_stage(s): + if advanced_update_stage(s): ret = True if ret: try: @@ -1736,7 +1845,7 @@ def update_stage(stage): related=stage, status=EventStatus.IN_PROGRESS) channel_layer_handler.set_contest(stage.contest) - stage.update() + update_stage(stage) channel_layer_handler.send_done(done=True) event_log.update_status(EventStatus.COMPLETED) except Exception as e: @@ -1750,7 +1859,7 @@ def update_stage(stage): if without_stage: self.logger.info(f'Skip stage: {stage}') else: - update_stage(stage) + advanced_update_stage(stage) if resource_event_log: resource_event_log.delete() @@ -1761,6 +1870,8 @@ def update_stage(stage): self.logger.info(f'Number of calculate rating problem: {n_calculated_problem_rating} of {total}') if n_calculated_rating_prediction: self.logger.info(f'Number of calculate rating prediction: {n_calculated_rating_prediction} of {total}') + if n_inherited_medals: + self.logger.info(f'Number of inherited medals: {n_inherited_medals} of {total}') self.logger.info(f'Number of updated account time: {n_account_time_update}') self.logger.info(f'Number of created statistics: {n_statistics_created} of {n_statistics_total}') @@ -1823,7 +1934,7 @@ def handle(self, *args, **options): before_date=args.before_date, after_date=args.after_date, title_regex=args.event, - users=args.users, + specific_users=args.users, with_stats=not args.no_stats, update_without_new_rating=args.update_without_new_rating, force_problems=args.force_problems, @@ -1831,14 +1942,17 @@ def handle(self, *args, **options): without_n_statistics=args.without_n_statistics, contest_id=args.contest_id, query=args.query, - without_fill_coder_problems=args.without_fill_coder_problems, + without_set_coder_problems=args.without_set_coder_problems, without_calculate_problem_rating=args.without_calculate_problem_rating, without_calculate_rating_prediction=args.without_calculate_rating_prediction, without_stage=args.without_stage, + without_subscriptions=args.without_subscriptions, no_update_problems=args.no_update_problems, is_rated=args.is_rated, for_account=args.for_account, ignore_stage=args.ignore_stage, with_reparse=args.reparse, enabled=not args.disabled, + without_delete_statistics=args.without_delete_statistics, + allow_delete_statistics=args.allow_delete_statistics, ) diff --git a/src/ranking/management/modules/acm_bsu.py b/src/ranking/management/modules/acm_bsu.py index aa406ec6..9b7a169a 100644 --- a/src/ranking/management/modules/acm_bsu.py +++ b/src/ranking/management/modules/acm_bsu.py @@ -4,7 +4,6 @@ import collections import re from datetime import timedelta -from pprint import pprint from django.utils.timezone import now @@ -34,7 +33,10 @@ def get_standings(self, users=None, statistics=None, **kwargs): if e.code == 403: raise ExceptionParseStandings('Forbidden') raise e - table = parsed_table.ParsedTable(html=page, xpath="//table[@class='ir-contest-standings']//tr") + try: + table = parsed_table.ParsedTable(html=page, xpath="//table[@class='ir-contest-standings']//tr") + except StopIteration: + raise ExceptionParseStandings('Not found table with standings') problems_info = collections.OrderedDict() has_plus = False for r in table: @@ -137,5 +139,3 @@ def get_standings(self, users=None, statistics=None, **kwargs): 'problems_time_format': '{H}:{m:02d}', } return standings - - diff --git a/src/ranking/management/modules/algorithm_yandex.py b/src/ranking/management/modules/algorithm_yandex.py index 09215a84..b627757c 100644 --- a/src/ranking/management/modules/algorithm_yandex.py +++ b/src/ranking/management/modules/algorithm_yandex.py @@ -14,4 +14,6 @@ def get_standings(self, *args, **kwargs): if 'medals' not in standings.get('options', {}) and 'medals' not in self.info.get('standings', {}): options = standings.setdefault('options', {}) options['medals'] = [{'name': name, 'count': 1} for name in ('gold', 'silver', 'bronze')] + if re.search(r'\balgorithm\b', self.name, re.I): + standings['series'] = 'yandex-algorithm' return standings diff --git a/src/ranking/management/modules/algotester.py b/src/ranking/management/modules/algotester.py index 71d55209..e95ccd74 100644 --- a/src/ranking/management/modules/algotester.py +++ b/src/ranking/management/modules/algotester.py @@ -80,6 +80,8 @@ def proccess_data(data): for row in data['rows']: contestant = row.pop('Contestant') url = contestant.pop('Url') + if url is None: + continue match = re.search('(?P<type>Account|Team)/Display/(?P<key>[0-9]+)', url) handle = match.group('type').lower() + match.group('key') r = result.setdefault(handle, OrderedDict()) diff --git a/src/ranking/management/modules/atcoder.py b/src/ranking/management/modules/atcoder.py index 06ff0d67..9c1d4c32 100644 --- a/src/ranking/management/modules/atcoder.py +++ b/src/ranking/management/modules/atcoder.py @@ -403,10 +403,10 @@ def get_problem_full_score(info): if users is not None and handle not in users: continue r = result.setdefault(handle, collections.OrderedDict()) + account_info = r.setdefault('info', {}) r['member'] = handle - if row.pop('UserIsDeleted', None): - r['action'] = 'delete' - continue + if (v := row.pop('UserIsDeleted', None)) is not None: + account_info['deleted'] = v r['place'] = row.pop('Rank') total_result = row.pop('TotalResult') @@ -461,7 +461,7 @@ def get_problem_full_score(info): row['AtcoderRank'] = row.pop('AtCoderRank') rating = row.pop('Rating', None) if rating is not None: - r['info'] = {'rating': rating} + account_info['rating'] = rating old_rating = row.pop('OldRating', None) for k, v in sorted(row.items()): @@ -495,11 +495,10 @@ def get_problem_full_score(info): if users is not None and handle not in users: continue r = result.setdefault(handle, collections.OrderedDict()) + account_info = r.setdefault('info', {}) r['member'] = handle - if row.pop('UserIsDeleted', None): - r['action'] = 'delete' - continue - + if (v := row.pop('UserIsDeleted', None)) is not None: + account_info['deleted'] = v r['country'] = row.pop('Country') if 'UserName' in row: r['name'] = row.pop('UserName') @@ -688,7 +687,7 @@ def get_source_code(contest, problem): filepath_proxies=filepath_proxies, connect=lambda req: req.get(problem['url']), ) as req: - _, page = req.proxer.get_connect_ret() + page = req.proxer.get_connect_ret() match = re.search('<pre[^>]*id="submission-code"[^>]*>(?P<source>[^<]*)</pre>', page) if not match: diff --git a/src/ranking/management/modules/codechef.py b/src/ranking/management/modules/codechef.py index b2897fc2..1bc41185 100644 --- a/src/ranking/management/modules/codechef.py +++ b/src/ranking/management/modules/codechef.py @@ -215,7 +215,7 @@ def get_standings(self, users=None, statistics=None, **kwargs): handle = d.pop('user_handle') d.pop('html_handle', None) problems_status = d.pop('problems_status') - if d['score'] < 1e-9 and not problems_status: + if handle is None or d['score'] < 1e-9 and not problems_status: LOG.warning(f'Skip handle = {handle}: {d}') continue row = result.setdefault(handle, OrderedDict()) diff --git a/src/ranking/management/modules/codeforces.py b/src/ranking/management/modules/codeforces.py index af6132cd..ee159db5 100644 --- a/src/ranking/management/modules/codeforces.py +++ b/src/ranking/management/modules/codeforces.py @@ -23,7 +23,7 @@ API_KEYS = conf.CODEFORCES_API_KEYS DEFAULT_API_KEY = API_KEYS[API_KEYS['__default__']] -SUBDOMAIN = '' +SUBDOMAIN = 'mirror.' def api_query( @@ -85,7 +85,7 @@ def api_query( continue ret = {'status': str(e)} break - + ret.setdefault('status', 'EMPTY') return ret @@ -488,9 +488,9 @@ def parse_points_info(points_info): if not users: data = api_query(method='contest.ratingChanges', params=params, api_key=self.api_key) - if data.get('status') not in ['OK', 'FAILED']: + if data['status'] not in ['OK', 'FAILED']: LOG.warning(f'Missing rating changes = {data}') - if data and data.get('status') == 'OK': + if data and data['status'] == 'OK': for row in data['result']: if str(row.pop('contestId')) != self.key: continue @@ -525,7 +525,7 @@ def parse_points_info(points_info): submissions = [] for params in array_params: data = api_query('contest.status', params=params, api_key=self.api_key) - if data.get('status') not in ['OK', 'FAILED']: + if data['status'] not in ['OK', 'FAILED']: raise ExceptionParseStandings(data) if data['status'] == 'OK': submissions.extend(data.pop('result')) @@ -647,7 +647,7 @@ def get_users_infos(users, resource=None, accounts=None, pbar=None): and (match := re.search('handles: User with handle (?P<handle>.*) not found', data['comment'])) ): handle = match.group('handle') - location = REQ.geturl(f'https://codeforces.com/profile/{handle}') + location = REQ.geturl(f'https://{SUBDOMAIN}codeforces.com/profile/{handle}') index = users.index(handle) if urlparse(location).path.rstrip('/'): target = location.rstrip('/').split('/')[-1] diff --git a/src/ranking/management/modules/common/__init__.py b/src/ranking/management/modules/common/__init__.py index c7ebdf06..600275b7 100644 --- a/src/ranking/management/modules/common/__init__.py +++ b/src/ranking/management/modules/common/__init__.py @@ -17,7 +17,10 @@ def create_requester(): - req = requester(cookie_filename='sharedfiles/cookies.txt') + req = requester( + cookie_filename='sharedfiles/cookies.txt', + proxy_filepath='sharedfiles/proxy', + ) req.caching = 'REQUESTER_CACHING' in os.environ req.time_out = 45 req.debug_output = 'REQUESTER_DEBUG' in os.environ diff --git a/src/ranking/management/modules/cphof.py b/src/ranking/management/modules/cphof.py index ec0a6f11..9b198522 100644 --- a/src/ranking/management/modules/cphof.py +++ b/src/ranking/management/modules/cphof.py @@ -105,7 +105,7 @@ def find_related(statistics): ignore_n_statistics = False ignore_title = None for mapping in host_mapping: - if re.search(mapping['regex'], host): + if re.search(mapping['regex'], host, re.IGNORECASE): host = mapping['host'] ignore_title = mapping.get('ignore_title') ignore_n_statistics = mapping.get('ignore_n_statistics', ignore_n_statistics) diff --git a/src/ranking/management/modules/facebook.py b/src/ranking/management/modules/facebook.py index 53f1fc3d..911a8a60 100644 --- a/src/ranking/management/modules/facebook.py +++ b/src/ranking/management/modules/facebook.py @@ -5,46 +5,52 @@ import re from collections import OrderedDict from concurrent.futures import ThreadPoolExecutor as PoolExecutor -from datetime import datetime +from datetime import datetime, timedelta +from threading import Lock from time import sleep import pytz import requests import tqdm +from django.utils.timezone import now +from ratelimiter import RateLimiter # from ranking.management.modules import conf from ranking.management.modules.common import LOG, REQ, BaseModule from ranking.management.modules.excepts import ExceptionParseStandings +def is_rate_limit_error(e): + return bool(re.search('rate limit exceeded', str(e), re.I)) + + class Statistic(BaseModule): API_GRAPH_URL_ = 'https://www.facebook.com/api/graphql/' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - user_agent = 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko' - self.headers = {'User-Agent': user_agent} - # facebook_page = REQ.get('https://facebook.com/', headers=self.headers) # noqa: F841 - # form = REQ.form(action='/login/') - # if form: - # form['post'].pop('sign_up', None) - # data = { - # 'email': conf.FACEBOOK_USERNAME, - # 'pass': conf.FACEBOOK_PASSWORD, - # } - # signin_page = REQ.submit_form(data=data, form=form, headers=self.headers) # noqa: F841 - # form = REQ.form(action='/login/') - # if form and 'validate-password' in form['url']: - # REQ.submit_form(data=data, form=form, headers=self.headers) + with open('sharedfiles/resource/facebook/headers.json') as file: + self.headers = json.load(file) def get_standings(self, users=None, statistics=None, **kwargs): + n_proxies = 50 + req = REQ.duplicate(cookie_filename='sharedfiles/resource/facebook/cookies.txt') + is_final = bool(re.search(r'\bfinals?\b', self.name, re.IGNORECASE)) - page = REQ.get(self.standings_url, headers=self.headers) + page = req.get(self.standings_url, headers=self.headers) matches = re.finditer(r'\["(?P<name>[^"]*)",\[\],{"token":"(?P<token>[^"]*)"', page) tokens = {} for match in matches: tokens[match.group('name').lower()] = match.group('token') + req = req.with_proxy( + time_limit=10, + n_limit=n_proxies, + filepath_proxies='sharedfiles/resource/facebook/proxies', + attributes={'n_attempts': n_proxies}, + ) + lock = Lock() + def query(name, variables): params = { 'fb_dtsg': tokens.get('dtsginitialdata', ''), @@ -56,10 +62,14 @@ def query(name, variables): 'doc_id': self.info['_scoreboard_ids'][name], } - n_attempts = 3 + n_attempts = 1 + n_rate_limit_exceeded = 3 seen = set() - for idx in range(n_attempts): - ret = REQ.get( + attempt = 0 + while True: + attempt += 1 + + ret = req.get( self.API_GRAPH_URL_, post=params, headers={'Accept-Language': 'en-US,en;q=1.0', **self.headers}, @@ -78,19 +88,28 @@ def query(name, variables): except Exception as e: msg = f'Exception on query {name} = {e}' - is_last = idx + 1 == n_attempts - if is_last: - raise ExceptionParseStandings(msg) if msg not in seen: LOG.warning(msg) seen.add(msg) - sleep(idx) - variables = {'id': self.key, 'force_limited_data': False, 'show_all_submissions': False} + if is_rate_limit_error(msg): + req.proxy_fail(force=True) + if n_rate_limit_exceeded and is_rate_limit_error(msg): + n_rate_limit_exceeded -= 1 + elif n_attempts: + n_attempts -= 1 + else: + raise ExceptionParseStandings(msg) + + with lock: + sleep(attempt) + + variables = {'id': self.key, 'force_limited_data': False, 'show_all_submissions': False, + 'should_include_scoreboard': True} scoreboard_data = query('CodingCompetitionsContestScoreboardQuery', variables) def get_advance(): - advancement = scoreboard_data['data']['contest'].get('advancement_requirement_text') + advancement = contest_data.get('advancement_requirement_text') if advancement: match = re.search('top (?P<place>[0-9]+) contestants', advancement) if match: @@ -107,9 +126,9 @@ def get_advance(): threshold = int(match.group('count')) return {'filter': [{'threshold': threshold, 'operator': 'ge', 'field': '_n_solved'}]} - advancement = scoreboard_data['data']['contest'].get('advancement_mode') + advancement = contest_data.get('advancement_mode') if advancement: - threshold = scoreboard_data['data']['contest']['advancement_value'] + threshold = contest_data['advancement_value'] if advancement == 'RANK': return {'filter': [{'threshold': threshold, 'operator': 'le', 'field': 'place'}]} if advancement == 'SCORE': @@ -119,8 +138,15 @@ def get_advance(): return {} + def get_contest(scoreboard_data): + data = scoreboard_data['data'] + for k in 'contest', 'fetch__CodingContest': + if k in data: + return data[k] + problems_info = OrderedDict() - for problem_set in scoreboard_data['data']['contest']['ordered_problem_sets']: + contest_data = get_contest(scoreboard_data) + for problem_set in contest_data['ordered_problem_sets']: for problem in problem_set['ordered_problems_with_display_indices']: code = str(problem['problem']['id']) info = { @@ -133,37 +159,55 @@ def get_advance(): problems_info[info['code']] = info limit = 50 - total = scoreboard_data['data']['contest']['entrant_performance_summaries']['count'] + total = contest_data['entrant_performance_summaries']['count'] + info_paging_offset = self.info.get('_paging_offset', 0) if statistics else 0 + paging_offset = info_paging_offset + parsed_percentage = None has_hidden = False result = OrderedDict() if users or users is None: has_users_filter = bool(users) - users = set(users) if users else None + if has_users_filter: + users = set(users) + paging_offset = 0 + with PoolExecutor(max_workers=3) as executor: stop = False + @RateLimiter(max_calls=1, period=1) def fetch_page(page): if stop: return - data = query('CCEScoreboardQuery', { - 'id': self.key, - 'start': page * limit, - 'count': limit, - 'friends_only': False, - 'force_limited_data': False, - 'country_filter': None, - 'show_all_submissions': False, - 'substring_filter': '', - }) + try: + data = query('CCEScoreboardQuery', { + 'id': self.key, + 'start': page * limit, + 'count': limit, + 'friends_only': False, + 'force_limited_data': False, + 'country_filter': None, + 'show_all_submissions': False, + 'substring_filter': '', + }) + except Exception as e: + if not is_rate_limit_error(e): + LOG.error(f'Fetch page exception = {e}') + return return data n_page = (total + limit - 1) // limit - for data in tqdm.tqdm(executor.map(fetch_page, range(n_page)), total=n_page, desc='paging'): + pages = list(range(paging_offset, n_page)) + tqdm_iterator = tqdm.tqdm(executor.map(fetch_page, pages), total=len(pages), desc='paging') + process_pages = set() + for page, data in zip(pages, tqdm_iterator): + if data is None: + stop = True + break if not data: continue - - for row in data['data']['contest']['entrant_performance_summaries']['nodes']: + process_pages.add(page) + for row in get_contest(data)['entrant_performance_summaries']['nodes']: row.update(row.pop('entrant')) handle = row.pop('id') if has_users_filter and handle not in users: @@ -237,13 +281,30 @@ def fetch_page(page): if not problems and not is_final: result.pop(handle) continue + while paging_offset in process_pages: + paging_offset += 1 + parsed_percentage = paging_offset * 100. / n_page if n_page else None + if paging_offset >= n_page: + paging_offset = 0 + + req.__exit__() standings = { 'result': result, 'problems': list(problems_info.values()), 'advance': get_advance(), 'has_hidden': has_hidden, + 'keep_results': info_paging_offset or paging_offset, + 'info_fields': ['_paging_offset'], } + if not has_users_filter: + standings['_paging_offset'] = paging_offset + standings['parsed_percentage'] = parsed_percentage + if ( + parsed_percentage and parsed_percentage < 100 and + self.end_time < now() < self.end_time + timedelta(days=1) + ): + standings['timing_statistic_delta'] = timedelta(minutes=15) if is_final: standings['series'] = 'FHC' diff --git a/src/ranking/management/modules/icpc_baylor.py b/src/ranking/management/modules/icpc_baylor.py index 22439719..7e866bb7 100644 --- a/src/ranking/management/modules/icpc_baylor.py +++ b/src/ranking/management/modules/icpc_baylor.py @@ -48,8 +48,8 @@ def names_iou(name1, name2): iou = max(list_iou, str_iou) n = min(len(canonized_name1), len(canonized_name2)) - prefix_iou = list_string_iou(canonized_name1[:n], canonized_name2[:n]) - suffix_iou = list_string_iou(canonized_name1[-n:], canonized_name2[-n:]) + prefix_iou = list_string_iou(canonized_name1[:n], canonized_name2[:n]) * 0.99 + suffix_iou = list_string_iou(canonized_name1[-n:], canonized_name2[-n:]) * 0.95 iou = max(iou, prefix_iou, suffix_iou) return iou @@ -433,7 +433,7 @@ def get(key, index): if is_icpc_api_standings_url: page = re.sub(r'</table>\s*<table>\s*(<tr[^>]*>\s*<t[^>]*>)', r'\1', page, flags=re.I) - regex = '''(?:<table[^>]*(?:id=["']standings|class=["']scoreboard)[^>]*>|"content":"[^"]*<table[^>]*>|<table[^>]*class="[^"]*(?:table[^"]*){3}"[^>]*>).*?</table>''' # noqa + regex = '''(?:<table[^>]*(?:id=["']standings|class=["'][^"']*scoreboard)[^>]*>|"content":"[^"]*<table[^>]*>|<table[^>]*class="[^"]*(?:table[^"]*){3}"[^>]*>).*?</table>''' # noqa match = re.search(regex, page, re.DOTALL) if match: html_table = match.group(0) @@ -489,9 +489,9 @@ def get(key, index): logo = urljoin(standings_url, src) row.setdefault('info', {}).setdefault('logo', logo) for el in vs: - region = el.column.node.xpath('.//*[@class="badge badge-warning"]') + region = el.column.node.xpath('.//*[contains(@class, "badge")]') if region: - region = ''.join([s.strip() for s in region[0].xpath('text()')]) + region = ' '.join([s.strip() for s in region[0].xpath('text()')]) if region: if is_regional: if region.lower() == 'ineligible': @@ -586,6 +586,25 @@ def get(key, index): region = ''.join([s.strip() for s in prv.xpath('text()')]) row['region'] = region + regex = '''<table[^>]*class="[^"]*table-sm[^"]*"[^>]*>.*?</table>''' + tables = re.findall(regex, page, re.DOTALL) + for table in tables: + table = parsed_table.ParsedTable(table) + values = [] + for r in table: + for k, v in r.items(): + values.append(k) + values.append(v.value) + if len(values) > 4: + break + if len(values) == 4 and values[:2] == ['Name', 'Category']: + name, region = values[2:] + member = f'{name} {season}' + if member not in result: + logger.warning(f'Not found member = {member} for region = {region}') + continue + result[member]['region'] = region + for team, row in result.items(): if statistics and team in statistics: row.pop('info', None) @@ -603,6 +622,8 @@ def get(key, index): if not stat: continue for k, v in stat.items(): + if k in ['medal']: + continue if k not in row: hidden_fields.add(k) row[k] = v @@ -692,7 +713,6 @@ def add_region(name, region, team): best_region = region best_team = team best_name = row['name'] - print(name) if best_name is None: break logger.info(f'max iou = {max_iou:.3f}, best_name = {best_name}') @@ -817,11 +837,57 @@ def add_team(university, handles): logger_func = logger.info add_team(best_name, handles) logger_func(f'best_iou = {best_iou:.3f}, best_name = {best_name}, university = {university}') + if 'use_zibada_list' in self.info and os.environ.get('USE_ZIBADA_LIST'): + zibada_info = self.info['use_zibada_list'] + data = REQ.get(zibada_info['url']) + data = re.sub(r'^[\w\d]+\s*=\s*', '', data) + data = json.loads(data) + for team in data: + name, handles = team + name = html.unescape(name) + member = f'{name} {season}' + if member not in result: + logger.warning(f'Not found team = {member}') + continue + row = result[member] + members = row.setdefault('_members', []) + accounts = {m['account'] for m in members} + for handle in handles: + handle = handle.split(':')[-1] + if handle in accounts: + continue + members.append({ + 'account': handle, + 'resource': zibada_info['resource'], + 'without_country': True, + }) + if len(members) > 3: + logger.warning(f'Too many members: {members}, team = {member}') event_feed = get_item(self.info, 'standings._event_feed') if event_feed: self._parse_event_feed(event_feed, result, problems_info) + info_external_urls = get_item(self.info, 'standings.external_urls', []) + info_external_urls_set = set(i['url'] for i in info_external_urls) + + external_urls = [ + f'https://icpc.kimden.online/wf/{year}/', + f'https://zibada.guru/finals/{year}/', + ] + for external_url in external_urls: + if external_url in info_external_urls_set: + continue + try: + REQ.head(external_url) + except FailOnGetResponse: + continue + info_external_urls.append({ + 'url': external_url, + 'name': urlparse(external_url).hostname, + }) + options['external_urls'] = info_external_urls + standings = { 'result': result, 'url': icpc_standings_url if is_icpc_api_standings_url else standings_url, diff --git a/src/ranking/management/modules/leetcode.py b/src/ranking/management/modules/leetcode.py index ce8aa575..a57e41cb 100644 --- a/src/ranking/management/modules/leetcode.py +++ b/src/ranking/management/modules/leetcode.py @@ -78,7 +78,7 @@ def _get(self, *args, req=None, n_addition_attempts=20, **kwargs): headers.setdefault(key, value) kwargs['with_curl'] = req.proxer is None and 'post' not in kwargs - kwargs['curl_cookie_file'] = os.path.join(os.path.dirname(__file__), '.leetcode.cookies') + kwargs['curl_cookie_file'] = 'sharedfiles/resource/leetcode/cookies.txt' kwargs['with_referer'] = False # if not getattr(self, '_authorized', None) and getattr(conf, 'LEETCODE_COOKIES', False): @@ -123,6 +123,7 @@ def fetch_standings_page(page): url = api_ranking_url_format.format(page + 1) content = Statistic._get(url, ignore_codes={404}, n_attempts=3) data = json.loads(content) + data['_page'] = page + 1 return data data = fetch_standings_page(0) @@ -184,21 +185,25 @@ def fetch_ranking(domain, region, n_page=None): raise e if not data: return - for p in data['questions']: - if str(p['question_id']) not in problems_info: - return + if 'questions' in data: + for p in data['questions']: + if str(p['question_id']) not in problems_info: + return per_page = len(data['total_rank']) n_page = n_page or (data['user_num'] - 1) // per_page + 1 if users and more_statistics: - pages = set() + pages = [] for more_stat in more_statistics.values(): place = as_number(more_stat['place'], force=True) if place is None: raise ExceptionParseStandings(f'Invalid place {more_stat["place"]}') - page = (place - 1) // per_page - pages.add(page) + base_page = (place - 1) // per_page + for page_delta in (0, -1, +1, -2, +2): + page = base_page + page_delta + if page not in pages: + pages.append(page) n_page = len(pages) else: pages = range(n_page) @@ -212,6 +217,8 @@ def fetch_ranking(domain, region, n_page=None): if stop_fetch_standings: break n_added = 0 + if 'submissions' not in data: + data['submissions'] = [r.pop('submissions') for r in data['total_rank']] for row, submissions in zip(data['total_rank'], data['submissions']): handle = row.pop('user_slug').lower() data_region = row.pop('data_region').lower() @@ -220,8 +227,8 @@ def fetch_ranking(domain, region, n_page=None): already_added = member in result if users is not None and member not in users: continue - row.pop('contest_id') - row.pop('global_ranking') + row.pop('contest_id', None) + row.pop('global_ranking', None) r = result.setdefault(member, OrderedDict()) @@ -230,14 +237,14 @@ def fetch_ranking(domain, region, n_page=None): stats = get_item(statistics, member, {}) problems = r.setdefault('problems', get_copy_item(stats, 'problems', {})) submitted_keys = set() - for i, (k, s) in enumerate(submissions.items(), start=1): + for k, s in submissions.items(): short = problems_info[k]['short'] submitted_keys.add(short) p = problems.setdefault(short, {}) time = datetime.fromtimestamp(s['date']) - start_time p['time_in_seconds'] = time.total_seconds() p['time'] = self.to_time(time) - if s['status'] == 10: + if 'status' not in s or s['status'] == 10: solved += 1 p['result'] = '+' + str(s['fail_count'] or '') else: @@ -247,7 +254,7 @@ def fetch_ranking(domain, region, n_page=None): if 'submission_id' in s: p['submission_id'] = s['submission_id'] p['external_solution'] = True - p['data_region'] = s['data_region'] + p['data_region'] = data_region skip = skip or p['submission_id'] in solutions_ids solutions_ids.add(p['submission_id']) @@ -275,10 +282,17 @@ def fetch_ranking(domain, region, n_page=None): r['place'] = rank r['info'] = {'profile_url': {'_domain': data_domain, '_handle': handle}} + if 'avatar_url' in row: + r['info']['userAvatar'] = row.pop('avatar_url') + + url = f"{standings_url.rstrip('/')}/{data['_page']}" + url = re.sub('[.][^./]+(?=/)', domain, url) + r['url'] = url country = None for field in 'country_code', 'country_name': - country = country or row.pop(field, None) + country_value = row.pop(field, None) + country = country or country_value if country: r['country'] = country @@ -296,8 +310,6 @@ def fetch_ranking(domain, region, n_page=None): if row.get('badge') or not row.get('user_badge'): row.pop('user_badge', None) - r.update(row) - for k in set(row): hidden_fields.add(k) if statistics and member in statistics: @@ -311,8 +323,9 @@ def fetch_ranking(domain, region, n_page=None): if n_added == 0 and not users: stop_fetch_standings = True - fetch_ranking(domain='.com', region='global') - fetch_ranking(domain='.cn', region='local') + fetch_ranking(domain='.com', region='global_v2') + if users is None or users: + fetch_ranking(domain='.cn', region='local_v2') if len(parsed_domains) > 1: def get_key(row): diff --git a/src/ranking/management/modules/open_kattis.py b/src/ranking/management/modules/open_kattis.py index 82808eb6..cc59c824 100644 --- a/src/ranking/management/modules/open_kattis.py +++ b/src/ranking/management/modules/open_kattis.py @@ -12,7 +12,7 @@ from tqdm import tqdm from clist.models import Contest -from clist.templatetags.extras import as_number +from clist.templatetags.extras import as_number, get_item, slug from ranking.management.modules.common import REQ, BaseModule, FailOnGetResponse, parsed_table @@ -22,6 +22,8 @@ def get_standings(self, users=None, statistics=None, **kwargs): url = self.url.split('?')[0].rstrip('/') standings_url = url + '/standings' problems_url = url + '/problems' + subdomain = urlparse(standings_url).netloc.split('.')[0] + has_subdomain = subdomain not in ('open', 'kattis') result = {} problems_info = OrderedDict() @@ -39,116 +41,146 @@ def get_standings(self, users=None, statistics=None, **kwargs): table = parsed_table.ParsedTable(html_table) for r in table: - short = r[''].value + short = r.pop('').value problem_info = problems_info.setdefault(short, {'short': short}) - problem_info['name'] = r['Name'].value - url = r['Name'].column.node.xpath('.//a/@href') - if url: - url = urljoin(problems_url, url[0]) - problem_info['url'] = url - code = url.rstrip('/').rsplit('/', 1)[-1] - problem_info['code'] = code + if 'Name' in r: + name = r.pop('Name') + problem_info['name'] = name.value + url = name.column.node.xpath('.//a/@href') + if url: + url = urljoin(problems_url, url[0]) + problem_info['url'] = url + code = url.rstrip('/').rsplit('/', 1)[-1] + if has_subdomain: + code = f'{subdomain}:{code}' + problem_info['code'] = code + if r: + more_fields = problem_info.setdefault('_more_fields', {}) + for k, v in r.items(): + more_fields[slug(k, sep='_')] = as_number(v.value) page = REQ.get(standings_url) - - regex = '<table[^>]*class="[^"]*standings-table[^"]*"[^>]*>.*?</table>' - entry = re.search(regex, page, re.DOTALL) - html_table = entry.group(0) - table = parsed_table.ParsedTable(html_table) + standings_urls = [standings_url] + options = re.findall(r'value="(?P<option>\?filter=[0-9]+)"', page) + for option in options: + standings_urls.append(urljoin(standings_url, option)) rows = [] standings_kind = Contest.STANDINGS_KINDS['icpc'] - for r in table: - row = {} - problems = row.setdefault('problems', {}) - - if 'Rk' in r: - row['place'] = r.pop('Rk').value - - team = r.pop('Team') - if isinstance(team, list): - if 'place' not in row: - v, *team = team - row['place'] = v.value - team, *more = team - for addition in more: - for img in addition.column.node.xpath('.//img'): - alt = img.attrib.get('alt', '') - if not alt: - continue - src = img.attrib.get('src', '') - if '/countries/' in src and 'country' not in row: - row['country'] = alt - elif '/universities/' in src and 'university' not in row: - row['university'] = alt - if not team.value: - continue - - row['name'] = team.value - - if 'Slv.' in r: - row['solving'] = as_number(r.pop('Slv.').value, force=True) - elif 'Score' in r: - row['solving'] = as_number(r.pop('Score').value, force=True) - - if 'Time' in r: - row['penalty'] = int(r.pop('Time').value) - - for k, v in r.items(): - k, *other = k.split() - if len(k) == 1: - full_score = None - if other and (match := re.match(r'\((\d+)\)', other[0])): - full_score = int(match.group(1)) - problems_info[k].setdefault('full_score', full_score) - standings_kind = Contest.STANDINGS_KINDS['scoring'] - - if not v.value: + seen_urls = set() + with PoolExecutor(max_workers=10) as executor: + for page in executor.map(REQ.get, standings_urls): + regex = '<table[^>]*class="[^"]*standings-table[^"]*"[^>]*>.*?</table>' + entry = re.search(regex, page, re.DOTALL) + html_table = entry.group(0) + table = parsed_table.ParsedTable(html_table) + + for r in table: + row = {} + problems = row.setdefault('problems', {}) + + if 'Rk' in r: + row['place'] = r.pop('Rk').value + + team = r.pop('Team') + if isinstance(team, list): + if 'place' not in row: + v, *team = team + row['place'] = v.value + team, *more = team + for addition in more: + for img in addition.column.node.xpath('.//img'): + alt = img.attrib.get('alt', '') + if not alt: + continue + src = img.attrib.get('src', '') + if '/countries/' in src and 'country' not in row: + row['country'] = alt + elif '/universities/' in src and 'university' not in row: + row['university'] = alt + if not team.value: continue - p = problems.setdefault(k, {}) - - score, *values = v.value.split() - if '+' in score: - score = sum(map(int, score.split('+'))) - else: - score = as_number(score) - classes = v.column.node.xpath('@class')[0].split() - - pending = 'pending' in classes - first = 'solvedfirst' in classes or bool(v.column.node.xpath('.//i[contains(@class,"cell-first")]')) - solved = first or 'solved' in classes - - if not full_score: - if solved: - p['result'] = '+' if score == 1 else f'+{score - 1}' - p['time'] = self.to_time(int(values[0]), 2) - elif pending: - p['result'] = '?' if score == 1 else f'?{score - 1}' - else: - p['result'] = f'-{score}' - else: - p['result'] = score - p['partial'] = not solved and full_score > score - - if first: - p['first_ac'] = True - - if not problems: - continue + row['name'] = team.value + + if 'Slv.' in r: + row['solving'] = as_number(r.pop('Slv.').value, force=True) + elif 'Score' in r: + row['solving'] = as_number(r.pop('Score').value, force=True) + + if 'Time' in r: + row['penalty'] = int(r.pop('Time').value) + + for k, v in r.items(): + k, *other = k.split() + if len(k) == 1: + full_score = None + if other and (match := re.match(r'\((\d+)\)', other[0])): + full_score = int(match.group(1)) + problems_info[k].setdefault('full_score', full_score) + standings_kind = Contest.STANDINGS_KINDS['scoring'] + + if not v.value: + continue + + p = problems.setdefault(k, {}) + + score, *values = v.value.split() + if '+' in score: + score = sum(map(int, score.split('+'))) + else: + score = as_number(score) + classes = v.column.node.xpath('@class')[0].split() + + pending = 'pending' in classes + first = 'solvedfirst' in classes + first = first or bool(v.column.node.xpath('.//i[contains(@class,"cell-first")]')) + solved = first or 'solved' in classes + if options: + first = False + + if not full_score: + if solved: + p['result'] = '+' if score == 1 else f'+{score - 1}' + p['time'] = self.to_time(int(values[0]), 2) + elif pending: + p['result'] = '?' if score == 1 else f'?{score - 1}' + else: + p['result'] = f'-{score}' + else: + p['result'] = score + p['partial'] = not solved and full_score > score + + if first: + p['first_ac'] = True + + if not problems: + continue - urls = team.column.node.xpath('.//a/@href') - assert len(urls) == 1 - url = urls[0] - assert url.startswith('/contests/') - row['_account_url'] = urljoin(standings_url, url) - rows.append(row) + urls = team.column.node.xpath('.//a/@href') + assert len(urls) == 1 + url = urls[0] + assert url.startswith('/contests/') + url = urljoin(standings_url, url) + if url in seen_urls: + continue + seen_urls.add(url) + row['_account_url'] = url + rows.append(row) + if options: + sorted_rows = sorted(rows, key=lambda x: (-x.get('solving', 0), x.get('penalty', 0))) + last_rank = None + last_score = None + for rank, row in enumerate(sorted_rows, start=1): + score = (row.get('solving', 0), row.get('penalty', 0)) + if last_score != score: + last_rank = rank + last_score = score + row['place'] = last_rank with PoolExecutor(max_workers=10) as executor: - - subdomain = urlparse(standings_url).netloc.split('.')[0] - has_subdomain = subdomain not in ('open', 'kattis') + split_team = get_item(self.resource.info, 'statistics.split_team', default=True) def fetch_members(row): page = REQ.get(row['_account_url']) @@ -178,12 +210,23 @@ def fetch_members(row): if real_members: members = real_members - for member in members: - row['member'] = member['username'] - account_info = row.setdefault('info', {}) - account_info['name'] = member['name'] - account_info['profile_url'] = member.get('profile_url') - result[row['member']] = deepcopy(row) + if split_team: + for member in members: + row['member'] = member['username'] + account_info = row.setdefault('info', {}) + account_info['name'] = member['name'] + account_info['profile_url'] = member.get('profile_url') + result[row['member']] = deepcopy(row) + else: + for member in row['_members']: + member.pop('account', None) + member = f'team-{row["team_id"]}' + if has_subdomain: + member = f'{subdomain}:{member}' + row['member'] = member + row.pop('team_id') + row.pop('_account_url') + result[member] = deepcopy(row) standings = { 'result': result, diff --git a/src/ranking/management/modules/topcoder.py b/src/ranking/management/modules/topcoder.py index f7f4eb9f..9490dbf6 100644 --- a/src/ranking/management/modules/topcoder.py +++ b/src/ranking/management/modules/topcoder.py @@ -22,6 +22,20 @@ from utils.requester import FailOnGetResponse +def parse_xml(page, exc=None): + try: + root = ET.fromstring(page) + except ET.ParseError as e: + if exc is not None: + raise exc(f'Failed to parse xml: {e}') + raise e + for child in root: + data = {} + for field in child: + data[field.tag] = field.text + yield data + + class Statistic(BaseModule): LEGACY_PROXY_PATH = 'logs/legacy/topcoder.proxy' @@ -104,6 +118,7 @@ def get_standings(self, users=None, statistics=None, **kwargs): filepath_proxies='sharedfiles/resource/topcoder/proxies', connect=lambda req: req.get('https://www.topcoder.com/', n_attempts=1), attributes=dict(n_attempts=5), + inplace=False, ) if not self.standings_url and datetime.now() - start_time < timedelta(days=30): @@ -161,11 +176,7 @@ def process_match(date, title, url): url = 'https://www.topcoder.com/tc?module=BasicData&c=dd_round_list' page = req.get(url) - root = ET.fromstring(page) - for child in root: - data = {} - for field in child: - data[field.tag] = field.text + for data in parse_xml(page, exc=ExceptionParseStandings): date = dateutil.parser.parse(data['date']) url = 'https://www.topcoder.com/stat?c=round_overview&er=5&rd=' + data['round_id'] process_match(date, data['full_name'], url) @@ -263,11 +274,7 @@ def process_match(date, title, url): url = f'https://www.topcoder.com/tc?module=BasicData&c=dd_round_results&rd={rd}' try: dd_round_results_page = req.get(url) - root = ET.fromstring(dd_round_results_page) - for child in root: - data = {} - for field in child: - data[field.tag] = field.text + for data in parse_xml(dd_round_results_page, exc=ExceptionParseStandings): handle = data.pop('handle') dd_round_results[handle] = self._dict_as_number(data) except FailOnGetResponse: @@ -589,14 +596,6 @@ def fetch_info(url): @staticmethod def get_users_infos(users, resource=None, accounts=None, pbar=None): - def parse_xml(page): - root = ET.fromstring(page) - for child in root: - data = {} - for field in child: - data[field.tag] = field.text - yield data - active_algorithm_list_url = 'https://www.topcoder.com/tc?module=BasicData&c=dd_active_algorithm_list' with REQ.with_proxy( time_limit=10, @@ -607,7 +606,7 @@ def parse_xml(page): ) as req: page = req.proxer.get_connect_ret() dd_active_algorithm = {} - for data in parse_xml(page): + for data in parse_xml(page, exc=ExceptionParseAccounts): dd_active_algorithm[data.pop('handle')] = data def fetch_profile(user): @@ -652,7 +651,7 @@ def fetch_profile(user): page = req.get(url) max_rating_order = -1 ret['rating'], ret['volatility'], n_rating = None, None, 0 - for data in parse_xml(page): + for data in parse_xml(page, exc=ExceptionParseAccounts): n_rating += 1 rating_order = as_number(data['rating_order']) if rating_order > max_rating_order: diff --git a/src/ranking/management/modules/yandex.py b/src/ranking/management/modules/yandex.py index 9b641a24..33838b6f 100644 --- a/src/ranking/management/modules/yandex.py +++ b/src/ranking/management/modules/yandex.py @@ -12,8 +12,8 @@ from clist.templatetags.extras import get_item from my_oauth.models import Service from ranking.management.modules.common import LOG, REQ, BaseModule, FailOnGetResponse, parsed_table -from ranking.management.modules.excepts import ExceptionParseStandings from ranking.management.modules.common.locator import Locator +from ranking.management.modules.excepts import ExceptionParseStandings class Statistic(BaseModule): @@ -50,7 +50,7 @@ def get_submission_infos(self, statistics, names): if not coder_pk: return submission_infos - ouath_service = Service.objects.get(name='competitive-hustle') + ouath_service = Service.objects.get(name='yandex-contest') oauth_token = ouath_service.token_set.filter(coder__pk=coder_pk).first() if not oauth_token: return submission_infos diff --git a/src/ranking/migrations/0133_subsriptions_and_parsestatistics.py b/src/ranking/migrations/0133_subsriptions_and_parsestatistics.py new file mode 100644 index 00000000..9e20c00c --- /dev/null +++ b/src/ranking/migrations/0133_subsriptions_and_parsestatistics.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1 on 2024-11-17 15:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('ranking', '0133_remove_account_is_subscribed_account_n_subsriptions'), ('ranking', '0134_rename_n_subsriptions_account_n_subscriptions'), ('ranking', '0135_alter_account_n_subscriptions'), ('ranking', '0136_rename_n_subscriptions_account_n_subscribers'), ('ranking', '0137_parsestatistics'), ('ranking', '0138_rename_parsestatistics_parsestatistic'), ('ranking', '0139_rename_parsestatistic_parsestatistics_and_more'), ('ranking', '0140_parsestatistics_parse_time')] + + dependencies = [ + ('clist', '0167_rename_inherit_medals_to_related_resource_has_inherit_medals_to_related'), + ('ranking', '0132_account_rating_prediction'), + ] + + operations = [ + migrations.RemoveField( + model_name='account', + name='is_subscribed', + ), + migrations.AddField( + model_name='account', + name='n_subscribers', + field=models.IntegerField(blank=True, db_index=True, default=0), + ), + migrations.CreateModel( + name='ParseStatistics', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('modified', models.DateTimeField(auto_now=True, db_index=True)), + ('delay', models.DurationField(blank=True, null=True)), + ('enable', models.BooleanField(blank=True, default=True)), + ('without_set_coder_problems', models.BooleanField(blank=True, default=True)), + ('without_stage', models.BooleanField(blank=True, default=True)), + ('without_subscriptions', models.BooleanField(blank=True, default=False)), + ('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clist.contest')), + ('parse_time', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'abstract': False, + 'verbose_name_plural': 'ParseStatistics', + }, + ), + ] diff --git a/src/ranking/models.py b/src/ranking/models.py index 744f7f0c..f7e74e0c 100644 --- a/src/ranking/models.py +++ b/src/ranking/models.py @@ -1,20 +1,15 @@ -import ast -import collections import hashlib import os import re -from copy import deepcopy -from pydoc import locate from urllib.parse import quote, urljoin import magic import requests -import tqdm from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.management import call_command -from django.db import models, transaction +from django.db import models from django.db.models import F, OuterRef, Q, Sum from django.db.models.functions import Coalesce, Upper from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save @@ -23,11 +18,10 @@ from django.utils import timezone from django.utils.crypto import get_random_string from django_countries.fields import CountryField -from django_print_sql import print_sql from sql_util.utils import Exists, SubqueryCount, SubquerySum from clist.models import Contest, Resource -from clist.templatetags.extras import add_prefix_to_problem_short, get_problem_short, has_season, slug +from clist.templatetags.extras import get_item, has_season from pyclist.indexes import ExpressionIndex, GistIndexTrgrmOps from pyclist.models import BaseManager, BaseModel from true_coders.models import Coder, Party @@ -44,6 +38,7 @@ class Account(BaseModel): url = models.CharField(max_length=4096, null=True, blank=True) n_contests = models.IntegerField(default=0, db_index=True) n_writers = models.IntegerField(default=0, db_index=True) + n_subscribers = models.IntegerField(default=0, db_index=True, blank=True) last_activity = models.DateTimeField(default=None, null=True, blank=True, db_index=True) last_submission = models.DateTimeField(default=None, null=True, blank=True, db_index=True) last_rating_activity = models.DateTimeField(default=None, null=True, blank=True, db_index=True) @@ -55,7 +50,6 @@ class Account(BaseModel): duplicate = models.ForeignKey('Account', null=True, blank=True, on_delete=models.CASCADE) global_rating = models.IntegerField(null=True, blank=True, default=None, db_index=True) need_verification = models.BooleanField(default=False) - is_subscribed = models.BooleanField(null=True, blank=True, default=None, db_index=True) deleted = models.BooleanField(null=True, blank=True, default=None, db_index=True) try_renaming_check_time = models.DateTimeField(null=True, blank=True, default=None) try_fill_missed_ranks_time = models.DateTimeField(null=True, blank=True, default=None) @@ -143,12 +137,22 @@ def update_last_rating_activity(self, statistic, contest=None, resource=None): def display(self, with_resource=None): if not with_resource and self.name and has_season(self.key, self.name): ret = self.name + elif not self.name or self.name == self.key: + ret = self.key else: ret = f'{self.key}, {self.name}' if with_resource: - ret += f' ({self.resource.host})' + ret += f', {self.resource.host}' return ret + def short_display(self, resource=None, name=None): + name = name or self.name + resource = resource or self.resource + name_instead_key = get_item(resource, 'info.standings.name_instead_key') + name_instead_key = get_item(self, 'info._name_instead_key', default=name_instead_key) + name_instead_key = name and (name_instead_key or has_season(self.key, name)) + return name if name_instead_key else self.key + def save(self, *args, **kwargs): update_fields = kwargs.get('update_fields') prev_rating = self.rating @@ -373,9 +377,8 @@ def update_account_writer(signal, instance, action, reverse, pk_set, **kwargs): return if reverse: - instance.n_writers += delta - instance.save() - else: + Account.objects.filter(pk=instance.pk).update(n_writers=F('n_writers') + delta) + elif pk_set: Account.objects.filter(pk__in=pk_set).update(n_writers=F('n_writers') + delta) @@ -387,7 +390,7 @@ def update_coder_n_accounts_and_n_contests(signal, instance, action, reverse, pk if reverse: instance.n_accounts = instance.account_set.count() instance.n_contests = instance.account_set.aggregate(total=Sum('n_contests'))['total'] or 0 - instance.save() + instance.save(update_fields=['n_accounts', 'n_contests']) resources = Resource.objects.annotate(has_account=Exists('account', filter=Q(pk__in=pk_set))) resources = resources.filter(has_account=True) @@ -403,7 +406,7 @@ def update_coder_n_accounts_and_n_contests(signal, instance, action, reverse, pk resources = [instance.resource.host] if coders and resources: - call_command('fill_coder_problems', coders=coders, resources=resources) + call_command('set_coder_problems', coders=coders, resources=resources) class AccountVerification(BaseModel): @@ -477,7 +480,7 @@ def is_special_addition_field(field): return field in settings.ADDITION_HIDE_FIELDS_ def __str__(self): - return f'Statistics#{self.id} {self.account_id} on {self.contest_id}' + return f'Statistics#{self.id} account#{self.account_id} on contest#{self.contest_id}' def get_old_rating(self, use_rating_prediction=True): rating_datas = [self.addition] @@ -497,6 +500,23 @@ def is_rated(self): return False return 'new_rating' in self.addition or 'rating_change' in self.addition + @property + def account_name(self): + resource = ( + self.fetched_field('contest__resource') or + self.fetched_field('account__resource') or + self.contest.resource + ) + return self.account.short_display(resource=resource, name=self.addition.get('name')) + + @classmethod + def top_n_filter(cls, n): + return Q(place_as_int__lte=n) + + @classmethod + def first_ac_filter(cls): + return Q(addition__problems__icontains='"first_ac": true') + class Meta: verbose_name_plural = 'Statistics' unique_together = ('account', 'contest') @@ -569,640 +589,6 @@ class Stage(BaseModel): def __str__(self): return 'Stage#%d %s' % (self.pk, self.contest) - def update(self): - eps = 1e-9 - stage = self.contest - timezone_now = timezone.now() - - filter_params = dict(self.filter_params) - spec_filter_params = dict() - for field in ('info__fields_types__new_rating__isnull',): - if field in filter_params: - spec_filter_params[field] = filter_params.pop(field) - is_over = self.contest.end_time < timezone_now - - contests = Contest.objects.filter( - resource=self.contest.resource, - start_time__gte=self.contest.start_time, - end_time__lte=self.contest.end_time, - **filter_params, - ).exclude(pk=self.contest.pk) - - if spec_filter_params: - contests = contests.filter(Q(**spec_filter_params) | Q(end_time__gt=timezone_now)) - - contests = contests.order_by('start_time') - contests = contests.prefetch_related('writers') - self.contests.set(contests) - - parsed_statistic = self.score_params.get('parse_statistic') - if parsed_statistic: - call_command('parse_statistic', - contest_id=stage.pk, - without_fill_coder_problems=True, - ignore_stage=True) - stage.refresh_from_db() - - placing = self.score_params.get('place') - n_best = self.score_params.get('n_best') - fields = self.score_params.get('fields', []) - scoring = self.score_params.get('scoring', {}) - detail_problems = self.score_params.get('detail_problems') - order_by = self.score_params['order_by'] - advances = self.score_params.get('advances', {}) - results = collections.defaultdict(collections.OrderedDict) - - mapping_account_by_coder = {} - fixed_fields = [] - hidden_fields = [] - - problems_infos = collections.OrderedDict() - divisions_order = [] - for idx, contest in enumerate(tqdm.tqdm(contests, desc=f'getting contests for stage {stage}'), start=1): - info = { - 'code': str(contest.pk), - 'name': contest.title, - 'url': reverse( - 'ranking:standings', - kwargs={'title_slug': slug(contest.title), 'contest_id': str(contest.pk)} - ), - } - - if contest.start_time > timezone_now: - info['subtext'] = {'text': 'upcoming', 'title': str(contest.start_time)} - - for division in contest.info.get('divisions_order', []): - if division not in divisions_order: - divisions_order.append(division) - - if self.score_params.get('regex_problem_name'): - match = re.search(self.score_params.get('regex_problem_name'), contest.title) - if match: - info['short'] = match.group(1) - if self.score_params.get('abbreviation_problem_name'): - info['short'] = ''.join(re.findall(r'(\b[A-Z]|[0-9])', info.get('short', contest.title))) - - problems = contest.info.get('problems', []) - if not detail_problems: - full_score = None - if placing: - if 'division' in placing: - full_score = max([max(p.values()) for p in placing['division'].values()]) - else: - full_score = max(placing.values()) - elif 'division' in problems: - full_scores = [] - for ps in problems['division'].values(): - full = 0 - for problem in ps: - full += problem.get('full_score', 1) - full_scores.append(full) - info['full_score'] = max(full_scores) - elif self.score_params.get('default_problem_full_score'): - full_score = self.score_params['default_problem_full_score'] - else: - full_score = 0 - for problem in problems: - full_score += problem.get('full_score', 1) - if full_score is not None: - info['full_score'] = full_score - problems_infos[str(contest.pk)] = info - else: - for problem in problems: - problem = dict(problem) - add_prefix_to_problem_short(problem, f'{idx}.') - problem['group'] = info.get('short', info['name']) - problem['url'] = info['url'] - problems_infos[get_problem_short(problem)] = problem - - exclude_advances = {} - if advances and advances.get('exclude_stages'): - qs = Statistics.objects \ - .filter(contest__stage__in=advances['exclude_stages'], addition___advance__isnull=False) \ - .values('account__key', 'addition___advance', 'contest__title') \ - .order_by('contest__end_time', 'contest__id') - for r in qs: - d = r['addition___advance'] - if 'contest' not in d: - d['contest'] = r['contest__title'] - exclude_advances[r['account__key']] = d - - statistics = Statistics.objects \ - .select_related('account', 'account__duplicate') \ - .prefetch_related('account__coders') - filter_statistics = self.score_params.get('filter_statistics') - if filter_statistics: - statistics = statistics.filter(**filter_statistics) - exclude_statistics = self.score_params.get('exclude_statistics') - if exclude_statistics: - statistics = statistics.exclude(**exclude_statistics) - - def get_placing(placing, stat): - return placing['division'][stat.addition['division']] if 'division' in placing else placing - - account_keys = dict() - total = statistics.filter(contest__in=contests).count() - with tqdm.tqdm(total=total, desc=f'getting statistics for stage {stage}') as pbar, print_sql(count_only=True): - for idx, contest in enumerate(contests, start=1): - skip_problem_stat = '_skip_for_problem_stat' in contest.info.get('fields', []) - contest_unrated = contest.info.get('unrated') - - if not detail_problems: - problem_info_key = str(contest.pk) - problem_short = get_problem_short(problems_infos[problem_info_key]) - pbar.set_postfix(contest=contest) - stats = statistics.filter(contest_id=contest.pk) - - if placing: - placing_scores = deepcopy(placing) - n_rows = 0 - for s in stats: - n_rows += 1 - placing_ = get_placing(placing_scores, s) - key = str(s.place_as_int) - if key in placing_: - placing_.setdefault('scores', {}) - placing_['scores'][key] = placing_.pop(key) - scores = [] - for place in reversed(range(1, n_rows + 1)): - placing_ = get_placing(placing_scores, s) - key = str(place) - if key in placing_: - scores.append(placing_.pop(key)) - else: - if scores: - placing_['scores'][key] += sum(scores) - placing_['scores'][key] /= len(scores) + 1 - scores = [] - - max_solving = 0 - n_effective = 0 - for stat in stats: - max_solving = max(max_solving, stat.solving) - n_effective += stat.solving > eps - - for s in stats: - if not detail_problems and not skip_problem_stat: - problems_infos[problem_info_key].setdefault('n_total', 0) - problems_infos[problem_info_key]['n_total'] += 1 - - score = None - if s.solving < eps: - score = 0 - if placing: - placing_ = get_placing(placing_scores, s) - score = placing_.get('zero', 0) - else: - if placing: - placing_ = get_placing(placing_scores, s) - score = placing_['scores'].get(str(s.place_as_int), placing_.get('default')) - if score is None: - continue - if scoring: - if score is None: - score = 0 - if scoring['name'] == 'general': - if s.place_as_int is None: - continue - solving_factor = s.solving / max_solving - rank_factor = (n_effective - s.place_as_int + 1) / n_effective - score += scoring['factor'] * solving_factor * rank_factor - elif scoring['name'] == 'field': - score += s.addition.get(scoring['field'], 0) - else: - raise NotImplementedError(f'scoring {scoring["name"]} is not implemented') - if score is None: - score = s.solving - - if not detail_problems and not skip_problem_stat: - problems_infos[problem_info_key].setdefault('n_teams', 0) - problems_infos[problem_info_key]['n_teams'] += 1 - if score: - problems_infos[problem_info_key].setdefault('n_accepted', 0) - problems_infos[problem_info_key]['n_accepted'] += 1 - - account = s.account - if account.duplicate is not None: - account = account.duplicate - - coders = account.coders.all() - has_mapping_account_by_coder = False - if len(coders) == 1: - coder = coders[0] - if coder not in mapping_account_by_coder: - mapping_account_by_coder[coder] = account - else: - account = mapping_account_by_coder[coder] - has_mapping_account_by_coder = True - - row = results[account] - row['member'] = account - account_keys[account.key] = account - - problems = row.setdefault('problems', {}) - if detail_problems: - for key, problem in s.addition.get('problems', {}).items(): - p = problems.setdefault(f'{idx}.' + key, {}) - if contest_unrated: - p = p.setdefault('upsolving', {}) - p.update(problem) - else: - problem = problems.setdefault(problem_short, {}) - if contest_unrated: - problem = problem.setdefault('upsolving', {}) - problem['result'] = score - url = s.addition.get('url') - if not url: - url = reverse('ranking:standings_by_id', kwargs={'contest_id': str(contest.pk)}) - url += f'?find_me={s.pk}' - problem['url'] = url - if contest_unrated: - score = 0 - - if n_best and not detail_problems: - row.setdefault('scores', []).append((score, problem)) - else: - row['score'] = row.get('score', 0) + score - - field_values = {} - for field in fields: - if field.get('skip_on_mapping_account_by_coder') and has_mapping_account_by_coder: - continue - if 'type' in field: - continue - inp = field['field'] - out = field.get('out', inp) - if field.get('first') and out in row or (inp not in s.addition and not hasattr(s, inp)): - continue - val = s.addition[inp] if inp in s.addition else getattr(s, inp) - if not field.get('safe') and isinstance(val, str): - val = ast.literal_eval(val) - if 'cast' in field: - val = locate(field['cast'])(val) - field_values[out] = val - if field.get('skip'): - continue - if field.get('accumulate'): - val = round(val + ast.literal_eval(str(row.get(out, 0))), 2) - if field.get('aggregate') == 'avg': - out_n = f'_{out}_n' - out_s = f'_{out}_s' - row[out_n] = row.get(out_n, 0) + 1 - row[out_s] = row.get(out_s, 0) + val - val = round(row[out_s] / row[out_n], 2) - if field.get('aggregate') == 'max' and out in row: - val = max(val, row[out]) - row[out] = val - - if 'solved' in s.addition and isinstance(s.addition['solved'], dict): - solved = row.setdefault('solved', {}) - for k, v in s.addition['solved'].items(): - solved[k] = solved.get(k, 0) + v - - if 'status' in self.score_params: - field = self.score_params['status'] - val = field_values.get(field, row.get(field)) - if val is None: - val = getattr(s, field) - if val is not None: - problem['status'] = val - else: - for field in order_by: - field = field.lstrip('-') - if field in ['score', 'rating', 'penalty']: - continue - status = field_values.get(field, row.get(field)) - if status is None: - continue - problem['status'] = status - break - pbar.update() - - for writer in contest.writers.all(): - account_keys[writer.key] = writer - - total = sum([len(contest.info.get('writers', [])) for contest in contests]) - with tqdm.tqdm(total=total, desc=f'getting writers for stage {stage}') as pbar, print_sql(count_only=True): - writers = set() - for contest in contests: - contest_writers = contest.info.get('writers', []) - if not contest_writers or detail_problems: - continue - problem_info_key = str(contest.pk) - problem_short = get_problem_short(problems_infos[problem_info_key]) - for writer in contest_writers: - if writer in account_keys: - account = account_keys[writer] - else: - try: - account = Account.objects.get(resource_id=contest.resource_id, key__iexact=writer) - except Account.DoesNotExist: - account = None - - pbar.update() - if not account: - continue - writers.add(account) - - row = results[account] - row['member'] = account - row.setdefault('score', 0) - if n_best: - row.setdefault('scores', []) - row.setdefault('writer', 0) - - row['writer'] += 1 - - problems = row.setdefault('problems', {}) - problem = problems.setdefault(problem_short, {}) - problem['status'] = 'W' - - if self.score_params.get('writers_proportionally_score'): - n_contests = sum(contest.start_time < timezone_now for contest in contests) - for account in writers: - row = results[account] - if n_contests == row['writer'] or 'score' not in row: - continue - row['score'] = row['score'] / (n_contests - row['writer']) * n_contests - if self.score_params.get('exponential_score_decay'): - for r in results.values(): - scores = [problem.get('result', 0) for problem in r.get('problems', {}).values()] - scores.sort(reverse=True) - k = self.score_params['exponential_score_decay'] - score = k * sum((1 - k) ** i * score for i, score in enumerate(scores)) - r['score'] = score - - for field in fields: - t = field.get('type') - if t is None: - continue - if t == 'points_for_common_problems': - group = field['group'] - inp = field['field'] - out = field.get('out', inp) - - excluding = bool(exclude_advances and field.get('exclude_advances')) - - groups = collections.defaultdict(list) - for row in results.values(): - key = row[group] - groups[key].append(row) - - advancement_position = 1 - for key, rows in sorted(groups.items(), key=lambda kv: kv[0], reverse=True): - common_problems = None - for row in rows: - handle = row['member'].key - if excluding and handle in exclude_advances: - exclude_advance = exclude_advances[handle] - for advance in advances.get('options', []): - if advance['next'] == exclude_advance['next']: - exclude_advance['skip'] = True - break - if advancement_position in advance['places']: - break - if exclude_advance.get('skip'): - continue - - problems = {k for k, p in row['problems'].items() if p.get('status') != 'W'} - common_problems = problems if common_problems is None else (problems & common_problems) - if common_problems is None: - for row in rows: - problems = {k for k, p in row['problems'].items() if p.get('status') != 'W'} - common_problems = problems if common_problems is None else (problems & common_problems) - - for row in rows: - handle = row['member'].key - if excluding and not exclude_advances.get(handle, {}).get('skip', False): - advancement_position += 1 - value = 0 - for k in common_problems: - value += float(row['problems'].get(k, {}).get(inp, 0)) - for k, v in row['problems'].items(): - if k not in common_problems and v.get('status') != 'W': - v['status_tag'] = 'strike' - row[out] = round(value, 2) - elif t == 'region_by_country': - out = field['out'] - - mapping_regions = dict() - for regional_event in field['data']: - for region in regional_event['regions']: - mapping_regions[region['code']] = {'regional_event': regional_event, 'region': region} - - for row in results.values(): - country = row['member'].country - if not country: - continue - code = country.code - if code not in mapping_regions: - continue - row[out] = mapping_regions[code]['regional_event']['name'] - elif t == 'n_medal_problems': - for row in results.values(): - for problem in row['problems'].values(): - medal = problem.get('medal') - if medal: - k = f'n_{medal}_problems' - row.setdefault(k, 0) - row[k] += 1 - if k not in hidden_fields: - hidden_fields.append(k) - for field in settings.STANDINGS_FIELDS_: - if field in hidden_fields: - fixed_fields.append(field) - hidden_fields.remove(field) - else: - raise ValueError(f'Unknown field type = {t}') - - hidden_fields += [field.get('out', field.get('inp')) for field in fields if field.get('hidden')] - - results = list(results.values()) - if n_best: - for row in results: - scores = row.pop('scores') - for index, (score, problem) in enumerate(sorted(scores, key=lambda s: s[0], reverse=True)): - if index < n_best: - row['score'] = row.get('score', 0) + score - else: - problem['status'] = problem.pop('result') - - filtered_results = [] - filter_zero_points = self.score_params.get('filter_zero_points', True) - for r in results: - if r['score'] > eps or not filter_zero_points or r.get('writer'): - filtered_results.append(r) - continue - if detail_problems: - continue - - problems = r.setdefault('problems', {}) - - for idx, contest in enumerate(contests, start=1): - skip_problem_stat = '_skip_for_problem_stat' in contest.info.get('fields', []) - if skip_problem_stat: - continue - - problem_info_key = str(contest.pk) - problem_short = get_problem_short(problems_infos[problem_info_key]) - - if problem_short in problems: - problems_infos[problem_info_key].setdefault('n_teams', 0) - problems_infos[problem_info_key]['n_teams'] -= 1 - results = filtered_results - - results = sorted( - results, - key=lambda r: tuple(r.get(k.lstrip('-'), 0) * (-1 if k.startswith('-') else 1) for k in order_by), - reverse=True, - ) - - additions = deepcopy(stage.info.get('additions', {})) - field_to_problem = self.score_params.get('field_to_problem') - with transaction.atomic(): - fields_set = set() - fields = list() - - pks = set() - placing_infos = {} - score_advance = None - place_advance = 0 - place_index = 0 - for row in tqdm.tqdm(results, desc=f'update statistics for stage {stage}'): - for field in [row.get('member'), row.get('name')]: - row.update(additions.pop(field, {})) - - division = row.get('division', 'none') - placing_info = placing_infos.setdefault(division, {}) - placing_info['index'] = placing_info.get('index', 0) + 1 - - curr_score = tuple(row.get(k.lstrip('-'), 0) for k in order_by) - if curr_score != placing_info.get('last_score'): - placing_info['last_score'] = curr_score - placing_info['place'] = placing_info['index'] - - if advances and ('divisions' not in advances or division in advances['divisions']): - tmp = score_advance, place_advance, place_index - - place_index += 1 - if curr_score != score_advance: - score_advance = curr_score - place_advance = place_index - - for advance in advances.get('options', []): - handle = row['member'].key - if handle in exclude_advances and advance['next'] == exclude_advances[handle]['next']: - advance = exclude_advances[handle] - if 'class' in advance and not advance['class'].startswith('text-'): - advance['class'] = f'text-{advance["class"]}' - row['_advance'] = advance - break - - if 'places' in advance and place_advance in advance['places']: - if not advances.get('inplace_fields_only'): - row['_advance'] = advance - if is_over: - for field in advance.get('inplace_fields', []): - row[field] = advance[field] - tmp = None - break - - if tmp is not None: - score_advance, place_advance, place_index = tmp - account = row.pop('member') - solving = row.pop('score') - - advanced = False - if row.get('_advance'): - adv = row['_advance'] - advanced = not adv.get('skip') and not adv.get('class', '').startswith('text-') - - defaults = { - 'place': str(placing_info['place']), - 'place_as_int': placing_info['place'], - 'solving': solving, - 'addition': row, - 'skip_in_stats': True, - 'advanced': advanced, - } - if parsed_statistic: - defaults['place'] = None - defaults['place_as_int'] = None - defaults['solving'] = 0 - stat = Statistics.objects.filter(account=account, contest=stage).first() - if not stat: - continue - for k, v in defaults['addition'].items(): - if k not in stat.addition or (v and not stat.addition.get(k)): - stat.addition[k] = v - stat.skip_in_stats = defaults['skip_in_stats'] - stat.advanced = defaults['advanced'] - stat.save(update_fields=['addition', 'skip_in_stats', 'advanced']) - else: - stat, created = Statistics.objects.update_or_create( - account=account, - contest=stage, - defaults=defaults, - ) - pks.add(stat.pk) - - for k in stat.addition.keys(): - if field_to_problem and re.search(field_to_problem['regex'], k): - continue - if k not in fields_set: - fields_set.add(k) - fields.append(k) - stage.info['problems'] = list(problems_infos.values()) - - if field_to_problem: - for stat in stage.statistics_set.all(): - problems = stat.addition.setdefault('problems', {}) - was_updated = False - for k, v in list(stat.addition.items()): - match = re.search(field_to_problem['regex'], k) - if match: - problem_short = field_to_problem['format'].format(**match.groupdict()) - if problem_short not in problems: - problems[problem_short] = {'result': v} - stat.addition.pop(k) - if 'fields_types' in stage.info: - stage.info['fields_types'].pop(k, None) - was_updated = True - if was_updated: - stat.save(update_fields=['addition']) - - if parsed_statistic: - for field in stage.info.setdefault('hidden_fields', []): - if field not in hidden_fields: - hidden_fields.append(field) - regex_hidden_fields = self.score_params.get('regex_hidden_fields') - if regex_hidden_fields: - for field in fields: - if field not in hidden_fields and re.search(regex_hidden_fields, field): - hidden_fields.append(field) - stage.info['fields'] = list(fields) - stage.info['hidden_fields'] = hidden_fields - - fields_types = self.score_params.get('fields_types', {}) - if fields_types: - stage.info.setdefault('fields_types', {}).update(fields_types) - - if not parsed_statistic: - stage.statistics_set.exclude(pk__in=pks).delete() - stage.n_statistics = len(results) - stage.parsed_time = timezone_now - - standings_info = self.score_params.get('info', {}) - standings_info['fixed_fields'] = fixed_fields + [(f.lstrip('-'), f.lstrip('-')) for f in order_by] - stage.info['standings'] = standings_info - - if divisions_order and self.score_params.get('divisions_ordering'): - stage.info['divisions_order'] = divisions_order - - if stage.is_rated is None: - stage.is_rated = False - stage.save() - class VirtualStart(BaseModel): coder = models.ForeignKey(Coder, on_delete=models.CASCADE, related_name='virtual_starts') @@ -1271,4 +657,25 @@ class Meta: unique_together = ('name', 'statistic') def __str__(self): - return f'AccountMatching#{self.pk} {self.name}, statistic#{self.statistic_id}' + return f'AccountMatching#{self.pk} {self.name} statistic#{self.statistic_id}' + + +class ParseStatistics(BaseModel): + contest = models.ForeignKey(Contest, on_delete=models.CASCADE) + delay = models.DurationField(null=True, blank=True) + parse_time = models.DateTimeField(null=True, blank=True) + enable = models.BooleanField(default=True, blank=True) + without_set_coder_problems = models.BooleanField(default=True, blank=True) + without_stage = models.BooleanField(default=True, blank=True) + without_subscriptions = models.BooleanField(default=False, blank=True) + + class Meta: + verbose_name_plural = 'ParseStatistics' + + @staticmethod + def relevant_contest(): + contests = Contest.objects.filter(parsestatistics__isnull=False, end_time__gt=timezone.now()) + return contests.order_by('end_time').first() + + def __str__(self): + return f'ParseStatistics#{self.pk} contest#{self.contest_id}' diff --git a/src/ranking/utils.py b/src/ranking/utils.py index dc8b128e..17b774d3 100644 --- a/src/ranking/utils.py +++ b/src/ranking/utils.py @@ -1,22 +1,31 @@ #!/usr/bin/env python3 +import ast +import collections import functools import json import operator +import re from collections import defaultdict +from copy import deepcopy from datetime import timedelta +from pydoc import locate import tqdm +from django.conf import settings +from django.core.management import call_command from django.db import transaction from django.db.models import Q +from django.urls import reverse from django.utils import timezone +from django_print_sql import print_sql from django_super_deduper.merge import MergedModelInstance -# from django.core.management import call_command from sql_util.utils import Exists from clist.models import Contest +from clist.templatetags.extras import add_prefix_to_problem_short, get_item, get_problem_short, slug from ranking.management.modules.common import LOG -from ranking.models import AccountRenaming, Statistics +from ranking.models import Account, AccountRenaming, Statistics from utils.logger import suppress_db_logging_context from utils.mathutils import max_with_none @@ -350,3 +359,655 @@ def create_upsolving_statistic(contest, account): stat.addition['_no_update_n_contests'] = True stat.save(update_fields=['skip_in_stats', 'addition']) return stat, created + + +def update_stage(self): + eps = 1e-9 + stage = self.contest + timezone_now = timezone.now() + + filter_params = dict(self.filter_params) + spec_filter_params = dict() + for field in ('info__fields_types__new_rating__isnull',): + if field in filter_params: + spec_filter_params[field] = filter_params.pop(field) + is_over = self.contest.end_time < timezone_now + + contests = Contest.objects.filter( + resource=self.contest.resource, + start_time__gte=self.contest.start_time, + end_time__lte=self.contest.end_time, + **filter_params, + ).exclude(pk=self.contest.pk) + + if spec_filter_params: + contests = contests.filter(Q(**spec_filter_params) | Q(end_time__gt=timezone_now)) + + contests = contests.order_by('start_time') + contests = contests.prefetch_related('writers') + self.contests.set(contests) + + parsed_statistic = self.score_params.get('parse_statistic') + if parsed_statistic: + call_command('parse_statistic', + contest_id=stage.pk, + without_set_coder_problems=True, + ignore_stage=True) + stage.refresh_from_db() + + placing = self.score_params.get('place') + n_best = self.score_params.get('n_best') + fields = self.score_params.get('fields', []) + scoring = self.score_params.get('scoring', {}) + detail_problems = self.score_params.get('detail_problems') + order_by = self.score_params['order_by'] + advances = self.score_params.get('advances', {}) + results = collections.defaultdict(collections.OrderedDict) + + mapping_account_by_coder = {} + fixed_fields = [] + hidden_fields = [] + + problems_infos = collections.OrderedDict() + divisions_order = [] + for idx, contest in enumerate(tqdm.tqdm(contests, desc=f'getting contests for stage {stage}'), start=1): + info = { + 'code': str(contest.pk), + 'name': contest.title, + 'url': reverse( + 'ranking:standings', + kwargs={'title_slug': slug(contest.title), 'contest_id': str(contest.pk)} + ), + } + + if contest.start_time > timezone_now: + info['subtext'] = {'text': 'upcoming', 'title': str(contest.start_time)} + + for division in contest.info.get('divisions_order', []): + if division not in divisions_order: + divisions_order.append(division) + + if self.score_params.get('regex_problem_name'): + match = re.search(self.score_params.get('regex_problem_name'), contest.title) + if match: + info['short'] = match.group(1) + if self.score_params.get('abbreviation_problem_name'): + info['short'] = ''.join(re.findall(r'(\b[A-Z]|[0-9])', info.get('short', contest.title))) + + problems = contest.info.get('problems', []) + if not detail_problems: + full_score = None + if placing: + if 'division' in placing: + full_score = max([max(p.values()) for p in placing['division'].values()]) + else: + full_score = max(placing.values()) + elif 'division' in problems: + full_scores = [] + for ps in problems['division'].values(): + full = 0 + for problem in ps: + full += problem.get('full_score', 1) + full_scores.append(full) + info['full_score'] = max(full_scores) + elif self.score_params.get('default_problem_full_score'): + full_score = self.score_params['default_problem_full_score'] + else: + full_score = 0 + for problem in problems: + full_score += problem.get('full_score', 1) + if full_score is not None: + info['full_score'] = full_score + problems_infos[str(contest.pk)] = info + else: + for problem in problems: + problem = dict(problem) + add_prefix_to_problem_short(problem, f'{idx}.') + problem['group'] = info.get('short', info['name']) + problem['url'] = info['url'] + problems_infos[get_problem_short(problem)] = problem + + exclude_advances = {} + if advances and advances.get('exclude_stages'): + qs = Statistics.objects \ + .filter(contest__stage__in=advances['exclude_stages'], addition___advance__isnull=False) \ + .values('account__key', 'addition___advance', 'contest__title') \ + .order_by('contest__end_time', 'contest__id') + for r in qs: + d = r['addition___advance'] + if 'contest' not in d: + d['contest'] = r['contest__title'] + exclude_advances[r['account__key']] = d + + statistics = Statistics.objects \ + .select_related('account', 'account__duplicate') \ + .prefetch_related('account__coders') + filter_statistics = self.score_params.get('filter_statistics') + if filter_statistics: + statistics = statistics.filter(**filter_statistics) + exclude_statistics = self.score_params.get('exclude_statistics') + if exclude_statistics: + statistics = statistics.exclude(**exclude_statistics) + re_ranking = self.score_params.get('re_ranking') + + def get_placing(placing, stat): + return placing['division'][stat.addition['division']] if 'division' in placing else placing + + account_keys = dict() + total = statistics.filter(contest__in=contests).count() + with tqdm.tqdm(total=total, desc=f'getting statistics for stage {stage}') as pbar, print_sql(count_only=True): + for idx, contest in enumerate(contests, start=1): + skip_problem_stat = '_skip_for_problem_stat' in contest.info.get('fields', []) + contest_unrated = contest.info.get('unrated') + + if not detail_problems: + problem_info_key = str(contest.pk) + problem_short = get_problem_short(problems_infos[problem_info_key]) + pbar.set_postfix(contest=contest) + stats = statistics.filter(contest_id=contest.pk) + + if placing: + placing_scores = deepcopy(placing) + n_rows = 0 + for s in stats: + n_rows += 1 + placing_ = get_placing(placing_scores, s) + key = str(s.place_as_int) + if key in placing_: + placing_.setdefault('scores', {}) + placing_['scores'][key] = placing_.pop(key) + scores = [] + for place in reversed(range(1, n_rows + 1)): + placing_ = get_placing(placing_scores, s) + key = str(place) + if key in placing_: + scores.append(placing_.pop(key)) + else: + if scores: + placing_['scores'][key] += sum(scores) + placing_['scores'][key] /= len(scores) + 1 + scores = [] + + max_solving = 0 + n_effective = 0 + for stat in stats: + max_solving = max(max_solving, stat.solving) + n_effective += stat.solving > eps + + if re_ranking: + order = contest.get_statistics_order() + stats = stats.order_by(*order) + stat_rank = 0 + stat_last = None + stat_attrs = [attr.strip('-') for attr in order] + + for stat_idx, stat in enumerate(stats, start=1): + if not detail_problems and not skip_problem_stat: + problems_infos[problem_info_key].setdefault('n_total', 0) + problems_infos[problem_info_key]['n_total'] += 1 + + if re_ranking: + stat_value = tuple(get_item(stat, attr) for attr in stat_attrs) + if stat_value != stat_last: + stat_rank = stat_idx + stat_last = stat_value + rank = stat_rank + else: + rank = stat.place_as_int + + score = None + if stat.solving < eps: + score = 0 + if placing: + placing_ = get_placing(placing_scores, stat) + score = placing_.get('zero', 0) + else: + if placing: + placing_ = get_placing(placing_scores, stat) + score = placing_['scores'].get(str(rank), placing_.get('default')) + if score is None: + continue + if scoring: + if score is None: + score = 0 + if scoring['name'] == 'general': + if rank is None: + continue + solving_factor = stat.solving / max_solving + rank_factor = (n_effective - rank + 1) / n_effective + score += scoring['factor'] * solving_factor * rank_factor + elif scoring['name'] == 'field': + score += stat.addition.get(scoring['field'], 0) + else: + raise NotImplementedError(f'scoring {scoring["name"]} is not implemented') + if score is None: + score = stat.solving + + if not detail_problems and not skip_problem_stat: + problems_infos[problem_info_key].setdefault('n_teams', 0) + problems_infos[problem_info_key]['n_teams'] += 1 + if score: + problems_infos[problem_info_key].setdefault('n_accepted', 0) + problems_infos[problem_info_key]['n_accepted'] += 1 + + account = stat.account + if account.duplicate is not None: + account = account.duplicate + + coders = account.coders.all() + has_mapping_account_by_coder = False + if len(coders) == 1: + coder = coders[0] + if coder not in mapping_account_by_coder: + mapping_account_by_coder[coder] = account + else: + account = mapping_account_by_coder[coder] + has_mapping_account_by_coder = True + + row = results[account] + row['member'] = account + account_keys[account.key] = account + + problems = row.setdefault('problems', {}) + if detail_problems: + for key, problem in stat.addition.get('problems', {}).items(): + p = problems.setdefault(f'{idx}.' + key, {}) + if contest_unrated: + p = p.setdefault('upsolving', {}) + p.update(problem) + else: + problem = problems.setdefault(problem_short, {}) + if contest_unrated: + problem = problem.setdefault('upsolving', {}) + problem['result'] = score + url = stat.addition.get('url') + if not url: + url = reverse('ranking:standings_by_id', kwargs={'contest_id': str(contest.pk)}) + url += f'?find_me={stat.pk}' + problem['url'] = url + if contest_unrated: + score = 0 + + if n_best and not detail_problems: + row.setdefault('scores', []).append((score, problem)) + else: + row['score'] = row.get('score', 0) + score + + field_values = {} + for field in fields: + if field.get('skip_on_mapping_account_by_coder') and has_mapping_account_by_coder: + continue + if 'type' in field: + continue + inp = field['field'] + out = field.get('out', inp) + if field.get('first') and out in row or (inp not in stat.addition and not hasattr(stat, inp)): + continue + val = stat.addition[inp] if inp in stat.addition else getattr(stat, inp) + if not field.get('safe') and isinstance(val, str): + val = ast.literal_eval(val) + if 'cast' in field: + val = locate(field['cast'])(val) + field_values[out] = val + if field.get('skip'): + continue + if field.get('accumulate'): + val = round(val + ast.literal_eval(str(row.get(out, 0))), 2) + if field.get('aggregate') == 'avg': + out_n = f'_{out}_n' + out_s = f'_{out}_s' + row[out_n] = row.get(out_n, 0) + 1 + row[out_s] = row.get(out_s, 0) + val + val = round(row[out_s] / row[out_n], 2) + if field.get('aggregate') == 'max' and out in row: + val = max(val, row[out]) + row[out] = val + + if 'solved' in stat.addition and isinstance(stat.addition['solved'], dict): + solved = row.setdefault('solved', {}) + for k, v in stat.addition['solved'].items(): + solved[k] = solved.get(k, 0) + v + + if 'status' in self.score_params: + field = self.score_params['status'] + val = field_values.get(field, row.get(field)) + if val is None: + val = getattr(stat, field) + if val is not None: + problem['status'] = val + else: + for field in order_by: + field = field.lstrip('-') + if field in ['score', 'rating', 'penalty']: + continue + status = field_values.get(field, row.get(field)) + if status is None: + continue + problem['status'] = status + break + pbar.update() + + for writer in contest.writers.all(): + account_keys[writer.key] = writer + + total = sum([len(contest.info.get('writers', [])) for contest in contests]) + with tqdm.tqdm(total=total, desc=f'getting writers for stage {stage}') as pbar, print_sql(count_only=True): + writers = set() + for contest in contests: + contest_writers = contest.info.get('writers', []) + if not contest_writers or detail_problems: + continue + problem_info_key = str(contest.pk) + problem_short = get_problem_short(problems_infos[problem_info_key]) + for writer in contest_writers: + if writer in account_keys: + account = account_keys[writer] + else: + try: + account = Account.objects.get(resource_id=contest.resource_id, key__iexact=writer) + except Account.DoesNotExist: + account = None + + pbar.update() + if not account: + continue + writers.add(account) + + row = results[account] + row['member'] = account + row.setdefault('score', 0) + if n_best: + row.setdefault('scores', []) + row.setdefault('writer', 0) + + row['writer'] += 1 + + problems = row.setdefault('problems', {}) + problem = problems.setdefault(problem_short, {}) + problem['status'] = 'W' + + if self.score_params.get('writers_proportionally_score'): + n_contests = sum(contest.start_time < timezone_now for contest in contests) + for account in writers: + row = results[account] + if n_contests == row['writer'] or 'score' not in row: + continue + row['score'] = row['score'] / (n_contests - row['writer']) * n_contests + if self.score_params.get('exponential_score_decay'): + for r in results.values(): + scores = [problem.get('result', 0) for problem in r.get('problems', {}).values()] + scores.sort(reverse=True) + k = self.score_params['exponential_score_decay'] + score = k * sum((1 - k) ** i * score for i, score in enumerate(scores)) + r['score'] = score + + for field in fields: + t = field.get('type') + if t is None: + continue + if t == 'points_for_common_problems': + group = field['group'] + inp = field['field'] + out = field.get('out', inp) + + excluding = bool(exclude_advances and field.get('exclude_advances')) + + groups = collections.defaultdict(list) + for row in results.values(): + key = row[group] + groups[key].append(row) + + advancement_position = 1 + for key, rows in sorted(groups.items(), key=lambda kv: kv[0], reverse=True): + common_problems = None + for row in rows: + handle = row['member'].key + if excluding and handle in exclude_advances: + exclude_advance = exclude_advances[handle] + for advance in advances.get('options', []): + if advance['next'] == exclude_advance['next']: + exclude_advance['skip'] = True + break + if advancement_position in advance['places']: + break + if exclude_advance.get('skip'): + continue + + problems = {k for k, p in row['problems'].items() if p.get('status') != 'W'} + common_problems = problems if common_problems is None else (problems & common_problems) + if common_problems is None: + for row in rows: + problems = {k for k, p in row['problems'].items() if p.get('status') != 'W'} + common_problems = problems if common_problems is None else (problems & common_problems) + + for row in rows: + handle = row['member'].key + if excluding and not exclude_advances.get(handle, {}).get('skip', False): + advancement_position += 1 + value = 0 + for k in common_problems: + value += float(row['problems'].get(k, {}).get(inp, 0)) + for k, v in row['problems'].items(): + if k not in common_problems and v.get('status') != 'W': + v['status_tag'] = 'strike' + row[out] = round(value, 2) + elif t == 'region_by_country': + out = field['out'] + + mapping_regions = dict() + for regional_event in field['data']: + for region in regional_event['regions']: + mapping_regions[region['code']] = {'regional_event': regional_event, 'region': region} + + for row in results.values(): + country = row['member'].country + if not country: + continue + code = country.code + if code not in mapping_regions: + continue + row[out] = mapping_regions[code]['regional_event']['name'] + elif t == 'n_medal_problems': + for row in results.values(): + for problem in row['problems'].values(): + medal = problem.get('medal') + if medal: + k = f'n_{medal}_problems' + row.setdefault(k, 0) + row[k] += 1 + if k not in hidden_fields: + hidden_fields.append(k) + for field in settings.STANDINGS_FIELDS_: + if field in hidden_fields: + fixed_fields.append(field) + hidden_fields.remove(field) + else: + raise ValueError(f'Unknown field type = {t}') + + hidden_fields += [field.get('out', field.get('inp')) for field in fields if field.get('hidden')] + + results = list(results.values()) + if n_best: + for row in results: + scores = row.pop('scores') + for index, (score, problem) in enumerate(sorted(scores, key=lambda s: s[0], reverse=True)): + if index < n_best: + row['score'] = row.get('score', 0) + score + else: + problem['status'] = problem.pop('result') + + filtered_results = [] + filter_zero_points = self.score_params.get('filter_zero_points', True) + for r in results: + if r['score'] > eps or not filter_zero_points or r.get('writer'): + filtered_results.append(r) + continue + if detail_problems: + continue + + problems = r.setdefault('problems', {}) + + for idx, contest in enumerate(contests, start=1): + skip_problem_stat = '_skip_for_problem_stat' in contest.info.get('fields', []) + if skip_problem_stat: + continue + + problem_info_key = str(contest.pk) + problem_short = get_problem_short(problems_infos[problem_info_key]) + + if problem_short in problems: + problems_infos[problem_info_key].setdefault('n_teams', 0) + problems_infos[problem_info_key]['n_teams'] -= 1 + results = filtered_results + + results = sorted( + results, + key=lambda r: tuple(r.get(k.lstrip('-'), 0) * (-1 if k.startswith('-') else 1) for k in order_by), + reverse=True, + ) + + additions = deepcopy(stage.info.get('additions', {})) + field_to_problem = self.score_params.get('field_to_problem') + with transaction.atomic(): + fields_set = set() + fields = list() + + pks = set() + placing_infos = {} + score_advance = None + place_advance = 0 + place_index = 0 + for row in tqdm.tqdm(results, desc=f'update statistics for stage {stage}'): + for field in [row.get('member'), row.get('name')]: + row.update(additions.pop(field, {})) + + division = row.get('division', 'none') + placing_info = placing_infos.setdefault(division, {}) + placing_info['index'] = placing_info.get('index', 0) + 1 + + curr_score = tuple(row.get(k.lstrip('-'), 0) for k in order_by) + if curr_score != placing_info.get('last_score'): + placing_info['last_score'] = curr_score + placing_info['place'] = placing_info['index'] + + if advances and ('divisions' not in advances or division in advances['divisions']): + tmp = score_advance, place_advance, place_index + + place_index += 1 + if curr_score != score_advance: + score_advance = curr_score + place_advance = place_index + + for advance in advances.get('options', []): + handle = row['member'].key + if handle in exclude_advances and advance['next'] == exclude_advances[handle]['next']: + advance = exclude_advances[handle] + if 'class' in advance and not advance['class'].startswith('text-'): + advance['class'] = f'text-{advance["class"]}' + row['_advance'] = advance + break + + if 'places' in advance and place_advance in advance['places']: + if not advances.get('inplace_fields_only'): + row['_advance'] = advance + if is_over: + for field in advance.get('inplace_fields', []): + row[field] = advance[field] + tmp = None + break + + if tmp is not None: + score_advance, place_advance, place_index = tmp + account = row.pop('member') + solving = row.pop('score') + + advanced = False + if row.get('_advance'): + adv = row['_advance'] + advanced = not adv.get('skip') and not adv.get('class', '').startswith('text-') + + defaults = { + 'place': str(placing_info['place']), + 'place_as_int': placing_info['place'], + 'solving': solving, + 'addition': row, + 'skip_in_stats': True, + 'advanced': advanced, + } + if parsed_statistic: + defaults['place'] = None + defaults['place_as_int'] = None + defaults['solving'] = 0 + stat = Statistics.objects.filter(account=account, contest=stage).first() + if not stat: + continue + for k, v in defaults['addition'].items(): + if k not in stat.addition or (v and not stat.addition.get(k)): + stat.addition[k] = v + stat.skip_in_stats = defaults['skip_in_stats'] + stat.advanced = defaults['advanced'] + stat.save(update_fields=['addition', 'skip_in_stats', 'advanced']) + else: + stat, created = Statistics.objects.update_or_create( + account=account, + contest=stage, + defaults=defaults, + ) + pks.add(stat.pk) + + for k in stat.addition.keys(): + if field_to_problem and re.search(field_to_problem['regex'], k): + continue + if k not in fields_set: + fields_set.add(k) + fields.append(k) + stage.info['problems'] = list(problems_infos.values()) + + if field_to_problem: + for stat in stage.statistics_set.all(): + problems = stat.addition.setdefault('problems', {}) + was_updated = False + for k, v in list(stat.addition.items()): + match = re.search(field_to_problem['regex'], k) + if match: + problem_short = field_to_problem['format'].format(**match.groupdict()) + if problem_short not in problems: + problems[problem_short] = {'result': v} + stat.addition.pop(k) + if 'fields_types' in stage.info: + stage.info['fields_types'].pop(k, None) + was_updated = True + if was_updated: + stat.save(update_fields=['addition']) + + if parsed_statistic: + for field in stage.info.setdefault('hidden_fields', []): + if field not in hidden_fields: + hidden_fields.append(field) + regex_hidden_fields = self.score_params.get('regex_hidden_fields') + if regex_hidden_fields: + for field in fields: + if field not in hidden_fields and re.search(regex_hidden_fields, field): + hidden_fields.append(field) + stage.info['fields'] = list(fields) + stage.info['hidden_fields'] = hidden_fields + + fields_types = self.score_params.get('fields_types', {}) + if fields_types: + stage.info.setdefault('fields_types', {}).update(fields_types) + + if not parsed_statistic: + stage.statistics_set.exclude(pk__in=pks).delete() + stage.n_statistics = len(results) + stage.parsed_time = timezone_now + + standings_info = self.score_params.get('info', {}) + standings_info['fixed_fields'] = fixed_fields + [(f.lstrip('-'), f.lstrip('-')) for f in order_by] + stage.info['standings'] = standings_info + + if divisions_order and self.score_params.get('divisions_ordering'): + stage.info['divisions_order'] = divisions_order + + if stage.is_rated is None: + stage.is_rated = False + stage.save() diff --git a/src/ranking/views.py b/src/ranking/views.py index 98b86994..acd15030 100644 --- a/src/ranking/views.py +++ b/src/ranking/views.py @@ -4,7 +4,6 @@ import re from collections import OrderedDict, defaultdict from functools import reduce -from itertools import accumulate import arrow from asgiref.sync import async_to_sync @@ -50,7 +49,8 @@ @page_template('standings_list_paging.html') @extra_context_without_pagination('clist.view_full_table') -def standings_list(request, template='standings_list.html', extra_context=None): +@context_pagination() +def standings_list(request, template='standings_list.html'): contests = ( Contest.objects.annotate_favorite(request.user) .select_related('resource', 'stage') @@ -89,10 +89,12 @@ def standings_list(request, template='standings_list.html', extra_context=None): 'writer': {'fields': ['info__writers__contains']}, 'coder': {'fields': ['statistics__account__coders__username']}, 'account': {'fields': ['statistics__account__key', 'statistics__account__name'], 'suff': '__iregex'}, - 'stage': {'fields': ['stage'], 'suff': '__isnull', 'func': lambda v: False}, - 'kind': {'fields': ['kind'], 'suff': '__isnull', 'func': lambda v: False}, - 'medal': {'fields': ['with_medals'], 'func': lambda v: True}, - 'advance': {'fields': ['with_advance'], 'func': lambda v: True}, + 'stage': {'fields': ['stage'], 'suff': '__isnull', 'func': lambda v: v not in settings.YES_}, + 'kind': {'fields': ['kind'], 'suff': '__isnull', 'func': lambda v: v not in settings.YES_}, + 'medal': {'fields': ['with_medals'], 'func': lambda v: v in settings.YES_}, + 'related_set': {'fields': ['related_set'], 'suff': '__isnull', + 'func': lambda v: v not in settings.YES_}, + 'advance': {'fields': ['with_advance'], 'func': lambda v: v in settings.YES_}, 'year': {'fields': ['start_time__year', 'end_time__year']}, 'invisible': {'fields': ['invisible'], 'func': lambda v: v in settings.YES_}, 'has_problems': {'fields': ['n_problems'], 'suff': '__isnull', @@ -127,7 +129,10 @@ def standings_list(request, template='standings_list.html', extra_context=None): series = [s for s in request.GET.getlist('series') if s] if series: - series = list(ContestSeries.objects.filter(slug__in=series)) + if 'all' in series: + series = list(ContestSeries.objects.all()) + else: + series = list(ContestSeries.objects.filter(slug__in=series)) link_series = request.GET.get('link_series') in settings.YES_ link_series = link_series and request.user.has_perm('clist.change_contestseries') link_series = link_series and len(series) == 1 @@ -240,10 +245,7 @@ def standings_list(request, template='standings_list.html', extra_context=None): 'contests': contests, }) - if extra_context is not None: - context.update(extra_context) - - return render(request, template, context) + return template, context def _standings_highlight(contest, statistics, options): @@ -546,6 +548,7 @@ def timeline_format(t): problems_chart = dict( field='solved_problems', type='line', + accumulate=True, fields=[], labels={}, bins=problems_bins, @@ -562,7 +565,7 @@ def timeline_format(t): values = problems_values.get(short, []) total_values.extend(values) hist, _ = make_histogram(values=values, bins=problems_bins) - for val, d in zip(accumulate(hist), problems_chart['data']): + for val, d in zip(hist, problems_chart['data']): d[short] = val problems_chart['fields'].append(short) problems_chart['labels'][short] = get_problem_title(problem) @@ -600,7 +603,7 @@ def timeline_format(t): deltas = [v[1] for v in values] values = [v[0] for v in values] hist, _ = make_histogram(values=values, deltas=deltas, bins=problems_bins) - for val, d in zip(accumulate(hist), problems_scoring_chart['data']): + for val, d in zip(hist, problems_scoring_chart['data']): d[short] = val charts.append(problems_scoring_chart) @@ -611,9 +614,10 @@ def timeline_format(t): fields=False, labels=False, my_dataset=None, + accumulate=False, )) hist, _ = make_histogram(values=total_values, bins=problems_bins) - for val, d in zip(accumulate(hist), total_solved_chart['data']): + for val, d in zip(hist, total_solved_chart['data']): d['value'] = val if total_values: charts.append(total_solved_chart) @@ -629,7 +633,7 @@ def timeline_format(t): values = [v[0] for v in total_scoring_values] deltas = [v[1] for v in total_scoring_values] hist, _ = make_histogram(values=values, deltas=deltas, bins=problems_bins) - for val, d in zip(accumulate(hist), total_scoring_chart['data']): + for val, d in zip(hist, total_scoring_chart['data']): d['value'] = val charts.append(total_scoring_chart) @@ -701,7 +705,7 @@ def render_standings_paging(contest, statistics, with_detail=True): statistics = statistics[:per_page] statistics = Statistics.objects.filter(pk__in=statistics) - order = get_statistics_order(contest) + ['pk'] + order = contest.get_statistics_order() + ['pk'] statistics = statistics.order_by(*order) divisions_order = get_standings_divisions_order(contest) @@ -898,21 +902,6 @@ def get_standings_fields(contest, division, with_detail, hidden_fields=None, hid return fields -def get_statistics_order(contest): - options = contest.info.get('standings', {}) - contest_fields = contest.info.get('fields', []).copy() - resource_standings = contest.resource.info.get('standings', {}) - order = copy.copy(options.get('order', resource_standings.get('order'))) - if order: - for f in order: - if f.startswith('addition__') and f.split('__', 1)[1] not in contest_fields: - order = None - break - if order is None: - order = ['place_as_int', '-solving'] - return order - - def get_advancing_contests(contest): ret = set() @@ -1005,7 +994,7 @@ def standings(request, contest, other_contests=None, template='standings.html', per_page = 50 if contests_ids else contest.standings_per_page per_page_more = per_page if find_me else 200 - order = get_statistics_order(contest) + order = contest.get_statistics_order() statistics = statistics \ .select_related('account') \ diff --git a/src/scripts/repack-database.bash b/src/scripts/repack-database.bash new file mode 100755 index 00000000..372b7337 --- /dev/null +++ b/src/scripts/repack-database.bash @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +date + +name=repack_database +exec 200>/tmp/$name.lock +flock -n 200 || { echo "Script '$name' is already running"; exit 0; } + + +function human_readable_size { + local size=$1 + if [ $size -lt 0 ]; then + size=$(($size * -1)) + echo -n "-" + fi + numfmt --to=iec --format="%.2f" $size +} + +table_filter=${1:-"."} +shift + +export PGHOST=$POSTGRES_HOST +export PGPORT=$POSTGRES_PORT +export PGDATABASE=$POSTGRES_DB +export PGUSER=$POSTGRES_USER +export PGPASSWORD=$POSTGRES_PASSWORD + +set -u -e + +psql_command="psql -t -c" +tables=$($psql_command "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public';" | grep $table_filter | xargs) +echo "Tables ($(echo $tables | wc -w)): $tables" + +total_saved=0 +for table in $tables; do + table_size=$($psql_command "SELECT pg_total_relation_size('public.$table');" | xargs) + disk_free_size=$(df -B1 / | tail -1 | awk '{print $4}') + threshold_size=$(echo "($disk_free_size * 0.8)/1" | bc | xargs) + echo "Table $table size: $(human_readable_size $table_size)" + echo "Disk free: $(human_readable_size $disk_free_size), threshold size (80%): $(human_readable_size $threshold_size)" + if [ $table_size -gt $threshold_size ]; then + echo "Skipping table $table because it is too big, table size = $(human_readable_size $table_size)" + indices=$($psql_command "SELECT indexname FROM pg_indexes WHERE tablename='$table';") + for index in $indices; do + index_size=$($psql_command "SELECT pg_total_relation_size('public.$index');" | xargs) + if [ $index_size -gt $threshold_size ]; then + echo "Skipping index $index because it is too big, index size = $(human_readable_size $index_size)" + else + pg_repack --index $index + fi + done + else + echo "Repacking (cluster) table $table" + pg_repack --table $table + echo "Repacking (vacuum full) table $table" + pg_repack --table $table --no-order + fi + new_table_size=$($psql_command "SELECT pg_total_relation_size('public.$table');" | xargs) + echo "Table $table new size: $(human_readable_size $new_table_size), saved $(human_readable_size $(($table_size - $new_table_size)))" + total_saved=$(($total_saved + $table_size - $new_table_size)) +done +echo "Total saved: $(human_readable_size $total_saved)" diff --git a/src/scripts/up.bash b/src/scripts/up.bash index 52175dcd..fc06cafa 100755 --- a/src/scripts/up.bash +++ b/src/scripts/up.bash @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -e -x +set -e cd "$(dirname "$0")" @@ -13,10 +13,21 @@ function allow_to_proxy() { bridge="br-clist" proxy_port=3128 comment="from $name to proxy" - sudo ufw status numbered | grep "# $comment" | grep -o -E "[0-9]+" | head -n 1 | xargs --no-run-if-empty -I {} sh -c 'yes | sudo ufw delete {}' - sudo ufw allow in on $bridge from $ip to any port $proxy_port comment "$comment" + + existing_rule=$(sudo ufw status numbered | grep "$comment" | grep "$ip") + if [ -n "$existing_rule" ]; then + echo "Rule already exists: $existing_rule" + return + fi + + rule_number=$(sudo ufw status numbered | grep "# $comment" | grep -o -E "[0-9]+" | head -n 1) + if [ -n "$rule_number" ]; then + yes | sudo ufw delete $rule_number + fi + (set -x; sudo ufw allow in on $bridge from $ip to any port $proxy_port comment "$comment") } +allow_to_proxy "prod" allow_to_proxy "dev" allow_to_proxy "legacy" diff --git a/src/static/css/base.css b/src/static/css/base.css index e729bdd1..f9ed5a90 100644 --- a/src/static/css/base.css +++ b/src/static/css/base.css @@ -112,6 +112,21 @@ body { flex: 1; } +.center-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + text-align: center; +} + +.scroll-container { + max-height: 100vh; + overflow-y: auto; + box-sizing: border-box; + padding: 50px; +} + img.resource-icon { vertical-align: top; margin-top: 3px; @@ -825,6 +840,16 @@ a[disabled] { padding: 0px; } +#filter-collapse .input-group { + display: inline-table; + vertical-align: middle; +} + +#filter-collapse .input-group-addon, +#filter-collapse .input-group-btn { + width: auto; +} + #filter-toggle { margin-bottom: 5px; font-size: 10px; @@ -957,3 +982,12 @@ a[data-toggle="tooltip"][disabled], font-size: 80%; cursor: pointer; } + + +/* + * select2 + */ + +.select2-search__field:not([placeholder=""]) { + width: 100% !important; +} diff --git a/src/static/css/form.css b/src/static/css/form.css new file mode 100644 index 00000000..36ff1afc --- /dev/null +++ b/src/static/css/form.css @@ -0,0 +1,25 @@ +#form .btn { + margin-bottom: 15px; +} + +.iframe-panel { + border-radius: 20px; + overflow: hidden; + padding: 40px; +} + +.center-container .scroll-container { + padding: 50px 200px; +} + +@media (max-width: 1200px) { + .center-container .scroll-container { + padding: 50px 100px; + } +} + +@media (max-width: 768px) { + .center-container .scroll-container { + padding: 50px 50px; + } +} diff --git a/src/static/css/settings.css b/src/static/css/settings.css index 20c6ad8c..fc792002 100644 --- a/src/static/css/settings.css +++ b/src/static/css/settings.css @@ -121,6 +121,11 @@ form.editableform .has-error .editable-input .form-control:focus { text-align: right; } +#subscriptions .subscription-account, +#subscriptions .subscription-coder { + margin-right: 10px; +} + .account-suggest { margin-right: 5px; } @@ -129,3 +134,16 @@ form.editableform .has-error .editable-input .form-control:focus { display: inline-block; max-width: 100%; } + +#list-accounts .panel-container { + display: inline-block; + max-width: 100%; +} + +#subscription_form .form-group .toggle { + display: block; +} + +#subscription_form .form-group:has(input:disabled) { + color: #888; +} diff --git a/src/static/css/standings.css b/src/static/css/standings.css index dbdd863d..2a698f55 100644 --- a/src/static/css/standings.css +++ b/src/static/css/standings.css @@ -223,6 +223,10 @@ a.solution:hover span { z-index: 10; } +#table-inner-scroll table tr:not(.info) .sticky-column { + background-color: inherit; +} + .table tr:nth-of-type(n).info td { background-color: #d9edf7; } diff --git a/src/static/img/resources/azspcs_com.png b/src/static/img/resources/azspcs_com.png new file mode 100644 index 0000000000000000000000000000000000000000..6dec68c16936ed178f437f8f8892efb4a4e1e902 GIT binary patch literal 12999 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 literal 0 HcmV?d00001 diff --git a/src/static/img/resources/calico_cs_berkeley_edu.png b/src/static/img/resources/calico_cs_berkeley_edu.png new file mode 100644 index 0000000000000000000000000000000000000000..f155e8537c195a6d98002830f2df6474512bcfff GIT binary patch literal 30550 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�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 TRV#q#9{i{%YQoCp%|rhONAYy} literal 0 HcmV?d00001 diff --git a/src/static/img/resources/coliseum_ai.png b/src/static/img/resources/coliseum_ai.png new file mode 100644 index 0000000000000000000000000000000000000000..d1c41334a1deeab15eeea6b8427f2c14eb587ac9 GIT binary patch literal 4764 zcmZ`-cQ72>yWSv(o`~L-4H9*Av07NYxA=6+Dy!|r?kYj_6(w3ih!WkW*9c*W-UZQF zAzDO@$O?ih_s-n;&7HY(<~`3j&pXfi&Y5%0KToW&p%yI_I~4!`pw-canqDLPZ%~q7 z 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ΝV#MkDPXDb2>9iE*TG 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 O038iOXtkPS#D4)GjrHgN literal 0 HcmV?d00001 diff --git a/src/static/img/resources/kattis_com.png b/src/static/img/resources/kattis_com.png new file mode 100644 index 0000000000000000000000000000000000000000..1f7e3fccd9d74c7494c45214d427aa46f8fd4555 GIT binary patch literal 29794 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 literal 0 HcmV?d00001 diff --git a/src/static/js/base.js b/src/static/js/base.js index c0598620..c36f57b5 100644 --- a/src/static/js/base.js +++ b/src/static/js/base.js @@ -200,7 +200,7 @@ function log_ajax_error(response, element = null) { message = response.responseJSON.message } } else { - if (response.responseText.length < 100) { + if (response.responseText && response.responseText.length < 100) { message = "{status} {statusText}: {responseText}".format(response) } else { message = "{status} {statusText}, more in console".format(response) @@ -213,6 +213,7 @@ function log_ajax_error(response, element = null) { } else { $.notify(message, 'error') } + $('.bootbox').effect('shake') } function log_ajax_error_callback(response) { @@ -657,7 +658,7 @@ function coders_select(id, submit) { data: function (params) { return { query: 'coders', - regex: params.term, + search: params.term, page: params.page || 1 } }, @@ -781,3 +782,24 @@ function is_12_hour_clock() { var time_parts = date_time_format.formatToParts(new Date()) return time_parts.some(time_part => time_part.type === 'dayPeriod') } + + +/* + * select2 + */ + +$(() => { + $('[data-init="select2"]').select2({theme: 'bootstrap', dropdownAutoWidth: true}) +}) + + +function show_extra(element) { + var $element = $(element) + var extra_id = $element.data('id') + var $extra = $('#' + extra_id) + $extra.toggleClass('hidden') + + var text = $element.data('toggle-text') || 'hide' + $element.data('toggle-text', $element.text()) + $element.text(text) +} diff --git a/src/static/js/contest/calendar.js b/src/static/js/contest/calendar.js index c49fe31d..149753fb 100644 --- a/src/static/js/contest/calendar.js +++ b/src/static/js/contest/calendar.js @@ -117,6 +117,10 @@ $(function() { .toArray(), }, success: function (response) { + response.forEach(function(event) { + event.original_start = event.start + event.original_end = event.end + }) successCallback(response) }, error: function(response) { @@ -128,8 +132,8 @@ $(function() { eventDidMount: function (info) { var event = info.event var element = info.el - var start = FullCalendar.Moment.toMoment(event.start, calendar) - var end = FullCalendar.Moment.toMoment(event.end, calendar) + var start = FullCalendar.Moment.toMoment(event.extendedProps.original_start, calendar) + var end = FullCalendar.Moment.toMoment(event.extendedProps.original_end, calendar) var now = FullCalendar.Moment.toMoment($.now(), calendar) var countdown = event.extendedProps.countdown var start_time = start.format('YYYY-MM-DD HH:mm') diff --git a/src/static/js/contest/main.js b/src/static/js/contest/main.js index a702a4a1..9c667f8a 100644 --- a/src/static/js/contest/main.js +++ b/src/static/js/contest/main.js @@ -61,7 +61,7 @@ $(function() { var modal = $(this) modal.find('.modal-title').text(title) modal.find('[name="contest_id"]').val(contest_id) - modal.find('select option[value="' + method + '"]').attr('selected', 'true') + modal.find('select option[value="' + method + '"]').attr('selected', 'true').trigger('change') }) $('#send_notification form').submit(function(e) { @@ -74,7 +74,8 @@ $(function() { }).done(function() { form.reset(); $('#send_notification').modal('hide') - }).fail(function() { + }).fail(function(response) { + log_ajax_error(response) $('#send_notification').effect("shake") }); e.preventDefault(); diff --git a/src/static/js/settings.js b/src/static/js/settings.js index 85bc67e8..57080502 100644 --- a/src/static/js/settings.js +++ b/src/static/js/settings.js @@ -639,7 +639,7 @@ $(function() { var $div = $('\ <div> \ <a href="#" class="filter" data-name="filter" data-value=\'' + JSON.stringify(data) + '\'></a> \ - <a href="#" data-id="' + data.id + '" data-action="delete-filter" data-success="$div.remove();" class="action-filter btn btn-default btn-xs"> \ + <a href="#" data-id="' + data.id + '" data-action="delete-filter" data-success="$element.remove();" class="action-filter btn btn-default btn-xs"> \ <i class="far fa-trash-alt"></i> \ </a> \ </div> \ @@ -894,51 +894,150 @@ $(function() { function process_subscription() { name = $(this).attr('data-name') + data = $(this).data('form') var form = $(` - <form id="process_subscription-form"> + <h3>` + (data? 'Edit' : 'Create') + ` subscription</h3> + <form id="subscription_form"> + <div class="form-group"> + <label class="control-label">Resource</label> + <select class="form-control" name="resource"></select> + </div> <div class="form-group"> <label class="control-label">Contest</label> - <select class="form-control" name="contest" required></select> - <small class="form-text text-muted">Required field</small> + <select class="form-control" name="contest"></select> + </div> + <div class="form-group"> + <label class="control-label">First accepted</label> + <input type="checkbox" data-toggle="toggle" data-on="On" data-off="Off" data-onstyle="default" data-offstyle="default" data-size="normal" name="with_first_accepted"> + </div> + <div class="form-group"> + <label class="control-label">Top n</label> + <input type="number" class="form-control" name="top_n" min="1" max="` + SUBSCRIPTION_TOP_N_LIMIT + `"> + </div> + <div class="form-group"> + <label class="control-label">Accounts <a href="` + ACCOUNTS_URL + `" target="_blank">` + EXTRA_URL_ICON + `</a></label> + <select class="form-control" name="accounts" multiple></select> + <small class="form-text text-muted">Limit ` + SUBSCRIPTION_N_LIMIT + ` accounts</small> + </div> + <div class="form-group"> + <label class="control-label">Coders <a href="` + CODERS_URL + `" target="_blank">` + EXTRA_URL_ICON + `</a></label> + <select class="form-control" name="coders" multiple></select> + <small class="form-text text-muted">Limit ` + SUBSCRIPTION_N_LIMIT + ` coders</small> + </div> + <div class="form-group"> + <label class="control-label">List <a href="` + LISTS_URL + `" target="_blank">` + EXTRA_URL_ICON + `</a></label> + <select class="form-control" name="list"></select> + <small class="form-text text-muted">Use coders and accounts from updated list</small> </div> <div class="form-group"> - <label class="control-label">Account</label> - <select class="form-control" name="account" required></select> - <small class="form-text text-muted">Will be available after selecting a contest. Required field</small> + <label class="control-label">Chat <a href="` + CHATS_URL + `" target="_blank">` + EXTRA_URL_ICON + `</a></label> + <select class="form-control" name="chat"></select> + <small class="form-text text-muted">Use coders and accounts from updated chat</small> </div> <div class="form-group"> <label class="control-label">Method</label> <select class="form-control" name="method" required></select> - <small class="form-text text-muted">Required field</small> + <small class="form-text text-muted">Notification method</small> </div> + <input type="hidden" name="no_stage" value="true"> </form> `); + function set_disabled() { + var disable_addition = !$select_resource.val() && !$select_contest.val() + $with_first_accepted.prop('disabled', disable_addition) + $top_n.prop('disabled', disable_addition) + + var disabled_chat_and_list = $select_accounts.val().length || $select_coders.val().length + var disabled_accounts_and_coders = $select_coder_list.val() || $select_coder_chat.val() + $select_coders.prop('disabled', disabled_accounts_and_coders) + $select_accounts.prop('disabled', disabled_accounts_and_coders) + $select_coder_chat.prop('disabled', $select_coder_list.val() || disabled_chat_and_list) + $select_coder_list.prop('disabled', $select_coder_chat.val() || disabled_chat_and_list) + } + + var $select_resource = form.find('select[name=resource]') + $select_resource.select2({ + dropdownAutoWidth : true, + width: '100%', + theme: 'bootstrap', + placeholder: 'Select resource', + ajax: select2_ajax_conf('resources', 'regex'), + minimumInputLength: 0, + allowClear: true, + }) + $select_resource.on('change', set_disabled) + + var $select_no_stage = form.find('input[name=no_stage]') var $select_contest = form.find('select[name=contest]') $select_contest.select2({ dropdownAutoWidth : true, width: '100%', theme: 'bootstrap', placeholder: 'Select contest', - ajax: select2_ajax_conf('contest-for-add-subscription', 'regex'), + ajax: select2_ajax_conf('contests', 'regex', {resource: $select_resource, 'no_stage': $select_no_stage}), + minimumInputLength: 0, + allowClear: true, + }) + $select_contest.on('change', set_disabled) + + var $with_first_accepted = form.find('input[name=with_first_accepted]') + $with_first_accepted.bootstrapToggle() + $with_first_accepted.prop('disabled', true) + + var $top_n = form.find('input[name=top_n]') + $top_n.prop('disabled', true) + + var $select_accounts = form.find('select[name=accounts]') + $select_accounts.select2({ + dropdownAutoWidth : true, + width: '100%', + theme: 'bootstrap', + placeholder: 'Select accounts', + ajax: select2_ajax_conf('accounts', 'search', {contest: $select_contest, resource: $select_resource}), minimumInputLength: 0, + allowClear: true, + multiple: true, }) - $select_contest.on('change', () => { - $select_account.prop('disabled', false) - $select_account.val(null).trigger('change') + $select_accounts.on('change', set_disabled) + + var $select_coders = form.find('select[name=coders]') + $select_coders.select2({ + dropdownAutoWidth : true, + width: '100%', + theme: 'bootstrap', + placeholder: 'Select coders', + ajax: select2_ajax_conf('coders', 'search', {contest: $select_contest, resource: $select_resource}), + minimumInputLength: 0, + allowClear: true, + multiple: true, + }) + $select_coders.on('change', set_disabled) + + var $select_coder_list = form.find('select[name=list]') + $select_coder_list.select2({ + dropdownAutoWidth : true, + width: '100%', + theme: 'bootstrap', + placeholder: 'Select coder list', + ajax: select2_ajax_conf('coder_lists', 'search'), + minimumInputLength: 0, + allowClear: true, }) + $select_coder_list.on('change', set_disabled) - var $select_account = form.find('select[name=account]') - $select_account.select2({ + var $select_coder_chat = form.find('select[name=chat]') + $select_coder_chat.select2({ dropdownAutoWidth : true, width: '100%', theme: 'bootstrap', - placeholder: 'Select account', - ajax: select2_ajax_conf('account-for-add-subscription', 'search', {contest: $select_contest}), - disabled: true, + placeholder: 'Select coder chat', + ajax: select2_ajax_conf('coder_chats', 'search'), minimumInputLength: 0, + allowClear: true, }) + $select_coder_chat.on('change', set_disabled) var $select_method = form.find('select[name=method]') $select_method.select2({ @@ -948,37 +1047,80 @@ $(function() { placeholder: 'Select method', }) + if (data) { + if (data.resource) { + $select_resource.select2('trigger', 'select', {data: data.resource}) + } + if (data.contest) { + $select_contest.select2('trigger', 'select', {data: data.contest}) + } + if (data.coder_list) { + $select_coder_list.select2('trigger', 'select', {data: data.coder_list}) + } else if (data.coder_chat) { + $select_coder_chat.select2('trigger', 'select', {data: data.coder_chat}) + } else { + if (data.accounts) { + data.accounts.forEach(option => { + $select_accounts.append(new Option(option.text, option.id, true, true)).trigger('change') + }) + $select_accounts.trigger('change') + } + if (data.coders) { + data.coders.forEach(option => { + $select_coders.append(new Option(option.text, option.id, true, true)).trigger('change') + }) + $select_coders.trigger('change') + } + } + if (data.with_first_accepted) { + $with_first_accepted.bootstrapToggle('on') + } + if (data.top_n) { + $top_n.val(data.top_n) + } + if (data.method) { + $select_method.select2('trigger', 'select', {data: data.method}) + } + } + bootbox.confirm(form, function(result) { if (!result) { return } - var form = document.getElementById('process_subscription-form') - if (!form.checkValidity()) { - form.reportValidity() - return false - } + + $('.bootbox-accept').attr('disabled', 'disabled') $.ajax({ type: 'POST', url: $.fn.editable.defaults.url, data: { pk: $.fn.editable.defaults.pk, name: name, + resource: $select_resource.val(), contest: $select_contest.val(), - account: $select_account.val(), + accounts: $select_accounts.val(), + coders: $select_coders.val(), + coder_list: $select_coder_list.val(), + coder_chat: $select_coder_chat.val(), method: $select_method.val(), + with_first_accepted: $with_first_accepted.prop('checked'), + top_n: $top_n.val(), + + ...(data && data.id ? {id: data.id} : {}), }, - success: function(data) { - window.location.replace(SUBSCRIPTIONS_URL) - }, - error: function(response) { + success: (data) => window.location.replace(SUBSCRIPTIONS_URL), + error: (response) => { + $('.bootbox-accept').removeAttr('disabled') log_ajax_error(response) }, }) + + return false }) $('.bootbox.modal').removeAttr('tabindex') } $('#add-subscription').click(process_subscription) + $('.edit-subscription').click(process_subscription) var ntf_form = $('#notification-form') var ntf_add = $('#add-notification') @@ -1036,10 +1178,12 @@ $(function() { function sentAction() { var $this = $(this) - var $div = $this.parent() + var element_closest = $this.attr('data-closest-element') + var $element = element_closest? $this.closest(element_closest) : $this.parent() var url = $this.attr('data-url') || $.fn.editable.defaults.url var name = $this.attr('data-name') || 'name' var type = $this.attr('data-type') || 'POST' + var dialog = undefined var data = { pk: $.fn.editable.defaults.pk, @@ -1053,28 +1197,37 @@ $(function() { } function queryAction() { + if (dialog) dialog.find('.bootbox-accept').attr('disabled', 'disabled') $.ajax({ type: type, url: url, data: data, success: function(data) { eval($this.attr('data-success')) + if (dialog) dialog.modal('hide') }, - error: function(data) { - $.notify("{status} {statusText}: {responseText}".format(data), "error"); - }, + error: (response) => log_ajax_error(response), + complete: () => { + if (dialog) dialog.find('.bootbox-accept').removeAttr('disabled') + } }) } if ($this.attr('data-confirm') === 'false') { queryAction() } else { - bootbox.confirm({ + dialog = bootbox.confirm({ size: 'small', - message: $div.text() + + message: $element.text() + "<br/><br/>" + "<b>" + $this.attr('data-action').replace('-', ' ').toTitleCase() + "?</b>", - callback: function(result) { if (result) { queryAction() } }, + callback: (result) => { + if (!result) { + return + } + queryAction() + return false + }, }) } return false @@ -1097,8 +1250,8 @@ $(function() { $('#first-name-native').editable({ type: 'text', }) $('#last-name-native').editable({ type: 'text', }) - var $resource = $('select#add-account-resource') - $resource.select2({ + var $search_resource = $('select#add-account-resource') + $search_resource.select2({ width: '40%', allowClear: true, placeholder: 'Search resource by regex', @@ -1158,23 +1311,12 @@ $(function() { }) } - function addAccount(index, element) { - var $block = $('<div class="account">') - .append($('<a>', {class: 'delete-account btn btn-default btn-xs'}).attr('data-id', element.pk).append($('<i>', {class: 'far fa-trash-alt'}))) - .append($('<span>', {text: ' '})) - .append($('<span>', {text: element.account + (element.name && element.account.indexOf(element.name) == -1? ' | ' + element.name : '')})) - .append($('<span>', {text: ' '})) - .append($('<a>', {class: 'text-muted small', href: 'http://' + element.resource, text: element.resource})) - - $block.find('.delete-account').click(deleteAccount) - $listAccount.prepend($block) - } $('.delete-account').click(deleteAccount) var $account_suggests = $('#account-suggests') function selectAccountSuggest() { - $search.val($(this).data('handle')).trigger('change') + $search_account.val($(this).data('handle')).trigger('change') $account_suggests.children().remove() } @@ -1188,46 +1330,64 @@ $(function() { $account_suggests.append($suggest) } - var $search = $('#add-account-search') - $search.css({'width': '40%'}); + var $search_account = $('#add-account-search') + $search_account.css({'width': '40%'}); var $add_account_button = $('#add-account') var $add_account_loading = $('#add-account-loading') function update_advanced_search() { + $add_account_button.prop('disabled', !($search_resource.val() && $search_account.val())) + $advanced_search = $('#add-account-advanced-search') href = ACCOUNTS_ADVANCED_SEARCH_URL - if ($resource.val()) { - href += '&resource=' + $resource.val() + if ($search_resource.val()) { + href += '&resource=' + $search_resource.val() } - if ($search.val()) { - href += '&search=' + encodeURIComponent($search.val()) + if ($search_account.val()) { + href += '&search=' + encodeURIComponent($search_account.val()) } $advanced_search.attr('href', href) } - $resource.on('change', update_advanced_search) - $search.on('keyup', update_advanced_search) + + $search_resource.on('change', update_advanced_search) + $search_account.on('keyup', update_advanced_search) update_advanced_search() + function filter_account_table() { + var resource_pk = $search_resource.val() + if (resource_pk) { + $('#list-accounts .account').addClass('hidden') + $('#list-accounts .account[data-account-resource="' + resource_pk + '"]').removeClass('hidden') + } else { + $('#list-accounts .account').removeClass('hidden') + } + } + $search_resource.on('change', filter_account_table) + filter_account_table() + $add_account_button.click(function() { $add_account_loading.removeClass('hidden') + $add_account_button.prop('disabled', true) $.ajax({ type: 'POST', url: $.fn.editable.defaults.url, data: { pk: $.fn.editable.defaults.pk, name: 'add-account', - resource: $resource.val(), - value: $search.val(), + resource: $search_resource.val(), + value: $search_account.val(), }, success: function(data) { - $add_account_loading.addClass('hidden') - $account_suggests.children().remove() if (data.message == 'add') { - addAccount(-1, data.account) - $resource.val(null).trigger('change') - $search.val(null).trigger('change') - } else if (data.message == 'suggest') { + window.location.replace(ACCOUNTS_TAB_URL + '?resource=' + $search_resource.val()) + return + } + + $add_account_loading.addClass('hidden') + $add_account_button.prop('disabled', false) + if (data.message == 'suggest') { + $account_suggests.children().remove() for (var i = 0; i < data.accounts.length; i++) { addAccountSuggest(data.accounts[i]) } @@ -1239,13 +1399,14 @@ $(function() { return } $add_account_loading.addClass('hidden') + $add_account_button.prop('disabled', false) $errorAccountTab.show().html(data.responseText) setTimeout(function() { $errorAccountTab.hide(500) }, 3000) }, }) }) - $search.keypress(function(e) { + $search_account.keypress(function(e) { if (e.which == 13 ) { e.preventDefault() $add_account_button.click() diff --git a/src/static/js/standings.js b/src/static/js/standings.js index b2aa01ac..7d19b452 100644 --- a/src/static/js/standings.js +++ b/src/static/js/standings.js @@ -256,8 +256,9 @@ function process_problem_cell(element, current_time, unfreeze_index, percentage_ } var visible = true + var is_active_switcher = stat.attr('data-active-switcher') - if (stat.attr('data-active-switcher')) { + if (is_active_switcher) { if (problem_submission) { data_penalty = Math.floor(problem_submission[0] / 60) data_score = problem_submission[1] @@ -311,14 +312,18 @@ function process_problem_cell(element, current_time, unfreeze_index, percentage_ var time = 0 console.log('Unknown problem time') } - visible = unfreezing || time <= current_time + to_show = time <= current_time + visible = unfreezing || to_show if (unfreezing || !is_virtual_start) { - if (time > unfreeze_duration() && !unfreeze_open) { + if ( + !unfreeze_open && time > unfreeze_duration() && !is_hidden(score) && !is_active_switcher || + !unfreeze_open && unfreezing && is_active_switcher + ) { score = '?' visible = false } - if (!with_virtual_start && time <= current_time) { + if (!with_virtual_start && to_show) { stat.addClass('result-question') } else { stat.removeClass('result-question') @@ -466,8 +471,7 @@ function prepare_unfreeze() { stat_cells.sort(cmp_row) - var last_problem_cell = null - var candidates_selector = '.problem-cell.problem-cell-stat.result-unfreeze' + var candidates_selector = '.problem-cell.problem-cell-stat.result-unfreeze:not(.prepered-unfreeze)' var has_empty_penalty = false $(candidates_selector).each((_, e) => has_empty_penalty |= !$(e).attr('data-penalty')) @@ -491,17 +495,8 @@ function prepare_unfreeze() { } }) - if (last_problem_cell == problem_cell) { - bootbox.alert({ - message: 'Something went wrong while building the unfreezing.', - className: 'text-danger text-weight-bold', - backdrop: true, - }); - return - } - last_problem_cell = problem_cell - var opening = $(problem_cell) + opening.addClass('prepered-unfreeze') var stat = opening.parent('.stat-cell') var statistic_id = stat.attr('data-statistic-id') var problem_key = opening.attr('data-problem-key') @@ -519,6 +514,7 @@ function prepare_unfreeze() { } } } + $('.prepered-unfreeze').removeClass('prepered-unfreeze') } function change_freeze_duration(select) { diff --git a/src/templates/account_table_cell.html b/src/templates/account_table_cell.html index eabef8d9..8b4521b9 100644 --- a/src/templates/account_table_cell.html +++ b/src/templates/account_table_cell.html @@ -60,7 +60,7 @@ </span> {% if not without_url and not without_inline_url %} -<span class="inline-button"> +<span{% if not without_inline_button %} class="inline-button"{% endif %}> {% if not without_profile_url %} {% profile_url account resource=resource %} {% endif %} diff --git a/src/templates/base.html b/src/templates/base.html index 078fa263..33242b65 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -7,6 +7,7 @@ <meta name="description" property="og:description" content="{% block description %}Join us on a journey into the world of competitive programming. Our platform keeps you updated about past events and future contests. Get personalized alerts for contests you're interested in and track the progress of coders worldwide. Learn from others, improve your coding skills, and become part of an active community of programmers. Begin your programming adventure with us today.{% endblock %}{% include "filter_title.html" with pretext="For" %}"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + {% if not nofavicon %} <link rel="apple-touch-icon" sizes="180x180" href="{% static_ts 'img/favicon/apple-touch-icon.png' %}"> <link rel="icon" type="image/png" sizes="32x32" href="{% static_ts 'img/favicon/favicon-32x32.png' %}"> <link rel="icon" type="image/png" sizes="16x16" href="{% static_ts 'img/favicon/favicon-16x16.png' %}"> @@ -15,6 +16,7 @@ <meta name="msapplication-TileColor" content="#2b5797"> <meta name="theme-color" content="#f8f8f8"> <meta name="msapplication-config" content="{% static_ts 'img/favicon/browserconfig.xml' %}"> + {% endif %} {% if user.is_authenticated %} <meta name="sw" content="{% static_ts 'js/sw.js' %}"> @@ -24,9 +26,9 @@ <meta property="og:title" content="{% block full_ogtitle %}{% block ogtitle %}{% endblock %}{% include "filter_title.html" with pretext="for" %} - CLIST{% endblock %}"> <title>{% block full_title %}{% block title %}{% endblock %}{% include "filter_title.html" with pretext="for" %} - CLIST{% endblock %} - + - {% if not DEBUG %} + {% if not DEBUG and not nocounter %} {% include 'counter/base.html' %} {% endif %} @@ -92,6 +94,7 @@ {% block end-head %}{% endblock %} + {% block body %} {% endif %} + {% endblock %} diff --git a/src/templates/chart.html b/src/templates/chart.html index 135b9294..d9e1454c 100644 --- a/src/templates/chart.html +++ b/src/templates/chart.html @@ -1,44 +1,51 @@ {% if chart %} +{% define chart.type|default:"bar" as chart_type %}
    {% if not without_buttons %}
    {% if not without_toggle_accumulate %} + {% if chart_type == 'line' or chart_type == 'bar' or chart.accumulate %} + type="checkbox" + data-toggle="toggle" + data-on="accumulate" + data-off="normal" + data-onstyle="default active" + data-offstyle="default active" + data-size="mini" + {% if chart.accumulate %}checked{% endif %} + /> {% endif %} + {% endif %} + type="checkbox" + data-toggle="toggle" + data-on="logarithmic" + data-off="linear" + data-onstyle="default active" + data-offstyle="default active" + data-size="mini" + /> {% if not without_toggle_type %} + {% if chart_type == 'line' or chart_type == 'bar' %} + type="checkbox" + data-toggle="toggle" + data-on="line" + data-off="bar" + data-onstyle="default active" + data-offstyle="default active" + data-size="mini" + {% if chart_type == 'line' %}checked{% endif %} + /> {% endif %} + {% endif %} {% if expand_class %} @@ -170,7 +177,7 @@ } var config = { - type: '{{ chart.type|default:"bar" }}', + type: '{{ chart_type }}', data: data, options: { responsive: true, @@ -422,7 +429,7 @@ original_data = null } chart.update() - }) + }){% if chart.accumulate %}.prop('checked', true).change(){% endif %} $('#chart_{{ chart.field }} .toggle-log-scale').change(function() { chart.options.scales.y.type = chart.options.scales.y.type == 'linear'? 'logarithmic' : 'linear' diff --git a/src/templates/coder.html b/src/templates/coder.html new file mode 100644 index 00000000..b61a700e --- /dev/null +++ b/src/templates/coder.html @@ -0,0 +1,12 @@ +{% if with_fixed_width %}
    {% endif %} +{% get_country_from_coder coder as country %} +{% if country %} + +
    +
    +{% endif %} +{% if with_fixed_width %}
    {% endif %} + + + {{ coder.display_name }} +
    diff --git a/src/templates/coder_list.html b/src/templates/coder_list.html index affc0a4d..5939b36b 100644 --- a/src/templates/coder_list.html +++ b/src/templates/coder_list.html @@ -78,9 +78,9 @@

    {% for v in data.list_values %} {% if forloop.counter0 %}|{% endif %} {% if v.coder %} - {{ v.coder.username }} + {% include "coder.html" with coder=v.coder with_fixed_width=True %} {% elif v.account %} - {% include "account_table_cell.html" with resource=v.account.resource account=v.account with_resource=True %} + {% include "account_table_cell.html" with resource=v.account.resource account=v.account with_resource=True with_fixed_width=True %} {% else %} — {% endif %} diff --git a/src/templates/event.html b/src/templates/event.html index 17a5151d..03692821 100644 --- a/src/templates/event.html +++ b/src/templates/event.html @@ -1,6 +1,4 @@ {% extends "base.html" %} -{% load jsonify %} - {% block end-head %} diff --git a/src/templates/field_value.html b/src/templates/field_value.html index ee156f01..acc58b5b 100644 --- a/src/templates/field_value.html +++ b/src/templates/field_value.html @@ -80,6 +80,8 @@ {% for handle in value %}{% if forloop.counter0 %}
    {% endif %}{% if resource %}{{ handle }}{% else %}{{ handle }}{% endif %}{% endfor %} {% elif field == 'hints' and types|to_json == '["list"]' %}
      {% for hint in value %}
    • {{ hint }}
    • {% endfor %}
    + {% elif value|get_type == 'RelatedManager' %} + {% for object in value.all %}{{ object }}{% endfor %} {% else %} {{ template_value }} {% endif %} diff --git a/src/templates/form.html b/src/templates/form.html new file mode 100644 index 00000000..0825e086 --- /dev/null +++ b/src/templates/form.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block full_ogtitle %}{% block full_title %}{{ form.name|striptags }} - Form - CLIST{% endblock %}{% endblock %} +{% block end-head %}{% endblock %} +{% block favicon %}{% endblock %} +{% block counter %}{% endblock %} + +{% block body %} +
    +
    +

    + {{ form.name|safe }} +

    + {% if form.is_closed %} +
    This form is closed
    + {% else %} + {% if token %} + +
    +
    + {{ code|safe }} +
    +
    + {% else %} + + {% endif %} + + {% if form.end_time %} +
    + Form will close in {{ form.end_time|hr_timedelta }} +
    + {% endif %} + {% endif %} +
    +
    +{% endblock %} diff --git a/src/templates/main.html b/src/templates/main.html index d9a10343..8002601d 100644 --- a/src/templates/main.html +++ b/src/templates/main.html @@ -90,6 +90,9 @@ {{ time_field|title }} time {{ time_field|title }}s in Duration + {% for field in more_fields %} + {% with title_field=field|title_field %}{% for f in title_field.split %}{% if forloop.counter0 %}
    {% endif %}{{ f }}{% endfor %}{% endwith %} + {% endfor %} {% if hide_contest %} @@ -109,7 +112,7 @@ {% with contest_time_field=time_field|add:"_time" %} - + {{ contest|get_item:contest_time_field|timezone:timezone|format_time:time_format }} {% endwith %} @@ -119,7 +122,19 @@ {% if contest.is_over %}over{% else %}{{ next_time|countdown }}{% endif %} {% endwith %} - {{ contest.hr_duration }} + + {{ contest.hr_duration }} + {% if contest.is_coming and contest.with_virtual_start %} + {% icon_to "period" %} + {% endif %} + + {% for field in more_fields %} + + {% with value=contest|get_item:field %} + {% if value is None %}·{% else %}{{ value }}{% endif %} + {% endwith %} + + {% endfor %} {% if hide_contest %} @@ -156,7 +171,7 @@ {{ contest.group_size }}  {% endif %} - {% include "contest_inline_buttons.html" with user=request.user contest=contest only %} + {% include "contest_inline_buttons.html" with user=request.user contest=contest perms=perms only %} diff --git a/src/templates/main_notification.html b/src/templates/main_notification.html index fcdfdda2..ba26148d 100644 --- a/src/templates/main_notification.html +++ b/src/templates/main_notification.html @@ -1,9 +1,9 @@
    - {% for k, v in request.user.coder.get_notifications %} - + {% endfor %}
    diff --git a/src/templates/message/telegram b/src/templates/message/telegram index c2b4aeb8..4c79b069 100644 --- a/src/templates/message/telegram +++ b/src/templates/message/telegram @@ -1,5 +1,5 @@ {% autoescape off %} {% if prefix %}*{{ prefix }}* -{% endif %}{% for c in contests %}[{{ c.title|md_escape }}]({{ c.url }}) `{{ c.host }}`{% if c.is_over %} is over{% else %} will {% if c.is_running %}end{% else %}start{% endif %} in [{{ c.next_time|hr_timedelta }}](https://www.timeanddate.com/worldclock/fixedtime.html?msg={{ c.title|urlencode }}&iso={% if c.is_running %}{{ c.end_time|format_time:"%Y%m%dT%H%M" }}{% else %}{{ c.start_time|format_time:"%Y%m%dT%H%M" }}{% endif %}{% if c.duration|less_24_hours %}&ah={{ c.duration|hours }}&am={{ c.duration|minutes }}{% endif %}){% endif %} +{% endif %}{% for c in contests %}[{{ c.title|md_url_text }}]({{ c.actual_url }}) `{{ c.host }}`{% if c.is_over %} ended{% else %} will {% if c.is_running %}end{% else %}start{% endif %} in{% endif %} [{% if c.is_over %}{{ c.end_time|naturaltime }}{% else %}{{ c.next_time|hr_timedelta }}{% endif %}](https://www.timeanddate.com/worldclock/fixedtime.html?msg={{ c.title|urlencode }}&iso={% if c.is_running or c.is_over %}{{ c.end_time|format_time:"%Y%m%dT%H%M" }}{% else %}{{ c.start_time|format_time:"%Y%m%dT%H%M" }}{% if c.duration|less_24_hours %}&ah={{ c.duration|hours }}&am={{ c.duration|minutes }}{% endif %}{% endif %}) {% endfor %} {% endautoescape %} diff --git a/src/templates/navbar.html b/src/templates/navbar.html index 00aecf30..6263f0c9 100644 --- a/src/templates/navbar.html +++ b/src/templates/navbar.html @@ -107,6 +107,7 @@
  •  Lists
  •  Notifications
  •  Calendars
  • +
  • {% icon_to 'subscription' '' %} Subscriptions
  •  API
  • diff --git a/src/templates/party.html b/src/templates/party.html index 004dd0d9..028c9688 100644 --- a/src/templates/party.html +++ b/src/templates/party.html @@ -26,7 +26,7 @@

    Coders - {{ party.coders.all.count }} + {{ coders|length }}

    {% for coder in coders %} @@ -46,18 +46,26 @@

    Contests
    Not set contest for party
    {% endfor %}

    - - - {% if party.author == request.user.coder %} - [Join] - [Leave] + {% if party.author == request.user.coder or request.user.coder in admins %} +
    + Copy join url + Copy leave url + {% if admins %} +

    Admins + {{ admins|length }} +

    +
    + {% for coder in admins %} + {{ coder.username }} + {% endfor %} +
    + {% endif %} {% endif %} -
    {% if user and user.is_authenticated %}{% if not user.first_name or not user.last_name %}
    - Fill in your first last and last name in social settings. + Fill in your first last and last name in social settings.
    {% endif %}{% endif %} @@ -75,7 +83,7 @@

    Contests
    - + {{ contest.title|truncatechars:33 }}
    {% if contest.is_coming %}starts{% else %}ends{% endif %} in {{ contest.next_time|countdown }}
    diff --git a/src/templates/profile.html b/src/templates/profile.html index 21377372..8b66d741 100644 --- a/src/templates/profile.html +++ b/src/templates/profile.html @@ -175,7 +175,7 @@

    Ratings{% if two_columns %}{{ history_resources|length|substract:history_resources_limit }} more + {{ history_resources|length|subtract:history_resources_limit }} more
    {% endif %}
    diff --git a/src/templates/profile_account.html b/src/templates/profile_account.html index ea08ae23..69a42424 100644 --- a/src/templates/profile_account.html +++ b/src/templates/profile_account.html @@ -26,7 +26,12 @@

    {% endif %} {% coder_color_circle account.resource account size=28 %} - {% if account.resource.info.standings.name_instead_key and account.name or account.info|get_item:"_name_instead_key" and account.name %}{{ account.name }}{% else %}{{ account.key }}{% endif %} + {% if account.deleted %} + {% define "strike" as account_tag %} + {% else %} + {% define "span" as account_tag %} + {% endif %} + <{{ account_tag }} class="{% coder_color_class account.resource account.info %}">{% if account.resource.info.standings.name_instead_key and account.name or account.info|get_item:"_name_instead_key" and account.name %}{{ account.name }}{% else %}{{ account.key }}{% endif %} {% profile_url account %} diff --git a/src/templates/profile_contests_paging.html b/src/templates/profile_contests_paging.html index b1e4581c..f95d0f57 100644 --- a/src/templates/profile_contests_paging.html +++ b/src/templates/profile_contests_paging.html @@ -11,7 +11,7 @@ {% with percent=statistic.place_as_int|multiply:100|divide:n_total %}
    Rank: {{ statistic.place }}
    Total: {{ n_total }}" data-toggle="tooltip" data-placement="top" data-html="true"> -
    +
    {% endwith %} diff --git a/src/templates/robots.txt b/src/templates/robots.txt index 379da7ed..8a05b468 100644 --- a/src/templates/robots.txt +++ b/src/templates/robots.txt @@ -17,6 +17,7 @@ Disallow: /signup/ Disallow: /oauth/ Disallow: /auth/ Disallow: /o/ +Disallow: /form/ Disallow: /coder/*/ratings/ Disallow: /account/*/ratings/ Disallow: /profile/*/ratings/ diff --git a/src/templates/series_filter.html b/src/templates/series_filter.html index 1a6a43ce..bc1a2b8b 100644 --- a/src/templates/series_filter.html +++ b/src/templates/series_filter.html @@ -15,7 +15,7 @@ {% for series in params.series %} - + {% endfor %} @@ -44,6 +44,9 @@ }; }, processResults: function (data, params) { + {% if with_all %}if ((params.page || 1) == 1) { + data.items.unshift({slug: 'all', text: 'Select All'}) + }{% endif %} return { results: data.items.map(function (item) { return {id: item.slug, text: item.text} }), pagination: { diff --git a/src/templates/settings.html b/src/templates/settings.html index 0d2172ba..66bc55ac 100644 --- a/src/templates/settings.html +++ b/src/templates/settings.html @@ -1,5 +1,4 @@ {% extends "base.html" %} -{% load jsonify %} {% load crispy_forms_tags %} {% block ogtitle %}{% block title %}Settings{% endblock %}{% endblock %} @@ -32,10 +31,16 @@ PREFERENCES_URL = "{% url 'coder:settings' 'preferences' %}" LISTS_URL = "{% url 'coder:settings' 'lists' %}" + CHATS_URL = "{% url 'coder:settings' 'chats' %}" + ACCOUNTS_TAB_URL = "{% url 'coder:settings' 'accounts' %}" FILTERS_URL = "{% url 'coder:settings' 'filters' %}" CALENDARS_URL = "{% url 'coder:settings' 'calendars' %}" SUBSCRIPTIONS_URL = "{% url 'coder:settings' 'subscriptions' %}" RESOURCES_URL = "{% url 'clist:resources' %}" + EXTRA_URL_ICON = `{% icon_to 'extra_url' '' %}` + + ACCOUNTS_URL = "{% url 'coder:accounts' %}" + CODERS_URL = "{% url 'coder:coders' %}" SUBSCRIPTIONS_METHODS = [ {% for k, v in notifications %}{% if forloop.counter0 %}, {% endif %}{'id': '{{ k }}', 'text': '{{ v }}'}{% endfor %} ] SHARE_TO_CATEGORY = { 'disable': 'Disable' {% for k, v in notifications %}, '{{ k }}': '{{ v }}'{% endfor %} } @@ -47,6 +52,9 @@ ACCESS_LEVELS = [ {% for access_level in access_levels %}{% if forloop.counter0 %}, {% endif %}{'id': '{{ access_level.value }}', 'text': '{{ access_level.label }}'}{% endfor %} ] WEEK_DAYS = [{'id': '2', 'text': 'Mon'}, {'id': '3', 'text': 'Tue'}, {'id': '4', 'text': 'Wed'}, {'id': '5', 'text': 'Thu'}, {'id': '6', 'text': 'Fri'}, {'id': '7', 'text': 'Sat'}, {'id': '1', 'text': 'Sun'}] + + SUBSCRIPTION_N_LIMIT = {{ coder.subscription_n_limit }} + SUBSCRIPTION_TOP_N_LIMIT = {{ coder.subscription_top_n_limit }} @@ -63,11 +71,9 @@ Filters Notifications Lists - {% if my_chats %} Chats - {% endif %} Calendars - {% comment %} Subscriptions {% endcomment %} + Subscriptions @@ -204,37 +210,17 @@ {% if my_list.access_level == 'restricted' %} and {{ my_list.shared_with|length }} shared with coder(s){% endif %} - +

    {% endfor %}

    - {% if my_chats %}
    -
    - - {% for chat in my_chats %} - - {% for field in my_chats_fields %} - - {% endfor %} - {% if perms.chat.change_chat %} - - {% endif %} - - {% endfor %} -
    - {% with value=chat|get_item:field %} - {% if value is None %}—{% else %}{{ value }}{% endif %} - {% endwith %} - - -
    -
    + {% include "settings_chats.html" with title="Joined" chats=chats.joined fields=chats.fields %} + {% if chats.owned %}{% include "settings_chats.html" with title="Owned" chats=chats.owned fields=chats.fields %}{% endif %}
    - {% endif %}
    {% if not notification_form.errors %} @@ -267,8 +253,8 @@ {% if n.last_time %}check in {{ n.last_time|timezone:coder.timezone|format_time:"%d.%m %a %H:%M" }}{% else %}nothing{% endif %} - - + + {% if perms.notification.change_notification %} {% endif %} @@ -334,6 +320,11 @@
    {% if token %}{{ token.user_id }}{% if token.email %} ({{ token.email }}){% endif %}{% else %}connect{% endif %}
    + {% if token and token.expires_at and service.refresh_token_uri %} + +
    {% icon_to 'expires' '' %}
    +
    + {% endif %} {% if token and tokens|length > 1 %} {% endif %} @@ -356,13 +347,25 @@
    - {% for account in coder.account_set_order_by_pk reversed %} -
    @@ -395,7 +398,7 @@ - +
    {% endfor %} @@ -404,8 +407,9 @@
    Create + {{ subscriptions|length }} of {{ coder.n_subscriptions_limit }} {% if perms.notification.view_subscription %} - + {% endif %}
    diff --git a/src/templates/settings_chats.html b/src/templates/settings_chats.html new file mode 100644 index 00000000..94ce95a4 --- /dev/null +++ b/src/templates/settings_chats.html @@ -0,0 +1,30 @@ +

    {{ title }}

    +{% if not chats %} +
    Empty list
    +{% else %} +
    + + + {% for field in fields %} + + {% endfor %} + +{% for chat in chats %} + + {% for field in fields %} + + {% endfor %} + {% if perms.chat.change_chat %} + + {% endif %} + +{% endfor %} +
    {{ field|title_field }}
    + {% with value=chat|get_item:field %} + {% if value is None %}—{% else %}{{ value }}{% endif %} + {% endwith %} + + +
    +
    +{% endif %} diff --git a/src/templates/settings_subscription.html b/src/templates/settings_subscription.html index f6d91a93..dfd1d6d1 100644 --- a/src/templates/settings_subscription.html +++ b/src/templates/settings_subscription.html @@ -1,5 +1,5 @@ -
    -
    +
    +
    @@ -29,34 +29,76 @@ {% endif %} - {% if subscription.account %} + {% if subscription.with_first_accepted %} - - + + + + {% endif %} + {% if subscription.top_n %} + + + {% endif %} - {% if subscription.coder_list %} + {% if subscription.coder_list_id %} {% endif %} - {% if subscription.coder_chat %} + {% if subscription.coder_chat_id %} - + + + {% endif %} + {% if subscription.accounts.all %} + + {% with n_split=3 %} + + + {% endwith %} + + {% endif %} + {% if subscription.coders.all %} + + {% with n_split=3 %} + + + {% endwith %} {% endif %} diff --git a/src/templates/standings.html b/src/templates/standings.html index cc70ce1c..5d245526 100644 --- a/src/templates/standings.html +++ b/src/templates/standings.html @@ -223,6 +223,7 @@

    updated + {% if contest.parsed_percentage and contest.parsed_percentage < 100 %}{{ contest.parsed_percentage|floatformat:1 }}%{% endif %} {{ contest.parsed_time|timezone:timezone|naturaltime }}
    diff --git a/src/templates/standings_account.html b/src/templates/standings_account.html index bdd7c1bc..929cde47 100644 --- a/src/templates/standings_account.html +++ b/src/templates/standings_account.html @@ -15,22 +15,18 @@ {% define country.name as account_country_name %} {% define country.code as account_country_code %} {% define country.flag_code as account_country_flag %} - {% else %} + {% endif %} + {% if not account_country_code %} {% define statistic.addition.country|get_country_code as code %} + {% if not code and members %} + {% get_country_from_members preload_statistics_data.accounts members as code %} + {% endif %} {% if code %} {% define code as account_country_code %} - {% define statistic.addition.country as account_country_name %} + {% define code|get_country_name as account_country_name %} {% define code as account_country_flag %} - {% elif members %} - {% get_country_from_members preload_statistics_data.accounts members as code %} - {% if code %} - {% define code as account_country_code %} - {% define code|get_country_name as account_country_name %} - {% define code as account_country_flag %} - {% endif %} {% endif %} {% endif %} - {% if account_country_code %} {% endif %} @@ -43,14 +39,19 @@
    {% if account.url and not team_id %}{% elif '_account_url' in statistic.addition %}{% endif %} - {% if with_detail and resource.info.standings.subname and account.key|split_account_key:resource.info.standings.subname %} - {% with names=account.key|split_account_key:resource.info.standings.subname %} - {{ names.0 }}
    {{ names.1 }}
    - {% endwith %} + {% if account.deleted %} + {% define "strike" as account_tag %} + {% else %} + {% define "span" as account_tag %} + {% endif %} + {% define False as subnames %} + {% if resource.info.standings.subname and account.key|split_account_key:resource.info.standings.subname %} + {% define account.key|split_account_key:resource.info.standings.subname as subnames %} + <{{ account_tag }}>{% trim_to subnames.0 40 %} {% elif statistic.addition.name and account.key|has_season:statistic.addition.name or members or statistic.addition.name and resource.info.standings.name_instead_key or statistic.addition.name and statistic.addition|get_item:"_name_instead_key" %} - {% trim_to statistic.addition.name 50 %} + <{{ account_tag }}>{% trim_to statistic.addition.name 50 %} {% else %} - {{ account.key }} + <{{ account_tag }}{% if statistic.addition.name or account.name %} title="{{ statistic.addition.name|default:account.name }}" data-placement="top"{% endif %} data-toggle="tooltip">{{ account.key }} {% endif %} {% if statistic.virtual_start %}Your virtual participation{% endif %} @@ -58,7 +59,7 @@ {% if account.url and not team_id or '_account_url' in statistic.addition %}
    {% endif %} {% with addition_countries=statistic.addition|get_item:"_countries" %} - {% if addition_countries %} + {% if not members and addition_countries %} {% for country_name in addition_countries %} {% with country_code=country_name|get_country_code %} {% if country_code %} @@ -72,7 +73,9 @@ {% endwith %}
    - {% if not with_detail %}{% include "standings_account_members.html" with members=members except_country_code=account_country_code inline=True %}{% endif %} + {% if not with_detail %} + {% include "standings_account_members.html" with subnames=subnames members=members except_country_code=account_country_code inline=True %} + {% endif %} {% if not without_addition_url %} @@ -116,7 +119,9 @@ {% endif %} - {% if with_detail %}{% include "standings_account_members.html" with members=members except_country_code=account_country_code inline=False %}{% endif %} + {% if with_detail %} + {% include "standings_account_members.html" with subnames=subnames members=members except_country_code=account_country_code inline=False %} + {% endif %} {% endwith %} diff --git a/src/templates/standings_account_members.html b/src/templates/standings_account_members.html index 0957461d..24525555 100644 --- a/src/templates/standings_account_members.html +++ b/src/templates/standings_account_members.html @@ -1,3 +1,6 @@ +{% if subnames %} +
    {{ subnames.1 }}
    +{% endif %} {% if members %}
    {% if inline %}:{% endif %} {% if with_fixed_width %} @@ -8,11 +11,11 @@ {% for member in members %} {% if member and member.account %} - {% with account=preload_statistics_data.accounts|get_item:member.account %} + {% with account=preload_statistics_data.accounts|get_item:member.account without_country=member|get_item:'without_country' %} {% if not account %} {{ member.account }} {% else %} - {% include "account_table_cell.html" with account=account addition=None resource=account.resource without_circle=True with_fixed_width=False without_inline_url=True %} + {% include "account_table_cell.html" with account=account addition=None resource=account.resource without_avatar=account.resource|ne:resource without_circle=True with_fixed_width=False without_inline_url=True without_country=has_country|iffalse:without_country %} {% endif %} {% endwith %} {% elif member and member.coder %} diff --git a/src/templates/standings_list_filters.html b/src/templates/standings_list_filters.html index 9e1c9deb..605d4e4a 100644 --- a/src/templates/standings_list_filters.html +++ b/src/templates/standings_list_filters.html @@ -39,7 +39,7 @@ {% if favorite_contests %}{% include 'favorite_filter.html' %}{% endif %} - {% include 'series_filter.html' %} + {% include 'series_filter.html' with with_all=True %} {% if request.GET.with_submissions|is_yes %} diff --git a/src/templates/standings_problem_progress.html b/src/templates/standings_problem_progress.html index c7257b43..e110e994 100644 --- a/src/templates/standings_problem_progress.html +++ b/src/templates/standings_problem_progress.html @@ -12,7 +12,7 @@
    -
    +
    {% else %}
    diff --git a/src/tg/bot.py b/src/tg/bot.py index 622985e0..dc638fed 100644 --- a/src/tg/bot.py +++ b/src/tg/bot.py @@ -12,15 +12,19 @@ import pytz import telegram from django.conf import settings -from django.db.models import Q +from django.db.models import Prefetch, Q from django.urls import reverse from django.utils.timezone import now from pytimeparse.timeparse import timeparse +from sql_util.utils import SubqueryCount from telegram.constants import MAX_MESSAGE_LENGTH from clist.api.v2 import ContestResource from clist.models import Contest, Resource -from clist.templatetags.extras import as_number, hr_timedelta, md_escape +from clist.templatetags.extras import as_number, hr_timedelta, md_escape, md_url, md_url_text +from notification.models import Subscription +from notification.utils import compose_message_by_problems +from ranking.models import ParseStatistics, Statistics from tg.models import Chat, History logging.basicConfig(level=logging.DEBUG) @@ -58,19 +62,6 @@ def escape(*args): return tuple(map(md_escape, args)) -def fix_url_text(msg): - - def url_text_repl(entry): - text = entry.group(1) - url = entry.group(2) - text = re.sub(r'\\', '', text) - text = re.sub(r'\[', '((', text) - text = re.sub(r'\]', '))', text) - return f'[{text}]({url})' - - return re.sub(r'\[([\]]*?)\]\(([\)]*?)\)', url_text_repl, msg) - - class Bot(telegram.Bot): ADMIN_CHAT_ID = settings.TELEGRAM_ADMIN_CHAT_ID @@ -116,7 +107,7 @@ def update_chat_info(self, chat, message): title = '@' + str(message_from[k]) if title != chat.title: chat.title = title - chat.save() + chat.save(update_fields=['title']) break names = [] @@ -124,9 +115,9 @@ def update_chat_info(self, chat, message): if k in message_from: names.append(message_from[k]) name = ' '.join(names).strip() - if name != chat.name: + if name and name != chat.name: chat.name = name - chat.save() + chat.save(update_fields=['name']) def start(self, args): success = False @@ -337,6 +328,209 @@ def unlink(self, args): self.chat.delete() self.chat_ = False + def subscribe(self, args): + + n_aggregated = 0 + aggregated_msg = None + + def messaging(msg): + nonlocal aggregated_msg, n_aggregated + if not aggregated_msg: + n_aggregated = 0 + if n_aggregated == 20 or len(aggregated_msg) + len(msg) + 1 >= MAX_MESSAGE_LENGTH: + yield aggregated_msg + aggregated_msg = '' + n_aggregated = 0 + if aggregated_msg: + aggregated_msg += '\n' + aggregated_msg += msg + n_aggregated += 1 + + def show_subscribed(method): + nonlocal aggregated_msg + subscriptions = Subscription.objects.filter(coder=self.coder, method=method, contest__isnull=False) + subscriptions = subscriptions.select_related('contest') + subscriptions = subscriptions.annotate(n_accounts=SubqueryCount('accounts')) + if subscriptions: + aggregated_msg = '' + yield from messaging('Subscribed contests:') + for subscription in subscriptions: + contest = subscription.contest + message = (f'[{contest.title}]({md_url(contest.actual_url)}) `{contest.pk}` ' + f', {subscription.n_accounts} account(s)' + + (f' + top {subscription.top_n}' if subscription.top_n else '') + + (' + first ac' if subscription.with_first_accepted else '')) + yield from messaging(message) + yield aggregated_msg + + if not self.is_private: + chat = self.group or self.chat + if not chat or chat.coder != self.coder: + yield 'Use /iamadmin before subscribe.' + return + + method = 'telegram' + if not self.coder or not self.is_private: + method += f':{self.chat_id}' + if self.thread_id: + method += f':{self.thread_id}' + + if args.contest: + contest = Contest.get(args.contest) + else: + contest = ParseStatistics.relevant_contest() + if not contest: + yield 'No set contest. Use `-c` or `--contest` option to choose contest.' + yield from show_subscribed(method) + return + + contest_msg = f'[{contest.title}]({md_url(contest.actual_url)}) `{contest.pk}`' + if args.show: + yield f'Current contest: {contest_msg}.' + yield from show_subscribed(method) + return + + subscription, created = Subscription.objects.get_or_create(coder=self.coder, contest=contest, method=method) + if created: + method_chat = f'telegram:{self.chat_id}' + already_subscriptions = Subscription.objects.filter(Q(coder__isnull=False, coder=self.coder) | + Q(method=method_chat) | + Q(method__startswith=f'{method_chat}:')) + n_subscriptions_limit = settings.CODER_N_SUBSCRIPTIONS_LIMIT_ + if already_subscriptions.count() > n_subscriptions_limit: + subscription.delete() + yield (f'You have reached the limit of {n_subscriptions_limit} subscriptions.\n' + f'Remove unnecessary subscriptions with `-r` or `--remove` options.') + return + + if args.list: + if subscription.is_empty(): + subscription.delete() + yield 'No subscribed participants.' + return + accounts = subscription.accounts.all() + statistics_prefetch = Prefetch('statistics_set', + queryset=Statistics.objects.filter(contest=contest), + to_attr='contest_statistics') + accounts = accounts.prefetch_related(statistics_prefetch) + + statistics_filter = Q(account__in=accounts) + if subscription.top_n: + statistics_filter |= Statistics.top_n_filter(subscription.top_n) + if subscription.with_first_accepted: + statistics_filter |= Statistics.first_ac_filter() + contest_statistics = Statistics.objects.filter(contest=contest).filter(statistics_filter) + contest_statistics = contest_statistics.select_related('account', 'contest__resource') + contest_statistics = contest_statistics.order_by('place_as_int', 'pk') + + aggregated_msg = '' + seen_accounts = set() + for stat in contest_statistics: + account_msg = compose_message_by_problems([], stat, {}, contest) + yield from messaging(account_msg) + seen_accounts.add(stat.account_id) + for account in accounts: + if account.pk in seen_accounts: + continue + account_msg = f'[{md_url_text(account.display())}]({md_url(account.url)})' + yield from messaging(account_msg) + yield aggregated_msg + return + + skip_names_info = False + + if args.first_ac is not None: + skip_names_info = True + first_ac_msg = 'subscribed to first accepted' if args.first_ac else 'unsubscribed from first accepted' + if subscription.with_first_accepted != args.first_ac: + subscription.with_first_accepted = args.first_ac + subscription.save(update_fields=['with_first_accepted']) + first_ac_msg = first_ac_msg.capitalize() + else: + first_ac_msg = f'Already {first_ac_msg}' + yield f'{first_ac_msg} for {contest_msg}.' + + if args.top_n is not None: + skip_names_info = True + args.top_n = args.top_n or None + if args.top_n: + if not 1 <= args.top_n <= settings.CODER_SUBSCRIPTION_TOP_N_LIMIT_: + yield rf'Top N should be in range \[1, {settings.CODER_SUBSCRIPTION_TOP_N_LIMIT_}].' + return + top_n_msg = f'subscribed to top {args.top_n}' if args.top_n else 'unsubscribed from top' + if subscription.top_n != args.top_n: + subscription.top_n = args.top_n + subscription.save(update_fields=['top_n']) + top_n_msg = top_n_msg.capitalize() + else: + top_n_msg = f'Already {top_n_msg}' + yield f'{top_n_msg} for {contest_msg}.' + + accounts_pk_set = set(subscription.accounts.values_list('pk', flat=True)) + processed = set() + changed = False + aggregated_msg = '' + subscription_n_limit = settings.CODER_SUBSCRIPTION_N_LIMIT_ + for name in args.names: + qs = Statistics.objects.filter(contest=contest) + qs = qs.filter(Q(account__name__icontains=name) | Q(account__key__icontains=name)) + qs = qs.select_related('account') + exact = any(statistic.account.name == name or statistic.account.key == name for statistic in qs) + for statistic in qs.select_related('account'): + account = statistic.account + if exact and account.name != name and account.key != name: + continue + if account in processed: + continue + processed.add(account) + account_msg = f'[{md_url_text(account.display())}]({md_url(account.url)})' + if args.remove: + if account.pk in accounts_pk_set: + accounts_pk_set.remove(account.pk) + yield from messaging(f'Unsubscribed {account_msg}.') + changed = True + else: + if account.pk not in accounts_pk_set: + if len(accounts_pk_set) >= subscription_n_limit: + yield from messaging(f'You have reached the limit of {subscription_n_limit} participants.') + break + accounts_pk_set.add(account.pk) + yield from messaging(f'Subscribed {account_msg}.') + changed = True + else: + yield from messaging(f'Already subscribed {account_msg}.') + yield aggregated_msg + + if changed: + subscription.accounts.set(accounts_pk_set) + if subscription.is_empty(): + subscription.delete() + if not args.names and args.remove: + if subscription.pk: + subscription.delete() + yield f'Unsubscribed from all participants of {contest_msg}.' + else: + yield f'No subscribed participants for {contest_msg}.' + elif not contest.n_statistics: + if contest.is_coming(): + yield 'Contest has not started yet, please try again later.' + else: + yield 'Participant list is unavailable, please try again later.' + elif skip_names_info: + return + elif not args.names: + yield f'Set the names of participants for {contest_msg}.' + elif not changed: + yield f'Participants not found to change for {contest_msg}.' + + def unsubscribe(self, args): + for action in self.subscribe_parser._actions: + if action.dest == 'help' or action.dest in args: + continue + setattr(args, action.dest, action.default) + args.remove = True + yield from self.subscribe(args) + @property def parser(self): if not hasattr(self, 'parser_'): @@ -379,6 +573,29 @@ def parser(self): command_p.add_parser('/unlink', description='Unlink account') + subscribe_p = command_p.add_parser('/subscribe', description='Subscribe to participants') + subscribe_p.add_argument('names', metavar='NAME', nargs='*', + help='Participants names (partial matches allowed). ' + 'Double quote names with spaces') + subscribe_p.add_argument('-c', '--contest', help='Contest id, series or name') + subscribe_p.add_argument('-fa', '--first-ac', action='store_true', + help='Subscribe to first accepted', default=None) + subscribe_p.add_argument('-nofa', '--no-first-ac', dest='first_ac', action='store_false', + help='Unsubscribe to first accepted') + subscribe_p.add_argument('-tn', '--top-n', type=int, metavar='N', + help='Subscribe to top N. Set 0 to unsubscribe top') + subscribe_p.add_argument('-r', '--remove', action='store_true', + help='Remove subscription or specified participants if specified') + subscribe_p.add_argument('-l', '--list', action='store_true', help='List subscribed participants') + subscribe_p.add_argument('-s', '--show', action='store_true', help='Show current contest and subscriptions') + self.subscribe_parser = subscribe_p + + unsubscribe_p = command_p.add_parser('/unsubscribe', description='Unsubscribe from participants') + unsubscribe_p.add_argument('names', metavar='NAME', nargs='*', + help='Participants names (partial matches allowed). ' + 'Double quote names with spaces') + unsubscribe_p.add_argument('-c', '--contest', help='Contest id, series or name') + for a in list_p._actions: if not isinstance(a, argparse._HelpAction): if a.default is not None: @@ -403,12 +620,10 @@ def execute_command(self, raw_query): ) regex = '(^%s)@%s' % (regex, settings.TELEGRAM_NAME) query = re.sub(regex, r'\1', query) - args = self.parser.parse_args(shlex.split(query)) - if args.command in ['/prev', '/next', '/repeat']: if not self.chat or not self.chat.last_command: - yield 'Sorry, not found previous command' + yield 'Not found previous command' return c = args.command dargs = vars(args) @@ -432,7 +647,7 @@ def execute_command(self, raw_query): dargs = vars(args) dargs['chat_id__'] = self.chat_id self.chat.last_command = dargs - self.chat.save() + self.chat.save(update_fields=['last_command']) for msg in getattr(self, args.command[1:])(args): paging = getattr(args, 'paging__', False) @@ -455,13 +670,10 @@ def execute_command(self, raw_query): self.delete_message(self.message['chat']['id'], self.message['message_id']) except telegram.error.BadRequest: pass - - except ArgumentCommandError: - pass - except ArgumentParserError as e: - yield 'I\'m sorry, could you clarify:\n' + escape(str(e)) + except (ArgumentParserError, ArgumentCommandError) as e: + yield 'Could you please clarify:\n' + escape(str(e)) except NeedHelpError as e: - yield 'If you need help, please:\n' + escape(str(e)) + yield 'If you need help, please:\n```help\n' + str(e) + '\n```' except Exception as e: self.sendMessage(self.ADMIN_CHAT_ID, 'Query: %s\n\n%s' % (raw_query, format_exc())) yield 'Oops, I\'m having a little trouble:\n' + escape(str(e)) @@ -471,7 +683,6 @@ def send_message(self, msg, chat_id=None, reply_markup=None): msg = {'text': msg} if not msg['text']: return - msg['text'] = fix_url_text(msg['text']) if len(msg['text']) > MAX_MESSAGE_LENGTH: msg['text'] = msg['text'][:MAX_MESSAGE_LENGTH - 3] + '...' @@ -530,6 +741,7 @@ def incoming(self, raw_data): self.chat_id = str(self.message['chat']['id']) self.chat_type = self.message['chat'].get('type') + self.is_private = self.chat_type == 'private' if self.message.get('is_topic_message'): thread = self.message.get('reply_to_message', {}).get('forum_topic_created') @@ -559,15 +771,12 @@ def incoming(self, raw_data): message_id=self.message['message_id'], ) - if not self.coder and was_messaging: - self.send_message(f'Follow {self.follow_url} to connect your account.') - else: - if self.coder and self.coder.settings.get('telegram', {}).get('unauthorized', False): - self.coder.settings.setdefault('telegram', {})['unauthorized'] = False - self.coder.save() - chat = self.group or self.chat - if chat: - History.objects.create(chat=chat, message=data).save() + if self.coder and not was_messaging and self.coder.settings.get('telegram', {}).get('unauthorized', False): + self.coder.settings.setdefault('telegram', {})['unauthorized'] = False + self.coder.save(update_fields=['settings']) + chat = self.group or self.chat + if chat: + History.objects.create(chat=chat, message=data).save() except Exception as e: self.logger.info('Exception incoming message:\n%s\n%s' % (format_exc(), raw_data)) self.logger.error(f'Exception incoming message: {e}') diff --git a/src/tg/models.py b/src/tg/models.py index 9306c472..bf545130 100644 --- a/src/tg/models.py +++ b/src/tg/models.py @@ -1,4 +1,6 @@ from django.db import models +from django.db.models.signals import m2m_changed +from django.dispatch import receiver from pyclist.models import BaseModel from ranking.models import Account @@ -38,6 +40,24 @@ def get_notification_method(self): class Meta: unique_together = ['chat_id', 'thread_id'] + def update_coders_or_accounts(self): + coders, accounts = list(self.coders.all()), list(self.accounts.all()) + for subscription in self.subscription_set.all(): + subscription.coders.set(coders) + subscription.accounts.set(accounts) + + +@receiver(m2m_changed, sender=Chat.coders.through) +@receiver(m2m_changed, sender=Chat.accounts.through) +def update_coders_or_accounts(sender, instance, reverse, pk_set, action, **kwargs): + if not action.startswith('post_'): + return + if reverse: + for chat in Chat.objects.filter(pk__in=pk_set): + chat.update_coders_or_accounts() + else: + instance.update_coders_or_accounts() + class History(BaseModel): LIMIT_BY_CHAT = 7 diff --git a/src/true_coders/admin.py b/src/true_coders/admin.py index d17dfc3b..e3a0ec58 100644 --- a/src/true_coders/admin.py +++ b/src/true_coders/admin.py @@ -11,6 +11,12 @@ class CoderAdmin(BaseModelAdmin): list_display = ['username', 'global_rating', 'last_activity', 'settings'] list_filter = ['party', 'account__resource'] + def get_readonly_fields(self, request, obj=None): + return ( + ['n_accounts', 'n_contests', 'n_subscribers', 'last_activity'] + + super().get_readonly_fields(request, obj) + ) + def clean_settings(self, request, queryset): count = 0 for c in queryset: diff --git a/src/true_coders/management/commands/fill_coder_problems.py b/src/true_coders/management/commands/set_coder_problems.py similarity index 91% rename from src/true_coders/management/commands/fill_coder_problems.py rename to src/true_coders/management/commands/set_coder_problems.py index f5e3bd1a..a78cece6 100644 --- a/src/true_coders/management/commands/fill_coder_problems.py +++ b/src/true_coders/management/commands/set_coder_problems.py @@ -17,11 +17,11 @@ class Command(BaseCommand): - help = 'Fill coder problems using linked accounts' + help = 'Set coder problems using linked accounts' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.logger = getLogger('coders.fill_coder_problems') + self.logger = getLogger('coders.set_coder_problems') def add_arguments(self, parser): parser.add_argument('-r', '--resources', metavar='HOST', nargs='*', help='resources hosts') @@ -40,10 +40,10 @@ def handle(self, *args, **options): args = AttrDict(options) coders = Coder.objects.all() - update_need_fill_coder_problems = False + update_need_set_coder_problems = False if args.no_filled: - coders = coders.filter(Q(settings__need_fill_coder_problems=True)) - update_need_fill_coder_problems = True + coders = coders.filter(Q(settings__need_set_coder_problems=True)) + update_need_set_coder_problems = True if args.coders: coders_filters = Q() @@ -51,7 +51,7 @@ def handle(self, *args, **options): coders_filters |= Q(username=c) coders = coders.filter(coders_filters) self.log_queryset('coders', coders) - update_need_fill_coder_problems = False + update_need_set_coder_problems = False resources = Resource.objects.all() if args.resources: @@ -64,7 +64,7 @@ def handle(self, *args, **options): if not args.coders: coders = coders.annotate(has_resource=Exists('account', filter=Q(account__resource__in=resources))) coders = coders.filter(has_resource=True) - update_need_fill_coder_problems = False + update_need_set_coder_problems = False if args.contest: contest = Contest.objects.get(pk=args.contest) @@ -73,7 +73,7 @@ def handle(self, *args, **options): coders = coders.filter(has_account=True) self.log_queryset('contest problems', problems) self.log_queryset('contest coders', coders) - update_need_fill_coder_problems = False + update_need_set_coder_problems = False else: problems = None @@ -140,8 +140,8 @@ def process_problem(problems, desc): for resource in tqdm(coder_resources, total=len(coder_resources), desc='resources'): resource_problems = resource.problem_set.all() process_problem(resource_problems, desc=f'{resource}') - if update_need_fill_coder_problems: - coder.settings.pop('need_fill_coder_problems', None) + if update_need_set_coder_problems: + coder.settings.pop('need_set_coder_problems', None) coder.save(update_fields=['settings']) self.logger.info(f'n_created = {n_created}, n_deleted = {n_deleted}, n_total = {n_total}') diff --git a/src/true_coders/migrations/0070_coder_n_subscribers.py b/src/true_coders/migrations/0070_coder_n_subscribers.py new file mode 100644 index 00000000..88cb7b9b --- /dev/null +++ b/src/true_coders/migrations/0070_coder_n_subscribers.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2024-11-17 15:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('true_coders', '0069_listvalue_true_coders_coder_l_1cff61_idx'), + ] + + operations = [ + migrations.AddField( + model_name='coder', + name='n_subscribers', + field=models.IntegerField(blank=True, db_index=True, default=0), + ), + ] diff --git a/src/true_coders/models.py b/src/true_coders/models.py index 48c9e050..913a0a43 100644 --- a/src/true_coders/models.py +++ b/src/true_coders/models.py @@ -10,6 +10,7 @@ from django.core.cache import cache from django.db import models from django.db.models import Case, Count, F, Q, When, signals +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import timezone from django_countries.fields import CountryField @@ -37,6 +38,7 @@ class Coder(BaseModel): addition_fields = models.JSONField(default=dict, blank=True) n_accounts = models.IntegerField(default=0, db_index=True) n_contests = models.IntegerField(default=0, db_index=True) + n_subscribers = models.IntegerField(default=0, db_index=True, blank=True) tshirt_size = models.CharField(max_length=10, default=None, null=True, blank=True) is_virtual = models.BooleanField(default=False, db_index=True) global_rating = models.IntegerField(null=True, blank=True, default=None, db_index=True) @@ -175,6 +177,24 @@ def get_ordered_resources(self): def display_name(self): return self.settings['display_name'] if self.is_virtual else self.username + @property + def detailed_name(self): + if self.is_virtual: + return self.settings['display_name'] + + if self.user.first_name and self.user.last_name: + ret = f'{self.user.first_name} {self.user.last_name}' + elif self.user.first_name or self.user.last_name: + ret = self.user.first_name or self.user.last_name + elif self.first_name_native and self.last_name_native: + ret = f'{self.first_name_native} {self.last_name_native}' + elif self.first_name_native or self.last_name_native: + ret = self.first_name_native or self.last_name_native + else: + return self.username + + return f'{self.username} aka {ret}' + @property def has_global_rating(self): return django_settings.ENABLE_GLOBAL_RATING_ and self.global_rating is not None @@ -189,7 +209,7 @@ def detect_country(self): if self.country != max_country and 2 * max_counter > len(countries): self.country = max_country self.auto_detect_country = True - self.save() + self.save(update_fields=['country', 'auto_detect_country']) def add_account(self, account): coder = self @@ -223,6 +243,27 @@ def primary_accounts(self): qs = qs.distinct('resource') return qs + def get_limit(self, name, default=None): + return self.settings.get('limits', {}).get(name, default) + + def set_limit(self, name, value): + limits = self.settings.setdefault('limits', {}) + ret = limits.get(name) != value + limits[name] = value + return ret + + @property + def n_subscriptions_limit(self): + return self.get_limit('n_subscriptions', django_settings.CODER_N_SUBSCRIPTIONS_LIMIT_) + + @property + def subscription_top_n_limit(self): + return self.get_limit('subscription_top_n', django_settings.CODER_SUBSCRIPTION_TOP_N_LIMIT_) + + @property + def subscription_n_limit(self): + return self.get_limit('subscription_n', django_settings.CODER_SUBSCRIPTION_N_LIMIT_) + class CoderProblem(BaseModel): coder = models.ForeignKey(Coder, on_delete=models.CASCADE, related_name='verdicts') @@ -401,9 +442,8 @@ def filter_for_coder(coder): qs = CoderList.objects condition = Q(access_level=AccessLevel.PUBLIC) if coder is not None: - condition |= Q(owner=coder) qs = qs.annotate(has_shared_with=Exists('shared_with_coders', filter=Q(coder=coder))) - condition |= Q(access_level=AccessLevel.RESTRICTED, has_shared_with=True) + condition |= Q(owner=coder) | Q(access_level=AccessLevel.RESTRICTED, has_shared_with=True) return qs.filter(condition) @staticmethod @@ -430,7 +470,7 @@ def coders_and_accounts_ids(uuids, coder=None, logger=None): if logger: logger.warning(f'Ignore list with uuid = "{uuid}"') continue - for v in coder_list.values.select_related('coder').select_related('account'): + for v in coder_list.related_values: if v.coder: coders.add(v.coder.pk) if v.account: @@ -463,6 +503,16 @@ def coders_filter(uuids, coder=None, logger=None): ret = Q(id=0) return ret + @property + def related_values(self): + return self.values.select_related('coder__user', 'account__resource') + + def update_coders_or_accounts(self): + coders, accounts = CoderList.coders_and_accounts_ids([self.uuid], coder=self.owner) + for subscription in self.subscription_set.all(): + subscription.coders.set(coders) + subscription.accounts.set(accounts) + class ListValue(BaseModel): coder = models.ForeignKey(Coder, null=True, blank=True, on_delete=models.CASCADE) @@ -489,6 +539,11 @@ class Meta: ] +@receiver([post_save, post_delete], sender=ListValue) +def update_list(sender, instance, **kwargs): + instance.coder_list.update_coders_or_accounts() + + class ListProblem(BaseModel): problem = models.ForeignKey(Problem, related_name='lists', on_delete=models.CASCADE) coder_list = models.ForeignKey(CoderList, related_name='problems', on_delete=models.CASCADE) diff --git a/src/true_coders/views.py b/src/true_coders/views.py index d216adfb..06b44fd8 100644 --- a/src/true_coders/views.py +++ b/src/true_coders/views.py @@ -7,6 +7,7 @@ from collections import Counter from datetime import datetime, timedelta +import django_rq import humanize import pytz from django.apps import apps @@ -29,7 +30,6 @@ from django.views.decorators.http import require_http_methods from django_countries import countries from django_ratelimit.core import get_usage -from django_rq import job from el_pagination.decorators import page_template, page_templates from sql_util.utils import Exists, SubqueryCount, SubqueryMax, SubquerySum from tastypie.models import ApiKey @@ -47,6 +47,7 @@ from notes.models import Note from notification.forms import Notification, NotificationForm from notification.models import Calendar, NotificationMessage, Subscription +from notification.utils import compose_message_by_problems, send_messages from pyclist.decorators import context_pagination from pyclist.middleware import RedirectException from ranking.models import (Account, AccountRenaming, AccountVerification, Module, Rating, Statistics, VerifiedAccount, @@ -290,6 +291,7 @@ def coders(request, template='coders.html'): 'noajax': True, 'nogroupby': True, 'nourl': True, + 'icon': 'ghost', } chat_fields = None @@ -918,11 +920,8 @@ def settings(request, tab=None): services = Service.active_objects services = services.annotate(n_tokens=Count('token')).order_by('-n_tokens') - selected_resource = request.GET.get('resource') - selected_account = None - if selected_resource: - selected_resource = Resource.objects.filter(host=selected_resource).first() - selected_account = request.GET.get('account') + selected_resource = request.get_resource() + selected_account = request.GET.get('account') if selected_resource else None categories = coder.get_categories() custom_categories = {c.get_notification_method(): c.title for c in coder.chat_set.filter(is_group=True)} @@ -930,10 +929,17 @@ def settings(request, tab=None): my_lists = coder.my_list_set.annotate(n_records=SubqueryCount('values')) my_lists = my_lists.prefetch_related('shared_with_coders') - my_chats = coder.chat_set.order_by('-modified') - my_chats = my_chats.annotate(n_coders=SubqueryCount('coders')) - my_chats = my_chats.annotate(n_accounts=SubqueryCount('accounts')) - my_chats_fields = ['chat_id', 'title', 'name', 'n_coders', 'n_accounts'] + owned_chats = coder.chat_set.order_by('-modified') + owned_chats = owned_chats.annotate(n_coders=SubqueryCount('coders')) + owned_chats = owned_chats.annotate(n_accounts=SubqueryCount('accounts')) + joined_chats = coder.chats.order_by('-modified') + joined_chats = joined_chats.annotate(n_coders=SubqueryCount('coders')) + joined_chats = joined_chats.annotate(n_accounts=SubqueryCount('accounts')) + chats_fields = ['chat_id', 'title', 'name', 'n_coders', 'n_accounts'] + + subscriptions = coder.subscription_set.order_by('-modified') + subscriptions = subscriptions.prefetch_related('coders__user', 'accounts__resource') + subscriptions = subscriptions.select_related('contest__resource') return render( request, @@ -946,11 +952,14 @@ def settings(request, tab=None): "tokens": {t.service_id: t for t in coder.token_set.all()}, "services": services, "my_lists": my_lists, - "my_chats": my_chats, - "my_chats_fields": my_chats_fields, + "chats": { + "owned": owned_chats, + "joined": joined_chats, + "fields": chats_fields, + }, "categories": categories, "calendars": coder.calendar_set.order_by('-modified'), - "subscriptions": coder.subscription_set.order_by('-modified'), + "subscriptions": subscriptions, "event_description": Calendar.EventDescription, "custom_categories": custom_categories, "coder_notifications": coder.notification_set.order_by('method'), @@ -966,7 +975,7 @@ def settings(request, tab=None): ) -@job +@django_rq.job def call_command_parse_statistics(**kwargs): return call_command('parse_statistic', **kwargs) @@ -1305,46 +1314,155 @@ def change(request): n.save() except Exception: return HttpResponseBadRequest('invalid notification id') - elif name == "add-subscription": - if coder.subscription_set.count() >= 3: - return HttpResponseBadRequest("reached the limit number of subscriptions") - contest = get_object_or_404(Contest, pk=request.POST.get("contest")) - account = get_object_or_404(Account, pk=request.POST.get("account")) + elif name == "add-subscription" or name == "edit-subscription": + n_subscriptions_limit = coder.n_subscriptions_limit + if name == "edit-subscription": + n_subscriptions_limit += 1 + if coder.subscription_set.count() >= n_subscriptions_limit: + return HttpResponseBadRequest(f"reached the limit number of subscriptions ({n_subscriptions_limit})") + + resource_id = request.POST.get("resource") or None + contest_id = request.POST.get("contest") or None + coder_list_id = request.POST.get("coder_list") or None + coder_chat_id = request.POST.get("coder_chat") or None + with_first_accepted = is_yes(request.POST.get("with_first_accepted")) + top_n = as_number(request.POST.get("top_n"), force=True) or None + + accounts_limit = coder.subscription_n_limit + account_ids = request.POST.getlist("accounts[]") + if len(account_ids) > accounts_limit: + return HttpResponseBadRequest(f"reached the limit number of accounts ({accounts_limit})") + accounts = Account.objects.filter(pk__in=account_ids).values_list('pk', flat=True) + if resource_id: + accounts = accounts.filter(resource_id=resource_id) + elif contest_id: + accounts = accounts.filter(statistics__contest_id=contest_id) + else: + with_first_accepted = False + top_n = None + accounts = list(accounts) + + coders_limit = coder.subscription_n_limit + coder_ids = request.POST.getlist("coders[]") + if len(coder_ids) > coders_limit: + return HttpResponseBadRequest(f"reached the limit number of coders ({coders_limit})") + coders = Coder.objects.filter(pk__in=coder_ids).values_list('pk', flat=True) + coders = list(coders) + + if top_n and not 1 <= top_n <= coder.subscription_top_n_limit: + return HttpResponseBadRequest(f"top n should be in [1, {coder.subscription_top_n_limit}]") + + n_choosen = bool(accounts or coders) + bool(coder_list_id) + bool(coder_chat_id) + if n_choosen > 1: + return HttpResponseBadRequest("choose only one of accounts/coders or coder list or coder chat") + n_addition = bool(with_first_accepted or top_n) + if n_choosen + n_addition < 1: + return HttpResponseBadRequest("choose at least one of accounts/coders or coder list or coder chat" + " or first accepted/top n") + if coder_list_id: + coder_list = get_object_or_404(coder.my_list_set, pk=coder_list_id) + coders, accounts = CoderList.coders_and_accounts_ids([coder_list.uuid], coder=coder) + if coder_chat_id: + coder_chat = get_object_or_404(coder.chat_set, pk=coder_chat_id) + coders, accounts = coder_chat.coders.all(), coder_chat.accounts.all() + method = request.POST.get("method") categories = [k for k, v in coder.get_notifications()] if method not in categories: return HttpResponseBadRequest("invalid method value") - sub, created = Subscription.objects.get_or_create( - coder=coder, - method=method, - contest=contest, - account=account, - ) - return HttpResponse("created" if created else "ok") + + if name == "add-subscription": + subscription = Subscription.objects.create( + resource_id=resource_id, + contest_id=contest_id, + with_first_accepted=with_first_accepted, + top_n=top_n, + coder_list_id=coder_list_id, + coder_chat_id=coder_chat_id, + coder=coder, + method=method, + ) + elif name == "edit-subscription": + pk = int(request.POST.get("id")) + subscription = Subscription.objects.get(pk=pk, coder=coder) + subscription.resource_id = resource_id + subscription.contest_id = contest_id + subscription.with_first_accepted = with_first_accepted + subscription.top_n = top_n + subscription.coder_list_id = coder_list_id + subscription.coder_chat_id = coder_chat_id + subscription.method = method + subscription.save() + else: + return HttpResponseBadRequest("invalid name") + + subscription.accounts.set(accounts) + subscription.coders.set(coders) + elif name == "disable-subscription" or name == "enable-subscription": + subscription = get_object_or_404(coder.subscription_set, pk=request.POST.get("id")) + subscription.enable = name == "enable-subscription" + subscription.save(update_fields=['enable']) elif name == "delete-subscription": pk = int(request.POST.get("id", -1)) - sub = Subscription.objects.get(pk=pk, coder=coder) - sub.delete() + Subscription.objects.get(pk=pk, coder=coder).delete() + elif name == "view-subscription": + pk = int(request.POST.get("id", -1)) + subscription = Subscription.objects.get(pk=pk, coder=coder) + + if subscription.contest: + contest = subscription.contest + elif subscription.resource: + contest = subscription.resource.latest_parsed_contest() + else: + contest = None + + sent_statistics = set() + + def view_statistic_by_filter(query): + statistics = Statistics.objects.filter(query, skip_in_stats=False) + if contest: + statistics = statistics.filter(contest=contest).order_by('place_as_int') + else: + statistics = statistics.order_by('-contest__end_time')[:1] + for statistic in statistics: + if statistic.pk in sent_statistics: + continue + sent_statistics.add(statistic.pk) + view_contest = contest or statistic.contest + message = compose_message_by_problems('all', statistic, {}, view_contest) + subscription.send(message=message, contest=view_contest) + + if subscription.top_n: + view_statistic_by_filter(Statistics.top_n_filter(subscription.top_n)) + if subscription.with_first_accepted: + view_statistic_by_filter(Statistics.first_ac_filter()) + for subscription_account in subscription.accounts.all(): + view_statistic_by_filter(Q(account=subscription_account)) + for subscription_coder in subscription.coders.all(): + view_statistic_by_filter(Q(account__coders=subscription_coder)) + + if sent_statistics: + send_messages(coders=[coder.username]) elif name == "first-name": if not value: return HttpResponseBadRequest("empty first name") user.first_name = value - user.save() + user.save(update_fields=['first_name']) elif name == "last-name": if not value: return HttpResponseBadRequest("empty last name") user.last_name = value - user.save() + user.save(update_fields=['last_name']) elif name == "first-name-native": if not value: return HttpResponseBadRequest("empty first name in native language") coder.first_name_native = value - coder.save() + coder.save(update_fields=['first_name_native']) elif name == "last-name-native": if not value: return HttpResponseBadRequest("empty last name in native language") coder.last_name_native = value - coder.save() + coder.save(update_fields=['last_name_native']) elif name == "add-account": try: if "resource" in request.POST: @@ -1658,8 +1776,8 @@ def search(request, **kwargs): ret = [{'id': r.id, 'text': r.host, 'icon': r.icon} for r in qs] elif query == 'contests': qs = Contest.objects.select_related('resource') - if 'regex' in request.GET: - qs = qs.filter(get_iregex_filter(request.GET['regex'], 'title')) + if resource_regex := request.GET.get('regex'): + qs = qs.filter(get_iregex_filter(resource_regex, 'title')) if request.GET.get('has_problems') in django_settings.YES_: qs = qs.filter(info__problems__isnull=False, stage__isnull=True).exclude(info__problems__exact=[]) if request.GET.get('has_submissions') in django_settings.YES_: @@ -1672,9 +1790,12 @@ def search(request, **kwargs): if request.user.is_authenticated: qs = qs.annotate(disabled=VirtualStart.contests_filter(request.user.coder)) qs = qs.filter(start_time__lt=timezone.now(), stage__isnull=True, invisible=False) - resources = [r for r in request.GET.getlist('resources[]') if r] - if resources: + if resources := [r for r in request.GET.getlist('resources[]') if r]: qs = qs.filter(resource__pk__in=resources) + if resource_id := request.GET.get('resource'): + qs = qs.filter(resource_id=resource_id) + if no_stage := request.GET.get('no_stage'): + qs = qs.filter(stage__isnull=is_yes(no_stage)) qs = qs.order_by('-end_time', '-id') qs = qs[(page - 1) * count:page * count] ret = [{'id': r.id, 'text': r.title, 'icon': r.resource.icon, 'disabled': getattr(r, 'disabled', False)} @@ -1771,29 +1892,6 @@ def search(request, **kwargs): qs = [(c, n) for c, n in countries if name in n.lower()] qs = qs[(page - 1) * count:page * count] ret = [{'id': c, 'text': n} for c, n in qs] - elif query == 'account-for-add-subscription': - contest = get_object_or_404(Contest, pk=request.GET.get('contest')) - qs = contest.statistics_set.select_related('account') - search = request.GET.get('search') - if search: - qs = qs.filter(Q(account__key__icontains=search) | Q(account__name__icontains=search)) - qs = qs.order_by('place', '-solving') - qs = qs[(page - 1) * count:page * count] - ret = [ - { - 'id': s.account.id, - 'text': f'{s.account.key}, {s.account.name}' if s.account.name else s.account.key, - } - for s in qs - ] - elif query == 'contest-for-add-subscription': - qs = Contest.objects.filter(n_statistics__gt=0, end_time__gte=timezone.now()) - regex = request.GET.get('regex') - if regex: - qs = qs.filter(title__iregex=verify_regex(regex)) - qs = qs.order_by('-end_time', '-id') - qs = qs[(page - 1) * count:page * count] - ret = [{'id': c.id, 'text': c.title} for c in qs] elif query == 'notpast': qs = Contest.objects.filter(end_time__gte=timezone.now()) regex = request.GET.get('regex') @@ -1898,50 +1996,80 @@ def search(request, **kwargs): f_text = str(getattr(f, field_name)) ret.append({'id': f_id, 'text': f_text}) elif query == 'coders': - qs = Coder.objects.all() + qs = Coder.objects.select_related('user') - if 'regex' in request.GET: - qs = qs.filter(get_iregex_filter(request.GET['regex'], 'username')) + if contest_id := request.GET.get('contest'): + qs = qs.annotate(has_contest=Exists('account', filter=Q(account__statistics__contest_id=contest_id))) + qs = qs.filter(has_contest=True) + if resource_id := request.GET.get('resource'): + qs = qs.annotate(has_resource=Exists('account', filter=Q(account__resource_id=resource_id))) + qs = qs.filter(has_resource=True) - order = ['-n_accounts', 'pk'] + order = [] + if search := request.GET.get('search'): + qs = qs.filter( + Q(username__icontains=search) | + Q(user__first_name__icontains=search) | + Q(user__last_name__icontains=search) + ) + qs = qs.annotate(search_weight=Case( + When(username__iexact=search, then=Value(0)), + When(user__first_name__iexact=search, then=Value(1)), + When(user__last_name__iexact=search, then=Value(1)), + default=Value(2), + output_field=IntegerField() + )) + order.append('search_weight') if request.user.is_authenticated: - qs = qs.annotate(iam=Case( + qs = qs.annotate(my_weight=Case( When(pk=request.user.coder.pk, then=Value(0)), default=Value(1), output_field=IntegerField() )) - order.insert(0, 'iam') - qs = qs.order_by(*order, 'pk') + order.append('my_weight') + qs = qs.order_by(*order, '-n_contests', 'pk') qs = qs[(page - 1) * count:page * count] - ret = [{'id': r.id, 'text': r.username} for r in qs] + ret = [{'id': coder.id, 'text': coder.detailed_name} for coder in qs] elif query == 'accounts': - qs = Account.objects.all() - if request.GET.get('resource'): - qs = qs.filter(resource_id=int(request.GET.get('resource'))) - - with_resource = True - if request.GET.get('contest'): - qs = qs.filter(statistics__contest_id=int(request.GET.get('contest'))) + qs = Account.objects + if contest_id := request.GET.get('contest'): + qs = qs.filter(statistics__contest_id=contest_id) + order = ['statistics__place_as_int', '-statistics__solving'] + with_resource = False + elif resource_id := request.GET.get('resource'): + qs = qs.filter(resource_id=resource_id) + order = ['resource_rank'] with_resource = False - if with_resource: + else: + order = ['-n_contests', 'pk'] + with_resource = True qs = qs.select_related('resource') - order = ['-n_contests', 'pk'] - if 'search' in request.GET: - search = request.GET['search'] + if search := request.GET.get('search'): qs = qs.filter(get_iregex_filter(search, 'key', 'name', suffix='__icontains')) - qs = qs.annotate(match=Case( + qs = qs.annotate(search_match=Case( When(Q(key__iexact=search) | Q(name__iexact=search), then=Value(True)), default=Value(False), output_field=BooleanField(), )) - order.insert(0, '-match') - + order = ['-search_match'] + order qs = qs.order_by(*order, 'pk') qs = qs[(page - 1) * count:page * count] - ret = [{'id': r.id, 'text': r.display(with_resource=with_resource)} for r in qs] + ret = [{'id': account.id, 'text': account.display(with_resource=with_resource)} for account in qs] + elif query == 'coder_lists' and request.user.is_authenticated: + qs = request.user.coder.my_list_set.all() + if search := request.GET.get('search'): + qs = qs.filter(name__icontains=search) + qs = qs[(page - 1) * count:page * count] + ret = [{'id': coder_list.id, 'text': coder_list.name} for coder_list in qs] + elif query == 'coder_chats' and request.user.is_authenticated: + qs = request.user.coder.chats.all() + if search := request.GET.get('search'): + qs = qs.filter(title__icontains=search) + qs = qs[(page - 1) * count:page * count] + ret = [{'id': coder_chat.id, 'text': coder_chat.title} for coder_chat in qs] else: return HttpResponseBadRequest(f'invalid query = {escape(query)}') @@ -2113,6 +2241,8 @@ def party(request, slug, tab='ranking'): place = i + 1 s['place'] = place + admins = party.admins.all() + return render( request, 'party.html', @@ -2125,6 +2255,7 @@ def party(request, slug, tab='ranking'): 'party_contests': party_contests, 'results': results, 'coders': coders, + 'admins': admins, 'tab': 'ranking' if tab is None else tab, }, ) @@ -2165,7 +2296,7 @@ def view_list(request, uuid): qs = qs.prefetch_related('values__coder') coder_list = get_object_or_404(qs, uuid=uuid) - is_owner = coder_list.owner == coder + is_owner = coder and coder_list.owner_id == coder.pk can_modify = is_owner request_post = request.session.pop('view_list_request_post', None) or request.POST @@ -2276,7 +2407,7 @@ def add_account(a, log_value=None): return allowed_redirect(request.path) coder_values = {} - for v in coder_list.values.order_by('group_id').all(): + for v in coder_list.related_values.order_by('group_id').all(): data = coder_values.setdefault(v.group_id, {}) data.setdefault('list_values', []).append(v) diff --git a/src/utils/custom_request.py b/src/utils/custom_request.py index 6e06edde..88f06c29 100644 --- a/src/utils/custom_request.py +++ b/src/utils/custom_request.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 from functools import partial +from typing import Optional from django.contrib import messages +from clist.models import Resource + class RequestLogger: @@ -17,10 +20,15 @@ def __getattr__(self, attr): return ret -def get_filtered_list(request, field, options=None): +def get_resource(self, field='resource') -> Optional[Resource]: + resource = self.GET.get(field) + return Resource.get(resource) + + +def get_filtered_list(self, field, options=None): values = [ value - for value in request.GET.getlist(field) + for value in self.GET.getlist(field) if options is not None and value in options or options is None and value ] if values and isinstance(options, list): @@ -30,10 +38,10 @@ def get_filtered_list(request, field, options=None): return values -def get_filtered_value(request, field, options=None, default_first=None, allow_empty=False): +def get_filtered_value(self, field, options=None, default_first=None, allow_empty=False): if allow_empty and '' not in options: options = options + [''] - ret = get_filtered_list(request, field, options) + ret = self.get_filtered_list(field, options) if ret: return ret[-1] if default_first and options: @@ -41,8 +49,17 @@ def get_filtered_value(request, field, options=None, default_first=None, allow_e return None -def custom_request(request): +def set_canonical(self, url): + url = self.build_absolute_uri(url) + self.canonical_url = url + return self + + +def CustomRequest(request): setattr(request, 'logger', RequestLogger(request)) + setattr(request, 'get_resource', partial(get_resource, request)) setattr(request, 'get_filtered_list', partial(get_filtered_list, request)) setattr(request, 'get_filtered_value', partial(get_filtered_value, request)) + setattr(request, 'canonical_url', None) + setattr(request, 'set_canonical', partial(set_canonical, request)) return request diff --git a/src/utils/db.py b/src/utils/db.py index e77bc983..14eae96a 100644 --- a/src/utils/db.py +++ b/src/utils/db.py @@ -18,7 +18,16 @@ def dictfetchone(cursor): def find_app_by_table(table_name): + # First, try to find a model with a direct mapping to the table name for model in apps.get_models(): if model._meta.db_table == table_name: return model._meta.app_label + + # If no direct model found, check if the table is an automatic through table + for model in apps.get_models(): + for field in model._meta.many_to_many: + if field.remote_field.through._meta.db_table == table_name: + return model._meta.app_label + + # If still not found, return None return None diff --git a/src/utils/requester/__init__.py b/src/utils/requester/__init__.py index 081e0394..8b39acd1 100644 --- a/src/utils/requester/__init__.py +++ b/src/utils/requester/__init__.py @@ -95,8 +95,7 @@ def __getattr__(self, name): return getattr(self.args[0], name, None) -def raise_fail(err): - exc = FailOnGetResponse(err) +def raise_fail(err, exc): if exc.code or exc.url: msg = f'code = {exc.code}, url = `{exc.url}`' if exc.response: @@ -114,7 +113,7 @@ class NoVerifyWord(Exception): class Proxer(): DIVIDER = 3 - LIMIT_TIME = 2.5 + LIMIT_TIME = float(environ.get('PROXY_LIMIT_TIME', 3)) def load_data(self): try: @@ -125,6 +124,12 @@ def load_data(self): self._data.setdefault('proxies', {}) self._data.setdefault('sources', {}) self._data.setdefault('deferred', {}) + + env_proxy = environ.get('REQUESTER_PROXY') + if env_proxy and re.match(r'^[0-9]+\.+[0-9]+\.+[0-9]+\.+[0-9]+:[0-9]+$', env_proxy): + self._data['proxies'] = {} + self.add(env_proxy) + for proxy in self.proxies.values(): self.init_proxy(proxy) @@ -291,11 +296,14 @@ def ok(self, proxy, time_response=None): self.print(f'ok, {time_response} with average {self.time_response()}') self.check_proxy() - def fail(self, proxy): + def fail(self, proxy, force=False): with self.lock: if not self.proxy or proxy != self.proxy_address: return - self.proxy['_state'] //= self.DIVIDER + if force: + self.proxy['_state'] = 0 + else: + self.proxy['_state'] //= self.DIVIDER self.proxy['_total_fail'] += 1 self.update_value('_fail', 1) self.check_proxy() @@ -529,7 +537,7 @@ def __init__(self, caching=None, user_agent=None, headers=None, - file_name_with_proxies=default_filepath_proxies): + proxy_filepath=default_filepath_proxies): if cookie_filename: self.cookie_filename = cookie_filename if caching is not None: @@ -550,7 +558,7 @@ def __init__(self, ] self._init_opener_headers = self.headers self.init_opener() - self.set_proxy(proxy, file_name_with_proxies) + self.set_proxy(proxy, proxy_filepath) atexit.register(self.cleanup) def init_opener(self): @@ -591,6 +599,10 @@ def set_proxy(proxy): if self.proxer: self.proxer.connect(req=self, set_proxy=set_proxy) + def proxy_fail(self, proxy=None, force=False): + if self.proxer: + self.proxer.fail(proxy=proxy or self.proxy, force=force) + def get( self, url, @@ -742,14 +754,14 @@ def get( else: self.print(f'[error] code = {error_code}, response = {str(err)[:200]}') self.error = err - if self.proxer: - self.proxer.fail(proxy=str(proxy)) + self.proxy_fail(proxy) + error_exception = FailOnGetResponse(err) if additional_attempts and error_code in additional_attempts: additional_attempt = additional_attempts[error_code] if ( additional_attempt['count'] > 0 and - ('func' not in additional_attempt or additional_attempt['func'](FailOnGetResponse(err))) + ('func' not in additional_attempt or additional_attempt['func'](error_exception)) ): additional_attempt['count'] -= 1 attempt -= 1 @@ -760,8 +772,11 @@ def get( if attempt < n_attempts: sleep(attempt_delay) continue + if (fp := os.environ.get('REQUESTER_PAGE_ON_FAIL')) and error_exception.response: + with open(fp, 'w') as fo: + fo.write(error_exception.response) if self.assert_on_fail: - raise_fail(err) + raise_fail(err, error_exception) else: traceback.print_exc() return @@ -838,7 +853,7 @@ def get( if not return_url and not return_code: return page ret = [page] - if return_url or return_code: + if return_url: ret += [last_url] if return_code: ret += [response.code] @@ -849,7 +864,10 @@ def current_url(self): return self.last_url def head(self, url): - return self.opener.open(url).getheaders() + try: + self.opener.open(url).getheaders() + except urllib.error.HTTPError as e: + raise FailOnGetResponse(e) def geturl(self, url): try: @@ -1030,20 +1048,27 @@ def close(self): self.proxer.save_data() self.save_cookie() + def duplicate(self, **kwargs): + ret = copy.copy(self) + for k, v in kwargs.items(): + setattr(ret, k, v) + ret.init_opener() + return ret + def with_proxy(self, inplace=True, attributes=None, **kwargs): self.save_cookie() - if inplace: - ret = self - else: - ret = copy.copy(self) - ret.init_opener() - ret.set_proxy(proxy=True, **kwargs) + ret = self if inplace else copy.copy(self) + if attributes: orig_attributes = {} for field, value in attributes.items(): orig_attributes[field] = getattr(ret, field, None) setattr(ret, field, value) setattr(ret, 'orig_attributes', orig_attributes) + + if not inplace: + ret.init_opener() + ret.set_proxy(proxy=True, **kwargs) return ret def __enter__(self): diff --git a/src/utils/strings.py b/src/utils/strings.py index 0f5bff9d..352a310e 100644 --- a/src/utils/strings.py +++ b/src/utils/strings.py @@ -1,7 +1,11 @@ import random +import secrets import string from collections import Counter +from bs4 import BeautifulSoup +from markdown import markdown + def trim_on_newline(text, max_length): if len(text) <= max_length: @@ -28,5 +32,25 @@ def list_string_iou(a, b): return intersection / union if union > 0 else 0.0 +def slug_string_iou(a, b): + return list_string_iou(a.split('-'), b.split('-')) + + def random_string(length=40): return ''.join(random.choices(list(string.ascii_letters + string.digits), k=length)) + + +def generate_secret(length=16): + return secrets.token_hex(length) + + +def generate_secret_64(): + return generate_secret(32) + + +def markdown_to_text(markdown_text): + return BeautifulSoup(markdown(markdown_text), 'html.parser').get_text() + + +def markdown_to_html(markdown_text): + return markdown(markdown_text) diff --git a/src/utils/urlutils.py b/src/utils/urlutils.py new file mode 100644 index 00000000..a58502e2 --- /dev/null +++ b/src/utils/urlutils.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 + + +from urllib.parse import urljoin + +from django.conf import settings + + +def absolute_url(url): + return urljoin(settings.HTTPS_HOST_URL_, url)

    Method
    Account - {% include "account_table_cell.html" with account=subscription.account resource=subscription.account.resource without_country=True without_avatar=True without_circle=True %} - First ACOn
    Top N{{ subscription.top_n }}
    List - - {{ subscription.coder_list.name }} - + {{ subscription.coder_list.name }} + {% if perms.true_coders.change_coderlist %}{% admin_url subscription.coder_list %}{% endif %}
    Chat{{ subscription.coder_chat.title }} + {{ subscription.coder_chat.title }} + {% if perms.tg.change_chat %}{% admin_url subscription.coder_chat %}{% endif %} +
    Accounts + {% for account in subscription.accounts.all %} + {% if forloop.counter0 == n_split %}{{ n_more }} more{% endif %} + {% endwith %} +
    Coders + {% for coder in subscription.coders.all %} + {% if forloop.counter0 == n_split %}{{ n_more }} more{% endif %} + {% endwith %} +
    - + + + + +