diff --git a/mo/imports.py b/mo/imports.py
index fcc4b1d2e9172026a1e39beca4ff400ce267b665..5df7011ec270fa3df09c52b4a4aa11f8656a2863 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):
@@ -212,7 +214,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 +222,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
@@ -714,7 +708,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 +728,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/web/org_contest.py b/mo/web/org_contest.py
index 367ef5368d8f63be704653522eb16d14a8cf7b5b..5be5e2455b5c85b446ead71507b4c879782aa19f 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -24,7 +24,7 @@ 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
@@ -680,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")
@@ -759,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:
@@ -788,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')
@@ -1026,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(
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 70727966cb2d2dcb14936475de2f84c46a3f3587..1db5b9393410f7a2a33be26c779a25d04d0bd360 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,8 @@ from typing import Optional, Tuple
import werkzeug.exceptions
import wtforms
from wtforms import validators, ValidationError
-from wtforms.fields.html5 import IntegerField
+from wtforms.fields.html5 import DecimalField, IntegerField
+from wtforms.widgets.html5 import NumberInput
import mo
import mo.db as db
@@ -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
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/parts/org_solution_table.html b/mo/web/templates/parts/org_solution_table.html
index 23ff5fcc6c7e951e7929a243816317ae5fb8c858..6223a92c5a84f2968b5c654c19302788949d248d 100644
--- a/mo/web/templates/parts/org_solution_table.html
+++ b/mo/web/templates/parts/org_solution_table.html
@@ -86,8 +86,13 @@ 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 %}
{{ sol.points|decimal|none_value(Markup('<span class="unknown">?</span>')) }}
{% endif %}