From 800b4e32030bab90187117b11f16b9cda02daf24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ji=C5=99=C3=AD=20Setni=C4=8Dka?= <setnicka@seznam.cz>
Date: Sun, 14 Mar 2021 00:26:35 +0100
Subject: [PATCH] =?UTF-8?q?Zad=C3=A1v=C3=A1n=C3=AD=20desetinn=C3=BDch=20bo?=
 =?UTF-8?q?d=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* přes detail submitu
* přes tabulku úlohy
* přes import

Na všech místech je použití společná funkce mo.util.parse_points()

Issue #189
---
 mo/imports.py                                 | 22 +++++---------
 mo/web/org_contest.py                         | 30 ++++++++-----------
 mo/web/org_round.py                           | 20 ++++++++-----
 .../templates/org_generic_batch_points.html   |  1 +
 .../templates/parts/org_solution_table.html   |  9 ++++--
 5 files changed, 42 insertions(+), 40 deletions(-)

diff --git a/mo/imports.py b/mo/imports.py
index fcc4b1d2..5df7011e 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 367ef536..5be5e245 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 70727966..1db5b939 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 e3c728b3..074f4265 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 23ff5fcc..6223a92c 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 %}
-- 
GitLab