diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py
index d37becbc0bf9487f8c6549715059ff1d7bfb6640..77be8ca6ec21c3d403ad988f3cf70cc33d69d156 100644
--- a/mo/jobs/__init__.py
+++ b/mo/jobs/__init__.py
@@ -186,4 +186,5 @@ def job_handler(type: db.JobType):
# Moduly implementující jednotlivé typy jobů
+import mo.jobs.protocols
import mo.jobs.submit
diff --git a/mo/jobs/protocols.py b/mo/jobs/protocols.py
new file mode 100644
index 0000000000000000000000000000000000000000..2655f26b7be4409a68ed44fcd4881df7ad9d8fbc
--- /dev/null
+++ b/mo/jobs/protocols.py
@@ -0,0 +1,140 @@
+# Implementace jobů na práci s protokoly
+
+import os
+import re
+from sqlalchemy.orm import joinedload
+import subprocess
+from typing import List, Optional
+
+import mo
+import mo.db as db
+from mo.jobs import TheJob, job_handler
+from mo.util import logger, part_path
+import mo.util_format
+
+
+#
+# Job create_protocols: Vygeneruje formuláře protokolů
+#
+# Vstupní JSON:
+# { 'contest_id': ID contestu,
+# 'site_id': ID soutěžního místa nebo none,
+# 'task_ids': [task_id, ...],
+# 'num_universal': počet papírů s univerzalní hlavičkou,
+# 'num_blank': pocet pokračovacích papírů,
+# }
+#
+# Výstupní JSON:
+# null
+#
+
+
+def schedule_create_protocols(contest: db.Contest, site: Optional[db.Place], for_user: db.User, tasks: List[db.Task], num_universal: int, num_blank: int):
+ place = site or contest.place
+
+ the_job = TheJob()
+ job = the_job.create(db.JobType.create_protocols, for_user)
+ job.description = f'Formuláře protokolů {contest.round.round_code_short()} {place.name}'
+ job.in_json = {
+ 'contest_id': contest.contest_id,
+ 'site_id': site.place_id if site else None,
+ 'task_ids': [t.task_id for t in tasks],
+ 'num_universal': num_universal,
+ 'num_blank': num_blank,
+ }
+ 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 + '}'
+
+
+@job_handler(db.JobType.create_protocols)
+def handle_create_protocols(the_job: TheJob):
+ job = the_job.job
+ assert job.in_json is not None
+ contest_id = job.in_json['contest_id'] # type: ignore
+ site_id = job.in_json['site_id'] # type: ignore
+ task_ids = job.in_json['task_ids'] # type: ignore
+ num_universal = job.in_json['num_universal'] # type: ignore
+ num_blank = job.in_json['num_blank'] # type: ignore
+
+ sess = db.get_session()
+ contest = sess.query(db.Contest).options(joinedload(db.Contest.round)).get(contest_id)
+ assert contest is not None
+ round = contest.round
+
+ user_subq = sess.query(db.Participation.user_id).filter_by(contest=contest)
+ if site_id is not None:
+ user_subq = user_subq.filter_by(place_id=site_id)
+ user_subq = (user_subq
+ .filter(db.Participation.state.in_((db.PartState.invited, db.PartState.registered, db.PartState.present)))
+ .subquery())
+
+ pants = (sess.query(db.Participant)
+ .options(joinedload(db.Participant.user), joinedload(db.Participant.school_place))
+ .filter(db.Participant.user_id.in_(user_subq))
+ .all())
+ pants.sort(key=lambda p: p.user.sort_key())
+
+ tasks = sess.query(db.Task).filter_by(round=round).filter(db.Task.task_id.in_(task_ids)).order_by(db.Task.code).all()
+
+ pages = []
+ for p in pants:
+ for t in tasks:
+ args = [
+ ':'.join(['MO', round.round_code_short(), t.code, str(p.user_id)]),
+ p.user.full_name(),
+ p.grade,
+ p.school_place.name,
+ t.code,
+ ]
+ pages.append('\\proto' + "".join([tex_arg(x) for x in args]))
+
+ for _ in range(num_universal):
+ pages.append('\\universal')
+
+ for _ in range(num_blank):
+ pages.append('\\blank')
+
+ if not pages:
+ the_job.error("Nebyly vyžádány žádné protokoly")
+ return
+
+ temp_dir = job.dir_path()
+ logger.debug('Job: Vytvářím protokoly v %s (%s listů)', temp_dir, len(pages))
+
+ tex_src = os.path.join(temp_dir, 'protokoly.tex')
+ with open(tex_src, 'w') as f:
+ f.write('\\input protokol.tex\n\n')
+ kolo = f'{round.name} {round.year}. ročníku Matematické olympiády'
+ kat = f'Kategorie {round.category}'
+ if round.level > 0:
+ kat += ', ' + contest.place.name
+ f.write('\\def\\kolo' + tex_arg(kolo) + '\n\n')
+ f.write('\\def\\kat' + tex_arg(kat) + '\n\n')
+
+ for p in pages:
+ f.write(p + '\n')
+
+ f.write('\n\\bye\n')
+
+ env = dict(os.environ)
+ env['TEXINPUTS'] = part_path('tex') + '//:'
+
+ subprocess.run(
+ ['luatex', '--interaction=errorstopmode', 'protokoly.tex'],
+ check=True,
+ cwd=temp_dir,
+ env=env,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ job.out_file = 'protokoly.pdf'
+ job.result = 'Celkem ' + mo.util_format.inflect_number(len(pages), 'list', 'listy', 'listů')