From 1ef925fea1fc3c1129acce1d6aa1673840396e8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ji=C5=99=C3=AD=20Setni=C4=8Dka?= <setnicka@seznam.cz>
Date: Sun, 5 Sep 2021 23:40:33 +0200
Subject: [PATCH] =?UTF-8?q?T=C5=99=C3=ADd=C4=9Bn=C3=AD=20sken=C5=AF=20-=20?=
 =?UTF-8?q?dolad=C4=9Bn=C3=AD=20UI=20+=20ukl=C3=A1d=C3=A1n=C3=AD=20zm?=
 =?UTF-8?q?=C4=9Bn=20t=C5=99=C3=ADd=C4=9Bn=C3=AD=20do=20datab=C3=A1ze?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_contest.py                         | 114 +++++++++++++++-
 .../templates/org_contest_scans_process.html  | 129 +++++++++++++++---
 2 files changed, 215 insertions(+), 28 deletions(-)

diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 53281559..4260a28d 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 f0f4a1f8..60d3ecfc 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) {
-- 
GitLab