From b596d6e9c27cfd72bc71ba4fa4549eec4194158e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ji=C5=99=C3=AD=20Setni=C4=8Dka?= <setnicka@seznam.cz>
Date: Sun, 21 Feb 2021 23:43:08 +0100
Subject: [PATCH] =?UTF-8?q?V=C3=BDsledkovka:=20Zobrazen=C3=AD=20v=C3=BDsle?=
 =?UTF-8?q?dkovky=20na=20webu=20pou=C5=BE=C3=ADv=C3=A1=20nov=C3=BD=20backe?=
 =?UTF-8?q?nd=20z=20mo.score?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Navenek vypadá pořád skoro stejně, ale již umí zjednoznačňovat podle
pravidel MO.

Navíc přibylo zvýraznění vítězů a úspěšných řešitelů.
---
 mo/web/org_score.py             | 127 +++++++++-----------------------
 mo/web/templates/org_score.html |  36 ++++++++-
 static/mo.css                   |   5 ++
 3 files changed, 73 insertions(+), 95 deletions(-)

diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index c20df961..4925de52 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -1,13 +1,12 @@
 from flask import render_template, request, g
 from flask.helpers import url_for
-from sqlalchemy import and_
-from sqlalchemy.orm import joinedload
-from typing import List, Tuple, Optional, Dict
+from typing import Optional
 import werkzeug.exceptions
 
 import mo
 import mo.db as db
 from mo.rights import Right
+from mo.score import Score
 from mo.web import app
 from mo.web.table import Cell, CellLink, Column, Row, Table, cell_pion_link
 
@@ -42,10 +41,10 @@ class SolPointsCell(Cell):
     user: db.User
     sol: Optional[db.Solution]
 
-    def __init__(self, contest_id: int, user: db.User):
+    def __init__(self, contest_id: int, user: db.User, sol: db.Solution):
         self.contest_id = contest_id
         self.user = user
-        self.sol = None
+        self.sol = sol
 
     def __str__(self) -> str:
         if not self.sol:
@@ -54,9 +53,6 @@ class SolPointsCell(Cell):
             return '?'
         return str(self.sol.points)
 
-    def set_sol(self, sol: db.Solution):
-        self.sol = sol
-
     def to_html(self) -> str:
         if not self.sol:
             return '<td>–'
@@ -85,66 +81,35 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
     format = request.args.get('format', "")
 
     sess = db.get_session()
-    user_id_subq = sess.query(db.Participation.user_id).join(
-        db.User, db.Participation.user_id == db.User.user_id
-    ).filter(
-        db.User.is_test == False,
-        db.Participation.state.notin_([db.PartState.disqualified, db.PartState.refused, db.PartState.absent]),
-    )
-
     if round_id:
         contest = None
         round = sess.query(db.Round).get(round_id)
         if not round:
             raise werkzeug.exceptions.NotFound()
         rr = g.gatekeeper.rights_for_round(round, True)
-        contest_subq = sess.query(db.Contest.contest_id).filter_by(round=round)
-        user_id_subq = user_id_subq.filter(db.Participation.contest_id.in_(contest_subq))
     else:
         contest = sess.query(db.Contest).get(contest_id)
         if not contest:
             raise werkzeug.exceptions.NotFound()
         round = contest.round
         rr = g.gatekeeper.rights_for_contest(contest)
-        contest_subq = [contest_id]
-        user_id_subq = user_id_subq.filter(db.Participation.contest == contest)
 
     if not rr.have_right(Right.view_submits):
         raise werkzeug.exceptions.Forbidden()
 
-    tasks_subq = sess.query(db.Task.task_id).filter_by(round=round)
-    tasks = (sess.query(db.Task)
-             .filter_by(round=round)
-             .order_by(db.Task.code)
-             .all())
-
-    sols: List[db.Solution] = sess.query(db.Solution).filter(
-        db.Solution.user_id.in_(user_id_subq),
-        db.Solution.task_id.in_(tasks_subq),
-    ).all()
-
-    data: List[Tuple[db.User, db.Participation, db.Participant]] = (
-        sess.query(db.User, db.Participation, db.Participant)
-        .select_from(db.Participation)
-        .join(db.User, isouter=True)
-        .join(db.Participant, and_(
-            db.Participant.user_id == db.Participation.user_id,
-            db.Participant.year == round.year
-        ), isouter=True).filter(
-            db.Participation.user_id.in_(user_id_subq),
-            db.Participation.contest_id.in_(contest_subq)
-        ).options(
-            joinedload(db.Participant.school_place),
-            joinedload(db.Participation.contest).joinedload(db.Contest.place),
-        ).all()
-    )
+    score = Score(round, contest)
+    tasks = score.get_tasks()
+    results = score.get_sorted_results()
+    messages = score.get_messages()
 
     # Construct columns
     is_export = (format != "")
-    columns = [
-        Column(key='order', name='poradi', title='Pořadí'),
-        Column(key='participant', name='ucastnik', title='Účastník'),
-    ]
+    columns = []
+    columns.append(Column(key='order', name='poradi', title='Pořadí'))
+    if is_export:
+        columns.append(Column(key='winner', name='vitez'))
+        columns.append(Column(key='successful', name='uspesny_resitel'))
+    columns.append(Column(key='participant', name='ucastnik', title='Účastník'))
     if is_export:
         columns.append(Column(key='email', name='email'))
     if not contest_id:
@@ -168,12 +133,17 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
                 )
         columns.append(Column(key=f'task_{task.task_id}', name=task.code, title=title))
     columns.append(Column(key='total_points', name='celkove_body', title='Celkové body'))
+    # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč'))
 
     # Construct rows
-    rows_map: Dict[int, Row] = {}
-    for user, pion, pant in data:
+    table_rows = []
+    for result in results:
+        user, pant, pion = result.user, result.pant, result.pion
         school = pant.school_place
         row = Row(keys={
+            'order':        OrderCell(result.order.place, result.order.span, result.order.continuation),
+            'winner':       'ano' if result.winner else '',
+            'successful':   'ano' if result.successful else '',
             'user':         user,
             'email':        user.email,
             'participant':  cell_pion_link(user, pion.contest_id, user.full_name()),
@@ -181,49 +151,20 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
             'pion_place':   pion.place.name,
             'school':       CellLink(school.name, url_for('org_place', id=school.place_id)),
             'grade':        pant.grade,
-            'total_points': 0,
+            'total_points': result.get_total_points(),
             'birth_year':   pant.birth_year,
+            'order_key':    result._order_key,
         })
+        sols = result.get_sols_map()
         for task in tasks:
-            row.keys[f'task_{task.task_id}'] = SolPointsCell(contest_id=pion.contest_id, user=user)
-        rows_map[user.user_id] = row
-    for sol in sols:
-        rows_map[sol.user_id].keys[f'task_{sol.task_id}'].set_sol(sol)
-        if sol.points:
-            rows_map[sol.user_id].keys['total_points'] += sol.points
-
-    rows = list(rows_map.values())
-
-    # FIXME: Pokud to chceme přetavit do finální výsledkovky, tak musíme
-    # předefinovat následující funkci (a udělat to konfigurovatelné, protože
-    # různé olympiády i různé ročníky to mohou mít různě?)
-    def get_result_cmp(result):
-        return -result.keys['total_points']
-
-    def get_result_full_cmp(result):
-        return (get_result_cmp(result), result.keys['user'].last_name, result.keys['user'].first_name)
-
-    rows.sort(key=get_result_full_cmp)
-
-    # Spočítáme pořadí - v prvním kroku prolinkujeme opakující se OrderCell na první,
-    # ve druhém kroku je pak správně rozkopírujeme s nastaveným continuation na True
-    last: Row = None
-    for row in rows:
-        if last is None:
-            row.keys['order'] = OrderCell(1)
-            last = row
-        elif get_result_cmp(last) == get_result_cmp(row):
-            row.keys['order'] = last.keys['order']
-            last.keys['order'].span += 1
-        else:
-            row.keys['order'] = OrderCell(last.keys['order'].place + last.keys['order'].span)
-            last = row
-    lastOrder: OrderCell = None
-    for row in rows:
-        if row.keys['order'] == lastOrder:
-            row.keys['order'] = OrderCell(lastOrder.place, lastOrder.span, True)
-        else:
-            lastOrder = row.keys['order']
+            row.keys[f'task_{task.task_id}'] = SolPointsCell(
+                contest_id=pion.contest_id, user=user, sol=sols.get(task.task_id)
+            )
+        if result.winner:
+            row.html_attr = {"class": "winner", "title": "Vítěz"}
+        elif result.successful:
+            row.html_attr = {"class": "successful", "title": "Úspěšný řešitel"}
+        table_rows.append(row)
 
     filename = f"vysledky_{round.year}-{round.category}-{round.level}"
     if contest_id:
@@ -231,7 +172,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
     table = Table(
         table_class="data full center",
         columns=columns,
-        rows=rows,
+        rows=table_rows,
         filename=filename,
     )
 
@@ -239,7 +180,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
         return render_template(
             'org_score.html',
             contest=contest, round=round, tasks=tasks,
-            table=table,
+            table=table, messages=messages,
         )
     else:
         return table.send_as(format)
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index 27bd42a5..15ae4beb 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -15,12 +15,44 @@
 	{% endif %}
 </div>
 
-<p>Toto je polotovar výsledkové listiny určený k ručnímu dodělání.
-Pořadí je neoficiální (seřazené podle součtu bodů s dělenými místy).
+{% if messages %}
+<div class="collapsible">
+	{% set error_count = messages | selectattr(0, "equalto", "error") | list | count %}
+
+	<input type="checkbox" class="toggle" id="messages-toggle">
+	<label for="messages-toggle" class="toggle">
+		Log vytváření výsledkové listiny ({{ messages|length|inflected('zpráva', 'zprávy', 'zpráv') }}
+		{%- if error_count > 0 %}, <span class="error">{{ error_count|inflected('chyba', 'chyby', 'chyb') }}</span>{% endif %})
+	</label>
+	<div class="collapsible-inner">
+		<div class="alert alert-warning">
+			<ul>
+			{% for (type, msg) in messages %}
+				{% if type == "error" %}<li class="error"><b>Chyba: {{ msg }}</b>
+				{% elif type == "warning" %}<li>Varování: {{ msg }}
+				{% else %}<li class="text-info">Info: {{ msg }}{% endif %}
+			{% endfor %}
+			</ul></div>
+		</div>
+	</div>
+</div>
+{% endif %}
+
+<p>Mód této výsledkové listiny je <b>{{ round.score_mode.friendly_name() }}</b>.
 Diskvalifikovaní, odmítnuvší a nepřítomní účastníci jsou skryti, stejně tak testovací uživatelé.
 Export pod tabulkou obsahuje sloupce navíc.
 Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 
+{% if round.score_winner_limit is not none or round.score_successful_limit is not none %}
+<p>
+{% if round.score_winner_limit is not none %}
+<b>Vítězi</b> se stávají účastníci s alespoň <b>{{ round.score_winner_limit|inflected("bodem", "body", "body") }}</b>.
+{% endif %}
+{% if round.score_successful_limit is not none %}
+<b>Úspěšnými řešiteli</b> se stávají účastníci s alespoň <b>{{ round.score_successful_limit|inflected("bodem", "body", "body") }}</b>.
+{% endif %}
+{% endif %}
+
 {{ table.to_html() }}
 
 {% endblock %}
diff --git a/static/mo.css b/static/mo.css
index 34c5a998..1eda5be8 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -130,6 +130,11 @@ table tr.state-refused, table tr.state-refused a:not(.btn) {
 	color: #888;
 }
 
+table tbody tr.winner { background-color: #fe5; }
+table tbody tr.winner:hover { background-color: #dc3; }
+table tbody tr.successful { background-color: #9e9; }
+table tbody tr.successful:hover { background-color: #7c7; }
+
 nav#main-menu {
 	display: flex;
 	flex-wrap: wrap;
-- 
GitLab