diff --git a/bin/publish-score b/bin/publish-score index d4b8ede92aec120105a8a7423abd0443938cc683..a9d23749197f0376b63f8317b9a8fe1c5c0ec658 100755 --- a/bin/publish-score +++ b/bin/publish-score @@ -46,7 +46,7 @@ for round in rounds: print('Publikoval bych') else: print('Publikuji') - mo.jobs.score.schedule_export_score_to_mo_web(contest.scoretable) + mo.jobs.score.schedule_export_score_to_mo_web(contest, contest.scoretable) if not args.dry_run: sess.commit() diff --git a/db/db.ddl b/db/db.ddl index 7b3dc02526a384c48b54481f366ea20035a5e8e1..61ca36ae5491f129486101c66dca35f48509da5a 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -354,6 +354,7 @@ CREATE TABLE jobs ( job_id serial PRIMARY KEY, type job_type NOT NULL, state job_state NOT NULL, + priority int NOT NULL DEFAULT 0, -- vyšší číslo = vyšší priorita user_id int NOT NULL REFERENCES users(user_id), -- komu patří created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, -- kdy byl založen finished_at timestamp with time zone DEFAULT NULL, -- kdy doběhl diff --git a/db/upgrade-20221008.sql b/db/upgrade-20221008.sql new file mode 100644 index 0000000000000000000000000000000000000000..f682a3ff3accc809c206e084d56c055fcebe41cf --- /dev/null +++ b/db/upgrade-20221008.sql @@ -0,0 +1,4 @@ +SET ROLE 'mo_osmo'; + +ALTER TABLE jobs ADD COLUMN + priority int NOT NULL DEFAULT 0; diff --git a/etc/uwsgi.ini.example b/etc/uwsgi.ini.example index 3a135d82be442be4d809fa548b1312fce0266cb4..d0819595522464902b4c56270c6179919689e955 100644 --- a/etc/uwsgi.ini.example +++ b/etc/uwsgi.ini.example @@ -25,6 +25,7 @@ plugin = python3 virtualenv = venv manage-script-name = true mule +mule # Můžeme také pomocí uwsgi servírovat static. Lepší je to dělat Nginxem. # static-map = /static=static diff --git a/mo/db.py b/mo/db.py index 22b86d31b95147281c590da6f79e0d4e0d94ad3a..fde19ad219940d724b3ec620e9201972ecd7992f 100644 --- a/mo/db.py +++ b/mo/db.py @@ -799,6 +799,7 @@ class Job(Base): job_id = Column(Integer, primary_key=True, server_default=text("nextval('jobs_job_id_seq'::regclass)")) type = Column(Enum(JobType, name='job_type'), nullable=False) state = Column(Enum(JobState, name='job_state'), nullable=False) + priority = Column(Integer, nullable=False, server_default=text("0")) user_id = Column(Integer, ForeignKey('users.user_id'), nullable=False) description = Column(Text, nullable=False, server_default=text("''::text")) result = Column(Text, nullable=False, server_default=text("''::text")) diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py index 67bff065b55cdb901fbc81829f5a232097848721..3775fc371bfcff8c03f42d7973154069f6bd149f 100644 --- a/mo/jobs/__init__.py +++ b/mo/jobs/__init__.py @@ -16,7 +16,7 @@ import mo.util from mo.util import logger, ExceptionInfo -def send_notify(): +def send_notify(priority: int): """Pošle notifikaci procesu, který zpracovává joby. Běžíme-li jako součást webu, init webu tuto funkci vymění.""" logger.debug('Job: Není komu poslat notifikaci') @@ -42,8 +42,8 @@ class TheJob: self.job = sess.query(db.Job).with_for_update().get(self.job_id) return self.job - def create(self, type: db.JobType, for_user: db.User) -> db.Job: - self.job = db.Job(type=type, state=db.JobState.preparing, user=for_user) + def create(self, type: db.JobType, for_user: db.User, priority: int = 0) -> db.Job: + self.job = db.Job(type=type, state=db.JobState.preparing, user=for_user, priority=priority) # Do DB přidáváme nehotový job, protože potřebujeme znát job_id pro založení adresáře sess = db.get_session() @@ -73,7 +73,7 @@ class TheJob: def submit(self): self.job.state = db.JobState.ready db.get_session().commit() - send_notify() + send_notify(self.job.priority) def _finish_remove(self): sess = db.get_session() @@ -184,7 +184,7 @@ class TheJob: mo.email.send_internal_error_email(f'Job #{job.job_id}', err_attrs, exc_info) -def process_jobs(): +def process_jobs(min_priority: int = 0): sess = db.get_session() assert hasattr(mo, 'now'), 'mo.now není nastaveno' @@ -207,10 +207,11 @@ def process_jobs(): # Probereme joby, které by měly běžet ready = (sess.query(db.Job.job_id) .filter_by(state=db.JobState.ready) + .filter(db.Job.priority >= min_priority) .order_by(db.Job.created_at) .all()) sess.rollback() - for job_id in ready: + for job_id, in ready: tj = TheJob(job_id) tj.run() diff --git a/mo/jobs/protocols.py b/mo/jobs/protocols.py index c7120ee3b56af07e4b8237384587efbf1959fa11..3ba6498f3076aa9eabc70ef11739709500a80bc4 100644 --- a/mo/jobs/protocols.py +++ b/mo/jobs/protocols.py @@ -37,7 +37,7 @@ import mo.util_format # -def schedule_create_protocols(contest: db.Contest, site: Optional[db.Place], for_user: db.User, tasks: List[db.Task], num_universal: int, num_blank: int): +def schedule_create_protocols(contest: db.Contest, site: Optional[db.Place], for_user: db.User, tasks: List[db.Task], num_universal: int, num_blank: int) -> int: place = site or contest.place the_job = TheJob() @@ -51,6 +51,8 @@ def schedule_create_protocols(contest: db.Contest, site: Optional[db.Place], for 'num_blank': num_blank, } the_job.submit() + assert the_job.job_id is not None + return the_job.job_id def _get_user_id_query(contest: db.Contest, site_id: Optional[int]) -> Query: @@ -413,6 +415,7 @@ def schedule_sort_scans(job_id: int, for_user: db.User) -> int: job.description = f'Rozdělení již roztříděných skenů {scans_desc} {contest.round.round_code_short()}' the_job.submit() + assert the_job.job_id is not None return the_job.job_id diff --git a/mo/jobs/score.py b/mo/jobs/score.py index d8f19c6a9228ee5ad03892e2022d71c6594d26a0..8471aad3a669e772f57dee5a17a9f2cf950121b4 100644 --- a/mo/jobs/score.py +++ b/mo/jobs/score.py @@ -30,12 +30,14 @@ from mo.util_format import format_decimal # -def schedule_snapshot_score(contest: db.Contest, for_user: db.User, snapshot_note: str) -> None: +def schedule_snapshot_score(contest: db.Contest, for_user: db.User, snapshot_note: str) -> int: the_job = TheJob() - job = the_job.create(db.JobType.snapshot_score, for_user) - job.description = 'Zmrazení aktuálního stavu výsledkové listiny' + job = the_job.create(db.JobType.snapshot_score, for_user, priority=1) + job.description = f'Zmrazení aktuálního stavu výsledkové listiny {contest.round.round_code_short()} {contest.place.name_locative()}' job.in_json = {"contest_id": contest.contest_id, "snapshot_note": snapshot_note} the_job.submit() + assert the_job.job_id is not None + return the_job.job_id class OrderKeyEncoder(json.JSONEncoder): @@ -236,6 +238,7 @@ def handle_snapshot_score(the_job: TheJob): ) sess.commit() + job.out_json = {'scoretable_id': score_table.scoretable_id} job.result = 'Zmrazení výsledkové listiny: Hotovo' @@ -263,10 +266,10 @@ def api_params(contest: db.Contest) -> Dict[str, Any]: } -def schedule_export_score_to_mo_web(score_table: db.ScoreTable) -> None: +def schedule_export_score_to_mo_web(contest: db.Contest, score_table: db.ScoreTable) -> None: the_job = TheJob() - job = the_job.create(db.JobType.export_score_to_mo_web, db.get_system_user()) - job.description = 'Publikování výsledkové listiny na webu MO' + job = the_job.create(db.JobType.export_score_to_mo_web, db.get_system_user(), priority=1) + job.description = f'Publikování výsledkové listiny {contest.round.round_code_short()} {contest.place.name_locative()} na webu MO' job.in_json = {"scoretable_id": score_table.scoretable_id} the_job.submit() @@ -423,8 +426,8 @@ def handle_export_score_to_mo_web(the_job: TheJob): def schedule_revert_export_score_to_mo_web(contest: db.Contest) -> None: the_job = TheJob() - job = the_job.create(db.JobType.revert_export_score_to_mo_web, db.get_system_user()) - job.description = 'Zrušení publikování výsledkové listiny na webu MO' + job = the_job.create(db.JobType.revert_export_score_to_mo_web, db.get_system_user(), priority=1) + job.description = f'Zrušení publikování výsledkové listiny {contest.round.round_code_short()} {contest.place.name_locative()} na webu MO' job.in_json = {"contest_id": contest.contest_id} the_job.submit() diff --git a/mo/jobs/submit.py b/mo/jobs/submit.py index 57ca8b41d57f720dbcab1fdd832bc78b510800da..fbc8bab34cafa8642e4950b024b32070bbb338cd 100644 --- a/mo/jobs/submit.py +++ b/mo/jobs/submit.py @@ -35,12 +35,14 @@ from mo.util_format import inflect_number, inflect_by_number, data_size # -def schedule_download_submits(paper_ids: List[int], description: str, for_user: db.User, want_subdirs: bool, out_name: str): +def schedule_download_submits(paper_ids: List[int], description: str, for_user: db.User, want_subdirs: bool, out_name: str) -> int: the_job = TheJob() job = the_job.create(db.JobType.download_submits, for_user) job.description = description job.in_json = {'papers': paper_ids, 'want_subdirs': want_subdirs, 'out_name': out_name} the_job.submit() + assert the_job.job_id is not None + return the_job.job_id @job_handler(db.JobType.download_submits) @@ -109,7 +111,7 @@ def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, f only_contest: Optional[db.Contest], only_site: Optional[db.Place], only_region: Optional[db.Place], - only_task: Optional[db.Task]): + only_task: Optional[db.Task]) -> int: the_job = TheJob() job = the_job.create(db.JobType.upload_feedback, for_user) job.description = description @@ -122,6 +124,8 @@ def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, f } job.in_file = the_job.attach_file(tmp_file, 'upload.zip') the_job.submit() + assert the_job.job_id is not None + return the_job.job_id @dataclass diff --git a/mo/web/__init__.py b/mo/web/__init__.py index 505bc1a278438d5719479319689f3abc9903cbaf..a8d42d3bc80c238eba3244b5b8f0391e60f4ca6a 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -218,17 +218,22 @@ try: with app.app_context(): collect_garbage() - # Obykle při vložení jobu dostaneme signál. - @signal(42, target='mule') + # Obvykle při vložení jobu dostaneme signál. + @signal(42, target='mule1') # prioritní mule + @signal(43, target='mule2') # normální mule def mule_signal(signum): app.logger.debug('Mule: Přijat signál') with app.app_context(): mo.now = mo.util.get_now() - mo.jobs.process_jobs() - - def wake_up_mule(): - app.logger.debug('Mule: Posíláme signál') - uwsgi.signal(42) + mo.jobs.process_jobs(min_priority=1 if signum == 42 else 0) + + def wake_up_mule(priority: int): + if priority > 0: + app.logger.debug('Mule: Posíláme signál prioritní mule') + uwsgi.signal(42) + else: + app.logger.debug('Mule: Posíláme signál normální mule') + uwsgi.signal(43) have_uwsgi = True mo.jobs.send_notify = wake_up_mule diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index f9015ea49eff73dcf81bb1f2fb0d90b1fa85a746..940f0880b40cb6df43c409c2e3d079d4edf2469d 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -1228,9 +1228,9 @@ class DownloadSubmitsForm(FlaskForm): download_fb_mix = wtforms.SubmitField('Stáhnout opravená/účastnická') -def download_submits(form: DownloadSubmitsForm, round: db.Round, sol_query, pion_query, out_name: str, subj_suffix: str, want_subdirs: bool) -> bool: +def download_submits(form: DownloadSubmitsForm, round: db.Round, sol_query, pion_query, out_name: str, subj_suffix: str, want_subdirs: bool) -> Tuple[bool, int]: if not form.validate_on_submit(): - return False + return (False, 0) sols = sol_query.all() @@ -1258,12 +1258,12 @@ def download_submits(form: DownloadSubmitsForm, round: db.Round, sol_query, pion paper_ids = [s.final_feedback or s.final_submit for s in sols] subj_prefix = 'Opravená/odevzdaná' else: - return False + return (False, 0) paper_ids = [p for p in paper_ids if p is not None] - mo.jobs.submit.schedule_download_submits(paper_ids, f'{subj_prefix} {subj_suffix}', g.user, want_subdirs, out_name) + job_id = mo.jobs.submit.schedule_download_submits(paper_ids, f'{subj_prefix} {subj_suffix}', g.user, want_subdirs, out_name) flash('Příprava řešení ke stažení zahájena.', 'success') - return True + return (True, job_id) @app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/download', methods=('GET', 'POST')) @@ -1302,8 +1302,9 @@ def org_generic_batch_download(task_id: int, round_id: Optional[int] = None, hie elif hier_place is not None: subj = f'{subj} ({hier_place.name})' out_name = f'reseni_{task.code}' - if download_submits(form, round, sol_query, pion_query, out_name, subj, contest is None): - return redirect(url_for('org_jobs')) + (do_download, job_id) = download_submits(form, round, sol_query, pion_query, out_name, subj, contest is None) + if do_download: + return redirect(url_for('org_job_wait', id=job_id, back=ctx.url_for('org_generic_batch_download'))) sol_paper = aliased(db.Paper) fb_paper = aliased(db.Paper) @@ -1354,10 +1355,10 @@ def org_generic_batch_upload(task_id: int, round_id: Optional[int] = None, hier_ if form.validate_on_submit(): file = form.file.data.stream - mo.jobs.submit.schedule_upload_feedback(round, file.name, f'Nahrání opravených řešení {round.round_code()}', - for_user=g.user, - only_contest=contest, only_site=site, only_region=hier_place, only_task=task) - return redirect(url_for('org_jobs')) + job_id = mo.jobs.submit.schedule_upload_feedback(round, file.name, f'Nahrání opravených řešení {round.round_code()}', + for_user=g.user, + only_contest=contest, only_site=site, only_region=hier_place, only_task=task) + return redirect(url_for('org_job_wait', id=job_id, back=ctx.url_for('org_generic_batch_upload'))) return render_template( 'org_generic_batch_upload.html', @@ -1831,14 +1832,14 @@ def org_contest_protocols(ct_id: int, site_id: Optional[int] = None): gen_task_fields = [f for f in gen_form if f.name.startswith('task_')] if gen_form.validate_on_submit() and gen_form.gen_protos.data: - mo.jobs.protocols.schedule_create_protocols( + job_id = mo.jobs.protocols.schedule_create_protocols( contest, site, g.user, tasks=[t for t in tasks if getattr(gen_form, f'task_{t.task_id}').data], num_universal=gen_form.num_universal.data, num_blank=gen_form.num_blank.data, ) - flash('Výroba prototokolů zahájena.', 'success') - return redirect(url_for('org_jobs')) + flash('Výroba protokolů zahájena.', 'success') + return redirect(url_for('org_job_wait', id=job_id, back=ctx.url_for('org_contest_protocols'))) return render_template( 'org_contest_protocols.html', @@ -1885,13 +1886,13 @@ def org_contest_scans(ct_id: int, site_id: Optional[int] = None): if proc_form.validate_on_submit() and proc_form.process_scans.data: files = request.files.getlist(proc_form.files.name) if check_scan_files(files): - mo.jobs.protocols.schedule_process_scans( + job_id = mo.jobs.protocols.schedule_process_scans( contest, site, proc_form.scans_type.data, g.user, tasks=[t for t in tasks if getattr(proc_form, f'task_{t.task_id}').data], in_file_names=[f.stream.name for f in files], ) flash('Zpracování skenů zahájeno. Vyčkejte chvíli, než budou připraveny, a poté je roztřiďte.', 'success') - return redirect(url_for('org_jobs', back=ctx.url_for('org_contest_scans'))) + return redirect(url_for('org_job_wait', id=job_id, back=ctx.url_for('org_contest_scans'))) jobs_query = sess.query(db.Job).filter_by(type=db.JobType.process_scans) if not g.user.is_admin: @@ -1943,7 +1944,7 @@ def org_contest_scans_process(ct_id: int, job_id: int, site_id: Optional[int] = if job.state != db.JobState.done: flash('Počkejte prosím, až dávka naskenovaných úloh doběhne.', 'danger') - return redirect(url_for('org_jobs')) + return redirect(url_for('org_job_wait', id=job.id)) if 'type' in job.in_json and job.in_json['type'] == 'feedback': scans_type = 'feedback' @@ -2054,9 +2055,9 @@ def org_contest_scans_process(ct_id: int, job_id: int, site_id: Optional[int] = if len(errors) > 0: flash('Nelze zpracovat, dokud kontrola vrací chyby. Nejdříve je opravte.') return redirect(self_url) - mo.jobs.protocols.schedule_sort_scans(job_id, for_user=g.user) + job_id = mo.jobs.protocols.schedule_sort_scans(job_id, for_user=g.user) flash('Skeny zařazeny ke zpracování, během několika chvil se uloží k soutěžícím.', 'success') - return redirect(url_for('org_jobs')) + return redirect(url_for('org_job_wait', id=job_id)) def png_small(page: db.ScanPage) -> str: return ctx.url_for( diff --git a/mo/web/org_jobs.py b/mo/web/org_jobs.py index 5018eeab4562c44fdc1497b4cf96f475a3353e02..27665fbd6e8d6ce14166071180951019e50411d6 100644 --- a/mo/web/org_jobs.py +++ b/mo/web/org_jobs.py @@ -2,7 +2,7 @@ from flask import render_template, g, redirect, url_for, flash, request from flask_wtf.form import FlaskForm import os from sqlalchemy.orm import joinedload -from typing import Optional +from typing import Optional, Tuple import werkzeug.exceptions import wtforms @@ -15,9 +15,44 @@ import mo.web.util class JobDeleteForm(FlaskForm): delete_job_id = wtforms.IntegerField() + back_url = wtforms.HiddenField() delete = wtforms.SubmitField('Smazat') +def org_job_get_result_url(job: db.Job) -> Tuple[Optional[str], Optional[str]]: + if job.state != db.JobState.done: + return None, None + + jin = job.in_json + jout = job.out_json + + if job.type == db.JobType.snapshot_score and 'contest_id' in jin and 'scoretable_id' in jout: + return "Výsledková listina", url_for( + 'org_score_snapshot', ct_id=jin['contest_id'], scoretable_id=jout['scoretable_id'] + ) + + elif job.type == db.JobType.upload_feedback and 'only_task_id' in jin and 'only_contest_id' in jin: + # task_id je nastavené vždy, ale dá se nahrávat ze stránky kola i soutěže + if jin['only_contest_id'] is not None: + return "Detail úlohy", url_for( + 'org_contest_task', ct_id=jin['only_contest_id'], site_id=jin['only_site_id'], task_id=jin['only_task_id'] + ) + else: + return "Stránka kola", url_for('org_round', round_id=jin['round_id'], hier_id=jin['only_hier_id']) + + elif job.type == db.JobType.process_scans and 'contest_id' in jin and 'site_id' in jin: + return "Roztřídit skeny", url_for( + 'org_contest_scans_process', ct_id=jin['contest_id'], site_id=jin['site_id'], job_id=job.job_id + ) + + elif job.type == db.JobType.sort_scans and 'contest_id' in jin and 'site_id' in jin: + return "Stránka soutěže", url_for( + 'org_contest', ct_id=jin['contest_id'], site_id=jin['site_id'], job_id=job.job_id + ) + + return None, None + + @app.route('/org/jobs/', methods=('GET', 'POST')) def org_jobs(): sess = db.get_session() @@ -35,6 +70,8 @@ def org_jobs(): else: tj.remove_loaded() flash('Dávka smazána', 'success') + if form_delete_job.back_url.data: + return redirect(form_delete_job.back_url.data) return redirect(url_for('org_jobs')) job_query = (sess.query(db.Job) @@ -49,6 +86,7 @@ def org_jobs(): jobs=jobs, form_delete_job=form_delete_job, back_url=request.args.get('back'), + get_result_url=org_job_get_result_url, ) @@ -81,15 +119,48 @@ def org_job(id: int): and isinstance(job.out_json, dict) and 'errors' in job.out_json) + (result_url_action, result_url) = org_job_get_result_url(job) + return render_template( 'org_job.html', job=job, has_errors=has_errors, in_size=job_file_size(job, job.in_file), out_size=job_file_size(job, job.out_file), + result_url=result_url, + result_url_action=result_url_action, + ) + + +@app.route('/org/jobs/<int:id>/wait') +def org_job_wait(id: int): + try: + job = get_job(id) + except werkzeug.exceptions.NotFound: + flash('Dávka neexistuje, pravděpodobně již byla smazána', 'danger') + return redirect(url_for('org_jobs')) + + (_, result_url) = org_job_get_result_url(job) + if result_url: + flash(f'Dávka "{job.description}" dokončena s výsledkem: {job.result}', 'success') + return redirect(result_url) + + form_delete_job = JobDeleteForm() + + return render_template( + 'org_job_wait.html', + job=job, + form_delete_job=form_delete_job, + back_url=request.args.get('back'), ) +@app.route('/org/jobs/<int:id>/state') +def org_job_state(id: int): + job = get_job(id) + return job.state + + @app.route('/org/jobs/<int:id>/output') def org_job_output(id: int): job = get_job(id) diff --git a/mo/web/org_score.py b/mo/web/org_score.py index e4b8995d5e9a4ebbccee29fd69292abfa199d994..04feb0221e3d54f48a8be5c994bf3356ea4bce43 100644 --- a/mo/web/org_score.py +++ b/mo/web/org_score.py @@ -1,15 +1,14 @@ -import decimal from flask import g, render_template, request from flask.helpers import flash, url_for -from typing import Iterable, List, Optional, Tuple, Union +from typing import List, Optional, Union from flask_wtf.form import FlaskForm -import json import werkzeug.exceptions from werkzeug.utils import redirect import wtforms import mo import mo.db as db +import mo.jobs.score from mo.rights import Right from mo.score import Score from mo.web import app @@ -262,6 +261,7 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_ class SetFinalScoretableForm(FlaskForm): scoretable_id = wtforms.IntegerField() + back_url = wtforms.HiddenField() submit_set_final = wtforms.SubmitField("Zveřejnit") submit_hide = wtforms.SubmitField("Skrýt") @@ -284,8 +284,8 @@ def org_score_snapshots(ct_id: int): snapshot_form = ScoreSnapshotForm() if snapshot_form.validate_on_submit() and snapshot_form.submit_snapshot.data: - mo.jobs.score.schedule_snapshot_score(ctx.contest, g.user, snapshot_form.note.data) - return redirect(url_for('org_jobs')) + job_id = mo.jobs.score.schedule_snapshot_score(ctx.contest, g.user, snapshot_form.note.data) + return redirect(url_for('org_job_wait', id=job_id, back=ctx.url_for('org_score'))) set_final_form = SetFinalScoretableForm() if set_final_form.validate_on_submit(): @@ -297,7 +297,7 @@ def org_score_snapshots(ct_id: int): break if found and set_final_form.submit_set_final: ctx.contest.scoretable_id = scoretable_id - mo.jobs.score.schedule_export_score_to_mo_web(scoretable) + mo.jobs.score.schedule_export_score_to_mo_web(ctx.contest, scoretable) mo.util.log( type=db.LogType.contest, what=ctx.contest.contest_id, @@ -324,6 +324,8 @@ def org_score_snapshots(ct_id: int): flash("Výsledková listina skryta.", "success") else: flash("Neznámé ID výsledkové listiny.", "danger") + if set_final_form.back_url.data: + return redirect(set_final_form.back_url.data) return redirect(ctx.url_for('org_score_snapshots')) return render_template( @@ -383,6 +385,7 @@ def org_score_snapshot(ct_id: int, scoretable_id: int): 'org_score_snapshot.html', ctx=ctx, table=table, + set_final_form=SetFinalScoretableForm() if ctx.rights.have_right(Right.manage_contest) else None, scoretable=scoretable, ) else: diff --git a/mo/web/templates/org_job.html b/mo/web/templates/org_job.html index 6dc621b7237506c9c65061cc7307cee06bf43fd5..1f90bb171db93c7e4a718d0ee0a8263743d4f26c 100644 --- a/mo/web/templates/org_job.html +++ b/mo/web/templates/org_job.html @@ -34,8 +34,8 @@ {% if job.out_file %} <a class='btn btn-primary' href='{{ url_for('org_job_output', id=job.job_id) }}'>Stáhnout výstup</a> -{% elif job.type == 'process_scans' and job.state == JobState.done and 'contest_id' in job.in_json and 'site_id' in job.in_json %} - <a class='btn btn-primary' href='{{ url_for('org_contest_scans_process', job_id=job.job_id, ct_id=job.in_json['contest_id'], site_id=job.in_json['site_id']) }}'>Roztřídit skeny</a> +{% elif result_url %} + <a class='btn btn-primary' href='{{ result_url }}'>{{ result_url_action }}</a> {% endif %} {% endblock %} diff --git a/mo/web/templates/org_job_wait.html b/mo/web/templates/org_job_wait.html new file mode 100644 index 0000000000000000000000000000000000000000..dbcbbd1d3a5d17ce5de3788163004d886b45b16c --- /dev/null +++ b/mo/web/templates/org_job_wait.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Zpracování dávky – {{ job.description }}{% endblock %} +{% block body %} + +{% if job.state in (JobState.failed, JobState.internal_error) %} + <p class="text-center">Stav: <b>{{ job.state.friendly_name() }}</b></p> + + <p>Při zpracování dávky bohužel došlo k chybám a nemohla být dokončena. + Kontaktujte prosím správce na adrese {{ config.MAIL_CONTACT|mailto }}.</p> + + {% if job.out_json and 'errors' in job.out_json %} + <h3>Chyby:</h3> + <div class="alert alert-danger" role="alert" style="white-space: pre-line">{{ "" -}} + {% for e in job.out_json['errors'] %} + {{ e }} + {% endfor %} + </div> + {% endif %} + +{% elif job.state == JobState.done and job.out_file %} + <div class="loading"> + <div class="done">✓</div> + <div class="caption">Stav: <b>{{ job.state.friendly_name() }}</b></div> + </div> + + <p>Dávka dokončena, výstup si můžete stáhnout kliknutím níže{% if back_url %} a pak se vrátit na původní stránku{% endif %}. + K souboru se můžete vrátit také přes záložku <a href="{{ url_for('org_jobs') }}">Dávky</a>.</p> + + <p>Soubor zůstane dostupný až do {{ job.expires_at|time_and_timedelta }}, pak bude smazán. + Pokud ho již nepotřebujete, můžete ho smazat rovnou.</p> + +{% elif job.state == JobState.done %} + <div class="loading"> + <div class="done">✓</div> + <div class="caption">Stav: <b>{{ job.state.friendly_name() }}</b></div> + </div> + + <p>Dávka byla úspěšně dokončena, ale bohužel neposkytla zpětný odkaz, na který + by mělo být provedeno přesměrování.{% if back_url %} Můžete se vrátit na původní stránku.{% endif %}</p> + +{% else %} + <div class="loading"> + <div class="circle"></div> + <div class="caption">Stav: <b>{{ job.state.friendly_name() }}</b></div> + </div> + + <p>Zpracování dávky zabere krátkou chvíli. Můžete počkat na této stránce na dokončení dávky + (stránka se po dokončení sama obnoví), nebo se můžete vydat do jiné části systému a mezitím dělat + něco jiného. Po dokončená zpracování naleznete dávku na záložce <a href="{{ url_for('org_jobs') }}">Dávky</a>.</p> + + <script type="text/javascript"> + setInterval(function() { + fetch("{{ url_for('org_job_state', id=job.job_id) }}") + .then(response => response.text()) + .then((value) => { + if (value != '{{ job.state.name }}') { + window.location.reload(); + } + }) + }, 5_000); + </script> +{% endif %} + +<div class="text-center"><div class="btn-group"> + {% if back_url -%} + <a class='btn btn-default' href='{{ back_url }}'>Zpět na původní stránku</a> + {%- endif %} + {% if job.state == JobState.done and job.out_file -%} + <a class='btn btn-success' href='{{ url_for('org_job_output', id=job.job_id) }}'>Stáhnout výstup</a> + <form action="{{ url_for('org_jobs') }}" method="POST" class="btn-group"> + {{ form_delete_job.csrf_token() }} + <input type="hidden" name="delete_job_id" value="{{ job.job_id }}"> + <input type="hidden" name="back_url" value="{{ back_url }}"> + <button type="submit" class="btn btn-danger">Smazat dávku</button> + </form> + {%- endif %} +</div></div> + +{% endblock %} diff --git a/mo/web/templates/org_jobs.html b/mo/web/templates/org_jobs.html index b117566e6efc9700f9d0f34de6ab16baa3d790da..979f97f983f47416cbb3e57bae6ba39472dc6167 100644 --- a/mo/web/templates/org_jobs.html +++ b/mo/web/templates/org_jobs.html @@ -38,12 +38,11 @@ dávku po stažení výstupu smažete sami – šetří to místo na serveru. <div class='btn-group'><form action="" method="POST" class="btn-group"> {{ form_delete_job.csrf_token() }} <a class='btn btn-xs btn-primary' href='{{ url_for('org_job', id=j.job_id) }}'>Detail</a> + {% set (result_url_action, result_url) = get_result_url(j) %} {% if j.out_file %} <a class='btn btn-xs btn-primary' href='{{ url_for('org_job_output', id=j.job_id) }}'>Výsledek</a> - {% elif j.type == JobType.process_scans and j.state == JobState.done and 'contest_id' in j.in_json and 'site_id' in j.in_json %} - <a class='btn btn-xs btn-primary' href='{{ url_for('org_contest_scans_process', job_id=j.job_id, ct_id=j.in_json['contest_id'], site_id=j.in_json['site_id']) }}'>Roztřídit skeny</a> - {% elif j.type == JobType.snapshot_score and j.state == JobState.done %} - <a class='btn btn-xs btn-primary' href='{{ url_for('org_score_snapshots', ct_id=j.in_json['contest_id']) }}'>Výsledek</a> + {% elif result_url %} + <a class='btn btn-xs btn-primary' href='{{ result_url }}'>{{ result_url_action }}</a> {% endif %} <input type="hidden" name="delete_job_id" value="{{ j.job_id }}"> <button type="submit" class="btn btn-xs btn-danger">Smazat</button> diff --git a/mo/web/templates/org_score_snapshot.html b/mo/web/templates/org_score_snapshot.html index 5d6b235fec6da8f6a9ba9da73dfe385259dd1e9a..6e432ad181d0df2c7ee95e2155c05c023756d734 100644 --- a/mo/web/templates/org_score_snapshot.html +++ b/mo/web/templates/org_score_snapshot.html @@ -11,7 +11,7 @@ {% if ctx.rights.have_right(Right.view_contestants) %} <div class="btn-group pull-right"> <a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Aktuální výsledky</a> - <a class="btn btn-default" href="{{ ctx.url_for('org_score_snapshots') }}">Uložené výsledky</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_score_snapshots') }}">Všechny uložené verze</a> {% if scoretable.pdf_file %} <a class="btn btn-default" href="{{ ctx.url_for('org_score_snapshot_pdf', scoretable_id=scoretable.scoretable_id) }}">PDF</a> {% endif %} @@ -21,9 +21,27 @@ {% block body %} +{% if set_final_form %} +<form method="POST" action="{{ ctx.url_for('org_score_snapshots') }}" class="pull-right"> + {{ set_final_form.csrf_token }} + <input type="hidden" name="back_url" value="{{ ctx.url_for('org_score_snapshot', scoretable_id=scoretable.scoretable_id) }}"> + {% if ctx.contest.scoretable_id == scoretable.scoretable_id %} + <input type="submit" name="submit_hide" class="btn btn-danger" value="Zrušit zveřejnění"> + {% else %} + <input type="hidden" name="scoretable_id" value="{{ scoretable.scoretable_id }}"> + <input type="submit" name="submit_set_final" class="btn btn-primary" value="Zveřejnit tuto verzi"> + {% endif %} +</form> +{% endif %} + {% if ctx.rights.have_right(Right.view_contestants) %} <p>Výsledková listina odpovídající stavu k {{ scoretable.created_at|timeformat }}. -Lze ji zveřejnit jako oficiální výsledkovou listinu v přehledu všech uložených verzí výsledkových listin pro tuto soutěž.</p> +{% if scoretable.scoretable_id == ctx.contest.scoretable_id %} +<strong>Tato verze je zveřejněna jako oficiální výsledková listina.</strong> +{% else %} +Tato verze není zveřejněna jako oficiální výsledková listina. +{% endif %} +</p> {% endif %} <table class='data'> diff --git a/static/mo.css b/static/mo.css index 225fae8c0fc3c1cb9824cabdbec73b41568e925a..ad845b0af24fab0a4356591edc7c7c0e0d1d6603 100644 --- a/static/mo.css +++ b/static/mo.css @@ -487,3 +487,37 @@ input.modified { background-color: rgb(255, 222, 152); border-color: orange; } + +/* Loading circle animation (source: https://codepen.io/jake-lee/pen/jrJYON) */ + +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.loading { + margin: 100px 0px; +} +.loading .circle { + width: 100px; + height: 100px; + margin: 0px auto; + border:solid 10px #222; + border-radius: 50%; + border-right-color: transparent; + border-bottom-color: transparent; + + transition: all 0.5s ease-in; + animation-name: rotate; + animation-duration: 2.0s; + animation-iteration-count: infinite; + animation-timing-function: linear; +} +.loading .done { + text-align: center; + font-size: 100px; +} +.loading .caption { + margin-top: 10px; + text-align: center; +}