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/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 fd8d1264f2bd23878f6fc31947a00e6edf41f780..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): @@ -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 @@ -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, 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/util.py b/mo/util.py index 790db555fc28e51c1fd33ea05670008d1d48f283..391097af852b5014aed763530dd1fd30e2e1e6ee 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 @@ -209,3 +211,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 5f9744f31ce0e1fed37d4620be1253721e59d611..d5710d507b53dac571933d68b1965de446b4e4d0 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 @@ -681,8 +680,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") @@ -760,11 +758,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: @@ -789,6 +783,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') @@ -1027,17 +1026,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( @@ -1435,7 +1430,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..8d8b16ff8c50e32b1bea8648aeb20a096826f7d5 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -1,3 +1,4 @@ +import decimal from flask import render_template, g, redirect, url_for, flash, request import locale from flask_wtf.form import FlaskForm @@ -11,7 +12,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 +20,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 @@ -238,19 +240,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 +286,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 @@ -423,14 +429,18 @@ class RoundEditForm(FlaskForm): 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') @@ -450,6 +460,7 @@ def org_round_edit(id: int): 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) diff --git a/mo/web/org_score.py b/mo/web/org_score.py index 82540beac2145cd0f9b7cb2e0c9f04d003b2f6c5..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,7 +53,7 @@ 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: @@ -60,7 +61,7 @@ class SolPointsCell(Cell): elif self.sol.points is None: 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 3cdfaf74bce212d18a0658306b1e1ef32aad6d55..36eb3b62772f8e11c69e1632c2796e9c2bfc361b 100644 --- a/mo/web/templates/org_contest.html +++ b/mo/web/templates/org_contest.html @@ -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_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_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_round.html b/mo/web/templates/org_round.html index fd14ad4c3019e683405e08eef39f4613b9057359..83aebd0afd2136206d826303af70daf091be066b 100644 --- a/mo/web/templates/org_round.html +++ b/mo/web/templates/org_round.html @@ -59,8 +59,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> @@ -139,7 +140,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 e6b1d7b066232b290bbc33c5ff185cb910ef4dcf..23be7e69516b006f47d87adce99589171296e297 100644 --- a/mo/web/templates/org_submit_list.html +++ b/mo/web/templates/org_submit_list.html @@ -13,8 +13,8 @@ <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-line;">{{ solution.note|or_dash }}</td> <tr title="Viditelná jen organizátorům"> @@ -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/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/util.py b/mo/web/util.py index 40e6ecd8f296dc69799ab6df69a0cec8f6209379..482e0de8b388cff99da6f2cae73bed6ec7b760d0 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 @@ -119,3 +121,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()