From ad91ee1225b7d0ddf819d9a82426d07e4adb7cc0 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 28 Aug 2023 15:12:53 +0200
Subject: [PATCH] =?UTF-8?q?Tabulka=20v=C5=A1ech=20=C5=99e=C5=A1en=C3=AD=20?=
 =?UTF-8?q?v=20sout=C4=9B=C5=BEi=20u=C5=BE=20tak=C3=A9=20um=C3=AD=20editov?=
 =?UTF-8?q?at=20body?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Sdílíme velkou část kódu s tabulkou řešení úlohy.
---
 mo/web/org_contest.py                         | 254 +++++++++---------
 mo/web/templates/org_contest_solutions.html   |  76 ++++--
 .../templates/parts/org_solution_table.html   |   5 +-
 3 files changed, 190 insertions(+), 145 deletions(-)

diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index c0c9ca82..d5c6ca3a 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -1,3 +1,4 @@
+from dataclasses import dataclass
 from decimal import Decimal
 import os
 from flask import render_template, g, redirect, url_for, flash, request
@@ -1053,8 +1054,99 @@ def get_solutions_query(
     return query
 
 
+@dataclass
+class SolEdit:
+    key: str
+    pion: db.Participation
+    task: db.Task
+    sol: Optional[db.Solution]
+    gp: Optional[GenPoints] = None
+
+
+def process_solution_edits(
+    ctx: Context,
+    edits: List[SolEdit],
+    edit_errors: Dict[str, str],
+) -> bool:
+    """Společná implementace editování zobecněných bodů pro org_contest_task a org_contest_solutions."""
+
+    # Parsování bodů
+    for edit in edits:
+        pts = request.form.get(f"points_{edit.key}")
+        gp = GenPoints.parse(pts)
+        if gp.error is not None:
+            edit_errors[edit.key] = gp.error
+        edit.gp = gp
+
+    if edit_errors:
+        return False
+
+    cnt_new_sols: int = 0
+    cnt_deleted_sols: int = 0
+    cnt_changed_sols: int = 0
+    to_log: List[str] = []
+
+    def edit_sol(edit) -> Optional[str]:
+        try:
+            sact = SolAction(
+                task=edit.task,
+                user=edit.pion.user,
+                sol=edit.sol,
+                gp=edit.gp,
+                reason='web',
+                rights=ctx.rights,
+                to_log=to_log,
+            )
+
+            added, deleted = sact.add_or_del()
+            if added:
+                nonlocal cnt_new_sols
+                cnt_new_sols += 1
+            if deleted:
+                nonlocal cnt_deleted_sols
+                cnt_deleted_sols += 1
+
+            if sact.set_points():
+                nonlocal cnt_changed_sols
+                cnt_changed_sols += 1
+
+            sact.log_changes()
+
+        except SolActionError as e:
+            return str(e)
+
+        return None
+
+    # Připravíme všechny změny, ale ještě necommitujeme ani nelogujeme
+    for edit in edits:
+        err = edit_sol(edit)
+        if err is not None:
+            edit_errors[edit.key] = err
+
+    if edit_errors:
+        return False
+
+    # Pokud jsme nic neudělali, vrátíme se
+    if cnt_new_sols + cnt_deleted_sols + cnt_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
+    for msg in to_log:
+        app.logger.info(msg)
+    if cnt_new_sols:
+        flash(inflect_with_number(cnt_new_sols, "Založeno %s nové řešení.", "Založena %s nová řešení.", "Založeno %s nových řešení."), "success")
+    if cnt_deleted_sols:
+        flash(inflect_with_number(cnt_deleted_sols, "Smazáno %s řešení.", "Smazána %s řešení.", "Smazáno %s nových řešení."), "success")
+    if cnt_changed_sols:
+        flash("Změněny body u " + inflect_number(cnt_changed_sols, "řešení", "řešení", "řešení") + '.', "success")
+    sess = db.get_session()
+    sess.commit()
+    return True
+
+
 class TaskPointsEditForm(FlaskForm):
-    submit = wtforms.SubmitField("Uložit")
+    submit = wtforms.SubmitField("Uložit změny")
 
 
 @app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/')
@@ -1076,93 +1168,23 @@ 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] = {}
-
-    def process_edit_form() -> bool:
-        assert edit_form is not None
-        if not edit_form.validate_on_submit():
-            return False
-
-        # Parsování bodů
-        gen_pts_for: Dict[db.Participation, GenPoints] = {}
-        for pion, sol in rows:
-            pts = request.form.get(f"points_{pion.user_id}")
-            gp = GenPoints.parse(pts)
-            if gp.error is not None:
-                edit_errors[pion.user_id] = gp.error
-            gen_pts_for[pion] = gp
-
-        if edit_errors:
-            return False
-
-        cnt_new_sols: int = 0
-        cnt_deleted_sols: int = 0
-        cnt_changed_sols: int = 0
-        to_log: List[str] = []
-
-        def update_sol(pion, sol, gp) -> Optional[str]:
-            try:
-                sact = SolAction(
-                    task=assert_not_none(ctx.task),
-                    user=pion.user,
-                    sol=sol,
-                    gp=gp,
-                    reason='web',
-                    rights=ctx.rights,
-                    to_log=to_log,
-                )
-
-                added, deleted = sact.add_or_del()
-                if added:
-                    nonlocal cnt_new_sols
-                    cnt_new_sols += 1
-                if deleted:
-                    nonlocal cnt_deleted_sols
-                    cnt_deleted_sols += 1
-
-                if sact.set_points():
-                    nonlocal cnt_changed_sols
-                    cnt_changed_sols += 1
-
-                sact.log_changes()
-
-            except SolActionError as e:
-                return str(e)
-
-            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, gen_pts_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 cnt_new_sols + cnt_deleted_sols + cnt_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
-        for msg in to_log:
-            app.logger.info(msg)
-        if cnt_new_sols:
-            flash(inflect_with_number(cnt_new_sols, "Založeno %s nové řešení.", "Založena %s nová řešení.", "Založeno %s nových řešení."), "success")
-        if cnt_deleted_sols:
-            flash(inflect_with_number(cnt_deleted_sols, "Smazáno %s řešení.", "Smazána %s řešení.", "Smazáno %s nových řešení."), "success")
-        if cnt_changed_sols:
-            flash("Změněny body u " + inflect_number(cnt_changed_sols, "řešení", "řešení", "řešení") + '.', "success")
-        sess.commit()
-        return True
+    edit_errors: Dict[str, str] = {}
 
     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")
+        if edit_form.validate_on_submit():
+            edits = [
+                SolEdit(
+                    key=str(pion.user_id),
+                    pion=pion,
+                    task=assert_not_none(ctx.task),
+                    sol=sol,
+                ) for pion, sol in rows
+            ]
+            if process_solution_edits(ctx, edits, edit_errors):
+                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 = {}
@@ -1186,7 +1208,7 @@ def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None):
 
 
 class ContestSolutionsEditForm(FlaskForm):
-    submit = wtforms.SubmitField("Založit označená řešení")
+    submit = wtforms.SubmitField("Uložit změny")
 
 
 @app.route('/org/contest/c/<int:ct_id>/solutions', methods=('GET', 'POST'))
@@ -1227,16 +1249,6 @@ def org_contest_solutions(ct_id: int, site_id: Optional[int] = None):
         joinedload(db.Solution.final_feedback_obj)
     ).all()
 
-    # Count papers for each task and solution
-    paper_counts = {}
-    for user_id, task_id, type, count in (
-        db.get_session().query(db.Paper.for_user, db.Paper.for_task, db.Paper.type, func.count(db.Paper.type))
-        .filter(db.Paper.for_task.in_(tasks_subq))
-        .group_by(db.Paper.for_user, db.Paper.for_task, db.Paper.type)
-        .all()
-    ):
-        paper_counts[(user_id, task_id, type.name)] = count
-
     task_sols: Dict[int, Dict[int, db.Solution]] = {}
     for t in tasks:
         task_sols[t.task_id] = {}
@@ -1244,38 +1256,33 @@ def org_contest_solutions(ct_id: int, site_id: Optional[int] = None):
         task_sols[s.task_id][s.user_id] = s
 
     edit_form: Optional[ContestSolutionsEditForm] = None
+    edit_errors: Dict[str, str] = {}
     if edit_action:
         edit_form = ContestSolutionsEditForm()
         if edit_form.validate_on_submit():
-            new_sol_count = 0
+            edits = []
             for task in tasks:
                 for pion in pions:
-                    if pion.user_id in task_sols[task.task_id]:
-                        continue  # již existuje
-                    if not request.form.get(f"create_sol_{task.task_id}_{pion.user_id}"):
-                        continue  # nikdo nežádá o vytvoření
+                    edits.append(SolEdit(
+                        key=f'{task.task_id}_{pion.user_id}',
+                        pion=pion,
+                        task=task,
+                        sol=task_sols[task.task_id].get(pion.user_id, None),
+                    ))
+            if process_solution_edits(ctx, edits, edit_errors):
+                return redirect(ctx.url_for('org_contest_solutions'))
+            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")
 
-                    sol = db.Solution(task=task, user=pion.user)
-                    sess.add(sol)
-                    mo.util.log(
-                        type=db.LogType.participant,
-                        what=pion.user_id,
-                        details={
-                            'action': 'solution-created',
-                            'task': task.task_id,
-                        },
-                    )
-                    app.logger.info(f"Řešení úlohy {task.code} od účastníka {pion.user_id} založeno")
-                    new_sol_count += 1
-
-            if new_sol_count > 0:
-                sess.commit()
-                flash(inflect_by_number(new_sol_count, "Založeno", "Založena", "Založeno") + ' '
-                      + inflect_number(new_sol_count, "nové řešení", "nová řešení", "nových řešení"),
-                      "success")
-            else:
-                flash("Žádné změny k uložení", "info")
-            return redirect(ctx.url_for('org_contest_solutions'))
+    # Count papers for each task and solution
+    paper_counts = {}
+    for user_id, task_id, type, count in (
+        db.get_session().query(db.Paper.for_user, db.Paper.for_task, db.Paper.type, func.count(db.Paper.type))
+        .filter(db.Paper.for_task.in_(tasks_subq))
+        .group_by(db.Paper.for_user, db.Paper.for_task, db.Paper.type)
+        .all()
+    ):
+        paper_counts[(user_id, task_id, type.name)] = count
 
     return render_template(
         'org_contest_solutions.html',
@@ -1283,7 +1290,8 @@ def org_contest_solutions(ct_id: int, site_id: Optional[int] = None):
         contest=ctx.contest, site=ctx.site, rights=ctx.rights,
         pions=pions, tasks=tasks, tasks_sols=task_sols, 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,
+        edit_form=edit_form, request_form=request.form,
+        edit_errors=edit_errors,
     )
 
 
diff --git a/mo/web/templates/org_contest_solutions.html b/mo/web/templates/org_contest_solutions.html
index ccef75c4..5e595c3a 100644
--- a/mo/web/templates/org_contest_solutions.html
+++ b/mo/web/templates/org_contest_solutions.html
@@ -5,12 +5,15 @@
 {% 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 %}
+{% set edit = edit_points or edit_create %}
+{% set edit_title = "Editace řešení" if edit_form else "Odevzdaná řešení" %}
+{% set edit_tip = 'Založit řešení nebo editovat body' if edit_both else 'Editovat body' if edit_points else 'Založit řešení' %}
 
 {% block title %}
-{{ "Založení řešení" if edit_form else "Tabulka řešení" }} {% if site %}soutěžního místa {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %}
+{{ edit_title }} {% if site %}soutěžního místa {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %}
 {% endblock %}
 {% block breadcrumbs %}
-{{ ctx.breadcrumbs(action="Založení řešení" if edit_form else "Tabulka řešení") }}
+{{ ctx.breadcrumbs(action=edit_title) }}
 {% endblock %}
 
 {% block pretitle %}
@@ -26,9 +29,19 @@
 
 <p><i>
 {% if edit_form %}
-Zaškrtnutím políček u řešení, která dosud neexistují, a odesláním tlačítkem pod tabulkou 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.
+{% if edit_points %}
+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“,
+P znamená „prázdný protokol, který neobsahuje řešení“.
+{% if edit_create %}
+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 %}
 {% else %}
 Všechna odevzdání od účastníka k úloze můžete vidět po kliknutí na ikonku <span class="icon">🔍</span>.
 Tamtéž můžete nahrávat skeny jednotlivých řešení.
@@ -54,12 +67,15 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 			{% for task in tasks %}<th colspan=4>
 				<a href="{{ ctx.url_for('org_contest_task', task_id=task.task_id) }}">{{ task.code }}</a>
 			{% endfor %}
+			{% if not edit_form %}
 			<th rowspan=2>Body celkem
+			{% endif %}
 		</tr>
 		<tr>
 			{% for task in tasks %}<th title="Řešení">Řeš<th title="Oprava">Opr<th title="Body">B<th title="Detail"><span class="icon">🔍</span>{% endfor %}
 		</tr>
 	</thead>
+	{% set tabindex = namespace(value=1) %}
 	{% for pion in pions %}
 	{% set u = pion.user %}
 	<tr class="state-{{ pion.state.name }}">
@@ -67,8 +83,8 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 		<td>{{ pion.state.friendly_name() }}
 		{% set sum_points = [] %}
 		{% for task in tasks %}
-			{% if u.user_id in tasks_sols[task.task_id] %}
-				{% set sol = tasks_sols[task.task_id][u.user_id] %}
+			{% set sol = tasks_sols[task.task_id].get(u.user_id) %}
+			{% if sol %}
 				{% if sol.final_submit_obj %}
 					{% set p = sol.final_submit_obj %}
 					{% set late = p.check_deadline(round) %}
@@ -92,25 +108,43 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 				{% else %}
 					<td class="sol">
 				{% endif %}
+			{% elif edit_form %}
+				<td>–
+				<td>–
+			{% else %}
+				<td colspan=3>–
+			{% endif %}
+			{% if edit_form %}
+				{% set key = '{}_{}'.format(task.task_id, pion.user_id) %}
+				{% set def_pts = sol | sol_editable_points %}
+				{% set new_pts = request_form.get("points_{}".format(key)) %}
+				{% set err = edit_errors[key] %}
+				<td><input
+					type="text" class="form-control{% if err %} has-error{% endif %}" name="points_{{key}}"
+					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 %}
+				>
+			{% elif sol %}
 				<td class="sol">
 					{{ sol|sol_display_points }}
 					{% if sol.points is not none %}{% do sum_points.append(sol.points) %}{% endif %}
-				<td class="sol">
-			{% else %}
-				<td colspan=3>
-				{% if edit_form %}
-					<label>
-						<input type="checkbox" name="create_sol_{{task.task_id}}_{{u.user_id}}">
-						Založit
-					</label>
-				{% else %}–{% endif %}
-				<td>
 			{% endif %}
-					<a class="btn btn-xs btn-link icon" title="Detail řešení" href="{{ ctx.url_for('org_submit_list', user_id=u.user_id, task_id=task.task_id) }}">🔍</a>
+			<td{% if sol %} class="sol"{% endif %}><a class="btn btn-xs btn-link icon" title="Detail řešení" href="{{ ctx.url_for('org_submit_list', user_id=u.user_id, task_id=task.task_id) }}">🔍</a>
 		{% endfor %}
+		{% if not edit_form %}
 		<th>{{ sum_points|sum|decimal }}</th>
+		{% endif %}
 	</tr>
 	{% endfor %}
+	{% if not edit_form %}
 	<tfoot>
 		<tr><td><td>
 		{% for task in tasks %}
@@ -120,15 +154,17 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 				<a class='btn btn-xs btn-primary' href="{{ ctx.url_for('org_generic_batch_upload', task_id=task.task_id) }}">Nahrát</a>
 			{% endif %}
 			{% if edit_points or edit_create %}
-				<a class='btn btn-xs btn-primary' title="{{'Založit řešení nebo editovat body' if edit_both else 'Editovat body' if edit_points else 'Založit řešení'}}" href="{{ ctx.url_for('org_contest_task_edit', task_id=task.task_id) }}">Editovat</a>
+				<a class='btn btn-xs btn-primary' title="{{ edit_tip }}" href="{{ ctx.url_for('org_contest_task_edit', task_id=task.task_id) }}">Editovat</a>
 			{% endif %}
 
 				</div>
 		{% endfor %}
 		<td>
 	</tfoot>
+	{% endif %}
 </table>
 {% if edit_form %}
+	{% include "parts/org_solution_js.html" %}
 	<div class='btn-group'>
 		{{ wtf.form_field(edit_form.submit, class="btn btn-primary") }}
 		<a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions') }}">Zrušit</a>
@@ -137,7 +173,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 {% else %}
 <div class='btn-group'>
 	{% if rights.can_create_solutions() %}
-	<a class="btn btn-primary" href="{{ ctx.url_for('org_contest_solutions_edit') }}">Založit řešení hromadně</a>
+	<a class="btn btn-primary" href="{{ ctx.url_for('org_contest_solutions_edit') }}" title="{{ edit_tip }}">Editovat</a>
 	{% endif %}
 </div>
 {% endif %}
diff --git a/mo/web/templates/parts/org_solution_table.html b/mo/web/templates/parts/org_solution_table.html
index 3b3252ad..7bcd89b7 100644
--- a/mo/web/templates/parts/org_solution_table.html
+++ b/mo/web/templates/parts/org_solution_table.html
@@ -121,9 +121,10 @@ finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_sol
 		{% endif %}
 		<td class="text-center">
 			{% if edit %}
+				{% set key = u.user_id | string %}
 				{% 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] %}
+				{% set new_pts = request_form.get("points_{}".format(key)) %}
+				{% set err = edit_errors[key] %}
 				<input
 					type="text" class="form-control{% if err %} has-error{% endif %}" name="points_{{u.user_id}}"
 					value="{{ new_pts|none_value(def_pts) }}"
-- 
GitLab