diff --git a/mo/db.py b/mo/db.py index 758b8ae59acfb03aed1f9944a24d6cdb9c345603..fe74c77135807e40f67e3c5ee9900ead88cbc7bd 100644 --- a/mo/db.py +++ b/mo/db.py @@ -470,6 +470,17 @@ class JobState(MOEnum): done = auto() failed = auto() + def friendly_name(self) -> str: + return job_state_names[self] + + +job_state_names = { + JobState.ready: 'čeká na spuštění', + JobState.running: 'zpracovává se', + JobState.done: 'dokončen', + JobState.failed: 'selhal', +} + class Job(Base): __tablename__ = 'jobs' diff --git a/mo/web/__init__.py b/mo/web/__init__.py index e3334be71a0a0ae64796dc7da95d0d265128fb00..e5261a5d432fe311de52b7056ec9e8edd6868736 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -127,6 +127,7 @@ import mo.web.menu import mo.web.misc import mo.web.org import mo.web.org_contest +import mo.web.org_jobs import mo.web.org_place import mo.web.org_round import mo.web.org_users diff --git a/mo/web/org_jobs.py b/mo/web/org_jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..30d61eeb0802c28232b9882aaae807d0b768bc8e --- /dev/null +++ b/mo/web/org_jobs.py @@ -0,0 +1,66 @@ +from flask import render_template, g, redirect, url_for, flash +from flask_wtf.form import FlaskForm +from sqlalchemy.orm import joinedload +import werkzeug.exceptions +import wtforms + +import mo +import mo.db as db +from mo.jobs import TheJob +from mo.web import app +import mo.web.util + + +class JobDeleteForm(FlaskForm): + delete_job_id = wtforms.IntegerField() + delete = wtforms.SubmitField('Smazat') + + +@app.route('/org/jobs/', methods=('GET', 'POST')) +def org_jobs(): + sess = db.get_session() + + form_delete_job = JobDeleteForm() + if form_delete_job.validate_on_submit(): + tj = TheJob(form_delete_job.delete_job_id.data) + job = tj.load() + if not job: + flash('Dávka mezitím zmizela.', 'success') + elif not (job.user_id == g.user.user_id or g.user.is_admin): + flash('Tuto dávku nemáte právo smazat', 'danger') + elif job.state == db.JobState.running: + flash('Běžící dávku nelze smazat', 'danger') + else: + tj.remove_loaded() + flash('Dávka smazána', 'success') + return redirect(url_for('org_jobs')) + + job_query = (sess.query(db.Job) + .options(joinedload(db.Job.user))) + if not g.user.is_admin: + job_query = job_query.filter_by(user=g.user) + + jobs = job_query.order_by(db.Job.created_at.desc()).all() + + return render_template( + 'org_jobs.html', + jobs=jobs, + db=db, + form_delete_job=form_delete_job, + ) + + +@app.route('/org/jobs/<int:id>/output') +def org_job_output(id: int): + sess = db.get_session() + job = sess.query(db.Job).get(id) + if job is None: + return werkzeug.exceptions.NotFound() + + if not (job.user_id == g.user.user_id or g.user.is_org): + return werkzeug.exceptions.Forbidden() + + if job.state != db.JobState.done or job.out_file is None: + return werkzeug.exceptions.NotFound() + + return mo.web.util.send_job_result(job) diff --git a/mo/web/templates/org_jobs.html b/mo/web/templates/org_jobs.html new file mode 100644 index 0000000000000000000000000000000000000000..6128122fe10370e07ff950281dbd0389d6131e37 --- /dev/null +++ b/mo/web/templates/org_jobs.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block body %} +<h2>Dávky ke zpracování</h2> + +<p>Činnosti, jejichž zpracování může trvat delší dobu, se zpracovávají +dávkově. To se týká třeba stahování více řešení najednou – když si je +objednáte, vytvoří se dávka. Až dávka doběhne, můžete si zde stáhnout výsledek. +Po nějaké době výsledek vyprší a je automaticky smazán. Budeme rádi, když +dávku po stažení výstupu smažete sami – šetří to místo na serveru. + +<h3>Seznam dávek</h3> + +{% if jobs %} +<table class=data> + <thead> + <tr> + <th>ID + <th>Název + <th>Spuštěno + <th>Stav + <th>Vyprší + {% if g.user.is_admin %}<th>Vlastník{% endif %} + <th>Akce + <tbody> + {% for j in jobs %} + <tr class="job-{{ j.state.name }}"> + <td>{{ j.job_id }} + <td>{{ j.description }} + <td>{{ j.created_at|timeformat }} + <td><b>{{ j.state.friendly_name() }}</b>{% if j.finished_at != None %} {{ j.finished_at|timedelta }}{% endif %} + <td>{% if j.expires_at %}{{ j.expires_at|timedelta}}{% endif %} + {% if g.user.is_admin %}<td>{{ j.user.full_name() }}{% endif %} + <td> + <div class='btn-group'><form action="" method="POST" class="btn-group"> + {{ form_delete_job.csrf_token() }} + {% if j.out_file %} + <a class='btn btn-xs btn-primary' href='{{ url_for('org_job_output', id=j.job_id) }}'>Výsledek</a> + {% endif %} + <input type="hidden" name="delete_job_id" value="{{ j.job_id }}"> + <button type="submit" class="btn btn-xs btn-danger">Smazat</button> + </form></div> + {% endfor %} +</table> +{% else %} +<p>Žádné dávky nejsou nyní naplánované. +{% endif %} + +{% endblock %} diff --git a/mo/web/util.py b/mo/web/util.py index cb356f6e800ae88ef84489d7b7a5eb3a302d0423..9aeeabbf84df08cb15b4b9b96aa83f3eff815415 100644 --- a/mo/web/util.py +++ b/mo/web/util.py @@ -8,6 +8,7 @@ import werkzeug.utils import wtforms import mo.db as db +import mo.jobs from mo.util import logger from mo.web import app @@ -72,3 +73,19 @@ def send_task_paper(paper: db.Paper) -> Response: else: logger.error(f'Soubor {file} je v papers, ale ve FS neexistuje') raise werkzeug.exceptions.NotFound() + + +def send_job_result(job: db.Job) -> Response: + assert job.out_file is not None + file = mo.jobs.job_file_path(job.out_file) + + if file.endswith('.zip'): + type = 'application/zip' + else: + type = 'application/binary' + + if os.path.isfile(file): + return send_file(file, mimetype=type) + else: + logger.error(f'Soubor {file} je výsledkem jobu, ale ve FS neexistuje') + raise werkzeug.exceptions.NotFound() diff --git a/static/mo.css b/static/mo.css index 02f61fa5d4a9fa050ed482f427e830735f8a4fe4..422daf27c033ad596c58a49af509323a75accffd 100644 --- a/static/mo.css +++ b/static/mo.css @@ -160,3 +160,29 @@ nav#main-menu a.active { .sol-late { color: red; } + +/* Jobs */ + +table.data tbody tr.job-running { + background-color: #f8f; +} + +table.data tbody tr.job-running:hover { + background-color: #a6a; +} + +table.data tbody tr.job-done { + background-color: #8f8; +} + +table.data tbody tr.job-done:hover { + background-color: #6a6; +} + +table.data tbody tr.job-failed { + background-color: #f88; +} + +table.data tbody tr.job-failed:hover { + background-color: #a66; +}