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;
+}