From bb60a2ba6719caf3d41c4fde05a9da3939b1e90d Mon Sep 17 00:00:00 2001
From: Patrick Jentsch
Date: Thu, 12 Dec 2024 10:32:08 +0100
Subject: [PATCH] Move jobs namespace back to http routes
---
app/__init__.py | 3 -
app/blueprints/errors/handlers.py | 14 ++-
app/blueprints/jobs/routes.py | 118 ++++++++++++++++++++++--
app/namespaces/jobs.py | 118 ------------------------
app/static/js/{app.js => app/client.js} | 2 +-
app/static/js/app/endpoints/jobs.js | 58 +++++++-----
app/templates/_base/scripts.html.j2 | 6 +-
app/templates/jobs/job.html.j2 | 2 +-
8 files changed, 157 insertions(+), 164 deletions(-)
delete mode 100644 app/namespaces/jobs.py
rename app/static/js/{app.js => app/client.js} (94%)
diff --git a/app/__init__.py b/app/__init__.py
index 80f77b1b..b555c0bc 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -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
diff --git a/app/blueprints/errors/handlers.py b/app/blueprints/errors/handlers.py
index a18979ab..87009486 100644
--- a/app/blueprints/errors/handlers.py
+++ b/app/blueprints/errors/handlers.py
@@ -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
diff --git a/app/blueprints/jobs/routes.py b/app/blueprints/jobs/routes.py
index 86e489ef..77e661cf 100644
--- a/app/blueprints/jobs/routes.py
+++ b/app/blueprints/jobs/routes.py
@@ -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('/')
-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('//inputs//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('/', 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('//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('//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('//inputs//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('//results//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,
diff --git a/app/namespaces/jobs.py b/app/namespaces/jobs.py
deleted file mode 100644
index 3ddb7a8b..00000000
--- a/app/namespaces/jobs.py
+++ /dev/null
@@ -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'
- }
diff --git a/app/static/js/app.js b/app/static/js/app/client.js
similarity index 94%
rename from app/static/js/app.js
rename to app/static/js/app/client.js
index 20811222..2891f8e1 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app/client.js
@@ -1,4 +1,4 @@
-nopaque.App = class App {
+nopaque.app.Client = class Client {
constructor() {
this.socket = io({transports: ['websocket'], upgrade: false});
diff --git a/app/static/js/app/endpoints/jobs.js b/app/static/js/app/endpoints/jobs.js
index aa42fa81..9b6828f0 100644
--- a/app/static/js/app/endpoints/jobs.js
+++ b/app/static/js/app/endpoints/jobs.js
@@ -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();
- return response.body;
+ if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
+
+ return data;
}
- async log(id) {
- const response = await this.socket.emitWithAck('log', id);
+ async restart(jobId) {
+ const options = {
+ headers: {
+ Accept: 'application/json'
+ }
+ };
- if (response.status != 200) {
- throw new Error(`[${response.status}] ${response.statusText}`);
- }
+ const response = await fetch(`/jobs/${jobId}/restart`, options);
+ const data = await response.json();
- return response.body;
- }
+ if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
- 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;
}
}
diff --git a/app/templates/_base/scripts.html.j2 b/app/templates/_base/scripts.html.j2
index 507c90ca..3421bf0e 100644
--- a/app/templates/_base/scripts.html.j2
+++ b/app/templates/_base/scripts.html.j2
@@ -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 @@
{% endassets -%}
+{# TODO: Think about implementing the following inside a main.js(.j2) #}