diff --git a/db/db.ddl b/db/db.ddl index 01b95a95bcd602355f97dd7647f0b8b77e79f0d3..ae71eae76c401b591c1141c55d4aa1514e736f58 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -83,6 +83,11 @@ CREATE TYPE round_state AS ENUM ( -- jen se odevzdaná řešení zobrazují jako opožděná. ); +CREATE TYPE score_mode AS ENUM ( + 'basic', -- základní mód výsledkovky se sdílenými místy + 'mo' -- jednoznačné pořadí podle pravidel MO +); + CREATE TABLE rounds ( round_id serial PRIMARY KEY, year int NOT NULL, -- ročník MO @@ -96,6 +101,9 @@ CREATE TABLE rounds ( ct_submit_end timestamp with time zone DEFAULT NULL, -- do kdy účastníci mohou regulérně odevzdávat pr_tasks_start timestamp with time zone DEFAULT NULL, -- od kdy dozor vidí zadání pr_submit_end timestamp with time zone DEFAULT NULL, -- do kdy dozor může regulérně odevzdávat + score_mode score_mode NOT NULL DEFAULT 'basic', -- mód výsledkovky + score_winner_limit int DEFAULT NULL, -- bodový limit na označení za vítěze + score_successful_limit int DEFAULT NULL, -- bodový limit na označení za úspěšného řešitele UNIQUE (year, category, seq) ); @@ -238,7 +246,7 @@ CREATE TABLE log ( log_entry_id serial PRIMARY KEY, changed_by int REFERENCES users(user_id), -- kdo změnu provedl changed_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, -- a kdy - type log_type NOT NULL, + type log_type NOT NULL, id int NOT NULL, -- jakého záznamu se změna týká details jsonb NOT NULL -- detaily (závislé na typu) ); diff --git a/db/upgrade-20210221.sql b/db/upgrade-20210221.sql new file mode 100644 index 0000000000000000000000000000000000000000..6cc6ad17a9bb78e94a897aa4d41fec200330c58d --- /dev/null +++ b/db/upgrade-20210221.sql @@ -0,0 +1,11 @@ +SET ROLE 'mo_osmo'; + +CREATE TYPE score_mode AS ENUM ( + 'basic', -- základní mód výsledkovky se sdílenými místy + 'mo' -- jednoznačné pořadí podle pravidel MO +); + +ALTER TABLE rounds + ADD COLUMN score_mode score_mode NOT NULL DEFAULT 'basic', -- mód výsledkovky + ADD COLUMN score_winner_limit int DEFAULT NULL, -- bodový limit na označení za vítěze + ADD COLUMN score_successful_limit int DEFAULT NULL; -- bodový limit na označení za úspěšného řešitele diff --git a/mo/db.py b/mo/db.py index 19b573e1c0c4613f40484c7f47d508bd478a7d83..95184e96b0eb92f939fdc8199f61aa3407b3dc29 100644 --- a/mo/db.py +++ b/mo/db.py @@ -177,6 +177,20 @@ round_state_names = { } +class RoundScoreMode(MOEnum): + basic = auto() + mo = auto() + + def friendly_name(self) -> str: + return round_score_mode_names[self] + + +round_score_mode_names = { + RoundScoreMode.basic: "Základní se sdílenými místy", + RoundScoreMode.mo: "Jednoznačné pořadí podle pravidel MO", +} + + class Round(Base): __tablename__ = 'rounds' __table_args__ = ( @@ -195,6 +209,9 @@ class Round(Base): ct_submit_end = Column(DateTime(True)) pr_tasks_start = Column(DateTime(True)) pr_submit_end = Column(DateTime(True)) + score_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False, server_default=text("'basic'::score_mode")) + score_winner_limit = Column(Integer) + score_successful_limit = Column(Integer) def round_code(self): return f"{self.year}-{self.category}-{self.seq}" diff --git a/mo/score.py b/mo/score.py new file mode 100644 index 0000000000000000000000000000000000000000..b7f4ed5d36fbf561fc29d520d02dd50f3a4e6448 --- /dev/null +++ b/mo/score.py @@ -0,0 +1,383 @@ +from fractions import Fraction +from sqlalchemy import and_ +from sqlalchemy.orm import joinedload +from sqlalchemy.orm.query import Query +from sqlalchemy.sql.expression import select +from typing import Any, List, Tuple, Optional, Dict + +import mo.db as db +from mo.util import normalize_grade + + +class ScoreOrder: + place: int + span: int # u nedělených míst 1, u dělených počet spojených míst (2.-5. -> span 4) + continuation: bool # jestli je pokračováním místa o jedno předcházející + + def __init__(self, place: int, span: int = 1, continuation: bool = False): + self.place = place + self.span = span + self.continuation = continuation + + +class ScoreResult: + user: db.User + pant: db.Participant + pion: db.Participation + order: ScoreOrder + successful: bool + winner: bool + + # Řešení jednotlivých kol (pro některá řazení je potřeba znát i výsledky + # z předcházejících kol). První index je krok (0 = toto kolo, 1 = předcházející, ...) + # a druhý index je task_id z db.Solution. + _sols: Dict[int, Dict[int, db.Solution]] + # Třídící klíč je n-tice klíčů podle kterých třídit, všechny klíče tříděny + # vzestupně (pro sestupné třídění podle čísla je potřeba klíč vynásobit -1) + _order_key: List[Any] + + def __init__(self, user: db.User, pant: db.Participant, pion: db.Participation): + self.user = user + self.pant = pant + self.pion = pion + + self._sols = {} + self.order = 1 + self.place_span = 1 + self.place_continuation = False + self.winner = False + self.successful = False + self._order_key = [] + + def get_sols(self) -> List[db.Solution]: + return list(self._sols[0].values()) + + def get_sols_map(self) -> Dict[int, db.Solution]: + return self._sols[0] + + def get_total_points(self) -> int: + sum = 0 + for sol in self.get_sols(): + if sol.points: + sum += sol.points + return sum + + +class ScoreTask: + task: db.Task + num_solutions: int + sum_points: int + + def __init__(self, task: db.Task): + self.task = task + self.num_solutions = 0 + self.sum_points = 0 + + def get_difficulty(self) -> Fraction: + if self.num_solutions == 0: + return 0 + return Fraction(self.sum_points, self.num_solutions) + + +class Score: + round: db.Round + contest: Optional[db.Contest] + part_states: List[db.PartState] + + # Řádky výsledkovky + _results: Dict[int, ScoreResult] + # Úlohy jednotlivých kol (pro některá řazení je potřeba znát i úlohy + # z předcházejících kol. První index je krok (0 = toto kolo, 1 = předcházející, ...) + # a druhý index je task_id z db.Task. + _tasks: Dict[int, Dict[int, ScoreTask]] + # Seznam předcházejících kol indexovaných krokem výpočtu (0 = toto kolo, 1 = předcházející, ...) + _prev_rounds: Dict[int, db.Round] + + # Zprávy o tvorbě výsledkovky, dvojice (typ, zprávy) kde typ může být info, warning nebo error + _messages: List[Tuple[str, str]] + + def __init__( + self, round: db.Round, contest: Optional[db.Contest] = None, + # Ze kterých stavů chceme výsledkovku počítat + part_states: List[db.PartState] = [db.PartState.registered, db.PartState.invited, db.PartState.present], + ): + self.round = round + self.contest = contest + self.part_states = part_states + + # Příprava subquery na účastníky + sess = db.get_session() + if contest: + contest_subq = [contest.contest_id] + else: + contest_subq = sess.query(db.Contest.contest_id).filter_by(round=round) + + # Načtení účastníků + data: List[Tuple[db.User, db.Participation, db.Participant]] = ( + sess.query(db.User, db.Participation, db.Participant) + .select_from(db.Participation) + .join(db.User) + .join(db.Participant, and_( + db.Participant.user_id == db.Participation.user_id, + db.Participant.year == round.year + ) + ).filter( + db.User.is_test == False, + db.Participation.state.in_(part_states), + db.Participation.contest_id.in_(contest_subq) + ).options( + joinedload(db.Participant.school_place), + joinedload(db.Participation.contest).joinedload(db.Contest.place), + ).all() + ) + self._results = {} + for user, pion, pant in data: + self._results[user.user_id] = ScoreResult(user, pant, pion) + + # Načtení úloh a řešení + self._prev_rounds = {0: round} + self._tasks = {} + self._messages = [] + self._load_tasks_and_sols(0, round, contest_subq) + self._mark_winners() + + def _load_tasks_and_sols(self, step: int, round: db.Round, contest_subq: Query): + """Obecná funkce na načtení úloh a řešení tohoto nebo předchozího kola""" + if step in self._tasks: + return + 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.in_(self.part_states), + db.Participation.contest_id.in_(contest_subq) + ) + + # Vyrobení polí + self._tasks[step] = {} + for result in self._results.values(): + result._sols[step] = {} + + # Spočítání počtu řešitelů + num_participants = db.get_count(user_id_subq) + + # Načtení úloh + tasks: List[db.Task] = sess.query(db.Task).filter_by(round=round).all() + for task in tasks: + self._tasks[step][task.task_id] = ScoreTask(task) + self._tasks[step][task.task_id].num_solutions = num_participants + + # Načtení řešení + task_ids = list(self._tasks[step].keys()) + sols: List[db.Solution] = sess.query(db.Solution).filter( + db.Solution.user_id.in_(user_id_subq), + db.Solution.task_id.in_(task_ids), + ).all() + for sol in sols: + if sol.user_id in self._results: + self._results[sol.user_id]._sols[step][sol.task_id] = sol + if sol.points: + self._tasks[step][sol.task_id].sum_points += sol.points + + def _mark_winners(self): + for result in self._results.values(): + total_points = result.get_total_points() + result.winner = ( + self.round.score_winner_limit is not None + and total_points >= self.round.score_winner_limit + ) + result.successful = ( + self.round.score_successful_limit is not None + and total_points >= self.round.score_successful_limit + ) + + def _load_prev_round(self, step: int) -> bool: + """Načtení úloh a řešení předchozího kola, pokud takové existuje.""" + if step in self._tasks: + return True + sess = db.get_session() + + # Zkusíme nalézt kolo o `step` kroků zpět + prev_round = sess.query(db.Round).filter_by( + year=self.round.year, category=self.round.category, seq=self.round.seq - step + ).one_or_none() + if prev_round is None: + return False + self._prev_rounds[step] = prev_round + + if self.contest: + # Pokud tvoříme výsledkovku pro contest, tak nás zajímají jen řešení + # z podoblastí contestu spadajícího pod hlavní + desc_cte = db.place_descendant_cte(self.contest.place, max_level=prev_round.level) + contest_subq = sess.query(db.Contest.contest_id).filter( + db.Contest.round == prev_round, + db.Contest.place_id.in_(select([desc_cte])) + ) + else: + # Pokud vytváříme výsledkovku pro celé kolo, bereme vše + contest_subq = sess.query(db.Contest.contest_id).filter_by(round=prev_round) + + self._load_tasks_and_sols(step, prev_round, contest_subq) + return True + + def get_tasks(self) -> List[db.Task]: + tasks = [] + for task in self._tasks[0].values(): + tasks.append(task.task) + return list(sorted(tasks, key=lambda task: task.code)) + + def _add_message(self, type: str, message: str): + self._messages.append((type, message)) + + def get_messages(self) -> List[Tuple[str, str]]: + return self._messages + + def get_sorted_results(self) -> List[ScoreResult]: + # Vygenerování třídícího klíče podle módu výsledkovky + if self.round.score_mode == db.RoundScoreMode.basic: + # Základní mód - jen podle celkových bodů, se sdílenými místy + for result in self._results.values(): + result._order_key = [-result.get_total_points()] + + elif self.round.score_mode == db.RoundScoreMode.mo: + self._add_mo_order_key() + else: + assert False + + # Kvůli pevnému pořadí při sdílených místech přidáme k finálnímu třídění + # ještě i příjmení, jméno a user_id + results: List[ScoreResult] = sorted(self._results.values(), key=lambda result: ( + result._order_key, result.user.last_name, result.user.first_name, result.user.user_id + )) + last: ScoreResult = None + # Spočítáme pořadí - v prvním kroku prolinkujeme opakující se ScoreOrder na první, + # ve druhém kroku je pak správně rozkopírujeme s nastaveným continuation na True + for result in results: + if last is None: + result.order = ScoreOrder(1) + last = result + elif last._order_key == result._order_key: + result.order = last.order + last.order.span += 1 + else: + result.order = ScoreOrder(last.order.place + last.order.span) + last = result + lastOrder: ScoreOrder = None + for result in results: + if result.order == lastOrder: + result.order = ScoreOrder(lastOrder.place, lastOrder.span, True) + else: + lastOrder = result.order + + return results + + def _exists_same_order_key(self) -> bool: + last = None + for result in sorted(self._results.values(), key=lambda result: result._order_key): + if result._order_key == last: + return True + last = result._order_key + return False + + def _add_mo_order_key(self): + """Jednoznačný mód MO, iterujeme po krocích dokud buď nevyrobíme + výsledkovku bez sdílených míst, nebo už neexistuje předchozí kolo, + na které bychom se ještě mohli podívat.""" + step = 0 + while True: + if self._load_prev_round(step) is False: + self._add_message("info", "I po započítání všech předchozích kol stále existují sdílená místa, řadím podle ročníku") + break + + if step != 0: + self._add_message( + "info", + f"Výpočet na úrovni kola {self._prev_rounds[step-1].round_code()} nestačil," + + f" započítávám body z kola {self._prev_rounds[step].round_code()}" + ) + + tasks_by_difficulty = sorted( + self._tasks[step].keys(), + key=lambda task_id: (self._tasks[step][task_id].get_difficulty(), self._tasks[step][task_id].task.code), + ) + last_task: ScoreTask = None + last_difficulty: Fraction = None + difficulty_report = [] + for task_id in tasks_by_difficulty: + task = self._tasks[step][task_id] + difficulty = task.get_difficulty() + if last_task is not None and difficulty == last_difficulty and difficulty != 0: + self._add_message( + "warning", + f"Úlohy {last_task.task.code} a {task.task.code} mají stejnou vypočtenou obtížnost " + + f" {difficulty}, pro výpočet obtížnosti je řadím podle kódu úlohy" + ) + difficulty_report.append(f"{task.task.code} ({round(float(difficulty), 2)} b)") + last_task, last_difficulty = task, difficulty + + self._add_message( + "info", + f"Průměrné body úloh kola {self._prev_rounds[step].round_code()} od nejobtížnější: " + + ", ".join(difficulty_report) + ) + + for result in self._results.values(): + sol_points = {} + for task_id in self._tasks[step].keys(): + sol_points[task_id] = 0 + for sol in result._sols[0].values(): + if sol.points: + sol_points[sol.task_id] = -sol.points # sestupné třídění + + total_points = sum(sol_points.values()) + points_from_max = list(sorted(sol_points.values())) + points_from_difficult = [sol_points[task_id] for task_id in tasks_by_difficulty] + + # Primárně podle počtu získaných bodů, sekundárně podle bodů od maxima, terciárně podle bodů od nejobtížnější + result._order_key.extend((total_points, points_from_max, points_from_difficult)) + + # Otestujeme, jestli teď existují sdílená místa + if not self._exists_same_order_key(): + break + # Pokud jsme našli stejný klíč, opakujeme cyklus s minulým kolem + step += 1 + + # Na konec třídícího klíče přidáme ročník (menší ročník první) + for result in self._results.values(): + grade = normalize_grade(result.pant.grade) + if grade == -1: + self._add_message( + "warning", + f"Účastník {result.user.first_name} {result.user.last_name} má neplatný ročník {result.pant.grade}" + ) + result._order_key.append(grade) + + if self._exists_same_order_key(): + self._add_message( + "error", + "I po započítání všech úloh (včetně minulých kol) a ročníků účastníků existují sdílená místa. Je potřeba určit pořadí losem" + ) + + # Další kontroly + # Pokud kontrolujeme výsledkovku pro celé kolo + if self.contest is None: + winners = 0 + successfulls = 0 + participants = len(self._results) + for result in self._results.values(): + if result.winner: + winners += 1 + if result.successful: + successfulls += 1 + if successfulls > participants/2: + self._add_message( + "error", + f"Počet úspěšných řešitelů ({successfulls}) převyšuje polovinu celkového počtu účastníků ({participants})" + ) + if winners > successfulls/2: + self._add_message( + "error", + f"Počet vítězů ({winners}) převyšuje polovinu počtu úspěšných řešitelů ({successfulls})" + ) diff --git a/mo/util.py b/mo/util.py index 7021284e21d63b3c613075e0dd375508a1859d06..aaf520c6d5d01c2eef81b4bd87e12957ec2e4a14 100644 --- a/mo/util.py +++ b/mo/util.py @@ -184,3 +184,25 @@ def unlink_if_exists(name: str): os.unlink(name) except FileNotFoundError: pass + + +def normalize_grade(grade: str) -> int: + """Pokusí se převést třídu ve formátu 7 nebo 3/4 na číslo odpovídající + třídě na základní škole (maturitní ročník gymnázia je tedy 9+4 = 13). + * Základní škola: nic + * Gymnázia: /8, /6 nebo /4 + * Nerozpoznané ročníky a chyby při převodu: -1""" + try: + parts = grade.split('/') + if len(parts) == 1: + return int(parts[0]) + if len(parts) > 2: + return -1 + year = int(parts[0]) + school_type = int(parts[1]) + if school_type in (8, 6, 4): + return year + 13 - school_type + else: + return -1 + except ValueError: + return -1 diff --git a/mo/web/jinja.py b/mo/web/jinja.py index 0e786a08965c4baeff8cf2b7e9f933d13ee20cac..893ac8ca01a617687a00fc49f20498b8843d7c73 100644 --- a/mo/web/jinja.py +++ b/mo/web/jinja.py @@ -72,6 +72,11 @@ def or_dash(s: Any) -> str: return str(s) if s else '–' +@app.template_filter() +def none_value(s: Any, none_value: Any) -> Any: + return none_value if s is None else s + + @app.template_filter() def json_pretty(js: Any) -> str: return json.dumps(js, sort_keys=True, indent=4, ensure_ascii=False) diff --git a/mo/web/org_round.py b/mo/web/org_round.py index 7e86f508f75e4886a3b6b5b76889140678c98dec..b66650966c95f99f263246704bfdd8043753c168 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -8,6 +8,7 @@ from typing import Optional, Tuple import werkzeug.exceptions import wtforms from wtforms import validators +from wtforms.fields.html5 import IntegerField import mo import mo.db as db @@ -360,6 +361,15 @@ class RoundEditForm(FlaskForm): pr_tasks_start = MODateTimeField("Čas zveřejnění úloh pro dozor", validators=[validators.Optional()]) ct_submit_end = MODateTimeField("Konec odevzdávání pro účastníky", validators=[validators.Optional()]) pr_submit_end = MODateTimeField("Konec odevzdávání pro dozor", validators=[validators.Optional()]) + score_mode = wtforms.SelectField("Výsledková listina", choices=db.RoundScoreMode.choices(), coerce=db.RoundScoreMode.coerce) + score_winner_limit = IntegerField( + "Hranice bodů pro vítěze", validators=[validators.Optional()], + description="Řešitelé s alespoň tolika body budou označeni za vítěze, prázdná hodnota = žádné neoznačovat", + ) + score_successful_limit = IntegerField( + "Hranice bodů pro úspěšné řešitele", validators=[validators.Optional()], + description="Řešitelé s alespoň tolika body budou označeni za úspěšné řešitele, prázdná hodnota = žádné neoznačovat", + ) submit = wtforms.SubmitField('Uložit') diff --git a/mo/web/org_score.py b/mo/web/org_score.py index c20df96183a37c63973996017c08d92ac09d6b99..4925de52983b4623069b642da1d8207bc0d6bc4a 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_round.html b/mo/web/templates/org_round.html index 9f6d685c9e13769ad89564da968fed8236a2dced..678a29209b41a099d4af898e38f81e0df83efff9 100644 --- a/mo/web/templates/org_round.html +++ b/mo/web/templates/org_round.html @@ -30,6 +30,9 @@ {% else %} – {% endif %} + <tr><td>Výsledková listina<td>{{ round.score_mode.friendly_name() }} + <tr><td>Hranice bodů pro vítěze<td>{{ round.score_winner_limit|none_value(Markup('<i>nenastaveno</i>')) }} + <tr><td>Hranice bodů pro úspěšné řešitele<td>{{ round.score_successful_limit|none_value(Markup('<i>nenastaveno</i>')) }} </table> <div class="btn-group"> diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html index 27bd42a5f09e75739766a69b8ef7237a71d4b153..15ae4bebd5e42a2953787808516afefd50b9be27 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 8978f2cd51d677e19ab53875e9175b7793006476..1eda5be8253ae8b24cc5c04f31fada18a84d6bd6 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; @@ -330,3 +335,41 @@ div.alert + div.alert { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + +/* Collapsible */ + +.collapsible { + position: relative; +} +.collapsible input[type="checkbox"].toggle { + position: absolute; + top: 0; + left: 0; + opacity: 0; +} +.collapsible label.toggle { + cursor: pointer; + margin-left: 15px; +} +.collapsible label.toggle::before { + position: absolute; + content: ""; + width: 0; + height: 0; + left: 0px; + border-left: 8px solid black; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + transition: 0.5s ease; +} +.collapsible input[type="checkbox"].toggle:checked ~ label.toggle::before { + transform: rotate(90deg); +} +.collapsible .collapsible-inner { + max-height: 0; + overflow-y: hidden; + transition: 0.5s ease; +} +.collapsible input[type="checkbox"].toggle:checked ~ .collapsible-inner { + max-height: 100vh; +}