From 72ba61f369c0f40291ee429ba3001dd6ce78fa5f Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 30 Nov 2021 16:22:16 +0100 Subject: [PATCH] Only reveal hashids to the ui --- app/__init__.py | 5 + app/admin/routes.py | 13 +- app/api/jobs.py | 2 +- app/corpora/cqi_over_socketio/__init__.py | 2 +- app/corpora/query_results_routes.py | 18 +- app/corpora/routes.py | 40 +- app/daemon/__init__.py | 3 +- app/events/socketio.py | 65 +- app/events/sqlalchemy.py | 33 +- app/jobs/routes.py | 18 +- app/models.py | 770 ++++++++++-------- app/services/routes.py | 9 +- app/static/js/nopaque/App.js | 80 ++ app/static/js/nopaque/JobStatusNotifier.js | 17 + .../RessourceDisplays/CorpusDisplay.js | 66 +- .../nopaque/RessourceDisplays/JobDisplay.js | 59 +- .../RessourceDisplays/RessourceDisplay.js | 31 +- .../nopaque/RessourceLists/CorpusFileList.js | 98 +-- .../js/nopaque/RessourceLists/CorpusList.js | 95 +-- .../js/nopaque/RessourceLists/JobInputList.js | 38 +- .../js/nopaque/RessourceLists/JobList.js | 95 +-- .../nopaque/RessourceLists/JobResultList.js | 49 +- .../nopaque/RessourceLists/QueryResultList.js | 93 ++- .../nopaque/RessourceLists/RessourceList.js | 115 ++- .../js/nopaque/RessourceLists/UserList.js | 77 +- app/static/js/nopaque/main.js | 174 ---- app/templates/_scripts.html.j2 | 14 +- app/templates/admin/edit_user.html.j2 | 2 +- app/templates/admin/user.html.j2 | 13 +- app/templates/admin/users.html.j2 | 2 +- app/templates/corpora/corpus.html.j2 | 5 +- app/templates/jobs/job.html.j2 | 7 +- app/templates/main/dashboard.html.j2 | 6 +- .../tasks/email/notification.html.j2 | 2 +- app/templates/tasks/email/notification.txt.j2 | 2 +- app/utils.py | 10 + config.py | 2 +- migrations/versions/68ed092ffe5e_.py | 50 ++ requirements.txt | 1 + 39 files changed, 1098 insertions(+), 1083 deletions(-) create mode 100644 app/static/js/nopaque/App.js create mode 100644 app/static/js/nopaque/JobStatusNotifier.js create mode 100644 app/utils.py create mode 100644 migrations/versions/68ed092ffe5e_.py diff --git a/app/__init__.py b/app/__init__.py index 37dc1a4b..c6df4293 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,11 +6,13 @@ from flask_migrate import Migrate from flask_paranoid import Paranoid from flask_socketio import SocketIO from flask_sqlalchemy import SQLAlchemy +from hashids import Hashids import flask_assets assets = flask_assets.Environment() db = SQLAlchemy() +hashids = Hashids(min_length=32) # , salt=current_app.config.get('SECRET_KEY') login = LoginManager() login.login_view = 'auth.login' login.login_message = 'Please log in to access this page.' @@ -37,6 +39,9 @@ def create_app(config_class=Config): message_queue=app.config.get('NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI') ) + from .utils import HashidConverter + app.url_map.converters['hashid'] = HashidConverter + from .events import socketio as socketio_events from .events import sqlalchemy as sqlalchemy_events diff --git a/app/admin/routes.py b/app/admin/routes.py index 3d7de6fb..902f8207 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -19,12 +19,13 @@ def index(): @login_required @admin_required def users(): - # users = [user.to_dict() for user in User.query.all()] - users = {user.id: user.to_dict() for user in User.query.all()} - return render_template('admin/users.html.j2', title='Users', users=users) + dict_users = {user.id: user.to_dict(backrefs=True, relationships=False) + for user in User.query.all()} + return render_template( + 'admin/users.html.j2', title='Users', dict_users=dict_users) -@bp.route('/users/') +@bp.route('/users/') @login_required @admin_required def user(user_id): @@ -32,7 +33,7 @@ def user(user_id): return render_template('admin/user.html.j2', title='User', user=user) -@bp.route('/users//delete') +@bp.route('/users//delete') @login_required @admin_required def delete_user(user_id): @@ -41,7 +42,7 @@ def delete_user(user_id): return redirect(url_for('.users')) -@bp.route('/users//edit', methods=['GET', 'POST']) # noqa +@bp.route('/users//edit', methods=['GET', 'POST']) # noqa @login_required @admin_required def edit_user(user_id): diff --git a/app/api/jobs.py b/app/api/jobs.py index c22de0ed..153d5060 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -27,7 +27,7 @@ class API_Jobs(Resource): pass -@ns.route('/') +@ns.route('/') class API_Job(Resource): '''Show a single job and lets you delete it''' diff --git a/app/corpora/cqi_over_socketio/__init__.py b/app/corpora/cqi_over_socketio/__init__.py index 2cce7834..d914973c 100644 --- a/app/corpora/cqi_over_socketio/__init__.py +++ b/app/corpora/cqi_over_socketio/__init__.py @@ -62,7 +62,7 @@ def connect(auth): if corpus is None: # return {'code': 404, 'msg': 'Not Found'} raise ConnectionRefusedError('Not Found') - if not (corpus.creator == current_user or current_user.is_administrator()): + if not (corpus.user == current_user or current_user.is_administrator()): # return {'code': 403, 'msg': 'Forbidden'} raise ConnectionRefusedError('Forbidden') if corpus.status not in ['prepared', 'start analysis', 'analysing', 'stop analysis']: diff --git a/app/corpora/query_results_routes.py b/app/corpora/query_results_routes.py index 1ccc477e..478b6fe1 100644 --- a/app/corpora/query_results_routes.py +++ b/app/corpora/query_results_routes.py @@ -22,7 +22,7 @@ def add_query_result(): if form.is_submitted(): if not form.validate(): return make_response(form.errors, 400) - query_result = QueryResult(creator=current_user, + query_result = QueryResult(user=current_user, description=form.description.data, filename=form.file.data.filename, title=form.title.data) @@ -65,19 +65,19 @@ def add_query_result(): form=form, title='Add query result') -@bp.route('/result/') +@bp.route('/result/') @login_required def query_result(query_result_id): abort(503) query_result = QueryResult.query.get_or_404(query_result_id) - if not (query_result.creator == current_user + if not (query_result.user == current_user or current_user.is_administrator()): abort(403) return render_template('corpora/query_results/query_result.html.j2', query_result=query_result, title='Query result') -@bp.route('/result//inspect') +@bp.route('/result//inspect') @login_required def inspect_query_result(query_result_id): ''' @@ -86,7 +86,7 @@ def inspect_query_result(query_result_id): abort(503) query_result = QueryResult.query.get_or_404(query_result_id) query_metadata = query_result.query_metadata - if not (query_result.creator == current_user + if not (query_result.user == current_user or current_user.is_administrator()): abort(403) display_options_form = DisplayOptionsForm( @@ -108,12 +108,12 @@ def inspect_query_result(query_result_id): title='Inspect query result') -@bp.route('/result//delete') +@bp.route('/result//delete') @login_required def delete_query_result(query_result_id): abort(503) query_result = QueryResult.query.get_or_404(query_result_id) - if not (query_result.creator == current_user + if not (query_result.user == current_user or current_user.is_administrator()): abort(403) flash('Query result "{}" has been marked for deletion!'.format(query_result), 'result') # noqa @@ -121,12 +121,12 @@ def delete_query_result(query_result_id): return redirect(url_for('services.service', service="corpus_analysis")) -@bp.route('/result//download') +@bp.route('/result//download') @login_required def download_query_result(query_result_id): abort(503) query_result = QueryResult.query.get_or_404(query_result_id) - if not (query_result.creator == current_user + if not (query_result.user == current_user or current_user.is_administrator()): abort(403) return send_from_directory(as_attachment=True, diff --git a/app/corpora/routes.py b/app/corpora/routes.py index f700a540..5af9ea92 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -21,7 +21,7 @@ def add_corpus(): form = AddCorpusForm(prefix='add-corpus-form') if form.validate_on_submit(): corpus = Corpus( - creator=current_user, + user=current_user, description=form.description.data, title=form.title.data ) @@ -52,7 +52,7 @@ def import_corpus(): if not form.validate(): return make_response(form.errors, 400) corpus = Corpus( - creator=current_user, + user=current_user, description=form.description.data, title=form.title.data ) @@ -115,18 +115,18 @@ def import_corpus(): title='Import Corpus') -@bp.route('/') +@bp.route('/') @login_required def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.creator == current_user or current_user.is_administrator()): + if not (corpus.user == current_user or current_user.is_administrator()): abort(403) corpus_files = [corpus_file.to_dict() for corpus_file in corpus.files] return render_template('corpora/corpus.html.j2', corpus=corpus, corpus_files=corpus_files, title='Corpus') -@bp.route('//analyse') +@bp.route('//analyse') @login_required def analyse_corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) @@ -137,37 +137,37 @@ def analyse_corpus(corpus_id): ) -@bp.route('//download') +@bp.route('//download') @login_required def download_corpus(corpus_id): abort(503) corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.creator == current_user or current_user.is_administrator()): + if not (corpus.user == current_user or current_user.is_administrator()): abort(403) return send_from_directory( as_attachment=True, - directory=os.path.join(corpus.creator.path, 'corpora'), + directory=os.path.join(corpus.user.path, 'corpora'), filename=corpus.archive_file, mimetype='zip' ) -@bp.route('//delete') +@bp.route('//delete') @login_required def delete_corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.creator == current_user or current_user.is_administrator()): + if not (corpus.user == current_user or current_user.is_administrator()): abort(403) flash('Corpus "{}" marked for deletion!'.format(corpus.title), 'corpus') tasks.delete_corpus(corpus_id) return redirect(url_for('main.dashboard')) -@bp.route('//files/add', methods=['GET', 'POST']) +@bp.route('//files/add', methods=['GET', 'POST']) @login_required def add_corpus_file(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.creator == current_user or current_user.is_administrator()): + if not (corpus.user == current_user or current_user.is_administrator()): abort(403) form = AddCorpusFileForm(corpus, prefix='add-corpus-file-form') if form.is_submitted(): @@ -200,13 +200,13 @@ def add_corpus_file(corpus_id): form=form, title='Add corpus file') -@bp.route('//files//delete') +@bp.route('//files//delete') @login_required def delete_corpus_file(corpus_id, corpus_file_id): corpus_file = CorpusFile.query.get_or_404(corpus_file_id) if not corpus_file.corpus_id == corpus_id: abort(404) - if not (corpus_file.corpus.creator == current_user + if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): abort(403) flash('Corpus file "{}" marked for deletion!'.format(corpus_file.filename), 'corpus') # noqa @@ -214,13 +214,13 @@ def delete_corpus_file(corpus_id, corpus_file_id): return redirect(url_for('.corpus', corpus_id=corpus_id)) -@bp.route('//files//download') +@bp.route('//files//download') @login_required def download_corpus_file(corpus_id, corpus_file_id): corpus_file = CorpusFile.query.get_or_404(corpus_file_id) if not corpus_file.corpus_id == corpus_id: abort(404) - if not (corpus_file.corpus.creator == current_user + if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): abort(403) return send_from_directory(as_attachment=True, @@ -228,11 +228,11 @@ def download_corpus_file(corpus_id, corpus_file_id): filename=corpus_file.filename) -@bp.route('//files/', methods=['GET', 'POST']) +@bp.route('//files/', methods=['GET', 'POST']) @login_required def corpus_file(corpus_id, corpus_file_id): corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.creator == current_user or current_user.is_administrator()): + if not (corpus.user == current_user or current_user.is_administrator()): abort(403) corpus_file = CorpusFile.query.get_or_404(corpus_file_id) if corpus_file.corpus != corpus: @@ -273,11 +273,11 @@ def corpus_file(corpus_id, corpus_file_id): title='Edit corpus file') -@bp.route('//prepare') +@bp.route('//prepare') @login_required def prepare_corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.creator == current_user or current_user.is_administrator()): + if not (corpus.user == current_user or current_user.is_administrator()): abort(403) if corpus.files.all(): tasks.build_corpus(corpus_id) diff --git a/app/daemon/__init__.py b/app/daemon/__init__.py index 60adcf2a..00977456 100644 --- a/app/daemon/__init__.py +++ b/app/daemon/__init__.py @@ -21,6 +21,7 @@ class Daemon(CheckCorporaMixin, CheckJobsMixin): self.check_corpora() self.check_jobs() db.session.commit() - except: + except Exception as e: + current_app.logger.warning(e) pass sleep(1.5) diff --git a/app/events/socketio.py b/app/events/socketio.py index 81f40533..ceb43a4f 100644 --- a/app/events/socketio.py +++ b/app/events/socketio.py @@ -1,4 +1,4 @@ -from flask import request +from app import hashids from flask_login import current_user from flask_socketio import join_room from .. import socketio @@ -6,68 +6,23 @@ from ..decorators import socketio_login_required from ..models import User -''' -' A list containing session ids of Socket.IO sessions, to keep track -' of all connected sessions, which can be used to determine the runtimes of -' associated background tasks. -''' -sessions = [] - - ############################################################################### # Socket.IO event handlers # ############################################################################### -@socketio.on('connect') +@socketio.on('users.user.get') @socketio_login_required -def socketio_connect(): - ''' - ' The Socket.IO module creates a session id (sid) for each request. - ' On connect the sid is saved in the sessions list. - ''' - sessions.append(request.sid) - # return {'code': 200, 'msg': 'OK'} - - -@socketio.on('disconnect') -def socketio_disconnect(): - ''' - ' On disconnect the session id gets removed from the sessions list. - ''' - try: - sessions.remove(request.sid) - except ValueError: - pass - # return {'code': 200, 'msg': 'OK'} - - -@socketio.on('start_user_session') -@socketio_login_required -def socketio_start_user_session(user_id): - user = User.query.get(user_id) - if user is None: - response = {'code': 404, 'msg': 'Not found'} - socketio.emit('start_user_session', response, room=request.sid) - elif not (user == current_user or current_user.is_administrator): - response = {'code': 403, 'msg': 'Forbidden'} - socketio.emit('start_user_session', response, room=request.sid) - else: - response = {'code': 200, 'msg': 'OK'} - socketio.emit('start_user_session', response, room=request.sid) - socketio.emit('user_{}_init'.format(user.id), user.to_dict(), - room=request.sid) - room = 'user_{}'.format(user.id) - join_room(room) - - -@socketio.on('users.request') -@socketio_login_required -def socketio_start_session(user_id): +def users_user_get(user_hashid): + user_id = hashids.decode(user_hashid)[0] user = User.query.get(user_id) if user is None: response = {'code': 404, 'msg': 'Not found'} elif not (user == current_user or current_user.is_administrator): response = {'code': 403, 'msg': 'Forbidden'} else: - response = {'code': 200, 'msg': 'OK', 'payload': user.to_dict()} - join_room('users.{}'.format(user.id)) + response = { + 'code': 200, + 'msg': 'OK', + 'payload': user.to_dict(backrefs=True, relationships=True) + } + join_room(f'users.{user.hashid}') return response diff --git a/app/events/sqlalchemy.py b/app/events/sqlalchemy.py index 9356cced..457a2bde 100644 --- a/app/events/sqlalchemy.py +++ b/app/events/sqlalchemy.py @@ -1,4 +1,5 @@ from datetime import datetime +from flask import current_app from .. import db, mail, socketio from ..email import create_message from ..models import Corpus, CorpusFile, Job, JobInput, JobResult, QueryResult @@ -14,10 +15,9 @@ from ..models import Corpus, CorpusFile, Job, JobInput, JobResult, QueryResult @db.event.listens_for(JobResult, 'after_delete') @db.event.listens_for(QueryResult, 'after_delete') def ressource_after_delete(mapper, connection, ressource): - event = 'user_{}_patch'.format(ressource.user_id) jsonpatch = [{'op': 'remove', 'path': ressource.jsonpatch_path}] - room = 'user_{}'.format(ressource.user_id) - socketio.emit(event, jsonpatch, room=room) + room = f'users.{ressource.user_hashid}' + socketio.emit('users.patch', jsonpatch, room=room) @db.event.listens_for(Corpus, 'after_insert') @@ -27,16 +27,12 @@ def ressource_after_delete(mapper, connection, ressource): @db.event.listens_for(JobResult, 'after_insert') @db.event.listens_for(QueryResult, 'after_insert') def ressource_after_insert_handler(mapper, connection, ressource): - event = 'user_{}_patch'.format(ressource.user_id) + value = ressource.to_dict(backrefs=False, relationships=False) jsonpatch = [ - { - 'op': 'add', - 'path': ressource.jsonpatch_path, - 'value': ressource.to_dict(include_relationships=False) - } + {'op': 'add', 'path': ressource.jsonpatch_path, 'value': value} ] - room = 'user_{}'.format(ressource.user_id) - socketio.emit(event, jsonpatch, room=room) + room = f'users.{ressource.user_hashid}' + socketio.emit('users.patch', jsonpatch, room=room) @db.event.listens_for(Corpus, 'after_update') @@ -63,26 +59,25 @@ def ressource_after_update_handler(mapper, connection, ressource): jsonpatch.append( { 'op': 'replace', - 'path': '{}/{}'.format(ressource.jsonpatch_path, attr.key), + 'path': f'{ressource.jsonpatch_path}/{attr.key}', 'value': new_value } ) # Job status update notification if it changed and wanted by the user if isinstance(ressource, Job) and attr.key == 'status': - if ressource.creator.setting_job_status_mail_notifications == 'none': # noqa + if ressource.user.setting_job_status_mail_notifications == 'none': # noqa pass - elif (ressource.creator.setting_job_status_mail_notifications == 'end' # noqa + elif (ressource.user.setting_job_status_mail_notifications == 'end' # noqa and ressource.status not in ['complete', 'failed']): pass else: msg = create_message( - ressource.creator.email, - 'Status update for your Job "{}"'.format(ressource.title), + ressource.user.email, + f'Status update for your Job "{ressource.title}"', 'tasks/email/notification', job=ressource ) mail.send(msg) if jsonpatch: - event = 'user_{}_patch'.format(ressource.user_id) - room = 'user_{}'.format(ressource.user_id) - socketio.emit(event, jsonpatch, room=room) + room = f'users.{ressource.user_hashid}' + socketio.emit('users.patch', jsonpatch, room=room) diff --git a/app/jobs/routes.py b/app/jobs/routes.py index 5db5692f..5138a28f 100644 --- a/app/jobs/routes.py +++ b/app/jobs/routes.py @@ -8,33 +8,33 @@ from ..models import Job, JobInput, JobResult import os -@bp.route('/') +@bp.route('/') @login_required def job(job_id): job = Job.query.get_or_404(job_id) - if not (job.creator == current_user or current_user.is_administrator()): + if not (job.user == current_user or current_user.is_administrator()): abort(403) job_inputs = [job_input.to_dict() for job_input in job.inputs] return render_template('jobs/job.html.j2', job=job, job_inputs=job_inputs, title='Job') -@bp.route('//delete') +@bp.route('//delete') @login_required def delete_job(job_id): job = Job.query.get_or_404(job_id) - if not (job.creator == current_user or current_user.is_administrator()): + if not (job.user == current_user or current_user.is_administrator()): abort(403) tasks.delete_job(job_id) flash('Job has been marked for deletion!', 'job') return redirect(url_for('main.dashboard')) -@bp.route('//inputs//download') +@bp.route('//inputs//download') @login_required def download_job_input(job_id, job_input_id): job_input = JobInput.query.filter(JobInput.job_id == job_id, JobInput.id == job_input_id).first_or_404() # noqa - if not (job_input.job.creator == current_user + if not (job_input.job.user == current_user or current_user.is_administrator()): abort(403) return send_from_directory(as_attachment=True, @@ -42,7 +42,7 @@ def download_job_input(job_id, job_input_id): filename=job_input.filename) -@bp.route('//restart') +@bp.route('//restart') @login_required @admin_required def restart(job_id): @@ -55,11 +55,11 @@ def restart(job_id): return redirect(url_for('.job', job_id=job_id)) -@bp.route('//results//download') +@bp.route('//results//download') @login_required def download_job_result(job_id, job_result_id): job_result = JobResult.query.filter(JobResult.job_id == job_id, JobResult.id == job_result_id).first_or_404() # noqa - if not (job_result.job.creator == current_user + if not (job_result.job.user == current_user or current_user.is_administrator()): abort(403) return send_from_directory(as_attachment=True, diff --git a/app/models.py b/app/models.py index 0cc4e83c..23b91cf3 100644 --- a/app/models.py +++ b/app/models.py @@ -5,30 +5,44 @@ from itsdangerous import BadSignature, TimedJSONWebSignatureSerializer from time import sleep from werkzeug.security import generate_password_hash, check_password_hash import xml.etree.ElementTree as ET -from . import db, login +from . import db, hashids, login import base64 +import enum import os import shutil -class Permission: +class HashidMixin: + @property + def hashid(self): + return hashids.encode(self.id) + + +class FileMixin: + creation_date = db.Column(db.DateTime, default=datetime.utcnow) + filename = db.Column(db.String(256)) + last_edited_date = db.Column(db.DateTime, default=datetime.utcnow) + mimetype = db.Column(db.String(255)) + + def file_mixin_to_dict(self, backrefs=False, relationships=False): + return { + 'creation_date': self.creation_date.isoformat() + 'Z', + 'filename': self.filename, + 'last_edited_date': self.last_edited_date.isoformat() + 'Z', + 'mimetype': self.mimetype + } + + +class Permission(enum.IntEnum): ''' Defines User permissions as integers by the power of 2. User permission - can be evaluated using the bitwise operator &. 3 equals to CREATE_JOB and - DELETE_JOB and so on. + can be evaluated using the bitwise operator &. ''' - MANAGE_CORPORA = 1 - MANAGE_JOBS = 2 - # PERMISSION_NAME = 4 - # PERMISSION_NAME = 8 - ADMIN = 16 + ADMINISTRATE = 1 + USE_API = 2 -class Role(db.Model): - ''' - Model for the different roles Users can have. Is a one-to-many - relationship. A Role can be associated with many User rows. - ''' +class Role(HashidMixin, db.Model): __tablename__ = 'roles' # Primary key id = db.Column(db.Integer, primary_key=True) @@ -39,78 +53,62 @@ class Role(db.Model): # Relationships users = db.relationship('User', backref='role', lazy='dynamic') - def to_dict(self, include_relationships=True): - return {'id': self.id, - 'default': self.default, - 'name': self.name, - 'permissions': self.permissions} - def __init__(self, **kwargs): - super(Role, self).__init__(**kwargs) + super().__init__(**kwargs) if self.permissions is None: self.permissions = 0 def __repr__(self): - ''' - String representation of the Role. For human readability. - ''' - return ''.format(self.name) + return f'' - def add_permission(self, perm): - ''' - Add new permission to Role. Input is a Permission. - ''' - if not self.has_permission(perm): - self.permissions += perm + def add_permission(self, permission): + if not self.has_permission(permission): + self.permissions += permission - def remove_permission(self, perm): - ''' - Removes permission from a Role. Input a Permission. - ''' - if self.has_permission(perm): - self.permissions -= perm + def has_permission(self, permission): + return self.permissions & permission == permission + + def remove_permission(self, permission): + if self.has_permission(permission): + self.permissions -= permission def reset_permissions(self): - ''' - Resets permissions to zero. Zero equals no permissions at all. - ''' self.permissions = 0 - def has_permission(self, perm): - ''' - Checks if a Role has a specific Permission. Does this with the bitwise - operator. - ''' - return self.permissions & perm == perm + def to_dict(self, backrefs=False, relationships=False): + dict_role = { + 'id': self.hashid, + 'default': self.default, + 'name': self.name, + 'permissions': self.permissions + } + if relationships: + dict_role['users']: { + x.to_dict(backrefs=False, relationships=True) + for x in self.users + } + return dict_role @staticmethod def insert_roles(): - ''' - Inserts roles into the database. This has to be executed befor Users - are added to the database. Otherwiese Users will not have a Role - assigned to them. Order of the roles dictionary determines the ID of - each role. Users have the ID 1 and Administrators have the ID 2. - ''' - roles = {'User': [Permission.MANAGE_CORPORA, Permission.MANAGE_JOBS], - 'Administrator': [Permission.MANAGE_CORPORA, - Permission.MANAGE_JOBS, Permission.ADMIN]} - default_role = 'User' - for r in roles: - role = Role.query.filter_by(name=r).first() + roles = { + 'User': [], + 'Administrator': [Permission.USE_API, Permission.ADMINISTRATE] + } + default_role_name = 'User' + for role_name, permissions in roles.items(): + role = Role.query.filter_by(name=role_name).first() if role is None: - role = Role(name=r) + role = Role(name=role_name) role.reset_permissions() - for perm in roles[r]: - role.add_permission(perm) - role.default = (role.name == default_role) + for permission in permissions: + role.add_permission(permission) + role.default = role.name == default_role_name db.session.add(role) db.session.commit() -class User(UserMixin, db.Model): - ''' - Model for Users that are registered to Opaque. - ''' +class User(HashidMixin, UserMixin, db.Model): __tablename__ = 'users' # Primary key id = db.Column(db.Integer, primary_key=True) @@ -122,28 +120,50 @@ class User(UserMixin, db.Model): last_seen = db.Column(db.DateTime(), default=datetime.utcnow) member_since = db.Column(db.DateTime(), default=datetime.utcnow) password_hash = db.Column(db.String(128)) - setting_dark_mode = db.Column(db.Boolean, default=False) - setting_job_status_mail_notifications = db.Column(db.String(16), - default='end') - setting_job_status_site_notifications = db.Column(db.String(16), - default='all') token = db.Column(db.String(32), index=True, unique=True) token_expiration = db.Column(db.DateTime) username = db.Column(db.String(64), unique=True, index=True) + setting_dark_mode = db.Column(db.Boolean, default=False) + setting_job_status_mail_notifications = db.Column( + db.String(16), default='end') + setting_job_status_site_notifications = db.Column( + db.String(16), default='all') + # Backrefs: role: Role # Relationships - corpora = db.relationship('Corpus', backref='creator', lazy='dynamic', - cascade='save-update, merge, delete') - jobs = db.relationship('Job', backref='creator', lazy='dynamic', - cascade='save-update, merge, delete') - query_results = db.relationship('QueryResult', - backref='creator', - cascade='save-update, merge, delete', - lazy='dynamic') + corpora = db.relationship( + 'Corpus', + backref='user', + cascade='all, delete-orphan', + lazy='dynamic' + ) + jobs = db.relationship( + 'Job', + backref='user', + cascade='all, delete-orphan', + lazy='dynamic' + ) + query_results = db.relationship( + 'QueryResult', + backref='user', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.role is not None: + return + if self.email == current_app.config['NOPAQUE_ADMIN']: + self.role = Role.query.filter_by(name='Administrator').first() + else: + self.role = Role.query.filter_by(default=True).first() + + def __repr__(self): + return f'' @property - def path(self): - return os.path.join(current_app.config['NOPAQUE_DATA_DIR'], - str(self.id)) + def jsonpatch_path(self): + return f'/users/{self.hashid}' @property def password(self): @@ -153,82 +173,102 @@ class User(UserMixin, db.Model): def password(self, password): self.password_hash = generate_password_hash(password) - def to_dict(self, include_relationships=True): - dict_user = { - 'id': self.id, - 'role_id': self.role_id, - 'confirmed': self.confirmed, - 'email': self.email, - 'last_seen': self.last_seen.isoformat() + 'Z', - 'member_since': self.member_since.isoformat() + 'Z', - 'settings': {'dark_mode': self.setting_dark_mode, - 'job_status_mail_notifications': - self.setting_job_status_mail_notifications, - 'job_status_site_notifications': - self.setting_job_status_site_notifications}, - 'username': self.username, - 'role': self.role.to_dict() - } - if include_relationships: - dict_user['corpora'] = {corpus.id: corpus.to_dict() - for corpus in self.corpora} - dict_user['jobs'] = {job.id: job.to_dict() for job in self.jobs} - dict_user['query_results'] = { - query_result.id: query_result.to_dict() - for query_result in self.query_results - } - return dict_user + @property + def path(self): + return os.path.join( + current_app.config.get('NOPAQUE_DATA_DIR'), str(self.id)) - def __repr__(self): - ''' - String representation of the User. For human readability. - ''' - return ''.format(self.username) - - def __init__(self, **kwargs): - super(User, self).__init__(**kwargs) - if self.role is None: - if self.email == current_app.config['NOPAQUE_ADMIN']: - self.role = Role.query.filter_by(name='Administrator').first() - if self.role is None: - self.role = Role.query.filter_by(default=True).first() - - def generate_confirmation_token(self, expiration=3600): - ''' - Generates a confirmation token for user confirmation via email. - ''' - s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], - expiration) - return s.dumps({'confirm': self.id}).decode('utf-8') - - def generate_reset_token(self, expiration=3600): - ''' - Generates a reset token for password reset via email. - ''' - s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], - expiration) - return s.dumps({'reset': self.id}).decode('utf-8') + def can(self, permission): + return self.role.has_permission(permission) def confirm(self, token): - ''' - Confirms User if the given token is valid and not expired. - ''' s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) try: data = s.loads(token.encode('utf-8')) except BadSignature: return False - if data.get('confirm') != self.id: + if data.get('confirm') != self.hashid: return False self.confirmed = True db.session.add(self) return True + def delete(self): + shutil.rmtree(self.path, ignore_errors=True) + db.session.delete(self) + + def generate_confirmation_token(self, expiration=3600): + s = TimedJSONWebSignatureSerializer( + current_app.config['SECRET_KEY'], expiration) + return s.dumps({'confirm': self.hashid}).decode('utf-8') + + def generate_reset_token(self, expiration=3600): + s = TimedJSONWebSignatureSerializer( + current_app.config['SECRET_KEY'], expiration) + return s.dumps({'reset': self.hashid}).decode('utf-8') + + def get_token(self, expires_in=3600): + now = datetime.utcnow() + if self.token and self.token_expiration > now + timedelta(seconds=60): + return self.token + self.token = base64.b64encode(os.urandom(24)).decode('utf-8') + self.token_expiration = now + timedelta(seconds=expires_in) + db.session.add(self) + return self.token + + def is_administrator(self): + return self.can(Permission.ADMINISTRATE) + + def revoke_token(self): + self.token_expiration = datetime.utcnow() - timedelta(seconds=1) + + def to_dict(self, backrefs=False, relationships=False): + dict_user = { + 'id': self.hashid, + 'role_id': self.role.hashid, + 'confirmed': self.confirmed, + 'email': self.email, + 'last_seen': self.last_seen.isoformat() + 'Z', + 'member_since': self.member_since.isoformat() + 'Z', + 'username': self.username, + 'settings': { + 'dark_mode': self.setting_dark_mode, + 'job_status_mail_notifications': + self.setting_job_status_mail_notifications, + 'job_status_site_notifications': + self.setting_job_status_site_notifications + } + } + if backrefs: + dict_user['role'] = self.role.to_dict( + backrefs=True, relationships=False) + if relationships: + dict_user['corpora'] = { + x.hashid: x.to_dict(backrefs=False, relationships=True) + for x in self.corpora + } + dict_user['jobs'] = { + x.hashid: x.to_dict(backrefs=False, relationships=True) + for x in self.jobs + } + dict_user['query_results'] = { + x.hashid: x.to_dict(backrefs=False, relationships=True) + for x in self.query_results + } + return dict_user + + def verify_password(self, password): + return check_password_hash(self.password_hash, password) + + @staticmethod + def check_token(token): + user = User.query.filter_by(token=token).first() + if user is None or user.token_expiration < datetime.utcnow(): + return None + return user + @staticmethod def reset_password(token, new_password): - ''' - Resets password for User if the given token is valid and not expired. - ''' s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) try: data = s.loads(token.encode('utf-8')) @@ -241,146 +281,123 @@ class User(UserMixin, db.Model): db.session.add(user) return True - def verify_password(self, password): - return check_password_hash(self.password_hash, password) - def can(self, perm): - ''' - Checks if a User with its current role can doe something. Checks if the - associated role actually has the needed Permission. - ''' - return self.role is not None and self.role.has_permission(perm) - - def is_administrator(self): - ''' - Checks if User has Admin permissions. - ''' - return self.can(Permission.ADMIN) - - def delete(self): - ''' - Delete the user and its corpora and jobs from database and filesystem. - ''' - shutil.rmtree(self.path, ignore_errors=True) - db.session.delete(self) - - def get_token(self, expires_in=3600): - now = datetime.utcnow() - if self.token and self.token_expiration > now + timedelta(seconds=60): - return self.token - self.token = base64.b64encode(os.urandom(24)).decode('utf-8') - self.token_expiration = now + timedelta(seconds=expires_in) - db.session.add(self) - return self.token - - def revoke_token(self): - self.token_expiration = datetime.utcnow() - timedelta(seconds=1) - - @staticmethod - def check_token(token): - user = User.query.filter_by(token=token).first() - if user is None or user.token_expiration < datetime.utcnow(): - return None - return user - - -class JobInput(db.Model): - ''' - Class to define JobInputs. - ''' +class JobInput(FileMixin, HashidMixin, db.Model): __tablename__ = 'job_inputs' # Primary key id = db.Column(db.Integer, primary_key=True) # Foreign keys job_id = db.Column(db.Integer, db.ForeignKey('jobs.id')) - # Fields - filename = db.Column(db.String(255)) + # Backrefs: job: Job + + def __repr__(self): + return f'' @property def download_url(self): - return url_for('jobs.download_job_input', job_id=self.job_id, - job_input_id=self.id) + return url_for( + 'jobs.download_job_input', + job_id=self.job.id, + job_input_id=self.id + ) @property def jsonpatch_path(self): - return '/jobs/{}/inputs/{}'.format(self.job_id, self.id) + return f'{self.job.jsonpatch_path}/inputs/{self.hashid}' @property def path(self): return os.path.join(self.job.path, self.filename) + def to_dict(self, backrefs=False, relationships=False): + dict_job_input = { + 'id': self.hashid, + 'job_id': self.job.hashid, + 'download_url': self.download_url, + 'url': self.url, + **self.file_mixin_to_dict() + } + if backrefs: + dict_job_input['job'] = self.job.to_dict( + backrefs=True, relationships=False) + return dict_job_input + @property def url(self): - return url_for('jobs.job', job_id=self.job_id, - _anchor='job-{}-input-{}'.format(self.job_id, self.id)) + return url_for( + 'jobs.job', + job_id=self.job_id, + _anchor=f'job-{self.job.hashid}-input-{self.hashid}' + ) + + @property + def user_hashid(self): + return self.job.user.hashid @property def user_id(self): return self.job.user_id - def __repr__(self): - ''' - String representation of the JobInput. For human readability. - ''' - return ''.format(self.filename) - def to_dict(self, include_relationships=True): - return {'download_url': self.download_url, - 'url': self.url, - 'id': self.id, - 'job_id': self.job_id, - 'filename': self.filename} - - -class JobResult(db.Model): - ''' - Class to define JobResults. - ''' +class JobResult(FileMixin, HashidMixin, db.Model): __tablename__ = 'job_results' # Primary key id = db.Column(db.Integer, primary_key=True) # Foreign keys job_id = db.Column(db.Integer, db.ForeignKey('jobs.id')) - # Fields - filename = db.Column(db.String(255)) + # Backrefs: job: Job + + def __repr__(self): + return f'' @property def download_url(self): - return url_for('jobs.download_job_result', job_id=self.job_id, - job_result_id=self.id) + return url_for( + 'jobs.download_job_result', + job_id=self.job_id, + job_result_id=self.id + ) @property def jsonpatch_path(self): - return '/jobs/{}/results/{}'.format(self.job_id, self.id) + return f'{self.job.jsonpatch_path}/results/{self.hashid}' @property def path(self): return os.path.join(self.job.path, 'output', self.filename) + def to_dict(self, backrefs=False, relationships=False): + dict_job_result = { + 'id': self.hashid, + 'job_id': self.job.hashid, + 'download_url': self.download_url, + 'url': self.url, + **self.file_mixin_to_dict( + backrefs=backrefs, relationships=relationships) + } + if backrefs: + dict_job_result['job'] = self.job.to_dict( + backrefs=True, relationships=False) + return dict_job_result + @property def url(self): - return url_for('jobs.job', job_id=self.job_id, - _anchor='job-{}-result-{}'.format(self.job_id, self.id)) + return url_for( + 'jobs.job', + job_id=self.job_id, + _anchor=f'job-{self.job.hashid}-result-{self.hashid}' + ) + + @property + def user_hashid(self): + return self.job.user.hashid @property def user_id(self): return self.job.user_id - def __repr__(self): - ''' - String representation of the JobResult. For human readability. - ''' - return ''.format(self.filename) - def to_dict(self, include_relationships=True): - return {'download_url': self.download_url, - 'url': self.url, - 'id': self.id, - 'job_id': self.job_id, - 'filename': self.filename} - - -class Job(db.Model): +class Job(HashidMixin, db.Model): ''' Class to define Jobs. ''' @@ -402,29 +419,39 @@ class Job(db.Model): service_version = db.Column(db.String(16)) status = db.Column(db.String(16)) title = db.Column(db.String(32)) + # Backrefs: user: User # Relationships - inputs = db.relationship('JobInput', backref='job', lazy='dynamic', - cascade='save-update, merge, delete') - results = db.relationship('JobResult', backref='job', lazy='dynamic', - cascade='save-update, merge, delete') + inputs = db.relationship( + 'JobInput', + backref='job', + cascade='all, delete-orphan', + lazy='dynamic' + ) + results = db.relationship( + 'JobResult', + backref='job', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + def __repr__(self): + return f'' @property def jsonpatch_path(self): - return '/jobs/{}'.format(self.id) + return f'{self.user.jsonpatch_path}/jobs/{self.hashid}' @property def path(self): - return os.path.join(self.creator.path, 'jobs', str(self.id)) + return os.path.join(self.user.path, 'jobs', str(self.id)) @property def url(self): return url_for('jobs.job', job_id=self.id) - def __repr__(self): - ''' - String representation of the Job. For human readability. - ''' - return ''.format(self.title) + @property + def user_hashid(self): + return self.user.hashid def delete(self): ''' @@ -457,32 +484,36 @@ class Job(db.Model): self.end_date = None self.status = 'submitted' - def to_dict(self, include_relationships=True): + def to_dict(self, backrefs=False, relationships=False): dict_job = { - 'url': self.url, - 'id': self.id, - 'user_id': self.user_id, + 'id': self.hashid, + 'user_id': self.user.hashid, 'creation_date': self.creation_date.isoformat() + 'Z', 'description': self.description, - 'end_date': self.end_date.isoformat() + 'Z' if self.end_date else None, + 'end_date': None if self.end_date is None else f'{self.end_date.isoformat()}Z', # noqa 'service': self.service, 'service_args': self.service_args, 'service_version': self.service_version, 'status': self.status, 'title': self.title, + 'url': self.url } - if include_relationships: - dict_job['inputs'] = {input.id: input.to_dict() - for input in self.inputs} - dict_job['results'] = {result.id: result.to_dict() - for result in self.results} + if backrefs: + dict_job['user'] = self.user.to_dict( + backrefs=True, relationships=False) + if relationships: + dict_job['inputs'] = { + x.hashid: x.to_dict(backrefs=False, relationships=True) + for x in self.inputs + } + dict_job['results'] = { + x.hashid: x.to_dict(backrefs=False, relationships=True) + for x in self.results + } return dict_job -class CorpusFile(db.Model): - ''' - Class to define Files. - ''' +class CorpusFile(FileMixin, HashidMixin, db.Model): __tablename__ = 'corpus_files' # Primary key id = db.Column(db.Integer, primary_key=True) @@ -494,7 +525,6 @@ class CorpusFile(db.Model): booktitle = db.Column(db.String(255)) chapter = db.Column(db.String(255)) editor = db.Column(db.String(255)) - filename = db.Column(db.String(255)) institution = db.Column(db.String(255)) journal = db.Column(db.String(255)) pages = db.Column(db.String(255)) @@ -502,15 +532,19 @@ class CorpusFile(db.Model): publishing_year = db.Column(db.Integer) school = db.Column(db.String(255)) title = db.Column(db.String(255)) + # Backrefs: corpus: Corpus @property def download_url(self): - return url_for('corpora.download_corpus_file', - corpus_id=self.corpus_id, corpus_file_id=self.id) + return url_for( + 'corpora.download_corpus_file', + corpus_id=self.corpus_id, + corpus_file_id=self.id + ) @property def jsonpatch_path(self): - return '/corpora/{}/files/{}'.format(self.corpus_id, self.id) + return f'/{self.corpus.jsonpatch_path}/files/{self.hashid}' @property def path(self): @@ -518,8 +552,15 @@ class CorpusFile(db.Model): @property def url(self): - return url_for('corpora.corpus_file', corpus_id=self.corpus_id, - corpus_file_id=self.id) + return url_for( + 'corpora.corpus_file', + corpus_id=self.corpus_id, + corpus_file_id=self.id + ) + + @property + def user_hashid(self): + return self.corpus.user.hashid @property def user_id(self): @@ -536,27 +577,33 @@ class CorpusFile(db.Model): db.session.delete(self) self.corpus.status = 'unprepared' - def to_dict(self, include_relationships=True): - return {'download_url': self.download_url, - 'url': self.url, - 'id': self.id, - 'corpus_id': self.corpus_id, - 'address': self.address, - 'author': self.author, - 'booktitle': self.booktitle, - 'chapter': self.chapter, - 'editor': self.editor, - 'filename': self.filename, - 'institution': self.institution, - 'journal': self.journal, - 'pages': self.pages, - 'publisher': self.publisher, - 'publishing_year': self.publishing_year, - 'school': self.school, - 'title': self.title} + def to_dict(self, backrefs=False, relationships=False): + dict_corpus_file = { + 'id': self.hashid, + 'corpus_id': self.corpus.hashid, + 'download_url': self.download_url, + 'url': self.url, + 'address': self.address, + 'author': self.author, + 'booktitle': self.booktitle, + 'chapter': self.chapter, + 'editor': self.editor, + 'institution': self.institution, + 'journal': self.journal, + 'pages': self.pages, + 'publisher': self.publisher, + 'publishing_year': self.publishing_year, + 'school': self.school, + 'title': self.title, + **self.file_mixin_to_dict( + backrefs=backrefs, relationships=relationships) + } + if backrefs: + dict_corpus_file['corpus'] = self.corpus.to_dict( + backrefs=True, relationships=False) -class Corpus(db.Model): +class Corpus(HashidMixin, db.Model): ''' Class to define a corpus. ''' @@ -574,47 +621,39 @@ class Corpus(db.Model): num_analysis_sessions = db.Column(db.Integer, default=0) num_tokens = db.Column(db.Integer, default=0) archive_file = db.Column(db.String(255)) + # Backrefs: user: User # Relationships - files = db.relationship('CorpusFile', backref='corpus', lazy='dynamic', - cascade='save-update, merge, delete') + files = db.relationship( + 'CorpusFile', + backref='corpus', + lazy='dynamic', + cascade='all, delete-orphan' + ) # Python class variables max_num_tokens = 2147483647 + def __repr__(self): + return f'' + @property def analysis_url(self): return url_for('corpora.analyse_corpus', corpus_id=self.id) @property def jsonpatch_path(self): - return '/corpora/{}'.format(self.id) + return f'{self.user.jsonpatch_path}/corpora/{self.hashid}' @property def path(self): - return os.path.join(self.creator.path, 'corpora', str(self.id)) + return os.path.join(self.user.path, 'corpora', str(self.id)) @property def url(self): return url_for('corpora.corpus', corpus_id=self.id) - def to_dict(self, include_relationships=True): - dict_corpus = { - 'analysis_url': self.analysis_url, - 'url': self.url, - 'id': self.id, - 'user_id': self.user_id, - 'creation_date': self.creation_date.isoformat() + 'Z', - 'description': self.description, - 'max_num_tokens': self.max_num_tokens, - 'num_analysis_sessions': self.num_analysis_sessions, - 'num_tokens': self.num_tokens, - 'status': self.status, - 'last_edited_date': self.last_edited_date.isoformat() + 'Z', - 'title': self.title - } - if include_relationships: - dict_corpus['files'] = {file.id: file.to_dict() - for file in self.files} - return dict_corpus + @property + def user_hashid(self): + return self.user.hashid def build(self): output_dir = os.path.join(self.path, 'merged') @@ -646,17 +685,33 @@ class Corpus(db.Model): shutil.rmtree(self.path, ignore_errors=True) db.session.delete(self) - def __repr__(self): - ''' - String representation of the corpus. For human readability. - ''' - return ''.format(self.title) + def to_dict(self, backrefs=False, relationships=False): + dict_corpus = { + 'id': self.hashid, + 'user_id': self.user.hashid, + 'analysis_url': self.analysis_url, + 'url': self.url, + 'creation_date': self.creation_date.isoformat() + 'Z', + 'description': self.description, + 'max_num_tokens': self.max_num_tokens, + 'num_analysis_sessions': self.num_analysis_sessions, + 'num_tokens': self.num_tokens, + 'status': self.status, + 'last_edited_date': self.last_edited_date.isoformat() + 'Z', + 'title': self.title + } + if backrefs: + dict_corpus['user'] = self.user.to_dict( + backrefs=True, relationships=False) + if relationships: + dict_corpus['files'] = { + x.id: x.to_dict(backrefs=False, relationships=True) + for x in self.files + } + return dict_corpus -class QueryResult(db.Model): - ''' - Class to define a corpus analysis result. - ''' +class QueryResult(FileMixin, HashidMixin, db.Model): __tablename__ = 'query_results' # Primary key id = db.Column(db.Integer, primary_key=True) @@ -664,49 +719,60 @@ class QueryResult(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('users.id')) # Fields description = db.Column(db.String(255)) - filename = db.Column(db.String(255)) query_metadata = db.Column(db.JSON()) title = db.Column(db.String(32)) - - @property - def download_url(self): - return url_for('corpora.download_query_result', - query_result_id=self.id) - - @property - def jsonpatch_path(self): - return '/query_results/{}'.format(self.id) - - @property - def path(self): - return os.path.join( - self.creator.path, 'query_results', str(self.id), self.filename) - - @property - def url(self): - return url_for('corpora.query_result', query_result_id=self.id) - - def delete(self): - shutil.rmtree(self.path, ignore_errors=True) - db.session.delete(self) - - def to_dict(self, include_relationships=True): - return {'download_url': self.download_url, - 'url': self.url, - 'id': self.id, - 'user_id': self.user_id, - 'corpus_title': self.query_metadata['corpus_name'], - 'description': self.description, - 'filename': self.filename, - 'query': self.query_metadata['query'], - 'query_metadata': self.query_metadata, - 'title': self.title} + # Backrefs: user: User def __repr__(self): ''' String representation of the QueryResult. For human readability. ''' - return ''.format(self.title) + return f'' + + @property + def download_url(self): + return url_for( + 'corpora.download_query_result', query_result_id=self.id) + + @property + def jsonpatch_path(self): + return f'{self.user.jsonpatch_path}/query_results/{self.hashid}' + + @property + def path(self): + return os.path.join( + self.user.path, 'query_results', str(self.id), self.filename) + + @property + def url(self): + return url_for('corpora.query_result', query_result_id=self.id) + + @property + def user_hashid(self): + return self.user.hashid + + def delete(self): + shutil.rmtree(self.path, ignore_errors=True) + db.session.delete(self) + + def to_dict(self, backrefs=False, relationships=False): + dict_query_result = { + 'id': self.hashid, + 'user_id': self.user.hashid, + 'download_url': self.download_url, + 'url': self.url, + 'corpus_title': self.query_metadata['corpus_name'], + 'description': self.description, + 'filename': self.filename, + 'query': self.query_metadata['query'], + 'query_metadata': self.query_metadata, + 'title': self.title, + **self.file_mixin_to_dict( + backrefs=backrefs, relationships=relationships) + } + if backrefs: + dict_query_result['user'] = self.user.to_dict( + backrefs=True, relationships=False) @login.user_loader diff --git a/app/services/routes.py b/app/services/routes.py index fc7b0aa7..26218226 100644 --- a/app/services/routes.py +++ b/app/services/routes.py @@ -24,8 +24,8 @@ def service(service): # Check if the requested service exist if service not in SERVICES or service not in AddJobForms: abort(404) - version = request.args.get('version', - SERVICES[service]['versions']['latest']) + version = request.args.get( + 'version', SERVICES[service]['versions']['latest']) if version not in SERVICES[service]['versions']: abort(404) form = AddJobForms[service](prefix='add-job-form', version=version) @@ -44,7 +44,7 @@ def service(service): service_args.append('-l {}'.format(form.language.data)) if form.binarization.data: service_args.append('--binarize') - job = Job(creator=current_user, + job = Job(user=current_user, description=form.description.data, service=service, service_args=json.dumps(service_args), service_version=form.version.data, @@ -65,7 +65,8 @@ def service(service): else: for file in form.files.data: filename = secure_filename(file.filename) - job_input = JobInput(filename=filename, job=job) + job_input = JobInput( + filename=filename, job=job, mimetype=file.mimetype) file.save(job_input.path) db.session.add(job_input) job.status = 'submitted' diff --git a/app/static/js/nopaque/App.js b/app/static/js/nopaque/App.js new file mode 100644 index 00000000..9162640a --- /dev/null +++ b/app/static/js/nopaque/App.js @@ -0,0 +1,80 @@ +class App { + constructor() { + this.data = {users: {}}; + this.eventListeners = {'users.patch': []}; + this.socket = io({transports: ['websocket'], upgrade: false}); + this.socket.on('users.patch', patch => this.usersPatchHandler(patch)); + } + + get users() {return this.data.users;} + + addEventListener(type, listener) { + if (!(type in this.eventListeners)) {throw `Unknown event type: ${type}`;} + this.eventListeners[type].push(listener); + } + + flash(message, category) { + let toast, toastCloseActionElement; + switch (category) { + case "corpus": + message = `book${message}`; + break; + case "error": + message = `error${message}`; + break; + case "job": + message = `J${message}`; + break; + default: + message = `notifications${message}`; + } + toast = M.toast({ + html: ` + ${message} + + `.trim() + }); + toastCloseActionElement = toast.el.querySelector('.toast-action[data-action="close"]'); + toastCloseActionElement.addEventListener('click', () => {toast.dismiss();}); + } + + getUserById(userId) { + return new Promise((resolve, reject) => { + if (userId in this.data.users) {resolve(this.users[userId]);} + this.socket.emit('users.user.get', userId, response => { + if (response.code === 200) { + this.data.users[userId] = response.payload; + resolve(this.data.users[userId]); + } else { + reject(response); + } + }); + }); + } + + usersPatchHandler(patch) { + let re, match, userId, ressourceId, jobId, relationship; + for (let operation of patch.filter(operation => operation.op === 'add')) { + re = new RegExp(`^/users/([A-Za-z0-9]*)/corpora/([A-Za-z0-9]*)/(files)`); + if (re.test(operation.path)) { + [match, userId, ressourceId, relationship] = operation.path.match(re); + if (!(relationship in this.users[userId].corpora[ressourceId])) { + this.users[userId].corpora[ressourceId][relationship] = {}; + } + continue; + } + re = new RegExp(`^/users/([A-Za-z0-9]*)/jobs/([A-Za-z0-9]*)/(inputs|results)`); + if (re.test(operation.path)) { + [match, userId, ressourceId, relationship] = operation.path.match(re); + if (!(relationship in this.users[userId].jobs[ressourceId])) { + this.users[userId].jobs[ressourceId][relationship] = {}; + } + continue; + } + } + this.data = jsonpatch.apply_patch(this.data, patch); + for (let listener of this.eventListeners['users.patch']) {listener(patch);} + } +} diff --git a/app/static/js/nopaque/JobStatusNotifier.js b/app/static/js/nopaque/JobStatusNotifier.js new file mode 100644 index 00000000..51adf06e --- /dev/null +++ b/app/static/js/nopaque/JobStatusNotifier.js @@ -0,0 +1,17 @@ +class JobStatusNotifier { + constructor(userId) { + this.userId = userId; + } + + usersPatchHandler(patch) { + let re, filteredPatch, match, jobId; + re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/status$`) + filteredPatch = patch + .filter(operation => operation.op === 'replace') + .filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { + [match, jobId] = operation.path.match(re); + app.flash(`[${app.users[this.userId].jobs[jobId].title}] New status: ${operation.value}`, 'job'); + } + } +} diff --git a/app/static/js/nopaque/RessourceDisplays/CorpusDisplay.js b/app/static/js/nopaque/RessourceDisplays/CorpusDisplay.js index d6038ef6..739dde2f 100644 --- a/app/static/js/nopaque/RessourceDisplays/CorpusDisplay.js +++ b/app/static/js/nopaque/RessourceDisplays/CorpusDisplay.js @@ -2,31 +2,34 @@ class CorpusDisplay extends RessourceDisplay { constructor(displayElement) { super(displayElement); this.corpusId = displayElement.dataset.corpusId; - this.user.eventListeners.corpus.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.corpusId); + for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) { + exportCorpusTriggerElement.addEventListener('click', () => this.requestCorpusExport()); + } + app.socket.on(`export_corpus_${this.corpusId}`, () => this.downloadCorpus()); } - init() { - for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) {exportCorpusTriggerElement.addEventListener('click', () => this.requestCorpusExport());} - nopaque.appClient.socket.on(`export_corpus_${this.user.data.corpora[this.corpusId].id}`, () => this.downloadCorpus()); - this.setCreationDate(this.user.data.corpora[this.corpusId].creation_date); - this.setDescription(this.user.data.corpora[this.corpusId].description); - this.setLastEditedDate(this.user.data.corpora[this.corpusId].last_edited_date); - this.setStatus(this.user.data.corpora[this.corpusId].status); - this.setTitle(this.user.data.corpora[this.corpusId].title); - this.setTokenRatio(this.user.data.corpora[this.corpusId].num_tokens, this.user.data.corpora[this.corpusId].max_num_tokens); + init(user) { + let corpus; + corpus = user.corpora[this.corpusId]; + this.setCreationDate(corpus.creation_date); + this.setDescription(corpus.description); + this.setLastEditedDate(corpus.last_edited_date); + this.setStatus(corpus.status); + this.setTitle(corpus.title); + this.setTokenRatio(corpus.num_tokens, corpus.max_num_tokens); } patch(patch) { - let re; - for (let operation of patch) { + let re, filteredPatch; + re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`); + filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { case 'replace': - // Matches: /jobs/{this.job.id}/status - re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/last_edited_date'); + re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/last_edited_date$`); if (re.test(operation.path)) {this.setLastEditedDate(operation.value); break;} - // Matches: /jobs/{this.job.id}/status - re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/status$'); - if (re.test(operation.path)) {this.setStatus(operation.value); break;} + re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/status$`); + if (re.test(operation.path)) {this.status$(operation.value); break;} break; default: break; @@ -35,18 +38,19 @@ class CorpusDisplay extends RessourceDisplay { } requestCorpusExport() { - nopaque.appClient.socket.emit('export_corpus', this.user.data.corpora[this.corpusId].id); - nopaque.appClient.flash('Preparing your corpus export...', 'corpus'); + app.socket.emit('export_corpus', app.users[this.userId].corpora[this.corpusId]); + app.flash('Preparing your corpus export...', 'corpus'); for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) {exportCorpusTriggerElement.classList.toggle('disabled', true);} } downloadCorpus() { - nopaque.appClient.flash('Corpus export is done. Your corpus download is ready!', 'corpus'); + let downloadButton; + app.flash('Corpus download is ready!', 'corpus'); for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) {exportCorpusTriggerElement.classList.toggle('disabled', false);} // Little trick to call the download view after ziping has finished - let fakeBtn = document.createElement('a'); - fakeBtn.href = `/corpora/${this.user.data.corpora[this.corpusId].id}/download`; - fakeBtn.click(); + downloadButton = document.createElement('a'); + downloadButton.href = `/corpora/${app.users[this.userId].corpora[this.corpusId]}/download`; + downloadButton.click(); } setTitle(title) { @@ -70,7 +74,7 @@ class CorpusDisplay extends RessourceDisplay { } } for (let element of this.displayElement.querySelectorAll('.build-corpus-trigger')) { - if (status === 'unprepared' && Object.values(this.user.data.corpora[this.corpusId].files).length > 0) { + if (status === 'unprepared' && Object.values(app.users[this.userId].corpora[this.corpusId].files).length > 0) { element.classList.remove('disabled'); } else { element.classList.add('disabled'); @@ -90,13 +94,15 @@ class CorpusDisplay extends RessourceDisplay { } } - setCreationDate(iso8601CreationDate) { - let creationDate = new Date(iso8601CreationDate).toLocaleString("en-US"); - for (let element of this.displayElement.querySelectorAll('.corpus-creation-date')) {this.setElement(element, creationDate);} + setCreationDate(creationDate) { + for (let element of this.displayElement.querySelectorAll('.corpus-creation-date')) { + this.setElement(element, creationDate.toLocaleString("en-US")); + } } - setLastEditedDate(iso8601LastEditedDate) { - let endDate = new Date(iso8601LastEditedDate).toLocaleString("en-US"); - for (let element of this.displayElement.querySelectorAll('.corpus-end-date')) {this.setElement(element, endDate);} + setLastEditedDate(lastEditedDate) { + for (let element of this.displayElement.querySelectorAll('.corpus-end-date')) { + this.setElement(element, lastEditedDate.toLocaleString("en-US")); + } } } diff --git a/app/static/js/nopaque/RessourceDisplays/JobDisplay.js b/app/static/js/nopaque/RessourceDisplays/JobDisplay.js index dfe42904..ac181509 100644 --- a/app/static/js/nopaque/RessourceDisplays/JobDisplay.js +++ b/app/static/js/nopaque/RessourceDisplays/JobDisplay.js @@ -1,32 +1,37 @@ class JobDisplay extends RessourceDisplay { constructor(displayElement) { super(displayElement); - this.jobId = displayElement.dataset.jobId; - this.user.eventListeners.job.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.jobId); + this.jobId = this.displayElement.dataset.jobId; } - init(job) { - this.setCreationDate(this.user.data.jobs[this.jobId].creation_date); - this.setEndDate(this.user.data.jobs[this.jobId].creation_date); - this.setDescription(this.user.data.jobs[this.jobId].description); - this.setService(this.user.data.jobs[this.jobId].service); - this.setServiceArgs(this.user.data.jobs[this.jobId].service_args); - this.setServiceVersion(this.user.data.jobs[this.jobId].service_version); - this.setStatus(this.user.data.jobs[this.jobId].status); - this.setTitle(this.user.data.jobs[this.jobId].title); + init(user) { + let job = user.jobs[this.jobId]; + this.setCreationDate(job.creation_date); + this.setEndDate(job.creation_date); + this.setDescription(job.description); + this.setService(job.service); + this.setServiceArgs(job.service_args); + this.setServiceVersion(job.service_version); + this.setStatus(job.status); + this.setTitle(job.title); } - patch(patch) { - let re; - for (let operation of patch) { + usersPatchHandler(patch) { + let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { case 'replace': - // Matches: /jobs/{this.user.data.jobs[this.jobId].id}/status - re = new RegExp('^/jobs/' + this.user.data.jobs[this.jobId].id + '/end_date'); - if (re.test(operation.path)) {this.setEndDate(operation.value); break;} - // Matches: /jobs/{this.user.data.jobs[this.jobId].id}/status - re = new RegExp('^/jobs/' + this.user.data.jobs[this.jobId].id + '/status$'); - if (re.test(operation.path)) {this.setStatus(operation.value); break;} + re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/end_date$`); + if (re.test(operation.path)) { + this.setEndDate(operation.value); + break; + } + re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/status$`); + if (re.test(operation.path)) { + this.setStatus(operation.value); + break; + } break; default: break; @@ -63,14 +68,16 @@ class JobDisplay extends RessourceDisplay { } } - setCreationDate(iso8601CreationDate) { - let creationDate = new Date(iso8601CreationDate).toLocaleString("en-US"); - for (let element of this.displayElement.querySelectorAll('.job-creation-date')) {this.setElement(element, creationDate);} + setCreationDate(creationDate) { + for (let element of this.displayElement.querySelectorAll('.job-creation-date')) { + this.setElement(element, creationDate.toLocaleString('en-US')); + } } - setEndDate(iso8601EndDate) { - let endDate = new Date(iso8601EndDate).toLocaleString("en-US"); - for (let element of this.displayElement.querySelectorAll('.job-end-date')) {this.setElement(element, endDate);} + setEndDate(endDate) { + for (let element of this.displayElement.querySelectorAll('.job-end-date')) { + this.setElement(element, endDate.toLocaleString('en-US')); + } } setService(service) { diff --git a/app/static/js/nopaque/RessourceDisplays/RessourceDisplay.js b/app/static/js/nopaque/RessourceDisplays/RessourceDisplay.js index 13ca95d0..c7aebe75 100644 --- a/app/static/js/nopaque/RessourceDisplays/RessourceDisplay.js +++ b/app/static/js/nopaque/RessourceDisplays/RessourceDisplay.js @@ -1,35 +1,14 @@ class RessourceDisplay { constructor(displayElement) { - if (displayElement.dataset.userId) { - if (displayElement.dataset.userId in nopaque.appClient.users) { - this.user = nopaque.appClient.users[displayElement.dataset.userId]; - } else { - console.error(`User not found: ${displayElement.dataset.userId}`); - return; - } - } else { - this.user = nopaque.appClient.users.self; - } this.displayElement = displayElement; + this.userId = this.displayElement.dataset.userId; + app.addEventListener('users.patch', patch => this.usersPatchHandler(patch)); + app.getUserById(this.userId).then(user => this.init(user), error => {throw JSON.stringify(error);}); } - eventHandler(eventType, payload) { - switch (eventType) { - case 'init': - this.init(payload); - break; - case 'patch': - this.patch(payload); - break; - default: - console.error(`Unknown event type: ${eventType}`); - break; - } - } + init(user) {throw 'Not implemented';} - init() {console.error('init method not implemented!');} - - patch() {console.error('patch method not implemented!');} + usersPatchHandler(patch) {throw 'Not implemented';} setElement(element, value) { switch (element.tagName) { diff --git a/app/static/js/nopaque/RessourceLists/CorpusFileList.js b/app/static/js/nopaque/RessourceLists/CorpusFileList.js index b5b636fa..fdd26fcd 100644 --- a/app/static/js/nopaque/RessourceLists/CorpusFileList.js +++ b/app/static/js/nopaque/RessourceLists/CorpusFileList.js @@ -2,32 +2,32 @@ class CorpusFileList extends RessourceList { constructor(listElement, options = {}) { super(listElement, {...CorpusFileList.options, ...options}); this.corpusId = listElement.dataset.corpusId; - this.user.eventListeners.corpus.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.corpusId); } - init() { - super.init(this.user.data.corpora[this.corpusId].files); + init(user) { + this._init(user.corpora[this.corpusId].files); } onclick(event) { - let ressourceElement = event.target.closest('tr'); - if (ressourceElement === null) {return;} - let corpusFileId = ressourceElement.dataset.id; - let actionButtonElement = event.target.closest('.action-button'); - if (actionButtonElement === null) {return;} + let corpusFileElement = event.target.closest('tr[data-id]'); + if (corpusFileElement === null) {throw 'Could not locate corpus file element';} + let corpusFileId = corpusFileElement.dataset.id; + let actionButtonElement = event.target.closest('.action-button[data-action]'); let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; switch (action) { case 'delete': - let deleteModalHTML = ``; + let deleteModalHTML = ` + + `.trim(); let deleteModalParentElement = document.querySelector('#modals'); deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML); let deleteModalElement = deleteModalParentElement.lastChild; @@ -35,40 +35,37 @@ class CorpusFileList extends RessourceList { deleteModal.open(); break; case 'download': - window.location.href = this.user.data.corpora[this.corpusId].files[corpusFileId].download_url; + window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}/download`; break; case 'view': - if (corpusFileId !== '-1') {window.location.href = this.user.data.corpora[this.corpusId].files[corpusFileId].url;} + window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}`; break; default: - console.error(`Unknown action: "${action}"`); break; } } - patch(patch) { - let id, match, re, valueName; - for (let operation of patch) { + usersPatchHandler(patch) { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { case 'add': - // Matches the only paths that should be handled here: /corpora/{this.user.data.corpora[this.corpusId].id}/files/{corpusFileId} - re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/files/(\\d+)$'); + re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`); if (re.test(operation.path)) {this.add(operation.value);} break; case 'remove': - // See case add ;) - re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/files/(\\d+)$'); + re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`); if (re.test(operation.path)) { - [match, id] = operation.path.match(re); - this.remove(id); + [match, corpusFileId] = operation.path.match(re); + this.remove(corpusFileId); } break; case 'replace': - // Matches the only paths that should be handled here: /corpora/{corpusId}/{status || description || title} - re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/files/(\\d+)/(author|filename|publishing_year|title)$'); + re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`); if (re.test(operation.path)) { - [match, id, valueName] = operation.path.match(re); - this.replace(id, valueName, operation.value); + [match, corpusFileId, valueName] = operation.path.match(re); + this.replace(corpusFileId, valueName, operation.value); } break; default: @@ -78,20 +75,29 @@ class CorpusFileList extends RessourceList { } preprocessRessource(corpusFile) { - return {id: corpusFile.id, author: corpusFile.author, filename: corpusFile.filename, publishing_year: corpusFile.publishing_year, title: corpusFile.title}; + return { + id: corpusFile.id, + author: corpusFile.author, + creationDate: corpusFile.creation_date, + filename: corpusFile.filename, + publishing_year: corpusFile.publishing_year, + title: corpusFile.title + }; } } CorpusFileList.options = { - item: ` - - - - - - delete - file_download - send - - `, + item: ` + + + + + + + delete + file_download + send + + + `.trim(), valueNames: [{data: ['id']}, 'author', 'filename', 'publishing_year', 'title'] }; diff --git a/app/static/js/nopaque/RessourceLists/CorpusList.js b/app/static/js/nopaque/RessourceLists/CorpusList.js index ffe555cf..f9c5fead 100644 --- a/app/static/js/nopaque/RessourceLists/CorpusList.js +++ b/app/static/js/nopaque/RessourceLists/CorpusList.js @@ -1,31 +1,32 @@ class CorpusList extends RessourceList { constructor(listElement, options = {}) { super(listElement, {...CorpusList.options, ...options}); - this.user.eventListeners.corpus.addEventListener((eventType, payload) => this.eventHandler(eventType, payload)); } - init() { - super.init(this.user.data.corpora); + init(user) { + super._init(user.corpora); } onclick(event) { - let ressourceElement = event.target.closest('tr'); - if (ressourceElement === null) {return;} - let corpusId = ressourceElement.dataset.id; - let actionButtonElement = event.target.closest('.action-button'); - let action = (actionButtonElement === null) ? 'view' : actionButtonElement.dataset.action; + let corpusElement = event.target.closest('tr[data-id]'); + if (corpusElement === null) {throw 'Could not locate corpus element';} + let corpusId = corpusElement.dataset.id; + let actionButtonElement = event.target.closest('.action-button[data-action]'); + let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; switch (action) { case 'delete': - let deleteModalHTML = ``; + let deleteModalHTML = ` + + `.trim(); let deleteModalParentElement = document.querySelector('#modals'); deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML); let deleteModalElement = deleteModalParentElement.lastChild; @@ -33,37 +34,34 @@ class CorpusList extends RessourceList { deleteModal.open(); break; case 'view': - if (corpusId !== '-1') {window.location.href = this.user.data.corpora[corpusId].url;} + window.location.href = `/corpora/${corpusId}`; break; default: - console.error(`Unknown action: ${action}`); break; } } - patch(patch) { - let id, match, re, valueName; - for (let operation of patch) { + usersPatchHandler(patch) { + let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { case 'add': - // Matches the only paths that should be handled here: /corpora/{corpusId} - re = /^\/corpora\/(\d+)$/; + re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`); if (re.test(operation.path)) {this.add(operation.value);} break; case 'remove': - // See case 'add' ;) - re = /^\/corpora\/(\d+)$/; + re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`); if (re.test(operation.path)) { - [match, id] = operation.path.match(re); - this.remove(id); + let [match, corpusId] = operation.path.match(re); + this.remove(corpusId); } break; case 'replace': - // Matches the only paths that should be handled here: /corpora/{corpusId}/{status || description || title} - re = /^\/corpora\/(\d+)\/(status|description|title)$/; + re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`); if (re.test(operation.path)) { - [match, id, valueName] = operation.path.match(re); - this.replace(id, valueName, operation.value); + let [match, corpusId, valueName] = operation.path.match(re); + this.replace(corpusId, valueName, operation.value); } break; default: @@ -73,21 +71,26 @@ class CorpusList extends RessourceList { } preprocessRessource(corpus) { - return {id: corpus.id, - status: corpus.status, - description: corpus.description, - title: corpus.title}; + return { + id: corpus.id, + creationDate: corpus.creation_date, + description: corpus.description, + status: corpus.status, + title: corpus.title + }; } } CorpusList.options = { - item: ` - book -
- - - delete - send - - `, + item: ` + + book +
+ + + delete + send + + + `.trim(), valueNames: [{data: ['id']}, {name: 'status', attr: 'data-status'}, 'description', 'title'] }; diff --git a/app/static/js/nopaque/RessourceLists/JobInputList.js b/app/static/js/nopaque/RessourceLists/JobInputList.js index 6d3dcd4f..1a919552 100644 --- a/app/static/js/nopaque/RessourceLists/JobInputList.js +++ b/app/static/js/nopaque/RessourceLists/JobInputList.js @@ -2,40 +2,46 @@ class JobInputList extends RessourceList { constructor(listElement, options = {}) { super(listElement, {...JobInputList.options, ...options}); this.jobId = listElement.dataset.jobId; - this.user.eventListeners.job.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.jobId); } - init() { - super.init(this.user.data.jobs[this.jobId].inputs); + init(user) { + this._init(user.jobs[this.jobId].inputs); } onclick(event) { - let ressourceElement = event.target.closest('tr'); - if (ressourceElement === null) {return;} - let jobInputId = ressourceElement.dataset.id; - let actionButtonElement = event.target.closest('.action-button'); + let jobInputElement = event.target.closest('tr[data-id]'); + if (jobInputElement === null) {return;} + let jobInputId = jobInputElement.dataset.id; + let actionButtonElement = event.target.closest('.action-button[data-action]'); if (actionButtonElement === null) {return;} let action = actionButtonElement.dataset.action; switch (action) { case 'download': - window.location.href = this.user.data.jobs[this.jobId].inputs[jobInputId].download_url; + window.location.href = `/jobs/${this.jobId}/inputs/${jobInputId}/download`; break; default: - console.error(`Unknown action: "${action}"`); break; } } + usersPatchHandler(patch) {return;} + preprocessRessource(jobInput) { - return {id: jobInput.id, filename: jobInput.filename}; + return { + id: jobInput.id, + creationDate: jobInput.creation_date, + filename: jobInput.filename + }; } } JobInputList.options = { - item: ` - - - file_download - - `, + item: ` + + + + file_download + + + `.trim(), valueNames: [{data: ['id']}, 'filename'] }; diff --git a/app/static/js/nopaque/RessourceLists/JobList.js b/app/static/js/nopaque/RessourceLists/JobList.js index d0311928..3c21abcb 100644 --- a/app/static/js/nopaque/RessourceLists/JobList.js +++ b/app/static/js/nopaque/RessourceLists/JobList.js @@ -1,31 +1,32 @@ class JobList extends RessourceList { constructor(listElement, options = {}) { super(listElement, {...JobList.options, ...options}); - this.user.eventListeners.job.addEventListener((eventType, payload) => this.eventHandler(eventType, payload)); } - init() { - super.init(this.user.data.jobs); + init(user) { + this._init(user.jobs); } onclick(event) { - let ressourceElement = event.target.closest('tr'); - if (ressourceElement === null) {return;} - let jobId = ressourceElement.dataset.id; - let actionButtonElement = event.target.closest('.action-button'); + let jobElement = event.target.closest('tr[data-id]'); + if (jobElement === null) {throw 'Could not locate job element';} + let jobId = jobElement.dataset.id; + let actionButtonElement = event.target.closest('.action-button[data-action]'); let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; switch (action) { case 'delete': - let deleteModalHTML = ``; + let deleteModalHTML = ` + + `.trim(); let deleteModalParentElement = document.querySelector('#modals'); deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML); let deleteModalElement = deleteModalParentElement.lastChild; @@ -33,37 +34,34 @@ class JobList extends RessourceList { deleteModal.open(); break; case 'view': - if (jobId !== '-1') {window.location.href = this.user.data.jobs[jobId].url;} + window.location.href = `/jobs/${jobId}`; break; default: - console.error(`Unknown action: "${action}"`); break; } } - patch(patch) { - let id, match, re, valueName; - for (let operation of patch) { + usersPatchHandler(patch) { + let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { case 'add': - // Matches the only paths that should be handled here: /jobs/{jobId} - re = /^\/jobs\/(\d+)$/; + re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`); if (re.test(operation.path)) {this.add(operation.value);} break; case 'remove': - // See case add ;) - re = /^\/jobs\/(\d+)$/; + re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`); if (re.test(operation.path)) { - [match, id] = operation.path.match(re); - this.remove(id); + let [match, jobId] = operation.path.match(re); + this.remove(jobId); } break; case 'replace': - // Matches the only paths that should be handled here: /jobs/{jobId}/{service || status || description || title} - re = /^\/jobs\/(\d+)\/(service|status|description|title)$/; + re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`); if (re.test(operation.path)) { - [match, id, valueName] = operation.path.match(re); - this.replace(id, valueName, operation.value); + let [match, jobId, valueName] = operation.path.match(re); + this.replace(jobId, valueName, operation.value); } break; default: @@ -73,22 +71,27 @@ class JobList extends RessourceList { } preprocessRessource(job) { - return {id: job.id, - service: job.service, - status: job.status, - description: job.description, - title: job.title}; + return { + id: job.id, + creationDate: job.creation_date, + description: job.description, + service: job.service, + status: job.status, + title: job.title + }; } } JobList.options = { - item: ` - -
- - - delete - send - - `, + item: ` + + +
+ + + delete + send + + + `.trim(), valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: 'status', attr: 'data-status'}, 'description', 'title'] }; diff --git a/app/static/js/nopaque/RessourceLists/JobResultList.js b/app/static/js/nopaque/RessourceLists/JobResultList.js index 44b35675..e7410157 100644 --- a/app/static/js/nopaque/RessourceLists/JobResultList.js +++ b/app/static/js/nopaque/RessourceLists/JobResultList.js @@ -2,37 +2,35 @@ class JobResultList extends RessourceList { constructor(listElement, options = {}) { super(listElement, {...JobResultList.options, ...options}); this.jobId = listElement.dataset.jobId; - this.user.eventListeners.job.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.jobId); } - init() { - super.init(this.user.data.jobs[this.jobId].results); + init(user) { + super._init(user.jobs[this.jobId].results); } onclick(event) { - let ressourceElement = event.target.closest('tr'); - if (ressourceElement === null) {return;} - let jobResultId = ressourceElement.dataset.id; - let actionButtonElement = event.target.closest('.action-button'); + let jobResultElement = event.target.closest('tr[data-id]'); + if (jobResultElement === null) {return;} + let jobResultId = jobResultElement.dataset.id; + let actionButtonElement = event.target.closest('.action-button[data-action]'); if (actionButtonElement === null) {return;} let action = actionButtonElement.dataset.action; switch (action) { case 'download': - window.location.href = this.user.data.jobs[this.jobId].results[jobResultId].download_url; + window.location.href = `/jobs/${this.jobId}/results/${jobResultId}`; break; default: - console.error(`Unknown action: "${action}"`); break; } } - patch(patch) { - let re; - for (let operation of patch) { + usersPatchHandler(patch) { + let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { case 'add': - // Matches the only paths that should be handled here: /jobs/{this.user.data.jobs[this.jobId].id}/results/{jobResultId} - re = new RegExp('^/jobs/' + this.user.data.jobs[this.jobId].id + '/results/(\\d+)$'); + re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`); if (re.test(operation.path)) {this.add(operation.value);} break; default: @@ -56,16 +54,23 @@ class JobResultList extends RessourceList { } else { description = 'All result files created during this job'; } - return {id: jobResult.id, description: description, filename: jobResult.filename}; + return { + id: jobResult.id, + creationDate: jobResult.creation_date, + description: description, + filename: jobResult.filename + }; } } JobResultList.options = { - item: ` - - - - file_download - - `, + item: ` + + + + + file_download + + + `.trim(), valueNames: [{data: ['id']}, 'description', 'filename'] }; diff --git a/app/static/js/nopaque/RessourceLists/QueryResultList.js b/app/static/js/nopaque/RessourceLists/QueryResultList.js index 4fb7b44b..c64361b5 100644 --- a/app/static/js/nopaque/RessourceLists/QueryResultList.js +++ b/app/static/js/nopaque/RessourceLists/QueryResultList.js @@ -1,31 +1,32 @@ class QueryResultList extends RessourceList { constructor(listElement, options = {}) { super(listElement, {...QueryResultList.options, ...options}); - this.user.eventListeners.queryResult.addEventListener((eventType, payload) => this.eventHandler(eventType, payload)); } - init() { - super.init(this.user.data.query_results); + init(user) { + super.init(user.query_results); } onclick(event) { - let ressourceElement = event.target.closest('tr'); - if (ressourceElement === null) {return;} - let queryResultId = ressourceElement.dataset.id; - let actionButtonElement = event.target.closest('.action-button'); + let queryResultElement = event.target.closest('tr[data-id]'); + if (queryResultElement === null) {return;} + let queryResultId = queryResultElement.dataset.id; + let actionButtonElement = event.target.closest('.action-button[data-action]'); let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; switch (action) { case 'delete': - let deleteModalHTML = ``; + let deleteModalHTML = ` + + `.trim(); let deleteModalParentElement = document.querySelector('#modals'); deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML); let deleteModalElement = deleteModalParentElement.lastChild; @@ -33,37 +34,34 @@ class QueryResultList extends RessourceList { deleteModal.open(); break; case 'view': - if (queryResultId !== '-1') {window.location.href = this.user.data.query_results[queryResultId].url;} + window.location.href = `/query_results/${queryResultId}`; break; default: - console.error(`Unknown action: "${action}"`); break; } } - patch(patch) { - let id, match, re, valueName; - for (let operation of patch) { + usersPatchHandler(patch) { + let re = new RegExp(`^/users/${this.userId}/query_results/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { case 'add': - // Matches the only paths that should be handled here: /jobs/{jobId} - re = /^\/query_results\/(\d+)$/; + re = new RegExp(`^/users/${this.userId}/query_results/([A-Za-z0-9]*)$`); if (re.test(operation.path)) {this.add(operation.value);} break; case 'remove': - // See case add ;) - re = /^\/query_results\/(\d+)$/; + re = new RegExp(`^/users/${this.userId}/query_results/([A-Za-z0-9]*)$`); if (re.test(operation.path)) { - [match, id] = operation.path.match(re); - this.remove(id); + let [match, queryResultId] = operation.path.match(re); + this.remove(queryResultId); } break; case 'replace': - // Matches the only paths that should be handled here: /jobs/{jobId}/{service || status || description || title} - re = /^\/query_results\/(\d+)\/(corpus_title|description|query|title)$/; + re = new RegExp(`^/users/${this.userId}/query_results/([A-Za-z0-9]*)/(corpus_title|description|query|title)$`); if (re.test(operation.path)) { - [match, id, valueName] = operation.path.match(re); - this.replace(id, valueName, operation.value); + let [match, queryResultId, valueName] = operation.path.match(re); + this.replace(queryResultId, valueName, operation.value); } break; default: @@ -73,21 +71,26 @@ class QueryResultList extends RessourceList { } preprocessRessource(queryResult) { - return {id: queryResult.id, - corpus_title: queryResult.corpus_title, - description: queryResult.description, - query: queryResult.query, - title: queryResult.title}; + return { + id: queryResult.id, + corpus_title: queryResult.corpus_title, + creationDate: queryResult.creation_date, + description: queryResult.description, + query: queryResult.query, + title: queryResult.title + }; } } QueryResultList.options = { - item: ` -

-
- - delete - send - - `, + item: ` + +

+
+ + delete + send + + + `.trim(), valueNames: [{data: ['id']}, 'corpus_title', 'description', 'query', 'title'] }; diff --git a/app/static/js/nopaque/RessourceLists/RessourceList.js b/app/static/js/nopaque/RessourceLists/RessourceList.js index ec289861..81b9133d 100644 --- a/app/static/js/nopaque/RessourceLists/RessourceList.js +++ b/app/static/js/nopaque/RessourceLists/RessourceList.js @@ -4,87 +4,68 @@ class RessourceList { * a base class for concrete ressource list implementations. */ constructor(listElement, options = {}) { - if (listElement.dataset.userId) { - if (listElement.dataset.userId in nopaque.appClient.users) { - this.user = nopaque.appClient.users[listElement.dataset.userId]; - } else { - console.error(`User not found: ${listElement.dataset.userId}`); - return; - } - } else { - this.user = nopaque.appClient.users.self; - } this.list = new List(listElement, {...RessourceList.options, ...options}); - this.list.list.innerHTML = ` - -
 
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Waiting for data... -

This list is not initialized yet.

-
- - `; + this.list.list.innerHTML = ` + + +
 
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Waiting for data... +

This list is not initialized yet.

+
+ + + `.trim(); this.list.list.style.cursor = 'pointer'; + this.userId = listElement.dataset.userId; if (typeof this.onclick === 'function') {this.list.list.addEventListener('click', event => this.onclick(event));} - } - - eventHandler(eventType, payload) { - switch (eventType) { - case 'init': - this.init(); - break; - case 'patch': - this.patch(payload); - break; - default: - console.error(`Unknown event type: ${eventType}`); - break; + if (this.userId) { + app.addEventListener('users.patch', patch => this.usersPatchHandler(patch)); + app.getUserById(this.userId).then( + user => this.init(user), + error => {throw JSON.stringify(error);} + ); } } - init(ressources) { + _init(ressources) { this.list.clear(); this.add(Object.values(ressources)); - this.list.sort('id', {order: 'desc'}); - let emptyListElementHTML = ` - - file_downloadNothing here... -

No ressource available.

- - `; + let emptyListElementHTML = ` + + + file_downloadNothing here... +

No ressource available.

+ + + `.trim(); this.list.list.insertAdjacentHTML('afterbegin', emptyListElementHTML); } - patch(patch) { - /* - * It's not possible to generalize a patch Handler for all type of - * ressources. So this method is meant to be an interface. - */ - console.error('patch method not implemented!'); - } + init(user) {throw 'Not implemented';} + + usersPatchHandler(patch) {throw 'Not implemented';} + + preprocessRessource() {throw 'Not implemented'} add(values) { let ressources = Array.isArray(values) ? values : [values]; - if (typeof this.preprocessRessource === 'function') { - ressources = ressources.map(ressource => this.preprocessRessource(ressource)); - } - // Set a callback function ('() => {return;}') to force List.js perform the - // add method asynchronous: https://listjs.com/api/#add + ressources = ressources.map(ressource => this.preprocessRessource(ressource)); this.list.add(ressources, () => { this.list.sort('id', {order: 'desc'}); }); diff --git a/app/static/js/nopaque/RessourceLists/UserList.js b/app/static/js/nopaque/RessourceLists/UserList.js index 60c6ad02..04100d25 100644 --- a/app/static/js/nopaque/RessourceLists/UserList.js +++ b/app/static/js/nopaque/RessourceLists/UserList.js @@ -1,32 +1,32 @@ class UserList extends RessourceList { constructor(listElement, options = {}) { super(listElement, {...UserList.options, ...options}); - users = undefined; } init(users) { - this.users = users; - super.init(users); + super._init(Object.values(users)); } onclick(event) { - let ressourceElement = event.target.closest('tr'); - if (ressourceElement === null) {return;} - let userId = ressourceElement.dataset.id; - let actionButtonElement = event.target.closest('.action-button'); + let userElement = event.target.closest('tr[data-id]'); + if (userElement === null) {return;} + let userId = userElement.dataset.id; + let actionButtonElement = event.target.closest('.action-button[data-action]'); let action = (actionButtonElement === null) ? 'view' : actionButtonElement.dataset.action; switch (action) { case 'delete': - let deleteModalHTML = ``; + let deleteModalHTML = ` + + `.trim(); let deleteModalParentElement = document.querySelector('#modals'); deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML); let deleteModalElement = deleteModalParentElement.lastChild; @@ -37,35 +37,38 @@ class UserList extends RessourceList { window.location.href = `/admin/users/${userId}/edit`; break; case 'view': - if (userId !== '-1') {window.location.href = `/admin/users/${userId}`;} + window.location.href = `/admin/users/${userId}`; break; default: - console.error(`Unknown action: ${action}`); break; } } preprocessRessource(user) { - return {id: user.id, - id_: user.id, - username: user.username, - email: user.email, - last_seen: new Date(user.last_seen).toLocaleString("en-US"), - role: user.role.name}; + return { + id: user.id, + id_: user.id, + username: user.username, + email: user.email, + last_seen: user.last_seen.toLocaleString("en-US"), + role: user.role.name + }; } } UserList.options = { - item: ` - - - - - - - delete - edit - send - - `, + item: ` + + + + + + + + delete + edit + send + + + `.trim(), valueNames: [{data: ['id']}, 'id_', 'username', 'email', 'last_seen', 'role'] }; diff --git a/app/static/js/nopaque/main.js b/app/static/js/nopaque/main.js index 3ae00ee7..118af400 100644 --- a/app/static/js/nopaque/main.js +++ b/app/static/js/nopaque/main.js @@ -1,177 +1,3 @@ -class AppClient { - constructor(currentUserId) { - if (currentUserId) { - this.socket = io({transports: ['websocket'], upgrade: false}); - this.users = {}; - this.users.self = this.loadUser(currentUserId); - this.users.self.eventListeners.job.addEventListener((eventType, payload) => this.jobEventHandler(eventType, payload)); - } - } - - flash(message, category) { - let toast; - let toastCloseActionElement; - - switch (category) { - case "corpus": - message = `book${message}`; - break; - case "error": - message = `error${message}`; - break; - case "job": - message = `J${message}`; - break; - default: - message = `notifications${message}`; - } - - toast = M.toast({html: `${message} - `}); - toastCloseActionElement = toast.el.querySelector('.toast-action[data-action="close"]'); - toastCloseActionElement.addEventListener('click', () => {toast.dismiss();}); - } - - jobEventHandler(eventType, payload) { - switch (eventType) { - case 'init': - break; - case 'patch': - this.jobPatch(payload); - break; - default: - console.error(`[AppClient.jobEventHandler] Unknown event type: ${eventType}`); - break; - } - } - - loadUser(userId) { - if (userId in this.users) {return this.users[userId];} - let user = new User(); - this.users[userId] = user; - this.socket.on(`user_${userId}_init`, msg => user.init(msg)); - this.socket.on(`user_${userId}_patch`, msg => user.patch(msg)); - this.socket.emit('start_user_session', userId); - return user; - } - - jobPatch(patch) { - if (this.users.self.data.settings.job_status_site_notifications === 'none') {return;} - let jobStatusPatches = patch.filter(operation => operation.op === 'replace' && /^\/jobs\/(\d+)\/status$/.test(operation.path)); - for (let operation of jobStatusPatches) { - let [match, jobId] = operation.path.match(/^\/jobs\/(\d+)\/status$/); - if (this.users.self.data.settings.job_status_site_notifications === "end" && !['complete', 'failed'].includes(operation.value)) {continue;} - this.flash(`[${this.users.self.data.jobs[jobId].title}] New status: ${operation.value}`, 'job'); - } - } -} - -class User { - constructor() { - this.data = undefined; - this.eventListeners = { - corpus: { - addEventListener(listener, corpusId='*') { - if (corpusId in this) {this[corpusId].push(listener);} else {this[corpusId] = [listener];} - } - }, - job: { - addEventListener(listener, jobId='*') { - if (jobId in this) {this[jobId].push(listener);} else {this[jobId] = [listener];} - } - }, - queryResult: { - addEventListener(listener, queryResultId='*') { - if (queryResultId in this) {this[queryResultId].push(listener);} else {this[queryResultId] = [listener];} - } - } - }; - } - - init(data) { - this.data = data; - - for (let [corpusId, eventListeners] of Object.entries(this.eventListeners.corpus)) { - if (corpusId === '*') { - for (let eventListener of eventListeners) {eventListener('init');} - } else { - if (corpusId in this.data.corpora) { - for (let eventListener of eventListeners) {eventListener('init');} - } - } - } - - for (let [jobId, eventListeners] of Object.entries(this.eventListeners.job)) { - if (jobId === '*') { - for (let eventListener of eventListeners) {eventListener('init');} - } else { - if (jobId in this.data.jobs) { - for (let eventListener of eventListeners) {eventListener('init');} - } - } - } - - for (let [queryResultId, eventListeners] of Object.entries(this.eventListeners.queryResult)) { - if (queryResultId === '*') { - for (let eventListener of eventListeners) {eventListener('init');} - } else { - if (queryResultId in this.data.query_results) { - for (let eventListener of eventListeners) {eventListener('init');} - } - } - } - } - - patch(patch) { - this.data = jsonpatch.apply_patch(this.data, patch); - - let corporaPatch = patch.filter(operation => operation.path.startsWith("/corpora")); - if (corporaPatch.length > 0) { - for (let [corpusId, eventListeners] of Object.entries(this.eventListeners.corpus)) { - if (corpusId === '*') { - for (let eventListener of eventListeners) {eventListener('patch', corporaPatch);} - } else { - let corpusPatch = corporaPatch.filter(operation => operation.path.startsWith(`/corpora/${corpusId}`)); - if (corpusPatch.length > 0) { - for (let eventListener of eventListeners) {eventListener('patch', corpusPatch);} - } - } - } - } - - let jobsPatch = patch.filter(operation => operation.path.startsWith("/jobs")); - if (jobsPatch.length > 0) { - for (let [jobId, eventListeners] of Object.entries(this.eventListeners.job)) { - if (jobId === '*') { - for (let eventListener of eventListeners) {eventListener('patch', jobsPatch);} - } else { - let jobPatch = jobsPatch.filter(operation => operation.path.startsWith(`/jobs/${jobId}`)); - if (jobPatch.length > 0) { - for (let eventListener of eventListeners) {eventListener('patch', jobPatch);} - } - } - } - } - - let queryResultsPatch = patch.filter(operation => operation.path.startsWith("/query_results")); - if (queryResultsPatch.length > 0) { - for (let [queryResultId, eventListeners] of Object.entries(this.eventListeners.queryResult)) { - if (queryResultId === '*') { - for (let eventListener of eventListeners) {eventListener('patch', queryResultsPatch);} - } else { - let queryResultPatch = queryResultsPatch.filter(operation => operation.path.startsWith(`/query_results/${queryResultId}`)); - if (queryResultPatch.length > 0) { - for (let eventListener of eventListeners) {eventListener('patch', queryResultPatch);} - } - } - } - } - } -} - - /* * The nopaque object is used as a namespace for nopaque specific functions and * variables. diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2 index 49796bcc..a86e3cfb 100644 --- a/app/templates/_scripts.html.j2 +++ b/app/templates/_scripts.html.j2 @@ -7,6 +7,8 @@ + + {% assets filters='rjsmin', output="js/nopaque/RessourceDisplays.min.bundle.js", "js/nopaque/RessourceDisplays/RessourceDisplay.js", @@ -14,7 +16,7 @@ "js/nopaque/RessourceDisplays/JobDisplay.js" %} {% endassets %} -{% assets filters='rjsmin', output="js/nopaque/RessourceLists.min.bundle.js", +{% assets output="js/nopaque/RessourceLists.min.bundle.js", "js/nopaque/RessourceLists/RessourceList.js", "js/nopaque/RessourceLists/CorpusList.js", "js/nopaque/RessourceLists/CorpusFileList.js", @@ -31,7 +33,13 @@ M.AutoInit(); M.CharacterCounter.init(document.querySelectorAll('input[data-length][type="email"], input[data-length][type="password"], input[data-length][type="text"], textarea[data-length]')); M.Dropdown.init(document.querySelectorAll('#nav-more-dropdown-trigger'), {alignment: 'right', constrainWidth: false, coverTrigger: false}); - nopaque.appClient = new AppClient({% if current_user.is_authenticated %}{{ current_user.id }}{% endif %}); + var app = new App(); + {% if current_user.is_authenticated %} + var currentUserId = '{{ current_user.hashid }}'; + let jobStatusNotifier = new JobStatusNotifier(currentUserId); + app.addEventListener('users.patch', patch => jobStatusNotifier.usersPatchHandler(patch)); + app.getUserById(currentUserId).then(user => {}, error => {throw JSON.stringify(error)}); + {% endif %} nopaque.Forms.init(); - for (let flashedMessage of {{ get_flashed_messages(with_categories=True)|tojson }}) {nopaque.appClient.flash(flashedMessage[1], flashedMessage[0]);} + for (let flashedMessage of {{ get_flashed_messages(with_categories=True)|tojson }}) {app.flash(flashedMessage[1], flashedMessage[0]);} diff --git a/app/templates/admin/edit_user.html.j2 b/app/templates/admin/edit_user.html.j2 index bd75147f..89a73d03 100644 --- a/app/templates/admin/edit_user.html.j2 +++ b/app/templates/admin/edit_user.html.j2 @@ -12,7 +12,7 @@

{{ user.username }}

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,

- arrow_backBack to user administration + arrow_backBack to user administration
diff --git a/app/templates/admin/user.html.j2 b/app/templates/admin/user.html.j2 index ada3a55c..7122e58a 100644 --- a/app/templates/admin/user.html.j2 +++ b/app/templates/admin/user.html.j2 @@ -21,11 +21,11 @@
  • Username: {{ user.username }}
  • Email: {{ user.email }}
  • -
  • ID: {{ user.id }}
  • -
  • Member since: {{ user.member_since.strftime('%m/%d/%Y, %H:%M:%S %p') }}
  • +
  • Id: {{ user.id }}
  • +
  • Hashid: {{ user.hashid }}
  • +
  • Member since: {{ user.member_since }}
  • Confirmed status: {{ user.confirmed }}
  • -
  • Last seen: {{ user.last_seen.strftime('%m/%d/%Y, %H:%M:%S %p') }}
  • -
  • Role ID: {{ user.role_id }}
  • +
  • Last seen: {{ user.last_seen }}
  • Permissions as Int: {{ user.role.permissions }}
  • Role name: {{ user.role.name }}
@@ -37,7 +37,7 @@
-
+

Corpora

@@ -65,7 +65,7 @@
-
+

Jobs

@@ -111,7 +111,6 @@ {% block scripts %} {{ super() }} diff --git a/app/templates/admin/users.html.j2 b/app/templates/admin/users.html.j2 index 19991a51..e8ffac29 100644 --- a/app/templates/admin/users.html.j2 +++ b/app/templates/admin/users.html.j2 @@ -41,6 +41,6 @@ {{ super() }} {% endblock scripts %} diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index c1ac05a1..c6c7702f 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -6,7 +6,7 @@ {% block page_content %}
-
+

@@ -83,7 +83,7 @@
-
+
Corpus files @@ -118,7 +118,6 @@ {% block scripts %} {{ super() }} diff --git a/app/templates/jobs/job.html.j2 b/app/templates/jobs/job.html.j2 index 53eed62f..344794bd 100644 --- a/app/templates/jobs/job.html.j2 +++ b/app/templates/jobs/job.html.j2 @@ -6,7 +6,7 @@ {% block page_content %}
-
+

@@ -111,7 +111,7 @@ {% endif %}
-
+
@@ -136,7 +136,7 @@
-
+
@@ -168,7 +168,6 @@ {% block scripts %} {{ super() }} {% endblock scripts %} diff --git a/app/templates/tasks/email/notification.html.j2 b/app/templates/tasks/email/notification.html.j2 index 1aac0bf7..019dd456 100644 --- a/app/templates/tasks/email/notification.html.j2 +++ b/app/templates/tasks/email/notification.html.j2 @@ -1,4 +1,4 @@ -

Dear {{ job.creator.username }},

+

Dear {{ job.user.username }},

The status of your Job "{{ job.title }}" has changed!

It is now {{ job.status }}!

diff --git a/app/templates/tasks/email/notification.txt.j2 b/app/templates/tasks/email/notification.txt.j2 index 03012b3e..be746b9c 100644 --- a/app/templates/tasks/email/notification.txt.j2 +++ b/app/templates/tasks/email/notification.txt.j2 @@ -1,4 +1,4 @@ -Dear {{ job.creator.username }}, +Dear {{ job.user.username }}, The status of your Job "{{ job.title }}" has changed! It is now {{ job.status }}! diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 00000000..a320d4bb --- /dev/null +++ b/app/utils.py @@ -0,0 +1,10 @@ +from app import hashids +from werkzeug.routing import BaseConverter + + +class HashidConverter(BaseConverter): + def to_python(self, value): + return hashids.decode(value)[0] + + def to_url(self, value): + return hashids.encode(value) diff --git a/config.py b/config.py index 06c48173..79f7f9b6 100644 --- a/config.py +++ b/config.py @@ -45,7 +45,7 @@ class Config: NOPAQUE_ADMIN = os.environ.get('NOPAQUE_ADMIN') NOPAQUE_DAEMON_ENABLED = \ os.environ.get('NOPAQUE_DAEMON_ENABLED', 'true').lower() == 'true' - NOPAQUE_DATA_DIR = os.environ.get('NOPAQUE_DATA_DIR', '/mnt/nopaque') + NOPAQUE_DATA_DIR = os.path.abspath(os.environ.get('NOPAQUE_DATA_DIR')) NOPAQUE_DOCKER_REGISTRY = 'gitlab.ub.uni-bielefeld.de:4567' NOPAQUE_DOCKER_IMAGE_PREFIX = f'{NOPAQUE_DOCKER_REGISTRY}/sfb1288inf/' NOPAQUE_DOCKER_REGISTRY_USERNAME = \ diff --git a/migrations/versions/68ed092ffe5e_.py b/migrations/versions/68ed092ffe5e_.py new file mode 100644 index 00000000..bbd8b967 --- /dev/null +++ b/migrations/versions/68ed092ffe5e_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: 68ed092ffe5e +Revises: be010d5d708d +Create Date: 2021-11-24 15:33:16.258600 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '68ed092ffe5e' +down_revision = 'be010d5d708d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('corpus_files', sa.Column('creation_date', sa.DateTime(), nullable=True)) + op.add_column('corpus_files', sa.Column('last_edited_date', sa.DateTime(), nullable=True)) + op.add_column('corpus_files', sa.Column('mimetype', sa.String(length=255), nullable=True)) + op.add_column('job_inputs', sa.Column('creation_date', sa.DateTime(), nullable=True)) + op.add_column('job_inputs', sa.Column('last_edited_date', sa.DateTime(), nullable=True)) + op.add_column('job_inputs', sa.Column('mimetype', sa.String(length=255), nullable=True)) + op.add_column('job_results', sa.Column('creation_date', sa.DateTime(), nullable=True)) + op.add_column('job_results', sa.Column('last_edited_date', sa.DateTime(), nullable=True)) + op.add_column('job_results', sa.Column('mimetype', sa.String(length=255), nullable=True)) + op.add_column('query_results', sa.Column('creation_date', sa.DateTime(), nullable=True)) + op.add_column('query_results', sa.Column('last_edited_date', sa.DateTime(), nullable=True)) + op.add_column('query_results', sa.Column('mimetype', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('query_results', 'mimetype') + op.drop_column('query_results', 'last_edited_date') + op.drop_column('query_results', 'creation_date') + op.drop_column('job_results', 'mimetype') + op.drop_column('job_results', 'last_edited_date') + op.drop_column('job_results', 'creation_date') + op.drop_column('job_inputs', 'mimetype') + op.drop_column('job_inputs', 'last_edited_date') + op.drop_column('job_inputs', 'creation_date') + op.drop_column('corpus_files', 'mimetype') + op.drop_column('corpus_files', 'last_edited_date') + op.drop_column('corpus_files', 'creation_date') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index f1afb44a..fbff2916 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ Flask-SocketIO~=5.1 Flask-SQLAlchemy Flask-WTF gunicorn +hashids hiredis jsonpatch jsonschema