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