Move jobs namespace back to http routes

This commit is contained in:
Patrick Jentsch 2024-12-12 10:32:08 +01:00
parent 328f85ba52
commit bb60a2ba67
8 changed files with 157 additions and 164 deletions

View File

@ -136,9 +136,6 @@ def create_app(config: Config = Config) -> Flask:
from .namespaces.corpora import CorporaNamespace
socketio.on_namespace(CorporaNamespace('/corpora'))
from .namespaces.jobs import JobsNamespace
socketio.on_namespace(JobsNamespace('/jobs'))
from .namespaces.users import UsersNamespace
socketio.on_namespace(UsersNamespace('/users'))
# endregion SocketIO Namespaces

View File

@ -4,11 +4,17 @@ from . import bp
@bp.app_errorhandler(HTTPException)
def handle_http_exception(error):
def handle_http_exception(e: HTTPException):
''' Generic HTTP exception handler '''
accept_json = request.accept_mimetypes.accept_json
accept_html = request.accept_mimetypes.accept_html
if accept_json and not accept_html:
response = jsonify(str(error))
return response, error.code
return render_template('errors/error.html.j2', error=error), error.code
error = {
'code': e.code,
'name': e.name,
'description': e.description
}
return jsonify(error), e.code
return render_template('errors/error.html.j2', error=e), e.code

View File

@ -1,25 +1,49 @@
from flask import (
abort,
current_app,
Flask,
jsonify,
redirect,
render_template,
send_from_directory,
url_for
)
from flask_login import current_user
from app.models import Job, JobInput, JobResult
from threading import Thread
from app import db
from app.models import Job, JobInput, JobResult, JobStatus
from . import bp
def _delete_job(app: Flask, job_id: int):
with app.app_context():
job = Job.query.get(job_id)
job.delete()
db.session.commit()
def _restart_job(app: Flask, job_id: int):
with app.app_context():
job = Job.query.get(job_id)
job.restart()
db.session.commit()
@bp.route('')
def jobs():
return redirect(url_for('main.dashboard', _anchor='jobs'))
@bp.route('/<hashid:job_id>')
def job(job_id):
def job(job_id: int):
job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator):
if not (
job.user == current_user
or current_user.is_administrator
):
abort(403)
return render_template(
'jobs/job.html.j2',
title='Job',
@ -27,11 +51,77 @@ def job(job_id):
)
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
def download_job_input(job_id, job_input_id):
job_input = JobInput.query.filter_by(job_id=job_id, id=job_input_id).first_or_404()
if not (job_input.job.user == current_user or current_user.is_administrator):
@bp.route('/<hashid:job_id>', methods=['DELETE'])
def delete_job(job_id: int):
job = Job.query.get_or_404(job_id)
if not (
job.user == current_user
or current_user.is_administrator
):
abort(403)
thread = Thread(
target=_delete_job,
args=(current_app._get_current_object(), job.id)
)
thread.start()
return jsonify(f'Job "{job.title}" marked for deletion.'), 202
@bp.route('/<hashid:job_id>/log')
def get_job_log(job_id: int):
job = Job.query.get_or_404(job_id)
if not current_user.is_administrator:
abort(403)
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
abort(409)
log_file_path = job.path / 'pipeline_data' / 'logs' / 'pyflow_log.txt'
with log_file_path.open() as log_file:
log = log_file.read()
return jsonify(log)
@bp.route('/<hashid:job_id>/restart')
def restart_job(job_id: int):
job = Job.query.get_or_404(job_id)
if not (
job.user == current_user
or current_user.is_administrator
):
abort(403)
if job.status != JobStatus.FAILED:
abort(409)
thread = Thread(
target=_restart_job,
args=(current_app._get_current_object(), job.id)
)
thread.start()
return jsonify(f'Job "{job.title}" marked for restarting.'), 202
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
def download_job_input(job_id: int, job_input_id: int):
job_input = JobInput.query.filter_by(
job_id=job_id,
id=job_input_id
).first_or_404()
if not (
job_input.job.user == current_user
or current_user.is_administrator
):
abort(403)
return send_from_directory(
job_input.path.parent,
job_input.path.name,
@ -42,10 +132,18 @@ def download_job_input(job_id, job_input_id):
@bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download')
def download_job_result(job_id, job_result_id):
job_result = JobResult.query.filter_by(job_id=job_id, id=job_result_id).first_or_404()
if not (job_result.job.user == current_user or current_user.is_administrator):
def download_job_result(job_id: int, job_result_id: int):
job_result = JobResult.query.filter_by(
job_id=job_id,
id=job_result_id
).first_or_404()
if not (
job_result.job.user == current_user
or current_user.is_administrator
):
abort(403)
return send_from_directory(
job_result.path.parent,
job_result.path.name,

View File

@ -1,118 +0,0 @@
from flask import current_app, Flask
from flask_login import current_user
from flask_socketio import Namespace
from app import db, hashids, socketio
from app.decorators import socketio_admin_required, socketio_login_required
from app.models import Job, JobStatus
def _delete_job(app: Flask, job_id: int):
with app.app_context():
job = Job.query.get(job_id)
job.delete()
db.session.commit()
def _restart_job(app: Flask, job_id: int):
with app.app_context():
job = Job.query.get(job_id)
job.restart()
db.session.commit()
class JobsNamespace(Namespace):
@socketio_login_required
def on_delete(self, job_hashid: str) -> dict:
if not isinstance(job_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
job_id = hashids.decode(job_hashid)
if not isinstance(job_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
job = Job.query.get(job_id)
if job is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
job.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
socketio.start_background_task(
_delete_job,
current_app._get_current_object(),
job_id
)
return {
'body': f'Job "{job.title}" marked for deletion',
'status': 202,
'statusText': 'Accepted'
}
@socketio_admin_required
def on_log(self, job_hashid: str) -> dict:
if not isinstance(job_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
job_id = hashids.decode(job_hashid)
if not isinstance(job_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
job = Job.query.get(job_id)
if job is None:
return {'status': 404, 'statusText': 'Not Found'}
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
return {'status': 409, 'statusText': 'Conflict'}
with open(job.path / 'pipeline_data' / 'logs' / 'pyflow_log.txt') as log_file:
log = log_file.read()
return {
'body': log,
'status': 200,
'statusText': 'OK'
}
socketio_login_required
def on_restart(self, job_hashid: str) -> dict:
if not isinstance(job_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
job_id = hashids.decode(job_hashid)
if not isinstance(job_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
job = Job.query.get(job_id)
if job is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
job.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
if job.status == JobStatus.FAILED:
return {'status': 409, 'statusText': 'Conflict'}
socketio.start_background_task(
_restart_job,
current_app._get_current_object(),
job_id
)
return {
'body': f'Job "{job.title}" marked for restarting',
'status': 202,
'statusText': 'Accepted'
}

View File

@ -1,4 +1,4 @@
nopaque.App = class App {
nopaque.app.Client = class Client {
constructor() {
this.socket = io({transports: ['websocket'], upgrade: false});

View File

@ -1,37 +1,47 @@
nopaque.app.endpoints.Jobs = class Jobs {
constructor(app) {
this.app = app;
async delete(jobId) {
const options = {
headers: {
Accept: 'application/json'
},
method: 'DELETE'
};
this.socket = io('/jobs', {transports: ['websocket'], upgrade: false});
const response = await fetch(`/jobs/${jobId}`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async delete(id) {
const response = await this.socket.emitWithAck('delete', id);
async log(jobId) {
const options = {
headers: {
Accept: 'application/json'
}
};
if (response.status != 202) {
throw new Error(`[${response.status}] ${response.statusText}`);
const response = await fetch(`/jobs/${jobId}/log`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
return response.body;
async restart(jobId) {
const options = {
headers: {
Accept: 'application/json'
}
};
async log(id) {
const response = await this.socket.emitWithAck('log', id);
const response = await fetch(`/jobs/${jobId}/restart`, options);
const data = await response.json();
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return response.body;
}
async restart(id) {
const response = await this.socket.emitWithAck('restart', id);
if (response.status != 202) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
return response.body;
return data;
}
}

View File

@ -8,8 +8,8 @@
filters='rjsmin',
output='gen/nopaque.%(version)s.js',
'js/index.js',
'js/app.js',
'js/app/index.js',
'js/app/client.js',
'js/app/endpoints/index.js',
'js/app/endpoints/corpora.js',
'js/app/endpoints/jobs.js',
@ -80,9 +80,9 @@
<script src="{{ ASSET_URL }}"></script>
{% endassets -%}
{# TODO: Think about implementing the following inside a main.js(.j2) #}
<script>
// TODO: Implement an app.run method and use this for all of the following
const app = new nopaque.App();
const app = new nopaque.app.Client();
app.init();
{% if current_user.is_authenticated %}

View File

@ -151,9 +151,9 @@
{% if current_user.is_administrator %}
const jobLogButtonElement = document.querySelector('#job-log-button');
const jobLogModalElement = document.querySelector('#job-log-modal');
jobLogButtonElement.addEventListener('click', async (event) => {
const jobLogModalElement = document.querySelector('#job-log-modal');
const log = await app.jobs.log({{ job.hashid|tojson }});
jobLogModalElement.querySelector('pre code').textContent = log;
});