diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index 84e6821ce8bdd68893d60c936c2901328f4fb9cf..7c2c8198cf8128b291236328e3986d6954165f8e 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -1068,7 +1068,7 @@ def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None): assert ctx.contest and ctx.task action_edit = request.endpoint == "org_contest_task_edit" - if action_edit and not ctx.rights.can_edit_points() and not ctx.rights.can_create_solutions(): + if action_edit and not (ctx.rights.can_edit_points() or ctx.rights.can_create_solutions()): raise werkzeug.exceptions.Forbidden() sess = db.get_session() @@ -1078,77 +1078,120 @@ def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None): rows.sort(key=lambda r: r[0].user.sort_key()) edit_form: Optional[TaskPointsEditForm] = None + edit_errors: Dict[int, str] = {} - if action_edit: - edit_form = TaskPointsEditForm() - if edit_form.validate_on_submit(): - # Založení řešení: - new_sols: List[db.Solution] = [] - all_sols: List[db.Solution] = [] - for pion, sol in rows: - if sol: - all_sols.append(sol) - continue # již existuje - if not request.form.get(f"create_sol_{pion.user_id}"): - continue # nikdo nežádá o vytvoření + def process_edit_form() -> bool: + if not edit_form.validate_on_submit(): + return False + + # Parsování bodů + action_for: Dict[db.Participation, str] = {} + points_for: Dict[db.Participation, Decimal] = {} + for pion, sol in rows: + gen_pts = request.form.get(f"points_{pion.user_id}") + action_for[pion], points_for[pion], err = mo.util.parse_gen_points(gen_pts, for_task=ctx.task, for_round=ctx.round) + if err is not None: + edit_errors[pion.user_id] = err + + if edit_errors: + return False + + new_sols: List[db.Solution] = [] + deleted_sols: List[db.Solution] = [] + changed_sols: List[db.Solution] = [] + + def update_sol(pion, sol, action, points) -> Optional[str]: + print(f'update_sol: action={action} points={points}') + # Aktualizuje řešení, zkontroluje práva a případně vrátí chybovou hlašku. + # Vyhýbáme se všemu, co nejde rollbacknout, takže logování do souboru necháme na později. + if not sol: + if action == 'X': + return None + # Řešení neexistuje a chceme ho založit + if not ctx.rights.can_create_solutions(): + return 'Nemáte právo k zakládání nových řešení, můžete jen editovat body' sol = db.Solution(task=ctx.task, user=pion.user) + sess.add(sol) + mo.util.log( + type=db.LogType.participant, + what=pion.user_id, + details={ + 'action': 'solution-created', + 'task': task_id, + }, + ) new_sols.append(sol) - all_sols.append(sol) + elif action == 'X': + # Řešení existuje a chceme ho zrušit + if sol.final_submit or sol.final_feedback: + return 'Nelze smazat řešení, ke kterému již byl odevzdán soubor' + if not ctx.rights.can_create_solutions(): + return 'Nemáte právo k zakládání nových řešení, můžete jen editovat body' + sess.delete(sol) + mo.util.log( + type=db.LogType.participant, + what=pion.user_id, + details={ + 'action': 'solution-removed', + 'task': task_id, + }, + ) + deleted_sols.append(sol) + return None - if len(new_sols) > 0 and not ctx.rights.can_create_solutions(): - flash("Nemáte právo k zakládání nových řešení, můžete jen editovat body", "danger") - return redirect(ctx.url_for('org_contest_task')) - elif len(new_sols) > 0: - for sol in new_sols: - sess.add(sol) - mo.util.log( - type=db.LogType.participant, - what=pion.user_id, - details={ - 'action': 'solution-created', - 'task': task_id, - }, - ) - app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {pion.user_id} založeno") - sess.commit() - flash(inflect_with_number(len(new_sols), "Založeno %s nové řešení", "Založena %s nová řešení", "Založeno %s nových řešení"), "success") - - # Zadání bodů: - if site_id is not None: - # Body lze zadat jen pro celý contest, ne pro site - if len(new_sols) == 0: - flash("Žádné změny k uložení", "info") - return redirect(ctx.url_for('org_contest_task')) - count = 0 - ok = True - for sol in all_sols: - points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=ctx.task, for_round=ctx.round) - if error: - flash(f'{sol.user.first_name} {sol.user.last_name}: {error}', 'danger') - ok = False - - if ok and points != sol.points: - # Save points - sol.points = points - sess.add(db.PointsHistory( - task=ctx.task, - participant=sol.user, - user=g.user, - points_at=mo.now, - points=points, - )) - count += 1 + if sol.points != points: + # Chceme změnit počet bodů (po případném založení řešení) + if not ctx.rights.can_edit_points(): + return 'Nemáte právo k zadávání bodů, můžete jen zakládat řešení' + sol.points = points + sess.add(db.PointsHistory( + task=ctx.task, + participant=sol.user, + user=g.user, + points_at=mo.now, + points=points, + )) + changed_sols.append(sol) - if count > 0 and not ctx.rights.can_edit_points(): - flash("Nemáte právo k zadávání bodů, můžete jen zakládat řešení", "danger") - return redirect(ctx.url_for('org_contest_task')) - elif ok: - if count > 0: - sess.commit() - flash("Změněny body u " + inflect_number(count, "řešení", "řešení", "řešení"), "success") - elif len(new_sols) == 0: - flash("Žádné změny k uložení", "info") - return redirect(ctx.url_for('org_contest_task')) + return None + + # Připravíme všechny změny, ale ještě necommitujeme ani nelogujeme + for pion, sol in rows: + err = update_sol(pion, sol, action_for[pion], points_for[pion]) + if err is not None: + edit_errors[pion.user_id] = err + + if edit_errors: + return False + + # Pokud jsme nic neudělali, vrátíme se + if len(new_sols) + len(deleted_sols) + len(changed_sols) == 0: + flash("Žádné změny k uložení.", "info") + return True + + # Teď už víme, že se všechno povede, takže log a commit + if new_sols: + for sol in new_sols: + app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {pion.user_id} založeno") + flash(inflect_with_number(len(new_sols), "Založeno %s nové řešení.", "Založena %s nová řešení.", "Založeno %s nových řešení."), "success") + + if deleted_sols: + for sol in deleted_sols: + app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {pion.user_id} smazáno") + flash(inflect_with_number(len(new_sols), "Smazáno %s řešení.", "Smazána %s řešení.", "Smazáno %s nových řešení."), "success") + + if changed_sols: + flash("Změněny body u " + inflect_number(len(changed_sols), "řešení", "řešení", "řešení") + '.', "success") + + sess.commit() + return True + + if action_edit: + edit_form = TaskPointsEditForm() + if process_edit_form(): + return redirect(ctx.url_for('org_contest_task')) + if edit_errors: + flash("Některé změny se nepodařilo provést. Důvod uvidíte po najetí myší nad políčka s červeným okrajem.", "danger") # Count papers for each solution paper_counts = {} @@ -1167,6 +1210,7 @@ def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None): rows=rows, paper_counts=paper_counts, paper_link=lambda u, p: mo.web.util.org_paper_link(ctx.contest, ctx.site, u, p), edit_form=edit_form, request_form=request.form, + edit_errors=edit_errors, ) diff --git a/mo/web/templates/org_contest_user.html b/mo/web/templates/org_contest_user.html index 8b04def038ac6139c7df9a0c0a258c112e99af04..91919e04160490e1e5c87012284f2bdd70658596 100644 --- a/mo/web/templates/org_contest_user.html +++ b/mo/web/templates/org_contest_user.html @@ -55,7 +55,7 @@ <h3>Odevzdané úlohy</h3> -{% with for_user=user, for_task=None, rows=task_sols, site_id=None %} +{% with for_user=user, for_task=None, rows=task_sols %} {% include "parts/org_solution_table.html" %} {% endwith %} diff --git a/mo/web/templates/parts/org_solution_table.html b/mo/web/templates/parts/org_solution_table.html index a44677b2d5cbf4c9abb505bba271f8f1447db4a5..dc9619d0852d7d430caed7148ca6a8f69625eef6 100644 --- a/mo/web/templates/parts/org_solution_table.html +++ b/mo/web/templates/parts/org_solution_table.html @@ -1,16 +1,35 @@ -{% set edit_points = not ctx.site and rights.can_edit_points() %} -{% set edit_create = rights.can_create_solutions() %} -{% set edit_both = edit_points and edit_create %} +{# + Rozhraní: + + - ctx + - rights + - round + - contest + - for_user (máme vypsat submity účastníka pro všechny úlohy) + - for_task (máme vypsat submity všech účastníků pro jednu úlohu) + - rows: + - režim for_user: dvojice (Task, Solution) + - režit for_task: dvojice (Participation, Solution) + - edit_form + - edit_errors (jen když máme edit_form) +#} +{% set edit_points = edit_form and rights.can_edit_points() %} +{% set edit_create = edit_form and rights.can_create_solutions() %} +{% set edit = edit_points or edit_create %} -<p><i> {% if edit_form %} +<p><i> {% if edit_points %} -Změňte body ve sloupečku "Přidělené body". Prázdná hodnota znamená "nebodováno". +Zde můžete bodovat odevzdaná řešení. +{% else %} +Zde můžete zakládat a rušit odevzdaná řešení. {% endif %} +Prázdná hodnota ve sloupečku „Přidělené body“ znamená „neobodováno“, +hodnota X znamená „řešení nebylo odevzdáno“. {% if edit_create %} -Zaškrtnutím políček u řešení, která dosud neexistují, tato řešení založíte. -To se hodí, pokud se nechystáte do systému nahrávat soubory řešení, ale jen chcete řešení vytvořit, aby jim -bylo možné vyplnit body. Pokud nějaké řešení založíte omylem, lze toto prázdné řešení smazat v jeho detailu. +Smazáním X založíte nové řešení – to se hodí, pokud se nechystáte do systému nahrávat soubory řešení, +ale jen chcete řešení vytvořit, aby jim bylo možné vyplnit body. +Pokud nějaké řešení založíte omylem, lze toto prázdné řešení smazat přepsáním bodů zpět na X. {% endif %} </i></p> <p><i> @@ -24,7 +43,7 @@ finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_sol </i></p> <p><i>Legenda: <span class='sol-warn icon'>⚠</span> odevzdané po termínu, - <span class="icon">🛈</span> nahráno někým jiným, než řešitelem, <span class="icon">🗐</span> existuje více verzí. Symboly po najetí myší zobrazí bližší informace. + <span class="icon">🛈</span> nahráno někým jiným než řešitelem, <span class="icon">🗐</span> existuje více verzí. Symboly po najetí myší zobrazí bližší informace. </i></p> <table class="data full"> @@ -93,47 +112,34 @@ finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_sol <span title="Celkem {{ paper_counts[key]|inflected('verze', 'verze', 'verzí') }}" class="icon">🗐</span> {% endif %} {% else %}–{% endif %} - <td style="text-align: center;"> + <td class="text-center"> {% if sol.note %}<span class="icon" title="Poznámka pro řešitele: {{ sol.note }}">🗩</span>{% endif %} {% if sol.org_note %} <span class="icon" title="Interní poznámka: {{ sol.org_note }}">🗩</span>{% endif %} - <td> - {% if edit_points and edit_form %} + {% else %} + <td colspan="3" class="text-center">– + {% endif %} + <td class="text-center"> + {% if edit %} + {% set def_pts = sol | sol_editable_points %} + {% set new_pts = request_form.get("points_{}".format(u.user_id)) %} + {% set err = edit_errors[u.user_id] %} <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) }}" - oninput="this.classList.toggle('modified', this.value != '{{sol.points|decimal}}')" + type="text" class="form-control{% if err %} has-error{% endif %}" name="points_{{u.user_id}}" + value="{{ new_pts|none_value(def_pts) }}" + oninput="this.classList.toggle('modified', this.value != '{{ def_pts }}')" size="4" tabindex={{ tabindex.value }} autofocus + data-sol="{% if sol %}1{% else %}0{% endif %}" + {% if not edit_create %} + disabled + {% endif %} + {% if err %} + title="{{ err }}" + {% endif %} > - {% set tabindex.value = tabindex.value + 1%} + {% set tabindex.value = tabindex.value + 1 %} {% else %} - {{ sol.points|decimal|none_value(Markup('<span class="unknown">?</span>')) }} - {% endif %} - {% elif edit_create and edit_form %} - <td colspan="{{ 3 if edit_points else 4 }}" class="text-center"> - <input - type="checkbox" name="create_sol_{{u.user_id}}" id="create_sol_{{u.user_id}}" - onchange="document.getElementById('points_{{u.user_id}}').disabled = !this.checked" - {% if request_form.get("create_sol_{}".format(u.user_id)) %}checked{% endif %} - tabindex={{ tabindex.value }} autofocus - > - {% set tabindex.value = tabindex.value + 1%} - <label for="create_sol_{{u.user_id}}">Založit řešení</label> - {% if edit_points %} - <td><input - type="number" class="form-control" name="points_{{u.user_id}}" id="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('') }}" - oninput="this.classList.toggle('modified', this.value != '')" - size="4" tabindex={{ tabindex.value }} autofocus - > - {% set tabindex.value = tabindex.value + 1%} + {{ sol|sol_display_points }} {% endif %} - {% else %} - <td colspan="4" class="text-center">– - {% endif %} <td><div class="btn-group"> <a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_submit_list', user_id=u.user_id, task_id=task.task_id, site_id=ctx.site_id) }}">Detail</a> </div> @@ -141,13 +147,43 @@ finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_sol {% endfor %} </table> +{% if edit %} <script type="text/javascript"> window.addEventListener("load", function() { - for (const ch of document.querySelectorAll("input[type=checkbox")) { + for (const ch of document.querySelectorAll("input[type=checkbox]")) { ch.onchange(); } - for (const i of document.querySelectorAll("input[type=number]")) { + for (const i of document.querySelectorAll("input[type=text]")) { i.oninput(); } }); + + function update_disabled_fields(allow_create) { + for (const i of document.querySelectorAll("input[type=text]")) { + if (i.dataset.sol == "0") { + i.disabled = !allow_create; + } + } + } </script> +{% endif %} + +{% if edit_create %} +<div class="form-group"><div class="checkbox"> + <label> + <input + type="checkbox" + name="create_sols" + onchange="update_disabled_fields(this.checked)" + tabindex={{ tabindex.value }} autofocus + value="y" + {% if not edit_create %} + disabled + {% elif not contest_online_submit %} + checked + {% endif %} + > + Zakládat nová řešení + </label> +</div></div> +{% endif %} diff --git a/static/mo.css b/static/mo.css index b79032c3ddff01a76dd0a9b884fadf97b1690f81..1fa431b3254e40a41e522731a8f074e571866ec3 100644 --- a/static/mo.css +++ b/static/mo.css @@ -494,6 +494,19 @@ input.modified { border-color: orange; } +table.data input.has-error { + border-color: #a94442; /* See Bootstrap */ + border-width: 2px; +} + +table.data input.form-control { + /* Override Bootstrap's settings which are more suitable for large forms */ + display: inline; + width: auto; + height: auto; + padding: 0; +} + /* Loading circle animation (source: https://codepen.io/jake-lee/pen/jrJYON) */ @keyframes rotate {