diff --git a/README.md b/README.md
index e7e5f833bd7f1cf00cfa61abd3bc92621c708b56..e56e9efb8e57ecec30aad9c50272e8b47a4edd42 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,9 @@ License verze 3 nebo novější (viz soubor LICENSE).
 	. ../venv/bin/activate
 	bin/test-init mo_osmo_test data		# případně podmnožinu
 
+	# Vytvořit defaultní pozadí diplomů
+	cp nejake-pozadi.pdf data/certs/bg-standard.pdf
+
 	# Případně ručně otestovat, že uwsgi funguje
 	# uwsgi --ini etc/osmo.ini
 
diff --git a/bin/init-data-dir b/bin/init-data-dir
index cb05808fc759d8c60502b35c27a899836c05b1f5..e019f1f3fb8ac9ce2d8c9389f2b2d3583df63a55 100755
--- a/bin/init-data-dir
+++ b/bin/init-data-dir
@@ -2,4 +2,4 @@
 set -e
 
 DEST=${1:-.}
-mkdir -p $DEST/{errors,imports,jobs,statements,submits,tmp,score}
+mkdir -p $DEST/{certs,errors,imports,jobs,statements,submits,tmp,score}
diff --git a/constraints.txt b/constraints.txt
index 407794ac4b21feee39306eba23b8652548ae8005..5a177a924af22ac218d55bd30caa9c12c052fdd6 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -1,98 +1,58 @@
-astroid==3.2.4
-asttokens==2.4.1
-autopep8==2.3.1
 bcrypt==4.2.0
 bleach==6.1.0
 blinker==1.8.2
-certifi==2024.7.4
-charset-normalizer==3.3.2
+certifi==2024.8.30
+charset-normalizer==3.4.0
 click==8.1.7
 dateutils==0.6.12
-decorator==5.1.1
 Deprecated==1.2.14
-dill==0.3.8
-dnspython==2.6.1
-docstring-to-markdown==0.15
+dnspython==2.7.0
 dominate==2.9.1
-executing==2.0.1
 Flask==3.0.3
 Flask-Bootstrap==3.3.7.1
 Flask-SQLAlchemy==3.0.5
 Flask-WTF==1.2.1
-future==1.0.0
-greenlet==3.0.3
-idna==3.8
-importlib_metadata==8.4.0
-ipython==8.26.0
-isort==5.13.2
+greenlet==3.1.1
+idna==3.10
 itsdangerous==2.2.0
-jedi==0.19.1
 Jinja2==3.1.4
 lxml==5.3.0
 Markdown==3.7
-MarkupSafe==2.1.5
-matplotlib-inline==0.1.7
-mccabe==0.7.0
-mypy==1.11.2
+MarkupSafe==3.0.1
+mypy==1.12.0
 mypy-extensions==1.0.0
 packaging==24.1
-parso==0.8.4
-pexpect==4.9.0
-pikepdf==9.2.0
-pillow==10.4.0
-platformdirs==4.2.2
-pluggy==1.5.0
-prompt_toolkit==3.0.47
-psycopg2==2.9.9
-ptyprocess==0.7.0
-pure_eval==0.2.3
-pycodestyle==2.12.1
-pydocstyle==6.3.0
-pyflakes==3.2.0
-Pygments==2.18.0
-pylint==3.2.6
-pylsp-mypy==0.6.8
+pikepdf==9.3.0
+pillow==11.0.0
+psycopg2==2.9.10
 PyPDF2==3.0.1
 python-dateutil==2.9.0.post0
-python-lsp-jsonrpc==1.1.2
-python-lsp-server==1.12.0
 python-magic==0.4.27
 python-poppler==0.4.1
-pytoolconfig==1.3.1
-pytz==2024.1
+pytz==2024.2
 pyzbar==0.1.9
+qrcode==8.0
 requests==2.32.3
-rope==1.13.0
 six==1.16.0
-snowballstemmer==2.2.0
-SQLAlchemy==1.4.53
+SQLAlchemy==1.4.54
 sqlalchemy-stubs==0.4
 sqlalchemy2-stubs==0.0.2a38
-stack-data==0.6.3
 token-bucket==0.3.0
-tomli==2.0.1
-tomlkit==0.13.2
-traitlets==5.14.3
 types-bleach==6.1.0.20240331
 types-Flask-SQLAlchemy==2.5.9.4
-types-html5lib==1.1.11.20240806
+types-html5lib==1.1.11.20241018
 types-Markdown==3.7.0.20240822
 types-Pillow==10.2.0.20240822
-types-python-dateutil==2.9.0.20240821
-types-requests==2.32.0.20240712
-types-setuptools==73.0.0.20240822
+types-python-dateutil==2.9.0.20241003
+types-requests==2.32.0.20241016
+types-setuptools==75.2.0.20241018
 types-SQLAlchemy==1.4.53.38
 typing_extensions==4.12.2
-ujson==5.10.0
 Unidecode==1.3.8
-urllib3==2.2.2
+urllib3==2.2.3
 uwsgidecorators==1.1.0
 visitor==0.1.3
-wcwidth==0.2.13
 webencodings==0.5.1
 Werkzeug==3.0.4
-whatthepatch==1.0.6
 wrapt==1.16.0
 WTForms==3.1.2
-yapf==0.40.2
-zipp==3.20.1
diff --git a/db/db.ddl b/db/db.ddl
index 7d2775a8df480fe816865dba3712c9a1e34ff9e4..6634f5ea492f40563687e2bf9e055cf4914630a5 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -146,6 +146,7 @@ CREATE TABLE rounds (
 	score_winner_limit	numeric(5,1)	DEFAULT NULL,			-- bodový limit na označení za vítěze
 	score_successful_limit	numeric(5,1)	DEFAULT NULL,			-- bodový limit na označení za úspěšného řešitele
 	score_advance_limit	numeric(5,1)	DEFAULT NULL,			-- bodový limit na postup do dalšího kola
+	score_has_hm	boolean		NOT NULL DEFAULT false,			-- vydáváme pochvalná uznání
 	points_step	numeric(2,1)	NOT NULL DEFAULT 1,		-- s jakou přesností jsou přidělovány body (celé aneb 1, 0.5, 0.1)
 	has_messages	boolean		NOT NULL DEFAULT false,		-- má zprávičky
 	enroll_mode	enroll_mode	NOT NULL DEFAULT 'manual',	-- režim přihlašování (pro vyšší kola vždy 'manual')
@@ -322,7 +323,8 @@ CREATE TYPE log_type AS ENUM (
 	'contest',	-- contests(contest_id)
 	'participant',	-- participants(user_id)
 	'task',		-- tasks(task_id)
-	'user_role'	-- user_roles(user_id)		-- momentálně nepoužíváme, změny rolí logujeme pod user_id
+	'user_role'	-- user_roles(user_id),		-- momentálně nepoužíváme, změny rolí logujeme pod user_id,
+	'cert_set'	-- cert_sets(contest_id)
 );
 
 CREATE TABLE log (
@@ -348,7 +350,8 @@ CREATE TYPE job_type AS ENUM (
 	'snapshot_score',
 	'export_score_to_mo_web',
 	'revert_export_score_to_mo_web',
-	'round_switch_to_grading'
+	'round_switch_to_grading',
+	'create_certs'
 );
 
 CREATE TYPE job_state AS ENUM (
@@ -502,6 +505,48 @@ CREATE TABLE score_tables (
 
 ALTER TABLE contests ADD CONSTRAINT "contests_scoretable_id" FOREIGN KEY (scoretable_id) REFERENCES score_tables(scoretable_id);
 
+-- Diplomy
+
+CREATE TABLE cert_sets (
+	contest_id		int		PRIMARY KEY REFERENCES contests(contest_id) ON DELETE CASCADE,
+	-- nastavení certifikátů
+	changed_at		timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,	-- kdy se naposledy změnilo
+	background_file		text		DEFAULT NULL,		-- relativně vůči mo.util.data_dir('cert')
+	design_params		jsonb		NOT NULL DEFAULT '{}',	-- viz mo.jobs.certs.DesignParams
+	tex_hacks		text		NOT NULL DEFAULT '',
+	-- vygenerované certifikáty
+	scoretable_id		int		DEFAULT NULL REFERENCES score_tables(scoretable_id),	-- ze které výsledkovky
+	certs_issued_at		timestamp with time zone	DEFAULT NULL,				-- kdo a kdy je vygeneroval
+	certs_issued_by		int		DEFAULT NULL REFERENCES users(user_id),
+	job_id			int		DEFAULT NULL REFERENCES jobs(job_id) ON DELETE SET NULL,	-- generující job, pokud ještě existuje
+	CHECK ((certs_issued_at IS NOT NULL) = (certs_issued_by IS NOT NULL))
+);
+
+CREATE TYPE cert_type AS ENUM (
+	'participation',		-- účastnický list
+	'successful',			-- diplom úspěšného řešitele
+	'honorary_mention'		-- pochvalné uznání
+);
+
+CREATE TABLE cert_files (
+	cert_set_id	int		NOT NULL REFERENCES cert_sets(contest_id) ON DELETE CASCADE,
+	type		cert_type	NOT NULL,
+	pdf_file	text		NOT NULL,	-- relativně vůči mo.util.data_dir('cert')
+	approved	bool		NOT NULL DEFAULT false,
+	UNIQUE (cert_set_id, type)
+);
+
+CREATE TABLE certificates (
+	cert_set_id	int		NOT NULL REFERENCES cert_sets(contest_id) ON DELETE CASCADE,
+	user_id		int		NOT NULL REFERENCES users(user_id),
+	type		cert_type	NOT NULL,
+	achievement	text		NOT NULL DEFAULT '',
+	page_number	int		NOT NULL,
+	UNIQUE (cert_set_id, user_id, type)
+);
+
+CREATE INDEX certificates_user_id_index ON certificates (user_id);
+
 -- Odeslané mailové notifikace
 
 CREATE TABLE sent_email (
diff --git a/db/upgrade-20250117.sql b/db/upgrade-20250117.sql
new file mode 100644
index 0000000000000000000000000000000000000000..62ba3055c51b4f1700d6a48a0fa7f6d56f3c42ba
--- /dev/null
+++ b/db/upgrade-20250117.sql
@@ -0,0 +1,46 @@
+ALTER TABLE rounds ADD COLUMN
+	score_has_hm	boolean		NOT NULL DEFAULT false;
+
+ALTER TYPE log_type ADD VALUE 'cert_set';
+
+ALTER TYPE job_type ADD VALUE 'create_certs';
+
+CREATE TABLE cert_sets (
+	contest_id		int		PRIMARY KEY REFERENCES contests(contest_id) ON DELETE CASCADE,
+	-- nastavení certifikátů
+	changed_at		timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,	-- kdy se naposledy změnilo
+	background_file		text		DEFAULT NULL,		-- relativně vůči mo.util.data_dir('cert')
+	design_params		jsonb		NOT NULL DEFAULT '{}',	-- viz mo.jobs.certs.DesignParams
+	tex_hacks		text		NOT NULL DEFAULT '',
+	-- vygenerované certifikáty
+	scoretable_id		int		DEFAULT NULL REFERENCES score_tables(scoretable_id),	-- ze které výsledkovky
+	certs_issued_at		timestamp with time zone	DEFAULT NULL,				-- kdo a kdy je vygeneroval
+	certs_issued_by		int		DEFAULT NULL REFERENCES users(user_id),
+	job_id			int		DEFAULT NULL REFERENCES jobs(job_id) ON DELETE SET NULL,	-- generující job, pokud ještě existuje
+	CHECK ((certs_issued_at IS NOT NULL) = (certs_issued_by IS NOT NULL))
+);
+
+CREATE TYPE cert_type AS ENUM (
+	'participation',		-- účastnický list
+	'successful',			-- diplom úspěšného řešitele
+	'honorary_mention'		-- pochvalné uznání
+);
+
+CREATE TABLE cert_files (
+	cert_set_id	int		NOT NULL REFERENCES cert_sets(contest_id) ON DELETE CASCADE,
+	type		cert_type	NOT NULL,
+	pdf_file	text		NOT NULL,	-- relativně vůči mo.util.data_dir('cert')
+	approved	bool		NOT NULL DEFAULT false,
+	UNIQUE (cert_set_id, type)
+);
+
+CREATE TABLE certificates (
+	cert_set_id	int		NOT NULL REFERENCES cert_sets(contest_id) ON DELETE CASCADE,
+	user_id		int		NOT NULL REFERENCES users(user_id),
+	type		cert_type	NOT NULL,
+	achievement	text		NOT NULL DEFAULT '',
+	page_number	int		NOT NULL,
+	UNIQUE (cert_set_id, user_id, type)
+);
+
+CREATE INDEX certificates_user_id_index ON certificates (user_id);
diff --git a/doc/export-pro-web.md b/doc/export-pro-web.md
index b156100df12a7d04b4bf492ac562de3b4901bd89..dbafa69b048137a0e892fb9a5bf30fed695bbb57 100644
--- a/doc/export-pro-web.md
+++ b/doc/export-pro-web.md
@@ -49,7 +49,7 @@ Strojově čitelná podoba v JSON
 		// Čas poslední aktualizace listiny (formát dle RFC 3339)
 		"generated_at": "YYYY-MM-DD HH:MM:SS",
 
-        // Soutěžňí oblast
+        // Soutěžní oblast
         "region": "Královéhradecký kraj",
 
 		// Seznam úloh
diff --git a/doc/score-snapshot.md b/doc/score-snapshot.md
index f1d3402f7ced32b32fc97a970c5ba80405e68e7b..8a1b738270cb1550741cb264286e77568d02545f 100644
--- a/doc/score-snapshot.md
+++ b/doc/score-snapshot.md
@@ -44,6 +44,7 @@ rows
 		} or None # V případě, že místo nemá být uvedeno
 		'winner': <bool> result.winner
 		'successful': <bool> result.successful
+		'honorary_mention': <bool> result.honorary_mention  # zavedeno 2025-01, dříve vždy false
 		'name': <string> result.user.full_name
 		'user_id': <int> result.user.user_id, # u starších snapshotů není definován
 		'school': <str> result.pant.school_place.name or "?"
@@ -70,9 +71,10 @@ score_metadata
 ```json
 {
 	'boundary': { # u starších snapshotů není definován
-		'winnera   ': <Optional[str]> round.score_winner_limit     # decimal zapsaný řetězcem
+		'winner'    : <Optional[str]> round.score_winner_limit     # decimal zapsaný řetězcem
 		'successful': <Optional[str]> round.score_successful_limit # decimal zapsaný řetězcem
 	},
+    'has_hm': <bool> round.score_has_hm     # zavedeno 2025-01, dříve vždy false
     'comment_hack': 'nepovinný komentář pod výsledkovou listinou (HTML)',   # lze dohackovat ručně, nemáme na to UI
 }
 ```
diff --git a/etc/config.py.example b/etc/config.py.example
index a714932b78127839cda2fd3615ccb0808caba96c..26380d634e51d33ed2ddf059ed5b9465486fe097 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -29,7 +29,7 @@ MAIL_CONTACT = "osmo@mo.mff.cuni.cz"
 # Pozor, v debugovacím módu je ještě potřeba nastavit PROPAGATE_EXCEPTIONS = False.
 # MAIL_ERRORS_TO = "osmo@mo.mff.cuni.cz"
 
-# Kam posíláme mailové notitifikace pro systémového uživatele, třeba dokončení jobu na správu kola
+# Kam posíláme mailové notifikace pro systémového uživatele, třeba dokončení jobu na správu kola
 # (není-li definováno, neposílají se).
 # MAIL_SYSTEM_TO = "osmo@mo.mff.cuni.cz"
 
@@ -77,7 +77,7 @@ JOB_RETRY_CEILING = 60
 # Kolik nejvýše dovolujeme registrací za minutu
 REG_MAX_PER_MINUTE = 10
 
-# Jak dlouho vydrží tokeny používané při registraci a změnách e-mailu [min]
+# Jak dlouho vydrží tokeny používané při registraci a změnách/potvrzování e-mailu [min]
 REG_TOKEN_VALIDITY = 10
 
 # Jak dlouho si pamatujeme e-mailovou nedoručenku [min]
diff --git a/mo/contests.py b/mo/contests.py
index 5e40bedd490fb9c4be1de113a6e84371bd82c1e9..26eab85df60d3277ac78afd6154fe9ab9f52c9bd 100644
--- a/mo/contests.py
+++ b/mo/contests.py
@@ -128,4 +128,16 @@ def check_contest_state(round: db.Round, state: db.RoundState, contest: Optional
                        .all())
         add_ct_errors(ct_no_score, 'Chybí oficiální výsledková listina')
 
+        ct_bad_cert = (contests_query
+                      .filter((sess.query(db.Certificate)
+                               .filter(db.Certificate.cert_set_id == db.Contest.contest_id)
+                               .join(db.Participation, and_(db.Participation.user_id == db.Certificate.user_id,
+                                                            db.Participation.contest_id == db.Contest.contest_id,
+                                                            db.Participation.state != db.PartState.active))
+                               .join(db.User, and_(db.User.user_id == db.Participation.user_id,
+                                                   not_(db.User.is_test))))
+                              .exists())
+                      .all())
+        add_ct_errors(ct_bad_cert, 'Účastníci s diplomem a neodpovídajícím stavem účasti')
+
     return errors
diff --git a/mo/db.py b/mo/db.py
index 6be1ff0d57f14e75564071a3716cc312e6065f80..e6f9eddeca6750cd3feed69642c550ae17732e5a 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -227,6 +227,10 @@ class RoundType(MOEnum):
         """Písmena se používají při exportu výsledkovky na web MO"""
         return round_type_letters[self]
 
+    @classmethod
+    def from_letter(cls, letter: str) -> 'RoundType':
+        return round_type_by_letter[letter]
+
 
 round_type_names = {
     RoundType.domaci: 'domácí kolo',
@@ -246,6 +250,15 @@ round_type_names_genitive = {
     RoundType.other: 'jiného kola',
 }
 
+round_type_names_local = {
+    RoundType.domaci: 'v domácím kole',
+    RoundType.skolni: 'v školním kole',
+    RoundType.okresni: 'v okresním kole',
+    RoundType.krajske: 'v krajském kole',
+    RoundType.ustredni: 'v ústředním kole',
+    RoundType.other: 'v jiném kole',
+}
+
 round_type_letters = {
     RoundType.domaci: 'D',
     RoundType.skolni: 'S',
@@ -256,6 +269,9 @@ round_type_letters = {
 }
 
 
+round_type_by_letter = {letter: rtype for rtype, letter in round_type_letters.items() if letter is not None}
+
+
 class RoundScoreMode(MOEnum):
     basic = auto()
     mo = auto()
@@ -322,6 +338,7 @@ class Round(Base):
     score_winner_limit = Column(Numeric)
     score_successful_limit = Column(Numeric)
     score_advance_limit = Column(Numeric)
+    score_has_hm = Column(Boolean, nullable=False, server_default=text("false"))
     points_step = Column(Numeric, nullable=False)
     has_messages = Column(Boolean, nullable=False, server_default=text("false"))
     enroll_mode = Column(Enum(RoundEnrollMode, name='enroll_mode'), nullable=False, server_default=text("'manual'::enroll_mode"))
@@ -498,6 +515,7 @@ class Contest(Base):
     place = relationship('Place')
     round = relationship('Round')
     scoretable = relationship('ScoreTable', primaryjoin='Contest.scoretable_id == ScoreTable.scoretable_id', viewonly=True)
+    participations = relationship('Participation', viewonly=True)
 
     def is_subcontest(self) -> bool:
         return self.master_contest_id != self.contest_id
@@ -549,6 +567,7 @@ class LogType(MOEnum):
     participant = auto()
     task = auto()
     user_role = auto()
+    cert_set = auto()
 
 
 class Log(Base):
@@ -714,7 +733,7 @@ class UserRole(Base):
 
         return " ".join(parts)
 
-    # XXX: Tatáž logika je v DB dotazu v org_index()
+    # XXX: Tatáž logika je v DB dotazu v org_index() a v mo.rights.filter_query_rights_for
     def applies_to(self, at: Optional[Place] = None, year: Optional[int] = None, cat: Optional[str] = None, seq: Optional[int] = None) -> bool:
         return ((at is None or self.place_id == at.place_id)
                 and (self.year is None or year is None or self.year == year)
@@ -842,6 +861,7 @@ class JobType(MOEnum):
     export_score_to_mo_web = auto()
     revert_export_score_to_mo_web = auto()
     round_switch_to_grading = auto()
+    create_certs = auto()
 
 
 class JobState(MOEnum):
@@ -905,6 +925,21 @@ class Job(Base):
     def file_path(self, name: str) -> str:
         return os.path.join(self.dir_path(), name)
 
+    def is_active(self) -> bool:
+        """Job běží, nebo někdy v budoucnu poběží."""
+        return self.state in [JobState.ready, JobState.running, JobState.soft_error, JobState.waiting]
+
+    def is_finished(self) -> bool:
+        """Job definitivně doběhl."""
+        return self.state in [JobState.done, JobState.failed, JobState.internal_error]
+
+    def is_erroneous(self) -> bool:
+        return self.state in [JobState.failed, JobState.internal_error, JobState.soft_error]
+
+    def is_deletable(self, is_admin) -> bool:
+        return (self.state in [JobState.ready, JobState.done, JobState.failed, JobState.soft_error, JobState.waiting]
+                or self.state == JobState.internal_error and is_admin)
+
 
 class Message(Base):
     __tablename__ = 'messages'
@@ -1044,6 +1079,100 @@ class ScoreTable(Base):
     contest = relationship('Contest', primaryjoin='Contest.scoretable_id == ScoreTable.scoretable_id', viewonly=True)
 
 
+class CertSet(Base):
+    __tablename__ = 'cert_sets'
+
+    contest_id = Column(Integer, ForeignKey('contests.contest_id', ondelete='CASCADE'), primary_key=True)
+    changed_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP"))
+    background_file = Column(Text, nullable=True)
+    design_params = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))  # viz mo.jobs.certs.DesignParams
+    tex_hacks = Column(Text, nullable=False, server_default=text("''::text"))
+
+    scoretable_id = Column(Integer, ForeignKey('score_tables.scoretable_id', ondelete='CASCADE'))
+    certs_issued_at = Column(DateTime(True), nullable=True)
+    certs_issued_by = Column(Integer, ForeignKey('users.user_id'), nullable=True)
+    job_id = Column(Integer, ForeignKey('jobs.job_id', ondelete='SET NULL'), nullable=True)
+
+    certs_issued_by_user = relationship('User', primaryjoin='CertSet.certs_issued_by == User.user_id')
+    contest = relationship('Contest')
+    scoretable = relationship('ScoreTable')
+    job = relationship('Job')
+
+    def dir_path(self) -> str:
+        """Adresář s daty relativně k mo.util.data_dir('certs')."""
+        return os.path.join(self.contest.round.round_code_short(), str(self.contest.contest_id))
+
+
+class CertType(MOEnum):
+    participation = auto()
+    successful = auto()
+    honorary_mention = auto()
+
+    def friendly_name(self, plural: bool = False) -> str:
+        return cert_type_names[self][int(plural)]
+
+    def file_name(self, plural: bool = False) -> str:
+        return cert_type_file_names[self][int(plural)]
+
+    def short_code(self) -> str:
+        return cert_type_short_codes[self]
+
+    @classmethod
+    def from_short_code(cls, short: str) -> 'CertType':
+        return cert_type_by_short_code[short]
+
+
+cert_type_names = {
+    CertType.participation: ('účastnický list', 'účastnické listy'),
+    CertType.successful: ('diplom vítěze / úspěšného řešitele', 'diplomy vítěze / úspěšného řešitele'),
+    CertType.honorary_mention: ('pochvalné uznání', 'pochvalná uznání'),
+}
+
+
+cert_type_file_names = {
+    CertType.participation: ('ucastnicky-list', 'ucastnicke-listy'),
+    CertType.successful: ('diplom', 'diplomy'),
+    CertType.honorary_mention: ('pochvalne-uznani', 'pochvalna-uznani'),
+}
+
+
+# Zkrácené názvy používané v QR kódech
+cert_type_short_codes = {
+    CertType.participation: 'P',
+    CertType.successful: 'S',
+    CertType.honorary_mention: 'H',
+}
+
+
+cert_type_by_short_code = {short: ctype for ctype, short in cert_type_short_codes.items()}
+
+
+class CertFile(Base):
+    __tablename__ = 'cert_files'
+
+    cert_set_id = Column(Integer, ForeignKey('cert_sets.contest_id', ondelete='CASCADE'), primary_key=True)
+    type = Column(Enum(CertType, name='cert_type'), nullable=False, primary_key=True)
+    pdf_file = Column(Text, nullable=False)
+    approved = Column(Boolean, server_default=text("false"), nullable=False)
+
+
+class Certificate(Base):
+    __tablename__ = 'certificates'
+
+    cert_set_id = Column(Integer, ForeignKey('cert_sets.contest_id', ondelete='CASCADE'), primary_key=True)
+    user_id = Column(Integer, ForeignKey('users.user_id'), primary_key=True)
+    type = Column(Enum(CertType, name='cert_type'), nullable=False, primary_key=True)
+    achievement = Column(Text, nullable=False, server_default=text("''::text"))
+    page_number = Column(Integer, nullable=False)
+
+    cert_set = relationship('CertSet')
+    cert_file = relationship('CertFile',
+                             primaryjoin='and_(CertFile.cert_set_id == Certificate.cert_set_id, CertFile.type == Certificate.type)',
+                             foreign_keys='[Certificate.cert_set_id, Certificate.type]',
+                             viewonly=True)
+    user = relationship('User')
+
+
 class SentEmail(Base):
     __tablename__ = 'sent_email'
 
diff --git a/mo/email.py b/mo/email.py
index 78684545d2c23be6a41700618eb8c658e2c125a9..9d6c94fd274491777b01a6037705db80bf37314a 100644
--- a/mo/email.py
+++ b/mo/email.py
@@ -12,7 +12,7 @@ import subprocess
 import textwrap
 import token_bucket
 import traceback
-from typing import Mapping, Optional, Tuple
+from typing import Mapping, Optional, Tuple, List
 import urllib.parse
 
 import mo.db as db
@@ -302,19 +302,31 @@ def send_join_notify_email(dest: db.User, who: db.User, contest: db.Contest) ->
     '''), add_footer=True)
 
 
-def send_grading_info_email(dest: db.User, round: db.Round) -> bool:
-    return send_user_email(dest, f'{round.name} kategorie {round.category} opraveno', textwrap.dedent(f'''\
-        {round.name} Matematické olympiády kategorie {round.category} bylo opraveno.
+def send_grading_info_email(dest: db.User, round: db.Round, contest: db.Contest, is_teacher: bool, new_state: db.RoundState, items: List[str]) -> bool:
+    if is_teacher:
+        what = 'výsledky svých studentů'
+        url = f'{config.WEB_ROOT}org/contest/school-results/'
+    else:
+        what = 'své výsledky'
+        url = f'{config.WEB_ROOT}user/contest/{contest.contest_id}/'
 
-        Své výsledky najdete v odevzdávacím systému:
+    if new_state == db.RoundState.closed:
+        event = 'ukončeno'
+    else:
+        event = 'opraveno'
 
-                {config.WEB_ROOT}
+    formatted_items = '\n'.join([f'\t- {it}' for it in items])
 
-        Také tam můžete najít opravená řešení a/nebo komentáře opravovatelů,
-        pokud je opravovatelé nahráli.
+    return send_user_email(dest, f'{round.name} kategorie {round.category} {event}', textwrap.dedent(f'''\
+        {round.name} Matematické olympiády kategorie {round.category} ({contest.place.name}) bylo {event}.
 
-        Váš OSMO
-    '''), add_footer=True)
+        Na stránce Odevzdávacího systému MO:
+
+                {url}
+
+        najdete {what}:
+
+    ''') + formatted_items + '\n\nVáš OSMO\n', add_footer=True)
 
 
 # Omezení rychlosti odesílání mailů o chybách děravým kyblíkem
diff --git a/mo/ext/json_walker.py b/mo/ext/json_walker.py
new file mode 100644
index 0000000000000000000000000000000000000000..43eeec7cc0a3059a6e8226308f2cfb9cc7ae64ec
--- /dev/null
+++ b/mo/ext/json_walker.py
@@ -0,0 +1,229 @@
+# A simple module for walking through a parsed JSON file
+# (c) 2023 Martin Mareš <mj@ucw.cz>
+
+from collections.abc import Iterator
+from enum import Enum
+import re
+from typing import Any, Optional, NoReturn, Tuple, Set, Type, TypeVar
+
+
+T = TypeVar('T')
+E = TypeVar('E', bound=Enum)
+
+
+class MissingValue:
+    pass
+
+
+class Walker:
+    obj: Any
+    parent: Optional['Walker'] = None
+    custom_context: str = ""
+
+    def __init__(self, root: Any) -> None:
+        self.obj = root
+
+    def context(self) -> str:
+        return 'root'
+
+    def raise_error(self, msg) -> NoReturn:
+        raise WalkerError(self, msg)
+
+    def is_null(self) -> bool:
+        return self.obj is None
+
+    def is_str(self) -> bool:
+        return isinstance(self.obj, str)
+
+    def is_int(self) -> bool:
+        return isinstance(self.obj, int)
+
+    def is_number(self) -> bool:
+        return isinstance(self.obj, int) or isinstance(self.obj, float)
+
+    def is_missing(self) -> bool:
+        return isinstance(self.obj, MissingValue)
+
+    def is_present(self) -> bool:
+        return not isinstance(self.obj, MissingValue)
+
+    def is_bool(self) -> bool:
+        return isinstance(self.obj, bool)
+
+    def is_array(self) -> bool:
+        return isinstance(self.obj, list)
+
+    def is_object(self) -> bool:
+        return isinstance(self.obj, dict)
+
+    def expect_present(self):
+        if self.is_missing():
+            self.raise_error('Mandatory key is missing')
+
+    def as_type(self, typ: Type[T], msg: str, default: Optional[T] = None) -> T:
+        if isinstance(self.obj, typ):
+            return self.obj
+        elif self.is_missing():
+            if default is None:
+                self.raise_error('Mandatory key is missing')
+            else:
+                return default
+        else:
+            self.raise_error(msg)
+
+    def as_optional_type(self, typ: Type[T], msg: str) -> Optional[T]:
+        if isinstance(self.obj, typ):
+            return self.obj
+        elif self.is_missing():
+            return None
+        else:
+            self.raise_error(msg)
+
+    def as_str(self, default: Optional[str] = None) -> str:
+        return self.as_type(str, 'Expected a string', default)
+
+    def as_int(self, default: Optional[int] = None) -> int:
+        return self.as_type(int, 'Expected an integer', default)
+
+    def as_float(self, default: Optional[float] = None) -> float:
+        if isinstance(self.obj, int):
+            return float(self.obj)
+        else:
+            return self.as_type(float, 'Expected a number', default)
+
+    def as_bool(self, default: Optional[bool] = None) -> bool:
+        return self.as_type(bool, 'Expected a Boolean value', default)
+
+    def as_enum(self, enum: Type[E], default: Optional[E] = None) -> E:
+        if self.is_missing() and default is not None:
+            return default
+        try:
+            return enum(self.as_str())
+        except ValueError:
+            self.raise_error('Must be one of ' + '/'.join(sorted(enum.__members__.values())))  # FIXME: type
+
+    def as_optional_str(self) -> Optional[str]:
+        return self.as_optional_type(str, 'Expected a string')
+
+    def as_optional_int(self) -> Optional[int]:
+        return self.as_optional_type(int, 'Expected an integer')
+
+    def as_optional_float(self) -> Optional[float]:
+        if isinstance(self.obj, int):
+            return float(self.obj)
+        else:
+            return self.as_optional_type(float, 'Expected a number')
+
+    def as_optional_bool(self) -> Optional[bool]:
+        return self.as_optional_type(bool, 'Expected a Boolean value')
+
+    def array_values(self) -> Iterator['WalkerInArray']:
+        ary = self.as_type(list, 'Expected an array')
+        for i, obj in enumerate(ary):
+            yield WalkerInArray(obj, self, i)
+
+    def object_values(self) -> Iterator['WalkerInObject']:
+        dct = self.as_type(dict, 'Expected an object')
+        for key, obj in dct.items():
+            yield WalkerInObject(obj, self, key)
+
+    def object_items(self) -> Iterator[Tuple[str, 'WalkerInObject']]:
+        dct = self.as_type(dict, 'Expected an object')
+        for key, obj in dct.items():
+            yield key, WalkerInObject(obj, self, key)
+
+    def enter_object(self) -> 'ObjectWalker':
+        dct = self.as_type(dict, 'Expected an object')
+        return ObjectWalker(dct, self)
+
+    def default_to(self, default) -> 'Walker':    # XXX: Use Self when available
+        if self.is_missing():
+            self.obj = default
+        return self
+
+    def set_custom_context(self, ctx: str) -> None:
+        self.custom_context = ctx
+
+
+class WalkerInArray(Walker):
+    index: int
+
+    def __init__(self, obj: Any, parent: Walker, index: int) -> None:
+        super().__init__(obj)
+        self.parent = parent
+        self.index = index
+
+    def context(self) -> str:
+        return f'[{self.index}]'
+
+
+class WalkerInObject(Walker):
+    key: str
+
+    def __init__(self, obj: Any, parent: Walker, key: str) -> None:
+        super().__init__(obj)
+        self.parent = parent
+        self.key = key
+
+    def context(self) -> str:
+        if re.fullmatch(r'\w+', self.key):
+            return f'.{self.key}'
+        else:
+            quoted_key = re.sub(r'(\\|")', r'\\\1', self.key)
+            return f'."{quoted_key}"'
+
+    def unexpected(self) -> NoReturn:
+        self.raise_error('Unexpected key')
+
+
+class ObjectWalker(Walker):
+    referenced_keys: Set[str]
+
+    def __init__(self, obj: Any, parent: Walker) -> None:
+        super().__init__(obj)
+        assert isinstance(obj, dict)
+        self.parent = parent
+        self.referenced_keys = set()
+
+    def __enter__(self) -> 'ObjectWalker':
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        if exc_type is None:
+            self.assert_no_other_keys()
+
+    def context(self) -> str:
+        return ""
+
+    def __contains__(self, key: str) -> bool:
+        return key in self.obj
+
+    def __getitem__(self, key: str) -> WalkerInObject:
+        if key in self.obj:
+            self.referenced_keys.add(key)
+            return WalkerInObject(self.obj[key], self, key)
+        else:
+            return WalkerInObject(MissingValue(), self, key)
+
+    def assert_no_other_keys(self) -> None:
+        for key, val in self.obj.items():
+            if key not in self.referenced_keys:
+                WalkerInObject(val, self, key).unexpected()
+
+
+class WalkerError(Exception):
+    walker: Walker
+    msg: str
+
+    def __init__(self, walker: Walker, msg: str) -> None:
+        self.walker = walker
+        self.msg = msg
+
+    def __str__(self) -> str:
+        contexts = []
+        w: Optional[Walker] = self.walker
+        while w is not None:
+            contexts.append(w.context())
+            contexts.append(w.custom_context)
+            w = w.parent
+        return "".join(reversed(contexts)) + ": " + self.msg
diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py
index 95d6cdb669cf43431cbc388d1bdd490eceb2714c..36f69764bcba1c6ce11d69492f14b5dd7d7b233d 100644
--- a/mo/jobs/__init__.py
+++ b/mo/jobs/__init__.py
@@ -299,6 +299,7 @@ def job_handler(type: db.JobType):
 
 
 # Moduly implementující jednotlivé typy jobů
+import mo.jobs.certs
 import mo.jobs.notify
 import mo.jobs.protocols
 import mo.jobs.round
diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
new file mode 100644
index 0000000000000000000000000000000000000000..53c77c514f6534317a4464fcdae7f4f0062a681d
--- /dev/null
+++ b/mo/jobs/certs.py
@@ -0,0 +1,369 @@
+# Implementace jobů na práci s diplomy
+
+from dataclasses import dataclass, asdict, fields
+from enum import auto
+import os
+from sqlalchemy import and_
+from sqlalchemy.orm import joinedload
+from typing import Dict, List, Optional, Any
+import urllib.parse
+
+import mo
+import mo.config as config
+import mo.db as db
+from mo.ext.json_walker import Walker
+from mo.jobs import TheJob, job_handler
+import mo.util
+from mo.util import logger
+import mo.util_format
+from mo.util_tex import tex_arg, tex_key, run_tex, format_hacks, QREncoder
+
+
+def schedule_create_certs(contest: db.Contest, for_user: db.User) -> int:
+    place = contest.place
+
+    the_job = TheJob()
+    job = the_job.create(db.JobType.create_certs, for_user)
+    job.description = f'Diplomy {contest.round.round_code()} {place.name_locative()}'
+    job.in_json = {
+        'contest_id': contest.contest_id,
+    }
+    the_job.submit()
+    assert the_job.job_id is not None
+    return the_job.job_id
+
+
+class BackgroundType(db.MOEnum):
+    blank = auto()
+    standard = auto()
+    custom = auto()
+
+    def friendly_name(self) -> str:
+        return background_type_names[self]
+
+
+background_type_names = {
+    BackgroundType.standard: "standardní",
+    BackgroundType.blank: "prázdné",
+    BackgroundType.custom: "vlastní obrázek",
+}
+
+
+@dataclass
+class DesignParams:
+    signer1_name: str = ""
+    signer1_title: str = ""
+    signer2_name: str = ""
+    signer2_title: str = ""
+    issue_place: str = ""
+    issue_date: str = ""
+    background_type: BackgroundType = BackgroundType.standard
+    space1: float = -1  # kladná čísla jsou mm, záporná filly
+    space2: float = -1
+    space3: float = -1
+    space4: float = -1
+    space5: float = 15
+    space6: float = 0
+    SPACE_PARAMS = [f'space{i}' for i in range(1, 7)]
+    logo_visible: bool = True
+
+    def to_json(self) -> Any:
+        json = asdict(self)
+        json['background_type'] = self.background_type.name
+        return json
+
+    @classmethod
+    def from_json(cls, json: Any) -> 'DesignParams':
+        p = DesignParams()
+        with Walker(json).enter_object() as w:
+            p.signer1_name = w['signer1_name'].as_str(p.signer1_name)
+            p.signer1_title = w['signer1_title'].as_str(p.signer1_title)
+            p.signer2_name = w['signer2_name'].as_str(p.signer2_name)
+            p.signer2_title = w['signer2_title'].as_str(p.signer2_title)
+            p.issue_place = w['issue_place'].as_str(p.issue_place)
+            p.issue_date = w['issue_date'].as_str(p.issue_date)
+            p.background_type = w['background_type'].as_enum(BackgroundType, p.background_type)
+            for key in DesignParams.SPACE_PARAMS:
+                setattr(p, key, w[key].as_float(getattr(p, key)))
+            p.logo_visible = w['logo_visible'].as_bool(p.logo_visible)
+        return p
+
+
+@dataclass
+class Cert:
+    user: db.User
+    school: db.Place
+    type: db.CertType
+    tex_macro: str
+    achievement: str
+    sort_key: Any
+    page_number: int = -1
+
+
+class CertMaker:
+    the_job: TheJob
+    job: db.Job
+    contest_id: int
+    contest: db.Contest
+    round: db.Round
+    place: db.Place
+    cset: db.CertSet
+    design_params: DesignParams
+    scoretable: Optional[db.ScoreTable]
+
+    certs: List[Cert]
+    out_files: Dict[db.CertType, str]
+    qr_encoder: QREncoder
+    certs_dir: str
+    out_dir: str        # relativní k certs_dir
+    job_dir: str        # adresář jobu
+
+    def __init__(self, the_job: TheJob):
+        self.the_job = the_job
+        self.job = the_job.job
+        assert self.job.in_json is not None
+
+        walker = Walker(self.job.in_json)
+        with walker.enter_object() as root:
+            self.contest_id = root['contest_id'].as_int()
+
+        sess = db.get_session()
+        self.contest = (sess.query(db.Contest)
+                        .options(joinedload(db.Contest.round))
+                        .options(joinedload(db.Contest.place))
+                        .options(joinedload(db.Contest.scoretable))
+                        .get(self.contest_id)
+                       )
+        assert self.contest is not None
+        self.round = self.contest.round
+        self.place = self.contest.place
+        self.scoretable = self.contest.scoretable
+
+        self.cset = sess.query(db.CertSet).get(self.contest_id)
+        assert self.cset is not None
+        self.design_params = DesignParams.from_json(self.cset.design_params)
+
+        self.certs = []
+        self.out_files = {}
+
+        self.certs_dir = mo.util.data_dir('certs')
+        self.out_dir = self.cset.dir_path()
+        self.job_dir = self.job.dir_path()
+        self.qr_encoder = QREncoder(os.path.join(self.job_dir, 'qr'))
+
+    def plan(self) -> None:
+        sess = db.get_session()
+        pions_pants = (sess.query(db.Participation, db.Participant)
+                       .select_from(db.Participation)
+                       .join(db.Participation.contest).join(db.Contest.round)
+                       .join(db.Participant, and_(db.Participant.user_id == db.Participation.user_id, db.Participant.year == db.Round.year))
+                       .filter(db.Participation.contest_id == self.contest_id)
+                       .filter(db.Participation.state == db.PartState.active)
+                       .options(joinedload(db.Participation.user))
+                       .options(joinedload(db.Participant.school_place))
+                      )
+
+        score_rows_by_user_id = {}
+        if self.scoretable is not None:
+            for row in self.scoretable.rows:
+                uid: int = row['user_id']
+                score_rows_by_user_id[uid] = row
+
+        for pion, pant in pions_pants:
+            user = pion.user
+            row = score_rows_by_user_id.get(user.user_id)
+
+            def add_cert(type: db.CertType, tex_macro: str, achievement: str, sort_key: Any) -> None:
+                self.certs.append(Cert(
+                    user=user,
+                    school=pant.school_place,
+                    type=type,
+                    tex_macro=tex_macro,
+                    achievement=achievement,
+                    sort_key=sort_key,
+                ))
+
+            # Účastnický list
+            add_cert(db.CertType.participation, 'CertParticipation', 'za účast', user.sort_key())
+
+            # Diplom vítěze / úspěšného řešitele
+            if row is not None:
+                order = row.get('order')
+                if order is not None:
+                    if order['span'] == 1:
+                        place = f"{order['place']}."
+                    else:
+                        place = f"{order['place']}.–{order['place'] + order['span'] - 1}."
+                    if row['winner']:
+                        add_cert(db.CertType.successful, 'CertWinner', f'za {place} místo', (order['place'], user.sort_key()))
+                    elif row['successful']:
+                        add_cert(db.CertType.successful, 'CertSuccessful', f'za {place} místo', (order['place'], user.sort_key()))
+
+            # Pochvalné uznání
+            if row is not None and row.get('honorary_mention', False):
+                add_cert(db.CertType.honorary_mention, 'CertHonoraryMention', 'za úplné vyřešení úlohy', user.sort_key())
+
+    def prepare_files(self) -> None:
+        if b := self._find_background():
+            background = os.path.join(self.job_dir, 'background.pdf')
+            mo.util.unlink_if_exists(background)
+            os.link(b, background)
+
+
+    def _find_background(self) -> Optional[str]:
+        bgt = self.design_params.background_type
+        if bgt == BackgroundType.custom:
+            return os.path.join(self.certs_dir, self.cset.background_file)
+        elif bgt == BackgroundType.blank:
+            return None
+
+        bg = bgt.name
+        candidates = [
+            f'{self.round.round_code()}/bg-{bg}.pdf',
+            f'bg-{bg}.pdf',
+        ]
+
+        for cand in candidates:
+            bg = os.path.join(self.certs_dir, cand)
+            if os.path.isfile(bg):
+                return bg
+
+        raise RuntimeError("Nemohu najít standardní pozadí (kandidáti: " + " ".join(candidates) + ")")
+
+    def make_certs(self, cert_type: db.CertType) -> None:
+        certs = [cert for cert in self.certs if cert.type == cert_type]
+        if not certs:
+            return
+
+        name = cert_type.file_name(True)
+        logger.debug(f'{self.the_job.log_prefix} Vytvářím certifikáty typu {cert_type.name} v {self.job_dir} ({len(certs)} listů)')
+
+        certs.sort(key=lambda cert: cert.sort_key)
+        self.make_tex_source(f'{self.job_dir}/{name}.tex', certs)
+        run_tex(self.job_dir, f'{name}.tex')
+        self.out_files[cert_type] = f'{self.job_dir}/{name}.pdf'
+
+    def make_tex_source(self, filename: str, certs: List[Cert]) -> None:
+        with open(filename, 'w') as f:
+            f.write('\\input certifikaty.tex\n\n')
+
+            def attrs(adict: Dict[str, str]) -> None:
+                for key, val in sorted(adict.items()):
+                    f.write(f'\\def\\{key}' + tex_arg(val) + '\n')
+
+            dparams = self.design_params
+            ga = {
+                'kolo': db.round_type_names_local[self.round.round_type],
+                'kat': self.round.category,
+                'background': "background.pdf" if dparams.background_type != BackgroundType.blank else ""
+            }
+            for field in fields(DesignParams):
+                key = field.name
+                val = getattr(dparams, key)
+                if field.type is bool:
+                    tval = "true" if val else ""
+                elif field.type is float:
+                    tval = f'{val}mm' if val >= 0 else f'0 pt plus {-val}fill'
+                else:
+                    tval = str(val)
+                ga[tex_key(key)] = tval
+            if self.round.round_type in (db.RoundType.okresni, db.RoundType.krajske):
+                ga['oblast'] = self.place.name
+            attrs(ga)
+            f.write(format_hacks(self.cset.tex_hacks))
+
+            for i, cert in enumerate(certs):
+                qr_url = self._make_qr_url(cert)
+                qr_file = self.qr_encoder.generate(qr_url, border=4)
+                f.write('\n{\n')
+                attrs({
+                      'jmeno': cert.user.full_name(),
+                      'skola': cert.school.name,
+                      'uspech': cert.achievement,
+                      'qrurl': qr_url,
+                      'qrimg': os.path.basename(qr_file),
+                })
+                f.write('\\' + cert.tex_macro + '\n')
+                f.write('}\n')
+                cert.page_number = i + 1
+
+            f.write('\n\n\\bye\n')
+
+    def _make_qr_url(self, cert: Cert) -> str:
+        timestamp = int(self.job.started_at.timestamp())
+        parts = [
+            str(self.round.year),
+            self.round.category,
+            self.round.round_type.letter(),
+            self.place.nuts or f'o{self.place.place_id}',
+            cert.type.short_code(),
+            str(cert.user.user_id),
+            f'{timestamp:x}',
+        ]
+        return config.WEB_ROOT + 'cc/' + '/'.join(map(urllib.parse.quote, parts))
+
+    def store_results(self) -> None:
+        sess = db.get_session()
+        conn = sess.connection()
+
+        # Nejdříve smažeme už zbytečné soubory s QR kódy
+        self.qr_encoder.remove_all()
+
+        # Najdeme všechny staré soubory s certifikáty
+        old_files = [cfile.pdf_file for cfile in sess.query(db.CertFile).filter_by(cert_set_id=self.contest_id).all()]
+
+        # Smažeme z DB staré certifikáty
+        conn.execute(db.CertFile.__table__.delete().where(db.CertFile.cert_set_id == self.contest_id))
+        conn.execute(db.Certificate.__table__.delete().where(db.Certificate.cert_set_id == self.contest_id))
+
+        # Založíme nové soubory
+        for ctype, out_file in self.out_files.items():
+            pdf_file = mo.util.link_to_dir(out_file, self.out_dir, base_dir=self.certs_dir, prefix=f'{ctype.file_name(True)}-', suffix='.pdf', make_dirs=True)
+            sess.add(db.CertFile(
+                cert_set_id=self.contest_id,
+                type=ctype,
+                pdf_file=pdf_file,
+            ))
+
+        # Založíme nové certifikáty
+        for cert in self.certs:
+            sess.add(db.Certificate(
+                cert_set_id=self.contest_id,
+                user_id=cert.user.user_id,
+                type=cert.type,
+                achievement=cert.achievement,
+                page_number=cert.page_number,
+            ))
+
+        # Aktualizujeme CertSet.
+        # Timestamp výroby výsledkovky nastavujeme na start jobu, protože chceme,
+        # aby případné změny provedené během provádění jobu byly považovány za novější.
+        cset = self.cset
+        cset.certs_issued_at = self.job.started_at
+        cset.certs_issued_by = self.job.user_id
+        cset.scoretable = self.scoretable
+
+        # Zalogujeme změny CertSetu
+        changes = db.get_object_changes(cset)
+        mo.util.log(
+            type=db.LogType.cert_set,
+            what=self.contest.contest_id,
+            details={'action': 'issued', 'changes': changes},
+        )
+
+        db.get_session().commit()
+
+        # Nakonec smažeme staré soubory
+        for old_file in old_files:
+            mo.util.unlink_if_exists(os.path.join(self.certs_dir, old_file))
+
+
+@job_handler(db.JobType.create_certs)
+def handle_create_protocols(the_job: TheJob):
+    cm = CertMaker(the_job)
+    cm.plan()
+    cm.prepare_files()
+    for ctype in db.CertType:
+        cm.make_certs(ctype)
+    cm.store_results()
+    cm.job.result = f'Diplomy vytvořeny ({len(cm.certs)} ks).'
diff --git a/mo/jobs/notify.py b/mo/jobs/notify.py
index 50097deaa70e6f14f6662bdd7f558480faf5bcbc..0616855d659449bdf3e4dd968aadee7cef6d7e1c 100644
--- a/mo/jobs/notify.py
+++ b/mo/jobs/notify.py
@@ -1,20 +1,28 @@
-# Implementace jobů na posílání notifikací
+# Implementace jobů na posílání notifikací při změně stavu soutěže
 
+from enum import Enum, auto
+from collections import defaultdict
+from dataclasses import dataclass
 from sqlalchemy import and_
-from typing import Optional
+from sqlalchemy.orm import joinedload
+from typing import Optional, List, Set, DefaultDict, Callable, Dict
 
 import mo.db as db
 import mo.email
+import mo.rights
+from mo.ext.json_walker import Walker
 from mo.jobs import TheJob, job_handler
+from mo.util import logger
 from mo.util_format import inflect_number
 
 
 #
-# Job send_grading_info: Pošle upozornění na opravené úlohy
+# Job send_grading_info: Pošle upozornění při změně stavu soutěže
 #
 # Vstupní JSON:
 #        { 'round_id': id_kola,
-#          'contest_id': id_soutěže,    // může být null
+#          'contest_ids': [id_soutěží],    // může být null
+#          'new_state': nový_stav_soutěží,
 #        }
 #
 # Výstupní JSON:
@@ -22,25 +30,249 @@ from mo.util_format import inflect_number
 #
 
 
-def schedule_send_grading_info(round: db.Round, contest: Optional[db.Contest], for_user: db.User) -> None:
+def schedule_send_grading_info(round: db.Round, contests: Optional[List[db.Contest]], new_state: db.RoundState, for_user: db.User) -> bool:
+    if new_state not in [db.RoundState.graded, db.RoundState.closed]:
+        return False
+
+    if round.is_subround():
+        return False
+
     the_job = TheJob()
     job = the_job.create(db.JobType.send_grading_info, for_user)
     job.description = f'E-maily účastníkům {round.round_code_short()}'
-    if contest is not None:
-        job.description += f' {contest.place.name_locative()}'
     job.in_json = {
         'round_id': round.round_id,
-        'contest_id': contest.contest_id if contest else None,
+        'contest_ids': None,
+        'new_state': new_state.name,
     }
+    if contests is not None:
+        job.in_json['contest_ids'] = [c.contest_id for c in contests]
+        if len(contests) == 1:
+            job.description += f' {contests[0].place.name_locative()}'
+        else:
+            job.description += ' (' + inflect_number(len(contests), 'soutěž', 'soutěže', 'soutěží') + ')'
     the_job.submit()
+    return True
+
+
+class Topic(Enum):
+    corrected = auto()
+    points = auto()
+    score = auto()
+    certificate = auto()
+
+
+topic_to_item = {
+    Topic.corrected: 'opravená řešení',
+    Topic.points: 'bodové ohodnocení úloh',
+    Topic.score: 'výsledkovou listinu',
+    Topic.certificate: 'diplomy',
+}
+
+
+@dataclass
+class NotifyUser:
+    user: db.User
+    school_id: int
+    topics: Set[Topic]
+
+
+@dataclass
+class NotifyStats:
+    num_total:int = 0
+    num_sent:int = 0
+    num_before:int = 0
+    num_skipped:int = 0
+    num_dont_want:int = 0
+    num_failed:int = 0
+
+    def merge_from(self, f: 'NotifyStats') -> None:
+        self.num_total += f.num_total
+        self.num_sent += f.num_sent
+        self.num_before += f.num_before
+        self.num_skipped += f.num_skipped
+        self.num_dont_want += f.num_dont_want
+        self.num_failed += f.num_failed
+
+    def format(self, is_teacher: bool) -> str:
+        if is_teacher:
+            base = inflect_number(self.num_total, 'zpráva pro učitele', 'zprávy pro učitele', 'zprávy pro učitelů')
+        else:
+            base = inflect_number(self.num_total, 'zpráva pro účastníka', 'zprávy pro účastníky', 'zprávy pro účastníků')
+
+        stats = []
+        if self.num_sent > 0:
+            stats.append(inflect_number(self.num_sent, 'odeslána', 'odeslány', 'odesláno'))
+        if self.num_before > 0:
+            stats.append(inflect_number(self.num_before, 'odeslána', 'odeslány', 'odesláno') + ' dříve')
+        if self.num_skipped > 0:
+            stats.append(inflect_number(self.num_skipped, 'prázdná vynechána', 'prázdné vynechány', 'prázdných vynecháno'))
+        if self.num_dont_want > 0:
+            stats.append(inflect_number(self.num_dont_want, 'nechce', 'nechtějí', 'nechce'))
+        if self.num_failed > 0:
+            stats.append(inflect_number(self.num_skipped, 'selhal', 'selhaly', 'selhalo'))
+
+        return base + ' (' + ', '.join(stats) + ')'
+
+
+class Notifier:
+    the_job: TheJob
+    round: db.Round
+    contest: db.Contest
+    new_state: db.RoundState
+    notify_teachers: bool
+
+    ct_notifies: Dict[int, NotifyUser]
+    teacher_notifies: Dict[int, NotifyUser]
+
+    ct_stats: NotifyStats
+    teacher_stats: NotifyStats
+
+    def __init__(self, the_job: TheJob, round: db.Round, contest: db.Contest, new_state: db.RoundState):
+        self.the_job = the_job
+        self.round = round
+        self.contest = contest
+        self.new_state = new_state
+        self.notify_teachers = new_state == db.RoundState.closed and round.level < 4
+        logger.debug(f'{self.the_job.log_prefix} Notifikace pro soutěž #{contest.contest_id} ({contest.place.name}), stav={new_state.name} teachers={self.notify_teachers}')
+
+    def run(self) -> None:
+        self.load_users()
+        self.find_topics()
+        if self.notify_teachers:
+            self.find_teachers()
+        self.ct_stats = self.notify(notifies=self.ct_notifies, email_key=f'{self.new_state.name}:{self.round.round_id}', sender=self.notify_contestant)
+        if self.notify_teachers:
+            self.teacher_stats = self.notify(notifies=self.teacher_notifies, email_key=f't-{self.new_state.name}:{self.round.round_id}', sender=self.notify_teacher)
+
+    def load_users(self) -> None:
+        sess = db.get_session()
+        q = (sess.query(db.User, db.Participant.school)
+             .select_from(db.Participation)
+             .filter(db.Participation.contest == self.contest,
+                     db.Participation.state == db.PartState.active)
+             .join(db.User, db.User.user_id == db.Participation.user_id)
+             .join(db.Participant, and_(db.Participant.user_id == db.User.user_id,
+                                        db.Participant.year == self.round.year)))
+
+        self.ct_notifies = {user.user_id: NotifyUser(user, school_id, set()) for user, school_id in q.all()}
+
+    def find_topics(self) -> None:
+        sess = db.get_session()
+
+        # Body a opravená řešení
+        sq = (sess.query(db.Solution)
+              .join(db.Task, db.Task.task_id == db.Solution.task_id)
+              .join(db.Round, db.Round.master_round_id == self.round.round_id)
+              .filter(db.Task.round_id == db.Round.round_id,
+                      db.Solution.user_id.in_(self.ct_notifies.keys())))
+        for sol in sq.all():
+            nu = self.ct_notifies[sol.user_id]
+            if sol.final_feedback is not None:
+                nu.topics.add(Topic.corrected)
+            if sol.points is not None:
+                nu.topics.add(Topic.points)
+
+        # Diplomy
+        cq = (sess.query(db.Certificate.user_id)
+              .join(db.Certificate.cert_file)
+              .filter(db.Certificate.cert_set_id == self.contest.contest_id,
+                      db.Certificate.user_id.in_(self.ct_notifies.keys()),
+                      db.CertFile.approved))
+        for user_id, in cq.all():
+            self.ct_notifies[user_id].topics.add(Topic.certificate)
+
+        # Výsledková listina
+        if self.contest.scoretable_id is not None:
+            for nu in self.ct_notifies.values():
+                nu.topics.add(Topic.score)
+
+    def find_teachers(self) -> None:
+        topics_by_school_id: DefaultDict[int, Set[Topic]] = defaultdict(set)
+        for nu in self.ct_notifies.values():
+            topics_by_school_id[nu.school_id] |= nu.topics & {Topic.score, Topic.certificate}
+        school_ids = list(topics_by_school_id.keys())
+
+        sess = db.get_session()
+        teachers_by_school_id: DefaultDict[int, Set[db.User]] = defaultdict(set)
+        query = mo.rights.filter_query_rights_for(sess.query(db.UserRole), place_ids=school_ids, cat=self.round.category)
+        for ur in query.options(joinedload(db.UserRole.user)).all():
+            teachers_by_school_id[ur.place_id].add(ur.user)
+
+        self.teacher_notifies = {}
+        for school_id, users in teachers_by_school_id.items():
+            for user in users:
+                self.teacher_notifies[user.user_id] = NotifyUser(user, school_id, set(topics_by_school_id[school_id]))
+
+    def notify(self, notifies: Dict[int, NotifyUser], email_key: str, sender: Callable[[NotifyUser], Optional[bool]]) -> NotifyStats:
+        stats = NotifyStats()
+        sess = db.get_session()
+
+        # Podobně jako u importů i zde budeme vytvářet malé transakce
+        sess.commit()
+
+        for nu in notifies.values():
+            stats.num_total += 1
+
+            topic_names = " ".join([t.name for t in nu.topics])
+            logger.debug(f'{self.the_job.log_prefix} Notify: key={email_key} user=#{nu.user.user_id} topics=<{topic_names}>')
+
+            if not nu.user.wants_notify:
+                stats.num_dont_want += 1
+                continue
+
+            # Musíme se zeptat až tady, protože to mezitím mohl poslat jiný proces.
+            sent = sess.query(db.SentEmail).filter_by(user=nu.user, key=email_key).with_for_update().one_or_none()
+            if sent:
+                stats.num_before += 1
+            else:
+                success = sender(nu)
+                if success is None:
+                    stats.num_skipped += 1
+                elif success:
+                    stats.num_sent += 1
+                    sent = db.SentEmail(user=nu.user, key=email_key)
+                    sess.add(sent)
+                else:
+                    stats.num_failed += 1
+
+            sess.commit()
+
+        return stats
+
+    def notify_contestant(self, nu: NotifyUser) -> Optional[bool]:
+        return self.notify_generic(nu, False)
+
+    def notify_teacher(self, nu: NotifyUser) -> Optional[bool]:
+        return self.notify_generic(nu, True)
+
+    def notify_generic(self, nu: NotifyUser, is_teacher: bool) -> Optional[bool]:
+        if not nu.topics:
+            return None
+
+        items = [topic_to_item[topic] for topic in sorted(nu.topics, key=lambda topic: topic.value)]
+
+        return mo.email.send_grading_info_email(
+            dest=nu.user,
+            round=self.round,
+            contest=self.contest,
+            is_teacher=is_teacher,
+            new_state=self.new_state,
+            items=items,
+        )
 
 
 @job_handler(db.JobType.send_grading_info)
 def handle_send_grading_info(the_job: TheJob):
     job = the_job.job
     assert job.in_json is not None
-    round_id: int = job.in_json['round_id']  # type: ignore
-    contest_id: int = job.in_json['contest_id']  # type: ignore
+    with Walker(job.in_json).enter_object() as w:
+        round_id = w['round_id'].as_int()
+        if w['contest_ids'].is_null():
+            contest_ids = None
+        else:
+            contest_ids = [x.as_int() for x in w['contest_ids'].array_values()]
+        new_state = db.RoundState[w['new_state'].as_str()]
 
     sess = db.get_session()
     round = sess.query(db.Round).get(round_id)
@@ -48,49 +280,25 @@ def handle_send_grading_info(the_job: TheJob):
         the_job.error('Kolo nebylo nalezeno')
         return
 
-    email_key = f'graded:{round_id}'
-    todo = (sess.query(db.User, db.SentEmail)
-            .select_from(db.Contest)
-            .filter(db.Contest.round_id == round.master_round_id))
-    if contest_id is not None:
-        todo = todo.filter(db.Contest.contest_id == contest_id)
-    todo = (todo
-            .join(db.Participation, db.Participation.contest_id == db.Contest.contest_id)
-            .join(db.User, db.User.user_id == db.Participation.user_id)
-            .outerjoin(db.SentEmail, and_(db.SentEmail.user_id == db.User.user_id, db.SentEmail.key == email_key))
-            .all())
-
-    # Podobně jako u importů i zde budeme vytvářet malé transakce
+    ct_q = sess.query(db.Contest)
+    if contest_ids is None:
+        ct_q = ct_q.filter(db.Contest.round_id == round.round_id)
+    else:
+        ct_q = ct_q.filter(db.Contest.contest_id.in_(contest_ids))
+    ct_q = ct_q.options(joinedload(db.Contest.place))
+
     sess.commit()
 
-    num_total = 0
-    num_sent = 0
-    num_before = 0
-    num_dont_want = 0
-
-    for user, sent in todo:
-        num_total += 1
-        if not user.wants_notify:
-            num_dont_want += 1
-        elif sent:
-            num_before += 1
-        else:
-            # Musíme se zeptat znovu, protože to mezitím mohl poslat jiný proces.
-            sent = sess.query(db.SentEmail).filter_by(user=user, key=email_key).with_for_update().one_or_none()
-            if sent:
-                num_before += 1
-            else:
-                mo.email.send_grading_info_email(user, round)
-                sent = db.SentEmail(user=user, key=email_key)
-                sess.add(sent)
-                num_sent += 1
-        sess.commit()
+    ct_stats = NotifyStats()
+    teacher_stats = NotifyStats()
+
+    for ct in ct_q.all():
+        n = Notifier(the_job, round, ct, new_state)
+        n.run()
+        ct_stats.merge_from(n.ct_stats)
+        if n.notify_teachers:
+            teacher_stats.merge_from(n.teacher_stats)
 
-    stats = []
-    if num_sent > 0:
-        stats.append(inflect_number(num_sent, 'odeslána', 'odeslány', 'odesláno'))
-    if num_before > 0:
-        stats.append(inflect_number(num_before, 'odeslána', 'odeslány', 'odesláno') + ' dříve')
-    if num_dont_want > 0:
-        stats.append(inflect_number(num_dont_want, 'účastník nechce', 'účastníci nechtějí', 'účastníků nechce'))
-    job.result = 'Celkem ' + inflect_number(num_total, 'zpráva', 'zprávy', 'zpráv') + ': ' + ', '.join(stats) + '.'
+    job.result = ct_stats.format(is_teacher=False)
+    if teacher_stats.num_total > 0:
+        job.result += ', ' + teacher_stats.format(is_teacher=True)
diff --git a/mo/jobs/protocols.py b/mo/jobs/protocols.py
index 8b1209ed576c0d87717fa210dc4f931a72d1e517..0577ce486385396ac3b30a0f252b8e5db5053a67 100644
--- a/mo/jobs/protocols.py
+++ b/mo/jobs/protocols.py
@@ -9,7 +9,6 @@ import pyzbar.pyzbar as pyzbar
 from sqlalchemy import delete
 from sqlalchemy.orm import joinedload
 from sqlalchemy.orm.query import Query
-import subprocess
 from typing import Dict, List, Optional, Tuple
 import PyPDF2
 
@@ -19,8 +18,9 @@ import mo.db as db
 from mo.jobs import TheJob, job_handler
 import mo.submit
 import mo.util
-from mo.util import logger, part_path, tex_arg
+from mo.util import logger
 import mo.util_format
+from mo.util_tex import tex_arg, run_tex
 
 SCAN_BW_THRESHOLD = 180  # 0-255, pixel nad toto číslo = bílý pixel
 # Parametry pro detekci prázdných stránek:
@@ -146,18 +146,7 @@ def handle_create_protocols(the_job: TheJob):
 
         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,
-    )
+    run_tex(directory=temp_dir, source='protokoly.tex')
 
     job.out_file = 'protokoly.pdf'
     job.result = 'Celkem ' + mo.util_format.inflect_number(len(pages), 'list', 'listy', 'listů')
diff --git a/mo/jobs/score.py b/mo/jobs/score.py
index 4196dadb06dfbcf44e395cd03b991df79468bbd3..9cbcdb8c074973676703860f02f8b8120b575c2d 100644
--- a/mo/jobs/score.py
+++ b/mo/jobs/score.py
@@ -3,10 +3,8 @@
 import decimal
 import json
 import os
-import re
 import requests
 import requests.auth
-import subprocess
 from typing import Any, Dict, Iterable, Optional, List
 
 import mo
@@ -15,8 +13,9 @@ import mo.db as db
 from mo.jobs import TheJob, job_handler, SoftJobError
 from mo.score import Score, ScoreResult
 import mo.util
-from mo.util import tex_arg, assert_not_none, logger
+from mo.util import assert_not_none, logger
 from mo.util_format import format_decimal
+from mo.util_tex import tex_arg, format_hacks, run_tex
 
 
 #
@@ -68,27 +67,6 @@ def order_to_tex(result: ScoreResult) -> str:
         return f'{order.place}.'
 
 
-def parse_tex_hacks(tex_hacks: str) -> Dict[str, str]:
-    hacks = {}
-    fields = re.split(r'([A-Za-z]+)={([^}]*)}', 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ů
 
@@ -167,19 +145,7 @@ def make_score_pdf(temp_dir: str, score: Score, results: List[ScoreResult]) -> s
         f.write('}\n')
         f.write('\n\\bye\n')
 
-    env = dict(os.environ)
-    env['TEXINPUTS'] = mo.util.part_path('tex') + '//:'
-
-    subprocess.run(
-        ['luatex', '--interaction=errorstopmode', 'score.tex'],
-        check=True,
-        cwd=temp_dir,
-        env=env,
-        stdin=subprocess.DEVNULL,
-        stdout=subprocess.DEVNULL,
-        stderr=subprocess.DEVNULL,
-    )
-
+    run_tex(directory=temp_dir, source='score.tex')
     return os.path.join(temp_dir, 'score.pdf')
 
 
@@ -219,6 +185,7 @@ def handle_snapshot_score(the_job: TheJob):
             'order': order,
             'winner': result.winner,
             'successful': result.successful,
+            'honorary_mention': result.honorary_mention,
             'name': result.user.full_name(),
             'user_id': result.user.user_id,
             'school': result.pant.school_place.name or "?",
@@ -239,7 +206,7 @@ def handle_snapshot_score(the_job: TheJob):
             "winner": format_decimal(round.score_winner_limit),
             "successful": format_decimal(round.score_successful_limit),
         },
-
+        "has_hm": round.score_has_hm,
     }
 
     pdf_file = make_snapshot_score_pdf(the_job, contest, score, results)
diff --git a/mo/rights.py b/mo/rights.py
index 724f7b9d1f6fac873a08016d176c3427d299f587..3007956631e416330d7fa4d53f0727f83e6c4c35 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -18,7 +18,8 @@ class Right(Enum):
     manage_round = auto()
     manage_contest = auto()
     add_contest = auto()
-    view_contestants = auto()       # Prohlížet si seznam účastníků
+    view_contestants = auto()       # Prohlížet si seznam účastníků, výsledkové listiny a diplomy
+    view_school_contestants = auto()    # Totéž pro účastníky ze školy v dané oblasti
     upload_solutions = auto()       # Odevzdávat za účastníka ve stavu "running"
     upload_feedback = auto()        # Nahrávat opravené řešení ve stavech "grading" a "graded"
     view_submits = auto()           # Prohlížet si řešení a opravy
@@ -32,7 +33,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
+    edit_tex_hacks = auto()         # Nastavovat hacky pro sazbu výsledkovek a diplomů TeXem
     view_doc = auto()               # Prohlížet organizátorskou dokumentaci
     unrestricted_email = auto()     # Na posílání mailů se nevztahuje config.EMAILS_SHOW_MAX
 
@@ -62,6 +63,7 @@ roles: List[Role] = [
             Right.manage_contest,
             Right.add_contest,
             Right.view_contestants,
+            Right.view_school_contestants,
             Right.view_submits,
             Right.upload_submits,
             Right.upload_solutions,
@@ -84,6 +86,7 @@ roles: List[Role] = [
             Right.manage_contest,
             Right.add_contest,
             Right.view_contestants,
+            Right.view_school_contestants,
             Right.view_submits,
             Right.upload_submits,
             Right.upload_solutions,
@@ -105,6 +108,7 @@ roles: List[Role] = [
             Right.manage_contest,
             Right.add_contest,
             Right.view_contestants,
+            Right.view_school_contestants,
             Right.view_submits,
             Right.upload_submits,
             Right.upload_solutions,
@@ -126,6 +130,7 @@ roles: List[Role] = [
             Right.manage_contest,
             Right.add_contest,
             Right.view_contestants,
+            Right.view_school_contestants,
             Right.view_submits,
             Right.upload_submits,
             Right.upload_solutions,
@@ -165,6 +170,7 @@ roles: List[Role] = [
         role=db.RoleType.pozorovatel,
         rights={
             Right.view_contestants,
+            Right.view_school_contestants,
             Right.view_submits,
             Right.view_statement,
             Right.view_all_users,
@@ -424,7 +430,7 @@ class Gatekeeper:
         return ancestor.place_id in parent_ids
 
     def rights_for(
-            self, place: Optional[db.Place] = None, year: Optional[int] = None,
+            self, place: Optional[db.Place] = None, year: Optional[int] = config.CURRENT_YEAR,
             cat: Optional[str] = None, seq: Optional[int] = None,
             min_role: Optional[db.RoleType] = None) -> Rights:
         """Posbírá role a práva, která se vztahují k danému místu (možno i tranzitivně) a soutěži.
@@ -533,3 +539,33 @@ class Gatekeeper:
             return True
 
         return False
+
+
+def filter_query_rights_for(
+        q: Query,
+        place: Optional[db.Place] = None,
+        place_ids: Optional[List[int]] = None,
+        year: Optional[int] = config.CURRENT_YEAR,
+        cat: Optional[str] = None,
+        seq: Optional[int] = None) -> Query:
+
+    if place is not None:
+        q = q.filter(db.UserRole.place_id == place.place_id)
+    if place_ids is not None:
+        q = q.filter(db.UserRole.place_id.in_(place_ids))
+
+    if year is not None:
+        q = q.filter(or_(db.UserRole.year == year, db.UserRole.year == None))
+
+    if cat is not None:
+        cats = [cat]
+        if cat in "ABC":
+            cats.append('S')
+        elif cat.startswith('Z'):
+            cats.append('Z')
+        q = q.filter(or_(db.UserRole.category.in_(cats), db.UserRole.category == None))
+
+    if seq is not None:
+        q = q.filter(or_(db.UserRole.seq == seq, db.UserRole.seq == None))
+
+    return q
diff --git a/mo/score.py b/mo/score.py
index d43d783caabc7bc29c495dc1d2f228aa202eae63..5be7e5f9b7772aeda9eaebeaad4b47416d87b6db 100644
--- a/mo/score.py
+++ b/mo/score.py
@@ -35,6 +35,7 @@ class ScoreResult:
     order: ScoreOrder
     successful: bool
     winner: bool
+    honorary_mention: bool
     show_order: bool
 
     # Řešení jednotlivých kol (pro některá řazení je potřeba znát i výsledky
@@ -56,6 +57,7 @@ class ScoreResult:
         self.order = ScoreResult._null_score_order
         self.winner = False
         self.successful = False
+        self.honorary_mention = False
         self.show_order = True
         self._order_key = []
 
@@ -74,7 +76,7 @@ class ScoreResult:
 
     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})'
+        return f'ScoreResult(user=#{self.user.user_id} order={self.order}{hide} winner={self.winner} succ={self.successful} hm={self.honorary_mention})'
 
 
 class ScoreTask:
@@ -245,6 +247,14 @@ 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
+            result.honorary_mention = (self.round.score_has_hm
+                                       and not result.successful
+                                       and any(
+                                           (sp := sol.points) is not None
+                                           and (mp := self._tasks[0][sol.task_id].task.max_points) is not None
+                                           and sp == mp
+                                           for sol in result.get_sols()
+                                       ))
             if self.want_successful and not result.successful:
                 result.show_order = False
 
diff --git a/mo/submit.py b/mo/submit.py
index ac3a37ee42ad57b069402e58884ca68816c0742d..2b9efc4a9d06662dde61df42b95e2a9627777ec3 100644
--- a/mo/submit.py
+++ b/mo/submit.py
@@ -1,4 +1,5 @@
-# Back-end pro zpracování odevzdaných/opravených protokolů
+# Back-end pro zpracování odevzdaných/opravených protokolů,
+# také se používá pro kontrolu obrázků na pozadí diplomů.
 
 import datetime
 import multiprocessing
@@ -17,12 +18,7 @@ class SubmitException(RuntimeError):
 
 
 class Submitter:
-    submit_dir: str
-
-    def __init__(self):
-        self.submit_dir = mo.util.data_dir('submits')
-
-    def submit_paper(self, paper: db.Paper, tmpfile: str):
+    def submit_paper(self, paper: db.Paper, tmpfile: str) -> None:
         logger.info(f'Submit: Zpracovávám file={tmpfile} for=#{paper.for_user_obj.user_id} by=#{paper.uploaded_by_obj.user_id} type={paper.type.name}')
         t_start = datetime.datetime.now()
 
@@ -37,7 +33,7 @@ class Submitter:
             logger.info(f'Submit: Chyba: {e} (time={duration:.3f}), uloženo do {preserved_as}')
             raise
 
-    def submit_fix(self, paper: db.Paper, tmpfile: str):
+    def submit_fix(self, paper: db.Paper, tmpfile: str) -> None:
         logger.info(f'Submit fix: Zpracovávám file={tmpfile} fix_for=#{paper.paper_id}')
         t_start = datetime.datetime.now()
 
@@ -51,12 +47,28 @@ class Submitter:
             logger.info(f'Submit fix: {e} (time={duration:.3f})')
             raise
 
-    def _file_paper(self, paper: db.Paper, tmpfile: str, broken: bool):
+    def check_certificate_background(self, tmpfile: str) -> None:
+        logger.info(f'Submit: Zpracovávám pozadí diplomu file={tmpfile}')
+        paper = db.Paper(for_task=0, for_user=0, type=db.PaperType.solution, uploaded_by=0)
+        t_start = datetime.datetime.now()
+
+        try:
+            self._process_pdf(paper, tmpfile, allow_broken=False)
+            if paper.pages != 1:
+                raise SubmitException(f'Pozadí musí mít 1 stranu, nikoliv {paper.pages}.')
+            duration = (datetime.datetime.now() - t_start).total_seconds()
+            logger.info(f'Submit pozadí: Hotovo (time={duration:.3f}')
+        except SubmitException as e:
+            duration = (datetime.datetime.now() - t_start).total_seconds()
+            logger.info(f'Submit pozadí: {e} (time={duration:.3f})')
+            raise
+
+    def _file_paper(self, paper: db.Paper, tmpfile: str, broken: bool) -> None:
         round = paper.task.round
         secure_category = werkzeug.utils.secure_filename(round.category)
         top_level = f'{round.year}-{secure_category}-{round.seq}'
         user_dir = os.path.join(top_level, str(paper.for_user_obj.user_id))
-        sub_user_dir = os.path.join(self.submit_dir, user_dir)
+        sub_user_dir = os.path.join(mo.util.data_dir('submits'), user_dir)
         os.makedirs(sub_user_dir, exist_ok=True)
 
         secure_task_code = werkzeug.utils.secure_filename(paper.task.code)
@@ -108,7 +120,7 @@ class Submitter:
 
     # Zpracování PDF běží v samostatném procesu, výsledek pošle jako slovník rourou.
     @staticmethod
-    def _do_process_pdf(tmpfile: str, pipe):
+    def _do_process_pdf(tmpfile: str, pipe) -> None:
         result: Any = {}
         try:
             with pikepdf.open(tmpfile, attempt_recovery=False) as pdf:
diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
new file mode 100644
index 0000000000000000000000000000000000000000..4b2826afb5076cf9eebc9ff460e39c5494140a75
--- /dev/null
+++ b/mo/tex/certifikaty.tex
@@ -0,0 +1,168 @@
+\input ltluatex.tex
+\input luatex85.sty
+\input ucwmac2.tex
+\input mo-lib.tex
+
+\setmargins{0mm}
+\setuppage
+\nopagenumbers
+
+\ucwmodule{luaofs}
+\settextsize{12}
+\fontpagella
+\setfonts[Pagella/12]
+
+\uselanguage{czech}
+\frenchspacing
+
+\newbox\mologobox
+\setbox\mologobox=\putimage{width 35mm}{mo-logo.epdf}
+
+\newbox\jcmflogobox
+\setbox\jcmflogobox=\putimage{width 14mm}{jcmf-logo.epdf}
+
+\newcount\qrdpi
+\qrdpi=50
+
+% Základní údaje o soutěži
+\def\kolo{}
+\def\kat{}
+\def\oblast{}
+\def\signerAname{}
+\def\signerAtitle{}
+\def\signerBname{}
+\def\signerBtitle{}
+\def\issueplace{}
+\def\issuedate{}
+\def\background{}
+\def\spaceA{0pt}
+\def\spaceB{0pt}
+\def\spaceC{0pt}
+\def\spaceD{0pt}
+\def\spaceE{0pt}
+\def\spaceF{0pt}
+\def\spaceF{0pt}
+\def\logovisible{true}
+
+% Údaje o jednom certifikátu
+\def\typ{???}
+\def\jmeno{???}
+\def\skola{???}
+\def\uspech{???}
+\def\qrurl{???}
+\def\qrimg{???}
+
+\def\generic{
+	\offinterlineskip  % řádkování je proměnlivé a vše má podpěry
+	\topskip=0pt
+	\ifx\background\empty\else
+		\smash{\vhang{\putimage{width \hsize}{\background}}}
+	\fi
+	\vglue 10pt
+	\vskip\spaceA
+	\head
+	\vskip\spaceB
+	\centerline{\fontsize{36}\bf\jmeno}
+	\vskip 5mm
+	\centerline{\fontsize{16}\skola}
+	\vskip\spaceC
+	\centerline{\fontsize{24}\uspech}
+	\vskip 8mm
+	\centerline{\fontsize{16}\kolo}
+	% \ifx\oblast\empty\else~(\oblast)\fi
+	\vskip 5mm
+	\centerline{\fontsize{16}Matematické olympiády v~kategorii \kat}
+	\vskip\spaceD
+	\signatures
+	\vskip\spaceE
+	\bottomline
+	\vskip\spaceF
+	\vskip 15mm
+	\eject
+}
+
+\def\signatures{
+	\edef\tmp{\signerAname\signerAtitle\signerBname\signerBtitle}
+	\ifx\tmp\empty\else
+		\setbox0=\sign{\signerAname}{\signerAtitle}
+		\setbox1=\sign{\signerBname}{\signerBtitle}
+		\ifdim\wd0=0pt
+			\line{\hss\box1\hss}
+		\else\ifdim\wd1=0pt
+			\line{\hss\box0\hss}
+		\else
+			\dimen0=\wd0
+			\ifdim\wd1 > \dimen0
+				\dimen0=\wd1
+			\fi
+			\line{\hss\hbox to \dimen0{\hfil\box0\hfil}\hskip 0pt plus 0.7fil\hbox to \dimen0{\hfil\box1\hfil}\hss}
+		\fi\fi
+		\iffalse
+		\line{%
+			\hss
+			\sign{\signerAname}{\signerAtitle}
+			\edef\tmpa{\signerAname\signerAtitle}
+			\edef\tmpb{\signerBname\signerBtitle}
+			\ifx\tmpa\empty
+			\else
+				\ifx\tmpb\empty
+				\else
+					\hss
+				\fi
+			\fi
+			\sign{\signerBname}{\signerBtitle}
+			\hss
+		}
+		\fi
+	\fi
+}
+
+\def\sign#1#2{\vtop{\halign{%
+	\hfil\fontsize{12}##\hfil\cr
+	#1\cr\noalign{\vskip 3pt}
+	#2\cr
+}}}
+
+\def\bottomline{
+	\line{
+		\hskip 15mm
+		\rlap{\vbox{\datebox}}%
+		\hfil
+		\ifx\logovisible\empty\else
+			\hbox{\copy\mologobox\hskip 10mm\copy\jcmflogobox}%
+		\fi
+		\hfil
+		\llap{%
+			\pdfstartlink attr {/Border [0 0 0]} user {/Subtype/Link /A << /Type/Action /S/URI /URI(\qrurl) >>}\relax
+			\lower\dimexpr 1in/\qrdpi*4\relax\vbox{\pdfimageresolution=50\relax\putimage{}{\qrimg}}%
+			\pdfendlink
+		}
+		\hskip 15mm
+	}
+}
+
+\def\datebox{\halign{%
+	\fontsize{12}##\hfil\cr
+	\issueplace\cr
+	\issuedate\cr
+}}
+
+\def\CertParticipation{
+	\def\head{\centerline{\fontsize{36}\bf ÚČASTNICKÝ LIST}}
+	\generic
+}
+
+\def\CertWinner{
+	\def\head{\centerline{\fontsize{36}\bf DIPLOM}\vskip 5mm\centerline{\fontsize{30}\bf vítěze}}
+	\generic
+}
+
+\def\CertSuccessful{
+	\def\head{\centerline{\fontsize{36}\bf DIPLOM}\vskip 5mm\centerline{\fontsize{30}\bf úspěšného řešitele}}
+	\generic
+}
+
+\def\CertHonoraryMention{
+	\def\head{\centerline{\fontsize{36}\bf POCHVALNÉ UZNÁNÍ}}
+	\generic
+}
diff --git a/mo/tex/jcmf-logo.epdf b/mo/tex/jcmf-logo.epdf
new file mode 100644
index 0000000000000000000000000000000000000000..887aece3e8cc4f21c53bbc5aca701f4df0b4ec27
Binary files /dev/null and b/mo/tex/jcmf-logo.epdf differ
diff --git a/mo/tex/jcmf-logo.svg b/mo/tex/jcmf-logo.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2cbbe6911aefdd98abf66b3a378d75ba91d9dd1b
--- /dev/null
+++ b/mo/tex/jcmf-logo.svg
@@ -0,0 +1,136 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="71.740929mm"
+   height="71.740929mm"
+   viewBox="0 0 71.74093 71.740929"
+   version="1.1"
+   id="svg5"
+   inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
+   sodipodi:docname="jcmf.svg"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <sodipodi:namedview
+     id="namedview7"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:document-units="mm"
+     showgrid="false"
+     inkscape:zoom="2.1215755"
+     inkscape:cx="-55.147696"
+     inkscape:cy="134.09846"
+     inkscape:window-width="1918"
+     inkscape:window-height="1054"
+     inkscape:window-x="0"
+     inkscape:window-y="24"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="g10-0"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0"
+     inkscape:showpageshadow="2"
+     inkscape:deskcolor="#d1d1d1">
+    <inkscape:grid
+       type="xygrid"
+       id="grid824"
+       originx="-325.99064"
+       originy="-43.287222" />
+  </sodipodi:namedview>
+  <defs
+     id="defs2">
+    <clipPath
+       clipPathUnits="userSpaceOnUse"
+       id="clipPath16">
+      <path
+         d="M 0,419.53 H 595.28 V 0 H 0 Z"
+         id="path14" />
+    </clipPath>
+  </defs>
+  <g
+     inkscape:groupmode="layer"
+     id="layer2"
+     inkscape:label="Vrstva 2"
+     transform="translate(-325.99064,-43.287224)" />
+  <g
+     inkscape:label="Vrstva 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-325.99064,-43.287224)">
+    <g
+       id="g10-0"
+       transform="matrix(1.2588557,0,0,-1.2588557,325.98812,115.02566)">
+      <g
+         id="g12-6"
+         transform="translate(0,56.989021)">
+        <path
+           d="m 17.27,-25.605 c 1.152,-0.067 2.339,0.41 3.312,1.066 1.207,0.777 2.016,1.953 2.695,3.148 1.391,2.793 1.922,5.911 2.848,8.871 1.031,0.047 2.184,-0.101 3.227,-0.132 0.242,0.261 0.238,0.398 0.382,0.769 -0.898,0.055 -2.683,0.055 -3.394,0.11 0.562,1.882 0.984,3.796 1.711,5.625 0.308,0.765 0.887,1.39 1.594,1.8 0.425,0.266 0.937,0.789 0.914,1.172 -0.176,0.289 -0.43,0.594 -0.805,0.434 -0.512,-0.293 -0.774,-0.891 -1.234,-1.266 -0.547,-0.578 -1.313,-0.89 -2.075,-0.457 -0.742,0.332 -1.211,1.035 -1.652,1.672 -0.168,0.273 -0.746,0.598 -1.219,0.598 -0.695,0.027 -1.293,-0.399 -1.781,-0.856 -0.785,-0.851 -1.25,-2.035 -1.09,-2.871 0.094,-0.496 0.742,-0.773 1.238,-0.703 0.579,0.195 1.141,1.141 0.657,1.531 -0.321,0.258 -0.844,0.824 -0.649,1.332 0.11,0.293 0.465,0.532 0.789,0.465 0.985,-0.512 1.414,-1.715 2.45,-2.137 0.804,-0.273 1.593,0.2 2.335,0.434 0.516,-0.125 -0.105,-0.488 -0.218,-0.73 -1.192,-1.793 -1.504,-3.985 -2.305,-5.946 -1.414,0.035 -2.195,0.098 -3.27,0.168 0.008,-0.254 -0.492,-0.855 -0.019,-0.789 0.926,-0.066 2.199,-0.027 3.117,-0.137 -0.742,-2.375 -1.348,-4.843 -2.183,-7.203 -0.364,-1.015 -0.817,-1.89 -1.379,-2.773 -0.422,-0.664 -0.973,-1.344 -1.715,-1.852 -0.305,-0.258 -0.832,-0.515 -1.188,-0.679 -0.75,-0.215 -1.57,-0.266 -2.05,0.472 -0.356,0.559 -0.063,1.235 -0.067,1.844 0,0.355 0,0.656 -0.308,0.859 -0.321,0.266 -0.704,0.071 -1.122,-0.054 -0.308,-0.09 -0.449,-0.504 -0.472,-0.793 -0.043,-0.825 0.156,-1.789 0.914,-2.246 0.597,-0.446 1.301,-0.704 2.012,-0.746 z"
+           style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
+           id="path14-6" />
+      </g>
+      <g
+         id="g16"
+         transform="translate(0,56.989021)">
+        <path
+           d="m 35.75,-51.906 c 0.605,-0.032 1.273,0.117 1.746,0.496 1.25,0.992 1.902,2.16 2.313,4.195 0.562,2.801 1.156,5.793 1.832,8.758 0.675,0.004 1.398,0.035 1.98,0.047 0.223,0.332 0.434,0.871 0.106,0.754 -0.414,-0.004 -1.414,-0.071 -1.821,-0.012 0.332,1.57 0.723,3.156 1.383,4.605 0.254,0.528 0.727,1.278 1.344,1.094 0.648,-0.191 0.683,-1.484 1.683,-1.062 0.372,0.156 0.332,0.621 0.247,0.922 -0.395,0.742 -1.407,0.937 -2.122,0.757 -2.683,-0.957 -2.949,-3.945 -3.769,-6.308 -0.68,-0.031 -1.324,0.094 -2.012,-0.047 -0.129,-0.422 -0.285,-0.602 -0.027,-0.672 0.39,-0.039 1.437,0.02 1.824,-0.019 -0.406,-2.336 -1.211,-6.571 -2.18,-10.332 -0.179,-0.704 -0.367,-1.602 -1.058,-2.145 -0.211,-0.164 -0.504,-0.336 -0.887,-0.188 -0.383,0.145 -0.449,0.958 -1.031,1.09 -0.504,0.114 -0.852,-0.136 -1.031,-0.465 -0.149,-0.269 -0.086,-0.687 0.125,-0.91 0.394,-0.429 0.867,-0.531 1.355,-0.558 z"
+           style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
+           id="path18" />
+      </g>
+      <g
+         id="g20"
+         transform="translate(0,56.989021)">
+        <path
+           d="m 21.777,-47.383 c 0.305,-0.051 1.059,0.141 1.598,0.563 0.316,0.25 0.551,0.562 0.777,0.89 0.258,0.375 0.578,0.739 0.598,1.211 0.008,0.168 -0.344,-0.226 -0.488,-0.414 -0.5,-0.66 -0.672,-0.988 -1.199,-1.328 -0.153,-0.102 -0.274,-0.141 -0.536,-0.074 -0.562,0.137 -0.129,1.187 -0.039,1.586 0.7,2.281 1.18,3.551 1.828,5.492 0.18,0.512 0.336,1.156 0.196,1.687 -0.094,0.34 -0.219,0.606 -0.672,0.606 -0.508,-0.004 -1.016,-0.211 -1.406,-0.449 -0.993,-0.606 -1.661,-1.399 -2.176,-2.008 -0.754,-0.938 -1.121,-1.574 -1.797,-2.707 -0.117,-0.149 -0.402,-0.434 -0.332,-0.094 0.18,0.824 0.629,1.836 0.891,2.93 0.144,0.617 0.214,1.472 0.007,1.89 -0.097,0.196 -0.183,0.399 -0.386,0.399 -0.231,-0.004 -0.516,-0.016 -0.739,-0.067 -1.05,-0.218 -1.839,-1.175 -2.672,-2.242 -0.75,-0.968 -1.226,-1.754 -1.992,-2.929 -0.136,-0.215 -0.422,-0.305 -0.328,-0.059 0.434,1.172 0.656,1.934 0.942,2.961 0.164,0.586 0.328,1.23 0.164,1.824 -0.086,0.309 -0.442,0.465 -0.77,0.457 -0.754,-0.019 -1.476,-0.73 -1.762,-1.035 -0.359,-0.387 -0.945,-1.008 -1.078,-1.523 -0.047,-0.204 0.317,-0.161 0.465,0.062 0.309,0.469 0.766,1.012 1.18,1.371 0.164,0.145 0.512,0.383 0.648,0.172 0.305,-0.477 0.086,-1.164 -0.062,-1.684 -0.789,-2.433 -1.59,-4.878 -2.313,-7.335 0.383,-0.02 0.817,0.011 1.199,-0.008 0.672,1.82 1.375,3.59 2.391,5.246 0.699,0.969 1.012,1.484 1.895,2.515 0.421,0.5 0.906,1.036 1.48,1.293 0.184,0.082 0.715,0.086 0.816,-0.308 0.165,-0.664 -0.109,-1.285 -0.339,-2 -0.719,-2.262 -1.649,-4.778 -2.168,-6.738 h 1.191 c 0.43,1.343 0.859,2.664 1.527,3.906 0.672,1.394 1.887,3.355 3.344,4.738 0.254,0.242 0.875,0.586 1.235,0.652 0.394,0.075 0.628,-0.394 0.418,-1.007 -0.95,-2.754 -1.649,-4.493 -2.297,-6.977 -0.192,-0.73 0.324,-1.496 0.761,-1.465 0,0 -0.304,0.051 0,0 z"
+           style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
+           id="path22" />
+      </g>
+      <g
+         id="g24"
+         transform="translate(0,56.989021)">
+        <path
+           d="m 30.73,-47.289 h 0.653 c 0.433,1.906 0.738,3.34 1.035,4.633 1.148,0.023 2.508,0.008 4.145,0.039 0.089,0.16 0.113,0.312 0.214,0.66 -1.632,-0.016 -2.839,0.043 -4.254,0.008 0.313,1.758 0.582,2.765 0.903,4.344 -0.281,0.023 -0.473,0.066 -0.68,-0.036 -0.305,-1.422 -0.523,-2.297 -0.906,-4.261 -1.324,0.019 -3.559,0 -4.402,-0.008 -0.028,-0.137 -0.098,-0.379 -0.172,-0.672 1.41,0.008 2.699,-0.027 4.418,-0.055 -0.289,-1.554 -0.653,-3.117 -0.954,-4.652 z"
+           style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
+           id="path26" />
+      </g>
+      <g
+         id="g28"
+         transform="translate(0,56.989021)">
+        <path
+           d="m 36.492,-9.66 c 0.692,-0.008 1.18,0.765 1.508,1.14 0.91,1.196 1.984,2.54 2.547,3.286 0.176,0.234 0.215,0.597 0.117,0.586 -1.226,-1.032 -2.879,-3.438 -3.32,-3.454 -0.449,-0.011 -1.442,2.149 -2.34,3.622 -0.078,0.027 -0.453,0.273 -0.477,-0.125 0.543,-1.926 1.309,-5.047 1.965,-5.055 z"
+           style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
+           id="path30" />
+      </g>
+      <g
+         id="g32"
+         transform="translate(0,56.989021)">
+        <path
+           d="m 33.023,-22.383 c 1.766,0.004 2.907,0.945 3.61,1.664 0.289,0.274 0.601,0.692 0.68,1.055 0.027,0.144 -0.286,-0.149 -0.372,-0.195 -0.761,-0.567 -1.394,-1.161 -2.273,-1.379 -0.84,-0.211 -1.277,-0.012 -1.652,0.246 -0.18,0.125 -0.465,0.39 -0.621,0.867 -0.368,1.289 -0.036,2.375 0.316,3.625 0.238,0.848 1.09,2.469 1.996,3.16 0.465,0.356 0.715,0.527 1.152,0.582 0.407,0.047 0.575,-0.164 0.649,-0.305 0.246,-0.472 0,-1.066 0.265,-1.48 0.133,-0.211 0.696,-0.156 0.93,0.094 0.172,0.179 0.172,0.496 0.176,0.765 0,0.223 -0.043,0.457 -0.113,0.664 -0.446,0.907 -1.047,1.02 -1.727,0.891 -1.289,-0.242 -2.359,-1.168 -2.992,-1.906 -0.813,-0.945 -1.051,-1.305 -1.567,-2.461 -0.371,-0.832 -0.527,-1.754 -0.582,-2.652 -0.054,-1.762 0.465,-3.239 2.125,-3.235 z"
+           style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
+           id="path34" />
+      </g>
+      <g
+         id="g36"
+         transform="translate(0,56.989021)">
+        <path
+           d="m 56.641,-28.496 c 0,7.465 -2.969,14.625 -8.246,19.902 -5.278,5.278 -12.434,8.242 -19.899,8.242 -7.465,0 -14.625,-2.964 -19.902,-8.242 -5.278,-5.277 -8.242,-12.437 -8.242,-19.902 0,-7.461 2.964,-14.621 8.242,-19.899 5.277,-5.277 12.437,-8.246 19.902,-8.246 7.465,0 14.621,2.969 19.899,8.246 5.277,5.278 8.246,12.438 8.246,19.899 z"
+           style="fill:none;stroke:#000000;stroke-width:0.7;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
+           id="path38" />
+      </g>
+      <g
+         id="g40"
+         transform="translate(0,56.989021)">
+        <path
+           d="M 0.352,-28.496 H 56.641"
+           style="fill:none;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
+           id="path42" />
+      </g>
+    </g>
+  </g>
+</svg>
diff --git a/mo/tex/mo-lib.tex b/mo/tex/mo-lib.tex
index 57a1bcf293541eca3f2d51e97d3e3b14184d8e5c..3358f2d90c2dd8649f6c3faa23d0f2f5139106a9 100644
--- a/mo/tex/mo-lib.tex
+++ b/mo/tex/mo-lib.tex
@@ -1,5 +1,7 @@
 % Obecná makra pro sazbu věcí okolo MO
 
+% Zkracování kolonek ve výsledkových listinách
+
 \newbox\ellipsisbox
 \setbox\ellipsisbox=\hbox{\bf~\dots~~}
 
@@ -35,3 +37,21 @@
 	\fi
 	\box0
 }
+
+% Fonty
+
+\def\fontpagella{
+	\ofsdeclarefamily [Pagella] {%
+	   \loadtextfam qplr;%
+			qplb;%
+			qpli;%
+			qplbi;;%
+	}
+	\registertfm qplr      -      file:texgyrepagella-regular.otf\PGfeat{}
+	\registertfm qplb      -      file:texgyrepagella-bold.otf\PGfeat{}
+	\registertfm qpli      -      file:texgyrepagella-italic.otf\PGfeat{}
+	\registertfm qplbi     -      file:texgyrepagella-bolditalic.otf\PGfeat{}
+}
+\def\PGfeat#1{:mode=node;script=latn;+tlig}
+
+\def\fontsize#1{\setfonts[/#1]\setbaselines{#1}\strut}
diff --git a/mo/tex/vysledky.tex b/mo/tex/vysledky.tex
index 1cfa2b59629d0d99c6c197d0144ebfd684c3ff83..35a24edb5820fb7bb7d5a00b98943cbce7bdd440 100644
--- a/mo/tex/vysledky.tex
+++ b/mo/tex/vysledky.tex
@@ -10,20 +10,7 @@
 
 \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{}
-
+\fontpagella
 \setfonts[Pagella/12]
 
 \uselanguage{czech}
diff --git a/mo/util.py b/mo/util.py
index 22e2ada074e95ed523a1ae08e2a0bdc01fbc1efc..1e2b4c69ded0632e6263ad31785843652ccddfa8 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -4,6 +4,7 @@ from dataclasses import dataclass
 import datetime
 import decimal
 import dateutil.tz
+from inspect import getmembers
 import locale
 import logging
 import os
@@ -101,16 +102,21 @@ def part_path(name: str) -> str:
     return os.path.normpath(os.path.join(__file__, "..", name))
 
 
-def link_to_dir(src: str, dest_dir: str, prefix: str = "", suffix: str = "") -> str:
+def link_to_dir(src: str, dest_dir: str, base_dir: str = "", prefix: str = "", suffix: str = "", make_dirs: bool = False) -> str:
     """Vytvoří hardlink na zdrojový soubor pod unikátním jménem v cílovém adresáři."""
 
+    full_dir = os.path.join(base_dir, dest_dir)
+    if make_dirs:
+        os.makedirs(full_dir, exist_ok=True)
+
     while True:
-        dest = os.path.join(dest_dir, prefix + secrets.token_hex(8) + suffix)
+        file_name = prefix + secrets.token_hex(8) + suffix
+        full_dest = os.path.join(full_dir, file_name)
         try:
-            os.link(src, dest)
-            return dest
+            os.link(src, full_dest)
+            return os.path.join(dest_dir, file_name)
         except FileExistsError:
-            logger.warning('Iteruji link_to_dir: %s už existuje', dest)
+            logger.warning('Iteruji link_to_dir: %s už existuje', full_dest)
 
 
 def unlink_if_exists(name: str):
@@ -283,3 +289,14 @@ def asciify(s: str) -> str:
     return (unicodedata.normalize('NFD', s)
                        .encode('ascii', errors='ignore')
                        .decode('utf-8'))
+
+
+def merge_objects(*src_objects) -> object:
+    class X:
+        pass
+    dest = X()
+    for src in src_objects:
+        for key, val in getmembers(src):
+            if not key.startswith('_'):
+                setattr(dest, key, val)
+    return dest
diff --git a/mo/util_tex.py b/mo/util_tex.py
new file mode 100644
index 0000000000000000000000000000000000000000..2dd54c5630e5c14860eadd449be71d704a5c3ccf
--- /dev/null
+++ b/mo/util_tex.py
@@ -0,0 +1,105 @@
+# Interakce s TeXem
+
+import os
+import qrcode
+import re
+import subprocess
+from typing import Any, Dict, List
+
+import mo.util
+
+
+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) is not str:
+        s = str(s)
+    s = re.sub(r'[\\{}#$%^~]', '?', s)
+    s = re.sub(r'([&_])', r'\\\1', s)
+    s = re.sub(r' - ', r' -- ', s)
+    return '{' + s + '}'
+
+
+def tex_key(key: str) -> str:
+    """
+        Primitivní překlad klíčů ve slovnících na jména TeXových maker.
+    """
+    key = key.replace('_', "")
+    key = re.sub('[1-9]', lambda match: chr(64 + int(match[0])), key)
+    key = key.replace('0', 'Z')
+    return key
+
+
+
+def format_hacks(tex_hacks: str) -> str:
+    """
+        U výsledkových listin a diplomů lze zadat tex_hacks -- speciální
+        nastavítka pro různé typografické hacky.
+        Nemůžeme ovšem 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 _parse_tex_hacks(tex_hacks: str) -> Dict[str, str]:
+    hacks = {}
+    fields = re.split(r'([A-Za-z]+)={([^}]*)}', 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 run_tex(directory: str, source: str) -> None:
+    env = dict(os.environ)
+    env['TEXINPUTS'] = mo.util.part_path('tex') + '//:'
+
+    subprocess.run(
+        ['luatex', '--interaction=errorstopmode', source],
+        check=True,
+        cwd=directory,
+        env=env,
+        stdin=subprocess.DEVNULL,
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.DEVNULL,
+    )
+
+
+class QREncoder:
+    name_base: str
+    counter: int
+    generated: List[str]
+
+    ECC_L = qrcode.ERROR_CORRECT_L
+    ECC_M = qrcode.ERROR_CORRECT_M
+    ECC_Q = qrcode.ERROR_CORRECT_Q
+    ECC_H = qrcode.ERROR_CORRECT_H
+
+    def __init__(self, name_base: str) -> None:
+        self.name_base = name_base
+        self.counter = 0
+        self.generated = []
+
+    def generate(self, msg: str, ecc_level: int = qrcode.ERROR_CORRECT_M, border=0) -> str:
+        self.counter += 1
+        name = f'{self.name_base}{self.counter:04d}.png'
+        # Defaultní border je 4, ale my většinou kódy sázíme na bílé pozadí
+        # a bez okraje se snáz pozicuje.
+        img = qrcode.make(msg, error_correction=ecc_level, box_size=1, border=border)
+        img.save(name)   # type: ignore
+        self.generated.append(name)
+        return name
+
+    def remove_all(self) -> None:
+        for name in self.generated:
+            os.unlink(name)
diff --git a/mo/web/__init__.py b/mo/web/__init__.py
index f8ccc040f8ed3cb984b97e144e9190f76faef56d..0aecb5bc0a92a589f9c9714badd175103c451ad3 100644
--- a/mo/web/__init__.py
+++ b/mo/web/__init__.py
@@ -263,7 +263,7 @@ try:
     mo.jobs.send_notify = wake_up_mule
 
 except ImportError:
-    app.logger.warn('Nenalezeno UWSGI, takže servisní procesy nepoběží.')
+    app.logger.warning('Nenalezeno UWSGI, takže servisní procesy nepoběží.')
     have_uwsgi = False
 
 
@@ -277,6 +277,7 @@ import mo.web.jinja
 import mo.web.menu
 import mo.web.misc
 import mo.web.org
+import mo.web.org_certs
 import mo.web.org_contest
 import mo.web.org_export
 import mo.web.org_jobs
diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index 6df9522b145eeed32face2372f84428444e8bdcd..b0b8d6545ef2a0d48727e5255d8b7b60729d7336 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -60,6 +60,7 @@ jg.update(TaskType=db.TaskType)
 jg.update(JobType=db.JobType)
 jg.update(JobState=db.JobState)
 jg.update(RoundType=db.RoundType)
+jg.update(CertType=db.CertType)
 
 # Další typy:
 jg.update(Markup=Markup)
@@ -160,3 +161,9 @@ def sol_display_points(s: Optional[db.Solution], user: bool = False) -> Union[st
             return Markup('<span class="unknown">?</span>')
     else:
         return assert_not_none(util_format.format_decimal(s.points))
+
+
+@app.template_filter()
+def titlecase(s: str) -> str:
+    # Jako s.title(), ale titlecasujeme jen první slovo
+    return s[0].upper() + s[1:] if s else ""
diff --git a/mo/web/misc.py b/mo/web/misc.py
index 03df23b1addfbc0bf09eaf09023c998837d8229a..85adaef45c97d2860116d8046a1285d9b4af7c8f 100644
--- a/mo/web/misc.py
+++ b/mo/web/misc.py
@@ -1,6 +1,7 @@
 # Web: Stránky, které nepatří jinam
 
 from flask import render_template, redirect, url_for, g
+import werkzeug.exceptions
 
 from mo.web import app
 
@@ -16,3 +17,18 @@ def index():
             return redirect(url_for('user_index'))
 
     return render_template('main.html')
+
+
+# Odkazy na tuto stránku se generují explicitně v mo.jobs.certs.Cert._make_qr_url
+@app.route('/cc/<int:year>/<cat>/<round_type_letter>/<place_nuts>/<cert_type_short>/<int:user_id>/<time_code>')
+def cert_check(year: int, cat: str, round_type_letter: str, place_nuts: str, cert_type_short: str, user_id: int, time_code: str):
+    # Zatím jenom triviální přesměrování na webovou stránku ročníku
+
+    if cat in "ABC":
+        return redirect(f'https://www.matematickaolympiada.cz/mo-pro-ss/rocnik/{year}-rocnik')
+    elif cat.startswith('Z'):
+        return redirect(f'https://www.matematickaolympiada.cz/mo-pro-zs/rocnik/{year}-rocnik')
+    elif cat == 'P':
+        return redirect('https://mo.mff.cuni.cz/p/archiv.html')
+    else:
+        raise werkzeug.exceptions.NotFound('Neznámý kód diplomu.')
diff --git a/mo/web/org.py b/mo/web/org.py
index ed9faa60acfc65af675bd496ae60a451102fa5c1..f9a094e1d69bd8c4c6c665ba74e213b10a8ae872 100644
--- a/mo/web/org.py
+++ b/mo/web/org.py
@@ -102,6 +102,11 @@ def org_index():
     get_stats(overview)
     overview = filter_overview(overview)
 
+    show_certs_link = any(db.RoleType.garant_skola in o.role_set
+                          and o.contest
+                          and o.contest.state == db.RoundState.closed
+                          for o in overview)
+
     form_add_contest = mo.web.org_round.AddContestForm()
 
     return render_template(
@@ -109,6 +114,7 @@ def org_index():
         overview=overview,
         role_type_names=db.role_type_names,
         form_add_contest=form_add_contest,
+        show_certs_link=show_certs_link,
     )
 
 
diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
new file mode 100644
index 0000000000000000000000000000000000000000..8af0e0c93a59b54b58fd52688c25555adde1c27f
--- /dev/null
+++ b/mo/web/org_certs.py
@@ -0,0 +1,398 @@
+# Web: Certifikáty
+
+from flask import render_template, g, redirect, url_for, flash
+from flask.helpers import send_file
+from flask_wtf import FlaskForm
+import flask_wtf.file
+import locale
+from markupsafe import Markup
+import os
+import pikepdf
+from pikepdf.models.metadata import encode_pdf_date
+from sqlalchemy import func, select, and_
+from sqlalchemy.orm import joinedload
+from tempfile import NamedTemporaryFile
+from typing import Tuple, Optional, Dict
+import werkzeug.exceptions
+import wtforms
+from wtforms import validators
+
+import mo
+import mo.config as config
+import mo.db as db
+import mo.email
+from mo.jobs.certs import schedule_create_certs, DesignParams, BackgroundType
+from mo.rights import Right
+import mo.submit
+import mo.util
+from mo.util import merge_objects
+from mo.web import app
+from mo.web.org_contest import get_context
+import mo.web.fields as mo_fields
+from mo.web.user import send_certificate
+
+
+def validate_background(form: 'CertSetForm', field: wtforms.Field) -> None:
+    if not field.data:
+        return
+
+    file = field.data.stream
+    submitter = mo.submit.Submitter()
+
+    try:
+        submitter.check_certificate_background(file.name)
+    except mo.submit.SubmitException as e:
+        raise wtforms.ValidationError(str(e))
+
+
+class SpaceField(wtforms.FloatField):
+
+    def __init__(self, label: str, **kwargs):
+        super().__init__(label,
+                         validators=[validators.InputRequired(),
+                                     validators.NumberRange(-10, 200, "Hodnota musí být mezi %(min)s a %(max)s")],
+                         **kwargs)
+
+default_design = DesignParams()
+
+
+class CertSetForm(FlaskForm):
+    signer1_name = mo_fields.String("1. podepisující: jméno")
+    signer1_title = mo_fields.String("1. podepisující: funkce", render_kw={'placeholder': 'např. Předseda okresní komise MO'})
+    signer2_name = mo_fields.String("2. podepisující: jméno")
+    signer2_title = mo_fields.String("2. podepisující: funkce", render_kw={'placeholder': 'např. Ředitel školy'})
+    issue_place = mo_fields.String("Místo vydání")
+    issue_date = mo_fields.String("Datum vydání", render_kw={'placeholder': 'např. 3. března 2025'})
+    background_type = wtforms.SelectField(choices=BackgroundType.choices(), coerce=BackgroundType.coerce)
+    upload_background = flask_wtf.file.FileField(validators=[validate_background],
+                                                 render_kw={'accept': 'application/pdf'},
+                                                 description="Zde můžete nahrát obrázek ve formátu PDF, který se použije jako pozadí diplomu.")
+    space1 = SpaceField("Mezera 1", description=f"Mezera mezi horním okrajem a hlavičkou (default: {default_design.space1}).")
+    space2 = SpaceField("Mezera 2", description=f"Mezera mezi hlavičkou a jménem (default: {default_design.space2}).")
+    space3 = SpaceField("Mezera 3", description=f"Mezera mezi jménem a umístěním (default: {default_design.space3}).")
+    space4 = SpaceField("Mezera 4", description=f"Mezera mezi umístěním a podpisy (default: {default_design.space4}).")
+    space5 = SpaceField("Mezera 5", description=f"Mezera mezi podpisy a patičkou (default: {default_design.space5}).")
+    space6 = SpaceField("Mezera 6", description=f"Mezera mezi patičkou a dolním okrajem (default: {default_design.space6}).")
+    logo_visible = wtforms.BooleanField("Logo MO a JČMF v patičce")
+    tex_hacks = mo_fields.String("Nastavení sazby")  # description se nastavuje později
+    generate = wtforms.SubmitField("Vytvořit diplomy")
+    save = wtforms.SubmitField("Pouze uložit nastavení")
+    delete = wtforms.SubmitField("Smazat diplomy")
+
+    def osmo_validate(self, cset) -> bool:
+        ok = True
+
+        if self.background_type.data == BackgroundType.custom and self.upload_background.data is None and not cset.background_file:
+            self.upload_background.errors.append('Nahrajte obrázek na pozadí.')     # FIXME: typing
+            ok = False
+
+        if all(getattr(self, key).data >= 0 for key in DesignParams.SPACE_PARAMS):
+            self.space1.errors.append('Alespoň jedna z mezer musí být pružná.')     # FIXME: typing
+            ok = False
+
+        return ok
+
+
+class CertApproveForm(FlaskForm):
+    ctype = wtforms.HiddenField()
+    approve = wtforms.SubmitField("Schválit")
+    unapprove = wtforms.SubmitField("Zrušit schválení")
+
+
+@app.route('/org/contest/c/<int:ct_id>/certificates', methods=('GET', 'POST'))
+def org_certificates(ct_id: int):
+    ctx = get_context(ct_id=ct_id, right_needed=Right.view_contestants)
+    assert ctx.contest and ctx.master_contest
+
+    group_rounds = ctx.round.get_group_rounds(True)
+    group_rounds.sort(key=lambda r: r.round_code())
+
+    contest = ctx.master_contest
+    ct_id = contest.contest_id
+    can_change = ctx.rights.have_right(Right.manage_contest) and ctx.master_round.round_type != db.RoundType.other
+
+    sess = db.get_session()
+    cset = (sess.query(db.CertSet)
+            .with_for_update()
+            .filter_by(contest=contest)
+            .one_or_none())
+    if cset is None:
+        new_cset = True
+        cset = db.CertSet(
+            contest_id=ct_id,
+        )
+        dparams = DesignParams(issue_date=mo.now.strftime('%d. %B %Y'))
+    else:
+        new_cset = False
+        dparams = DesignParams.from_json(cset.design_params)
+
+    if can_change:
+        form = CertSetForm(obj=merge_objects(cset, dparams))
+        form.tex_hacks.description = Markup("Speciální nastavení sazby diplomů (viz <a href='" + url_for('doc_admin') + "'>návod</a>)")
+        if not ctx.rights.have_right(Right.edit_tex_hacks):
+            del form.tex_hacks
+        if new_cset:
+            del form.delete
+        approve_form = CertApproveForm()
+    else:
+        form = None
+        approve_form = None
+
+    if approve_form and approve_form.validate_on_submit() and (approve_form.approve.data or approve_form.unapprove.data):
+        try:
+            ctype = db.CertType.coerce(approve_form.ctype.data)
+        except ValueError:
+            raise werkzeug.exceptions.UnprocessableEntity()
+        cfile = sess.query(db.CertFile).filter_by(cert_set_id=ct_id, type=ctype).one_or_none()
+        if cfile:
+            if approve_form.approve.data:
+                cfile.approved = True
+                flash(f'Diplomy ({ctype.friendly_name()}) schváleny.', 'success')
+            elif approve_form.unapprove.data:
+                cfile.approved = False
+                flash(f'Odvoláno schválení diplomů ({ctype.friendly_name()}).', 'success')
+            sess.commit()
+            return redirect(ctx.url_for('org_certificates'))
+        else:
+            flash('Tento typ diplomů nebyl vytvořen.', 'danger')
+
+    elif form:
+        if not form.is_submitted():
+            pass
+        elif not form.validate() or not form.osmo_validate(cset):
+            flash('V nastavení diplomů byly nalezeny chyby.', 'danger')
+        elif not new_cset and form.delete.data:
+            sess.delete(cset)
+            mo.util.log(
+                type=db.LogType.cert_set,
+                what=contest.contest_id,
+                details={'action': 'delete', 'cert_set': db.row2dict(cset), 'reason': 'web'},
+            )
+            sess.commit()
+            app.logger.info(f'Smazány diplomy pro soutěž #{contest.contest_id}')
+            flash('Diplomy smazány.', 'success')
+            return redirect(ctx.url_for('org_certificates'))
+        elif form.generate.data or form.save.data:
+            form.populate_obj(cset)
+            form.populate_obj(dparams)
+            cset.design_params = dparams.to_json()
+            if dparams.background_type == BackgroundType.custom:
+                if form.upload_background.data:
+                    old_background = cset.background_file
+                    out_dir = cset.dir_path()
+                    cset.background_file = mo.util.link_to_dir(form.upload_background.data.stream.name,
+                                                               out_dir,
+                                                               base_dir=mo.util.data_dir('certs'),
+                                                               prefix='background-',
+                                                               suffix='.pdf',
+                                                               make_dirs=True)
+                    app.logger.info(f'Nahráno pozadí diplomů {cset.background_file}')
+                else:
+                    old_background = None
+            else:
+                old_background = cset.background_file
+                cset.background_file = None
+            changes = None
+            if new_cset:
+                sess.add(cset)
+                mo.util.log(
+                    type=db.LogType.cert_set,
+                    what=contest.contest_id,
+                    details={'action': 'new', 'cert_set': db.row2dict(cset), 'reason': 'web'},
+                )
+                app.logger.info(f'Založeny diplomy pro soutěž #{contest.contest_id}')
+                sess.commit()
+            elif sess.is_modified(cset):
+                cset.changed_at = mo.now
+                changes = db.get_object_changes(cset)
+                mo.util.log(
+                    type=db.LogType.cert_set,
+                    what=contest.contest_id,
+                    details={'action': 'edit', 'changes': changes, 'reason': 'web'},
+                )
+                app.logger.info(f'Upraveno nastavení diplomů pro soutěž #{contest.contest_id}')
+                sess.commit()
+                if old_background:
+                    mo.util.unlink_if_exists(os.path.join(mo.util.data_dir('certs'), old_background))
+            if form.generate.data:
+                if cset.job is not None and cset.job.is_active():
+                    flash('Počkejte, až doběhne předchozí dávka na tvorbu diplomů.', 'danger')
+                else:
+                    cset.job_id = schedule_create_certs(contest, g.user)
+                    sess.commit()
+                    return redirect(url_for('org_job_wait', id=cset.job_id, back=ctx.url_for('org_certificates')))
+            else:
+                if changes is not None:
+                    flash('Nastavení uloženo.', 'success')
+                else:
+                    flash('Žádné změny k uložení.', 'info')
+                return redirect(ctx.url_for('org_certificates'))
+
+    pions = sess.query(db.Participation).filter_by(contest_id=ct_id).options(joinedload(db.Participation.user)).all()
+    pions.sort(key=lambda p: p.user.sort_key())
+    users_pions = [(p.user, p) for p in pions]
+
+    cert_files_by_type: Dict[db.CertType, Optional[db.CertFile]] = {}
+    for cf in sess.query(db.CertFile).filter_by(cert_set_id=ct_id).all():
+        cert_files_by_type[cf.type] = cf
+    cert_file_columns = [(t, cert_files_by_type.get(t)) for t in db.CertType]
+
+    certs_by_uid_type: Dict[Tuple[int, db.CertType], db.Certificate] = {}
+    for c in sess.query(db.Certificate).filter_by(cert_set_id=ct_id).all():
+        certs_by_uid_type[c.user_id, c.type] = c
+
+    return render_template(
+        'org_certificates.html',
+        ctx=ctx,
+        group_rounds=group_rounds,
+        form=form,
+        approve_form=approve_form,
+        cset=cset,
+        users_pions=users_pions,
+        cert_file_columns=cert_file_columns,
+        certs_by_uid_type=certs_by_uid_type,
+        settings_changed=(cset.changed_at is not None and (cset.certs_issued_at is None or cset.certs_issued_at < cset.changed_at)),
+        scoretable_changed=(cset.scoretable != contest.scoretable),
+        form_has_errors=form and form.is_submitted(),
+    )
+
+
+@app.route('/org/contest/c/<int:ct_id>/certificate/<cert_type>/all/<filename>')
+@app.route('/org/contest/c/<int:ct_id>/certificate/<cert_type>/<int:user_id>/<filename>')
+def org_cert_file(ct_id: int, cert_type: str, filename: str, user_id: Optional[int] = None):
+    ctx = get_context(ct_id=ct_id, right_needed=Right.view_contestants)
+    assert ctx.contest and ctx.master_contest
+
+    return send_certificate(ct_id, cert_type, filename, user_id)
+
+
+@app.route('/org/contest/school-results/<int:school_id>/c/<int:ct_id>/certificates/<cert_type>/<filename>')
+def org_school_results_certs(school_id: int, ct_id: int, cert_type: str, filename: str):
+    sess = db.get_session()
+    place = sess.query(db.Place).filter_by(place_id=school_id).one_or_none()
+    if place is None:
+        raise werkzeug.exceptions.NotFound()
+
+    if not g.gatekeeper.rights_for(place=place).have_right(Right.view_school_contestants):
+        raise werkzeug.exceptions.Forbidden()
+
+    contest = sess.query(db.Contest).options(joinedload(db.Contest.round)).get(ct_id)
+    if contest is None or contest.round.year != config.CURRENT_YEAR:
+        raise werkzeug.exceptions.NotFound()
+
+    if contest.state != db.RoundState.closed:
+        raise werkzeug.exceptions.Forbidden()
+
+    try:
+        ctype: db.CertType = db.CertType.coerce(cert_type)
+    except ValueError:
+        raise werkzeug.exceptions.NotFound()
+
+    if filename != ctype.file_name(plural=True) + '.pdf':
+        raise werkzeug.exceptions.NotFound()
+
+    cfile = sess.query(db.CertFile).filter_by(cert_set_id=ct_id, type=ctype, approved=True).one_or_none()
+    if cfile is None:
+        raise werkzeug.exceptions.NotFound()
+
+    users_and_certs = (sess.query(db.User, db.Certificate)
+                       .select_from(db.Certificate)
+                       .join(db.Participant, and_(db.Participant.user_id == db.Certificate.user_id,
+                                                  db.Participant.year == config.CURRENT_YEAR,
+                                                  db.Participant.school == school_id))
+                       .join(db.Certificate.user)
+                       .filter(db.Certificate.cert_set_id == ct_id,
+                               db.Certificate.type == ctype)
+                       .all())
+    users_and_certs.sort(key=lambda x: x[0].sort_key())
+
+    try:
+        file = os.path.join(mo.util.data_dir('certs'), cfile.pdf_file)
+        if not os.path.isfile(file):
+            app.logger.error(f'Certifikát {file} je v DB, ale soubor neexistuje')
+            raise werkzeug.exceptions.NotFound()
+        with pikepdf.open(file, attempt_recovery=False) as src:
+            with pikepdf.new() as dst:
+                for _, cert in users_and_certs:
+                    dst.pages.append(src.pages[cert.page_number - 1])
+                dst.docinfo['/Title'] = f'Matematická Olympiáda – {ctype.friendly_name(plural=True)}'
+                dst.docinfo['/Creator'] = 'Odevzdávací Systém Matematické Olympiády'
+                dst.docinfo['/CreationDate'] = encode_pdf_date(mo.now.astimezone())
+                tmp_file = NamedTemporaryFile(dir=mo.util.data_dir('tmp'), prefix='cert-')
+                dst.save(tmp_file.name)
+    except pikepdf.PdfError as e:
+        app.logger.error(f'Chyba při zpracování PDF certifikátů: {e}')
+        raise werkzeug.exceptions.InternalServerError()
+
+    return send_file(open(tmp_file.name, 'rb'), mimetype='application/pdf')
+
+
+@app.route('/org/contest/school-results/<int:school_id>/')
+def org_school_results(school_id: int):
+    sess = db.get_session()
+    place = sess.query(db.Place).filter_by(place_id=school_id).one_or_none()
+    if place is None:
+        raise werkzeug.exceptions.NotFound()
+
+    # Úmyslně nekontrolujeme práva ke kategorii
+    if not g.gatekeeper.rights_for(place=place).have_right(Right.view_school_contestants):
+        raise werkzeug.exceptions.Forbidden()
+
+    pants_subq = (sess.query(db.Participant.user_id)
+                  .filter_by(year=config.CURRENT_YEAR,
+                             school=school_id)
+                  .subquery())
+
+    contest_counts = (sess.query(db.Contest.contest_id, func.count(db.Participation.user_id))
+                      .select_from(db.Contest)
+                      .join(db.Contest.round)
+                      .join(db.Contest.participations)
+                      .filter(db.Contest.contest_id == db.Contest.master_contest_id,
+                              db.Round.year == config.CURRENT_YEAR,
+                              db.Participation.user_id.in_(select(pants_subq)),
+                              db.Participation.state == db.PartState.active)
+                      .group_by(db.Contest.contest_id)
+                      .all())
+    contest_ids = [cid for cid, _ in contest_counts]
+    num_pants_by_cid = {cid: cnt for cid, cnt in contest_counts}
+
+    contests = (sess.query(db.Contest)
+                .filter(db.Contest.contest_id.in_(contest_ids))
+                .options(joinedload(db.Contest.round),
+                         joinedload(db.Contest.place))
+                .all())
+    contests.sort(key=lambda c: (c.round.category, c.round.seq, locale.strxfrm(c.place.name)))    # part nepotřebujeme, vše jsou hlavní soutěže
+
+    cert_counts = (sess.query(db.CertFile.cert_set_id, db.CertFile.type, func.count(db.Certificate.user_id))
+                   .select_from(db.Certificate)
+                   .join(db.Certificate.cert_file)
+                   .filter(db.Certificate.cert_set_id.in_(contest_ids))
+                   .filter(db.Certificate.user_id.in_(select(pants_subq)))
+                   .filter(db.CertFile.approved)
+                   .group_by(db.CertFile.cert_set_id, db.CertFile.type)
+                   .all())
+    cert_counts_by_cid_type = {(cid, ctype): cnt for cid, ctype, cnt in cert_counts}
+    contests_with_cert = {cid for cid, ctype, cnt in cert_counts}
+
+    return render_template(
+        'org_school_results.html',
+        place=place,
+        contests=contests,
+        num_pants_by_cid=num_pants_by_cid,
+        cert_counts_by_cid_type=cert_counts_by_cid_type,
+        contests_with_cert=contests_with_cert,
+    )
+
+
+# URL je zadrátované do mo.email.send_grading_info_email
+@app.route('/org/contest/school-results/')
+def org_school_results_all():
+    school_place_ids = set(ur.place_id for ur in g.gatekeeper.roles if ur.role == db.RoleType.garant_skola)
+    if len(school_place_ids) == 1:
+        return redirect(url_for('org_school_results', school_id=list(school_place_ids)[0]))
+
+    sess = db.get_session()
+    schools = sess.query(db.Place).filter(db.Place.place_id.in_(school_place_ids)).all()
+    schools.sort(key=lambda p: locale.strxfrm(p.name))
+
+    return render_template('org_school_results_all.html', schools=schools)
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index e6764f955e890ae58ab19ad1e5e85e0ec8a96d89..57062dc7d89c7bb7420ceedea8da105dc3aacb44 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -1790,8 +1790,7 @@ def org_contest_edit(ct_id: int):
             sess.commit()
             flash('Změny soutěže uloženy.', 'success')
 
-            if 'state' in changes and contest.state == db.RoundState.graded:
-                mo.jobs.notify.schedule_send_grading_info(round, contest, g.user)
+            if 'state' in changes and mo.jobs.notify.schedule_send_grading_info(round, [contest], contest.state, g.user):
                 flash('Založena dávka na rozeslání e-mailů účastníkům.', 'success')
         else:
             flash('Žádné změny k uložení.', 'info')
diff --git a/mo/web/org_jobs.py b/mo/web/org_jobs.py
index 6d99bd8c828c00a0e138138f6b02e07ac61ec9b4..fbda44d7c6621101aa3575fe8fb2db00c4c1349c 100644
--- a/mo/web/org_jobs.py
+++ b/mo/web/org_jobs.py
@@ -53,6 +53,11 @@ def org_job_get_result_url(job: db.Job) -> Tuple[Optional[str], Optional[str]]:
             'org_contest', ct_id=jin['contest_id'], site_id=jin['site_id'], job_id=job.job_id
         )
 
+    elif job.type == db.JobType.create_certs and 'contest_id' in jin:
+        return "Vygenerované diplomy", url_for(
+            'org_certificates', ct_id=jin['contest_id'],
+        )
+
     return None, None
 
 
@@ -145,11 +150,12 @@ def org_job(id: int):
             elif job.state == db.JobState.internal_error and not g.user.is_admin:
                 flash('Dávku, která selhala, smí smazat jenom správce. Dejte mu prosím vědět.', 'danger')
             else:
+                assert job.is_deletable(g.user.is_admin)
                 tj.remove_loaded()
                 flash('Dávka smazána', 'success')
                 return redirect(after_success_url)
         elif form.request_mail.data:
-            if job.state in [db.JobState.ready, db.JobState.running, db.JobState.waiting]:
+            if job.is_active():
                 job.email_when_done = True
                 db.get_session().commit()
                 flash('Až dávka doběhne, přijde o tom e-mail.', 'success')
@@ -158,7 +164,7 @@ def org_job(id: int):
             return redirect(after_success_url)
         return redirect(after_failure_url)
 
-    has_errors = (job.state in (db.JobState.failed, db.JobState.internal_error, db.JobState.soft_error)
+    has_errors = (job.is_erroneous()
                   and isinstance(job.out_json, dict)
                   and 'errors' in job.out_json)
 
diff --git a/mo/web/org_place.py b/mo/web/org_place.py
index 97daa0d5a45143f1e03f668ca5bb25d82f8ff402..6c7946ec93d240f2d579271755535e41a51bccc9 100644
--- a/mo/web/org_place.py
+++ b/mo/web/org_place.py
@@ -15,6 +15,7 @@ import mo
 import mo.db as db
 import mo.imports
 import mo.rights
+from mo.rights import Right
 import mo.util
 from mo.web import app
 import mo.web.fields as mo_fields
@@ -85,6 +86,7 @@ def org_place(id: int):
         'org_place.html', place=place, school=school,
         can_edit=rr.can_edit_place(place),
         can_add_child=rr.can_add_place_child(place),
+        can_view_school_contestants=rr.have_right(Right.view_school_contestants),
         children=children,
         search_form=search_form,
         found_places=found_places, search_failed=search_failed, search_limited=search_limited,
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 44a1dd8117bb2a6510513946b0ad68a0b1773619..b0b9dfc50849e0510a43bd6aedf53879337e0433 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -373,6 +373,7 @@ class RoundEditForm(FlaskForm):
     pr_submit_end = mo_fields.DateTime(
         "Konec odevzdávání pro dozor",
         validators=[validators.Optional()])
+    ## Nastavení výsledkové listiny se používá pouze v hlavních kolech
     score_mode = wtforms.SelectField("Výsledková listina", choices=db.RoundScoreMode.choices(), coerce=db.RoundScoreMode.coerce)
     score_winner_limit = mo_fields.Points(
         "Hranice bodů pro vítěze", validators=[validators.Optional(), validators.NumberRange(min=0)],
@@ -386,10 +387,12 @@ class RoundEditForm(FlaskForm):
         "Hranice bodů pro postup", validators=[validators.Optional(), validators.NumberRange(min=0)],
         description="Řešitelé s alespoň tolika body mají postoupit do dalšího kola, prázdná hodnota = není stanoveno",
     )
+    score_has_hm = wtforms.BooleanField("Udělovat pochvalná uznání neúspěšným řešitelům, kteří mají plný počet bodů za aspoň jednu úlohu")
     points_step = wtforms.SelectField(
         "Přesnost bodování", choices=db.round_points_step_choices, coerce=decimal.Decimal,
         description="Ovlivňuje možnost zadávání nových bodů, již uložené body nezmění"
     )
+    ## Režim přihlašování se také použivá jen v hlavních kolech
     enroll_mode = wtforms.SelectField("Režim přihlašování", choices=db.RoundEnrollMode.choices(), coerce=db.RoundEnrollMode.coerce)
     enroll_advert = mo_fields.String("Popis v přihlášce")
     enroll_deadline = mo_fields.DateTime("Konec registrace pro účastníky", validators=[validators.Optional()])
@@ -400,8 +403,10 @@ class RoundEditForm(FlaskForm):
     max_rec_grade = wtforms.IntegerField(
         "Maximální doporučený ročník",
         validators=[validators.Optional(), validators.NumberRange(1, 13, message="Ročník musí být od 1 do 13.")])
+    ## Primární i sekundární kola
     online_submit = wtforms.BooleanField("Povolit online odevzdávání v nově založených soutěžích")
     has_messages = wtforms.BooleanField("Zprávičky pro účastníky (aktivuje možnost vytvářet novinky zobrazované účastníkům)")
+    # Export jen v primárních kolech
     export_score_to_mo_web = wtforms.BooleanField("Automaticky publikovat výsledkovou listinu na webu MO")
     submit = wtforms.SubmitField('Uložit')
     force_submit = wtforms.SubmitField('Uložit s chybami')
@@ -422,10 +427,16 @@ def org_round_edit(round_id: int):
         del form.score_mode
         del form.score_winner_limit
         del form.score_successful_limit
+        del form.score_advance_limit
+        del form.score_has_hm
         del form.points_step
         del form.export_score_to_mo_web
         # ani nastavení přihlašování
         del form.enroll_mode
+        del form.enroll_advert
+        del form.enroll_deadline
+        del form.min_rec_grade
+        del form.max_rec_grade
 
     do_submit = False
     errors = []
@@ -470,8 +481,7 @@ def org_round_edit(round_id: int):
             sess.commit()
             flash('Změny kola uloženy.', 'success')
 
-            if 'state' in changes and round.state == db.RoundState.graded:
-                mo.jobs.notify.schedule_send_grading_info(round, None, g.user)
+            if 'state' in changes and mo.jobs.notify.schedule_send_grading_info(round, None, round.state, g.user):
                 flash('Založena dávka na rozeslání e-mailů účastníkům.', 'success')
         else:
             flash('Žádné změny k uložení.', 'info')
@@ -513,7 +523,10 @@ def check_round_settings(round: db.Round, form: RoundEditForm) -> List[str]:
     if form.export_score_to_mo_web is not None and form.export_score_to_mo_web.data and form.round_type.data == db.RoundType.other:
         errors.append('Není možné publikovat výsledkovou listinu na webu MO, když není nastaven typ kola.')
 
-    if form.enroll_deadline is not None and form.enroll_deadline.data and form.enroll_mode.data == db.RoundEnrollMode.manual:
+    if (form.enroll_deadline is not None
+            and form.enroll_deadline.data
+            and form.enroll_mode is not None
+            and form.enroll_mode.data == db.RoundEnrollMode.manual):
         errors.append('Nemá smysl definovat termín přihlašování, když není přihlašování zapnuto.')
 
     errors.extend(mo.contests.check_contest_state(round=round, state=state))
@@ -794,6 +807,8 @@ def org_close_contests(round_id: int, hier_id: Optional[int] = None):
 
             sess.commit()
             flash(inflect_with_number(len(contests), 'Ukončena %s soutěž.', 'Ukončeny %s soutěže.', 'Ukončeno %s soutěží.'), 'success')
+            if mo.jobs.notify.schedule_send_grading_info(ctx.round, contests, db.RoundState.closed, g.user):
+                flash('Založena dávka na rozeslání e-mailů účastníkům.', 'success')
             return redirect(ctx.url_for('org_round'))
 
     num_contests = db.get_count(contests_q)
diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index 17e343ef04e08ac73e6f0543cd2366b98c433ffa..60bb1c978756b99a526ca3570f096a70803a9ae8 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -197,6 +197,8 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_
             status = 'vítěz'
         elif result.successful:
             status = 'úspěšný'
+        elif result.honorary_mention:
+            status = 'pochvalné uznání'
         else:
             status = ""
 
@@ -234,6 +236,9 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_
         elif result.successful:
             classes.append('successful')
             hints.append('úspěšný řešitel')
+        elif result.honorary_mention:
+            classes.append('hm')
+            hints.append('pochvalné uznání')
         if round.score_advance_limit is not None and points is not None and points >= round.score_advance_limit:
             classes.append('advance')
             hints.append('postupuje')
@@ -298,6 +303,7 @@ def org_score_snapshots(ct_id: int):
 
     sess = db.get_session()
     scoretables = sess.query(db.ScoreTable).filter_by(contest_id=ctx.master_contest.contest_id).all()
+    cset = sess.query(db.CertSet).filter_by(contest_id=ctx.master_contest.contest_id).one_or_none()
 
     snapshot_form: Optional[ScoreSnapshotForm] = None
     set_final_form: Optional[SetFinalScoretableForm] = None
@@ -354,7 +360,8 @@ def org_score_snapshots(ct_id: int):
         'org_score_snapshots.html',
         ctx=ctx,
         scoretables=scoretables,
-        set_final_form=set_final_form
+        set_final_form=set_final_form,
+        cset=cset,
     )
 
 
@@ -390,6 +397,8 @@ def org_score_snapshot(ct_id: int, scoretable_id: int):
     if not scoretable or scoretable.contest_id != ctx.master_contest.contest_id:
         raise werkzeug.exceptions.NotFound()
 
+    cset = sess.query(db.CertSet).filter_by(contest_id=ctx.master_contest.contest_id).one_or_none()
+
     columns, table_rows, _ = scoretable_construct(scoretable)
     # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč'))
 
@@ -409,6 +418,7 @@ def org_score_snapshot(ct_id: int, scoretable_id: int):
             table=table,
             set_final_form=SetFinalScoretableForm() if ctx.rights.have_right(Right.manage_contest) else None,
             scoretable=scoretable,
+            cset=cset,
         )
     else:
         return table.send_as(format)
diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index be60c783a6446dca3effc800f4cd187d2d354be0..0066ba8fa0987f619afdb59e74909ba9a7fbe9a1 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -782,6 +782,14 @@ def org_user_delete(user_id: int):
     if num_uploads:
         errors.append(f'Nahrál řešení/opravy ({num_uploads})')
 
+    num_cert_sets = sess.query(db.CertSet).filter_by(certs_issued_by_user=user).count()
+    if num_cert_sets:
+        errors.append(f'Vydal diplomy ({num_cert_sets})')
+
+    num_certs = sess.query(db.Certificate).filter_by(user=user).count()
+    if num_certs:
+        errors.append(f'Získal diplomy ({num_certs})')
+
     logs = sess.query(db.Log).filter_by(user=user).all()
     num_good_logs = 0
     num_bad_logs = 0
diff --git a/mo/web/templates/doc_admin.html b/mo/web/templates/doc_admin.html
index f006468727823328caa181861f15e213f74a6a8c..26432b5d9f7f5cd235be68f35e7b7b3e9663a519 100644
--- a/mo/web/templates/doc_admin.html
+++ b/mo/web/templates/doc_admin.html
@@ -16,6 +16,10 @@ dvojice tvaru <i>klíč</i><code>={</code><i>hodnota</i><code>}</code>, oddělen
 	<li><code>podhlavicka</code> – druhý řádek hlavičky listiny, nahrazuje default s místem a datem konání (samotné „<code>-</code>“ řádek úplně vynechá)
 </ul>
 
+<h3>Speciality při sazbě diplomů</h3>
+
+<p>Podobně fungují dodatečné parametry pro sazbu diplomů. Zatím ale nejsou definovány žádné klíče.
+
 <h3>Exporty</h3>
 
 <p>URL pro exporty různých dat z OSMO, zatím je potřeba je zadávat rucně.
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
new file mode 100644
index 0000000000000000000000000000000000000000..714d6241345675c394a3a9141be5b9d5a7fcf291
--- /dev/null
+++ b/mo/web/templates/org_certificates.html
@@ -0,0 +1,231 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% set round = ctx.round %}
+{% set contest = ctx.contest %}
+
+{% block head %}
+	<script type="text/javascript">
+		function recalc_background_controls() {
+			const value = document.getElementById('background_type').value;
+			const controls = document.getElementById('background_controls');
+			if (value == 'custom') {
+				controls.style.display = 'block';
+			} else {
+				controls.style.display = 'none';
+			}
+		}
+		window.addEventListener("load", (event) => { recalc_background_controls(); });
+	</script>
+{% endblock %}
+
+{% block title %}
+{{ round.round_code() }}: Diplomy pro {{ round.name|lower }} kategorie {{ round.category }}{% if contest %}<br>{{ contest.place.name_locative() }}{% endif %}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Diplomy") }}
+{% endblock %}
+
+{% block body %}
+
+<p>
+Zde je možné vytvořit účastnické listy pro všechny soutěžící,
+po vytvoření oficiální výsledkové listiny také diplomy vítězů / úspěšných řešitelů
+a pochvalná uznání (pokud se v tomto kole vydávají).
+</p>
+
+<p>
+Hotové diplomy si můžete stáhnout a vytisknout.
+Pokud je schválíte, po uzavření kola budou dostupné soutěžícím a jejich
+školním garantům v OSMO.
+</p>
+
+<h3>Diplomy</h3>
+
+{% if group_rounds|length > 1 %}
+<p>Diplomy se vydávají <b>společné</b> pro skupinu kol:
+{% for r in group_rounds %}{% if loop.index > 1 %}, {% endif %}<a href="{{ url_for('org_round', round_id=r.round_id) }}">{{ r.round_code() }} {{ r.name }}</a>{% endfor %}.
+</p>
+{% endif %}
+
+{% if cset.certs_issued_at is not none %}
+
+<p>Diplomy vytvořil(a)
+{{ cset.certs_issued_by_user|user_link }}
+{{ cset.certs_issued_at|time_and_timedelta }}.
+
+<table class="data" id="diplomy">
+	<thead>
+		<tr>
+			<th>Jméno
+			{% for t, cfile in cert_file_columns %}
+			<th class=ac>{{ t.friendly_name()|titlecase }}<br>
+				{% if cfile %}
+					{% if cfile.approved %}
+						<em>(schváleno)</em>
+					{% else %}
+						<em>(neschváleno)</em>
+					{% endif %}
+				{% endif %}
+			{% endfor %}
+	<tbody>
+		{% for user, pion in users_pions %}
+		<tr class="state-{{ pion.state.name }}">
+			<td><a href='{{ url_for('org_contest_user', ct_id=ctx.ct_id, user_id=user.user_id) }}'>{{ user.full_name() }}</a>
+				{% if pion.state != PartState.active %}
+					({{ pion.state.friendly_name() }})
+				{% endif %}
+			{% for t, cfile in cert_file_columns %}
+				{% set cert = certs_by_uid_type[user.user_id, t] %}
+				{% if cert %}
+					<td class=ac><a href="{{ ctx.url_for('org_cert_file', cert_type=t.name, user_id=user.user_id, filename=t.file_name(False) + '.pdf') }}">{{ cert.achievement }}</a>
+				{% else %}
+					<td>
+				{% endif %}
+			{% endfor %}
+		{% endfor %}
+	<tfoot>
+		<tr>
+			<th>Všichni dohromady
+			{% for t, cfile in cert_file_columns %}
+				{% if cfile %}
+					<th class=ac><a href="{{ ctx.url_for('org_cert_file', cert_type=t.name, filename=t.file_name(True) + '.pdf') }}">stáhnout</a>
+				{% else %}
+					<th>
+				{% endif %}
+			{% endfor %}
+		{% if approve_form %}
+		<tr>
+			<th>Akce
+			{% for t, cfile in cert_file_columns %}
+				<td class=ac>
+				{% if cfile %}
+				<form method="POST" action="">
+					{{ approve_form.csrf_token }}
+					<input type="hidden" name="ctype" value="{{t.name}}">
+					{% if cfile.approved %}
+						{{ approve_form.unapprove(class='btn btn-xs btn-danger') }}
+					{% else %}
+						{{ approve_form.approve(class='btn btn-xs btn-success') }}
+					{% endif %}
+				</form>
+				{% endif %}
+			{% endfor %}
+		{% endif %}
+</table>
+
+{% if settings_changed or scoretable_changed %}
+<div class="alert alert-warning" role="alert">
+	{% if settings_changed %}Změnilo se nastavení diplomů.{% endif %}
+	{% if scoretable_changed %}Změnila se výsledková listina.{% endif %}
+	Diplomy je třeba znovu vytvořit.
+</div>
+{% endif %}
+
+{% else %}
+
+<p><em>Zatím nebyly vytvořeny.</em>
+
+{% endif %}
+
+{% if ctx.master_round.round_type == RoundType.other %}
+<div class="alert alert-danger" role="alert">
+	Pro tento typ kola zatím neumíme diplomy vydávat.
+</div>
+{% endif %}
+
+{% if g.user.is_admin %}
+<div class="btn-group" role="group">
+	<a class="btn btn-default" href="{{ log_url('cert_set', contest.contest_id) }}">Historie</a>
+</div>
+{% endif %}
+
+{% if form %}
+
+<h3>Nastavení</h3>
+
+{% if contest.scoretable is none %}
+<div class="alert alert-warning" role="alert">
+	Zatím je možné vydávat jen účastnické listy,
+	protože soutěž dosud nemá oficiální výsledkovou listinu.
+</div>
+{% endif %}
+
+{% macro field(f) %}
+{{ wtf.form_field(f, form_type='horizontal', horizontal_columns=('lg', 3, 9)) }}
+{% endmacro %}
+
+<form method="POST" class="form form-horizontal" enctype="multipart/form-data" action="">
+	{{ form.csrf_token }}
+	<div class='form-null-frame'>
+
+	{{ field(form.signer1_name) }}
+	{{ field(form.signer1_title) }}
+	{{ field(form.signer2_name) }}
+	{{ field(form.signer2_title) }}
+	{{ field(form.issue_place) }}
+	{{ field(form.issue_date) }}
+
+	<div class="form-group{% if form.upload_background.errors %} has-error{% endif %}">
+		<label class="control-label col-lg-3" for="background_type">Pozadí</label>
+		<div class="col-lg-9">
+			{{ form.background_type(class='form-control', onchange='recalc_background_controls()') }}
+			<div id='background_controls' class='form-horiz-frame' style='display: none; margin-top: 15px'>
+				{% if form.upload_background.errors %}
+					{% for e in form.upload_background.errors %}
+						<p class="help-block">{{ e }}</p>
+					{% endfor %}
+				{% else %}
+					<p class="help-block">
+						Zde můžete nahrát obrázek velikosti A4 ve formátu PDF, který se použije jako pozadí diplomu.
+						K výrobě pozadí se Vám může hodit <a href='https://www.matematickaolympiada.cz/dokumenty-mo'>logo MO nebo JČMF</a>.
+					</p>
+				{% endif %}
+				{% if cset.background_file %}
+					<p>Obrázek je nahraný.</p>
+				{% endif %}
+				<p>Nahrát nový obrázek: {{ form.upload_background(style='display: inline') }}</p>
+			</div>
+		</div>
+	</div>
+
+	</div>
+
+	{# Detailní nastavení vzhledu #}
+	<div class="collapsible">
+		<input type="checkbox" class="toggle" id="design-params-toggle"{% if form_has_errors %} checked{% endif %}>
+		<label for="design-params-toggle" class="toggle toggle-small">Detaily vzhledu</label>
+		<div class="collapsible-inner"><div class="form-horiz-frame">
+			<div class="form-group">
+				<p class="col-lg-9 col-lg-offset-3 help-block">
+					Pokud používáte vlastní obrázek na pozadí, může se hodit posunout text diplomu.
+					Proto je možné nastavovat mezery mezi jednotlivými částmi textu.
+					Kladná čísla odpovídají velikostem v milimetrech.
+					Záporná čísla určují pružné mezery, které vyplní zbylé místo na stránce
+					v zadaném poměru (např. -3 je třikrát větší mezera než -1).
+				</p>
+			</div>
+			{{ field(form.space1) }}
+			{{ field(form.space2) }}
+			{{ field(form.space3) }}
+			{{ field(form.space4) }}
+			{{ field(form.space5) }}
+			{{ field(form.space6) }}
+			{{ field(form.logo_visible) }}
+			{% if 'tex_hacks' in form %}
+			{{ field(form.tex_hacks) }}
+			{% endif %}
+		</div></div>
+	</div>
+
+	<div class="btn-group col-lg-offset-3">
+		{{ wtf.form_field(form.generate, class="btn btn-primary") }}
+		{{ wtf.form_field(form.save) }}
+		{% if 'delete' in form %}
+		{{ wtf.form_field(form.delete, class="btn btn-danger") }}
+		{% endif %}
+	</div>
+</form>
+
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index 2c40b96f655fa7da8ffdff57cff7adc075abf3d8..d30e80ef5b7029cb1ec527b764fc0386dcd162cd 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -59,7 +59,7 @@
 {% endif %}
 {% if state in [RoundState.grading, RoundState.graded, RoundState.closed] %}
 	<tr><td>Oficiální výsledková listina<td>
-		{% if contest.master.scoretable %}<a href="{{ ctx.url_for('org_score_snapshot', scoretable_id=contest.master.scoretable_id) }}">Zveřejněna</a>
+		{% if contest.master.scoretable %}<a href="{{ ctx.url_for('org_score_snapshot', scoretable_id=contest.master.scoretable_id) }}">zveřejněna</a>
 		{% else %}<i>zatím není</i>{% endif %}
 {% endif %}
 </table>
@@ -80,6 +80,7 @@
 	{% endif %}
 	{% if not site and can_view_contestants and state in [RoundState.grading, RoundState.graded, RoundState.closed] %}
 	<a class="btn btn-primary" href='{{ ctx.url_for('org_score') }}'>Výsledky</a>
+	<a class="btn btn-default" href='{{ ctx.url_for('org_certificates') }}'>Diplomy</a>
 	{% endif %}
 	{% if can_upload_anything %}
 	<a class="btn btn-default" href='{{ ctx.url_for('org_contest_protocols') }}'>Protokoly</a>
@@ -108,7 +109,7 @@
 <p class='space-top rights-elsewhere'>Další akce můžete provádět na stránce své soutěže nebo soutěžního místa.&nbsp;{{ rights_elsewhere_info() }}
 {% endif %}
 
-{% if can_manage and (round.round_type in [RoundType.domaci, RoundType.skolni, RoundType.okresni] or round.round_type == RoundType.krajske and round.category.startswith('Z')) %}
+{% if can_manage and round.round_type in [RoundType.domaci, RoundType.skolni, RoundType.okresni, RoundType.krajske] %}
 {% include "parts/org_contest_guide.html" %}
 {% endif %}
 
diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html
index bdc386f66dcd1397bd92b765037225259b943fb3..7e8fb50ad397981109ff55d7b155e95c74ac187b 100644
--- a/mo/web/templates/org_index.html
+++ b/mo/web/templates/org_index.html
@@ -82,6 +82,14 @@
 
 {% endif %}
 
+{% if show_certs_link %}
+
+<div class='btn-group'>
+	<a class='btn btn-primary' href='{{ url_for('org_school_results_all') }}'>Ocenění Vašich studentů</a>
+</div>
+
+{% endif %}
+
 {% if g.user.is_admin %}
 
 <h3>Nástroje pro správce</h3>
@@ -91,7 +99,7 @@
 	<input class='btn btn-primary' type="submit" value='Vyhledat'>
 </form>
 
-<div class='button-group space-top'>
+<div class='btn-group space-top'>
 	<a class='btn btn-default' href='{{ url_for('admin_all_dsn') }}'>Nedoručenky</a>
 </div>
 
diff --git a/mo/web/templates/org_job.html b/mo/web/templates/org_job.html
index f4bea90c3f7e94880fd378e6912b1e21155a37fd..586bcf5034f4bd97e24a65d8307219cf530beb89 100644
--- a/mo/web/templates/org_job.html
+++ b/mo/web/templates/org_job.html
@@ -57,8 +57,10 @@
 	{% elif result_url %}
 		<a class='btn btn-primary' href='{{ result_url }}'>{{ result_url_action }}</a>
 	{% endif %}
+	{% if job.is_deletable(g.user.is_admin) %}
 		<input type="submit" name="delete" value="Smazat dávku" class="btn btn-danger">
-	{% if job.state in (JobState.ready, JobState.running, JobState.waiting) and not job.email_when_done %}
+	{% endif %}
+	{% if job.is_active() and not job.email_when_done %}
 		<input type="submit" name="request_mail" value="Poslat mail, až to bude" class="btn btn-success">
 	{% endif %}
 		<a class='btn btn-default' href='{{ return_url }}'>Zpět</a>
diff --git a/mo/web/templates/org_job_wait.html b/mo/web/templates/org_job_wait.html
index 12e7adcb1bdae9951a85a71cea67067d62fa8e15..82fd670e12da7d22d6b7e903b48544971c850b9a 100644
--- a/mo/web/templates/org_job_wait.html
+++ b/mo/web/templates/org_job_wait.html
@@ -2,7 +2,7 @@
 {% block title %}Zpracování dávky – {{ job.description }}{% endblock %}
 {% block body %}
 
-{% if job.state in (JobState.failed, JobState.internal_error, JobState.soft_error) %}
+{% if job.is_erroneous() %}
 	<div class="loading">
 		<div class="done">☹</div>
 		<div class="caption">Stav: <b>{{ job.state.friendly_name() }}</b></div>
@@ -82,9 +82,10 @@
 	<a class='btn btn-success' href='{{ url_for('org_job_output', id=job.job_id) }}'>Stáhnout výstup</a>
 	{%- endif %}
 	<a class='btn btn-default' href='{{ url_for('org_job', id=job.job_id, came='org_job_wait', back=back_url) }}'>Detail dávky</a>
-	{% if job.state in (JobState.done, JobState.failed, JobState.soft_error) -%}
+	{% if job.is_deletable(g.user.is_admin) %}
 		<input type="submit" name="delete" value="Smazat dávku" class="btn btn-danger">
-	{% elif not job.email_when_done %}
+	{% endif %}
+	{% if job.is_active() and not job.email_when_done %}
 		<input type="submit" name="request_mail" value="Poslat mail, až to bude" class="btn btn-default">
 	{%- endif %}
 </div>
diff --git a/mo/web/templates/org_jobs.html b/mo/web/templates/org_jobs.html
index c4a46eb173d018e3287f0f7b07fa74a4ff756697..e5a99660a767ea3a39d5d24f9fa2538ef8b7d865 100644
--- a/mo/web/templates/org_jobs.html
+++ b/mo/web/templates/org_jobs.html
@@ -58,7 +58,9 @@ dávku po stažení výstupu smažete sami – šetří to místo na serveru.
 				{% elif result_url %}
 					<a class='btn btn-xs btn-primary' href='{{ result_url }}'>{{ result_url_action }}</a>
 				{% endif %}
+				{% if j.is_deletable(g.user.is_admin) %}
 					<input type="submit" name="delete" value="Smazat" class="btn btn-xs btn-danger">
+				{% endif %}
 				</form></div>
 	{% endfor %}
 </table>
diff --git a/mo/web/templates/org_place.html b/mo/web/templates/org_place.html
index 879ff6072d99487f124913df75978bdda4f30dc4..f942f32dcfbc9f9ad2e365e64faed7503e75751e 100644
--- a/mo/web/templates/org_place.html
+++ b/mo/web/templates/org_place.html
@@ -30,6 +30,9 @@
 		<a class="btn btn-default" href="{{ url_for('org_place_move', id=place.place_id) }}">Přesunout</a>
 {% endif %}
 		<a class="btn btn-default" href='{{ url_for('org_place_contests', id=place.place_id) }}'>Soutěže</a>
+{% if school and can_view_school_contestants %}
+		<a class="btn btn-default" href='{{ url_for('org_school_results', school_id=place.place_id) }}'>Ocenění</a>
+{% endif %}
 		<a class="btn btn-default" href='{{ url_for('org_place_roles', id=place.place_id) }}'>Organizátoři</a>
 {% if g.user.is_admin %}
 		<a class="btn btn-default" href="{{ log_url('place', place.place_id) }}">Historie</a>
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index 330d17d42b72df86724183196842bc6eee031696..1fa42b2f4b5c09755ac75fb13988f3c9ba63897e 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -96,6 +96,7 @@
 	<tr><td>Hranice bodů pro vítěze<td>{{ round.master.score_winner_limit|decimal|none_value(Markup('<i>nenastaveno</i>')) }}
 	<tr><td>Hranice bodů pro úspěšné řešitele<td>{{ round.master.score_successful_limit|decimal|none_value(Markup('<i>nenastaveno</i>')) }}
 	<tr><td>Hranice bodů pro postup<td>{{ round.master.score_advance_limit|decimal|none_value(Markup('<i>nenastaveno</i>')) }}
+	<tr><td>Udělovat pochvalná uznání<td>{{ round.master.score_has_hm|yes_no }}
 	<tr><td>Přesnost bodování<td>{{ round.master.points_step_name() }}
 	<tr><td>Automaticky publikovat na webu MO<td>{{ round.master.export_score_to_mo_web|yes_no }}
 </table>
diff --git a/mo/web/templates/org_school_results.html b/mo/web/templates/org_school_results.html
new file mode 100644
index 0000000000000000000000000000000000000000..e50d1893ff4ba8aa04807488300e43e76f575613
--- /dev/null
+++ b/mo/web/templates/org_school_results.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+
+{% block title %}
+Ocenění studentů {{ place.name }}
+{% endblock %}
+
+{% block breadcrumbs %}
+<li><a href='{{ url_for('org_school_results_all') }}'>Ocenění studentů</a><li>{{ place.name }}
+{% endblock %}
+
+{% block body %}
+
+{% if contests %}
+
+<table class='table table-bordered greyhead vcenter-rows'>
+	<thead>
+		<tr>
+			<th>Kat.
+			<th>Kolo
+			<th title='Počet studentů Vaší školy v tomto kole'>Počet
+			<th>Výsledky
+			<th>Diplomy
+	<tbody>
+{% for c in contests %}
+	<tr>
+		<td>{{ c.round.category }}
+		<td>{% if c.round.round_type == RoundType.other %}{{ c.round.name }}{% else %}{{ c.round.round_type.friendly_name() }}{% endif %}
+			{% if c.round.level < 4 %}
+				({{ c.place.name }})
+			{% endif %}
+		<td class=ar>{{ num_pants_by_cid[c.contest_id] }}
+		{% if c.state == RoundState.closed %}
+			<td>
+				{% if c.scoretable_id is not none %}
+					<a href='{{ url_for('org_score_snapshot', ct_id=c.contest_id, scoretable_id=c.scoretable_id) }}'>Zobrazit</a>
+				{% else %}
+					–
+				{% endif %}
+			<td>
+				{% if c.contest_id in contests_with_cert %}
+					<ul class=bare>
+					{% for t in CertType %}
+						{% set ccnt = cert_counts_by_cid_type[(c.contest_id, t)] %}
+						{% if ccnt %}
+						<li><a href='{{ url_for('org_school_results_certs', school_id=place.place_id, ct_id=c.contest_id, cert_type=t.name, filename=t.file_name(plural=True) + '.pdf') }}'>{{ t.friendly_name(plural=True) }}</a> ({{ ccnt }})
+						{% endif %}
+					{% endfor %}
+					</ul>
+				{% else %}
+					–
+				{% endif %}
+		{% else %}
+			<td colspan=2><em>kolo dosud není uzavřeno</em>
+		{% endif %}
+{% endfor %}
+</table>
+
+{% else %}
+
+<p><em>Vaši studenti se v letošním ročníku neúčastní žádné soutěže.</em></p>
+
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/templates/org_school_results_all.html b/mo/web/templates/org_school_results_all.html
new file mode 100644
index 0000000000000000000000000000000000000000..0b948ac7de7937e79d9cf7a167face00d4ab1d36
--- /dev/null
+++ b/mo/web/templates/org_school_results_all.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+
+{% block title %}
+Ocenění studentů
+{% endblock %}
+
+{% block breadcrumbs %}
+<li><a href='{{ url_for('org_school_results_all') }}'>Ocenění studentů</a>
+{% endblock %}
+
+{% block body %}
+
+{% if schools %}
+
+<p>
+	Jste školním garantem pro více škol.
+	Nejprve si vyberte školu, pro kterou chcete ocenění zobrazit:
+</p>
+
+<ul>
+	{% for s in schools %}
+	<li><a href='{{ url_for('org_school_results', school_id=s.place_id) }}'>{{ s.name }}</a>
+	{% endfor %}
+</ul>
+
+{% else %}
+
+<p>
+	Nejste školním garantem pro žádnou školu.
+</p>
+
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index b7f213ea5f526c1aabf93088e20a0449dcf30494..6f2f7579e2b61f567e872f40fc4784ad7148032b 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -4,7 +4,7 @@
 {% set contest = ctx.contest %}
 
 {% block title %}
-{{ round.round_code() }}: Výsledky pro {{ round.name|lower }} kategorie {{ round.category }}{% if contest %} {{ contest.place.name_locative() }}{% endif %}
+{{ round.round_code() }}: Výsledky pro {{ round.name|lower }} kategorie {{ round.category }}{% if contest %}<br>{{ contest.place.name_locative() }}{% endif %}
 {% if ctx.hier_place %} ({{ ctx.hier_place.name_locative() }}){% endif %}
 {% if not contest and round.level > 0 %}
  (všechny {{ place_levels[round.level].name_pl }})
@@ -67,6 +67,9 @@ Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 {% if master.score_successful_limit is not none %}
 <b class='legend-successful'>Úspěšnými řešiteli</b> se stávají účastníci s alespoň <b>{{ master.score_successful_limit|decimal }} {{ master.score_successful_limit|inflected_by("bodem", "body", "body") }}</b>.
 {% endif %}
+{% if master.score_has_hm %}
+<b class='legend-hm'>Pochvalné uznáni</b> dostávají neúspěšní řešitelé, kteří vyřešili aspoň jednu úlohu za plný počet bodů.
+{% endif %}
 {% if master.score_advance_limit is not none %}
 <b>Do dalšího kola <span class='legend-advance'>postupují</span></b> účastníci s alespoň <b>{{ master.score_advance_limit|decimal }} {{ master.score_advance_limit|inflected_by("bodem", "body", "body") }}</b>.
 {% endif %}
diff --git a/mo/web/templates/org_score_snapshot.html b/mo/web/templates/org_score_snapshot.html
index 274cb154e14dfe3bf1faff320d332b7e25c4c2a3..8cfdbadc980eb4d349d76d89de8f2bfa7e4b5f03 100644
--- a/mo/web/templates/org_score_snapshot.html
+++ b/mo/web/templates/org_score_snapshot.html
@@ -1,4 +1,5 @@
 {% extends "base.html" %}
+{% set master_ct_id = ctx.master_contest.contest_id %}
 
 {% block title %}
 {{ ctx.round.round_code() }}: Uložená verze výsledkové listiny pro {{ ctx.round.name|lower }} kategorie {{ ctx.round.category }} {{ ctx.contest.place.name_locative() }}
@@ -22,43 +23,54 @@
 {% block body %}
 
 {% if set_final_form %}
-<form method="POST" action="{{ ctx.url_for('org_score_snapshots') }}" class="pull-right">
+<form method="POST" action="{{ ctx.url_for('org_score_snapshots') }}">
 	{{ set_final_form.csrf_token }}
 	<input type="hidden" name="back_url" value="{{ ctx.url_for('org_score_snapshot', scoretable_id=scoretable.scoretable_id) }}">
+	<input type="hidden" name="scoretable_id" value="{{ scoretable.scoretable_id }}">
+	<div class="btn-group pull-right">
+	{% if cset and cset.scoretable_id == scoretable.scoretable_id %}
+		<a class="btn btn-default" href="{{ ctx.url_for('org_certificates', contest_id=master_ct_id) }}">Zobrazit diplomy</a>
+	{% endif %}
 	{% if ctx.master_contest.scoretable_id == scoretable.scoretable_id %}
+		{% if not cset or cset.scoretable_id is none %}
+			<a class="btn btn-primary" href="{{ ctx.url_for('org_certificates', contest_id=master_ct_id) }}">Vydat diplomy</a>
+		{% elif cset and cset.scoretable_id != scoretable.scoretable_id %}
+			<a class="btn btn-success" href="{{ ctx.url_for('org_certificates', contest_id=master_ct_id) }}">Aktualizovat diplomy</a>
+		{% endif %}
 		<input type="submit" name="submit_hide" class="btn btn-danger" value="Zrušit zveřejnění">
 	{% else %}
-		<input type="hidden" name="scoretable_id" value="{{ scoretable.scoretable_id }}">
 		<input type="submit" name="submit_set_final" class="btn btn-primary" value="Zveřejnit tuto verzi">
 	{% endif %}
+	</div>
 </form>
 {% endif %}
 
 {% if ctx.rights.have_right(Right.view_contestants) %}
-<p>Výsledková listina odpovídající stavu k {{ scoretable.created_at|timeformat }}.
+<p>Výsledková listina odpovídající stavu k {{ scoretable.created_at|timeformat }}.</p>
 {% if scoretable.scoretable_id == ctx.master_contest.scoretable_id %}
-<strong>Tato verze je zveřejněna jako oficiální výsledková listina.</strong>
+<p><strong>Tato verze je zveřejněna jako oficiální výsledková listina.</strong></p>
 {% else %}
-Tato verze není zveřejněna jako oficiální výsledková listina.
+<p>Tato verze není zveřejněna jako oficiální výsledková listina.
 	{% if ctx.master_contest.scoretable_id == None %}
 	Zkontrolujte ji prosím{% if scoretable.pdf_file %} (včetně <a href="{{ ctx.url_for('org_score_snapshot_pdf', scoretable_id=scoretable.scoretable_id) }}">PDF</a>){% endif %} a zveřejněte.
 	{% endif %}
-{% endif %}
 </p>
 {% endif %}
+{% endif %}
 
 <table class='data'>
-	<tr><td>Vygenerováno:<th>{{ scoretable.created_at|timeformat }}
-	<tr><td>Autor:<td>{{ scoretable.user|user_link }}
-	<tr><td>Mód výsledkové listiny:<td>{{ scoretable.score_mode.friendly_name() }}
-	<tr><td>Oficiální výsledková listina:<th>{{ "ano" if scoretable.scoretable_id == ctx.master_contest.scoretable_id else "ne" }}
-	{% if scoretable.note %}<tr><td>Poznámka:<td>{{ scoretable.note }}{% endif %}
+	<tr><td>Vygenerováno<th>{{ scoretable.created_at|timeformat }}
+	<tr><td>Autor<td>{{ scoretable.user|user_link }}
+	<tr><td>Mód výsledkové listiny<td>{{ scoretable.score_mode.friendly_name() }}
+	<tr><td>Oficiální výsledková listina<th>{{ (scoretable.scoretable_id == ctx.master_contest.scoretable_id) | yes_no }}
+	{% if scoretable.note %}<tr><td>Poznámka<td>{{ scoretable.note }}{% endif %}
 	{% if scoretable.score_metadata.get('boundary',{}).get('winner',None) is not none %}
-		<tr><td>Bodů na <span class='legend-winner'>vítězství</span>: <td> {{ scoretable.score_metadata['boundary']['winner'] }}
+		<tr><td>Bodů na <span class='legend-winner'>vítězství</span><td> {{ scoretable.score_metadata['boundary']['winner'] }}
 	{% endif %}
 	{% if scoretable.score_metadata.get('boundary',{}).get('successful',None) is not none %}
-		<tr><td>Bodů na <span class='legend-successful'>úspěšného řešitele</span>: <td> {{ scoretable.score_metadata['boundary']['successful'] }}
+		<tr><td>Bodů na <span class='legend-successful'>úspěšného řešitele</span><td> {{ scoretable.score_metadata['boundary']['successful'] }}
 	{% endif %}
+	<tr><td>Udělují se <span class='legend-hm'>pochvalná uznání</span><td>{{ scoretable.score_metadata.get('has_hm', False) | yes_no }}
 </table>
 
 {{ table.to_html() }}
diff --git a/mo/web/templates/org_score_snapshots.html b/mo/web/templates/org_score_snapshots.html
index dcc53e8f4ab95bc87052ae6cca46028bd915b638..b7991deab4273e9047db20a4c31817fcd5530b70 100644
--- a/mo/web/templates/org_score_snapshots.html
+++ b/mo/web/templates/org_score_snapshots.html
@@ -1,4 +1,5 @@
 {% extends "base.html" %}
+{% set master_ct_id = ctx.master_contest.contest_id %}
 
 {% block title %}
 {{ ctx.round.round_code() }}: Uložené výsledkové listiny {{ ctx.round.name|lower }} kategorie {{ ctx.round.category }} {{ ctx.contest.place.name_locative() }}
@@ -24,11 +25,17 @@
 		</tr>
 	</thead>
 {% for scoretable in scoretables %}
-	<tr {% if ctx.master_contest.scoretable_id == scoretable.scoretable_id %}class="active"{% endif %}>
+	{% set is_official = ctx.master_contest.scoretable_id == scoretable.scoretable_id %}
+	<tr {% if is_official %}class="active"{% endif %}>
 		<td>{{ scoretable.created_at|timeformat }}
 		<td>{{ scoretable.user|user_link }}
 		<td>{{ scoretable.note }}
-		<td>{% if ctx.master_contest.scoretable_id == scoretable.scoretable_id %}<strong>Zveřejněná verze</strong><br>{% endif %}
+		<td>
+			{% if is_official %}
+				<strong>Zveřejněná verze</strong><br>
+			{% elif cset and cset.scoretable == scoretable %}
+				<strong>Verze, z níž jsou vygenerovány diplomy</strong><br>
+			{% endif %}
 			<div class="btn-group">
 			<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_score_snapshot', scoretable_id=scoretable.scoretable_id) }}">Zobrazit</a>
 			{% if scoretable.pdf_file %}
@@ -37,7 +44,7 @@
 			{% if set_final_form %}
 			<form method="POST" class="btn-group">
 				{{ set_final_form.csrf_token }}
-				{% if ctx.master_contest.scoretable_id == scoretable.scoretable_id %}
+				{% if is_official %}
 					<input type="submit" name="submit_hide" class="btn btn-xs btn-danger" value="Zrušit zveřejnění">
 				{% else %}
 					<input type="hidden" name="scoretable_id" value="{{ scoretable.scoretable_id }}">
@@ -45,6 +52,13 @@
 				{% endif %}
 			</form>
 			{% endif %}
+			{% if cset and cset.scoretable == scoretable %}
+				<a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_certificates', contest_id=master_ct_id) }}">Zobrazit diplomy</a>
+			{% elif cset and cset.scoretable is not none and is_official %}
+				<a class="btn btn-xs btn-success" href="{{ ctx.url_for('org_certificates', contest_id=master_ct_id) }}">Aktualizovat diplomy</a>
+			{% elif is_official %}
+				<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_certificates', contest_id=master_ct_id) }}">Vydat diplomy</a>
+			{% endif %}
 		</div>
 	</tr>
 {% endfor %}
diff --git a/mo/web/templates/parts/org_contest_guide.html b/mo/web/templates/parts/org_contest_guide.html
index 8a0f2c74b4431f2ab3b4796147229f267c995c5f..bb4c1acb7952d5996fbc3720e353d09724187cf6 100644
--- a/mo/web/templates/parts/org_contest_guide.html
+++ b/mo/web/templates/parts/org_contest_guide.html
@@ -95,7 +95,20 @@
 		<li>Listinu ještě jednou zkontrolujte (včetně PDF) a zmáčkňěte „Zveřejnit tuto verzi“.
 		</ul>
 
-		<li>Až bude vše hotovo, <a href='{{ ctx.url_for('org_contest_edit', set_state='closed') }}'>uzavřete soutěž</a>.
+	<li>Vydejte <a href='{{ ctx.url_for('org_certificates') }}'>diplomy</a>
+		(účastnické listy{% if round.score_has_hm %}, pochvalná uznání{% endif %}
+		 a diplomy úspěšných řešitelů):
+		<ul>
+		<li>Vydávání diplomů přes OSMO není povinné.
+		</ul>
+
+	<li>Až bude vše hotovo, <a href='{{ ctx.url_for('org_contest_edit', set_state='closed') }}'>uzavřete soutěž</a>.
+		<ul>
+		<li>Pokud jste vydali výsledkovou listinu nebo diplomy, soutěžícím o tom přijde e-mail.
+		{% if round.level < 4 %}
+		<li>Výsledkové listiny a diplomy budou dostupné i jejich učitelům (školnim garantům jejich škol).
+		{% endif %}
+		</ul>
 
 {% elif state == RoundState.closed %}
 
diff --git a/mo/web/templates/user_contest.html b/mo/web/templates/user_contest.html
index 0a38bd5001b6955ebcb305fa095f1cc666107faf..f5579458d84df14fce4a69a5748881fe281ec603 100644
--- a/mo/web/templates/user_contest.html
+++ b/mo/web/templates/user_contest.html
@@ -77,9 +77,18 @@ Pokud si s tvorbou PDF nevíte rady, zkuste se podívat do <a href='https://docs
 {% elif state in [RoundState.graded, RoundState.closed] %}
 <p>Soutěžní kolo bylo ukončeno, níže si můžete prohlédnout svá ohodnocená a okomentovaná řešení
 (pokud je organizátoři do systému vložili).
-{% if contest.scoretable_id %}
-Také je již zveřejněna <strong><a href="{{ url_for('user_contest_score', id=contest.contest_id) }}">výsledková listina</a></strong>.
+
+{% if contest.scoretable_id or certs %}
+<div class="btn-group">
+	{% if contest.scoretable_id %}
+		<a class="btn btn-primary" href="{{ url_for('user_contest_score', id=contest.contest_id) }}">Výsledková listina</a>
+	{% endif %}
+	{% if certs %}
+		<a class="btn btn-success" href="{{ url_for('user_contest_certificates', id=contest.contest_id) }}">Diplomy</a>
+	{% endif %}
+</div>
 {% endif %}
+
 {% if state == RoundState.graded %}
 <p>Během několika dnů očekávejte uzavření kola{% if not contest.scoretable_id %} a zveřejnění oficiálních výsledkových listin{% endif %}.
 <p>Pokud máte k opravě úloh připomínky, ozvěte se prosím organizátorům tohoto kola.
diff --git a/mo/web/templates/user_contest_certs.html b/mo/web/templates/user_contest_certs.html
new file mode 100644
index 0000000000000000000000000000000000000000..ae38a5e27223230ffca862bcd4c2f5c072015dd4
--- /dev/null
+++ b/mo/web/templates/user_contest_certs.html
@@ -0,0 +1,42 @@
+{% extends "base.html" %}
+
+{% set round = contest.round %}
+
+{% block title %}Diplomy za {{ round.name|lower }} {{ round.year }}. ročníku kategorie {{ round.category }}: {{ contest.place.name }}{% endblock %}
+{% block breadcrumbs %}
+<li><a href='{{ url_for('user_index') }}'>Soutěže</a>
+<li><a href='{{ url_for('user_contest', id=contest.contest_id) }}'>{{ round.name }} {{ round.year }}. ročníku kategorie {{ round.category }}: {{ contest.place.name }}</a>
+<li>Diplomy
+{% endblock %}
+{% block body %}
+
+{% if cset is none %}
+	<p>
+		<em>Organizátor kola žádné diplomy nevydal.</em>
+	</p>
+{% elif not cset %}
+	<p>
+		<em>V tomto kole jste nezískal(a) žádné diplomy.</em>
+	</p>
+{% else %}
+	<p>
+		V tomto kole získáváte:
+	</p>
+
+	<table class="table table-bordered table-hover">
+		<thead>
+			<tr>
+				<th>Druh diplomu
+				<th>Získán za
+				<th>Akce
+		<tbody>
+			{% for cert in certs %}
+			<tr>
+				<td>{{ cert.type.friendly_name() }}
+				<td>{{ cert.achievement }}
+				<td><a class='btn btn-xs btn-primary' href='{{ url_for('user_cert_file', ct_id=contest.contest_id, cert_type=cert.type.name, filename=cert.type.file_name(False) + '.pdf') }}'>Zobrazit</a>
+			{% endfor %}
+	</table>
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/templates/user_contest_score.html b/mo/web/templates/user_contest_score.html
index 36b11cb0d1b27ca0f4e0a74b6e647b8261d48a27..a3615249c80c08f3f935745c00870c55b8c3a038 100644
--- a/mo/web/templates/user_contest_score.html
+++ b/mo/web/templates/user_contest_score.html
@@ -13,13 +13,17 @@
 
 {% set b_win = scoretable.score_metadata.get('boundary',{}).get('winner',None) %}
 {% set b_suc = scoretable.score_metadata.get('boundary',{}).get('successful',None) %}
+{% set has_hm = scoretable.score_metadata.get('has_hm',False) %}
 {% if b_suc is not none or b_win is not none %}
 	<p>
 	{% if b_suc is not none %}
-		Úspěšným řešitelem se stává každý, kdo získá alespoň {{ b_suc |parse_decimal| inflected("bod", "body", "bodů") }}.
+		<span class='legend-successful'>Úspěšným řešitelem</span> se stává každý, kdo získá alespoň {{ b_suc |parse_decimal| inflected("bod", "body", "bodů") }}.
 	{% endif %}
 	{% if b_win is not none %}
-		Vítězem se stává každý, kdo získá alespoň {{ b_win |parse_decimal| inflected("bod", "body", "bodů") }}.
+		<span class='legend-winner'>Vítězem</span> se stává každý, kdo získá alespoň {{ b_win |parse_decimal| inflected("bod", "body", "bodů") }}.
+	{% endif %}
+	{% if has_hm %}
+	<span class='legend-hm'>Pochvalné uznání</span> získává neúspěšný řešitel, který získal plný počet bodů za alespoň jednu úlohu.
 	{% endif %}
 	</p>
 {% endif %}
diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html
index 46d89241a41efe3881d7271960a0f6d0e7cd3109..e275cbd836c09b9aff30b81f079d6eac8e10383a 100644
--- a/mo/web/templates/user_index.html
+++ b/mo/web/templates/user_index.html
@@ -16,7 +16,7 @@
 				<th>Stav soutěže
 				<th>Odkazy
 		<tbody>
-			{% for pion, contest, round in pions %}
+			{% for pion, contest, round, certs in pions %}
 			{% set state = contest.ct_state() %}
 			<tr>
 				<td class="text-center"><b>{{ round.category }}</b>
@@ -41,6 +41,9 @@
 					{% if contest.ct_state() in [RoundState.graded, RoundState.closed] and contest.scoretable_id %}
 					<a class='btn btn-xs btn-success' href="{{ url_for('user_contest_score', id=contest.contest_id) }}">Výsledková listina</a>
 					{% endif %}
+					{% if certs %}
+					<a class='btn btn-xs btn-success' href="{{ url_for('user_contest_certificates', id=contest.contest_id) }}">Diplomy</a>
+					{% endif %}
 			{% endfor %}
 	</table>
 {% else %}
diff --git a/mo/web/user.py b/mo/web/user.py
index d39a61df97febe827d3a52c7f333058c865cd30c..be5f57f5bb1bf1dfdecfbae65d35e2d2e664a7bd 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -2,14 +2,20 @@
 
 from collections import defaultdict
 from dataclasses import dataclass
+from datetime import datetime
 from flask import render_template, jsonify, g, redirect, url_for, flash, request
+from flask.helpers import send_file
 from flask_wtf import FlaskForm
 import flask_wtf.file
 import hashlib
 import hmac
-from sqlalchemy import and_
+import os
+import pikepdf
+from pikepdf.models.metadata import encode_pdf_date
+from sqlalchemy import and_, func
 from sqlalchemy.dialects.postgresql import insert as pgsql_insert
 from sqlalchemy.orm import joinedload
+from tempfile import NamedTemporaryFile
 from typing import List, Tuple, Optional, Dict, DefaultDict
 import werkzeug.exceptions
 import wtforms
@@ -44,11 +50,19 @@ def user_index():
     )
 
 
-def load_pcrs() -> List[Tuple[db.Participation, db.Contest, db.Round]]:
-    return (db.get_session().query(db.Participation, db.Contest, db.Round)
+def load_pcrs() -> List[Tuple[db.Participation, db.Contest, db.Round, int]]:
+    cert_subq = (db.get_session()
+                 .query(db.Certificate.user_id, db.Certificate.cert_set_id, func.count(db.Certificate.type).label('count'))
+                 .join(db.Certificate.cert_file)
+                 .filter(db.CertFile.approved)
+                 .group_by(db.Certificate.user_id, db.Certificate.cert_set_id)
+                 .subquery())
+    return (db.get_session().query(db.Participation, db.Contest, db.Round, cert_subq.c.count)
             .select_from(db.Participation)
             .join(db.Contest, db.Contest.master_contest_id == db.Participation.contest_id)
             .join(db.Round)
+            .outerjoin(cert_subq, and_(cert_subq.c.user_id == db.Participation.user_id,
+                                       cert_subq.c.cert_set_id == db.Participation.contest_id))
             .filter(db.Participation.user == g.user)
             .filter(db.Round.year == config.CURRENT_YEAR)
             .options(joinedload(db.Contest.place))
@@ -311,6 +325,7 @@ def get_task(contest: db.Contest, id: int) -> db.Task:
     return task
 
 
+# URL je zadrátované do mo.email.send_grading_info_email
 @app.route('/user/contest/<int:id>/')
 def user_contest(id: int):
     sess = db.get_session()
@@ -327,12 +342,24 @@ def user_contest(id: int):
                  .order_by(db.Task.code)
                  .all())
 
+    if contest.state == db.RoundState.closed:
+        certs = (sess.query(db.Certificate)
+                 .join(db.Certificate.cert_file)
+                 .filter(db.Certificate.cert_set_id == id,
+                         db.Certificate.user == g.user,
+                         db.CertFile.approved)
+                 .limit(1)
+                 .all())
+    else:
+        certs = None
+
     return render_template(
         'user_contest.html',
         contest=contest,
         part_state=pion.state,
         task_sols=task_sols,
         messages=messages,
+        certs=certs,
         max_submit_size=config.MAX_CONTENT_LENGTH,
     )
 
@@ -501,7 +528,7 @@ def user_paper(contest_id: int, paper_id: int):
         raise werkzeug.exceptions.NotFound()
 
     if paper.for_user != g.user.user_id:
-        logger.warn(f'Účastník #{g.user.user_id} chce cizí papír')
+        logger.warning(f'Účastník #{g.user.user_id} chce cizí papír')
         raise werkzeug.exceptions.Forbidden()
 
     if paper.type == db.PaperType.solution:
@@ -512,7 +539,7 @@ def user_paper(contest_id: int, paper_id: int):
         assert False
 
     if paper.task.round != contest.round:
-        logger.warn(f'Účastník #{g.user.user_id} chce papír z jiného kola')
+        logger.warning(f'Účastník #{g.user.user_id} chce papír z jiného kola')
         raise werkzeug.exceptions.Forbidden()
 
     if contest.ct_state() not in allowed_states:
@@ -521,7 +548,7 @@ def user_paper(contest_id: int, paper_id: int):
     return mo.web.util.send_task_paper(paper)
 
 
-def scoretable_construct(scoretable: db.ScoreTable) -> Tuple[List[Column], List[Row]]:
+def scoretable_construct(scoretable: db.ScoreTable) -> Tuple[List[Column], List[Row], bool]:
     """Pro konstrukci výsledkovky zobrazované soutěžícím. Využito i při zobrazení
     uložených snapshotů výsledkovky v org_score.py.
     """
@@ -558,6 +585,9 @@ def scoretable_construct(scoretable: db.ScoreTable) -> Tuple[List[Column], List[
         elif row['successful']:
             row['status'] = 'úspěšný'
             html_attr = {"class": "successful", "title": "Úspěšný řešitel"}
+        elif row.get('honorary_mention', False):
+            row['status'] = 'pochvalné uznání'
+            html_attr = {"class": "hm", "title": "Pochvalné uznání"}
 
         for (task_column, points) in zip(tasks_columns, row['tasks']):
             row[task_column] = points or '–'
@@ -569,7 +599,7 @@ def scoretable_construct(scoretable: db.ScoreTable) -> Tuple[List[Column], List[
 
 @app.route('/user/contest/<int:id>/score')
 def user_contest_score(id: int):
-    contest, pion = get_contest_pion(id, require_reg=False)
+    contest = get_contest(id, require_reg=False)
     round = contest.round
     format = request.args.get('format', "")
 
@@ -606,7 +636,7 @@ def user_contest_score(id: int):
 
 @app.route('/user/contest/<int:id>/score.pdf')
 def user_contest_score_pdf(id: int):
-    contest, pion = get_contest_pion(id, require_reg=False)
+    contest = get_contest(id, require_reg=False)
     scoretable = contest.scoretable
 
     # Výsledkovku zobrazíme, jen pokud je soutěž již ukončená
@@ -617,6 +647,84 @@ def user_contest_score_pdf(id: int):
     return mo.web.util.send_score_pdf(scoretable)
 
 
+# Využívá se i z org_certs
+def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Optional[int] = None, approved_only: bool = False):
+    try:
+        typ = db.CertType.coerce(cert_type)
+    except ValueError:
+        raise werkzeug.exceptions.NotFound()
+
+    if user_filename != typ.file_name(user_id is None) + '.pdf':
+        raise werkzeug.exceptions.NotFound()
+
+    sess = db.get_session()
+    cfile = sess.query(db.CertFile).get((ct_id, typ))
+    if cfile is None or (approved_only and not cfile.approved):
+        raise werkzeug.exceptions.NotFound()
+
+    file = os.path.join(mo.util.data_dir('certs'), cfile.pdf_file)
+    try:
+        stat = os.stat(file)
+    except FileNotFoundError:
+        logger.error(f'Certifikát {file} je v DB, ale soubor neexistuje')
+        raise werkzeug.exceptions.NotFound()
+
+    if user_id is None:
+        return send_file(file, mimetype='application/pdf')
+
+    cert = sess.query(db.Certificate).get((ct_id, user_id, typ))
+    if cert is None:
+        raise werkzeug.exceptions.NotFound()
+
+    try:
+        with pikepdf.open(file, attempt_recovery=False) as src:
+            dst = pikepdf.new()
+            dst.pages.append(src.pages[cert.page_number - 1])
+            dst.docinfo['/Title'] = f'Matematická Olympiáda – {typ.friendly_name()}'
+            dst.docinfo['/Creator'] = 'Odevzdávací Systém Matematické Olympiády'
+            dst.docinfo['/CreationDate'] = encode_pdf_date(datetime.fromtimestamp(stat.st_mtime).astimezone())
+            tmp_file = NamedTemporaryFile(dir=mo.util.data_dir('tmp'), prefix='cert-')
+            dst.save(tmp_file.name)
+    except pikepdf.PdfError as e:
+        logger.error(f'Chyba při zpracování certifikátu {file}: {e}')
+        raise werkzeug.exceptions.InternalServerError()
+
+    return send_file(open(tmp_file.name, 'rb'), mimetype='application/pdf')
+
+
+@app.route('/user/contest/<int:id>/certificates')
+def user_contest_certificates(id: int):
+    contest = get_contest(id)
+
+    # Diplomy zobrazíme, jen pokud je soutěž již ukončená
+    state = contest.ct_state()
+    if state not in [db.RoundState.graded, db.RoundState.closed]:
+        raise werkzeug.exceptions.NotFound()
+
+    sess = db.get_session()
+    cset = sess.query(db.CertSet).filter_by(contest_id=id).one_or_none()
+    certs = (sess.query(db.Certificate)
+             .join(db.Certificate.cert_file)
+             .filter(db.Certificate.cert_set_id == id,
+                     db.Certificate.user == g.user,
+                     db.CertFile.approved)
+             .all())
+
+    return render_template(
+        'user_contest_certs.html',
+        contest=contest,
+        cset=cset,
+        certs=certs,
+    )
+
+
+@app.route('/user/contest/<int:ct_id>/certificates/<cert_type>/<filename>')
+def user_cert_file(ct_id: int, cert_type: str, filename: str):
+    _ = get_contest(ct_id)
+
+    return send_certificate(ct_id, cert_type, filename, g.user_id, approved_only=True)
+
+
 @app.route('/user/history/')
 def user_history() -> str:
     sess = db.get_session()
diff --git a/setup.py b/setup.py
index 0f81b8ece9b679358d0acc43d56fc25ac769bcfc..8d6a31d59a54724a5bca78d476ad9d27b5ec8de3 100644
--- a/setup.py
+++ b/setup.py
@@ -53,6 +53,7 @@ setuptools.setup(
         'python-magic',
         'python-poppler',
         'pyzbar',
+        'qrcode[pil]',
         'requests',
         'sqlalchemy[mypy] < 2.0',
         'token-bucket',
diff --git a/static/mo.css b/static/mo.css
index 655fbfecbd27b6eb226224ddfb72299156cd504e..2d914736760c9c3f309b109414a51494f5e918f2 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -149,10 +149,13 @@ 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; }
+table tbody tr.hm { background-color: #9ee; }
+table tbody tr.hm:hover { background-color: #cc7; }
 table tbody tr.advance td:first-child { color: #a0e; }
 
 .legend-winner { background-color: #fe5; }
 .legend-successful { background-color: #9e9; }
+.legend-hm { background-color: #9ee; }
 .legend-advance { color: #a0e; }
 
 table tr.place-hidden, table tr.place-hidden a:not(.btn) {
@@ -167,6 +170,10 @@ table.greyhead thead tr th {
 	border: none;
 }
 
+table.vcenter-rows tr td, table.vcenter-rows tr th {
+	vertical-align: middle;
+}
+
 .ac {
 	text-align: center;
 }
@@ -208,6 +215,8 @@ nav#main-menu a.active {
 	color: black;
 }
 
+/* Forms */
+
 .form-group.required .control-label:after {
 	content:"*";
 	color:red;
@@ -219,6 +228,32 @@ nav#main-menu a.active {
 	border-radius: 4px 4px;
 }
 
+.form-horiz-frame {
+	/* As .form-frame, but for groups of control in Bootstrap's horizontal forms */
+	padding: 10px;
+	border: 1px #ddd solid;
+	border-radius: 4px 4px;
+	overflow-x: clip;	/* Bootstrap's .form-group has negative margins */
+}
+
+.form-null-frame {
+	/* Shrinks width the same as .form-(horiz-)frame does */
+	padding-left: 11px;
+	padding-right: 11px;
+}
+
+.form-horiz-frame .form-group:last-child {
+	margin-bottom: 0;
+}
+
+.collapsible + .form-null-frame {
+	margin-top: 15px;
+}
+
+.collapsible + .btn-group {
+	margin-top: 15px;
+}
+
 select.no-scroll::-webkit-scrollbar {
 	display: none;
 }
@@ -611,6 +646,14 @@ table.dsn-check-err tr th {
 	padding-right: 2ex;
 }
 
+/* Seznamy bez odrážek, používáme uvnitř tabulek */
+
+ul.bare {
+	padding: 0;
+	margin: 0;
+	list-style-type: none;
+}
+
 /* Vzhled pro mobily a úzké displeje */
 
 @media only screen and (max-width: 600px) {