diff --git a/bin/fix-submits b/bin/fix-submits index 9fccdc73e2cf501b8e8e2e00acbeefd0a7311842..8bde2641512ea89c6d7bcd05fc45a3de698707f4 100755 --- a/bin/fix-submits +++ b/bin/fix-submits @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse +import datetime import os from sqlalchemy.orm import joinedload import subprocess @@ -29,7 +30,7 @@ def fix_paper(id: int): .get(id)) assert paper is not None assert paper.orig_file_name is not None - print(f"=== Paper #{id} ({paper.orig_file_name})") + print(f"=== Paper #{id} ({paper.orig_file_name}){' [RETRY]' if paper.fixed_at else ''}") tmp_file = tempfile.NamedTemporaryFile(dir=mo.util.data_dir('tmp'), prefix='fix-') res = subprocess.run(['qpdf', os.path.join(mo.util.data_dir('submits'), paper.orig_file_name), tmp_file.name]) @@ -37,16 +38,16 @@ def fix_paper(id: int): sub = mo.submit.Submitter() try: sub.submit_fix(paper, tmp_file.name) - sess.commit() fixed += 1 except mo.submit.SubmitException: - sess.rollback() errors += 1 else: print(f'--> ERROR: qpdf failed with exit code {res.returncode}') - sess.rollback() errors += 1 + paper.fixed_at = datetime.datetime.now() + sess.commit() + if args.id is not None: fix_paper(args.id) diff --git a/constraints.txt b/constraints.txt index 0fba56cb92a81b77389414ce6b606e64914e0228..ffbd91dfd5c61e59d173287e87cc557da30cedea 100644 --- a/constraints.txt +++ b/constraints.txt @@ -24,6 +24,7 @@ pycparser==2.20 pyparsing==2.4.7 PyPDF2==1.26.0 python-dateutil==2.8.1 +python-magic==0.4.24 python-poppler==0.2.2 pytz==2020.5 pyzbar==0.1.8 diff --git a/db/db.ddl b/db/db.ddl index 3709f66c44c519f7a0bc3ffa1783bfa30c61b6c2..378c9d9e3c41fe291c43f032ab993f0387d3ed9c 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -31,6 +31,12 @@ CREATE TABLE users ( email_notify boolean NOT NULL DEFAULT true -- přeje si dostávat mailové notifikace ); +-- Uživatel s user_id=0 vždy existuje a je to systémový uživatel s právy admina. +-- Nemá nastavené heslo, takže se na něj nejde přihlásit. +-- Nelze mu ani heslo resetovat (to nejde adminům obecně), ani poslat pozvánku (má last_login_at). +INSERT INTO users(user_id, email, first_name, last_name, is_admin, email_notify, last_login_at, note) + VALUES(0, 'system', 'Systém', 'OSMO', true, false, NOW(), 'Systémový uživatel'); + -- Hierarchie regionů a organizací CREATE TYPE place_type AS ENUM ( @@ -219,11 +225,13 @@ CREATE TABLE papers ( bytes int DEFAULT NULL, -- velikost souboru file_name varchar(255) DEFAULT NULL, -- relativní cesta k souboru orig_file_name varchar(255) DEFAULT NULL, -- původní cesta, pokud PDF bylo poničené - note text NOT NULL DEFAULT '' -- komentář uploadujícího + note text NOT NULL DEFAULT '', -- komentář uploadujícího + fixed_at timestamp with time zone DEFAULT NULL -- Sémantika práce s poničenými soubory: -- - správná PDF mají orig_file_name=NULL -- - pokud někdo odevzdá poničené, vyplní se orig_file_name, ale file_name=NULL - -- - časem se spustí oprava, která vyplní i file_name a přepočítá bytes + -- - časem se spustí oprava, která vyplní i file_name, přepočítá bytes a nastaví fixed_at + -- - pokud oprava selže, nastaví pouze fixed_at ); CREATE INDEX papers_for_task_index ON papers (for_task); @@ -312,7 +320,8 @@ CREATE TYPE job_type AS ENUM ( 'download_submits', 'upload_feedback', 'create_protocols', - 'process_scans' + 'process_scans', + 'fix_submits' ); CREATE TYPE job_state AS ENUM ( @@ -320,7 +329,8 @@ CREATE TYPE job_state AS ENUM ( 'ready', 'running', 'done', -- Hotovo, out_json a out_file jsou platné - 'failed' -- Interní chyba při zpracování, viz log + 'failed', -- Chyba při zpracování + 'internal_error' -- Interní chyba při zpracování (viz log) ); CREATE TABLE jobs ( diff --git a/db/upgrade-20211130.sql b/db/upgrade-20211130.sql new file mode 100644 index 0000000000000000000000000000000000000000..462a1726220b1ba89d490b91bc029ece86be29fd --- /dev/null +++ b/db/upgrade-20211130.sql @@ -0,0 +1,11 @@ +SET ROLE 'mo_osmo'; + +ALTER TABLE papers ADD COLUMN + fixed_at timestamp with time zone DEFAULT NULL; + +INSERT INTO users(user_id, email, first_name, last_name, is_admin, email_notify, last_login_at, note) + VALUES(0, 'system', 'Systém', 'OSMO', true, false, NOW(), 'Systémový uživatel'); + +ALTER TYPE job_state ADD VALUE 'internal_error'; + +ALTER TYPE job_type ADD VALUE 'fix_submits'; diff --git a/mo/db.py b/mo/db.py index ed3bcd3c872283fffc7faf25d159b1bdd476dd2d..226aef6905462a5566102a38536c6937483aa908 100644 --- a/mo/db.py +++ b/mo/db.py @@ -140,7 +140,7 @@ class Place(Base): def get_root_place() -> Place: - """Obvykle voláme mo.rights.Gatekeeper.get_root_place(), kterékešuje.""" + """Obvykle voláme mo.rights.Gatekeeper.get_root_place(), které kešuje.""" return get_session().query(Place).filter_by(parent=None).one() @@ -373,6 +373,13 @@ class User(Base): return self.password_hash is None +def get_system_user() -> User: + """Uživatel s user_id=0 je systémový (viz db.ddl)""" + user = get_session().query(User).get(0) + assert user is not None + return user + + class Contest(Base): __tablename__ = 'contests' __table_args__ = ( @@ -629,6 +636,7 @@ class Paper(Base): file_name = Column(String(255)) orig_file_name = Column(String(255)) note = Column(Text, nullable=False, server_default=text("''::text")) + fixed_at = Column(DateTime(True)) task = relationship('Task') for_user_obj = relationship('User', primaryjoin='Paper.for_user == User.user_id') @@ -650,6 +658,9 @@ class Paper(Base): def is_broken(self) -> bool: return self.file_name is None + def is_nonfixable(self) -> bool: + return self.is_broken() and self.fixed_at is not None + def is_fixed(self) -> bool: return self.orig_file_name is not None and self.file_name is not None @@ -692,6 +703,7 @@ class JobType(MOEnum): create_protocols = auto() process_scans = auto() sort_scans = auto() + fix_submits = auto() class JobState(MOEnum): @@ -700,6 +712,7 @@ class JobState(MOEnum): running = auto() done = auto() failed = auto() + internal_error = auto() def friendly_name(self) -> str: return job_state_names[self] @@ -711,6 +724,7 @@ job_state_names = { JobState.running: 'zpracovává se', JobState.done: 'dokončena', JobState.failed: 'selhala', + JobState.internal_error: 'interní chyba', } diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py index 2d7da1a06762287ec011e153a8292657d6acbaab..71fd12966ebae07d2192cfeab29bebcabf1b25b2 100644 --- a/mo/jobs/__init__.py +++ b/mo/jobs/__init__.py @@ -136,6 +136,9 @@ class TheJob: job.state = db.JobState.running job.finished_at = None job.expires_at = None + job.result = "" + job.out_json = None + job.out_file = None sess.commit() try: @@ -144,7 +147,11 @@ class TheJob: _handler_table[job.type](self) if self.errors: logger.info(f'Job: Neúspěšně dokončen job #{job.job_id} ({job.result})') - job.state = db.JobState.failed + if job.user_id == 0: + # Joby běžící na systémového uživatele produkují interní chyby + job.state = db.JobState.internal_error + else: + job.state = db.JobState.failed job.out_json = {'errors': self.errors} if job.result == "": job.result = 'Došlo k chybám, viz detail' @@ -153,11 +160,12 @@ class TheJob: job.state = db.JobState.done except Exception as e: logger.error(f'Job: Chyba při zpracování jobu #{job.job_id}: %s', e, exc_info=e) - job.state = db.JobState.failed + job.state = db.JobState.internal_error job.result = 'Interní chyba, informujte prosím správce systému.' job.finished_at = mo.util.get_now() - job.expires_at = job.finished_at + timedelta(minutes=self.expires_in_minutes) + if job.state != db.JobState.internal_error: + job.expires_at = job.finished_at + timedelta(minutes=self.expires_in_minutes) sess.commit() diff --git a/mo/jobs/protocols.py b/mo/jobs/protocols.py index 65b6b9e7d8e3970cfd63371be3697d9daf306d6d..ae9e8a7360e2275cf968ee6fbb519a7e3e53bb5c 100644 --- a/mo/jobs/protocols.py +++ b/mo/jobs/protocols.py @@ -1,7 +1,7 @@ # Implementace jobů na práci s protokoly from PIL import Image -from dataclasses import dataclass +from dataclasses import dataclass, field import multiprocessing import os import poppler @@ -218,6 +218,12 @@ class ScanJobPage: code: Optional[str] +@dataclass +class ScanJobResult: + pages: List[ScanJobPage] = field(default_factory=list) + error: Optional[str] = None + + @job_handler(db.JobType.process_scans) def handle_process_scans(the_job: TheJob): job = the_job.job @@ -291,7 +297,11 @@ def handle_process_scans(the_job: TheJob): num_pages = 0 for fi, fn in enumerate(in_files): prev_page: Optional[db.ScanPage] = None - for pi, pr in enumerate(results[fi]): + res = results[fi] + if res.error: + the_job.error(f'{fn}: {res.error}') + return + for pi, pr in enumerate(res.pages): sp = db.ScanPage( job_id=job.job_id, file_nr=fi, @@ -321,19 +331,23 @@ def handle_process_scans(the_job: TheJob): the_job.expires_in_minutes = config.JOB_EXPIRATION_LONG -def _process_scan_file(args: ScanJobArgs) -> List[ScanJobPage]: +def _process_scan_file(args: ScanJobArgs) -> ScanJobResult: # Zpracuje jeden soubor se skeny. Běží v odděleném procesu. - # FIXME: Ošetření chyb + res = ScanJobResult() logger.debug(f'Scan: Analyzuji soubor {args.in_path}') pdf = poppler.load_from_file(args.in_path) + if not pdf._document: + # XXX: Poppler neumí hlásit chybu při otevírání dokumentu (https://github.com/cbrunet/python-poppler/issues/48) + # Tak zatím saháme dovnitř a detekujeme si ji sami. + res.error = 'Soubor není ve formátu PDF' + return res renderer = poppler.PageRenderer() renderer.set_render_hint(poppler.RenderHint.antialiasing, True) renderer.set_render_hint(poppler.RenderHint.text_antialiasing, True) dpi = 300 - output = [] for page_nr in range(pdf.pages): page = pdf.create_page(page_nr) page_img = renderer.render_page(page, xres=dpi, yres=dpi) @@ -360,7 +374,7 @@ def _process_scan_file(args: ScanJobArgs) -> List[ScanJobPage]: qr = code.data.decode('US-ASCII') # FIXME: Tady by se dala podle kódu otočit stránka - output.append(ScanJobPage(code=qr)) + res.pages.append(ScanJobPage(code=qr)) full_img.save(f'{args.out_prefix}-{page_nr:04d}-full.png') @@ -369,7 +383,7 @@ def _process_scan_file(args: ScanJobArgs) -> List[ScanJobPage]: logger.debug(f'Scan: Strana #{page_nr}: {qr}') - return output + return res # diff --git a/mo/jobs/submit.py b/mo/jobs/submit.py index 136d17f07449464558216267a80c2ea988f23d24..57ca8b41d57f720dbcab1fdd832bc78b510800da 100644 --- a/mo/jobs/submit.py +++ b/mo/jobs/submit.py @@ -1,11 +1,13 @@ # Implementace jobů pracujících se submity from dataclasses import dataclass +import datetime import os import re import shutil from sqlalchemy import and_ from sqlalchemy.orm import joinedload +import subprocess from tempfile import NamedTemporaryFile from typing import List, Optional import unicodedata @@ -311,3 +313,77 @@ def handle_upload_feedback(the_job: TheJob): + inflect_by_number(len(the_job.errors), 'nastala', 'nastaly', 'nastalo') + ' ' + inflect_number(len(the_job.errors), 'chyba', 'chyby', 'chyb')) + + +# +# Job fix_submits: Opravuje rozbité submity +# +# Vstupní JSON: +# { 'papers': [ seznam paper_id k opravení ], +# } +# +# Výstupní JSON: +# null +# + + +def check_broken_submits() -> None: + sess = db.get_session() + papers = sess.query(db.Paper).filter_by(file_name=None, fixed_at=None).all() + + if not papers: + return + + the_job = TheJob() + job = the_job.create(db.JobType.fix_submits, db.get_system_user()) + job.description = 'Oprava rozbitých protokolů' + job.in_json = {'papers': [p.paper_id for p in papers]} + the_job.submit() + + +@job_handler(db.JobType.fix_submits) +def handle_fix_submits(the_job: TheJob): + job = the_job.job + assert job.in_json is not None + paper_ids: List[int] = job.in_json['papers'] # type: ignore + sess = db.get_session() + + cnt_fixed = 0 + cnt_failed = 0 + cnt_skipped = 0 + + for paper_id in paper_ids: + log_prefix = f'Protokol #{paper_id}' + paper = (sess.query(db.Paper) + .options(joinedload(db.Paper.for_user_obj)) + .get(paper_id)) + if paper is None or paper.fixed_at is not None: + cnt_skipped += 1 + logger.debug(f'Job: {log_prefix}: Již byl vyřízen') + continue + logger.debug(f'Job: {log_prefix}: Opravuji') + + tmp_file = NamedTemporaryFile(dir=data_dir('tmp'), prefix='fix-') + res = subprocess.run(['qpdf', os.path.join(data_dir('submits'), paper.orig_file_name), tmp_file.name]) + if res.returncode in [0, 3]: + sub = Submitter() + try: + sub.submit_fix(paper, tmp_file.name) + logger.info(f'Job: {log_prefix}: Opraven') + cnt_fixed += 1 + except SubmitException: + the_job.error(f'{log_prefix}: Oprava selhala') + cnt_failed += 1 + else: + the_job.error(f'{log_prefix}: qpdf selhalo s exit code {res.returncode}') + cnt_failed += 1 + + paper.fixed_at = datetime.datetime.now() + sess.commit() + + report = [inflect_number(cnt_fixed, 'opraven', 'opraveny', 'opraveno')] + if cnt_failed > 0: + report.append(inflect_number(cnt_failed, 'selhal', 'selhaly', 'selhalo')) + if cnt_skipped > 0: + report.append(inflect_number(cnt_skipped, 'přeskočen', 'přeskočeny', 'přeskočeno')) + job.result = 'Oprava protokolů: ' + ', '.join(report) diff --git a/mo/users.py b/mo/users.py index 32c858ce08ac61166863fe801d3e3aacbb0ef0d5..f5fe5642bd3328ed36d899e84a7c8d10a1d71fcc 100644 --- a/mo/users.py +++ b/mo/users.py @@ -286,6 +286,7 @@ def expire_reg_requests(): def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest]: logger.info('Login: Požadavek na reset hesla pro <%s>', user.email) + assert not user.is_admin rr = new_reg_request(db.RegReqType.reset_passwd, client) if rr: db.get_session().add(rr) diff --git a/mo/web/__init__.py b/mo/web/__init__.py index 2333eeca1ac63ca6d82a066a99cd4bc7aa8d9a8d..c15c9abb0777718a37e93c534c57efc11f8a125d 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -16,6 +16,7 @@ import mo.config as config import mo.db as db import mo.ext.assets import mo.jobs +import mo.jobs.submit import mo.rights import mo.users import mo.util @@ -177,6 +178,7 @@ app.before_request(init_request) def collect_garbage() -> None: mo.now = mo.util.get_now() + mo.jobs.submit.check_broken_submits() mo.jobs.process_jobs() mo.users.expire_reg_requests() @@ -189,6 +191,7 @@ def collect_garbage() -> None: def gc(): """Run garbage collector.""" collect_garbage() + db.get_session().rollback() # Pokud zůstala otevřená DB session try: diff --git a/mo/web/acct.py b/mo/web/acct.py index 4cb0451f63ea30d3c32717519b10f3f343ce3f0f..214da32dc193bc510c6d837d8b60ca42dffcc1c9 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -61,13 +61,16 @@ def login(): app.logger.error('Login: Neznámý uživatel <%s>', email) flash('Neznámý uživatel', 'danger') elif form.reset.data: - rr = mo.users.request_reset_password(user, request.remote_addr) - if rr: - db.get_session().commit() - mo.email.send_password_reset_email(user, rr.email_token) - flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success') + if user.is_admin: + flash('Obnova hesla účtu správce není možná.', 'danger') else: - flash('Příliš časté požadavky na obnovu hesla.', 'danger') + rr = mo.users.request_reset_password(user, request.remote_addr) + if rr: + db.get_session().commit() + mo.email.send_password_reset_email(user, rr.email_token) + flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success') + else: + flash('Příliš časté požadavky na obnovu hesla.', 'danger') elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data): app.logger.error('Login: Špatné heslo pro uživatele <%s>', email) flash('Chybné heslo', 'danger') diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index a89b3330780e5ebe0ceef452852354f77251e24f..10cd5c65d228a9a6b2cdd555e24f5782d57d4951 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -6,6 +6,7 @@ from flask_wtf import FlaskForm import flask_wtf.file import json import locale +import magic from markupsafe import Markup from sqlalchemy import func, and_, select from sqlalchemy.orm import joinedload, aliased @@ -13,6 +14,7 @@ from sqlalchemy.orm.query import Query from sqlalchemy.dialects.postgresql import insert as pgsql_insert from typing import Any, List, Tuple, Optional, Dict import urllib.parse +from werkzeug.datastructures import FileStorage import werkzeug.exceptions import wtforms import wtforms.validators as validators @@ -1745,13 +1747,14 @@ def org_contest_scans(ct_id: int, site_id: Optional[int] = None): if proc_form.validate_on_submit() and proc_form.process_scans.data: files = request.files.getlist(proc_form.files.name) - job_id = mo.jobs.protocols.schedule_process_scans( - contest, site, proc_form.scans_type.data, g.user, - tasks=[t for t in tasks if getattr(proc_form, f'task_{t.task_id}').data], - in_file_names=[f.stream.name for f in files], - ) - flash('Zpracování skenů zahájeno. Vyčkejte chvíli, než budou připraveny, a poté je roztřiďte.', 'success') - return redirect(ctx.url_for('org_contest_scans_process', job_id=job_id)) + if check_scan_files(files): + job_id = mo.jobs.protocols.schedule_process_scans( + contest, site, proc_form.scans_type.data, g.user, + tasks=[t for t in tasks if getattr(proc_form, f'task_{t.task_id}').data], + in_file_names=[f.stream.name for f in files], + ) + flash('Zpracování skenů zahájeno. Vyčkejte chvíli, než budou připraveny, a poté je roztřiďte.', 'success') + return redirect(ctx.url_for('org_contest_scans')) jobs_query = sess.query(db.Job).filter_by(type=db.JobType.process_scans) if not g.user.is_admin: @@ -1781,6 +1784,17 @@ def org_contest_scans(ct_id: int, site_id: Optional[int] = None): ) +def check_scan_files(files: List[FileStorage]) -> bool: + ok = True + mag = magic.Magic(mime=True) + for i, f in enumerate(files): + mime_type = mag.from_file(f.stream.name) + if mime_type != 'application/pdf': + flash(f'Soubor {f.filename} není ve formátu PDF, nýbrž {mime_type}.', 'danger') + ok = False + return ok + + class ScanProcessForm(FlaskForm): data = wtforms.HiddenField() save = wtforms.SubmitField() diff --git a/mo/web/org_jobs.py b/mo/web/org_jobs.py index 8cf9786e86bdc9f7d335dd69da3cec78ddd83808..7851eab283ec46b899d7005f9f504c8dc2e4dd9d 100644 --- a/mo/web/org_jobs.py +++ b/mo/web/org_jobs.py @@ -76,7 +76,7 @@ def job_file_size(job: db.Job, name: Optional[str]) -> Optional[int]: def org_job(id: int): job = get_job(id) - has_errors = (job.state == db.JobState.failed + has_errors = (job.state in (db.JobState.failed, db.JobState.internal_error) and isinstance(job.out_json, dict) and 'errors' in job.out_json) diff --git a/mo/web/org_round.py b/mo/web/org_round.py index df0623d40a699ea96122185bfec98d9bdf814541..5de37170833f01334ee05a88014203117fa7282d 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -655,8 +655,10 @@ def org_round_create_contests(round_id: int): new_places = (sess.query(db.Place) .select_from(db.Contest) .filter(db.Contest.round == prev_round) - .filter(db.Contest.place_id.notin_(have_places_subq)) - .join(db.Place) + .join(db.RegionDescendant, db.RegionDescendant.descendant == db.Contest.place_id) + .join(db.Place, db.Place.place_id == db.RegionDescendant.region) + .filter(db.Place.level == round.level) + .filter(db.Place.place_id.notin_(have_places_subq)) .all()) form = CreateContestsForm() diff --git a/mo/web/templates/org_contest_scans_process.html b/mo/web/templates/org_contest_scans_process.html index 03af33e3767c2553a215b3c54944205bad61850e..c6f3842599f1e44cd9634c5b62f5954685dd97e9 100644 --- a/mo/web/templates/org_contest_scans_process.html +++ b/mo/web/templates/org_contest_scans_process.html @@ -22,7 +22,7 @@ Třídění skenů {{ scans_title }} pro {{ ctx.round.name|lower }} kategorie {{ <script> setTimeout(function () { location.reload(1); }, 10_000); </script> -{% elif job.state == JobState.failed %} +{% elif job.state != JobState.done %} <p>Zpracování selhalo, více detailů naleznete v <a href="{{ url_for('org_job', id=job.job_id) }}">detailu dávky</a>.</p> {% else %} diff --git a/mo/web/templates/org_submit_list.html b/mo/web/templates/org_submit_list.html index 09a62ca21fea47eea2cc23b480251453d4c50195..1cbedf61cd8f07d870f64423f34a339555dd0f30 100644 --- a/mo/web/templates/org_submit_list.html +++ b/mo/web/templates/org_submit_list.html @@ -69,7 +69,7 @@ Existuje více než jedna verze řešení, finální je podbarvená. {% set late = p.check_deadline(ctx.round) %} <tr{% if p.paper_id == active_sol_id %} class='sol-active'{% endif %}> <td{% if late %} class='sol-warn'{% endif %}>{{ p.uploaded_at|timeformat }} - <td>{% if p.is_broken() %}nekorektní PDF{% else %}{{ p.pages|or_dash }}{% endif %} + <td>{% if p.is_broken() %}nekorektní PDF{% if p.is_nonfixable() %} (oprava selhala){% endif %}{% else %}{{ p.pages|or_dash }}{% endif %} <td>{{ p.bytes|or_dash }} <td>{% if p.uploaded_by_obj == ctx.user %}<i>účastník</i>{% else %}{{ p.uploaded_by_obj|user_link }}{% endif %} <td>{% if late %}<span class='sol-warn'>({{ late }})</span> {% endif %}{{ p.note }} @@ -122,7 +122,7 @@ Existuje více než jedna verze oprav, finální je podbarvená. {% for p in fb_papers %} <tr{% if p.paper_id == active_fb_id %} class='sol-active'{% endif %}> <td>{{ p.uploaded_at|timeformat }} - <td>{% if p.is_broken() %}nekorektní PDF{% else %}{{ p.pages|or_dash }}{% endif %} + <td>{% if p.is_broken() %}nekorektní PDF{% if p.is_nonfixable() %} (oprava selhala){% endif %}{% else %}{{ p.pages|or_dash }}{% endif %} <td>{{ p.bytes|or_dash }} <td>{{ p.uploaded_by_obj|user_link }} <td>{{ p.note }} diff --git a/setup.py b/setup.py index 3f37cbbff8fde7138cf213699d53fd0feb509b97..6a90c1850f9285dcf6bdb90daa8d3918b8a15245 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ setuptools.setup( 'pikepdf', 'pillow', 'psycopg2', + 'python-magic', 'python-poppler', 'pyzbar', 'sqlalchemy', diff --git a/static/mo.css b/static/mo.css index 91cfe734ef48aac2f952406663a6000bf80aed4d..13b0d8d3f5766592c10b17b9c0ca018e209a94e0 100644 --- a/static/mo.css +++ b/static/mo.css @@ -259,6 +259,14 @@ table.data tbody tr.job-failed:hover { background-color: #a66; } +table.data tbody tr.job-internal_error { + background-color: #c22; +} + +table.data tbody tr.job-internal_error:hover { + background-color: #a44; +} + /* Users */ .user-test {