From 2cf2831a309f4784cfc2470bc471f6b21b30a9f5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:51:22 +0100
Subject: [PATCH 001/103] =?UTF-8?q?HM:=20Kolo=20m=C3=A1=20v=20DB=20flag=20?=
 =?UTF-8?q?pro=20generov=C3=A1n=C3=AD=20HM?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 db/db.ddl               | 1 +
 db/upgrade-20250117.sql | 2 ++
 mo/db.py                | 1 +
 3 files changed, 4 insertions(+)
 create mode 100644 db/upgrade-20250117.sql

diff --git a/db/db.ddl b/db/db.ddl
index 7d2775a8..45443a73 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')
diff --git a/db/upgrade-20250117.sql b/db/upgrade-20250117.sql
new file mode 100644
index 00000000..635b35fa
--- /dev/null
+++ b/db/upgrade-20250117.sql
@@ -0,0 +1,2 @@
+ALTER TABLE rounds ADD COLUMN
+	score_has_hm	boolean		NOT NULL DEFAULT false;
diff --git a/mo/db.py b/mo/db.py
index 6be1ff0d..8d0964fa 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -322,6 +322,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"))
-- 
GitLab


From d9a11d60e50663eebbaa1be4ccbd61ad52d70265 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:52:32 +0100
Subject: [PATCH 002/103] =?UTF-8?q?HM:=20Nastaven=C3=AD=20kola?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_round.py             | 1 +
 mo/web/templates/org_round.html | 1 +
 2 files changed, 2 insertions(+)

diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 44a1dd81..4e5e9dab 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -386,6 +386,7 @@ 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í"
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index 330d17d4..1fa42b2f 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>
-- 
GitLab


From 7bce705ed57139c80362b8cc90d77f702f09015a Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:53:10 +0100
Subject: [PATCH 003/103] =?UTF-8?q?HM:=20Gener=C3=A1tor=20v=C3=BDsledkovky?=
 =?UTF-8?q?=20po=C4=8D=C3=ADt=C3=A1=20HM?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/score.py | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/mo/score.py b/mo/score.py
index d43d783c..5be7e5f9 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
 
-- 
GitLab


From 89dec141fd885bee0140dafe88d9ea3df928a52f Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:54:48 +0100
Subject: [PATCH 004/103] =?UTF-8?q?HM:=20Orgovsk=C3=A1=20v=C3=BDsledkovka?=
 =?UTF-8?q?=20ukazuje?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_score.py             | 5 +++++
 mo/web/templates/org_score.html | 3 +++
 static/mo.css                   | 3 +++
 3 files changed, 11 insertions(+)

diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index 17e343ef..33e4dcb1 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')
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index b7f213ea..be5872e2 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -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/static/mo.css b/static/mo.css
index 655fbfec..4191ca56 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) {
-- 
GitLab


From 225158014d83c5f772def79332a7964c9bb2360c Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:55:24 +0100
Subject: [PATCH 005/103] =?UTF-8?q?HM:=20Zmra=C5=BEen=C3=A1=20v=C3=BDsledk?=
 =?UTF-8?q?ovka=20obsahuje?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 doc/score-snapshot.md | 4 +++-
 mo/jobs/score.py      | 3 ++-
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/doc/score-snapshot.md b/doc/score-snapshot.md
index f1d3402f..8a1b7382 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/mo/jobs/score.py b/mo/jobs/score.py
index 4196dadb..03b86791 100644
--- a/mo/jobs/score.py
+++ b/mo/jobs/score.py
@@ -219,6 +219,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 +240,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)
-- 
GitLab


From a7f17e1613c5664cadccb7050d4faeb02edc4205 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:56:18 +0100
Subject: [PATCH 006/103] =?UTF-8?q?UI=20k=20mra=C5=BEen=C3=A9=20v=C3=BDsle?=
 =?UTF-8?q?dkovce:=20kosmetika?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_score_snapshot.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/templates/org_score_snapshot.html b/mo/web/templates/org_score_snapshot.html
index 274cb154..5e226d91 100644
--- a/mo/web/templates/org_score_snapshot.html
+++ b/mo/web/templates/org_score_snapshot.html
@@ -51,7 +51,7 @@ Tato verze není zveřejněna jako oficiální výsledková listina.
 	<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" }}
+	<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'] }}
-- 
GitLab


From c5defaa9fdbd8f3e35a6edfbd80363fe2a3a5eb5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:57:03 +0100
Subject: [PATCH 007/103] =?UTF-8?q?=C3=9A=C4=8Dastnick=C3=A1=20v=C3=BDsled?=
 =?UTF-8?q?kovka=20m=C3=A1=20tak=C3=A9=20legendu=20k=20barvi=C4=8Dk=C3=A1m?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/user_contest_score.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/mo/web/templates/user_contest_score.html b/mo/web/templates/user_contest_score.html
index 36b11cb0..4e3cc1a9 100644
--- a/mo/web/templates/user_contest_score.html
+++ b/mo/web/templates/user_contest_score.html
@@ -16,10 +16,10 @@
 {% 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 %}
 	</p>
 {% endif %}
-- 
GitLab


From 52d573fb5549141679a40a0232fefb1555ee5dd1 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:57:29 +0100
Subject: [PATCH 008/103] =?UTF-8?q?HM:=20Zobrazen=C3=AD=20v=20mra=C5=BEen?=
 =?UTF-8?q?=C3=BDch=20v=C3=BDsledkovk=C3=A1ch?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_score_snapshot.html | 1 +
 mo/web/templates/user_contest_score.html | 4 ++++
 mo/web/user.py                           | 3 +++
 3 files changed, 8 insertions(+)

diff --git a/mo/web/templates/org_score_snapshot.html b/mo/web/templates/org_score_snapshot.html
index 5e226d91..7bdfa0f9 100644
--- a/mo/web/templates/org_score_snapshot.html
+++ b/mo/web/templates/org_score_snapshot.html
@@ -59,6 +59,7 @@ Tato verze není zveřejněna jako oficiální výsledková listina.
 	{% 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'] }}
 	{% 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/user_contest_score.html b/mo/web/templates/user_contest_score.html
index 4e3cc1a9..a3615249 100644
--- a/mo/web/templates/user_contest_score.html
+++ b/mo/web/templates/user_contest_score.html
@@ -13,6 +13,7 @@
 
 {% 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 %}
@@ -21,6 +22,9 @@
 	{% if b_win is not none %}
 		<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/user.py b/mo/web/user.py
index d39a61df..970c341d 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -558,6 +558,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 '–'
-- 
GitLab


From a8fde23bada2d6de5dfce1b846c5ae6092da062e Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 17:58:44 +0100
Subject: [PATCH 009/103] =?UTF-8?q?Orgovsk=C3=A1=20mra=C5=BEen=C3=A1=20v?=
 =?UTF-8?q?=C3=BDsledkovka=20nepou=C5=BE=C3=ADv=C3=A1=20dvojte=C4=8Dky=20v?=
 =?UTF-8?q?=20tabulk=C3=A1ch?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Teď už je konzistentní s ostatními tabulkami.
---
 mo/web/templates/org_score_snapshot.html | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/mo/web/templates/org_score_snapshot.html b/mo/web/templates/org_score_snapshot.html
index 7bdfa0f9..ce2e23b9 100644
--- a/mo/web/templates/org_score_snapshot.html
+++ b/mo/web/templates/org_score_snapshot.html
@@ -48,18 +48,18 @@ Tato verze není zveřejněna jako oficiální výsledková listina.
 {% 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>{{ (scoretable.scoretable_id == ctx.master_contest.scoretable_id) | yes_no }}
-	{% 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 }}
+	<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() }}
-- 
GitLab


From 2037966ec50ebc1483a00b24cf919413abf8c0f5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 14:27:44 +0100
Subject: [PATCH 010/103] =?UTF-8?q?DB:=20Testy=20na=20b=C4=9B=C5=BEn=C3=A9?=
 =?UTF-8?q?=20vlastnosti=20job=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/mo/db.py b/mo/db.py
index 8d0964fa..d42c822a 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -906,6 +906,15 @@ class Job(Base):
     def file_path(self, name: str) -> str:
         return os.path.join(self.dir_path(), name)
 
+    def is_active(self) -> bool:
+        return self.state in [JobState.ready, JobState.running, JobState.waiting]
+
+    def is_finished(self) -> bool:
+        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]
+
 
 class Message(Base):
     __tablename__ = 'messages'
-- 
GitLab


From 8596136a9b698ae658f01810b4af349bfcae1fa4 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 14:28:14 +0100
Subject: [PATCH 011/103] =?UTF-8?q?Pou=C5=BEit=C3=AD=20nov=C3=BDch=20test?=
 =?UTF-8?q?=C5=AF=20na=20vlastnosti=20job=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_jobs.py                 | 4 ++--
 mo/web/templates/org_job.html      | 2 +-
 mo/web/templates/org_job_wait.html | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/mo/web/org_jobs.py b/mo/web/org_jobs.py
index 6d99bd8c..cd77d4b8 100644
--- a/mo/web/org_jobs.py
+++ b/mo/web/org_jobs.py
@@ -149,7 +149,7 @@ def org_job(id: int):
                 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 +158,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/templates/org_job.html b/mo/web/templates/org_job.html
index f4bea90c..c1231e74 100644
--- a/mo/web/templates/org_job.html
+++ b/mo/web/templates/org_job.html
@@ -58,7 +58,7 @@
 		<a class='btn btn-primary' href='{{ result_url }}'>{{ result_url_action }}</a>
 	{% endif %}
 		<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 %}
+	{% 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 12e7adcb..42ff261e 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>
-- 
GitLab


From 9fbb92a2cc176c4121e4a09e853161eb7c15af6a Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 17 Jan 2025 22:52:14 +0100
Subject: [PATCH 012/103] =?UTF-8?q?org=5Fscore:=20Explicitn=C3=AD=20l?=
 =?UTF-8?q?=C3=A1m=C3=A1n=C3=AD=20nadpisu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_score.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index be5872e2..6f2f7579 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 }})
-- 
GitLab


From 2d42905eb16c324ee807143dcde37dc2613a29d2 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 17 Jan 2025 22:50:29 +0100
Subject: [PATCH 013/103] =?UTF-8?q?Zobecn=C4=9Bno=20vol=C3=A1n=C3=AD=20TeX?=
 =?UTF-8?q?u?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/jobs/protocols.py | 17 +++----------
 mo/jobs/score.py     | 40 +++---------------------------
 mo/util_tex.py       | 59 ++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 65 insertions(+), 51 deletions(-)
 create mode 100644 mo/util_tex.py

diff --git a/mo/jobs/protocols.py b/mo/jobs/protocols.py
index 8b1209ed..0577ce48 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 03b86791..9cbcdb8c 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')
 
 
diff --git a/mo/util_tex.py b/mo/util_tex.py
new file mode 100644
index 00000000..07006406
--- /dev/null
+++ b/mo/util_tex.py
@@ -0,0 +1,59 @@
+# Interakce s TeXem
+
+import os
+import re
+import subprocess
+from typing import Any, Dict
+
+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 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,
+    )
-- 
GitLab


From 8c8e8a7c819e6f97e8de2f2922174216a99459d5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 13:24:39 +0100
Subject: [PATCH 014/103] =?UTF-8?q?TeX:=20Pagella=20p=C5=99est=C4=9Bhov?=
 =?UTF-8?q?=C3=A1na=20do=20mo-lib.tex?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/tex/mo-lib.tex   | 20 ++++++++++++++++++++
 mo/tex/vysledky.tex | 15 +--------------
 2 files changed, 21 insertions(+), 14 deletions(-)

diff --git a/mo/tex/mo-lib.tex b/mo/tex/mo-lib.tex
index 57a1bcf2..3358f2d9 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 1cfa2b59..35a24edb 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}
-- 
GitLab


From 2c4ba9ccdbeb9c467f664005945ee252f41e2058 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 17 Jan 2025 22:49:40 +0100
Subject: [PATCH 015/103] =?UTF-8?q?DB:=20Typy=20kol=20v=206.=20p=C3=A1d?=
 =?UTF-8?q?=C4=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/mo/db.py b/mo/db.py
index d42c822a..b5a2aed7 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -246,6 +246,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',
-- 
GitLab


From 83f98f2268af4915653460ebb4e6ab17b17f9f26 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 17 Jan 2025 17:14:52 +0100
Subject: [PATCH 016/103] =?UTF-8?q?DB:=20Datov=C3=BD=20model=20diplom?=
 =?UTF-8?q?=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 db/db.ddl               | 50 ++++++++++++++++++++++-
 db/upgrade-20250117.sql | 46 +++++++++++++++++++++
 mo/db.py                | 89 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 183 insertions(+), 2 deletions(-)

diff --git a/db/db.ddl b/db/db.ddl
index 45443a73..5fcb73bb 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -323,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 (
@@ -349,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 (
@@ -503,6 +505,50 @@ 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
+	signer1_name		text		DEFAULT NULL,
+	signer1_title		text		DEFAULT NULL,
+	signer2_name		text		DEFAULT NULL,
+	signer2_title		text		DEFAULT NULL,
+	issue_place		text		DEFAULT NULL,
+	issue_date		text		DEFAULT NULL,
+	background_file		text		DEFAULT NULL,		-- relativně vůči mo.util.data_dir('cert')
+	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')
+	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)
+);
+
 -- Odeslané mailové notifikace
 
 CREATE TABLE sent_email (
diff --git a/db/upgrade-20250117.sql b/db/upgrade-20250117.sql
index 635b35fa..3ed3bffb 100644
--- a/db/upgrade-20250117.sql
+++ b/db/upgrade-20250117.sql
@@ -1,2 +1,48 @@
 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
+	signer1_name		text		DEFAULT NULL,
+	signer1_title		text		DEFAULT NULL,
+	signer2_name		text		DEFAULT NULL,
+	signer2_title		text		DEFAULT NULL,
+	issue_place		text		DEFAULT NULL,
+	issue_date		text		DEFAULT NULL,
+	background_file		text		DEFAULT NULL,		-- relativně vůči mo.util.data_dir('cert')
+	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')
+	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)
+);
diff --git a/mo/db.py b/mo/db.py
index b5a2aed7..740747dd 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -559,6 +559,7 @@ class LogType(MOEnum):
     participant = auto()
     task = auto()
     user_role = auto()
+    cert_set = auto()
 
 
 class Log(Base):
@@ -852,6 +853,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):
@@ -1063,6 +1065,93 @@ 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"))
+    signer1_name = Column(Text, nullable=True)
+    signer1_title = Column(Text, nullable=True)
+    signer2_name = Column(Text, nullable=True)
+    signer2_title = Column(Text, nullable=True)
+    issue_place = Column(Text, nullable=True)
+    issue_date = Column(Text, nullable=True)
+    background_file = Column(Text, nullable=True)
+    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')
+
+
+class CertType(MOEnum):
+    participation = auto()
+    successful = auto()
+    honorary_mention = auto()
+
+    def friendly_name(self) -> str:
+        return cert_type_names[self]
+
+    def file_name(self) -> str:
+        return cert_type_file_names[self]
+
+    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',
+    CertType.successful: 'diplom úspěšného řešitele',
+    CertType.honorary_mention: 'pochvalné uznání',
+}
+
+
+cert_type_file_names = {
+    CertType.participation: 'ucastnici',
+    CertType.successful: 'diplomy',
+    CertType.honorary_mention: 'pochvalne',
+}
+
+
+# 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)
+
+
+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)
+
+
 class SentEmail(Base):
     __tablename__ = 'sent_email'
 
-- 
GitLab


From b481edeebbbaeb8d5849d591a0b149272f2f0f5b Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 17 Jan 2025 23:52:31 +0100
Subject: [PATCH 017/103] Jinja: CertType

---
 mo/web/jinja.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index 6df9522b..be6e7fd2 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)
-- 
GitLab


From ba8eeba562355b9c5cce0129a790ce4d0e7f7c66 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 18:18:25 +0100
Subject: [PATCH 018/103] Jinja: titlecase

---
 mo/web/jinja.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index be6e7fd2..b0b8d654 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -161,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 ""
-- 
GitLab


From 25d3d2fc717cf5636e35efef9e50fb771d72aadc Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 17 Jan 2025 22:51:13 +0100
Subject: [PATCH 019/103] =?UTF-8?q?Diplomy:=20Job=20na=20sazbu=20diplom?=
 =?UTF-8?q?=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/jobs/__init__.py    |   1 +
 mo/jobs/certs.py       | 256 +++++++++++++++++++++++++++++++++++++++++
 mo/tex/certifikaty.tex | 127 ++++++++++++++++++++
 3 files changed, 384 insertions(+)
 create mode 100644 mo/jobs/certs.py
 create mode 100644 mo/tex/certifikaty.tex

diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py
index 95d6cdb6..36f69764 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 00000000..3802cc1e
--- /dev/null
+++ b/mo/jobs/certs.py
@@ -0,0 +1,256 @@
+# Implementace jobů na práci s diplomy
+
+from dataclasses import dataclass
+import os
+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.jobs import TheJob, job_handler
+import mo.util
+from mo.util import logger
+import mo.util_format
+from mo.util_tex import tex_arg, run_tex, format_hacks
+
+
+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
+
+
+@dataclass
+class Cert:
+    user: db.User
+    school: db.Place
+    type: db.CertType
+    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
+    scoretable: Optional[db.ScoreTable]
+
+    certs: List[Cert]
+    out_files: Dict[db.CertType, str]
+
+    def __init__(self, the_job: TheJob):
+        self.the_job = the_job
+        self.job = the_job.job
+        assert self.job.in_json is not None
+        self.contest_id = self.job.in_json['contest_id']  # type: ignore
+
+        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.certs = []
+        self.out_files = {}
+
+    def plan(self) -> None:
+        sess = db.get_session()
+        pions_pants = (sess.query(db.Participation, db.Participant)
+                       .select_from(db.Participation)
+                       .join(db.Participant, db.Participant.user_id == db.Participation.user_id)
+                       .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, achievement: str, sort_key: Any) -> None:
+                self.certs.append(Cert(
+                    user=user,
+                    school=pant.school_place,
+                    type=type,
+                    achievement=achievement,
+                    sort_key=sort_key,
+                ))
+
+            # Účastnický list
+            add_cert(db.CertType.participation, 'za účast', user.sort_key())
+
+            # Diplom úspěšného řešitele
+            if row is not None:
+                order = row.get('order')
+                if row['successful'] and order is not None:
+                    if order['span'] == 1:
+                        place = f"{order['place']}."
+                    else:
+                        place = f"{order['place']}.--{order['place'] + order['span'] - 1}."
+                    add_cert(db.CertType.successful, 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, 'za úplné vyřešení úlohy', user.sort_key())
+
+    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
+
+        temp_dir = self.job.dir_path()
+        name = cert_type.file_name()
+        logger.debug(f'{self.the_job.log_prefix} Vytvářím certifikáty typu {name} v {temp_dir} ({len(certs)}) listů)')
+
+        certs.sort(key=lambda cert: cert.sort_key)
+        self.make_tex_source(f'{temp_dir}/{name}.tex', certs)
+        run_tex(temp_dir, f'{name}.tex')
+        self.out_files[cert_type] = f'{temp_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')
+
+            ga = {
+                'kolo': db.round_type_names_local[self.round.round_type],
+                'kat': self.round.category,
+                'signerAname': self.cset.signer1_name,
+                'signerAtitle': self.cset.signer1_title,
+                'signerBname': self.cset.signer2_name,
+                'signerBtitle': self.cset.signer2_title,
+                'issueplace': self.cset.issue_place,
+                'issuedate': self.cset.issue_date,
+            }
+            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):
+                f.write('\n{\n')
+                attrs({
+                      'jmeno': cert.user.full_name(),
+                      'skola': cert.school.name,
+                      'uspech': cert.achievement,
+                      'qrurl': self._make_qr_url(cert),
+                })
+                f.write('\\Cert' + cert.type.name.replace('_', '').title() + '\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()
+
+        certs_dir = mo.util.data_dir('certs')
+        out_dir = os.path.join(self.round.round_code_short(), str(self.contest.contest_id))
+        full_dir = os.path.join(certs_dir, out_dir)
+        os.makedirs(full_dir, exist_ok=True)
+
+        # Nejdříve 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():
+            file = mo.util.link_to_dir(out_file, full_dir, prefix=f'{ctype.file_name()}-', suffix='.pdf')
+            sess.add(db.CertFile(
+                cert_set_id=self.contest_id,
+                type=ctype,
+                pdf_file=os.path.join(out_dir, os.path.basename(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(certs_dir, old_file))
+
+
+@job_handler(db.JobType.create_certs)
+def handle_create_protocols(the_job: TheJob):
+    cm = CertMaker(the_job)
+    cm.plan()
+    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/tex/certifikaty.tex b/mo/tex/certifikaty.tex
new file mode 100644
index 00000000..ea8c33b8
--- /dev/null
+++ b/mo/tex/certifikaty.tex
@@ -0,0 +1,127 @@
+\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\logobox
+\setbox\logobox=\putimage{width 30mm}{mo-logo.epdf}
+
+\input qrcode.tex
+\qrset{height=23mm, level=M, tight, silent, link, qrborder={1 1 1}}
+\newbox\codebox
+
+% Základní údaje o soutěži
+\def\kolo{}
+\def\kat{}
+\def\oblast{}
+\def\signerAname{}
+\def\signerAtitle{}
+\def\signerBname{}
+\def\signerBtitle{}
+\def\issueplace{}
+\def\issuedate{}
+
+% Údaje o jednom certifikátu
+\def\typ{???}
+\def\jmeno{???}
+\def\skola{???}
+\def\uspech{???}
+\def\qrurl{???}
+
+\def\generic{
+	\offinterlineskip  % řádkování je proměnlivé a vše má podpěry
+	\setbox\codebox=\hbox{\qrcode{\qrurl}}
+	\vglue 0pt
+	\vfill
+	\head
+	\vfill
+	\centerline{\fontsize{36}\bf\jmeno}
+	\vskip 5mm
+	\centerline{\fontsize{16}\skola}
+	\vfill
+	\centerline{\fontsize{24}\uspech}
+	\vskip 8mm
+	\centerline{\fontsize{16}\kolo}
+	% \ifx\oblast\empty\else~(\oblast)\fi
+	\vskip 5mm
+	\centerline{\fontsize{16}Matematické olympiády kategorie \kat}
+	\vfill
+	\vfill
+	\signatures
+	\vskip 15mm
+	\bottomline
+	\vskip 15mm
+	\eject
+}
+
+\def\signatures{
+	\edef\tmp{\signerAname\signerAtitle\signerBname\signerBtitle}
+	\ifx\tmp\empty\else
+		\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
+}
+
+\def\sign#1#2{\hlap{\vtop{\halign{%
+	\hfil\fontsize{12}##\hfil\cr
+	#1\cr\noalign{\vskip 3pt}
+	#2\cr
+}}}}
+
+\def\bottomline{
+	\line{
+		\hskip 15mm
+		\rlap{\vbox{\datebox}}%
+		\hfil
+		\vbox{\copy\logobox}%
+		\hfil
+		\llap{\vbox{\box\codebox}}%
+		\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\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
+}
-- 
GitLab


From 8773bb1591c0a807319902969fbda635c805aae2 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 17 Jan 2025 22:51:59 +0100
Subject: [PATCH 020/103] Diplomy: UI

---
 mo/web/__init__.py                     |   1 +
 mo/web/org_certs.py                    | 199 +++++++++++++++++++++++++
 mo/web/org_jobs.py                     |   5 +
 mo/web/templates/org_certificates.html | 119 +++++++++++++++
 mo/web/templates/org_contest.html      |   1 +
 5 files changed, 325 insertions(+)
 create mode 100644 mo/web/org_certs.py
 create mode 100644 mo/web/templates/org_certificates.html

diff --git a/mo/web/__init__.py b/mo/web/__init__.py
index f8ccc040..1f7eaa6a 100644
--- a/mo/web/__init__.py
+++ b/mo/web/__init__.py
@@ -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/org_certs.py b/mo/web/org_certs.py
new file mode 100644
index 00000000..eb756612
--- /dev/null
+++ b/mo/web/org_certs.py
@@ -0,0 +1,199 @@
+# Web: Certifikáty
+
+import os
+from flask import render_template, g, redirect, url_for
+from flask.helpers import send_file, flash
+from flask_wtf import FlaskForm
+import flask_wtf.file
+from markupsafe import Markup
+import pikepdf
+from sqlalchemy.orm import joinedload
+from tempfile import NamedTemporaryFile
+from typing import Tuple, Optional, Dict
+import werkzeug.exceptions
+import wtforms
+
+import mo
+import mo.db as db
+import mo.email
+import mo.jobs.certs
+from mo.rights import Right
+import mo.util
+from mo.util import logger
+from mo.web import app
+from mo.web.org_contest import get_context
+import mo.web.fields as mo_fields
+
+
+# Využívá se i z účastnické části webu
+def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Optional[int] = None):
+    try:
+        typ = db.CertType.coerce(cert_type)
+    except ValueError:
+        raise werkzeug.exceptions.NotFound()
+
+    if user_filename != typ.file_name() + '.pdf':
+        raise werkzeug.exceptions.NotFound()
+
+    sess = db.get_session()
+    cfile = sess.query(db.CertFile).get((ct_id, typ))
+    if cfile is None:
+        raise werkzeug.exceptions.NotFound()
+
+    file = os.path.join(mo.util.data_dir('certs'), cfile.pdf_file)
+    if not os.path.isfile(file):
+        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])
+            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')
+
+
+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'})
+    upload_background = flask_wtf.file.FileField("Obrázek na pozadí", description="Zde můžete nahrát obrázek ve formátu PDF, který se použije jako pozadí diplomu.")
+    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")
+
+
+@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
+
+    contest = ctx.contest
+    can_change = ctx.rights.have_right(Right.manage_contest)
+
+    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,
+            issue_date=mo.now.strftime('%d. %B %Y'),
+        )
+    else:
+        new_cset = False
+
+    if can_change:
+        form = CertSetForm(obj=cset)
+        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 cset is None:
+            del form.delete
+    else:
+        form = None
+
+    if form and form.validate_on_submit():
+        if form.delete.data:
+            if not new_cset:
+                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)
+            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}')
+            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 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 = mo.jobs.certs.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
+
+    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,
+        form=form,
+        cset=cset,
+        users_pions=users_pions,
+        cert_files_by_type=cert_files_by_type,
+        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),
+    )
+
+
+@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)
+
+
+# URL je explicitně uvedeno v mo.jobs.certs.Cert._make_qr_url
+@app.route('/cc/<int:year>/<cat>/<round_type>/<place>/<cert_type_short>/<int:user_id>/<time_code>')
+def cert_check(year: int, cat: str, round_type: str, place: str, cert_type_short: str, user_id: int, time_code: str):
+    return "Not implemented yet."
diff --git a/mo/web/org_jobs.py b/mo/web/org_jobs.py
index cd77d4b8..45acb6a7 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
 
 
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
new file mode 100644
index 00000000..1b53e40e
--- /dev/null
+++ b/mo/web/templates/org_certificates.html
@@ -0,0 +1,119 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% set round = ctx.round %}
+{% set contest = ctx.contest %}
+
+{% 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>TODO: Úvodní text
+
+<h3>Diplomy</h3>
+
+{% 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 in CertType %}
+			<th class=ac>{{ t.friendly_name()|titlecase }}
+			{% endfor %}
+	<tbody>
+		{% for user, pion in users_pions %}
+		<tr class="state-{{ pion.state.name }}">
+			<td>{{ user.full_name() }}
+				{% if pion.state != PartState.active %}
+					({{ pion.state.friendly_name() }})
+				{% endif %}
+			{% for t in CertType %}
+				{% 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() + '.pdf') }}">{{ cert.achievement }}</a>
+				{% else %}
+					<td>
+				{% endif %}
+			{% endfor %}
+		{% endfor %}
+	<tfoot>
+		<tr>
+			<th>Všichni dohromady
+			{% for t in CertType %}
+				{% set cfile = cert_files_by_type[t] %}
+				{% if cfile %}
+					<th class=ac><a href="{{ ctx.url_for('org_cert_file', cert_type=t.name, filename=t.file_name() + '.pdf') }}">stáhnout</a>
+				{% else %}
+					<th>
+				{% endif %}
+			{% endfor %}
+</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 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, 7)) }}
+{% endmacro %}
+
+<form method="POST" class="form form-horizontal" action="">
+	{{ form.csrf_token }}
+	{{ field(form.signer1_name) }}
+	{{ field(form.signer1_title) }}
+	{{ field(form.signer2_name) }}
+	{{ field(form.signer2_title) }}
+	{{ field(form.issue_place) }}
+	{{ field(form.issue_date) }}
+	{{ field(form.upload_background) }}
+	{% if 'tex_hacks' in form %}
+	{{ field(form.tex_hacks) }}
+	{% endif %}
+	<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 2c40b96f..73883c0b 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -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>
-- 
GitLab


From f64d54ee274d341c3359ac3f546b4e30d89ae03d Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 20:28:19 +0100
Subject: [PATCH 021/103] =?UTF-8?q?Diplomy:=20Dokumentace=20k=20tex=5Fhack?=
 =?UTF-8?q?s=20(trivi=C3=A1ln=C3=AD)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/doc_admin.html | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/mo/web/templates/doc_admin.html b/mo/web/templates/doc_admin.html
index f0064687..26432b5d 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ě.
-- 
GitLab


From a62c34fe4f0a8f4598d8347f420ad180ceca814e Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 20:31:12 +0100
Subject: [PATCH 022/103] =?UTF-8?q?doc/export-pro-web:=20P=C5=99eklep?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 doc/export-pro-web.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/doc/export-pro-web.md b/doc/export-pro-web.md
index b156100d..dbafa69b 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
-- 
GitLab


From f2ee6fe39a31561cd925009bcf8f281a7a30ffe7 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 20:32:29 +0100
Subject: [PATCH 023/103] =?UTF-8?q?config.py.example:=20P=C5=99eklep?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 etc/config.py.example | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/etc/config.py.example b/etc/config.py.example
index a714932b..d72ea919 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"
 
-- 
GitLab


From bc35433f0876c91c2b808f3d0f7f13e62da1cc0b Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 21:02:06 +0100
Subject: [PATCH 024/103] =?UTF-8?q?Diplomy:=20P=C5=99eklep=20v=20logov?=
 =?UTF-8?q?=C3=A1n=C3=AD=20p=C5=99i=20sazb=C4=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/jobs/certs.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index 3802cc1e..d04b2f97 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -131,7 +131,7 @@ class CertMaker:
 
         temp_dir = self.job.dir_path()
         name = cert_type.file_name()
-        logger.debug(f'{self.the_job.log_prefix} Vytvářím certifikáty typu {name} v {temp_dir} ({len(certs)}) listů)')
+        logger.debug(f'{self.the_job.log_prefix} Vytvářím certifikáty typu {name} v {temp_dir} ({len(certs)} listů)')
 
         certs.sort(key=lambda cert: cert.sort_key)
         self.make_tex_source(f'{temp_dir}/{name}.tex', certs)
-- 
GitLab


From 891d12f667cf16f5686db150f53995273605f6dd Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 18 Jan 2025 21:27:28 +0100
Subject: [PATCH 025/103] =?UTF-8?q?Diplomy:=20Oprava=20joinu=20v=20sazb?=
 =?UTF-8?q?=C4=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/jobs/certs.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index d04b2f97..594b4dca 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -2,6 +2,7 @@
 
 from dataclasses import dataclass
 import os
+from sqlalchemy import and_
 from sqlalchemy.orm import joinedload
 from typing import Dict, List, Optional, Any
 import urllib.parse
@@ -81,7 +82,8 @@ class CertMaker:
         sess = db.get_session()
         pions_pants = (sess.query(db.Participation, db.Participant)
                        .select_from(db.Participation)
-                       .join(db.Participant, db.Participant.user_id == db.Participation.user_id)
+                       .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))
-- 
GitLab


From f406ed058bfcc223d8506b68c2708c9e18b75587 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 00:21:46 +0100
Subject: [PATCH 026/103] =?UTF-8?q?Nastaven=C3=AD=20kola:=20Dopln=C4=9Bno?=
 =?UTF-8?q?=20schov=C3=A1v=C3=A1n=C3=AD=20nastav=C3=ADtek=20v=20sekund?=
 =?UTF-8?q?=C3=A1rn=C3=ADch=20kolech?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_round.py | 15 ++++++++++++++-
 1 file changed, 14 insertions(+), 1 deletion(-)

diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 4e5e9dab..a7f78bab 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)],
@@ -391,6 +392,7 @@ class RoundEditForm(FlaskForm):
         "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()])
@@ -401,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')
@@ -423,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 = []
@@ -514,7 +524,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))
-- 
GitLab


From 499a6100e3448d2a351a901d809fa9108bdb63aa Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 00:22:22 +0100
Subject: [PATCH 027/103] =?UTF-8?q?Diplomy:=20Pro=20sekund=C3=A1rn=C3=AD?=
 =?UTF-8?q?=20kola=20pracovat=20s=20diplomy=20prim=C3=A1rn=C3=ADho?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

To je stejné řešení, jako jsme dříve zvolili pro výsledkové listiny.
---
 mo/web/org_certs.py                    | 7 ++++++-
 mo/web/templates/org_certificates.html | 6 ++++++
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index eb756612..9c0ae359 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -84,7 +84,11 @@ 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
 
-    contest = ctx.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)
 
     sess = db.get_session()
@@ -174,6 +178,7 @@ def org_certificates(ct_id: int):
     return render_template(
         'org_certificates.html',
         ctx=ctx,
+        group_rounds=group_rounds,
         form=form,
         cset=cset,
         users_pions=users_pions,
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index 1b53e40e..486e216e 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -16,6 +16,12 @@
 
 <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)
-- 
GitLab


From e3ce2781b639b1b4604fd039d144e3f283aee0ba Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 00:25:23 +0100
Subject: [PATCH 028/103] =?UTF-8?q?Diplomy:=20Opraveno=20schov=C3=A1v?=
 =?UTF-8?q?=C3=A1n=C3=AD=20tla=C4=8D=C3=ADtka=20"Smazat"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 9c0ae359..976a9e92 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -110,7 +110,7 @@ def org_certificates(ct_id: int):
         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 cset is None:
+        if new_cset:
             del form.delete
     else:
         form = None
-- 
GitLab


From 40da2f7ff8f984a5a42f97ba0a5a6f16f452a427 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 00:47:53 +0100
Subject: [PATCH 029/103] =?UTF-8?q?Joby:=20Generalizace=20test=C5=AF=20sta?=
 =?UTF-8?q?v=C5=AF=20pokra=C4=8Duje?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/mo/db.py b/mo/db.py
index 740747dd..ee013563 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -918,14 +918,20 @@ class Job(Base):
         return os.path.join(self.dir_path(), name)
 
     def is_active(self) -> bool:
-        return self.state in [JobState.ready, JobState.running, JobState.waiting]
+        """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'
-- 
GitLab


From 89e7984fe812f3a5df71f934edeff02a38e879a5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 00:48:22 +0100
Subject: [PATCH 030/103] =?UTF-8?q?Joby:=20Vyu=C5=BE=C3=ADv=C3=A1me=20obec?=
 =?UTF-8?q?n=C3=A9=20testy=20stav=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py                | 23 +++++++++++------------
 mo/web/org_jobs.py                 |  1 +
 mo/web/templates/org_job.html      |  2 ++
 mo/web/templates/org_job_wait.html |  5 +++--
 mo/web/templates/org_jobs.html     |  2 ++
 5 files changed, 19 insertions(+), 14 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 976a9e92..ff09581d 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -116,18 +116,17 @@ def org_certificates(ct_id: int):
         form = None
 
     if form and form.validate_on_submit():
-        if form.delete.data:
-            if not new_cset:
-                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'))
+        if 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)
             changes = None
diff --git a/mo/web/org_jobs.py b/mo/web/org_jobs.py
index 45acb6a7..fbda44d7 100644
--- a/mo/web/org_jobs.py
+++ b/mo/web/org_jobs.py
@@ -150,6 +150,7 @@ 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)
diff --git a/mo/web/templates/org_job.html b/mo/web/templates/org_job.html
index c1231e74..586bcf50 100644
--- a/mo/web/templates/org_job.html
+++ b/mo/web/templates/org_job.html
@@ -57,7 +57,9 @@
 	{% 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">
+	{% 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 %}
diff --git a/mo/web/templates/org_job_wait.html b/mo/web/templates/org_job_wait.html
index 42ff261e..82fd670e 100644
--- a/mo/web/templates/org_job_wait.html
+++ b/mo/web/templates/org_job_wait.html
@@ -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 c4a46eb1..e5a99660 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>
-- 
GitLab


From 2b5db770070a83b6d15a1966a539b771a1f49310 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 17:00:49 +0100
Subject: [PATCH 031/103] =?UTF-8?q?Requirements:=20P=C5=99id=C3=A1na=20z?=
 =?UTF-8?q?=C3=A1vislost=20na=20qrcode?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 setup.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/setup.py b/setup.py
index 0f81b8ec..8d6a31d5 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',
-- 
GitLab


From 8dfcad4f49ccc1adf3a4b5494de1e2c74e3c5c53 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 17:01:03 +0100
Subject: [PATCH 032/103] Constraints: Aktualizace
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

U mnoha balíčků používáme trochu vyšší verzi, snad se nic nerozbije.
---
 constraints.txt | 76 ++++++++++++-------------------------------------
 1 file changed, 18 insertions(+), 58 deletions(-)

diff --git a/constraints.txt b/constraints.txt
index 407794ac..5a177a92 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
-- 
GitLab


From 5a1f178845510c6605e9f633227a239893548d1c Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 17:35:41 +0100
Subject: [PATCH 033/103] =?UTF-8?q?Knihovna=20na=20generov=C3=A1n=C3=AD=20?=
 =?UTF-8?q?QR=20k=C3=B3d=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Momentálně lehká obálka kolem pythoního modulu qrcode, ale v případě
potřeby lze snadno vyměnit třeba za volání programu qrencode nebo
volání libqrencode pomocí cffi.
---
 mo/util_tex.py | 31 ++++++++++++++++++++++++++++++-
 1 file changed, 30 insertions(+), 1 deletion(-)

diff --git a/mo/util_tex.py b/mo/util_tex.py
index 07006406..73d2f0ba 100644
--- a/mo/util_tex.py
+++ b/mo/util_tex.py
@@ -1,9 +1,10 @@
 # Interakce s TeXem
 
 import os
+import qrcode
 import re
 import subprocess
-from typing import Any, Dict
+from typing import Any, Dict, List
 
 import mo.util
 
@@ -57,3 +58,31 @@ def run_tex(directory: str, source: str) -> None:
         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) -> str:
+        self.counter += 1
+        name = f'{self.name_base}{self.counter:04d}.png'
+        img = qrcode.make(msg, error_correction=ecc_level, box_size=1, border=0)
+        img.save(name)   # type: ignore
+        self.generated.append(name)
+        return name
+
+    def remove_all(self) -> None:
+        for name in self.generated:
+            os.unlink(name)
-- 
GitLab


From 45b6a12c4c3ceeb0e01e7ba82beaa70f73958488 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 17:37:36 +0100
Subject: [PATCH 034/103] =?UTF-8?q?Diplomy:=20QR=20k=C3=B3dy=20generujeme?=
 =?UTF-8?q?=20v=20Pythonu=20m=C3=ADsto=20v=20TeXu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Není hezké vyrábět hromady souborů s obrázky, ale pořád je to řádově
rychlejší než qrcode.tex.
---
 mo/jobs/certs.py       | 14 +++++++++++---
 mo/tex/certifikaty.tex | 12 ++++++------
 2 files changed, 17 insertions(+), 9 deletions(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index 594b4dca..e15b0234 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -14,7 +14,7 @@ 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, run_tex, format_hacks
+from mo.util_tex import tex_arg, run_tex, format_hacks, QREncoder
 
 
 def schedule_create_certs(contest: db.Contest, for_user: db.User) -> int:
@@ -53,6 +53,7 @@ class CertMaker:
 
     certs: List[Cert]
     out_files: Dict[db.CertType, str]
+    qr_encoder: QREncoder
 
     def __init__(self, the_job: TheJob):
         self.the_job = the_job
@@ -77,6 +78,7 @@ class CertMaker:
 
         self.certs = []
         self.out_files = {}
+        self.qr_encoder = QREncoder(f'{self.job.dir_path()}/qr')
 
     def plan(self) -> None:
         sess = db.get_session()
@@ -164,12 +166,15 @@ class CertMaker:
             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)
                 f.write('\n{\n')
                 attrs({
                       'jmeno': cert.user.full_name(),
                       'skola': cert.school.name,
                       'uspech': cert.achievement,
-                      'qrurl': self._make_qr_url(cert),
+                      'qrurl': qr_url,
+                      'qrimg': os.path.basename(qr_file),
                 })
                 f.write('\\Cert' + cert.type.name.replace('_', '').title() + '\n')
                 f.write('}\n')
@@ -199,7 +204,10 @@ class CertMaker:
         full_dir = os.path.join(certs_dir, out_dir)
         os.makedirs(full_dir, exist_ok=True)
 
-        # Nejdříve najdeme všechny staré soubory s certifikáty
+        # 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
diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
index ea8c33b8..f487e995 100644
--- a/mo/tex/certifikaty.tex
+++ b/mo/tex/certifikaty.tex
@@ -18,10 +18,6 @@
 \newbox\logobox
 \setbox\logobox=\putimage{width 30mm}{mo-logo.epdf}
 
-\input qrcode.tex
-\qrset{height=23mm, level=M, tight, silent, link, qrborder={1 1 1}}
-\newbox\codebox
-
 % Základní údaje o soutěži
 \def\kolo{}
 \def\kat{}
@@ -39,10 +35,10 @@
 \def\skola{???}
 \def\uspech{???}
 \def\qrurl{???}
+\def\qrimg{???}
 
 \def\generic{
 	\offinterlineskip  % řádkování je proměnlivé a vše má podpěry
-	\setbox\codebox=\hbox{\qrcode{\qrurl}}
 	\vglue 0pt
 	\vfill
 	\head
@@ -100,7 +96,11 @@
 		\hfil
 		\vbox{\copy\logobox}%
 		\hfil
-		\llap{\vbox{\box\codebox}}%
+		\llap{%
+			\pdfstartlink attr {/Border [0 0 0]} user {/Subtype/Link /A << /Type/Action /S/URI /URI(\qrurl) >>}\relax
+			\vbox{\pdfimageresolution=50\relax\putimage{}{\qrimg}}%
+			\pdfendlink
+		}
 		\hskip 15mm
 	}
 }
-- 
GitLab


From cea36a3dd8a6ecc8e73e743c6cafdb9a5b9d50a8 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 17:58:48 +0100
Subject: [PATCH 035/103] =?UTF-8?q?P=C5=99id=C3=A1no=20vektorov=C3=A9=20lo?=
 =?UTF-8?q?go=20J=C4=8CMF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/tex/jcmf-logo.epdf | Bin 0 -> 4473 bytes
 mo/tex/jcmf-logo.svg  | 136 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 136 insertions(+)
 create mode 100644 mo/tex/jcmf-logo.epdf
 create mode 100644 mo/tex/jcmf-logo.svg

diff --git a/mo/tex/jcmf-logo.epdf b/mo/tex/jcmf-logo.epdf
new file mode 100644
index 0000000000000000000000000000000000000000..887aece3e8cc4f21c53bbc5aca701f4df0b4ec27
GIT binary patch
literal 4473
zcmY!laB<T$)HCH$-THRjZ!QxB1BLvgEG`=x1%02?y!4U`1ycnBg&-~k1qFS#%$$<c
zA_aZ7oWzn;m(=9^lvFM|JFeoAqSVA(u8KLmk(TRJjthQ$EmGR3m-=L$o-89vN`Mio
zq!YJ-;u(o4JaO^)CtunBvOU(o@IR$QZ{xlQt#d8^<r@D#4Ow?7*u`>weE<FaCG-D%
ze*JTK{j$&3|G)bh|G#Egw#!T7`SlIoJ_|nI-@pGq^Ocf+uRBWqJ^k~yr$1Xb|8?P4
zndix@^E94c-@H9rWNr1S^!V$q-qxHcJ!^VCa9xzzw8vSwbI%v|WMAQP+IuYLxaH-m
z+<lMr>*hc8;$7~(-)r3syXURm^0L|gdQLB%*MDtY%(>8YGOuf{RDHGMe-jtmKk3pr
zkHVz~9^P6P!jx?jx$0UV^KE5Qn^%7=551nrFTf?T+2KX_rM8->S4&hLcM7Opd(_Rl
zX3w;zQJb@728nv}irfh}@aaff;Z@d;pZ8=*@8H|j#$mZGw4^rX;M<LQ5`x($ZWLTp
z{<YOjusfM&&B3f$Rz~agc&rR6TvE!)EAsP|_7*?Ze3yXb?R^hQ-+5R>mN<Jqn3d1R
zDO+1~a{9a=nXhech20eWO}{$p>u?HQe-SG)L70R0aq#EF&5kcjG(=k3c~*CM35LCU
z-uQQif|Gd9tn*XNj!UMk`_kw&eNp<woDWNHzBIb@HRrmIh4l%UKP%%(s`YnTzKL)4
zv%0plx@fZew#<cXGMl|-xF2X?%x`)gwKn)((^DBmZ`Ztg{@k`kvP|C(3O2PJ-PbvN
z*_pGd^Oj$F_+V-3+xU)-ncIa91f5@R@E}|H^RLp-Dxs3JJkb)z<MtKR50f8kWt+>;
zGO^7h?0_ntzNO~@>y=Fx?kY~4e<g6e@A2C$it0ReyY4O0li0PmbF0zIsoJM?`sd`F
zQ*&2d-?6*ccW1BP^t}H~jH^qhr(gKDIps8$eFBfj-7wKM4k_FDf_ta-t7<$6l(@=1
zbK)%RH#c_8<p0y$(zc?lV9JcsO&(2)qB5hFN$0P)-z>s(`NqfQ3H<M;=U-KtnYt{$
zuB}+qkz>vx{i>ws_Bz#Xwm*L-sciMR$r-<Ejsz#`W4+yKS1#`E^(tugS2*_3#OA5M
z4c|?dmi#@w<FAaI+Qk=I0ryYvSSzca;aD)0`EW(AVR^|6=5@|1)^a=CVcf8z?_zVZ
zLuQqHSoX#JRu=0*Eo>LecT1TY<+R{&&gHD*sUfdF=Nyo4I&jTHTD(wj&DtkZ9pq1)
zPI;zwq1$@>?&}42EGurlxgdOZm$4V)i)I6^(4LHRWhaxrd~btx_LOU_F|~@|Haz?K
zg=IvVu6T_~v1Xt^(B`W0M^88So%e26<qUlJAiBgkYuS2T&B6ppnT0c2{+#%7Oyr~H
zZ`<?t?+HKhf37bt`F(2EedElO-)e_g5_vc7pEq&3sAJH{&pk~?CQ0;|SbyM{*t7pu
z<Ae6ew)_{=HnTVjJZs*#SWV4HXvUmEUg1efLUBR8uL_c$-fCE@y<LEH#-Bw_7b}ck
zsn`jqGdM;5T&eoA-_u8oSHfr&r_WTz;7c58H)1-%H*!S@cd`9xeEG>SGc5Mj#7!5z
z^R3M|lfKcDIdS>%*3Khs2OgJlGP^98JlnBKU_wDu$28HC3Jhskm-lj1p5i<bb&YjK
z*a}yJhu!;CI)o1?JwM3%B&ktkY6(l)_6I&uJ{OmiaJoKdsb){o)7BS~YISQ|k+XVM
z$O$=)t(<da8#J?M{QGe9tbeGaph!Z*xhl?iNlxds91_{o<-lscRC<a6gWbJ3%g<&u
zr!NxY%#BaxeI0VoC}45%smWQV)RsiO$Z=K^UK(o15fpkgXxF?4@jo^GeY?6P@s{SU
zd2BJRjb5+cYHWV}cB{Uk*k#ALN;?%lrRA^mYuLMLr}NSUpELwBzkK>uqn=c>-O(x{
zv`bne#Ud)=pW(4($vu`w^|+%ZeP?_W;pfWOwIq}2WAnmWukTHKEBs-nO_x5?5&1<s
zrX;qp$#{Ra^-17+uK8rvx)-uj^dc0WywN=)ucFW^XT9(3#+fJQ=&Mc@j<J5)ES(ZQ
zPf|$8U}N(1_dAa5uoXLVrq}*p#V;O@mhJBsT<K?$$<|Su=&;VKW9s_n*P1jWZ*o_y
zy1arVeEv~My<ayth36<8NR3}OS!ffJ=*FWQwNv%>uFwlFT`|da!ov>v17FuXaJ;Z$
z%Dl^#a}C%2P+BO(KJ`hv;C#K%1<C3WbN!3H?=HQ#n1^qginF@N#xLxZ_STDU#<Z+@
zdGp}fMMu9Drq5iV*TJaKB)_owPSd7aQr3=>d9Piw*m(3%Ah+LskphNK0mgo6+fpsk
zjhQW{m`Iv9-(}NgeYx9b>2u3%`9?W60}Gw+|FAk{cj#02fxBz|ubaV|w<9j-W7+!o
zTQaAu^H2<b*fv32;KlP;EwS$j{-Kvv&f<P0pE}|9%tb*T5)_ZD5^8EOvx(&9)M$`6
zGq3iZ^sY^z<<}aM5)Lh5?p;wm<I01q4KZ7VQ=Zza*&XCtT)R@)=LG*D4W>2uEr%z+
zk@)@2bH$ydllGj?4bqq{+4*$l!EGyFJnY<B`g)T5l(3^BC5PQOTXdE!us(6}j^NL&
zBHp!mZ!d32sd~f9U{VlTrW(I#SAdMC`4jQ4IUXh7ToQC{<n6w^SmYM}$AfPY9V6AR
z-o1Z$wNcHi`mV1}vgE9`uQxxOlq=n_;$}}1xAYt3J-a^%-nn@B+m$YkC2doSGtM7V
zU;SZ%>t7Weg_F$P{cm?}DDbvk`>y3NueCd`SmTZ*_eI%Dv$dm)bG~aFf4lQsz*ehw
zg$*~`|2AvSSBqKLb!_fqF6V6@I&Z`@{x>oaoM@JLyy!Szx1r(DoE7F-VwYC;&5kP<
zWBfArrjC{H3b~oZKY}FItU7$TyM_7L@vG(YLWKiQ%VkbbVGb)jHjUXTS!?Pi$-J$%
zntUdUEk4y`yDk6lN(;Zh&=U(iCNJCc`6ZwJh7+?q8N9cr*oFUgpAuf(&>i?sRxQvy
z)pGVmtGScBeE0aRC|D$sx!7uNyPt}D)|YM0&q7-}>o*8jZ^~{6mS&OYSo=n5W?AdY
z$jMxP6t0FGJ*jrIxObDuhK%yI<xd%kZQoVAo}R{#(55w6FVll%f#Xfp<Bm?nzQ!RN
zG}3GzEIi@xH1Edb)|&~6Dc#<4Rxvg>g-zyl7ua&~+YUEXjW2&!?TG)CYCe76!WzZb
zZLxRO>o8uM`6flB%)P}t_Rywk$=+jO8riuG?@qsp;uoFT?ovGeXkO0SFNRYWmX&Q>
zyix7b`P|>U%WJb&Ya4QleV?(Xp|81g$G!PlmRB8i|8)GZ>9X9KMTcWHn?4XoKR0*Q
za*=a-QPE{fZFk*bn5wYnZ}G%We|#(}T9mx!&icAX_UBvct>^f+H)e9Om)gFr+0v-<
zW&gq&%bOh;7d#fPe$c&Z@g~pPujKFS+Zt{w^;$~0s?%l-!)}2N7O{ONFY~X9EtqKX
z?A+ASug)T-ue0_qa0ojT`7v(#u`++rN!ByID6I~Ro+c~RK55~uXE*+-lqYgO*|mMs
z$wO85wz3{?k9~9BHt~(}=jKTYI`0d1)|*edXnQ@t|M4=$S(06UHks5econ04czUN+
zNS=Ro@%5Og%PX1R2FV^>IF0#o^tBs?$4alx$a!PdbhAD8$fFC@!WPrx4m&%(DtpUm
zB9QXj$#0uwb?ICG?kTF*uDIS#vlF?=%)4#nl~k8o!ORu5n{UkaUOA!u`)2vFz#k0(
zt5%dRYu_|ywwi+Ws#ul#M-NZ6ekQMax4HPp2bHd;>$qGj4kfX!JM>cQqu<me?Opz*
z%l&+2X=lBy5=#}Rwi7B_y8hYsm1O}{Cr)N*SYIy`J5aXKfIY+WvhCATr#35XR6Q2o
z8+{-&Y;ySasg}p?`(EKw+&X=Z){~c8)&5Bt>uioJzJG9`&lKChH0Py7TXsDAvnJ-|
zE8mCqIje3Q4s&>V#p2b~?-Q9i!l$2V)J-_M?Y6RT$(hqK(+k3Cd)}-_ay-dZ-C`d-
zMJfJC%H@krXN~rk{GM?v?51=;zwEA{`791M77KhiZzO+jgPQ5T+6|xEm$59XjyQeR
zb4s&M@e}79fu8=fFVeFX+PUrZtzOKn(NfFA9IfwlGs0R(Y@0{$T&_gL?5f@F(Ki%I
zQoNS6yM-UCTi}1kSZoo$Bd_F)1<rb*CxjSY)c=t<u-?)6U_$tV4L9SrYi#7wop!>Y
zAu3l%hg0j@3gw4crDe-jT3mU^TN1qU<b_Skx9PHPEpV~@o16Zg!TqW;&$bWz2Y3(W
zx--0<I4$(`g$b)BZ(JAJwtC*ije%LY=YyA2J!@VepPlrdXZ>!A59$)aXN22pyuRx^
zdlhhS!MjPR`K4{!tWQ>4o&Uq6=H#z6Z0esPTt6JT-`*~LbgDl4_tX<2Qal@$1{6uY
zo1Zx=usq5mX|*VGO-TLsESc?`saHQ-yd!3F=vF%SxtlpE{ieH*skd_+VcS;s?cftb
zrJ4zK3%AJe9ocy9{K8cS>bc|D>~c1;@qRo0QSPJVUEy;s(T`4a8_k-0f~$T`l2-DO
z{+z{!E$4h)$mG!6*7xt&^!NHcA{p^-j()zgy=v$BJ^a6pJ${@vIbPq}-aI<$&FT5y
zC+Qcz5M942e9zn2^5*Z)_fOwmVjjKyi~05H+Bb}Ik5s+AIeoug<;rJ|=T83?7xnAH
zkF;-_IWta{Z~ntOt>^Re_cK2HH~IMW?Jj+*U#CCX|2Zgplq)qa1=_tz%}W7w%s{;{
z5Urq~U~FP&1X2Lv8pHc-;GUXmMTvWGNn%N=f{hJG0MvU+G*GayQP6jGR4`P~Pfk=Y
zRIsxH$=TU~I&o0rjo{`e=!aAmq$=nKB&Me-s45tOl<Eg07NzEuC>X-KgubaMnTbyM
z6$;S`1_}lWMh3=u#%2bFhQ`RuSg>B_{JfIXypm!CP#+W0p>!|GFD*cFBE&|pC`b#)
z)xiq-Aw`LK#RVXnlPh7uo(d&JrKvEUbFhNGOKMqWa%zyf6G#x^GyR~{;{4L0<kVsX
zQ1}EP`4i*=P`H6Y1Y#@5pT!`(`rer-#R}01pr8m+fVsUC<ZUDa%#Z>qpeR43G&!|M
zK_fXavnXG|P|wgz&p<&Vqokyu*h*g?ESg@FSdfvKT&$O0l&+}>w#d0CHL)bWNWsR<
z)y>Voz{JwPz|7Uaz|6_Oz}(otzzl>94Ghc;3=GUn4GfG73=E774GfH24GfHpkZ1z~
z10xFq12YQ)19KB7Hh`F6>}X(M<OacJgv<xo?Pg$LZe(C!W^Q0$WC>0m`e0{f=I6P9
z(vyaZm63swsez%PrJ=d0k*T@1fuXvAfhJPWm?H%ZBy61%OA>SP)4`qwhch$<+1bI;
zZ$(jR8kd2B1(yLFD43a=8k;JlDL}=HEsPBnz_JQ?a4}O$OH45%b96BS14C1EF=JB$
z6AUp6OEYva6GL+(J4%WYGjmdlzyl<~nN_I@7GPfm<>!|ufIJRK*q(W5`3j&U0#0+q
WC5c5PU>}+o85?q`s=E5SaRC4Y$D61C

literal 0
HcmV?d00001

diff --git a/mo/tex/jcmf-logo.svg b/mo/tex/jcmf-logo.svg
new file mode 100644
index 00000000..2cbbe691
--- /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>
-- 
GitLab


From 489b0221e555fa3dfc870ca58464f04c0f4eb81f Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 17:59:28 +0100
Subject: [PATCH 036/103] =?UTF-8?q?Diplomy:=20P=C5=99id=C3=A1no=20logo=20J?=
 =?UTF-8?q?=C4=8CMF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/tex/certifikaty.tex | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
index f487e995..a05449d3 100644
--- a/mo/tex/certifikaty.tex
+++ b/mo/tex/certifikaty.tex
@@ -15,8 +15,11 @@
 \uselanguage{czech}
 \frenchspacing
 
-\newbox\logobox
-\setbox\logobox=\putimage{width 30mm}{mo-logo.epdf}
+\newbox\mologobox
+\setbox\mologobox=\putimage{width 35mm}{mo-logo.epdf}
+
+\newbox\jcmflogobox
+\setbox\jcmflogobox=\putimage{width 14mm}{jcmf-logo.epdf}
 
 % Základní údaje o soutěži
 \def\kolo{}
@@ -94,7 +97,7 @@
 		\hskip 15mm
 		\rlap{\vbox{\datebox}}%
 		\hfil
-		\vbox{\copy\logobox}%
+		\hbox{\copy\mologobox\hskip 10mm\copy\jcmflogobox}%
 		\hfil
 		\llap{%
 			\pdfstartlink attr {/Border [0 0 0]} user {/Subtype/Link /A << /Type/Action /S/URI /URI(\qrurl) >>}\relax
-- 
GitLab


From c1715f3ee2dabc35a0971431641158efa4fe3e51 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 19:59:33 +0100
Subject: [PATCH 037/103] =?UTF-8?q?Diplomy:=20Unicodov=C3=BD=20TeX=20snese?=
 =?UTF-8?q?=20en-dash=20jako=20codepoint?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Pak se správně zobrazí i na webu.
---
 mo/jobs/certs.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index e15b0234..fd99e210 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -121,7 +121,7 @@ class CertMaker:
                     if order['span'] == 1:
                         place = f"{order['place']}."
                     else:
-                        place = f"{order['place']}.--{order['place'] + order['span'] - 1}."
+                        place = f"{order['place']}.–{order['place'] + order['span'] - 1}."
                     add_cert(db.CertType.successful, f'za {place} místo', (order['place'], user.sort_key()))
 
             # Pochvalné uznání
-- 
GitLab


From 454d3eaae32fce9b3d5d8c9e336f8698a9df73a4 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 21:31:02 +0100
Subject: [PATCH 038/103] =?UTF-8?q?Diplomy:=20PDF=20s=20vykousnutou=20str?=
 =?UTF-8?q?=C3=A1nkou=20m=C3=A1=20metadata?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index ff09581d..8510f2d7 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -1,12 +1,14 @@
 # Web: Certifikáty
 
-import os
+from datetime import datetime
 from flask import render_template, g, redirect, url_for
 from flask.helpers import send_file, flash
 from flask_wtf import FlaskForm
 import flask_wtf.file
 from markupsafe import Markup
+import os
 import pikepdf
+from pikepdf.models.metadata import encode_pdf_date
 from sqlalchemy.orm import joinedload
 from tempfile import NamedTemporaryFile
 from typing import Tuple, Optional, Dict
@@ -41,7 +43,9 @@ def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Op
         raise werkzeug.exceptions.NotFound()
 
     file = os.path.join(mo.util.data_dir('certs'), cfile.pdf_file)
-    if not os.path.isfile(file):
+    try:
+        stat = os.stat(file)
+    except FileNotFoundError:
         logger.error(f'Certifikát {file} je v DB, ale soubor neexistuje')
         raise werkzeug.exceptions.NotFound()
 
@@ -56,6 +60,9 @@ def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Op
         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:
-- 
GitLab


From d6234dc690584c39708298e5bbaf61d6ab4b37af Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 19 Jan 2025 21:42:06 +0100
Subject: [PATCH 039/103] =?UTF-8?q?Diplomy:=20Pokud=20je=20round=5Ftype=20?=
 =?UTF-8?q?=3D=3D=20other,=20diplomy=20odm=C3=ADt=C3=A1me=20vyd=C3=A1vat?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py                    | 2 +-
 mo/web/templates/org_certificates.html | 6 ++++++
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 8510f2d7..36322c8f 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -96,7 +96,7 @@ def org_certificates(ct_id: int):
 
     contest = ctx.master_contest
     ct_id = contest.contest_id
-    can_change = ctx.rights.have_right(Right.manage_contest)
+    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)
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index 486e216e..96fbec31 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -78,6 +78,12 @@
 
 {% 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>
-- 
GitLab


From 138341b0bad21a469c1f5e407eeb5e583fd2b1ba Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 00:13:33 +0100
Subject: [PATCH 040/103] =?UTF-8?q?QR=20k=C3=B3dy:=20Nastaviteln=C3=BD=20o?=
 =?UTF-8?q?kraj?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/util_tex.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/mo/util_tex.py b/mo/util_tex.py
index 73d2f0ba..0ccfc22b 100644
--- a/mo/util_tex.py
+++ b/mo/util_tex.py
@@ -75,10 +75,12 @@ class QREncoder:
         self.counter = 0
         self.generated = []
 
-    def generate(self, msg: str, ecc_level: int = qrcode.ERROR_CORRECT_M) -> str:
+    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'
-        img = qrcode.make(msg, error_correction=ecc_level, box_size=1, border=0)
+        # 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
-- 
GitLab


From f915ec87ab233d39afaa48b7d3f03eeb577510df Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 00:13:50 +0100
Subject: [PATCH 041/103] =?UTF-8?q?link=5Fto=5Fdir=20si=20nech=C3=A1=20nas?=
 =?UTF-8?q?tavit=20base=5Fdir=20a=20make=5Fdirs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Skoro všechna naše použití link_to_dir zakládají adresáře a dělají
složité tanečky s os.path.basename(výstup). Tak pojďme link_to_dir
naučit se o to takové věci postarat.
---
 mo/util.py | 15 ++++++++++-----
 1 file changed, 10 insertions(+), 5 deletions(-)

diff --git a/mo/util.py b/mo/util.py
index 22e2ada0..fa6994f7 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -101,16 +101,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):
-- 
GitLab


From 98a3acd22ecd45a3a48d4a24f6b968c7d00e8312 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 00:16:46 +0100
Subject: [PATCH 042/103] =?UTF-8?q?Diplomy:=20=C3=9Aklid=20kolem=20link=5F?=
 =?UTF-8?q?to=5Fdir=20a=20adres=C3=A1=C5=99=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/jobs/certs.py | 27 ++++++++++++++-------------
 1 file changed, 14 insertions(+), 13 deletions(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index fd99e210..52dd45d8 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -54,6 +54,9 @@ class CertMaker:
     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
@@ -80,6 +83,10 @@ class CertMaker:
         self.out_files = {}
         self.qr_encoder = QREncoder(f'{self.job.dir_path()}/qr')
 
+        self.certs_dir = mo.util.data_dir('certs')
+        self.out_dir = os.path.join(self.round.round_code_short(), str(self.contest.contest_id))
+        self.job_dir = self.job.dir_path()
+
     def plan(self) -> None:
         sess = db.get_session()
         pions_pants = (sess.query(db.Participation, db.Participant)
@@ -133,14 +140,13 @@ class CertMaker:
         if not certs:
             return
 
-        temp_dir = self.job.dir_path()
         name = cert_type.file_name()
-        logger.debug(f'{self.the_job.log_prefix} Vytvářím certifikáty typu {name} v {temp_dir} ({len(certs)} listů)')
+        logger.debug(f'{self.the_job.log_prefix} Vytvářím certifikáty typu {name} v {self.job_dir} ({len(certs)} listů)')
 
         certs.sort(key=lambda cert: cert.sort_key)
-        self.make_tex_source(f'{temp_dir}/{name}.tex', certs)
-        run_tex(temp_dir, f'{name}.tex')
-        self.out_files[cert_type] = f'{temp_dir}/{name}.pdf'
+        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:
@@ -199,11 +205,6 @@ class CertMaker:
         sess = db.get_session()
         conn = sess.connection()
 
-        certs_dir = mo.util.data_dir('certs')
-        out_dir = os.path.join(self.round.round_code_short(), str(self.contest.contest_id))
-        full_dir = os.path.join(certs_dir, out_dir)
-        os.makedirs(full_dir, exist_ok=True)
-
         # Nejdříve smažeme už zbytečné soubory s QR kódy
         self.qr_encoder.remove_all()
 
@@ -216,11 +217,11 @@ class CertMaker:
 
         # Založíme nové soubory
         for ctype, out_file in self.out_files.items():
-            file = mo.util.link_to_dir(out_file, full_dir, prefix=f'{ctype.file_name()}-', suffix='.pdf')
+            pdf_file = mo.util.link_to_dir(out_file, self.out_dir, base_dir=self.certs_dir, prefix=f'{ctype.file_name()}-', suffix='.pdf', make_dirs=True)
             sess.add(db.CertFile(
                 cert_set_id=self.contest_id,
                 type=ctype,
-                pdf_file=os.path.join(out_dir, os.path.basename(file)),
+                pdf_file=pdf_file,
             ))
 
         # Založíme nové certifikáty
@@ -253,7 +254,7 @@ class CertMaker:
 
         # Nakonec smažeme staré soubory
         for old_file in old_files:
-            mo.util.unlink_if_exists(os.path.join(certs_dir, old_file))
+            mo.util.unlink_if_exists(os.path.join(self.certs_dir, old_file))
 
 
 @job_handler(db.JobType.create_certs)
-- 
GitLab


From 656d0b5209d15dc2cedda6300f8e5f8fc71af6b2 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 00:17:32 +0100
Subject: [PATCH 043/103] =?UTF-8?q?Diplomy:=20QR=20k=C3=B3dy=20maj=C3=AD?=
 =?UTF-8?q?=20okraj?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

To se hodí, pokud pod nimi je podkladový obrázek.
---
 mo/jobs/certs.py       | 2 +-
 mo/tex/certifikaty.tex | 5 ++++-
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index 52dd45d8..49781077 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -173,7 +173,7 @@ class CertMaker:
 
             for i, cert in enumerate(certs):
                 qr_url = self._make_qr_url(cert)
-                qr_file = self.qr_encoder.generate(qr_url)
+                qr_file = self.qr_encoder.generate(qr_url, border=4)
                 f.write('\n{\n')
                 attrs({
                       'jmeno': cert.user.full_name(),
diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
index a05449d3..10cb8bcd 100644
--- a/mo/tex/certifikaty.tex
+++ b/mo/tex/certifikaty.tex
@@ -21,6 +21,9 @@
 \newbox\jcmflogobox
 \setbox\jcmflogobox=\putimage{width 14mm}{jcmf-logo.epdf}
 
+\newcount\qrdpi
+\qrdpi=50
+
 % Základní údaje o soutěži
 \def\kolo{}
 \def\kat{}
@@ -101,7 +104,7 @@
 		\hfil
 		\llap{%
 			\pdfstartlink attr {/Border [0 0 0]} user {/Subtype/Link /A << /Type/Action /S/URI /URI(\qrurl) >>}\relax
-			\vbox{\pdfimageresolution=50\relax\putimage{}{\qrimg}}%
+			\lower\dimexpr 1in/\qrdpi*4\relax\vbox{\pdfimageresolution=50\relax\putimage{}{\qrimg}}%
 			\pdfendlink
 		}
 		\hskip 15mm
-- 
GitLab


From a75212137d987ed306404a53f20bb4c82c7bdaf0 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 00:17:56 +0100
Subject: [PATCH 044/103] =?UTF-8?q?Submit:=20Lze=20pou=C5=BE=C3=ADt=20t?=
 =?UTF-8?q?=C3=A9=C5=BE=20na=20validaci=20podkladov=C3=BDch=20obr=C3=A1zk?=
 =?UTF-8?q?=C5=AF=20diplom=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/submit.py | 34 +++++++++++++++++++++++-----------
 1 file changed, 23 insertions(+), 11 deletions(-)

diff --git a/mo/submit.py b/mo/submit.py
index ac3a37ee..2b9efc4a 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:
-- 
GitLab


From 1fe442229d55b3e21453d300c9368e6bd88589a9 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 00:18:31 +0100
Subject: [PATCH 045/103] =?UTF-8?q?Diplomy:=20UI=20na=20upload=20pozad?=
 =?UTF-8?q?=C3=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py                    | 39 +++++++++++++++++++++++++-
 mo/web/templates/org_certificates.html | 25 +++++++++++++++--
 2 files changed, 61 insertions(+), 3 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 36322c8f..94c17d08 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -20,6 +20,7 @@ import mo.db as db
 import mo.email
 import mo.jobs.certs
 from mo.rights import Right
+import mo.submit
 import mo.util
 from mo.util import logger
 from mo.web import app
@@ -72,6 +73,19 @@ def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Op
     return send_file(open(tmp_file.name, 'rb'), mimetype='application/pdf')
 
 
+def validate_background(form, field):
+    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 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'})
@@ -79,7 +93,11 @@ class CertSetForm(FlaskForm):
     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'})
-    upload_background = flask_wtf.file.FileField("Obrázek na pozadí", description="Zde můžete nahrát obrázek ve formátu PDF, který se použije jako pozadí diplomu.")
+    upload_background = flask_wtf.file.FileField("Obrázek na pozadí",
+                                                 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.")
+    delete_background = wtforms.BooleanField("Smazat obrázek")
     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í")
@@ -119,6 +137,8 @@ def org_certificates(ct_id: int):
             del form.tex_hacks
         if new_cset:
             del form.delete
+        if not cset.background_file:
+            del form.delete_background
     else:
         form = None
 
@@ -136,6 +156,21 @@ def org_certificates(ct_id: int):
             return redirect(ctx.url_for('org_certificates'))
         elif form.generate.data or form.save.data:
             form.populate_obj(cset)
+            if form.upload_background.data:
+                old_background = cset.background_file
+                out_dir = os.path.join(ctx.master_round.round_code_short(), str(contest.contest_id))
+                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}')
+            elif form.delete_background.data:
+                old_background = cset.background_file
+                cset.background_file = None
+            else:
+                old_background = None
             changes = None
             if new_cset:
                 sess.add(cset)
@@ -155,6 +190,8 @@ def org_certificates(ct_id: int):
                 )
                 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')
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index 96fbec31..417d7c6b 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -105,7 +105,7 @@
 {{ wtf.form_field(f, form_type='horizontal', horizontal_columns=('lg', 3, 7)) }}
 {% endmacro %}
 
-<form method="POST" class="form form-horizontal" action="">
+<form method="POST" class="form form-horizontal" enctype="multipart/form-data" action="">
 	{{ form.csrf_token }}
 	{{ field(form.signer1_name) }}
 	{{ field(form.signer1_title) }}
@@ -113,7 +113,28 @@
 	{{ field(form.signer2_title) }}
 	{{ field(form.issue_place) }}
 	{{ field(form.issue_date) }}
-	{{ field(form.upload_background) }}
+	<div class="form-group">
+		<label class="control-label col-lg-3" for="upload_background">Obrázek na pozadí</label>
+		<div class="col-lg-7">
+			{{ form.upload_background() }}
+		</div>
+		<div class="col-lg-offset-3 col-lg-7">
+			{% if form.delete_background %}
+				<div class="checkbox">
+					<label>
+						{{ form.delete_background() }} {{ form.delete_background.label }}
+					</label>
+				</div>
+				<p class="help-block">
+					Obrázek na pozadí je nahraný. Můžete ho smazat, nebo nahradit novým.
+				</p>
+			{% 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.
+				</p>
+			{% endif %}
+		</div>
+	</div>
 	{% if 'tex_hacks' in form %}
 	{{ field(form.tex_hacks) }}
 	{% endif %}
-- 
GitLab


From 234fb9ce874153d4305d355d6f622f8b5b80e0e2 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 00:18:49 +0100
Subject: [PATCH 046/103] =?UTF-8?q?Diplomy:=20Sazba=20obr=C3=A1zk=C5=AF=20?=
 =?UTF-8?q?na=20pozad=C3=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/jobs/certs.py       | 8 ++++++++
 mo/tex/certifikaty.tex | 7 ++++++-
 2 files changed, 14 insertions(+), 1 deletion(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index 49781077..97cb998e 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -135,6 +135,12 @@ class CertMaker:
             if row is not None and row.get('honorary_mention', False):
                 add_cert(db.CertType.honorary_mention, 'za úplné vyřešení úlohy', user.sort_key())
 
+    def prepare_files(self) -> None:
+        if self.cset.background_file:
+            background = os.path.join(self.job_dir, 'background.pdf')
+            mo.util.unlink_if_exists(background)
+            os.link(os.path.join(self.certs_dir, self.cset.background_file), background)
+
     def make_certs(self, cert_type: db.CertType) -> None:
         certs = [cert for cert in self.certs if cert.type == cert_type]
         if not certs:
@@ -165,6 +171,7 @@ class CertMaker:
                 'signerBtitle': self.cset.signer2_title,
                 'issueplace': self.cset.issue_place,
                 'issuedate': self.cset.issue_date,
+                'background': "background.pdf" if self.cset.background_file else "",
             }
             if self.round.round_type in (db.RoundType.okresni, db.RoundType.krajske):
                 ga['oblast'] = self.place.name
@@ -261,6 +268,7 @@ class CertMaker:
 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()
diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
index 10cb8bcd..bef1add4 100644
--- a/mo/tex/certifikaty.tex
+++ b/mo/tex/certifikaty.tex
@@ -34,6 +34,7 @@
 \def\signerBtitle{}
 \def\issueplace{}
 \def\issuedate{}
+\def\background{}
 
 % Údaje o jednom certifikátu
 \def\typ{???}
@@ -45,7 +46,11 @@
 
 \def\generic{
 	\offinterlineskip  % řádkování je proměnlivé a vše má podpěry
-	\vglue 0pt
+	\topskip=0pt
+	\ifx\background\empty\else
+		\smash{\vhang{\putimage{width \hsize}{\background}}}
+	\fi
+	\vglue 10pt
 	\vfill
 	\head
 	\vfill
-- 
GitLab


From 2b3f326d4ed3f74ae9631e5eaeb30a7222679563 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 17:09:12 +0100
Subject: [PATCH 047/103] =?UTF-8?q?P=C5=99id=C3=A1n=20json=5Fwalker=20z=20?=
 =?UTF-8?q?KSP=20do=20mo.ext?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

OSMO už je několikátý projekt, kde ho používám. Tak by mohlo stát
za to udělat z něj samostatný balíček a zveřejnit ho v PyPI.
---
 mo/ext/json_walker.py | 214 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 214 insertions(+)
 create mode 100644 mo/ext/json_walker.py

diff --git a/mo/ext/json_walker.py b/mo/ext/json_walker.py
new file mode 100644
index 00000000..6a25e1b4
--- /dev/null
+++ b/mo/ext/json_walker.py
@@ -0,0 +1,214 @@
+# 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_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_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_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
-- 
GitLab


From 7bfe0ba25bdf22fb7b202c8addb6f5bd99d35db0 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 17:10:05 +0100
Subject: [PATCH 048/103] Diplomy: Parametry jobu parsujeme json_walker-em

---
 mo/jobs/certs.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index 97cb998e..cebde1f2 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -10,6 +10,7 @@ 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
@@ -62,7 +63,10 @@ class CertMaker:
         self.the_job = the_job
         self.job = the_job.job
         assert self.job.in_json is not None
-        self.contest_id = self.job.in_json['contest_id']  # type: ignore
+
+        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)
-- 
GitLab


From a45b74d29db8edf3b29ddf232652182229b953b5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 18:05:28 +0100
Subject: [PATCH 049/103] =?UTF-8?q?Diplomy:=20Drobn=C3=A9=20chyby=20v=20UI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 94c17d08..f0379155 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -166,7 +166,7 @@ def org_certificates(ct_id: int):
                                                            suffix='.pdf',
                                                            make_dirs=True)
                 app.logger.info(f'Nahráno pozadí diplomů {cset.background_file}')
-            elif form.delete_background.data:
+            elif 'delete_background' in form and form.delete_background.data:
                 old_background = cset.background_file
                 cset.background_file = None
             else:
@@ -180,6 +180,7 @@ def org_certificates(ct_id: int):
                     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)
-- 
GitLab


From 11815db0752fb6e54809a36d809a9bc101178c55 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 18:06:29 +0100
Subject: [PATCH 050/103] =?UTF-8?q?mo.util:=20Funkce=20na=20sl=C3=A9v?=
 =?UTF-8?q?=C3=A1n=C3=AD=20slovn=C3=ADk=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Hodí se při inicializaci FlaskForm() z více zdrojů.
---
 mo/util.py | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/mo/util.py b/mo/util.py
index fa6994f7..1e2b4c69 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
@@ -288,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
-- 
GitLab


From 114eab6cbcbf663496ffca58d7f4b3eb5757a0c1 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 19:20:43 +0100
Subject: [PATCH 051/103] =?UTF-8?q?DB:=20Nastaven=C3=AD=20sazby=20diplom?=
 =?UTF-8?q?=C5=AF=20schovan=C3=A9=20do=20JSONov=C3=BDch=20design=5Fparams?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Je značně otravné muset při každém přidaném parametru měnit DB schéma.
Navíc se zpětná kompatibilita bude zařizovat daleko snáz v Pythonu
než v SQL.
---
 db/db.ddl               | 7 +------
 db/upgrade-20250117.sql | 7 +------
 mo/db.py                | 7 +------
 3 files changed, 3 insertions(+), 18 deletions(-)

diff --git a/db/db.ddl b/db/db.ddl
index 5fcb73bb..ecf0cd7b 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -511,13 +511,8 @@ 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
-	signer1_name		text		DEFAULT NULL,
-	signer1_title		text		DEFAULT NULL,
-	signer2_name		text		DEFAULT NULL,
-	signer2_title		text		DEFAULT NULL,
-	issue_place		text		DEFAULT NULL,
-	issue_date		text		DEFAULT NULL,
 	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
diff --git a/db/upgrade-20250117.sql b/db/upgrade-20250117.sql
index 3ed3bffb..b57a38de 100644
--- a/db/upgrade-20250117.sql
+++ b/db/upgrade-20250117.sql
@@ -9,13 +9,8 @@ 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
-	signer1_name		text		DEFAULT NULL,
-	signer1_title		text		DEFAULT NULL,
-	signer2_name		text		DEFAULT NULL,
-	signer2_title		text		DEFAULT NULL,
-	issue_place		text		DEFAULT NULL,
-	issue_date		text		DEFAULT NULL,
 	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
diff --git a/mo/db.py b/mo/db.py
index ee013563..7aa9b67e 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -1076,13 +1076,8 @@ class CertSet(Base):
 
     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"))
-    signer1_name = Column(Text, nullable=True)
-    signer1_title = Column(Text, nullable=True)
-    signer2_name = Column(Text, nullable=True)
-    signer2_title = Column(Text, nullable=True)
-    issue_place = Column(Text, nullable=True)
-    issue_date = Column(Text, nullable=True)
     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'))
-- 
GitLab


From af69ff92dad96994dbac8cfd62cadc514e1aaf4b Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 19:21:55 +0100
Subject: [PATCH 052/103] =?UTF-8?q?Diplomy/sazba:=20P=C5=99echod=20na=20De?=
 =?UTF-8?q?signParams?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/jobs/certs.py | 63 ++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 56 insertions(+), 7 deletions(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index cebde1f2..cf941a87 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -1,6 +1,7 @@
 # Implementace jobů na práci s diplomy
 
-from dataclasses import dataclass
+from dataclasses import dataclass, asdict
+from enum import auto
 import os
 from sqlalchemy import and_
 from sqlalchemy.orm import joinedload
@@ -32,6 +33,51 @@ def schedule_create_certs(contest: db.Contest, for_user: db.User) -> int:
     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
+
+    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)
+        return p
+
+
 @dataclass
 class Cert:
     user: db.User
@@ -50,6 +96,7 @@ class CertMaker:
     round: db.Round
     place: db.Place
     cset: db.CertSet
+    design_params: DesignParams
     scoretable: Optional[db.ScoreTable]
 
     certs: List[Cert]
@@ -82,6 +129,7 @@ class CertMaker:
 
         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 = {}
@@ -166,15 +214,16 @@ class CertMaker:
                 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,
-                'signerAname': self.cset.signer1_name,
-                'signerAtitle': self.cset.signer1_title,
-                'signerBname': self.cset.signer2_name,
-                'signerBtitle': self.cset.signer2_title,
-                'issueplace': self.cset.issue_place,
-                'issuedate': self.cset.issue_date,
+                'signerAname': dparams.signer1_name,
+                'signerAtitle': dparams.signer1_title,
+                'signerBname': dparams.signer2_name,
+                'signerBtitle': dparams.signer2_title,
+                'issueplace': dparams.issue_place,
+                'issuedate': dparams.issue_date,
                 'background': "background.pdf" if self.cset.background_file else "",
             }
             if self.round.round_type in (db.RoundType.okresni, db.RoundType.krajske):
-- 
GitLab


From a1a550b3644d33416fbd0fbb385a3385ff4ebb82 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 19:22:11 +0100
Subject: [PATCH 053/103] =?UTF-8?q?Diplomy/UI:=20P=C5=99echod=20na=20Desig?=
 =?UTF-8?q?nParams?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py | 13 ++++++++-----
 1 file changed, 8 insertions(+), 5 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index f0379155..34b94b34 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -18,11 +18,11 @@ import wtforms
 import mo
 import mo.db as db
 import mo.email
-import mo.jobs.certs
+from mo.jobs.certs import schedule_create_certs, DesignParams
 from mo.rights import Right
 import mo.submit
 import mo.util
-from mo.util import logger
+from mo.util import logger, merge_objects
 from mo.web import app
 from mo.web.org_contest import get_context
 import mo.web.fields as mo_fields
@@ -125,13 +125,14 @@ def org_certificates(ct_id: int):
         new_cset = True
         cset = db.CertSet(
             contest_id=ct_id,
-            issue_date=mo.now.strftime('%d. %B %Y'),
         )
+        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=cset)
+        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
@@ -156,6 +157,8 @@ def org_certificates(ct_id: int):
             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 form.upload_background.data:
                 old_background = cset.background_file
                 out_dir = os.path.join(ctx.master_round.round_code_short(), str(contest.contest_id))
@@ -197,7 +200,7 @@ def org_certificates(ct_id: int):
                 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 = mo.jobs.certs.schedule_create_certs(contest, g.user)
+                    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:
-- 
GitLab


From dd3a317791581779e06e622c59b2727584c80e44 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 20 Jan 2025 21:54:14 +0100
Subject: [PATCH 054/103] =?UTF-8?q?Diplomy/UI:=20=C4=8C=C3=A1st=20ovl?=
 =?UTF-8?q?=C3=A1dac=C3=ADch=20prvk=C5=AF=20se=20schov=C3=A1v=C3=A1=20+=20?=
 =?UTF-8?q?nov=C3=BD=20upload=20obr=C3=A1zku?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py                    | 43 +++++++++-------
 mo/web/templates/org_certificates.html | 71 +++++++++++++++++---------
 static/mo.css                          | 28 ++++++++++
 3 files changed, 100 insertions(+), 42 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 34b94b34..05d0205b 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -18,7 +18,7 @@ import wtforms
 import mo
 import mo.db as db
 import mo.email
-from mo.jobs.certs import schedule_create_certs, DesignParams
+from mo.jobs.certs import schedule_create_certs, DesignParams, BackgroundType
 from mo.rights import Right
 import mo.submit
 import mo.util
@@ -93,11 +93,10 @@ class CertSetForm(FlaskForm):
     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'})
-    upload_background = flask_wtf.file.FileField("Obrázek na pozadí",
-                                                 validators=[validate_background],
+    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.")
-    delete_background = wtforms.BooleanField("Smazat obrázek")
     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í")
@@ -138,12 +137,17 @@ def org_certificates(ct_id: int):
             del form.tex_hacks
         if new_cset:
             del form.delete
-        if not cset.background_file:
-            del form.delete_background
     else:
         form = None
 
+    form_ok = False
     if form and form.validate_on_submit():
+        if form.background_type.data == BackgroundType.custom and form.upload_background.data is None and not cset.background_file:
+            form.upload_background.errors.append('Nahrajte obrázek na pozadí.')     # FIXME: typing
+        else:
+            form_ok = True
+
+    if form and form_ok:
         if not new_cset and form.delete.data:
             sess.delete(cset)
             mo.util.log(
@@ -159,21 +163,22 @@ def org_certificates(ct_id: int):
             form.populate_obj(cset)
             form.populate_obj(dparams)
             cset.design_params = dparams.to_json()
-            if form.upload_background.data:
-                old_background = cset.background_file
-                out_dir = os.path.join(ctx.master_round.round_code_short(), str(contest.contest_id))
-                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}')
-            elif 'delete_background' in form and form.delete_background.data:
+            if dparams.background_type == BackgroundType.custom:
+                if form.upload_background.data:
+                    old_background = cset.background_file
+                    out_dir = os.path.join(ctx.master_round.round_code_short(), str(contest.contest_id))
+                    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
-            else:
-                old_background = None
             changes = None
             if new_cset:
                 sess.add(cset)
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index 417d7c6b..6c8f345a 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -3,6 +3,21 @@
 {% 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 %}
@@ -102,42 +117,52 @@
 {% endif %}
 
 {% macro field(f) %}
-{{ wtf.form_field(f, form_type='horizontal', horizontal_columns=('lg', 3, 7)) }}
+{{ 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">
-		<label class="control-label col-lg-3" for="upload_background">Obrázek na pozadí</label>
-		<div class="col-lg-7">
-			{{ form.upload_background() }}
+
+	<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.</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 class="col-lg-offset-3 col-lg-7">
-			{% if form.delete_background %}
-				<div class="checkbox">
-					<label>
-						{{ form.delete_background() }} {{ form.delete_background.label }}
-					</label>
-				</div>
-				<p class="help-block">
-					Obrázek na pozadí je nahraný. Můžete ho smazat, nebo nahradit novým.
-				</p>
-			{% 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.
-				</p>
+	</div>
+
+	</div>
+
+	{# Detailní nastavení vzhledu #}
+	<div class="collapsible"><input type="checkbox" class="toggle" id="design-params-toggle">
+		<label for="design-params-toggle" class="toggle toggle-small">Detaily vzhledu</label>
+		<div class="collapsible-inner"><div class="form-horiz-frame">
+			{% if 'tex_hacks' in form %}
+			{{ field(form.tex_hacks) }}
 			{% endif %}
-		</div>
+		</div></div>
 	</div>
-	{% if 'tex_hacks' in form %}
-	{{ field(form.tex_hacks) }}
-	{% endif %}
+
 	<div class="btn-group col-lg-offset-3">
 		{{ wtf.form_field(form.generate, class="btn btn-primary") }}
 		{{ wtf.form_field(form.save) }}
diff --git a/static/mo.css b/static/mo.css
index 4191ca56..803a79b7 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -211,6 +211,8 @@ nav#main-menu a.active {
 	color: black;
 }
 
+/* Forms */
+
 .form-group.required .control-label:after {
 	content:"*";
 	color:red;
@@ -222,6 +224,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;
 }
-- 
GitLab


From 5a6d948ff89b0d5968faf64dfc55671f29d5ef18 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 11:47:30 +0100
Subject: [PATCH 055/103] =?UTF-8?q?util=5Ftex:=20=C3=9Aklid?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/util_tex.py | 21 +++++++++++++--------
 1 file changed, 13 insertions(+), 8 deletions(-)

diff --git a/mo/util_tex.py b/mo/util_tex.py
index 0ccfc22b..a4562e5d 100644
--- a/mo/util_tex.py
+++ b/mo/util_tex.py
@@ -10,8 +10,10 @@ 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.
+    """
+        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:
@@ -22,18 +24,21 @@ def tex_arg(s: Any) -> str:
     return '{' + s + '}'
 
 
+
 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.
+    """
+        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():
+    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]:
+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):
-- 
GitLab


From eeaba908a9257662c5147f8f9de18f8d026892b1 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 14:43:22 +0100
Subject: [PATCH 056/103] =?UTF-8?q?DB:=20CertSet=20m=C3=A1=20metodu=20dir?=
 =?UTF-8?q?=5Fpath()?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Zatím se cesta všude konstruovala manuálně.
---
 mo/db.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/mo/db.py b/mo/db.py
index 7aa9b67e..7fc3a4ce 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -1090,6 +1090,10 @@ class CertSet(Base):
     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()
-- 
GitLab


From 347690af442a9829aca672d8b0f51e8a25d8b8ff Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 14:44:43 +0100
Subject: [PATCH 057/103] =?UTF-8?q?json=5Fwalker:=20P=C5=99id=C3=A1na=20po?=
 =?UTF-8?q?dpora=20pro=20floaty?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/ext/json_walker.py | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/mo/ext/json_walker.py b/mo/ext/json_walker.py
index 6a25e1b4..43eeec7c 100644
--- a/mo/ext/json_walker.py
+++ b/mo/ext/json_walker.py
@@ -38,6 +38,9 @@ class Walker:
     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)
 
@@ -82,6 +85,12 @@ class Walker:
     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)
 
@@ -99,6 +108,12 @@ class Walker:
     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')
 
-- 
GitLab


From d4a66b2c7a787a053c56699a5fcfa4231a433428 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 14:45:47 +0100
Subject: [PATCH 058/103] =?UTF-8?q?util=5Ftex:=20Jednoduch=C3=A1=20transfo?=
 =?UTF-8?q?rmace=20kli=C4=8D=C5=AF/atribut=C5=AF=20na=20jm=C3=A9na=20TeXov?=
 =?UTF-8?q?=C3=BDch=20maker?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/util_tex.py | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/mo/util_tex.py b/mo/util_tex.py
index a4562e5d..2dd54c56 100644
--- a/mo/util_tex.py
+++ b/mo/util_tex.py
@@ -24,6 +24,16 @@ def tex_arg(s: Any) -> str:
     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:
     """
-- 
GitLab


From e7a3f22b374681d7a722638dc0f9a83e44cb9542 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 14:47:20 +0100
Subject: [PATCH 059/103] Diplomy/sazba: Zbytek DesignParams

---
 mo/jobs/certs.py       | 48 ++++++++++++++++++++++++++++++------------
 mo/tex/certifikaty.tex | 24 +++++++++++++++------
 2 files changed, 52 insertions(+), 20 deletions(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index cf941a87..db99e26e 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -1,6 +1,6 @@
 # Implementace jobů na práci s diplomy
 
-from dataclasses import dataclass, asdict
+from dataclasses import dataclass, asdict, fields
 from enum import auto
 import os
 from sqlalchemy import and_
@@ -16,7 +16,7 @@ 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, run_tex, format_hacks, QREncoder
+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:
@@ -58,6 +58,14 @@ class DesignParams:
     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)
@@ -75,6 +83,9 @@ class DesignParams:
             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
 
 
@@ -133,11 +144,11 @@ class CertMaker:
 
         self.certs = []
         self.out_files = {}
-        self.qr_encoder = QREncoder(f'{self.job.dir_path()}/qr')
 
         self.certs_dir = mo.util.data_dir('certs')
-        self.out_dir = os.path.join(self.round.round_code_short(), str(self.contest.contest_id))
+        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()
@@ -188,10 +199,17 @@ class CertMaker:
                 add_cert(db.CertType.honorary_mention, 'za úplné vyřešení úlohy', user.sort_key())
 
     def prepare_files(self) -> None:
-        if self.cset.background_file:
+        if self.design_params.background_type == BackgroundType.custom:
+            b = os.path.join(self.certs_dir, self.cset.background_file)
+        elif self.design_params.background_type == BackgroundType.standard:
+            b = os.path.join(mo.util.part_path('tex'), 'cert-background.pdf')
+        else:
+            b = None
+
+        if b:
             background = os.path.join(self.job_dir, 'background.pdf')
             mo.util.unlink_if_exists(background)
-            os.link(os.path.join(self.certs_dir, self.cset.background_file), background)
+            os.link(b, background)
 
     def make_certs(self, cert_type: db.CertType) -> None:
         certs = [cert for cert in self.certs if cert.type == cert_type]
@@ -218,14 +236,18 @@ class CertMaker:
             ga = {
                 'kolo': db.round_type_names_local[self.round.round_type],
                 'kat': self.round.category,
-                'signerAname': dparams.signer1_name,
-                'signerAtitle': dparams.signer1_title,
-                'signerBname': dparams.signer2_name,
-                'signerBtitle': dparams.signer2_title,
-                'issueplace': dparams.issue_place,
-                'issuedate': dparams.issue_date,
-                'background': "background.pdf" if self.cset.background_file else "",
+                '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)
diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
index bef1add4..78c87517 100644
--- a/mo/tex/certifikaty.tex
+++ b/mo/tex/certifikaty.tex
@@ -35,6 +35,14 @@
 \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{???}
@@ -51,24 +59,24 @@
 		\smash{\vhang{\putimage{width \hsize}{\background}}}
 	\fi
 	\vglue 10pt
-	\vfill
+	\vskip\spaceA
 	\head
-	\vfill
+	\vskip\spaceB
 	\centerline{\fontsize{36}\bf\jmeno}
 	\vskip 5mm
 	\centerline{\fontsize{16}\skola}
-	\vfill
+	\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 kategorie \kat}
-	\vfill
-	\vfill
+	\vskip\spaceD
 	\signatures
-	\vskip 15mm
+	\vskip\spaceE
 	\bottomline
+	\vskip\spaceF
 	\vskip 15mm
 	\eject
 }
@@ -105,7 +113,9 @@
 		\hskip 15mm
 		\rlap{\vbox{\datebox}}%
 		\hfil
-		\hbox{\copy\mologobox\hskip 10mm\copy\jcmflogobox}%
+		\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
-- 
GitLab


From bb4a993968716cd7c3554eceff52b35c85d1dbeb Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 14:47:50 +0100
Subject: [PATCH 060/103] Diplomy/UI: Zbytek DesignParams

---
 mo/web/org_certs.py                    | 52 ++++++++++++++++++++------
 mo/web/templates/org_certificates.html | 19 +++++++++-
 2 files changed, 59 insertions(+), 12 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 05d0205b..9dfbfdc2 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -14,6 +14,7 @@ from tempfile import NamedTemporaryFile
 from typing import Tuple, Optional, Dict
 import werkzeug.exceptions
 import wtforms
+from wtforms import validators
 
 import mo
 import mo.db as db
@@ -73,7 +74,7 @@ def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Op
     return send_file(open(tmp_file.name, 'rb'), mimetype='application/pdf')
 
 
-def validate_background(form, field):
+def validate_background(form: 'CertSetForm', field: wtforms.Field) -> None:
     if not field.data:
         return
 
@@ -86,6 +87,17 @@ def validate_background(form, field):
         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'})
@@ -97,11 +109,31 @@ class CertSetForm(FlaskForm):
     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
+
 
 @app.route('/org/contest/c/<int:ct_id>/certificates', methods=('GET', 'POST'))
 def org_certificates(ct_id: int):
@@ -140,15 +172,12 @@ def org_certificates(ct_id: int):
     else:
         form = None
 
-    form_ok = False
-    if form and form.validate_on_submit():
-        if form.background_type.data == BackgroundType.custom and form.upload_background.data is None and not cset.background_file:
-            form.upload_background.errors.append('Nahrajte obrázek na pozadí.')     # FIXME: typing
-        else:
-            form_ok = True
-
-    if form and form_ok:
-        if not new_cset and form.delete.data:
+    if 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,
@@ -166,7 +195,7 @@ def org_certificates(ct_id: int):
             if dparams.background_type == BackgroundType.custom:
                 if form.upload_background.data:
                     old_background = cset.background_file
-                    out_dir = os.path.join(ctx.master_round.round_code_short(), str(contest.contest_id))
+                    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'),
@@ -238,6 +267,7 @@ def org_certificates(ct_id: int):
         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(),
     )
 
 
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index 6c8f345a..dd44b081 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -154,9 +154,26 @@
 	</div>
 
 	{# Detailní nastavení vzhledu #}
-	<div class="collapsible"><input type="checkbox" class="toggle" id="design-params-toggle">
+	<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 %}
-- 
GitLab


From 003a892ccc8580474b47e20609946d6eaaf63e73 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 15:36:30 +0100
Subject: [PATCH 061/103] =?UTF-8?q?Diplomy/sazba:=20Lep=C5=A1=C3=AD=20zaro?=
 =?UTF-8?q?vn=C3=A1n=C3=AD=20podpis=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/tex/certifikaty.tex | 19 +++++++++++++++++--
 1 file changed, 17 insertions(+), 2 deletions(-)

diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
index 78c87517..7306e135 100644
--- a/mo/tex/certifikaty.tex
+++ b/mo/tex/certifikaty.tex
@@ -84,6 +84,20 @@
 \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}
@@ -99,14 +113,15 @@
 			\sign{\signerBname}{\signerBtitle}
 			\hss
 		}
+		\fi
 	\fi
 }
 
-\def\sign#1#2{\hlap{\vtop{\halign{%
+\def\sign#1#2{\vtop{\halign{%
 	\hfil\fontsize{12}##\hfil\cr
 	#1\cr\noalign{\vskip 3pt}
 	#2\cr
-}}}}
+}}}
 
 \def\bottomline{
 	\line{
-- 
GitLab


From b2e60e8ad8433e80cdae316603470118bce88f3b Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 15:45:29 +0100
Subject: [PATCH 062/103] =?UTF-8?q?Diplomy/UI:=20Odkaz=20na=20sta=C5=BEen?=
 =?UTF-8?q?=C3=AD=20log?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_certificates.html | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index dd44b081..9c4ff491 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -141,7 +141,10 @@
 						<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.</p>
+					<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>
-- 
GitLab


From fafbf75eda61116956dacf4449e322f2a8af849a Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 21 Jan 2025 16:12:41 +0100
Subject: [PATCH 063/103] =?UTF-8?q?Diplomy/UI:=20=C3=9Avodn=C3=AD=20odstav?=
 =?UTF-8?q?ec?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_certificates.html | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index 9c4ff491..0933f23f 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -27,7 +27,13 @@
 
 {% block body %}
 
-<p>TODO: Úvodní text
+<p>
+Zde je možné vytvořit účastnické listy pro všechny soutěžící,
+po vytvoření oficiální výsledkové listiny také diplomy úspěšných řešitelů
+a pochvalná uznání (pokud se v tomto kole vydávají).
+Hotové diplomy si můžete stáhnout a vytisknout.
+Po uzavření kola budou také dostupné soutěžícím v OSMO.
+</p>
 
 <h3>Diplomy</h3>
 
-- 
GitLab


From e78e13ecc022b2f57e0174a4816b87fe5a895b83 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 07:53:09 +0100
Subject: [PATCH 064/103] =?UTF-8?q?Users:=20P=C5=99=C3=AD=20maz=C3=A1n?=
 =?UTF-8?q?=C3=AD=20u=C5=BEivatele=20kontrolujeme=20vlastn=C4=9Bn=C3=A9=20?=
 =?UTF-8?q?diplomy?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_users.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index be60c783..0066ba8f 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
-- 
GitLab


From 61330fc2a89eea7cd048332487c45f4a96422fe3 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 07:58:41 +0100
Subject: [PATCH 065/103] =?UTF-8?q?DB:=20Index=20na=20hled=C3=A1n=C3=AD=20?=
 =?UTF-8?q?diplom=C5=AF=20podle=20u=C5=BEivatele?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Index k primárnímu klíči vyžaduje znát i certificate_set.
---
 db/db.ddl               | 2 ++
 db/upgrade-20250117.sql | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/db/db.ddl b/db/db.ddl
index ecf0cd7b..a996106c 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -544,6 +544,8 @@ CREATE TABLE certificates (
 	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
index b57a38de..03a9bf07 100644
--- a/db/upgrade-20250117.sql
+++ b/db/upgrade-20250117.sql
@@ -41,3 +41,5 @@ CREATE TABLE certificates (
 	page_number	int		NOT NULL,
 	UNIQUE (cert_set_id, user_id, type)
 );
+
+CREATE INDEX certificates_user_id_index ON certificates (user_id);
-- 
GitLab


From 978ad88a26ee4e3048c9fa532283c4882c1992b0 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 07:59:16 +0100
Subject: [PATCH 066/103] =?UTF-8?q?DB:=20P=C5=99id=C3=A1ny=20relationships?=
 =?UTF-8?q?=20pro=20Certificate?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/mo/db.py b/mo/db.py
index 7fc3a4ce..0be51cbc 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -1156,6 +1156,9 @@ class Certificate(Base):
     achievement = Column(Text, nullable=False, server_default=text("''::text"))
     page_number = Column(Integer, nullable=False)
 
+    cert_set = relationship('CertSet')
+    user = relationship('User')
+
 
 class SentEmail(Base):
     __tablename__ = 'sent_email'
-- 
GitLab


From 75d637b4c44705cfeefdc9372e260a38a9098f0e Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 08:05:32 +0100
Subject: [PATCH 067/103] =?UTF-8?q?Pr=C5=AFvodce:=20Vyd=C3=A1v=C3=A1n?=
 =?UTF-8?q?=C3=AD=20diplom=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/parts/org_contest_guide.html | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/mo/web/templates/parts/org_contest_guide.html b/mo/web/templates/parts/org_contest_guide.html
index 8a0f2c74..81f65b78 100644
--- a/mo/web/templates/parts/org_contest_guide.html
+++ b/mo/web/templates/parts/org_contest_guide.html
@@ -95,7 +95,14 @@
 		<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>.
 
 {% elif state == RoundState.closed %}
 
-- 
GitLab


From daac456ce305f2befdd623ed407993f85febdf04 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 08:39:46 +0100
Subject: [PATCH 068/103] =?UTF-8?q?Uzav=C3=ADr=C3=A1n=C3=AD=20sout=C4=9B?=
 =?UTF-8?q?=C5=BEe=20kontroluje=20diplomy=20pro=20neaktivn=C3=AD=20=C3=BA?=
 =?UTF-8?q?=C4=8Dastn=C3=ADky?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/contests.py | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/mo/contests.py b/mo/contests.py
index 5e40bedd..26eab85d 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
-- 
GitLab


From f9de62259cb5649706df80a12540494c4700f58e Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 08:46:18 +0100
Subject: [PATCH 069/103] =?UTF-8?q?Str=C3=A1nka=20sout=C4=9B=C5=BEe:=20Kos?=
 =?UTF-8?q?metika?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_contest.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index 73883c0b..d1b3a1c8 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>
-- 
GitLab


From 4edab5e73d9737133f809f620ab2b61bb897383f Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 11:18:40 +0100
Subject: [PATCH 070/103] logger.warn je deprecated alias pro warning

---
 mo/web/__init__.py | 2 +-
 mo/web/user.py     | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/mo/web/__init__.py b/mo/web/__init__.py
index 1f7eaa6a..0aecb5bc 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
 
 
diff --git a/mo/web/user.py b/mo/web/user.py
index 970c341d..faa5bea1 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -501,7 +501,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 +512,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:
-- 
GitLab


From fa2c5db293ee178c7fcb367b1d4eb6cb1dd09526 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 11:20:56 +0100
Subject: [PATCH 071/103] User: Cleanup

---
 mo/web/user.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/mo/web/user.py b/mo/web/user.py
index faa5bea1..b132fedb 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -572,7 +572,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', "")
 
@@ -609,7 +609,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á
-- 
GitLab


From 9e55ac0fd446b46841c1f98832962b0ee8a3ed43 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 30 Jan 2025 08:47:30 +0100
Subject: [PATCH 072/103] =?UTF-8?q?Diplomy:=20send=5Fcertificate=20p=C5=99?=
 =?UTF-8?q?esunuto=20do=20mo.web.user?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Ostatní funkce sdílené mezi orgovskou a účastnickou částí webu
sídlí v mo.web.user, tak by send_certificate nemělo být výjimkou.
---
 mo/web/org_certs.py | 56 +++------------------------------------------
 mo/web/user.py      | 49 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 52 insertions(+), 53 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 9dfbfdc2..93ed9669 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -1,18 +1,12 @@
 # Web: Certifikáty
 
-from datetime import datetime
-from flask import render_template, g, redirect, url_for
-from flask.helpers import send_file, flash
+from flask import render_template, g, redirect, url_for, flash
 from flask_wtf import FlaskForm
 import flask_wtf.file
 from markupsafe import Markup
 import os
-import pikepdf
-from pikepdf.models.metadata import encode_pdf_date
 from sqlalchemy.orm import joinedload
-from tempfile import NamedTemporaryFile
 from typing import Tuple, Optional, Dict
-import werkzeug.exceptions
 import wtforms
 from wtforms import validators
 
@@ -23,55 +17,11 @@ 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 logger, merge_objects
+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
-
-
-# Využívá se i z účastnické části webu
-def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Optional[int] = None):
-    try:
-        typ = db.CertType.coerce(cert_type)
-    except ValueError:
-        raise werkzeug.exceptions.NotFound()
-
-    if user_filename != typ.file_name() + '.pdf':
-        raise werkzeug.exceptions.NotFound()
-
-    sess = db.get_session()
-    cfile = sess.query(db.CertFile).get((ct_id, typ))
-    if cfile is None:
-        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')
+from mo.web.user import send_certificate
 
 
 def validate_background(form: 'CertSetForm', field: wtforms.Field) -> None:
diff --git a/mo/web/user.py b/mo/web/user.py
index b132fedb..000099e7 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
+import os
+import pikepdf
+from pikepdf.models.metadata import encode_pdf_date
 from sqlalchemy import and_
 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
@@ -620,6 +626,49 @@ 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):
+    try:
+        typ = db.CertType.coerce(cert_type)
+    except ValueError:
+        raise werkzeug.exceptions.NotFound()
+
+    if user_filename != typ.file_name() + '.pdf':
+        raise werkzeug.exceptions.NotFound()
+
+    sess = db.get_session()
+    cfile = sess.query(db.CertFile).get((ct_id, typ))
+    if cfile is None:
+        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/history/')
 def user_history() -> str:
     sess = db.get_session()
-- 
GitLab


From fb9916a93df3d0c825830fa0a954e231dc9a30f0 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 11:32:00 +0100
Subject: [PATCH 073/103] User: Oprava typu scoretable_construct

---
 mo/web/user.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/user.py b/mo/web/user.py
index 000099e7..583f44dc 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -527,7 +527,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.
     """
-- 
GitLab


From ffb01f09cba2eeb0fbfdda6f6c3c6b956578557e Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 30 Jan 2025 08:47:54 +0100
Subject: [PATCH 074/103] =?UTF-8?q?User:=20Zobrazov=C3=A1n=C3=AD=20diplom?=
 =?UTF-8?q?=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/user_contest_certs.html | 42 ++++++++++++++++++++++++
 mo/web/user.py                           | 32 ++++++++++++++++++
 2 files changed, 74 insertions(+)
 create mode 100644 mo/web/templates/user_contest_certs.html

diff --git a/mo/web/templates/user_contest_certs.html b/mo/web/templates/user_contest_certs.html
new file mode 100644
index 00000000..f9f8e419
--- /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() + '.pdf') }}'>Zobrazit</a>
+			{% endfor %}
+	</table>
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/user.py b/mo/web/user.py
index 583f44dc..5cda2dbf 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -669,6 +669,38 @@ def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Op
         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)
+             .filter_by(cert_set_id=id, user=g.user)
+             .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)
+
+
 @app.route('/user/history/')
 def user_history() -> str:
     sess = db.get_session()
-- 
GitLab


From c186eb7936ba8f67191aa126eef82f925874a3e9 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 12:08:20 +0100
Subject: [PATCH 075/103] =?UTF-8?q?User:=20Odkazy=20na=20diplomy=20ze=20st?=
 =?UTF-8?q?r=C3=A1nky=20sout=C4=9B=C5=BEe?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/user_contest.html | 13 +++++++++++--
 mo/web/user.py                     |  9 +++++++++
 2 files changed, 20 insertions(+), 2 deletions(-)

diff --git a/mo/web/templates/user_contest.html b/mo/web/templates/user_contest.html
index 0a38bd50..f5579458 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/user.py b/mo/web/user.py
index 5cda2dbf..129783f7 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -333,12 +333,21 @@ def user_contest(id: int):
                  .order_by(db.Task.code)
                  .all())
 
+    if contest.state == db.RoundState.closed:
+        certs = (sess.query(db.Certificate)
+                 .filter_by(cert_set_id=id, user=g.user)
+                 .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,
     )
 
-- 
GitLab


From 1b68d7328263427f92d306decb8e11202a329514 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 12:08:37 +0100
Subject: [PATCH 076/103] =?UTF-8?q?User:=20Odkazy=20na=20diplomy=20z=20?=
 =?UTF-8?q?=C3=BA=C4=8Dastnick=C3=A9=20hlavn=C3=AD=20str=C3=A1nky?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/user_index.html |  5 ++++-
 mo/web/user.py                   | 12 +++++++++---
 2 files changed, 13 insertions(+), 4 deletions(-)

diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html
index 46d89241..e275cbd8 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 129783f7..a3f1ca11 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -12,7 +12,7 @@ import hmac
 import os
 import pikepdf
 from pikepdf.models.metadata import encode_pdf_date
-from sqlalchemy import and_
+from sqlalchemy import and_, func
 from sqlalchemy.dialects.postgresql import insert as pgsql_insert
 from sqlalchemy.orm import joinedload
 from tempfile import NamedTemporaryFile
@@ -50,11 +50,17 @@ 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'))
+                 .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))
-- 
GitLab


From 5fb038ea54eb16b512bffe73ae7e1d75590f1579 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 16:32:04 +0100
Subject: [PATCH 077/103] =?UTF-8?q?Diplomy:=20Hez=C4=8D=C3=AD=20jm=C3=A9na?=
 =?UTF-8?q?=20soubor=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py                                 | 10 +++++-----
 mo/jobs/certs.py                         |  6 +++---
 mo/web/templates/org_certificates.html   |  4 ++--
 mo/web/templates/user_contest_certs.html |  2 +-
 mo/web/user.py                           |  2 +-
 5 files changed, 12 insertions(+), 12 deletions(-)

diff --git a/mo/db.py b/mo/db.py
index 0be51cbc..cfab4717 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -1103,8 +1103,8 @@ class CertType(MOEnum):
     def friendly_name(self) -> str:
         return cert_type_names[self]
 
-    def file_name(self) -> str:
-        return cert_type_file_names[self]
+    def file_name(self, plural: bool) -> str:
+        return cert_type_file_names[self][int(plural)]
 
     def short_code(self) -> str:
         return cert_type_short_codes[self]
@@ -1122,9 +1122,9 @@ cert_type_names = {
 
 
 cert_type_file_names = {
-    CertType.participation: 'ucastnici',
-    CertType.successful: 'diplomy',
-    CertType.honorary_mention: 'pochvalne',
+    CertType.participation: ('ucastnicky-list', 'ucastnicke-listy'),
+    CertType.successful: ('diplom', 'diplomy'),
+    CertType.honorary_mention: ('pochvalne-uznani', 'pochvalna-uznani'),
 }
 
 
diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index db99e26e..e7e1eb7e 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -216,8 +216,8 @@ class CertMaker:
         if not certs:
             return
 
-        name = cert_type.file_name()
-        logger.debug(f'{self.the_job.log_prefix} Vytvářím certifikáty typu {name} v {self.job_dir} ({len(certs)} listů)')
+        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)
@@ -299,7 +299,7 @@ class CertMaker:
 
         # 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()}-', suffix='.pdf', make_dirs=True)
+            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,
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index 0933f23f..d2544853 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -66,7 +66,7 @@ Po uzavření kola budou také dostupné soutěžícím v OSMO.
 			{% for t in CertType %}
 				{% 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() + '.pdf') }}">{{ cert.achievement }}</a>
+					<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 %}
@@ -78,7 +78,7 @@ Po uzavření kola budou také dostupné soutěžícím v OSMO.
 			{% for t in CertType %}
 				{% set cfile = cert_files_by_type[t] %}
 				{% if cfile %}
-					<th class=ac><a href="{{ ctx.url_for('org_cert_file', cert_type=t.name, filename=t.file_name() + '.pdf') }}">stáhnout</a>
+					<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 %}
diff --git a/mo/web/templates/user_contest_certs.html b/mo/web/templates/user_contest_certs.html
index f9f8e419..ae38a5e2 100644
--- a/mo/web/templates/user_contest_certs.html
+++ b/mo/web/templates/user_contest_certs.html
@@ -34,7 +34,7 @@
 			<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() + '.pdf') }}'>Zobrazit</a>
+				<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 %}
diff --git a/mo/web/user.py b/mo/web/user.py
index a3f1ca11..1e4615b8 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -648,7 +648,7 @@ def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Op
     except ValueError:
         raise werkzeug.exceptions.NotFound()
 
-    if user_filename != typ.file_name() + '.pdf':
+    if user_filename != typ.file_name(user_id is None) + '.pdf':
         raise werkzeug.exceptions.NotFound()
 
     sess = db.get_session()
-- 
GitLab


From acaf2d0d26df336beff01b87f3473db36f97c694 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 17:09:12 +0100
Subject: [PATCH 078/103] =?UTF-8?q?Diplomy/sazba:=20Hled=C3=A1n=C3=AD=20st?=
 =?UTF-8?q?andardn=C3=ADho=20pozad=C3=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/jobs/certs.py | 30 ++++++++++++++++++++++--------
 1 file changed, 22 insertions(+), 8 deletions(-)

diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index e7e1eb7e..4ef4b106 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -199,18 +199,32 @@ class CertMaker:
                 add_cert(db.CertType.honorary_mention, 'za úplné vyřešení úlohy', user.sort_key())
 
     def prepare_files(self) -> None:
-        if self.design_params.background_type == BackgroundType.custom:
-            b = os.path.join(self.certs_dir, self.cset.background_file)
-        elif self.design_params.background_type == BackgroundType.standard:
-            b = os.path.join(mo.util.part_path('tex'), 'cert-background.pdf')
-        else:
-            b = None
-
-        if b:
+        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:
-- 
GitLab


From 58b3dc07ce55a71a660583494f818707dee13349 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 18:41:59 +0100
Subject: [PATCH 079/103] init-data-dir: certs

---
 bin/init-data-dir | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/bin/init-data-dir b/bin/init-data-dir
index cb05808f..e019f1f3 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}
-- 
GitLab


From fc473bc49f6aff879595384209ca1e12b9c9ad44 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 18:42:16 +0100
Subject: [PATCH 080/103] =?UTF-8?q?README:=20Nastaven=C3=AD=20defaultn?=
 =?UTF-8?q?=C3=ADho=20pozad=C3=AD=20diplom=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 README.md | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/README.md b/README.md
index e7e5f833..e56e9efb 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
 
-- 
GitLab


From 3b99f8a4375f08c6a10e868c49855ddb987b21f7 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 22 Jan 2025 19:26:18 +0100
Subject: [PATCH 081/103] =?UTF-8?q?Z=20ulo=C5=BEen=C3=BDch=20v=C3=BDsledko?=
 =?UTF-8?q?vek=20vedou=20odkazy=20na=20diplomy?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_score.py                       |  7 ++++++-
 mo/web/templates/org_score_snapshot.html  | 23 +++++++++++++++++------
 mo/web/templates/org_score_snapshots.html | 20 +++++++++++++++++---
 3 files changed, 40 insertions(+), 10 deletions(-)

diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index 33e4dcb1..60bb1c97 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -303,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
@@ -359,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,
     )
 
 
@@ -395,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íč'))
 
@@ -414,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/templates/org_score_snapshot.html b/mo/web/templates/org_score_snapshot.html
index ce2e23b9..8cfdbadc 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,30 +23,40 @@
 {% 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 }}
diff --git a/mo/web/templates/org_score_snapshots.html b/mo/web/templates/org_score_snapshots.html
index dcc53e8f..b7991dea 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 %}
-- 
GitLab


From 2eb94187313b74505f3e3e269d65a5007a78ac22 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 23 Jan 2025 15:20:26 +0100
Subject: [PATCH 082/103] =?UTF-8?q?Diplomy/sazba:=20Olympi=C3=A1da=20m?=
 =?UTF-8?q?=C3=A1=20kategorie,=20nikoliv=20kategorie=20olympi=C3=A1du=20:)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/tex/certifikaty.tex | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
index 7306e135..0a1ff58a 100644
--- a/mo/tex/certifikaty.tex
+++ b/mo/tex/certifikaty.tex
@@ -71,7 +71,7 @@
 	\centerline{\fontsize{16}\kolo}
 	% \ifx\oblast\empty\else~(\oblast)\fi
 	\vskip 5mm
-	\centerline{\fontsize{16}Matematické olympiády kategorie \kat}
+	\centerline{\fontsize{16}Matematické olympiády v~kategorii \kat}
 	\vskip\spaceD
 	\signatures
 	\vskip\spaceE
-- 
GitLab


From 8b7de5e8607443cc7fe5a91bed5b8579dbfc8e9b Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 23 Jan 2025 15:24:52 +0100
Subject: [PATCH 083/103] =?UTF-8?q?DB:=20Konverze=20p=C3=ADsmenka=20na=20t?=
 =?UTF-8?q?yp=20kola?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/mo/db.py b/mo/db.py
index cfab4717..8a2d3f1f 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',
@@ -265,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()
-- 
GitLab


From a8dbdc36455c8b0ce6cdc10c61eecf14ef121832 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 23 Jan 2025 15:39:54 +0100
Subject: [PATCH 084/103] =?UTF-8?q?Diplomy:=20Trivi=C3=A1ln=C3=AD=20implem?=
 =?UTF-8?q?entace=20endpointu=20na=20ov=C4=9B=C5=99ov=C3=A1n=C3=AD=20diplo?=
 =?UTF-8?q?mu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Zatím jenom přesměrovává na web MO / MO-P, kde jsou výsledkové listiny.
---
 mo/web/misc.py      | 16 ++++++++++++++++
 mo/web/org_certs.py |  6 ------
 2 files changed, 16 insertions(+), 6 deletions(-)

diff --git a/mo/web/misc.py b/mo/web/misc.py
index 03df23b1..85adaef4 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_certs.py b/mo/web/org_certs.py
index 93ed9669..d3110a82 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -228,9 +228,3 @@ def org_cert_file(ct_id: int, cert_type: str, filename: str, user_id: Optional[i
     assert ctx.contest and ctx.master_contest
 
     return send_certificate(ct_id, cert_type, filename, user_id)
-
-
-# URL je explicitně uvedeno v mo.jobs.certs.Cert._make_qr_url
-@app.route('/cc/<int:year>/<cat>/<round_type>/<place>/<cert_type_short>/<int:user_id>/<time_code>')
-def cert_check(year: int, cat: str, round_type: str, place: str, cert_type_short: str, user_id: int, time_code: str):
-    return "Not implemented yet."
-- 
GitLab


From b76d10e13cbae83b4f9e5e2cafaf2e841fdc3a52 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 23 Jan 2025 15:58:17 +0100
Subject: [PATCH 085/103] =?UTF-8?q?Diplomy:=20Rozli=C5=A1ujeme=20v=C3=ADt?=
 =?UTF-8?q?=C4=9Bze=20od=20ostatn=C3=ADch=20=C3=BAsp=C4=9B=C5=A1n=C3=BDch?=
 =?UTF-8?q?=20=C5=99e=C5=A1itel=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py                               |  2 +-
 mo/jobs/certs.py                       | 19 ++++++++++++-------
 mo/tex/certifikaty.tex                 |  7 ++++++-
 mo/web/templates/org_certificates.html |  2 +-
 4 files changed, 20 insertions(+), 10 deletions(-)

diff --git a/mo/db.py b/mo/db.py
index 8a2d3f1f..0013f7c4 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -1123,7 +1123,7 @@ class CertType(MOEnum):
 
 cert_type_names = {
     CertType.participation: 'účastnický list',
-    CertType.successful: 'diplom úspěšného řešitele',
+    CertType.successful: 'diplom vítěze / úspěšného řešitele',
     CertType.honorary_mention: 'pochvalné uznání',
 }
 
diff --git a/mo/jobs/certs.py b/mo/jobs/certs.py
index 4ef4b106..53c77c51 100644
--- a/mo/jobs/certs.py
+++ b/mo/jobs/certs.py
@@ -94,6 +94,7 @@ class Cert:
     user: db.User
     school: db.Place
     type: db.CertType
+    tex_macro: str
     achievement: str
     sort_key: Any
     page_number: int = -1
@@ -172,31 +173,35 @@ class CertMaker:
             user = pion.user
             row = score_rows_by_user_id.get(user.user_id)
 
-            def add_cert(type: db.CertType, achievement: str, sort_key: Any) -> None:
+            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, 'za účast', user.sort_key())
+            add_cert(db.CertType.participation, 'CertParticipation', 'za účast', user.sort_key())
 
-            # Diplom úspěšného řešitele
+            # Diplom vítěze / úspěšného řešitele
             if row is not None:
                 order = row.get('order')
-                if row['successful'] and order is not None:
+                if order is not None:
                     if order['span'] == 1:
                         place = f"{order['place']}."
                     else:
                         place = f"{order['place']}.–{order['place'] + order['span'] - 1}."
-                    add_cert(db.CertType.successful, f'za {place} místo', (order['place'], user.sort_key()))
+                    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, 'za úplné vyřešení úlohy', user.sort_key())
+                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():
@@ -278,7 +283,7 @@ class CertMaker:
                       'qrurl': qr_url,
                       'qrimg': os.path.basename(qr_file),
                 })
-                f.write('\\Cert' + cert.type.name.replace('_', '').title() + '\n')
+                f.write('\\' + cert.tex_macro + '\n')
                 f.write('}\n')
                 cert.page_number = i + 1
 
diff --git a/mo/tex/certifikaty.tex b/mo/tex/certifikaty.tex
index 0a1ff58a..4b2826af 100644
--- a/mo/tex/certifikaty.tex
+++ b/mo/tex/certifikaty.tex
@@ -152,12 +152,17 @@
 	\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\CertHonoraryMention{
 	\def\head{\centerline{\fontsize{36}\bf POCHVALNÉ UZNÁNÍ}}
 	\generic
 }
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index d2544853..aaf7408d 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -29,7 +29,7 @@
 
 <p>
 Zde je možné vytvořit účastnické listy pro všechny soutěžící,
-po vytvoření oficiální výsledkové listiny také diplomy úspěšných řešitelů
+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í).
 Hotové diplomy si můžete stáhnout a vytisknout.
 Po uzavření kola budou také dostupné soutěžícím v OSMO.
-- 
GitLab


From 1fd41280d7b19c0d355832b2f35d9832e509fb00 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 13:49:11 +0100
Subject: [PATCH 086/103] =?UTF-8?q?DB:=20Soubor=20s=20diplomy=20si=20pamat?=
 =?UTF-8?q?uje,=20zda=20je=20schv=C3=A1len?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 db/db.ddl               | 1 +
 db/upgrade-20250117.sql | 1 +
 mo/db.py                | 5 +++++
 3 files changed, 7 insertions(+)

diff --git a/db/db.ddl b/db/db.ddl
index a996106c..6634f5ea 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -532,6 +532,7 @@ 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)
 );
 
diff --git a/db/upgrade-20250117.sql b/db/upgrade-20250117.sql
index 03a9bf07..62ba3055 100644
--- a/db/upgrade-20250117.sql
+++ b/db/upgrade-20250117.sql
@@ -30,6 +30,7 @@ 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)
 );
 
diff --git a/mo/db.py b/mo/db.py
index 0013f7c4..2ea2f9cd 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -1152,6 +1152,7 @@ class CertFile(Base):
     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):
@@ -1164,6 +1165,10 @@ class Certificate(Base):
     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')
 
 
-- 
GitLab


From 76237934457fdad69aa8bfddecfd7fcb74d795bc Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 13:49:49 +0100
Subject: [PATCH 087/103] =?UTF-8?q?Diplomy/UI:=20Schvalov=C3=A1n=C3=AD=20d?=
 =?UTF-8?q?iplom=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py                    | 33 ++++++++++++++++++--
 mo/web/templates/org_certificates.html | 42 +++++++++++++++++++++-----
 2 files changed, 66 insertions(+), 9 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index d3110a82..56355140 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -7,6 +7,7 @@ from markupsafe import Markup
 import os
 from sqlalchemy.orm import joinedload
 from typing import Tuple, Optional, Dict
+import werkzeug.exceptions
 import wtforms
 from wtforms import validators
 
@@ -85,6 +86,12 @@ class CertSetForm(FlaskForm):
         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)
@@ -119,10 +126,30 @@ def org_certificates(ct_id: int):
             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')
 
-    if form:
+    elif form:
         if not form.is_submitted():
             pass
         elif not form.validate() or not form.osmo_validate(cset):
@@ -201,6 +228,7 @@ def org_certificates(ct_id: int):
     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():
@@ -211,9 +239,10 @@ def org_certificates(ct_id: int):
         ctx=ctx,
         group_rounds=group_rounds,
         form=form,
+        approve_form=approve_form,
         cset=cset,
         users_pions=users_pions,
-        cert_files_by_type=cert_files_by_type,
+        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),
diff --git a/mo/web/templates/org_certificates.html b/mo/web/templates/org_certificates.html
index aaf7408d..714d6241 100644
--- a/mo/web/templates/org_certificates.html
+++ b/mo/web/templates/org_certificates.html
@@ -31,8 +31,12 @@
 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.
-Po uzavření kola budou také dostupné soutěžícím v OSMO.
+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>
@@ -53,17 +57,24 @@ Po uzavření kola budou také dostupné soutěžícím v OSMO.
 	<thead>
 		<tr>
 			<th>Jméno
-			{% for t in CertType %}
-			<th class=ac>{{ t.friendly_name()|titlecase }}
+			{% 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>{{ user.full_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 in CertType %}
+			{% 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>
@@ -75,14 +86,31 @@ Po uzavření kola budou také dostupné soutěžícím v OSMO.
 	<tfoot>
 		<tr>
 			<th>Všichni dohromady
-			{% for t in CertType %}
-				{% set cfile = cert_files_by_type[t] %}
+			{% 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 %}
-- 
GitLab


From b5be52deda14b325b9470b7cb71a11709afb11a8 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 13:50:00 +0100
Subject: [PATCH 088/103] =?UTF-8?q?Diplomy/UI:=20=C3=9A=C4=8Dastn=C3=ADci?=
 =?UTF-8?q?=20vid=C3=AD=20jen=20schv=C3=A1len=C3=A9=20diplomy?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/user.py | 18 +++++++++++++-----
 1 file changed, 13 insertions(+), 5 deletions(-)

diff --git a/mo/web/user.py b/mo/web/user.py
index 1e4615b8..61cd6812 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -53,6 +53,8 @@ def user_index():
 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)
@@ -341,7 +343,10 @@ def user_contest(id: int):
 
     if contest.state == db.RoundState.closed:
         certs = (sess.query(db.Certificate)
-                 .filter_by(cert_set_id=id, user=g.user)
+                 .join(db.Certificate.cert_file)
+                 .filter(db.Certificate.cert_set_id == id,
+                         db.Certificate.user == g.user,
+                         db.CertFile.approved)
                  .limit(1)
                  .all())
     else:
@@ -642,7 +647,7 @@ def user_contest_score_pdf(id: int):
 
 
 # Využívá se i z org_certs
-def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Optional[int] = None):
+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:
@@ -653,7 +658,7 @@ def send_certificate(ct_id: int, cert_type: str, user_filename: str, user_id: Op
 
     sess = db.get_session()
     cfile = sess.query(db.CertFile).get((ct_id, typ))
-    if cfile is None:
+    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)
@@ -698,7 +703,10 @@ def user_contest_certificates(id: int):
     sess = db.get_session()
     cset = sess.query(db.CertSet).filter_by(contest_id=id).one_or_none()
     certs = (sess.query(db.Certificate)
-             .filter_by(cert_set_id=id, user=g.user)
+             .join(db.Certificate.cert_file)
+             .filter(db.Certificate.cert_set_id == id,
+                     db.Certificate.user == g.user,
+                     db.CertFile.approved)
              .all())
 
     return render_template(
@@ -713,7 +721,7 @@ def user_contest_certificates(id: int):
 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)
+    return send_certificate(ct_id, cert_type, filename, g.user_id, approved_only=True)
 
 
 @app.route('/user/history/')
-- 
GitLab


From 23bd7ac483584152da84d05b6f50c32f0ccfcd13 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 17:15:08 +0100
Subject: [PATCH 089/103] =?UTF-8?q?Diplomy:=20CertType=20um=C3=AD=20i=20n?=
 =?UTF-8?q?=C3=A1zev=20v=20mno=C5=BEn=C3=A9m=20=C4=8D=C3=ADsle?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/mo/db.py b/mo/db.py
index 2ea2f9cd..3b9477e8 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -1107,10 +1107,10 @@ class CertType(MOEnum):
     successful = auto()
     honorary_mention = auto()
 
-    def friendly_name(self) -> str:
-        return cert_type_names[self]
+    def friendly_name(self, plural: bool = False) -> str:
+        return cert_type_names[self][int(plural)]
 
-    def file_name(self, plural: bool) -> str:
+    def file_name(self, plural: bool = False) -> str:
         return cert_type_file_names[self][int(plural)]
 
     def short_code(self) -> str:
@@ -1122,9 +1122,9 @@ class CertType(MOEnum):
 
 
 cert_type_names = {
-    CertType.participation: 'účastnický list',
-    CertType.successful: 'diplom vítěze / úspěšného řešitele',
-    CertType.honorary_mention: 'pochvalné uznání',
+    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í'),
 }
 
 
-- 
GitLab


From a4f389ed1b85e11578ef9a0a5de51be89681d283 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 17:15:38 +0100
Subject: [PATCH 090/103] DB: Contest.participations

---
 mo/db.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/mo/db.py b/mo/db.py
index 3b9477e8..5e246f09 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -515,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
-- 
GitLab


From 9786c214e05808b3754e4fae352cdbaaea42b6c7 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 17:16:01 +0100
Subject: [PATCH 091/103] =?UTF-8?q?Rights:=20Koment=C3=A1=C5=99e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/rights.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/mo/rights.py b/mo/rights.py
index 724f7b9d..162bb114 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -18,7 +18,7 @@ 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
     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 +32,7 @@ class Right(Enum):
     edit_school_users = auto()      # Editovat uživatele ze své školy (jen garant_skola)
     add_orgs = auto()
     edit_orgs = auto()
-    edit_tex_hacks = auto()         # Nastavovat hacky pro sazbu výsledkovek TeXem
+    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
 
-- 
GitLab


From 9d4d89dfd3e9f5f6f0d64873008460d22e3fc5b8 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 17:16:41 +0100
Subject: [PATCH 092/103] =?UTF-8?q?Diplomy/UI:=20P=C5=99ehled=20ocen=C4=9B?=
 =?UTF-8?q?n=C3=AD=20student=C5=AF=20pro=20=C5=A1koln=C3=AD=20garanty?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py                          | 138 +++++++++++++++++++
 mo/web/templates/org_school_results.html     |  64 +++++++++
 mo/web/templates/org_school_results_all.html |  34 +++++
 static/mo.css                                |  12 ++
 4 files changed, 248 insertions(+)
 create mode 100644 mo/web/templates/org_school_results.html
 create mode 100644 mo/web/templates/org_school_results_all.html

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 56355140..a21f0525 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -1,17 +1,24 @@
 # 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
@@ -257,3 +264,134 @@ def org_cert_file(ct_id: int, cert_type: str, filename: str, user_id: Optional[i
     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_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_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,
+    )
+
+
+@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/templates/org_school_results.html b/mo/web/templates/org_school_results.html
new file mode 100644
index 00000000..e50d1893
--- /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 00000000..0b948ac7
--- /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/static/mo.css b/static/mo.css
index 803a79b7..2d914736 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -170,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;
 }
@@ -642,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) {
-- 
GitLab


From 656443f7af4b032c4d2b1722f00d7f3926ec073c Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 17:17:06 +0100
Subject: [PATCH 093/103] =?UTF-8?q?Org=20home:=20Odkaz=20na=20ocen=C4=9Bn?=
 =?UTF-8?q?=C3=AD=20pro=20=C5=A1koln=C3=AD=20garanty?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org.py                   | 6 ++++++
 mo/web/templates/org_index.html | 8 ++++++++
 2 files changed, 14 insertions(+)

diff --git a/mo/web/org.py b/mo/web/org.py
index ed9faa60..f9a094e1 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/templates/org_index.html b/mo/web/templates/org_index.html
index bdc386f6..7b057f69 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>
-- 
GitLab


From 3108697674755ba0706a3258f1297ed156429e21 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 17:17:23 +0100
Subject: [PATCH 094/103] =?UTF-8?q?Org=20home:=20Oprava=20t=C5=99=C3=ADdy?=
 =?UTF-8?q?=20pro=20tla=C4=8D=C3=ADtka?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_index.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html
index 7b057f69..7e8fb50a 100644
--- a/mo/web/templates/org_index.html
+++ b/mo/web/templates/org_index.html
@@ -99,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>
 
-- 
GitLab


From c82776f2dffe8f0dd5c0bfeace7db64a23cd5ead Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 18:59:12 +0100
Subject: [PATCH 095/103] =?UTF-8?q?Rights:=20Nen=C3=AD-li=20uveden=20rok,?=
 =?UTF-8?q?=20kontrolujeme=20aktu=C3=A1ln=C3=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

U soutěží a kol se chování nemění (tam se vždy ptáme na konkrétní rok),
ale u míst a účtů nebereme v úvahu role, které jsou specifické pro
jiné (zejména asi minulé) ročníky MO.
---
 mo/rights.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/rights.py b/mo/rights.py
index 162bb114..b5ab9562 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -424,7 +424,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.
-- 
GitLab


From 35df3bd368e9350d943e31b17847910e5fdcd78d Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 19:12:14 +0100
Subject: [PATCH 096/103] =?UTF-8?q?Rights:=20Nov=C3=A9=20pr=C3=A1vo=20na?=
 =?UTF-8?q?=20=C3=BA=C4=8Dastn=C3=ADky=20ze=20=C5=A1koly=20v=20oblasti?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

... ať už soutěží v kterékoliv soutěži.
---
 mo/rights.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/mo/rights.py b/mo/rights.py
index b5ab9562..8f1fd28a 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -19,6 +19,7 @@ class Right(Enum):
     manage_contest = auto()
     add_contest = auto()
     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
@@ -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,
-- 
GitLab


From eb00e5e419bb1de3853d407129a5d4542d5f3ae6 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 19:13:17 +0100
Subject: [PATCH 097/103] =?UTF-8?q?Diplomy/UI:=20V=20p=C5=99ehledu=20pro?=
 =?UTF-8?q?=20u=C4=8Ditele=20pou=C5=BE=C3=ADv=C3=A1me=20pr=C3=A1vo=20k=20?=
 =?UTF-8?q?=C3=BA=C4=8Dastn=C3=ADk=C5=AFm=20ve=20=C5=A1kole?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_certs.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index a21f0525..8b3f901d 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -273,7 +273,7 @@ def org_school_results_certs(school_id: int, ct_id: int, cert_type: str, filenam
     if place is None:
         raise werkzeug.exceptions.NotFound()
 
-    if not g.gatekeeper.rights_for(place=place).have_right(Right.view_contestants):
+    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)
@@ -335,7 +335,7 @@ def org_school_results(school_id: int):
         raise werkzeug.exceptions.NotFound()
 
     # Úmyslně nekontrolujeme práva ke kategorii
-    if not g.gatekeeper.rights_for(place=place).have_right(Right.view_contestants):
+    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)
-- 
GitLab


From bf933d9e0e8d4b52ff7d2aef1904ac20575acd80 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Jan 2025 19:13:44 +0100
Subject: [PATCH 098/103] =?UTF-8?q?Str=C3=A1nka=20=C5=A1koly=20odkazuje=20?=
 =?UTF-8?q?na=20ocen=C4=9Bn=C3=AD=20student=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_place.py             | 2 ++
 mo/web/templates/org_place.html | 3 +++
 2 files changed, 5 insertions(+)

diff --git a/mo/web/org_place.py b/mo/web/org_place.py
index 97daa0d5..6c7946ec 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/templates/org_place.html b/mo/web/templates/org_place.html
index 879ff607..f942f32d 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>
-- 
GitLab


From ce7acf4d1e852444be4bd4f4f63d6d619da23507 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 29 Jan 2025 11:53:57 +0100
Subject: [PATCH 099/103] =?UTF-8?q?Config:=20Lep=C5=A1=C3=AD=20koment?=
 =?UTF-8?q?=C3=A1=C5=99=20k=20REG=5FTOKEN=5FVALIDITY?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 etc/config.py.example | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/etc/config.py.example b/etc/config.py.example
index d72ea919..26380d63 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -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]
-- 
GitLab


From a757104fa7b5e5d67d9daba52849e8d5fcb54bd2 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 29 Jan 2025 20:24:17 +0100
Subject: [PATCH 100/103] =?UTF-8?q?Rights:=20Datab=C3=A1zov=C3=BD=20filtr?=
 =?UTF-8?q?=20na=20role?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Obdoba Gatekeeper.rights_for(), ale pro DB.
---
 mo/db.py     |  2 +-
 mo/rights.py | 30 ++++++++++++++++++++++++++++++
 2 files changed, 31 insertions(+), 1 deletion(-)

diff --git a/mo/db.py b/mo/db.py
index 5e246f09..e6f9edde 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -733,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)
diff --git a/mo/rights.py b/mo/rights.py
index 8f1fd28a..30079566 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -539,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
-- 
GitLab


From f2b389221bf25f03bcb58ccba12e36c2fb1e9caa Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 29 Jan 2025 20:26:14 +0100
Subject: [PATCH 101/103] =?UTF-8?q?Nov=C3=BD=20syst=C3=A9m=20mailov=C3=BDc?=
 =?UTF-8?q?h=20notifikac=C3=AD=20o=20zm=C4=9Bn=C4=9B=20stavu=20sout=C4=9B?=
 =?UTF-8?q?=C5=BEe?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Maily rozesíláme při změně na graded nebo closed.
Vyjmenováváme, co všechno je v OSMO k dispozici v dané soutěži.
Informujeme jak účastníky, tak jejich školní garanty.
---
 mo/email.py           |  32 +++--
 mo/jobs/notify.py     | 314 +++++++++++++++++++++++++++++++++++-------
 mo/web/org_certs.py   |   1 +
 mo/web/org_contest.py |   3 +-
 mo/web/org_round.py   |   5 +-
 mo/web/user.py        |   1 +
 6 files changed, 289 insertions(+), 67 deletions(-)

diff --git a/mo/email.py b/mo/email.py
index 78684545..9d6c94fd 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/jobs/notify.py b/mo/jobs/notify.py
index 50097dea..0616855d 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/web/org_certs.py b/mo/web/org_certs.py
index 8b3f901d..8af0e0c9 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -384,6 +384,7 @@ def org_school_results(school_id: int):
     )
 
 
+# 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)
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index e6764f95..57062dc7 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_round.py b/mo/web/org_round.py
index a7f78bab..b0b9dfc5 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -481,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')
@@ -808,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/user.py b/mo/web/user.py
index 61cd6812..be5f57f5 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -325,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()
-- 
GitLab


From ff59dd79e894a8e45b360e27168d8591630de7e7 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 30 Jan 2025 14:38:45 +0100
Subject: [PATCH 102/103] =?UTF-8?q?Pr=C5=AFvodce:=20Upozorn=C4=9Bn=C3=AD?=
 =?UTF-8?q?=20na=20rozes=C3=ADl=C3=A1n=C3=AD=20mail=C5=AF=20p=C5=99i=20uza?=
 =?UTF-8?q?v=C5=99en=C3=AD=20sout=C4=9B=C5=BEe?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/parts/org_contest_guide.html | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/mo/web/templates/parts/org_contest_guide.html b/mo/web/templates/parts/org_contest_guide.html
index 81f65b78..bb4c1acb 100644
--- a/mo/web/templates/parts/org_contest_guide.html
+++ b/mo/web/templates/parts/org_contest_guide.html
@@ -103,6 +103,12 @@
 		</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 %}
 
-- 
GitLab


From d28f1b4e9637d885ebaab33da2eea7705b7b9fae Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 30 Jan 2025 14:45:04 +0100
Subject: [PATCH 103/103] =?UTF-8?q?Pr=C5=AFvodce:=20Zobrazuje=20se=20ve=20?=
 =?UTF-8?q?v=C5=A1ech=20krajsk=C3=BDch=20kolech?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_contest.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index d1b3a1c8..d30e80ef 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -109,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 %}
 
-- 
GitLab