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