From f2bc84963df0d5d749e8cf3d13ea20166d8bb457 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ji=C5=99=C3=AD=20Setni=C4=8Dka?= <setnicka@seznam.cz>
Date: Sat, 4 Dec 2021 21:44:25 +0100
Subject: [PATCH] =?UTF-8?q?Ukl=C3=A1d=C3=A1n=C3=AD=20a=20zve=C5=99ej=C5=88?=
 =?UTF-8?q?ov=C3=A1n=C3=AD=20snapshot=C5=AF=20v=C3=BDsledkov=C3=BDch=20lis?=
 =?UTF-8?q?tin?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Issue #209
---
 mo/web/org_score.py                       | 190 +++++++++++++++++++++-
 mo/web/templates/org_contest.html         |   5 +
 mo/web/templates/org_score.html           |  25 +++
 mo/web/templates/org_score_snapshot.html  |  30 ++++
 mo/web/templates/org_score_snapshots.html |  53 ++++++
 mo/web/user.py                            |  39 +++++
 6 files changed, 337 insertions(+), 5 deletions(-)
 create mode 100644 mo/web/templates/org_score_snapshot.html
 create mode 100644 mo/web/templates/org_score_snapshots.html

diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index fe5d112d..1370d552 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -1,7 +1,9 @@
-from flask import render_template, request
+import decimal
+from flask import g, render_template, request
 from flask.helpers import flash, url_for
-from typing import List, Optional, Union
+from typing import Iterable, List, Optional, Tuple, Union
 from flask_wtf.form import FlaskForm
+import json
 import werkzeug.exceptions
 from werkzeug.utils import redirect
 import wtforms
@@ -14,6 +16,7 @@ from mo.web import app
 from mo.web.org_contest import get_context
 from mo.web.table import Cell, CellInput, CellLink, Column, Row, Table, OrderCell, cell_pion_link
 from mo.util_format import format_decimal, inflect_number
+from mo.web.user import scoretable_construct
 
 
 class SolPointsCell(Cell):
@@ -59,6 +62,11 @@ class ScoreEditForm(FlaskForm):
     submit = wtforms.SubmitField("Uložit zjednoznačnění")
 
 
+class ScoreSnapshotForm(FlaskForm):
+    note = wtforms.TextField("Poznámka k verzi (pro organizátory)")
+    submit_snapshot = wtforms.SubmitField("Uložit současnou verzi")
+
+
 @app.route('/org/contest/r/<int:round_id>/score')
 @app.route('/org/contest/r/<int:round_id>/score/edit', methods=('GET', 'POST'), endpoint="org_score_edit")
 @app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/score')
@@ -87,6 +95,7 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_
     messages = score.get_messages()
 
     edit_form: Optional[ScoreEditForm] = None
+    snapshot_form: Optional[ScoreSnapshotForm] = None
 
     if is_edit:
         edit_form = ScoreEditForm()
@@ -229,15 +238,186 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_
     group_rounds = round.get_group_rounds(True)
     group_rounds.sort(key=lambda r: r.round_code())
 
+    snapshots_count = db.get_count(sess.query(db.ScoreTable).filter_by(contest_id=ct_id))
+
+    if ctx.rights.have_right(Right.manage_contest):
+        snapshot_form = ScoreSnapshotForm()
+
     if format == "":
         return render_template(
             'org_score.html',
             ctx=ctx,
-            contest=contest, round=round, tasks=tasks,
+            tasks=tasks,
             table=table, messages=messages,
             group_rounds=group_rounds,
-            round_id=round_id, ct_id=ct_id,
-            edit_form=edit_form,
+            snapshots_count=snapshots_count,
+            edit_form=edit_form, snapshot_form=snapshot_form,
+        )
+    else:
+        return table.send_as(format)
+
+
+class SetFinalScoretableForm(FlaskForm):
+    scoretable_id = wtforms.IntegerField()
+    submit_set_final = wtforms.SubmitField("Zveřejnit")
+    submit_hide = wtforms.SubmitField("Skrýt")
+
+
+class OrderKeyEncoder(json.JSONEncoder):
+    def encode(self, o):
+        if isinstance(o, Iterable) and (not isinstance(o, str)):
+            return '[' + ', '.join(map(self.encode, o)) + ']'
+        if isinstance(o, decimal.Decimal):
+            return f'{o.normalize():f}'  # using normalize() gets rid of trailing 0s, using ':f' prevents scientific notation
+        return super().encode(o)
+
+
+@app.route('/org/contest/c/<int:ct_id>/score/snapshots', methods=('GET', 'POST'))
+def org_score_snapshots(ct_id: int):
+    ctx = get_context(ct_id=ct_id)
+    assert ctx.contest
+
+    if not ctx.rights.have_right(Right.view_contestants):
+        raise werkzeug.exceptions.Forbidden()
+
+    sess = db.get_session()
+    scoretables = sess.query(db.ScoreTable).filter_by(contest_id=ct_id).all()
+
+    snapshot_form: Optional[ScoreSnapshotForm] = None
+    set_final_form: Optional[SetFinalScoretableForm] = None
+
+    if ctx.rights.have_right(Right.manage_contest):
+        snapshot_form = ScoreSnapshotForm()
+        if snapshot_form.validate_on_submit() and snapshot_form.submit_snapshot.data:
+
+            score = Score(ctx.round.master, ctx.contest)
+            tasks = score.get_tasks()
+            results = score.get_sorted_results()
+
+            snapshot_tasks: List[Tuple[str, str]] = [
+                (task.code, task.name) for task in tasks
+            ]
+
+            snapshot_rows = []
+            for result in results:
+                snapshot_row = {
+                    'order': result.order.__dict__,
+                    'winner': result.winner,
+                    'successful': result.successful,
+                    'name': result.user.full_name(),
+                    'school': result.pant.school_place.name or "?",
+                    'grade': result.pant.grade,
+                    'tasks': [],
+                    'total_points': format_decimal(result.get_total_points()),
+                    'birth_year': result.pant.birth_year,
+                    'order_key': json.dumps(result._order_key, cls=OrderKeyEncoder)
+                }
+                sols = result.get_sols_map()
+                for task in tasks:
+                    sol = sols.get(task.task_id)
+                    snapshot_row['tasks'].append(format_decimal(sol.points) if sol else None)
+                snapshot_rows.append(snapshot_row)
+
+            score_table = db.ScoreTable(
+                contest_id=ct_id,
+                user=g.user,
+                score_mode=ctx.round.score_mode,
+                note=snapshot_form.note.data,
+                tasks=snapshot_tasks,
+                rows=snapshot_rows,
+            )
+            sess.add(score_table)
+            sess.flush()
+            mo.util.log(
+                type=db.LogType.contest,
+                what=ctx.contest.contest_id,
+                details={
+                    'action': 'score-snapshot-created',
+                    'scoretable_id': score_table.scoretable_id,
+                },
+            )
+            sess.commit()
+            app.logger.info(f"Nový snapshot výsledkové listiny #{score_table.scoretable_id} pro soutěž #{ctx.contest.contest_id} vytvořen")
+            flash("Současný stav výsledkové listiny uložen, nyní můžete tuto verzi zveřejnit.", "success")
+            return redirect(ctx.url_for('org_score_snapshots'))
+
+        set_final_form = SetFinalScoretableForm()
+        if set_final_form.validate_on_submit():
+            found = False
+            scoretable_id = set_final_form.scoretable_id.data
+            for scoretable in scoretables:
+                if scoretable.scoretable_id == scoretable_id:
+                    found = True
+                    break
+            if found and set_final_form.submit_set_final:
+                ctx.contest.scoretable_id = scoretable_id
+                mo.util.log(
+                    type=db.LogType.contest,
+                    what=ctx.contest.contest_id,
+                    details={
+                        'action': 'score-publish',
+                        'scoretable_id': scoretable_id,
+                    },
+                )
+                sess.commit()
+                app.logger.info(f"Zveřejněna výsledková listina #{scoretable_id} pro soutěž #{ctx.contest.contest_id}")
+                flash("Výsledková listina zveřejněna.", "success")
+            elif set_final_form.submit_hide.data:
+                ctx.contest.scoretable_id = None
+                mo.util.log(
+                    type=db.LogType.contest,
+                    what=ctx.contest.contest_id,
+                    details={
+                        'action': 'score-hide',
+                    },
+                )
+                sess.commit()
+                app.logger.info(f"Skryta výsledková listina pro soutěž #{ctx.contest.contest_id}")
+                flash("Výsledková listina skryta.", "success")
+            else:
+                flash("Neznámé ID výsledkové listiny.", "danger")
+            return redirect(ctx.url_for('org_score_snapshots'))
+
+    return render_template(
+        'org_score_snapshots.html',
+        ctx=ctx,
+        scoretables=scoretables,
+        set_final_form=set_final_form
+    )
+
+
+@app.route('/org/contest/c/<int:ct_id>/score/<int:scoretable_id>')
+def org_score_snapshot(ct_id: int, scoretable_id: int):
+    ctx = get_context(ct_id=ct_id)
+    assert ctx.contest
+
+    if not ctx.rights.have_right(Right.view_contestants):
+        raise werkzeug.exceptions.Forbidden()
+
+    format = request.args.get('format', "")
+    sess = db.get_session()
+
+    scoretable = sess.query(db.ScoreTable).get(scoretable_id)
+    if not scoretable or scoretable.contest_id != ct_id:
+        raise werkzeug.exceptions.NotFound()
+
+    columns, table_rows = scoretable_construct(scoretable, format != "")
+    # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč'))
+
+    filename = f"vysledky_{ctx.round.year}-{ctx.round.category}-{ctx.round.level}_oblast_{ctx.contest.place.code or ctx.contest.place.place_id}"
+    table = Table(
+        table_class="data full center",
+        columns=columns,
+        rows=table_rows,
+        filename=filename,
+    )
+
+    if format == "":
+        return render_template(
+            'org_score_snapshot.html',
+            ctx=ctx,
+            table=table,
+            scoretable=scoretable,
         )
     else:
         return table.send_as(format)
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index 3e472539..199bc6d4 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -46,6 +46,11 @@
 {% else %}
 	–
 {% endif %}
+{% if state in [RoundState.grading, RoundState.closed] %}
+	<tr><td>Oficiální výsledková listina<td>
+		{% if contest.scoretable %}<a href="{{ ctx.url_for('org_score_snapshot', scoretable_id=contest.scoretable_id) }}">Zveřejněna</a>
+		{% else %}<i>zatím není</i>{% endif %}
+{% endif %}
 </table>
 
 <div class="btn-group">
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index 5aa15a38..a725ddb8 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -1,5 +1,7 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
+{% set round = ctx.round %}
+{% set contest = ctx.contest %}
 
 {% block title %}
 {{ round.round_code() }}: Výsledky pro {{ round.name|lower }} kategorie {{ round.category }}{% if contest %} {{ contest.place.name_locative() }}{% endif %}
@@ -64,6 +66,16 @@ Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 {% endif %}
 {% endif %}
 
+{% if contest and (snapshots_count or snapshot_form) %}
+<p>
+{% if snapshots_count %}
+	K této výsledkové listině {{ snapshots_count|inflected_by('existuje', 'existují', 'existují')}}
+	<strong><a href="{{ ctx.url_for('org_score_snapshots') }}">{{ snapshots_count|inflected('uložená verze', 'uložené verze', 'uložených verzí') }}</a></strong>,
+	{% if contest.scoretable_id %}jedna z nich je{% else %}žádná z nich není{% endif %} vydána jako oficiální výsledková listina.
+{% endif %}
+{% if snapshot_form %}Uložit současnou verzi výsledkové listiny můžete formulářem na spodku stránky.{% endif %}
+{% endif %}
+
 {% if edit_form %}
 <p><strong>Zjednoznačnění pořadí:</strong> U soutěžících na sdílených pozicích vyplňte číslo do políčka na konci řádku. Třídí se vzestupně od nejmenšího, prázdné políčko se považuje za nulu.</p>
 <form method="POST" class="form form-horizontal" action="">
@@ -82,4 +94,17 @@ Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 	<a class="btn btn-default pull-right" href="{{ ctx.url_for('org_score_edit') }}">Zjednoznačnit pořadí</a><br>
 {% endif %}
 
+{% if snapshot_form %}
+<div class="form-frame" style="margin-top: 20px; padding-top: 0px;">
+	<h4>Uložit současnou verzi výsledkové listiny</h4>
+	<p>Uložené verze výsledkové listiny odpovídají stavu k určitému datu. Jedna z nich může být označena jako oficiální a zveřejněna.</p>
+	<form method="post" action="{{ ctx.url_for('org_score_snapshots') }}" class="form form-inline">
+		{{ snapshot_form.csrf_token }}
+		{{ wtf.form_field(snapshot_form.note) }}
+		<input type="submit" name="submit_snapshot" class="btn btn-primary" value="Uložit současnou verzi">
+	</form>
+	</div>
+</div>
+{% endif %}
+
 {% endblock %}
diff --git a/mo/web/templates/org_score_snapshot.html b/mo/web/templates/org_score_snapshot.html
new file mode 100644
index 00000000..3eb9f7ed
--- /dev/null
+++ b/mo/web/templates/org_score_snapshot.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+
+{% block title %}
+{{ ctx.round.round_code() }}: Uložená verze výsledkové listiny pro {{ ctx.round.name|lower }} kategorie {{ ctx.round.category }} {{ ctx.contest.place.name_locative() }}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Uložená verze výsledkové listiny") }}
+{% endblock %}
+
+{% block pretitle %}
+<div class="btn-group pull-right">
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Aktuální výsledky</a>
+</div>
+{% endblock %}
+{% block body %}
+
+<p>Výsledková listina odpovídající stavu k {{ scoretable.created_at|timeformat }}. Lze ji
+zveřejnit jako oficiální výsledkovou listinu v přehledu všech uložených verzí výsledkových listin pro tuto soutěž.</p>
+
+<table class='data'>
+	<tr><td>Vygenerováno:<th>{{ scoretable.created_at|timeformat }}
+	<tr><td>Autor:<td>{{ scoretable.user|user_link }}
+	<tr><td>Mód výsledkové listiny:<td>{{ scoretable.score_mode.friendly_name() }}
+	<tr><td>Oficiální výsledková listina:<th>{{ "ano" if scoretable.scoretable_id == ctx.contest.scoretable_id else "ne" }}
+	{% if scoretable.note %}<tr><td>Poznámka:<td>{{ scoretable.note }}{% endif %}
+</table>
+
+{{ table.to_html() }}
+
+{% endblock %}
diff --git a/mo/web/templates/org_score_snapshots.html b/mo/web/templates/org_score_snapshots.html
new file mode 100644
index 00000000..26322244
--- /dev/null
+++ b/mo/web/templates/org_score_snapshots.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %}
+
+{% block title %}
+{{ ctx.round.round_code() }}: Uložené výsledkové listiny {{ ctx.round.name|lower }} kategorie {{ ctx.round.category }} {{ ctx.contest.place.name_locative() }}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Uložené výsledkové listiny") }}
+{% endblock %}
+
+{% block pretitle %}
+<div class="btn-group pull-right">
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Aktuální výsledky</a>
+</div>
+{% endblock %}
+{% block body %}
+
+<p>Uložené verze výsledkové listiny odpovídají stavu k určitému datu. Jedna z nich může být označena jako oficiální a zveřejněna.</p>
+
+{% if scoretables %}
+<table class="data full">
+	<thead>
+		<tr>
+			<th>Datum</th><th>Autor</th><th>Poznámka</th><th>Akce</th>
+		</tr>
+	</thead>
+{% for scoretable in scoretables %}
+	<tr {% if ctx.contest.scoretable_id == scoretable.scoretable_id %}class="active"{% endif %}>
+		<td>{{ scoretable.created_at|timeformat }}
+		<td>{{ scoretable.user|user_link }}
+		<td>{{ scoretable.note }}
+		<td>{% if ctx.contest.scoretable_id == scoretable.scoretable_id %}<strong>Zveřejněná verze</strong><br>{% endif %}
+			<div class="btn-group">
+			<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_score_snapshot', scoretable_id=scoretable.scoretable_id) }}">Zobrazit</a>
+			{% if set_final_form %}
+			<form method="POST" class="btn-group">
+				{{ set_final_form.csrf_token }}
+				{% if ctx.contest.scoretable_id == scoretable.scoretable_id %}
+					<input type="submit" name="submit_hide" class="btn btn-xs btn-danger" value="Zrušit zveřejnění">
+				{% else %}
+					<input type="hidden" name="scoretable_id" value="{{ scoretable.scoretable_id }}">
+					<input type="submit" name="submit_set_final" class="btn btn-xs btn-primary" value="Zveřejnit tuto verzi">
+				{% endif %}
+			</form>
+			{% endif %}
+		</div>
+	</tr>
+{% endfor %}
+</table>
+{% else %}
+<i>Žádné uložené verze dosud neexistují.</i>
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/user.py b/mo/web/user.py
index a8e3d49a..64ea0c49 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -21,6 +21,7 @@ from mo.util_format import time_and_timedelta
 from mo.web import app
 import mo.web.fields as mo_fields
 import mo.web.org_round
+from mo.web.table import Column, Row, OrderCell, Table
 import mo.web.util
 
 
@@ -492,3 +493,41 @@ def user_paper(contest_id: int, paper_id: int):
         raise werkzeug.exceptions.Forbidden()
 
     return mo.web.util.send_task_paper(paper)
+
+
+def scoretable_construct(scoretable: db.ScoreTable, is_export: bool = False) -> Tuple[List[Column], List[Row]]:
+    """Pro konstrukci výsledkovky zobrazované soutěžícím. Využito i při zobrazení
+    uložených snapshotů výsledkovky v org_score.py.
+    """
+    columns = [
+        Column(key='order', name='poradi', title='Pořadí'),
+        Column(key='name', name='ucastnik', title='Účastník'),
+        Column(key='school', name='skola', title='Škola'),
+        Column(key='grade', name='rocnik', title='Ročník')
+    ]
+    if is_export:
+        columns.insert(1, Column(key='status', name='stav'))
+
+    for (code, name) in scoretable.tasks:
+        columns.append(Column(key=f'task_{code}', name=code, title=code))
+    columns.append(Column(key='total_points', name='celkove_body', title='Celkové body'))
+
+    table_rows = []
+    for row in scoretable.rows:
+        order_cell = OrderCell(place=row['order']['place'], span=row['order']['span'], continuation=row['order']['continuation'])
+        row['order'] = order_cell
+
+        html_attr = {}
+        if row['winner']:
+            row['status'] = 'vítěz'
+            html_attr = {"class": "winner", "title": "Vítěz"}
+        elif row['successful']:
+            row['status'] = 'úspěšný'
+            html_attr = {"class": "successful", "title": "Úspěšný řešitel"}
+
+        for ((code, _), points) in zip(scoretable.tasks, row['tasks']):
+            row[f'task_{code}'] = points or '–'
+
+        table_rows.append(Row(keys=row, html_attr=html_attr))
+
+    return (columns, table_rows)
-- 
GitLab