7 Commits

32 changed files with 618 additions and 465 deletions

View File

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

View File

@ -1,45 +0,0 @@
from flask_login import current_user
from flask_socketio import join_room
from app import hashids, socketio
from app.decorators import socketio_login_required
from app.models import Corpus
@socketio.on('GET /corpora/<corpus_id>')
@socketio_login_required
def get_corpus(corpus_hashid):
corpus_id = hashids.decode(corpus_hashid)
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'options': {'status': 404, 'statusText': 'Not found'}}
if not (
corpus.is_public
or corpus.user == current_user
or current_user.is_administrator
):
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
return {
'body': corpus.to_json_serializable(),
'options': {
'status': 200,
'statusText': 'OK',
'headers': {'Content-Type: application/json'}
}
}
@socketio.on('SUBSCRIBE /corpora/<corpus_id>')
@socketio_login_required
def subscribe_corpus(corpus_hashid):
corpus_id = hashids.decode(corpus_hashid)
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'options': {'status': 404, 'statusText': 'Not found'}}
if not (
corpus.is_public
or corpus.user == current_user
or current_user.is_administrator
):
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
join_room(f'/corpora/{corpus.hashid}')
return {'options': {'status': 200, 'statusText': 'OK'}}

View File

@ -4,11 +4,17 @@ from . import bp
@bp.app_errorhandler(HTTPException) @bp.app_errorhandler(HTTPException)
def handle_http_exception(error): def handle_http_exception(e: HTTPException):
''' Generic HTTP exception handler ''' ''' Generic HTTP exception handler '''
accept_json = request.accept_mimetypes.accept_json accept_json = request.accept_mimetypes.accept_json
accept_html = request.accept_mimetypes.accept_html accept_html = request.accept_mimetypes.accept_html
if accept_json and not accept_html: if accept_json and not accept_html:
response = jsonify(str(error)) error = {
return response, error.code 'code': e.code,
return render_template('errors/error.html.j2', error=error), error.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,18 +1,13 @@
from flask import Blueprint from flask import Blueprint
from flask_login import login_required
bp = Blueprint('jobs', __name__) bp = Blueprint('jobs', __name__)
@bp.before_request from . import routes
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from .inputs import bp as inputs_bp
bp.register_blueprint(inputs_bp, url_prefix='/<hashid:job_id>/inputs')
from . import routes, json_routes from .results import bp as results_bp
bp.register_blueprint(results_bp, url_prefix='/<hashid:job_id>/results')

View File

@ -0,0 +1,7 @@
from flask import Blueprint
bp = Blueprint('inputs', __name__)
from . import routes

View File

@ -0,0 +1,27 @@
from flask import abort, send_from_directory
from flask_login import current_user, login_required
from app.models import JobInput
from . import bp
@bp.route('/<hashid:job_input_id>/download')
@login_required
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,
as_attachment=True,
download_name=job_input.filename,
mimetype=job_input.mimetype
)

View File

@ -1,72 +0,0 @@
from flask import abort, current_app
from flask_login import current_user
from threading import Thread
from app import db
from app.decorators import admin_required, content_negotiation
from app.models import Job, JobStatus
from . import bp
@bp.route('/<hashid:job_id>', methods=['DELETE'])
@content_negotiation(produces='application/json')
def delete_job(job_id):
def _delete_job(app, job_id):
with app.app_context():
job = Job.query.get(job_id)
job.delete()
db.session.commit()
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()
response_data = {
'message': f'Job "{job.title}" marked for deletion'
}
return response_data, 202
@bp.route('/<hashid:job_id>/log')
@admin_required
@content_negotiation(produces='application/json')
def job_log(job_id):
job = Job.query.get_or_404(job_id)
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
response = {'errors': {'message': 'Job status is not completed or failed'}}
return response, 409
with open(job.path / 'pipeline_data' / 'logs' / 'pyflow_log.txt') as log_file:
log = log_file.read()
response_data = {
'jobLog': log
}
return response_data, 200
@bp.route('/<hashid:job_id>/restart', methods=['POST'])
@content_negotiation(produces='application/json')
def restart_job(job_id):
def _restart_job(app, job_id):
with app.app_context():
job = Job.query.get(job_id)
job.restart()
db.session.commit()
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:
response = {'errors': {'message': 'Job status is not "failed"'}}
return response, 409
thread = Thread(
target=_restart_job,
args=(current_app._get_current_object(), job_id)
)
thread.start()
response_data = {
'message': f'Job "{job.title}" marked for restarting'
}
return response_data, 202

View File

@ -0,0 +1,7 @@
from flask import Blueprint
bp = Blueprint('results', __name__)
from . import routes

View File

@ -0,0 +1,27 @@
from flask import abort, send_from_directory
from flask_login import current_user, login_required
from app.models import JobResult
from . import bp
@bp.route('/<hashid:job_result_id>/download')
@login_required
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,
as_attachment=True,
download_name=job_result.filename,
mimetype=job_result.mimetype
)

View File

@ -1,25 +1,37 @@
from flask import ( from flask import (
abort, abort,
current_app,
Flask,
jsonify,
redirect, redirect,
render_template, render_template,
send_from_directory,
url_for url_for
) )
from flask_login import current_user from flask_login import current_user, login_required
from app.models import Job, JobInput, JobResult from threading import Thread
from app import db
from app.decorators import admin_required
from app.models import Job, JobStatus
from . import bp from . import bp
@bp.route('') @bp.route('')
def jobs(): @login_required
def index():
return redirect(url_for('main.dashboard', _anchor='jobs')) return redirect(url_for('main.dashboard', _anchor='jobs'))
@bp.route('/<hashid:job_id>') @bp.route('/<hashid:job_id>')
def job(job_id): @login_required
def job(job_id: int):
job = Job.query.get_or_404(job_id) 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) abort(403)
return render_template( return render_template(
'jobs/job.html.j2', 'jobs/job.html.j2',
title='Job', title='Job',
@ -27,29 +39,73 @@ def job(job_id):
) )
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download') def _delete_job(app: Flask, job_id: int):
def download_job_input(job_id, job_input_id): with app.app_context():
job_input = JobInput.query.filter_by(job_id=job_id, id=job_input_id).first_or_404() job = Job.query.get(job_id)
if not (job_input.job.user == current_user or current_user.is_administrator): job.delete()
abort(403) db.session.commit()
return send_from_directory(
job_input.path.parent,
job_input.path.name,
as_attachment=True,
download_name=job_input.filename,
mimetype=job_input.mimetype
)
@bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download') @bp.route('/<hashid:job_id>', methods=['DELETE'])
def download_job_result(job_id, job_result_id): @login_required
job_result = JobResult.query.filter_by(job_id=job_id, id=job_result_id).first_or_404() def delete_job(job_id: int):
if not (job_result.job.user == current_user or current_user.is_administrator): job = Job.query.get_or_404(job_id)
if not (
job.user == current_user
or current_user.is_administrator
):
abort(403) abort(403)
return send_from_directory(
job_result.path.parent, thread = Thread(
job_result.path.name, target=_delete_job,
as_attachment=True, args=(current_app._get_current_object(), job.id)
download_name=job_result.filename,
mimetype=job_result.mimetype
) )
thread.start()
return jsonify(f'Job "{job.title}" marked for deletion.'), 202
@bp.route('/<hashid:job_id>/log')
@admin_required
def job_log(job_id: int):
job = Job.query.get_or_404(job_id)
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)
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('/<hashid:job_id>/restart', methods=['POST'])
@login_required
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

View File

@ -20,14 +20,6 @@ class JobInput(FileMixin, HashidMixin, db.Model):
def __repr__(self): def __repr__(self):
return f'<JobInput {self.filename}>' return f'<JobInput {self.filename}>'
@property
def content_url(self):
return url_for(
'jobs.download_job_input',
job_id=self.job.id,
job_input_id=self.id
)
@property @property
def jsonpatch_path(self): def jsonpatch_path(self):
return f'{self.job.jsonpatch_path}/inputs/{self.hashid}' return f'{self.job.jsonpatch_path}/inputs/{self.hashid}'
@ -40,7 +32,7 @@ class JobInput(FileMixin, HashidMixin, db.Model):
def url(self): def url(self):
return url_for( return url_for(
'jobs.job', 'jobs.job',
job_id=self.job_id, job_input_id=self.id,
_anchor=f'job-{self.job.hashid}-input-{self.hashid}' _anchor=f'job-{self.job.hashid}-input-{self.hashid}'
) )

View File

@ -22,14 +22,6 @@ class JobResult(FileMixin, HashidMixin, db.Model):
def __repr__(self): def __repr__(self):
return f'<JobResult {self.filename}>' return f'<JobResult {self.filename}>'
@property
def download_url(self):
return url_for(
'jobs.download_job_result',
job_id=self.job_id,
job_result_id=self.id
)
@property @property
def jsonpatch_path(self): def jsonpatch_path(self):
return f'{self.job.jsonpatch_path}/results/{self.hashid}' return f'{self.job.jsonpatch_path}/results/{self.hashid}'
@ -41,8 +33,8 @@ class JobResult(FileMixin, HashidMixin, db.Model):
@property @property
def url(self): def url(self):
return url_for( return url_for(
'jobs.job', 'job_results.job_result',
job_id=self.job_id, job_result_id=self.id,
_anchor=f'job-{self.job.hashid}-result-{self.hashid}' _anchor=f'job-{self.job.hashid}-result-{self.hashid}'
) )

215
app/namespaces/corpora.py Normal file
View File

@ -0,0 +1,215 @@
from datetime import datetime
from flask import current_app, Flask, url_for
from flask_login import current_user
from flask_socketio import Namespace
from string import punctuation
import nltk
from app import db, hashids, socketio
from app.decorators import socketio_login_required
from app.models import Corpus, CorpusFollowerAssociation, CorpusFollowerRole
def _delete_corpus(app: Flask, corpus_id: int):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.delete()
db.session.commit()
def _build_corpus(app: Flask, corpus_id: int):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.build()
db.session.commit()
class CorporaNamespace(Namespace):
@socketio_login_required
def on_delete(self, corpus_hashid: str) -> dict:
if not isinstance(corpus_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
corpus_id = hashids.decode(corpus_hashid)
if not isinstance(corpus_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
corpus.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
socketio.start_background_task(
_delete_corpus,
current_app._get_current_object(),
corpus_id
)
return {
'body': f'Corpus "{corpus.title}" marked for deletion',
'status': 202,
'statusText': 'Accepted'
}
@socketio_login_required
def on_build(self, corpus_hashid: str) -> dict:
if not isinstance(corpus_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
corpus_id = hashids.decode(corpus_hashid)
if not isinstance(corpus_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'status': 404, 'statusText': 'Not Found'}
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if not (
cfa is not None and cfa.role.has_permission('MANAGE_FILES')
or corpus.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
if len(corpus.files.all()) == 0:
return {'status': 409, 'statusText': 'Conflict'}
socketio.start_background_task(
_build_corpus,
current_app._get_current_object(),
corpus_id
)
return {
'body': f'Corpus "{corpus.title}" marked for building',
'status': 202,
'statusText': 'Accepted'
}
# TODO: Think about where to place this, as this does not belong here...
@socketio_login_required
def on_get_stopwords(self):
languages = [
'german',
'english',
'catalan',
'greek',
'spanish',
'french',
'italian',
'russian',
'chinese'
]
nltk.download('stopwords', quiet=True)
stopwords = {
language: nltk.corpus.stopwords.words(language)
for language in languages
}
stopwords['punctuation'] = list(punctuation)
stopwords['punctuation'] += ['', '|', '', '', '', '--']
stopwords['user_stopwords'] = []
return {
'body': stopwords,
'status': 200,
'statusText': 'OK'
}
@socketio_login_required
def on_create_share_link(self, corpus_hashid: str, expiration_date: str, role_name: str) -> dict:
if not isinstance(corpus_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
if not isinstance(expiration_date, str):
return {'status': 400, 'statusText': 'Bad Request'}
if not isinstance(role_name, str):
return {'status': 400, 'statusText': 'Bad Request'}
print(corpus_hashid, expiration_date, role_name)
corpus_id = hashids.decode(corpus_hashid)
if not isinstance(corpus_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'status': 404, 'statusText': 'Not Found'}
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if not (
cfa is not None and cfa.role.has_permission('MANAGE_FOLLOWERS')
or corpus.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
_expiration_date = datetime.strptime(expiration_date, '%b %d, %Y')
cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
if cfr is None:
return {'status': 400, 'statusText': 'Bad Request'}
token = current_user.generate_follow_corpus_token(
corpus.hashid,
role_name,
_expiration_date
)
corpus_share_link = url_for(
'corpora.follow_corpus',
corpus_id=corpus_id,
token=token,
_external=True
)
return {
'body': corpus_share_link,
'status': 200,
'statusText': 'OK'
}
@socketio_login_required
def on_set_is_public(corpus_hashid: str, new_value: bool) -> dict:
if not isinstance(corpus_id, str):
return {'status': 400, 'statusText': 'Bad Request'}
if not isinstance(new_value, bool):
return {'status': 400, 'statusText': 'Bad Request'}
corpus_id = hashids.decode(corpus_hashid)
if not isinstance(corpus_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
corpus.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
corpus.is_public = new_value
db.session.commit()
return {
'body': f'Corpus "{corpus.title}" is now {"public" if new_value else "private"}',
'status': 200,
'statusText': 'OK'
}

View File

@ -169,6 +169,7 @@ class CQiOverSocketIONamespace(Namespace):
for param in signature(fn).parameters.values(): for param in signature(fn).parameters.values():
# Check if the parameter is optional or required # Check if the parameter is optional or required
# The following is true for required parameters
if param.default is param.empty: if param.default is param.empty:
if param.name not in fn_args: if param.name not in fn_args:
return {'code': 400, 'msg': 'Bad Request'} return {'code': 400, 'msg': 'Bad Request'}

View File

@ -1,109 +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, job_id):
with app.app_context():
job = Job.query.get(job_id)
job.restart()
db.session.commit()
class UsersNamespace(Namespace):
@socketio_login_required
def on_delete(self, job_hashid: str) -> dict:
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):
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': 'Forbidden'
}
socketio_login_required
def on_restart(self, job_hashid: str):
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

@ -16,6 +16,9 @@ def _delete_user(app: Flask, user_id: int):
class UsersNamespace(Namespace): class UsersNamespace(Namespace):
@socketio_login_required @socketio_login_required
def on_get(self, user_hashid: str) -> dict: def on_get(self, user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid) user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int): if not isinstance(user_id, int):
@ -24,7 +27,7 @@ class UsersNamespace(Namespace):
user = User.query.get(user_id) user = User.query.get(user_id)
if user is None: if user is None:
return {'status': 404, 'statusText': 'Not found'} return {'status': 404, 'statusText': 'Not Found'}
if not ( if not (
user == current_user user == current_user
@ -43,6 +46,9 @@ class UsersNamespace(Namespace):
@socketio_login_required @socketio_login_required
def on_subscribe(self, user_hashid: str) -> dict: def on_subscribe(self, user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid) user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int): if not isinstance(user_id, int):
@ -51,7 +57,7 @@ class UsersNamespace(Namespace):
user = User.query.get(user_id) user = User.query.get(user_id)
if user is None: if user is None:
return {'status': 404, 'statusText': 'Not found'} return {'status': 404, 'statusText': 'Not Found'}
if not ( if not (
user == current_user user == current_user
@ -65,6 +71,9 @@ class UsersNamespace(Namespace):
@socketio_login_required @socketio_login_required
def on_unsubscribe(self, user_hashid: str) -> dict: def on_unsubscribe(self, user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid) user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int): if not isinstance(user_id, int):
@ -73,7 +82,7 @@ class UsersNamespace(Namespace):
user = User.query.get(user_id) user = User.query.get(user_id)
if user is None: if user is None:
return {'status': 404, 'statusText': 'Not found'} return {'status': 404, 'statusText': 'Not Found'}
if not ( if not (
user == current_user user == current_user
@ -87,6 +96,9 @@ class UsersNamespace(Namespace):
@socketio_login_required @socketio_login_required
def on_delete(self, user_hashid: str) -> dict: def on_delete(self, user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid) user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int): if not isinstance(user_id, int):
@ -95,7 +107,7 @@ class UsersNamespace(Namespace):
user = User.query.get(user_id) user = User.query.get(user_id)
if user is None: if user is None:
return {'status': 404, 'statusText': 'Not found'} return {'status': 404, 'statusText': 'Not Found'}
if not ( if not (
user == current_user user == current_user

View File

@ -1,8 +1,10 @@
nopaque.App = class App { nopaque.app.Client = class Client {
constructor() { constructor() {
this.socket = io({transports: ['websocket'], upgrade: false}); this.socket = io({transports: ['websocket'], upgrade: false});
// Endpoints // Endpoints
this.corpora = new nopaque.app.endpoints.Corpora(this);
this.jobs = new nopaque.app.endpoints.Jobs(this);
this.users = new nopaque.app.endpoints.Users(this); this.users = new nopaque.app.endpoints.Users(this);
// Extensions // Extensions

View File

@ -0,0 +1,57 @@
nopaque.app.endpoints.Corpora = class Corpora {
constructor(app) {
this.app = app;
this.socket = io('/corpora', {transports: ['websocket'], upgrade: false});
}
async delete(id) {
const response = await this.socket.emitWithAck('delete', id);
if (response.status != 202) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
return response.body;
}
async build(id) {
const response = await this.socket.emitWithAck('build', id);
if (response.status != 202) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
return response.body;
}
async getStopwords() {
const response = await this.socket.emitWithAck('get_stopwords');
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
return response.body;
}
async createShareLink(id, expirationDate, roleName) {
const response = await this.socket.emitWithAck('create_share_link', id, expirationDate, roleName);
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
return response.body;
}
async setIsPublic(id, newValue) {
const response = await this.socket.emitWithAck('set_is_public', id, newValue);
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
return response.body;
}
}

View File

@ -0,0 +1,52 @@
nopaque.app.endpoints.Jobs = class Jobs {
constructor(app) {
this.app = app;
}
async delete(jobId) {
const options = {
headers: {
Accept: 'application/json'
},
method: 'DELETE'
};
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 log(jobId) {
const options = {
headers: {
Accept: 'application/json'
}
};
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;
}
async restart(jobId) {
const options = {
headers: {
Accept: 'application/json'
},
method: 'POST'
};
const response = await fetch(`/jobs/${jobId}/restart`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
}

View File

@ -5,8 +5,8 @@ nopaque.app.endpoints.Users = class Users {
this.socket = io('/users', {transports: ['websocket'], upgrade: false}); this.socket = io('/users', {transports: ['websocket'], upgrade: false});
} }
async get(userId) { async get(id) {
const response = await this.socket.emitWithAck('get', userId); const response = await this.socket.emitWithAck('get', id);
if (response.status !== 200) { if (response.status !== 200) {
throw new Error(`[${response.status}] ${response.statusText}`); throw new Error(`[${response.status}] ${response.statusText}`);
@ -15,27 +15,29 @@ nopaque.app.endpoints.Users = class Users {
return response.body; return response.body;
} }
async subscribe(userId) { async subscribe(id) {
const response = await this.socket.emitWithAck('subscribe', userId); const response = await this.socket.emitWithAck('subscribe', id);
if (response.status != 200) { if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`); throw new Error(`[${response.status}] ${response.statusText}`);
} }
} }
async unsubscribe(userId) { async unsubscribe(id) {
const response = await this.socket.emitWithAck('unsubscribe', userId); const response = await this.socket.emitWithAck('unsubscribe', id);
if (response.status != 200) { if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`); throw new Error(`[${response.status}] ${response.statusText}`);
} }
} }
async delete(userId) { async delete(id) {
const response = await this.socket.emitWithAck('delete', userId); const response = await this.socket.emitWithAck('delete', id);
if (response.status != 202) { if (response.status != 202) {
throw new Error(`[${response.status}] ${response.statusText}`); throw new Error(`[${response.status}] ${response.statusText}`);
} }
return response.body;
} }
} }

View File

@ -7,7 +7,6 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
stopwords: undefined, stopwords: undefined,
originalStopwords: {}, originalStopwords: {},
stopwordCache: {}, stopwordCache: {},
promises: {getStopwords: undefined},
tokenSet: new Set() tokenSet: new Set()
}; };
@ -73,22 +72,11 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
} }
} }
getStopwords() { async getStopwords() {
this.data.promises.getStopwords = new Promise((resolve, reject) => { const stopwords = await app.corpora.getStopwords();
nopaque.requests.corpora.entity.getStopwords() this.data.originalStopwords = structuredClone(stopwords);
.then((response) => { this.data.stopwords = structuredClone(stopwords);
response.json() return stopwords;
.then((json) => {
this.data.originalStopwords = structuredClone(json);
this.data.stopwords = structuredClone(json);
resolve(this.data.stopwords);
})
.catch((error) => {
reject(error);
});
});
});
return this.data.promises.getStopwords;
} }
renderGeneralCorpusInfo() { renderGeneralCorpusInfo() {

View File

@ -5,50 +5,6 @@ nopaque.requests.corpora = {};
nopaque.requests.corpora.entity = {}; nopaque.requests.corpora.entity = {};
nopaque.requests.corpora.entity.delete = (corpusId) => {
let input = `/corpora/${corpusId}`;
let init = {
method: 'DELETE'
};
return nopaque.requests.JSONfetch(input, init);
};
nopaque.requests.corpora.entity.build = (corpusId) => {
let input = `/corpora/${corpusId}/build`;
let init = {
method: 'POST',
};
return nopaque.requests.JSONfetch(input, init);
};
nopaque.requests.corpora.entity.generateShareLink = (corpusId, role, expiration) => {
let input = `/corpora/${corpusId}/generate-share-link`;
let init = {
method: 'POST',
body: JSON.stringify({role: role, expiration: expiration})
};
return nopaque.requests.JSONfetch(input, init);
};
nopaque.requests.corpora.entity.getStopwords = () => {
let input = `/corpora/stopwords`;
let init = {
method: 'GET'
};
return nopaque.requests.JSONfetch(input, init);
};
nopaque.requests.corpora.entity.isPublic = {};
nopaque.requests.corpora.entity.isPublic.update = (corpusId, isPublic) => {
let input = `/corpora/${corpusId}/is_public`;
let init = {
method: 'PUT',
body: JSON.stringify(isPublic)
};
return nopaque.requests.JSONfetch(input, init);
};
/***************************************************************************** /*****************************************************************************
* Requests for /corpora/<entity>/files routes * * Requests for /corpora/<entity>/files routes *

View File

@ -1,30 +0,0 @@
/*****************************************************************************
* Requests for /jobs routes *
*****************************************************************************/
nopaque.requests.jobs = {};
nopaque.requests.jobs.entity = {};
nopaque.requests.jobs.entity.delete = (jobId) => {
let input = `/jobs/${jobId}`;
let init = {
method: 'DELETE'
};
return nopaque.requests.JSONfetch(input, init);
};
nopaque.requests.jobs.entity.log = (jobId) => {
let input = `/jobs/${jobId}/log`;
let init = {
method: 'GET'
};
return nopaque.requests.JSONfetch(input, init);
};
nopaque.requests.jobs.entity.restart = (jobId) => {
let input = `/jobs/${jobId}/restart`;
let init = {
method: 'POST'
};
return nopaque.requests.JSONfetch(input, init);
};

View File

@ -7,7 +7,7 @@ nopaque.resource_displays.CorpusDisplay = class CorpusDisplay extends nopaque.re
this.displayElement this.displayElement
.querySelector('.action-button[data-action="build-request"]') .querySelector('.action-button[data-action="build-request"]')
.addEventListener('click', (event) => { .addEventListener('click', (event) => {
nopaque.requests.corpora.entity.build(this.corpusId); app.corpora.build(this.corpusId);
}); });
} }

View File

@ -180,7 +180,7 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
window.location.reload(); window.location.reload();
}); });
} else { } else {
nopaque.requests.corpora.entity.delete(itemId); app.corpora.delete(itemId);
} }
}); });
modal.open(); modal.open();
@ -276,7 +276,7 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
let listItem = this.listjs.get('id', selectedItemId)[0].elm; let listItem = this.listjs.get('id', selectedItemId)[0].elm;
let values = this.listjs.get('id', listItem.dataset.id)[0].values(); let values = this.listjs.get('id', listItem.dataset.id)[0].values();
if (values['is-owner']) { if (values['is-owner']) {
nopaque.requests.corpora.entity.delete(selectedItemId); app.corpora.delete(selectedItemId);
} else { } else {
nopaque.requests.corpora.entity.followers.entity.delete(selectedItemId, currentUserId); nopaque.requests.corpora.entity.followers.entity.delete(selectedItemId, currentUserId);
setTimeout(() => { setTimeout(() => {

View File

@ -136,8 +136,9 @@ nopaque.resource_lists.JobList = class JobList extends nopaque.resource_lists.Re
} }
); );
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => { confirmElement.addEventListener('click', async (event) => {
nopaque.requests.jobs.entity.delete(itemId); const message = await app.jobs.delete(itemId);
app.ui.flash(message, 'job');
}); });
modal.open(); modal.open();
break; break;
@ -221,8 +222,9 @@ nopaque.resource_lists.JobList = class JobList extends nopaque.resource_lists.Re
); );
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => { confirmElement.addEventListener('click', (event) => {
this.selectedItemIds.forEach(selectedItemId => { this.selectedItemIds.forEach(async (selectedItemId) => {
nopaque.requests.jobs.entity.delete(selectedItemId); const message = await app.jobs.delete(selectedItemId);
app.ui.flash(message, 'job');
}); });
this.selectedItemIds.clear(); this.selectedItemIds.clear();
this.renderingItemSelection(); this.renderingItemSelection();

View File

@ -56,5 +56,24 @@
Log out Log out
</a> </a>
</li> </li>
{% if current_user.can('USE_API') or current_user.is_administrator %}
<li class="divider" tabindex="-1"></li>
{% endif %}
{% if current_user.can('USE_API') %}
<li>
<a href="{{ url_for('apifairy.docs') }}">
<i class="material-icons left">api</i>
API
</a>
</li>
{% endif %}
{% if current_user.is_administrator %}
<li {% if request.path == url_for('admin.admin') %}class="active"{% endif %}>
<a href="{{ url_for('admin.admin') }}">
<i class="material-icons left">admin_panel_settings</i>
Administration
</a>
</li>
{% endif %}
</ul> </ul>
{% endif %} {% endif %}

View File

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

View File

@ -105,19 +105,21 @@
</li> </li>
{% endif %} {% endif %}
{% if current_user.is_authenticated and current_user.can('ADMINISTRATE') %} {% if current_user.can('USE_API') or current_user.is_administrator %}
{# administration section #}
<li><div class="divider"></div></li> <li><div class="divider"></div></li>
<li><a class="subheader">Administration</a></li> {% endif %}
{# corpora #} {% if current_user.can('USE_API') %}
{# API #}
<li> <li>
<a class="waves-effect" href="{{ url_for('admin.corpora') }}"><i class="nopaque-icons">I</i>Corpora</a> <a class="waves-effect" href="{{ url_for('apifairy.docs') }}"><i class="material-icons">api</i>API</a>
</li> </li>
{% endif %}
{# users #} {% if current_user.is_administrator %}
<li> {# Administration #}
<a class="waves-effect" href="{{ url_for('admin.users') }}"><i class="material-icons">manage_accounts</i>Users</a> <li {% if request.path == url_for('admin.admin') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('admin.admin') }}"><i class="material-icons">admin_panel_settings</i>Administration</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -246,7 +246,7 @@
let publishingModalIsPublicSwitchElement = document.querySelector('#publishing-modal-is-public-switch'); let publishingModalIsPublicSwitchElement = document.querySelector('#publishing-modal-is-public-switch');
publishingModalIsPublicSwitchElement.addEventListener('change', (event) => { publishingModalIsPublicSwitchElement.addEventListener('change', (event) => {
let newIsPublic = publishingModalIsPublicSwitchElement.checked; let newIsPublic = publishingModalIsPublicSwitchElement.checked;
nopaque.requests.corpora.entity.isPublic.update({{ corpus.hashid|tojson }}, newIsPublic) app.corpora.setIsPublic.update({{ corpus.hashid|tojson }}, newIsPublic)
.catch((response) => { .catch((response) => {
publishingModalIsPublicSwitchElement.checked = !newIsPublic; publishingModalIsPublicSwitchElement.checked = !newIsPublic;
}); });
@ -256,7 +256,7 @@ publishingModalIsPublicSwitchElement.addEventListener('change', (event) => {
// #region Delete // #region Delete
let deleteModalDeleteButtonElement = document.querySelector('#delete-modal-delete-button'); let deleteModalDeleteButtonElement = document.querySelector('#delete-modal-delete-button');
deleteModalDeleteButtonElement.addEventListener('click', (event) => { deleteModalDeleteButtonElement.addEventListener('click', (event) => {
nopaque.requests.corpora.entity.delete({{ corpus.hashid|tojson }}) app.corpora.delete({{ corpus.hashid|tojson }})
.then((response) => { .then((response) => {
window.location.href = {{ url_for('main.dashboard')|tojson }}; window.location.href = {{ url_for('main.dashboard')|tojson }};
}); });
@ -346,19 +346,14 @@ M.Modal.init(
shareLinkModalOutputContainerElement.classList.add('hide'); shareLinkModalOutputContainerElement.classList.add('hide');
} }
} }
) );
shareLinkModalCreateButtonElement.addEventListener('click', (event) => { shareLinkModalCreateButtonElement.addEventListener('click', async (event) => {
let role = shareLinkModalCorpusFollowerRoleSelectElement.value; const role = shareLinkModalCorpusFollowerRoleSelectElement.value;
let expiration = shareLinkModalExpirationDateDatepickerElement.value const expiration = shareLinkModalExpirationDateDatepickerElement.value;
nopaque.requests.corpora.entity.generateShareLink({{ corpus.hashid|tojson }}, role, expiration) const shareLink = await app.corpora.createShareLink({{ corpus.hashid|tojson }}, expiration, role);
.then((response) => { shareLinkModalOutputContainerElement.classList.remove('hide');
response.json() shareLinkModalOutputFieldElement.value = shareLink;
.then((json) => {
shareLinkModalOutputContainerElement.classList.remove('hide');
shareLinkModalOutputFieldElement.value = json.corpusShareLink;
});
});
}); });
shareLinkModalOutputCopyButtonElement.addEventListener('click', (event) => { shareLinkModalOutputCopyButtonElement.addEventListener('click', (event) => {

View File

@ -273,7 +273,7 @@ publicCorpusFollowerList.add(
{% if cfr.has_permission('MANAGE_FILES') %} {% if cfr.has_permission('MANAGE_FILES') %}
let followerBuildRequest = document.querySelector('#follower-build-request'); let followerBuildRequest = document.querySelector('#follower-build-request');
followerBuildRequest.addEventListener('click', () => { followerBuildRequest.addEventListener('click', () => {
nopaque.requests.corpora.entity.build({{ corpus.hashid|tojson }}) app.corpora.build({{ corpus.hashid|tojson }})
.then((response) => { .then((response) => {
window.location.reload(); window.location.reload();
}); });
@ -380,17 +380,12 @@ M.Modal.init(
} }
) )
shareLinkModalCreateButtonElement.addEventListener('click', (event) => { shareLinkModalCreateButtonElement.addEventListener('click', async (event) => {
let role = shareLinkModalCorpusFollowerRoleSelectElement.value; const roleName = shareLinkModalCorpusFollowerRoleSelectElement.value;
let expiration = shareLinkModalExpirationDateDatepickerElement.value const expirationDate = shareLinkModalExpirationDateDatepickerElement.value;
nopaque.requests.corpora.entity.generateShareLink({{ corpus.hashid|tojson }}, role, expiration) const shareLink = await app.corpora.createShareLink({{ corpus.hashid|tojson }}, expiration, role);
.then((response) => { shareLinkModalOutputContainerElement.classList.remove('hide');
response.json() shareLinkModalOutputFieldElement.value = shareLink;
.then((json) => {
shareLinkModalOutputContainerElement.classList.remove('hide');
shareLinkModalOutputFieldElement.value = json.corpusShareLink;
});
});
}); });
shareLinkModalOutputCopyButtonElement.addEventListener('click', (event) => { shareLinkModalOutputCopyButtonElement.addEventListener('click', (event) => {

View File

@ -137,28 +137,26 @@
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script> <script>
let deleteJobRequestElement = document.querySelector('#delete-job-request'); const deleteJobRequestElement = document.querySelector('#delete-job-request');
let restartJobRequestElement = document.querySelector('#restart-job-request'); const restartJobRequestElement = document.querySelector('#restart-job-request');
deleteJobRequestElement.addEventListener('click', (event) => {
nopaque.requests.jobs.entity.delete({{ job.hashid|tojson }}); deleteJobRequestElement.addEventListener('click', async (event) => {
}); const message = await app.jobs.delete({{ job.hashid|tojson }});
restartJobRequestElement.addEventListener('click', (event) => { app.ui.flash(message, 'job');
nopaque.requests.jobs.entity.restart({{ job.hashid|tojson }}); });
restartJobRequestElement.addEventListener('click', async (event) => {
const message = await app.jobs.restart({{ job.hashid|tojson }});
app.ui.flash(message, 'job');
}); });
if ({{ current_user.is_administrator|tojson }}) { {% if current_user.is_administrator %}
let jobLogButtonElement = document.querySelector('#job-log-button'); const jobLogButtonElement = document.querySelector('#job-log-button');
jobLogButtonElement.addEventListener('click', (event) => { const jobLogModalElement = document.querySelector('#job-log-modal');
nopaque.requests.jobs.entity.log({{ job.hashid|tojson }})
.then( jobLogButtonElement.addEventListener('click', async (event) => {
(response) => { const log = await app.jobs.log({{ job.hashid|tojson }});
response.json() jobLogModalElement.querySelector('pre code').textContent = log;
.then((json) => { });
let jobLogModalElement = document.querySelector('#job-log-modal'); {% endif %}
jobLogModalElement.querySelector('pre code').textContent = json.jobLog;
});
});
});
}
</script> </script>
{% endblock scripts %} {% endblock scripts %}