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 (113)
recursive-include mo/web/templates * recursive-include mo/web/templates *
recursive-include mo/web/static * recursive-include mo/tex *
...@@ -10,6 +10,7 @@ parser = argparse.ArgumentParser(description='Založí soutěžní kolo') ...@@ -10,6 +10,7 @@ parser = argparse.ArgumentParser(description='Založí soutěžní kolo')
parser.add_argument('-y', '--year', type=int, required=True, help='ročník') 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('-c', '--cat', type=str, required=True, help='kategorie')
parser.add_argument('-s', '--seq', type=int, required=True, help='pořadí kola') parser.add_argument('-s', '--seq', type=int, required=True, help='pořadí kola')
parser.add_argument('-C', '--code', type=str, help='kód kola (default: roven pořadí)')
parser.add_argument('-l', '--level', type=int, required=True, help='úroveň v hierarchii oblastí') 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('-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') parser.add_argument('-n', '--name', type=str, required=True, help='název kola')
...@@ -33,6 +34,7 @@ rnd = db.Round( ...@@ -33,6 +34,7 @@ rnd = db.Round(
year=args.year, year=args.year,
category=args.cat, category=args.cat,
seq=args.seq, seq=args.seq,
code=args.code or str(args.seq),
part=args.part, part=args.part,
level=args.level, level=args.level,
name=args.name, name=args.name,
......
#!/usr/bin/env python3
import argparse
import mo.db as db
import mo.util
from mo.util import die, init_standalone
parser = argparse.ArgumentParser(description='Založí úlohy pro dané kolo')
parser.add_argument(dest='round', type=str, metavar='YY-C-S[p]', help='kód kola')
parser.add_argument(dest='count', type=int, help='počet úloh')
parser.add_argument('-p', '--points', type=int, default=None, help='maximální počet bodů')
args = parser.parse_args()
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!")
if round.state != db.RoundState.preparing:
die("Kolo musí být ve stavu 'připravuje se'")
for i in range(1, args.count + 1):
code = f'{round.category}-{round.code or round.seq}-{i}'
task = sess.query(db.Task).filter_by(round=round, code=code).one_or_none()
if task:
print(f'{code}: již existuje')
else:
task = db.Task(round=round, code=code, name=f'Úloha {i}', max_points=args.points)
sess.add(task)
print(f'{code}: zakládám')
sess.commit()
...@@ -29,14 +29,17 @@ class Row: ...@@ -29,14 +29,17 @@ class Row:
output = [] output = []
for user in sorted(orgs, key=lambda u: u.sort_key()): for user in sorted(orgs, key=lambda u: u.sort_key()):
o = Row(
def row() -> Row:
return Row(
jmeno=user.full_name(), jmeno=user.full_name(),
email=user.email, email=user.email,
last_login=(user.last_login_at.strftime('%Y-%m-%d') if user.last_login_at is not None else '-'), last_login=(user.last_login_at.strftime('%Y-%m-%d') if user.last_login_at is not None else '-'),
) )
if user.roles: if user.roles:
for r in user.roles: for r in sorted(user.roles, key=lambda r: (r.role, r.category or "", r.year or -1, r.seq or -1, r.place_id or -1)):
o = row()
o.role = r.role o.role = r.role
o.kod_souteze = f"{r.category or '*'}-{r.year or '*'}-{r.seq or '*'}" o.kod_souteze = f"{r.category or '*'}-{r.year or '*'}-{r.seq or '*'}"
p = r.place p = r.place
...@@ -44,6 +47,7 @@ for user in sorted(orgs, key=lambda u: u.sort_key()): ...@@ -44,6 +47,7 @@ for user in sorted(orgs, key=lambda u: u.sort_key()):
o.misto = p.type_name() + " " + p.name o.misto = p.type_name() + " " + p.name
output.append(o) output.append(o)
else: else:
o = row()
o.role = '-' o.role = '-'
output.append(o) output.append(o)
......
...@@ -5,7 +5,7 @@ import sys ...@@ -5,7 +5,7 @@ import sys
from mo.csv import FileFormat from mo.csv import FileFormat
import mo.db as db import mo.db as db
from mo.imports import create_import, ImportType from mo.imports import PointsImport
import mo.users import mo.users
import mo.util import mo.util
from mo.util import die from mo.util import die
...@@ -37,14 +37,13 @@ user = mo.users.user_by_email(args.user) ...@@ -37,14 +37,13 @@ user = mo.users.user_by_email(args.user)
if user is None: if user is None:
die(f"Uživatel {args.user} neexistuje") die(f"Uživatel {args.user} neexistuje")
imp = create_import( imp = PointsImport(
user, user,
type=ImportType.points,
fmt=FileFormat.tsv,
round=round, round=round,
task=task, task=task,
allow_add_del=args.add_del, allow_add_del=args.add_del,
) )
imp.fmt = FileFormat.tsv
if args.import_file: if args.import_file:
if not imp.run(args.import_file): if not imp.run(args.import_file):
......
#!/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
...@@ -27,7 +27,8 @@ CREATE TABLE users ( ...@@ -27,7 +27,8 @@ CREATE TABLE users (
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í reset/aktivace nebo žádost o ně 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
email_notify boolean NOT NULL DEFAULT true -- přeje si dostávat mailové notifikace
); );
-- Hierarchie regionů a organizací -- Hierarchie regionů a organizací
...@@ -112,9 +113,10 @@ CREATE TABLE rounds ( ...@@ -112,9 +113,10 @@ CREATE TABLE rounds (
master_round_id int DEFAULT NULL REFERENCES rounds(round_id), master_round_id int DEFAULT NULL REFERENCES rounds(round_id),
year int NOT NULL, -- ročník MO year int NOT NULL, -- ročník MO
category varchar(2) NOT NULL, -- "A", "Z5" apod. category varchar(2) NOT NULL, -- "A", "Z5" apod.
seq int NOT NULL, -- 1=domácí kolo atd. seq int NOT NULL, -- pořadí kola v kategorii (od 1)
part int NOT NULL DEFAULT 0, -- část kola (nenulová u dělených kol) part int NOT NULL DEFAULT 0, -- část kola (nenulová u dělených kol)
level int NOT NULL, -- úroveň hierarchie míst level int NOT NULL, -- úroveň hierarchie míst
code varchar(255) NOT NULL, -- kód kola ("1", "S" apod.)
name varchar(255) NOT NULL, -- zobrazované jméno ("Krajské kolo" apod.) name varchar(255) NOT NULL, -- zobrazované jméno ("Krajské kolo" apod.)
state round_state NOT NULL DEFAULT 'preparing', -- stav kola state round_state NOT NULL DEFAULT 'preparing', -- stav kola
tasks_file varchar(255) DEFAULT NULL, -- jméno souboru se zadáním úloh tasks_file varchar(255) DEFAULT NULL, -- jméno souboru se zadáním úloh
...@@ -129,7 +131,8 @@ CREATE TABLE rounds ( ...@@ -129,7 +131,8 @@ CREATE TABLE rounds (
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_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 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),
UNIQUE (year, category, code, part)
); );
CREATE INDEX rounds_master_round_id_index ON rounds (master_round_id); CREATE INDEX rounds_master_round_id_index ON rounds (master_round_id);
...@@ -179,13 +182,20 @@ CREATE TABLE participations ( ...@@ -179,13 +182,20 @@ CREATE TABLE participations (
CREATE INDEX participations_contest_id_index ON participations (contest_id, place_id); CREATE INDEX participations_contest_id_index ON participations (contest_id, place_id);
-- Úloha -- Úlohy
CREATE TYPE task_type AS ENUM (
'regular', -- obyčejná úloha
'cms' -- praktická úloha kategorie P odevzdávaná přes CMS
);
CREATE TABLE tasks ( CREATE TABLE tasks (
task_id serial PRIMARY KEY, task_id serial PRIMARY KEY,
round_id int NOT NULL REFERENCES rounds(round_id), round_id int NOT NULL REFERENCES rounds(round_id),
code varchar(255) NOT NULL, -- např. "P-I-1" code varchar(255) NOT NULL, -- např. "P-I-1"
name varchar(255) NOT NULL, 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ů max_points numeric(5,1) DEFAULT NULL, -- maximální počet bodů, pokud je nastaven, nelze zadat více bodů
type task_type NOT NULL DEFAULT 'regular',
UNIQUE (round_id, code) UNIQUE (round_id, code)
); );
...@@ -250,11 +260,14 @@ CREATE TYPE role_type AS ENUM ( ...@@ -250,11 +260,14 @@ CREATE TYPE role_type AS ENUM (
'garant_okres', -- okresní garant 'garant_okres', -- okresní garant
'garant_skola', -- školní garant 'garant_skola', -- školní garant
'dozor', -- dozor na soutěži (může odevzdávat řešení za účastníky) 'dozor', -- dozor na soutěži (může odevzdávat řešení za účastníky)
'opravovatel' -- opravovatel 'opravovatel', -- opravovatel
'pozorovatel' -- pozorovatel (má read-only práva ke všemu)
); );
-- Uživatelům majícím is_org=true lze přidělit roli ke konkrétnímu regionu (včetně podregionů) a volitelně kategorii/kolu -- Uživatelům majícím is_org=true lze přidělit roli ke konkrétnímu regionu (včetně podregionů) a volitelně kategorii/kolu
-- HACK: Pokud category='Z', role platí pro všechny kategorie začínající na 'Z' (extra výjimka v mo.rights) -- HACK: category='Z 'platí pro všechny kategorie začínající na 'Z'
-- category='S' platí pro kategorie 'A', 'B', 'C'
-- (ošetřeno v mo.db.UserRight)
CREATE TABLE user_roles ( CREATE TABLE user_roles (
user_role_id serial PRIMARY KEY, user_role_id serial PRIMARY KEY,
user_id int NOT NULL REFERENCES users(user_id), user_id int NOT NULL REFERENCES users(user_id),
...@@ -295,10 +308,13 @@ CREATE INDEX log_type_id_index ON log (type, id); ...@@ -295,10 +308,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é
...@@ -394,3 +410,20 @@ CREATE VIEW region_task_stats AS ...@@ -394,3 +410,20 @@ CREATE VIEW region_task_stats AS
JOIN solutions s USING(user_id, task_id) JOIN solutions s USING(user_id, task_id)
JOIN region_descendants rd ON rd.descendant = c.place_id JOIN region_descendants rd ON rd.descendant = c.place_id
GROUP BY r.round_id, rd.region, t.task_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 task_type AS ENUM (
'regular', -- obyčejná úloha
'cms' -- praktická úloha kategorie P odevzdávaná přes CMS
);
ALTER TABLE tasks ADD COLUMN type task_type NOT NULL DEFAULT 'regular';
SET ROLE 'mo_osmo';
ALTER TABLE ROUNDS ADD COLUMN
code varchar(255) NOT NULL DEFAULT '';
SET ROLE mo_osmo;
ALTER TABLE users ADD COLUMN
email_notify boolean NOT NULL DEFAULT true;
SET ROLE mo_osmo;
ALTER TABLE rounds ALTER COLUMN code DROP DEFAULT;
UPDATE rounds SET code=seq WHERE code='';
ALTER TABLE rounds ADD UNIQUE(year, category, code, part);
SET ROLE mo_osmo;
ALTER TYPE role_type ADD VALUE 'pozorovatel';
...@@ -48,6 +48,9 @@ GC_PERIOD = 60 ...@@ -48,6 +48,9 @@ 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
# Některé dávky (analýza scanů) mají delší expiraci [min]
JOB_EXPIRATION_LONG = 1440
# Kolik nejvýše dovolujeme registrací za minutu # Kolik nejvýše dovolujeme registrací za minutu
REG_MAX_PER_MINUTE = 10 REG_MAX_PER_MINUTE = 10
...@@ -56,3 +59,8 @@ REG_TOKEN_VALIDITY = 10 ...@@ -56,3 +59,8 @@ REG_TOKEN_VALIDITY = 10
# Aktuální ročník MO # Aktuální ročník MO
CURRENT_YEAR = 71 CURRENT_YEAR = 71
# Instance CMS, ve které žijí praktické programovací úlohy, a její SSO secret.
# Pokud se neuvede nebo je None, praktické úlohy nejde odevzdávat.
# CMS_ROOT = 'https://contest.kam.mff.cuni.cz/cms/'
# CMS_SSO_SECRET = 'BrumBrum'
[uwsgi] [uwsgi]
strict = true
chdir = /akce/mo/osmo-test/ chdir = /akce/mo/osmo-test/
socket = var/osmo.sock socket = var/osmo.sock
chmod-socket = 666 chmod-socket = 666
...@@ -12,9 +13,9 @@ log-date = %%Y-%%m-%%d %%H:%%M:%%S ...@@ -12,9 +13,9 @@ log-date = %%Y-%%m-%%d %%H:%%M:%%S
logformat-strftime logformat-strftime
log-format = %(ftime) %(addr) %(method) "%(uri)" %(proto) %(status) t=%(msecs) rxb=%(cl) txb=%(rsize) pid=%(pid) user=%(osmo_uid) log-format = %(ftime) %(addr) %(method) "%(uri)" %(proto) %(status) t=%(msecs) rxb=%(cl) txb=%(rsize) pid=%(pid) user=%(osmo_uid)
master = True master = true
processes = 2 processes = 2
vacuum = True vacuum = true
die-on-term = true die-on-term = true
max-requests = 10000 max-requests = 10000
......
...@@ -5,11 +5,12 @@ import datetime ...@@ -5,11 +5,12 @@ 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, or_, and_
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload, aliased 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
...@@ -22,6 +23,7 @@ from sqlalchemy.sql.sqltypes import Numeric ...@@ -22,6 +23,7 @@ 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 timeformat_short, timedelta, time_and_timedelta from mo.util_format import timeformat_short, timedelta, time_and_timedelta
...@@ -136,7 +138,8 @@ class Place(Base): ...@@ -136,7 +138,8 @@ class Place(Base):
return place_levels[self.level].in_name() + " " + name return place_levels[self.level].in_name() + " " + name
def get_root_place(): def get_root_place() -> Place:
"""Obvykle voláme mo.rights.Gatekeeper.get_root_place(), kterékešuje."""
return get_session().query(Place).filter_by(parent=None).one() return get_session().query(Place).filter_by(parent=None).one()
...@@ -249,6 +252,7 @@ class Round(Base): ...@@ -249,6 +252,7 @@ class Round(Base):
seq = Column(Integer, nullable=False) seq = Column(Integer, nullable=False)
part = Column(Integer, nullable=False) part = Column(Integer, nullable=False)
level = Column(Integer, nullable=False) level = Column(Integer, nullable=False)
code = Column(String(255), nullable=False)
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
state = Column(Enum(RoundState, name='round_state'), nullable=False, server_default=text("'preparing'::round_state")) state = Column(Enum(RoundState, name='round_state'), nullable=False, server_default=text("'preparing'::round_state"))
tasks_file = Column(String(255)) tasks_file = Column(String(255))
...@@ -268,7 +272,7 @@ class Round(Base): ...@@ -268,7 +272,7 @@ class Round(Base):
def round_code_short(self): def round_code_short(self):
""" Pro samostatné kolo ekvivalentní s `round_code()`, pro skupinu kol společná část kódu. """ """ Pro samostatné kolo ekvivalentní s `round_code()`, pro skupinu kol společná část kódu. """
return f"{self.year}-{self.category}-{self.seq}" return f"{self.year}-{self.category}-{self.code}"
def part_code(self): def part_code(self):
return chr(ord('a') + self.part - 1) if self.part > 0 else "" return chr(ord('a') + self.part - 1) if self.part > 0 else ""
...@@ -316,6 +320,25 @@ class Round(Base): ...@@ -316,6 +320,25 @@ class Round(Base):
return " ".join(times) return " ".join(times)
def find_master_round(year: Optional[int], category: Optional[str], code: str) -> Round:
if not year:
raise mo.CheckError('Neuveden ročník pro nalezení kola')
if not category:
raise mo.CheckError('Neuvedena kategorie pro nalezení kola')
r = (
get_session().query(Round)
.filter_by(year=year, category=category, code=code)
.filter(Round.master_round_id == Round.round_id)
.all()
)
if len(r) < 1:
raise mo.CheckError(f'Kolo {year}-{category}-{code} nenalezeno')
if len(r) > 1:
raise mo.CheckError(f'Kolo {year}-{category}-{code} nelze určit jednoznačně')
return r[0]
class User(Base): class User(Base):
__tablename__ = 'users' __tablename__ = 'users'
...@@ -331,6 +354,7 @@ class User(Base): ...@@ -331,6 +354,7 @@ class User(Base):
reset_at = Column(DateTime(True)) reset_at = Column(DateTime(True))
password_hash = Column(String(255), server_default=text("NULL::character varying")) password_hash = Column(String(255), server_default=text("NULL::character varying"))
note = Column(Text, nullable=False, server_default=text("''::text")) note = Column(Text, nullable=False, server_default=text("''::text"))
email_notify = Column(Boolean, nullable=False, server_default=text("true"))
roles = relationship('UserRole', primaryjoin='UserRole.user_id == User.user_id') roles = relationship('UserRole', primaryjoin='UserRole.user_id == User.user_id')
participants = relationship('Participant', primaryjoin='Participant.user_id == User.user_id') participants = relationship('Participant', primaryjoin='Participant.user_id == User.user_id')
...@@ -476,6 +500,20 @@ class Participation(Base): ...@@ -476,6 +500,20 @@ class Participation(Base):
user = relationship('User') user = relationship('User')
class TaskType(MOEnum):
regular = auto()
cms = auto()
def friendly_name(self) -> str:
return task_type_names[self]
task_type_names = {
TaskType.regular: 'standardní',
TaskType.cms: 'programovací (CMS)',
}
class Task(Base): class Task(Base):
__tablename__ = 'tasks' __tablename__ = 'tasks'
__table_args__ = ( __table_args__ = (
...@@ -487,6 +525,7 @@ class Task(Base): ...@@ -487,6 +525,7 @@ class Task(Base):
code = Column(String(255), nullable=False) code = Column(String(255), nullable=False)
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
max_points = Column(Numeric) max_points = Column(Numeric)
type = Column(Enum(TaskType, name='task_type'), nullable=False, default=TaskType.regular)
round = relationship('Round') round = relationship('Round')
...@@ -498,6 +537,7 @@ class RoleType(MOEnum): ...@@ -498,6 +537,7 @@ class RoleType(MOEnum):
garant_skola = auto() garant_skola = auto()
dozor = auto() dozor = auto()
opravovatel = auto() opravovatel = auto()
pozorovatel = auto()
def friendly_name(self) -> str: def friendly_name(self) -> str:
return role_type_names[self] return role_type_names[self]
...@@ -510,6 +550,7 @@ role_type_names = { ...@@ -510,6 +550,7 @@ role_type_names = {
RoleType.garant_skola: 'školní garant', RoleType.garant_skola: 'školní garant',
RoleType.dozor: 'dozor', RoleType.dozor: 'dozor',
RoleType.opravovatel: 'opravovatel', RoleType.opravovatel: 'opravovatel',
RoleType.pozorovatel: 'pozorovatel',
} }
...@@ -543,12 +584,29 @@ class UserRole(Base): ...@@ -543,12 +584,29 @@ class UserRole(Base):
return " ".join(parts) return " ".join(parts)
# XXX: Tatáž logika je v DB dotazu v org_index()
def applies_to(self, at: Optional[Place] = None, year: Optional[int] = None, cat: Optional[str] = None, seq: Optional[int] = None) -> bool: 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) 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.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.category is None
or cat is None
or self.category == cat
or (self.category == 'Z' and cat.startswith('Z'))
or (self.category == 'S' and cat in "ABC"))
and (self.seq is None or seq is None or self.seq == seq)) and (self.seq is None or seq is None or self.seq == seq))
def is_legal(self) -> bool:
# Některé role mají omezení na úroveň hierarchie.
level = self.place.level if self.place else -1
rt = self.role
if (rt == RoleType.garant and not level <= 0
or rt == RoleType.garant_kraj and not level == 1
or rt == RoleType.garant_okres and not level == 2
or rt == RoleType.garant_skola and not level >= 3):
return False
return True
class PaperType(MOEnum): class PaperType(MOEnum):
solution = auto() solution = auto()
...@@ -629,9 +687,12 @@ class Solution(Base): ...@@ -629,9 +687,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()
...@@ -642,6 +703,7 @@ class JobState(MOEnum): ...@@ -642,6 +703,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',
...@@ -668,6 +730,14 @@ class Job(Base): ...@@ -668,6 +730,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'
...@@ -750,6 +820,29 @@ class RegionTaskStat(Base): ...@@ -750,6 +820,29 @@ class RegionTaskStat(Base):
task = relationship('Task') 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
......
...@@ -74,7 +74,7 @@ def confirm_url(type: str, token: str) -> str: ...@@ -74,7 +74,7 @@ def confirm_url(type: str, token: str) -> str:
def contestant_list_url(contest: db.Contest, registered_only: bool) -> str: def contestant_list_url(contest: db.Contest, registered_only: bool) -> str:
url = config.WEB_ROOT + f'org/contest/c/{contest.contest_id}/ucastnici' url = config.WEB_ROOT + f'org/contest/c/{contest.contest_id}/participants'
if registered_only: if registered_only:
url += '?participation_state=registered' url += '?participation_state=registered'
return url return url
......
This diff is collapsed.
...@@ -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