Change the Subscription Logic for Socket.IO Data exchange

This commit is contained in:
Patrick Jentsch 2022-07-08 11:46:47 +02:00
parent 5771e156ce
commit 4e5957eea2
17 changed files with 137 additions and 134 deletions

View File

@ -11,8 +11,6 @@ from flask import (
send_from_directory send_from_directory
) )
from flask_login import current_user, login_required from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from zipfile import ZipFile
from . import bp from . import bp
from . import tasks from . import tasks
from .forms import ( from .forms import (
@ -24,7 +22,6 @@ from .forms import (
import os import os
import shutil import shutil
import tempfile import tempfile
import glob
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET

View File

@ -1,21 +1,25 @@
class App { class App {
constructor() { constructor() {
this.data = {users: {}}; this.data = {
this.promises = {users: {}}; promises: {getUser: {}, subscribeUser: {}},
users: {},
};
this.socket = io({transports: ['websocket'], upgrade: false}); this.socket = io({transports: ['websocket'], upgrade: false});
this.socket.on('PATCH', (patch) => {this.data = jsonpatch.applyPatch(this.data, patch).newDocument;}); this.socket.on('PATCH', (patch) => {
const re = new RegExp(`^/users/(${Object.keys(this.data.users).join('|')})`);
const filteredPatch = patch.filter(operation => re.test(operation.path));
jsonpatch.applyPatch(this.data, filteredPatch);
});
} }
get users() { getUser(userId) {
return this.data.users; if (userId in this.data.promises.getUser) {
return this.data.promises.getUser[userId];
} }
subscribeUser(userId) { this.data.promises.getUser[userId] = new Promise((resolve, reject) => {
if (userId in this.promises.users) { this.socket.emit('GET /users/<user_id>', userId, (response) => {
return this.promises.users[userId];
}
this.promises.users[userId] = new Promise((resolve, reject) => {
this.socket.emit('SUBSCRIBE /users/<user_id>', userId, response => {
if (response.code === 200) { if (response.code === 200) {
this.data.users[userId] = response.payload; this.data.users[userId] = response.payload;
resolve(this.data.users[userId]); resolve(this.data.users[userId]);
@ -24,7 +28,26 @@ class App {
} }
}); });
}); });
return this.promises.users[userId];
return this.data.promises.getUser[userId];
}
subscribeUser(userId) {
if (userId in this.data.promises.subscribeUser) {
return this.data.promises.subscribeUser[userId];
}
this.data.promises.subscribeUser[userId] = new Promise((resolve, reject) => {
this.socket.emit('SUBSCRIBE /users/<user_id>', userId, (response) => {
if (response.code === 200) {
resolve(response);
} else {
reject(response);
}
});
});
return this.data.promises.subscribeUser[userId];
} }
flash(message, category) { flash(message, category) {

View File

@ -1,10 +1,18 @@
class JobStatusNotifier { class JobStatusNotifier {
constructor(userId) { constructor(userId) {
this.userId = userId; this.userId = userId;
this.isInitialized = false;
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {this.onPATCH(patch);}); app.socket.on('PATCH', (patch) => {this.onPATCH(patch);});
});
app.getUser(this.userId).then((user) => {
this.isInitialized = true;
});
} }
onPATCH(patch) { onPATCH(patch) {
if (!this.isInitialized) {return;}
let filteredPatch; let filteredPatch;
let jobId; let jobId;
let match; let match;
@ -13,11 +21,11 @@ class JobStatusNotifier {
re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/status$`); re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/status$`);
filteredPatch = patch filteredPatch = patch
.filter(operation => operation.op === 'replace') .filter((operation) => {return operation.op === 'replace';})
.filter(operation => re.test(operation.path)); .filter((operation) => {return re.test(operation.path);});
for (operation of filteredPatch) { for (operation of filteredPatch) {
[match, jobId] = operation.path.match(re); [match, jobId] = operation.path.match(re);
app.flash(`[<a href="/jobs/${jobId}">${app.users[this.userId].jobs[jobId].title}</a>] New status: <span class="job-status-text" data-job-status="${operation.value}"></span>`, 'job'); app.flash(`[<a href="/jobs/${jobId}">${app.data.users[this.userId].jobs[jobId].title}</a>] New status: <span class="job-status-text" data-job-status="${operation.value}"></span>`, 'job');
} }
} }
} }

View File

@ -5,9 +5,8 @@ class CorpusDisplay extends RessourceDisplay {
} }
init(user) { init(user) {
let corpus; const corpus = user.corpora[this.corpusId];
corpus = user.corpora[this.corpusId];
this.setCreationDate(corpus.creation_date); this.setCreationDate(corpus.creation_date);
this.setDescription(corpus.description); this.setDescription(corpus.description);
this.setLastEditedDate(corpus.last_edited_date); this.setLastEditedDate(corpus.last_edited_date);
@ -17,12 +16,15 @@ class CorpusDisplay extends RessourceDisplay {
} }
onPATCH(patch) { onPATCH(patch) {
if (!this.isInitialized) {return;}
let filteredPatch; let filteredPatch;
let operation; let operation;
let re; let re;
re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`); re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`);
filteredPatch = patch.filter(operation => re.test(operation.path)); filteredPatch = patch.filter(operation => re.test(operation.path));
for (operation of filteredPatch) { for (operation of filteredPatch) {
switch(operation.op) { switch(operation.op) {
case 'replace': case 'replace':
@ -55,7 +57,7 @@ class CorpusDisplay extends RessourceDisplay {
setNumTokens(numTokens) { setNumTokens(numTokens) {
this.setElements( this.setElements(
this.displayElement.querySelectorAll('.corpus-token-ratio'), this.displayElement.querySelectorAll('.corpus-token-ratio'),
`${numTokens}/${app.users[this.userId].corpora[this.corpusId].max_num_tokens}` `${numTokens}/${app.data.users[this.userId].corpora[this.corpusId].max_num_tokens}`
); );
} }
@ -77,7 +79,7 @@ class CorpusDisplay extends RessourceDisplay {
} }
elements = this.displayElement.querySelectorAll('.corpus-build-trigger'); elements = this.displayElement.querySelectorAll('.corpus-build-trigger');
for (element of elements) { for (element of elements) {
if (['UNPREPARED', 'FAILED'].includes(status) && Object.values(app.users[this.userId].corpora[this.corpusId].files).length > 0) { if (['UNPREPARED', 'FAILED'].includes(status) && Object.values(app.data.users[this.userId].corpora[this.corpusId].files).length > 0) {
element.classList.remove('disabled'); element.classList.remove('disabled');
} else { } else {
element.classList.add('disabled'); element.classList.add('disabled');

View File

@ -5,9 +5,8 @@ class JobDisplay extends RessourceDisplay {
} }
init(user) { init(user) {
let job; const job = user.jobs[this.jobId];
job = user.jobs[this.jobId];
this.setCreationDate(job.creation_date); this.setCreationDate(job.creation_date);
this.setEndDate(job.creation_date); this.setEndDate(job.creation_date);
this.setDescription(job.description); this.setDescription(job.description);
@ -19,12 +18,15 @@ class JobDisplay extends RessourceDisplay {
} }
onPATCH(patch) { onPATCH(patch) {
if (!this.isInitialized) {return;}
let filteredPatch; let filteredPatch;
let operation; let operation;
let re; let re;
re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`); re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`);
filteredPatch = patch.filter(operation => re.test(operation.path)); filteredPatch = patch.filter(operation => re.test(operation.path));
for (operation of filteredPatch) { for (operation of filteredPatch) {
switch(operation.op) { switch(operation.op) {
case 'replace': case 'replace':

View File

@ -2,8 +2,16 @@ class RessourceDisplay {
constructor(displayElement) { constructor(displayElement) {
this.displayElement = displayElement; this.displayElement = displayElement;
this.userId = this.displayElement.dataset.userId; this.userId = this.displayElement.dataset.userId;
this.isInitialized = false;
if (this.userId) {
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {this.onPATCH(patch);}); app.socket.on('PATCH', (patch) => {this.onPATCH(patch);});
app.subscribeUser(this.userId).then((user) => {this.init(user);}); });
app.getUser(this.userId).then((user) => {
this.init(user);
this.isInitialized = true;
});
}
} }
init(user) {throw 'Not implemented';} init(user) {throw 'Not implemented';}

View File

@ -65,7 +65,7 @@ class CorpusFileList extends RessourceList {
<div class="modal"> <div class="modal">
<div class="modal-content"> <div class="modal-content">
<h4>Confirm corpus deletion</h4> <h4>Confirm corpus deletion</h4>
<p>Do you really want to delete the corpus file <b>${app.users[this.userId].corpora[this.corpusId].files[corpusFileId].filename}</b>? It will be permanently deleted!</p> <p>Do you really want to delete the corpus file <b>${app.data.users[this.userId].corpora[this.corpusId].files[corpusFileId].filename}</b>? It will be permanently deleted!</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a> <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
@ -97,6 +97,8 @@ class CorpusFileList extends RessourceList {
} }
onPATCH(patch) { onPATCH(patch) {
if (!this.isInitialized) {return;}
let corpusFileId; let corpusFileId;
let filteredPatch; let filteredPatch;
let match; let match;

View File

@ -60,7 +60,7 @@ class CorpusList extends RessourceList {
<div class="modal"> <div class="modal">
<div class="modal-content"> <div class="modal-content">
<h4>Confirm corpus deletion</h4> <h4>Confirm corpus deletion</h4>
<p>Do you really want to delete the corpus <b>${app.users[this.userId].corpora[corpusId].title}</b>? All files will be permanently deleted!</p> <p>Do you really want to delete the corpus <b>${app.data.users[this.userId].corpora[corpusId].title}</b>? All files will be permanently deleted!</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a> <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
@ -89,6 +89,8 @@ class CorpusList extends RessourceList {
} }
onPATCH(patch) { onPATCH(patch) {
if (!this.isInitialized) {return;}
let corpusId; let corpusId;
let filteredPatch; let filteredPatch;
let match; let match;

View File

@ -36,7 +36,6 @@ class JobList extends RessourceList {
] ]
}; };
constructor(listElement, options = {}) { constructor(listElement, options = {}) {
super(listElement, {...JobList.options, ...options}); super(listElement, {...JobList.options, ...options});
} }
@ -66,7 +65,7 @@ class JobList extends RessourceList {
<div class="modal"> <div class="modal">
<div class="modal-content"> <div class="modal-content">
<h4>Confirm job deletion</h4> <h4>Confirm job deletion</h4>
<p>Do you really want to delete the job <b>${app.users[this.userId].jobs[jobId].title}</b>? All files will be permanently deleted!</p> <p>Do you really want to delete the job <b>${app.data.users[this.userId].jobs[jobId].title}</b>? All files will be permanently deleted!</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a> <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
@ -95,6 +94,8 @@ class JobList extends RessourceList {
} }
onPATCH(patch) { onPATCH(patch) {
if (!this.isInitialized) {return;}
let filteredPatch; let filteredPatch;
let jobId; let jobId;
let match; let match;

View File

@ -58,6 +58,8 @@ class JobResultList extends RessourceList {
} }
onPATCH(patch) { onPATCH(patch) {
if (!this.isInitialized) {return;}
let filteredPatch; let filteredPatch;
let operation; let operation;
let re; let re;

View File

@ -90,12 +90,15 @@ class RessourceList {
this.listjs.list.style.cursor = 'pointer'; this.listjs.list.style.cursor = 'pointer';
this.userId = this.listjs.listContainer.dataset.userId; this.userId = this.listjs.listContainer.dataset.userId;
this.listjs.list.addEventListener('click', event => this.onclick(event)); this.listjs.list.addEventListener('click', event => this.onclick(event));
this.isInitialized = false;
if (this.userId) { if (this.userId) {
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {this.onPATCH(patch);}); app.socket.on('PATCH', (patch) => {this.onPATCH(patch);});
app.subscribeUser(this.userId).then( });
(user) => {this.init(user);}, app.getUser(this.userId).then((user) => {
(error) => {throw JSON.stringify(error);} this.init(user);
); this.isInitialized = true;
});
} }
} }

View File

@ -20,7 +20,7 @@ class UserList extends RessourceList {
'id-1': user.id, 'id-1': user.id,
'username': user.username, 'username': user.username,
'email': user.email, 'email': user.email,
'last-seen': new Date(user.last_seen).toLocaleString("en-US"), 'last-seen': new Date(user.last_seen).toLocaleString('en-US'),
'member-since': user.member_since, 'member-since': user.member_since,
'role': user.role.name 'role': user.role.name
}; };

View File

@ -32,11 +32,7 @@
const jobStatusNotifier = new JobStatusNotifier(currentUserId); const jobStatusNotifier = new JobStatusNotifier(currentUserId);
// Initialize components for current user // Initialize components for current user
app.subscribeUser(currentUserId) app.subscribeUser(currentUserId).catch((error) => {throw JSON.stringify(error);});
.then(
(user) => {return;},
(error) => {throw JSON.stringify(error);}
);
{%- endif %} {%- endif %}
// Disable all option elements with no value // Disable all option elements with no value

View File

@ -1,8 +1,8 @@
{% set breadcrumbs %} {% set breadcrumbs %}
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
<li class="tab"><a href="{{ url_for('main.dashboard', _anchor='jobs') }}" target="_self">My jobs</a></li> <li class="tab"><a href="{{ url_for('main.dashboard', _anchor='jobs') }}" target="_self">My jobs</a></li>
<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
{% if request.path == url_for('.job', job_id=job.id) %} {% if request.path == url_for('.job', job_id=job.id) %}
<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
<li class="tab"><a class="active" href="{{ url_for('.job', job_id=job.id) }}" target="_self">{{ job.title }}</a></li> <li class="tab"><a class="active" href="{{ url_for('.job', job_id=job.id) }}" target="_self">{{ job.title }}</a></li>
{% endif %} {% endif %}
{% endset %} {% endset %}

View File

@ -7,10 +7,28 @@
<h1 id="title">{{ title }}</h1> <h1 id="title">{{ title }}</h1>
</div> </div>
<div class="col s12"> <div class="col s12 nopaque-ressource-list no-autoinit" data-ressource-type="User" id="users">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<table class="" id="users"></table> <div class="input-field">
<i class="material-icons prefix">search</i>
<input id="search-user" class="search" type="text"></input>
<label for="search-user">Search user</label>
</div>
<table>
<thead>
<tr>
<th>Id</th>
<th>Username</th>
<th>Email</th>
<th>Last seen</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div> </div>
</div> </div>
</div> </div>
@ -18,52 +36,10 @@
</div> </div>
{% endblock page_content %} {% endblock page_content %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
<script> <script>
const updateUrl = (prev, query) => { let userList = new UserList(document.querySelector('#users'));
return prev + (prev.indexOf('?') >= 0 ? '&' : '?') + new URLSearchParams(query).toString(); userList.init({{ dict_users|tojson }});
};
new gridjs.Grid({
columns: [
{ id: 'username', name: 'Username' },
{ id: 'email', name: 'Email' },
],
server: {
url: '/users/api_users',
then: results => results.data,
total: results => results.total,
},
search: {
enabled: true,
server: {
url: (prev, search) => {
return updateUrl(prev, {search});
},
},
},
sort: {
enabled: true,
multiColumn: true,
server: {
url: (prev, columns) => {
const columnIds = ['username', 'email'];
const sort = columns.map(col => (col.direction === 1 ? '+' : '-') + columnIds[col.index]);
return updateUrl(prev, {sort});
},
},
},
pagination: {
enabled: true,
server: {
url: (prev, page, limit) => {
return updateUrl(prev, {offset: page * limit, limit: limit});
},
},
}
}).render(document.getElementById('users'));
</script> </script>
{% endblock scripts %} {% endblock scripts %}

View File

@ -5,6 +5,19 @@ from flask_login import current_user
from flask_socketio import join_room, leave_room from flask_socketio import join_room, leave_room
@socketio.on('GET /users/<user_id>')
@socketio_login_required
def subscribe_user(user_hashid):
user_id = hashids.decode(user_hashid)
user = User.query.get(user_id)
if user is None:
return {'code': 404, 'msg': 'Not found'}
if not (user == current_user or current_user.is_administrator):
return {'code': 403, 'msg': 'Forbidden'}
dict_user = user.to_dict(backrefs=True, relationships=True)
return {'code': 200, 'msg': 'OK', 'payload': dict_user}
@socketio.on('SUBSCRIBE /users/<user_id>') @socketio.on('SUBSCRIBE /users/<user_id>')
@socketio_login_required @socketio_login_required
def subscribe_user(user_hashid): def subscribe_user(user_hashid):
@ -14,9 +27,10 @@ def subscribe_user(user_hashid):
return {'code': 404, 'msg': 'Not found'} return {'code': 404, 'msg': 'Not found'}
if not (user == current_user or current_user.is_administrator): if not (user == current_user or current_user.is_administrator):
return {'code': 403, 'msg': 'Forbidden'} return {'code': 403, 'msg': 'Forbidden'}
dict_user = user.to_dict(backrefs=True, relationships=True) # dict_user = user.to_dict(backrefs=True, relationships=True)
join_room(f'/users/{user.hashid}') join_room(f'/users/{user.hashid}')
return {'code': 200, 'msg': 'OK', 'payload': dict_user} # return {'code': 200, 'msg': 'OK', 'payload': dict_user}
return {'code': 200, 'msg': 'OK'}
@socketio.on('UNSUBSCRIBE /users/<user_id>') @socketio.on('UNSUBSCRIBE /users/<user_id>')

View File

@ -1,50 +1,17 @@
from app.decorators import admin_required
from app.models import User from app.models import User
from flask import render_template, request, url_for from flask import render_template, request
from flask_login import login_required
from . import bp from . import bp
@bp.route('/') @bp.route('/')
@login_required
@admin_required
def users(): def users():
dict_users = [u.to_dict(backrefs=True, relationships=False) for u in User.query.all()]
return render_template( return render_template(
'users/users.html.j2', 'users/users.html.j2',
title='Users' title='Users',
dict_users=dict_users
) )
@bp.route('/api_users')
def api_users():
query = User.query
# search filter
search = request.args.get('search')
if search:
query = query.filter(User.username.like(f'%{search}%') | User.email.like(f'%{search}%'))
total = query.count()
# sorting
sort = request.args.get('sort')
if sort:
order = []
for s in sort.split(','):
direction = s[0]
name = s[1:]
if name not in ['username', 'email']:
name = 'username'
col = getattr(User, name)
if direction == '-':
col = col.desc()
order.append(col)
if order:
query = query.order_by(*order)
# pagination
offset = request.args.get('offset', type=int, default=-1)
limit = request.args.get('limit', type=int, default=-1)
if offset != -1 and limit != -1:
query = query.offset(offset).limit(limit)
# response
return {
'data': [user.to_dict() for user in query],
'total': total
}