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. {{ 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