Compare commits

...

2 Commits

Author SHA1 Message Date
Patrick Jentsch
df2bffe0fd implement first version of jobs socketio namespace 2024-12-03 16:09:14 +01:00
Patrick Jentsch
aafb3ca3ec Update javascript app structure 2024-12-03 15:59:08 +01:00
25 changed files with 451 additions and 305 deletions

View File

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

View File

@ -15,4 +15,4 @@ def before_request():
pass pass
from . import cli, events, json_routes, routes, settings from . import cli, json_routes, routes, settings

View File

@ -1,122 +0,0 @@
from flask import current_app, Flask
from flask_login import current_user
from flask_socketio import join_room, leave_room
from app import db, hashids, socketio
from app.decorators import socketio_login_required
from app.models import User
def _delete_user(app: Flask, user_id: int):
with app.app_context():
user = User.query.get(user_id)
user.delete()
db.session.commit()
@socketio.on('users.delete')
@socketio_login_required
def delete_user(user_hashid: str) -> dict:
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
socketio.start_background_task(
_delete_user,
current_app._get_current_object(),
user.id
)
return {
'body': f'User "{user.username}" marked for deletion',
'status': 202,
'statusText': 'Accepted'
}
@socketio.on('users.get')
@socketio_login_required
def get_user(user_hashid: str) -> dict:
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
return {
'body': user.to_json_serializeable(
backrefs=True,
relationships=True
),
'status': 200,
'statusText': 'OK'
}
@socketio.on('users.subscribe')
@socketio_login_required
def subscribe_user(user_hashid: str) -> dict:
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
join_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}
@socketio.on('users.unsubscribe')
@socketio_login_required
def unsubscribe_user(user_hashid: str) -> dict:
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
leave_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}

View File

@ -26,7 +26,7 @@ def socketio_login_required(f):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if current_user.is_authenticated: if current_user.is_authenticated:
return f(*args, **kwargs) return f(*args, **kwargs)
return {'code': 401, 'body': 'Unauthorized'} return {'status': 401, 'statusText': 'Unauthorized'}
return wrapper return wrapper
@ -35,7 +35,7 @@ def socketio_permission_required(permission):
@wraps(f) @wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if not current_user.can(permission): if not current_user.can(permission):
return {'code': 403, 'body': 'Forbidden'} return {'status': 403, 'statusText': 'Forbidden'}
return f(*args, **kwargs) return f(*args, **kwargs)
return wrapper return wrapper
return decorator return decorator

View File

@ -42,8 +42,9 @@ def resource_after_delete(mapper, connection, resource):
'path': resource.jsonpatch_path 'path': resource.jsonpatch_path
} }
] ]
namespace = '/users'
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
def cfa_after_delete(mapper, connection, cfa): def cfa_after_delete(mapper, connection, cfa):
@ -54,8 +55,9 @@ def cfa_after_delete(mapper, connection, cfa):
'path': jsonpatch_path 'path': jsonpatch_path
} }
] ]
namespace = '/users'
room = f'/users/{cfa.corpus.user.hashid}' room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
def resource_after_insert(mapper, connection, resource): def resource_after_insert(mapper, connection, resource):
@ -69,8 +71,9 @@ def resource_after_insert(mapper, connection, resource):
'value': jsonpatch_value 'value': jsonpatch_value
} }
] ]
namespace = '/users'
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
def cfa_after_insert(mapper, connection, cfa): def cfa_after_insert(mapper, connection, cfa):
@ -83,8 +86,9 @@ def cfa_after_insert(mapper, connection, cfa):
'value': jsonpatch_value 'value': jsonpatch_value
} }
] ]
namespace = '/users'
room = f'/users/{cfa.corpus.user.hashid}' room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
def resource_after_update(mapper, connection, resource): def resource_after_update(mapper, connection, resource):
@ -109,8 +113,9 @@ def resource_after_update(mapper, connection, resource):
} }
) )
if jsonpatch: if jsonpatch:
namespace = '/users'
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('patch', jsonpatch, namespace=namespace, room=room)
def job_after_update(mapper, connection, job): def job_after_update(mapper, connection, job):

View File

@ -9,8 +9,8 @@ from threading import Lock
from app import db, docker_client, hashids, socketio from app import db, docker_client, hashids, socketio
from app.decorators import socketio_login_required from app.decorators import socketio_login_required
from app.models import Corpus, CorpusStatus from app.models import Corpus, CorpusStatus
from . import cqi_extensions from . import cqi_extension_functions
from .utils import CQiOverSocketIOSessionManager from .utils import SessionManager
''' '''
@ -85,6 +85,16 @@ CQI_API_FUNCTION_NAMES = [
] ]
CQI_EXTENSION_FUNCTION_NAMES = [
'ext_corpus_update_db',
'ext_corpus_static_data',
'ext_corpus_paginate_corpus',
'ext_cqp_paginate_subcorpus',
'ext_cqp_partial_export_subcorpus',
'ext_cqp_export_subcorpus',
]
class CQiOverSocketIONamespace(Namespace): class CQiOverSocketIONamespace(Namespace):
@socketio_login_required @socketio_login_required
def on_connect(self): def on_connect(self):
@ -135,25 +145,25 @@ class CQiOverSocketIONamespace(Namespace):
cqi_client = CQiClient(cqpserver_ip_address) cqi_client = CQiClient(cqpserver_ip_address)
cqi_client_lock = Lock() cqi_client_lock = Lock()
CQiOverSocketIOSessionManager.setup() SessionManager.setup()
CQiOverSocketIOSessionManager.set_corpus_id(corpus_id) SessionManager.set_corpus_id(corpus_id)
CQiOverSocketIOSessionManager.set_cqi_client(cqi_client) SessionManager.set_cqi_client(cqi_client)
CQiOverSocketIOSessionManager.set_cqi_client_lock(cqi_client_lock) SessionManager.set_cqi_client_lock(cqi_client_lock)
return {'code': 200, 'msg': 'OK'} return {'code': 200, 'msg': 'OK'}
@socketio_login_required @socketio_login_required
def on_exec(self, fn_name: str, fn_args: dict = {}) -> dict: def on_exec(self, fn_name: str, fn_args: dict = {}) -> dict:
try: try:
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client = SessionManager.get_cqi_client()
cqi_client_lock = CQiOverSocketIOSessionManager.get_cqi_client_lock() cqi_client_lock = SessionManager.get_cqi_client_lock()
except KeyError: except KeyError:
return {'code': 424, 'msg': 'Failed Dependency'} return {'code': 424, 'msg': 'Failed Dependency'}
if fn_name in CQI_API_FUNCTION_NAMES: if fn_name in CQI_API_FUNCTION_NAMES:
fn = getattr(cqi_client.api, fn_name) fn = getattr(cqi_client.api, fn_name)
elif fn_name in cqi_extensions.CQI_EXTENSION_FUNCTION_NAMES: elif fn_name in cqi_extension_functions.CQI_EXTENSION_FUNCTION_NAMES:
fn = getattr(cqi_extensions, fn_name) fn = getattr(cqi_extension_functions, fn_name)
else: else:
return {'code': 400, 'msg': 'Bad Request'} return {'code': 400, 'msg': 'Bad Request'}
@ -198,10 +208,10 @@ class CQiOverSocketIONamespace(Namespace):
def on_disconnect(self): def on_disconnect(self):
try: try:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id() corpus_id = SessionManager.get_corpus_id()
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client = SessionManager.get_cqi_client()
cqi_client_lock = CQiOverSocketIOSessionManager.get_cqi_client_lock() cqi_client_lock = SessionManager.get_cqi_client_lock()
CQiOverSocketIOSessionManager.teardown() SessionManager.teardown()
except KeyError: except KeyError:
return return

View File

@ -8,22 +8,12 @@ import json
import math import math
from app import db from app import db
from app.models import Corpus from app.models import Corpus
from .utils import CQiOverSocketIOSessionManager from .utils import SessionManager
CQI_EXTENSION_FUNCTION_NAMES = [
'ext_corpus_update_db',
'ext_corpus_static_data',
'ext_corpus_paginate_corpus',
'ext_cqp_paginate_subcorpus',
'ext_cqp_partial_export_subcorpus',
'ext_cqp_export_subcorpus',
]
def ext_corpus_update_db(corpus: str) -> CQiStatusOk: def ext_corpus_update_db(corpus: str) -> CQiStatusOk:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id() corpus_id = SessionManager.get_corpus_id()
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client = SessionManager.get_cqi_client()
db_corpus = Corpus.query.get(corpus_id) db_corpus = Corpus.query.get(corpus_id)
cqi_corpus = cqi_client.corpora.get(corpus) cqi_corpus = cqi_client.corpora.get(corpus)
db_corpus.num_tokens = cqi_corpus.size db_corpus.num_tokens = cqi_corpus.size
@ -32,7 +22,7 @@ def ext_corpus_update_db(corpus: str) -> CQiStatusOk:
def ext_corpus_static_data(corpus: str) -> dict: def ext_corpus_static_data(corpus: str) -> dict:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id() corpus_id = SessionManager.get_corpus_id()
db_corpus = Corpus.query.get(corpus_id) db_corpus = Corpus.query.get(corpus_id)
static_data_file_path = db_corpus.path / 'cwb' / 'static.json.gz' static_data_file_path = db_corpus.path / 'cwb' / 'static.json.gz'
@ -40,7 +30,7 @@ def ext_corpus_static_data(corpus: str) -> dict:
with static_data_file_path.open('rb') as f: with static_data_file_path.open('rb') as f:
return f.read() return f.read()
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client = SessionManager.get_cqi_client()
cqi_corpus = cqi_client.corpora.get(corpus) cqi_corpus = cqi_client.corpora.get(corpus)
cqi_p_attrs = cqi_corpus.positional_attributes.list() cqi_p_attrs = cqi_corpus.positional_attributes.list()
cqi_s_attrs = cqi_corpus.structural_attributes.list() cqi_s_attrs = cqi_corpus.structural_attributes.list()
@ -168,7 +158,7 @@ def ext_corpus_paginate_corpus(
page: int = 1, page: int = 1,
per_page: int = 20 per_page: int = 20
) -> dict: ) -> dict:
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client = SessionManager.get_cqi_client()
cqi_corpus = cqi_client.corpora.get(corpus) cqi_corpus = cqi_client.corpora.get(corpus)
# Sanity checks # Sanity checks
if ( if (
@ -215,7 +205,7 @@ def ext_cqp_paginate_subcorpus(
per_page: int = 20 per_page: int = 20
) -> dict: ) -> dict:
corpus_name, subcorpus_name = subcorpus.split(':', 1) corpus_name, subcorpus_name = subcorpus.split(':', 1)
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client = SessionManager.get_cqi_client()
cqi_corpus = cqi_client.corpora.get(corpus_name) cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name) cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
# Sanity checks # Sanity checks
@ -262,7 +252,7 @@ def ext_cqp_partial_export_subcorpus(
context: int = 50 context: int = 50
) -> dict: ) -> dict:
corpus_name, subcorpus_name = subcorpus.split(':', 1) corpus_name, subcorpus_name = subcorpus.split(':', 1)
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client = SessionManager.get_cqi_client()
cqi_corpus = cqi_client.corpora.get(corpus_name) cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name) cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
cqi_subcorpus_partial_export = _partial_export_subcorpus(cqi_subcorpus, match_id_list, context=context) cqi_subcorpus_partial_export = _partial_export_subcorpus(cqi_subcorpus, match_id_list, context=context)
@ -271,7 +261,7 @@ def ext_cqp_partial_export_subcorpus(
def ext_cqp_export_subcorpus(subcorpus: str, context: int = 50) -> dict: def ext_cqp_export_subcorpus(subcorpus: str, context: int = 50) -> dict:
corpus_name, subcorpus_name = subcorpus.split(':', 1) corpus_name, subcorpus_name = subcorpus.split(':', 1)
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client = SessionManager.get_cqi_client()
cqi_corpus = cqi_client.corpora.get(corpus_name) cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name) cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
cqi_subcorpus_export = _export_subcorpus(cqi_subcorpus, context=context) cqi_subcorpus_export = _export_subcorpus(cqi_subcorpus, context=context)

View File

@ -3,7 +3,7 @@ from threading import Lock
from flask import session from flask import session
class CQiOverSocketIOSessionManager: class SessionManager:
@staticmethod @staticmethod
def setup(): def setup():
session['cqi_over_sio'] = {} session['cqi_over_sio'] = {}

109
app/namespaces/jobs.py Normal file
View File

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

116
app/namespaces/users.py Normal file
View File

@ -0,0 +1,116 @@
from flask import current_app, Flask
from flask_login import current_user
from flask_socketio import join_room, leave_room, Namespace
from app import db, hashids, socketio
from app.decorators import socketio_login_required
from app.models import User
def _delete_user(app: Flask, user_id: int):
with app.app_context():
user = User.query.get(user_id)
user.delete()
db.session.commit()
class UsersNamespace(Namespace):
@socketio_login_required
def on_get(self, user_hashid: str) -> dict:
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
return {
'body': user.to_json_serializeable(
backrefs=True,
relationships=True
),
'status': 200,
'statusText': 'OK'
}
@socketio_login_required
def on_subscribe(self, user_hashid: str) -> dict:
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
join_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}
@socketio_login_required
def on_unsubscribe(self, user_hashid: str) -> dict:
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
leave_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}
@socketio_login_required
def on_delete(self, user_hashid: str) -> dict:
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
socketio.start_background_task(
_delete_user,
current_app._get_current_object(),
user.id
)
return {
'body': f'User "{user.username}" marked for deletion',
'status': 202,
'statusText': 'Accepted'
}

View File

@ -1,10 +1,9 @@
nopaque.App = class App { nopaque.App = class App {
constructor() { constructor() {
this.data = {};
this.socket = io({transports: ['websocket'], upgrade: false}); this.socket = io({transports: ['websocket'], upgrade: false});
this.ui = new nopaque.UIExtension(this); this.ui = new nopaque.UIExtension(this);
this.liveUserRegistry = new nopaque.LiveUserRegistryExtension(this);
this.users = new nopaque.UsersExtension(this); this.users = new nopaque.UsersExtension(this);
} }
@ -29,5 +28,7 @@ nopaque.App = class App {
init() { init() {
this.ui.init(); this.ui.init();
this.liveUserRegistry.init();
this.users.init();
} }
}; };

View File

@ -1,53 +0,0 @@
nopaque.UsersExtension = class UsersExtension {
#data;
#promises;
constructor(app) {
this.app = app;
this.#data = {};
this.app.data.users = this.#data;
this.#promises = {
get: {},
subscribe: {}
};
}
async #get(userId) {
const response = await this.app.socket.emitWithAck('users.get', userId);
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
this.#data[userId] = response.body;
return this.#data[userId];
}
get(userId) {
if (userId in this.#promises.get) {
return this.#promises.get[userId];
}
this.#promises.get[userId] = this.#get(userId);
return this.#promises.get[userId];
}
async #subscribe(userId) {
const response = await this.app.socket.emitWithAck('users.subscribe', userId);
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
}
subscribe(userId) {
if (userId in this.#promises.subscribe) {
return this.#promises.subscribe[userId];
}
this.#promises.subscribe[userId] = this.#subscribe(userId);
return this.#promises.subscribe[userId];
}
}

View File

@ -0,0 +1,70 @@
nopaque.LiveUserRegistryExtension = class LiveUserRegistryExtension extends EventTarget {
#data;
constructor(app) {
super();
this.app = app;
this.#data = {
users: {},
promises: {}
};
}
init() {
this.app.users.socket.on('patch', (patch) => {this.#onPatch(patch)});
}
add(userId) {
if (!(userId in this.#data.promises)) {
this.#data.promises[userId] = this.#add(userId);
}
return this.#data.promises[userId];
}
async #add(userId) {
await this.app.users.subscribe(userId);
this.#data.users[userId] = await this.app.users.get(userId);
}
async get(userId) {
await this.add(userId);
return this.#data.users[userId];
}
#onPatch(patch) {
// Filter patch to only include operations on users that are initialized
let filterRegExp = new RegExp(`^/users/(${Object.keys(this.#data.users).join('|')})`);
let filteredPatch = patch.filter(operation => filterRegExp.test(operation.path));
// Apply patch
jsonpatch.applyPatch(this.#data, filteredPatch);
// Notify event listeners
let event = new CustomEvent('patch', {detail: filteredPatch});
this.dispatchEvent(event);
/*
// Notify event listeners. Event type: "patch *"
let event = new CustomEvent('patch *', {detail: filteredPatch});
this.dispatchEvent(event);
// Group patches by user id: {<user-id>: [op, ...], ...}
let patches = {};
let matchRegExp = new RegExp(`^/users/([A-Za-z0-9]+)`);
for (let operation of filteredPatch) {
let [match, userId] = operation.path.match(matchRegExp);
if (!(userId in patches)) {patches[userId] = [];}
patches[userId].push(operation);
}
// Notify event listeners. Event type: "patch <user-id>"
for (let [userId, patch] of Object.entries(patches)) {
let event = new CustomEvent(`patch ${userId}`, {detail: patch});
this.dispatchEvent(event);
}
*/
}
}

View File

@ -0,0 +1,43 @@
nopaque.UsersExtension = class UsersExtension {
constructor(app) {
this.app = app;
this.socket = io('/users', {transports: ['websocket'], upgrade: false});
}
init() {}
async get(userId) {
const response = await this.socket.emitWithAck('get', userId);
if (response.status !== 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
return response.body;
}
async subscribe(userId) {
const response = await this.socket.emitWithAck('subscribe', userId);
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
}
async unsubscribe(userId) {
const response = await this.socket.emitWithAck('unsubscribe', userId);
if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
}
async delete(userId) {
const response = await this.socket.emitWithAck('delete', userId);
if (response.status != 202) {
throw new Error(`[${response.status}] ${response.statusText}`);
}
}
}

View File

@ -5,19 +5,14 @@ nopaque.resource_displays.ResourceDisplay = class ResourceDisplay {
this.displayElement = displayElement; this.displayElement = displayElement;
this.userId = this.displayElement.dataset.userId; this.userId = this.displayElement.dataset.userId;
this.isInitialized = false; this.isInitialized = false;
if (this.userId) { if (this.userId === undefined) {return;}
app.users.subscribe(this.userId) app.liveUserRegistry.addEventListener('patch', (event) => {
.then((response) => { if (this.isInitialized) {this.onPatch(event.detail);}
app.socket.on('users.patch', (patch) => { });
if (this.isInitialized) {this.onPatch(patch);} app.liveUserRegistry.get(this.userId).then((user) => {
}); this.init(user);
}); this.isInitialized = true;
app.users.get(this.userId) });
.then((user) => {
this.init(user);
this.isInitialized = true;
});
}
} }
init(user) {throw 'Not implemented';} init(user) {throw 'Not implemented';}

View File

@ -14,12 +14,11 @@ nopaque.resource_lists.CorpusFileList = class CorpusFileList extends nopaque.res
this.hasPermissionView = listContainerElement.dataset?.hasPermissionView == 'true' || false; this.hasPermissionView = listContainerElement.dataset?.hasPermissionView == 'true' || false;
this.hasPermissionManageFiles = listContainerElement.dataset?.hasPermissionManageFiles == 'true' || false; this.hasPermissionManageFiles = listContainerElement.dataset?.hasPermissionManageFiles == 'true' || false;
if (this.userId === undefined || this.corpusId === undefined) {return;} if (this.userId === undefined || this.corpusId === undefined) {return;}
app.users.subscribe(this.userId).then((response) => { app.liveUserRegistry.addEventListener('patch', (event) => {
app.socket.on('users.patch', (patch) => { if (this.isInitialized) {this.onPatch(event.detail);}
if (this.isInitialized) {this.onPatch(patch);}
});
}); });
app.users.get(this.userId).then((user) => { app.liveUserRegistry.get(this.userId).then((user) => {
// TODO: Make this better understandable
this.add(Object.values(user.corpora[this.corpusId].files || user.followed_corpora[this.corpusId].files)); this.add(Object.values(user.corpora[this.corpusId].files || user.followed_corpora[this.corpusId].files));
this.isInitialized = true; this.isInitialized = true;
}); });

View File

@ -12,15 +12,16 @@ nopaque.resource_lists.CorpusFollowerList = class CorpusFollowerList extends nop
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
this.corpusId = listContainerElement.dataset.corpusId; this.corpusId = listContainerElement.dataset.corpusId;
if (this.userId === undefined || this.corpusId === undefined) {return;} if (this.userId === undefined || this.corpusId === undefined) {return;}
app.users.subscribe(this.userId).then((response) => { app.liveUserRegistry.addEventListener('patch', (event) => {
app.socket.on('users.patch', (patch) => { if (this.isInitialized) {this.onPatch(event.detail);}
if (this.isInitialized) {this.onPatch(patch);}
});
}); });
app.users.get(this.userId).then((user) => { app.liveUserRegistry.get(this.userId).then((user) => {
// TODO: Check if the following is better
// let corpusFollowerAssociations = Object.values(user.corpora[this.corpusId].corpus_follower_associations); // let corpusFollowerAssociations = Object.values(user.corpora[this.corpusId].corpus_follower_associations);
// let filteredList = corpusFollowerAssociations.filter(association => association.follower.id != currentUserId); // let filteredList = corpusFollowerAssociations.filter(association => association.follower.id != currentUserId);
// this.add(filteredList); // this.add(filteredList);
// TODO: Make this better understandable
this.add(Object.values(user.corpora[this.corpusId].corpus_follower_associations)); this.add(Object.values(user.corpora[this.corpusId].corpus_follower_associations));
this.isInitialized = true; this.isInitialized = true;
}); });

View File

@ -11,12 +11,10 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
this.selectedItemIds = new Set(); this.selectedItemIds = new Set();
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;} if (this.userId === undefined) {return;}
app.users.subscribe(this.userId).then((response) => { app.liveUserRegistry.addEventListener('patch', (event) => {
app.socket.on('users.patch', (patch) => { if (this.isInitialized) {this.onPatch(event.detail);}
if (this.isInitialized) {this.onPatch(patch);}
});
}); });
app.users.get(this.userId).then((user) => { app.liveUserRegistry.get(this.userId).then((user) => {
this.add(this.aggregateData(user)); this.add(this.aggregateData(user));
this.isInitialized = true; this.isInitialized = true;
}); });

View File

@ -8,8 +8,10 @@ nopaque.resource_lists.JobInputList = class JobInputList extends nopaque.resourc
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
this.jobId = listContainerElement.dataset.jobId; this.jobId = listContainerElement.dataset.jobId;
if (this.userId === undefined || this.jobId === undefined) {return;} if (this.userId === undefined || this.jobId === undefined) {return;}
app.users.subscribe(this.userId); // app.liveUserRegistry.addEventListener('patch', (event) => {
app.users.get(this.userId).then((user) => { // if (this.isInitialized) {this.onPatch(event.detail);}
// });
app.liveUserRegistry.get(this.userId).then((user) => {
this.add(Object.values(user.jobs[this.jobId].inputs)); this.add(Object.values(user.jobs[this.jobId].inputs));
this.isInitialized = true; this.isInitialized = true;
}); });

View File

@ -12,12 +12,10 @@ nopaque.resource_lists.JobList = class JobList extends nopaque.resource_lists.Re
this.selectedItemIds = new Set(); this.selectedItemIds = new Set();
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;} if (this.userId === undefined) {return;}
app.users.subscribe(this.userId).then((response) => { app.liveUserRegistry.addEventListener('patch', (event) => {
app.socket.on('users.patch', (patch) => { if (this.isInitialized) {this.onPatch(event.detail);}
if (this.isInitialized) {this.onPatch(patch);}
});
}); });
app.users.get(this.userId).then((user) => { app.liveUserRegistry.get(this.userId).then((user) => {
this.add(Object.values(user.jobs)); this.add(Object.values(user.jobs));
this.isInitialized = true; this.isInitialized = true;
}); });

View File

@ -8,12 +8,10 @@ nopaque.resource_lists.JobResultList = class JobResultList extends nopaque.resou
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
this.jobId = listContainerElement.dataset.jobId; this.jobId = listContainerElement.dataset.jobId;
if (this.userId === undefined || this.jobId === undefined) {return;} if (this.userId === undefined || this.jobId === undefined) {return;}
app.users.subscribe(this.userId).then((response) => { app.liveUserRegistry.addEventListener('patch', (event) => {
app.socket.on('users.patch', (patch) => { if (this.isInitialized) {this.onPatch(event.detail);}
if (this.isInitialized) {this.onPatch(patch);}
});
}); });
app.users.get(this.userId).then((user) => { app.liveUserRegistry.get(this.userId).then((user) => {
this.add(Object.values(user.jobs[this.jobId].results)); this.add(Object.values(user.jobs[this.jobId].results));
this.isInitialized = true; this.isInitialized = true;
}); });

View File

@ -8,12 +8,10 @@ nopaque.resource_lists.SpaCyNLPPipelineModelList = class SpaCyNLPPipelineModelLi
this.isInitialized = false; this.isInitialized = false;
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;} if (this.userId === undefined) {return;}
app.users.subscribe(this.userId).then((response) => { app.liveUserRegistry.addEventListener('patch', (event) => {
app.socket.on('users.patch', (patch) => { if (this.isInitialized) {this.onPatch(event.detail);}
if (this.isInitialized) {this.onPatch(patch);}
});
}); });
app.users.get(this.userId).then((user) => { app.liveUserRegistry.get(this.userId).then((user) => {
this.add(Object.values(user.spacy_nlp_pipeline_models)); this.add(Object.values(user.spacy_nlp_pipeline_models));
this.isInitialized = true; this.isInitialized = true;
}); });

View File

@ -8,21 +8,11 @@ nopaque.resource_lists.TesseractOCRPipelineModelList = class TesseractOCRPipelin
this.isInitialized = false; this.isInitialized = false;
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;} if (this.userId === undefined) {return;}
app.users.subscribe(this.userId).then((response) => { app.liveUserRegistry.addEventListener('patch', (event) => {
app.socket.on('users.patch', (patch) => { if (this.isInitialized) {this.onPatch(event.detail);}
if (this.isInitialized) {this.onPatch(patch);}
});
}); });
app.users.get(this.userId).then((user) => { app.liveUserRegistry.get(this.userId).then((user) => {
this.add(Object.values(user.tesseract_ocr_pipeline_models)); this.add(Object.values(user.tesseract_ocr_pipeline_models));
for (let uncheckedCheckbox of this.listjs.list.querySelectorAll('input[data-checked="True"]')) {
uncheckedCheckbox.setAttribute('checked', '');
}
if (user.role.name !== ('Administrator' || 'Contributor')) {
for (let switchElement of this.listjs.list.querySelectorAll('.is_public')) {
switchElement.setAttribute('disabled', '');
}
}
this.isInitialized = true; this.isInitialized = true;
}); });
} }

View File

@ -9,8 +9,9 @@
output='gen/nopaque.%(version)s.js', output='gen/nopaque.%(version)s.js',
'js/index.js', 'js/index.js',
'js/app.js', 'js/app.js',
'js/app.ui.js', 'js/app/ui.js',
'js/app.users.js', 'js/app/user-live-registry.js',
'js/app/users.js',
'js/utils.js', 'js/utils.js',
'js/forms/index.js', 'js/forms/index.js',
@ -75,41 +76,35 @@
{% endassets -%} {% endassets -%}
<script> <script>
// TODO: Implement an app.run method and use this for all of the following // TODO: Implement an app.run method and use this for all of the following
const app = new nopaque.App(); const app = new nopaque.App();
app.init(); app.init();
{% if current_user.is_authenticated -%} {% if current_user.is_authenticated -%}
// TODO: Set this as a property of the app object const currentUserId = {{ current_user.hashid|tojson }};
const currentUserId = {{ current_user.hashid|tojson }};
// Subscribe to the current user's data events app.liveUserRegistry.add(currentUserId)
app.users.subscribe(currentUserId)
.catch((error) => {throw JSON.stringify(error);}); .catch((error) => {throw JSON.stringify(error);});
// Get the current user's data {% if not current_user.terms_of_use_accepted -%}
app.users.get(currentUserId, true, true) M.Modal.getInstance(document.querySelector('#terms-of-use-modal')).open();
.catch((error) => {throw JSON.stringify(error);}); {% endif -%}
{% endif -%}
{% if not current_user.terms_of_use_accepted -%} // Display flashed messages
M.Modal.getInstance(document.querySelector('#terms-of-use-modal')).open(); for (let [category, message] of {{ get_flashed_messages(with_categories=True)|tojson }}) {
{% endif -%}
{% endif -%}
// Display flashed messages
for (let [category, message] of {{ get_flashed_messages(with_categories=True)|tojson }}) {
app.ui.flash(message, message); app.ui.flash(message, message);
} }
</script> </script>
<script> <script>
let languageModalSwitch = document.querySelector('#terms-of-use-modal-switch'); let languageModalSwitch = document.querySelector('#terms-of-use-modal-switch');
let termsOfUseModalContent = document.querySelectorAll('.terms-of-use-modal-content'); let termsOfUseModalContent = document.querySelectorAll('.terms-of-use-modal-content');
if (languageModalSwitch) { if (languageModalSwitch) {
languageModalSwitch.addEventListener('change', function() { languageModalSwitch.addEventListener('change', () => {
termsOfUseModalContent.forEach(content => { termsOfUseModalContent.forEach(content => {
content.classList.toggle('hide'); content.classList.toggle('hide');
}); });
}); });
} }
</script> </script>