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 = ""