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
  • devel
  • fo
  • fo-base
  • honza/add-contestant
  • honza/kolo-vs-soutez
  • honza/mr6
  • honza/mr7
  • honza/mra
  • honza/mrd
  • honza/mrf
  • honza/submit-images
  • jh-stress-test-wip
  • jirka/typing
  • jk/issue-196
  • jk/issue-96
  • master
  • mj/submit-images
  • shorten-schools
18 results

Target

Select target project
  • mj/mo-submit
1 result
Select Git revision
  • devel
  • fo
  • fo-base
  • honza/add-contestant
  • honza/kolo-vs-soutez
  • honza/mr6
  • honza/mr7
  • honza/mra
  • honza/mrd
  • honza/mrf
  • honza/submit-images
  • jh-stress-test-wip
  • jirka/typing
  • jk/issue-196
  • jk/issue-96
  • master
  • mj/submit-images
  • shorten-schools
18 results
Show changes

Commits on Source 180

80 additional commits have been omitted to prevent performance issues.
......@@ -7,7 +7,7 @@ import mo.util
from mo.util import die, init_standalone
parser = argparse.ArgumentParser(description='Založí soutěže pro dané kolo')
parser.add_argument(dest='round', type=str, metavar='YY-C-S', help='ID kola')
parser.add_argument(dest='round', type=str, metavar='YY-C-S[p]', help='kód kola')
parser.add_argument('-n', '--dry-run', default=False, action='store_true', help='pouze ukáže, co by bylo provedeno')
args = parser.parse_args()
......@@ -22,18 +22,43 @@ round = mo.util.get_round_by_code(round_code)
if round is None:
die("Kolo s tímto kódem neexistuje!")
if round.state != db.RoundState.delegate:
state = round.state
else:
state = db.RoundState.preparing
if round.is_subround():
# Pokud je to podkolo, kopírujeme soutěže z hlavního kola
for mc in sess.query(db.Contest).filter_by(round=round.master):
r = mc.place
print(f"Zakládám {round.round_code()} pro místo {r.name} (podsoutěž)")
if not args.dry_run:
c = db.Contest(round=round, place=r, master=mc, state=state)
sess.add(c)
sess.flush()
mo.util.log(db.LogType.contest, c.contest_id, {
'action': 'created',
})
else:
regions = sess.query(db.Place).filter_by(level=round.level).all()
assert regions, "Neexistují žádná místa dané úrovně"
for r in regions:
print(f"Zakládám {round.round_code()} pro místo {r.name}")
if not args.dry_run:
c = db.Contest(round=round, place=r)
c = db.Contest(round=round, place=r, state=state)
sess.add(c)
sess.flush()
c.master = c
mo.util.log(db.LogType.contest, c.contest_id, {
'action': 'created',
})
if not args.dry_run:
sess.commit()
......@@ -4,12 +4,14 @@ import argparse
import mo.db as db
import mo.util
from mo.util import die
parser = argparse.ArgumentParser(description='Založí soutěžní kolo')
parser.add_argument('-y', '--year', type=int, required=True, help='ročník')
parser.add_argument('-c', '--cat', type=str, required=True, help='kategorie')
parser.add_argument('-s', '--seq', type=int, required=True, help='pořadí kola')
parser.add_argument('-l', '--level', type=int, required=True, help='úroveň v hierarchii oblastí')
parser.add_argument('-p', '--part', type=int, default=0, help='část v rámci skupiny kol (default: 0)')
parser.add_argument('-n', '--name', type=str, required=True, help='název kola')
args = parser.parse_args()
......@@ -17,10 +19,20 @@ args = parser.parse_args()
mo.util.init_standalone()
sess = db.get_session()
if args.part > 1:
master_rnd = sess.query(db.Round).filter_by(year=args.year, category=args.cat, seq=args.seq, part=1).one_or_none()
if master_rnd is None or master_rnd.is_subround():
die("Nemohu nalézt hlavní kolo")
if master_rnd.level != args.level:
die("Hlavní kolo probíhá na odlišné úrovni hierarchie")
else:
master_rnd = None
rnd = db.Round(
year=args.year,
category=args.cat,
seq=args.seq,
part=args.part,
level=args.level,
name=args.name,
)
......@@ -28,6 +40,11 @@ rnd = db.Round(
sess.add(rnd)
sess.flush()
if master_rnd is not None:
rnd.master_round_id = master_rnd.round_id
else:
rnd.master_round_id = rnd.round_id
mo.util.log(
type=db.LogType.round,
what=rnd.round_id,
......
#!/usr/bin/env python3
import argparse
import datetime
import os
from sqlalchemy.orm import joinedload
import subprocess
......@@ -18,57 +17,43 @@ args = parser.parse_args()
mo.util.init_standalone()
sess = db.get_session()
fixed = 0
errors = 0
def fix_paper(id: int):
global fixed, errors
paper = (sess.query(db.Paper)
.options(joinedload(db.Paper.for_user_obj))
.get(id))
assert paper is not None
print(f"=== Paper #{id} ({paper.file_name})")
sol = (sess.query(db.Solution)
.filter_by(task_id=paper.for_task, user_id=paper.for_user)
.one())
if paper.paper_id not in [sol.final_submit, sol.final_feedback]:
print('--> not final\n')
sess.rollback()
return
assert paper.orig_file_name is not None
print(f"=== Paper #{id} ({paper.orig_file_name})")
tmp_file = tempfile.NamedTemporaryFile(dir=mo.util.data_dir('tmp'), prefix='fix-')
res = subprocess.run(['qpdf', os.path.join(mo.util.data_dir('submits'), paper.file_name), tmp_file.name])
res = subprocess.run(['qpdf', os.path.join(mo.util.data_dir('submits'), paper.orig_file_name), tmp_file.name])
if res.returncode in [0, 3]:
sub = mo.submit.Submitter()
new_paper = db.Paper(task=paper.task,
for_user_obj=paper.for_user_obj,
type=paper.type,
uploaded_by_obj=paper.uploaded_by_obj,
uploaded_at=paper.uploaded_at + datetime.timedelta(seconds=1),
note='Automatická konverze rozbitého PDF')
sub.submit_paper(new_paper, tmp_file.name)
assert not new_paper.broken
sess.add(new_paper)
if new_paper.type == db.PaperType.solution:
sol.final_submit_obj = new_paper
elif new_paper.type == db.PaperType.feedback:
sol.final_feedback_obj = new_paper
else:
assert False
print('--> OK')
try:
sub.submit_fix(paper, tmp_file.name)
sess.commit()
fixed += 1
except mo.submit.SubmitException:
sess.rollback()
errors += 1
else:
print(f'--> ERROR: qpdf failed with exit code {res.returncode}')
sess.rollback()
print("")
errors += 1
if args.id is not None:
fix_paper(args.id)
else:
papers = sess.query(db.Paper).filter_by(broken=True).all()
papers = sess.query(db.Paper).filter_by(file_name=None).all()
sess.rollback()
for p in papers:
fix_paper(p.paper_id)
print(f'Opraveno {fixed} PDF, stále rozbito {errors}')
#!/usr/bin/env python3
import argparse
import sys
from mo.csv import FileFormat
import mo.db as db
from mo.imports import create_import, ImportType
import mo.users
import mo.util
from mo.util import die
parser = argparse.ArgumentParser(description='Importuje body za úlohu (bez --import vypíše šablonu).')
parser.add_argument('--round', '-r', type=str, required=True, metavar='YY-C-S[p]', help='kód kola')
parser.add_argument('--task', '-t', type=str, required=True, metavar='ID', help='kód úlohy')
parser.add_argument('--user', '-u', type=str, required=True, metavar='EMAIL', help='uživatel, ktery provádí import')
parser.add_argument('--import', '-i', dest='import_file', type=str, metavar='NAME', help='importuje data ze souboru (TSV)')
parser.add_argument('--add-del', default=False, action='store_true', help='povolí zakládat/rušit řešení')
args = parser.parse_args()
mo.util.init_standalone()
sess = db.get_session()
round_code = mo.util.RoundCode.parse(args.round)
if round_code is None:
die("Chybná syntaxe kódu kola")
round = mo.util.get_round_by_code(round_code)
if round is None:
die("Kolo s tímto kódem neexistuje!")
task = sess.query(db.Task).filter_by(round=round, code=args.task).one_or_none()
if task is None:
die("Úloha s tímto kódem neexistuje")
user = mo.users.user_by_email(args.user)
if user is None:
die(f"Uživatel {args.user} neexistuje")
imp = create_import(
user,
type=ImportType.points,
fmt=FileFormat.tsv,
round=round,
task=task,
allow_add_del=args.add_del,
)
if args.import_file:
if not imp.run(args.import_file):
sys.exit(1)
else:
sys.stdout.write(imp.get_template())
#!/usr/bin/env python3
import argparse
from sqlalchemy import and_
import mo.db as db
import mo.users
......@@ -33,10 +34,31 @@ print(f"Slučuji UID {suid} do UID {duid}")
sess = db.get_session()
conn = sess.connection()
test_round = sess.query(db.Round).filter_by(category='T').one_or_none()
if test_round is not None:
test_submits = (sess
.query(db.Solution)
.join(db.Task)
.filter(db.Solution.user_id == suid)
.filter(db.Task.round == test_round)
.filter(db.Round.category == 'T')
.all())
if test_submits:
mo.util.die("Zdrojový účastník něco odevzdal v testovací soutěži, nutno vyřešit ručně")
test_contest = sess.query(db.Contest).filter_by(round=test_round).one_or_none()
test_contest_id = test_contest.contest_id if test_contest is not None else None
else:
test_contest = None
test_contest_id = None
sess.flush()
conn.execute(db.Log.__table__.update().where(db.Log.changed_by == suid).values(changed_by=duid))
conn.execute(db.Participant.__table__.delete().where(db.Participant.user_id == suid))
conn.execute(db.Participation.__table__.delete().where(and_(db.Participation.user_id == suid, db.Participation.contest_id == test_contest_id)))
conn.execute(db.Participation.__table__.update().where(db.Participation.user_id == suid).values(user_id=duid))
conn.execute(db.UserRole.__table__.update().where(db.UserRole.user_id == suid).values(user_id=duid))
......
#!/usr/bin/env python3
# Generátor výsledkové listiny pro MO-P
import argparse
from sqlalchemy.orm import joinedload
import mo.db as db
from mo.score import Score
from mo.util import die, init_standalone
parser = argparse.ArgumentParser(description='Vygeneruje výsledkovou listinu MO-P')
parser.add_argument('year', type=int)
parser.add_argument('seq', type=int)
args = parser.parse_args()
init_standalone()
sess = db.get_session()
def get_results(round, contests):
results = {}
for contest in contests:
place_code = contest.place.get_code()
print(f"Počítám oblast {place_code}")
score = Score(round, contest)
results[place_code] = score.get_sorted_results()
for msg in score.get_messages():
if msg[0] != 'info':
print(f'\t{msg[0].upper()}: {msg[1]}')
return results
def write_tex(round, tasks, contests, results):
with open('final.tex', 'w') as out:
out.write(r'\def\HranicePostupu{%s}' % (round.score_winner_limit,) + "\n")
out.write(r'\def\HraniceUspesnychResitelu{%s}' % (round.score_successful_limit,) + "\n")
out.write('\n')
for c in contests:
res = results[c.place.get_code()]
if round.seq == 2:
out.write(r'\kraj{%s}' % c.place.name + '\n')
if not res:
out.write(r'\nobody' + '\n')
out.write(r'\endkraj' + '\n\n')
continue
out.write(r'\begintable' + '\n')
prev_typ = ""
for r in res:
if r.winner:
typ = 'v'
elif r.successful:
typ = 'u'
else:
typ = 'n'
if typ != prev_typ:
if prev_typ:
out.write(r'\sep%s' % typ)
prev_typ = typ
out.write(r'\%s' % typ)
cols = []
o = r.order
if not r.successful or o.continuation:
cols.append("")
elif o.span > 1:
cols.append(f'{o.place}.--{o.place + o.span - 1}.')
else:
cols.append(f'{o.place}.')
cols.append(r.user.full_name())
cols.append(r.pant.school_place.name)
cols.append(r.pant.grade)
sol_map = r.get_sols_map()
for t in tasks:
s = sol_map.get(t.task_id)
if s is not None:
cols.append(s.points)
else:
cols.append('--')
cols.append(r.get_total_points())
out.write("".join(['{' + str(col) + '}' for col in cols]) + '\n')
out.write(r'\endtable' + '\n')
if round.seq == 2:
out.write(r'\endkraj' + '\n\n')
def write_html(round, tasks, contests, results):
num_cols = 4 + len(tasks) + 1
with open('final.html', 'w') as out:
for c in contests:
out.write(f'<tr><th colspan={num_cols}>{c.place.name}\n')
res = results[c.place.get_code()]
if not res:
out.write(f'<tr class=nobody><td colspan={num_cols}>Nikdo se nezúčastnil.\n')
out.write(f'<tr><td colspan={num_cols}>\n\n')
continue
for r in res:
if r.winner:
out.write('<tr class=marked>')
elif r.successful:
out.write('<tr class=success>')
else:
out.write('<tr>')
cols = []
o = r.order
if not r.successful or o.continuation:
cols.append("")
elif o.span > 1:
cols.append(f'{o.place}.–{o.place + o.span - 1}.')
else:
cols.append(f'{o.place}.')
cols.append(r.user.full_name())
cols.append(r.pant.school_place.name)
cols.append(r.pant.grade)
sol_map = r.get_sols_map()
for t in tasks:
s = sol_map.get(t.task_id)
if s is not None:
cols.append(s.points)
else:
cols.append('')
cols.append(r.get_total_points())
out.write("".join(['<td>' + str(col) for col in cols]) + '\n')
out.write(f'<tr><td colspan={num_cols}>\n\n')
round = sess.query(db.Round).filter_by(year=args.year, category='P', seq=args.seq).filter(db.Round.master_round_id == db.Round.round_id).one()
print(f"Kolo {round.round_code()}")
round_group_subq = sess.query(db.Round.round_id).filter_by(master_round_id=round.round_id).subquery()
tasks = sess.query(db.Task).filter(db.Task.round_id.in_(round_group_subq)).order_by(db.Task.code).all()
contests = (sess.query(db.Contest)
.filter_by(round=round)
.options(joinedload(db.Contest.place))
.all())
assert contests
contests.sort(key=lambda c: c.place.get_code())
results = get_results(round, contests)
write_tex(round, tasks, contests, results)
write_html(round, tasks, contests, results)
bcrypt==3.2.0
bleach==3.3.0
blinker==1.4
cffi==1.14.4
click==7.1.2
......@@ -11,6 +12,7 @@ Flask-WTF==0.14.3
itsdangerous==1.1.0
Jinja2==2.11.2
lxml==4.6.2
markdown==3.3.4
MarkupSafe==1.1.1
pikepdf==2.3.0
Pillow==8.1.0
......
......@@ -77,10 +77,11 @@ CREATE TYPE round_state AS ENUM (
'preparing', -- v přípravě (viditelné pouze organizátorům)
'running', -- je možno odevzdávat
'grading', -- je možno opravovat a vyplňovat body
'closed' -- uzavřeno, není dovoleno nic měnit, zveřejněny výsledky
'closed', -- uzavřeno, není dovoleno nic měnit, zveřejněny výsledky
-- Garanta stavy neomezují, vždycky může všechno.
-- Ve stavu "running" mohou odevzdávat účastníci i dozor, a to i po termínu,
-- jen se odevzdaná řešení zobrazují jako opožděná.
'delegate' -- každá soutěž má svůj stav
);
CREATE TYPE score_mode AS ENUM (
......@@ -90,9 +91,11 @@ CREATE TYPE score_mode AS ENUM (
CREATE TABLE rounds (
round_id serial PRIMARY KEY,
master_round_id int DEFAULT NULL REFERENCES rounds(round_id),
year int NOT NULL, -- ročník MO
category varchar(2) NOT NULL, -- "A", "Z5" apod.
seq int NOT NULL, -- 1=domácí kolo atd.
part int NOT NULL DEFAULT 0, -- část kola (nenulová u dělených kol)
level int NOT NULL, -- úroveň hierarchie míst
name varchar(255) NOT NULL, -- zobrazované jméno ("Krajské kolo" apod.)
state round_state NOT NULL DEFAULT 'preparing', -- stav kola
......@@ -104,17 +107,25 @@ CREATE TABLE rounds (
score_mode score_mode NOT NULL DEFAULT 'basic', -- mód výsledkovky
score_winner_limit int DEFAULT NULL, -- bodový limit na označení za vítěze
score_successful_limit int DEFAULT NULL, -- bodový limit na označení za úspěšného řešitele
UNIQUE (year, category, seq)
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
UNIQUE (year, category, seq, part)
);
CREATE INDEX rounds_master_round_id_index ON rounds (master_round_id);
-- Soutěže (instance kola v konkrétním místě)
CREATE TABLE contests (
contest_id serial PRIMARY KEY,
master_contest_id int DEFAULT NULL REFERENCES contests(contest_id),
round_id int NOT NULL REFERENCES rounds(round_id),
place_id int NOT NULL REFERENCES places(place_id),
state round_state NOT NULL DEFAULT 'preparing', -- používá se, pokud round.state='delegate', jinak kopíruje round.state
UNIQUE (round_id, place_id)
);
CREATE INDEX contests_master_contest_id_index ON contests (master_contest_id);
-- Detaily účastníka
CREATE TABLE participants (
user_id int NOT NULL REFERENCES users(user_id),
......@@ -152,6 +163,7 @@ CREATE TABLE tasks (
round_id int NOT NULL REFERENCES rounds(round_id),
code varchar(255) NOT NULL, -- např. "P-I-1"
name varchar(255) NOT NULL,
max_points numeric(5,1) DEFAULT NULL, -- maximální počet bodů, pokud je nastaven, nelze zadat více bodů
UNIQUE (round_id, code)
);
......@@ -171,9 +183,13 @@ CREATE TABLE papers (
uploaded_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
pages int DEFAULT NULL, -- počet stránek
bytes int DEFAULT NULL, -- velikost souboru
file_name varchar(255) NOT NULL, -- relativní cesta k souboru
note text NOT NULL DEFAULT '', -- komentář uploadujícího
broken bool NOT NULL DEFAULT false -- poničené PDF přijaté s varováním
file_name varchar(255) DEFAULT NULL, -- relativní cesta k souboru
orig_file_name varchar(255) DEFAULT NULL, -- původní cesta, pokud PDF bylo poničené
note text NOT NULL DEFAULT '' -- komentář uploadujícího
-- Sémantika práce s poničenými soubory:
-- - správná PDF mají orig_file_name=NULL
-- - pokud někdo odevzdá poničené, vyplní se orig_file_name, ale file_name=NULL
-- - časem se spustí oprava, která vyplní i file_name a přepočítá bytes
);
CREATE INDEX papers_for_task_index ON papers (for_task);
......@@ -185,7 +201,7 @@ CREATE TABLE solutions (
user_id int NOT NULL REFERENCES users(user_id),
final_submit int DEFAULT NULL REFERENCES papers(paper_id), -- verze odevzdání, která se má hodnotit
final_feedback int DEFAULT NULL REFERENCES papers(paper_id), -- verze komentáře opravovatelů, kterou má vidět účastník
points int DEFAULT NULL,
points numeric(5,1) DEFAULT NULL,
note text NOT NULL DEFAULT '', -- komentář pro řešitele
org_note text NOT NULL DEFAULT '', -- komentář viditelný jen organizátorům
PRIMARY KEY (task_id, user_id)
......@@ -198,7 +214,7 @@ CREATE TABLE points_history (
points_history_id serial PRIMARY KEY,
task_id int NOT NULL REFERENCES tasks(task_id),
participant_id int NOT NULL REFERENCES users(user_id),
points int DEFAULT NULL,
points numeric(5,1) DEFAULT NULL,
points_by int NOT NULL REFERENCES users(user_id), -- kdo přidělil body
points_at timestamp with time zone NOT NULL -- a kdy
);
......@@ -284,3 +300,15 @@ CREATE TABLE jobs (
in_file varchar(255) DEFAULT NULL,
out_file varchar(255) DEFAULT NULL
);
-- Zprávičky k soutěžím
CREATE TABLE messages (
message_id serial PRIMARY KEY,
round_id int NOT NULL REFERENCES rounds(round_id),
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, -- čas publikování zprávičky
created_by int NOT NULL REFERENCES users(user_id), -- autor zprávičky
title text NOT NULL,
markdown text NOT NULL,
html text NOT NULL
);
SET ROLE 'mo_osmo';
ALTER TABLE tasks
ADD COLUMN max_points int DEFAULT NULL; -- maximální počet bodů, pokud je nastaven, nelze zadat více bodů
SET ROLE 'mo_osmo';
ALTER TABLE rounds
ADD COLUMN master_round_id int DEFAULT NULL REFERENCES rounds(round_id),
ADD COLUMN part int NOT NULL DEFAULT 0; -- část kola (u dělených kol)
ALTER TABLE contests
ADD COLUMN master_contest_id int DEFAULT NULL REFERENCES contests(contest_id);
ALTER TABLE rounds
DROP CONSTRAINT "rounds_year_category_seq_key",
ADD CONSTRAINT "rounds_year_category_seq_part_key" UNIQUE (year, category, seq, part);
CREATE INDEX rounds_master_round_id_index ON rounds (master_round_id);
CREATE INDEX contests_master_contest_id_index ON contests (master_contest_id);
UPDATE rounds SET master_round_id=round_id;
UPDATE contests SET master_contest_id=contest_id;
SET ROLE 'mo_osmo';
ALTER TYPE round_state ADD VALUE 'delegate';
ALTER TABLE contests
ADD COLUMN state round_state NOT NULL DEFAULT 'preparing';
SET ROLE 'mo_osmo';
ALTER TABLE papers
ADD COLUMN orig_file_name varchar(255) DEFAULT NULL;
ALTER TABLE papers
ALTER COLUMN file_name DROP NOT NULL;
ALTER TABLE papers
ALTER COLUMN file_name SET DEFAULT NULL;
ALTER TABLE papers
DROP COLUMN broken;
SET ROLE 'mo_osmo';
-- Zprávičky k soutěžím
ALTER TABLE rounds
ADD COLUMN has_messages boolean NOT NULL DEFAULT false; -- má zprávičky
CREATE TABLE messages (
message_id serial PRIMARY KEY,
round_id int NOT NULL REFERENCES rounds(round_id),
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, -- čas publikování zprávičky
created_by int NOT NULL REFERENCES users(user_id), -- autor zprávičky
title text NOT NULL,
markdown text NOT NULL,
html text NOT NULL
);
SET ROLE 'mo_osmo';
ALTER TABLE rounds
ADD COLUMN 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)
ALTER COLUMN score_winner_limit SET DATA TYPE numeric(5,1),
ALTER COLUMN score_successful_limit SET DATA TYPE numeric(5,1);
ALTER TABLE solutions
ALTER COLUMN points SET DATA TYPE numeric(5,1);
ALTER TABLE points_history
ALTER COLUMN points SET DATA TYPE numeric(5,1);
ALTER TABLE tasks
ALTER COLUMN max_points SET DATA TYPE numeric(5,1);
......@@ -3,6 +3,14 @@
SQLALCHEMY_DATABASE_URI = "postgresql:///mo_osmo"
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
SQLALCHEMY_ENGINE_OPTIONS = {
# SQLAlchemy neumí bez pingnutí databáze na začátku každé transakce
# sama ohandlovat zavření spojení. Po restartu Postgresu bez tohoto
# nastavení vydá každý worker jednu 500, než začne fungovat normálně.
# Pokud běží databáze na stejném serveru, zapnutí by mělo mít
# zanedbatelný overhead.
"pool_pre_ping": True,
}
# Vytvořte pomocí python3 -c 'import secrets; print(secrets.token_hex(32))'
SECRET_KEY = "FIXME"
......@@ -11,6 +19,9 @@ SESSION_COOKIE_PATH = '/'
SESSION_COOKIE_NAME = 'mo_session'
# SESSION_COOKIE_SECURE=True
# Kontaktní email (v patičce, Reply-To a také používaný jako adresát při generování pošty s Bcc)
MAIL_CONTACT = "osmo@mo.mff.cuni.cz"
# Odesilatel generovaných mailů (není-li definován, neposílají se)
# MAIL_FROM = "osmo-auto@mo.mff.cuni.cz"
......@@ -36,3 +47,7 @@ JOB_GC_PERIOD = 60
# Za jak dlouho expiruje dokončená dávka [min]
JOB_EXPIRATION = 5
# Automatické přihlašování účastníků do testovací soutěže
# (kolo aktuální_ročník-T-1, celostátní soutěž)
AUTO_REGISTER_TEST = False
......@@ -7,6 +7,7 @@
# - quoting pomocí uvozovek
import csv
import difflib
from enum import auto
from dataclasses import dataclass, fields
from typing import Type, List, IO, Sequence
......@@ -108,6 +109,7 @@ def write(file: IO, fmt: FileFormat, row_class: Type[Row], rows: Sequence[Row]):
def read(file: IO, fmt: FileFormat, row_class: Type[Row]):
reader = csv.reader(file, dialect=fmt.get_dialect(), strict=True)
warnings = []
header: List[str] = []
rows: List[Row] = []
columns = set(field.name for field in fields(row_class))
......@@ -120,6 +122,13 @@ def read(file: IO, fmt: FileFormat, row_class: Type[Row]):
header = r
if not any(h in columns for h in header):
raise MissingHeaderError()
for h in header:
if not h in columns:
best_matches = difflib.get_close_matches(h, columns, n=1, cutoff=0.8)
if best_matches:
warnings.append(
"Neznámý sloupec '{}', měli jste na mysli '{}'?".format(
h, best_matches[0]))
else:
row = row_class()
not_empty = False
......@@ -133,4 +142,4 @@ def read(file: IO, fmt: FileFormat, row_class: Type[Row]):
if not_empty:
rows.append(row)
return rows
return (rows, warnings)
......@@ -2,6 +2,7 @@
# Generated by sqlacodegen and then heavily edited.
import datetime
import decimal
from enum import Enum as PythonEnum, auto
import locale
import re
......@@ -15,6 +16,7 @@ from sqlalchemy.orm.attributes import get_history
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.expression import CTE
from sqlalchemy.sql.sqltypes import Numeric
from typing import Optional, List, Tuple
import mo
......@@ -118,15 +120,8 @@ class Place(Base):
return len(PlaceType.choices(level=self.level + 1)) > 0
# Předpokládáme, že za běhu aplikace se root nezmění
root_place_cache: Optional[Place] = None
def get_root_place():
global root_place_cache
if root_place_cache is None:
root_place_cache = get_session().query(Place).filter_by(parent=None).one()
return root_place_cache
return get_session().query(Place).filter_by(parent=None).one()
def get_place_by_code(code: str, fetch_school: bool = False) -> Optional[Place]:
......@@ -164,6 +159,7 @@ class RoundState(MOEnum):
running = auto()
grading = auto()
closed = auto()
delegate = auto()
def friendly_name(self) -> str:
return round_state_names[self]
......@@ -174,6 +170,7 @@ round_state_names = {
RoundState.running: 'běží',
RoundState.grading: 'opravuje se',
RoundState.closed: 'ukončeno',
RoundState.delegate: 'po oblastech',
}
......@@ -191,16 +188,27 @@ round_score_mode_names = {
}
# V DB jako numeric(2,1), používá se tak snadněji, než enum
round_points_step_names = {
decimal.Decimal('1'): "Celé body",
decimal.Decimal('0.5'): "Půlbody",
decimal.Decimal('0.1'): "Desetinné body",
}
round_points_step_choices = round_points_step_names.items()
class Round(Base):
__tablename__ = 'rounds'
__table_args__ = (
UniqueConstraint('year', 'category', 'seq'),
UniqueConstraint('year', 'category', 'seq', 'part'),
)
round_id = Column(Integer, primary_key=True, server_default=text("nextval('rounds_round_id_seq'::regclass)"))
master_round_id = Column(Integer, ForeignKey('rounds.round_id'))
year = Column(Integer, nullable=False)
category = Column(String(2), nullable=False)
seq = Column(Integer, nullable=False)
part = Column(Integer, nullable=False)
level = Column(Integer, nullable=False)
name = Column(String(255), nullable=False)
state = Column(Enum(RoundState, name='round_state'), nullable=False, server_default=text("'preparing'::round_state"))
......@@ -210,43 +218,50 @@ class Round(Base):
pr_tasks_start = Column(DateTime(True))
pr_submit_end = Column(DateTime(True))
score_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False, server_default=text("'basic'::score_mode"))
score_winner_limit = Column(Integer)
score_successful_limit = Column(Integer)
score_winner_limit = Column(Numeric)
score_successful_limit = Column(Numeric)
points_step = Column(Numeric, nullable=False)
has_messages = Column(Boolean, nullable=False, server_default=text("false"))
def round_code(self):
master = relationship('Round', primaryjoin='Round.master_round_id == Round.round_id', remote_side='Round.round_id', post_update=True)
def round_code_short(self):
""" Pro samostatné kolo ekvivalentní s `round_code()`, pro skupinu kol společná část kódu. """
return f"{self.year}-{self.category}-{self.seq}"
def part_code(self):
return chr(ord('a') + self.part - 1) if self.part > 0 else ""
def round_code(self):
""" Kód kola včetně označení části, pokud je ve skupině kol. """
code = self.round_code_short()
part = self.part_code()
return f"{code}{part}"
def has_tasks(self):
return self.tasks_file
def long_state(self) -> str:
details = ""
if self.state == RoundState.preparing:
if self.ct_tasks_start and self.ct_tasks_start > mo.now:
details = f" – začne {time_and_timedelta(self.ct_tasks_start)}"
elif self.state == RoundState.running:
if self.ct_tasks_start and self.ct_tasks_start > mo.now:
details = f" – zadání bude zveřejněno {time_and_timedelta(self.ct_tasks_start)}"
elif self.ct_submit_end and self.ct_submit_end < mo.now:
details = f" – odevzdávání skončilo {time_and_timedelta(self.ct_submit_end)}"
elif self.ct_submit_end:
details = f" – odevzdávejte do {time_and_timedelta(self.ct_submit_end)}"
return self.state.friendly_name() + details
def task_statement_available(self) -> bool:
"""Je zadaní dostupné pro účastníky?"""
# Zde jsme raději přepečliví...
return (self.state != RoundState.preparing
and self.tasks_file is not None
and self.ct_tasks_start is not None
and mo.now >= self.ct_tasks_start)
def is_subround(self) -> bool:
return self.master_round_id != self.round_id
def ct_can_submit(self) -> bool:
return (
self.state == RoundState.running and (
not self.ct_tasks_start or mo.now >= self.ct_tasks_start
)
)
def get_group_rounds(self, add_self=False) -> List['Round']:
# podle PEP484 se má použít string při forward typové referenci
return get_session().query(Round).filter(
Round.master_round_id == self.master_round_id,
add_self or Round.round_id != self.round_id,
).all()
def ct_state(self) -> RoundState:
"""Vrátí stav z pohledu účastníků (vynucené 'preparing' před začátkem soutěže)."""
if not self.ct_tasks_start or self.ct_tasks_start > mo.now:
return RoundState.preparing
else:
return self.state
def points_step_name(self) -> str:
if self.points_step in round_points_step_names:
return round_points_step_names[self.points_step]
return str(self.points_step)
class User(Base):
......@@ -274,6 +289,9 @@ class User(Base):
def sort_key(self) -> Tuple[str, str, int]:
return (locale.strxfrm(self.last_name), locale.strxfrm(self.first_name), self.user_id)
def name_sort_key(self) -> Tuple[str, str]:
return (locale.strxfrm(self.last_name), locale.strxfrm(self.first_name))
class Contest(Base):
__tablename__ = 'contests'
......@@ -282,12 +300,55 @@ class Contest(Base):
)
contest_id = Column(Integer, primary_key=True, server_default=text("nextval('contests_contest_id_seq'::regclass)"))
master_contest_id = Column(Integer, ForeignKey('contests.contest_id'))
round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
place_id = Column(Integer, ForeignKey('places.place_id'), nullable=False)
state = Column(Enum(RoundState, name='round_state'), nullable=False, server_default=text("'preparing'::round_state"))
master = relationship('Contest', primaryjoin='Contest.master_contest_id == Contest.contest_id', remote_side='Contest.contest_id', post_update=True)
place = relationship('Place')
round = relationship('Round')
def is_subcontest(self) -> bool:
return self.master_contest_id != self.contest_id
def get_group_contests(self, add_self=False) -> List['Contest']:
# podle PEP484 se má použít string při forward typové referenci
return get_session().query(Contest).filter(
Contest.master_contest_id == self.master_contest_id,
add_self or Contest.contest_id != self.contest_id,
).options(joinedload(Contest.round)).all()
def ct_state(self) -> RoundState:
"""Vrátí stav z pohledu účastníků (vynucené 'preparing' před začátkem soutěže)."""
state = self.round.ct_state()
if state != RoundState.delegate:
return state
else:
return self.state
def ct_long_state(self) -> str:
state = self.ct_state()
round = self.round
details = ""
if state == RoundState.preparing:
if round.ct_tasks_start and round.ct_tasks_start > mo.now:
details = f" – začne {time_and_timedelta(round.ct_tasks_start)}"
elif state == RoundState.running:
if round.ct_submit_end and round.ct_submit_end < mo.now:
details = f" – odevzdávání skončilo {time_and_timedelta(round.ct_submit_end)}"
elif round.ct_submit_end:
details = f" – odevzdávejte do {time_and_timedelta(round.ct_submit_end)}"
return state.friendly_name() + details
def ct_task_statement_available(self) -> bool:
"""Je zadaní dostupné pro účastníky?"""
return self.ct_state() != RoundState.preparing and self.round.tasks_file is not None
def ct_can_submit(self) -> bool:
"""Mohou účastníci odevzdávat?"""
return self.ct_state() == RoundState.running
class LogType(MOEnum):
general = auto()
......@@ -371,6 +432,7 @@ class Task(Base):
round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
code = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
max_points = Column(Numeric)
round = relationship('Round')
......@@ -445,9 +507,9 @@ class Paper(Base):
uploaded_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP"))
pages = Column(Integer)
bytes = Column(Integer)
file_name = Column(String(255), nullable=False)
file_name = Column(String(255))
orig_file_name = Column(String(255))
note = Column(Text, nullable=False, server_default=text("''::text"))
broken = Column(Boolean, nullable=False, server_default=text("false"))
task = relationship('Task')
for_user_obj = relationship('User', primaryjoin='Paper.for_user == User.user_id')
......@@ -466,6 +528,12 @@ class Paper(Base):
else:
return None
def is_broken(self) -> bool:
return self.file_name is None
def is_fixed(self) -> bool:
return self.orig_file_name is not None and self.file_name is not None
class PointsHistory(Base):
__tablename__ = 'points_history'
......@@ -473,7 +541,7 @@ class PointsHistory(Base):
points_history_id = Column(Integer, primary_key=True, server_default=text("nextval('points_history_points_history_id_seq'::regclass)"))
task_id = Column(Integer, ForeignKey('tasks.task_id'), nullable=False)
participant_id = Column(Integer, ForeignKey('users.user_id'), nullable=False)
points = Column(Integer)
points = Column(Numeric)
points_by = Column(Integer, ForeignKey('users.user_id'), nullable=False)
points_at = Column(DateTime(True), nullable=False)
......@@ -489,7 +557,7 @@ class Solution(Base):
user_id = Column(Integer, ForeignKey('users.user_id'), primary_key=True, nullable=False)
final_submit = Column(Integer, ForeignKey('papers.paper_id'))
final_feedback = Column(Integer, ForeignKey('papers.paper_id'))
points = Column(Integer)
points = Column(Numeric)
note = Column(Text, nullable=False, server_default=text("''::text"))
org_note = Column(Text, nullable=False, server_default=text("''::text"))
......@@ -542,6 +610,20 @@ class Job(Base):
user = relationship('User')
class Message(Base):
__tablename__ = 'messages'
message_id = Column(Integer, primary_key=True, server_default=text("nextval('messages_message_id_seq'::regclass)"))
round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
created_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP"))
created_by = Column(Integer, ForeignKey('users.user_id'))
title = Column(Text, nullable=False)
markdown = Column(Text, nullable=False)
html = Column(Text, nullable=False)
created_by_user = relationship('User')
_engine: Optional[Engine] = None
_session: Optional[Session] = None
flask_db: Any = None
......@@ -648,6 +730,8 @@ def row2dict(row):
if isinstance(val, datetime.datetime):
# datetime neumíme serializovat do JSONu, ale nevadí to, protože ho stejně nemá smysl logovat
pass
elif isinstance(val, decimal.Decimal):
d[column.name] = float(val)
else:
d[column.name] = getattr(row, column.name)
......
from dataclasses import dataclass
import decimal
from enum import auto
import io
import re
......@@ -13,6 +14,7 @@ import mo.rights
import mo.users
import mo.util
from mo.util import logger
from mo.util_format import format_decimal
class ImportType(db.MOEnum):
......@@ -36,6 +38,7 @@ import_type_names = {
class Import:
# Výsledek importu
errors: List[str]
warnings: List[str]
cnt_rows: int = 0
cnt_new_users: int = 0
cnt_new_participants: int = 0
......@@ -69,6 +72,7 @@ class Import:
def __init__(self):
self.errors = []
self.warnings = []
self.rr = None
self.place_cache = {}
self.school_place_cache = {}
......@@ -122,7 +126,7 @@ class Import:
rights = self.gatekeeper.rights_for(place, round.year, round.category, round.seq)
return rights.have_right(mo.rights.Right.manage_contest)
def parse_opt_place(self, kod: str) -> Optional[db.Place]:
def parse_opt_place(self, kod: str, what: str) -> Optional[db.Place]:
if kod == "":
return None
......@@ -131,7 +135,8 @@ class Import:
place = db.get_place_by_code(kod)
if not place:
return self.error(f'Místo "{kod}" nenalezeno')
return self.error(f'{what.title()} s kódem "{kod}" neexistuje'+
('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
if not self.check_rights(place):
return self.error(f'K místu "{kod}" nemáte práva na správu soutěže')
......@@ -148,7 +153,8 @@ class Import:
place = db.get_place_by_code(kod, fetch_school=True)
if not place:
return self.error(f'Škola "{kod}" nenalezena')
return self.error(f'Škola s kódem "{kod}" nenalezena'+
('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
if place.type != db.PlaceType.school:
return self.error(f'Kód školy "{kod}" neodpovídá škole')
......@@ -164,11 +170,16 @@ class Import:
# lidé připisují všechny možné i nemožné znaky, které vypadají jako apostrof :)
rocnik = re.sub('[\'"\u00b4\u2019]', "", rocnik)
if (school.is_ss and re.fullmatch(r'\d/\d', rocnik)
or school.is_zs and re.fullmatch(r'\d', rocnik)):
return rocnik
if (not re.fullmatch(r'\d(/\d)?', rocnik)):
return self.error(f'Ročník má neplatný formát, musí to být buď číslice, nebo číslice/číslice')
if (not school.is_zs and re.fullmatch(r'\d', rocnik)):
return self.error(f'Ročník pro střední školu ({school.place.name}) zapisujte ve formátu číslice/číslice')
return self.error('Ročník neodpovídá typu školy: pro základní je to číslice, pro střední čí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]:
if not re.fullmatch(r'\d{4}', rok):
......@@ -205,7 +216,7 @@ class Import:
self.new_user_ids.append(user.user_id)
return user
def parse_points(self, points_str: str) -> Union[int, str, None]:
def parse_points(self, points_str: str) -> Union[decimal.Decimal, str, None]:
if points_str == "":
return self.error('Body musí být vyplněny')
......@@ -213,13 +224,9 @@ class Import:
if points_str in ['X', '?']:
return points_str
try:
pts = int(points_str)
except ValueError:
return self.error('Body nejsou celé číslo')
if pts < 0:
return self.error('Body nesmí být záporné')
pts, error = mo.util.parse_points(points_str, self.task, self.round)
if error:
return self.error(error)
return pts
......@@ -377,9 +384,11 @@ class Import:
try:
with open(path, encoding=charset) as file:
try:
rows: List[mo.csv.Row] = mo.csv.read(file=file, fmt=self.fmt, row_class=self.row_class)
rows: List[mo.csv.Row]
rows, warnings = mo.csv.read(file=file, fmt=self.fmt, row_class=self.row_class)
self.warnings += warnings
except MissingHeaderError:
return self.error('Souboru chybí hlavička s názvy sloupců')
return self.error('Souboru chybí první řádek s názvy sloupců')
except UnicodeDecodeError:
return self.error(f'Soubor není v kódování {self.fmt.get_charset()}')
except Exception as e:
......@@ -470,8 +479,8 @@ class ContestImport(Import):
school_place = self.parse_school(r.kod_skoly)
rocnik = self.parse_grade(r.rocnik, (school_place.school if school_place else None))
rok_naroz = self.parse_born(r.rok_naroz)
misto = self.parse_opt_place(r.kod_mista)
oblast = self.parse_opt_place(r.kod_oblasti)
misto = self.parse_opt_place(r.kod_mista, 'místo')
oblast = self.parse_opt_place(r.kod_oblasti, 'oblast')
if (len(self.errors) > num_prev_errs
or email is None
......@@ -526,7 +535,7 @@ class ProctorImport(Import):
email = self.parse_email(r.email)
krestni = self.parse_name(r.krestni)
prijmeni = self.parse_name(r.prijmeni)
misto = self.parse_opt_place(r.kod_mista)
misto = self.parse_opt_place(r.kod_mista, 'místo')
if misto is None:
return self.error('Kód místa je povinné uvést')
......@@ -563,9 +572,11 @@ class JudgeImport(Import):
log_msg_prefix = 'Opravovatelé'
log_details = {'action': 'import-judges'}
template_basename = 'sablona-oprav'
root_place: db.Place
def setup(self):
assert self.round is not None
self.root_place = db.get_root_place()
def import_row(self, r: mo.csv.Row):
assert isinstance(r, JudgeImportRow)
......@@ -573,7 +584,7 @@ class JudgeImport(Import):
email = self.parse_email(r.email)
krestni = self.parse_name(r.krestni)
prijmeni = self.parse_name(r.prijmeni)
oblast = self.parse_opt_place(r.kod_oblasti)
oblast = self.parse_opt_place(r.kod_oblasti, 'oblast')
if (len(self.errors) > num_prev_errs
or email is None
......@@ -586,7 +597,7 @@ class JudgeImport(Import):
return
contest = self.obtain_contest(oblast, allow_none=True)
place = contest.place if contest else db.get_root_place()
place = contest.place if contest else self.root_place
if not self.check_rights(place):
return self.error(f'K místu "{place.get_code()}" nemáte práva na správu soutěže')
......@@ -619,9 +630,9 @@ class PointsImport(Import):
.options(joinedload(db.Participation.user)))
if self.contest is not None:
query = query.filter(db.Participation.contest == self.contest)
query = query.filter(db.Participation.contest_id == self.contest.master_contest_id)
else:
contest_query = sess.query(db.Contest.contest_id).filter_by(round=self.round)
contest_query = sess.query(db.Contest.master_contest_id).filter_by(round=self.round)
query = query.filter(db.Participation.contest_id.in_(contest_query.subquery()))
return query
......@@ -659,7 +670,7 @@ class PointsImport(Import):
return self.error('Soutěžící nesoutěží v této oblasti')
rights = self.gatekeeper.rights_for_contest(pion.contest)
if not rights.can_edit_points(self.round):
if not rights.can_edit_points():
return self.error('Nemáte právo na úpravu bodů')
user = pion.user
......@@ -671,7 +682,7 @@ class PointsImport(Import):
return
if not self.allow_add_del:
return self.error('Tento soutěžící úlohu neodevzdal')
if not rights.can_upload_solutions(round):
if not rights.can_upload_solutions():
return self.error('Nemáte právo na zakládání nových řešení')
sol = db.Solution(user_id=user_id, task_id=task_id)
sess.add(sol)
......@@ -687,7 +698,7 @@ class PointsImport(Import):
return self.error('Tento soutěžící úlohu odevzdal')
if sol.final_submit is not None or sol.final_feedback is not None:
return self.error('Nelze smazat řešení, ke kterému existují odevzdané soubory')
if not rights.can_upload_solutions(round):
if not rights.can_upload_solutions():
return self.error('Nemáte právo na mazání řešení')
logger.info(f'Import: Smazáno řešení user=#{user_id} task=#{task_id}')
mo.util.log(
......@@ -699,7 +710,7 @@ class PointsImport(Import):
sess.delete(sol)
return
points = body if isinstance(body, int) else None
points = body if isinstance(body, decimal.Decimal) else None
if sol.points != points:
sol.points = points
sess.add(db.PointsHistory(
......@@ -719,7 +730,7 @@ class PointsImport(Import):
elif sol.points is None:
pts = '?'
else:
pts = str(sol.points)
pts = format_decimal(sol.points)
user = pion.user
rows.append(PointsImportRow(
user_id=user.user_id,
......
......@@ -110,7 +110,7 @@ class TheJob:
sess = db.get_session()
if not self.load() or self.job.state != db.JobState.ready:
# Někdo ho mezitím smazal nebo vyřídil
logger.info(f'Job: Job #{self.job.job_id} vyřizuje někdo jiný')
logger.info(f'Job: Job #{self.job_id} vyřizuje někdo jiný')
sess.rollback()
return
......
......@@ -45,13 +45,13 @@ def handle_download_submits(the_job: TheJob):
want_subdirs: bool = job.in_json['want_subdirs'] # type: ignore
sess = db.get_session()
papers = (sess.query(db.Paper, db.User, db.Task.code, db.Place.code)
papers = (sess.query(db.Paper, db.User, db.Task.code, db.Place)
.select_from(db.Paper)
.filter(db.Paper.paper_id.in_(ids))
.join(db.User, db.User.user_id == db.Paper.for_user)
.join(db.Task, db.Task.task_id == db.Paper.for_task)
.join(db.Participation, db.Participation.user_id == db.Paper.for_user)
.join(db.Contest, and_(db.Contest.contest_id == db.Participation.contest_id, db.Contest.round_id == db.Task.round_id))
.join(db.Contest, and_(db.Contest.master_contest_id == db.Participation.contest_id, db.Contest.round_id == db.Task.round_id))
.join(db.Place, db.Place.place_id == db.Contest.place_id)
.all())
papers.sort(key=lambda p: (p[1].sort_key(), p[2]))
......@@ -61,7 +61,7 @@ def handle_download_submits(the_job: TheJob):
cnt = 0
with zipfile.ZipFile(temp_file, mode='w') as zip:
for p, u, task_code, place_code in papers:
for p, u, task_code, place in papers:
cnt += 1
full_name = u.full_name()
ascii_name = (unicodedata.normalize('NFD', full_name)
......@@ -70,9 +70,9 @@ def handle_download_submits(the_job: TheJob):
fn = f'{task_code}_{cnt:04d}_{u.user_id}_{p.paper_id}_{ascii_name}.pdf'
fn = werkzeug.utils.secure_filename(fn)
if want_subdirs:
fn = f'{place_code}/{fn}'
fn = f'{place.get_code()}/{fn}'
logger.debug('Job: Přidávám %s', fn)
zip.write(filename=os.path.join(data_dir('submits'), p.file_name),
zip.write(filename=os.path.join(data_dir('submits'), p.file_name or p.orig_file_name),
arcname=fn)
job.out_file = the_job.attach_file(temp_file.name, '.zip')
......@@ -216,7 +216,7 @@ def handle_upload_feedback(the_job: TheJob):
rows = (sess.query(db.User, db.Participation, db.Contest)
.select_from(db.Participation)
.join(db.User, db.User.user_id == db.Participation.user_id)
.join(db.Contest, db.Contest.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.Participation.user_id.in_(user_dict.keys()))
.all())
......@@ -227,7 +227,7 @@ def handle_upload_feedback(the_job: TheJob):
contest_dict[user.user_id] = contest
site_id_dict[user.user_id] = pion.place_id
rr = the_job.gatekeeper.rights_for_contest(contest)
user_rights[user.user_id] = rr.can_upload_feedback(round)
user_rights[user.user_id] = rr.can_upload_feedback()
for f in files:
f.user = user_dict[f.user_id]
......