diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 13085d7eb6cd45953619cf60d1e7f2762811af2c..56792880990d643be5dbf57475b0718d8123d656 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -1,4 +1,6 @@
+import os
from flask import render_template, g, redirect, url_for, flash, request
+from flask.helpers import send_file
from flask_wtf import FlaskForm
import flask_wtf.file
import locale
@@ -1775,3 +1777,78 @@ def org_contest_scans(ct_id: int, site_id: Optional[int] = None):
proc_task_fields=proc_task_fields,
jobs=jobs,
)
+
+
+@app.route('/org/contest/c/<int:ct_id>/scans/<int:job_id>', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/scans/<int:job_id>', methods=('GET', 'POST'))
+def org_contest_scans_process(ct_id: int, job_id: int, site_id: Optional[int] = None):
+ ctx = get_context(ct_id=ct_id, site_id=site_id, right_needed=Right.upload_solutions)
+ contest = ctx.contest
+ assert contest
+
+ sess = db.get_session()
+
+ # Získáme job a zkontrolujeme, že je to správný job a máme na něj práva
+ job = sess.query(db.Job).get(job_id)
+ if not job or job.type != db.JobType.process_scans:
+ flash('Dávka naskenovaných úloh nenalezena, skeny již byly pravděpodobně zpracovány nebo smazány.')
+ return redirect(ctx.url_for('org_contest_scans'))
+ if not g.user.is_admin and g.user.user_id != job.user_id:
+ raise werkzeug.exceptions.Forbidden()
+
+ pages = sess.query(db.ScanPage).filter_by(job_id=job_id).order_by('file_nr', 'page_nr').all()
+ tasks = sess.query(db.Task).filter(db.Task.task_id.in_(job.in_json['task_ids'])).order_by('code').all()
+ pion_query = sess.query(db.Participation).filter(
+ db.Participation.contest == contest,
+ db.Participation.state == db.PartState.active
+ )
+ if site_id is not None:
+ pion_query = pion_query.filter_by(place_id=site_id)
+ pions = pion_query.options(joinedload(db.Participation.user)).all()
+
+ def png_small(page: db.ScanPage) -> str:
+ return url_for(
+ 'org_contest_scans_file', id=id, job_id=job_id, site_id=site_id,
+ file=f'p-{page.file_nr:02d}-{page.page_nr:04d}-small.png'
+ )
+
+ def png_full(page: db.ScanPage) -> str:
+ return url_for(
+ 'org_contest_scans_file', id=id, job_id=job_id, site_id=site_id,
+ file=f'p-{page.file_nr:02d}-{page.page_nr:04d}-full.png'
+ )
+
+ return render_template(
+ 'org_contest_scans_process.html',
+ ctx=ctx,
+ job=job,
+ tasks=tasks,
+ pions=pions,
+ pages=pages,
+ png_small=png_small,
+ png_full=png_full,
+ )
+
+
+@app.route('/org/contest/c/<int:ct_id>/scans/<int:job_id>/file/<file>')
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/scans/<int:job_id>/file/<file>')
+def org_contest_scans_file(ct_id: int, job_id: int, file: str, site_id: Optional[int] = None):
+ get_context(ct_id=ct_id, site_id=site_id, right_needed=Right.upload_solutions)
+
+ sess = db.get_session()
+
+ # Získáme job a zkontrolujeme, že je to správný job a máme na něj práva
+ job = sess.query(db.Job).get(job_id)
+ if not job or job.type != db.JobType.process_scans:
+ raise werkzeug.exceptions.NotFound()
+ if not g.user.is_admin and g.user.user_id != job.user_id:
+ raise werkzeug.exceptions.Forbidden()
+
+ if os.path.basename(file) != file:
+ raise werkzeug.exceptions.Forbidden()
+
+ path = os.path.join(job.dir_path(), file)
+ if os.path.isfile(path):
+ return send_file(path)
+ else:
+ raise werkzeug.exceptions.NotFound()
diff --git a/mo/web/templates/org_contest_scans_process.html b/mo/web/templates/org_contest_scans_process.html
new file mode 100644
index 0000000000000000000000000000000000000000..f0f4a1f8c7414bc4a2704acafd63bf44c8fe7c81
--- /dev/null
+++ b/mo/web/templates/org_contest_scans_process.html
@@ -0,0 +1,351 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block head %}
+ <script src="{{ asset_url('js/autocomplete.js') }}" type="text/javascript"></script>
+{% endblock %}
+
+{% block title %}
+Třídění skenů pro {{ ctx.round.name|lower }} kategorie {{ ctx.round.category }}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Třídění skenů") }}
+{% endblock %}
+{% block body %}
+
+{% if job.state in [JobState.preparing, JobState.ready, JobState.running] %}
+<p>Právě běží zpracování, vyčkejte prosím několik okamžiků (stránka se sama obnoví).</p>
+
+<script>
+setTimeout(function () { location.reload(1); }, 10_000);
+</script>
+{% elif job.state == JobState.failed %}
+<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 %}
+
+<p>Napravo můžete klikáním vybírat jednotlivé naskenované stránky a pomocí vrchních políček je přiřazovat jednotlivým úlohám a soutěžícím. Pokud
+je nějaké řešení přes více stránek, musí na sebe navazovat číslování stránek. Až bude vše správně zatříděné, můžete aktuální stav uložit
+tlačítkem <b>[Uložit]</b>. Poté můžete celou dávku odeslat ke zpracování pomocí <b>[Ukončit a zpracovat]</b> (řešení se uloží k řešitelům,
+tuto akci nelze vzít zpět).
+
+<p><b>TODO: Zatím není backend pro tlačítka, ale bude</b>
+
+<form method="post" class="btn-group pull-right">
+ <input class="btn btn-primary" type="submit" name="savel" value="Uložit" id="save-button">
+ <input class="btn btn-primary" type="submit" name="process-all" value="Ukončit a zpracovat" id="process-all-button" onclick="return confirm('Opravud ukončit a zpracovat? Nelze vzít zpět.');">
+</form>
+
+<style>
+ #sort_wrapper {
+ display: grid;
+ grid-template-columns: auto 350px;
+ grid-template-rows: 100px auto;
+ gap: 5px;
+ }
+ #sort_wrapper div.controls {
+ border: 1px #bbbbbb solid;
+ background-color: #e5e5e5;
+ border-radius: 5px;
+ padding: 5px 10px;
+ }
+ #sort_wrapper div.scan {
+ position: relative;
+ }
+ #sort_wrapper div.scan #scan_loader {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ background-color: black;
+ transition: 0.5s;
+ }
+ #sort_wrapper div.scan img {
+ max-width: 100%;
+ max-height: 100%;
+ }
+ #sort_wrapper div.pages {
+ max-height: 80vh;
+ grid-row: span 2;
+ overflow-y: scroll;
+ position: relative;
+ }
+ #sort_wrapper div.pages table {
+ width: 100%;
+ margin: 0px;
+ }
+ #sort_wrapper div.pages table thead {
+ position: sticky;
+ top: 0px;
+ }
+ #sort_wrapper div.pages table tr {
+ cursor: pointer;
+ }
+ #sort_wrapper div.pages table tr.ok { background-color: lightgreen; }
+ #sort_wrapper div.pages table tr.empty { background-color: #ff7777; }
+ #sort_wrapper div.pages table tr.changed { background-color: yellow; }
+ #sort_wrapper div.pages table tr.active {
+ color: white;
+ background-color: black;
+ }
+ #sort_wrapper div.pages table tr.active.changed { color: yellow; }
+</style>
+
+<p><b>Ovládání klávesnicí:</b> <code>↑</code> a <code>↓</code> – posun ve skenech, <code>e</code> – přepnutí se na editaci,
+<code>[esc]</code> – vyskočení z editačního políčka při editaci, <code>r</code> – reset (vrácení) změn u konkrétního skenu,
+<code>x</code> – nastavit stránku jako prázdnou, <code>f</code> – nastavit stránku jako pokračování minulé.
+
+<div id="sort_wrapper">
+ <div class="controls form-horizontal">
+ <span tabindex=1 onfocus="document.getElementById('seq_input').focus();"></span>
+ <div class="form-group">
+ <label class="col-sm-2 control-label" for="user_input">Soutěžící:</label>
+ <div class="col-sm-10 autocomplete">
+ <input tabindex=2 class="form-control" type="text" id="user_input">
+ </div>
+ </div>
+ <div class="form-group">
+ <label class="col-sm-2 control-label" for="task_input">Úloha:</label>
+ <div class="col-sm-4 autocomplete">
+ <input tabindex=3 class="form-control" type="text" id="task_input">
+ </div>
+ <label class="col-sm-2 control-label" for="seq_input">Stránka:</label>
+ <div class="col-sm-4 autocomplete">
+ <input tabindex=4 class="form-control" type="number" min="1" id="seq_input">
+ </div>
+ </div>
+ <span tabindex=5 onfocus="document.getElementById('user_input').focus();"></span>
+ </div>
+ <div class="pages">
+ <table class="data">
+ <thead>
+ <th title="Číslo skenu">#
+ <th>Úloha
+ <th>Soutěžící
+ <th title="Stránka">St.
+ </thead>
+ <tbody id="pages_rows">
+ </tbody>
+ </table>
+ </div>
+ <div class="scan">
+ <div id="scan_loader"></div>
+ <img id="scan_img">
+ </div>
+</div>
+
+<script type="text/javascript">
+var tasks = [
+{% for task in tasks %}
+ { id: {{ task.task_id }}, code: "{{ task.code }}", name: "{{ task.name }}" },
+{% endfor %}
+];
+
+var users = [
+{% for pion in pions %}
+ { id: {{ pion.user_id }}, name: "{{ pion.user.first_name }} {{ pion.user.last_name }}" },
+{% endfor %}
+];
+
+var pages = [
+{% for page in pages %}
+ {
+ file_nr: {{ page.file_nr }},
+ page_nr: {{ page.page_nr }},
+ user_id: {{ page.user_id if page.user_id else 'null' }},
+ orig_user_id: {{ page.user_id if page.user_id else 'null' }},
+ task_id: {{ page.task_id if page.task_id else 'null' }},
+ orig_task_id: {{ page.task_id if page.task_id else 'null' }},
+ seq_id: {{ page.seq_id }},
+ orig_seq_id: {{ page.seq_id }},
+ img_full: "{{ png_full(page) }}",
+ img_small: "{{ png_small(page) }}",
+ },
+{% endfor %}
+];
+
+var tasks_map = {};
+var tasks_autocomplete = [];
+var users_map = {};
+var users_autocomplete = [];
+
+// Global elements
+var tbody = document.getElementById('pages_rows');
+var img = document.getElementById('scan_img');
+var loader = document.getElementById('scan_loader');
+var rows = tbody.rows;
+var user_input = document.getElementById('user_input');
+var task_input = document.getElementById('task_input');
+var seq_input = document.getElementById('seq_input');
+var process_all_button = document.getElementById('process-all-button');
+
+function isChanged(p) {return (p.orig_user_id != p.user_id || p.orig_task_id != p.task_id || p.orig_seq_id != p.seq_id); }
+function isOk(p) { return (p.user_id && p.task_id); }
+function isEmpty(p) { return (p.user_id == null && p.task_id == null); }
+
+function beforeUnload(e) {
+ e.preventDefault();
+ return e.returnValue = "Byly provedeny editace, opuštěním stránky je ztratíte. Skutečně opustit stránku?";
+}
+
+function setRow(i) {
+ p = pages[i];
+ row = rows[i];
+
+ row.innerHTML = '';
+ row.className = '';
+ row.title = '';
+ cellScan = row.insertCell(-1);
+ cellTask = row.insertCell(-1);
+ cellUser = row.insertCell(-1);
+ cellSeq = row.insertCell(-1);
+
+ cellScan.innerHTML = p.file_nr + '/' + p.page_nr;
+ if (p.task_id) {
+ task = tasks_map[p.task_id];
+ cellTask.innerHTML = task.code + ' ' + task.name;
+ }
+ if (p.user_id) {
+ user = users_map[p.user_id];
+ cellUser.innerHTML = user.name;
+ }
+ cellSeq.innerHTML = p.seq_id;
+
+ if (isOk(p)) {
+ row.classList.add('ok');
+ }
+ if (isEmpty(p)) {
+ row.classList.add('empty');
+ row.title += "Prázdná stránka, nebude se ukládat\n";
+ }
+
+ if (isChanged(p)) {
+ process_all_button.disabled = true;
+ window.onbeforeunload = beforeUnload;
+ row.classList.add('changed');
+ row.title += "Neuložené změny\n";
+ }
+}
+
+var activeRow = 0;
+function selectRow(i) {
+ page = pages[i];
+ user = null;
+
+ user_input.blur();
+
+ rows[activeRow].classList.remove('active');
+ rows[i].classList.add('active');
+ activeRow = i;
+
+ loader.style.opacity = 0.7;
+ img.onload = function() {
+ loader.style.opacity = 0;
+ if (page.user_id) {
+ user_input.value = users_map[page.user_id].name;
+ } else {
+ user_input.value = '';
+ }
+
+ if (page.task_id) {
+ task = tasks_map[page.task_id];
+ task_input.value = task.code + ' ' + task.name;
+ } else {
+ task_input.value = '';
+ }
+
+ seq_input.value = page.seq_id;
+ }
+ img.src = page.img_full;
+}
+
+function refreshActiveRow() {
+ setRow(activeRow);
+ selectRow(activeRow);
+}
+
+// Activate autocomplete on two fields + prevent catching keys on third one
+autocomplete(task_input, tasks_autocomplete, callback=function (id) {
+ pages[activeRow].task_id = id;
+ refreshActiveRow();
+});
+autocomplete(user_input, users_autocomplete, callback=function (id) {
+ pages[activeRow].user_id = id;
+ refreshActiveRow();
+});
+seq_input.addEventListener("keydown", function(e) {
+ if (e.keyCode == 27) { // escape
+ this.blur();
+ }
+ e.stopPropagation();
+});
+seq_input.addEventListener("input", function(e) {
+ pages[activeRow].seq_id = seq_input.value;
+ refreshActiveRow();
+});
+
+// Keyboard control
+function checkKey(e) {
+ e = e || window.event;
+ if (e.keyCode == '38') {
+ if (activeRow > 0) {
+ selectRow(activeRow - 1);
+ }
+ } else if (e.keyCode == '40') {
+ if (activeRow < pages.length - 1) {
+ selectRow(activeRow + 1);
+ }
+ } else if (e.key == 'e') {
+ user_input.focus();
+ } else if (e.key == 'x') {
+ page = pages[activeRow];
+ page.user_id = null;
+ page.task_id = null;
+ page.seq_id = null;
+ refreshActiveRow();
+ } else if (e.key == 'r') {
+ page = pages[activeRow];
+ page.user_id = page.orig_user_id;
+ page.task_id = page.orig_task_id;
+ page.seq_id = page.orig_seq_id;
+ refreshActiveRow();
+ } else if (e.key == 'f') {
+ if (activeRow > 1) {
+ prev = pages[activeRow-1];
+ page = pages[activeRow];
+ if (isOk(prev)) {
+ page.user_id = prev.user_id;
+ page.task_id = prev.task_id;
+ page.seq_id = prev.seq_id + 1;
+ refreshActiveRow();
+ } else {
+ alert('Nelze nastavit jako pokračování předchozí stránky, je nekompletní.');
+ }
+ }
+ } else {
+ return;
+ }
+ e.preventDefault();
+}
+document.onkeydown = checkKey;
+
+// Start everything :)
+
+for (task of tasks) {
+ tasks_map[task.id] = task; console.log(task);
+ tasks_autocomplete.push([task.id, task.code + ' ' + task.name]);
+}
+for (user of users) {
+ users_map[user.id] = user;
+ users_autocomplete.push([user.id, user.name]);
+}
+for (page of pages) { tbody.insertRow(); }
+for (let i = 0; i < pages.length; i++) {
+ setRow(i);
+ rows[i].onclick = function() { selectRow(i); }
+}
+
+selectRow(0);
+</script>
+
+{% endif %}
+{% endblock %}