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 (44)
Showing with 355 additions and 120 deletions
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
from sqlalchemy import and_
import mo.db as db import mo.db as db
import mo.users import mo.users
...@@ -33,10 +34,31 @@ print(f"Slučuji UID {suid} do UID {duid}") ...@@ -33,10 +34,31 @@ print(f"Slučuji UID {suid} do UID {duid}")
sess = db.get_session() sess = db.get_session()
conn = sess.connection() 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.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.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.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)) conn.execute(db.UserRole.__table__.update().where(db.UserRole.user_id == suid).values(user_id=duid))
......
...@@ -107,6 +107,7 @@ CREATE TABLE rounds ( ...@@ -107,6 +107,7 @@ CREATE TABLE rounds (
score_mode score_mode NOT NULL DEFAULT 'basic', -- mód výsledkovky 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_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 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)
has_messages boolean NOT NULL DEFAULT false, -- má zprávičky has_messages boolean NOT NULL DEFAULT false, -- má zprávičky
UNIQUE (year, category, seq, part) UNIQUE (year, category, seq, part)
); );
...@@ -162,7 +163,7 @@ CREATE TABLE tasks ( ...@@ -162,7 +163,7 @@ CREATE TABLE tasks (
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 int 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ů
UNIQUE (round_id, code) UNIQUE (round_id, code)
); );
...@@ -200,7 +201,7 @@ CREATE TABLE solutions ( ...@@ -200,7 +201,7 @@ CREATE TABLE solutions (
user_id int NOT NULL REFERENCES users(user_id), 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_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 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 note text NOT NULL DEFAULT '', -- komentář pro řešitele
org_note text NOT NULL DEFAULT '', -- komentář viditelný jen organizátorům org_note text NOT NULL DEFAULT '', -- komentář viditelný jen organizátorům
PRIMARY KEY (task_id, user_id) PRIMARY KEY (task_id, user_id)
...@@ -213,7 +214,7 @@ CREATE TABLE points_history ( ...@@ -213,7 +214,7 @@ CREATE TABLE points_history (
points_history_id serial PRIMARY KEY, points_history_id serial PRIMARY KEY,
task_id int NOT NULL REFERENCES tasks(task_id), task_id int NOT NULL REFERENCES tasks(task_id),
participant_id int NOT NULL REFERENCES users(user_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_by int NOT NULL REFERENCES users(user_id), -- kdo přidělil body
points_at timestamp with time zone NOT NULL -- a kdy points_at timestamp with time zone NOT NULL -- a kdy
); );
......
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);
...@@ -19,7 +19,7 @@ SESSION_COOKIE_PATH = '/' ...@@ -19,7 +19,7 @@ SESSION_COOKIE_PATH = '/'
SESSION_COOKIE_NAME = 'mo_session' SESSION_COOKIE_NAME = 'mo_session'
# SESSION_COOKIE_SECURE=True # SESSION_COOKIE_SECURE=True
# Kontaktní email (v patičce a také používaný jako adresát při generování pošty s Bcc) # 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" MAIL_CONTACT = "osmo@mo.mff.cuni.cz"
# Odesilatel generovaných mailů (není-li definován, neposílají se) # Odesilatel generovaných mailů (není-li definován, neposílají se)
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
# Generated by sqlacodegen and then heavily edited. # Generated by sqlacodegen and then heavily edited.
import datetime import datetime
import decimal
from enum import Enum as PythonEnum, auto from enum import Enum as PythonEnum, auto
import locale import locale
import re import re
...@@ -15,9 +16,11 @@ from sqlalchemy.orm.attributes import get_history ...@@ -15,9 +16,11 @@ from sqlalchemy.orm.attributes import get_history
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.sqltypes import Numeric
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
import mo import mo
from mo.place_level import place_levels, PlaceLevel
from mo.util_format import timedelta, time_and_timedelta from mo.util_format import 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
...@@ -71,7 +74,7 @@ class PlaceType(MOEnum): ...@@ -71,7 +74,7 @@ class PlaceType(MOEnum):
(name, levels) = place_type_names_and_levels[item] (name, levels) = place_type_names_and_levels[item]
if level is None or level in levels: if level is None or level in levels:
if item == enum.region and level is not None: if item == enum.region and level is not None:
name += " (" + place_level_names[level] + ")" name += " (" + place_levels[level].name + ")"
out.append((item.name, name)) out.append((item.name, name))
return out return out
...@@ -83,8 +86,6 @@ place_type_names_and_levels = { ...@@ -83,8 +86,6 @@ place_type_names_and_levels = {
PlaceType.site: ('Soutěžní místo', [4]), PlaceType.site: ('Soutěžní místo', [4]),
} }
place_level_names = ['stát', 'kraj', 'okres', 'obec', 'škola']
class Place(Base): class Place(Base):
__tablename__ = 'places' __tablename__ = 'places'
...@@ -106,8 +107,8 @@ class Place(Base): ...@@ -106,8 +107,8 @@ class Place(Base):
return "soutěžní místo" return "soutěžní místo"
elif self.type == PlaceType.school: elif self.type == PlaceType.school:
return "škola" return "škola"
elif self.level < len(place_level_names): elif self.level < len(place_levels):
return place_level_names[self.level] return place_levels[self.level].name
else: else:
return "region" return "region"
...@@ -117,6 +118,15 @@ class Place(Base): ...@@ -117,6 +118,15 @@ class Place(Base):
def can_have_child(self): def can_have_child(self):
return len(PlaceType.choices(level=self.level + 1)) > 0 return len(PlaceType.choices(level=self.level + 1)) > 0
def get_level(self) -> PlaceLevel:
return place_levels[self.level]
def name_locative(self):
name = self.name
if self.level == 1:
name = name.replace("ý kraj", "ém").replace("Kraj ", "")
return place_levels[self.level].in_name() + " " + name
def get_root_place(): def get_root_place():
return get_session().query(Place).filter_by(parent=None).one() return get_session().query(Place).filter_by(parent=None).one()
...@@ -186,6 +196,15 @@ round_score_mode_names = { ...@@ -186,6 +196,15 @@ 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): class Round(Base):
__tablename__ = 'rounds' __tablename__ = 'rounds'
__table_args__ = ( __table_args__ = (
...@@ -207,8 +226,9 @@ class Round(Base): ...@@ -207,8 +226,9 @@ class Round(Base):
pr_tasks_start = Column(DateTime(True)) pr_tasks_start = Column(DateTime(True))
pr_submit_end = 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_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False, server_default=text("'basic'::score_mode"))
score_winner_limit = Column(Integer) score_winner_limit = Column(Numeric)
score_successful_limit = Column(Integer) score_successful_limit = Column(Numeric)
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"))
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)
...@@ -226,6 +246,9 @@ class Round(Base): ...@@ -226,6 +246,9 @@ class Round(Base):
part = self.part_code() part = self.part_code()
return f"{code}{part}" return f"{code}{part}"
def get_level(self) -> PlaceLevel:
return place_levels[self.level]
def has_tasks(self): def has_tasks(self):
return self.tasks_file return self.tasks_file
...@@ -246,6 +269,11 @@ class Round(Base): ...@@ -246,6 +269,11 @@ class Round(Base):
else: else:
return self.state 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): class User(Base):
__tablename__ = 'users' __tablename__ = 'users'
...@@ -415,7 +443,7 @@ class Task(Base): ...@@ -415,7 +443,7 @@ class Task(Base):
round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False) round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
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(Integer) max_points = Column(Numeric)
round = relationship('Round') round = relationship('Round')
...@@ -467,9 +495,8 @@ class UserRole(Base): ...@@ -467,9 +495,8 @@ class UserRole(Base):
parts.append(f"{self.year}. ročníku") parts.append(f"{self.year}. ročníku")
if self.category: if self.category:
parts.append(f"kategorie {self.category}") parts.append(f"kategorie {self.category}")
parts.append("pro") if self.place.level > 0:
parts.append(self.place.type_name()) parts.append(self.place.name_locative())
parts.append(self.place.name)
return " ".join(parts) return " ".join(parts)
...@@ -524,7 +551,7 @@ class PointsHistory(Base): ...@@ -524,7 +551,7 @@ class PointsHistory(Base):
points_history_id = Column(Integer, primary_key=True, server_default=text("nextval('points_history_points_history_id_seq'::regclass)")) 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) task_id = Column(Integer, ForeignKey('tasks.task_id'), nullable=False)
participant_id = Column(Integer, ForeignKey('users.user_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_by = Column(Integer, ForeignKey('users.user_id'), nullable=False)
points_at = Column(DateTime(True), nullable=False) points_at = Column(DateTime(True), nullable=False)
...@@ -540,7 +567,7 @@ class Solution(Base): ...@@ -540,7 +567,7 @@ class Solution(Base):
user_id = Column(Integer, ForeignKey('users.user_id'), primary_key=True, nullable=False) user_id = Column(Integer, ForeignKey('users.user_id'), primary_key=True, nullable=False)
final_submit = Column(Integer, ForeignKey('papers.paper_id')) final_submit = Column(Integer, ForeignKey('papers.paper_id'))
final_feedback = 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")) note = Column(Text, nullable=False, server_default=text("''::text"))
org_note = Column(Text, nullable=False, server_default=text("''::text")) org_note = Column(Text, nullable=False, server_default=text("''::text"))
...@@ -713,6 +740,8 @@ def row2dict(row): ...@@ -713,6 +740,8 @@ def row2dict(row):
if isinstance(val, datetime.datetime): if isinstance(val, datetime.datetime):
# datetime neumíme serializovat do JSONu, ale nevadí to, protože ho stejně nemá smysl logovat # datetime neumíme serializovat do JSONu, ale nevadí to, protože ho stejně nemá smysl logovat
pass pass
elif isinstance(val, decimal.Decimal):
d[column.name] = float(val)
else: else:
d[column.name] = getattr(row, column.name) d[column.name] = getattr(row, column.name)
......
from dataclasses import dataclass from dataclasses import dataclass
import decimal
from enum import auto from enum import auto
import io import io
import re import re
...@@ -13,6 +14,7 @@ import mo.rights ...@@ -13,6 +14,7 @@ import mo.rights
import mo.users import mo.users
import mo.util import mo.util
from mo.util import logger from mo.util import logger
from mo.util_format import format_decimal
class ImportType(db.MOEnum): class ImportType(db.MOEnum):
...@@ -137,7 +139,7 @@ class Import: ...@@ -137,7 +139,7 @@ class Import:
('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else '')) ('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
if not self.check_rights(place): if not self.check_rights(place):
return self.error(f'K místu "{kod}" nemáte práva na správu soutěže') return self.error(f'Nemáte práva na správu soutěže {place.name_locative()}')
self.place_cache[kod] = place self.place_cache[kod] = place
return place return place
...@@ -214,7 +216,7 @@ class Import: ...@@ -214,7 +216,7 @@ class Import:
self.new_user_ids.append(user.user_id) self.new_user_ids.append(user.user_id)
return user 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 == "": if points_str == "":
return self.error('Body musí být vyplněny') return self.error('Body musí být vyplněny')
...@@ -222,17 +224,9 @@ class Import: ...@@ -222,17 +224,9 @@ class Import:
if points_str in ['X', '?']: if points_str in ['X', '?']:
return points_str return points_str
try: pts, error = mo.util.parse_points(points_str, self.task, self.round)
pts = int(points_str) if error:
except ValueError: return self.error(error)
return self.error('Body nejsou celé číslo')
if pts < 0:
return self.error('Body nesmí být záporné')
assert self.task is not None
if self.task.max_points is not None and pts > self.task.max_points:
return self.error(f'Body převyšují maximální počet bodů pro tuto úlohu ({self.task.max_points})')
return pts return pts
...@@ -280,7 +274,7 @@ class Import: ...@@ -280,7 +274,7 @@ class Import:
elif len(pions) == 1: elif len(pions) == 1:
pion = pions[0] pion = pions[0]
if pion.place != place: if pion.place != place:
return self.error(f'Již se tohoto kola účastní v jiné oblasti ({pion.place.get_code()})') 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: else:
return self.error('Již se tohoto kola účastní ve vice oblastech, což by nemělo být možné') return self.error('Již se tohoto kola účastní ve vice oblastech, což by nemělo být možné')
...@@ -294,11 +288,11 @@ class Import: ...@@ -294,11 +288,11 @@ class Import:
else: else:
if oblast is None: if oblast is None:
if not allow_none: if not allow_none:
self.error('Je nutné uvést oblast') self.error('Je nutné uvést ' + self.round.get_level().name)
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 uvedené oblasti toto kolo neprobíhá') return self.error('V ' + self.round.get_level().name_locative("uvedeném", "uvedené", "uvedeném") + ' toto kolo neprobíhá')
return contest return contest
...@@ -605,7 +599,7 @@ class JudgeImport(Import): ...@@ -605,7 +599,7 @@ class JudgeImport(Import):
contest = self.obtain_contest(oblast, allow_none=True) contest = self.obtain_contest(oblast, allow_none=True)
place = contest.place if contest else self.root_place place = contest.place if contest else self.root_place
if not self.check_rights(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') return self.error(f'Nemáte práva na správu soutěže {place.name_locative()}')
self.add_role(user, place, db.RoleType.opravovatel) self.add_role(user, place, db.RoleType.opravovatel)
...@@ -673,7 +667,7 @@ class PointsImport(Import): ...@@ -673,7 +667,7 @@ class PointsImport(Import):
if self.contest is not None: if self.contest is not None:
if pion.contest != self.contest: if pion.contest != self.contest:
return self.error('Soutěžící nesoutěží v této oblasti') return self.error('Soutěžící nesoutěží v ' + self.round.get_level().name_locative('tomto', 'této', 'tomto'))
rights = self.gatekeeper.rights_for_contest(pion.contest) rights = self.gatekeeper.rights_for_contest(pion.contest)
if not rights.can_edit_points(): if not rights.can_edit_points():
...@@ -716,7 +710,7 @@ class PointsImport(Import): ...@@ -716,7 +710,7 @@ class PointsImport(Import):
sess.delete(sol) sess.delete(sol)
return return
points = body if isinstance(body, int) else None points = body if isinstance(body, decimal.Decimal) else None
if sol.points != points: if sol.points != points:
sol.points = points sol.points = points
sess.add(db.PointsHistory( sess.add(db.PointsHistory(
...@@ -736,7 +730,7 @@ class PointsImport(Import): ...@@ -736,7 +730,7 @@ class PointsImport(Import):
elif sol.points is None: elif sol.points is None:
pts = '?' pts = '?'
else: else:
pts = str(sol.points) pts = format_decimal(sol.points)
user = pion.user user = pion.user
rows.append(PointsImportRow( rows.append(PointsImportRow(
user_id=user.user_id, user_id=user.user_id,
......
...@@ -110,7 +110,7 @@ class TheJob: ...@@ -110,7 +110,7 @@ class TheJob:
sess = db.get_session() sess = db.get_session()
if not self.load() or self.job.state != db.JobState.ready: if not self.load() or self.job.state != db.JobState.ready:
# Někdo ho mezitím smazal nebo vyřídil # 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() sess.rollback()
return return
......
from dataclasses import dataclass
@dataclass
class PlaceLevel:
level: int
genus: str
name: str
name_gen: str
name_acc: str
name_loc: str
in_prep: str
def name_genitive(self, m="", f="", n="") -> str:
w = {'m': m, 'f': f, 'n': n}[self.genus]
return (w + ' ' if w is not '' else '') + self.name_gen
def name_accusative(self, m="", f="", n="") -> str:
w = {'m': m, 'f': f, 'n': n}[self.genus]
return (w + ' ' if w is not '' else '') + self.name_acc
def name_locative(self, m="", f="", n="") -> str:
w = {'m': m, 'f': f, 'n': n}[self.genus]
return (w + ' ' if w is not '' else '') + self.name_loc
def in_name(self) -> str:
return self.in_prep + ' ' + self.name_loc
place_levels = [
PlaceLevel(level=0, genus='m', in_prep='ve',
name='stát', name_gen='státu', name_acc='stát', name_loc='státě'),
PlaceLevel(level=1, genus='m', in_prep='v',
name='kraj', name_gen='kraje', name_acc='kraj', name_loc='kraji'),
PlaceLevel(level=2, genus='m', in_prep='v',
name='okres', name_gen='okresu', name_acc='okres', name_loc='okrese'),
PlaceLevel(level=3, genus='f', in_prep='v',
name='obec', name_gen='obce', name_acc='obec', name_loc='obci'),
PlaceLevel(level=4, genus='f', in_prep='ve',
name='škola', name_gen='školy', name_acc='školu', name_loc='škole'),
]
import decimal
from fractions import Fraction from fractions import Fraction
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
...@@ -56,8 +57,8 @@ class ScoreResult: ...@@ -56,8 +57,8 @@ class ScoreResult:
def get_sols_map(self) -> Dict[int, db.Solution]: def get_sols_map(self) -> Dict[int, db.Solution]:
return self._sols[0] return self._sols[0]
def get_total_points(self) -> int: def get_total_points(self) -> decimal.Decimal:
sum = 0 sum = decimal.Decimal(0)
for sol in self.get_sols(): for sol in self.get_sols():
if sol.points: if sol.points:
sum += sol.points sum += sol.points
...@@ -67,17 +68,17 @@ class ScoreResult: ...@@ -67,17 +68,17 @@ class ScoreResult:
class ScoreTask: class ScoreTask:
task: db.Task task: db.Task
num_solutions: int num_solutions: int
sum_points: int sum_points: decimal.Decimal
def __init__(self, task: db.Task): def __init__(self, task: db.Task):
self.task = task self.task = task
self.num_solutions = 0 self.num_solutions = 0
self.sum_points = 0 self.sum_points = decimal.Decimal(0)
def get_difficulty(self) -> Fraction: def get_difficulty(self) -> Fraction:
if self.num_solutions == 0: if self.num_solutions == 0:
return Fraction(0) return Fraction(0)
return Fraction(self.sum_points, self.num_solutions) return Fraction(Fraction(self.sum_points), self.num_solutions)
def get_difficulty_str(self) -> str: def get_difficulty_str(self) -> str:
return f'{self.sum_points}/{self.num_solutions}' return f'{self.sum_points}/{self.num_solutions}'
......
...@@ -92,11 +92,11 @@ class Submitter: ...@@ -92,11 +92,11 @@ class Submitter:
if 'error' in result: if 'error' in result:
logger.info('Submit: PDF error: %s', result['error']) logger.info('Submit: PDF error: %s', result['error'])
if result['pdf-like'] and allow_broken: if result.get('pdf-like', False) and allow_broken:
logger.info('Submit: Soubor akceptován s varováním') logger.info('Submit: Soubor akceptován s varováním')
broken = True broken = True
else: else:
raise SubmitException('Soubor není korektní PDF.') raise SubmitException(result.get('user-error', 'Soubor není korektní PDF.'))
else: else:
paper.pages = result['pages'] paper.pages = result['pages']
broken = False broken = False
...@@ -114,6 +114,9 @@ class Submitter: ...@@ -114,6 +114,9 @@ class Submitter:
except pikepdf.PdfError as e: except pikepdf.PdfError as e:
result['error'] = str(e) result['error'] = str(e)
result['pdf-like'] = Submitter._looks_like_pdf(tmpfile) result['pdf-like'] = Submitter._looks_like_pdf(tmpfile)
except pikepdf.PasswordError:
result['error'] = 'Soubor je chráněný heslem'
result['user-error'] = 'Soubor je chráněný heslem'
pipe.send(result) pipe.send(result)
@staticmethod @staticmethod
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
from dataclasses import dataclass from dataclasses import dataclass
import datetime import datetime
import decimal
import dateutil.tz import dateutil.tz
import email.message import email.message
import email.headerregistry import email.headerregistry
...@@ -12,13 +13,14 @@ import re ...@@ -12,13 +13,14 @@ import re
import secrets import secrets
import subprocess import subprocess
import sys import sys
from typing import Any, Optional, NoReturn from typing import Any, Optional, NoReturn, Tuple
import textwrap import textwrap
import urllib.parse import urllib.parse
import mo import mo
import mo.db as db import mo.db as db
import mo.config as config import mo.config as config
from mo.util_format import format_decimal
# Uživatel, který se uvádí jako pachatel v databázovém logu # Uživatel, který se uvádí jako pachatel v databázovém logu
current_log_user: Optional[db.User] = None current_log_user: Optional[db.User] = None
...@@ -77,6 +79,10 @@ def send_user_email(user: db.User, subject: str, body: str) -> bool: ...@@ -77,6 +79,10 @@ def send_user_email(user: db.User, subject: str, body: str) -> bool:
addr_spec=user.email, 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['Subject'] = 'OSMO – ' + subject
msg['Date'] = datetime.datetime.now() msg['Date'] = datetime.datetime.now()
...@@ -209,3 +215,35 @@ def normalize_grade(grade: str) -> int: ...@@ -209,3 +215,35 @@ def normalize_grade(grade: str) -> int:
return -1 return -1
except ValueError: except ValueError:
return -1 return -1
def parse_points(
raw_points: str, for_task: Optional[db.Task] = None, for_round: Optional[db.Round] = None,
) -> Tuple[Optional[decimal.Decimal], Optional[str]]:
"""Naparsuje a zkontroluje body. Vrátí body (jako decimal.Decimal nebo None
při prázdných bodech) a případný error (None pokud nenastal, jinak text chyby)."""
if raw_points == "":
return None, None
try:
points = decimal.Decimal(raw_points.replace(',', '.'))
except decimal.InvalidOperation:
return 0, f"Hodnota '{raw_points}' není číslo"
return points, check_points(points, for_task, for_round)
def check_points(points: decimal.Decimal, for_task: Optional[db.Task] = None, for_round: Optional[db.Round] = None) -> Optional[str]:
"""Zkontroluje body. Pokud je vše ok, tak vrátí None, jinak vrátí text chyby."""
if points < 0:
return f'Nelze zadat záporné body (zadáno {format_decimal(points)})'
if for_task and for_task.max_points is not None and points > for_task.max_points:
return f'Maximální počet bodů za úlohu je {format_decimal(for_task.max_points)}, nelze zadat více (zadáno {format_decimal(points)})'
if for_round and (points % for_round.master.points_step) != 0:
points_step = for_round.master.points_step
if points_step == 1:
return f'Podle nastavení kola lze zadat pouze celé body (hodnota {points} je neplatná)'
elif points_step == 0.5:
return f'Podle nastavení kola nelze zadat body s libovolnými desetinami, pouze půlbody (hodnota {points} je neplatná)'
else:
return f'Podle nastavení kola zadat body jen s krokem {points_step} (hodnota {points} je neplatná)'
return None
# Utils that do not depend on any other in mo (to avoid circular dependency) # Utils that do not depend on any other in mo (to avoid circular dependency)
from datetime import datetime from datetime import datetime
import decimal
from typing import Optional from typing import Optional
import mo import mo
...@@ -120,3 +121,12 @@ def data_size(bytes: int) -> str: ...@@ -120,3 +121,12 @@ def data_size(bytes: int) -> str:
return f'{bytes/(1<<20):.1f} MiB' return f'{bytes/(1<<20):.1f} MiB'
else: else:
return f'{bytes/(1<<10):.1f} KiB' return f'{bytes/(1<<10):.1f} KiB'
def format_decimal(points: Optional[decimal.Decimal]) -> Optional[str]:
if points is None:
return None
elif points % 1 == 0:
return str(int(points))
else:
return str(points)
...@@ -24,9 +24,11 @@ app.jinja_env.trim_blocks = True ...@@ -24,9 +24,11 @@ app.jinja_env.trim_blocks = True
app.jinja_env.filters.update(timeformat=util_format.timeformat) app.jinja_env.filters.update(timeformat=util_format.timeformat)
app.jinja_env.filters.update(inflected=util_format.inflect_number) app.jinja_env.filters.update(inflected=util_format.inflect_number)
app.jinja_env.filters.update(inflected_by=util_format.inflect_by_number)
app.jinja_env.filters.update(timedelta=util_format.timedelta) app.jinja_env.filters.update(timedelta=util_format.timedelta)
app.jinja_env.filters.update(time_and_timedelta=util_format.time_and_timedelta) app.jinja_env.filters.update(time_and_timedelta=util_format.time_and_timedelta)
app.jinja_env.filters.update(data_size=util_format.data_size) app.jinja_env.filters.update(data_size=util_format.data_size)
app.jinja_env.filters.update(decimal=util_format.format_decimal)
# Exporty proměnných # Exporty proměnných
......
...@@ -24,10 +24,9 @@ import mo.util ...@@ -24,10 +24,9 @@ import mo.util
from mo.util_format import inflect_number, inflect_by_number from mo.util_format import inflect_number, inflect_by_number
from mo.web import app from mo.web import app
import mo.web.util import mo.web.util
from mo.web.util import PagerForm from mo.web.util import MODecimalField, PagerForm
from mo.web.table import CellCheckbox, Table, Row, Column, cell_pion_link, cell_place_link, cell_email_link from mo.web.table import CellCheckbox, Table, Row, Column, cell_pion_link, cell_place_link, cell_email_link
import wtforms.validators as validators import wtforms.validators as validators
from wtforms.fields.html5 import IntegerField
from wtforms.widgets.html5 import NumberInput from wtforms.widgets.html5 import NumberInput
...@@ -89,7 +88,8 @@ class ParticipantsFilterForm(PagerForm): ...@@ -89,7 +88,8 @@ class ParticipantsFilterForm(PagerForm):
class ParticipantsActionForm(FlaskForm): class ParticipantsActionForm(FlaskForm):
action_on = wtforms.RadioField( action_on = wtforms.RadioField(
"Provést akci na", validators=[validators.DataRequired()], "Provést akci na", validators=[validators.DataRequired()],
choices=[('all', 'všech vyfiltrovaných účastnících'), ('checked', 'označených účastnících')] choices=[('all', 'všech vyfiltrovaných účastnících'), ('checked', 'zaškrtnutých účastnících')],
default='checked',
# checkboxes are handled not through FlaskForm, see below # checkboxes are handled not through FlaskForm, see below
) )
...@@ -129,16 +129,16 @@ class ParticipantsActionForm(FlaskForm): ...@@ -129,16 +129,16 @@ class ParticipantsActionForm(FlaskForm):
elif self.set_contest.data: elif self.set_contest.data:
contest_place = db.get_place_by_code(self.contest_place.data) contest_place = db.get_place_by_code(self.contest_place.data)
if not contest_place: if not contest_place:
flash("Nepovedlo se najít zadanou soutěžní oblast", 'danger') flash("Nepovedlo se najít "+round.get_level().name_accusative("zadaný", "zadanou", "zadané"), 'danger')
return False return False
# Contest hledáme vždy v master kole, abychom náhodou nepřesunuli účastníky do soutěže v podkole # Contest hledáme vždy v master kole, abychom náhodou nepřesunuli účastníky do soutěže v podkole
contest = sess.query(db.Contest).filter_by(round_id=round.master_round_id, place_id=contest_place.place_id).one_or_none() contest = sess.query(db.Contest).filter_by(round_id=round.master_round_id, place_id=contest_place.place_id).one_or_none()
if not contest: if not contest:
flash(f"Nepovedlo se najít soutěž v kole {round.round_code_short()} v oblasti {contest_place.name}", 'danger') flash(f"Nepovedlo se najít soutěž v kole {round.round_code_short()} {contest_place.name_locative()}", 'danger')
return False return False
rr = g.gatekeeper.rights_for_contest(contest) rr = g.gatekeeper.rights_for_contest(contest)
if not rr.have_right(Right.manage_contest): if not rr.have_right(Right.manage_contest):
flash(f"Nemáte právo ke správě soutěže v kole {round.round_code_short()} v oblasti {contest_place.name}, nelze do ní přesunout účastníky", 'danger') flash(f"Nemáte právo ke správě soutěže v kole {round.round_code_short()} {contest_place.name_locative()}, nelze do ní přesunout účastníky", 'danger')
return False return False
elif self.remove_participation.data: elif self.remove_participation.data:
pass pass
...@@ -161,7 +161,7 @@ class ParticipantsActionForm(FlaskForm): ...@@ -161,7 +161,7 @@ class ParticipantsActionForm(FlaskForm):
rr = g.gatekeeper.rights_for_contest(pion.contest) rr = g.gatekeeper.rights_for_contest(pion.contest)
if not rr.have_right(Right.manage_contest): if not rr.have_right(Right.manage_contest):
flash( flash(
f"Nemáte právo ke správě soutěže v kole {round.round_code_short()} v oblasti {pion.contest.place.name} " f"Nemáte právo ke správě soutěže v kole {round.round_code_short()} {pion.contest.place.name_locative()} "
+ f"(účastník {u.full_name()}). Žádná akce nebyla provedena.", 'danger' + f"(účastník {u.full_name()}). Žádná akce nebyla provedena.", 'danger'
) )
return False return False
...@@ -223,7 +223,7 @@ class ParticipantsActionForm(FlaskForm): ...@@ -223,7 +223,7 @@ class ParticipantsActionForm(FlaskForm):
elif self.set_contest.data: elif self.set_contest.data:
flash( flash(
inflect_number(count, 'účastník přesunut', 'účastníci přesunuti', 'účastníků přesunuto') inflect_number(count, 'účastník přesunut', 'účastníci přesunuti', 'účastníků přesunuto')
+ f' do soutěže v oblasti {contest_place.name}', + f' do soutěže {contest_place.name_locative()}',
'success' 'success'
) )
elif self.remove_participation.data: elif self.remove_participation.data:
...@@ -471,7 +471,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None): ...@@ -471,7 +471,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None):
else: else:
# (count, query) = filter.apply_limits(query, pagesize=50) # (count, query) = filter.apply_limits(query, pagesize=50)
count = db.get_count(query) count = db.get_count(query)
table = make_contestant_table(query, add_checkbox=can_edit) table = make_contestant_table(query, master_contest.round, add_checkbox=can_edit)
return render_template( return render_template(
'org_contest_list.html', 'org_contest_list.html',
...@@ -480,7 +480,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None): ...@@ -480,7 +480,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None):
filter=filter, count=count, action_form=action_form, filter=filter, count=count, action_form=action_form,
) )
else: else:
table = make_contestant_table(query) table = make_contestant_table(query, master_contest.round, is_export=True)
return table.send_as(format) return table.send_as(format)
...@@ -535,7 +535,7 @@ def get_contestants_query( ...@@ -535,7 +535,7 @@ def get_contestants_query(
return query return query
def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_column: bool = False): def make_contestant_table(query: Query, round: db.Round, add_checkbox: bool = False, add_contest_column: bool = False, is_export: bool = False):
ctants = query.all() ctants = query.all()
rows: List[Row] = [] rows: List[Row] = []
...@@ -550,6 +550,7 @@ def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_ ...@@ -550,6 +550,7 @@ def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_
rows.append(Row( rows.append(Row(
keys={ keys={
'sort_key': u.sort_key(), 'sort_key': u.sort_key(),
'user_id': u.user_id,
'first_name': cell_pion_link(u, pion.contest_id, u.first_name), 'first_name': cell_pion_link(u, pion.contest_id, u.first_name),
'last_name': cell_pion_link(u, pion.contest_id, u.last_name), 'last_name': cell_pion_link(u, pion.contest_id, u.last_name),
'email': cell_email_link(u), 'email': cell_email_link(u),
...@@ -567,11 +568,13 @@ def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_ ...@@ -567,11 +568,13 @@ def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_
rows.sort(key=lambda r: r.keys['sort_key']) rows.sort(key=lambda r: r.keys['sort_key'])
cols: Sequence[Column] = contest_list_columns cols: List[Column] = list(contest_list_columns)
if add_checkbox: if add_checkbox:
cols = [Column(key='checkbox', name=' ', title=' ')] + list(cols) cols = [Column(key='checkbox', name=' ', title=' ')] + cols
if add_contest_column: if add_contest_column:
cols = list(cols) + [Column(key='region_code', name='kod_oblasti', title='Oblast')] cols.append(Column(key='region_code', name='kod_oblasti', title=round.get_level().name.title()))
if is_export:
cols.append(Column(key='user_id', name='user_id'))
return Table( return Table(
columns=cols, columns=cols,
...@@ -680,8 +683,7 @@ def get_solution_context(contest_id: int, user_id: Optional[int], task_id: Optio ...@@ -680,8 +683,7 @@ def get_solution_context(contest_id: int, user_id: Optional[int], task_id: Optio
class SubmitForm(FlaskForm): class SubmitForm(FlaskForm):
note = wtforms.TextAreaField("Poznámka pro účastníka", description="Viditelná účastníkovi po uzavření kola", render_kw={'autofocus': True}) note = wtforms.TextAreaField("Poznámka pro účastníka", description="Viditelná účastníkovi po uzavření kola", render_kw={'autofocus': True})
org_note = wtforms.TextAreaField("Interní poznámka", description="Viditelná jen organizátorům") org_note = wtforms.TextAreaField("Interní poznámka", description="Viditelná jen organizátorům")
# Validátory k points budou přidány podle počtu maximálních bodů úlohy v org_submit_list points = MODecimalField('Body', description="Účastník po uzavření kola uvidí jen naposledy zadané body", validators=[validators.Optional()])
points = IntegerField('Body', description="Účastník po uzavření kola uvidí jen naposledy zadané body")
submit = wtforms.SubmitField('Uložit') submit = wtforms.SubmitField('Uložit')
file = flask_wtf.file.FileField("Soubor") file = flask_wtf.file.FileField("Soubor")
...@@ -702,6 +704,7 @@ class SetFinalForm(FlaskForm): ...@@ -702,6 +704,7 @@ class SetFinalForm(FlaskForm):
def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Optional[int] = None): def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Optional[int] = None):
sc = get_solution_context(contest_id, user_id, task_id, site_id) sc = get_solution_context(contest_id, user_id, task_id, site_id)
assert sc.user is not None assert sc.user is not None
assert sc.task is not None
sess = db.get_session() sess = db.get_session()
self_url = url_for('org_submit_list', contest_id=contest_id, user_id=user_id, task_id=task_id, site_id=site_id) self_url = url_for('org_submit_list', contest_id=contest_id, user_id=user_id, task_id=task_id, site_id=site_id)
...@@ -759,11 +762,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option ...@@ -759,11 +762,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
return redirect(self_url) return redirect(self_url)
form = SubmitForm(obj=sol) form = SubmitForm(obj=sol)
form.points.validators = [ form.points.widget = NumberInput(min=0, max=sc.task.max_points, step=sc.master_round.points_step) # min a max v HTML
validators.Optional(),
validators.NumberRange(min=0, max=sc.task.max_points, message="Počet bodů musí být mezi %(min)s a %(max)s")
]
form.points.widget = NumberInput(min=0, max=sc.task.max_points) # min a max v HTML
if form.validate_on_submit(): if form.validate_on_submit():
if sol and form.delete.data: if sol and form.delete.data:
if sol.final_submit or sol.final_feedback: if sol.final_submit or sol.final_feedback:
...@@ -788,6 +787,11 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option ...@@ -788,6 +787,11 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
if (form.submit_sol.data or form.submit_fb.data) and form.file.data is None: if (form.submit_sol.data or form.submit_fb.data) and form.file.data is None:
flash('Schází soubor k nahrání, žádné změny nebyly uloženy', 'danger') flash('Schází soubor k nahrání, žádné změny nebyly uloženy', 'danger')
return redirect(self_url) return redirect(self_url)
if points:
error = mo.util.check_points(points, for_task=sc.task, for_round=sc.round)
if error:
flash(error, 'danger')
return redirect(self_url)
if not sol and (sc.allow_edit_points or sc.allow_upload_solutions or sc.allow_upload_feedback): if not sol and (sc.allow_edit_points or sc.allow_upload_solutions or sc.allow_upload_feedback):
flash('Řešení založeno', 'success') flash('Řešení založeno', 'success')
...@@ -969,6 +973,7 @@ class TaskCreateForm(FlaskForm): ...@@ -969,6 +973,7 @@ class TaskCreateForm(FlaskForm):
@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create") @app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create")
def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = None): def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = None):
sc = get_solution_context(contest_id, None, task_id, site_id) sc = get_solution_context(contest_id, None, task_id, site_id)
assert sc.task is not None
action_create = request.endpoint == "org_contest_task_create" action_create = request.endpoint == "org_contest_task_create"
action_points = request.endpoint == "org_contest_task_points" action_points = request.endpoint == "org_contest_task_points"
...@@ -1026,17 +1031,13 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non ...@@ -1026,17 +1031,13 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
for _, sol in rows: for _, sol in rows:
if sol is None: if sol is None:
continue continue
points = request.form.get(f"points_{sol.user_id}", type=int)
if points and points < 0: points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=sc.task, for_round=sc.round)
flash('Nelze zadat záporné body', 'danger') if error:
ok = False flash(f'{sol.user.first_name} {sol.user.last_name}: {error}', 'danger')
break
elif points and sc.task.max_points is not None and points > sc.task.max_points:
flash(f'Maximální počet bodů za úlohu je {sc.task.max_points}, nelze zadat více', 'danger')
ok = False ok = False
break
if points != sol.points: if ok and points != sol.points:
# Save points # Save points
sol.points = points sol.points = points
sess.add(db.PointsHistory( sess.add(db.PointsHistory(
...@@ -1434,7 +1435,7 @@ def org_contest_user(contest_id: int, user_id: int): ...@@ -1434,7 +1435,7 @@ def org_contest_user(contest_id: int, user_id: int):
class AdvanceForm(FlaskForm): class AdvanceForm(FlaskForm):
boundary = IntegerField( boundary = MODecimalField(
'Bodová hranice', render_kw={'autofocus': True}, 'Bodová hranice', render_kw={'autofocus': True},
description="Postoupí všichni účastníci, kteří v minulém kole získali aspoň tolik bodů.", description="Postoupí všichni účastníci, kteří v minulém kole získali aspoň tolik bodů.",
validators=[validators.InputRequired()] validators=[validators.InputRequired()]
......
import decimal
from flask import render_template, g, redirect, url_for, flash, request from flask import render_template, g, redirect, url_for, flash, request
import locale import locale
import flask_wtf.file
from flask_wtf.form import FlaskForm from flask_wtf.form import FlaskForm
import bleach import bleach
from bleach.sanitizer import ALLOWED_TAGS from bleach.sanitizer import ALLOWED_TAGS
import markdown import markdown
import os
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.sql.functions import coalesce from sqlalchemy.sql.functions import coalesce
...@@ -11,7 +14,7 @@ from typing import Optional, Tuple ...@@ -11,7 +14,7 @@ from typing import Optional, Tuple
import werkzeug.exceptions import werkzeug.exceptions
import wtforms import wtforms
from wtforms import validators, ValidationError from wtforms import validators, ValidationError
from wtforms.fields.html5 import IntegerField from wtforms.widgets.html5 import NumberInput
import mo import mo
import mo.db as db import mo.db as db
...@@ -19,6 +22,7 @@ import mo.imports ...@@ -19,6 +22,7 @@ import mo.imports
from mo.rights import Right, RoundRights from mo.rights import Right, RoundRights
import mo.util import mo.util
from mo.web import app from mo.web import app
from mo.web.util import MODecimalField
from mo.web.org_contest import ParticipantsActionForm, ParticipantsFilterForm, get_contestant_emails, get_contestants_query, make_contestant_table, \ from mo.web.org_contest import ParticipantsActionForm, ParticipantsFilterForm, get_contestant_emails, get_contestants_query, make_contestant_table, \
generic_import, generic_batch_download, generic_batch_upload, generic_batch_points generic_import, generic_batch_download, generic_batch_upload, generic_batch_points
...@@ -51,7 +55,7 @@ def org_rounds(): ...@@ -51,7 +55,7 @@ def org_rounds():
sess = db.get_session() sess = db.get_session()
rounds = sess.query(db.Round).filter_by(year=mo.current_year).order_by(db.Round.year, db.Round.category, db.Round.seq, db.Round.part) rounds = sess.query(db.Round).filter_by(year=mo.current_year).order_by(db.Round.year, db.Round.category, db.Round.seq, db.Round.part)
return render_template('org_rounds.html', rounds=rounds, level_names=mo.db.place_level_names) return render_template('org_rounds.html', rounds=rounds)
class TaskDeleteForm(FlaskForm): class TaskDeleteForm(FlaskForm):
...@@ -106,7 +110,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool: ...@@ -106,7 +110,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
return False return False
if place.level != round.level: if place.level != round.level:
flash(f'{place.type_name().title()} {place.name} není {db.place_level_names[round.level]}', 'danger') flash(f'{place.type_name().title()} {place.name} není {round.get_level().name}', 'danger')
return False return False
sess = db.get_session() sess = db.get_session()
...@@ -124,7 +128,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool: ...@@ -124,7 +128,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
contest = db.Contest(round=round.master, place=place, state=state) contest = db.Contest(round=round.master, place=place, state=state)
rr = g.gatekeeper.rights_for_contest(contest) rr = g.gatekeeper.rights_for_contest(contest)
if not rr.have_right(Right.add_contest): if not rr.have_right(Right.add_contest):
flash('Vaše role nedovoluje vytvořit soutěž v oblasti {place.type_name()} {place.name}', 'danger') flash(f'Vaše role nedovoluje vytvořit soutěž {place.name_locative()}', 'danger')
return False return False
sess.add(contest) sess.add(contest)
...@@ -160,7 +164,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool: ...@@ -160,7 +164,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
app.logger.info(f"Soutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}") app.logger.info(f"Soutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}")
sess.commit() sess.commit()
flash(f'Soutěž v oblasti {place.type_name()} {place.name} založena', 'success') flash(f'Založena soutěž {place.name_locative()}', 'success')
return True return True
...@@ -209,6 +213,7 @@ def org_round(id: int): ...@@ -209,6 +213,7 @@ def org_round(id: int):
return redirect(url_for('org_round', id=id)) return redirect(url_for('org_round', id=id))
form_add_contest = AddContestForm() form_add_contest = AddContestForm()
form_add_contest.place_code.label.text = "Nová soutěž " + round.get_level().in_name()
if add_contest(round, form_add_contest): if add_contest(round, form_add_contest):
return redirect(url_for('org_round', id=id)) return redirect(url_for('org_round', id=id))
...@@ -222,13 +227,13 @@ def org_round(id: int): ...@@ -222,13 +227,13 @@ def org_round(id: int):
contests_counts=contests_counts, contests_counts=contests_counts,
tasks=tasks, form_delete_task=form_delete_task, tasks=tasks, form_delete_task=form_delete_task,
form_add_contest=form_add_contest, form_add_contest=form_add_contest,
level_names=mo.db.place_level_names,
can_manage_round=can_manage_round, can_manage_round=can_manage_round,
can_manage_contestants=can_manage_contestants, can_manage_contestants=can_manage_contestants,
can_handle_submits=rr.have_right(Right.view_submits), can_handle_submits=rr.have_right(Right.view_submits),
can_upload=rr.offer_upload_feedback(), can_upload=rr.offer_upload_feedback(),
can_view_statement=rr.can_view_statement(), can_view_statement=rr.can_view_statement(),
can_add_contest=g.gatekeeper.rights_generic().have_right(Right.add_contest), can_add_contest=g.gatekeeper.rights_generic().have_right(Right.add_contest),
statement_exists=mo.web.util.task_statement_exists(round),
) )
...@@ -238,19 +243,23 @@ class TaskEditForm(FlaskForm): ...@@ -238,19 +243,23 @@ class TaskEditForm(FlaskForm):
validators.Regexp(r'^[A-Za-z0-9-]+$', message="Kód úlohy smí obsahovat jen nediakritická písmena, čísla a znak -"), validators.Regexp(r'^[A-Za-z0-9-]+$', message="Kód úlohy smí obsahovat jen nediakritická písmena, čísla a znak -"),
], render_kw={'autofocus': True}) ], render_kw={'autofocus': True})
name = wtforms.StringField('Název úlohy') name = wtforms.StringField('Název úlohy')
max_points = IntegerField( max_points = MODecimalField(
'Maximum bodů', validators=[validators.Optional()], 'Maximum bodů', validators=[validators.Optional(), validators.NumberRange(min=0)],
description="Při nastavení maxima nelze udělit více bodů, pro zrušení uložte prázdnou hodnotu", description="Při nastavení maxima nelze udělit více bodů, pro zrušení uložte prázdnou hodnotu",
) )
submit = wtforms.SubmitField('Uložit') submit = wtforms.SubmitField('Uložit')
def __init__(self, points_step: decimal.Decimal, *args, **kwargs):
super().__init__(*args, **kwargs)
self.max_points.widget = NumberInput(min=0, step=points_step)
@app.route('/org/contest/r/<int:id>/task/new', methods=('GET', 'POST')) @app.route('/org/contest/r/<int:id>/task/new', methods=('GET', 'POST'))
def org_round_task_new(id: int): def org_round_task_new(id: int):
sess = db.get_session() sess = db.get_session()
round, _, _ = get_round_rr(id, Right.manage_round, True) round, master_round, _ = get_round_rr(id, Right.manage_round, True)
form = TaskEditForm() form = TaskEditForm(master_round.points_step)
if form.validate_on_submit(): if form.validate_on_submit():
task = db.Task() task = db.Task()
task.round = round task.round = round
...@@ -280,14 +289,14 @@ def org_round_task_new(id: int): ...@@ -280,14 +289,14 @@ def org_round_task_new(id: int):
@app.route('/org/contest/r/<int:id>/task/<int:task_id>/edit', methods=('GET', 'POST')) @app.route('/org/contest/r/<int:id>/task/<int:task_id>/edit', methods=('GET', 'POST'))
def org_round_task_edit(id: int, task_id: int): def org_round_task_edit(id: int, task_id: int):
sess = db.get_session() sess = db.get_session()
round, _, _ = get_round_rr(id, Right.manage_round, True) round, master_round, _ = get_round_rr(id, Right.manage_round, True)
task = sess.query(db.Task).get(task_id) task = sess.query(db.Task).get(task_id)
# FIXME: Check contest! # FIXME: Check contest!
if not task: if not task:
raise werkzeug.exceptions.NotFound() raise werkzeug.exceptions.NotFound()
form = TaskEditForm(obj=task) form = TaskEditForm(master_round.points_step, obj=task)
if form.validate_on_submit(): if form.validate_on_submit():
if sess.query(db.Task).filter( if sess.query(db.Task).filter(
db.Task.task_id != task_id, db.Task.round_id == id, db.Task.code == form.code.data db.Task.task_id != task_id, db.Task.round_id == id, db.Task.code == form.code.data
...@@ -374,7 +383,7 @@ def org_round_list(id: int): ...@@ -374,7 +383,7 @@ def org_round_list(id: int):
else: else:
(count, query) = filter.apply_limits(query, pagesize=50) (count, query) = filter.apply_limits(query, pagesize=50)
# count = db.get_count(query) # count = db.get_count(query)
table = make_contestant_table(query, add_contest_column=True, add_checkbox=True) table = make_contestant_table(query, round, add_contest_column=True, add_checkbox=True)
return render_template( return render_template(
'org_round_list.html', 'org_round_list.html',
...@@ -383,7 +392,7 @@ def org_round_list(id: int): ...@@ -383,7 +392,7 @@ def org_round_list(id: int):
filter=filter, count=count, action_form=action_form, filter=filter, count=count, action_form=action_form,
) )
else: else:
table = make_contestant_table(query) table = make_contestant_table(query, round, is_export=True)
return table.send_as(format) return table.send_as(format)
...@@ -410,6 +419,8 @@ class MODateTimeField(wtforms.DateTimeField): ...@@ -410,6 +419,8 @@ class MODateTimeField(wtforms.DateTimeField):
class RoundEditForm(FlaskForm): class RoundEditForm(FlaskForm):
_for_round: Optional[db.Round] = None
name = wtforms.StringField("Název", render_kw={'autofocus': True}) name = wtforms.StringField("Název", render_kw={'autofocus': True})
state = wtforms.SelectField( state = wtforms.SelectField(
"Stav kola", choices=db.RoundState.choices(), coerce=db.RoundState.coerce, "Stav kola", choices=db.RoundState.choices(), coerce=db.RoundState.coerce,
...@@ -417,26 +428,34 @@ class RoundEditForm(FlaskForm): ...@@ -417,26 +428,34 @@ class RoundEditForm(FlaskForm):
) )
# Only the desktop Firefox does not support datetime-local field nowadays, # Only the desktop Firefox does not support datetime-local field nowadays,
# other browsers does provide date and time picker UI :( # other browsers does provide date and time picker UI :(
tasks_file = wtforms.StringField("Soubor se zadáním", description="Cesta k ručně uploadovanému souboru", filters=[lambda x: x or None])
ct_tasks_start = MODateTimeField("Čas zveřejnění úloh pro účastníky", validators=[validators.Optional()]) ct_tasks_start = MODateTimeField("Čas zveřejnění úloh pro účastníky", validators=[validators.Optional()])
pr_tasks_start = MODateTimeField("Čas zveřejnění úloh pro dozor", validators=[validators.Optional()]) pr_tasks_start = MODateTimeField("Čas zveřejnění úloh pro dozor", validators=[validators.Optional()])
ct_submit_end = MODateTimeField("Konec odevzdávání pro účastníky", validators=[validators.Optional()]) ct_submit_end = MODateTimeField("Konec odevzdávání pro účastníky", validators=[validators.Optional()])
pr_submit_end = MODateTimeField("Konec odevzdávání pro dozor", validators=[validators.Optional()]) pr_submit_end = MODateTimeField("Konec odevzdávání pro dozor", validators=[validators.Optional()])
score_mode = wtforms.SelectField("Výsledková listina", choices=db.RoundScoreMode.choices(), coerce=db.RoundScoreMode.coerce) score_mode = wtforms.SelectField("Výsledková listina", choices=db.RoundScoreMode.choices(), coerce=db.RoundScoreMode.coerce)
score_winner_limit = IntegerField( score_winner_limit = MODecimalField(
"Hranice bodů pro vítěze", validators=[validators.Optional()], "Hranice bodů pro vítěze", validators=[validators.Optional(), validators.NumberRange(min=0)],
description="Řešitelé s alespoň tolika body budou označeni za vítěze, prázdná hodnota = žádné neoznačovat", description="Řešitelé s alespoň tolika body budou označeni za vítěze, prázdná hodnota = žádné neoznačovat",
) )
score_successful_limit = IntegerField( score_successful_limit = MODecimalField(
"Hranice bodů pro úspěšné řešitele", validators=[validators.Optional()], "Hranice bodů pro úspěšné řešitele", validators=[validators.Optional(), validators.NumberRange(min=0)],
description="Řešitelé s alespoň tolika body budou označeni za úspěšné řešitele, prázdná hodnota = žádné neoznačovat", description="Řešitelé s alespoň tolika body budou označeni za úspěšné řešitele, prázdná hodnota = žádné neoznačovat",
) )
points_step = wtforms.SelectField(
"Přesnost bodování", choices=db.round_points_step_choices, coerce=decimal.Decimal,
description="Ovlivňuje možnost zadávání nových bodů, již uložené body nezmění"
)
has_messages = wtforms.BooleanField("Zprávičky pro účastníky (aktivuje možnost vytvářet novinky zobrazované účastníkům)") has_messages = wtforms.BooleanField("Zprávičky pro účastníky (aktivuje možnost vytvářet novinky zobrazované účastníkům)")
submit = wtforms.SubmitField('Uložit') submit = wtforms.SubmitField('Uložit')
def validate_state(self, field): def validate_state(self, field):
if field.data != db.RoundState.preparing and self.ct_tasks_start.data is None: if field.data != db.RoundState.preparing:
if self.ct_tasks_start.data is None:
raise ValidationError('Není-li nastaven času začátku soutěže, stav musí být "připravuje se"') raise ValidationError('Není-li nastaven času začátku soutěže, stav musí být "připravuje se"')
if self._for_round is not None:
num_tasks = db.get_session().query(db.Task).filter_by(round=self._for_round).count()
if num_tasks == 0:
raise ValidationError('Nejsou-li definovány žádné úlohy, stav musí být "připravuje se"')
@app.route('/org/contest/r/<int:id>/edit', methods=('GET', 'POST')) @app.route('/org/contest/r/<int:id>/edit', methods=('GET', 'POST'))
...@@ -445,11 +464,13 @@ def org_round_edit(id: int): ...@@ -445,11 +464,13 @@ def org_round_edit(id: int):
round, _, rr = get_round_rr(id, Right.manage_round, True) round, _, rr = get_round_rr(id, Right.manage_round, True)
form = RoundEditForm(obj=round) form = RoundEditForm(obj=round)
form._for_round = round
if round.is_subround(): if round.is_subround():
# podkolo nemá nastavení výsledkové listiny # podkolo nemá nastavení výsledkové listiny
del form.score_mode del form.score_mode
del form.score_winner_limit del form.score_winner_limit
del form.score_successful_limit del form.score_successful_limit
del form.points_step
if form.validate_on_submit(): if form.validate_on_submit():
form.populate_obj(round) form.populate_obj(round)
...@@ -500,6 +521,61 @@ def org_task_statement(id: int): ...@@ -500,6 +521,61 @@ def org_task_statement(id: int):
return mo.web.util.send_task_statement(round) return mo.web.util.send_task_statement(round)
class StatementEditForm(FlaskForm):
file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
upload = wtforms.SubmitField('Nahrát')
delete = wtforms.SubmitField('Smazat')
@app.route('/org/contest/r/<int:id>/task-statement/edit', methods=('GET', 'POST'))
def org_edit_statement(id: int):
sess = db.get_session()
round, _, rr = get_round_rr(id, Right.manage_round, True)
def log_changes():
if sess.is_modified(round):
changes = db.get_object_changes(round)
app.logger.info(f"Kolo #{id} změněno, změny: {changes}")
mo.util.log(
type=db.LogType.round,
what=id,
details={'action': 'edit', 'changes': changes},
)
form = StatementEditForm()
if form.validate_on_submit():
if form.upload.data:
if form.file.data is not None:
file = form.file.data.stream
secure_category = werkzeug.utils.secure_filename(round.category)
stmt_dir = f'{round.year}-{secure_category}-{round.seq}'
full_dir = os.path.join(mo.util.data_dir('statements'), stmt_dir)
os.makedirs(full_dir, exist_ok=True)
full_name = mo.util.link_to_dir(file.name, full_dir, suffix='.pdf')
file_name = os.path.join(stmt_dir, os.path.basename(full_name))
app.logger.info(f'Nahráno zadání: {file_name}')
round.tasks_file = file_name
log_changes()
sess.commit()
flash('Zadání nahráno', 'success')
return redirect(url_for('org_round', id=id))
else:
flash('Vyberte si prosím soubor', 'danger')
if form.delete.data:
round.tasks_file = None
log_changes()
sess.commit()
flash('Zadání smazáno', 'success')
return redirect(url_for('org_round', id=id))
return render_template(
'org_edit_statement.html',
round=round,
form=form,
)
class MessageAddForm(FlaskForm): class MessageAddForm(FlaskForm):
title = wtforms.StringField('Nadpis', validators=[validators.Required()]) title = wtforms.StringField('Nadpis', validators=[validators.Required()])
markdown = wtforms.TextAreaField( markdown = wtforms.TextAreaField(
......
...@@ -10,6 +10,7 @@ from mo.rights import Right ...@@ -10,6 +10,7 @@ from mo.rights import Right
from mo.score import Score from mo.score import Score
from mo.web import app from mo.web import app
from mo.web.table import Cell, CellLink, Column, Row, Table, cell_pion_link from mo.web.table import Cell, CellLink, Column, Row, Table, cell_pion_link
from mo.util_format import format_decimal
class OrderCell(Cell): class OrderCell(Cell):
...@@ -52,15 +53,15 @@ class SolPointsCell(Cell): ...@@ -52,15 +53,15 @@ class SolPointsCell(Cell):
return '' return ''
elif self.sol.points is None: elif self.sol.points is None:
return '?' return '?'
return str(self.sol.points) return format_decimal(self.sol.points)
def to_html(self) -> str: def to_html(self) -> str:
if not self.sol: if not self.sol:
return '<td>–' return '<td>–'
elif self.sol.points is None: elif self.sol.points is None:
points = '<span class="unknown">?</span></a>' points = '<span class="unknown">?</span>'
else: else:
points = str(self.sol.points) points = format_decimal(self.sol.points)
if self.sol.final_feedback_obj: if self.sol.final_feedback_obj:
url = mo.web.util.org_paper_link(self.contest_id, None, self.user, self.sol.final_feedback_obj) url = mo.web.util.org_paper_link(self.contest_id, None, self.user, self.sol.final_feedback_obj)
...@@ -127,7 +128,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): ...@@ -127,7 +128,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
if is_export: if is_export:
columns.append(Column(key='email', name='email')) columns.append(Column(key='email', name='email'))
if not contest_id: if not contest_id:
columns.append(Column(key='contest', name='oblast', title='Soutěžní oblast')) columns.append(Column(key='contest', name='oblast', title=round.get_level().name.title()))
if is_export: if is_export:
columns.append(Column(key='pion_place', name='soutezni_misto')) columns.append(Column(key='pion_place', name='soutezni_misto'))
columns.append(Column(key='school', name='skola', title='Škola')) columns.append(Column(key='school', name='skola', title='Škola'))
...@@ -180,7 +181,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): ...@@ -180,7 +181,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
'pion_place': pion.place.name, 'pion_place': pion.place.name,
'school': CellLink(school.name or "?", url_for('org_place', id=school.place_id)), 'school': CellLink(school.name or "?", url_for('org_place', id=school.place_id)),
'grade': pant.grade, 'grade': pant.grade,
'total_points': result.get_total_points(), 'total_points': format_decimal(result.get_total_points()),
'birth_year': pant.birth_year, 'birth_year': pant.birth_year,
'order_key': result._order_key, 'order_key': result._order_key,
}) })
......
...@@ -390,6 +390,7 @@ def org_user_edit(id: int): ...@@ -390,6 +390,7 @@ def org_user_edit(id: int):
if form.validate_on_submit(): if form.validate_on_submit():
check = True check = True
if hasattr(form, 'email') and form.email is not None:
other_user = mo.users.user_by_email(form.email.data) other_user = mo.users.user_by_email(form.email.data)
if other_user is not None and other_user != user: if other_user is not None and other_user != user:
flash('Zadaný e-mail nelze použít, existuje jiný účet s tímto e-mailem', 'danger') flash('Zadaný e-mail nelze použít, existuje jiný účet s tímto e-mailem', 'danger')
......
{% extends "base.html" %} {% extends "base.html" %}
{% set round = contest.round %} {% set round = contest.round %}
{% set state = contest.state %}
{% set ct_state = contest.ct_state() %}
{% set site_id = site.place_id if site else None %} {% set site_id = site.place_id if site else None %}
{% block title %} {% block title %}
...@@ -16,15 +18,11 @@ ...@@ -16,15 +18,11 @@
{% if site %} {% if site %}
<tr><td>Soutěžní místo<td><a href='{{ url_for('org_place', id=site.place_id) }}'>{{ site.name }}</a> <tr><td>Soutěžní místo<td><a href='{{ url_for('org_place', id=site.place_id) }}'>{{ site.name }}</a>
{% endif %} {% endif %}
{% with state=contest.state %}
<tr><td>Stav<td><span class='rstate-{{state.name}}'>{{ state.friendly_name() }}</span> <tr><td>Stav<td><span class='rstate-{{state.name}}'>{{ state.friendly_name() }}</span>
{% if round.state != RoundState.delegate %} {% if round.state != RoundState.delegate %}
(určeno nastavením kola) (určeno nastavením kola)
{% endif %} {% endif %}
{% endwith %} <tr><td>Stav pro účastníky<td><span class='rstate-{{ct_state.name}}'>{{ ct_state.friendly_name() }}</span>
{% with state=contest.ct_state() %}
<tr><td>Stav pro účastníky<td><span class='rstate-{{state.name}}'>{{ state.friendly_name() }}</span>
{% endwith %}
<tr><td>Vaše role<td>{% if g.user.is_admin %}správce{% elif roles %}{{ roles|join(", ") }}{% else %}–{% endif %} <tr><td>Vaše role<td>{% if g.user.is_admin %}správce{% elif roles %}{{ roles|join(", ") }}{% else %}–{% endif %}
{% if group_contests|length > 1 %} {% if group_contests|length > 1 %}
<tr><td>Soutěže ve skupině kol:<td> <tr><td>Soutěže ve skupině kol:<td>
...@@ -48,21 +46,21 @@ ...@@ -48,21 +46,21 @@
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-primary" href='{{ url_for('org_contest_list', id=contest.contest_id, site_id=site_id) }}'>Seznam účastníků</a> <a class="btn btn-primary" href='{{ url_for('org_contest_list', id=contest.contest_id, site_id=site_id) }}'>Seznam účastníků</a>
{% if round.state != RoundState.preparing %} {% if state != RoundState.preparing %}
<a class="btn btn-primary" href='{{ url_for('org_contest_solutions', id=contest.contest_id, site_id=site_id) }}'>Odevzdaná řešení</a> <a class="btn btn-primary" href='{{ url_for('org_contest_solutions', id=contest.contest_id, site_id=site_id) }}'>Odevzdaná řešení</a>
{% endif %} {% endif %}
{% if not site %} {% if not site %}
{% if round.state in [RoundState.grading, RoundState.closed] %} {% if state in [RoundState.grading, RoundState.closed] %}
<a class="btn btn-primary" href='{{ url_for('org_score', contest_id=contest.contest_id) }}'>Výsledky</a> <a class="btn btn-primary" href='{{ url_for('org_score', contest_id=contest.contest_id) }}'>Výsledky</a>
{% endif %} {% endif %}
{% if round.state == RoundState.preparing and round.seq > 1 %} {% if state == RoundState.preparing and round.seq > 1 %}
<a class="btn btn-primary" href='{{ url_for('org_contest_advance', contest_id=contest.contest_id) }}'>Postup z minulého kola</a> <a class="btn btn-primary" href='{{ url_for('org_contest_advance', contest_id=contest.contest_id) }}'>Postup z minulého kola</a>
{% endif %} {% endif %}
{% if can_manage %} {% if can_manage %}
<a class="btn btn-default" href='{{ url_for('org_contest_import', id=contest.contest_id) }}'>Importovat data</a> <a class="btn btn-default" href='{{ url_for('org_contest_import', id=contest.contest_id) }}'>Importovat data</a>
{% endif %} {% endif %}
{% if can_manage and not site %} {% if can_manage and not site %}
<a class="btn btn-default" href='{{ url_for('org_contest_edit', id=contest.contest_id) }}'>Editovat nastavení</a> <a class="btn btn-default" href='{{ url_for('org_contest_edit', id=contest.contest_id) }}'>Nastavení</a>
{% endif %} {% endif %}
{% if g.user.is_admin %} {% if g.user.is_admin %}
<a class="btn btn-default" href="{{ log_url('contest', contest.contest_id) }}">Historie</a> <a class="btn btn-default" href="{{ log_url('contest', contest.contest_id) }}">Historie</a>
...@@ -113,7 +111,7 @@ ...@@ -113,7 +111,7 @@
<td>{{ task.code }} <td>{{ task.code }}
<td>{{ task.name }} <td>{{ task.name }}
<td>{{ task.sol_count }} <td>{{ task.sol_count }}
<td>{{ task.max_points|none_value('–') }} <td>{{ task.max_points|decimal|none_value('–') }}
<td><div class="btn-group"> <td><div class="btn-group">
<a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_task', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Odevzdaná řešení</a> <a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_task', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Odevzdaná řešení</a>
{% if not site and can_edit_points %} {% if not site and can_edit_points %}
......
...@@ -21,19 +21,21 @@ ...@@ -21,19 +21,21 @@
<table class='data'> <table class='data'>
<thead> <thead>
<tr><th>Oblast<th>Postoupilo<th>Nepostoupilo <tr><th>{{ prev_round.get_level().name|capitalize }}<th>Postoupilo<th>Nepostoupilo<th>
<tbody> <tbody>
{% for c in prev_contests %} {% for c in prev_contests %}
<tr> <tr>
<td>{{ c.place.name }} <td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ c.place.name }}</a>
<td>{{ accept_by_place_id[c.place.place_id] }} <td>{{ accept_by_place_id[c.place.place_id] }}
<td>{{ reject_by_place_id[c.place.place_id] }} <td>{{ reject_by_place_id[c.place.place_id] }}
<td><a class='btn btn-warning btn-xs' href='{{ url_for('org_score', contest_id=c.contest_id) }}'>Výsledková listina</a>
{% endfor %} {% endfor %}
<tfoot> <tfoot>
<tr> <tr>
<th>Celkem <th>Celkem
<th>{{ accept_by_place_id.values()|sum }} <th>{{ accept_by_place_id.values()|sum }}
<th>{{ reject_by_place_id.values()|sum }} <th>{{ reject_by_place_id.values()|sum }}
<th>
</tfoot> </tfoot>
</table> </table>
{% endif %} {% endif %}
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
{% set round = contest.round %} {% set round = contest.round %}
{% block title %} {% block title %}
Seznam účastníků {% if site %}soutěžního místa {{ site.name }}{% else %}oblasti {{ contest.place.name }}{% endif %} Seznam účastníků {% if site %}v soutěžním místě {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %}
{% endblock %} {% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Seznam účastníků") }} {{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Seznam účastníků") }}
......