diff --git a/db/db.ddl b/db/db.ddl index 7e27eb955a61e9ab922a18fdf60801f9699f9e5b..fd3ce6c2c8f86c9b736993a36e2911491ddd23f6 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -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 ); diff --git a/db/upgrade-20210328.sql b/db/upgrade-20210328.sql new file mode 100644 index 0000000000000000000000000000000000000000..5d2b29cbaff1d1dcd0961bea710abf436d183e82 --- /dev/null +++ b/db/upgrade-20210328.sql @@ -0,0 +1,15 @@ +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); diff --git a/etc/config.py.example b/etc/config.py.example index 1e93f3d23f0f1e66854fe33b9d08eb6e96128357..9b9e4ec05976ee108f147230b9bcea884241682c 100644 --- a/etc/config.py.example +++ b/etc/config.py.example @@ -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) diff --git a/mo/db.py b/mo/db.py index 5d2c50189a63c27e52d5a01d9a96d216c8eb8e14..141fb727a38de304e0b935506332fa0cadf52c3f 100644 --- a/mo/db.py +++ b/mo/db.py @@ -2,6 +2,7 @@ # Generated by sqlacodegen and then heavily edited. import datetime +import decimal from enum import Enum as PythonEnum, auto import locale import re @@ -15,6 +16,7 @@ from sqlalchemy.orm.attributes import get_history from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql.expression import CTE +from sqlalchemy.sql.sqltypes import Numeric from typing import Optional, List, Tuple import mo @@ -186,6 +188,15 @@ round_score_mode_names = { } +# V DB jako numeric(2,1), používá se tak snadněji, než enum +round_points_step_names = { + 1: "Celé body", + 0.5: "Půlbody", + 0.1: "Desetinné body", +} +round_points_step_choices = round_points_step_names.items() + + class Round(Base): __tablename__ = 'rounds' __table_args__ = ( @@ -207,8 +218,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) @@ -246,6 +258,11 @@ class Round(Base): else: return self.state + def points_step_name(self) -> str: + if float(self.points_step) in round_points_step_names: + return round_points_step_names[float(self.points_step)] + return str(self.points_step) + class User(Base): __tablename__ = 'users' @@ -415,7 +432,7 @@ class Task(Base): round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False) code = Column(String(255), nullable=False) name = Column(String(255), nullable=False) - max_points = Column(Integer) + max_points = Column(Numeric) round = relationship('Round') @@ -524,7 +541,7 @@ class PointsHistory(Base): points_history_id = Column(Integer, primary_key=True, server_default=text("nextval('points_history_points_history_id_seq'::regclass)")) task_id = Column(Integer, ForeignKey('tasks.task_id'), nullable=False) participant_id = Column(Integer, ForeignKey('users.user_id'), nullable=False) - points = Column(Integer) + points = Column(Numeric) points_by = Column(Integer, ForeignKey('users.user_id'), nullable=False) points_at = Column(DateTime(True), nullable=False) @@ -540,7 +557,7 @@ class Solution(Base): user_id = Column(Integer, ForeignKey('users.user_id'), primary_key=True, nullable=False) final_submit = Column(Integer, ForeignKey('papers.paper_id')) final_feedback = Column(Integer, ForeignKey('papers.paper_id')) - points = Column(Integer) + points = Column(Numeric) note = Column(Text, nullable=False, server_default=text("''::text")) org_note = Column(Text, nullable=False, server_default=text("''::text")) @@ -713,6 +730,8 @@ def row2dict(row): if isinstance(val, datetime.datetime): # datetime neumíme serializovat do JSONu, ale nevadí to, protože ho stejně nemá smysl logovat pass + elif isinstance(val, decimal.Decimal): + d[column.name] = float(val) else: d[column.name] = getattr(row, column.name) diff --git a/mo/imports.py b/mo/imports.py index fcc4b1d2e9172026a1e39beca4ff400ce267b665..03ec65e05912774c76d23662cde2c4a568b24b4b 100644 --- a/mo/imports.py +++ b/mo/imports.py @@ -1,4 +1,5 @@ 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): @@ -124,7 +126,7 @@ class Import: rights = self.gatekeeper.rights_for(place, round.year, round.category, round.seq) return rights.have_right(mo.rights.Right.manage_contest) - def parse_opt_place(self, kod: str) -> Optional[db.Place]: + def parse_opt_place(self, kod: str, what: str) -> Optional[db.Place]: if kod == "": return None @@ -133,7 +135,8 @@ class Import: place = db.get_place_by_code(kod) if not place: - return self.error(f'Místo "{kod}" nenalezeno') + return self.error(f'{what.title()} s kódem "{kod}" neexistuje'+ + ('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else '')) if not self.check_rights(place): return self.error(f'K místu "{kod}" nemáte práva na správu soutěže') @@ -150,7 +153,8 @@ class Import: place = db.get_place_by_code(kod, fetch_school=True) if not place: - return self.error(f'Škola "{kod}" nenalezena') + return self.error(f'Škola s kódem "{kod}" nenalezena'+ + ('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else '')) if place.type != db.PlaceType.school: return self.error(f'Kód školy "{kod}" neodpovídá škole') @@ -212,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') @@ -220,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 @@ -483,8 +479,8 @@ class ContestImport(Import): school_place = self.parse_school(r.kod_skoly) rocnik = self.parse_grade(r.rocnik, (school_place.school if school_place else None)) rok_naroz = self.parse_born(r.rok_naroz) - misto = self.parse_opt_place(r.kod_mista) - oblast = self.parse_opt_place(r.kod_oblasti) + misto = self.parse_opt_place(r.kod_mista, 'místo') + oblast = self.parse_opt_place(r.kod_oblasti, 'oblast') if (len(self.errors) > num_prev_errs or email is None @@ -539,7 +535,7 @@ class ProctorImport(Import): email = self.parse_email(r.email) krestni = self.parse_name(r.krestni) prijmeni = self.parse_name(r.prijmeni) - misto = self.parse_opt_place(r.kod_mista) + misto = self.parse_opt_place(r.kod_mista, 'místo') if misto is None: return self.error('Kód místa je povinné uvést') @@ -588,7 +584,7 @@ class JudgeImport(Import): email = self.parse_email(r.email) krestni = self.parse_name(r.krestni) prijmeni = self.parse_name(r.prijmeni) - oblast = self.parse_opt_place(r.kod_oblasti) + oblast = self.parse_opt_place(r.kod_oblasti, 'oblast') if (len(self.errors) > num_prev_errs or email is None @@ -714,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( @@ -734,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, diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py index 2566a295c0f5fa9f233e2b94cc126246f6fa00ed..eaf9782b892cd8781b3b0a3e987f198e07ae2439 100644 --- a/mo/jobs/__init__.py +++ b/mo/jobs/__init__.py @@ -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 diff --git a/mo/score.py b/mo/score.py index ccbefbf0909b9c72a216bc2855a1ce85a3cd42fb..01a26d786d8157cb36364e19f69d24ace9abf42c 100644 --- a/mo/score.py +++ b/mo/score.py @@ -1,3 +1,4 @@ +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}' diff --git a/mo/submit.py b/mo/submit.py index ac46677b87b98a11dc35ca9d8068d370b0d3885d..10ff0f6330bcb36ee3b4d9897f30091a3a67dc1a 100644 --- a/mo/submit.py +++ b/mo/submit.py @@ -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 diff --git a/mo/users.py b/mo/users.py index 0bbbb587ba07ef0a4b67e0160a331f54d08b4525..503276570d83c2962f82a7b67b8b28be1192d716 100644 --- a/mo/users.py +++ b/mo/users.py @@ -15,13 +15,17 @@ import mo.tokens def normalize_email(addr: str) -> str: if not re.fullmatch(r'.+@.+', addr): - raise mo.CheckError('Chybný formát mailové adresy') + raise mo.CheckError('V e-mailové adrese chybí zavináč') if re.search(r'[ \t]', addr): raise mo.CheckError('E-mailová adresa obsahuje mezeru') - if not re.fullmatch(r'[!-~]+', addr): - raise mo.CheckError('E-mailová adresa obsahuje znaky mimo ASCII') + m = re.search(r'[^!-~]+', addr) + if m: + if m[0].isprintable(): + raise mo.CheckError(f'E-mailová adresa obsahuje nepovolené znaky: {m[0]}') + else: + raise mo.CheckError('E-mailová adresa obsahuje netisknutelné znaky: '+repr(m[0])) try: # Tady úmyslně používáme knihovnu jen ke kontrole a ne k normalizaci, diff --git a/mo/util.py b/mo/util.py index 790db555fc28e51c1fd33ea05670008d1d48f283..a72c5bd6fdf34800d2c58501adec3d719c18aa32 100644 --- a/mo/util.py +++ b/mo/util.py @@ -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 diff --git a/mo/util_format.py b/mo/util_format.py index 6ed6a48591fafb6b5f4f950e663dc06f78452286..7b20df1ca28b965a0c012909aa50377e20125c95 100644 --- a/mo/util_format.py +++ b/mo/util_format.py @@ -1,6 +1,7 @@ # 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) diff --git a/mo/web/jinja.py b/mo/web/jinja.py index f16c2e5c4efec31f9bfc7c637e1655456936775e..9ccf3e5e419a69be102b08ac89fe07fa5a6c5c16 100644 --- a/mo/web/jinja.py +++ b/mo/web/jinja.py @@ -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 diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index 367ef5368d8f63be704653522eb16d14a8cf7b5b..f9bdb12ca0b40f1696aa7399f6a881aa6413d81f 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -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 ) @@ -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, 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, 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='Oblast')) + 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") @@ -759,11 +761,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 +786,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') @@ -1026,17 +1029,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 +1433,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()] diff --git a/mo/web/org_round.py b/mo/web/org_round.py index 96b94c61c878b66ee0b828bd20545aaa07261e87..08864412158c168f58e4f9da01678caa96939e78 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -1,9 +1,12 @@ +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 @@ -229,6 +233,7 @@ def org_round(id: int): 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 @@ -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, 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, + 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: - raise ValidationError('Není-li nastaven času začátku soutěže, stav musí být "připravuje se"') + 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( diff --git a/mo/web/org_score.py b/mo/web/org_score.py index cbebbcda83cdf883faccb1a583864d98a2ac1204..02c5e1cd5b25495be2ec2f02f074ee2123365920 100644 --- a/mo/web/org_score.py +++ b/mo/web/org_score.py @@ -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) @@ -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, }) diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html index 203a7ddc8f9075ad185ef850aef8f8591174351c..36eb3b62772f8e11c69e1632c2796e9c2bfc361b 100644 --- a/mo/web/templates/org_contest.html +++ b/mo/web/templates/org_contest.html @@ -60,7 +60,7 @@ <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> @@ -111,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 %} diff --git a/mo/web/templates/org_contest_advance.html b/mo/web/templates/org_contest_advance.html index f7a86b84908938777a2f9327a3e063d4fc28c6b7..3bea35e52ec7000651dd1ad460b0cb18cec97a70 100644 --- a/mo/web/templates/org_contest_advance.html +++ b/mo/web/templates/org_contest_advance.html @@ -21,19 +21,21 @@ <table class='data'> <thead> - <tr><th>Oblast<th>Postoupilo<th>Nepostoupilo + <tr><th>Oblast<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 %} diff --git a/mo/web/templates/org_contest_solutions.html b/mo/web/templates/org_contest_solutions.html index 0f24a7fb0abacdb2c23f26f7eab3893324030233..99f04c1e347b98721d57dcc77746a0fdbf6997cf 100644 --- a/mo/web/templates/org_contest_solutions.html +++ b/mo/web/templates/org_contest_solutions.html @@ -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> diff --git a/mo/web/templates/org_contest_user.html b/mo/web/templates/org_contest_user.html index f4a4944e46447e869d5fd3357d29cea9fa167447..c0420489eaa4faa094e9cdcb0039a012d132603c 100644 --- a/mo/web/templates/org_contest_user.html +++ b/mo/web/templates/org_contest_user.html @@ -36,7 +36,7 @@ <tr><td>Škola:<td><a href='{{ url_for('org_place', id=pant.school) }}'>{{ pant.school_place.name }}</a> <tr><td>Třída:<td>{{ pant.grade }} <tr><td>Rok narození:<td>{{ pant.birth_year }} - <tr><td>Poznámka:<td style="white-space: pre;">{{ user.note }} + <tr><td>Poznámka:<td style="white-space: pre-line;">{{ user.note }} <thead> <tr><th colspan='2'>Účast v kole </thead> diff --git a/mo/web/templates/org_edit_statement.html b/mo/web/templates/org_edit_statement.html new file mode 100644 index 0000000000000000000000000000000000000000..3f5a3cf06d560096db40821cc50e27b9ac22cfb6 --- /dev/null +++ b/mo/web/templates/org_edit_statement.html @@ -0,0 +1,12 @@ +{% 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 %} diff --git a/mo/web/templates/org_generic_batch_points.html b/mo/web/templates/org_generic_batch_points.html index e3c728b385f6e650c5edbdc4eac166d2092866c0..074f4265deee32e6383d322e8bfc8b86ae49091b 100644 --- a/mo/web/templates/org_generic_batch_points.html +++ b/mo/web/templates/org_generic_batch_points.html @@ -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'}) }} diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html index f9b25110c6bbb99209ebd3f5647134d148ca17ac..4cdbe0bc68203db7f8f4a5c0841f8dae40188ddc 100644 --- a/mo/web/templates/org_org.html +++ b/mo/web/templates/org_org.html @@ -12,7 +12,7 @@ <tr><td>Účet založen:<td>{{ user.created_at|timeformat }} <tr><td>Poslední přihlášení:<td>{{ user.last_login_at|timeformat }} {% if user.reset_at %}<tr><td>Reset hesla:<td>{{ user.reset_at|timeformat }}{% endif %} -<tr><td>Poznámka:<td style="white-space: pre;">{{ user.note }} +<tr><td>Poznámka:<td style="white-space: pre-line;">{{ user.note }} </table> <div class="btn-group" role="group"> diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html index aa1a62a677497f02ed0459b0d5b7c2edb15e8b05..17637aeaae19e82a285d533061e2d7b8c4dec380 100644 --- a/mo/web/templates/org_round.html +++ b/mo/web/templates/org_round.html @@ -37,7 +37,7 @@ </table> <table class=data style="float: left;"> <thead> - <tr><th colspan=2>Termíny + <tr><th colspan=2>Termíny a zadání </thead> <tr><td>Účastníci vidí zadání od<td>{{ round.ct_tasks_start|time_and_timedelta }} <tr><td>Účastníci odevzdávají do<td>{{ round.ct_submit_end|time_and_timedelta }} @@ -45,7 +45,9 @@ <tr><td>Dozor odevzdává do<td>{{ round.pr_submit_end|time_and_timedelta }} <tr><td>Zadání<td> {% if round.tasks_file %} - {% if can_view_statement %} + {% if not statement_exists %} + <span class=error>soubor neexistuje</span> + {% elif can_view_statement %} <a href='{{ url_for('org_task_statement', id=round.round_id) }}'>stáhnout</a> {% else %} není dostupné @@ -59,8 +61,9 @@ {% endif %} </thead> <tr><td>Výsledková listina<td>{{ round.master.score_mode.friendly_name() }} - <tr><td>Hranice bodů pro vítěze<td>{{ round.master.score_winner_limit|none_value(Markup('<i>nenastaveno</i>')) }} - <tr><td>Hranice bodů pro úspěšné řešitele<td>{{ round.master.score_successful_limit|none_value(Markup('<i>nenastaveno</i>')) }} + <tr><td>Hranice bodů pro vítěze<td>{{ round.master.score_winner_limit|decimal|none_value(Markup('<i>nenastaveno</i>')) }} + <tr><td>Hranice bodů pro úspěšné řešitele<td>{{ round.master.score_successful_limit|decimal|none_value(Markup('<i>nenastaveno</i>')) }} + <tr><td>Přesnost bodování<td>{{ round.points_step_name() }} </table> <div style="clear: both;"></div> @@ -73,7 +76,8 @@ <a class="btn btn-default" href='{{ url_for('org_round_import', id=round.round_id) }}'>Importovat data</a> {% endif %} {% if can_manage_round %} - <a class="btn btn-default" href='{{ url_for('org_round_edit', id=round.round_id) }}'>Editovat nastavení a termíny</a> + <a class="btn btn-default" href='{{ url_for('org_round_edit', id=round.round_id) }}'>Nastavení a termíny</a> + <a class="btn btn-default" href='{{ url_for('org_edit_statement', id=round.round_id) }}'>Zadání</a> {% endif %} {% if round.has_messages %} <a class="btn btn-default" href='{{ url_for('org_round_messages', id=round.round_id) }}'>Zprávičky</a> @@ -139,7 +143,7 @@ <td>{{ task.code }} <td>{{ task.name }} <td>{{ task.sol_count }} - <td>{{ task.max_points|none_value('–') }} + <td>{{ task.max_points|decimal|none_value('–') }} {% if can_manage_round %} <td><div class="btn-group"> <a class="btn btn-xs btn-primary" href="{{ url_for('org_round_task_edit', id=round.round_id, task_id=task.task_id) }}">Editovat</a> diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html index 21740346c8516b19cb4868f69edb124a0b5cbc34..8220ad36f33b6e58fd839288f2ad6dd5c915b3f7 100644 --- a/mo/web/templates/org_score.html +++ b/mo/web/templates/org_score.html @@ -55,10 +55,10 @@ Rozkliknutím bodů se lze dostat na detail daného řešení.</p> {% if master.score_winner_limit is not none or master.score_successful_limit is not none %} <p> {% if master.score_winner_limit is not none %} -<b>Vítězi</b> se stávají účastníci s alespoň <b>{{ master.score_winner_limit|inflected("bodem", "body", "body") }}</b>. +<b>Vítězi</b> se stávají účastníci s alespoň <b>{{ master.score_winner_limit|decimal }} {{ master.score_winner_limit|inflected_by("bodem", "body", "body") }}</b>. {% endif %} {% if master.score_successful_limit is not none %} -<b>Úspěšnými řešiteli</b> se stávají účastníci s alespoň <b>{{ master.score_successful_limit|inflected("bodem", "body", "body") }}</b>. +<b>Úspěšnými řešiteli</b> se stávají účastníci s alespoň <b>{{ master.score_successful_limit|decimal }} {{ master.score_successful_limit|inflected_by("bodem", "body", "body") }}</b>. {% endif %} {% endif %} diff --git a/mo/web/templates/org_submit_list.html b/mo/web/templates/org_submit_list.html index 49f9b8f4cd800c7084b8120ff6e9d430ba323164..de2b4e61978bcfa29e4a95b554faf75390612d1d 100644 --- a/mo/web/templates/org_submit_list.html +++ b/mo/web/templates/org_submit_list.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}Řešení {{ sc.task.code }} {{ sc.task.name }} – {{ sc.user.full_name() }}{% endblock %} +{% block title %}{{ sc.user.full_name() }} – řešení úlohy {{ sc.task.code }} {{ sc.task.name }}{% endblock %} {% block breadcrumbs %} {{ contest_breadcrumbs(round=sc.round, contest=sc.contest, site=sc.site, task=sc.task, user=sc.user, action="Detail řešení") }} {% endblock %} @@ -13,12 +13,12 @@ <tr><th>Úloha<td><a href='{{ url_for('org_contest_task', contest_id=sc.contest.contest_id, site_id=site_id, task_id=sc.task.task_id) }}'>{{ sc.task.code }} {{ sc.task.name }}</a> {% if solution %} <tr><th>Body<td> - {% if solution.points is not none %}{{solution.points}}{% else %}<span class="unknown">?</span>{% endif %} - {% if sc.task.max_points is not none %}<span class="hint"> / {{ sc.task.max_points }}</span>{% endif %} + {{ solution.points|decimal|none_value(Markup('<span class="unknown">?</span>')) }} + {% if sc.task.max_points is not none %}<span class="hint"> / {{ sc.task.max_points|decimal }}</span>{% endif %} <tr title="Viditelná účastníkovi po uzavření kola"> - <th>Poznámka k řešení:<td style="white-space: pre;">{{ solution.note|or_dash }}</td> + <th>Poznámka k řešení:<td style="white-space: pre-line;">{{ solution.note|or_dash }}</td> <tr title="Viditelná jen organizátorům"> - <th>Interní poznámka:<td style="white-space: pre;">{{ solution.org_note|or_dash }}</td> + <th>Interní poznámka:<td style="white-space: pre-line;">{{ solution.org_note|or_dash }}</td> {% endif %} </table> @@ -153,7 +153,7 @@ Existuje více než jedna verze oprav, finální je podbarvená. {% for p in points_history %} <tr {% if loop.index == 1 %} class='sol-active'{% endif %}> <td>{{ p.points_at|timeformat }} - <td>{% if p.points is not none %}{{ p.points }}{% else %}–{% endif %} + <td>{{ p.points|decimal|none_value('–') }} <td>{{ p.user|user_link }} </tr> {% endfor %} diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html index 51d9027b4efec843b05a47a408c6ca3f0060d905..839868169385401f09ddd1475a36892949a86c1c 100644 --- a/mo/web/templates/org_user.html +++ b/mo/web/templates/org_user.html @@ -12,7 +12,7 @@ <tr><td>Účet založen:<td>{{ user.created_at|timeformat }} <tr><td>Poslední přihlášení:{% if user.last_login_at %}<td>{{ user.last_login_at|timeformat }}{% else %}<td class="error"><i>Zatím nepřihlášen</i>{% endif %} {% if user.reset_at %}<tr><td>Reset hesla:<td>{{ user.reset_at|timeformat }}{% endif %} -<tr><td>Poznámka:<td style="white-space: pre;">{{ user.note }} +<tr><td>Poznámka:<td style="white-space: pre-line;">{{ user.note }} </table> <div class="btn-group" role="group"> diff --git a/mo/web/templates/org_user_edit.html b/mo/web/templates/org_user_edit.html index c4e9308c16d2dfea137c21e3ac2c1c33d4c8cd36..1930c9abd4ac0c848efd86d52c18577a4fa82c35 100644 --- a/mo/web/templates/org_user_edit.html +++ b/mo/web/templates/org_user_edit.html @@ -9,7 +9,7 @@ <tr><td>E-mail:</td><td>{{ user.email|mailto }}</td></tr> {% if user.is_admin %}<tr><td>Správce:</td><td>ano</td></tr>{% endif %} {% if user.is_org %}<tr><td>Organizátor:</td><td>ano</td></tr>{% endif %} -<tr><td>Poznámka:</td><td style="white-space: pre;">{{ user.note }}</td></tr> +<tr><td>Poznámka:</td><td style="white-space: pre-line;">{{ user.note }}</td></tr> </table> <a href='{% if is_org %}{{ url_for('org_org', id=user.user_id) }}{% else %}{{ url_for('org_user', id=user.user_id) }}{% endif %}'>Zpět na detail</a> diff --git a/mo/web/templates/parts/org_participants_table_actions.html b/mo/web/templates/parts/org_participants_table_actions.html index 9838f8348e226f31e1775771f141005dbe8bb811..1e27b5f97e2ea0c4b696bf50d0bcc7385e72521e 100644 --- a/mo/web/templates/parts/org_participants_table_actions.html +++ b/mo/web/templates/parts/org_participants_table_actions.html @@ -23,8 +23,8 @@ </label> </div><div class="col-sm-5 radio"> <label> - <input id="action_on-1" name="action_on" type="radio" value="checked" required{% if action_form.action_on.data == 'all' %} checked{% endif %}> - označených účastnících + <input id="action_on-1" name="action_on" type="radio" value="checked" required{% if action_form.action_on.data == 'checked' %} checked{% endif %}> + pouze zaškrtnutých účastnících </label> </div> </div> diff --git a/mo/web/templates/parts/org_solution_table.html b/mo/web/templates/parts/org_solution_table.html index aa7f0df1b108f9a11db11947d9d9735d7309fdc3..6223a92c5a84f2968b5c654c19302788949d248d 100644 --- a/mo/web/templates/parts/org_solution_table.html +++ b/mo/web/templates/parts/org_solution_table.html @@ -86,10 +86,15 @@ finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solut {% if sol.org_note %} <span class="icon" title="Interní poznámka: {{ sol.org_note }}">🗩</span>{% endif %} <td> {% if points_form %} - <input type="number" min=0 {% if task.max_points is not none %}max={{ task.max_points }}{% endif %} class="form-control" name="points_{{u.user_id}}" value="{{ request_form.get("points_{}".format(u.user_id)) or sol.points }}" size="4" tabindex={{ tabindex.value }} autofocus> - {% set tabindex.value = tabindex.value + 1%} + <input + type="number" class="form-control" name="points_{{u.user_id}}" + min=0 {% if task.max_points is not none %}max={{ task.max_points }}{% endif %} + step="{{ round.points_step }}" + value="{{ request_form.get("points_{}".format(u.user_id))|none_value(sol.points|decimal) }}" + size="4" tabindex={{ tabindex.value }} autofocus + > {% else %} - {% if sol.points is not none %}{{ sol.points}}{% else %}<span class="unknown">?</span>{% endif %} + {{ sol.points|decimal|none_value(Markup('<span class="unknown">?</span>')) }} {% endif %} {% else %} <td colspan="4" class="text-center"> diff --git a/mo/web/templates/user_contest_task.html b/mo/web/templates/user_contest_task.html index 32308295ab3757e3044308856810e9b2b888b6cc..acb0086de6868f64c8227b524c28f36b87e5d43a 100644 --- a/mo/web/templates/user_contest_task.html +++ b/mo/web/templates/user_contest_task.html @@ -50,7 +50,7 @@ ({{ sol.final_feedback_obj.pages|inflected('stránka', 'stránky', 'stránek') }}, {{ sol.final_feedback_obj.bytes }} bajtů) {% endif %} {% if sol.note %} - <tr><th>Poznámka od organizátorů:<td style="white-space: pre;">{{ sol.note }} + <tr><th>Poznámka od organizátorů:<td style="white-space: pre-line;">{{ sol.note }} {% endif %} </table> {% else %} diff --git a/mo/web/util.py b/mo/web/util.py index 40e6ecd8f296dc69799ab6df69a0cec8f6209379..4f10a68d52fc20246453ea9ddf7b88a9f8a88ba5 100644 --- a/mo/web/util.py +++ b/mo/web/util.py @@ -1,3 +1,4 @@ +import decimal from flask import Response, send_file, url_for from flask_wtf import FlaskForm import os @@ -7,6 +8,7 @@ import unicodedata import werkzeug.exceptions import werkzeug.utils import wtforms +from wtforms.fields.html5 import DecimalField import mo.db as db import mo.jobs @@ -40,6 +42,13 @@ class PagerForm(FlaskForm): return (count, query) +def task_statement_exists(round: db.Round) -> bool: + if round.tasks_file is None: + return False + file = os.path.join(mo.util.data_dir('statements'), round.tasks_file) + return os.path.isfile(file) + + def send_task_statement(round: db.Round) -> Response: assert round.tasks_file is not None file = os.path.join(mo.util.data_dir('statements'), round.tasks_file) @@ -119,3 +128,20 @@ def send_job_result(job: db.Job) -> Response: else: logger.error(f'Soubor {file} je výsledkem jobu, ale ve FS neexistuje') raise werkzeug.exceptions.NotFound() + + +class MODecimalField(DecimalField): + """Upravený DecimalField, který formátuje číslo podle jeho skutečného počtu + desetinných míst a zadané `places` používá jen jako maximální počet desetinných míst.""" + def _value(self): + if self.data is not None: + # Spočítání počtu desetinných míst, zbytek necháme na původní implementaci + max_places = self.places + self.places = 0 + d = decimal.Decimal(1) + + while self.data % d != 0 and self.places < max_places: + self.places += 1 + d /= 10 + + return super(MODecimalField, self)._value()