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

Target

Select target project
  • mj/mo-submit
1 result
Select Git revision
Show changes
Commits on Source (44)
Showing with 355 additions and 120 deletions
#!/usr/bin/env python3
import argparse
from sqlalchemy import and_
import mo.db as db
import mo.users
......@@ -33,10 +34,31 @@ print(f"Slučuji UID {suid} do UID {duid}")
sess = db.get_session()
conn = sess.connection()
test_round = sess.query(db.Round).filter_by(category='T').one_or_none()
if test_round is not None:
test_submits = (sess
.query(db.Solution)
.join(db.Task)
.filter(db.Solution.user_id == suid)
.filter(db.Task.round == test_round)
.filter(db.Round.category == 'T')
.all())
if test_submits:
mo.util.die("Zdrojový účastník něco odevzdal v testovací soutěži, nutno vyřešit ručně")
test_contest = sess.query(db.Contest).filter_by(round=test_round).one_or_none()
test_contest_id = test_contest.contest_id if test_contest is not None else None
else:
test_contest = None
test_contest_id = None
sess.flush()
conn.execute(db.Log.__table__.update().where(db.Log.changed_by == suid).values(changed_by=duid))
conn.execute(db.Participant.__table__.delete().where(db.Participant.user_id == suid))
conn.execute(db.Participation.__table__.delete().where(and_(db.Participation.user_id == suid, db.Participation.contest_id == test_contest_id)))
conn.execute(db.Participation.__table__.update().where(db.Participation.user_id == suid).values(user_id=duid))
conn.execute(db.UserRole.__table__.update().where(db.UserRole.user_id == suid).values(user_id=duid))
......
......@@ -107,6 +107,7 @@ CREATE TABLE rounds (
score_mode score_mode NOT NULL DEFAULT 'basic', -- mód výsledkovky
score_winner_limit int DEFAULT NULL, -- bodový limit na označení za vítěze
score_successful_limit int DEFAULT NULL, -- bodový limit na označení za úspěšného řešitele
points_step numeric(2,1) NOT NULL DEFAULT 1, -- s jakou přesností jsou přidělovány body (celé aneb 1, 0.5, 0.1)
has_messages boolean NOT NULL DEFAULT false, -- má zprávičky
UNIQUE (year, category, seq, part)
);
......@@ -162,7 +163,7 @@ CREATE TABLE tasks (
round_id int NOT NULL REFERENCES rounds(round_id),
code varchar(255) NOT NULL, -- např. "P-I-1"
name varchar(255) NOT NULL,
max_points 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)
);
......@@ -200,7 +201,7 @@ CREATE TABLE solutions (
user_id int NOT NULL REFERENCES users(user_id),
final_submit int DEFAULT NULL REFERENCES papers(paper_id), -- verze odevzdání, která se má hodnotit
final_feedback int DEFAULT NULL REFERENCES papers(paper_id), -- verze komentáře opravovatelů, kterou má vidět účastník
points int DEFAULT NULL,
points numeric(5,1) DEFAULT NULL,
note text NOT NULL DEFAULT '', -- komentář pro řešitele
org_note text NOT NULL DEFAULT '', -- komentář viditelný jen organizátorům
PRIMARY KEY (task_id, user_id)
......@@ -213,7 +214,7 @@ CREATE TABLE points_history (
points_history_id serial PRIMARY KEY,
task_id int NOT NULL REFERENCES tasks(task_id),
participant_id int NOT NULL REFERENCES users(user_id),
points int DEFAULT NULL,
points numeric(5,1) DEFAULT NULL,
points_by int NOT NULL REFERENCES users(user_id), -- kdo přidělil body
points_at timestamp with time zone NOT NULL -- a kdy
);
......
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 = '/'
SESSION_COOKIE_NAME = 'mo_session'
# 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"
# Odesilatel generovaných mailů (není-li definován, neposílají se)
......
......@@ -2,6 +2,7 @@
# Generated by sqlacodegen and then heavily edited.
import datetime
import decimal
from enum import Enum as PythonEnum, auto
import locale
import re
......@@ -15,9 +16,11 @@ from sqlalchemy.orm.attributes import get_history
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.expression import CTE
from sqlalchemy.sql.sqltypes import Numeric
from typing import Optional, List, Tuple
import mo
from mo.place_level import place_levels, PlaceLevel
from mo.util_format import timedelta, time_and_timedelta
# HACK: Work-around for https://github.com/dropbox/sqlalchemy-stubs/issues/114
......@@ -71,7 +74,7 @@ class PlaceType(MOEnum):
(name, levels) = place_type_names_and_levels[item]
if level is None or level in levels:
if item == enum.region and level is not None:
name += " (" + place_level_names[level] + ")"
name += " (" + place_levels[level].name + ")"
out.append((item.name, name))
return out
......@@ -83,8 +86,6 @@ place_type_names_and_levels = {
PlaceType.site: ('Soutěžní místo', [4]),
}
place_level_names = ['stát', 'kraj', 'okres', 'obec', 'škola']
class Place(Base):
__tablename__ = 'places'
......@@ -106,8 +107,8 @@ class Place(Base):
return "soutěžní místo"
elif self.type == PlaceType.school:
return "škola"
elif self.level < len(place_level_names):
return place_level_names[self.level]
elif self.level < len(place_levels):
return place_levels[self.level].name
else:
return "region"
......@@ -117,6 +118,15 @@ class Place(Base):
def can_have_child(self):
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():
return get_session().query(Place).filter_by(parent=None).one()
......@@ -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):
__tablename__ = 'rounds'
__table_args__ = (
......@@ -207,8 +226,9 @@ class Round(Base):
pr_tasks_start = Column(DateTime(True))
pr_submit_end = Column(DateTime(True))
score_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False, server_default=text("'basic'::score_mode"))
score_winner_limit = Column(Integer)
score_successful_limit = Column(Integer)
score_winner_limit = Column(Numeric)
score_successful_limit = Column(Numeric)
points_step = Column(Numeric, nullable=False)
has_messages = Column(Boolean, nullable=False, server_default=text("false"))
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):
part = self.part_code()
return f"{code}{part}"
def get_level(self) -> PlaceLevel:
return place_levels[self.level]
def has_tasks(self):
return self.tasks_file
......@@ -246,6 +269,11 @@ class Round(Base):
else:
return self.state
def points_step_name(self) -> str:
if self.points_step in round_points_step_names:
return round_points_step_names[self.points_step]
return str(self.points_step)
class User(Base):
__tablename__ = 'users'
......@@ -415,7 +443,7 @@ class Task(Base):
round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
code = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
max_points = Column(Integer)
max_points = Column(Numeric)
round = relationship('Round')
......@@ -467,9 +495,8 @@ class UserRole(Base):
parts.append(f"{self.year}. ročníku")
if self.category:
parts.append(f"kategorie {self.category}")
parts.append("pro")
parts.append(self.place.type_name())
parts.append(self.place.name)
if self.place.level > 0:
parts.append(self.place.name_locative())
return " ".join(parts)
......@@ -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)"))
task_id = Column(Integer, ForeignKey('tasks.task_id'), nullable=False)
participant_id = Column(Integer, ForeignKey('users.user_id'), nullable=False)
points = Column(Integer)
points = Column(Numeric)
points_by = Column(Integer, ForeignKey('users.user_id'), nullable=False)
points_at = Column(DateTime(True), nullable=False)
......@@ -540,7 +567,7 @@ class Solution(Base):
user_id = Column(Integer, ForeignKey('users.user_id'), primary_key=True, nullable=False)
final_submit = Column(Integer, ForeignKey('papers.paper_id'))
final_feedback = Column(Integer, ForeignKey('papers.paper_id'))
points = Column(Integer)
points = Column(Numeric)
note = Column(Text, nullable=False, server_default=text("''::text"))
org_note = Column(Text, nullable=False, server_default=text("''::text"))
......@@ -713,6 +740,8 @@ def row2dict(row):
if isinstance(val, datetime.datetime):
# datetime neumíme serializovat do JSONu, ale nevadí to, protože ho stejně nemá smysl logovat
pass
elif isinstance(val, decimal.Decimal):
d[column.name] = float(val)
else:
d[column.name] = getattr(row, column.name)
......
from dataclasses import dataclass
import decimal
from enum import auto
import io
import re
......@@ -13,6 +14,7 @@ import mo.rights
import mo.users
import mo.util
from mo.util import logger
from mo.util_format import format_decimal
class ImportType(db.MOEnum):
......@@ -137,7 +139,7 @@ class Import:
('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
if not self.check_rights(place):
return self.error(f'K místu "{kod}" nemáte práva na správu soutěže')
return self.error(f'Nemáte práva na správu soutěže {place.name_locative()}')
self.place_cache[kod] = place
return place
......@@ -214,7 +216,7 @@ class Import:
self.new_user_ids.append(user.user_id)
return user
def parse_points(self, points_str: str) -> Union[int, str, None]:
def parse_points(self, points_str: str) -> Union[decimal.Decimal, str, None]:
if points_str == "":
return self.error('Body musí být vyplněny')
......@@ -222,17 +224,9 @@ class Import:
if points_str in ['X', '?']:
return points_str
try:
pts = int(points_str)
except ValueError:
return self.error('Body nejsou celé číslo')
if pts < 0:
return self.error('Body nesmí být záporné')
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})')
pts, error = mo.util.parse_points(points_str, self.task, self.round)
if error:
return self.error(error)
return pts
......@@ -280,7 +274,7 @@ class Import:
elif len(pions) == 1:
pion = pions[0]
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:
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:
else:
if oblast is 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
contest = db.get_session().query(db.Contest).filter_by(round=self.round, place=oblast).one_or_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
......@@ -605,7 +599,7 @@ class JudgeImport(Import):
contest = self.obtain_contest(oblast, allow_none=True)
place = contest.place if contest else self.root_place
if not self.check_rights(place):
return self.error(f'K místu "{place.get_code()}" nemáte práva na správu soutěže')
return self.error(f'Nemáte práva na správu soutěže {place.name_locative()}')
self.add_role(user, place, db.RoleType.opravovatel)
......@@ -673,7 +667,7 @@ class PointsImport(Import):
if self.contest is not None:
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)
if not rights.can_edit_points():
......@@ -716,7 +710,7 @@ class PointsImport(Import):
sess.delete(sol)
return
points = body if isinstance(body, int) else None
points = body if isinstance(body, decimal.Decimal) else None
if sol.points != points:
sol.points = points
sess.add(db.PointsHistory(
......@@ -736,7 +730,7 @@ class PointsImport(Import):
elif sol.points is None:
pts = '?'
else:
pts = str(sol.points)
pts = format_decimal(sol.points)
user = pion.user
rows.append(PointsImportRow(
user_id=user.user_id,
......
......@@ -110,7 +110,7 @@ class TheJob:
sess = db.get_session()
if not self.load() or self.job.state != db.JobState.ready:
# Někdo ho mezitím smazal nebo vyřídil
logger.info(f'Job: Job #{self.job.job_id} vyřizuje někdo jiný')
logger.info(f'Job: Job #{self.job_id} vyřizuje někdo jiný')
sess.rollback()
return
......
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 sqlalchemy import and_
from sqlalchemy.orm import joinedload
......@@ -56,8 +57,8 @@ class ScoreResult:
def get_sols_map(self) -> Dict[int, db.Solution]:
return self._sols[0]
def get_total_points(self) -> int:
sum = 0
def get_total_points(self) -> decimal.Decimal:
sum = decimal.Decimal(0)
for sol in self.get_sols():
if sol.points:
sum += sol.points
......@@ -67,17 +68,17 @@ class ScoreResult:
class ScoreTask:
task: db.Task
num_solutions: int
sum_points: int
sum_points: decimal.Decimal
def __init__(self, task: db.Task):
self.task = task
self.num_solutions = 0
self.sum_points = 0
self.sum_points = decimal.Decimal(0)
def get_difficulty(self) -> Fraction:
if self.num_solutions == 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:
return f'{self.sum_points}/{self.num_solutions}'
......
......@@ -92,11 +92,11 @@ class Submitter:
if 'error' in result:
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')
broken = True
else:
raise SubmitException('Soubor není korektní PDF.')
raise SubmitException(result.get('user-error', 'Soubor není korektní PDF.'))
else:
paper.pages = result['pages']
broken = False
......@@ -114,6 +114,9 @@ class Submitter:
except pikepdf.PdfError as e:
result['error'] = str(e)
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)
@staticmethod
......
......@@ -2,6 +2,7 @@
from dataclasses import dataclass
import datetime
import decimal
import dateutil.tz
import email.message
import email.headerregistry
......@@ -12,13 +13,14 @@ import re
import secrets
import subprocess
import sys
from typing import Any, Optional, NoReturn
from typing import Any, Optional, NoReturn, Tuple
import textwrap
import urllib.parse
import mo
import mo.db as db
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
current_log_user: Optional[db.User] = None
......@@ -77,6 +79,10 @@ def send_user_email(user: db.User, subject: str, body: str) -> bool:
addr_spec=user.email,
)
]
msg['Reply-To'] = email.headerregistry.Address(
display_name='Správce OSMO',
addr_spec=config.MAIL_CONTACT,
)
msg['Subject'] = 'OSMO – ' + subject
msg['Date'] = datetime.datetime.now()
......@@ -209,3 +215,35 @@ def normalize_grade(grade: str) -> int:
return -1
except ValueError:
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)
from datetime import datetime
import decimal
from typing import Optional
import mo
......@@ -120,3 +121,12 @@ def data_size(bytes: int) -> str:
return f'{bytes/(1<<20):.1f} MiB'
else:
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
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_by=util_format.inflect_by_number)
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(data_size=util_format.data_size)
app.jinja_env.filters.update(decimal=util_format.format_decimal)
# Exporty proměnných
......
......@@ -24,10 +24,9 @@ import mo.util
from mo.util_format import inflect_number, inflect_by_number
from mo.web import app
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
import wtforms.validators as validators
from wtforms.fields.html5 import IntegerField
from wtforms.widgets.html5 import NumberInput
......@@ -89,7 +88,8 @@ class ParticipantsFilterForm(PagerForm):
class ParticipantsActionForm(FlaskForm):
action_on = wtforms.RadioField(
"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
)
......@@ -129,16 +129,16 @@ class ParticipantsActionForm(FlaskForm):
elif self.set_contest.data:
contest_place = db.get_place_by_code(self.contest_place.data)
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
# 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()
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
rr = g.gatekeeper.rights_for_contest(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
elif self.remove_participation.data:
pass
......@@ -161,7 +161,7 @@ class ParticipantsActionForm(FlaskForm):
rr = g.gatekeeper.rights_for_contest(pion.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 {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'
)
return False
......@@ -223,7 +223,7 @@ class ParticipantsActionForm(FlaskForm):
elif self.set_contest.data:
flash(
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'
)
elif self.remove_participation.data:
......@@ -471,7 +471,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None):
else:
# (count, query) = filter.apply_limits(query, pagesize=50)
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(
'org_contest_list.html',
......@@ -480,7 +480,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None):
filter=filter, count=count, action_form=action_form,
)
else:
table = make_contestant_table(query)
table = make_contestant_table(query, master_contest.round, is_export=True)
return table.send_as(format)
......@@ -535,7 +535,7 @@ def get_contestants_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()
rows: List[Row] = []
......@@ -550,6 +550,7 @@ def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_
rows.append(Row(
keys={
'sort_key': u.sort_key(),
'user_id': u.user_id,
'first_name': cell_pion_link(u, pion.contest_id, u.first_name),
'last_name': cell_pion_link(u, pion.contest_id, u.last_name),
'email': cell_email_link(u),
......@@ -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'])
cols: Sequence[Column] = contest_list_columns
cols: List[Column] = list(contest_list_columns)
if add_checkbox:
cols = [Column(key='checkbox', name=' ', title=' ')] + list(cols)
cols = [Column(key='checkbox', name=' ', title=' ')] + cols
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(
columns=cols,
......@@ -680,8 +683,7 @@ def get_solution_context(contest_id: int, user_id: Optional[int], task_id: Optio
class SubmitForm(FlaskForm):
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")
# Validátory k points budou přidány podle počtu maximálních bodů úlohy v org_submit_list
points = IntegerField('Body', description="Účastník po uzavření kola uvidí jen naposledy zadané body")
points = MODecimalField('Body', description="Účastník po uzavření kola uvidí jen naposledy zadané body", validators=[validators.Optional()])
submit = wtforms.SubmitField('Uložit')
file = flask_wtf.file.FileField("Soubor")
......@@ -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):
sc = get_solution_context(contest_id, user_id, task_id, site_id)
assert sc.user is not None
assert sc.task is not None
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)
......@@ -759,11 +762,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
return redirect(self_url)
form = SubmitForm(obj=sol)
form.points.validators = [
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
form.points.widget = NumberInput(min=0, max=sc.task.max_points, step=sc.master_round.points_step) # min a max v HTML
if form.validate_on_submit():
if sol and form.delete.data:
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
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')
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):
flash('Řešení založeno', 'success')
......@@ -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")
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)
assert sc.task is not None
action_create = request.endpoint == "org_contest_task_create"
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
for _, sol in rows:
if sol is None:
continue
points = request.form.get(f"points_{sol.user_id}", type=int)
if points and points < 0:
flash('Nelze zadat záporné body', 'danger')
ok = False
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')
points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=sc.task, for_round=sc.round)
if error:
flash(f'{sol.user.first_name} {sol.user.last_name}: {error}', 'danger')
ok = False
break
if points != sol.points:
if ok and points != sol.points:
# Save points
sol.points = points
sess.add(db.PointsHistory(
......@@ -1434,7 +1435,7 @@ def org_contest_user(contest_id: int, user_id: int):
class AdvanceForm(FlaskForm):
boundary = IntegerField(
boundary = MODecimalField(
'Bodová hranice', render_kw={'autofocus': True},
description="Postoupí všichni účastníci, kteří v minulém kole získali aspoň tolik bodů.",
validators=[validators.InputRequired()]
......
import decimal
from flask import render_template, g, redirect, url_for, flash, request
import locale
import flask_wtf.file
from flask_wtf.form import FlaskForm
import bleach
from bleach.sanitizer import ALLOWED_TAGS
import markdown
import os
from sqlalchemy import func
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.functions import coalesce
......@@ -11,7 +14,7 @@ from typing import Optional, Tuple
import werkzeug.exceptions
import wtforms
from wtforms import validators, ValidationError
from wtforms.fields.html5 import IntegerField
from wtforms.widgets.html5 import NumberInput
import mo
import mo.db as db
......@@ -19,6 +22,7 @@ import mo.imports
from mo.rights import Right, RoundRights
import mo.util
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, \
generic_import, generic_batch_download, generic_batch_upload, generic_batch_points
......@@ -51,7 +55,7 @@ def org_rounds():
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)
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):
......@@ -106,7 +110,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
return False
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
sess = db.get_session()
......@@ -124,7 +128,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
contest = db.Contest(round=round.master, place=place, state=state)
rr = g.gatekeeper.rights_for_contest(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
sess.add(contest)
......@@ -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)}")
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
......@@ -209,6 +213,7 @@ def org_round(id: int):
return redirect(url_for('org_round', id=id))
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):
return redirect(url_for('org_round', id=id))
......@@ -222,13 +227,13 @@ def org_round(id: int):
contests_counts=contests_counts,
tasks=tasks, form_delete_task=form_delete_task,
form_add_contest=form_add_contest,
level_names=mo.db.place_level_names,
can_manage_round=can_manage_round,
can_manage_contestants=can_manage_contestants,
can_handle_submits=rr.have_right(Right.view_submits),
can_upload=rr.offer_upload_feedback(),
can_view_statement=rr.can_view_statement(),
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):
validators.Regexp(r'^[A-Za-z0-9-]+$', message="Kód úlohy smí obsahovat jen nediakritická písmena, čísla a znak -"),
], render_kw={'autofocus': True})
name = wtforms.StringField('Název úlohy')
max_points = IntegerField(
'Maximum bodů', validators=[validators.Optional()],
max_points = MODecimalField(
'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",
)
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'))
def org_round_task_new(id: int):
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():
task = db.Task()
task.round = round
......@@ -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'))
def org_round_task_edit(id: int, task_id: int):
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)
# FIXME: Check contest!
if not task:
raise werkzeug.exceptions.NotFound()
form = TaskEditForm(obj=task)
form = TaskEditForm(master_round.points_step, obj=task)
if form.validate_on_submit():
if sess.query(db.Task).filter(
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):
else:
(count, query) = filter.apply_limits(query, pagesize=50)
# 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(
'org_round_list.html',
......@@ -383,7 +392,7 @@ def org_round_list(id: int):
filter=filter, count=count, action_form=action_form,
)
else:
table = make_contestant_table(query)
table = make_contestant_table(query, round, is_export=True)
return table.send_as(format)
......@@ -410,6 +419,8 @@ class MODateTimeField(wtforms.DateTimeField):
class RoundEditForm(FlaskForm):
_for_round: Optional[db.Round] = None
name = wtforms.StringField("Název", render_kw={'autofocus': True})
state = wtforms.SelectField(
"Stav kola", choices=db.RoundState.choices(), coerce=db.RoundState.coerce,
......@@ -417,26 +428,34 @@ class RoundEditForm(FlaskForm):
)
# Only the desktop Firefox does not support datetime-local field nowadays,
# 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()])
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()])
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_winner_limit = IntegerField(
"Hranice bodů pro vítěze", validators=[validators.Optional()],
score_winner_limit = MODecimalField(
"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",
)
score_successful_limit = IntegerField(
"Hranice bodů pro úspěšné řešitele", validators=[validators.Optional()],
score_successful_limit = MODecimalField(
"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",
)
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)")
submit = wtforms.SubmitField('Uložit')
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"')
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'))
......@@ -445,11 +464,13 @@ def org_round_edit(id: int):
round, _, rr = get_round_rr(id, Right.manage_round, True)
form = RoundEditForm(obj=round)
form._for_round = round
if round.is_subround():
# podkolo nemá nastavení výsledkové listiny
del form.score_mode
del form.score_winner_limit
del form.score_successful_limit
del form.points_step
if form.validate_on_submit():
form.populate_obj(round)
......@@ -500,6 +521,61 @@ def org_task_statement(id: int):
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):
title = wtforms.StringField('Nadpis', validators=[validators.Required()])
markdown = wtforms.TextAreaField(
......
......@@ -10,6 +10,7 @@ from mo.rights import Right
from mo.score import Score
from mo.web import app
from mo.web.table import Cell, CellLink, Column, Row, Table, cell_pion_link
from mo.util_format import format_decimal
class OrderCell(Cell):
......@@ -52,15 +53,15 @@ class SolPointsCell(Cell):
return ''
elif self.sol.points is None:
return '?'
return str(self.sol.points)
return format_decimal(self.sol.points)
def to_html(self) -> str:
if not self.sol:
return '<td>–'
elif self.sol.points is None:
points = '<span class="unknown">?</span></a>'
points = '<span class="unknown">?</span>'
else:
points = str(self.sol.points)
points = format_decimal(self.sol.points)
if 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):
if is_export:
columns.append(Column(key='email', name='email'))
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:
columns.append(Column(key='pion_place', name='soutezni_misto'))
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):
'pion_place': pion.place.name,
'school': CellLink(school.name or "?", url_for('org_place', id=school.place_id)),
'grade': pant.grade,
'total_points': result.get_total_points(),
'total_points': format_decimal(result.get_total_points()),
'birth_year': pant.birth_year,
'order_key': result._order_key,
})
......
......@@ -390,6 +390,7 @@ def org_user_edit(id: int):
if form.validate_on_submit():
check = True
if hasattr(form, 'email') and form.email is not None:
other_user = mo.users.user_by_email(form.email.data)
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')
......
{% extends "base.html" %}
{% set round = contest.round %}
{% set state = contest.state %}
{% set ct_state = contest.ct_state() %}
{% set site_id = site.place_id if site else None %}
{% block title %}
......@@ -16,15 +18,11 @@
{% if site %}
<tr><td>Soutěžní místo<td><a href='{{ url_for('org_place', id=site.place_id) }}'>{{ site.name }}</a>
{% endif %}
{% with state=contest.state %}
<tr><td>Stav<td><span class='rstate-{{state.name}}'>{{ state.friendly_name() }}</span>
{% if round.state != RoundState.delegate %}
(určeno nastavením kola)
{% endif %}
{% endwith %}
{% with state=contest.ct_state() %}
<tr><td>Stav pro účastníky<td><span class='rstate-{{state.name}}'>{{ state.friendly_name() }}</span>
{% endwith %}
<tr><td>Stav pro účastníky<td><span class='rstate-{{ct_state.name}}'>{{ ct_state.friendly_name() }}</span>
<tr><td>Vaše role<td>{% if g.user.is_admin %}správce{% elif roles %}{{ roles|join(", ") }}{% else %}–{% endif %}
{% if group_contests|length > 1 %}
<tr><td>Soutěže ve skupině kol:<td>
......@@ -48,21 +46,21 @@
<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>
{% 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>
{% endif %}
{% 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>
{% 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>
{% endif %}
{% if can_manage %}
<a class="btn btn-default" href='{{ url_for('org_contest_import', id=contest.contest_id) }}'>Importovat data</a>
{% endif %}
{% 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 %}
{% if g.user.is_admin %}
<a class="btn btn-default" href="{{ log_url('contest', contest.contest_id) }}">Historie</a>
......@@ -113,7 +111,7 @@
<td>{{ task.code }}
<td>{{ task.name }}
<td>{{ task.sol_count }}
<td>{{ task.max_points|none_value('–') }}
<td>{{ task.max_points|decimal|none_value('–') }}
<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>
{% if not site and can_edit_points %}
......
......@@ -21,19 +21,21 @@
<table class='data'>
<thead>
<tr><th>Oblast<th>Postoupilo<th>Nepostoupilo
<tr><th>{{ prev_round.get_level().name|capitalize }}<th>Postoupilo<th>Nepostoupilo<th>
<tbody>
{% for c in prev_contests %}
<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>{{ 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 %}
<tfoot>
<tr>
<th>Celkem
<th>{{ accept_by_place_id.values()|sum }}
<th>{{ reject_by_place_id.values()|sum }}
<th>
</tfoot>
</table>
{% endif %}
......
......@@ -3,7 +3,7 @@
{% set round = contest.round %}
{% 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 %}
{% block breadcrumbs %}
{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Seznam účastníků") }}
......