diff --git a/db/db.ddl b/db/db.ddl index 378c9d9e3c41fe291c43f032ab993f0387d3ed9c..f6dc6f5beada03e45c29e40d127817c76175fd5c 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -151,6 +151,7 @@ CREATE TABLE contests ( round_id int NOT NULL REFERENCES rounds(round_id), place_id int NOT NULL REFERENCES places(place_id), state round_state NOT NULL DEFAULT 'preparing', -- používá se, pokud round.state='delegate', jinak kopíruje round.state + scoretable_id int DEFAULT NULL, -- odkaz na snapshot představující oficiální výsledkovou listinu soutěže UNIQUE (round_id, place_id) ); @@ -439,3 +440,18 @@ CREATE TABLE scan_pages ( -- -4 pro stránku, která nepatří do této soutěže UNIQUE (job_id, file_nr, page_nr) ); + +-- Uložené výsledkové listiny (pro zveřejnění) + +CREATE TABLE score_tables ( + scoretable_id serial PRIMARY KEY, + contest_id int NOT NULL REFERENCES contests(contest_id) ON DELETE CASCADE, -- soutěž ke které patří + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, -- datum vytvoření snapshotu + created_by int NOT NULL REFERENCES users(user_id), -- autor snapshotu + score_mode score_mode NOT NULL, -- mód výsledkovky + note text NOT NULL, -- poznámka viditelná pro orgy + tasks jsonb NOT NULL, -- seznam názvů a kódů úloh + rows jsonb NOT NULL -- seznam řádků výsledkové listiny +); + +ALTER TABLE contests ADD CONSTRAINT "contests_scoretable_id" FOREIGN KEY (scoretable_id) REFERENCES score_tables(scoretable_id); diff --git a/db/upgrade-20211204.sql b/db/upgrade-20211204.sql new file mode 100644 index 0000000000000000000000000000000000000000..eecfd5bf5a714b538c6ab5442f24be8adfebecab --- /dev/null +++ b/db/upgrade-20211204.sql @@ -0,0 +1,15 @@ +SET ROLE 'mo_osmo'; + +CREATE TABLE score_tables ( + scoretable_id serial PRIMARY KEY, + contest_id int NOT NULL REFERENCES contests(contest_id) ON DELETE CASCADE, -- soutěž ke které patří + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, -- datum vytvoření snapshotu + created_by int NOT NULL REFERENCES users(user_id), -- autor snapshotu + score_mode score_mode NOT NULL, -- mód výsledkovky + note text NOT NULL, -- poznámka viditelná pro orgy + tasks jsonb NOT NULL, -- seznam názvů a kódů úloh + rows jsonb NOT NULL -- seznam řádků výsledkové listiny +); + +ALTER TABLE contests ADD COLUMN + scoretable_id int DEFAULT NULL REFERENCES score_tables(scoretable_id); diff --git a/mo/db.py b/mo/db.py index b9f1d42693cff5c9bad3cada8a2a000346fab85b..af3d60184be11bb64566afc4ea681fdb4b013209 100644 --- a/mo/db.py +++ b/mo/db.py @@ -10,7 +10,7 @@ import re from sqlalchemy import \ Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, \ text, func, \ - create_engine, inspect, select, or_, and_ + create_engine, inspect, select from sqlalchemy.engine import Engine from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload, aliased from sqlalchemy.orm.attributes import get_history @@ -391,10 +391,12 @@ class Contest(Base): round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False) place_id = Column(Integer, ForeignKey('places.place_id'), nullable=False) state = Column(Enum(RoundState, name='round_state'), nullable=False, server_default=text("'preparing'::round_state")) + scoretable_id = Column(Integer, ForeignKey('score_tables.scoretable_id'), nullable=True) master = relationship('Contest', primaryjoin='Contest.master_contest_id == Contest.contest_id', remote_side='Contest.contest_id', post_update=True) place = relationship('Place') round = relationship('Round') + scoretable = relationship('ScoreTable', primaryjoin='Contest.scoretable_id == ScoreTable.scoretable_id') def is_subcontest(self) -> bool: return self.master_contest_id != self.contest_id @@ -869,6 +871,22 @@ SCAN_PAGE_CONTINUE = -3 SCAN_PAGE_UFO = -4 +class ScoreTable(Base): + __tablename__ = 'score_tables' + + scoretable_id = Column(Integer, primary_key=True, server_default=text("nextval('score_tables_scoretable_id_seq'::regclass)")) + contest_id = Column(Integer, ForeignKey('contests.contest_id', ondelete='CASCADE'), nullable=False) + created_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP")) + created_by = Column(Integer, ForeignKey('users.user_id'), nullable=False) + score_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False) + note = Column(Text, nullable=False) + tasks = Column(JSONB, nullable=False) + rows = Column(JSONB, nullable=False) + + user = relationship('User') + contest = relationship('Contest', primaryjoin='Contest.scoretable_id == ScoreTable.scoretable_id') + + _engine: Optional[Engine] = None _session: Optional[Session] = None flask_db: Any = None diff --git a/mo/web/org_score.py b/mo/web/org_score.py index 1e5c0681e246d20b3ad157d5c01778f93a0c0957..1370d552f35acc186f3216480ab4726087493869 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 @@ -12,33 +14,9 @@ from mo.rights import Right from mo.score import Score from mo.web import app from mo.web.org_contest import get_context -from mo.web.table import Cell, CellInput, CellLink, Column, Row, Table, cell_pion_link +from mo.web.table import Cell, CellInput, CellLink, Column, Row, Table, OrderCell, cell_pion_link from mo.util_format import format_decimal, inflect_number - - -class OrderCell(Cell): - place: int - span: int - continuation: bool - - def __init__(self, place: int, span: int = 1, continuation: bool = False): - self.place = place - self.span = span - self.continuation = continuation - - def __str__(self) -> str: - if self.span == 1: - return f"{self.place}." - else: - return f"{self.place}.–{self.place + self.span - 1}." - - def to_html(self) -> str: - if self.continuation: - return "" # covered by rowspan cell above this one - elif self.span == 1: - return f"<td>{self.__str__()}" - else: - return f"<td rowspan='{self.span}'>{self.__str__()}" +from mo.web.user import scoretable_construct class SolPointsCell(Cell): @@ -84,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') @@ -112,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() @@ -254,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/table.py b/mo/web/table.py index 95995dc2d25990aa44e1e750f123f53ee1ae9810..f95571a34282f974d6f807535a59c97e55b69e9f 100644 --- a/mo/web/table.py +++ b/mo/web/table.py @@ -116,6 +116,31 @@ class CellMarkup(Cell): return self.html +class OrderCell(Cell): + place: int + span: int + continuation: bool + + def __init__(self, place: int, span: int = 1, continuation: bool = False): + self.place = place + self.span = span + self.continuation = continuation + + def __str__(self) -> str: + if self.span == 1: + return f"{self.place}." + else: + return f"{self.place}.–{self.place + self.span - 1}." + + def to_html(self) -> str: + if self.continuation: + return "" # covered by rowspan cell above this one + elif self.span == 1: + return f"<td>{self.__str__()}" + else: + return f"<td rowspan='{self.span}'>{self.__str__()}" + + class Table: columns: Sequence[Column] rows: Iterable[Row] diff --git a/mo/web/templates/doc_org.html b/mo/web/templates/doc_org.html index 8855d382b6780bd9cbb8219324704fc8492f46fa..9b4601598a2531e3d65a69ec5a339feba2169eeb 100644 --- a/mo/web/templates/doc_org.html +++ b/mo/web/templates/doc_org.html @@ -207,4 +207,46 @@ a po soutěži naskenujete, OSMO umí skeny automaticky roztřídit podle úloh při zpracování skenů je potřeba je zadat ručně. </ul> +<h3>Bodování a výsledkové listiny</h3> + +<p>V nastavení soutěžního kola lze určit několik vlastností pro bodování: + +<ul> +<li><b>přesnost bodování</b> – jestli lze zadávat jen celé body, půlbody nebo desetinné body +<li><b>hranice bodů pro úspěšné řešitele a vítěze</b> – pro jejich automatické vyznačení + ve výsledkové listině, typicky se nastavuje až po uzavření soutěže před + zveřejněním výsledkové listiny +<li><b>mód výsledkové listiny</b> – určuje algoritmus, kterým se výsledková listina sestavuje, + v současnosti jsou dostupné tyto: + <ul> + <li><b>Základní se sdílenými místy</b> – pořadí je určeno součtem bodů za všechny úlohy, + při stejném počtu bodů vznikají sdílená místa + <li><b>Jednoznačné pořadí podle pravidel MO</b> – výpočet podle pravidel pro + <a href="http://www.matematickaolympiada.cz/media/41276/pokyny17.pdf">Vyhodnocování krajských a ústředních kol</a> + snažící se o jednoznačné pořadí + </ul> +</ul> + +<p>Dále se v soutěžním kole definují <b>úlohy</b>, které mohou (ale nemusí) mít +stanovený maximální počet bodů. Body za jednotlivé úlohy se pak dají vyplňovat +přes tabulku úlohy na webu, nebo importem souboru s body. + +<p><b>Výsledkové listiny</b> systém zobrazuje hlavně pro jednotlivé soutěže, ale +lze si nechat zobrazit výsledkovou listinu pro kteroukoliv úroveň hierarchie +kola (v takovém případě může obsahovat sdružené výsledky z více soutěží). + +<p>Pokud je potřeba zaznamenat <b>výsledek losování</b> pro zjednoznačnění +pořadí, může to udělat garant dané soutěže na stránce s výsledkovou listinou +pomocí tlačítka „Zjednoznačnit pořadí“. + +<p>Pro <b>vydání oficiální výsledkové listiny</b> (kterou lze zveřejnit i +řešitelům) nejprve musí garant soutěže <b>uložit současnou verzi</b> výsledkové +listiny – tím vznikne uložená verze, o které systém zaručuje, že se již nebude +měnit. Toto je možné udělat jen u výsledkové listiny pro konkrétní soutěž (nelze +to učinit pro sdružené výsledkové listiny v hierarchii soutěžního kola). + +<p>Po uložení verze výsledkové listiny ji může garant soutěže <b>zveřejnit</b>. +Tím se výsledková listina zobrazí soutěžícím v jejich rozhraní, pokud je soutěž +ve stavu <i>ukončeno</i>. + {% endblock %} diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html index 1f33dbe4eaa7ff6505e17d67184084ef32df85bb..b6f438df4dab6b1b1ba3c25f85b3675a97ab896d 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 5aa15a3890f8aecda1c3bd12401b98380e281089..a725ddb86d5913f35ae49f8be059e8ea7b66d0e6 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 0000000000000000000000000000000000000000..3eb9f7edfc4c1a0f8bf288c46993cdf0b756ee67 --- /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 0000000000000000000000000000000000000000..26322244651bbcaab99810dbcd054a370051b8e9 --- /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/templates/user_contest.html b/mo/web/templates/user_contest.html index 8d670b246347262f061a07b57e0b8c6b557dd98d..805ce489af24046c76510183724317db01f144cd 100644 --- a/mo/web/templates/user_contest.html +++ b/mo/web/templates/user_contest.html @@ -71,6 +71,9 @@ Pokud si s tvorbou PDF nevíte rady, zkuste se podívat do <a href='https://docs <p>Odevzdávání bylo ukončeno. Vyčkejte prosím, až úlohy opravíme. {% elif state == RoundState.closed %} <p>Soutěžní kolo bylo ukončeno, níže si můžete prohlédnout svá ohodnocená a okomentovaná řešení. +{% if contest.ct_state() == RoundState.closed and contest.scoretable_id %} +Také je již zveřejněna <strong><a href="{{ url_for('user_contest_score', id=contest.contest_id) }}">výsledková listina</a></strong>. +{% endif %} {% else %} <p>Soutěž se nachází v neznámém stavu. To by se nemělo stát :) {% endif %} diff --git a/mo/web/templates/user_contest_score.html b/mo/web/templates/user_contest_score.html new file mode 100644 index 0000000000000000000000000000000000000000..a9d0b2f8434665253d6c2d3847759f739f3c3c3f --- /dev/null +++ b/mo/web/templates/user_contest_score.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% set round = contest.round %} + +{% block title %}Výsledky pro {{ round.name|lower }} {{ round.year }}. ročníku kategorie {{ round.category }}: {{ contest.place.name }}{% endblock %} +{% block breadcrumbs %} +<li><a href='{{ url_for('user_index') }}'>Soutěže</a> +<li><a href='{{ url_for('user_contest', id=contest.contest_id) }}'>{{ round.name }} {{ round.year }}. ročníku kategorie {{ round.category }}: {{ contest.place.name }}</a> +<li>Výsledky +{% endblock %} +{% block body %} + +{{ table.to_html() }} + +{% endblock %} diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html index 440b9a16f664150213f456f110b2329d6680a3cd..fb74e4d25a2f8e8aef69c16fa042efa3b5a74d65 100644 --- a/mo/web/templates/user_index.html +++ b/mo/web/templates/user_index.html @@ -36,6 +36,9 @@ Detail kola {% endif %} </a> + {% if contest.ct_state() == RoundState.closed and contest.scoretable_id %} + <a class='btn btn-xs btn-success' href="{{ url_for('user_contest_score', id=contest.contest_id) }}">Výsledková listina</a> + {% endif %} {% endfor %} </table> {% else %} diff --git a/mo/web/user.py b/mo/web/user.py index a8e3d49af07b1f56ee5c04ff93208113f10675ad..3bdd3515f745577ee9572397ed60fce9ba0df645 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,73 @@ 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) + + +@app.route('/user/contest/<int:id>/score') +def user_contest_score(id: int): + contest, pion = get_contest_pion(id, require_reg=False) + round = contest.round + format = request.args.get('format', "") + + # Výsledkovku zobrazíme jen pokud je soutěž již ukončená + state = contest.ct_state() + if not contest.scoretable or state != db.RoundState.closed: + raise werkzeug.exceptions.NotFound() + + columns, table_rows = scoretable_construct(contest.scoretable, format != "") + # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč')) + + filename = f"vysledky_{round.year}-{round.category}-{round.level}_oblast_{contest.place.code or contest.place.place_id}" + table = Table( + table_class="data full center", + columns=columns, + rows=table_rows, + filename=filename, + ) + + if format == "": + return render_template( + 'user_contest_score.html', + contest=contest, + table=table, + ) + else: + return table.send_as(format)