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) #}