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 %}