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