diff --git a/bin/p-score b/bin/p-score new file mode 100755 index 0000000000000000000000000000000000000000..cd15d5ce30ccbfe08726e41bccd0042252b8a116 --- /dev/null +++ b/bin/p-score @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# Generátor výsledkové listiny pro MO-P + +import argparse +from sqlalchemy.orm import joinedload + +import mo.db as db +from mo.score import Score +from mo.util import die, init_standalone + +parser = argparse.ArgumentParser(description='Vygeneruje výsledkovou listinu MO-P') +parser.add_argument('year', type=int) +parser.add_argument('seq', type=int) + +args = parser.parse_args() +init_standalone() +sess = db.get_session() + + +def get_results(round, contests): + results = {} + for contest in contests: + place_code = contest.place.get_code() + print(f"Počítám oblast {place_code}") + + score = Score(round, contest) + results[place_code] = score.get_sorted_results() + + for msg in score.get_messages(): + if msg[0] != 'info': + print(f'\t{msg[0].upper()}: {msg[1]}') + + return results + + +def write_tex(round, tasks, contests, results): + with open('final.tex', 'w') as out: + out.write(r'\def\HranicePostupu{%s}' % (round.score_winner_limit,) + "\n") + out.write(r'\def\HraniceUspesnychResitelu{%s}' % (round.score_successful_limit,) + "\n") + out.write('\n') + for c in contests: + res = results[c.place.get_code()] + if round.seq == 2: + out.write(r'\kraj{%s}' % c.place.name + '\n') + if not res: + out.write(r'\nobody' + '\n') + out.write(r'\endkraj' + '\n\n') + continue + + out.write(r'\begintable' + '\n') + prev_typ = "" + + for r in res: + if r.winner: + typ = 'v' + elif r.successful: + typ = 'u' + else: + typ = 'n' + if typ != prev_typ: + if prev_typ: + out.write(r'\sep%s' % typ) + prev_typ = typ + out.write(r'\%s' % typ) + + cols = [] + o = r.order + if not r.successful or o.continuation: + cols.append("") + elif o.span > 1: + cols.append(f'{o.place}.--{o.place + o.span - 1}.') + else: + cols.append(f'{o.place}.') + + cols.append(r.user.full_name()) + cols.append(r.pant.school_place.name) + cols.append(r.pant.grade) + + sol_map = r.get_sols_map() + for t in tasks: + s = sol_map.get(t.task_id) + if s is not None: + cols.append(s.points) + else: + cols.append('--') + + cols.append(r.get_total_points()) + + out.write("".join(['{' + str(col) + '}' for col in cols]) + '\n') + + out.write(r'\endtable' + '\n') + if round.seq == 2: + out.write(r'\endkraj' + '\n\n') + + +def write_html(round, tasks, contests, results): + num_cols = 4 + len(tasks) + 1 + with open('final.html', 'w') as out: + for c in contests: + out.write(f'<tr><th colspan={num_cols}>{c.place.name}\n') + + res = results[c.place.get_code()] + if not res: + out.write(f'<tr class=nobody><td colspan={num_cols}>Nikdo se nezúčastnil.\n') + out.write(f'<tr><td colspan={num_cols}>\n\n') + continue + + for r in res: + if r.winner: + out.write('<tr class=marked>') + elif r.successful: + out.write('<tr class=success>') + else: + out.write('<tr>') + + cols = [] + o = r.order + if not r.successful or o.continuation: + cols.append("") + elif o.span > 1: + cols.append(f'{o.place}.–{o.place + o.span - 1}.') + else: + cols.append(f'{o.place}.') + + cols.append(r.user.full_name()) + cols.append(r.pant.school_place.name) + cols.append(r.pant.grade) + + sol_map = r.get_sols_map() + for t in tasks: + s = sol_map.get(t.task_id) + if s is not None: + cols.append(s.points) + else: + cols.append('–') + + cols.append(r.get_total_points()) + + out.write("".join(['<td>' + str(col) for col in cols]) + '\n') + + out.write(f'<tr><td colspan={num_cols}>\n\n') + + +round = sess.query(db.Round).filter_by(year=args.year, category='P', seq=args.seq).one() +print(f"Kolo {round.round_code()}") + +tasks = sess.query(db.Task).filter_by(round=round).order_by(db.Task.code).all() + +contests = (sess.query(db.Contest) + .filter_by(round=round) + .options(joinedload(db.Contest.place)) + .all()) +assert contests +contests.sort(key=lambda c: c.place.get_code()) + +results = get_results(round, contests) +write_tex(round, tasks, contests, results) +write_html(round, tasks, contests, results) diff --git a/mo/db.py b/mo/db.py index 769e839d8e6e251ea413d37bc984d35964387890..1aa61c26d2acb1657f124280411d6057f6cdc5be 100644 --- a/mo/db.py +++ b/mo/db.py @@ -291,6 +291,9 @@ class User(Base): def sort_key(self) -> Tuple[str, str, int]: return (locale.strxfrm(self.last_name), locale.strxfrm(self.first_name), self.user_id) + def name_sort_key(self) -> Tuple[str, str]: + return (locale.strxfrm(self.last_name), locale.strxfrm(self.first_name)) + class Contest(Base): __tablename__ = 'contests' diff --git a/mo/score.py b/mo/score.py index aba2ffe7517ffe8c92f4e21e9bbe1ff82896ebda..8d7280eac8c539f5d91d79f43fa7c4a9d1a1bfb4 100644 --- a/mo/score.py +++ b/mo/score.py @@ -3,10 +3,11 @@ 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 +from typing import Any, List, Tuple, Optional, Dict, Union import mo.db as db from mo.util import normalize_grade +from mo.util_format import inflect_with_number class ScoreOrder: @@ -36,15 +37,15 @@ class ScoreResult: # vzestupně (pro sestupné třídění podle čísla je potřeba klíč vynásobit -1) _order_key: List[Any] + _null_score_order = ScoreOrder(0) + 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.order = ScoreResult._null_score_order self.winner = False self.successful = False self._order_key = [] @@ -75,14 +76,19 @@ class ScoreTask: def get_difficulty(self) -> Fraction: if self.num_solutions == 0: - return 0 + return Fraction(0) return Fraction(self.sum_points, self.num_solutions) + def get_difficulty_str(self) -> str: + return f'{self.sum_points}/{self.num_solutions}' + class Score: round: db.Round contest: Optional[db.Contest] part_states: List[db.PartState] + want_successful: bool + want_winners: bool # Řádky výsledkovky _results: Dict[int, ScoreResult] @@ -104,10 +110,13 @@ class Score: self.round = round self.contest = contest self.part_states = part_states + self.want_successful = round.score_successful_limit is not None + self.want_winners = round.score_winner_limit is not None # Příprava subquery na účastníky (contest_subq obsahuje master_contest_id) sess = db.get_session() if contest: + assert contest.master_contest_id is not None contest_subq = [contest.master_contest_id] else: contest_subq = sess.query(db.Contest.master_contest_id).filter_by(round=round) @@ -118,10 +127,9 @@ class Score: .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.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) @@ -141,7 +149,19 @@ class Score: 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): + # Vynecháme účastníky, kteří nic neodevzdali + to_remove = [] + for user_id, results in self._results.items(): + if not results._sols[0]: + to_remove.append(user_id) + if to_remove: + self._add_message('info', + inflect_with_number(len(to_remove), 'Vynechán %s soutěžící', 'Vynecháni %s soutěžící', 'Vynecháno %s soutěžících') + + ' bez odevzdaných řešení.') + for user_id in to_remove: + self._results.pop(user_id) + + def _load_tasks_and_sols(self, step: int, round: db.Round, contest_subq: Union[Query, List[int]]): """Obecná funkce na načtení úloh a řešení tohoto nebo předchozího kola""" if step in self._tasks: return @@ -186,14 +206,8 @@ class Score: 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 - ) + result.winner = self.want_winners and total_points >= self.round.score_winner_limit + result.successful = self.want_successful 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.""" @@ -253,7 +267,7 @@ class Score: 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 + last: Optional[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: @@ -266,7 +280,7 @@ class Score: else: result.order = ScoreOrder(last.order.place + last.order.span) last = result - lastOrder: ScoreOrder = None + lastOrder: Optional[ScoreOrder] = None for result in results: if result.order == lastOrder: result.order = ScoreOrder(lastOrder.place, lastOrder.span, True) @@ -313,10 +327,10 @@ class Score: 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"Ú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)") + difficulty_report.append(f"{task.task.code} ({task.get_difficulty_str()}={float(difficulty):.2f})") last_task, last_difficulty = task, difficulty self._add_message( @@ -337,8 +351,12 @@ class Score: 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)) + if result.successful or not self.want_successful: + # 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)) + else: + # Neúspěšné řešitele třídíme podle počtu získaných bodů, sekundárně podle jména + result._order_key.extend((total_points, result.user.name_sort_key())) # Otestujeme, jestli teď existují sdílená místa if not self._exists_same_order_key(): @@ -373,12 +391,12 @@ class Score: winners += 1 if result.successful: successfulls += 1 - if successfulls > participants/2: + 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: + 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_format.py b/mo/util_format.py index d1b83905c11fe8fef9e59c3db63166c2ed9802a2..50e0f43457b6bb887b2935a40c68da9b3d3300f2 100644 --- a/mo/util_format.py +++ b/mo/util_format.py @@ -24,6 +24,15 @@ def inflect_by_number(n: int, w1: str, w234: str, wother: str, unitprefix: str = return f'{unitprefix}{wother}' +def inflect_with_number(n: int, w1: str, w234: str, wother: str) -> str: + if n == 1: + return w1 % n + elif 2 <= n <= 4: + return w234 % n + else: + return wother % n + + def timeformat(dt: datetime) -> str: if dt is None: return '–' diff --git a/mo/web/org_score.py b/mo/web/org_score.py index 190e5e44911f393116c8f34d6597b0c15a440b87..d0613d2aaae9b55efccc80d830b05af6b3106fdd 100644 --- a/mo/web/org_score.py +++ b/mo/web/org_score.py @@ -41,7 +41,7 @@ class SolPointsCell(Cell): user: db.User sol: Optional[db.Solution] - def __init__(self, contest_id: int, user: db.User, sol: db.Solution): + def __init__(self, contest_id: int, user: db.User, sol: Optional[db.Solution]): self.contest_id = contest_id self.user = user self.sol = sol @@ -117,8 +117,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): 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='status', name='stav')) columns.append(Column(key='participant', name='ucastnik', title='Účastník')) if is_export: columns.append(Column(key='email', name='email')) @@ -152,21 +151,35 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): user, pant, pion = result.user, result.pant, result.pion school = pant.school_place local_pion_ct_id = subcontest_id_map[(round.round_id, pion.contest_id)] + + order_cell: Union[Cell, str] + if result.successful or not score.want_successful: + order_cell = OrderCell(result.order.place, result.order.span, result.order.continuation) + else: + order_cell = "" + + if result.winner: + status = 'vítěz' + elif result.successful: + status = 'úspěšný' + else: + status = "" + 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 '', + 'order': order_cell, + 'status': status, 'user': user, 'email': user.email, 'participant': cell_pion_link(user, local_pion_ct_id, user.full_name()), - 'contest': CellLink(pion.contest.place.name, url_for('org_contest', id=pion.contest_id)), + 'contest': CellLink(pion.contest.place.name or "?", url_for('org_contest', id=pion.contest_id)), 'pion_place': pion.place.name, - 'school': CellLink(school.name, url_for('org_place', id=school.place_id)), + 'school': CellLink(school.name or "?", url_for('org_place', id=school.place_id)), 'grade': pant.grade, '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: local_sol_ct_id = subcontest_id_map[(task.round_id, pion.contest_id)] @@ -180,7 +193,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): table_rows.append(row) filename = f"vysledky_{round.year}-{round.category}-{round.level}" - if contest_id: + if contest: filename += f"_oblast_{contest.place.code or contest.place.place_id}" table = Table( table_class="data full center",