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
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
Show changes

Commits on Source 44

36 files
+ 446
158
Compare changes
  • Side-by-side
  • Inline

Files

+22 −0
Original line number Diff line number Diff line
#!/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))
+4 −3
Original line number Diff line number Diff line
@@ -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
);
+15 −0
Original line number Diff line number Diff line
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);
Original line number Diff line number Diff line
@@ -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)
+42 −13
Original line number Diff line number Diff line
@@ -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)

+14 −20
Original line number Diff line number Diff line
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,
Original line number Diff line number Diff line
@@ -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

mo/place_level.py

0 → 100644
+41 −0
Original line number Diff line number Diff line
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'),
]
+6 −5
Original line number Diff line number Diff line
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}'
+5 −2
Original line number Diff line number Diff line
@@ -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
+39 −1
Original line number Diff line number Diff line
@@ -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
Original line number Diff line number Diff line
# 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)
+2 −0
Original line number Diff line number Diff line
@@ -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

Original line number Diff line number Diff line
@@ -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()]
Original line number Diff line number Diff line
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(
Original line number Diff line number Diff line
@@ -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,
        })
Original line number Diff line number Diff line
@@ -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')
Original line number Diff line number Diff line
{% 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 %}
Original line number Diff line number Diff line
@@ -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 %}
Original line number Diff line number Diff line
@@ -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ů") }}
Original line number Diff line number Diff line
@@ -4,16 +4,16 @@
{% set site_id = site.place_id if site else None %}

{% block title %}
{{ "Založení řešení" if edit_form else "Tabulka řešení" }} {% if site %}soutěžního místa {{ site.name }}{% else %}oblasti {{ contest.place.name }}{% endif %}
{{ "Založení řešení" if edit_form else "Tabulka řešení" }} {% if site %}soutěžního místa {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %}
{% endblock %}
{% block breadcrumbs %}
{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Založení řešení" if edit_form else "Tabulka řešení") }}
{% endblock %}

{% block pretitle %}
{% if round.state in [RoundState.grading, RoundState.closed] %}
{% if contest.state in [RoundState.grading, RoundState.closed] %}
<div class="btn-group pull-right">
	<a class="btn btn-default" href="{{ url_for('org_score', contest_id=contest.contest_id) }}">Výsledky oblasti</a>
	<a class="btn btn-default" href="{{ url_for('org_score', contest_id=contest.contest_id) }}">Výsledky {{ round.get_level().name_genitive() }}</a>
	<a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky kola</a>
</div>
{% endif %}
@@ -93,7 +93,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
				{% endif %}
				<td class="sol">
					{% if sol.points is not none %}
						{{ sol.points }}
						{{ sol.points|decimal }}
						{% if sum_points.append(sol.points) %}{% endif %}
					{% else %}
						<span class="unknown">?</span>
@@ -111,7 +111,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
			{% endif %}
					<a class="btn btn-xs btn-link icon" title="Detail řešení" href="{{ url_for('org_submit_list', contest_id=contest.contest_id, user_id=u.user_id, task_id=task.task_id, site_id=site_id) }}">🔍</a>
		{% endfor %}
		<th>{{ sum_points|sum }}</th>
		<th>{{ sum_points|sum|decimal }}</th>
	</tr>
	{% endfor %}
	<tfoot>
Original line number Diff line number Diff line
@@ -15,8 +15,8 @@
{% block pretitle %}
<div class="btn-group pull-right">
	<a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=ct_id, site_id=site_id) }}">Všechny úlohy</a>
	{% if round.state in [RoundState.grading, RoundState.closed] %}
	<a class="btn btn-default" href="{{ url_for('org_score', contest_id=ct_id) }}">Výsledky oblasti</a>
	{% if contest.state in [RoundState.grading, RoundState.closed] %}
	<a class="btn btn-default" href="{{ url_for('org_score', contest_id=ct_id) }}">Výsledky {{ round.get_level().name_genitive() }}</a>
	<a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky kola</a>
	{% endif %}
</div>
Original line number Diff line number Diff line
@@ -18,7 +18,7 @@
		<a class="btn btn-default" href="{{ url_for('org_round_list', id=round.round_id) }}">Účastníci</a>
		<a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky</a>
	</div>
	<br>Soutěžní oblast:
	<br>{{ round.get_level().name|capitalize }}:
	<div class="btn-group">
		<a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=ct_id) }}">Tabulka řešení</a>
		<a class="btn btn-default" href="{{ url_for('org_contest_list', id=ct_id) }}">Účastníci</a>
@@ -40,7 +40,7 @@
	<thead>
			<tr><th colspan='2'>Účast v kole
	</thead>
	<tr><td>Soutěžní oblast:<td><a href='{{ url_for('org_contest', id=ct_id) }}'>{{ contest.place.name }}</a>
	<tr><td>{{ round.get_level().name|capitalize }}:<td><a href='{{ url_for('org_contest', id=ct_id) }}'>{{ contest.place.name }}</a>
	<tr><td>Soutěžní místo:<td><a href='{{ url_for('org_contest', id=ct_id, site_id=sc.pion.place_id) }}'>{{ sc.pion.place.name }}</a>
	<tr><td>Stav účasti:<td>{{ sc.pion.state.friendly_name() }}
</table>
Original line number Diff line number Diff line
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Zadání kola {{ round.round_code() }}{% endblock %}
{% block breadcrumbs %}
{{ contest_breadcrumbs(round=round, action="Zadáni") }}
{% endblock %}
{% block body %}

{{ wtf.quick_form(form, form_type='horizontal', button_map={'upload': 'primary', 'delete': 'danger'}) }}

{% endblock %}
Original line number Diff line number Diff line
@@ -15,7 +15,7 @@

<p>Zde si můžete stáhnout všechna řešení této úlohy
{% if contest %}
	z dané oblasti.
    {{ contest.place.name_locative() }}.
{% else %}
	ze všech oblastí tohoto kola.
{% endif %}
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@
<p>Zde si můžete stáhnout bodovací formulář v zadaném formátu a pak ho nahrát zpět
s vyplněnými body. "<code>?</code>" místo bodů značí dosud neobodované řešení,
"<code>X</code>" značí řešení neodevzdané.
{% if round.points_step < 1 %}Při zadávání desetinných bodů můžete použít desetinnou tečku i čárku.{% endif %}

{{ wtf.quick_form(form, form_type='simple', button_map={'submit': 'primary'}) }}

Original line number Diff line number Diff line
@@ -2,7 +2,7 @@
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}
Import dat do {% if contest %}soutěžní oblasti {{ contest.place.name }}{% else %}kola {{ round.round_code() }}{% endif %}
Import dat do {% if contest %}soutěže {{ contest.place.name_locative() }}{% else %}kola {{ round.round_code() }}{% endif %}
{% endblock %}
{% block breadcrumbs %}
{{ contest_breadcrumbs(round=round, contest=contest, action="Import dat") }}
Original line number Diff line number Diff line
@@ -11,6 +11,7 @@
	<a href='{{ url_for('org_export_skoly', format='en_csv') }}'>CSV s čárkami</a>,
	<a href='{{ url_for('org_export_skoly', format='cs_csv') }}'>CSV se středníky</a>,
	<a href='{{ url_for('org_export_skoly', format='tsv') }}'>TSV</a>
<li><a href='https://docs.google.com/document/d/1XXk7Od-ZKtfmfNa-9FpFjUqmy0Ekzf2-2q3EpSWyn1w/edit?usp=sharing'>Návod na tvorbu PDF</a>
</ul>

<h3>Rychlé hledání</h3>