Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
Loading items

Target

Select target project
  • mj/mo-submit
1 result
Select Git revision
Loading items
Show changes
Commits on Source (132)
recursive-include mo/web/templates * recursive-include mo/web/templates *
recursive-include mo/web/static * recursive-include mo/tex *
...@@ -4,6 +4,7 @@ import argparse ...@@ -4,6 +4,7 @@ import argparse
import sys import sys
import mo.db as db import mo.db as db
import mo.email
import mo.users import mo.users
import mo.util import mo.util
...@@ -13,8 +14,7 @@ parser.add_argument(dest='first_name', help='křestní jméno (jedno nebo více) ...@@ -13,8 +14,7 @@ parser.add_argument(dest='first_name', help='křestní jméno (jedno nebo více)
parser.add_argument(dest='last_name', help='příjmení (jedno nebo více)') parser.add_argument(dest='last_name', help='příjmení (jedno nebo více)')
parser.add_argument('--org', default=False, action='store_true', help='přidělí uživateli organizátorská práva') parser.add_argument('--org', default=False, action='store_true', help='přidělí uživateli organizátorská práva')
parser.add_argument('--admin', default=False, action='store_true', help='přidělí uživateli správcovská práva') parser.add_argument('--admin', default=False, action='store_true', help='přidělí uživateli správcovská práva')
parser.add_argument('--passwd', type=str, help='nastaví počáteční heslo') parser.add_argument('--passwd', type=str, help='nastaví počáteční heslo (jinak pošle aktivační mail)')
parser.add_argument('--mail', default=False, action='store_true', help='pošle uživateli mail o založení účtu')
args = parser.parse_args() args = parser.parse_args()
email = mo.users.normalize_email(args.email) email = mo.users.normalize_email(args.email)
...@@ -45,11 +45,9 @@ mo.util.log(db.LogType.user, user.user_id, { ...@@ -45,11 +45,9 @@ mo.util.log(db.LogType.user, user.user_id, {
if args.passwd is not None: if args.passwd is not None:
mo.users.set_password(user, args.passwd) mo.users.set_password(user, args.passwd)
token = mo.users.make_activation_token(user)
if args.mail:
token = mo.users.ask_reset_password(user)
session.commit() session.commit()
if args.mail: if args.passwd is None:
mo.util.send_new_account_email(user, token) mo.email.send_new_account_email(user, token)
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import sys
import mo.config as config import mo.config
import mo.email
import mo.db as db import mo.db as db
import mo.users import mo.users
import mo.util import mo.util
parser = argparse.ArgumentParser(description='Resetuje uživateli heslo a pošle mail') parser = argparse.ArgumentParser(description='Pošle uživateli nový aktivační mail')
parser.add_argument(dest='email', help='e-mailová adresa') parser.add_argument(dest='email', help='e-mailová adresa')
parser.add_argument('--new', default=False, action='store_true', help='pošle mail o založení účtu')
parser.add_argument('--mail-instead', metavar='EMAIL', default=None, help='pošle mail někomu jinému') parser.add_argument('--mail-instead', metavar='EMAIL', default=None, help='pošle mail někomu jinému')
args = parser.parse_args() args = parser.parse_args()
...@@ -22,13 +21,10 @@ user = mo.users.user_by_email(args.email) ...@@ -22,13 +21,10 @@ user = mo.users.user_by_email(args.email)
if user is None: if user is None:
mo.util.die('Tento uživatel neexistuje') mo.util.die('Tento uživatel neexistuje')
token = mo.users.ask_reset_password(user) token = mo.users.make_activation_token(user)
session.commit() session.commit()
if args.mail_instead: if args.mail_instead:
mo.config.MAIL_INSTEAD = args.mail_instead mo.config.MAIL_INSTEAD = args.mail_instead
if args.new: mo.email.send_new_account_email(user, token)
mo.util.send_new_account_email(user, token)
else:
mo.util.send_password_reset_email(user, token)
#!/usr/bin/env python3 #!/usr/bin/env python3
import mo.jobs import mo.jobs
import mo.util from mo.util import die, init_standalone
import argparse import argparse
parser = argparse.ArgumentParser(description='Spustí joby ve frontě') parser = argparse.ArgumentParser(description='Spustí joby ve frontě')
parser.add_argument('-j', '--job', type=int, metavar='ID', help='Spustí konkrétní job')
parser.add_argument('-r', '--retry', default=False, action='store_true', help='Znovu spustí dokončený job')
args = parser.parse_args() args = parser.parse_args()
mo.util.init_standalone() init_standalone()
if args.job is None:
if args.retry:
die("Přepínač --retry lze použít jen s --job")
mo.jobs.process_jobs() mo.jobs.process_jobs()
else:
tj = mo.jobs.TheJob(args.job)
if not tj.load():
die("Tento job neexistuje")
tj.run(retry=args.retry)
...@@ -9,21 +9,29 @@ Flask==1.1.2 ...@@ -9,21 +9,29 @@ Flask==1.1.2
Flask-Bootstrap==3.3.7.1 Flask-Bootstrap==3.3.7.1
Flask-SQLAlchemy==2.4.4 Flask-SQLAlchemy==2.4.4
Flask-WTF==0.14.3 Flask-WTF==0.14.3
importlib-metadata==4.6.0
itsdangerous==1.1.0 itsdangerous==1.1.0
Jinja2==2.11.2 Jinja2==2.11.2
lxml==4.6.2 lxml==4.6.2
markdown==3.3.4 Markdown==3.3.4
MarkupSafe==1.1.1 MarkupSafe==1.1.1
packaging==21.0
pikepdf==2.3.0 pikepdf==2.3.0
Pillow==8.1.0 Pillow==8.1.0
pkg-resources==0.0.0 pkg-resources==0.0.0
psycopg2==2.8.6 psycopg2==2.8.6
pycparser==2.20 pycparser==2.20
pyparsing==2.4.7
python-dateutil==2.8.1 python-dateutil==2.8.1
python-poppler==0.2.2
pytz==2020.5 pytz==2020.5
pyzbar==0.1.8
six==1.15.0 six==1.15.0
SQLAlchemy==1.3.22 SQLAlchemy==1.3.22
typing-extensions==3.10.0.0
uwsgidecorators==1.1.0 uwsgidecorators==1.1.0
visitor==0.1.3 visitor==0.1.3
webencodings==0.5.1
Werkzeug==1.0.1 Werkzeug==1.0.1
WTForms==2.3.3 WTForms==2.3.3
zipp==3.5.0
-- CREATE ROLE mo_osmo LOGIN PASSWORD 'pass'; -- CREATE ROLE mo_osmo LOGIN PASSWORD 'pass';
-- CREATE DATABASE mo_osmo WITH OWNER=mo_osmo; -- CREATE DATABASE mo_osmo WITH OWNER=mo_osmo;
-- GRANT mo_osmo TO some_admin; -- GRANT mo_osmo TO some_admin;
-- CREATE EXTENSION unaccent;
SET ROLE mo_osmo; SET ROLE mo_osmo;
-- Funkce pro odakcentování textu pomocí extension unaccent.
-- Je immutable, takže se dá používat i v indexech.
-- Zdroj: http://stackoverflow.com/questions/11005036/does-postgresql-support-accent-insensitive-collations
CREATE OR REPLACE FUNCTION f_unaccent(text)
RETURNS text AS
$func$
SELECT unaccent('unaccent', $1)
$func$ LANGUAGE sql IMMUTABLE SET search_path = public, pg_temp;
-- Uživatelský účet -- Uživatelský účet
CREATE TABLE users ( CREATE TABLE users (
user_id serial PRIMARY KEY, user_id serial PRIMARY KEY,
...@@ -15,7 +25,7 @@ CREATE TABLE users ( ...@@ -15,7 +25,7 @@ CREATE TABLE users (
is_test boolean NOT NULL DEFAULT false, -- testovací účastník, není vidět ve výsledkovkách is_test boolean NOT NULL DEFAULT false, -- testovací účastník, není vidět ve výsledkovkách
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at timestamp with time zone DEFAULT NULL, last_login_at timestamp with time zone DEFAULT NULL,
reset_at timestamp with time zone DEFAULT NULL, -- poslední požadavek na reset hesla reset_at timestamp with time zone DEFAULT NULL, -- poslední reset/aktivace nebo žádost o ně
password_hash varchar(255) DEFAULT NULL, -- heš hesla (je-li nastaveno) password_hash varchar(255) DEFAULT NULL, -- heš hesla (je-li nastaveno)
note text NOT NULL DEFAULT '' -- poznámka viditelná pro orgy note text NOT NULL DEFAULT '' -- poznámka viditelná pro orgy
); );
...@@ -45,6 +55,8 @@ CREATE TABLE places ( ...@@ -45,6 +55,8 @@ CREATE TABLE places (
); );
CREATE INDEX places_parent_index ON places (parent); CREATE INDEX places_parent_index ON places (parent);
-- XXX: Potřebujeme operator class text_pattern_ops, aby index fungoval na prefixové LIKE
CREATE INDEX places_noacc_index ON places ((lower(f_unaccent(name))) text_pattern_ops);
-- Rekurzivní dotaz na nadřazené regiony: -- Rekurzivní dotaz na nadřazené regiony:
-- WITH RECURSIVE parent_regions(parent, place_id) AS ( -- WITH RECURSIVE parent_regions(parent, place_id) AS (
...@@ -89,6 +101,12 @@ CREATE TYPE score_mode AS ENUM ( ...@@ -89,6 +101,12 @@ CREATE TYPE score_mode AS ENUM (
'mo' -- jednoznačné pořadí podle pravidel MO 'mo' -- jednoznačné pořadí podle pravidel MO
); );
CREATE TYPE enroll_mode AS ENUM ( -- režim přihlašování účastníků
'manual', -- přihlašuje organizátor
'register', -- účastník se registruje
'confirm' -- účastník se registruje, ale org ho musí potvrdit
);
CREATE TABLE rounds ( CREATE TABLE rounds (
round_id serial PRIMARY KEY, round_id serial PRIMARY KEY,
master_round_id int DEFAULT NULL REFERENCES rounds(round_id), master_round_id int DEFAULT NULL REFERENCES rounds(round_id),
...@@ -109,6 +127,8 @@ CREATE TABLE rounds ( ...@@ -109,6 +127,8 @@ CREATE TABLE rounds (
score_successful_limit int DEFAULT NULL, -- bodový limit na označení za úspěšného řešitele score_successful_limit int DEFAULT NULL, -- bodový limit na označení za úspěšného řešitele
points_step numeric(2,1) NOT NULL DEFAULT 1, -- s jakou přesností jsou přidělovány body (celé aneb 1, 0.5, 0.1) points_step numeric(2,1) NOT NULL DEFAULT 1, -- s jakou přesností jsou přidělovány body (celé aneb 1, 0.5, 0.1)
has_messages boolean NOT NULL DEFAULT false, -- má zprávičky has_messages boolean NOT NULL DEFAULT false, -- má zprávičky
enroll_mode enroll_mode NOT NULL DEFAULT 'manual', -- režim přihlašování (pro vyšší kola vždy 'manual')
enroll_advert varchar(255) NOT NULL DEFAULT '', -- popis v přihlašovacím formuláři
UNIQUE (year, category, seq, part) UNIQUE (year, category, seq, part)
); );
...@@ -133,17 +153,19 @@ CREATE TABLE participants ( ...@@ -133,17 +153,19 @@ CREATE TABLE participants (
school int NOT NULL REFERENCES places(place_id), school int NOT NULL REFERENCES places(place_id),
birth_year int NOT NULL, birth_year int NOT NULL,
grade varchar(20) NOT NULL, -- třída ve tvaru "X/Y" grade varchar(20) NOT NULL, -- třída ve tvaru "X/Y"
registered_on timestamp with time zone DEFAULT NULL, -- kdy se účastník přihlásil (NULL, pokud ho přihlásil organizátor)
PRIMARY KEY (user_id, year) PRIMARY KEY (user_id, year)
); );
-- Účast v soutěžním kole -- Účast v soutěžním kole
CREATE TYPE part_state AS ENUM ( CREATE TYPE part_state AS ENUM (
'registered', -- sám se přihlásil 'registered', -- sám se přihlásil, čeká na potvrzení organizátorem
'invited', -- pozván -- 'invited', -- pozván (už nepoužíváme)
'refused', -- odmítl účast 'active', -- soutěží (přihlášku zadal/potvrdil organizátor)
'present', -- soutěžil 'refused', -- organizátor odmítl přihlášku
'absent', -- bez omluvy nedorazil -- 'present', -- soutěžil (už nepoužíváme)
'absent', -- nedorazil
'disqualified' -- diskvalifikovaný 'disqualified' -- diskvalifikovaný
); );
...@@ -273,10 +295,13 @@ CREATE INDEX log_type_id_index ON log (type, id); ...@@ -273,10 +295,13 @@ CREATE INDEX log_type_id_index ON log (type, id);
CREATE TYPE job_type AS ENUM ( CREATE TYPE job_type AS ENUM (
'download_submits', 'download_submits',
'upload_feedback' 'upload_feedback',
'create_protocols',
'process_scans'
); );
CREATE TYPE job_state AS ENUM ( CREATE TYPE job_state AS ENUM (
'preparing',
'ready', 'ready',
'running', 'running',
'done', -- Hotovo, out_json a out_file jsou platné 'done', -- Hotovo, out_json a out_file jsou platné
...@@ -312,3 +337,80 @@ CREATE TABLE messages ( ...@@ -312,3 +337,80 @@ CREATE TABLE messages (
markdown text NOT NULL, markdown text NOT NULL,
html text NOT NULL html text NOT NULL
); );
-- Požadavky na registraci a změny vlastností účtu
CREATE TYPE reg_req_type AS ENUM (
'register',
'change_email',
'reset_password'
);
CREATE TABLE reg_requests (
reg_id serial PRIMARY KEY,
type reg_req_type NOT NULL,
created_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone NOT NULL,
used_at timestamp with time zone DEFAULT NULL,
email varchar(255) DEFAULT NULL, -- adresa, kterou potvrzujeme
captcha_token varchar(255) DEFAULT NULL, -- token pro fázi 1 registrace
email_token varchar(255) UNIQUE NOT NULL, -- token pro fázi 2 registrace
user_id int DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE,
client varchar(255) NOT NULL -- kdo si registraci vyžádal
);
-- Statistiky
-- Pro každý region spočítáme všechna podřízená místa
CREATE VIEW region_descendants AS
WITH RECURSIVE descendant_regions(place_id, descendant) AS (
SELECT place_id, place_id FROM places
UNION SELECT r.place_id, p.place_id
FROM descendant_regions r, places p
WHERE p.parent = r.descendant
) SELECT place_id AS region, descendant FROM descendant_regions;
-- Pro každou trojici (kolo, region, stav soutěže) spočítáme soutěže.
CREATE VIEW region_contest_stats AS
SELECT c.round_id, rd.region, c.state, count(*) AS count
FROM contests c
JOIN region_descendants rd ON rd.descendant = c.place_id
WHERE rd.region <> c.place_id
GROUP BY c.round_id, rd.region, c.state;
-- Pro každou trojici (kolo, region, stav účasti) spočítáme účastníky.
-- Pokud se to ukáže být příliš pomalé, přejdeme na materializovaná views.
CREATE VIEW region_participant_stats AS
SELECT c.round_id, rd.region, p.state, count(*) AS count
FROM participations p
JOIN contests c USING(contest_id)
JOIN region_descendants rd ON rd.descendant = c.place_id
GROUP BY c.round_id, rd.region, p.state;
-- Pro každou trojici (kolo, region, úloha) spočítáme řešení.
CREATE VIEW region_task_stats AS
SELECT r.round_id, rd.region, t.task_id, count(*) AS count
FROM rounds r
JOIN contests c USING(round_id)
JOIN participations p USING(contest_id)
JOIN tasks t USING(round_id)
JOIN solutions s USING(user_id, task_id)
JOIN region_descendants rd ON rd.descendant = c.place_id
GROUP BY r.round_id, rd.region, t.task_id;
-- Stav zpracování scanů (vázaný na joby)
CREATE TABLE scan_pages (
job_id int NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE,
file_nr int NOT NULL, -- co to je za stránku (od 0)
page_nr int NOT NULL,
user_id int DEFAULT NULL REFERENCES users(user_id), -- přiřazení účastníkovi a úloze
task_id int DEFAULT NULL REFERENCES tasks(task_id),
seq_id int NOT NULL, -- pořadové číslo v rámci úlohy (od 0)
-- Pokud user_id i task_id jsou NULL, seq_id znamená:
-- -1 pro stránku vyžadující pozornost
-- -2 pro prázdnou stránku
-- -3 pro pokračovací stránku
-- -4 pro stránku, která nepatří do této soutěže
UNIQUE (job_id, file_nr, page_nr)
);
SET ROLE 'mo_osmo';
ALTER TYPE job_type ADD VALUE 'create_protocols';
ALTER TYPE job_type ADD VALUE 'process_scans';
ALTER TYPE job_state ADD VALUE 'preparing';
CREATE TABLE scan_pages (
job_id int NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE,
file_nr int NOT NULL, -- co to je za stránku
page_nr int NOT NULL,
user_id int DEFAULT NULL REFERENCES users(user_id), -- přiřazení účastníkovi a úloze
task_id int DEFAULT NULL REFERENCES tasks(task_id),
seq_id int NOT NULL, -- pořadové číslo v rámci úlohy
UNIQUE (job_id, file_nr, page_nr)
);
SET ROLE 'mo_osmo';
CREATE TYPE enroll_mode AS ENUM ( -- režim přihlašování účastníků
'manual', -- přihlašuje organizátor
'register', -- účastník se registruje
'confirm' -- účastník se registruje, ale org ho musí potvrdit
);
ALTER TABLE rounds ADD COLUMN enroll_mode enroll_mode NOT NULL DEFAULT 'manual';
ALTER TABLE rounds ADD COLUMN enroll_advert varchar(255) NOT NULL DEFAULT '';
ALTER TYPE part_state ADD VALUE 'active' AFTER 'invited';
ALTER TABLE participants ADD COLUMN registered_on timestamp with time zone DEFAULT NULL;
UPDATE participations SET state='active' WHERE state IN ('registered', 'invited');
CREATE TYPE reg_req_type AS ENUM (
'register',
'change_email',
'reset_passwd'
);
CREATE TABLE reg_requests (
reg_id serial PRIMARY KEY,
type reg_req_type NOT NULL,
created_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone NOT NULL,
used_at timestamp with time zone DEFAULT NULL,
email varchar(255) DEFAULT NULL,
captcha_token varchar(255) DEFAULT NULL,
email_token varchar(255) UNIQUE NOT NULL,
user_id int DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE,
client varchar(255) NOT NULL
);
SET ROLE 'mo_osmo';
-- Musí udělat správce (ale uvnitř naší DB):
-- CREATE EXTENSION unaccent;
CREATE OR REPLACE FUNCTION f_unaccent(text)
RETURNS text AS
$func$
SELECT unaccent('unaccent', $1)
$func$ LANGUAGE sql IMMUTABLE SET search_path = public, pg_temp;
CREATE INDEX places_noacc_index ON places ((lower(f_unaccent(name))) text_pattern_ops);
SET ROLE 'mo_osmo';
-- Pro každý region spočítáme všechna podřízená místa
CREATE VIEW region_descendants AS
WITH RECURSIVE descendant_regions(place_id, descendant) AS (
SELECT place_id, place_id FROM places
UNION SELECT r.place_id, p.place_id
FROM descendant_regions r, places p
WHERE p.parent = r.descendant
) SELECT place_id AS region, descendant FROM descendant_regions;
-- Pro každou trojici (kolo, region, stav soutěže) spočítáme soutěže.
CREATE VIEW region_contest_stats AS
SELECT c.round_id, rd.region, c.state, count(*) AS count
FROM contests c
JOIN region_descendants rd ON rd.descendant = c.place_id
WHERE rd.region <> c.place_id
GROUP BY c.round_id, rd.region, c.state;
-- Pro každou trojici (kolo, region, stav účasti) spočítáme účastníky.
-- Pokud se to ukáže být příliš pomalé, přejdeme na materializovaná views.
CREATE VIEW region_participant_stats AS
SELECT c.round_id, rd.region, p.state, count(*) AS count
FROM participations p
JOIN contests c USING(contest_id)
JOIN region_descendants rd ON rd.descendant = c.place_id
GROUP BY c.round_id, rd.region, p.state;
-- Pro každou trojici (kolo, region, úloha) spočítáme řešení.
CREATE VIEW region_task_stats AS
SELECT r.round_id, rd.region, t.task_id, count(*) AS count
FROM rounds r
JOIN contests c USING(round_id)
JOIN participations p USING(contest_id)
JOIN tasks t USING(round_id)
JOIN solutions s USING(user_id, task_id)
JOIN region_descendants rd ON rd.descendant = c.place_id
GROUP BY r.round_id, rd.region, t.task_id;
...@@ -42,12 +42,20 @@ MAX_BATCH_CONTENT_LENGTH = 1000000000 ...@@ -42,12 +42,20 @@ MAX_BATCH_CONTENT_LENGTH = 1000000000
# Adresář, do kterého ukládáme data (pro vývoj relativní, pro instalaci absolutní) # Adresář, do kterého ukládáme data (pro vývoj relativní, pro instalaci absolutní)
DATA_DIR = 'data' DATA_DIR = 'data'
# Jak často se má provádět periodická kontrola dávek [s] # Jak často se má spouštět garbage collector na dávky a tokeny [s]
JOB_GC_PERIOD = 60 GC_PERIOD = 60
# Za jak dlouho expiruje dokončená dávka [min] # Za jak dlouho expiruje dokončená dávka [min]
JOB_EXPIRATION = 5 JOB_EXPIRATION = 5
# Automatické přihlašování účastníků do testovací soutěže # Některé dávky (analýza scanů) mají delší expiraci [min]
# (kolo aktuální_ročník-T-1, celostátní soutěž) JOB_EXPIRATION_LONG = 1440
AUTO_REGISTER_TEST = False
# Kolik nejvýše dovolujeme registrací za minutu
REG_MAX_PER_MINUTE = 10
# Jak dlouho vydrží tokeny používané při registraci a změnách e-mailu [min]
REG_TOKEN_VALIDITY = 10
# Aktuální ročník MO
CURRENT_YEAR = 71
...@@ -2,9 +2,6 @@ ...@@ -2,9 +2,6 @@
import datetime import datetime
# Aktuální ročník
current_year = 70
# Referenční čas nastavovaný v initu requestu (web) nebo při volání skriptu # Referenční čas nastavovaný v initu requestu (web) nebo při volání skriptu
now: datetime.datetime now: datetime.datetime
......
...@@ -5,23 +5,27 @@ import datetime ...@@ -5,23 +5,27 @@ import datetime
import decimal import decimal
from enum import Enum as PythonEnum, auto from enum import Enum as PythonEnum, auto
import locale import locale
import os
import re import re
from sqlalchemy import \ from sqlalchemy import \
Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, \ Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, \
text, func, \ text, func, \
create_engine, inspect, select create_engine, inspect, select
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload, aliased
from sqlalchemy.orm.attributes import get_history from sqlalchemy.orm.attributes import get_history
from sqlalchemy.orm.query import Query
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.expression import CTE from sqlalchemy.sql.expression import CTE
from sqlalchemy.sql.functions import ReturnTypeFromArgs
from sqlalchemy.sql.sqltypes import Numeric from sqlalchemy.sql.sqltypes import Numeric
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
import mo import mo
import mo.config as config
from mo.place_level import place_levels, PlaceLevel from mo.place_level import place_levels, PlaceLevel
from mo.util_format import timedelta, time_and_timedelta from mo.util_format import timeformat_short, timedelta, time_and_timedelta
# HACK: Work-around for https://github.com/dropbox/sqlalchemy-stubs/issues/114 # HACK: Work-around for https://github.com/dropbox/sqlalchemy-stubs/issues/114
from typing import TYPE_CHECKING, TypeVar, Type, Any from typing import TYPE_CHECKING, TypeVar, Type, Any
...@@ -34,6 +38,11 @@ else: ...@@ -34,6 +38,11 @@ else:
from sqlalchemy import Enum from sqlalchemy import Enum
# Funkce f_unaccent je definovaná v db.ddl, naučme Alchymii volat ji:
class f_unaccent(ReturnTypeFromArgs):
pass
Base = declarative_base() Base = declarative_base()
metadata = Base.metadata metadata = Base.metadata
...@@ -99,6 +108,7 @@ class Place(Base): ...@@ -99,6 +108,7 @@ class Place(Base):
nuts = Column(String(255), unique=True, server_default=text("NULL::character varying")) nuts = Column(String(255), unique=True, server_default=text("NULL::character varying"))
note = Column(Text, nullable=False, server_default=text("''::text")) note = Column(Text, nullable=False, server_default=text("''::text"))
parent_place = relationship('Place', primaryjoin='Place.parent == Place.place_id', remote_side='Place.place_id')
children = relationship('Place') children = relationship('Place')
school = relationship('School', uselist=False, back_populates='place') school = relationship('School', uselist=False, back_populates='place')
...@@ -132,6 +142,13 @@ def get_root_place(): ...@@ -132,6 +142,13 @@ def get_root_place():
return get_session().query(Place).filter_by(parent=None).one() return get_session().query(Place).filter_by(parent=None).one()
def get_place_by_id(place_id: int, fetch_school: bool = False) -> Place:
q = get_session().query(Place)
if fetch_school:
q = q.options(joinedload(Place.school))
return q.filter_by(place_id=place_id).one()
def get_place_by_code(code: str, fetch_school: bool = False) -> Optional[Place]: def get_place_by_code(code: str, fetch_school: bool = False) -> Optional[Place]:
code = code.strip() code = code.strip()
if code == "": if code == "":
...@@ -196,6 +213,22 @@ round_score_mode_names = { ...@@ -196,6 +213,22 @@ round_score_mode_names = {
} }
class RoundEnrollMode(MOEnum):
manual = auto()
register = auto()
confirm = auto()
def friendly_name(self) -> str:
return round_enroll_mode_names[self]
round_enroll_mode_names = {
RoundEnrollMode.manual: "Jen organizátoři",
RoundEnrollMode.register: "Účastníci sami",
RoundEnrollMode.confirm: "Potvrzení organizátorem",
}
# V DB jako numeric(2,1), používá se tak snadněji, než enum # V DB jako numeric(2,1), používá se tak snadněji, než enum
round_points_step_names = { round_points_step_names = {
decimal.Decimal('1'): "Celé body", decimal.Decimal('1'): "Celé body",
...@@ -230,6 +263,8 @@ class Round(Base): ...@@ -230,6 +263,8 @@ class Round(Base):
score_successful_limit = Column(Numeric) score_successful_limit = Column(Numeric)
points_step = Column(Numeric, nullable=False) points_step = Column(Numeric, nullable=False)
has_messages = Column(Boolean, nullable=False, server_default=text("false")) has_messages = Column(Boolean, nullable=False, server_default=text("false"))
enroll_mode = Column(Enum(RoundEnrollMode, name='enroll_mode'), nullable=False, server_default=text("'basic'::enroll_mode"))
enroll_advert = Column(String(255), nullable=False, server_default=text("''::text"))
master = relationship('Round', primaryjoin='Round.master_round_id == Round.round_id', remote_side='Round.round_id', post_update=True) master = relationship('Round', primaryjoin='Round.master_round_id == Round.round_id', remote_side='Round.round_id', post_update=True)
...@@ -274,6 +309,14 @@ class Round(Base): ...@@ -274,6 +309,14 @@ class Round(Base):
return round_points_step_names[self.points_step] return round_points_step_names[self.points_step]
return str(self.points_step) return str(self.points_step)
def format_times(self) -> str:
times = []
if self.ct_tasks_start is not None:
times.append('od ' + timeformat_short(self.ct_tasks_start))
if self.ct_submit_end is not None:
times.append('do ' + timeformat_short(self.ct_submit_end))
return " ".join(times)
class User(Base): class User(Base):
__tablename__ = 'users' __tablename__ = 'users'
...@@ -396,6 +439,7 @@ class Participant(Base): ...@@ -396,6 +439,7 @@ class Participant(Base):
school = Column(Integer, ForeignKey('places.place_id'), nullable=False) school = Column(Integer, ForeignKey('places.place_id'), nullable=False)
birth_year = Column(Integer, nullable=False) birth_year = Column(Integer, nullable=False)
grade = Column(String(20), nullable=False) grade = Column(String(20), nullable=False)
registered_on = Column(DateTime(True))
user = relationship('User') user = relationship('User')
school_place = relationship('Place', primaryjoin='Participant.school == Place.place_id') school_place = relationship('Place', primaryjoin='Participant.school == Place.place_id')
...@@ -403,9 +447,8 @@ class Participant(Base): ...@@ -403,9 +447,8 @@ class Participant(Base):
class PartState(MOEnum): class PartState(MOEnum):
registered = auto() registered = auto()
invited = auto() active = auto()
refused = auto() refused = auto()
present = auto()
absent = auto() absent = auto()
disqualified = auto() disqualified = auto()
...@@ -415,9 +458,8 @@ class PartState(MOEnum): ...@@ -415,9 +458,8 @@ class PartState(MOEnum):
part_state_names = { part_state_names = {
PartState.registered: 'přihlášený', PartState.registered: 'přihlášený',
PartState.invited: 'pozvaný', PartState.active: 'soutěží',
PartState.refused: 'odmítnutý', PartState.refused: 'odmítnutý',
PartState.present: 'přítomný',
PartState.absent: 'nepřítomný', PartState.absent: 'nepřítomný',
PartState.disqualified: 'diskvalifikovaný', PartState.disqualified: 'diskvalifikovaný',
} }
...@@ -503,6 +545,12 @@ class UserRole(Base): ...@@ -503,6 +545,12 @@ class UserRole(Base):
return " ".join(parts) return " ".join(parts)
def applies_to(self, at: Optional[Place] = None, year: Optional[int] = None, cat: Optional[str] = None, seq: Optional[int] = None) -> bool:
return ((at is None or self.place_id == at.place_id)
and (self.year is None or year is None or self.year == year)
and (self.category is None or cat is None or self.category == cat or (self.category == 'Z' and cat.startswith('Z')))
and (self.seq is None or seq is None or self.seq == seq))
class PaperType(MOEnum): class PaperType(MOEnum):
solution = auto() solution = auto()
...@@ -583,9 +631,12 @@ class Solution(Base): ...@@ -583,9 +631,12 @@ class Solution(Base):
class JobType(MOEnum): class JobType(MOEnum):
download_submits = auto() download_submits = auto()
upload_feedback = auto() upload_feedback = auto()
create_protocols = auto()
process_scans = auto()
class JobState(MOEnum): class JobState(MOEnum):
preparing = auto()
ready = auto() ready = auto()
running = auto() running = auto()
done = auto() done = auto()
...@@ -596,6 +647,7 @@ class JobState(MOEnum): ...@@ -596,6 +647,7 @@ class JobState(MOEnum):
job_state_names = { job_state_names = {
JobState.preparing: 'připravuje se',
JobState.ready: 'čeká na spuštění', JobState.ready: 'čeká na spuštění',
JobState.running: 'zpracovává se', JobState.running: 'zpracovává se',
JobState.done: 'dokončena', JobState.done: 'dokončena',
...@@ -622,6 +674,14 @@ class Job(Base): ...@@ -622,6 +674,14 @@ class Job(Base):
user = relationship('User') user = relationship('User')
def dir_path(self) -> str:
"""Adresář se soubory příslušejícími k jobu."""
# Nepoužíváme mo.util.data_dir, abychom se vyhnuli cyklické závislosti modulů.
return os.path.join(config.DATA_DIR, 'jobs', str(self.job_id))
def file_path(self, name: str) -> str:
return os.path.join(self.dir_path(), name)
class Message(Base): class Message(Base):
__tablename__ = 'messages' __tablename__ = 'messages'
...@@ -637,6 +697,96 @@ class Message(Base): ...@@ -637,6 +697,96 @@ class Message(Base):
created_by_user = relationship('User') created_by_user = relationship('User')
class RegReqType(MOEnum):
register = auto()
change_email = auto()
reset_passwd = auto()
class RegRequest(Base):
__tablename__ = 'reg_requests'
reg_id = Column(Integer, primary_key=True, server_default=text("nextval('reg_requests_reg_id_seq'::regclass)"))
type = Column(Enum(RegReqType, name='reg_req_type'), nullable=False)
created_at = Column(DateTime(True), nullable=False)
expires_at = Column(DateTime(True), nullable=False)
used_at = Column(DateTime(True))
captcha_token = Column(Text)
email = Column(Text)
email_token = Column(Text, nullable=False, unique=True)
user_id = Column(Integer, ForeignKey('users.user_id'))
client = Column(Text, nullable=False)
user = relationship('User')
class RegionDescendant(Base):
__tablename__ = 'region_descendants'
region = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
descendant = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
class RegionContestStat(Base):
__tablename__ = 'region_contest_stats'
round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True)
region = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
state = Column(Enum(RoundState, name='round_state'), primary_key=True)
count = Column(Integer, nullable=False)
round = relationship('Round')
region_place = relationship('Place', primaryjoin='RegionContestStat.region == Place.place_id', remote_side='Place.place_id')
class RegionParticipantStat(Base):
__tablename__ = 'region_participant_stats'
round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True)
region = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
state = Column(Enum(PartState, name='part_state'), primary_key=True)
count = Column(Integer, nullable=False)
round = relationship('Round')
region_place = relationship('Place', primaryjoin='RegionParticipantStat.region == Place.place_id', remote_side='Place.place_id')
class RegionTaskStat(Base):
__tablename__ = 'region_task_stats'
round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True)
region = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
task_id = Column(Integer, ForeignKey('tasks.task_id'), primary_key=True)
count = Column(Integer, nullable=False)
round = relationship('Round')
region_place = relationship('Place', primaryjoin='RegionTaskStat.region == Place.place_id', remote_side='Place.place_id')
task = relationship('Task')
class ScanPage(Base):
__tablename__ = 'scan_pages'
job_id = Column(Integer, ForeignKey('jobs.job_id', ondelete='CASCADE'), primary_key=True, nullable=False)
file_nr = Column(Integer, primary_key=True, nullable=False)
page_nr = Column(Integer, primary_key=True, nullable=False)
user_id = Column(Integer, ForeignKey('users.user_id'))
task_id = Column(Integer, ForeignKey('tasks.task_id'))
seq_id = Column(Integer, nullable=False)
UniqueConstraint('job_id', 'file_nr', 'page_nr')
job = relationship('Job')
user = relationship('User')
task = relationship('Task')
# Speciální seq_id ve ScanPage
SCAN_PAGE_FIX = -1
SCAN_PAGE_EMPTY = -2
SCAN_PAGE_CONTINUE = -3
SCAN_PAGE_UFO = -4
_engine: Optional[Engine] = None _engine: Optional[Engine] = None
_session: Optional[Session] = None _session: Optional[Session] = None
flask_db: Any = None flask_db: Any = None
...@@ -663,22 +813,24 @@ def get_seqs() -> List[int]: ...@@ -663,22 +813,24 @@ def get_seqs() -> List[int]:
return [seq for (seq,) in get_session().query(Round.seq).distinct()] return [seq for (seq,) in get_session().query(Round.seq).distinct()]
def get_place_parents(place: Place) -> List[Place]: def get_place_ancestors(place: Place) -> List[Place]:
"""Low-level funkce pro zjištění předků místa. """Low-level funkce pro zjištění předků místa.
Obvykle voláme mo.rights.Gatekeeper.get_parents(), které kešuje.""" Obvykle voláme mo.rights.Gatekeeper.get_ancestors(), které kešuje.
Pozor, výsledkem není plnohodnotný objekt Place, ale jen named tuple.
"""
sess = get_session() sess = get_session()
topq = (sess.query(Place) topq = (sess.query(Place)
.filter(Place.place_id == place.place_id) .filter(Place.place_id == place.place_id)
.cte('parents', recursive=True)) .cte('ancestors', recursive=True))
botq = (sess.query(Place) botq = (sess.query(Place)
.join(topq, Place.place_id == topq.c.parent)) .join(topq, Place.place_id == topq.c.parent))
recq = topq.union(botq) recq = topq.union(botq)
return sess.query(recq).all() return sorted(sess.query(recq).all(), key=lambda p: p.level)
def place_descendant_cte(place: Place, max_level: Optional[int] = None) -> CTE: def place_descendant_cte(place: Place, max_level: Optional[int] = None) -> CTE:
...@@ -708,6 +860,15 @@ def get_place_descendants(place: Place, min_level: Optional[int] = None, max_lev ...@@ -708,6 +860,15 @@ def get_place_descendants(place: Place, min_level: Optional[int] = None, max_lev
return q.all() return q.all()
def filter_place_nth_parent(query: Query, place_attr: Any, n: int, parent_id: int) -> Query:
assert n >= 0
for _ in range(n):
pp = aliased(Place)
query = query.join(pp, pp.place_id == place_attr)
place_attr = pp.parent
return query.filter(place_attr == parent_id)
def get_object_changes(obj): def get_object_changes(obj):
""" Given a model instance, returns dict of pending """ Given a model instance, returns dict of pending
changes waiting for database flush/commit. changes waiting for database flush/commit.
......
# Rozesílání e-mailových notifikací všeho druhu
import datetime
import email.message
import email.headerregistry
import subprocess
import textwrap
import urllib.parse
import mo.db as db
import mo.config as config
from mo.util import logger
def send_user_email(user: db.User, subject: str, body: str) -> bool:
logger.info(f'Mail: "{subject}" -> {user.email}')
mail_from = getattr(config, 'MAIL_FROM', None)
if mail_from is None:
logger.error('Mail: V configu chybí nastavení MAIL_FROM')
return False
msg = email.message.EmailMessage()
msg['From'] = email.headerregistry.Address(
display_name='Odevzdávací Systém MO',
addr_spec=mail_from,
)
msg['To'] = [
email.headerregistry.Address(
display_name=user.full_name(),
addr_spec=user.email,
)
]
msg['Reply-To'] = email.headerregistry.Address(
display_name='Správce OSMO',
addr_spec=config.MAIL_CONTACT,
)
msg['Subject'] = 'OSMO – ' + subject
msg['Date'] = datetime.datetime.now()
msg.set_content(body, cte='quoted-printable')
mail_instead = getattr(config, 'MAIL_INSTEAD', None)
if mail_instead is None:
send_to = user.email
else:
send_to = mail_instead
sm = subprocess.Popen(
[
'/usr/sbin/sendmail',
'-oi',
'-f',
mail_from,
send_to,
],
stdin=subprocess.PIPE,
)
sm.communicate(msg.as_bytes())
if sm.returncode != 0:
logger.error('Mail: Sendmail failed with return code {}'.format(sm.returncode))
return False
return True
def activate_url(token: str) -> str:
return config.WEB_ROOT + 'acct/activate?' + urllib.parse.urlencode({'token': token}, safe=':')
def confirm_url(type: str, token: str) -> str:
return config.WEB_ROOT + f'acct/confirm/{type}?' + urllib.parse.urlencode({'token': token}, safe=':')
def contestant_list_url(contest: db.Contest, registered_only: bool) -> str:
url = config.WEB_ROOT + f'org/contest/c/{contest.contest_id}/ucastnici'
if registered_only:
url += '?participation_state=registered'
return url
def send_new_account_email(user: db.User, token: str) -> bool:
return send_user_email(user, 'Založen nový účet', textwrap.dedent('''\
Vítejte!
Právě Vám byl založen účet v Odevzdávacím systému Matematické olympiády.
Nastavte si prosím heslo na následující stránce:
{}
Váš OSMO
'''.format(activate_url(token))))
def send_password_reset_email(user: db.User, token: str) -> bool:
return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\
Někdo (pravděpodobně Vy) požádal o obnovení hesla k Vašemu účtu v Odevzdávacím
systému Matematické olympiády. Heslo si můžete nastavit, případně požadavek
zrušit, na následující stránce:
{}
Váš OSMO
'''.format(confirm_url('p', token))))
def send_confirm_create_email(user: db.User, token: str) -> bool:
return send_user_email(user, 'Založení účtu', textwrap.dedent('''\
Někdo (pravděpodobně Vy) požádal o založení účtu s touto e-mailovou adresou
v Odevzdávacím systému Matematické olympiády. Pokud účet chcete založit,
následujte tento odkaz:
{}
Váš OSMO
'''.format(confirm_url('r', token))))
def send_confirm_change_email(user: db.User, token: str) -> bool:
return send_user_email(user, 'Změna e-mailové adresy', textwrap.dedent('''\
Někdo (pravděpodobně Vy) požádal o nastavení e-mailové adresy k účtu
v Odevzdávacím systému Matematické olympiády na tuto adresu.
Pokud změnu chcete provést, následujte tento odkaz:
{}
Váš OSMO
'''.format(confirm_url('e', token))))
def send_join_notify_email(dest: db.User, who: db.User, contest: db.Contest) -> bool:
round = contest.round
place = contest.place
if contest.round.enroll_mode == db.RoundEnrollMode.confirm:
confirm = 'Přihlášku je potřeba potvrdit v seznamu účastníků soutěže.'
url = 'Přihlášky k potvrzení: ' + contestant_list_url(contest, True)
else:
confirm = 'Přihláška byla schválena automaticky.'
url = 'Seznam účastníků: ' + contestant_list_url(contest, False)
return send_user_email(dest, f'Nový účastník kategorie {round.category}', textwrap.dedent(f'''\
Nový účastník se přihlásil do MO v oblasti, kterou garantujete.
Jméno: {who.full_name()}
E-mail: {who.email}
Kategorie: {round.category}
Kolo: {round.name}
Místo: {place.name}
{confirm}
{url}
Váš OSMO
'''))
...@@ -5,11 +5,13 @@ import io ...@@ -5,11 +5,13 @@ import io
import re import re
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm import joinedload, Query from sqlalchemy.orm import joinedload, Query
from typing import List, Optional, Any, Dict, Type, Union from typing import List, Optional, Any, Dict, Type, Union, Set
import mo.config as config
import mo.csv import mo.csv
from mo.csv import FileFormat, MissingHeaderError from mo.csv import FileFormat, MissingHeaderError
import mo.db as db import mo.db as db
import mo.email
import mo.rights import mo.rights
import mo.users import mo.users
import mo.util import mo.util
...@@ -55,6 +57,7 @@ class Import: ...@@ -55,6 +57,7 @@ class Import:
user: db.User user: db.User
round: Optional[db.Round] round: Optional[db.Round]
contest: Optional[db.Contest] contest: Optional[db.Contest]
only_region: Optional[db.Place]
task: Optional[db.Task] # pro Import bodů task: Optional[db.Task] # pro Import bodů
allow_add_del: bool # pro Import bodů: je povoleno zakládat/mazat řešení allow_add_del: bool # pro Import bodů: je povoleno zakládat/mazat řešení
fmt: FileFormat fmt: FileFormat
...@@ -69,6 +72,7 @@ class Import: ...@@ -69,6 +72,7 @@ class Import:
gatekeeper: mo.rights.Gatekeeper gatekeeper: mo.rights.Gatekeeper
new_user_ids: List[int] new_user_ids: List[int]
line_number: int = 0 line_number: int = 0
row_name: Optional[str] = None
def __init__(self): def __init__(self):
self.errors = [] self.errors = []
...@@ -84,6 +88,9 @@ class Import: ...@@ -84,6 +88,9 @@ class Import:
def error(self, msg: str) -> Any: def error(self, msg: str) -> Any:
if self.line_number > 0: if self.line_number > 0:
if self.row_name:
msg = f"Řádek {self.line_number} ({self.row_name}): {msg}"
else:
msg = f"Řádek {self.line_number}: {msg}" msg = f"Řádek {self.line_number}: {msg}"
self.errors.append(msg) self.errors.append(msg)
logger.info('Import: >> %s', msg) logger.info('Import: >> %s', msg)
...@@ -145,21 +152,16 @@ class Import: ...@@ -145,21 +152,16 @@ class Import:
return place return place
def parse_school(self, kod: str) -> Optional[db.Place]: def parse_school(self, kod: str) -> Optional[db.Place]:
if kod == "":
return self.error('Škola je povinná')
if kod in self.school_place_cache: if kod in self.school_place_cache:
return self.school_place_cache[kod] return self.school_place_cache[kod]
place = db.get_place_by_code(kod, fetch_school=True) try:
if not place: place = mo.users.validate_and_find_school(kod)
return self.error(f'Škola s kódem "{kod}" nenalezena'+ except mo.CheckError as e:
('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else '')) return self.error(str(e))
if place.type != db.PlaceType.school:
return self.error(f'Kód školy "{kod}" neodpovídá škole')
self.school_place_cache[kod] = place self.school_place_cache[kod] = place
return place return place
def parse_grade(self, rocnik: str, school: Optional[db.School]) -> Optional[str]: def parse_grade(self, rocnik: str, school: Optional[db.School]) -> Optional[str]:
...@@ -170,48 +172,30 @@ class Import: ...@@ -170,48 +172,30 @@ class Import:
# lidé připisují všechny možné i nemožné znaky, které vypadají jako apostrof :) # lidé připisují všechny možné i nemožné znaky, které vypadají jako apostrof :)
rocnik = re.sub('[\'"\u00b4\u2019]', "", rocnik) rocnik = re.sub('[\'"\u00b4\u2019]', "", rocnik)
if (not re.fullmatch(r'\d(/\d)?', rocnik)): try:
return self.error(f'Ročník má neplatný formát, musí to být buď číslice, nebo číslice/číslice') return mo.users.normalize_grade(rocnik, school)
except mo.CheckError as e:
if (not school.is_zs and re.fullmatch(r'\d', rocnik)): return self.error(str(e))
return self.error(f'Ročník pro střední školu ({school.place.name}) zapisujte ve formátu číslice/číslice')
if (not school.is_ss and re.fullmatch(r'\d/\d', rocnik)):
return self.error(f'Ročník pro základní školu ({school.place.name}) zapisujte jako číslici 1–9')
return rocnik
def parse_born(self, rok: str) -> Optional[int]: def parse_born(self, rok: str) -> Optional[int]:
if not re.fullmatch(r'\d{4}', rok): if not re.fullmatch(r'\d{4}', rok):
return self.error('Rok narození musí být čtyřciferné číslo') return self.error('Rok narození musí být čtyřciferné číslo')
r = int(rok) r = int(rok)
if r < 2000 or r > 2099:
return self.error('Rok narození musí být v intervalu [2000,2099]') try:
mo.users.validate_born_year(r)
except mo.CheckError as e:
return self.error(str(e))
return r return r
def find_or_create_user(self, email: str, krestni: str, prijmeni: str, is_org: bool) -> Optional[db.User]: def find_or_create_user(self, email: str, krestni: Optional[str], prijmeni: Optional[str], is_org: bool) -> Optional[db.User]:
sess = db.get_session() try:
user = sess.query(db.User).filter_by(email=email).one_or_none() user, is_new = mo.users.find_or_create_user(email, krestni, prijmeni, is_org, reason='import')
if user: except mo.CheckError as e:
if user.first_name != krestni or user.last_name != prijmeni: return self.error(str(e))
return self.error(f'Osoba již registrována s odlišným jménem {user.full_name()}') if is_new:
if (user.is_admin or user.is_org) != is_org:
if is_org:
return self.error('Nelze předefinovat účastníka na organizátora')
else:
return self.error('Nelze předefinovat organizátora na účastníka')
else:
user = db.User(email=email, first_name=krestni, last_name=prijmeni, is_org=is_org)
sess.add(user)
sess.flush() # Aby uživatel dostal user_id
logger.info(f'Import: Založen uživatel user=#{user.user_id} email=<{user.email}>')
mo.util.log(
type=db.LogType.user,
what=user.user_id,
details={'action': 'import', 'new': db.row2dict(user)},
)
self.cnt_new_users += 1 self.cnt_new_users += 1
self.new_user_ids.append(user.user_id) self.new_user_ids.append(user.user_id)
return user return user
...@@ -230,69 +214,48 @@ class Import: ...@@ -230,69 +214,48 @@ class Import:
return pts return pts
def find_or_create_participant(self, user: db.User, year: int, school_id: int, birth_year: int, grade: str) -> Optional[db.Participant]: def find_or_create_participant(self, user: db.User, year: int, school_id: Optional[int], birth_year: Optional[int], grade: Optional[str]) -> Optional[db.Participant]:
sess = db.get_session() try:
part = sess.query(db.Participant).get((user.user_id, year)) part, is_new = mo.users.find_or_create_participant(user, year, school_id, birth_year, grade, reason='import')
if part: except mo.CheckError as e:
if (part.school != school_id return self.error(str(e))
or part.grade != grade if is_new:
or part.birth_year != birth_year):
return self.error('Účastník již zaregistrován s odlišnou školou/ročníkem/rokem narození')
else:
part = db.Participant(user=user, year=year, school=school_id, birth_year=birth_year, grade=grade)
sess.add(part)
logger.info(f'Import: Založen účastník #{user.user_id}')
mo.util.log(
type=db.LogType.participant,
what=user.user_id,
details={'action': 'import', 'new': db.row2dict(part)},
)
self.cnt_new_participants += 1 self.cnt_new_participants += 1
return part return part
def find_or_create_participation(self, user: db.User, contest: db.Contest, place: Optional[db.Place]) -> Optional[db.Participation]: def find_or_create_participation(self, user: db.User, contest: db.Contest, place: Optional[db.Place]) -> Optional[db.Participation]:
if place is None: try:
place = contest.place pion, is_new = mo.users.find_or_create_participation(user, contest, place, reason='import')
except mo.CheckError as e:
sess = db.get_session() return self.error(str(e))
pions = (sess.query(db.Participation) if is_new:
.filter_by(user=user)
.filter(db.Participation.contest.has(db.Contest.round == contest.round))
.all())
if not pions:
pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.invited)
sess.add(pion)
logger.info(f'Import: Založena účast user=#{user.user_id} contest=#{contest.contest_id} place=#{place.place_id}')
mo.util.log(
type=db.LogType.participant,
what=user.user_id,
details={'action': 'add-to-contest', 'new': db.row2dict(pion)},
)
self.cnt_new_participations += 1 self.cnt_new_participations += 1
elif len(pions) == 1:
pion = pions[0]
if pion.place != place:
return self.error(f'Již se tohoto kola účastní v {contest.round.get_level().name_locative("jiném", "jiné", "jiném")} ({pion.place.get_code()})')
else:
return self.error('Již se tohoto kola účastní ve vice oblastech, což by nemělo být možné')
return pion return pion
def place_is_allowed(self, place: db.Place) -> bool:
if self.contest is not None and self.contest.place_id != place.place_id:
return False
if self.only_region is not None and not self.gatekeeper.is_ancestor_of(self.only_region, place):
return False
return True
def obtain_contest(self, oblast: Optional[db.Place], allow_none: bool = False): def obtain_contest(self, oblast: Optional[db.Place], allow_none: bool = False):
assert self.round
if oblast is not None and not self.place_is_allowed(oblast):
return self.error('Oblast neodpovídá té, do které se importuje')
if self.contest: if self.contest:
contest = self.contest contest = self.contest
if oblast is not None and oblast.place_id != contest.place.place_id:
return self.error('Oblast neodpovídá té, do které se importuje')
else: else:
# Zde mluvíme o oblastech, místo abychom používali place_levels,
# protože sloupec má ve jménu oblast a také je potřeba rozlišovat školu
# účastníka a školu jako oblast.
if oblast is None: if oblast is None:
if not allow_none: if not allow_none:
self.error('Je nutné uvést ' + self.round.get_level().name) self.error('Je nutné uvést kód oblasti')
return None return None
contest = db.get_session().query(db.Contest).filter_by(round=self.round, place=oblast).one_or_none() contest = db.get_session().query(db.Contest).filter_by(round=self.round, place=oblast).one_or_none()
if contest is None: if contest is None:
return self.error('V ' + self.round.get_level().name_locative("uvedeném", "uvedené", "uvedeném") + ' toto kolo neprobíhá') return self.error('V uvedené oblasti toto kolo neprobíhá')
return contest return contest
...@@ -330,6 +293,8 @@ class Import: ...@@ -330,6 +293,8 @@ class Import:
args.append(f'round=#{self.round.round_id}') args.append(f'round=#{self.round.round_id}')
if self.contest is not None: if self.contest is not None:
args.append(f'contest=#{self.contest.contest_id}') args.append(f'contest=#{self.contest.contest_id}')
if self.only_region is not None:
args.append(f'region=#{self.only_region.place_id}')
if self.task is not None: if self.task is not None:
args.append(f'task=#{self.task.task_id}') args.append(f'task=#{self.task.task_id}')
...@@ -350,17 +315,21 @@ class Import: ...@@ -350,17 +315,21 @@ class Import:
args.append(f'{key}={val}') args.append(f'{key}={val}')
logger.info('Import: Hotovo (%s)', " ".join(args)) logger.info('Import: Hotovo (%s)', " ".join(args))
details = self.log_details.copy()
if self.only_region:
details['region'] = self.only_region.place_id
if self.contest is not None: if self.contest is not None:
mo.util.log( mo.util.log(
type=db.LogType.contest, type=db.LogType.contest,
what=self.contest.contest_id, what=self.contest.contest_id,
details=self.log_details, details=details,
) )
elif self.round is not None: elif self.round is not None:
mo.util.log( mo.util.log(
type=db.LogType.round, type=db.LogType.round,
what=self.round.round_id, what=self.round.round_id,
details=self.log_details, details=details,
) )
else: else:
assert False assert False
...@@ -376,6 +345,12 @@ class Import: ...@@ -376,6 +345,12 @@ class Import:
except UnicodeDecodeError: except UnicodeDecodeError:
return False return False
def get_row_name(self, row: mo.csv.Row) -> Optional[str]:
if hasattr(row, 'email'):
return row.email # type: ignore
# čtení prvku potomka
return None
def generic_import(self, path: str) -> bool: def generic_import(self, path: str) -> bool:
charset = self.fmt.get_charset() charset = self.fmt.get_charset()
if charset != 'utf-8' and self.check_utf8(path): if charset != 'utf-8' and self.check_utf8(path):
...@@ -396,12 +371,14 @@ class Import: ...@@ -396,12 +371,14 @@ class Import:
self.line_number = 2 self.line_number = 2
for row in rows: for row in rows:
self.row_name = self.get_row_name(row)
self.cnt_rows += 1 self.cnt_rows += 1
self.import_row(row) self.import_row(row)
if len(self.errors) >= 100: if len(self.errors) >= 100:
self.errors.append('Import přerušen pro příliš mnoho chyb') self.errors.append('Import přerušen pro příliš mnoho chyb')
break break
self.line_number += 1 self.line_number += 1
self.row_name = None
return len(self.errors) == 0 return len(self.errors) == 0
...@@ -415,9 +392,9 @@ class Import: ...@@ -415,9 +392,9 @@ class Import:
for uid in self.new_user_ids: for uid in self.new_user_ids:
u = sess.query(db.User).get(uid) u = sess.query(db.User).get(uid)
if u and not u.password_hash and not u.reset_at: if u and not u.password_hash and not u.reset_at:
token = mo.users.ask_reset_password(u) token = mo.users.make_activation_token(u)
sess.commit() sess.commit()
mo.util.send_new_account_email(u, token) mo.email.send_new_account_email(u, token)
else: else:
sess.rollback() sess.rollback()
...@@ -475,28 +452,23 @@ class ContestImport(Import): ...@@ -475,28 +452,23 @@ class ContestImport(Import):
assert isinstance(r, ContestImportRow) assert isinstance(r, ContestImportRow)
num_prev_errs = len(self.errors) num_prev_errs = len(self.errors)
email = self.parse_email(r.email) email = self.parse_email(r.email)
krestni = self.parse_name(r.krestni) krestni = self.parse_name(r.krestni) if r.krestni else None
prijmeni = self.parse_name(r.prijmeni) prijmeni = self.parse_name(r.prijmeni) if r.prijmeni else None
school_place = self.parse_school(r.kod_skoly) school_place = self.parse_school(r.kod_skoly) if r.kod_skoly else None
rocnik = self.parse_grade(r.rocnik, (school_place.school if school_place else None)) rocnik = self.parse_grade(r.rocnik, (school_place.school if school_place else None)) if r.rocnik else None
rok_naroz = self.parse_born(r.rok_naroz) rok_naroz = self.parse_born(r.rok_naroz) if r.rok_naroz else None
misto = self.parse_opt_place(r.kod_mista, 'místo') misto = self.parse_opt_place(r.kod_mista, 'místo')
oblast = self.parse_opt_place(r.kod_oblasti, 'oblast') oblast = self.parse_opt_place(r.kod_oblasti, 'oblast')
if (len(self.errors) > num_prev_errs if (len(self.errors) > num_prev_errs
or email is None or email is None):
or krestni is None
or prijmeni is None
or school_place is None
or rocnik is None
or rok_naroz is None):
return return
user = self.find_or_create_user(email, krestni, prijmeni, is_org=False) user = self.find_or_create_user(email, krestni, prijmeni, is_org=False)
if user is None: if user is None:
return return
part = self.find_or_create_participant(user, mo.current_year, school_place.place_id, rok_naroz, rocnik) part = self.find_or_create_participant(user, config.CURRENT_YEAR, school_place.place_id if school_place else None, rok_naroz, rocnik)
if part is None: if part is None:
return return
...@@ -634,6 +606,9 @@ class PointsImport(Import): ...@@ -634,6 +606,9 @@ class PointsImport(Import):
query = query.filter(db.Participation.contest_id == self.contest.master_contest_id) query = query.filter(db.Participation.contest_id == self.contest.master_contest_id)
else: else:
contest_query = sess.query(db.Contest.master_contest_id).filter_by(round=self.round) contest_query = sess.query(db.Contest.master_contest_id).filter_by(round=self.round)
if self.only_region:
assert self.round
contest_query = db.filter_place_nth_parent(contest_query, db.Contest.place_id, self.round.level - self.only_region.level, self.only_region.place_id)
query = query.filter(db.Participation.contest_id.in_(contest_query.subquery())) query = query.filter(db.Participation.contest_id.in_(contest_query.subquery()))
return query return query
...@@ -661,7 +636,13 @@ class PointsImport(Import): ...@@ -661,7 +636,13 @@ class PointsImport(Import):
query = self._pion_sol_query().filter(db.Participation.user_id == user_id) query = self._pion_sol_query().filter(db.Participation.user_id == user_id)
pion_sols = query.all() pion_sols = query.all()
if not pion_sols: if not pion_sols:
return self.error('Soutěžící nenalezen v tomto kole') if self.contest is not None:
msg = self.round.get_level().name_locative('tomto', 'této', 'tomto')
elif self.only_region is not None:
msg = self.only_region.get_level().name_locative('tomto', 'této', 'tomto')
else:
msg = 'tomto kole'
return self.error(f'Soutěžící nenalezen v {msg}')
elif len(pion_sols) > 1: elif len(pion_sols) > 1:
return self.error('Soutěžící v tomto kole soutěží vícekrát, neumím zpracovat') return self.error('Soutěžící v tomto kole soutěží vícekrát, neumím zpracovat')
pion, sol = pion_sols[0] pion, sol = pion_sols[0]
...@@ -671,10 +652,6 @@ class PointsImport(Import): ...@@ -671,10 +652,6 @@ class PointsImport(Import):
else: else:
contest = sess.query(db.Contest).filter_by(round=self.round, master_contest_id=pion.contest_id).one() contest = sess.query(db.Contest).filter_by(round=self.round, master_contest_id=pion.contest_id).one()
if self.contest is not None:
if contest != self.contest:
return self.error('Soutěžící nesoutěží v ' + self.round.get_level().name_locative('tomto', 'této', 'tomto'))
rights = self.gatekeeper.rights_for_contest(contest) rights = self.gatekeeper.rights_for_contest(contest)
if not rights.can_edit_points(): if not rights.can_edit_points():
return self.error('Nemáte právo na úpravu bodů') return self.error('Nemáte právo na úpravu bodů')
...@@ -755,6 +732,7 @@ def create_import(user: db.User, ...@@ -755,6 +732,7 @@ def create_import(user: db.User,
fmt: FileFormat, fmt: FileFormat,
round: Optional[db.Round] = None, round: Optional[db.Round] = None,
contest: Optional[db.Contest] = None, contest: Optional[db.Contest] = None,
only_region: Optional[db.Place] = None,
task: Optional[db.Task] = None, task: Optional[db.Task] = None,
allow_add_del: bool = False): allow_add_del: bool = False):
imp: Import imp: Import
...@@ -772,6 +750,7 @@ def create_import(user: db.User, ...@@ -772,6 +750,7 @@ def create_import(user: db.User,
imp.user = user imp.user = user
imp.round = round imp.round = round
imp.contest = contest imp.contest = contest
imp.only_region = only_region
imp.task = task imp.task = task
imp.allow_add_del = allow_add_del imp.allow_add_del = allow_add_del
imp.fmt = fmt imp.fmt = fmt
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
from datetime import timedelta from datetime import timedelta
import os import os
import shutil
from sqlalchemy import or_ from sqlalchemy import or_
from typing import Optional, Dict, Callable, List from typing import Optional, Dict, Callable, List
...@@ -19,20 +20,6 @@ def send_notify(): ...@@ -19,20 +20,6 @@ def send_notify():
logger.debug('Job: Není komu poslat notifikaci') logger.debug('Job: Není komu poslat notifikaci')
def job_file_path(name: str) -> str:
return os.path.join(mo.util.data_dir('jobs'), name)
def job_file_size(name: Optional[str]) -> Optional[int]:
if name is None:
return None
try:
return os.path.getsize(job_file_path(name))
except OSError:
return -1
class TheJob: class TheJob:
"""Job z pohledu Pythonu.""" """Job z pohledu Pythonu."""
...@@ -40,6 +27,7 @@ class TheJob: ...@@ -40,6 +27,7 @@ class TheJob:
job_id: Optional[int] job_id: Optional[int]
gatekeeper: Optional[mo.rights.Gatekeeper] gatekeeper: Optional[mo.rights.Gatekeeper]
errors: List[str] errors: List[str]
expires_in_minutes: int
def __init__(self, job_id: Optional[int] = None): def __init__(self, job_id: Optional[int] = None):
"""Pokud chceme pracovat s existujícím jobem, zadáme jeho ID.""" """Pokud chceme pracovat s existujícím jobem, zadáme jeho ID."""
...@@ -47,40 +35,51 @@ class TheJob: ...@@ -47,40 +35,51 @@ class TheJob:
self.errors = [] self.errors = []
def load(self) -> db.Job: def load(self) -> db.Job:
if getattr(self, 'job', None) is None:
sess = db.get_session() sess = db.get_session()
self.job = sess.query(db.Job).with_for_update().get(self.job_id) self.job = sess.query(db.Job).with_for_update().get(self.job_id)
return self.job return self.job
def create(self, type: db.JobType, for_user: db.User) -> db.Job: def create(self, type: db.JobType, for_user: db.User) -> db.Job:
self.job = db.Job(type=type, state=db.JobState.ready, user=for_user) self.job = db.Job(type=type, state=db.JobState.preparing, user=for_user)
return self.job
def attach_file(self, tmp_name: str, suffix: str):
"""Vytvoří hardlink na daný pracovní soubor v adresáři jobů."""
full_name = mo.util.link_to_dir(tmp_name, mo.util.data_dir('jobs'), suffix=suffix) # Do DB přidáváme nehotový job, protože potřebujeme znát job_id pro založení adresáře
name = os.path.basename(full_name)
logger.debug(f'Job: Příloha {tmp_name} -> {name}')
return name
def submit(self):
sess = db.get_session() sess = db.get_session()
sess.add(self.job) sess.add(self.job)
sess.flush() sess.flush()
self.job_id = self.job.job_id self.job_id = self.job.job_id
logger.info(f'Job: Vytvořen job #{self.job_id} pro uživatele #{self.job.user_id}') logger.info(f'Job: Vytvořen job #{self.job_id} pro uživatele #{self.job.user_id}')
sess.commit()
job_dir = self.job.dir_path()
if os.path.exists(job_dir):
# Hypoteticky by se mohlo stát, že se recykluje job_id od jobu, jehož
# vytvoření selhalo před commitem. Zkusíme tedy smazat prázdný adresář.
os.rmdir(job_dir)
os.mkdir(job_dir)
return self.job
def attach_file(self, tmp_name: str, attachment_name: str) -> str:
"""Vytvoří hardlink na daný pracovní soubor v adresáři jobu."""
full_name = self.job.file_path(attachment_name)
os.link(tmp_name, full_name)
logger.debug(f'Job: Příloha {tmp_name} -> {full_name}')
return attachment_name
def submit(self):
self.job.state = db.JobState.ready
db.get_session().commit()
send_notify() send_notify()
def _finish_remove(self): def _finish_remove(self):
sess = db.get_session() sess = db.get_session()
job = self.job job = self.job
if job.in_file is not None: job_dir = self.job.dir_path()
mo.util.unlink_if_exists(job_file_path(job.in_file)) if os.path.exists(job_dir):
shutil.rmtree(job_dir)
if job.out_file is not None:
mo.util.unlink_if_exists(job_file_path(job.out_file))
sess.delete(job) sess.delete(job)
sess.commit() sess.commit()
...@@ -106,21 +105,42 @@ class TheJob: ...@@ -106,21 +105,42 @@ class TheJob:
logger.info(f'Job: >> {msg}') logger.info(f'Job: >> {msg}')
self.errors.append(msg) self.errors.append(msg)
def run(self): def _check_runnable(self, retry: bool) -> Optional[str]:
s = self.job.state
if s == db.JobState.ready:
return None
elif s == db.JobState.running:
# Může se stát, že ho mezitím začal vyřizovat jiný proces
return 'právě běží'
elif s in (db.JobState.done, db.JobState.failed):
return None if retry else 'je už hotový'
else:
return 'je v neznámém stavu'
def run(self, retry: bool = False):
sess = db.get_session() sess = db.get_session()
if not self.load() or self.job.state != db.JobState.ready: if not self.load():
# Někdo ho mezitím smazal nebo vyřídil # Někdo ho mezitím smazal
logger.info(f'Job: Job #{self.job_id} vyřizuje někdo jiný') logger.info(f'Job: Job #{self.job_id} neexistuje')
sess.rollback()
return
reject_reason = self._check_runnable(retry)
if reject_reason is not None:
logger.info(f'Job: Job #{self.job_id} {reject_reason}')
sess.rollback() sess.rollback()
return return
job = self.job job = self.job
logger.info(f'Job: Spouštím job #{job.job_id} ({job.type}) uživatele #{job.user_id}') logger.info(f'Job: Spouštím job #{job.job_id} ({job.type}) uživatele #{job.user_id}')
job.state = db.JobState.running job.state = db.JobState.running
job.finished_at = None
job.expires_at = None
sess.commit() sess.commit()
try: try:
self.gatekeeper = mo.rights.Gatekeeper(job.user) self.gatekeeper = mo.rights.Gatekeeper(job.user)
self.expires_in_minutes = config.JOB_EXPIRATION
_handler_table[job.type](self) _handler_table[job.type](self)
if self.errors: if self.errors:
logger.info(f'Job: Neúspěšně dokončen job #{job.job_id} ({job.result})') logger.info(f'Job: Neúspěšně dokončen job #{job.job_id} ({job.result})')
...@@ -137,7 +157,7 @@ class TheJob: ...@@ -137,7 +157,7 @@ class TheJob:
job.result = 'Interní chyba, informujte prosím správce systému.' job.result = 'Interní chyba, informujte prosím správce systému.'
job.finished_at = mo.util.get_now() job.finished_at = mo.util.get_now()
job.expires_at = job.finished_at + timedelta(minutes=config.JOB_EXPIRATION) job.expires_at = job.finished_at + timedelta(minutes=self.expires_in_minutes)
sess.commit() sess.commit()
...@@ -189,4 +209,5 @@ def job_handler(type: db.JobType): ...@@ -189,4 +209,5 @@ def job_handler(type: db.JobType):
# Moduly implementující jednotlivé typy jobů # Moduly implementující jednotlivé typy jobů
import mo.jobs.protocols
import mo.jobs.submit import mo.jobs.submit
# Implementace jobů na práci s protokoly
from PIL import Image
from dataclasses import dataclass
import multiprocessing
import os
import poppler
import pyzbar.pyzbar as pyzbar
import re
from sqlalchemy import delete
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.query import Query
import subprocess
from typing import List, Optional
import mo
import mo.config as config
import mo.db as db
from mo.jobs import TheJob, job_handler
from mo.util import logger, part_path
import mo.util_format
#
# Job create_protocols: Vygeneruje formuláře protokolů
#
# Vstupní JSON:
# { 'contest_id': ID contestu,
# 'site_id': ID soutěžního místa nebo none,
# 'task_ids': [task_id, ...],
# 'num_universal': počet papírů s univerzalní hlavičkou,
# 'num_blank': pocet pokračovacích papírů,
# }
#
# Výstupní JSON:
# null
#
def schedule_create_protocols(contest: db.Contest, site: Optional[db.Place], for_user: db.User, tasks: List[db.Task], num_universal: int, num_blank: int):
place = site or contest.place
the_job = TheJob()
job = the_job.create(db.JobType.create_protocols, for_user)
job.description = f'Formuláře protokolů {contest.round.round_code_short()} {place.name}'
job.in_json = {
'contest_id': contest.contest_id,
'site_id': site.place_id if site else None,
'task_ids': [t.task_id for t in tasks],
'num_universal': num_universal,
'num_blank': num_blank,
}
the_job.submit()
def tex_arg(s: str) -> str:
# Primitivní escapování do TeXu. Nesnaží se ani tak o věrnou intepretaci všech znaků,
# jako o zabránění pádu TeXu kvůli divným znakům.
s = re.sub(r'[\\{}#$%^~]', '?', s)
s = re.sub(r'([&_])', r'\\\1', s)
return '{' + s + '}'
def _get_user_id_query(contest: db.Contest, site_id: Optional[int]) -> Query:
q = db.get_session().query(db.Participation.user_id).filter_by(contest=contest, state=db.PartState.active)
if site_id is not None:
q = q.filter_by(place_id=site_id)
return q
def _get_pants(contest: db.Contest, site_id: Optional[int]) -> List[db.Participant]:
user_id_subq = _get_user_id_query(contest, site_id).subquery()
pants = (db.get_session().query(db.Participant)
.options(joinedload(db.Participant.user), joinedload(db.Participant.school_place))
.filter_by(year=config.CURRENT_YEAR)
.filter(db.Participant.user_id.in_(user_id_subq))
.all())
pants.sort(key=lambda p: p.user.sort_key())
return pants
@job_handler(db.JobType.create_protocols)
def handle_create_protocols(the_job: TheJob):
job = the_job.job
assert job.in_json is not None
contest_id: int = job.in_json['contest_id'] # type: ignore
site_id: int = job.in_json['site_id'] # type: ignore
task_ids: List[int] = job.in_json['task_ids'] # type: ignore
num_universal: int = job.in_json['num_universal'] # type: ignore
num_blank: int = job.in_json['num_blank'] # type: ignore
sess = db.get_session()
contest = sess.query(db.Contest).options(joinedload(db.Contest.round)).get(contest_id)
assert contest is not None
round = contest.round
pants = _get_pants(contest, site_id)
tasks = sess.query(db.Task).filter_by(round=round).filter(db.Task.task_id.in_(task_ids)).order_by(db.Task.code).all()
pages = []
for p in pants:
for t in tasks:
args = [
':'.join(['MO', round.round_code_short(), t.code, str(p.user_id)]),
p.user.full_name(),
p.grade,
p.school_place.name or '???',
t.code,
]
pages.append('\\proto' + "".join([tex_arg(x) for x in args]))
for _ in range(num_universal):
pages.append('\\universal')
for _ in range(num_blank):
pages.append('\\blank')
if not pages:
the_job.error("Nebyly vyžádány žádné protokoly")
return
temp_dir = job.dir_path()
logger.debug('Job: Vytvářím protokoly v %s (%s listů)', temp_dir, len(pages))
tex_src = os.path.join(temp_dir, 'protokoly.tex')
with open(tex_src, 'w') as f:
f.write('\\input protokol.tex\n\n')
kolo = f'{round.name} {round.year}. ročníku Matematické olympiády'
kat = f'Kategorie {round.category}'
if round.level > 0:
kat += ', ' + contest.place.name
f.write('\\def\\kolo' + tex_arg(kolo) + '\n\n')
f.write('\\def\\kat' + tex_arg(kat) + '\n\n')
for p in pages:
f.write(p + '\n')
f.write('\n\\bye\n')
env = dict(os.environ)
env['TEXINPUTS'] = part_path('tex') + '//:'
subprocess.run(
['luatex', '--interaction=errorstopmode', 'protokoly.tex'],
check=True,
cwd=temp_dir,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
job.out_file = 'protokoly.pdf'
job.result = 'Celkem ' + mo.util_format.inflect_number(len(pages), 'list', 'listy', 'listů')
#
# Job process_scans: Zpracuje nascanované protokoly
#
# Vstupní JSON:
# { 'contest_id': ID contestu,
# 'site_id': ID soutěžního místa nebo none,
# 'task_ids': [task_id, ...],
# 'in_files': [názvy vstupních souborů]
# }
#
# Výstupní JSON:
# null
#
# Výstupn soubory:
# p-{file_nr:02d}-{page_nr:04d}-(full|small).png
#
def schedule_process_scans(contest: db.Contest, site: Optional[db.Place], for_user: db.User, tasks: List[db.Task], in_file_names: List[str]):
place = site or contest.place
the_job = TheJob()
job = the_job.create(db.JobType.process_scans, for_user)
job.description = f'Zpracování scanů {contest.round.round_code_short()} {place.name}'
in_files = []
num_files = 0
for ifn in in_file_names:
num_files += 1
in_name = f'input-{num_files:03d}.pdf'
the_job.attach_file(ifn, in_name)
in_files.append(in_name)
assert in_files
job.in_json = {
'contest_id': contest.contest_id,
'site_id': site.place_id if site else None,
'task_ids': [t.task_id for t in tasks],
'in_files': in_files,
}
the_job.submit()
@dataclass
class ScanJobArgs:
in_path: str
out_prefix: str
@dataclass
class ScanJobPage:
code: Optional[str]
@job_handler(db.JobType.process_scans)
def handle_process_scans(the_job: TheJob):
job = the_job.job
assert job.in_json is not None
contest_id = job.in_json['contest_id'] # type: ignore
site_id = job.in_json['site_id'] # type: ignore
task_ids = job.in_json['task_ids'] # type: ignore
in_files: List[str] = job.in_json['in_files'] # type: ignore
sess = db.get_session()
contest = sess.query(db.Contest).options(joinedload(db.Contest.round)).get(contest_id)
assert contest is not None
round = contest.round
round_code = round.round_code_short()
user_ids = set(u[0] for u in _get_user_id_query(contest, site_id).all())
tasks = sess.query(db.Task).filter(db.Task.task_id.in_(task_ids)).all()
tasks_by_code = {t.code: t for t in tasks}
# Jelikož se plánujeme zamyslet na dlouhou dobu, uzavřeme databázovou session.
sess.commit()
with multiprocessing.Pool(1) as pool:
args = [ScanJobArgs(in_path=job.file_path(fn),
out_prefix=job.file_path(f'p-{fi:02d}'))
for fi, fn in enumerate(in_files)]
results = pool.map(_process_scan_file, args)
def _parse_code(pr: ScanJobPage, sp: db.ScanPage) -> Optional[str]:
if pr.code is None:
return None
fields = pr.code.split(':')
if fields[0] != 'MO':
return 'Neznámý prefix'
if len(fields) == 2:
if fields[1] == '*':
# Univerzální hlavička úlohy
sp.seq_id = db.SCAN_PAGE_FIX
return None
if fields[1] == '+':
# Pokračovací papír s kódem
sp.seq_id = db.SCAN_PAGE_CONTINUE
return None
elif len(fields) == 4:
if not fields[3].isnumeric():
return 'User ID není číslo'
user_id = int(fields[3])
if fields[1] != round_code:
return 'Nesouhlasí kód kola'
if fields[2] not in tasks_by_code:
return 'Neznámá úloha'
if user_id not in user_ids:
return 'Neznámý účastník'
sp.user_id = user_id
sp.task_id = tasks_by_code[fields[2]].task_id
sp.seq_id = 0
return None
return 'Neznamý formát kódu'
# Pokud jsme job spustili podruhé (ruční retry), chceme smazat všechny záznamy v scan_pages.
# Pozor, nesynchronizujeme ORM, ale nevadí to, protože v této chvíli mame čerstvou session.
conn = sess.connection()
conn.execute(delete(db.ScanPage.__table__).where(db.ScanPage.job_id == job.job_id))
num_pages = 0
for fi, fn in enumerate(in_files):
for pi, pr in enumerate(results[fi]):
sp = db.ScanPage(
job_id=job.job_id,
file_nr=fi,
page_nr=pi,
seq_id=db.SCAN_PAGE_FIX,
)
err = _parse_code(pr, sp)
if err is not None:
logger.debug(f'Scan: {fi}/{pi} ({pr.code}): {err}')
sp.seq_id = db.SCAN_PAGE_UFO
sess.add(sp)
num_pages += 1
job.result = 'Celkem ' + mo.util_format.inflect_number(num_pages, 'strana', 'strany', 'stran')
the_job.expires_in_minutes = config.JOB_EXPIRATION_LONG
def _process_scan_file(args: ScanJobArgs) -> List[ScanJobPage]:
# Zpracuje jeden soubor se scany. Běží v odděleném procesu.
# FIXME: Ošetření chyb
logger.debug(f'Scan: Analyzuji soubor {args.in_path}')
pdf = poppler.load_from_file(args.in_path)
renderer = poppler.PageRenderer()
renderer.set_render_hint(poppler.RenderHint.antialiasing, True)
renderer.set_render_hint(poppler.RenderHint.text_antialiasing, True)
dpi = 300
output = []
for page_nr in range(pdf.pages):
page = pdf.create_page(page_nr)
page_img = renderer.render_page(page, xres=dpi, yres=dpi)
full_img = Image.frombytes(
"RGBA",
(page_img.width, page_img.height),
page_img.data,
"raw",
str(page_img.format),
)
del page_img
full_img = full_img.convert('L') # Grayscale
full_size = full_img.size
codes = pyzbar.decode(full_img, symbols=[pyzbar.ZBarSymbol.QRCODE])
codes = [c for c in codes if c.type == 'QRCODE' and c.data.startswith(b'MO:')]
qr = None
if codes:
if len(codes) > 1:
logger.warning(f'Scan: Strana #{page_nr} obsahuje více QR kódů')
code = codes[0]
qr = code.data.decode('US-ASCII')
# FIXME: Tady by se dala podle kódu otočit stránka
output.append(ScanJobPage(code=qr))
full_img.save(f'{args.out_prefix}-{page_nr:04d}-full.png')
# FIXME: Potřebujeme vytvářet miniaturu?
small_img = full_img.resize((full_size[0] // 4, full_size[1] // 4))
small_img.save(f'{args.out_prefix}-{page_nr:04d}-small.png')
logger.debug(f'Scan: Strana #{page_nr}: {qr}')
return output
...@@ -5,6 +5,7 @@ import os ...@@ -5,6 +5,7 @@ import os
import re import re
import shutil import shutil
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm import joinedload
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import List, Optional from typing import List, Optional
import unicodedata import unicodedata
...@@ -12,37 +13,41 @@ import werkzeug.utils ...@@ -12,37 +13,41 @@ import werkzeug.utils
import zipfile import zipfile
import mo.db as db import mo.db as db
from mo.jobs import TheJob, job_handler, job_file_path from mo.jobs import TheJob, job_handler
from mo.submit import Submitter, SubmitException from mo.submit import Submitter, SubmitException
from mo.util import logger, data_dir from mo.util import logger, data_dir
from mo.util_format import inflect_number, inflect_by_number, data_size from mo.util_format import inflect_number, inflect_by_number, data_size
def schedule_download_submits(paper_ids: List[int], description: str, for_user: db.User, want_subdirs: bool): #
# Job download_submits: Zazipuje vybrané papíry
#
# Vstupní JSON:
# { 'papers': [ seznam paper_id ke stažení ],
# 'want_feedback': true/false,
# 'out_name': jméno výstupního souboru bez přípony,
# }
#
# Výstupní JSON:
# null
#
def schedule_download_submits(paper_ids: List[int], description: str, for_user: db.User, want_subdirs: bool, out_name: str):
the_job = TheJob() the_job = TheJob()
job = the_job.create(db.JobType.download_submits, for_user) job = the_job.create(db.JobType.download_submits, for_user)
job.description = description job.description = description
job.in_json = {'papers': paper_ids, 'want_subdirs': want_subdirs} job.in_json = {'papers': paper_ids, 'want_subdirs': want_subdirs, 'out_name': out_name}
the_job.submit() the_job.submit()
@job_handler(db.JobType.download_submits) @job_handler(db.JobType.download_submits)
def handle_download_submits(the_job: TheJob): def handle_download_submits(the_job: TheJob):
"""Zazipuje zadané papíry.
Vstupní JSON:
{ 'papers': [ seznam paper_id ke stažení ],
'want_feedback': true/false,
}
Výstupní JSON:
null
"""
job = the_job.job job = the_job.job
assert job.in_json is not None assert job.in_json is not None
ids: List[int] = job.in_json['papers'] # type: ignore ids: List[int] = job.in_json['papers'] # type: ignore
want_subdirs: bool = job.in_json['want_subdirs'] # type: ignore want_subdirs: bool = job.in_json['want_subdirs'] # type: ignore
out_name: str = job.in_json['out_name'] # type: ignore
sess = db.get_session() sess = db.get_session()
papers = (sess.query(db.Paper, db.User, db.Task.code, db.Place) papers = (sess.query(db.Paper, db.User, db.Task.code, db.Place)
...@@ -56,11 +61,13 @@ def handle_download_submits(the_job: TheJob): ...@@ -56,11 +61,13 @@ def handle_download_submits(the_job: TheJob):
.all()) .all())
papers.sort(key=lambda p: (p[1].sort_key(), p[2])) papers.sort(key=lambda p: (p[1].sort_key(), p[2]))
temp_file = NamedTemporaryFile(suffix='.zip', dir=data_dir('tmp'), mode='w+b') out_name = werkzeug.utils.secure_filename(out_name + '.zip')
logger.debug('Job: Vytvářím archiv %s', temp_file.name) out_path = job.file_path(out_name)
out_file = open(out_path, mode='w+b')
logger.debug('Job: Vytvářím archiv %s', out_path)
cnt = 0 cnt = 0
with zipfile.ZipFile(temp_file, mode='w') as zip: with zipfile.ZipFile(out_file, mode='w') as zip:
for p, u, task_code, place in papers: for p, u, task_code, place in papers:
cnt += 1 cnt += 1
full_name = u.full_name() full_name = u.full_name()
...@@ -75,15 +82,31 @@ def handle_download_submits(the_job: TheJob): ...@@ -75,15 +82,31 @@ def handle_download_submits(the_job: TheJob):
zip.write(filename=os.path.join(data_dir('submits'), p.file_name or p.orig_file_name), zip.write(filename=os.path.join(data_dir('submits'), p.file_name or p.orig_file_name),
arcname=fn) arcname=fn)
job.out_file = the_job.attach_file(temp_file.name, '.zip') job.out_file = out_name
out_size = temp_file.tell() out_size = out_file.tell()
job.result = 'Celkem ' + inflect_number(cnt, 'soubor', 'soubory', 'souborů') + ', ' + data_size(out_size) job.result = 'Celkem ' + inflect_number(cnt, 'soubor', 'soubory', 'souborů') + ', ' + data_size(out_size)
temp_file.close() out_file.close()
#
# Job upload_feedback: Uploaduje opravená řešení
#
# Vstupní JSON:
# { 'round_id': <id>,
# 'only_task_id': <id_or_null>,
# 'only_contest_id': <id_or_null>,
# 'only_site_id': <id_or_null>,
# }
#
# Výstupní JSON:
# null
#
def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, for_user: db.User, def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, for_user: db.User,
only_contest: Optional[db.Contest], only_contest: Optional[db.Contest],
only_site: Optional[db.Place], only_site: Optional[db.Place],
only_region: Optional[db.Place],
only_task: Optional[db.Task]): only_task: Optional[db.Task]):
the_job = TheJob() the_job = TheJob()
job = the_job.create(db.JobType.upload_feedback, for_user) job = the_job.create(db.JobType.upload_feedback, for_user)
...@@ -92,9 +115,10 @@ def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, f ...@@ -92,9 +115,10 @@ def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, f
'round_id': round.round_id, 'round_id': round.round_id,
'only_contest_id': only_contest.contest_id if only_contest is not None else None, 'only_contest_id': only_contest.contest_id if only_contest is not None else None,
'only_site_id': only_site.place_id if only_site is not None else None, 'only_site_id': only_site.place_id if only_site is not None else None,
'only_region_id': only_region.place_id if only_region is not None else None,
'only_task_id': only_task.task_id if only_task is not None else None, 'only_task_id': only_task.task_id if only_task is not None else None,
} }
job.in_file = the_job.attach_file(tmp_file, '.zip') job.in_file = the_job.attach_file(tmp_file, 'upload.zip')
the_job.submit() the_job.submit()
...@@ -145,19 +169,6 @@ def parse_feedback_name(name: str) -> Optional[UploadFeedback]: ...@@ -145,19 +169,6 @@ def parse_feedback_name(name: str) -> Optional[UploadFeedback]:
@job_handler(db.JobType.upload_feedback) @job_handler(db.JobType.upload_feedback)
def handle_upload_feedback(the_job: TheJob): def handle_upload_feedback(the_job: TheJob):
"""Uploaduje opravená řešení.
Vstupní JSON:
{ 'round_id': <id>,
'only_task_id': <id_or_null>,
'only_contest_id': <id_or_null>,
'only_site_id': <id_or_null>,
}
Výstupní JSON:
null
"""
job = the_job.job job = the_job.job
assert job.in_file is not None assert job.in_file is not None
in_json = job.in_json in_json = job.in_json
...@@ -165,12 +176,19 @@ def handle_upload_feedback(the_job: TheJob): ...@@ -165,12 +176,19 @@ def handle_upload_feedback(the_job: TheJob):
round_id: int = in_json['round_id'] # type: ignore round_id: int = in_json['round_id'] # type: ignore
only_contest_id: Optional[int] = in_json['only_contest_id'] # type: ignore only_contest_id: Optional[int] = in_json['only_contest_id'] # type: ignore
only_site_id: Optional[int] = in_json['only_site_id'] # type: ignore only_site_id: Optional[int] = in_json['only_site_id'] # type: ignore
only_region_id: Optional[int] = in_json['only_region_id'] # type: ignore
only_task_id: Optional[int] = in_json['only_task_id'] # type: ignore only_task_id: Optional[int] = in_json['only_task_id'] # type: ignore
sess = db.get_session() sess = db.get_session()
round = sess.query(db.Round).get(round_id) round = sess.query(db.Round).get(round_id)
assert round is not None assert round is not None
if only_region_id is not None:
only_region = sess.query(db.Place).get(only_region_id)
assert only_region is not None
else:
only_region = None
files: List[UploadFeedback] = [] files: List[UploadFeedback] = []
def parse_zip(in_path: str): def parse_zip(in_path: str):
...@@ -219,6 +237,7 @@ def handle_upload_feedback(the_job: TheJob): ...@@ -219,6 +237,7 @@ def handle_upload_feedback(the_job: TheJob):
.join(db.Contest, db.Contest.master_contest_id == db.Participation.contest_id) .join(db.Contest, db.Contest.master_contest_id == db.Participation.contest_id)
.filter(db.Contest.round == round) .filter(db.Contest.round == round)
.filter(db.Participation.user_id.in_(user_dict.keys())) .filter(db.Participation.user_id.in_(user_dict.keys()))
.options(joinedload(db.Contest.place))
.all()) .all())
user_rights = {} user_rights = {}
...@@ -237,6 +256,8 @@ def handle_upload_feedback(the_job: TheJob): ...@@ -237,6 +256,8 @@ def handle_upload_feedback(the_job: TheJob):
the_job.error(f'{f.file_name}: Účastník leží mimo vybranou soutěž') the_job.error(f'{f.file_name}: Účastník leží mimo vybranou soutěž')
elif only_site_id is not None and site_id_dict[f.user_id] != only_site_id: elif only_site_id is not None and site_id_dict[f.user_id] != only_site_id:
the_job.error(f'{f.file_name}: Účastník leží mimo vybrané soutěžní místo') the_job.error(f'{f.file_name}: Účastník leží mimo vybrané soutěžní místo')
elif only_region is not None and not the_job.gatekeeper.is_ancestor_of(only_region, contest_dict[f.user_id].place):
the_job.error(f'{f.file_name}: Účastník leží mimo vybraný region')
elif not user_rights[f.user_id]: elif not user_rights[f.user_id]:
the_job.error(f'{f.file_name}: K tomuto účastníkovi nemáte dostatečná oprávnění') the_job.error(f'{f.file_name}: K tomuto účastníkovi nemáte dostatečná oprávnění')
...@@ -267,7 +288,7 @@ def handle_upload_feedback(the_job: TheJob): ...@@ -267,7 +288,7 @@ def handle_upload_feedback(the_job: TheJob):
return False return False
cnt_good = 0 cnt_good = 0
parse_zip(job_file_path(job.in_file)) parse_zip(job.file_path(job.in_file))
if not the_job.errors: if not the_job.errors:
resolve_tasks(files) resolve_tasks(files)
......
...@@ -167,7 +167,7 @@ class Rights: ...@@ -167,7 +167,7 @@ class Rights:
return right in self.rights return right in self.rights
def get_roles(self) -> List[db.RoleType]: def get_roles(self) -> List[db.RoleType]:
"""Seznam unikátních rolí, stříděných od nejvýznamnější.""" """Seznam unikátních rolí, setříděných od nejvýznamnější."""
role_set = set(ur.role for ur in self.user_roles) role_set = set(ur.role for ur in self.user_roles)
return sorted(role_set, key=lambda r: role_order_by_type[r]) return sorted(role_set, key=lambda r: role_order_by_type[r])
...@@ -198,31 +198,6 @@ class Rights: ...@@ -198,31 +198,6 @@ class Rights:
return self.have_right(Right.edit_orgs) return self.have_right(Right.edit_orgs)
return self.have_right(Right.edit_users) return self.have_right(Right.edit_users)
# Interní rozhodovaní o dostupnosti zadání
def _check_view_statement(self, round: db.Round):
if round.tasks_file is None:
return False
if self.have_right(Right.manage_round):
# Správce kola může vždy všechno
return True
# Pokud už soutěž skončila, přístup k zadání má každý org.
# XXX: Rozhodujeme podle stavu kola, nikoliv soutěže!
if round.state in [db.RoundState.grading, db.RoundState.closed]:
return True
# Od stanoveného času vidí zadání orgové s právem view_statement.
if (self.have_right(Right.view_statement)
and round.state != db.RoundState.preparing
and round.pr_tasks_start is not None
and mo.now >= round.pr_tasks_start):
return True
# Ve zbylých případech jsme konzervativní a zadání neukazujeme
return False
class RoundRights(Rights): class RoundRights(Rights):
"""Práva ke kolu.""" """Práva ke kolu."""
...@@ -237,8 +212,11 @@ class RoundRights(Rights): ...@@ -237,8 +212,11 @@ class RoundRights(Rights):
# Metody offer_* testují, zda se má v UI nabízet příslušná funkce. # Metody offer_* testují, zda se má v UI nabízet příslušná funkce.
# Skutečnou kontrolu práv dělá až implementace funkce podle stavu soutěže. # Skutečnou kontrolu práv dělá až implementace funkce podle stavu soutěže.
def _get_state(self) -> db.RoundState:
return self.round.state
def _is_active(self) -> bool: def _is_active(self) -> bool:
return self.round.state not in [db.RoundState.preparing, db.RoundState.closed] return self._get_state() not in [db.RoundState.preparing, db.RoundState.closed]
def offer_upload_solutions(self) -> bool: def offer_upload_solutions(self) -> bool:
return (self.have_right(Right.upload_submits) return (self.have_right(Right.upload_submits)
...@@ -252,11 +230,47 @@ class RoundRights(Rights): ...@@ -252,11 +230,47 @@ class RoundRights(Rights):
return (self.have_right(Right.manage_contest) return (self.have_right(Right.manage_contest)
or (self.have_right(Right.edit_points) and self._is_active())) or (self.have_right(Right.edit_points) and self._is_active()))
def can_view_statement(self) -> bool: def can_upload_solutions(self) -> bool:
return self._check_view_statement(self.round) return (self.have_right(Right.upload_submits)
or self.have_right(Right.upload_solutions) and self._get_state() == db.RoundState.running)
def can_upload_feedback(self) -> bool:
return (self.have_right(Right.upload_submits)
or self.have_right(Right.upload_feedback) and self._get_state() == db.RoundState.grading)
def can_edit_points(self) -> bool:
return (self.have_right(Right.edit_points) and self._get_state() == db.RoundState.grading
or self.have_right(Right.manage_contest))
def can_create_solutions(self) -> bool:
return self.can_upload_solutions() or self.can_upload_feedback()
class ContestRights(Rights): def can_view_statement(self):
round = self.round
if round.tasks_file is None:
return False
if self.have_right(Right.manage_round):
# Správce kola může vždy všechno
return True
# Pokud už soutěž skončila, přístup k zadání má každý org.
# XXX: Rozhodujeme podle stavu kola, nikoliv soutěže!
if round.state in [db.RoundState.grading, db.RoundState.closed]:
return True
# Od stanoveného času vidí zadání orgové s právem view_statement.
if (self.have_right(Right.view_statement)
and round.state != db.RoundState.preparing
and round.pr_tasks_start is not None
and mo.now >= round.pr_tasks_start):
return True
# Ve zbylých případech jsme konzervativní a zadání neukazujeme
return False
class ContestRights(RoundRights):
"""Práva k soutěži.""" """Práva k soutěži."""
contest: db.Contest contest: db.Contest
...@@ -264,22 +278,10 @@ class ContestRights(Rights): ...@@ -264,22 +278,10 @@ class ContestRights(Rights):
def __repr__(self): def __repr__(self):
ros = " ".join([r.role.name for r in self.user_roles]) ros = " ".join([r.role.name for r in self.user_roles])
ris = " ".join([r.name for r in self.rights]) ris = " ".join([r.name for r in self.rights])
return f"ContestRights(uid={self.user.user_id} is_admin={self.user.is_admin} contest=#{self.contest.contest_id} roles=<{ros}> rights=<{ris}>)" return f"ContestRights(uid={self.user.user_id} is_admin={self.user.is_admin} round=#{self.round.round_id} contest=#{self.contest.contest_id} roles=<{ros}> rights=<{ris}>)"
def can_upload_solutions(self) -> bool:
return (self.have_right(Right.upload_submits)
or self.have_right(Right.upload_solutions) and self.contest.state == db.RoundState.running)
def can_upload_feedback(self) -> bool:
return (self.have_right(Right.upload_submits)
or self.have_right(Right.upload_feedback) and self.contest.state == db.RoundState.grading)
def can_edit_points(self) -> bool: def _get_state(self) -> db.RoundState:
return (self.have_right(Right.edit_points) and self.contest.state == db.RoundState.grading return self.contest.state
or self.have_right(Right.manage_contest))
def can_view_statement(self) -> bool:
return self._check_view_statement(self.contest.round)
class Gatekeeper: class Gatekeeper:
...@@ -299,12 +301,17 @@ class Gatekeeper: ...@@ -299,12 +301,17 @@ class Gatekeeper:
self.parent_cache = {} self.parent_cache = {}
self.rights_cache = {} self.rights_cache = {}
def get_parents(self, place: db.Place) -> List[db.Place]: def get_ancestors(self, place: db.Place) -> List[db.Place]:
pid = place.place_id pid = place.place_id
if pid not in self.parent_cache: if pid not in self.parent_cache:
self.parent_cache[pid] = db.get_place_parents(place) self.parent_cache[pid] = db.get_place_ancestors(place)
return self.parent_cache[pid] return self.parent_cache[pid]
def is_ancestor_of(self, ancestor: db.Place, of: db.Place) -> bool:
ancestors = self.get_ancestors(of)
parent_ids = set(p.place_id for p in ancestors)
return ancestor.place_id in parent_ids
def rights_for( def rights_for(
self, place: Optional[db.Place] = None, year: Optional[int] = None, self, place: Optional[db.Place] = None, year: Optional[int] = None,
cat: Optional[str] = None, seq: Optional[int] = None, cat: Optional[str] = None, seq: Optional[int] = None,
...@@ -325,10 +332,7 @@ class Gatekeeper: ...@@ -325,10 +332,7 @@ class Gatekeeper:
rights.rights = set() rights.rights = set()
def try_role(role: db.UserRole, at: Optional[db.Place]): def try_role(role: db.UserRole, at: Optional[db.Place]):
if ((at is None or role.place_id == at.place_id) if (role.applies_to(at=at, year=year, cat=cat, seq=seq)
and (role.year is None or year is None or role.year == year)
and (role.category is None or cat is None or role.category == cat or (role.category == 'Z' and cat.startswith('Z')))
and (role.seq is None or seq is None or role.seq == seq)
and (min_role is None or role_order_by_type[min_role] >= role_order_by_type[role.role])): and (min_role is None or role_order_by_type[min_role] >= role_order_by_type[role.role])):
rights.user_roles.append(role) rights.user_roles.append(role)
r = roles_by_type[role.role] r = roles_by_type[role.role]
...@@ -339,7 +343,7 @@ class Gatekeeper: ...@@ -339,7 +343,7 @@ class Gatekeeper:
for role in self.roles: for role in self.roles:
try_role(role, None) try_role(role, None)
else: else:
for at in self.get_parents(place): for at in self.get_ancestors(place):
for role in self.roles: for role in self.roles:
try_role(role, at) try_role(role, at)
...@@ -350,9 +354,11 @@ class Gatekeeper: ...@@ -350,9 +354,11 @@ class Gatekeeper:
"""Posbírá role a práva, ale ignoruje omezení rolí na místa a soutěže. Hodí se pro práva k editaci uživatelů apod.""" """Posbírá role a práva, ale ignoruje omezení rolí na místa a soutěže. Hodí se pro práva k editaci uživatelů apod."""
return self.rights_for() return self.rights_for()
def rights_for_round(self, round: db.Round, any_place: bool) -> RoundRights: def rights_for_round(self, round: db.Round, any_place: bool = False, for_place: Optional[db.Place] = None) -> RoundRights:
if any_place: if any_place:
place = None place = None
elif for_place:
place = for_place
else: else:
place = db.get_root_place() place = db.get_root_place()
rights = RoundRights() rights = RoundRights()
...@@ -367,6 +373,7 @@ class Gatekeeper: ...@@ -367,6 +373,7 @@ class Gatekeeper:
def rights_for_contest(self, contest: db.Contest, site: Optional[db.Place] = None) -> ContestRights: def rights_for_contest(self, contest: db.Contest, site: Optional[db.Place] = None) -> ContestRights:
rights = ContestRights() rights = ContestRights()
rights.round = contest.round
rights.contest = contest rights.contest = contest
rights._clone_from(self.rights_for( rights._clone_from(self.rights_for(
place=site or contest.place, place=site or contest.place,
......
...@@ -106,7 +106,7 @@ class Score: ...@@ -106,7 +106,7 @@ class Score:
def __init__( def __init__(
self, round: db.Round, contest: Optional[db.Contest] = None, self, round: db.Round, contest: Optional[db.Contest] = None,
# Ze kterých stavů chceme výsledkovku počítat # Ze kterých stavů chceme výsledkovku počítat
part_states: List[db.PartState] = [db.PartState.registered, db.PartState.invited, db.PartState.present], part_states: List[db.PartState] = [db.PartState.registered, db.PartState.active],
): ):
self.round = round self.round = round
self.contest = contest self.contest = contest
......