diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 532815592c4c5b1635195607502f748b2e8b5415..4260a28de82988ba2ec04f17d4ab4cdbd98be9a4 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -3,13 +3,13 @@ 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 json
import locale
from markupsafe import Markup
from sqlalchemy import func, and_, select
from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.orm.query import Query
from sqlalchemy.dialects.postgresql import insert as pgsql_insert
-import sqlalchemy.sql.schema
from typing import Any, List, Tuple, Optional, Dict
import urllib.parse
import werkzeug.exceptions
@@ -1751,7 +1751,7 @@ def org_contest_scans(ct_id: int, site_id: Optional[int] = None):
in_file_names=[f.stream.name for f in files],
)
flash('Zpracování skenů zahájeno. Vyčkejte chvíli, než budou připraveny k roztřídění, poté je roztřiďte', 'success')
- return redirect(url_for('org_contest_scans_process', id=id, site_id=site_id, job_id=job_id))
+ return redirect(ctx.url_for('org_contest_scans_process', job_id=job_id))
jobs_query = sess.query(db.Job).filter_by(type=db.JobType.process_scans)
if not g.user.is_admin:
@@ -1779,6 +1779,12 @@ def org_contest_scans(ct_id: int, site_id: Optional[int] = None):
)
+class ScanProcessForm(FlaskForm):
+ data = wtforms.HiddenField()
+ save = wtforms.SubmitField()
+ process_all = wtforms.SubmitField()
+
+
@app.route('/org/contest/c/<int:ct_id>/scans/<int:job_id>', methods=('GET', 'POST', 'PUT'))
@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/scans/<int:job_id>', methods=('GET', 'POST', 'PUT'))
def org_contest_scans_process(ct_id: int, job_id: int, site_id: Optional[int] = None):
@@ -1804,17 +1810,108 @@ def org_contest_scans_process(ct_id: int, job_id: int, site_id: Optional[int] =
)
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()
+ pions: List[db.Participation] = pion_query.options(joinedload(db.Participation.user)).all()
+
+ # Vytvoříme si mapu stránek načtených z databáze, mapu uživatelů a úloh
+ p_map: Dict[Tuple[int, int], db.ScanPage] = {(p.file_nr, p.page_nr): p for p in pages}
+ pion_map: Dict[int, db.Participation] = {pion.user_id: pion for pion in pions}
+ task_map: Dict[int, db.Task] = {task.task_id: task for task in tasks}
+
+ print(pion_map)
+
+ # Uložení stránek z GUI
+ self_url = ctx.url_for('org_contest_scans_process', job_id=job_id)
+ process_form = ScanProcessForm()
+ if process_form.validate_on_submit() and process_form.save.data:
+ data: List = json.loads(process_form.data.data)
+ if len(data) == 0:
+ flash('Žádné změny k uložení', 'info')
+ return redirect(self_url)
+ count = 0
+ for p in data:
+ if 'file_nr' not in p or 'page_nr' not in p:
+ flash('Schází file_nr a page_nr, nelze zpracovat!', 'danger')
+ return redirect(self_url)
+ index = (p['file_nr'], p['page_nr'])
+ if 'user_id' not in p or 'task_id' not in p or 'seq_id' not in p:
+ flash(f'Schází user_id, task_id nebo seq_id u skenu {index}, nelze zpracovat!', 'danger')
+ return redirect(self_url)
+
+ user_id = p['user_id']
+ task_id = p['task_id']
+ seq_id = p['seq_id']
+ if seq_id is None:
+ seq_id = 0
+
+ if index not in p_map:
+ flash(f'Stránka {index} není v databázi, nebyla uložena', 'danger')
+ continue
+ if user_id is not None and user_id not in pion_map:
+ flash(f'Neexistující uživatel {user_id} u stránky {index}', 'danger')
+ continue
+ if task_id is not None and task_id not in task_map:
+ flash(f'Neexistující úloha {task_id} u stránky {index}', 'danger')
+ continue
+ if seq_id < -4:
+ flash(f'Schází číslo stránky u stránky {index}', 'danger')
+ continue
+
+ pp = p_map[index]
+ if pp.user_id != user_id or pp.task_id != task_id or pp.seq_id != seq_id:
+ pp.user_id = user_id
+ pp.task_id = task_id
+ pp.seq_id = seq_id
+ sess.add(pp)
+ count += 1
+ # neukládáme do databázového logu, zpracování skenů je jen dočasné
+ # a finální uložení založí nová řešení
+ sess.commit()
+ flash('Uloženy změny u ' + inflect_number(count, 'skenu', 'skenů', 'skenů'), 'success')
+ return redirect(self_url)
+
+ # Kontrola stavu
+ warnings: List[str] = []
+ errors: List[str] = []
+ seq_map: Dict[Tuple[int, int, int], db.ScanPage] = {}
+ sol_map: Dict[Tuple[int, int], bool] = {}
+ for p in pages:
+ if not p.is_ok() and not p.is_empty():
+ errors.append(f'Sken {p.file_nr}/{p.page_nr} není označen jako prázdný ani není správně určen!')
+ if p.is_ok():
+ index = (p.task_id, p.user_id, p.seq_id)
+ if index in seq_map:
+ pp = seq_map[index]
+ t = task_map[p.task_id]
+ u = pion_map[p.user_id].user
+ errors.append(
+ f'Duplicita - Sken {p.file_nr}/{p.page_nr} je označený jako {p.seq_id}. stránka řešení'
+ +f' úlohy {t.code} {t.name} soutěžícího {u.full_name()} ale'
+ +f' sken {pp.file_nr}/{pp.page_nr} je označený úplně stejně!'
+ )
+ else:
+ seq_map[index] = p
+ sol_map[(p.task_id, p.user_id)] = True
+ for task in tasks:
+ for pion in pions:
+ index = (task.task_id, pion.user_id)
+ if index not in sol_map:
+ warnings.append(f'Mezi skeny není řešení úlohy {task.code} {task.name} od {pion.user.full_name()}')
+
+ if process_form.validate_on_submit() and process_form.process_all.data:
+ if len(errors) > 0:
+ flash('Nelze zpracovat, dokud kontrola vrací chyby. Nejdříve je opravte.')
+ return redirect(self_url)
+ print("PROCESS")
def png_small(page: db.ScanPage) -> str:
- return url_for(
- 'org_contest_scans_file', id=id, job_id=job_id, site_id=site_id,
+ return ctx.url_for(
+ 'org_contest_scans_file', job_id=job_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,
+ return ctx.url_for(
+ 'org_contest_scans_file', job_id=job_id,
file=f'p-{page.file_nr:02d}-{page.page_nr:04d}-full.png'
)
@@ -1825,6 +1922,9 @@ def org_contest_scans_process(ct_id: int, job_id: int, site_id: Optional[int] =
tasks=tasks,
pions=pions,
pages=pages,
+ process_form=process_form,
+ errors=errors,
+ warnings=warnings,
png_small=png_small,
png_full=png_full,
)
diff --git a/mo/web/templates/org_contest_scans_process.html b/mo/web/templates/org_contest_scans_process.html
index f0f4a1f8c7414bc4a2704acafd63bf44c8fe7c81..60d3ecfca79adc0cb00e2f80813cfebe902b6427 100644
--- a/mo/web/templates/org_contest_scans_process.html
+++ b/mo/web/templates/org_contest_scans_process.html
@@ -28,11 +28,45 @@ je nějaké řešení přes více stránek, musí na sebe navazovat číslován
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>
+{% if errors or warnings %}
+<div class="collapsible">
+ {% set error_count = errors | count %}
+ {% set warning_count = warnings | count %}
+
+ <input type="checkbox" class="toggle" id="messages-toggle" {% if errors %}checked{% endif %}>
+ <label for="messages-toggle" class="toggle">
+ Kontrola třídění (
+ {%- if warning_count > 0 %}{{ warning_count }} varování{% endif -%}
+ {%- if warning_count > 0 and error_count > 0 %}, {% endif %}
+ {%- if error_count > 0 %}<span class="error">{{ error_count|inflected('chyba', 'chyby', 'chyb') }}</span>{% endif -%}
+ )
+ </label>
+ <div class="collapsible-inner">
+ <div class="alert alert-warning">
+ <ul>
+ {% for msg in warnings %}
+ <li>Varování: {{ msg }}
+ {% endfor %}
+ {% for msg in errors %}
+ <li class="error"><b>Chyba: {{ msg }}</b>
+ {% endfor %}
+ </ul></div>
+ </div>
+ </div>
+</div>
+{% else %}
+<p><span class="text-success">Skeny zkontrolovány, nenalezeny žádné chyby ani varování.</span></p>
+{% endif %}
<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.');">
+ <input class="btn btn-primary" type="submit" name="save" value="Uložit" id="save-button" onclick="saveData()">
+ {{ process_form.csrf_token }}
+ <input type="hidden" name="data" id="save-data">
+ {% if errors %}
+ <input class="btn btn-primary" type="submit" value="Ukončit a zpracovat" title="Kontrola ukázala chyby, nejdříve je opravte a uložte pomocí [Uložit]" disabled>
+ {% else %}
+ <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.');">
+ {% endif %}
</form>
<style>
@@ -81,7 +115,8 @@ tuto akci nelze vzít zpět).
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.empty { background-color: #aabbff; }
+ #sort_wrapper div.pages table tr.error { background-color: #ff7777; }
#sort_wrapper div.pages table tr.changed { background-color: yellow; }
#sort_wrapper div.pages table tr.active {
color: white;
@@ -90,9 +125,9 @@ tuto akci nelze vzít zpět).
#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é.
+<p><b>Ovládání klávesnicí:</b> <b><code>↑</code></b> a <b><code>↓</code></b> – posun ve skenech, <b><code>e</code></b> – přepnutí se na editaci,
+<b><code>[esc]</code></b> – vyskočení z editačního políčka při editaci, <b><code>r</code></b> – reset (vrácení) změn u konkrétního skenu,
+<b><code>x</code></b> – nastavit stránku jako prázdnou, <b><code>f</code></b> – nastavit stránku jako pokračování minulé.
<div id="sort_wrapper">
<div class="controls form-horizontal">
@@ -177,16 +212,46 @@ 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');
+var save_data_field = document.getElementById('save-data');
+
+PAGE_FIX = -1;
+PAGE_EMPTY = -2;
+PAGE_CONTINUE = -3;
+PAGE_UFO = -4;
-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 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 && p.seq_id >= 0); }
+function isEmpty(p) { return (p.user_id == null && p.task_id == null && p.seq_id == PAGE_EMPTY); }
+function isError(p) { return (!isEmpty(p) && !isOk(p)); }
+function anyChanged(p) { return pages.some(isChanged); }
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 refreshButtons() {
+ if (anyChanged()) {
+ if (process_all_button) {
+ process_all_button.disabled = true;
+ process_all_button.title = "Neuložené změny, nejdříve je uložte vedlejším tlačítkem";
+ }
+ window.onbeforeunload = beforeUnload;
+ } else {
+ if (process_all_button) {
+ process_all_button.disabled = false;
+ process_all_button.title = '';
+ }
+ window.onbeforeunload = null;
+ }
+}
+
+function saveData() {
+ data = pages.filter(isChanged).map(p => ({file_nr: p.file_nr, page_nr: p.page_nr, user_id: p.user_id, task_id: p.task_id, seq_id: p.seq_id}));
+ save_data_field.value = JSON.stringify(data);
+ window.onbeforeunload = null;
+}
+
function setRow(i) {
p = pages[i];
row = rows[i];
@@ -208,7 +273,18 @@ function setRow(i) {
user = users_map[p.user_id];
cellUser.innerHTML = user.name;
}
- cellSeq.innerHTML = p.seq_id;
+ switch (p.seq_id) {
+ case PAGE_EMPTY:
+ cellSeq.innerHTML = '';
+ break
+ case PAGE_FIX:
+ case PAGE_CONTINUE:
+ case PAGE_UFO:
+ cellSeq.innerHTML = '?';
+ break;
+ default:
+ cellSeq.innerHTML = p.seq_id;
+ }
if (isOk(p)) {
row.classList.add('ok');
@@ -217,13 +293,16 @@ function setRow(i) {
row.classList.add('empty');
row.title += "Prázdná stránka, nebude se ukládat\n";
}
+ if (isError(p)) {
+ row.classList.add('error');
+ row.title += "Neprázdná nerozpoznaná stránka, potřebuje opravit\n";
+ }
if (isChanged(p)) {
- process_all_button.disabled = true;
- window.onbeforeunload = beforeUnload;
row.classList.add('changed');
row.title += "Neuložené změny\n";
}
+ refreshButtons();
}
var activeRow = 0;
@@ -252,8 +331,11 @@ function selectRow(i) {
} else {
task_input.value = '';
}
-
- seq_input.value = page.seq_id;
+ if (page.seq_id >= 0) {
+ seq_input.value = page.seq_id;
+ } else {
+ seq_input.value = '';
+ }
}
img.src = page.img_full;
}
@@ -265,21 +347,24 @@ function refreshActiveRow() {
// Activate autocomplete on two fields + prevent catching keys on third one
autocomplete(task_input, tasks_autocomplete, callback=function (id) {
- pages[activeRow].task_id = id;
+ pages[activeRow].task_id = parseInt(id);
refreshActiveRow();
});
autocomplete(user_input, users_autocomplete, callback=function (id) {
- pages[activeRow].user_id = id;
+ pages[activeRow].user_id = parseInt(id);
refreshActiveRow();
});
+seq_input.addEventListener("focus", closeAllAutocomplete);
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;
+seq_input.addEventListener("blur", function(e) {
+ x = parseInt(seq_input.value);
+ if (Number.isNaN(x)) x = 0
+ pages[activeRow].seq_id = x;
refreshActiveRow();
});
@@ -289,10 +374,12 @@ function checkKey(e) {
if (e.keyCode == '38') {
if (activeRow > 0) {
selectRow(activeRow - 1);
+ rows[activeRow].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else if (e.keyCode == '40') {
if (activeRow < pages.length - 1) {
selectRow(activeRow + 1);
+ rows[activeRow].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else if (e.key == 'e') {
user_input.focus();
@@ -300,7 +387,7 @@ function checkKey(e) {
page = pages[activeRow];
page.user_id = null;
page.task_id = null;
- page.seq_id = null;
+ page.seq_id = PAGE_EMPTY;
refreshActiveRow();
} else if (e.key == 'r') {
page = pages[activeRow];
@@ -331,7 +418,7 @@ document.onkeydown = checkKey;
// Start everything :)
for (task of tasks) {
- tasks_map[task.id] = task; console.log(task);
+ tasks_map[task.id] = task;
tasks_autocomplete.push([task.id, task.code + ' ' + task.name]);
}
for (user of users) {