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