34 Commits

Author SHA1 Message Date
41a88fce33 Bump nopaque version 1.1.0 -> 1.1.1 2025-06-03 15:03:29 +02:00
56844e0898 Update Terms of Use Modal and fix message flashing. 2025-06-03 13:51:28 +02:00
c28d534942 Fix redirect loop 2025-05-23 11:38:32 +02:00
80604bf8de enhance modals and terms of use html 2024-12-23 14:49:06 +01:00
d4cd313940 Implement /admin using flask-admin. Overall cleanup 2024-12-16 15:37:19 +01:00
c405061574 Restructure corpora blueprint 2024-12-16 11:39:54 +01:00
6c1f48eb2f Update corpora package 2024-12-16 10:09:54 +01:00
cda28910f5 Update settings 2024-12-16 10:07:21 +01:00
9a805b9d14 Add value to submit button 2024-12-16 09:57:18 +01:00
16bf891654 fix wtf macros 2024-12-16 09:45:19 +01:00
cb53b27ebf Rename function 2024-12-12 15:55:39 +01:00
6684257bc4 added job inputs/results blueprints 2024-12-12 15:32:56 +01:00
0d1805fb76 Update Job Blueprint package 2024-12-12 15:26:01 +01:00
bb60a2ba67 Move jobs namespace back to http routes 2024-12-12 10:32:08 +01:00
328f85ba52 Implement corpora endpoint/socket.io namespace 2024-12-09 16:12:49 +01:00
93344c9573 Add job namespace and remove old json_routes logic 2024-12-06 11:41:36 +01:00
1372c86609 Add api and administration links to navigations 2024-12-06 10:14:45 +01:00
713a7645db Bump nopaque version 2024-12-05 15:34:11 +01:00
0c64c07925 Update corpus analysis loading modal 2024-12-05 15:33:15 +01:00
a6ddf4c980 Remove import corpus button 2024-12-05 15:12:53 +01:00
cab5f7ea05 More js enhancements 2024-12-05 15:07:13 +01:00
07f09cdbd9 fix cqi_over_socketio 2024-12-05 15:07:03 +01:00
c97b2a886e Further js refactoring 2024-12-05 14:26:05 +01:00
df2bffe0fd implement first version of jobs socketio namespace 2024-12-03 16:09:14 +01:00
aafb3ca3ec Update javascript app structure 2024-12-03 15:59:08 +01:00
12a3ac1d5d Update JS code structure 2024-12-02 09:34:17 +01:00
a2904caea2 Update cqpserver image version 2024-11-28 10:02:27 +01:00
e325552100 Update corpus analysis tabs to look the same as before base template update 2024-11-28 10:02:00 +01:00
e269156925 fix socketio emits from database event listeners 2024-11-27 15:46:54 +01:00
9c9de242ca Remove unsed css 2024-11-27 11:35:51 +01:00
ec54fdc3bb Restore service scheme on pages 2024-11-27 11:34:21 +01:00
2263a8d27d codestyle enhancements in base template 2024-11-21 11:22:57 +01:00
143cdd91f9 update workspace settings 2024-11-21 11:22:46 +01:00
b5f7478e14 Update templates 2024-11-21 11:12:11 +01:00
116 changed files with 2507 additions and 2601 deletions

16
.vscode/settings.json vendored
View File

@ -1,10 +1,24 @@
{
"editor.rulers": [79],
"editor.tabSize": 4,
"emmet.includeLanguages": {
"jinja-html": "html"
},
"files.associations": {
".flaskenv": "env",
"*.env.tpl": "env",
"*.txt.j2": "jinja"
},
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true,
"[html]": {
"editor.tabSize": 2
},
"[javascript]": {
"editor.tabSize": 2,
"editor.tabSize": 2
},
"[jinja-html]": {
"editor.tabSize": 2
}
}

View File

@ -3,6 +3,7 @@ from config import Config
from docker import DockerClient
from flask import Flask
from flask.logging import default_handler
from flask_admin import Admin
from flask_apscheduler import APScheduler
from flask_assets import Environment
from flask_login import LoginManager
@ -15,10 +16,12 @@ from flask_sqlalchemy import SQLAlchemy
from flask_hashids import Hashids
from logging import Formatter, StreamHandler
from werkzeug.middleware.proxy_fix import ProxyFix
from .extensions.nopaque_flask_admin_views import AdminIndexView, ModelView
docker_client = DockerClient.from_env()
admin = Admin()
apifairy = APIFairy()
assets = Environment()
db = SQLAlchemy()
@ -74,6 +77,7 @@ def create_app(config: Config = Config) -> Flask:
from .models import AnonymousUser, User
admin.init_app(app, index_view=AdminIndexView())
apifairy.init_app(app)
assets.init_app(app)
db.init_app(app)
@ -92,9 +96,6 @@ def create_app(config: Config = Config) -> Flask:
# endregion Extensions
# region Blueprints
from .blueprints.admin import bp as admin_blueprint
app.register_blueprint(admin_blueprint, url_prefix='/admin')
from .blueprints.api import bp as api_blueprint
app.register_blueprint(api_blueprint, url_prefix='/api')
@ -127,6 +128,10 @@ def create_app(config: Config = Config) -> Flask:
from .blueprints.workshops import bp as workshops_blueprint
app.register_blueprint(workshops_blueprint, url_prefix='/workshops')
from .models import _models
for model in _models:
admin.add_view(ModelView(model, db.session, category='Database'))
# endregion Blueprints
# region SocketIO Namespaces

View File

@ -1,20 +0,0 @@
from flask import Blueprint
from flask_login import login_required
from app.decorators import admin_required
bp = Blueprint('admin', __name__)
@bp.before_request
@login_required
@admin_required
def before_request():
'''
Ensures that the routes in this package can be visited only by users with
administrator privileges (login_required and admin_required).
'''
pass
from . import json_routes, routes

View File

@ -1,16 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import SelectField, SubmitField
from app.models import Role
class UpdateUserForm(FlaskForm):
role = SelectField('Role')
submit = SubmitField()
def __init__(self, user, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = {'role': user.role.hashid}
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-user-form'
super().__init__(*args, **kwargs)
self.role.choices = [(x.hashid, x.name) for x in Role.query.all()]

View File

@ -1,23 +0,0 @@
from flask import abort, request
from app.decorators import content_negotiation
from app import db
from app.models import User
from . import bp
@bp.route('/users/<hashid:user_id>/confirmed', methods=['PUT'])
@content_negotiation(consumes='application/json', produces='application/json')
def update_user_role(user_id):
confirmed = request.json
if not isinstance(confirmed, bool):
abort(400)
user = User.query.get_or_404(user_id)
user.confirmed = confirmed
db.session.commit()
response_data = {
'message': (
f'User "{user.username}" is now '
f'{"confirmed" if confirmed else "unconfirmed"}'
)
}
return response_data, 200

View File

@ -1,136 +0,0 @@
from flask import abort, flash, redirect, render_template, url_for
from app import db, hashids
from app.models import Avatar, Corpus, Role, User
from app.blueprints.users.settings.forms import (
UpdateAvatarForm,
UpdatePasswordForm,
UpdateNotificationsForm,
UpdateAccountInformationForm,
UpdateProfileInformationForm
)
from . import bp
from .forms import UpdateUserForm
@bp.route('')
def admin():
return render_template(
'admin/admin.html.j2',
title='Administration'
)
@bp.route('/corpora')
def corpora():
corpora = Corpus.query.all()
return render_template(
'admin/corpora.html.j2',
title='Corpora',
corpora=corpora
)
@bp.route('/users')
def users():
users = User.query.all()
return render_template(
'admin/users.html.j2',
title='Users',
users=users
)
@bp.route('/users/<hashid:user_id>')
def user(user_id):
user = User.query.get_or_404(user_id)
corpora = Corpus.query.filter(Corpus.user == user).all()
return render_template(
'admin/user.html.j2',
title=user.username,
user=user,
corpora=corpora
)
@bp.route('/users/<hashid:user_id>/settings', methods=['GET', 'POST'])
def user_settings(user_id):
user = User.query.get_or_404(user_id)
update_account_information_form = UpdateAccountInformationForm(user)
update_profile_information_form = UpdateProfileInformationForm(user)
update_avatar_form = UpdateAvatarForm()
update_password_form = UpdatePasswordForm(user)
update_notifications_form = UpdateNotificationsForm(user)
update_user_form = UpdateUserForm(user)
# region handle update profile information form
if update_profile_information_form.submit.data and update_profile_information_form.validate():
user.about_me = update_profile_information_form.about_me.data
user.location = update_profile_information_form.location.data
user.organization = update_profile_information_form.organization.data
user.website = update_profile_information_form.website.data
user.full_name = update_profile_information_form.full_name.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update profile information form
# region handle update avatar form
if update_avatar_form.submit.data and update_avatar_form.validate():
try:
Avatar.create(
update_avatar_form.avatar.data,
user=user
)
except (AttributeError, OSError):
abort(500)
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update avatar form
# region handle update account information form
if update_account_information_form.submit.data and update_account_information_form.validate():
user.email = update_account_information_form.email.data
user.username = update_account_information_form.username.data
db.session.commit()
flash('Profile settings updated')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update account information form
# region handle update password form
if update_password_form.submit.data and update_password_form.validate():
user.password = update_password_form.new_password.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update password form
# region handle update notifications form
if update_notifications_form.submit.data and update_notifications_form.validate():
user.setting_job_status_mail_notification_level = \
update_notifications_form.job_status_mail_notification_level.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update notifications form
# region handle update user form
if update_user_form.submit.data and update_user_form.validate():
role_id = hashids.decode(update_user_form.role.data)
user.role = Role.query.get(role_id)
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update user form
return render_template(
'admin/user_settings.html.j2',
title='Settings',
update_account_information_form=update_account_information_form,
update_avatar_form=update_avatar_form,
update_notifications_form=update_notifications_form,
update_password_form=update_password_form,
update_profile_information_form=update_profile_information_form,
update_user_form=update_user_form,
user=user
)

View File

@ -19,11 +19,9 @@ def before_request():
and request.endpoint
and request.blueprint != 'auth'
and request.endpoint != 'static'
and request.endpoint != 'main.accept_terms_of_use'
):
return redirect(url_for('auth.unconfirmed'))
if not current_user.terms_of_use_accepted:
return redirect(url_for('main.terms_of_use'))
from . import routes

View File

@ -16,4 +16,4 @@ def before_request():
pass
from . import cli, files, followers, routes, json_routes
from . import cli, files, followers, routes

View File

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

View File

@ -1,125 +0,0 @@
from datetime import datetime
from flask import abort, current_app, request, url_for
from flask_login import current_user
from threading import Thread
from app import db
from app.decorators import content_negotiation
from app.models import Corpus, CorpusFollowerRole
from . import bp
from .decorators import corpus_follower_permission_required, corpus_owner_or_admin_required
import nltk
from string import punctuation
@bp.route('/<hashid:corpus_id>', methods=['DELETE'])
@corpus_owner_or_admin_required
@content_negotiation(produces='application/json')
def delete_corpus(corpus_id):
def _delete_corpus(app, corpus_id):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.delete()
db.session.commit()
corpus = Corpus.query.get_or_404(corpus_id)
thread = Thread(
target=_delete_corpus,
args=(current_app._get_current_object(), corpus.id)
)
thread.start()
response_data = {
'message': f'Corpus "{corpus.title}" marked for deletion',
'category': 'corpus'
}
return response_data, 200
@bp.route('/<hashid:corpus_id>/build', methods=['POST'])
@corpus_follower_permission_required('MANAGE_FILES')
@content_negotiation(produces='application/json')
def build_corpus(corpus_id):
def _build_corpus(app, corpus_id):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.build()
db.session.commit()
corpus = Corpus.query.get_or_404(corpus_id)
if len(corpus.files.all()) == 0:
abort(409)
thread = Thread(
target=_build_corpus,
args=(current_app._get_current_object(), corpus_id)
)
thread.start()
response_data = {
'message': f'Corpus "{corpus.title}" marked for building',
'category': 'corpus'
}
return response_data, 202
@bp.route('/stopwords')
@content_negotiation(produces='application/json')
def get_stopwords():
nltk.download('stopwords', quiet=True)
languages = ["german", "english", "catalan", "greek", "spanish", "french", "italian", "russian", "chinese"]
stopwords = {}
for language in languages:
stopwords[language] = nltk.corpus.stopwords.words(language)
stopwords['punctuation'] = list(punctuation) + ['', '|', '', '', '', '--']
stopwords['user_stopwords'] = []
response_data = stopwords
return response_data, 202
@bp.route('/<hashid:corpus_id>/generate-share-link', methods=['POST'])
@corpus_follower_permission_required('MANAGE_FOLLOWERS')
@content_negotiation(consumes='application/json', produces='application/json')
def generate_corpus_share_link(corpus_id):
data = request.json
if not isinstance(data, dict):
abort(400)
expiration = data.get('expiration')
if not isinstance(expiration, str):
abort(400)
role_name = data.get('role')
if not isinstance(role_name, str):
abort(400)
expiration_date = datetime.strptime(expiration, '%b %d, %Y')
cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
if cfr is None:
abort(400)
corpus = Corpus.query.get_or_404(corpus_id)
token = current_user.generate_follow_corpus_token(corpus.hashid, role_name, expiration_date)
corpus_share_link = url_for(
'corpora.follow_corpus',
corpus_id=corpus_id,
token=token,
_external=True
)
response_data = {
'message': 'Corpus share link generated',
'category': 'corpus',
'corpusShareLink': corpus_share_link
}
return response_data, 200
@bp.route('/<hashid:corpus_id>/is_public', methods=['PUT'])
@corpus_owner_or_admin_required
@content_negotiation(consumes='application/json', produces='application/json')
def update_corpus_is_public(corpus_id):
is_public = request.json
if not isinstance(is_public, bool):
abort(400)
corpus = Corpus.query.get_or_404(corpus_id)
corpus.is_public = is_public
db.session.commit()
response_data = {
'message': (
f'Corpus "{corpus.title}" is now'
f' {"public" if is_public else "private"}'
),
'category': 'corpus'
}
return response_data, 200

View File

@ -1,5 +1,19 @@
from flask import abort, flash, redirect, render_template, url_for
from datetime import datetime
from flask import (
abort,
current_app,
flash,
Flask,
jsonify,
redirect,
request,
render_template,
url_for
)
from flask_login import current_user
from string import punctuation
from threading import Thread
import nltk
from app import db
from app.models import (
Corpus,
@ -12,6 +26,21 @@ from .decorators import corpus_follower_permission_required
from .forms import CreateCorpusForm
def _delete_corpus(app: Flask, corpus_id: int):
with app.app_context():
corpus: Corpus = Corpus.query.get(corpus_id)
corpus.delete()
db.session.commit()
def _build_corpus(app: Flask, corpus_id: int):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.build()
db.session.commit()
@bp.route('')
def corpora():
return redirect(url_for('main.dashboard', _anchor='corpora'))
@ -20,6 +49,7 @@ def corpora():
@bp.route('/create', methods=['GET', 'POST'])
def create_corpus():
form = CreateCorpusForm()
if form.validate_on_submit():
try:
corpus = Corpus.create(
@ -30,8 +60,10 @@ def create_corpus():
except OSError:
abort(500)
db.session.commit()
flash(f'Corpus "{corpus.title}" created', 'corpus')
return redirect(corpus.url)
return render_template(
'corpora/create.html.j2',
title='Create corpus',
@ -40,12 +72,14 @@ def create_corpus():
@bp.route('/<hashid:corpus_id>')
def corpus(corpus_id):
def corpus(corpus_id: int):
corpus = Corpus.query.get_or_404(corpus_id)
cfrs = CorpusFollowerRole.query.all()
# TODO: Better solution for filtering admin
users = User.query.filter(User.is_public == True, User.id != current_user.id, User.id != corpus.user.id, User.role_id < 4).all()
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
cfa = CorpusFollowerAssociation.query.filter_by(
corpus_id=corpus_id,
follower_id=current_user.id
).first()
if cfa is None:
if corpus.user == current_user or current_user.is_administrator:
cfr = CorpusFollowerRole.query.filter_by(name='Administrator').first()
@ -53,7 +87,21 @@ def corpus(corpus_id):
cfr = CorpusFollowerRole.query.filter_by(name='Anonymous').first()
else:
cfr = cfa.role
if corpus.user == current_user or current_user.is_administrator:
cfrs = CorpusFollowerRole.query.all()
# TODO: Better solution for filtering admin
users = User.query.filter(
User.is_public == True,
User.id != current_user.id,
User.id != corpus.user.id,
User.role_id < 4
).all()
if (
corpus.user == current_user
or current_user.is_administrator
):
return render_template(
'corpora/corpus.html.j2',
title=corpus.title,
@ -62,8 +110,15 @@ def corpus(corpus_id):
cfrs=cfrs,
users=users
)
if (current_user.is_following_corpus(corpus) or corpus.is_public):
cfas = CorpusFollowerAssociation.query.filter(Corpus.id == corpus_id, CorpusFollowerAssociation.follower_id != corpus.user.id).all()
if (
current_user.is_following_corpus(corpus)
or corpus.is_public
):
cfas = CorpusFollowerAssociation.query.filter(
Corpus.id == corpus_id,
CorpusFollowerAssociation.follower_id != corpus.user.id
).all()
return render_template(
'corpora/public_corpus.html.j2',
title=corpus.title,
@ -73,14 +128,110 @@ def corpus(corpus_id):
cfas=cfas,
users=users
)
abort(403)
@bp.route('/<hashid:corpus_id>', methods=['DELETE'])
def delete_corpus(corpus_id: int):
corpus = Corpus.query.get_or_404(corpus_id)
if not (
corpus.user == current_user
or current_user.is_administrator
):
abort(403)
thread = Thread(
target=_delete_corpus,
args=(current_app._get_current_object(), corpus.id)
)
thread.start()
return jsonify(f'Corpus "{corpus.title}" marked for deletion.'), 202
@bp.route('/<hashid:corpus_id>/build', methods=['POST'])
def build_corpus(corpus_id: int):
corpus = Corpus.query.get_or_404(corpus_id)
cfa = CorpusFollowerAssociation.query.filter_by(
corpus_id=corpus_id,
follower_id=current_user.id
).first()
if not (
cfa is not None and cfa.role.has_permission('MANAGE_FILES')
or corpus.user == current_user
or current_user.is_administrator
):
abort(403)
if len(corpus.files.all()) == 0:
abort(409)
thread = Thread(
target=_build_corpus,
args=(current_app._get_current_object(), corpus.id)
)
thread.start()
return jsonify(f'Corpus "{corpus.title}" marked for building.'), 202
@bp.route('/<hashid:corpus_id>/create-share-link', methods=['POST'])
def create_share_link(corpus_id: int):
data = request.json
expiration_date = data['expiration_date']
if not isinstance(expiration_date, str):
abort(400)
role_name = data['role_name']
if not isinstance(role_name, str):
abort(400)
corpus = Corpus.query.get_or_404(corpus_id)
cfa = CorpusFollowerAssociation.query.filter_by(
corpus_id=corpus_id,
follower_id=current_user.id
).first()
if not (
cfa is not None and cfa.role.has_permission('MANAGE_FOLLOWERS')
or corpus.user == current_user
or current_user.is_administrator
):
abort(403)
_expiration_date = datetime.strptime(expiration_date, '%b %d, %Y')
cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
if cfr is None:
abort(400)
token = current_user.generate_follow_corpus_token(
corpus.hashid,
role_name,
_expiration_date
)
corpus_share_link = url_for(
'corpora.follow_corpus',
corpus_id=corpus_id,
token=token,
_external=True
)
return jsonify(corpus_share_link)
@bp.route('/<hashid:corpus_id>/analysis')
@corpus_follower_permission_required('VIEW')
def analysis(corpus_id):
def analysis(corpus_id: int):
corpus = Corpus.query.get_or_404(corpus_id)
return render_template(
'corpora/analysis.html.j2',
corpus=corpus,
@ -88,22 +239,61 @@ def analysis(corpus_id):
)
@bp.route('/<hashid:corpus_id>/analysis/stopwords')
def get_stopwords(corpus_id: int):
languages = [
'german',
'english',
'catalan',
'greek',
'spanish',
'french',
'italian',
'russian',
'chinese'
]
nltk.download('stopwords', quiet=True)
stopwords = {
language: nltk.corpus.stopwords.words(language)
for language in languages
}
stopwords['punctuation'] = list(punctuation)
stopwords['punctuation'] += ['', '|', '', '', '', '--']
stopwords['user_stopwords'] = []
return jsonify(stopwords)
@bp.route('/<hashid:corpus_id>/follow/<token>')
def follow_corpus(corpus_id, token):
def follow_corpus(corpus_id: int, token: str):
corpus = Corpus.query.get_or_404(corpus_id)
if current_user.follow_corpus_by_token(token):
db.session.commit()
flash(f'You are following "{corpus.title}" now', category='corpus')
return redirect(url_for('corpora.corpus', corpus_id=corpus_id))
abort(403)
if not current_user.follow_corpus_by_token(token):
abort(403)
db.session.commit()
flash(f'You are following "{corpus.title}" now', category='corpus')
return redirect(corpus.url)
@bp.route('/import', methods=['GET', 'POST'])
def import_corpus():
abort(503)
@bp.route('/<hashid:corpus_id>/is-public', methods=['PUT'])
def update_is_public(corpus_id):
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
corpus = Corpus.query.get_or_404(corpus_id)
if not (
corpus.user == current_user
or current_user.is_administrator
):
abort(403)
@bp.route('/<hashid:corpus_id>/export')
@corpus_follower_permission_required('VIEW')
def export_corpus(corpus_id):
abort(503)
corpus.is_public = new_value
db.session.commit()
return jsonify(f'Corpus "{corpus.title}" is now {"public" if new_value else "private"}'), 200

View File

@ -4,11 +4,17 @@ from . import bp
@bp.app_errorhandler(HTTPException)
def handle_http_exception(error):
def handle_http_exception(e: HTTPException):
''' Generic HTTP exception handler '''
accept_json = request.accept_mimetypes.accept_json
accept_html = request.accept_mimetypes.accept_html
if accept_json and not accept_html:
response = jsonify(str(error))
return response, error.code
return render_template('errors/error.html.j2', error=error), error.code
error = {
'code': e.code,
'name': e.name,
'description': e.description
}
return jsonify(error), e.code
return render_template('errors/error.html.j2', error=e), e.code

View File

@ -1,18 +1,13 @@
from flask import Blueprint
from flask_login import login_required
bp = Blueprint('jobs', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import routes
from .inputs import bp as inputs_bp
bp.register_blueprint(inputs_bp, url_prefix='/<hashid:job_id>/inputs')
from . import routes, json_routes
from .results import bp as results_bp
bp.register_blueprint(results_bp, url_prefix='/<hashid:job_id>/results')

View File

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

View File

@ -0,0 +1,27 @@
from flask import abort, send_from_directory
from flask_login import current_user, login_required
from app.models import JobInput
from . import bp
@bp.route('/<hashid:job_input_id>/download')
@login_required
def download_job_input(job_id: int, job_input_id: int):
job_input = JobInput.query.filter_by(
job_id=job_id,
id=job_input_id
).first_or_404()
if not (
job_input.job.user == current_user
or current_user.is_administrator
):
abort(403)
return send_from_directory(
job_input.path.parent,
job_input.path.name,
as_attachment=True,
download_name=job_input.filename,
mimetype=job_input.mimetype
)

View File

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

View File

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

View File

@ -0,0 +1,27 @@
from flask import abort, send_from_directory
from flask_login import current_user, login_required
from app.models import JobResult
from . import bp
@bp.route('/<hashid:job_result_id>/download')
@login_required
def download_job_result(job_id: int, job_result_id: int):
job_result = JobResult.query.filter_by(
job_id=job_id,
id=job_result_id
).first_or_404()
if not (
job_result.job.user == current_user
or current_user.is_administrator
):
abort(403)
return send_from_directory(
job_result.path.parent,
job_result.path.name,
as_attachment=True,
download_name=job_result.filename,
mimetype=job_result.mimetype
)

View File

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

View File

@ -1,8 +1,9 @@
from flask import flash, redirect, render_template, url_for
from flask import abort, flash, jsonify, redirect, render_template, url_for
from flask_login import current_user, login_required, login_user
from app.blueprints.auth.forms import LoginForm
from app.models import Corpus, User
from . import bp
from app import db
@bp.route('/', methods=['GET', 'POST'])
@ -56,7 +57,7 @@ def news():
)
@bp.route('/privacy_policy')
@bp.route('/privacy-policy')
def privacy_policy():
return render_template(
'main/privacy_policy.html.j2',
@ -64,14 +65,24 @@ def privacy_policy():
)
@bp.route('/terms_of_use')
@bp.route('/terms-of-use')
def terms_of_use():
return render_template(
'main/terms_of_use.html.j2',
title='Terms of Use'
title='Terms of use'
)
@bp.route('/accept-terms-of-use', methods=['POST'])
@login_required
def accept_terms_of_use():
current_user.terms_of_use_accepted = True
db.session.commit()
return jsonify('You accepted the terms of use'), 202
@bp.route('/social')
@login_required
def social():

View File

@ -1,18 +1,7 @@
from flask import Blueprint
from flask_login import login_required
bp = Blueprint('settings', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import routes

View File

@ -38,8 +38,8 @@ class UpdateAccountInformationForm(FlaskForm):
]
)
submit = SubmitField()
def __init__(self, user, *args, **kwargs):
def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:
@ -64,7 +64,7 @@ class UpdateProfileInformationForm(FlaskForm):
validators=[Length(max=128)]
)
about_me = TextAreaField(
'About me',
'About me',
validators=[
Length(max=254)
]
@ -89,7 +89,7 @@ class UpdateProfileInformationForm(FlaskForm):
)
submit = SubmitField()
def __init__(self, user, *args, **kwargs):
def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:
@ -130,7 +130,7 @@ class UpdatePasswordForm(FlaskForm):
)
submit = SubmitField()
def __init__(self, user, *args, **kwargs):
def __init__(self, user: User, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-password-form'
super().__init__(*args, **kwargs)
@ -152,7 +152,7 @@ class UpdateNotificationsForm(FlaskForm):
)
submit = SubmitField()
def __init__(self, user, *args, **kwargs):
def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:

View File

@ -1,10 +1,158 @@
from flask import g, url_for
from flask_login import current_user
from app.blueprints.users.settings.routes import settings as settings_route
from flask import (
abort,
flash,
jsonify,
redirect,
render_template,
request,
url_for
)
from flask_login import current_user, login_required
from app import db
from app.models import Avatar
from . import bp
from .forms import (
UpdateAvatarForm,
UpdatePasswordForm,
UpdateNotificationsForm,
UpdateAccountInformationForm,
UpdateProfileInformationForm
)
@bp.route('/settings', methods=['GET', 'POST'])
def settings():
g._nopaque_redirect_location_on_post = url_for('.settings')
return settings_route(current_user.id)
@bp.route('', methods=['GET', 'POST'])
@login_required
def index():
update_account_information_form = UpdateAccountInformationForm(current_user)
update_profile_information_form = UpdateProfileInformationForm(current_user)
update_avatar_form = UpdateAvatarForm()
update_password_form = UpdatePasswordForm(current_user)
update_notifications_form = UpdateNotificationsForm(current_user)
# region handle update profile information form
if update_profile_information_form.submit.data and update_profile_information_form.validate():
current_user.about_me = update_profile_information_form.about_me.data
current_user.location = update_profile_information_form.location.data
current_user.organization = update_profile_information_form.organization.data
current_user.website = update_profile_information_form.website.data
current_user.full_name = update_profile_information_form.full_name.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.index'))
# endregion handle update profile information form
# region handle update avatar form
if update_avatar_form.submit.data and update_avatar_form.validate():
try:
Avatar.create(
update_avatar_form.avatar.data,
user=current_user
)
except (AttributeError, OSError):
abort(500)
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.index'))
# endregion handle update avatar form
# region handle update account information form
if update_account_information_form.submit.data and update_account_information_form.validate():
current_user.email = update_account_information_form.email.data
current_user.username = update_account_information_form.username.data
db.session.commit()
flash('Profile settings updated')
return redirect(url_for('.index'))
# endregion handle update account information form
# region handle update password form
if update_password_form.submit.data and update_password_form.validate():
current_user.password = update_password_form.new_password.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.index'))
# endregion handle update password form
# region handle update notifications form
if update_notifications_form.submit.data and update_notifications_form.validate():
current_user.setting_job_status_mail_notification_level = \
update_notifications_form.job_status_mail_notification_level.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.index'))
# endregion handle update notifications form
return render_template(
'settings/index.html.j2',
title='Settings',
update_account_information_form=update_account_information_form,
update_avatar_form=update_avatar_form,
update_notifications_form=update_notifications_form,
update_password_form=update_password_form,
update_profile_information_form=update_profile_information_form,
user=current_user
)
@bp.route('/profile-is-public', methods=['PUT'])
@login_required
def update_profile_is_public():
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
current_user.is_public = new_value
db.session.commit()
return jsonify('Your changes have been saved'), 200
@bp.route('/profile-show-email', methods=['PUT'])
@login_required
def update_profile_show_email():
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
if new_value:
current_user.add_profile_privacy_setting('SHOW_EMAIL')
else:
current_user.remove_profile_privacy_setting('SHOW_EMAIL')
db.session.commit()
return jsonify('Your changes have been saved'), 200
@bp.route('/profile-show-last-seen', methods=['PUT'])
@login_required
def update_profile_show_last_seen():
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
if new_value:
current_user.add_profile_privacy_setting('SHOW_LAST_SEEN')
else:
current_user.remove_profile_privacy_setting('SHOW_LAST_SEEN')
db.session.commit()
return jsonify('Your changes have been saved'), 200
@bp.route('/profile-show-member-since', methods=['PUT'])
@login_required
def update_profile_show_member_since():
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
if new_value:
current_user.add_profile_privacy_setting('SHOW_MEMBER_SINCE')
else:
current_user.remove_profile_privacy_setting('SHOW_MEMBER_SINCE')
db.session.commit()
return jsonify('Your changes have been saved'), 200

View File

@ -1,18 +1,7 @@
from flask import Blueprint
from flask_login import login_required
bp = Blueprint('users', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import cli, events, json_routes, routes, settings
from . import cli, events, routes

View File

@ -5,78 +5,87 @@ from app.decorators import socketio_login_required
from app.models import User
@socketio.on('users.get_user')
@socketio.on('SUBSCRIBE User')
@socketio_login_required
def get_user(user_hashid: str) -> dict:
def subscribe(user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {
'code': 400,
'name': 'Bad Request',
'description': 'Invalid User ID.'
}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
return {
'code': 400,
'name': 'Bad Request',
'description': 'Invalid User ID.'
}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
return {
'code': 404,
'name': 'Not Found',
'description': 'User 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_user')
@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'}
return {
'code': 403,
'name': 'Forbidden',
'description': 'Not allowed to subscribe to this user.'
}
join_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}
return {'code': 204, 'name': 'No Content'}
@socketio.on('users.unsubscribe_user')
@socketio.on('UNSUBSCRIBE User')
@socketio_login_required
def on_unsubscribe_user(user_hashid: str) -> dict:
def unsubscribe(user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {
'code': 400,
'name': 'Bad Request',
'description': 'Invalid User ID.'
}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
return {
'code': 400,
'name': 'Bad Request',
'description': 'Invalid User ID.'
}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
return {
'code': 404,
'name': 'Not Found',
'description': 'User not found.'
}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
return {
'code': 403,
'name': 'Forbidden',
'description': 'Not allowed to unsubscribe from this user.'
}
leave_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}
return {'code': 204, 'name': 'No Content'}

View File

@ -1,69 +0,0 @@
from flask import abort, current_app
from flask_login import current_user, logout_user
from threading import Thread
from app import db
from app.decorators import content_negotiation
from app.models import Avatar, User
from . import bp
@bp.route('/<hashid:user_id>', methods=['DELETE'])
@content_negotiation(produces='application/json')
def delete_user(user_id):
def _delete_user(app, user_id):
with app.app_context():
user = User.query.get(user_id)
user.delete()
db.session.commit()
user = User.query.get_or_404(user_id)
if not (user == current_user or current_user.is_administrator):
abort(403)
thread = Thread(
target=_delete_user,
args=(current_app._get_current_object(), user.id)
)
if user == current_user:
logout_user()
thread.start()
response_data = {
'message': f'User "{user.username}" marked for deletion'
}
return response_data, 202
@bp.route('/<hashid:user_id>/avatar', methods=['DELETE'])
@content_negotiation(produces='application/json')
def delete_user_avatar(user_id):
def _delete_avatar(app, avatar_id):
with app.app_context():
avatar = Avatar.query.get(avatar_id)
avatar.delete()
db.session.commit()
user = User.query.get_or_404(user_id)
if user.avatar is None:
abort(404)
if not (user == current_user or current_user.is_administrator):
abort(403)
thread = Thread(
target=_delete_avatar,
args=(current_app._get_current_object(), user.avatar.id)
)
thread.start()
response_data = {
'message': f'Avatar marked for deletion'
}
return response_data, 202
@bp.route('/accept-terms-of-use', methods=['POST'])
@content_negotiation(produces='application/json')
def accept_terms_of_use():
if not (current_user.is_authenticated or current_user.confirmed):
abort(403)
current_user.terms_of_use_accepted = True
db.session.commit()
response_data = {
'message': 'You accepted the terms of use',
}
return response_data, 202

View File

@ -1,25 +1,48 @@
from flask import (
abort,
redirect,
render_template,
current_app,
Flask,
jsonify,
redirect,
render_template,
request,
send_from_directory,
url_for
)
from flask_login import current_user
from app.models import User
from flask_login import current_user, login_required, logout_user
from threading import Thread
from app import db
from app.models import Avatar, User
from . import bp
@bp.route('')
def users():
@login_required
def index():
return redirect(url_for('main.social_area', _anchor='users'))
@bp.route('/<hashid:user_id>')
def user(user_id):
@login_required
def user(user_id: int):
user = User.query.get_or_404(user_id)
if not (user.is_public or user == current_user or current_user.is_administrator):
if not (
user.is_public
or user == current_user
or current_user.is_administrator
):
abort(403)
accept_json = request.accept_mimetypes.accept_json
accept_html = request.accept_mimetypes.accept_html
if accept_json and not accept_html:
return user.to_json_serializeable(
backrefs=True,
relationships=True
)
return render_template(
'users/user.html.j2',
title=user.username,
@ -27,13 +50,51 @@ def user(user_id):
)
@bp.route('/<hashid:user_id>/avatar')
def user_avatar(user_id):
def _delete_user(app: Flask, user_id: int):
with app.app_context():
user = User.query.get(user_id)
user.delete()
db.session.commit()
@bp.route('/<hashid:user_id>', methods=['DELETE'])
@login_required
def delete_user(user_id: int):
user = User.query.get_or_404(user_id)
if not (user.is_public or user == current_user or current_user.is_administrator):
if not (
user == current_user
or current_user.is_administrator
):
abort(403)
if user == current_user:
logout_user()
thread = Thread(
target=_delete_user,
args=(current_app._get_current_object(), user.id)
)
thread.start()
return jsonify(f'User "{user.username}" marked for deletion'), 202
@bp.route('/<hashid:user_id>/avatar')
@login_required
def user_avatar(user_id: int):
user = User.query.get_or_404(user_id)
if not (
user.is_public
or user == current_user
or current_user.is_administrator
):
abort(403)
if user.avatar is None:
return redirect(url_for('static', filename='images/user_avatar.png'))
return send_from_directory(
user.avatar.path.parent,
user.avatar.path.name,
@ -41,3 +102,33 @@ def user_avatar(user_id):
download_name=user.avatar.filename,
mimetype=user.avatar.mimetype
)
def _delete_avatar(app: Flask, avatar_id: int):
with app.app_context():
avatar = Avatar.query.get(avatar_id)
avatar.delete()
db.session.commit()
@bp.route('/<hashid:user_id>/avatar', methods=['DELETE'])
@login_required
def delete_user_avatar(user_id: int):
user = User.query.get_or_404(user_id)
if user.avatar is None:
abort(409)
if not (
user == current_user
or current_user.is_administrator
):
abort(403)
thread = Thread(
target=_delete_avatar,
args=(current_app._get_current_object(), user.avatar.id)
)
thread.start()
return jsonify('Avatar marked for deletion'), 202

View File

@ -1,2 +0,0 @@
from .. import bp
from . import json_routes, routes

View File

@ -1,49 +0,0 @@
from flask import abort, request
from flask_login import current_user
from app import db
from app.decorators import content_negotiation
from app.models import User, ProfilePrivacySettings
from . import bp
@bp.route('/<hashid:user_id>/settings/profile-privacy/is-public', methods=['PUT'])
@content_negotiation(consumes='application/json', produces='application/json')
def update_user_profile_privacy_setting_is_public(user_id):
user = User.query.get_or_404(user_id)
if not (user == current_user or current_user.is_administrator):
abort(403)
enabled = request.json
if not isinstance(enabled, bool):
abort(400)
user.is_public = enabled
db.session.commit()
response_data = {
'message': 'Profile privacy settings updated',
'category': 'settings'
}
return response_data, 200
@bp.route('/<hashid:user_id>/settings/profile-privacy/<string:profile_privacy_setting_name>', methods=['PUT'])
@content_negotiation(consumes='application/json', produces='application/json')
def update_user_profile_privacy_settings(user_id, profile_privacy_setting_name):
user = User.query.get_or_404(user_id)
try:
profile_privacy_setting = ProfilePrivacySettings[profile_privacy_setting_name]
except KeyError:
abort(404)
if not (user == current_user or current_user.is_administrator):
abort(403)
enabled = request.json
if not isinstance(enabled, bool):
abort(400)
if enabled:
user.add_profile_privacy_setting(profile_privacy_setting)
else:
user.remove_profile_privacy_setting(profile_privacy_setting)
db.session.commit()
response_data = {
'message': 'Profile privacy settings updated',
'category': 'settings'
}
return response_data, 200

View File

@ -1,93 +0,0 @@
from flask import abort, flash, g, redirect, render_template, url_for
from flask_login import current_user
from app import db
from app.models import Avatar, User
from . import bp
from .forms import (
UpdateAvatarForm,
UpdatePasswordForm,
UpdateNotificationsForm,
UpdateAccountInformationForm,
UpdateProfileInformationForm
)
@bp.route('/<hashid:user_id>/settings', methods=['GET', 'POST'])
def settings(user_id):
user = User.query.get_or_404(user_id)
if not (user == current_user or current_user.is_administrator):
abort(403)
redirect_location_on_post = g.pop(
'_nopaque_redirect_location_on_post',
url_for('.settings', user_id=user_id)
)
update_account_information_form = UpdateAccountInformationForm(user)
update_profile_information_form = UpdateProfileInformationForm(user)
update_avatar_form = UpdateAvatarForm()
update_password_form = UpdatePasswordForm(user)
update_notifications_form = UpdateNotificationsForm(user)
# region handle update profile information form
if update_profile_information_form.submit.data and update_profile_information_form.validate():
user.about_me = update_profile_information_form.about_me.data
user.location = update_profile_information_form.location.data
user.organization = update_profile_information_form.organization.data
user.website = update_profile_information_form.website.data
user.full_name = update_profile_information_form.full_name.data
db.session.commit()
flash('Your changes have been saved')
return redirect(redirect_location_on_post)
# endregion handle update profile information form
# region handle update avatar form
if update_avatar_form.submit.data and update_avatar_form.validate():
try:
Avatar.create(
update_avatar_form.avatar.data,
user=user
)
except (AttributeError, OSError):
abort(500)
db.session.commit()
flash('Your changes have been saved')
return redirect(redirect_location_on_post)
# endregion handle update avatar form
# region handle update account information form
if update_account_information_form.submit.data and update_account_information_form.validate():
user.email = update_account_information_form.email.data
user.username = update_account_information_form.username.data
db.session.commit()
flash('Profile settings updated')
return redirect(redirect_location_on_post)
# endregion handle update account information form
# region handle update password form
if update_password_form.submit.data and update_password_form.validate():
user.password = update_password_form.new_password.data
db.session.commit()
flash('Your changes have been saved')
return redirect(redirect_location_on_post)
# endregion handle update password form
# region handle update notifications form
if update_notifications_form.submit.data and update_notifications_form.validate():
user.setting_job_status_mail_notification_level = \
update_notifications_form.job_status_mail_notification_level.data
db.session.commit()
flash('Your changes have been saved')
return redirect(redirect_location_on_post)
# endregion handle update notifications form
return render_template(
'users/settings/settings.html.j2',
title='Settings',
update_account_information_form=update_account_information_form,
update_avatar_form=update_avatar_form,
update_notifications_form=update_notifications_form,
update_password_form=update_password_form,
update_profile_information_form=update_profile_information_form,
user=user
)

View File

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

View File

@ -0,0 +1,20 @@
from flask import abort
from flask_admin import (
AdminIndexView as _AdminIndexView,
expose
)
from flask_admin.contrib.sqla import ModelView as _ModelView
from flask_login import current_user
class AdminIndexView(_AdminIndexView):
@expose('/')
def index(self):
if not current_user.is_administrator:
abort(403)
return super().index()
class ModelView(_ModelView):
def is_accessible(self):
return current_user.is_administrator

View File

@ -1,2 +0,0 @@
from .types import ContainerColumn
from .types import IntEnumColumn

View File

@ -50,7 +50,7 @@ def _create_build_corpus_service(corpus: Corpus):
''' ## Constraints ## '''
constraints = ['node.role==worker']
''' ## Image ## '''
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1879'
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1887'
''' ## Labels ## '''
labels = {
'nopaque.server_name': current_app.config['SERVER_NAME']
@ -141,7 +141,7 @@ def _create_cqpserver_container(corpus: Corpus):
''' ## Entrypoint ## '''
entrypoint = ['bash', '-c']
''' ## Image ## '''
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1879'
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1887'
''' ## Name ## '''
name = f'nopaque-cqpserver-{corpus.id}'
''' ## Network ## '''

View File

@ -1,14 +1,45 @@
from .anonymous_user import *
from .avatar import *
from .corpus_file import *
from .corpus_follower_association import *
from .corpus_follower_role import *
from .corpus import *
from .job_input import *
from .job_result import *
from .job import *
from .role import *
from .spacy_nlp_pipeline_model import *
from .tesseract_ocr_pipeline_model import *
from .token import *
from .user import *
from .anonymous_user import AnonymousUser
from .avatar import Avatar
from .corpus_file import CorpusFile
from .corpus_follower_association import CorpusFollowerAssociation
from .corpus_follower_role import CorpusFollowerPermission, CorpusFollowerRole
from .corpus import CorpusStatus, Corpus
from .job_input import JobInput
from .job_result import JobResult
from .job import JobStatus, Job
from .role import Permission, Role
from .spacy_nlp_pipeline_model import SpaCyNLPPipelineModel
from .tesseract_ocr_pipeline_model import TesseractOCRPipelineModel
from .token import Token
from .user import (
ProfilePrivacySettings,
UserSettingJobStatusMailNotificationLevel,
User
)
_models = [
Avatar,
CorpusFile,
CorpusFollowerAssociation,
CorpusFollowerRole,
Corpus,
JobInput,
JobResult,
Job,
Role,
SpaCyNLPPipelineModel,
TesseractOCRPipelineModel,
Token,
User
]
_enums = [
CorpusFollowerPermission,
CorpusStatus,
JobStatus,
Permission,
ProfilePrivacySettings,
UserSettingJobStatusMailNotificationLevel
]

View File

@ -8,7 +8,7 @@ import shutil
import xml.etree.ElementTree as ET
from app import db
from app.converters.vrt import normalize_vrt_file
from app.extensions.nopaque_sqlalchemy_extras import IntEnumColumn
from app.extensions.nopaque_sqlalchemy_type_decorators import IntEnumColumn
from .corpus_follower_association import CorpusFollowerAssociation

View File

@ -43,7 +43,7 @@ def resource_after_delete(mapper, connection, resource):
}
]
room = f'/users/{resource.user_hashid}'
socketio.emit('patch_user', jsonpatch, namespace='/users', room=room)
socketio.emit('PATCH', jsonpatch, room=room)
def cfa_after_delete(mapper, connection, cfa):
@ -55,7 +55,7 @@ def cfa_after_delete(mapper, connection, cfa):
}
]
room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('patch_user', jsonpatch, namespace='/users', room=room)
socketio.emit('PATCH', jsonpatch, room=room)
def resource_after_insert(mapper, connection, resource):
@ -70,7 +70,7 @@ def resource_after_insert(mapper, connection, resource):
}
]
room = f'/users/{resource.user_hashid}'
socketio.emit('patch_user', jsonpatch, namespace='/users', room=room)
socketio.emit('PATCH', jsonpatch, room=room)
def cfa_after_insert(mapper, connection, cfa):
@ -84,7 +84,7 @@ def cfa_after_insert(mapper, connection, cfa):
}
]
room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('patch_user', jsonpatch, namespace='/users', room=room)
socketio.emit('PATCH', jsonpatch, room=room)
def resource_after_update(mapper, connection, resource):
@ -110,7 +110,7 @@ def resource_after_update(mapper, connection, resource):
)
if jsonpatch:
room = f'/users/{resource.user_hashid}'
socketio.emit('patch_user', jsonpatch, namespace='/users', room=room)
socketio.emit('PATCH', jsonpatch, room=room)
def job_after_update(mapper, connection, job):

View File

@ -6,7 +6,7 @@ from time import sleep
from pathlib import Path
import shutil
from app import db
from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn, IntEnumColumn
from app.extensions.nopaque_sqlalchemy_type_decorators import ContainerColumn, IntEnumColumn
class JobStatus(IntEnum):

View File

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

View File

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

View File

@ -5,7 +5,7 @@ from pathlib import Path
import requests
import yaml
from app import db
from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn
from app.extensions.nopaque_sqlalchemy_type_decorators import ContainerColumn
from .file_mixin import FileMixin
from .user import User

View File

@ -5,7 +5,7 @@ from pathlib import Path
import requests
import yaml
from app import db
from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn
from app.extensions.nopaque_sqlalchemy_type_decorators import ContainerColumn
from .file_mixin import FileMixin
from .user import User

View File

@ -11,7 +11,7 @@ import re
import secrets
import shutil
from app import db, hashids
from app.extensions.nopaque_sqlalchemy_extras import IntEnumColumn
from app.extensions.nopaque_sqlalchemy_type_decorators import IntEnumColumn
from .corpus import Corpus
from .corpus_follower_association import CorpusFollowerAssociation
from .corpus_follower_role import CorpusFollowerRole

View File

@ -9,8 +9,8 @@ from threading import Lock
from app import db, docker_client, hashids, socketio
from app.decorators import socketio_login_required
from app.models import Corpus, CorpusStatus
from . import cqi_extensions
from .utils import CQiOverSocketIOSessionManager
from . import cqi_extension_functions
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):
@socketio_login_required
def on_connect(self):
@ -135,30 +145,31 @@ class CQiOverSocketIONamespace(Namespace):
cqi_client = CQiClient(cqpserver_ip_address)
cqi_client_lock = Lock()
CQiOverSocketIOSessionManager.setup()
CQiOverSocketIOSessionManager.set_corpus_id(corpus_id)
CQiOverSocketIOSessionManager.set_cqi_client(cqi_client)
CQiOverSocketIOSessionManager.set_cqi_client_lock(cqi_client_lock)
SessionManager.setup()
SessionManager.set_corpus_id(corpus_id)
SessionManager.set_cqi_client(cqi_client)
SessionManager.set_cqi_client_lock(cqi_client_lock)
return {'code': 200, 'msg': 'OK'}
@socketio_login_required
def on_exec(self, fn_name: str, fn_args: dict = {}) -> dict:
try:
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client()
cqi_client_lock = CQiOverSocketIOSessionManager.get_cqi_client_lock()
cqi_client = SessionManager.get_cqi_client()
cqi_client_lock = SessionManager.get_cqi_client_lock()
except KeyError:
return {'code': 424, 'msg': 'Failed Dependency'}
if fn_name in CQI_API_FUNCTION_NAMES:
fn = getattr(cqi_client.api, fn_name)
elif fn_name in cqi_extensions.CQI_EXTENSION_FUNCTION_NAMES:
fn = getattr(cqi_extensions, fn_name)
elif fn_name in CQI_EXTENSION_FUNCTION_NAMES:
fn = getattr(cqi_extension_functions, fn_name)
else:
return {'code': 400, 'msg': 'Bad Request'}
for param in signature(fn).parameters.values():
# Check if the parameter is optional or required
# The following is true for required parameters
if param.default is param.empty:
if param.name not in fn_args:
return {'code': 400, 'msg': 'Bad Request'}
@ -198,10 +209,10 @@ class CQiOverSocketIONamespace(Namespace):
def on_disconnect(self):
try:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id()
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client()
cqi_client_lock = CQiOverSocketIOSessionManager.get_cqi_client_lock()
CQiOverSocketIOSessionManager.teardown()
corpus_id = SessionManager.get_corpus_id()
cqi_client = SessionManager.get_cqi_client()
cqi_client_lock = SessionManager.get_cqi_client_lock()
SessionManager.teardown()
except KeyError:
return

View File

@ -8,22 +8,12 @@ import json
import math
from app import db
from app.models import Corpus
from .utils import CQiOverSocketIOSessionManager
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',
]
from .utils import SessionManager
def ext_corpus_update_db(corpus: str) -> CQiStatusOk:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id()
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client()
corpus_id = SessionManager.get_corpus_id()
cqi_client = SessionManager.get_cqi_client()
db_corpus = Corpus.query.get(corpus_id)
cqi_corpus = cqi_client.corpora.get(corpus)
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:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id()
corpus_id = SessionManager.get_corpus_id()
db_corpus = Corpus.query.get(corpus_id)
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:
return f.read()
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client()
cqi_client = SessionManager.get_cqi_client()
cqi_corpus = cqi_client.corpora.get(corpus)
cqi_p_attrs = cqi_corpus.positional_attributes.list()
cqi_s_attrs = cqi_corpus.structural_attributes.list()
@ -168,7 +158,7 @@ def ext_corpus_paginate_corpus(
page: int = 1,
per_page: int = 20
) -> dict:
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client()
cqi_client = SessionManager.get_cqi_client()
cqi_corpus = cqi_client.corpora.get(corpus)
# Sanity checks
if (
@ -215,7 +205,7 @@ def ext_cqp_paginate_subcorpus(
per_page: int = 20
) -> dict:
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_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
# Sanity checks
@ -262,7 +252,7 @@ def ext_cqp_partial_export_subcorpus(
context: int = 50
) -> dict:
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_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
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:
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_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
cqi_subcorpus_export = _export_subcorpus(cqi_subcorpus, context=context)

View File

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

View File

@ -2,6 +2,10 @@
--corpus-status-content: "unprepared";
}
[data-corpus-status="SUBMITTED"] {
--corpus-status-content: "submitted";
}
[data-corpus-status="QUEUED"] {
--corpus-status-content: "queued";
}

47
app/static/css/height.css Normal file
View File

@ -0,0 +1,47 @@
.h-10 {
height: 10% !important;
}
.h-20 {
height: 20% !important;
}
.h-25 {
height: 25% !important;
}
.h-30 {
height: 30% !important;
}
.h-40 {
height: 40% !important;
}
.h-50 {
height: 50% !important;
}
.h-60 {
height: 60% !important;
}
.h-70 {
height: 70% !important;
}
.h-75 {
height: 75% !important;
}
.h-80 {
height: 80% !important;
}
.h-90 {
height: 90% !important;
}
.h-100 {
height: 100% !important;
}

47
app/static/css/width.css Normal file
View File

@ -0,0 +1,47 @@
.w-10 {
width: 10% !important;
}
.w-20 {
width: 20% !important;
}
.w-25 {
width: 25% !important;
}
.w-30 {
width: 30% !important;
}
.w-40 {
width: 40% !important;
}
.w-50 {
width: 50% !important;
}
.w-60 {
width: 60% !important;
}
.w-70 {
width: 70% !important;
}
.w-75 {
width: 75% !important;
}
.w-80 {
width: 80% !important;
}
.w-90 {
width: 90% !important;
}
.w-100 {
width: 100% !important;
}

View File

@ -0,0 +1,24 @@
nopaque.app.Client = class Client {
constructor() {
this.socket = io({transports: ['websocket'], upgrade: false});
// Endpoints
this.corpora = new nopaque.app.endpoints.Corpora(this);
this.jobs = new nopaque.app.endpoints.Jobs(this);
this.main = new nopaque.app.endpoints.Main(this);
this.settings = new nopaque.app.endpoints.Settings(this);
this.users = new nopaque.app.endpoints.Users(this);
// Extensions
this.toaster = new nopaque.app.extensions.Toaster(this);
this.ui = new nopaque.app.extensions.UI(this);
this.userHub = new nopaque.app.extensions.UserHub(this);
}
init() {
// Initialize extensions
this.toaster.init();
this.ui.init();
this.userHub.init();
}
};

View File

@ -0,0 +1,93 @@
nopaque.app.endpoints.Corpora = class Corpora {
constructor(app) {
this.app = app;
this.socket = io('/corpora', {transports: ['websocket'], upgrade: false});
}
async delete(corpusId) {
const options = {
headers: {
Accept: 'application/json'
},
method: 'DELETE'
};
const response = await fetch(`/corpora/${corpusId}`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async build(corpusId) {
const options = {
headers: {
Accept: 'application/json'
},
method: 'POST'
};
const response = await fetch(`/corpora/${corpusId}/build`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async getStopwords(corpusId) {
const options = {
headers: {
Accept: 'application/json'
}
};
const response = await fetch(`/corpora/${corpusId}/analysis/stopwords`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async createShareLink(corpusId, expirationDate, roleName) {
const options = {
body: JSON.stringify({
'expiration_date': expirationDate,
'role_name': roleName
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'POST'
};
const response = await fetch(`/corpora/${corpusId}/create-share-link`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async updateIsPublic(corpusId, newValue) {
const options = {
body: JSON.stringify(newValue),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'PUT',
};
const response = await fetch(`/corpora/${corpusId}/is-public`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
}

View File

@ -0,0 +1 @@
nopaque.app.endpoints = {};

View File

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

View File

@ -0,0 +1,22 @@
nopaque.app.endpoints.Main = class Main {
constructor(app) {
this.app = app;
}
async acceptTermsOfUse() {
const options = {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'POST',
};
const response = await fetch(`/accept-terms-of-use`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
}

View File

@ -0,0 +1,77 @@
nopaque.app.endpoints.Settings = class Settings {
constructor(app) {
this.app = app;
}
async updateProfileIsPublic(newValue) {
const options = {
body: JSON.stringify(newValue),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'PUT',
};
const response = await fetch(`/settings/profile-is-public`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async updateProfileShowEmail(newValue) {
const options = {
body: JSON.stringify(newValue),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'PUT',
};
const response = await fetch(`/settings/profile-show-email`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async updateProfileShowLastSeen(newValue) {
const options = {
body: JSON.stringify(newValue),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'PUT',
};
const response = await fetch(`/settings/profile-show-last-seen`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async updateProfileShowMemberSince(newValue) {
const options = {
body: JSON.stringify(newValue),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'PUT',
};
const response = await fetch(`/settings/profile-show-member-since`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
}

View File

@ -0,0 +1,52 @@
nopaque.app.endpoints.Users = class Users {
constructor(app) {
this.app = app;
}
async get(userId) {
const options = {
headers: {
Accept: 'application/json'
}
};
const response = await fetch(`/users/${userId}`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
async subscribe(userId) {
const response = await this.app.socket.emitWithAck('SUBSCRIBE User', userId);
if (response.code != 204) {
throw new Error(`${response.name}: ${response.description}`);
}
}
async unsubscribe(userId) {
const response = await this.app.socket.emitWithAck('UNSUBSCRIBE User', userId);
if (response.status != 204) {
throw new Error(`${response.name}: ${response.description}`);
}
}
async delete(userId) {
const options = {
headers: {
Accept: 'application/json'
},
method: 'DELETE'
};
const response = await fetch(`/users/${userId}`, options);
const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
}
}

View File

@ -0,0 +1 @@
nopaque.app.extensions = {};

View File

@ -0,0 +1,56 @@
nopaque.app.extensions.Toaster = class Toaster {
constructor(app) {
this.app = app;
}
init() {
this.app.userHub.addEventListener('patch', (event) => {this.#onPatch(event.detail);});
}
async #onPatch(patch) {
// Handle corpus updates
const corpusRegExp = new RegExp(`^/users/([A-Za-z0-9]+)/corpora/([A-Za-z0-9]+)`);
const corpusPatch = patch.filter((operation) => {return corpusRegExp.test(operation.path);});
this.#onCorpusPatch(corpusPatch);
// Handle job updates
const jobRegExp = new RegExp(`^/users/([A-Za-z0-9]+)/jobs/([A-Za-z0-9]+)`);
const jobPatch = patch.filter((operation) => {return jobRegExp.test(operation.path);});
this.#onJobPatch(jobPatch);
}
async #onCorpusPatch(patch) {
return;
// Handle corpus status updates
const corpusStatusRegExp = new RegExp(`^/users/([A-Za-z0-9]+)/corpora/([A-Za-z0-9]+)/status$`);
const corpusStatusPatch = patch
.filter((operation) => {return corpusStatusRegExp.test(operation.path);})
.filter((operation) => {return operation.op === 'replace';});
for (let operation of corpusStatusPatch) {
const [match, userId, corpusId] = operation.path.match(corpusStatusRegExp);
const user = await this.app.userHub.get(userId);
const corpus = user.corpora[corpusId];
this.app.ui.flash(`[<a href="/corpora/${corpusId}">${corpus.title}</a>] New status: <span class="corpus-status-text" data-corpus-status="${operation.value}"></span>`, 'corpus');
}
}
async #onJobPatch(patch) {
// Handle job status updates
const jobStatusRegExp = new RegExp(`^/users/([A-Za-z0-9]+)/jobs/([A-Za-z0-9]+)/status$`);
const jobStatusPatch = patch
.filter((operation) => {return jobStatusRegExp.test(operation.path);})
.filter((operation) => {return operation.op === 'replace';});
for (let operation of jobStatusPatch) {
const [match, userId, jobId] = operation.path.match(jobStatusRegExp);
const user = await this.app.userHub.get(userId);
const job = user.jobs[jobId];
this.app.ui.flash(`[<a href="/jobs/${jobId}">${job.title}</a>] New status: <span class="job-status-text" data-job-status="${operation.value}"></span>`, 'job');
}
}
}

View File

@ -1,120 +1,9 @@
nopaque.App = class App {
#promises;
constructor() {
this.data = {
users: {}
};
this.#promises = {
getUser: {},
subscribeUser: {}
};
this.socket = io({transports: ['websocket'], upgrade: false});
this.socket.on('patch_user', (patch) => {this.onPatch(patch);});
}
getUser(userId) {
if (userId in this.#promises.getUser) {
return this.#promises.getUser[userId];
}
this.#promises.getUser[userId] = new Promise((resolve, reject) => {
this.socket.emit('users.get_user', userId, (response) => {
if (response.status === 200) {
this.data.users[userId] = response.body;
resolve(this.data.users[userId]);
} else {
reject(`[${response.status}] ${response.statusText}`);
}
});
});
return this.#promises.getUser[userId];
}
subscribeUser(userId) {
if (userId in this.#promises.subscribeUser) {
return this.#promises.subscribeUser[userId];
}
this.#promises.subscribeUser[userId] = new Promise((resolve, reject) => {
this.socket.emit('users.subscribe_user', userId, (response) => {
if (response.status === 200) {
resolve(response);
} else {
reject(response);
}
});
});
return this.#promises.subscribeUser[userId];
}
flash(message, category) {
let iconPrefix = '';
switch (category) {
case 'corpus': {
iconPrefix = '<i class="left material-icons">book</i>';
break;
}
case 'error': {
iconPrefix = '<i class="error-color-text left material-icons">error</i>';
break;
}
case 'job': {
iconPrefix = '<i class="left nopaque-icons">J</i>';
break;
}
case 'settings': {
iconPrefix = '<i class="left material-icons">settings</i>';
break;
}
default: {
iconPrefix = '<i class="left material-icons">notifications</i>';
break;
}
}
let toast = M.toast(
{
html: `
<span>${iconPrefix}${message}</span>
<button class="action-button btn-flat toast-action white-text" data-action="close">
<i class="material-icons">close</i>
</button>
`.trim()
}
);
let toastCloseActionElement = toast.el.querySelector('.action-button[data-action="close"]');
toastCloseActionElement.addEventListener('click', () => {toast.dismiss();});
}
onPatch(patch) {
// Filter Patch to only include operations on users that are initialized
let regExp = new RegExp(`^/users/(${Object.keys(this.data.users).join('|')})`);
let filteredPatch = patch.filter(operation => regExp.test(operation.path));
// Handle job status updates
let subRegExp = new RegExp(`^/users/([A-Za-z0-9]*)/jobs/([A-Za-z0-9]*)/status$`);
let subFilteredPatch = filteredPatch
.filter((operation) => {return operation.op === 'replace';})
.filter((operation) => {return subRegExp.test(operation.path);});
for (let operation of subFilteredPatch) {
let [match, userId, jobId] = operation.path.match(subRegExp);
this.flash(`[<a href="/jobs/${jobId}">${this.data.users[userId].jobs[jobId].title}</a>] New status: <span class="job-status-text" data-job-status="${operation.value}"></span>`, 'job');
}
// Apply Patch
jsonpatch.applyPatch(this.data, filteredPatch);
nopaque.app.extensions.UI = class UI {
constructor(app) {
this.app = app;
}
init() {
this.initUi();
}
initUi() {
/* Pre-Initialization fixes */
// #region
@ -182,10 +71,7 @@ nopaque.App = class App {
M.Modal.init(
document.querySelector('#terms-of-use-modal'),
{
dismissible: false,
onCloseEnd: (modalElement) => {
nopaque.requests.users.entity.acceptTermsOfUse();
}
dismissible: false
}
);
// #endregion
@ -198,4 +84,40 @@ nopaque.App = class App {
nopaque.forms.AutoInit();
// #endregion
}
};
flash(message, category) {
let iconPrefix;
switch (category) {
case 'corpus': {
iconPrefix = '<i class="material-icons left">book</i>';
break;
}
case 'job': {
iconPrefix = '<i class="nopaque-icons left">J</i>';
break;
}
case 'error': {
iconPrefix = '<i class="material-icons left error-color-text">error</i>';
break;
}
default: {
iconPrefix = '<i class="material-icons left">notifications</i>';
break;
}
}
let toast = M.toast(
{
html: `
<span>${iconPrefix}${message}</span>
<button class="btn-flat toast-action white-text" data-toast-action="dismiss">
<i class="material-icons">close</i>
</button>
`.trim()
}
);
let dismissToastElement = toast.el.querySelector('.toast-action[data-toast-action="dismiss"]');
dismissToastElement.addEventListener('click', () => {toast.dismiss();});
}
}

View File

@ -0,0 +1,68 @@
nopaque.app.extensions.UserHub = class UserHub extends EventTarget {
#data;
constructor(app) {
super();
this.app = app;
this.#data = {
users: {},
promises: {}
};
}
init() {
this.app.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
const filterRegExp = new RegExp(`^/users/(${Object.keys(this.#data.users).join('|')})`);
const filteredPatch = patch.filter(operation => filterRegExp.test(operation.path));
// Apply patch
jsonpatch.applyPatch(this.#data, filteredPatch);
// Notify event listeners
const patchEventa = new CustomEvent('patch', {detail: filteredPatch});
this.dispatchEvent(patchEventa);
// Notify event listeners. Event type: "patch *"
const patchEvent = new CustomEvent('patch *', {detail: filteredPatch});
this.dispatchEvent(patchEvent);
// Group patches by user id: {<user-id>: [op, ...], ...}
const patches = {};
const matchRegExp = new RegExp(`^/users/([A-Za-z0-9]+)`);
for (let operation of filteredPatch) {
const [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)) {
const userPatchEvent = new CustomEvent(`patch ${userId}`, {detail: patch});
this.dispatchEvent(userPatchEvent);
}
}
}

View File

@ -0,0 +1 @@
nopaque.app = {};

View File

@ -66,7 +66,7 @@ nopaque.corpus_analysis.ConcordanceExtension = class ConcordanceExtension {
errorString += `${error.constructor.name}`;
this.elements.error.innerText = errorString;
this.elements.error.classList.remove('hide');
app.flash(errorString, 'error');
app.ui.flash(errorString, 'error');
this.elements.progress.classList.add('hide');
}
this.app.enableActionElements();
@ -239,7 +239,7 @@ nopaque.corpus_analysis.ConcordanceExtension = class ConcordanceExtension {
if (subcorpus.selectedItems.size === 0) {
this.elements.progress.classList.add('hide');
this.app.enableActionElements();
app.flash('No matches selected', 'error');
app.ui.flash('No matches selected', 'error');
return;
}
promise = subcorpus.o.partialExport([...subcorpus.selectedItems], 50);
@ -298,7 +298,7 @@ nopaque.corpus_analysis.ConcordanceExtension = class ConcordanceExtension {
let subcorpus = this.data.subcorpora[this.settings.selectedSubcorpus];
subcorpus.o.drop().then(
(cQiStatus) => {
app.flash(`${subcorpus.o.name} deleted`, 'corpus');
app.ui.flash(`${subcorpus.o.name} deleted`, 'corpus');
delete this.data.subcorpora[subcorpus.o.name];
this.settings.selectedSubcorpus = undefined;
for (let subcorpusName in this.data.subcorpora) {
@ -320,7 +320,7 @@ nopaque.corpus_analysis.ConcordanceExtension = class ConcordanceExtension {
},
(cqiError) => {
let errorString = `${cqiError.code}: ${cqiError.constructor.name}`;
app.flash(errorString, 'error');
app.ui.flash(errorString, 'error');
}
);
});

View File

@ -46,7 +46,7 @@ nopaque.corpus_analysis.ReaderExtension = class ReaderExtension {
if ('description' in error) {errorString += `: ${error.description}`;}
this.elements.error.innerText = errorString;
this.elements.error.classList.remove('hide');
app.flash(errorString, 'error');
app.ui.flash(errorString, 'error');
this.elements.progress.classList.add('hide');
}
this.app.enableActionElements();
@ -205,7 +205,7 @@ nopaque.corpus_analysis.ReaderExtension = class ReaderExtension {
`
);
this.elements.corpusPagination.appendChild(pageElement);
for (let paginateTriggerElement of this.elements.corpusPagination.querySelectorAll('.pagination-trigger[data-target]')) {
paginateTriggerElement.addEventListener('click', (event) => {
event.preventDefault();

View File

@ -7,7 +7,6 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
stopwords: undefined,
originalStopwords: {},
stopwordCache: {},
promises: {getStopwords: undefined},
tokenSet: new Set()
};
@ -73,22 +72,11 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
}
}
getStopwords() {
this.data.promises.getStopwords = new Promise((resolve, reject) => {
nopaque.requests.corpora.entity.getStopwords()
.then((response) => {
response.json()
.then((json) => {
this.data.originalStopwords = structuredClone(json);
this.data.stopwords = structuredClone(json);
resolve(this.data.stopwords);
})
.catch((error) => {
reject(error);
});
});
});
return this.data.promises.getStopwords;
async getStopwords() {
const stopwords = await app.corpora.getStopwords(this.app.corpusId);
this.data.originalStopwords = structuredClone(stopwords);
this.data.stopwords = structuredClone(stopwords);
return stopwords;
}
renderGeneralCorpusInfo() {
@ -121,7 +109,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
num_unique_pos: Object.entries(corpusData.s_attrs.text.lexicon[i].freqs.pos).length,
num_unique_simple_pos: Object.entries(corpusData.s_attrs.text.lexicon[i].freqs.simple_pos).length
};
textData.push(resource);
}
@ -262,7 +250,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
}
return filteredData;
}
renderFrequenciesGraphic(tokenSet) {
this.data.tokenSet = tokenSet;
@ -272,7 +260,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
let texts = Object.entries(corpusData.s_attrs.text.lexicon);
let graphtype = document.querySelector('.frequencies-graph-mode-button.disabled').dataset.graphType;
let tokenCategory = frequenciesTokenCategoryDropdownElement.firstChild.textContent.toLowerCase();
let graphData = this.createFrequenciesGraphData(tokenCategory, texts, graphtype, tokenSet);
let graphLayout = {
barmode: graphtype === 'bar' ? 'stack' : '',
@ -328,7 +316,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
for (let i = 0; i < texts.length; i++) {
tokenCountPerText[i] = (tokenCountPerText[i] || 0) + (texts[i][1].freqs[tokenCategory][originalId] || 0);
}
}
}
let data = {
x: textTitles,
y: tokenCountPerText,
@ -353,7 +341,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
stopwordLanguageChipList.innerHTML = '';
userStopwordListContainer.innerHTML = '';
stopwordInputField.value = '';
// Render stopword language selection. Set english as default language. Filter out user_stopwords.
if (stopwordLanguageSelection.children.length === 0) {
Object.keys(stopwords).forEach(language => {
@ -379,7 +367,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
// Render english stopwords as default ...
let selectedLanguage = document.querySelector('#stopword-language-selection').value;
this.renderStopwordLanguageChipList(selectedLanguage, stopwords[selectedLanguage]);
// ... or render selected language stopwords.
stopwordLanguageSelection.addEventListener('change', (event) => {
this.renderStopwordLanguageChipList(event.target.value, stopwords[event.target.value]);
@ -402,7 +390,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
// Initialize Materialize components.
M.Chips.init(
stopwordInputField,
stopwordInputField,
{
placeholder: 'Add stopwords',
onChipAdd: (event) => {
@ -428,7 +416,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
deleteLanguageStopwordListEntriesButton.classList.toggle('disabled', stopwordLength === 0);
resetLanguageStopwordListEntriesButton.classList.toggle('disabled', stopwordLength === originalStopwordListLength);
}
renderStopwordLanguageChipList(language, stopwords) {
let stopwordLanguageChipList = document.querySelector('#stopword-language-chip-list');
stopwordLanguageChipList.innerHTML = '';

View File

@ -101,7 +101,7 @@ nopaque.forms.BaseForm = class BaseForm {
}
}
if (request.status === 500) {
app.flash('Internal Server Error', 'error');
app.ui.flash('Internal Server Error', 'error');
}
modal.close();
});

View File

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

View File

@ -18,23 +18,23 @@ nopaque.requests.JSONfetch = (input, init={}) => {
}
if (response.status === 204) {
return;
}
}
response.json()
.then(
(json) => {
let message = json.message;
let category = json.category || 'message';
if (message) {
app.flash(message, category);
app.ui.flash(message, category);
}
},
(error) => {
app.flash(`[${response.status}]: ${response.statusText}`, 'error');
app.ui.flash(`[${response.status}]: ${response.statusText}`, 'error');
}
);
},
(response) => {
app.flash('Something went wrong', 'error');
app.ui.flash('Something went wrong', 'error');
reject(response);
}
);

View File

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

View File

@ -7,7 +7,7 @@ nopaque.resource_displays.CorpusDisplay = class CorpusDisplay extends nopaque.re
this.displayElement
.querySelector('.action-button[data-action="build-request"]')
.addEventListener('click', (event) => {
nopaque.requests.corpora.entity.build(this.corpusId);
app.corpora.build(this.corpusId);
});
}
@ -52,22 +52,23 @@ nopaque.resource_displays.CorpusDisplay = class CorpusDisplay extends nopaque.re
}
}
setTitle(title) {
this.setElements(this.displayElement.querySelectorAll('.corpus-title'), title);
async setTitle(title) {
const corpusTitleElements = this.displayElement.querySelectorAll('.corpus-title');
this.setElements(corpusTitleElements, title);
}
setNumTokens(numTokens) {
this.setElements(
this.displayElement.querySelectorAll('.corpus-token-ratio'),
`${numTokens}/${app.data.users[this.userId].corpora[this.corpusId].max_num_tokens}`
);
const corpusTokenRatioElements = this.displayElement.querySelectorAll('.corpus-token-ratio');
const maxNumTokens = 2147483647;
this.setElements(corpusTokenRatioElements, `${numTokens}/${maxNumTokens}`);
}
setDescription(description) {
this.setElements(this.displayElement.querySelectorAll('.corpus-description'), description);
}
setStatus(status) {
async setStatus(status) {
let elements = this.displayElement.querySelectorAll('.action-button[data-action="analyze"]');
for (let element of elements) {
if (['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) {
@ -77,8 +78,10 @@ nopaque.resource_displays.CorpusDisplay = class CorpusDisplay extends nopaque.re
}
}
elements = this.displayElement.querySelectorAll('.action-button[data-action="build-request"]');
const user = await app.userHub.get(this.userId);
const corpusFiles = user.corpora[this.corpusId].files;
for (let element of elements) {
if (['UNPREPARED', 'FAILED'].includes(status) && Object.values(app.data.users[this.userId].corpora[this.corpusId].files).length > 0) {
if (['UNPREPARED', 'FAILED'].includes(status) && Object.values(corpusFiles.length > 0)) {
element.classList.remove('disabled');
} else {
element.classList.add('disabled');

View File

@ -5,19 +5,14 @@ nopaque.resource_displays.ResourceDisplay = class ResourceDisplay {
this.displayElement = displayElement;
this.userId = this.displayElement.dataset.userId;
this.isInitialized = false;
if (this.userId) {
app.subscribeUser(this.userId)
.then((response) => {
app.socket.on('patch_user', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId)
.then((user) => {
this.init(user);
this.isInitialized = true;
});
}
if (this.userId === undefined) {return;}
app.userHub.addEventListener('patch', (event) => {
if (this.isInitialized) {this.onPatch(event.detail);}
});
app.userHub.get(this.userId).then((user) => {
this.init(user);
this.isInitialized = true;
});
}
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.hasPermissionManageFiles = listContainerElement.dataset?.hasPermissionManageFiles == 'true' || false;
if (this.userId === undefined || this.corpusId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => {
app.socket.on('patch_user', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
app.userHub.addEventListener('patch', (event) => {
if (this.isInitialized) {this.onPatch(event.detail);}
});
app.getUser(this.userId).then((user) => {
app.userHub.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.isInitialized = true;
});

View File

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

View File

@ -11,12 +11,10 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
this.selectedItemIds = new Set();
this.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => {
app.socket.on('patch_user', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
app.userHub.addEventListener('patch', (event) => {
if (this.isInitialized) {this.onPatch(event.detail);}
});
app.getUser(this.userId).then((user) => {
app.userHub.get(this.userId).then((user) => {
this.add(this.aggregateData(user));
this.isInitialized = true;
});
@ -69,6 +67,7 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
<span class="disable-on-click"></span>
</label>
</td>
<td><a class="btn-floating service-color darken" data-service="corpus-analysis"><i class="material-icons">book</i></a></td>
<td>
<b class="title"></b><br>
<i class="description"></i>
@ -80,7 +79,7 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
<td>${values['current-user-is-following'] ? '<span><i class="left material-icons">visibility</i>Following</span>' : ''}</td>
<td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating darken waves-effect waves-light" data-list-action="view"><i class="material-icons">send</i></a>
<a class="list-action-trigger btn-floating waves-effect waves-light" data-list-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
@ -119,6 +118,7 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
<span></span>
</label>
</th>
<th></th>
<th>Title and Description</th>
<th>Owner</th>
<th>Status</th>
@ -180,7 +180,7 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
window.location.reload();
});
} else {
nopaque.requests.corpora.entity.delete(itemId);
app.corpora.delete(itemId);
}
});
modal.open();
@ -276,7 +276,7 @@ nopaque.resource_lists.CorpusList = class CorpusList extends nopaque.resource_li
let listItem = this.listjs.get('id', selectedItemId)[0].elm;
let values = this.listjs.get('id', listItem.dataset.id)[0].values();
if (values['is-owner']) {
nopaque.requests.corpora.entity.delete(selectedItemId);
app.corpora.delete(selectedItemId);
} else {
nopaque.requests.corpora.entity.followers.entity.delete(selectedItemId, currentUserId);
setTimeout(() => {

View File

@ -8,8 +8,10 @@ nopaque.resource_lists.JobInputList = class JobInputList extends nopaque.resourc
this.userId = listContainerElement.dataset.userId;
this.jobId = listContainerElement.dataset.jobId;
if (this.userId === undefined || this.jobId === undefined) {return;}
app.subscribeUser(this.userId);
app.getUser(this.userId).then((user) => {
// app.userHub.addEventListener('patch', (event) => {
// if (this.isInitialized) {this.onPatch(event.detail);}
// });
app.userHub.get(this.userId).then((user) => {
this.add(Object.values(user.jobs[this.jobId].inputs));
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.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => {
app.socket.on('patch_user', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
app.userHub.addEventListener('patch', (event) => {
if (this.isInitialized) {this.onPatch(event.detail);}
});
app.getUser(this.userId).then((user) => {
app.userHub.get(this.userId).then((user) => {
this.add(Object.values(user.jobs));
this.isInitialized = true;
});
@ -138,8 +136,9 @@ nopaque.resource_lists.JobList = class JobList extends nopaque.resource_lists.Re
}
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
nopaque.requests.jobs.entity.delete(itemId);
confirmElement.addEventListener('click', async (event) => {
const message = await app.jobs.delete(itemId);
app.ui.flash(message, 'job');
});
modal.open();
break;
@ -223,8 +222,9 @@ nopaque.resource_lists.JobList = class JobList extends nopaque.resource_lists.Re
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
this.selectedItemIds.forEach(selectedItemId => {
nopaque.requests.jobs.entity.delete(selectedItemId);
this.selectedItemIds.forEach(async (selectedItemId) => {
const message = await app.jobs.delete(selectedItemId);
app.ui.flash(message, 'job');
});
this.selectedItemIds.clear();
this.renderingItemSelection();

View File

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

View File

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

View File

@ -8,21 +8,11 @@ nopaque.resource_lists.TesseractOCRPipelineModelList = class TesseractOCRPipelin
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => {
app.socket.on('patch_user', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
app.userHub.addEventListener('patch', (event) => {
if (this.isInitialized) {this.onPatch(event.detail);}
});
app.getUser(this.userId).then((user) => {
app.userHub.get(this.userId).then((user) => {
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;
});
}

View File

@ -0,0 +1,79 @@
{% if current_user.is_authenticated %}
<ul class="dropdown-content" id="navbar-data-processing-and-analysis-dropdown-content">
<li {% if request.path == url_for('services.file_setup_pipeline') %}class="active"{% endif %}>
<a href="{{ url_for('services.file_setup_pipeline') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="file-setup-pipeline"></i>
File Setup Pipeline
</a>
</li>
<li {% if request.path == url_for('services.tesseract_ocr_pipeline') %}class="active"{% endif %}>
<a href="{{ url_for('services.tesseract_ocr_pipeline') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="tesseract-ocr-pipeline"></i>
Tesseract OCR Pipeline
</a>
</li>
{% if config.NOPAQUE_TRANSKRIBUS_ENABLED %}
<li {% if request.path == url_for('services.transkribus_htr_pipeline') %}class="active"{% endif %}>
<a href="{{ url_for('services.transkribus_htr_pipeline') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="transkribus-htr-pipeline"></i>
Transkribus HTR Pipeline
</a>
</li>
{% endif %}
<li {% if request.path == url_for('services.spacy_nlp_pipeline') %}class="active"{% endif %}>
<a href="{{ url_for('services.spacy_nlp_pipeline') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="spacy-nlp-pipeline"></i>
SpaCy NLP Pipeline
</a>
</li>
<li class="divider" tabindex="-1"></li>
<li {% if request.path == url_for('services.corpus_analysis') %}class="active"{% endif %}>
<a href="{{ url_for('services.corpus_analysis') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="corpus-analysis"></i>
Corpus Analyis
</a>
</li>
</ul>
{% endif %}
{% if current_user.is_authenticated %}
<ul class="dropdown-content" id="navbar-account-dropdown-content">
<li {% if request.path == url_for('users.user', user_id=current_user.id) %}class="active"{% endif %}>
<a href="{{ url_for('users.user', user_id=current_user.id) }}">
<i class="material-icons">person</i>
Your profile
</a>
</li>
<li {% if request.path == url_for('settings.index') %}class="active"{% endif %}>
<a href="{{ url_for('settings.index') }}">
<i class="material-icons">settings</i>
Settings
</a>
</li>
<li>
<a href="{{ url_for('auth.logout') }}">
<i class="material-icons">logout</i>
Log out
</a>
</li>
{% if current_user.can('USE_API') or current_user.is_administrator %}
<li class="divider" tabindex="-1"></li>
{% endif %}
{% if current_user.can('USE_API') %}
<li>
<a href="{{ url_for('apifairy.docs') }}">
<i class="material-icons left">api</i>
API
</a>
</li>
{% endif %}
{% if current_user.is_administrator %}
<li>
<a href="{{ url_for('admin.index') }}">
<i class="material-icons left">admin_panel_settings</i>
Administration
</a>
</li>
{% endif %}
</ul>
{% endif %}

View File

@ -0,0 +1,45 @@
<div class="container">
<div class="row">
<div class="col s12 l3">
<h5 class="white-text">Legal Notice</h5>
<ul>
<li><a class="grey-text text-lighten-3" href="https://www.uni-bielefeld.de/(en)/impressum/">Legal Notice</a></li>
<li><a class="grey-text text-lighten-3" href="{{ url_for('main.privacy_policy') }}">Privacy statement (GDPR)</a></li>
<li><a class="grey-text text-lighten-3" href="{{ url_for('main.terms_of_use') }}">Terms of use</a></li>
</ul>
</div>
<div class="col s12 l3">
<h5 class="white-text">More Resources</h5>
<ul>
<li><a class="grey-text text-lighten-3" href="{{ url_for('main.faq') }}">Frequently asked questions</a></li>
<li><a class="grey-text text-lighten-3" href="mailto:{{ config.NOPAQUE_SERVICE_DESK }}">Report an issue</a></li>
<li><a class="grey-text text-lighten-3" href="https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque">GitLab (source code)</a></li>
</ul>
</div>
<div class="col s12 l4">
<h5 class="white-text">Who made this?</h5>
<p class="grey-text text-lighten-4">
This software is developed by the SFB 1288 INF project at Bielefeld University.
Thanks to all the people who made nopaque possible.
<span class="red-text">&hearts;</span>
</p>
</div>
<div class="col s12 l2">
<br class="hide-on-med-and-down">
<br class="hide-on-med-and-down">
<a href="https://www.dfg.de/">
<img class="responsive-img" src="{{ url_for('static', filename='images/logo_-_dfg.gif') }}">
</a>
</div>
</div>
</div>
<div class="footer-copyright">
<div class="container">
© 2024 Bielefeld University
<a class="grey-text text-lighten-4 right" href="https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque/-/releases/{{ config.NOPAQUE_VERSION }}">Version {{ config.NOPAQUE_VERSION }}</a>
</div>
</div>

View File

@ -0,0 +1 @@
<link href="{{ url_for('static', filename='images/nopaque_-_favicon.png') }}" rel="icon">

View File

@ -0,0 +1,2 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@ -0,0 +1,28 @@
<div class="modal modal-fixed-footer" id="terms-of-use-modal">
<div class="modal-content">
<div class="card-panel primary-color white-text">
<h4 class="m-3 center-align">Terms of use</h4>
</div>
<ul class="tabs tabs-fixed-width z-depth-1">
<li class="tab"><a class="active" href="#terms-of-use-modal-content-german">German</a></li>
<li class="tab"><a href="#terms-of-use-modal-content-english">English</a></li>
</ul>
<div id="terms-of-use-modal-content-german">
{% include 'main/_terms_of_use/german.html.j2' %}
</div>
<div id="terms-of-use-modal-content-english">
{% include 'main/_terms_of_use/english.html.j2' %}
</div>
</div>
<div class="modal-footer">
{% if current_user.is_authenticated and not current_user.terms_of_use_accepted %}
<a href="#!" class="btn waves-effect waves-light modal-close" id="terms-of-use-modal-accept-button">Accept</a>
{% else %}
<a href="#!" class="btn-flat waves-effect waves-light modal-close">Close</a>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,105 @@
<div class="navbar-fixed">
<nav>
<div class="nav-wrapper">
{# menu icon #}
{# small/medium devices #}
<a href="#!" class="sidenav-trigger" data-target="sidenav">
<i class="material-icons">menu</i>
</a>
{% if current_user.is_authenticated %}
{# nopaque logo #}
{# large devices #}
<a href="{{ url_for('main.index') }}" class="brand-logo hide-on-med-and-down h-100">
<img src="{{ url_for('static', filename='images/nopaque_-_logo.png') }}" alt="" class="mx-3 py-3 h-100">
</a>
{# left aligned navigation items #}
{# large devices #}
<ul class="hide-on-med-and-down" style="margin-left: calc(57px + 1.5rem);">
{# dashboard #}
<li {% if request.path == url_for('main.dashboard') %}class="active"{% endif %}>
<a href="{{ url_for('main.dashboard') }}">
<i class="material-icons left">dashboard</i>
Dashboard
</a>
</li>
{# data processing & analysis #}
<li>
<a href="#!" class="dropdown-trigger no-autoinit" data-target="navbar-data-processing-and-analysis-dropdown-content" id="navbar-data-processing-and-analysis-dropdown-trigger">
<i class="material-icons left">miscellaneous_services</i>
Data Processing & Analysis
</a>
</li>
{# contributions #}
<li {% if request.path == url_for('contributions.index') %}class="active"{% endif %}>
<a href="{{ url_for('contributions.index') }}">
<i class="material-icons left">new_label</i>
Contributions
</a>
</li>
{# social #}
<li {% if request.path == url_for('main.social') %}class="active"{% endif %}>
<a href="{{ url_for('main.social') }}">
<i class="material-icons left">groups</i>
Social
</a>
</li>
</ul>
{% else %}
{# nopaque logo+wordmark+slogan #}
{# large devices #}
<a href="{{ url_for('main.index') }}" class="brand-logo hide-on-med-and-down h-100">
<img src="{{ url_for('static', filename='images/nopaque_-_logo+wordmark+slogan.png') }}" alt="" class="mx-3 py-3 h-100">
</a>
{% endif %}
{# nopaque logo+wordmark #}
{# small/medium devices #}
<a href="{{ url_for('main.index') }}" class="brand-logo center hide-on-large-only h-100">
<img src="{{ url_for('static', filename='images/nopaque_-_logo+wordmark.png') }}" alt="" class="py-3 h-100">
</a>
{# right aligned navigation items #}
{# large devices #}
<ul class="right hide-on-med-and-down h-100">
{# manual #}
<li class="tooltipped {% if request.path == url_for('main.manual') %}active{% endif %}" data-position="bottom" data-tooltip="Manual">
<a href="{{ url_for('main.manual') }}">
<i class="material-icons">help_outline</i>
</a>
</li>
{# news #}
<li class="tooltipped {% if request.path == url_for('main.news') %}active{% endif %}" data-position="bottom" data-tooltip="News">
<a href="{{ url_for('main.news') }}">
<i class="material-icons">newspaper</i>
</a>
</li>
{% if current_user.is_authenticated %}
{# avatar #}
<li class="h-100">
<a href="#!" class="dropdown-trigger no-autoinit h-100" data-target="navbar-account-dropdown-content" id="navbar-account-dropdown-trigger">
<span class="mr-3">{{ current_user.username }}</span>
<img src="{{ url_for('users.user_avatar', user_id=current_user.id) }}" alt="" class="circle py-3 h-100 right">
</a>
</li>
{% else %}
{# log in #}
<li {% if request.path == url_for('auth.login') %}class="active"{% endif %}>
<a href="{{ url_for('auth.login') }}">Log in</a>
</li>
{# register #}
<li {% if request.path == url_for('auth.register') %}class="active"{% endif %}>
<a href="{{ url_for('auth.register') }}" class="btn waves-effect waves-light primary-color lighten">Register</a>
</li>
{% endif %}
</ul>
</div>
</nav>
</div>

View File

@ -0,0 +1,131 @@
<script src="{{ url_for('static', filename='external/materialize/js/materialize.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/JSON-Patch/js/fast-json-patch.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/list.js/js/list.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/pako/js/pako_inflate.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/plotly.js/js/plotly.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/socket.io/js/socket.io.min.js') }}"></script>
{% assets
filters='rjsmin',
output='gen/nopaque.%(version)s.js',
'js/index.js',
'js/app/index.js',
'js/app/client.js',
'js/app/endpoints/index.js',
'js/app/endpoints/corpora.js',
'js/app/endpoints/jobs.js',
'js/app/endpoints/main.js',
'js/app/endpoints/settings.js',
'js/app/endpoints/users.js',
'js/app/extensions/index.js',
'js/app/extensions/toaster.js',
'js/app/extensions/ui.js',
'js/app/extensions/user-hub.js',
'js/utils.js',
'js/forms/index.js',
'js/forms/base-form.js',
'js/forms/create-contribution-form.js',
'js/forms/create-corpus-file-form.js',
'js/forms/create-job-form.js',
'js/resource-displays/index.js',
'js/resource-displays/resource-display.js',
'js/resource-displays/corpus-display.js',
'js/resource-displays/job-display.js',
'js/resource-lists/index.js',
'js/resource-lists/resource-list.js',
'js/resource-lists/admin-user-list.js',
'js/resource-lists/corpus-file-list.js',
'js/resource-lists/corpus-follower-list.js',
'js/resource-lists/corpus-list.js',
'js/resource-lists/corpus-text-info-list.js',
'js/resource-lists/corpus-token-list.js',
'js/resource-lists/detailed-public-corpus-list.js',
'js/resource-lists/job-input-list.js',
'js/resource-lists/job-list.js',
'js/resource-lists/job-result-list.js',
'js/resource-lists/public-corpus-list.js',
'js/resource-lists/public-user-list.js',
'js/resource-lists/spacy-nlp-pipeline-model-list.js',
'js/resource-lists/tesseract-ocr-pipeline-model-list.js',
'js/requests/index.js',
'js/requests/admin.js',
'js/requests/contributions.js',
'js/requests/corpora.js',
'js/requests/users.js',
'js/corpus-analysis/index.js',
'js/corpus-analysis/cqi/index.js',
'js/corpus-analysis/cqi/constants.js',
'js/corpus-analysis/cqi/errors.js',
'js/corpus-analysis/cqi/status.js',
'js/corpus-analysis/cqi/api/index.js',
'js/corpus-analysis/cqi/api/client.js',
'js/corpus-analysis/cqi/models/index.js',
'js/corpus-analysis/cqi/models/resource.js',
'js/corpus-analysis/cqi/models/attributes.js',
'js/corpus-analysis/cqi/models/subcorpora.js',
'js/corpus-analysis/cqi/models/corpora.js',
'js/corpus-analysis/cqi/client.js',
'js/corpus-analysis/query-builder/index.js',
'js/corpus-analysis/query-builder/element-references.js',
'js/corpus-analysis/query-builder/query-builder.js',
'js/corpus-analysis/query-builder/structural-attribute-builder-functions.js',
'js/corpus-analysis/query-builder/token-attribute-builder-functions.js',
'js/corpus-analysis/app.js',
'js/corpus-analysis/concordance-extension.js',
'js/corpus-analysis/reader-extension.js',
'js/corpus-analysis/static-visualization-extension.js'
-%}
<script src="{{ ASSET_URL }}"></script>
{% endassets -%}
{# TODO: Think about implementing the following inside a main.js(.j2) #}
<script>
var app;
var currentUserId;
async function main() {
app = new nopaque.app.Client();
app.init();
{% if not current_user.is_authenticated %}
const currentUserId = null;
{% else %}
currentUserId = {{ current_user.hashid|tojson }};
try {
await app.userHub.add(currentUserId);
} catch (error) {
app.ui.flash('Failed to load user data.', 'error');
}
{% if not current_user.terms_of_use_accepted %}
const termsOfUseAcceptButtonElement = document.querySelector('#terms-of-use-modal-accept-button');
termsOfUseAcceptButtonElement.addEventListener('click', async () => {
try {
await app.main.acceptTermsOfUse();
app.ui.flash('Terms of use accepted.');
} catch (error) {
app.ui.flash('Failed to accept terms of use.', 'error');
}
});
const termsOfUseModalElement = document.querySelector('#terms-of-use-modal');
const termsOfUseModal = M.Modal.getInstance(termsOfUseModalElement);
termsOfUseModal.open();
{% endif %}
{% endif %}
const flashedMessages = {{ get_flashed_messages(with_categories=true)|tojson }};
for (let [category, message] of flashedMessages) {
app.ui.flash(message, category);
}
}
main();
</script>

View File

@ -0,0 +1,125 @@
<ul class="sidenav" id="sidenav">
{% if current_user.is_authenticated %}
{# user view #}
<li>
<div class="user-view">
<div class="background primary-color"></div>
<a><img class="circle" src="{{ url_for('users.user_avatar', user_id=current_user.id) }}" alt=""></a>
<a><span class="white-text name">{{ current_user.username }}</span></a>
<a><span class="white-text email">{{ current_user.email }}</span></a>
</div>
</li>
{% endif %}
{% if current_user.is_authenticated %}
{# dashboard #}
<li {% if request.path == url_for('main.dashboard') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('main.dashboard') }}"><i class="material-icons">dashboard</i>Dashboard</a>
</li>
{# contributions #}
<li {% if request.path == url_for('contributions.index') %}class="active"{% endif %}>
<a href="{{ url_for('contributions.index') }}">
<i class="material-icons left">new_label</i>
Contributions
</a>
</li>
{# social #}
<li {% if request.path == url_for('main.social') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('main.social') }}"><i class="material-icons">groups</i>Social</a>
</li>
{% endif %}
{# news #}
<li {% if request.path == url_for('main.news') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('main.news') }}"><i class="material-icons">newspaper</i>News</a>
</li>
{# manual #}
<li {% if request.path == url_for('main.manual') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('main.manual') }}"><i class="material-icons">help_outline</i>Manual</a>
</li>
{% if current_user.is_authenticated %}
{# data processing & analysis section #}
<li><div class="divider"></div></li>
<li><a class="subheader">Data Processing & Analysis</a></li>
{# file setup pipeline #}
<li class="service-color service-color-border border-darken" data-service="file-setup-pipeline" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.file_setup_pipeline') }}"><i class="nopaque-icons service-icons" data-service="file-setup-pipeline"></i>File setup</a>
</li>
{# tesseract ocr pipeline #}
<li class="service-color service-color-border border-darken mt-1" data-service="tesseract-ocr-pipeline" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.tesseract_ocr_pipeline') }}"><i class="nopaque-icons service-icons" data-service="tesseract-ocr-pipeline"></i>OCR</a>
</li>
{% if config.NOPAQUE_TRANSKRIBUS_ENABLED %}
{# transkribus htr pipeline #}
<li class="service-color service-color-border border-darken mt-1" data-service="transkribus-htr-pipeline" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.transkribus_htr_pipeline') }}"><i class="nopaque-icons service-icons" data-service="transkribus-htr-pipeline"></i>HTR</a>
</li>
{% endif %}
{# spacy nlp pipeline #}
<li class="service-color service-color-border border-darken mt-1" data-service="spacy-nlp-pipeline" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.spacy_nlp_pipeline') }}"><i class="nopaque-icons service-icons" data-service="spacy-nlp-pipeline"></i>NLP</a>
</li>
{# corpus analysis #}
<li class="service-color service-color-border border-darken mt-1" data-service="corpus-analysis" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icons" data-service="corpus-analysis"></i>Corpus Analysis</a>
</li>
{% endif %}
{# account section #}
<li><div class="divider"></div></li>
<li><a class="subheader">Account</a></li>
{% if current_user.is_authenticated %}
{# my profile #}
<li {% if request.path == url_for('users.user', user_id=current_user.id) %}class="active"{% endif %}>
<a href="{{ url_for('users.user', user_id=current_user.id) }}"><i class="material-icons">person</i>My Profile</a>
</li>
{# settings #}
<li {% if request.path == url_for('settings.index') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('settings.index') }}"><i class="material-icons">settings</i>Settings</a>
</li>
{# log out #}
<li>
<a class="waves-effect" href="{{ url_for('auth.logout') }}"><i class="material-icons">logout</i>Log out</a>
</li>
{% else %}
{# log in #}
<li {% if request.path == url_for('auth.login') %}class="active"{% endif %}>
<a href="{{ url_for('auth.login') }}">Log in</a>
</li>
{# register #}
<li {% if request.path == url_for('auth.register') %}class="active"{% endif %}>
<a href="{{ url_for('auth.register') }}" class="btn waves-effect waves-light">Register</a>
</li>
{% endif %}
{% if current_user.can('USE_API') or current_user.is_administrator %}
<li><div class="divider"></div></li>
{% endif %}
{% if current_user.can('USE_API') %}
{# API #}
<li>
<a class="waves-effect" href="{{ url_for('apifairy.docs') }}"><i class="material-icons">api</i>API</a>
</li>
{% endif %}
{% if current_user.is_administrator %}
{# Administration #}
<li>
<a class="waves-effect" href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a>
</li>
{% endif %}
</ul>

View File

@ -0,0 +1,23 @@
<link href="{{ url_for('static', filename='external/material-design-icons/css/material-icons.css') }}" rel="stylesheet">
{% assets
output='gen/nopaque.%(version)s.css',
'css/materialize.css',
'css/materialize.override.css',
'css/nopaque-icons.css',
'css/theme-colors.css',
'css/corpus-status-colors.css',
'css/corpus-status-text.css',
'css/height.css',
'css/job-status-colors.css',
'css/job-status-text.css',
'css/service-colors.css',
'css/pagination.css',
'css/service-icons.css',
'css/s-attr-colors.css',
'css/spacing.css',
'css/status-spinner.css',
'css/utils.css',
'css/width.css'
%}
<link href="{{ ASSET_URL }}" rel="stylesheet">
{% endassets %}

View File

@ -1,43 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<div class="row">
<div class="col s12">
<h1 id="title">{{ title }}</h1>
</div>
<div class="col s12 l4">
<div class="card hoverable">
<a href="{{ url_for('.users') }}" style="position: absolute; width: 100%; height: 100%;"></a>
<div class="card-content">
<span class="card-title"><i class="material-icons left">group</i>Users</span>
<p>Edit the individual user accounts. You have the following options:</p>
<ul>
<li>- View, edit and delete user accounts</li>
<li>- View, edit and delete user corpora</li>
<li>- View, edit and delete user jobs</li>
<li>- View, edit and delete user added Tesseract models</li>
<li>- View, edit and delete user added SpaCy models</li>
</ul>
</div>
</div>
</div>
<div class="col s12 l4">
<div class="card hoverable">
<a href="{{ url_for('.corpora') }}" style="position: absolute; width: 100%; height: 100%;"></a>
<div class="card-content">
<span class="card-title"><i class="nopaque-icons left">I</i>Corpora</span>
<p>Edit all Corpora. You have the following options:</p>
<ul>
<li>- View, edit and delete corpora</li>
<li>- View, edit and delete corpus jobs</li>
<li>- Edit corpus follower roles and the public status of the corpus</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock page_content %}

View File

@ -1,29 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<h1 id="title">{{ title }}</h1>
<div class="card">
<div class="card-content">
<div class="corpus-list no-autoinit" id="corpus-list"></div>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block scripts %}
{{ super() }}
<script>
let corpusListElement = document.querySelector('#corpus-list');
let corpusList = new nopaque.resource_lists.CorpusList(corpusListElement);
corpusList.add(
[
{% for corpus in corpora %}
{{ corpus.to_json_serializeable(backrefs=True,relationships=True)|tojson }},
{% endfor %}
]
);
</script>
{% endblock scripts %}

View File

@ -1,104 +0,0 @@
{% extends "base.html.j2" %}
{% import "wtf.html.j2" as wtf %}
{% block page_content %}
<div class="container">
<div class="row">
<div class="col s12">
<h1 id="title">{{ title }}</h1>
</div>
<div class="col s12">
<form method="POST">
{{ edit_profile_settings_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">General settings</span>
{{ wtf.render_field(edit_profile_settings_form.username, material_icon='person') }}
{{ wtf.render_field(edit_profile_settings_form.email, material_icon='email') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_profile_settings_form.submit, material_icon='send') }}
</div>
</div>
</form>
</div>
</form>
<form method="POST">
{{ edit_notification_settings_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">Notification settings</span>
{{ wtf.render_field(edit_notification_settings_form.job_status_mail_notification_level, material_icon='notifications') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_notification_settings_form.submit, material_icon='send') }}
</div>
</div>
</div>
</form>
<form method="POST">
{{ admin_edit_user_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">Administrator settings</span>
{{ wtf.render_field(admin_edit_user_form.role, material_icon='swap_vert') }}
<div class="row">
<div class="col s12"><p>&nbsp;</p></div>
<div class="col s1">
<p><i class="material-icons">check</i></p>
</div>
<div class="col s8">
<p>{{ admin_edit_user_form.confirmed.label.text }}</p>
<p class="light">Change confirmation status manually.</p>
</div>
<div class="col s3 right-align">
<div class="switch">
<label>
{{ admin_edit_user_form.confirmed() }}
<span class="lever"></span>
</label>
</div>
</div>
</div>
</div>
<div class="card-action right-align">
{{ wtf.render_field(admin_edit_user_form.submit, material_icon='send') }}
</div>
</div>
</form>
<div class="card">
<div class="card-content">
<span class="card-title">Delete account</span>
<p>Deleting an account has the following effects:</p>
<ul>
<li>All data associated with your corpora and jobs will be permanently deleted.</li>
<li>All settings will be permanently deleted.</li>
</ul>
</div>
<div class="card-action right-align">
<a href="#delete-account-modal" class="btn modal-trigger red waves-effect waves-light"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block modals %}
{{ super() }}
<div class="modal" id="delete-account-modal">
<div class="modal-content">
<h4>Confirm deletion</h4>
<p>Do you really want to delete your account and all associated data? All associated corpora, jobs and files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a href="{{ url_for('.delete_user', user_id=user.id) }}" class="btn red waves-effect waves-light"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
{% endblock modals %}

View File

@ -1,131 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<div class="row">
<div class="col s12 l2">
<p>&nbsp;</p>
{# <img src="{{ url_for('users.user_avatar', user_id=user.id) }}" alt="user-image" class="circle responsive-img"> #}
</div>
<div class="col s12 l10">
<h1 id="title">{{ title }}</h1>
<p>
<span class="chip hoverable tooltipped no-autoinit" id="user-role-chip">{{ user.role.name }}</span>
{% if user.confirmed %}
<span class="chip white-text" id="user-confirmed-chip" style="background-color: #4caf50;">confirmed</span>
{% else %}
<span class="chip white-text" id="user-confirmed-chip" style="background-color: #f44336;">unconfirmed</span>
{% endif %}
</p>
{% if user.about_me %}
<p>{{ user.about_me }}</p>
{% endif %}
</div>
<div class="col s12 hide-on-med-and-down">&nbsp;</div>
<div class="col s12">
<ul class="tabs tabs-fixed-width z-depth-1">
<li class="tab"><a href="#user-info">User info</a></li>
<li class="tab"><a href="#user-corpora">Corpora</a></li>
<li class="tab"><a href="#user-jobs">Jobs</a></li>
<li class="tab"><a href="#user-tesseract-ocr-pipeline-models">Tesseract OCR Pipeline Models</a></li>
<li class="tab"><a href="#user-spacy-nlp-pipeline-models">SpaCy NLP Pipeline Models</a></li>
</ul>
</div>
<div class="col s12" id="user-info">
<div class="card">
<div class="card-content">
<ul>
<li>Username: {{ user.username }}</li>
<li>Email: {{ user.email }}</li>
<li>Id: {{ user.id }}</li>
<li>Hashid: {{ user.hashid }}</li>
<li>Member since: {{ user.member_since.strftime('%Y-%m-%d') }}</li>
<li>Last seen: {% if user.last_seen %}{{ user.last_seen.strftime('%Y-%m-%d') }}</li>{% endif %}
</ul>
</div>
<div class="card-action right-align">
<a class="btn waves-effect waves-light" href="{{ url_for('.user_settings', user_id=user.id) }}"><i class="material-icons left">edit</i>Edit</a>
<a class="btn red modal-trigger waves-effect waves-light" data-target="delete-user-modal"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
</div>
<div class="col s12" id="user-corpora">
<div class="card">
<div class="card-content">
<div class="corpus-list" data-user-id="{{ user.hashid }}"></div>
</div>
</div>
</div>
<div class="col s12" id="user-jobs">
<div class="card">
<div class="card-content">
<div class="job-list" data-user-id="{{ user.hashid }}"></div>
</div>
</div>
</div>
<div class="col s12" id="user-spacy-nlp-pipeline-models">
<div class="card">
<div class="card-content">
<div class="spacy-nlp-pipeline-model-list" data-user-id="{{ user.hashid }}"></div>
</div>
</div>
</div>
<div class="col s12" id="user-tesseract-ocr-pipeline-models">
<div class="card">
<div class="card-content">
<div class="tesseract-ocr-pipeline-model-list" data-user-id="{{ user.hashid }}"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block modals %}
{{ super() }}
<div id="delete-user-modal" class="modal">
<div class="modal-content">
<h3>Delete user</h3>
<p>Do you really want to delete the user {{ user.username }}? All associated data will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn red modal-close waves-effect waves-light"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
{% endblock modals %}
{% block scripts %}
{{ super() }}
<script>
let userRoleChip = document.querySelector('#user-role-chip');
let userRoleChipTooltip = M.Tooltip.init(
userRoleChip,
{
html: `
<p>Permissions</p>
<p class="left-align">
{% for permission in ['ADMINISTRATE', 'CONTRIBUTE', 'USE_API'] %}
<label>
<input class="filled-in" type="checkbox" {{ 'checked' if user.can(permission) }}>
<span>{{ permission|capitalize }}</span>
</label>
{% if not loop.last %}
<br>
{% endif %}
{% endfor %}
</p>
`.trim()
}
);
</script>
{% endblock scripts %}

View File

@ -1,66 +0,0 @@
{% extends "users/settings/settings.html.j2" %}
{% block admin_settings %}
<div class="col s12"></div>
<div class="col s12 l4">
<h4>Administrator Settings</h4>
<p>Here the Confirmation Status of the user can be set manually and a special role can be assigned.</p>
</div>
<div class="col s12 l8">
<br>
<ul class="collapsible no-autoinit settings-collapsible">
<li>
<div class="collapsible-header" style="justify-content: space-between;">
<span>Confirmation status</span>
<i class="caret material-icons">keyboard_arrow_right</i>
</div>
<div class="collapsible-body">
<div style="overflow: auto;">
<p class="left"><i class="material-icons">check</i></p>
<p class="left" style="margin-left: 10px;">
Confirmed<br>
<span class="light">Change confirmation status manually.</span>
</p>
<br class="hide-on-med-and-down">
<div class="switch right">
<label>
<input {% if user.confirmed %}checked{% endif %} id="user-confirmed-switch" type="checkbox">
<span class="lever"></span>
</label>
</div>
</div>
</div>
</li>
<li>
<div class="collapsible-header" style="justify-content: space-between;">
<span>Role</span>
<i class="caret material-icons">keyboard_arrow_right</i>
</div>
<div class="collapsible-body">
<form method="POST">
{{ update_user_form.hidden_tag() }}
{{ wtf.render_field(update_user_form.role, material_icon='manage_accounts') }}
<div class="right-align">
{{ wtf.render_field(update_user_form.submit, material_icon='send') }}
</div>
</form>
</div>
</li>
</ul>
</div>
{% endblock admin_settings %}
{% block scripts %}
{{ super() }}
<script>
let userConfirmedSwitchElement = document.querySelector('#user-confirmed-switch');
userConfirmedSwitchElement.addEventListener('change', (event) => {
let newConfirmed = userConfirmedSwitchElement.checked;
nopaque.requests.admin.users.entity.confirmed.update({{ user.hashid|tojson }}, newConfirmed)
.catch((response) => {
userConfirmedSwitchElement.checked = !userConfirmedSwitchElement;
});
});
</script>
{% endblock scripts %}

View File

@ -1,34 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<div class="row">
<div class="col s12">
<h1 id="title">{{ title }}</h1>
</div>
<div class="col s12">
<div class="card">
<div class="card-content">
<div class="admin-user-list no-autoinit" id="admin-user-list"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block scripts %}
{{ super() }}
<script>
let adminUserListElement = document.querySelector('#admin-user-list');
let adminUserList = new nopaque.resource_lists.AdminUserList(adminUserListElement);
adminUserList.add(
[
{% for user in users %}
{{ user.to_json_serializeable(backrefs=True)|tojson }},
{% endfor %}
]
);
</script>
{% endblock scripts %}

View File

@ -1,544 +1,75 @@
{% if title is not defined %}
{% set title = 'nopaque' %}
{% endif %}
{% block doc %}
<!DOCTYPE html>
<html lang="en">
<head>
<!-- #region meta -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- #endregion meta -->
<html {% block html_attributes %}lang="en"{% endblock html_attributes %}>
{% block html %}
<head {% block head_attributes %}{% endblock head_attributes %}>
{% block head %}
{% block metas %}
{% include '_base/metas.html.j2' %}
{% endblock metas %}
<title>{{ title if title is defined else 'nopaque' }}</title>
<title {% block title_attribs %}{% endblock title_attribs %}>
{% block title %}
{{ title }}
{% endblock title %}
</title>
<link href="{{ url_for('static', filename='images/nopaque_-_favicon.png') }}" rel="icon">
{% block icons %}
{% include '_base/icons.html.j2' %}
{% endblock icons %}
<!-- #region stylesheets -->
{% block stylesheets %}
<link href="{{ url_for('static', filename='external/material-design-icons/css/material-icons.css') }}" rel="stylesheet">
{% assets
output='gen/nopaque.%(version)s.css',
'css/materialize.css',
'css/materialize.override.css',
'css/nopaque-icons.css',
'css/theme-colors.css',
'css/corpus-status-colors.css',
'css/corpus-status-text.css',
'css/job-status-colors.css',
'css/job-status-text.css',
'css/service-colors.css',
'css/pagination.css',
'css/service-icons.css',
'css/s-attr-colors.css',
'css/spacing.css',
'css/status-spinner.css',
'css/utils.css'
-%}
<link href="{{ ASSET_URL }}" rel="stylesheet">
{% endassets -%}
{% endblock stylesheets %}
<!-- #endregion stylesheets -->
{% block styles %}
{% include '_base/stylesheets.html.j2' %}
{% endblock styles %}
{% endblock head %}
</head>
<body>
<header>
<div class="navbar-fixed">
<nav>
<div class="nav-wrapper">
{# menu icon #}
{# small/medium devices #}
<a href="#!" class="sidenav-trigger" data-target="sidenav"><i class="material-icons">menu</i></a>
<body {% block body_attributes %}{% endblock body_attributes %}>
{% block body %}
<header {% block header_attributes %}{% endblock header_attributes %}>
{% block header %}
{% block navbar %}
{% include '_base/navbar.html.j2' %}
{% endblock navbar %}
{% if current_user.is_authenticated %}
{# nopaque logo #}
{# large devices #}
<a href="{{ url_for('main.index') }}" class="brand-logo hide-on-med-and-down" style="height: 100%;">
<img src="{{ url_for('static', filename='images/nopaque_-_logo.png') }}" alt="" class="mx-3 py-3" style="height: 100%;">
</a>
{# left aligned navigation items #}
{# large devices #}
<ul class="hide-on-med-and-down" style="margin-left: calc(57px + 1.5rem);">
{# dashboard #}
<li {% if request.path == url_for('main.dashboard') %}class="active"{% endif %}>
<a href="{{ url_for('main.dashboard') }}">
<i class="material-icons left">dashboard</i>
Dashboard
</a>
</li>
{# data processing & analysis #}
<li>
<a href="#!" class="dropdown-trigger no-autoinit" data-target="navbar-data-processing-and-analysis-dropdown-content" id="navbar-data-processing-and-analysis-dropdown-trigger">
<i class="material-icons left">miscellaneous_services</i>
Data Processing & Analysis
</a>
</li>
{# contributions #}
<li {% if request.path == url_for('contributions.index') %}class="active"{% endif %}>
<a href="{{ url_for('contributions.index') }}">
<i class="material-icons left">new_label</i>
Contributions
</a>
</li>
{# social #}
<li {% if request.path == url_for('main.social') %}class="active"{% endif %}>
<a href="{{ url_for('main.social') }}">
<i class="material-icons left">groups</i>
Social
</a>
</li>
</ul>
{% else %}
{# nopaque logo+wordmark+slogan #}
{# large devices #}
<a href="{{ url_for('main.index') }}" class="brand-logo hide-on-med-and-down" style="height: 100%;">
<img src="{{ url_for('static', filename='images/nopaque_-_logo+wordmark+slogan.png') }}" alt="" class="mx-3 py-3" style="height: 100%;">
</a>
{% endif %}
{# nopaque logo+wordmark #}
{# small/medium devices #}
<a href="{{ url_for('main.index') }}" class="brand-logo center hide-on-large-only" style="height: 100%;">
<img src="{{ url_for('static', filename='images/nopaque_-_logo+wordmark.png') }}" alt="" class="py-3" style="height: 100%;">
</a>
{# right aligned navigation items #}
{# large devices #}
<ul class="right hide-on-med-and-down" style="height: 64px;">
{# manual #}
<li class="tooltipped {% if request.path == url_for('main.manual') %}active{% endif %}" data-position="bottom" data-tooltip="Manual">
<a href="{{ url_for('main.manual') }}">
<i class="material-icons">help_outline</i>
</a>
</li>
{# news #}
<li class="tooltipped {% if request.path == url_for('main.news') %}active{% endif %}" data-position="bottom" data-tooltip="News">
<a href="{{ url_for('main.news') }}">
<i class="material-icons">newspaper</i>
</a>
</li>
{% if current_user.is_authenticated %}
{# avatar #}
<li style="height: 100%;">
<a href="#!" class="dropdown-trigger no-autoinit" data-target="navbar-account-dropdown-content" id="navbar-account-dropdown-trigger" style="height: 100%;">
<span class="mr-3">{{ current_user.username }}</span>
<img src="{{ url_for('users.user_avatar', user_id=current_user.id) }}" alt="" class="circle py-3 right" style="height: 100%;">
</a>
</li>
{% else %}
{# log in #}
<li {% if request.path == url_for('auth.login') %}class="active"{% endif %}>
<a href="{{ url_for('auth.login') }}">Log in</a>
</li>
{# register #}
<li {% if request.path == url_for('auth.register') %}class="active"{% endif %}>
<a href="{{ url_for('auth.register') }}" class="btn waves-effect waves-light primary-color lighten">Register</a>
</li>
{% endif %}
</ul>
</div>
</nav>
</div>
<ul class="sidenav" id="sidenav">
{% if current_user.is_authenticated %}
{# user view #}
<li>
<div class="user-view">
<div class="background primary-color"></div>
<a><img class="circle" src="{{ url_for('users.user_avatar', user_id=current_user.id) }}" alt=""></a>
<a><span class="white-text name">{{ current_user.username }}</span></a>
<a><span class="white-text email">{{ current_user.email }}</span></a>
</div>
</li>
{% endif %}
{% if current_user.is_authenticated %}
{# dashboard #}
<li {% if request.path == url_for('main.dashboard') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('main.dashboard') }}"><i class="material-icons">dashboard</i>Dashboard</a>
</li>
{# contributions #}
<li {% if request.path == url_for('contributions.index') %}class="active"{% endif %}>
<a href="{{ url_for('contributions.index') }}">
<i class="material-icons left">new_label</i>
Contributions
</a>
</li>
{# social #}
<li {% if request.path == url_for('main.social') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('main.social') }}"><i class="material-icons">groups</i>Social</a>
</li>
{% endif %}
{# news #}
<li {% if request.path == url_for('main.news') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('main.news') }}"><i class="material-icons">newspaper</i>News</a>
</li>
{# manual #}
<li {% if request.path == url_for('main.manual') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('main.manual') }}"><i class="material-icons">help_outline</i>Manual</a>
</li>
{% if current_user.is_authenticated %}
{# data processing & analysis section #}
<li><div class="divider"></div></li>
<li><a class="subheader">Data Processing & Analysis</a></li>
{# file setup pipeline #}
<li class="service-color service-color-border border-darken" data-service="file-setup-pipeline" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.file_setup_pipeline') }}"><i class="nopaque-icons service-icons" data-service="file-setup-pipeline"></i>File setup</a>
</li>
{# tesseract ocr pipeline #}
<li class="service-color service-color-border border-darken mt-1" data-service="tesseract-ocr-pipeline" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.tesseract_ocr_pipeline') }}"><i class="nopaque-icons service-icons" data-service="tesseract-ocr-pipeline"></i>OCR</a>
</li>
{% if config.NOPAQUE_TRANSKRIBUS_ENABLED %}
{# transkribus htr pipeline #}
<li class="service-color service-color-border border-darken mt-1" data-service="transkribus-htr-pipeline" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.transkribus_htr_pipeline') }}"><i class="nopaque-icons service-icons" data-service="transkribus-htr-pipeline"></i>HTR</a>
</li>
{% endif %}
{# spacy nlp pipeline #}
<li class="service-color service-color-border border-darken mt-1" data-service="spacy-nlp-pipeline" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.spacy_nlp_pipeline') }}"><i class="nopaque-icons service-icons" data-service="spacy-nlp-pipeline"></i>NLP</a>
</li>
{# corpus analysis #}
<li class="service-color service-color-border border-darken mt-1" data-service="corpus-analysis" style="border-left: 10px solid;">
<a class="waves-effect" href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icons" data-service="corpus-analysis"></i>Corpus Analysis</a>
</li>
{% endif %}
{# account section #}
<li><div class="divider"></div></li>
<li><a class="subheader">Account</a></li>
{% if current_user.is_authenticated %}
{# my profile #}
<li {% if request.path == url_for('users.user', user_id=current_user.id) %}class="active"{% endif %}>
<a href="{{ url_for('users.user', user_id=current_user.id) }}"><i class="material-icons">person</i>My Profile</a>
</li>
{# settings #}
<li {% if request.path == url_for('settings.settings') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>Settings</a>
</li>
{# log out #}
<li>
<a class="waves-effect" href="{{ url_for('auth.logout') }}"><i class="material-icons">logout</i>Log out</a>
</li>
{% else %}
{# log in #}
<li {% if request.path == url_for('auth.login') %}class="active"{% endif %}>
<a href="{{ url_for('auth.login') }}">Log in</a>
</li>
{# register #}
<li {% if request.path == url_for('auth.register') %}class="active"{% endif %}>
<a href="{{ url_for('auth.register') }}" class="btn waves-effect waves-light">Register</a>
</li>
{% endif %}
{% if current_user.is_authenticated and current_user.can('ADMINISTRATE') %}
{# administration section #}
<li><div class="divider"></div></li>
<li><a class="subheader">Administration</a></li>
{# corpora #}
<li>
<a class="waves-effect" href="{{ url_for('admin.corpora') }}"><i class="nopaque-icons">I</i>Corpora</a>
</li>
{# users #}
<li>
<a class="waves-effect" href="{{ url_for('admin.users') }}"><i class="material-icons">manage_accounts</i>Users</a>
</li>
{% endif %}
</ul>
{% block sidenav %}
{% include '_base/sidenav.html.j2' %}
{% endblock sidenav %}
{% endblock header %}
</header>
<main {% block main_attribs %}{% endblock main_attribs %}>
<main {% block main_attributes %}{% endblock main_attributes %}>
{% block main %}
{% block page_content %}{% endblock page_content %}
{% endblock main %}
</main>
<footer class="page-footer">
<div class="container">
<div class="row">
<div class="col s12 l3">
<h5 class="white-text">Legal Notice</h5>
<ul>
<li><a class="grey-text text-lighten-3" href="https://www.uni-bielefeld.de/(en)/impressum/">Legal Notice</a></li>
<li><a class="grey-text text-lighten-3" href="{{ url_for('main.privacy_policy') }}">Privacy statement (GDPR)</a></li>
<li><a class="grey-text text-lighten-3" href="{{ url_for('main.terms_of_use') }}">Terms of use</a></li>
</ul>
</div>
<div class="col s12 l3">
<h5 class="white-text">More Resources</h5>
<ul>
<li><a class="grey-text text-lighten-3" href="{{ url_for('main.faq') }}">Frequently asked questions</a></li>
<li><a class="grey-text text-lighten-3" href="mailto:{{ config.NOPAQUE_SERVICE_DESK }}">Report an issue</a></li>
<li><a class="grey-text text-lighten-3" href="https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque">GitLab (source code)</a></li>
</ul>
</div>
<div class="col s12 l4">
<h5 class="white-text">Who made this?</h5>
<p class="grey-text text-lighten-4">
This software is developed by the SFB 1288 INF project at Bielefeld University.
Thanks to all the people who made nopaque possible.
<span class="red-text">&hearts;</span>
</p>
</div>
<div class="col s12 l2">
<br class="hide-on-med-and-down">
<br class="hide-on-med-and-down">
<a href="https://www.dfg.de/">
<img class="responsive-img" src="{{ url_for('static', filename='images/logo_-_dfg.gif') }}">
</a>
</div>
</div>
</div>
<div class="footer-copyright">
<div class="container">
© 2024 Bielefeld University
<a class="grey-text text-lighten-4 right" href="https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque/-/releases/{{ config.NOPAQUE_VERSION }}">Version {{ config.NOPAQUE_VERSION }}</a>
</div>
</div>
<footer {% block footer_attributes %}class="page-footer"{% endblock footer_attributes %}>
{% block footer %}
{% include '_base/footer.html.j2' %}
{% endblock footer %}
</footer>
<div id="dropdowns">
<div {% block dropdowns_attributes %}id="dropdowns"{% endblock dropdowns_attributes %}>
{% block dropdowns %}
{% if current_user.is_authenticated %}
<ul class="dropdown-content" id="navbar-data-processing-and-analysis-dropdown-content">
<li {% if request.path == url_for('services.file_setup_pipeline') %}class="active"{% endif %}>
<a href="{{ url_for('services.file_setup_pipeline') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="file-setup-pipeline"></i>
File Setup Pipeline
</a>
</li>
<li {% if request.path == url_for('services.tesseract_ocr_pipeline') %}class="active"{% endif %}>
<a href="{{ url_for('services.tesseract_ocr_pipeline') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="tesseract-ocr-pipeline"></i>
Tesseract OCR Pipeline
</a>
</li>
{% if config.NOPAQUE_TRANSKRIBUS_ENABLED %}
<li {% if request.path == url_for('services.transkribus_htr_pipeline') %}class="active"{% endif %}>
<a href="{{ url_for('services.transkribus_htr_pipeline') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="transkribus-htr-pipeline"></i>
Transkribus HTR Pipeline
</a>
</li>
{% endif %}
<li {% if request.path == url_for('services.spacy_nlp_pipeline') %}class="active"{% endif %}>
<a href="{{ url_for('services.spacy_nlp_pipeline') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="spacy-nlp-pipeline"></i>
SpaCy NLP Pipeline
</a>
</li>
<li class="divider" tabindex="-1"></li>
<li {% if request.path == url_for('services.corpus_analysis') %}class="active"{% endif %}>
<a href="{{ url_for('services.corpus_analysis') }}">
<i class="nopaque-icons service-icons service-color-text text-darken" data-service="corpus-analysis"></i>
Corpus Analyis
</a>
</li>
</ul>
{% endif %}
{% if current_user.is_authenticated %}
<ul class="dropdown-content" id="navbar-account-dropdown-content">
<li {% if request.path == url_for('users.user', user_id=current_user.id) %}class="active"{% endif %}>
<a href="{{ url_for('users.user', user_id=current_user.id) }}">
<i class="material-icons">person</i>
Your profile
</a>
</li>
<li {% if request.path == url_for('settings.settings') %}class="active"{% endif %}>
<a href="{{ url_for('settings.settings') }}">
<i class="material-icons">settings</i>
Settings
</a>
</li>
<li>
<a href="{{ url_for('auth.logout') }}">
<i class="material-icons">logout</i>
Log out
</a>
</li>
</ul>
{% endif %}
{% include '_base/dropdowns.html.j2' %}
{% endblock dropdowns %}
</div>
<div id="modals">
<div {% block modals_attributes %}id="modals"{% endblock modals_attributes %}>
{% block modals %}
{% if current_user.is_authenticated and not current_user.terms_of_use_accepted %}
<div id="terms-of-use-modal" class="modal modal-fixed-footer">
<div class="modal-content">
<div class="container">
<div class="row">
<div class="col s12">
<h1 id="title">Terms of use</h1>
</div>
<div class="col s12">
<div class="switch">
<label>
DE
<input type="checkbox" id="terms-of-use-modal-switch">
<span class="lever"></span>
EN
</label>
</div>
<br>
</div>
<div class="terms-of-use-modal-content hide">
{% include "main/terms_of_use_en.html.j2" %}
</div>
<div class="terms-of-use-modal-content">
{% include "main/terms_of_use_de.html.j2" %}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<span style="margin-right:20px;">I have taken note of the new GTC and agree to their validity in the context of my further use.</span>
<a href="#!" class="modal-close waves-effect waves-green btn">Yes</a>
</div>
</div>
{% endif %}
{% include '_base/modals.html.j2' %}
{% endblock modals %}
</div>
<!-- #region scripts -->
{% block scripts %}
<script src="{{ url_for('static', filename='external/materialize/js/materialize.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/JSON-Patch/js/fast-json-patch.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/list.js/js/list.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/pako/js/pako_inflate.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/plotly.js/js/plotly.min.js') }}"></script>
<script src="{{ url_for('static', filename='external/socket.io/js/socket.io.min.js') }}"></script>
{% assets
filters='rjsmin',
output='gen/nopaque.%(version)s.js',
'js/index.js',
'js/app.js',
'js/utils.js',
'js/forms/index.js',
'js/forms/base-form.js',
'js/forms/create-contribution-form.js',
'js/forms/create-corpus-file-form.js',
'js/forms/create-job-form.js',
'js/resource-displays/index.js',
'js/resource-displays/resource-display.js',
'js/resource-displays/corpus-display.js',
'js/resource-displays/job-display.js',
'js/resource-lists/index.js',
'js/resource-lists/resource-list.js',
'js/resource-lists/admin-user-list.js',
'js/resource-lists/corpus-file-list.js',
'js/resource-lists/corpus-follower-list.js',
'js/resource-lists/corpus-list.js',
'js/resource-lists/corpus-text-info-list.js',
'js/resource-lists/corpus-token-list.js',
'js/resource-lists/detailed-public-corpus-list.js',
'js/resource-lists/job-input-list.js',
'js/resource-lists/job-list.js',
'js/resource-lists/job-result-list.js',
'js/resource-lists/public-corpus-list.js',
'js/resource-lists/public-user-list.js',
'js/resource-lists/spacy-nlp-pipeline-model-list.js',
'js/resource-lists/tesseract-ocr-pipeline-model-list.js',
'js/requests/index.js',
'js/requests/admin.js',
'js/requests/contributions.js',
'js/requests/corpora.js',
'js/requests/jobs.js',
'js/requests/users.js',
'js/corpus-analysis/index.js',
'js/corpus-analysis/cqi/index.js',
'js/corpus-analysis/cqi/constants.js',
'js/corpus-analysis/cqi/errors.js',
'js/corpus-analysis/cqi/status.js',
'js/corpus-analysis/cqi/api/index.js',
'js/corpus-analysis/cqi/api/client.js',
'js/corpus-analysis/cqi/models/index.js',
'js/corpus-analysis/cqi/models/resource.js',
'js/corpus-analysis/cqi/models/attributes.js',
'js/corpus-analysis/cqi/models/subcorpora.js',
'js/corpus-analysis/cqi/models/corpora.js',
'js/corpus-analysis/cqi/client.js',
'js/corpus-analysis/query-builder/index.js',
'js/corpus-analysis/query-builder/element-references.js',
'js/corpus-analysis/query-builder/query-builder.js',
'js/corpus-analysis/query-builder/structural-attribute-builder-functions.js',
'js/corpus-analysis/query-builder/token-attribute-builder-functions.js',
'js/corpus-analysis/app.js',
'js/corpus-analysis/concordance-extension.js',
'js/corpus-analysis/reader-extension.js',
'js/corpus-analysis/static-visualization-extension.js'
-%}
<script src="{{ ASSET_URL }}"></script>
{% endassets -%}
<script>
// TODO: Implement an app.run method and use this for all of the following
const app = new nopaque.App();
app.init();
{% if current_user.is_authenticated -%}
// TODO: Set this as a property of the app object
const currentUserId = {{ current_user.hashid|tojson }};
// Subscribe to the current user's data events
app.subscribeUser(currentUserId)
.catch((error) => {throw JSON.stringify(error);});
// Get the current user's data
app.getUser(currentUserId, true, true)
.catch((error) => {throw JSON.stringify(error);});
{% if not current_user.terms_of_use_accepted -%}
M.Modal.getInstance(document.querySelector('#terms-of-use-modal')).open();
{% endif -%}
{% endif -%}
// Display flashed messages
for (let [category, message] of {{ get_flashed_messages(with_categories=True)|tojson }}) {
app.flash(message, message);
}
</script>
<script>
let languageModalSwitch = document.querySelector('#terms-of-use-modal-switch');
let termsOfUseModalContent = document.querySelectorAll('.terms-of-use-modal-content');
if (languageModalSwitch) {
languageModalSwitch.addEventListener('change', function() {
termsOfUseModalContent.forEach(content => {
content.classList.toggle('hide');
});
});
}
</script>
{% include '_base/scripts.html.j2' %}
{% endblock scripts %}
<!-- #endregion scripts -->
{% endblock body %}
</body>
{% endblock html %}
</html>
{% endblock doc %}

View File

@ -14,8 +14,11 @@
{% endblock stylesheets %}
{% block navbar_secondary_content %}
<ul class="tabs tabs-transparent no-autoinit" id="corpus-analysis-extension-tabs">
{% block main_attributes %}class="service-color lighten" data-service="corpus-analysis" id="corpus-analysis-container"{% endblock main_attributes %}
{% block page_content %}
<ul class="tabs no-autoinit" id="corpus-analysis-extension-tabs">
<li class="tab">
<a class="active" href="#corpus-analysis-home-container"><i class="nopaque-icons service-icons left" data-service="corpus-analysis" style="line-height: inherit;"></i>Corpus analysis</a>
</li>
@ -26,13 +29,7 @@
<a href="#corpus-analysis-reader-container"><i class="material-icons left" style="line-height: inherit;">{{ reader_extension.icon }}</i>{{ reader_extension.name }}</a>
</li>
</ul>
{% endblock navbar_secondary_content %}
{% block main_attribs %} class="service-color lighten" data-service="corpus-analysis" id="corpus-analysis-container" style="margin-top: 48px;"{% endblock main_attribs %}
{% block page_content %}
<div id="corpus-analysis-home-container">
<h1>{{ title }}</h1>
@ -72,10 +69,9 @@
{{ super() }}
<div class="modal no-autoinit" id="corpus-analysis-init-modal">
<div class="modal-content">
<div class="card-panel service-color darken white-text" data-service="corpus-analysis">
<h4 class="m-3"><i class="material-icons left" style="font-size: inherit; line-height: inherit;">hourglass_empty</i>We are preparing your analysis session</h4>
<div class="card-panel primary-color white-text" data-service="corpus-analysis">
<h4 class="m-3 center-align">Preparing your analysis session</h4>
</div>
<h4>We are preparing your analysis session</h4>
<p>
Our server works as hard as it can to prepare your analysis session. Please be patient and give it some time.<br>
If initialization takes longer than usual or an error occurs, <a onclick="window.location.reload()" href="#">reload the page</a>.

View File

@ -8,7 +8,7 @@
{% endblock stylesheets %}
{% block main_attribs %} class="service-color lighten" data-service="corpus-analysis"{% endblock main_attribs %}
{% block main_attributes %} class="service-color lighten" data-service="corpus-analysis"{% endblock main_attributes %}
{% block page_content %}
<div class="container">
@ -246,7 +246,7 @@
let publishingModalIsPublicSwitchElement = document.querySelector('#publishing-modal-is-public-switch');
publishingModalIsPublicSwitchElement.addEventListener('change', (event) => {
let newIsPublic = publishingModalIsPublicSwitchElement.checked;
nopaque.requests.corpora.entity.isPublic.update({{ corpus.hashid|tojson }}, newIsPublic)
app.corpora.updateIsPublic({{ corpus.hashid|tojson }}, newIsPublic)
.catch((response) => {
publishingModalIsPublicSwitchElement.checked = !newIsPublic;
});
@ -256,7 +256,7 @@ publishingModalIsPublicSwitchElement.addEventListener('change', (event) => {
// #region Delete
let deleteModalDeleteButtonElement = document.querySelector('#delete-modal-delete-button');
deleteModalDeleteButtonElement.addEventListener('click', (event) => {
nopaque.requests.corpora.entity.delete({{ corpus.hashid|tojson }})
app.corpora.delete({{ corpus.hashid|tojson }})
.then((response) => {
window.location.href = {{ url_for('main.dashboard')|tojson }};
});
@ -346,26 +346,21 @@ M.Modal.init(
shareLinkModalOutputContainerElement.classList.add('hide');
}
}
)
);
shareLinkModalCreateButtonElement.addEventListener('click', (event) => {
let role = shareLinkModalCorpusFollowerRoleSelectElement.value;
let expiration = shareLinkModalExpirationDateDatepickerElement.value
nopaque.requests.corpora.entity.generateShareLink({{ corpus.hashid|tojson }}, role, expiration)
.then((response) => {
response.json()
.then((json) => {
shareLinkModalOutputContainerElement.classList.remove('hide');
shareLinkModalOutputFieldElement.value = json.corpusShareLink;
});
});
shareLinkModalCreateButtonElement.addEventListener('click', async (event) => {
const role = shareLinkModalCorpusFollowerRoleSelectElement.value;
const expiration = shareLinkModalExpirationDateDatepickerElement.value;
const shareLink = await app.corpora.createShareLink({{ corpus.hashid|tojson }}, expiration, role);
shareLinkModalOutputContainerElement.classList.remove('hide');
shareLinkModalOutputFieldElement.value = shareLink;
});
shareLinkModalOutputCopyButtonElement.addEventListener('click', (event) => {
navigator.clipboard.writeText(shareLinkModalOutputFieldElement.value)
.then(
() => {app.flash('Copied!');},
() => {app.flash('Could not copy to clipboard. Please copy manually.', 'error');}
() => {app.ui.flash('Copied!');},
() => {app.ui.flash('Could not copy to clipboard. Please copy manually.', 'error');}
);
});

View File

@ -1,7 +1,7 @@
{% extends "base.html.j2" %}
{% import "wtf.html.j2" as wtf %}
{% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
{% block main_attributes %}class="service-color lighten" data-service="corpus-analysis"{% endblock main_attributes %}
{% block page_content %}
<div class="container">

View File

@ -1,7 +1,7 @@
{% extends "base.html.j2" %}
{% import "wtf.html.j2" as wtf %}
{% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
{% block main_attributes %}class="service-color lighten" data-service="corpus-analysis"{% endblock main_attributes %}
{% block page_content %}
<div class="container">

View File

@ -1,7 +1,7 @@
{% extends "base.html.j2" %}
{% import "wtf.html.j2" as wtf %}
{% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
{% block main_attributes %}class="service-color lighten" data-service="corpus-analysis"{% endblock main_attributes %}
{% block page_content %}
<div class="container">
@ -273,7 +273,7 @@ publicCorpusFollowerList.add(
{% if cfr.has_permission('MANAGE_FILES') %}
let followerBuildRequest = document.querySelector('#follower-build-request');
followerBuildRequest.addEventListener('click', () => {
nopaque.requests.corpora.entity.build({{ corpus.hashid|tojson }})
app.corpora.build({{ corpus.hashid|tojson }})
.then((response) => {
window.location.reload();
});
@ -380,24 +380,19 @@ M.Modal.init(
}
)
shareLinkModalCreateButtonElement.addEventListener('click', (event) => {
let role = shareLinkModalCorpusFollowerRoleSelectElement.value;
let expiration = shareLinkModalExpirationDateDatepickerElement.value
nopaque.requests.corpora.entity.generateShareLink({{ corpus.hashid|tojson }}, role, expiration)
.then((response) => {
response.json()
.then((json) => {
shareLinkModalOutputContainerElement.classList.remove('hide');
shareLinkModalOutputFieldElement.value = json.corpusShareLink;
});
});
shareLinkModalCreateButtonElement.addEventListener('click', async (event) => {
const roleName = shareLinkModalCorpusFollowerRoleSelectElement.value;
const expirationDate = shareLinkModalExpirationDateDatepickerElement.value;
const shareLink = await app.corpora.createShareLink({{ corpus.hashid|tojson }}, expiration, role);
shareLinkModalOutputContainerElement.classList.remove('hide');
shareLinkModalOutputFieldElement.value = shareLink;
});
shareLinkModalOutputCopyButtonElement.addEventListener('click', (event) => {
navigator.clipboard.writeText(shareLinkModalOutputFieldElement.value)
.then(
() => {app.flash('Copied!');},
() => {app.flash('Could not copy to clipboard. Please copy manually.', 'error');}
() => {app.ui.flash('Copied!');},
() => {app.ui.flash('Could not copy to clipboard. Please copy manually.', 'error');}
);
});

Some files were not shown because too many files have changed in this diff Show More