diff --git a/bin/test-pdf-score b/bin/test-pdf-score new file mode 100755 index 0000000000000000000000000000000000000000..6181a062599b1b18c2fead236a8604652769c106 --- /dev/null +++ b/bin/test-pdf-score @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +import argparse + +import mo.db as db +import mo.jobs.score +from mo.score import Score +from mo.util import init_standalone +import os +import shutil +from sqlalchemy.orm import joinedload + +parser = argparse.ArgumentParser(description='Testuje generování výsledkových listin v PDF') +parser.add_argument('--contest', type=int, metavar='ID', help='soutěž') +parser.add_argument('--year', type=int, metavar='ID', help='ročník') + +args = parser.parse_args() + +init_standalone() +sess = db.get_session() + +ctq = sess.query(db.Contest).join(db.Contest.round) +if args.contest is not None: + ctq = ctq.filter(db.Contest.contest_id == args.contest) +else: + ctq = ctq.filter(db.Round.round_type != db.RoundType.other) +if args.year is not None: + ctq = ctq.filter(db.Round.year == args.year) + +contests = ctq.options(joinedload(db.Contest.round)).all() + +for ct in contests: + r = ct.round + print(f'#{ct.contest_id}: {r.round_code_short()} ({ct.place.get_code()})') + + score = Score(r.master, ct) + results = score.get_sorted_results() + + for severity, msg in score.get_messages(): + if severity != 'info': + print(f'\t{severity}: {msg}') + + temp_dir = f'tmp/score/{r.round_code_short()}/{ct.place.get_code()}' + shutil.rmtree(temp_dir, ignore_errors=True) + os.makedirs(temp_dir, exist_ok=True) + + pdf = mo.jobs.score.make_score_pdf(temp_dir, score, results) + + final = f'{temp_dir}.pdf' + try: + os.unlink(final) + except FileNotFoundError: + pass + os.link(pdf, final) + + print(f'\t=> {final}') diff --git a/db/db.ddl b/db/db.ddl index be72a0c8a6b2cb3f16c4ecacb34d1905b52645cd..2a4bbf191cfaa25763b9866d6ea5440048c9ba68 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -164,6 +164,7 @@ CREATE TABLE contests ( 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 + tex_hacks text NOT NULL DEFAULT '', -- speciální nastavení pro sazbu výsledkovky UNIQUE (round_id, place_id) ); diff --git a/db/upgrade-20220728.sql b/db/upgrade-20220728.sql new file mode 100644 index 0000000000000000000000000000000000000000..f0514049869632556d0c893ed868b518832c4743 --- /dev/null +++ b/db/upgrade-20220728.sql @@ -0,0 +1,4 @@ +SET ROLE 'mo_osmo'; + +ALTER TABLE contests ADD COLUMN + tex_hacks text NOT NULL DEFAULT ''; diff --git a/dev-requirements.txt b/dev-requirements.txt index c13db9a2fb4443e96789d5b7528ae07b7c1d26b6..821ea8324ecb0f07cbaf60ba19705100200aeccd 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -13,4 +13,7 @@ python-lsp-server rope sqlacodegen sqlalchemy-stubs +types-python-dateutil +types-requests +whatthepatch yapf diff --git a/mo/db.py b/mo/db.py index 3efc429f8ca1b30fc4c6e94668c8dc88ce14c839..f88b6b617f5f9c1e7edc955869d5618cc47fc46a 100644 --- a/mo/db.py +++ b/mo/db.py @@ -217,6 +217,9 @@ class RoundType(MOEnum): def friendly_name(self) -> str: return round_type_names[self] + def friendly_name_genitive(self) -> str: + return round_type_names_genitive[self] + def letter(self) -> Optional[str]: """Písmena se používají při exportu výsledkovky na web MO""" return round_type_letters[self] @@ -231,6 +234,15 @@ round_type_names = { RoundType.other: 'jiné', } +round_type_names_genitive = { + RoundType.domaci: 'domácího kola', + RoundType.skolni: 'školního kola', + RoundType.okresni: 'okresního kola', + RoundType.krajske: 'krajského kola', + RoundType.ustredni: 'ústředního kola', + RoundType.other: 'jiného kola', +} + round_type_letters = { RoundType.domaci: 'D', RoundType.skolni: 'S', @@ -439,6 +451,7 @@ class Contest(Base): 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) + tex_hacks = Column(Text, nullable=False, server_default=text("''::text")) master = relationship('Contest', primaryjoin='Contest.master_contest_id == Contest.contest_id', remote_side='Contest.contest_id', post_update=True) place = relationship('Place') diff --git a/mo/jobs/protocols.py b/mo/jobs/protocols.py index 63c58628f328b3b73ed4955c55604f6ceccae7a2..729b0e52ef711f8c30275435e1cfbe91c78f9e9f 100644 --- a/mo/jobs/protocols.py +++ b/mo/jobs/protocols.py @@ -18,7 +18,7 @@ import mo import mo.config as config import mo.db as db from mo.jobs import TheJob, job_handler -from mo.util import logger, part_path +from mo.util import logger, part_path, tex_arg import mo.util_format @@ -54,14 +54,6 @@ def schedule_create_protocols(contest: db.Contest, site: Optional[db.Place], for the_job.submit() -def tex_arg(s: str) -> str: - # Primitivní escapování do TeXu. Nesnaží se ani tak o věrnou intepretaci všech znaků, - # jako o zabránění pádu TeXu kvůli divným znakům. - s = re.sub(r'[\\{}#$%^~]', '?', s) - s = re.sub(r'([&_])', r'\\\1', s) - return '{' + s + '}' - - def _get_user_id_query(contest: db.Contest, site_id: Optional[int]) -> Query: q = db.get_session().query(db.Participation.user_id).filter( db.Participation.contest == contest, diff --git a/mo/jobs/score.py b/mo/jobs/score.py index 32f44a04a9c7eea6017d41f85ed01aa5d6ed9214..452e6662721570cd88d3272242e0249d284522ae 100644 --- a/mo/jobs/score.py +++ b/mo/jobs/score.py @@ -3,20 +3,22 @@ import decimal import json import os -import subprocess +import re import requests -from typing import Any, Dict, Iterable, Optional +import subprocess +from typing import Any, Dict, Iterable, Optional, List import mo import mo.config as config import mo.db as db from mo.jobs import TheJob, job_handler -from mo.score import Score +from mo.score import Score, ScoreResult +from mo.util import tex_arg, assert_not_none from mo.util_format import format_decimal # -# Job snapshot_score: Vytvoří kopii aktuální výsledkovky (TODO: a pdf k ní) +# Job snapshot_score: Vytvoří kopii aktuální výsledkovky # # Vstupní JSON: # { 'contest_id': int, @@ -45,17 +47,95 @@ class OrderKeyEncoder(json.JSONEncoder): return super().encode(o) -def make_snapshot_score_pdf(the_job: TheJob, score: db.ScoreTable) -> str: - job = the_job.job - contest = score.contest - round = contest.round +def order_to_tex(result: ScoreResult) -> str: + if not result.show_order: + return "" + order = result.order + if order.continuation: + return "" + elif order.span > 1: + return f'{order.place}.--{order.place + order.span -1}.' + else: + return f'{order.place}.' - temp_dir = job.dir_path() - tex_src = os.path.join(temp_dir, 'score.tex') +def parse_tex_hacks(tex_hacks: str) -> Dict[str, str]: + hacks = {} + fields = re.split(r'(\w+)={([^}]*)}', tex_hacks) + for i, f in enumerate(fields): + if i % 3 == 0: + if f.strip() != "": + raise RuntimeError('Chyba při parsování tex_hacks') + elif i % 3 == 1: + hacks[f] = fields[i + 1] + return hacks + + +def format_hacks(tex_hacks: str) -> str: + # Nemůžeme uživatelům dovolit předávat TeXu libovolné příkazy, + # protože bychom jim například zpřístupnili celý filesystem. + lines = [] + for k, v in parse_tex_hacks(tex_hacks).items(): + lines.append('\\hack' + k + tex_arg(v) + '\n') + return "".join(lines) + + +def make_score_pdf(temp_dir: str, score: Score, results: List[ScoreResult]) -> str: + # Pozor, toto se volá i z pomocných skriptů + contest = assert_not_none(score.contest) + round = contest.round + tasks = score.get_tasks() + + tex_src = os.path.join(temp_dir, 'score.tex') with open(tex_src, 'w') as f: - f.write('Zde bude výsledkovka\\bye\n') + f.write('\\input vysledky.tex\n\n') + + hlavicka = f'Výsledková listina {round.round_type.friendly_name_genitive()} {round.year}. ročníku MO kategorie {round.category}' + f.write('\\def\\hlavicka' + tex_arg(hlavicka) + '\n') + if round.level > 0 and contest.place.name: + f.write('\\def\\misto' + tex_arg(contest.place.name) + '\n') + f.write(format_hacks(contest.tex_hacks)) + f.write('\n') + + last_type = "" + + f.write('\\scoretable{\n') + for result in score.get_sorted_results(): + if result.winner: + type = 'winner' + elif result.successful: + type = 'successful' + else: + type = 'normal' + + if type != last_type: + f.write('\\section' + tex_arg(type) + '\n') + last_type = type + + cols = [ + order_to_tex(result), + result.user.full_name(), + result.pant.grade, + result.pant.school_place.name, + ] + f.write('\\row' + "".join(map(tex_arg, cols))) + + f.write('{') + sols = result.get_sols_map() + for task in tasks: + sol = sols.get(task.task_id) + if sol is not None and sol.points is not None: + pts = assert_not_none(format_decimal(sol.points)) + else: + pts = '\\n' + f.write('\\p{' + pts + '}') + f.write('}') + + f.write(tex_arg(format_decimal(result.get_total_points())) + '\n') + + f.write('}\n') + f.write('\n\\bye\n') env = dict(os.environ) env['TEXINPUTS'] = mo.util.part_path('tex') + '//:' @@ -69,10 +149,17 @@ def make_snapshot_score_pdf(the_job: TheJob, score: db.ScoreTable) -> str: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - dir = str(contest.contest_id) + + return os.path.join(temp_dir, 'score.pdf') + + +def make_snapshot_score_pdf(the_job: TheJob, contest: db.Contest, score: Score, results: List[ScoreResult]) -> str: + temp_dir = the_job.job.dir_path() + temp_pdf = make_score_pdf(temp_dir, score, results) + dir = contest.round.round_code_short() full_dir = os.path.join(mo.util.data_dir('score'), dir) os.makedirs(full_dir, exist_ok=True) - full_name = mo.util.link_to_dir(os.path.join(temp_dir, 'score.pdf'), full_dir, suffix='.pdf') + full_name = mo.util.link_to_dir(temp_pdf, full_dir, suffix='.pdf') return os.path.join(dir, os.path.basename(full_name)) @@ -96,9 +183,9 @@ def handle_snapshot_score(the_job: TheJob): snapshot_rows = [] for result in results: order: Optional[dict] = None - if result.successful or not score.want_successful: + if result.show_order: order = result.order.__dict__ - snapshot_row = { + snapshot_row: Dict[str, Any] = { 'order': order, 'winner': result.winner, 'successful': result.successful, @@ -125,7 +212,7 @@ def handle_snapshot_score(the_job: TheJob): } - pdf_file = make_snapshot_score_pdf(the_job, score) + pdf_file = make_snapshot_score_pdf(the_job, contest, score, results) score_table = db.ScoreTable( contest_id=ct_id, @@ -175,6 +262,7 @@ def api_params(contest: db.Contest) -> Dict[str, Any]: 'region': contest.place.nuts, } + def schedule_export_score_to_mo_web(score_table: db.ScoreTable) -> None: the_job = TheJob() job = the_job.create(db.JobType.export_score_to_mo_web, db.get_system_user()) diff --git a/mo/rights.py b/mo/rights.py index 6735c2c2b1cbc8ae59cbb30ba853ae0656756edc..542f63da92aaf66cea5694e967d709f7f128f222 100644 --- a/mo/rights.py +++ b/mo/rights.py @@ -32,6 +32,7 @@ class Right(Enum): edit_school_users = auto() # Editovat uživatele ze své školy (jen garant_skola) add_orgs = auto() edit_orgs = auto() + edit_tex_hacks = auto() # Nastavovat hacky pro sazbu výsledkovek TeXem @dataclass @@ -67,6 +68,7 @@ roles: List[Role] = [ Right.edit_all_users, Right.add_orgs, Right.edit_orgs, + Right.edit_tex_hacks, }, ), Role( diff --git a/mo/score.py b/mo/score.py index 04440a2bdae59da18dfef66610287fef3ede128e..24a211a3ef07a7055bf08e30c4c9a78a27877b2d 100644 --- a/mo/score.py +++ b/mo/score.py @@ -21,6 +21,10 @@ class ScoreOrder: self.span = span self.continuation = continuation + def __repr__(self) -> str: + cont = '/cont' if self.continuation else "" + return f'ScoreOrder({self.place}+{self.span}{cont})' + class ScoreResult: user: db.User @@ -29,6 +33,7 @@ class ScoreResult: order: ScoreOrder successful: bool winner: bool + show_order: 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í, ...) @@ -49,6 +54,7 @@ class ScoreResult: self.order = ScoreResult._null_score_order self.winner = False self.successful = False + self.show_order = True self._order_key = [] def get_sols(self) -> List[db.Solution]: @@ -64,6 +70,10 @@ class ScoreResult: sum += sol.points return sum + def __repr__(self) -> str: + hide = "" if self.show_order else '(hidden)' + return f'ScoreResult(user=#{self.user.user_id} order={self.order}{hide} winner={self.winner} succ={self.successful})' + class ScoreTask: task: db.Task @@ -232,6 +242,8 @@ class Score: total_points = result.get_total_points() 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 + if self.want_successful and not result.successful: + result.show_order = False def _load_prev_round(self, step: int) -> bool: """Načtení úloh a řešení předchozího kola, pokud takové existuje.""" @@ -375,11 +387,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] - if result.successful or not self.want_successful: + if result.show_order: # 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.append((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, jednoznačně podle user_id + # Neúspěšné řešitele bez uvedeného pořadí třídíme podle počtu získaných bodů, sekundárně podle jména, + # jednoznačně podle user_id result._order_key.append((total_points, result.user.name_sort_key(), result.user.user_id)) # Otestujeme, jestli teď existují sdílená místa diff --git a/mo/tex/mo-lib.tex b/mo/tex/mo-lib.tex new file mode 100644 index 0000000000000000000000000000000000000000..57a1bcf293541eca3f2d51e97d3e3b14184d8e5c --- /dev/null +++ b/mo/tex/mo-lib.tex @@ -0,0 +1,37 @@ +% Obecná makra pro sazbu věcí okolo MO + +\newbox\ellipsisbox +\setbox\ellipsisbox=\hbox{\bf~\dots~~} + +\directlua{ + function cut_box(box_nr, max_w) + local box = tex.box[box_nr] + % nodetree.analyze(box) + local n + local total_w = 0 + local last_visible + for n in node.traverse(box.head) do + local w, h, d = node.dimensions(n, n.next) + total_w = total_w + w + if total_w > max_w then + local new = node.copy_list(box.head, last_visible.next) + tex.box[box_nr] = node.hpack(new) + % nodetree.analyze(tex.box[box_nr]) + return + end + if n.id == 0 or n.id == 2 or n.id == 29 then % hlist, rule, glyph + last_visible = n + end + end + end +} + +\def\limitedbox#1#2{% + \setbox0=\hbox{#2}% + \ifdim \wd0 > #1\relax + \dimen0=\dimexpr #1 - \wd\ellipsisbox\relax + \directlua{cut_box(0, tex.dimen[0])}% + \setbox0=\hbox{\box0\copy\ellipsisbox}% + \fi + \box0 +} diff --git a/mo/tex/protokol.tex b/mo/tex/protokol.tex index 0d494403ef04e7dbb60415efaad04dbfca8097f3..1cd939351ca2f0d801777e6d3138ccca9abf1546 100644 --- a/mo/tex/protokol.tex +++ b/mo/tex/protokol.tex @@ -1,6 +1,7 @@ \input ltluatex.tex \input luatex85.sty \input ucwmac2.tex +\input mo-lib.tex \setmargins{15mm} \setuppage @@ -23,42 +24,6 @@ \def\kolo{TODO} \def\kat{TODO} -\newbox\ellipsisbox -\setbox\ellipsisbox=\hbox{\bf~\dots~~} - -\directlua{ - function cut_box(box_nr, max_w) - local box = tex.box[box_nr] - % nodetree.analyze(box) - local n - local total_w = 0 - local last_visible - for n in node.traverse(box.head) do - local w, h, d = node.dimensions(n, n.next) - total_w = total_w + w - if total_w > max_w then - local new = node.copy_list(box.head, last_visible.next) - tex.box[box_nr] = node.hpack(new) - % nodetree.analyze(tex.box[box_nr]) - return - end - if n.id == 0 or n.id == 2 or n.id == 29 then % hlist, rule, glyph - last_visible = n - end - end - end -} - -\def\limitedbox#1#2{% - \setbox0=\hbox{#2}% - \ifdim \wd0 > #1\relax - \dimen0=\dimexpr #1 - \wd\ellipsisbox\relax - \directlua{cut_box(0, tex.dimen[0])}% - \setbox0=\hbox{\box0\copy\ellipsisbox}% - \fi - \box0 -} - \def\field#1#2{\hbox to #1{\limitedbox{#1}{#2}\hss}} \def\fillin#1{\smash{\lower 2pt\hbox to #1{\hrulefill}}} diff --git a/mo/tex/vysledky.tex b/mo/tex/vysledky.tex new file mode 100644 index 0000000000000000000000000000000000000000..b5a7bd0159c7b8b111f7e0c5a98f966c9d7c7ed2 --- /dev/null +++ b/mo/tex/vysledky.tex @@ -0,0 +1,182 @@ +\input ltluatex.tex +\input luatex85.sty +\input ucwmac2.tex +\input mo-lib.tex + +\setmargins{15mm} +\setuppage +\nopagenumbers +\raggedbottom + +\ucwmodule{luaofs} +\settextsize{12} + +\ofsdeclarefamily [Pagella] {% + \loadtextfam qplr;% + qplb;% + qpli;% + qplbi;;% +} + +\def\MSfeat#1{:mode=node;script=latn;+tlig} +\registertfm qplr - file:texgyrepagella-regular.otf\MSfeat{} +\registertfm qplb - file:texgyrepagella-bold.otf\MSfeat{} +\registertfm qpli - file:texgyrepagella-italic.otf\MSfeat{} +\registertfm qplbi - file:texgyrepagella-bolditalic.otf\MSfeat{} + +\setfonts[Pagella/12] + +\uselanguage{czech} +\frenchspacing + +% Základní údaje o soutěži +\def\hlavicka{TODO} +\def\misto{} +\def\podhlavicka{} + +% Řádky tabulky +\newtoks\rows + +\def\scoretable#1{ + \heading + \rows={#1} + \findpointswidth + \tryone +} + +\def\tryone{ + % První pokus: vše na plnou šířku, široké mezery mezi sloupci + \colskip=20pt + \pointswidth=\dimexpr\rawpointswidth + 6pt\relax + \trytable + \ifdim\wd0 < \hsize + \finaltable + \else + \trytwo + \fi +} + +\def\trytwo{ + % Druhý pokus: vše na plnou šířku, ale zúžíme mezery mezi sloupci + \colskip=10pt + \pointswidth=\dimexpr\rawpointswidth + 4pt\relax + \trytable + \ifdim\wd0 < \hsize + \finaltable + \else + \trythree + \fi +} + +\def\trythree{ + % Třetí pokus: zkracujeme názvy škol + % Nejdřív si změříme, jak bude tabulka široká bez nich + { + \let\row=\rownoschools + \trytable + \global\schoolwd=\dimexpr\hsize - \wd0\relax + } + \let\row=\rowshortschools + \ifdim\schoolwd < 30pt + % Lepší je nechat tabulku trochu přetéci než zkrátit školy absurdně + \schoolwd=30pt + \fi + \finaltable +} + +%%% Hlavička %%% + +\def\heading{ + \centerline{\bf\hlavicka} + \ifx\misto\empty\else + \medskip + \centerline{\bf\misto} + \fi + \ifx\podhlavicka\empty\else + \medskip + \centerline{\bf\podhlavicka} + \fi + \bigskip + \hrule + \bigskip +} + +%%% Oddíly výsledkovky %%% + +\def\section#1{% + \noalign{\goodbreak\bigskip} + \multispan6\hfil\bf \csname sec#1\endcsname\hfil\cr + \noalign{\nobreak\medskip} +} + +\def\secwinner{Vítězové} +\def\secsuccessful{Úspěšní řešitelé} +\def\secnormal{Ostatní účastníci} + +%%% Hlavní tabulka %%% + +% Mezera mezi sloupci +\newdimen\colskip +\def\cs{\hskip\colskip} + +% Pružná mezera na levém a pravém okraji tabulky +% (uplatní se pouze, pokud sázíme finální tabulku roztaženou na \hsize) +\newskip\borderskip +\borderskip=0pt plus 1fil minus 1fil + +\def\maintable#1{% + {\tabskip=\borderskip + \halign#1{% + \tabskip=0pt + ##\hfil\cs&% pořadí + \it ##\hfil\cs&% jméno + \hfil##\hfil\cs&% ročník + ##\hfil\cs&% škola + ##\p{}&% body za úlohy (a pak mezera na šířku počtu bodů) + \hfil##% celkové body + \tabskip=\borderskip\cr + \the\rows + }} +} + +% Volá se \row{1.}{Pokusný Králík}{3/4}{Gymnázium, Praha 8, U Libeňského zámku 1}{\p{10}\p{8}\p{\n}}{5} +% #1 #2 #3 #4 #5 #6 +\def\row#1#2#3#4#5#6{#1\cr} + +\def\rownoschools#1#2#3#4#5#6{#1&\cr} + +\newdimen\schoolwd +\def\rowshortschools#1#2#3#4#5#6{#1&\limitedbox{\schoolwd}{#4}\cr} + +\def\p#1{\hbox to \pointswidth{\hfil #1}} +\def\n{--} + +\def\trytable{ + \setbox0=\vbox{\maintable{}} +} + +\def\finaltable{ + \maintable{to \hsize} +} + +%%% Stanovení maximální šířky počtu bodů %%% + +\newdimen\rawpointswidth +\newdimen\pointswidth + +\def\findpointswidth{{ + \let\section=\ignoreone + \let\row=\fpwrow + \let\p=\fpwp + \setbox0=\vbox{\halign{\hfil ##\hfil\cr\the\rows}} + \global\rawpointswidth=\wd0 +}} + +\def\ignoreone#1{} + +\def\fpwrow#1#2#3#4#5#6{#5} +\def\fpwp#1{\hfil #1\cr} + +%%% Hacky %%% + +\def\hackpodhlavicka#1{\def\podhlavicka{#1}} diff --git a/mo/util.py b/mo/util.py index d15232e3145c49ee8917b608c0902e0faa4d3ba6..ede00bc0375a1ee9d7da29ec5a6f2956528d8ce3 100644 --- a/mo/util.py +++ b/mo/util.py @@ -202,3 +202,16 @@ T = TypeVar('T') def assert_not_none(x: Optional[T]) -> T: assert x is not None return x + + +def tex_arg(s: Any) -> str: + # Primitivní escapování do TeXu. Nesnaží se ani tak o věrnou intepretaci všech znaků, + # jako o zabránění pádu TeXu kvůli divným znakům. + if s is None: + s = "" + elif type(s) != str: + s = str(s) + s = re.sub(r'[\\{}#$%^~]', '?', s) + s = re.sub(r'([&_])', r'\\\1', s) + s = re.sub(r' - ', r' -- ', s) + return '{' + s + '}' diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index d312008099b29f1d879732987284c384d0839d2a..96ad0090e8ddaae0f343d2bee9236f295943f714 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -1557,6 +1557,8 @@ class ContestEditForm(FlaskForm): state = wtforms.SelectField("Stav soutěže", choices=[ch for ch in db.RoundState.choices() if ch[0] != 'delegate'], coerce=db.RoundState.coerce) + tex_hacks = mo_fields.String("Nastavení sazby", + description="Speciální nastavení sazby výsledkové listiny (klíč={hodnota}).") submit = wtforms.SubmitField('Uložit') force_submit = wtforms.SubmitField('Uložit s chybami') @@ -1575,6 +1577,9 @@ def org_contest_edit(ct_id: int): form.state.render_kw = {'disabled': ""} form.state.description = 'Nastavení kola neumožňuje měnit stav soutěže.' + if not ctx.rights.have_right(Right.edit_tex_hacks): + del form.tex_hacks + do_submit = False errors = [] offer_force_submit = False diff --git a/mo/web/org_score.py b/mo/web/org_score.py index 4341ae6c78c015c3bf148401eda87db67b13ccd5..6ffaa654667b06fc9f78e1ff18c478cf5a638d14 100644 --- a/mo/web/org_score.py +++ b/mo/web/org_score.py @@ -183,7 +183,7 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_ 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: + if result.show_order: order_cell = OrderCell(result.order.place, result.order.span, result.order.continuation) else: order_cell = ""