From 59252e28f0c8eb4d54f0803762e8c89959667190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Pracha=C5=99?= <jan.prachar@gmail.com> Date: Fri, 5 Mar 2021 17:39:34 +0100 Subject: [PATCH] =?UTF-8?q?Sout=C4=9B=C5=BE=C3=ADc=C3=AD=20m=C5=AF=C5=BEe?= =?UTF-8?q?=20odevzd=C3=A1vat=20=C5=99e=C5=A1en=C3=AD=20jako=20sadu=20obr?= =?UTF-8?q?=C3=A1zk=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mo/submit.py | 77 ++++++++++++++++++++- mo/web/templates/user_contest.html | 10 +-- mo/web/templates/user_contest_task.html | 23 ++++++- mo/web/user.py | 92 +++++++++++++++++-------- 4 files changed, 166 insertions(+), 36 deletions(-) diff --git a/mo/submit.py b/mo/submit.py index ec8a998f..62d51ade 100644 --- a/mo/submit.py +++ b/mo/submit.py @@ -2,8 +2,11 @@ import datetime import multiprocessing import os import pikepdf -from typing import Any +import PIL +import tempfile +from typing import Any, BinaryIO, List import werkzeug.utils +import zipfile from img2pdf import img2pdf import mo.db as db @@ -11,6 +14,9 @@ import mo.util from mo.util import logger +allowed_extensions = ('.pdf', '.png', '.jpg', 'jpeg') + + class SubmitException(RuntimeError): pass @@ -21,6 +27,75 @@ class Submitter: def __init__(self): self.submit_dir = mo.util.data_dir('submits') + def submit_images(self, paper: db.Paper, files: List[str], names: List[str]): + logger.info(f'Submit: Zpracovávám images={files} for=#{paper.for_user_obj.user_id} by=#{paper.uploaded_by_obj.user_id} type={paper.type.name}') + t_start = datetime.datetime.now() + try: + self._do_submit_images(paper, files, names) + duration = (datetime.datetime.now() - t_start).total_seconds() + logger.info(f'Submit: Hotovo: file={paper.file_name} pages={paper.pages} bytes={paper.bytes} time={duration:.3f}') + except SubmitException as e: + duration = (datetime.datetime.now() - t_start).total_seconds() + + zip_tmpfile = tempfile.NamedTemporaryFile(suffix='.zip', mode='w+b') + with zipfile.ZipFile(zip_tmpfile, mode='w') as zip: + for i, f in enumerate(files, start=0): + fn = werkzeug.utils.secure_filename(names[i]) + zip.write(filename=f, arcname=fn) + + preserved_as = mo.util.link_to_dir(zip_tmpfile.name, mo.util.data_dir('errors'), prefix='submit-') + logger.info(f'Submit: Chyba: {e} (time={duration:.3f}), uloženo do {preserved_as}') + zip_tmpfile.close() + raise + + def _do_submit_images(self, paper: db.Paper, files: List[str], names: List[str]): + # Zpracování obrázků spustíme v samostatném procesu, aby bylo dostatečně oddělené + with tempfile.NamedTemporaryFile(prefix='fpdf-', mode='w+b') as tmpfile: + pipe_rx, pipe_tx = multiprocessing.Pipe(duplex=False) + proc = multiprocessing.Process(name='submit', + target=Submitter._process_images, + args=(tmpfile, files, names, pipe_tx)) + proc.start() + pipe_tx.close() + + if not pipe_rx.poll(60): + proc.terminate() + proc.join() + raise SubmitException('Timeout při zpracování obrázků.') + + try: + result = pipe_rx.recv() + except EOFError: + result = None + proc.terminate() + proc.join() + + assert proc.exitcode is not None + if proc.exitcode != 0: + raise SubmitException(f'Interní chyba při zpracování obrázků: Exit code {proc.exitcode}.') + if not result: + raise SubmitException('Interní chyba při zpracování obrázků: EOF.') + + if 'error' in result: + logger.info('Submit: %s', result['error']) + raise SubmitException(result['error']) + else: + paper.bytes = result['bytes'] + paper.pages = len(files) + self._file_paper(paper, tmpfile.name) + + def _process_images(tmpfile: BinaryIO, files: List[str], names: List[str], pipe): + result: Any = {} + a4inpt = (img2pdf.mm_to_pt(210),img2pdf.mm_to_pt(297)) + try: + layout_fun = img2pdf.get_layout_fun(a4inpt) + tmpfile.write(img2pdf.convert(files, layout_fun=layout_fun)) + result['bytes'] = tmpfile.tell() + except Exception as e: + result['error'] = f'Selhala konverze obrázků do PDF: {e}' + + pipe.send(result) + def submit_paper(self, paper: db.Paper, tmpfile: str): 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() diff --git a/mo/web/templates/user_contest.html b/mo/web/templates/user_contest.html index 4c68aa93..aa55da11 100644 --- a/mo/web/templates/user_contest.html +++ b/mo/web/templates/user_contest.html @@ -39,7 +39,7 @@ {% endif %} </p> {% if contest.ct_can_submit() %} -<p>Řešení odevzdávejte ve formátu PDF jako soubor o velikosti maximálně +<p>Řešení úloh odevzdávejte jako sadu obrázků (JPG nebo PNG) nebo jeden soubor ve formátu PDF. Celková velikost může být maximálně {{ max_submit_size // 1048576 }} MB. Pokud si s tvorbou PDF nevíte rady, zkuste se podívat do <a href='https://docs.google.com/document/d/1XXk7Od-ZKtfmfNa-9FpFjUqmy0Ekzf2-2q3EpSWyn1w/edit?usp=sharing'>návodu</a>. {% endif %} @@ -52,14 +52,14 @@ Pokud si s tvorbou PDF nevíte rady, zkuste se podívat do <a href='https://docs <p>Soutěž se nachází v neznámém stavu. To by se nemělo stát :) {% endif %} -{% if contest.ct_task_statement_available() %} -<p>Můžete si stáhnout <a href='{{ url_for('user_task_statement', id=contest.contest_id) }}'>zadání úloh</a>. -{% endif %} - {% if state != RoundState.preparing %} <h3>Úlohy</h3> +{% if contest.ct_task_statement_available() %} +<p>Můžete si stáhnout <a href='{{ url_for('user_task_statement', id=contest.contest_id) }}'>zadání úloh</a>. +{% endif %} + <table class="table table-bordered table-hover"> <thead> <tr> diff --git a/mo/web/templates/user_contest_task.html b/mo/web/templates/user_contest_task.html index 32308295..1d651a7f 100644 --- a/mo/web/templates/user_contest_task.html +++ b/mo/web/templates/user_contest_task.html @@ -23,6 +23,7 @@ {% if state == RoundState.running %} {% if contest.ct_can_submit() %} <h3>Odevzdat řešení</h3> + <p>Pokud nahráváte obrázky, vždy vyberte všechny listy, které chcete odevzdávat. {% if round.ct_submit_end and g.now > round.ct_submit_end %} <p class="alert alert-danger">Pozor, odevzdáváte po termínu, uplynul {{ round.ct_submit_end|time_and_timedelta }}. Vaše řešení nemusí být hodnoceno. Doporučujeme využít políčko pro poznámku a vysvětlit situaci. @@ -31,7 +32,27 @@ řešením správným. V tom případě však uveďte do poznámky, proč jste řešení nahradili (např. nahráli jste omylem řešení jiné úlohy). {% endif %} - {{ wtf.quick_form(form, form_type='basic', button_map={'submit': 'primary'}) }} + + <form action="" method="post" class="form" role="form" enctype="multipart/form-data"> + {{ form.csrf_token }} + <div class="form-group required"> + <label class="control-label" for="file">Vypracované řešení</label> + <input id="file" multiple="" name="file" required="" type="file" accept=".png,.jpeg,.jpg,.pdf"> + <p class="help-block">Vyberte jeden PDF soubor, nebo několik obrázků (JPG, PNG).</p> + </div> + {{ wtf.form_field(form.note) }} + {{ wtf.form_field(form.submit, class='btn btn-primary') }} + </form> + + <h4>Maximální velikost</h4> + + <p>Celková velikost všech souborů, které najednou nahráváte, může + být maximálně <b>{{ max_submit_size // 1048576 }} MB</b>. Pokud po + odeslání formuláře dostanete odpověď <i>Request Entity Too + Large</i>, přesáhli jste tento limit. Zmenšete prosím velikost + odesílaných obrázků (například foťte z větší dálky a výsledek + ořízněte) a zkuste to znovu. + {% else %} <p>Již není možné odevzdat řešení, termín na odevzdávání vypršel.</p> {% endif %} diff --git a/mo/web/user.py b/mo/web/user.py index d4c70451..5e1d0f8a 100644 --- a/mo/web/user.py +++ b/mo/web/user.py @@ -1,6 +1,7 @@ from flask import render_template, jsonify, g, redirect, url_for, flash from flask_wtf import FlaskForm import flask_wtf.file +import os from sqlalchemy import and_ from sqlalchemy.orm import joinedload from typing import List, Tuple @@ -154,7 +155,7 @@ def user_task_statement(id: int): class SubmitForm(FlaskForm): - file = flask_wtf.file.FileField("Soubor", validators=[flask_wtf.file.FileRequired()], render_kw={'autofocus': True}) + file = wtforms.MultipleFileField("Soubor", validators=[validators.required()]) note = wtforms.TextAreaField("Poznámka", description="Zde můžete něco vzkázat organizátorům soutěže.") submit = wtforms.SubmitField('Odevzdat') @@ -175,38 +176,70 @@ def user_contest_task(contest_id: int, task_id: int): form = SubmitForm() if contest.ct_can_submit() and form.validate_on_submit(): - file = form.file.data.stream paper = db.Paper(task=task, for_user_obj=g.user, uploaded_by_obj=g.user, type=db.PaperType.solution, note=form.note.data) submitter = mo.submit.Submitter() - try: - submitter.submit_paper(paper, file.name) - except mo.submit.SubmitException as e: - flash(f'Chyba: {e}', 'danger') - return redirect(url_for('user_contest_task', contest_id=contest_id, task_id=task_id)) - - sess.add(paper) - - # FIXME: Bylo by hezké použít INSERT ... ON CONFLICT UPDATE - # (SQLAlchemy to umí, ale ne přes ORM, jen core rozhraním) - sol = (sess.query(db.Solution) - .filter_by(task=task, user=g.user) - .with_for_update() - .one_or_none()) - if sol is None: - sol = db.Solution(task=task, user=g.user) - sess.add(sol) - sol.final_submit_obj = paper - - sess.commit() - - if paper.is_broken(): - flash('Soubor není korektní PDF, ale přesto jsme ho přijali a pokusíme se ho zpracovat. ' + - 'Zkontrolujte prosím, že se na vašem počítači zobrazuje správně.', - 'warning') + files = request.files.getlist(form.file.name) + sizeok = [f.filename.lower().endswith('.pdf') + or os.path.getsize(f.stream.name) <= 10*1024*1024 + for f in files] + + if not files: + flash('Neodeslali jste žádné soubory.', 'danger') + + elif len(files) > 1 and any(f.filename.lower().endswith('.pdf') for f in files): + flash('Odevzdávat můžete pouze jeden PDF soubor.', 'danger') + + elif not all(f.filename.lower().endswith(mo.submit.allowed_extensions) for f in files): + invalid = [] + for f in files: + if not f.filename.lower().endswith(mo.submit.allowed_extensions): + invalid.append(f.filename) + flash( + 'Odevzdávat můžete jen obrázky a PDF soubory. Nepovolené soubory: {}'.format( + ', '.join(invalid)), + 'danger') + + elif not all(sizeok): + for i, s in enumerate(sizeok, start=0): + if not s: + flash(f'Obrázek {files[i].filename} je větší než 10 MB. Zmenšete ho prosím.', 'danger') + else: - flash('Řešení odevzdáno', 'success') - return redirect(url_for('user_contest', id=contest_id)) + try: + if not files[0].filename.lower().endswith('.pdf'): + submitter.submit_images( + paper, + [f.stream.name for f in files], + [f.filename for f in files]) + else: + submitter.submit_paper(paper, files[0].stream.name) + except mo.submit.SubmitException as e: + flash(f'Chyba: {e}', 'danger') + return redirect(url_for('user_contest_task', contest_id=contest_id, task_id=task_id)) + + sess.add(paper) + + # FIXME: Bylo by hezké použít INSERT ... ON CONFLICT UPDATE + # (SQLAlchemy to umí, ale ne přes ORM, jen core rozhraním) + sol = (sess.query(db.Solution) + .filter_by(task=task, user=g.user) + .with_for_update() + .one_or_none()) + if sol is None: + sol = db.Solution(task=task, user=g.user) + sess.add(sol) + sol.final_submit_obj = paper + + sess.commit() + + if paper.is_broken(): + flash('Soubor není korektní PDF, ale přesto jsme ho přijali a pokusíme se ho zpracovat. ' + + 'Zkontrolujte prosím, že se na vašem počítači zobrazuje správně.', + 'warning') + else: + flash('Řešení odevzdáno', 'success') + return redirect(url_for('user_contest', id=contest_id)) sol = sess.query(db.Solution).filter_by(task=task, user=g.user).one_or_none() @@ -224,6 +257,7 @@ def user_contest_task(contest_id: int, task_id: int): papers=papers, form=form, messages=messages, + max_submit_size=config.MAX_CONTENT_LENGTH, ) -- GitLab