Restructure project

This commit is contained in:
Patrick Jentsch 2022-09-02 13:07:30 +02:00
parent ec9225b881
commit 8e1d94bb5d
73 changed files with 2105 additions and 2468 deletions

View File

@ -51,9 +51,6 @@ def create_app(config: Config = Config) -> Flask:
from .admin import bp as admin_blueprint from .admin import bp as admin_blueprint
app.register_blueprint(admin_blueprint, url_prefix='/admin') app.register_blueprint(admin_blueprint, url_prefix='/admin')
from .api import bp as api_blueprint
app.register_blueprint(api_blueprint, url_prefix='/api')
from .auth import bp as auth_blueprint from .auth import bp as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth') app.register_blueprint(auth_blueprint, url_prefix='/auth')
@ -70,7 +67,7 @@ def create_app(config: Config = Config) -> Flask:
app.register_blueprint(jobs_blueprint, url_prefix='/jobs') app.register_blueprint(jobs_blueprint, url_prefix='/jobs')
from .main import bp as main_blueprint from .main import bp as main_blueprint
app.register_blueprint(main_blueprint) app.register_blueprint(main_blueprint, url_prefix='/')
from .services import bp as services_blueprint from .services import bp as services_blueprint
app.register_blueprint(services_blueprint, url_prefix='/services') app.register_blueprint(services_blueprint, url_prefix='/services')

View File

@ -1,4 +1,3 @@
from app.models import User
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import ( from wtforms import (
BooleanField, BooleanField,
@ -7,32 +6,45 @@ from wtforms import (
SubmitField, SubmitField,
ValidationError ValidationError
) )
from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp from wtforms.validators import InputRequired, Email, EqualTo, Length, Regexp
from app.models import User
from . import USERNAME_REGEX from . import USERNAME_REGEX
class LoginForm(FlaskForm):
user = StringField('Email or username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Keep me logged in')
submit = SubmitField('Log In')
class RegistrationForm(FlaskForm): class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()]) email = StringField(
username = StringField('Username', 'Email',
validators=[InputRequired(), Email(), Length(max=254)]
)
username = StringField(
'Username',
validators=[ validators=[
InputRequired(), InputRequired(),
Length(1, 64), Length(max=64),
Regexp( Regexp(
USERNAME_REGEX, USERNAME_REGEX,
message='Usernames must have only letters, numbers, dots or underscores' # noqa message=(
) 'Usernames must have only letters, numbers, dots or '
'underscores'
)
)
] ]
) )
password = PasswordField('Password', validators=[DataRequired(), EqualTo('password_confirmation', message='Passwords must match')]) password = PasswordField(
password_confirmation = PasswordField('Password confirmation', validators=[DataRequired(), EqualTo('password', message='Passwords must match')]) 'Password',
submit = SubmitField('Register') validators=[
InputRequired(),
EqualTo('password_2', message='Passwords must match')
]
)
password_2 = PasswordField(
'Password confirmation',
validators=[
InputRequired(),
EqualTo('password', message='Passwords must match')
]
)
submit = SubmitField()
def validate_email(self, field): def validate_email(self, field):
if User.query.filter_by(email=field.data.lower()).first(): if User.query.filter_by(email=field.data.lower()).first():
@ -43,12 +55,31 @@ class RegistrationForm(FlaskForm):
raise ValidationError('Username already in use') raise ValidationError('Username already in use')
class ResetPasswordForm(FlaskForm): class LoginForm(FlaskForm):
password = PasswordField('New password', validators=[DataRequired(), EqualTo('password_confirmation', message='Passwords must match')]) user = StringField('Email or username', validators=[InputRequired()])
password_confirmation = PasswordField('Password confirmation', validators=[DataRequired(), EqualTo('password', message='Passwords must match')]) password = PasswordField('Password', validators=[InputRequired()])
submit = SubmitField('Reset Password') remember_me = BooleanField('Keep me logged in')
submit = SubmitField()
class ResetPasswordRequestForm(FlaskForm): class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()]) email = StringField('Email', validators=[InputRequired(), Email()])
submit = SubmitField('Reset Password') submit = SubmitField()
class ResetPasswordForm(FlaskForm):
password = PasswordField(
'New password',
validators=[
InputRequired(),
EqualTo('password_2', message='Passwords must match')
]
)
password_2 = PasswordField(
'New password confirmation',
validators=[
InputRequired(),
EqualTo('password', message='Passwords must match')
]
)
submit = SubmitField()

View File

@ -1,10 +1,5 @@
from app import db
from app.email import create_message, send
from app.models import User
from datetime import datetime
from flask import ( from flask import (
abort, abort,
current_app,
flash, flash,
redirect, redirect,
render_template, render_template,
@ -12,7 +7,9 @@ from flask import (
url_for url_for
) )
from flask_login import current_user, login_user, login_required, logout_user from flask_login import current_user, login_user, login_required, logout_user
from sqlalchemy import or_ from app import db
from app.email import create_message, send
from app.models import User
from . import bp from . import bp
from .forms import ( from .forms import (
LoginForm, LoginForm,
@ -29,69 +26,32 @@ def before_request():
unconfirmed view if user is unconfirmed. unconfirmed view if user is unconfirmed.
""" """
if current_user.is_authenticated: if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow() current_user.ping()
db.session.commit() db.session.commit()
if ( if (not current_user.confirmed
not current_user.confirmed and request.endpoint
and request.endpoint and request.blueprint != 'auth'
and request.blueprint != 'auth' and request.endpoint != 'static'):
and request.endpoint != 'static'
):
return redirect(url_for('auth.unconfirmed')) return redirect(url_for('auth.unconfirmed'))
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = LoginForm(prefix='login-form')
if form.validate_on_submit():
user = User.query.filter(
or_(
User.username == form.user.data,
User.email == form.user.data.lower()
)
).first()
if user and user.verify_password(form.password.data):
login_user(user, form.remember_me.data)
next = request.args.get('next')
if next is None or not next.startswith('/'):
next = url_for('main.dashboard')
return redirect(next)
flash('Invalid email/username or password', category='error')
return render_template('auth/login.html.j2', form=form, title='Log in')
@bp.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out')
return redirect(url_for('main.index'))
@bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
form = RegistrationForm(prefix='registration-form') form = RegistrationForm(prefix='registration-form')
if form.validate_on_submit(): if form.validate_on_submit():
user = User(
email=form.email.data.lower(),
password=form.password.data,
username=form.username.data
)
db.session.add(user)
db.session.flush(objects=[user])
db.session.refresh(user)
try: try:
user.makedirs() user = User.create(
except OSError as e: email=form.email.data.lower(),
current_app.logger.error(e) password=form.password.data,
db.session.rollback() username=form.username.data
)
except OSError:
flash('Internal Server Error', category='error') flash('Internal Server Error', category='error')
abort(500) abort(500)
token = user.generate_confirm_user_token() flash(f'User "{user.username}" created')
token = user.generate_confirm_token()
msg = create_message( msg = create_message(
user.email, user.email,
'Confirm Your Account', 'Confirm Your Account',
@ -110,36 +70,46 @@ def register():
) )
@bp.route('/confirm/<token>') @bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = LoginForm(prefix='login-form')
if form.validate_on_submit():
user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
if user and user.verify_password(form.password.data):
login_user(user, form.remember_me.data)
next = request.args.get('next')
if next is None or not next.startswith('/'):
next = url_for('main.dashboard')
flash('You have been logged in')
return redirect(next)
flash('Invalid email/username or password', category='error')
return render_template('auth/login.html.j2', form=form, title='Log in')
@bp.route('/logout')
@login_required @login_required
def confirm(token): def logout():
if current_user.confirmed: logout_user()
return redirect(url_for('main.dashboard')) flash('You have been logged out')
if current_user.confirm_user(token): return redirect(url_for('main.index'))
db.session.commit()
flash('You have confirmed your account')
return redirect(url_for('main.dashboard'))
else:
flash(
'The confirmation link is invalid or has expired',
category='error'
)
return redirect(url_for('.unconfirmed'))
@bp.route('/unconfirmed') @bp.route('/unconfirmed')
@login_required
def unconfirmed(): def unconfirmed():
if current_user.is_anonymous: if current_user.confirmed:
return redirect(url_for('main.index'))
elif current_user.confirmed:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
return render_template('auth/unconfirmed.html.j2', title='Unconfirmed') return render_template('auth/unconfirmed.html.j2', title='Unconfirmed')
@bp.route('/confirm') @bp.route('/confirm')
@login_required @login_required
def resend_confirmation(): def confirm_request():
token = current_user.generate_confirm_user_token() if current_user.confirmed:
return redirect(url_for('main.dashboard'))
token = current_user.generate_confirm_token()
msg = create_message( msg = create_message(
current_user.email, current_user.email,
'Confirm Your Account', 'Confirm Your Account',
@ -149,10 +119,23 @@ def resend_confirmation():
) )
send(msg) send(msg)
flash('A new confirmation email has been sent to you by email') flash('A new confirmation email has been sent to you by email')
return redirect(url_for('auth.unconfirmed')) return redirect(url_for('.unconfirmed'))
@bp.route('/reset', methods=['GET', 'POST']) @bp.route('/confirm/<token>')
@login_required
def confirm(token):
if current_user.confirmed:
return redirect(url_for('main.dashboard'))
if current_user.confirm(token):
db.session.commit()
flash('You have confirmed your account')
return redirect(url_for('main.dashboard'))
flash('The confirmation link is invalid or has expired', category='error')
return redirect(url_for('.unconfirmed'))
@bp.route('/reset_password', methods=['GET', 'POST'])
def reset_password_request(): def reset_password_request():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@ -160,7 +143,7 @@ def reset_password_request():
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first() user = User.query.filter_by(email=form.email.data.lower()).first()
if user is not None: if user is not None:
token = user.generate_password_reset_token() token = user.generate_reset_password_token()
msg = create_message( msg = create_message(
user.email, user.email,
'Reset Your Password', 'Reset Your Password',
@ -170,7 +153,8 @@ def reset_password_request():
) )
send(msg) send(msg)
flash( flash(
'An email with instructions to reset your password has been sent to you' # noqa 'An email with instructions to reset your password has been sent '
'to you'
) )
return redirect(url_for('.login')) return redirect(url_for('.login'))
return render_template( return render_template(
@ -180,7 +164,7 @@ def reset_password_request():
) )
@bp.route('/reset/<token>', methods=['GET', 'POST']) @bp.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token): def reset_password(token):
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@ -190,8 +174,7 @@ def reset_password(token):
db.session.commit() db.session.commit()
flash('Your password has been updated') flash('Your password has been updated')
return redirect(url_for('.login')) return redirect(url_for('.login'))
else: return redirect(url_for('main.index'))
return redirect(url_for('main.index'))
return render_template( return render_template(
'auth/reset_password.html.j2', 'auth/reset_password.html.j2',
form=form, form=form,

View File

@ -1,15 +0,0 @@
from app.models import User
from flask_wtf import FlaskForm
from wtforms import (
BooleanField,
PasswordField,
StringField,
SubmitField,
ValidationError
)
from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp
from . import USERNAME_REGEX
class ContributeTesseractOCRModel(FlaskForm):
pass

View File

@ -1,80 +1,67 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired from flask_wtf.file import FileField, FileRequired
from werkzeug.utils import secure_filename from wtforms import StringField, SubmitField, ValidationError, IntegerField
from wtforms import ( from wtforms.validators import InputRequired, Length
StringField,
SubmitField,
ValidationError,
IntegerField
)
from wtforms.validators import DataRequired, InputRequired, Length
class AddCorpusFileForm(FlaskForm): class CreateCorpusForm(FlaskForm):
''' description = StringField(
Form to add a .vrt corpus file to the current corpus. 'Description',
''' validators=[InputRequired(), Length(max=255)]
# Required fields )
author = StringField('Author', validators=[InputRequired(), Length(1, 255)]) title = StringField('Title', validators=[InputRequired(), Length(max=32)])
publishing_year = IntegerField('Publishing year', validators=[InputRequired()])
title = StringField('Title', validators=[InputRequired(), Length(1, 255)])
vrt = FileField('File', validators=[FileRequired()])
# Optional fields
address = StringField('Adress', validators=[Length(0, 255)])
booktitle = StringField('Booktitle', validators=[Length(0, 255)])
chapter = StringField('Chapter', validators=[Length(0, 255)])
editor = StringField('Editor', validators=[Length(0, 255)])
institution = StringField('Institution', validators=[Length(0, 255)])
journal = StringField('Journal', validators=[Length(0, 255)])
pages = StringField('Pages', validators=[Length(0, 255)])
publisher = StringField('Publisher', validators=[Length(0, 255)])
school = StringField('School', validators=[Length(0, 255)])
submit = SubmitField() submit = SubmitField()
class CorpusFileBaseForm(FlaskForm):
author = StringField(
'Author',
validators=[InputRequired(), Length(max=255)]
)
publishing_year = IntegerField(
'Publishing year',
validators=[InputRequired()]
)
title = StringField(
'Title',
validators=[InputRequired(), Length(max=255)]
)
address = StringField('Adress', validators=[Length(max=255)])
booktitle = StringField('Booktitle', validators=[Length(max=255)])
chapter = StringField('Chapter', validators=[Length(max=255)])
editor = StringField('Editor', validators=[Length(max=255)])
institution = StringField('Institution', validators=[Length(max=255)])
journal = StringField('Journal', validators=[Length(max=255)])
pages = StringField('Pages', validators=[Length(max=255)])
publisher = StringField('Publisher', validators=[Length(max=255)])
school = StringField('School', validators=[Length(max=255)])
submit = SubmitField()
class CreateCorpusFileForm(CorpusFileBaseForm):
vrt = FileField('File', validators=[FileRequired()])
def validate_vrt(self, field): def validate_vrt(self, field):
if not field.data.filename.lower().endswith('.vrt'): if not field.data.filename.lower().endswith('.vrt'):
raise ValidationError('VRT files only!') raise ValidationError('VRT files only!')
class EditCorpusFileForm(FlaskForm):
'''
Form to edit meta data of one corpus file.
'''
# Required fields
author = StringField('Author', validators=[InputRequired(), Length(1, 255)])
publishing_year = IntegerField('Publishing year', validators=[InputRequired()])
title = StringField('Title', validators=[InputRequired(), Length(1, 255)])
# Optional fields
address = StringField('Adress', validators=[Length(0, 255)])
booktitle = StringField('Booktitle', validators=[Length(0, 255)])
chapter = StringField('Chapter', validators=[Length(0, 255)])
editor = StringField('Editor', validators=[Length(0, 255)])
institution = StringField('Institution', validators=[Length(0, 255)])
journal = StringField('Journal', validators=[Length(0, 255)])
pages = StringField('Pages', validators=[Length(0, 255)])
publisher = StringField('Publisher', validators=[Length(0, 255)])
school = StringField('School', validators=[Length(0, 255)])
submit = SubmitField()
class EditCorpusFileForm(CorpusFileBaseForm):
class AddCorpusForm(FlaskForm): def prefill(self, corpus_file):
''' ''' Pre-fill the form with data of an exististing corpus file '''
Form to add a a new corpus. self.address.data = corpus_file.address
''' self.author.data = corpus_file.author
description = StringField('Description', validators=[InputRequired(), Length(1, 255)]) self.booktitle.data = corpus_file.booktitle
title = StringField('Title', validators=[InputRequired(), Length(1, 32)]) self.chapter.data = corpus_file.chapter
submit = SubmitField() self.editor.data = corpus_file.editor
self.institution.data = corpus_file.institution
self.journal.data = corpus_file.journal
self.pages.data = corpus_file.pages
self.publisher.data = corpus_file.publisher
self.publishing_year.data = corpus_file.publishing_year
self.school.data = corpus_file.school
self.title.data = corpus_file.title
class ImportCorpusForm(FlaskForm): class ImportCorpusForm(FlaskForm):
''' pass
Form to import a corpus.
'''
description = StringField('Description', validators=[InputRequired(), Length(1, 255)])
archive = FileField('File', validators=[FileRequired()])
title = StringField('Title', validators=[InputRequired(), Length(1, 32)])
submit = SubmitField()
def validate_archive(self, field):
valid_mimetypes = ['application/zip', 'application/x-zip', 'application/x-zip-compressed']
if field.data.mimetype not in valid_mimetypes:
raise ValidationError('ZIP files only!')

View File

@ -1,140 +1,44 @@
from app import db
from app.models import Corpus, CorpusFile, CorpusStatus
from flask import ( from flask import (
abort, abort,
current_app, current_app,
flash, flash,
make_response, Markup,
redirect, redirect,
render_template, render_template,
url_for,
send_from_directory send_from_directory
) )
from flask_login import current_user, login_required from flask_login import current_user, login_required
from . import bp from threading import Thread
from . import tasks
from .forms import (
AddCorpusFileForm,
AddCorpusForm,
EditCorpusFileForm,
ImportCorpusForm
)
import os import os
import shutil from app import db
import tempfile from app.models import Corpus, CorpusFile, CorpusStatus
import xml.etree.ElementTree as ET from . import bp
from .forms import CreateCorpusFileForm, CreateCorpusForm, EditCorpusFileForm
@bp.route('/add', methods=['GET', 'POST']) @bp.route('/create', methods=['GET', 'POST'])
@login_required @login_required
def add_corpus(): def create_corpus():
form = AddCorpusForm(prefix='add-corpus-form') form = CreateCorpusForm(prefix='create-corpus-form')
if form.validate_on_submit(): if form.validate_on_submit():
corpus = Corpus(
user=current_user,
description=form.description.data,
title=form.title.data
)
db.session.add(corpus)
db.session.flush()
db.session.refresh(corpus)
try: try:
corpus.makedirs() corpus = Corpus.create(
except OSError as e: title=form.title.data,
current_app.logger.error(e) description=form.description.data,
db.session.rollback() user=current_user
flash('Internal Server Error', category='error') )
except OSError:
abort(500) abort(500)
db.session.commit() db.session.commit()
flash(f'Corpus "{corpus.title}" added', category='corpus') message = Markup(
return redirect(url_for('.corpus', corpus_id=corpus.id)) f'Corpus "<a href="{corpus.url}">{corpus.title}</a>" created'
return render_template(
'corpora/add_corpus.html.j2',
form=form,
title='Add corpus'
)
@bp.route('/import', methods=['GET', 'POST'])
@login_required
def import_corpus():
form = ImportCorpusForm(prefix='import-corpus-form')
if form.is_submitted():
if not form.validate():
return make_response(form.errors, 400)
corpus = Corpus(
user=current_user,
description=form.description.data,
title=form.title.data
) )
db.session.add(corpus) flash(message, 'corpus')
db.session.flush(objects=[corpus]) return redirect(corpus.url)
db.session.refresh(corpus)
try:
corpus.makedirs()
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
flash('Internal Server Error', category='error')
return make_response({'redirect_url': url_for('.import_corpus')}, 500) # noqa
# Save the uploaded zip file in a temporary directory
tmp_dir_base = os.path.join(current_app.config['NOPAQUE_DATA_DIR'], 'tmp') # noqa
with tempfile.TemporaryDirectory(dir=tmp_dir_base) as tmp_dir:
archive_file = os.path.join(tmp_dir, 'corpus.zip')
try:
form.archive.data.save(archive_file)
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
flash('Internal Server Error1', category='error')
return make_response({'redirect_url': url_for('.import_corpus')}, 500) # noqa
shutil.unpack_archive(archive_file, extract_dir=tmp_dir)
for vrt_filename in [x for x in os.listdir(tmp_dir) if x.endswith('.vrt')]:
vrt_file = os.path.join(tmp_dir, vrt_filename)
element_tree = ET.parse(vrt_file)
text_node = element_tree.find('text')
corpus_file = CorpusFile(
author=text_node.get('author'),
corpus=corpus,
filename=vrt_filename,
mimetype='application/vrt+xml',
publishing_year=int(text_node.get('publishing_year')),
title=text_node.get('title')
)
if 'address' not in text_node.attrib:
corpus_file.address = text_node.get('address')
if 'booktitle' not in text_node.attrib:
corpus_file.booktitle = text_node.get('booktitle')
if 'chapter' not in text_node.attrib:
corpus_file.chapter = text_node.get('chapter')
if 'editor' not in text_node.attrib:
corpus_file.editor = text_node.get('editor')
if 'institution' not in text_node.attrib:
corpus_file.institution = text_node.get('institution')
if 'journal' not in text_node.attrib:
corpus_file.journal = text_node.get('journal')
if 'pages' not in text_node.attrib:
corpus_file.pages = text_node.get('pages')
if 'publisher' not in text_node.attrib:
corpus_file.publisher = text_node.get('publisher')
if 'school' not in text_node.attrib:
corpus_file.school = text_node.get('school')
db.session.add(corpus_file)
db.session.flush(objects=[corpus_file])
db.session.refresh(corpus)
try:
shutil.copy2(vrt_file, corpus_file.path)
except Exception as e:
db.session.rollback()
flash('Internal Server Error2', category='error')
return make_response({'redirect_url': url_for('.import_corpus')}, 500) # noqa
db.session.commit()
flash(f'Corpus "{corpus.title}" imported', 'corpus')
return make_response({'redirect_url': url_for('.corpus', corpus_id=corpus.id)}, 201)
return render_template( return render_template(
'corpora/import_corpus.html.j2', 'corpora/create_corpus.html.j2',
form=form, form=form,
title='Import Corpus' title='Create corpus'
) )
@ -151,6 +55,26 @@ def corpus(corpus_id):
) )
@bp.route('/<hashid:corpus_id>', methods=['DELETE'])
@login_required
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)
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 {}, 202
@bp.route('/<hashid:corpus_id>/analyse') @bp.route('/<hashid:corpus_id>/analyse')
@login_required @login_required
def analyse_corpus(corpus_id): def analyse_corpus(corpus_id):
@ -162,95 +86,132 @@ def analyse_corpus(corpus_id):
) )
@bp.route('/<hashid:corpus_id>/build') @bp.route('/<hashid:corpus_id>/build', methods=['POST'])
@login_required @login_required
def build_corpus(corpus_id): 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) corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()): if not (corpus.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
if corpus.files.all(): # Check if the corpus has corpus files
tasks.build_corpus(corpus_id) if not corpus.files.all():
flash( response = {'errors': {'message': 'Corpus file(s) required'}}
f'Corpus "{corpus.title}" marked for building', return response, 409
category='corpus' thread = Thread(
) target=_build_corpus,
else: args=(current_app._get_current_object(), corpus_id)
flash( )
f'Can\'t build corpus "{corpus.title}": No corpus file(s)', thread.start()
category='error' return {}, 202
)
return redirect(url_for('.corpus', corpus_id=corpus_id))
@bp.route('/<hashid:corpus_id>/delete') @bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
@login_required @login_required
def delete_corpus(corpus_id): def create_corpus_file(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id) corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()): if not (corpus.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
flash(f'Corpus "{corpus.title}" marked for deletion', 'corpus') form = CreateCorpusFileForm(prefix='create-corpus-file-form')
tasks.delete_corpus(corpus_id) if form.is_submitted():
return redirect(url_for('main.dashboard')) if not form.validate():
response = {'errors': form.errors}
return response, 400
@bp.route('/<hashid:corpus_id>/export') try:
@login_required corpus_file = CorpusFile.create(
def export_corpus(corpus_id): form.vrt.data,
abort(503) address=form.address.data,
corpus = Corpus.query.get_or_404(corpus_id) author=form.author.data,
if not (corpus.user == current_user or current_user.is_administrator()): booktitle=form.booktitle.data,
abort(403) chapter=form.chapter.data,
return send_from_directory( editor=form.editor.data,
as_attachment=True, institution=form.institution.data,
directory=os.path.join(corpus.user.path, 'corpora'), journal=form.journal.data,
filename=corpus.archive_file, pages=form.pages.data,
mimetype='zip' publisher=form.publisher.data,
publishing_year=form.publishing_year.data,
school=form.school.data,
title=form.title.data,
mimetype='application/vrt+xml',
corpus=corpus
)
except OSError:
abort(500)
corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
message = Markup(
'Corpus file'
f'"<a href="{corpus_file.url}">{corpus_file.filename}</a>" added'
)
flash(message, category='corpus')
return {}, 201, {'Location': corpus.url}
return render_template(
'corpora/create_corpus_file.html.j2',
corpus=corpus,
form=form,
title='Add corpus file'
) )
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST']) # noqa @bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>',
methods=['GET', 'POST'])
@login_required @login_required
def corpus_file(corpus_id, corpus_file_id): def corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter( corpus_file = CorpusFile.query.get_or_404(corpus_file_id)
CorpusFile.corpus_id == corpus_id, if corpus_file.corpus.id != corpus_id:
CorpusFile.id == corpus_file_id abort(404)
).first_or_404() if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
if not (
corpus_file.corpus.user == current_user
or current_user.is_administrator()
):
abort(403) abort(403)
form = EditCorpusFileForm(prefix='edit-corpus-file-form') form = EditCorpusFileForm(prefix='edit-corpus-file-form')
if form.validate_on_submit(): if form.validate_on_submit():
corpus_file.address = form.address.data has_changes = False
corpus_file.author = form.author.data if corpus_file.address != form.address.data:
corpus_file.booktitle = form.booktitle.data corpus_file.address = form.address.data
corpus_file.chapter = form.chapter.data has_changes = True
corpus_file.editor = form.editor.data if corpus_file.author != form.author.data:
corpus_file.institution = form.institution.data corpus_file.author = form.author.data
corpus_file.journal = form.journal.data has_changes = True
corpus_file.pages = form.pages.data if corpus_file.booktitle != form.booktitle.data:
corpus_file.publisher = form.publisher.data corpus_file.booktitle = form.booktitle.data
corpus_file.publishing_year = form.publishing_year.data has_changes = True
corpus_file.school = form.school.data if corpus_file.chapter != form.chapter.data:
corpus_file.title = form.title.data corpus_file.chapter = form.chapter.data
corpus_file.corpus.status = CorpusStatus.UNPREPARED has_changes = True
if corpus_file.editor != form.editor.data:
corpus_file.editor = form.editor.data
has_changes = True
if corpus_file.institution != form.institution.data:
corpus_file.institution = form.institution.data
has_changes = True
if corpus_file.journal != form.journal.data:
corpus_file.journal = form.journal.data
has_changes = True
if corpus_file.pages != form.pages.data:
corpus_file.pages = form.pages.data
has_changes = True
if corpus_file.publisher != form.publisher.data:
corpus_file.publisher = form.publisher.data
has_changes = True
if corpus_file.publishing_year != form.publishing_year.data:
corpus_file.publishing_year = form.publishing_year.data
has_changes = True
if corpus_file.school != form.school.data:
corpus_file.school = form.school.data
has_changes = True
if corpus_file.title != form.title.data:
corpus_file.title = form.title.data
has_changes = True
if has_changes:
corpus_file.corpus.status = CorpusStatus.UNPREPARED
db.session.commit() db.session.commit()
flash(f'Corpus file "{corpus_file.filename}" edited', category='corpus') # noqa message = Markup(f'Corpus file "<a href="{corpus_file.url}">{corpus_file.filename}</a>" updated')
return redirect(url_for('.corpus', corpus_id=corpus_id)) flash(message, category='corpus')
# If no form is submitted or valid, fill out fields with current values return redirect(corpus_file.corpus.url)
form.address.data = corpus_file.address form.prefill(corpus_file)
form.author.data = corpus_file.author
form.booktitle.data = corpus_file.booktitle
form.chapter.data = corpus_file.chapter
form.editor.data = corpus_file.editor
form.institution.data = corpus_file.institution
form.journal.data = corpus_file.journal
form.pages.data = corpus_file.pages
form.publisher.data = corpus_file.publisher
form.publishing_year.data = corpus_file.publishing_year
form.school.data = corpus_file.school
form.title.data = corpus_file.title
return render_template( return render_template(
'corpora/corpus_file.html.j2', 'corpora/corpus_file.html.j2',
corpus=corpus_file.corpus, corpus=corpus_file.corpus,
@ -260,91 +221,52 @@ def corpus_file(corpus_id, corpus_file_id):
) )
@bp.route('/<hashid:corpus_id>/files/add', methods=['GET', 'POST']) @bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['DELETE'])
@login_required
def add_corpus_file(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
abort(403)
form = AddCorpusFileForm(prefix='add-corpus-file-form')
if form.is_submitted():
if not form.validate():
return make_response(form.errors, 400)
# Save the file
corpus_file = CorpusFile(
address=form.address.data,
author=form.author.data,
booktitle=form.booktitle.data,
chapter=form.chapter.data,
corpus=corpus,
editor=form.editor.data,
filename=form.vrt.data.filename,
institution=form.institution.data,
journal=form.journal.data,
mimetype='application/vrt+xml',
pages=form.pages.data,
publisher=form.publisher.data,
publishing_year=form.publishing_year.data,
school=form.school.data,
title=form.title.data
)
db.session.add(corpus_file)
db.session.flush(objects=[corpus_file])
db.session.refresh(corpus_file)
try:
form.vrt.data.save(corpus_file.path)
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
flash('Internal Server Error', category='error')
return make_response({'redirect_url': url_for('.add_corpus_file', corpus_id=corpus.id)}, 500) # noqa
corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
flash(f'Corpus file "{corpus_file.filename}" added', category='corpus')
return make_response({'redirect_url': url_for('.corpus', corpus_id=corpus.id)}, 201) # noqa
return render_template(
'corpora/add_corpus_file.html.j2',
corpus=corpus,
form=form,
title='Add corpus file'
)
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/delete')
@login_required @login_required
def delete_corpus_file(corpus_id, corpus_file_id): def delete_corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter( def _delete_corpus_file(app, corpus_file_id):
CorpusFile.corpus_id == corpus_id, with app.app_context():
CorpusFile.id == corpus_file_id corpus_file = CorpusFile.query.get(corpus_file_id)
).first_or_404() corpus_file.delete()
if not ( db.session.commit()
corpus_file.corpus.user == current_user
or current_user.is_administrator() corpus_file = CorpusFile.query.get_or_404(corpus_file_id)
): if corpus_file.corpus.id != corpus_id:
abort(404)
if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
flash( thread = Thread(
f'Corpus file "{corpus_file.filename}" marked for deletion', target=_delete_corpus_file,
category='corpus' args=(current_app._get_current_object(), corpus_file_id)
) )
tasks.delete_corpus_file(corpus_file_id) thread.start()
return redirect(url_for('.corpus', corpus_id=corpus_id)) return {}, 202
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download') @bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
@login_required @login_required
def download_corpus_file(corpus_id, corpus_file_id): def download_corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter( corpus_file = CorpusFile.query.get_or_404(corpus_file_id)
CorpusFile.corpus_id == corpus_id, if corpus_file.corpus.id != corpus_id:
CorpusFile.id == corpus_file_id abort(404)
).first_or_404() if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
if not (
corpus_file.corpus.user == current_user
or current_user.is_administrator()
):
abort(403) abort(403)
return send_from_directory( return send_from_directory(
os.path.dirname(corpus_file.path),
os.path.basename(corpus_file.path),
as_attachment=True, as_attachment=True,
attachment_filename=corpus_file.filename, attachment_filename=corpus_file.filename,
directory=os.path.dirname(corpus_file.path), mimetype=corpus_file.mimetype
filename=os.path.basename(corpus_file.path)
) )
@bp.route('/import', methods=['GET', 'POST'])
@login_required
def import_corpus():
abort(503)
@bp.route('/<hashid:corpus_id>/export')
@login_required
def export_corpus(corpus_id):
abort(503)

View File

@ -1,34 +0,0 @@
from app import db
from app.decorators import background
from app.models import Corpus, CorpusFile
@background
def build_corpus(corpus_id, *args, **kwargs):
app = kwargs['app']
with app.app_context():
corpus = Corpus.query.get(corpus_id)
if corpus is None:
raise Exception(f'Corpus {corpus_id} not found')
corpus.build()
db.session.commit()
@background
def delete_corpus(corpus_id, *args, **kwargs):
with kwargs['app'].app_context():
corpus = Corpus.query.get(corpus_id)
if corpus is None:
raise Exception(f'Corpus {corpus_id} not found')
corpus.delete()
db.session.commit()
@background
def delete_corpus_file(corpus_file_id, *args, **kwargs):
with kwargs['app'].app_context():
corpus_file = CorpusFile.query.get(corpus_file_id)
if corpus_file is None:
raise Exception(f'Corpus file {corpus_file_id} not found')
corpus_file.delete()
db.session.commit()

View File

@ -1,4 +1,4 @@
from app import db, docker_client from app import db, docker_client, hashids
from app.models import ( from app.models import (
Job, Job,
JobResult, JobResult,
@ -89,7 +89,14 @@ def _create_job_service(job):
input_mount = f'{input_mount_source}:{input_mount_target}:ro' input_mount = f'{input_mount_source}:{input_mount_target}:ro'
mounts.append(input_mount) mounts.append(input_mount)
if job.service == 'tesseract-ocr-pipeline': if job.service == 'tesseract-ocr-pipeline':
model = TesseractOCRModel.query.get(job.service_args['model']) if isinstance(job.service_args['model'], str):
model_id = hashids.decode(job.service_args['model'])
elif isinstance(job.service_args['model'], int):
model_id = job.service_args['model']
else:
job.status = JobStatus.FAILED
return
model = TesseractOCRModel.query.get(model_id)
if model is None: if model is None:
job.status = JobStatus.FAILED job.status = JobStatus.FAILED
return return

View File

@ -1,52 +1,11 @@
from flask import render_template, request, jsonify from flask import render_template, request
from werkzeug.exceptions import HTTPException
from . import bp from . import bp
@bp.app_errorhandler(403) @bp.errorhandler(HTTPException)
def forbidden(e): def generic_error_handler(e):
if (request.accept_mimetypes.accept_json if (request.accept_mimetypes.accept_json
and not request.accept_mimetypes.accept_html): and not request.accept_mimetypes.accept_html):
response = jsonify({'error': 'forbidden'}) return {'errors': {'message': e.description}}, e.code
response.status_code = 403 return render_template('errors/error.html.j2', error=e), e.code
return response
return render_template('errors/403.html.j2', title='Forbidden'), 403
@bp.app_errorhandler(404)
def not_found(e):
if (request.accept_mimetypes.accept_json
and not request.accept_mimetypes.accept_html):
response = jsonify({'error': 'not found'})
response.status_code = 404
return response
return render_template('errors/404.html.j2', title='Not Found'), 404
@bp.app_errorhandler(413)
def payload_too_large(e):
if (request.accept_mimetypes.accept_json
and not request.accept_mimetypes.accept_html):
response = jsonify({'error': 'payload too large'})
response.status_code = 413
return response
return render_template('errors/413.html.j2', title='Payload Too Large'), 413
@bp.app_errorhandler(500)
def internal_server_error(e):
if (request.accept_mimetypes.accept_json
and not request.accept_mimetypes.accept_html):
response = jsonify({'error': 'internal server error'})
response.status_code = 500
return response
return render_template('errors/500.html.j2', title='Internal Server Error'), 500
@bp.app_errorhandler(503)
def service_unavailable_error(e):
if (request.accept_mimetypes.accept_json
and not request.accept_mimetypes.accept_html):
response = jsonify({'error': 'service unavailable'})
response.status_code = 503
return response
return render_template('errors/503.html.j2', title='Service Unavailable'), 503

View File

@ -1,17 +1,16 @@
from app.decorators import admin_required
from app.models import Job, JobInput, JobResult, JobStatus
from flask import ( from flask import (
abort, abort,
flash, current_app,
redirect,
render_template, render_template,
send_from_directory, send_from_directory
url_for
) )
from flask_login import current_user, login_required from flask_login import current_user, login_required
from . import bp from threading import Thread
from . import tasks
import os import os
from app import db
from app.decorators import admin_required
from app.models import Job, JobInput, JobResult, JobStatus
from . import bp
@bp.route('/<hashid:job_id>') @bp.route('/<hashid:job_id>')
@ -27,35 +26,24 @@ def job(job_id):
) )
@bp.route('/<hashid:job_id>/delete') @bp.route('/<hashid:job_id>', methods=['DELETE'])
@login_required @login_required
def delete_job(job_id): 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) job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator()): if not (job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
tasks.delete_job(job_id) thread = Thread(
flash(f'Job "{job.title}" marked for deletion', 'job') target=_delete_job,
return redirect(url_for('main.dashboard')) args=(current_app._get_current_object(), job_id)
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
@login_required
def download_job_input(job_id, job_input_id):
job_input = JobInput.query.filter(
JobInput.job_id == job_id,
JobInput.id == job_input_id
).first_or_404()
if not (
job_input.job.user == current_user
or current_user.is_administrator()
):
abort(403)
return send_from_directory(
as_attachment=True,
attachment_filename=job_input.filename,
directory=os.path.dirname(job_input.path),
filename=os.path.basename(job_input.path)
) )
thread.start()
return {}, 202
@bp.route('/<hashid:job_id>/log') @bp.route('/<hashid:job_id>/log')
@ -64,48 +52,65 @@ def download_job_input(job_id, job_input_id):
def job_log(job_id): def job_log(job_id):
job = Job.query.get_or_404(job_id) job = Job.query.get_or_404(job_id)
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
flash( response = {'errors': {'message': 'Job status is not completed or failed'}}
f'Can\'t restart job "{job.title}": Status is not "Completed/Failed"', # noqa return response, 409
category='error' with open(os.path.join(job.path, 'pipeline_data', 'logs', 'pyflow_log.txt')) as log_file:
) log = log_file.read()
return send_from_directory( return log, 200, {'Content-Type': 'text/plain; charset=utf-8'}
attachment_filename=f'job_{job.hashid}_log.txt',
directory=os.path.join(job.path, 'pipeline_data'),
filename=os.path.join('logs', 'pyflow_log.txt')
)
@bp.route('/<hashid:job_id>/restart') @bp.route('/<hashid:job_id>/restart', methods=['POST'])
@login_required @login_required
@admin_required def restart_job(job_id):
def restart(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) job = Job.query.get_or_404(job_id)
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: if not (job.user == current_user or current_user.is_administrator()):
flash( abort(403)
f'Can\'t restart job "{job.title}": Status is not "Completed/Failed"', # noqa if job.status == JobStatus.FAILED:
category='error' response = {'errors': {'message': 'Job status is not "failed"'}}
) return response, 409
else: thread = Thread(
tasks.restart_job(job_id) target=_restart_job,
flash(f'Job "{job.title}" marked to get restarted', category='job') args=(current_app._get_current_object(), job_id)
return redirect(url_for('.job', job_id=job_id)) )
thread.start()
return {}, 202
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
@login_required
def download_job_input(job_id, job_input_id):
job_input = JobInput.query.get_or_404(job_input_id)
if job_input.job.id != job_id:
abort(404)
if not (job_input.job.user == current_user or current_user.is_administrator()):
abort(403)
return send_from_directory(
os.path.dirname(job_input.path),
os.path.basename(job_input.path),
as_attachment=True,
attachment_filename=job_input.filename,
mimetype=job_input.mimetype
)
@bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download') @bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download')
@login_required @login_required
def download_job_result(job_id, job_result_id): def download_job_result(job_id, job_result_id):
job_result = JobResult.query.filter( job_result = JobResult.query.get_or_404(job_result_id)
JobResult.job_id == job_id, if job_result.job.id != job_id:
JobResult.id == job_result_id abort(404)
).first_or_404() if not (job_result.job.user == current_user or current_user.is_administrator()):
if not (
job_result.job.user == current_user
or current_user.is_administrator()
):
abort(403) abort(403)
return send_from_directory( return send_from_directory(
os.path.dirname(job_result.path),
os.path.basename(job_result.path),
as_attachment=True, as_attachment=True,
attachment_filename=job_result.filename, attachment_filename=job_result.filename,
directory=os.path.dirname(job_result.path), mimetype=job_result.mimetype
filename=os.path.basename(job_result.path)
) )

View File

@ -1,27 +0,0 @@
from app import db
from app.decorators import background
from app.models import Job
@background
def delete_job(job_id, *args, **kwargs):
with kwargs['app'].app_context():
job = Job.query.get(job_id)
if job is None:
raise Exception(f'Job {job_id} not found')
job.delete()
db.session.commit()
@background
def restart_job(job_id, *args, **kwargs):
with kwargs['app'].app_context():
job = Job.query.get(job_id)
if job is None:
raise Exception(f'Job {job_id} not found')
try:
job.restart()
except Exception:
pass
else:
db.session.commit()

View File

@ -1,30 +1,27 @@
from app.auth.forms import LoginForm
from app.models import User
from flask import flash, redirect, render_template, url_for from flask import flash, redirect, render_template, url_for
from flask_login import login_required, login_user from flask_login import login_required, login_user
from app.auth.forms import LoginForm
from app.models import User
from . import bp from . import bp
@bp.route('/', methods=['GET', 'POST']) @bp.route('', methods=['GET', 'POST'])
def index(): def index():
form = LoginForm(prefix='login-form') form = LoginForm(prefix='login-form')
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(username=form.user.data).first() user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
if user is None: if user and user.verify_password(form.password.data):
user = User.query.filter_by(email=form.user.data.lower()).first()
if user is not None and user.verify_password(form.password.data):
login_user(user, form.remember_me.data) login_user(user, form.remember_me.data)
flash('You have been logged in')
return redirect(url_for('.dashboard')) return redirect(url_for('.dashboard'))
flash('Invalid email/username or password.') flash('Invalid email/username or password', category='error')
redirect(url_for('.index'))
return render_template('main/index.html.j2', form=form, title='nopaque') return render_template('main/index.html.j2', form=form, title='nopaque')
@bp.route('/faq') @bp.route('/faq')
def faq(): def faq():
return render_template( return render_template('main/faq.html.j2', title='Frequently Asked Questions')
'main/faq.html.j2',
title='Frequently Asked Questions'
)
@bp.route('/dashboard') @bp.route('/dashboard')
@ -45,10 +42,7 @@ def news():
@bp.route('/privacy_policy') @bp.route('/privacy_policy')
def privacy_policy(): def privacy_policy():
return render_template( return render_template('main/privacy_policy.html.j2', title='Privacy statement (GDPR)')
'main/privacy_policy.html.j2',
title='Privacy statement (GDPR)'
)
@bp.route('/terms_of_use') @bp.route('/terms_of_use')

View File

@ -1,6 +1,3 @@
from app import db, hashids, login, mail, socketio
from app.converters.vrt import normalize_vrt_file
from app.email import create_message
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum, IntEnum from enum import Enum, IntEnum
from flask import current_app, url_for from flask import current_app, url_for
@ -9,7 +6,7 @@ from flask_login import UserMixin
from time import sleep from time import sleep
from tqdm import tqdm from tqdm import tqdm
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import base64 from werkzeug.utils import secure_filename
import json import json
import jwt import jwt
import os import os
@ -17,6 +14,9 @@ import requests
import shutil import shutil
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import yaml import yaml
from app import db, hashids, login, mail, socketio
from app.converters.vrt import normalize_vrt_file
from app.email import create_message
TRANSKRIBUS_HTR_MODELS = \ TRANSKRIBUS_HTR_MODELS = \
@ -77,14 +77,17 @@ class FileMixin:
''' '''
creation_date = db.Column(db.DateTime, default=datetime.utcnow) creation_date = db.Column(db.DateTime, default=datetime.utcnow)
filename = db.Column(db.String(255)) filename = db.Column(db.String(255))
last_edited_date = db.Column(db.DateTime, default=datetime.utcnow) last_edited_date = db.Column(db.DateTime)
mimetype = db.Column(db.String(255)) mimetype = db.Column(db.String(255))
def file_mixin_to_dict(self, backrefs=False, relationships=False): def file_mixin_to_json(self, backrefs=False, relationships=False):
return { return {
'creation_date': self.creation_date.isoformat() + 'Z', 'creation_date': f'{self.creation_date.isoformat()}Z',
'filename': self.filename, 'filename': self.filename,
'last_edited_date': self.last_edited_date.isoformat() + 'Z', 'last_edited_date': (
None if self.last_edited_date is None
else f'{self.last_edited_date.isoformat()}Z'
),
'mimetype': self.mimetype 'mimetype': self.mimetype
} }
# endregion mixins # endregion mixins
@ -123,10 +126,8 @@ class ContainerColumn(db.TypeDecorator):
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
if isinstance(value, self.container_type): if isinstance(value, self.container_type):
return json.dumps(value) return json.dumps(value)
elif ( elif (isinstance(value, str)
isinstance(value, str) and isinstance(json.loads(value), self.container_type)):
and isinstance(json.loads(value), self.container_type)
):
return value return value
else: else:
return TypeError() return TypeError()
@ -145,17 +146,12 @@ class Role(HashidMixin, db.Model):
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# Fields # Fields
default = db.Column(db.Boolean, default=False, index=True)
name = db.Column(db.String(64), unique=True) name = db.Column(db.String(64), unique=True)
permissions = db.Column(db.Integer) default = db.Column(db.Boolean, default=False, index=True)
permissions = db.Column(db.Integer, default=0)
# Relationships # Relationships
users = db.relationship('User', backref='role', lazy='dynamic') users = db.relationship('User', backref='role', lazy='dynamic')
def __init__(self, **kwargs):
super().__init__(**kwargs)
if self.permissions is None:
self.permissions = 0
def __repr__(self): def __repr__(self):
return f'<Role {self.name}>' return f'<Role {self.name}>'
@ -173,19 +169,19 @@ class Role(HashidMixin, db.Model):
def reset_permissions(self): def reset_permissions(self):
self.permissions = 0 self.permissions = 0
def to_dict(self, backrefs=False, relationships=False): def to_json(self, backrefs=False, relationships=False):
dict_role = { _json = {
'id': self.hashid, 'id': self.hashid,
'default': self.default, 'default': self.default,
'name': self.name, 'name': self.name,
'permissions': self.permissions 'permissions': self.permissions
} }
if relationships: if relationships:
dict_role['users'] = { _json['users'] = {
x.hashid: x.to_dict(backrefs=False, relationships=True) x.hashid: x.to_json(relationships=True)
for x in self.users for x in self.users
} }
return dict_role return _json
@staticmethod @staticmethod
def insert_defaults(): def insert_defaults():
@ -197,7 +193,8 @@ class Role(HashidMixin, db.Model):
Permission.ADMINISTRATE, Permission.ADMINISTRATE,
Permission.CONTRIBUTE, Permission.CONTRIBUTE,
Permission.USE_API Permission.USE_API
] ],
'System user': []
} }
default_role_name = 'User' default_role_name = 'User'
for role_name, permissions in roles.items(): for role_name, permissions in roles.items():
@ -212,29 +209,6 @@ class Role(HashidMixin, db.Model):
db.session.commit() db.session.commit()
class Token(db.Model):
__tablename__ = 'tokens'
# Primary key
id = db.Column(db.Integer, primary_key=True)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# Fields
access_token = db.Column(db.String(64), nullable=False, index=True)
access_expiration = db.Column(db.DateTime, nullable=False)
refresh_token = db.Column(db.String(64), nullable=False, index=True)
refresh_expiration = db.Column(db.DateTime, nullable=False)
# def generate(self):
# header = {'alg': 'HS256', 'exp': int(time.time()) + expiration}
# payload = {'confirm': self.hashid}
# return jwt.encode(header, payload, current_app.config['SECRET_KEY'])
# self.access_token = secrets.token_urlsafe()
# self.access_expiration = datetime.utcnow() + \
# timedelta(minutes=current_app.config['ACCESS_TOKEN_MINUTES'])
# self.refresh_token = secrets.token_urlsafe()
# self.refresh_expiration = datetime.utcnow() + \
# timedelta(days=current_app.config['REFRESH_TOKEN_DAYS'])
class User(HashidMixin, UserMixin, db.Model): class User(HashidMixin, UserMixin, db.Model):
__tablename__ = 'users' __tablename__ = 'users'
# Primary key # Primary key
@ -242,19 +216,17 @@ class User(HashidMixin, UserMixin, db.Model):
# Foreign keys # Foreign keys
role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
# Fields # Fields
confirmed = db.Column(db.Boolean, default=False) email = db.Column(db.String(254), index=True, unique=True)
email = db.Column(db.String(254), unique=True, index=True) username = db.Column(db.String(64), index=True, unique=True)
last_seen = db.Column(db.DateTime(), default=datetime.utcnow)
member_since = db.Column(db.DateTime(), default=datetime.utcnow)
password_hash = db.Column(db.String(128)) password_hash = db.Column(db.String(128))
token = db.Column(db.String(32), index=True, unique=True) confirmed = db.Column(db.Boolean, default=False)
token_expiration = db.Column(db.DateTime) member_since = db.Column(db.DateTime(), default=datetime.utcnow)
username = db.Column(db.String(64), unique=True, index=True)
setting_dark_mode = db.Column(db.Boolean, default=False) setting_dark_mode = db.Column(db.Boolean, default=False)
setting_job_status_mail_notification_level = db.Column( setting_job_status_mail_notification_level = db.Column(
IntEnumColumn(UserSettingJobStatusMailNotificationLevel), IntEnumColumn(UserSettingJobStatusMailNotificationLevel),
default=UserSettingJobStatusMailNotificationLevel.END default=UserSettingJobStatusMailNotificationLevel.END
) )
last_seen = db.Column(db.DateTime())
# Backrefs: role: Role # Backrefs: role: Role
# Relationships # Relationships
tesseract_ocr_models = db.relationship( tesseract_ocr_models = db.relationship(
@ -311,131 +283,35 @@ class User(HashidMixin, UserMixin, db.Model):
return os.path.join( return os.path.join(
current_app.config.get('NOPAQUE_DATA_DIR'), 'users', str(self.id)) current_app.config.get('NOPAQUE_DATA_DIR'), 'users', str(self.id))
def can(self, permission):
return self.role.has_permission(permission)
def confirm_user(self, token):
try:
payload = jwt.decode(
token,
current_app.config['SECRET_KEY'],
algorithms=['HS256'],
issuer=current_app.config['SERVER_NAME'],
options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
)
except jwt.PyJWTError:
return False
if payload.get('purpose') != 'confirm_user':
return False
if payload.get('sub') != self.hashid:
return False
self.confirmed = True
db.session.add(self)
return True
def delete(self):
shutil.rmtree(self.path, ignore_errors=True)
db.session.delete(self)
def generate_confirm_user_token(self, expiration=3600):
utc_now = datetime.utcnow()
payload = {
'exp': utc_now + timedelta(seconds=expiration),
'iat': utc_now,
'iss': current_app.config['SERVER_NAME'],
'purpose': 'confirm_user',
'sub': self.hashid
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
def generate_password_reset_token(self, expiration=3600):
utc_now = datetime.utcnow()
payload = {
'exp': utc_now + timedelta(seconds=expiration),
'iat': utc_now,
'iss': current_app.config['SERVER_NAME'],
'purpose': 'reset_password',
'sub': self.hashid
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
def get_token(self, expires_in=3600):
now = datetime.utcnow()
if self.token and self.token_expiration > now + timedelta(seconds=60):
return self.token
self.token = base64.b64encode(os.urandom(24)).decode('utf-8')
self.token_expiration = now + timedelta(seconds=expires_in)
db.session.add(self)
return self.token
def is_administrator(self):
return self.can(Permission.ADMINISTRATE)
def makedirs(self):
os.mkdir(self.path)
os.mkdir(os.path.join(self.path, 'tesseract_ocr_models'))
os.mkdir(os.path.join(self.path, 'corpora'))
os.mkdir(os.path.join(self.path, 'jobs'))
def revoke_token(self):
self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
def to_dict(self, backrefs=False, relationships=False):
dict_user = {
'id': self.hashid,
'role_id': self.role.hashid,
'confirmed': self.confirmed,
'email': self.email,
'last_seen': self.last_seen.isoformat() + 'Z',
'member_since': self.member_since.isoformat() + 'Z',
'username': self.username,
'settings': {
'dark_mode': self.setting_dark_mode,
'job_status_mail_notification_level':
self.setting_job_status_mail_notification_level.name
}
}
if backrefs:
dict_user['role'] = self.role.to_dict(
backrefs=True, relationships=False)
if relationships:
dict_user['corpora'] = {
x.hashid: x.to_dict(backrefs=False, relationships=True)
for x in self.corpora
}
dict_user['jobs'] = {
x.hashid: x.to_dict(backrefs=False, relationships=True)
for x in self.jobs
}
dict_user['tesseract_ocr_models'] = {
x.hashid: x.to_dict(backrefs=False, relationships=True)
for x in self.tesseract_ocr_models
}
return dict_user
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
@staticmethod @staticmethod
def check_token(token): def create(**kwargs):
user = User.query.filter_by(token=token).first() user = User(**kwargs)
if user is None or user.token_expiration < datetime.utcnow():
return None
return user
@staticmethod
def insert_defaults():
if User.query.filter_by(username='nopaque').first() is not None:
return
user = User(username='nopaque')
db.session.add(user) db.session.add(user)
db.session.flush(objects=[user]) db.session.flush(objects=[user])
db.session.refresh(user) db.session.refresh(user)
try: try:
user.makedirs() os.mkdir(user.path)
os.mkdir(os.path.join(user.path, 'tesseract_ocr_models'))
os.mkdir(os.path.join(user.path, 'corpora'))
os.mkdir(os.path.join(user.path, 'jobs'))
except OSError as e: except OSError as e:
current_app.logger.error(e) current_app.logger.error(e)
db.session.rollback() db.session.rollback()
raise e
return user
@staticmethod
def insert_defaults():
nopaque_user = User.query.filter_by(username='nopaque').first()
system_user_role = Role.query.filter_by(name='System user').first()
if nopaque_user is None:
nopaque_user = User.create(
username='nopaque',
role=system_user_role
)
db.session.add(nopaque_user)
elif nopaque_user.role != system_user_role:
nopaque_user.role = system_user_role
db.session.commit() db.session.commit()
@staticmethod @staticmethod
@ -450,11 +326,9 @@ class User(HashidMixin, UserMixin, db.Model):
) )
except jwt.PyJWTError: except jwt.PyJWTError:
return False return False
if payload.get('purpose') != 'reset_password': if payload.get('purpose') != 'User.reset_password':
return False return False
user_hashid = payload.get('sub') user_hashid = payload.get('sub')
if user_hashid is None:
return False
user_id = hashids.decode(user_hashid) user_id = hashids.decode(user_hashid)
user = User.query.get(user_id) user = User.query.get(user_id)
if user is None: if user is None:
@ -463,6 +337,107 @@ class User(HashidMixin, UserMixin, db.Model):
db.session.add(user) db.session.add(user)
return True return True
def can(self, permission):
return self.role.has_permission(permission)
def confirm(self, confirmation_token):
try:
payload = jwt.decode(
confirmation_token,
current_app.config['SECRET_KEY'],
algorithms=['HS256'],
issuer=current_app.config['SERVER_NAME'],
options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
)
current_app.logger.warning(payload)
except jwt.PyJWTError:
return False
if payload.get('purpose') != 'user.confirm':
return False
if payload.get('sub') != self.hashid:
return False
self.confirmed = True
db.session.add(self)
return True
def delete(self):
shutil.rmtree(self.path, ignore_errors=True)
db.session.delete(self)
def generate_confirm_token(self, expiration=3600):
now = datetime.utcnow()
payload = {
'exp': now + timedelta(seconds=expiration),
'iat': now,
'iss': current_app.config['SERVER_NAME'],
'purpose': 'user.confirm',
'sub': self.hashid
}
return jwt.encode(
payload,
current_app.config['SECRET_KEY'],
algorithm='HS256'
)
def generate_reset_password_token(self, expiration=3600):
now = datetime.utcnow()
payload = {
'exp': now + timedelta(seconds=expiration),
'iat': now,
'iss': current_app.config['SERVER_NAME'],
'purpose': 'User.reset_password',
'sub': self.hashid
}
return jwt.encode(
payload,
current_app.config['SECRET_KEY'],
algorithm='HS256'
)
def is_administrator(self):
return self.can(Permission.ADMINISTRATE)
def ping(self):
self.last_seen = datetime.utcnow()
def verify_password(self, password):
if self.role.name == 'System user':
return False
return check_password_hash(self.password_hash, password)
def to_json(self, backrefs=False, relationships=False):
_json = {
'id': self.hashid,
'confirmed': self.confirmed,
'email': self.email,
'last_seen': (
None if self.last_seen is None
else f'{self.last_seen.isoformat()}Z'
),
'member_since': f'{self.member_since.isoformat()}Z',
'username': self.username,
'settings': {
'dark_mode': self.setting_dark_mode,
'job_status_mail_notification_level': \
self.setting_job_status_mail_notification_level.name
}
}
if backrefs:
_json['role'] = self.role.to_json(backrefs=True)
if relationships:
_json['corpora'] = {
x.hashid: x.to_json(relationships=True)
for x in self.corpora
}
_json['jobs'] = {
x.hashid: x.to_json(relationships=True)
for x in self.jobs
}
_json['tesseract_ocr_models'] = {
x.hashid: x.to_json(relationships=True)
for x in self.tesseract_ocr_models
}
return _json
class TesseractOCRModel(FileMixin, HashidMixin, db.Model): class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
__tablename__ = 'tesseract_ocr_models' __tablename__ = 'tesseract_ocr_models'
@ -471,15 +446,15 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
# Foreign keys # Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id')) user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# Fields # Fields
compatible_service_versions = db.Column(ContainerColumn(list, 255)) title = db.Column(db.String(64))
description = db.Column(db.String(255)) description = db.Column(db.String(255))
version = db.Column(db.String(16))
compatible_service_versions = db.Column(ContainerColumn(list, 255))
publisher = db.Column(db.String(128)) publisher = db.Column(db.String(128))
publisher_url = db.Column(db.String(512)) publisher_url = db.Column(db.String(512))
publishing_url = db.Column(db.String(512)) publishing_url = db.Column(db.String(512))
publishing_year = db.Column(db.Integer) publishing_year = db.Column(db.Integer)
shared = db.Column(db.Boolean, default=False) shared = db.Column(db.Boolean, default=False)
title = db.Column(db.String(64))
version = db.Column(db.String(16))
# Backrefs: user: User # Backrefs: user: User
@property @property
@ -490,30 +465,9 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
str(self.id) str(self.id)
) )
def to_dict(self, backrefs=False, relationships=False):
dict_tesseract_ocr_model = {
'id': self.hashid,
'user_id': self.user.hashid,
'compatible_service_versions': self.compatible_service_versions,
'description': self.description,
'publisher': self.publisher,
'publisher_url': self.publisher_url,
'publishing_url': self.publishing_url,
'publishing_year': self.publishing_year,
'shared': self.shared,
'title': self.title,
**self.file_mixin_to_dict()
}
if backrefs:
dict_tesseract_ocr_model['user'] = self.user.to_dict(
backrefs=True, relationships=False)
if relationships:
pass
return dict_tesseract_ocr_model
@staticmethod @staticmethod
def insert_defaults(): def insert_defaults():
user = User.query.filter_by(username='nopaque').first() nopaque_user = User.query.filter_by(username='nopaque').first()
defaults_file = os.path.join( defaults_file = os.path.join(
os.path.dirname(os.path.abspath(__file__)), os.path.dirname(os.path.abspath(__file__)),
'TesseractOCRModel.defaults.yml' 'TesseractOCRModel.defaults.yml'
@ -542,7 +496,7 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
publishing_year=m['publishing_year'], publishing_year=m['publishing_year'],
shared=True, shared=True,
title=m['title'], title=m['title'],
user=user, user=nopaque_user,
version=m['version'] version=m['version']
) )
db.session.add(model) db.session.add(model)
@ -566,6 +520,23 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
pbar.close() pbar.close()
db.session.commit() db.session.commit()
def to_json(self, backrefs=False, relationships=False):
_json = {
'id': self.hashid,
'compatible_service_versions': self.compatible_service_versions,
'description': self.description,
'publisher': self.publisher,
'publisher_url': self.publisher_url,
'publishing_url': self.publishing_url,
'publishing_year': self.publishing_year,
'shared': self.shared,
'title': self.title,
**self.file_mixin_to_json()
}
if backrefs:
_json['user'] = self.user.to_json(backrefs=True)
return _json
class TranskribusHTRModel(HashidMixin, db.Model): class TranskribusHTRModel(HashidMixin, db.Model):
__tablename__ = 'transkribus_htr_models' __tablename__ = 'transkribus_htr_models'
@ -579,23 +550,9 @@ class TranskribusHTRModel(HashidMixin, db.Model):
transkribus_name = db.Column(db.String(64)) transkribus_name = db.Column(db.String(64))
# Backrefs: user: User # Backrefs: user: User
def to_dict(self, backrefs=False, relationships=False):
dict_tesseract_ocr_model = {
'id': self.hashid,
'user_id': self.user.hashid,
'shared': self.shared,
'transkribus_model_id': self.transkribus_model_id,
}
if backrefs:
dict_tesseract_ocr_model['user'] = \
self.user.to_dict(backrefs=True, relationships=False)
if relationships:
pass
return dict_tesseract_ocr_model
@staticmethod @staticmethod
def insert_defaults(): def insert_defaults():
user = User.query.filter_by(username='nopaque').first() nopaque_user = User.query.filter_by(username='nopaque').first()
# models = [ # models = [
# m for m in TRANSKRIBUS_HTR_MODELS if True # m for m in TRANSKRIBUS_HTR_MODELS if True
# and 'creator' in m and m['creator'] == 'Transkribus Team' # and 'creator' in m and m['creator'] == 'Transkribus Team'
@ -610,11 +567,22 @@ class TranskribusHTRModel(HashidMixin, db.Model):
model = TranskribusHTRModel( model = TranskribusHTRModel(
shared=True, shared=True,
transkribus_model_id=m['modelId'], transkribus_model_id=m['modelId'],
user=user, user=nopaque_user,
) )
db.session.add(model) db.session.add(model)
db.session.commit() db.session.commit()
def to_json(self, backrefs=False, relationships=False):
_json = {
'id': self.hashid,
'user_id': self.user.hashid,
'shared': self.shared,
'transkribus_model_id': self.transkribus_model_id,
}
if backrefs:
_json['user'] = self.user.to_json(backrefs=True)
return _json
class JobInput(FileMixin, HashidMixin, db.Model): class JobInput(FileMixin, HashidMixin, db.Model):
__tablename__ = 'job_inputs' __tablename__ = 'job_inputs'
@ -628,7 +596,7 @@ class JobInput(FileMixin, HashidMixin, db.Model):
return f'<JobInput {self.filename}>' return f'<JobInput {self.filename}>'
@property @property
def download_url(self): def content_url(self):
return url_for( return url_for(
'jobs.download_job_input', 'jobs.download_job_input',
job_id=self.job.id, job_id=self.job.id,
@ -643,19 +611,6 @@ class JobInput(FileMixin, HashidMixin, db.Model):
def path(self): def path(self):
return os.path.join(self.job.path, 'inputs', str(self.id)) return os.path.join(self.job.path, 'inputs', str(self.id))
def to_dict(self, backrefs=False, relationships=False):
dict_job_input = {
'id': self.hashid,
'job_id': self.job.hashid,
'download_url': self.download_url,
'url': self.url,
**self.file_mixin_to_dict()
}
if backrefs:
dict_job_input['job'] = self.job.to_dict(
backrefs=True, relationships=False)
return dict_job_input
@property @property
def url(self): def url(self):
return url_for( return url_for(
@ -672,6 +627,35 @@ class JobInput(FileMixin, HashidMixin, db.Model):
def user_id(self): def user_id(self):
return self.job.user_id return self.job.user_id
@staticmethod
def create(input_file, **kwargs):
filename = kwargs.get('filename', input_file.filename)
mimetype = kwargs.get('mimetype', input_file.mimetype)
job_input = JobInput(
filename=secure_filename(filename),
mimetype=mimetype,
**kwargs
)
db.session.add(job_input)
db.session.flush(objects=[job_input])
db.session.refresh(job_input)
try:
input_file.save(job_input.path)
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
raise e
return job_input
def to_json(self, backrefs=False, relationships=False):
_json = {
'id': self.hashid,
**self.file_mixin_to_json()
}
if backrefs:
_json['job'] = self.job.to_json(backrefs=True)
return _json
class JobResult(FileMixin, HashidMixin, db.Model): class JobResult(FileMixin, HashidMixin, db.Model):
__tablename__ = 'job_results' __tablename__ = 'job_results'
@ -702,21 +686,6 @@ class JobResult(FileMixin, HashidMixin, db.Model):
def path(self): def path(self):
return os.path.join(self.job.path, 'results', str(self.id)) return os.path.join(self.job.path, 'results', str(self.id))
def to_dict(self, backrefs=False, relationships=False):
dict_job_result = {
'id': self.hashid,
'job_id': self.job.hashid,
'description': self.description,
'download_url': self.download_url,
'url': self.url,
**self.file_mixin_to_dict(
backrefs=backrefs, relationships=relationships)
}
if backrefs:
dict_job_result['job'] = self.job.to_dict(
backrefs=True, relationships=False)
return dict_job_result
@property @property
def url(self): def url(self):
return url_for( return url_for(
@ -733,6 +702,39 @@ class JobResult(FileMixin, HashidMixin, db.Model):
def user_id(self): def user_id(self):
return self.job.user_id return self.job.user_id
@staticmethod
def create(input_file, **kwargs):
filename = kwargs.get('filename', input_file.filename)
mimetype = kwargs.get('mimetype', input_file.mimetype)
job_result = JobResult(
filename=secure_filename(filename),
mimetype=mimetype,
**kwargs
)
db.session.add(job_result)
db.session.flush(objects=[job_result])
db.session.refresh(job_result)
try:
input_file.save(job_result.path)
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
raise e
return job_result
def to_json(self, backrefs=False, relationships=False):
_json = {
'id': self.hashid,
'description': self.description,
**self.file_mixin_to_json(
backrefs=backrefs,
relationships=relationships
)
}
if backrefs:
_json['job'] = self.job.to_json(backrefs=True)
return _json
class Job(HashidMixin, db.Model): class Job(HashidMixin, db.Model):
''' '''
@ -744,7 +746,8 @@ class Job(HashidMixin, db.Model):
# Foreign keys # Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id')) user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# Fields # Fields
creation_date = db.Column(db.DateTime(), default=datetime.utcnow) creation_date = \
db.Column(db.DateTime(), default=datetime.utcnow)
description = db.Column(db.String(255)) description = db.Column(db.String(255))
end_date = db.Column(db.DateTime()) end_date = db.Column(db.DateTime())
service = db.Column(db.String(64)) service = db.Column(db.String(64))
@ -789,10 +792,26 @@ class Job(HashidMixin, db.Model):
def user_hashid(self): def user_hashid(self):
return self.user.hashid return self.user.hashid
@staticmethod
def create(**kwargs):
job = Job(**kwargs)
db.session.add(job)
db.session.flush(objects=[job])
db.session.refresh(job)
try:
os.mkdir(job.path)
os.mkdir(os.path.join(job.path, 'inputs'))
os.mkdir(os.path.join(job.path, 'pipeline_data'))
os.mkdir(os.path.join(job.path, 'results'))
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
raise e
return job
def delete(self): def delete(self):
''' ''' Delete the job and its inputs and results from the database. '''
Delete the job and its inputs and results from the database.
'''
if self.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: # noqa if self.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: # noqa
self.status = JobStatus.CANCELING self.status = JobStatus.CANCELING
db.session.commit() db.session.commit()
@ -803,36 +822,34 @@ class Job(HashidMixin, db.Model):
db.session.commit() db.session.commit()
sleep(1) sleep(1)
db.session.refresh(self) db.session.refresh(self)
shutil.rmtree(self.path, ignore_errors=True) try:
shutil.rmtree(self.path)
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
raise e
db.session.delete(self) db.session.delete(self)
def makedirs(self):
os.mkdir(self.path)
os.mkdir(os.path.join(self.path, 'inputs'))
os.mkdir(os.path.join(self.path, 'pipeline_data'))
os.mkdir(os.path.join(self.path, 'results'))
def restart(self): def restart(self):
''' ''' Restart a job - only if the status is failed '''
Restart a job - only if the status is complete or failed if self.status != JobStatus.FAILED:
''' raise Exception('Job status is not "failed"')
if self.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: # noqa
raise Exception('Could not restart job: status is not "completed/failed"') # noqa
shutil.rmtree(os.path.join(self.path, 'results'), ignore_errors=True) shutil.rmtree(os.path.join(self.path, 'results'), ignore_errors=True)
shutil.rmtree(os.path.join(self.path, 'pyflow.data'), ignore_errors=True) # noqa shutil.rmtree(os.path.join(self.path, 'pyflow.data'), ignore_errors=True)
for result in self.results: for result in self.results:
db.session.delete(result) db.session.delete(result)
self.end_date = None self.end_date = None
self.status = JobStatus.SUBMITTED self.status = JobStatus.SUBMITTED
def to_dict(self, backrefs=False, relationships=False): def to_json(self, backrefs=False, relationships=False):
dict_job = { _json = {
'id': self.hashid, 'id': self.hashid,
'user_id': self.user.hashid, 'creation_date': f'{self.creation_date.isoformat()}Z',
'creation_date': self.creation_date.isoformat() + 'Z',
'description': self.description, 'description': self.description,
'end_date': None if self.end_date is None else f'{self.end_date.isoformat()}Z', # noqa 'end_date': (
None if self.end_date is None
else f'{self.end_date.isoformat()}Z'
),
'service': self.service, 'service': self.service,
'service_args': self.service_args, 'service_args': self.service_args,
'service_version': self.service_version, 'service_version': self.service_version,
@ -841,18 +858,17 @@ class Job(HashidMixin, db.Model):
'url': self.url 'url': self.url
} }
if backrefs: if backrefs:
dict_job['user'] = self.user.to_dict( _json['user'] = self.user.to_json(backrefs=True)
backrefs=True, relationships=False)
if relationships: if relationships:
dict_job['inputs'] = { _json['inputs'] = {
x.hashid: x.to_dict(backrefs=False, relationships=True) x.hashid: x.to_json(relationships=True)
for x in self.inputs for x in self.inputs
} }
dict_job['results'] = { _json['results'] = {
x.hashid: x.to_dict(backrefs=False, relationships=True) x.hashid: x.to_json(relationships=True)
for x in self.results for x in self.results
} }
return dict_job return _json
class CorpusFile(FileMixin, HashidMixin, db.Model): class CorpusFile(FileMixin, HashidMixin, db.Model):
@ -862,8 +878,10 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
# Foreign keys # Foreign keys
corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id'))
# Fields # Fields
address = db.Column(db.String(255))
author = db.Column(db.String(255)) author = db.Column(db.String(255))
publishing_year = db.Column(db.Integer)
title = db.Column(db.String(255))
address = db.Column(db.String(255))
booktitle = db.Column(db.String(255)) booktitle = db.Column(db.String(255))
chapter = db.Column(db.String(255)) chapter = db.Column(db.String(255))
editor = db.Column(db.String(255)) editor = db.Column(db.String(255))
@ -871,9 +889,7 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
journal = db.Column(db.String(255)) journal = db.Column(db.String(255))
pages = db.Column(db.String(255)) pages = db.Column(db.String(255))
publisher = db.Column(db.String(255)) publisher = db.Column(db.String(255))
publishing_year = db.Column(db.Integer)
school = db.Column(db.String(255)) school = db.Column(db.String(255))
title = db.Column(db.String(255))
# Backrefs: corpus: Corpus # Backrefs: corpus: Corpus
@property @property
@ -919,11 +935,9 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
db.session.delete(self) db.session.delete(self)
self.corpus.status = CorpusStatus.UNPREPARED self.corpus.status = CorpusStatus.UNPREPARED
def to_dict(self, backrefs=False, relationships=False): def to_json(self, backrefs=False, relationships=False):
dict_corpus_file = { _json = {
'id': self.hashid, 'id': self.hashid,
'corpus_id': self.corpus.hashid,
'download_url': self.download_url,
'url': self.url, 'url': self.url,
'address': self.address, 'address': self.address,
'author': self.author, 'author': self.author,
@ -937,14 +951,34 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
'publishing_year': self.publishing_year, 'publishing_year': self.publishing_year,
'school': self.school, 'school': self.school,
'title': self.title, 'title': self.title,
**self.file_mixin_to_dict( **self.file_mixin_to_json(
backrefs=backrefs, relationships=relationships) backrefs=backrefs,
relationships=relationships
)
} }
if backrefs: if backrefs:
dict_corpus_file['corpus'] = self.corpus.to_dict( _json['corpus'] = self.corpus.to_json(backrefs=True)
backrefs=True, relationships=False) return _json
return dict_corpus_file
@staticmethod
def create(input_file, **kwargs):
filename = kwargs.pop('filename', input_file.filename)
mimetype = kwargs.pop('mimetype', input_file.mimetype)
corpus_file = CorpusFile(
filename=secure_filename(filename),
mimetype=mimetype,
**kwargs,
)
db.session.add(corpus_file)
db.session.flush(objects=[corpus_file])
db.session.refresh(corpus_file)
try:
input_file.save(corpus_file.path)
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
raise e
return corpus_file
class Corpus(HashidMixin, db.Model): class Corpus(HashidMixin, db.Model):
''' '''
@ -958,7 +992,7 @@ class Corpus(HashidMixin, db.Model):
# Fields # Fields
creation_date = db.Column(db.DateTime(), default=datetime.utcnow) creation_date = db.Column(db.DateTime(), default=datetime.utcnow)
description = db.Column(db.String(255)) description = db.Column(db.String(255))
last_edited_date = db.Column(db.DateTime(), default=datetime.utcnow) last_edited_date = db.Column(db.DateTime())
status = db.Column( status = db.Column(
IntEnumColumn(CorpusStatus), IntEnumColumn(CorpusStatus),
default=CorpusStatus.UNPREPARED default=CorpusStatus.UNPREPARED
@ -1000,6 +1034,24 @@ class Corpus(HashidMixin, db.Model):
def user_hashid(self): def user_hashid(self):
return self.user.hashid return self.user.hashid
@staticmethod
def create(**kwargs):
corpus = Corpus(**kwargs)
db.session.add(corpus)
db.session.flush(objects=[corpus])
db.session.refresh(corpus)
try:
os.mkdir(corpus.path)
os.mkdir(os.path.join(corpus.path, 'files'))
os.mkdir(os.path.join(corpus.path, 'cwb'))
os.mkdir(os.path.join(corpus.path, 'cwb', 'data'))
os.mkdir(os.path.join(corpus.path, 'cwb', 'registry'))
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
raise e
return corpus
def build(self): def build(self):
corpus_element = ET.fromstring('<corpus>\n</corpus>') corpus_element = ET.fromstring('<corpus>\n</corpus>')
for corpus_file in self.files: for corpus_file in self.files:
@ -1011,18 +1063,21 @@ class Corpus(HashidMixin, db.Model):
return return
element_tree = ET.parse(normalized_vrt_path) element_tree = ET.parse(normalized_vrt_path)
text_element = element_tree.getroot() text_element = element_tree.getroot()
text_element.set('address', corpus_file.address or 'NULL')
text_element.set('author', corpus_file.author) text_element.set('author', corpus_file.author)
text_element.set('title', corpus_file.title)
text_element.set(
'publishing_year',
f'{corpus_file.publishing_year}'
)
text_element.set('address', corpus_file.address or 'NULL')
text_element.set('booktitle', corpus_file.booktitle or 'NULL') text_element.set('booktitle', corpus_file.booktitle or 'NULL')
text_element.set('chapter', corpus_file.chapter or 'NULL') text_element.set('chapter', corpus_file.chapter or 'NULL')
text_element.set('editor', corpus_file.editor or 'NULL') text_element.set('editor', corpus_file.editor or 'NULL')
text_element.set('institution', corpus_file.institution or 'NULL') text_element.set('institution', corpus_file.institution or 'NULL')
text_element.set('journal', corpus_file.journal or 'NULL') text_element.set('journal', corpus_file.journal or 'NULL')
text_element.set('pages', corpus_file.pages or 'NULL') text_element.set('pages', f'{corpus_file.pages}' or 'NULL')
text_element.set('publisher', corpus_file.publisher or 'NULL') text_element.set('publisher', corpus_file.publisher or 'NULL')
text_element.set('publishing_year', str(corpus_file.publishing_year)) # noqa
text_element.set('school', corpus_file.school or 'NULL') text_element.set('school', corpus_file.school or 'NULL')
text_element.set('title', corpus_file.title)
text_element.tail = '\n' text_element.tail = '\n'
# corpus_element.insert(1, text_element) # corpus_element.insert(1, text_element)
corpus_element.append(text_element) corpus_element.append(text_element)
@ -1037,39 +1092,29 @@ class Corpus(HashidMixin, db.Model):
shutil.rmtree(self.path, ignore_errors=True) shutil.rmtree(self.path, ignore_errors=True)
db.session.delete(self) db.session.delete(self)
def makedirs(self): def to_json(self, backrefs=False, relationships=False):
os.mkdir(self.path) _json = {
os.mkdir(os.path.join(self.path, 'files'))
os.mkdir(os.path.join(self.path, 'cwb'))
os.mkdir(os.path.join(self.path, 'cwb', 'data'))
os.mkdir(os.path.join(self.path, 'cwb', 'registry'))
def to_dict(self, backrefs=False, relationships=False):
dict_corpus = {
'id': self.hashid, 'id': self.hashid,
'user_id': self.user.hashid, 'creation_date': f'{self.creation_date.isoformat()}Z',
'analysis_url': self.analysis_url,
'url': self.url,
'creation_date': self.creation_date.isoformat() + 'Z',
'description': self.description, 'description': self.description,
'max_num_tokens': self.max_num_tokens, 'max_num_tokens': self.max_num_tokens,
'num_analysis_sessions': self.num_analysis_sessions, 'num_analysis_sessions': self.num_analysis_sessions,
'num_tokens': self.num_tokens, 'num_tokens': self.num_tokens,
'status': self.status.name, 'status': self.status.name,
'last_edited_date': self.last_edited_date.isoformat() + 'Z', 'last_edited_date': (
None if self.last_edited_date is None
else f'{self.last_edited_date.isoformat()}Z'
),
'title': self.title 'title': self.title
} }
if backrefs: if backrefs:
dict_corpus['user'] = self.user.to_dict( _json['user'] = self.user.to_json(backrefs=True)
backrefs=True,
relationships=False
)
if relationships: if relationships:
dict_corpus['files'] = { _json['files'] = {
x.hashid: x.to_dict(backrefs=False, relationships=True) x.hashid: x.to_json(relationships=True)
for x in self.files for x in self.files
} }
return dict_corpus return _json
# endregion models # endregion models
@ -1077,6 +1122,8 @@ class Corpus(HashidMixin, db.Model):
# event_handlers # # event_handlers #
############################################################################## ##############################################################################
# region event_handlers # region event_handlers
@db.event.listens_for(Corpus, 'after_delete') @db.event.listens_for(Corpus, 'after_delete')
@db.event.listens_for(CorpusFile, 'after_delete') @db.event.listens_for(CorpusFile, 'after_delete')
@db.event.listens_for(Job, 'after_delete') @db.event.listens_for(Job, 'after_delete')
@ -1096,7 +1143,7 @@ def ressource_after_delete(mapper, connection, ressource):
@db.event.listens_for(JobInput, 'after_insert') @db.event.listens_for(JobInput, 'after_insert')
@db.event.listens_for(JobResult, 'after_insert') @db.event.listens_for(JobResult, 'after_insert')
def ressource_after_insert_handler(mapper, connection, ressource): def ressource_after_insert_handler(mapper, connection, ressource):
value = ressource.to_dict(backrefs=False, relationships=False) value = ressource.to_json()
for attr in mapper.relationships: for attr in mapper.relationships:
value[attr.key] = {} value[attr.key] = {}
jsonpatch = [ jsonpatch = [
@ -1119,7 +1166,7 @@ def ressource_after_update_handler(mapper, connection, ressource):
if not attr.load_history().has_changes(): if not attr.load_history().has_changes():
continue continue
if isinstance(attr.value, datetime): if isinstance(attr.value, datetime):
value = attr.value.isoformat() + 'Z' value = f'{attr.value.isoformat()}Z'
elif isinstance(attr.value, Enum): elif isinstance(attr.value, Enum):
value = attr.value.name value = attr.value.name
else: else:

View File

@ -42,21 +42,17 @@ class QueryResult(FileMixin, HashidMixin, db.Model):
shutil.rmtree(self.path, ignore_errors=True) shutil.rmtree(self.path, ignore_errors=True)
db.session.delete(self) db.session.delete(self)
def to_dict(self, backrefs=False, relationships=False): def to_json(self, backrefs=False, relationships=False):
dict_query_result = { _json = {
'id': self.hashid, 'id': self.hashid,
'user_id': self.user.hashid,
'download_url': self.download_url,
'url': self.url,
'corpus_title': self.query_metadata['corpus_name'], 'corpus_title': self.query_metadata['corpus_name'],
'description': self.description, 'description': self.description,
'filename': self.filename, 'filename': self.filename,
'query': self.query_metadata['query'], 'query': self.query_metadata['query'],
'query_metadata': self.query_metadata, 'query_metadata': self.query_metadata,
'title': self.title, 'title': self.title,
**self.file_mixin_to_dict( **self.file_mixin_to_json(
backrefs=backrefs, relationships=relationships) backrefs=backrefs, relationships=relationships)
} }
if backrefs: if backrefs:
dict_query_result['user'] = self.user.to_dict( _json['user'] = self.user.to_json(backrefs=True, relationships=False)
backrefs=True, relationships=False)

View File

@ -1,4 +1,3 @@
from app.models import TesseractOCRModel, TranskribusHTRModel
from flask_login import current_user from flask_login import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired from flask_wtf.file import FileField, FileRequired
@ -10,19 +9,26 @@ from wtforms import (
SubmitField, SubmitField,
ValidationError ValidationError
) )
from wtforms.validators import DataRequired, InputRequired, Length from wtforms.validators import InputRequired, Length
from app.models import TesseractOCRModel, TranskribusHTRModel
from . import SERVICES from . import SERVICES
class AddJobForm(FlaskForm): class CreateJobBaseForm(FlaskForm):
description = StringField('Description', validators=[InputRequired(), Length(1, 255)]) description = StringField(
title = StringField('Title', validators=[InputRequired(), Length(1, 32)]) 'Description',
version = SelectField('Version', validators=[DataRequired()]) validators=[InputRequired(), Length(max=255)]
)
title = StringField(
'Title',
validators=[InputRequired(), Length(max=32)]
)
version = SelectField('Version', validators=[InputRequired()])
submit = SubmitField() submit = SubmitField()
class AddFileSetupPipelineJobForm(AddJobForm): class CreateFileSetupPipelineJobForm(CreateJobBaseForm):
images = MultipleFileField('File(s)', validators=[DataRequired()]) images = MultipleFileField('File(s)', validators=[InputRequired()])
def validate_images(form, field): def validate_images(form, field):
valid_mimetypes = ['image/jpeg', 'image/png', 'image/tiff'] valid_mimetypes = ['image/jpeg', 'image/png', 'image/tiff']
@ -39,18 +45,15 @@ class AddFileSetupPipelineJobForm(AddJobForm):
self.version.default = service_manifest['latest_version'] self.version.default = service_manifest['latest_version']
class AddTesseractOCRPipelineJobForm(AddJobForm): class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
binarization = BooleanField('Binarization') binarization = BooleanField('Binarization')
pdf = FileField('File', validators=[FileRequired()]) pdf = FileField('File', validators=[FileRequired()])
model = SelectField('Model', validators=[DataRequired()]) model = SelectField('Model', validators=[InputRequired()])
def validate_binarization(self, field): def validate_binarization(self, field):
service_info = SERVICES['tesseract-ocr-pipeline']['versions'][self.version.data] service_info = SERVICES['tesseract-ocr-pipeline']['versions'][self.version.data]
if field.data: if field.data:
if( if not('methods' in service_info and 'binarization' in service_info['methods']):
'methods' not in service_info
or 'binarization' not in service_info['methods']
):
raise ValidationError('Binarization is not available') raise ValidationError('Binarization is not available')
def validate_pdf(self, field): def validate_pdf(self, field):
@ -81,10 +84,10 @@ class AddTesseractOCRPipelineJobForm(AddJobForm):
self.version.default = service_manifest['latest_version'] self.version.default = service_manifest['latest_version']
class AddTranskribusHTRPipelineJobForm(AddJobForm): class CreateTranskribusHTRPipelineJobForm(CreateJobBaseForm):
binarization = BooleanField('Binarization') binarization = BooleanField('Binarization')
pdf = FileField('File', validators=[FileRequired()]) pdf = FileField('File', validators=[FileRequired()])
model = SelectField('Model', validators=[DataRequired()]) model = SelectField('Model', validators=[InputRequired()])
def validate_binarization(self, field): def validate_binarization(self, field):
service_info = SERVICES['transkribus-htr-pipeline']['versions'][self.version.data] service_info = SERVICES['transkribus-htr-pipeline']['versions'][self.version.data]
@ -123,10 +126,10 @@ class AddTranskribusHTRPipelineJobForm(AddJobForm):
self.version.default = service_manifest['latest_version'] self.version.default = service_manifest['latest_version']
class AddSpacyNLPPipelineJobForm(AddJobForm): class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
encoding_detection = BooleanField('Encoding detection', render_kw={'disabled': True}) encoding_detection = BooleanField('Encoding detection', render_kw={'disabled': True})
txt = FileField('File', validators=[FileRequired()]) txt = FileField('File', validators=[FileRequired()])
model = SelectField('Model', validators=[DataRequired()]) model = SelectField('Model', validators=[InputRequired()])
def validate_encoding_detection(self, field): def validate_encoding_detection(self, field):
service_info = SERVICES['spacy-nlp-pipeline']['versions'][self.version.data] service_info = SERVICES['spacy-nlp-pipeline']['versions'][self.version.data]

View File

@ -1,3 +1,5 @@
from flask import abort, current_app, flash, Markup, render_template, request
from flask_login import current_user, login_required
from app import db, hashids from app import db, hashids
from app.models import ( from app.models import (
Job, Job,
@ -7,26 +9,13 @@ from app.models import (
TRANSKRIBUS_HTR_MODELS, TRANSKRIBUS_HTR_MODELS,
TranskribusHTRModel TranskribusHTRModel
) )
from flask import ( from . import bp, SERVICES
abort,
current_app,
flash,
make_response,
render_template,
request,
url_for
)
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from . import bp
from . import SERVICES
from .forms import ( from .forms import (
AddFileSetupPipelineJobForm, CreateFileSetupPipelineJobForm,
AddTesseractOCRPipelineJobForm, CreateTesseractOCRPipelineJobForm,
AddTranskribusHTRPipelineJobForm, CreateTranskribusHTRPipelineJobForm,
AddSpacyNLPPipelineJobForm CreateSpacyNLPPipelineJobForm
) )
import json
@bp.route('/file-setup-pipeline', methods=['GET', 'POST']) @bp.route('/file-setup-pipeline', methods=['GET', 'POST'])
@ -37,49 +26,32 @@ def file_setup_pipeline():
version = request.args.get('version', service_manifest['latest_version']) version = request.args.get('version', service_manifest['latest_version'])
if version not in service_manifest['versions']: if version not in service_manifest['versions']:
abort(404) abort(404)
form = AddFileSetupPipelineJobForm(prefix='add-job-form', version=version) form = CreateFileSetupPipelineJobForm(prefix='create-job-form', version=version)
if form.is_submitted(): if form.is_submitted():
if not form.validate(): if not form.validate():
return make_response(form.errors, 400) response = {'errors': form.errors}
service_args = {} return response, 400
job = Job(
user=current_user,
description=form.description.data,
service=service,
service_args=service_args,
service_version=form.version.data,
title=form.title.data
)
db.session.add(job)
db.session.flush(objects=[job])
db.session.refresh(job)
try: try:
job.makedirs() job = Job.create(
except OSError as e: title=form.title.data,
current_app.logger.error(e) description=form.description.data,
db.session.rollback() service=service,
flash('Internal Server Error', 'error') service_args={},
return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa service_version=form.version.data,
for image_file in form.images.data: user=current_user
job_input = JobInput(
filename=secure_filename(image_file.filename),
job=job,
mimetype=image_file.mimetype
) )
db.session.add(job_input) except OSError:
db.session.flush(objects=[job_input]) abort(500)
db.session.refresh(job_input) for input_file in form.images.data:
try: try:
image_file.save(job_input.path) JobInput.create(input_file, job=job)
except OSError as e: except OSError:
current_app.logger.error(e) abort(500)
db.session.rollback()
flash('Internal Server Error', 'error')
return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa
job.status = JobStatus.SUBMITTED job.status = JobStatus.SUBMITTED
db.session.commit() db.session.commit()
flash(f'Job "{job.title}" added', 'job') message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201) # noqa flash(message, 'job')
return {}, 201, {'Location': job.url}
return render_template( return render_template(
'services/file_setup_pipeline.html.j2', 'services/file_setup_pipeline.html.j2',
form=form, form=form,
@ -90,58 +62,41 @@ def file_setup_pipeline():
@bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST']) @bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST'])
@login_required @login_required
def tesseract_ocr_pipeline(): def tesseract_ocr_pipeline():
service = 'tesseract-ocr-pipeline' service_name = 'tesseract-ocr-pipeline'
service_manifest = SERVICES[service] service_manifest = SERVICES[service_name]
version = request.args.get('version', service_manifest['latest_version']) version = request.args.get('version', service_manifest['latest_version'])
if version not in service_manifest['versions']: if version not in service_manifest['versions']:
abort(404) abort(404)
form = AddTesseractOCRPipelineJobForm(prefix='add-job-form', version=version) form = CreateTesseractOCRPipelineJobForm(prefix='create-job-form', version=version)
if form.is_submitted(): if form.is_submitted():
if not form.validate(): if not form.validate():
return make_response(form.errors, 400) response = {'errors': form.errors}
service_args = {} return response, 400
service_args['model'] = hashids.decode(form.model.data)
if form.binarization.data:
service_args['binarization'] = True
job = Job(
user=current_user,
description=form.description.data,
service=service,
service_args=service_args,
service_version=form.version.data,
title=form.title.data
)
db.session.add(job)
db.session.flush(objects=[job])
db.session.refresh(job)
try: try:
job.makedirs() job = Job.create(
except OSError as e: title=form.title.data,
current_app.logger.error(e) description=form.description.data,
db.session.rollback() service=service_name,
flash('Internal Server Error', 'error') service_args={
return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa 'binarization': form.binarization.data,
job_input = JobInput( 'model': hashids.decode(form.model.data)
filename=secure_filename(form.pdf.data.filename), },
job=job, service_version=form.version.data,
mimetype=form.pdf.data.mimetype user=current_user
) )
db.session.add(job_input) except OSError:
db.session.flush(objects=[job_input]) abort(500)
db.session.refresh(job_input)
try: try:
form.pdf.data.save(job_input.path) JobInput.create(form.pdf.data, job=job)
except OSError as e: except OSError:
current_app.logger.error(e) abort(500)
db.session.rollback()
flash('Internal Server Error', 'error')
return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa
job.status = JobStatus.SUBMITTED job.status = JobStatus.SUBMITTED
db.session.commit() db.session.commit()
flash(f'Job "{job.title}" added', 'job') message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201) # noqa flash(message, 'job')
return {}, 201, {'Location': job.url}
tesseract_ocr_models = [ tesseract_ocr_models = [
x for x in TesseractOCRModel.query.filter().all() x for x in TesseractOCRModel.query.all()
if version in x.compatible_service_versions and (x.shared == True or x.user == current_user) if version in x.compatible_service_versions and (x.shared == True or x.user == current_user)
] ]
return render_template( return render_template(
@ -162,57 +117,40 @@ def transkribus_htr_pipeline():
version = request.args.get('version', service_manifest['latest_version']) version = request.args.get('version', service_manifest['latest_version'])
if version not in service_manifest['versions']: if version not in service_manifest['versions']:
abort(404) abort(404)
form = AddTranskribusHTRPipelineJobForm(prefix='add-job-form', version=version) form = CreateTranskribusHTRPipelineJobForm(prefix='create-job-form', version=version)
if form.is_submitted(): if form.is_submitted():
if not form.validate(): if not form.validate():
return make_response(form.errors, 400) response = {'errors': form.errors}
service_args = {} return response, 400
service_args['model'] = hashids.decode(form.model.data)
if form.binarization.data:
service_args['binarization'] = True
job = Job(
user=current_user,
description=form.description.data,
service=service,
service_args=service_args,
service_version=form.version.data,
title=form.title.data
)
db.session.add(job)
db.session.flush(objects=[job])
db.session.refresh(job)
try: try:
job.makedirs() job = Job.create(
except OSError as e: title=form.title.data,
current_app.logger.error(e) description=form.description.data,
db.session.rollback() service=service,
flash('Internal Server Error', 'error') service_args={
return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa 'binarization': form.binarization.data,
job_input = JobInput( 'model': hashids.decode(form.model.data)
filename=secure_filename(form.pdf.data.filename), },
job=job, service_version=form.version.data,
mimetype=form.pdf.data.mimetype user=current_user
) )
db.session.add(job_input) except OSError:
db.session.flush(objects=[job_input]) abort(500)
db.session.refresh(job_input)
try: try:
form.pdf.data.save(job_input.path) JobInput.create(form.pdf.data, job=job)
except OSError as e: except OSError:
current_app.logger.error(e) abort(500)
db.session.rollback()
flash('Internal Server Error', 'error')
return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa
job.status = JobStatus.SUBMITTED job.status = JobStatus.SUBMITTED
db.session.commit() db.session.commit()
flash(f'Job "{job.title}" added', 'job') message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201) # noqa flash(message, 'job')
return {}, 201, {'Location': job.url}
transkribus_htr_models = [ transkribus_htr_models = [
x for x in TranskribusHTRModel.query.filter().all() x for x in TranskribusHTRModel.query.all()
if x.shared == True or x.user == current_user if x.shared == True or x.user == current_user
] ]
return render_template( return render_template(
f'services/transkribus_htr_pipeline.html.j2', 'services/transkribus_htr_pipeline.html.j2',
form=form, form=form,
title=service_manifest['name'], title=service_manifest['name'],
TRANSKRIBUS_HTR_MODELS=TRANSKRIBUS_HTR_MODELS, TRANSKRIBUS_HTR_MODELS=TRANSKRIBUS_HTR_MODELS,
@ -228,51 +166,34 @@ def spacy_nlp_pipeline():
version = request.args.get('version', SERVICES[service]['latest_version']) version = request.args.get('version', SERVICES[service]['latest_version'])
if version not in service_manifest['versions']: if version not in service_manifest['versions']:
abort(404) abort(404)
form = AddSpacyNLPPipelineJobForm(prefix='add-job-form', version=version) form = CreateSpacyNLPPipelineJobForm(prefix='create-job-form', version=version)
if form.is_submitted(): if form.is_submitted():
if not form.validate(): if not form.validate():
return make_response(form.errors, 400) response = {'errors': form.errors}
service_args = {} return response, 400
service_args['model'] = form.model.data
if form.encoding_detection.data:
service_args['encoding_detection'] = True
job = Job(
user=current_user,
description=form.description.data,
service=service,
service_args=service_args,
service_version=form.version.data,
title=form.title.data
)
db.session.add(job)
db.session.flush(objects=[job])
db.session.refresh(job)
try: try:
job.makedirs() job = Job.create(
except OSError as e: title=form.title.data,
current_app.logger.error(e) description=form.description.data,
db.session.rollback() service=service,
flash('Internal Server Error', 'error') service_args={
return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa 'encoding_detection': form.encoding_detection.data,
job_input = JobInput( 'model': form.model.data
filename=secure_filename(form.txt.data.filename), },
job=job, service_version=form.version.data,
mimetype=form.txt.data.mimetype user=current_user
) )
db.session.add(job_input) except OSError:
db.session.flush(objects=[job_input]) abort(500)
db.session.refresh(job_input)
try: try:
form.txt.data.save(job_input.path) JobInput.create(form.txt.data, job=job)
except OSError as e: except OSError:
current_app.logger.error(e) abort(500)
db.session.rollback()
flash('Internal Server Error', 'error')
return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa
job.status = JobStatus.SUBMITTED job.status = JobStatus.SUBMITTED
db.session.commit() db.session.commit()
flash(f'Job "{job.title}" added', 'job') message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201) # noqa flash(message, 'job')
return {}, 201, {'Location': job.url}
return render_template( return render_template(
'services/spacy_nlp_pipeline.html.j2', 'services/spacy_nlp_pipeline.html.j2',
form=form, form=form,

View File

@ -17,6 +17,11 @@ tesseract-ocr-pipeline:
- 'binarization' - 'binarization'
publishing_year: 2022 publishing_year: 2022
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.0' url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.0'
0.1.1:
methods:
- 'binarization'
publishing_year: 2022
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.1'
transkribus-htr-pipeline: transkribus-htr-pipeline:
name: 'Transkribus HTR Pipeline' name: 'Transkribus HTR Pipeline'
publisher: 'Bielefeld University - CRC 1288 - INF' publisher: 'Bielefeld University - CRC 1288 - INF'
@ -47,4 +52,4 @@ spacy-nlp-pipeline:
ru: 'Russian' ru: 'Russian'
zh: 'Chinese' zh: 'Chinese'
publishing_year: 2022 publishing_year: 2022
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.0' url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.0'

View File

@ -1,5 +1,3 @@
from app.auth import USERNAME_REGEX
from app.models import User, UserSettingJobStatusMailNotificationLevel
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import ( from wtforms import (
BooleanField, BooleanField,
@ -9,14 +7,35 @@ from wtforms import (
SubmitField, SubmitField,
ValidationError ValidationError
) )
from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp from wtforms.validators import (
DataRequired,
InputRequired,
Email,
EqualTo,
Length,
Regexp
)
from app.models import User, UserSettingJobStatusMailNotificationLevel
from app.auth import USERNAME_REGEX
class ChangePasswordForm(FlaskForm): class ChangePasswordForm(FlaskForm):
password = PasswordField('Old password', validators=[DataRequired()]) password = PasswordField('Old password', validators=[DataRequired()])
new_password = PasswordField('New password', validators=[DataRequired(), EqualTo('new_password_confirmation', message='Passwords must match')]) new_password = PasswordField(
new_password_confirmation = PasswordField('Confirm new password', validators=[DataRequired(), EqualTo('new_password', message='Passwords must match')]) 'New password',
submit = SubmitField('Submit') validators=[
DataRequired(),
EqualTo('new_password_2', message='Passwords must match')
]
)
new_password_2 = PasswordField(
'New password confirmation',
validators=[
DataRequired(),
EqualTo('new_password', message='Passwords must match')
]
)
submit = SubmitField()
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -28,43 +47,51 @@ class ChangePasswordForm(FlaskForm):
class EditGeneralSettingsForm(FlaskForm): class EditGeneralSettingsForm(FlaskForm):
email = StringField('E-Mail', validators=[DataRequired(), Length(1, 254), Email()]) email = StringField(
'E-Mail',
validators=[InputRequired(), Length(max=254), Email()]
)
username = StringField( username = StringField(
'Username', 'Username',
validators=[ validators=[
InputRequired(), InputRequired(),
Length(1, 64), Length(max=64),
Regexp( Regexp(
USERNAME_REGEX, USERNAME_REGEX,
message='Usernames must have only letters, numbers, dots or underscores' # noqa message=(
'Usernames must have only letters, numbers, dots or '
'underscores'
)
) )
] ]
) )
submit = SubmitField('Submit') submit = SubmitField()
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.user = user self.user = user
def prefill(self, user):
self.email.data = user.email
self.username.data = user.username
def validate_email(self, field): def validate_email(self, field):
if ( if (field.data != self.user.email
field.data != self.user.email and User.query.filter_by(email=field.data).first()):
and User.query.filter_by(email=field.data).first()
):
raise ValidationError('Email already registered') raise ValidationError('Email already registered')
def validate_username(self, field): def validate_username(self, field):
if ( if (field.data != self.user.username
field.data != self.user.username and User.query.filter_by(username=field.data).first()):
and User.query.filter_by(username=field.data).first()
):
raise ValidationError('Username already in use') raise ValidationError('Username already in use')
class EditInterfaceSettingsForm(FlaskForm): class EditInterfaceSettingsForm(FlaskForm):
dark_mode = BooleanField('Dark mode') dark_mode = BooleanField('Dark mode')
submit = SubmitField('Submit') submit = SubmitField()
def prefill(self, user):
self.dark_mode.data = user.setting_dark_mode
class EditNotificationSettingsForm(FlaskForm): class EditNotificationSettingsForm(FlaskForm):
job_status_mail_notification_level = SelectField( job_status_mail_notification_level = SelectField(
@ -72,11 +99,15 @@ class EditNotificationSettingsForm(FlaskForm):
choices=[('', 'Choose your option')], choices=[('', 'Choose your option')],
validators=[DataRequired()] validators=[DataRequired()]
) )
submit = SubmitField('Submit') submit = SubmitField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.job_status_mail_notification_level.choices += [ self.job_status_mail_notification_level.choices += [
(enum_member.name, enum_member.name.capitalize()) (x.name, x.name.capitalize())
for enum_member in UserSettingJobStatusMailNotificationLevel for x in UserSettingJobStatusMailNotificationLevel
] ]
def prefill(self, user):
self.job_status_mail_notification_level.data = \
user.setting_job_status_mail_notification_level.name

View File

@ -1,32 +1,32 @@
from flask import flash, redirect, render_template, url_for from flask import flash, redirect, render_template, url_for
from flask_login import current_user, login_required, logout_user from flask_login import current_user, login_required
from . import bp, tasks from app import db
from app.models import UserSettingJobStatusMailNotificationLevel
from . import bp
from .forms import ( from .forms import (
ChangePasswordForm, ChangePasswordForm,
EditGeneralSettingsForm, EditGeneralSettingsForm,
EditInterfaceSettingsForm, EditInterfaceSettingsForm,
EditNotificationSettingsForm EditNotificationSettingsForm
) )
from .. import db
from ..models import UserSettingJobStatusMailNotificationLevel
@bp.route('', methods=['GET', 'POST']) @bp.route('', methods=['GET', 'POST'])
@login_required @login_required
def index(): def settings():
change_password_form = ChangePasswordForm( change_password_form = ChangePasswordForm(
current_user._get_current_object(), current_user,
prefix='change_password_form' prefix='change-password-form'
) )
edit_general_settings_form = EditGeneralSettingsForm( edit_general_settings_form = EditGeneralSettingsForm(
current_user._get_current_object(), current_user,
prefix='edit_general_settings_form' prefix='edit-general-settings-form'
) )
edit_interface_settings_form = EditInterfaceSettingsForm( edit_interface_settings_form = EditInterfaceSettingsForm(
prefix='edit_interface_settings_form' prefix='edit-interface-settings-form'
) )
edit_notification_settings_form = EditNotificationSettingsForm( edit_notification_settings_form = EditNotificationSettingsForm(
prefix='edit_notification_settings_form' prefix='edit-notification-settings-form'
) )
if change_password_form.submit.data and change_password_form.validate(): if change_password_form.submit.data and change_password_form.validate():
@ -34,58 +34,38 @@ def index():
db.session.commit() db.session.commit()
flash('Your changes have been saved') flash('Your changes have been saved')
return redirect(url_for('.index')) return redirect(url_for('.index'))
if ( if (edit_general_settings_form.submit.data
edit_general_settings_form.submit.data and edit_general_settings_form.validate()):
and edit_general_settings_form.validate()
):
current_user.email = edit_general_settings_form.email.data current_user.email = edit_general_settings_form.email.data
current_user.username = edit_general_settings_form.username.data current_user.username = edit_general_settings_form.username.data
db.session.commit() db.session.commit()
flash('Your changes have been saved') flash('Your changes have been saved')
return redirect(url_for('.index')) return redirect(url_for('.settings'))
if ( if (edit_interface_settings_form.submit.data
edit_interface_settings_form.submit.data and edit_interface_settings_form.validate()):
and edit_interface_settings_form.validate() current_user.setting_dark_mode = (
): edit_interface_settings_form.dark_mode.data)
current_user.setting_dark_mode = \
edit_interface_settings_form.dark_mode.data
db.session.commit() db.session.commit()
flash('Your changes have been saved') flash('Your changes have been saved')
return redirect(url_for('.index')) return redirect(url_for('.settings'))
if ( if (edit_notification_settings_form.submit.data
edit_notification_settings_form.submit.data and edit_notification_settings_form.validate()):
and edit_notification_settings_form.validate() current_user.setting_job_status_mail_notification_level = (
):
current_user.setting_job_status_mail_notification_level = \
UserSettingJobStatusMailNotificationLevel[ UserSettingJobStatusMailNotificationLevel[
edit_notification_settings_form.job_status_mail_notification_level.data # noqa edit_notification_settings_form.job_status_mail_notification_level.data # noqa
] ]
)
db.session.commit() db.session.commit()
flash('Your changes have been saved') flash('Your changes have been saved')
return redirect(url_for('.index')) return redirect(url_for('.settings'))
edit_general_settings_form.email.data = current_user.email edit_general_settings_form.prefill(current_user)
edit_general_settings_form.username.data = current_user.username edit_interface_settings_form.prefill(current_user)
edit_interface_settings_form.dark_mode.data = \ edit_notification_settings_form.prefill(current_user)
current_user.setting_dark_mode
edit_notification_settings_form.job_status_mail_notification_level.data = \
current_user.setting_job_status_mail_notification_level.name
return render_template( return render_template(
'settings/index.html.j2', 'settings/settings.html.j2',
change_password_form=change_password_form, change_password_form=change_password_form,
edit_general_settings_form=edit_general_settings_form, edit_general_settings_form=edit_general_settings_form,
edit_interface_settings_form=edit_interface_settings_form, edit_interface_settings_form=edit_interface_settings_form,
edit_notification_settings_form=edit_notification_settings_form, edit_notification_settings_form=edit_notification_settings_form,
title='Settings' title='Settings'
) )
@bp.route('/delete')
@login_required
def delete():
"""
View to delete current_user and all associated data.
"""
tasks.delete_user(current_user.id)
logout_user()
flash('Your account has been marked for deletion')
return redirect(url_for('main.index'))

View File

@ -1,13 +0,0 @@
from app import db
from app.decorators import background
from app.models import User
@background
def delete_user(user_id, *args, **kwargs):
with kwargs['app'].app_context():
user = User.query.get(user_id)
if user is None:
raise Exception(f'User {user_id} not found')
user.delete()
db.session.commit()

View File

@ -5,12 +5,7 @@ class App {
users: {}, users: {},
}; };
this.socket = io({transports: ['websocket'], upgrade: false}); this.socket = io({transports: ['websocket'], upgrade: false});
this.socket.on('PATCH', (patch) => { this.socket.on('PATCH', (patch) => {this.onPatch(patch);});
const re = new RegExp(`^/users/(${Object.keys(this.data.users).join('|')})`);
const filteredPatch = patch.filter(operation => re.test(operation.path));
jsonpatch.applyPatch(this.data, filteredPatch);
});
} }
getUser(userId) { getUser(userId) {
@ -19,14 +14,25 @@ class App {
} }
this.data.promises.getUser[userId] = new Promise((resolve, reject) => { this.data.promises.getUser[userId] = new Promise((resolve, reject) => {
this.socket.emit('GET /users/<user_id>', userId, (response) => { fetch(`/users/${userId}?backrefs=true&relationships=true`, {headers: {Accept: 'application/json'}})
if (response.code === 200) { .then(
this.data.users[userId] = response.payload; (response) => {return response.json();},
resolve(this.data.users[userId]); (response) => {
} else { if (response.status === 403) {this.flash('Forbidden', 'error');}
reject(response); if (response.status === 404) {this.flash('Not Found', 'error');}
} reject(response);
}); }
)
.then(
(user) => {
this.data.users[userId] = user;
resolve(this.data.users[userId]);
},
(error) => {
console.error(error, 'error');
reject(error);
}
);
}); });
return this.data.promises.getUser[userId]; return this.data.promises.getUser[userId];
@ -51,35 +57,55 @@ class App {
} }
flash(message, category) { flash(message, category) {
let iconPrefix; let iconPrefix = '';
let toast;
let toastCloseActionElement;
switch (category) { switch (category) {
case 'corpus': case 'corpus': {
iconPrefix = '<i class="left material-icons">book</i>'; iconPrefix = '<i class="left material-icons">book</i>';
break; break;
case 'error': }
case 'error': {
iconPrefix = '<i class="error-color-text left material-icons">error</i>'; iconPrefix = '<i class="error-color-text left material-icons">error</i>';
break; break;
case 'job': }
case 'job': {
iconPrefix = '<i class="left nopaque-icons">J</i>'; iconPrefix = '<i class="left nopaque-icons">J</i>';
break; break;
default: }
default: {
iconPrefix = '<i class="left material-icons">notifications</i>'; iconPrefix = '<i class="left material-icons">notifications</i>';
break; break;
}
} }
toast = M.toast( let toast = M.toast(
{ {
html: ` html: `
<span>${iconPrefix}${message}</span> <span>${iconPrefix}${message}</span>
<button class="btn-flat toast-action white-text" data-action="close"> <button class="action-button btn-flat toast-action white-text" data-action="close">
<i class="material-icons">close</i> <i class="material-icons">close</i>
</button> </button>
`.trim() `.trim()
} }
); );
toastCloseActionElement = toast.el.querySelector('.toast-action[data-action="close"]'); let toastCloseActionElement = toast.el.querySelector('.action-button[data-action="close"]');
toastCloseActionElement.addEventListener('click', () => {toast.dismiss();}); 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);
}
} }

View File

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

View File

@ -2,11 +2,20 @@ class CorpusDisplay extends RessourceDisplay {
constructor(displayElement) { constructor(displayElement) {
super(displayElement); super(displayElement);
this.corpusId = displayElement.dataset.corpusId; this.corpusId = displayElement.dataset.corpusId;
this.displayElement
.querySelector('.action-button[data-action="build-request"]')
.addEventListener('click', (event) => {
Utils.buildCorpusRequest(this.userId, this.corpusId);
});
this.displayElement
.querySelector('.action-button[data-action="delete-request"]')
.addEventListener('click', (event) => {
Utils.deleteCorpusRequest(this.userId, this.corpusId);
});
} }
init(user) { init(user) {
const corpus = user.corpora[this.corpusId]; let corpus = user.corpora[this.corpusId];
this.setCreationDate(corpus.creation_date); this.setCreationDate(corpus.creation_date);
this.setDescription(corpus.description); this.setDescription(corpus.description);
this.setLastEditedDate(corpus.last_edited_date); this.setLastEditedDate(corpus.last_edited_date);
@ -15,20 +24,20 @@ class CorpusDisplay extends RessourceDisplay {
this.setNumTokens(corpus.num_tokens); this.setNumTokens(corpus.num_tokens);
} }
onPATCH(patch) { onPatch(patch) {
if (!this.isInitialized) {return;} let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
let filteredPatch; for (let operation of filteredPatch) {
let operation;
let re;
re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`);
filteredPatch = patch.filter(operation => re.test(operation.path));
for (operation of filteredPatch) {
switch(operation.op) { switch(operation.op) {
case 'replace': case 'remove': {
re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/last_edited_date$`); let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}$`);
if (re.test(operation.path)) {
window.location.href = '/dashboard#corpora';
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/last_edited_date$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {
this.setLastEditedDate(operation.value); this.setLastEditedDate(operation.value);
break; break;
@ -44,8 +53,10 @@ class CorpusDisplay extends RessourceDisplay {
break; break;
} }
break; break;
default: }
default: {
break; break;
}
} }
} }
} }
@ -66,19 +77,16 @@ class CorpusDisplay extends RessourceDisplay {
} }
setStatus(status) { setStatus(status) {
let element; let elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger')
let elements; for (let element of elements) {
elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger')
for (element of elements) {
if (['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) { if (['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) {
element.classList.remove('disabled'); element.classList.remove('disabled');
} else { } else {
element.classList.add('disabled'); element.classList.add('disabled');
} }
} }
elements = this.displayElement.querySelectorAll('.corpus-build-trigger'); elements = this.displayElement.querySelectorAll('.action-button[data-action="build-request"]');
for (element of elements) { 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(app.data.users[this.userId].corpora[this.corpusId].files).length > 0) {
element.classList.remove('disabled'); element.classList.remove('disabled');
} else { } else {
@ -86,11 +94,11 @@ class CorpusDisplay extends RessourceDisplay {
} }
} }
elements = this.displayElement.querySelectorAll('.corpus-status'); elements = this.displayElement.querySelectorAll('.corpus-status');
for (element of elements) { for (let element of elements) {
element.dataset.corpusStatus = status; element.dataset.corpusStatus = status;
} }
elements = this.displayElement.querySelectorAll('.corpus-status-spinner'); elements = this.displayElement.querySelectorAll('.corpus-status-spinner');
for (element of elements) { for (let element of elements) {
if (['SUBMITTED', 'QUEUED', 'BUILDING', 'STARTING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) { if (['SUBMITTED', 'QUEUED', 'BUILDING', 'STARTING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) {
element.classList.remove('hide'); element.classList.remove('hide');
} else { } else {

View File

@ -2,11 +2,25 @@ class JobDisplay extends RessourceDisplay {
constructor(displayElement) { constructor(displayElement) {
super(displayElement); super(displayElement);
this.jobId = this.displayElement.dataset.jobId; this.jobId = this.displayElement.dataset.jobId;
this.displayElement
.querySelector('.action-button[data-action="delete-request"]')
.addEventListener('click', (event) => {
Utils.deleteJobRequest(this.userId, this.jobId);
});
this.displayElement
.querySelector('.action-button[data-action="get-log-request"]')
.addEventListener('click', (event) => {
Utils.getJobLogRequest(this.userId, this.jobId);
});
this.displayElement
.querySelector('.action-button[data-action="restart-request"]')
.addEventListener('click', (event) => {
Utils.restartJobRequest(this.userId, this.jobId);
});
} }
init(user) { init(user) {
const job = user.jobs[this.jobId]; let job = user.jobs[this.jobId];
this.setCreationDate(job.creation_date); this.setCreationDate(job.creation_date);
this.setEndDate(job.creation_date); this.setEndDate(job.creation_date);
this.setDescription(job.description); this.setDescription(job.description);
@ -17,20 +31,20 @@ class JobDisplay extends RessourceDisplay {
this.setTitle(job.title); this.setTitle(job.title);
} }
onPATCH(patch) { onPatch(patch) {
if (!this.isInitialized) {return;} let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
let filteredPatch; for (let operation of filteredPatch) {
let operation;
let re;
re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`);
filteredPatch = patch.filter(operation => re.test(operation.path));
for (operation of filteredPatch) {
switch(operation.op) { switch(operation.op) {
case 'replace': case 'remove': {
re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/end_date$`); let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}$`);
if (re.test(operation.path)) {
window.location.href = '/dashboard#jobs';
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/end_date$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {
this.setEndDate(operation.value); this.setEndDate(operation.value);
break; break;
@ -41,8 +55,10 @@ class JobDisplay extends RessourceDisplay {
break; break;
} }
break; break;
default: }
default: {
break; break;
}
} }
} }
} }
@ -56,15 +72,12 @@ class JobDisplay extends RessourceDisplay {
} }
setStatus(status) { setStatus(status) {
let element; let elements = this.displayElement.querySelectorAll('.job-status');
let elements; for (let element of elements) {
elements = this.displayElement.querySelectorAll('.job-status');
for (element of elements) {
element.dataset.jobStatus = status; element.dataset.jobStatus = status;
} }
elements = this.displayElement.querySelectorAll('.job-status-spinner'); elements = this.displayElement.querySelectorAll('.job-status-spinner');
for (element of elements) { for (let element of elements) {
if (['COMPLETED', 'FAILED'].includes(status)) { if (['COMPLETED', 'FAILED'].includes(status)) {
element.classList.add('hide'); element.classList.add('hide');
} else { } else {
@ -72,19 +85,27 @@ class JobDisplay extends RessourceDisplay {
} }
} }
elements = this.displayElement.querySelectorAll('.job-log-trigger'); elements = this.displayElement.querySelectorAll('.job-log-trigger');
for (element of elements) { for (let element of elements) {
if (['COMPLETED', 'FAILED'].includes(status)) { if (['COMPLETED', 'FAILED'].includes(status)) {
element.classList.remove('hide'); element.classList.remove('hide');
} else { } else {
element.classList.add('hide'); element.classList.add('hide');
} }
} }
elements = this.displayElement.querySelectorAll('.job-restart-trigger'); elements = this.displayElement.querySelectorAll('.action-button[data-action="get-log-request"]');
for (element of elements) { for (let element of elements) {
if (['COMPLETED', 'FAILED'].includes(status)) { if (['COMPLETED', 'FAILED'].includes(status)) {
element.classList.remove('hide'); element.classList.remove('disabled');
} else { } else {
element.classList.add('hide'); element.classList.add('disabled');
}
}
elements = this.displayElement.querySelectorAll('.action-button[data-action="restart-request"]');
for (let element of elements) {
if (status === 'FAILED') {
element.classList.remove('disabled');
} else {
element.classList.add('disabled');
} }
} }
} }

View File

@ -4,36 +4,40 @@ class RessourceDisplay {
this.userId = this.displayElement.dataset.userId; this.userId = this.displayElement.dataset.userId;
this.isInitialized = false; this.isInitialized = false;
if (this.userId) { if (this.userId) {
app.subscribeUser(this.userId).then((response) => { app.subscribeUser(this.userId)
app.socket.on('PATCH', (patch) => {this.onPATCH(patch);}); .then((response) => {
}); app.socket.on('PATCH', (patch) => {
app.getUser(this.userId).then((user) => { if (this.isInitialized) {this.onPatch(patch);}
this.init(user); });
this.isInitialized = true; });
}); app.getUser(this.userId)
.then((user) => {
this.init(user);
this.isInitialized = true;
});
} }
} }
init(user) {throw 'Not implemented';} init(user) {throw 'Not implemented';}
onPATCH(patch) {throw 'Not implemented';} onPatch(patch) {throw 'Not implemented';}
setElement(element, value) { setElement(element, value) {
switch (element.tagName) { switch (element.tagName) {
case 'INPUT': case 'INPUT': {
element.value = value; element.value = value;
M.updateTextFields(); M.updateTextFields();
break; break;
default: }
default: {
element.innerText = value; element.innerText = value;
break; break;
}
} }
} }
setElements(elements, value) { setElements(elements, value) {
let element; for (let element of elements) {
for (element of elements) {
this.setElement(element, value); this.setElement(element, value);
} }
} }

View File

@ -1,19 +1,47 @@
class CorpusFileList extends RessourceList { class CorpusFileList extends RessourceList {
static autoInit() {
for (let corpusFileListElement of document.querySelectorAll('.corpus-file-list:not(.no-autoinit)')) {
new CorpusFileList(corpusFileListElement);
}
}
static options = { static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search corpus file</label>
</div>
<table>
<thead>
<tr>
<th>Filename</th>
<th>Author</th>
<th>Title</th>
<th>Publishing year</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: ` item: `
<tr class="hoverable"> <tr class="clickable hoverable">
<td><span class="filename"></span></td> <td><span class="filename"></span></td>
<td><span class="author"></span></td> <td><span class="author"></span></td>
<td><span class="title"></span></td> <td><span class="title"></span></td>
<td><span class="publishing-year"></span></td> <td><span class="publishing-year"></span></td>
<td class="right-align"> <td class="right-align">
<a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a> <a class="action-button btn-floating red waves-effect waves-light" data-action="delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating tooltipped nopaque-service-color darken waves-effect waves-light" data-action="download" data-position="top" data-service="corpus-analysis" data-tooltip="View"><i class="material-icons">file_download</i></a> <a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="download" data-service="corpus-analysis"><i class="material-icons">file_download</i></a>
<a class="action-button btn-floating tooltipped nopaque-service-color darken waves-effect waves-light" data-action="view" data-position="top" data-service="corpus-analysis" data-tooltip="View"><i class="material-icons">send</i></a> <a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(), `.trim(),
ressourceMapper: corpusFile => { ressourceMapper: (corpusFile) => {
return { return {
'id': corpusFile.id, 'id': corpusFile.id,
'author': corpusFile.author, 'author': corpusFile.author,
@ -23,7 +51,7 @@ class CorpusFileList extends RessourceList {
'title': corpusFile.title 'title': corpusFile.title
}; };
}, },
sortValueName: 'creation-date', sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [ valueNames: [
{data: ['id']}, {data: ['id']},
{data: ['creation-date']}, {data: ['creation-date']},
@ -34,7 +62,6 @@ class CorpusFileList extends RessourceList {
] ]
}; };
constructor(listElement, options = {}) { constructor(listElement, options = {}) {
super(listElement, {...CorpusFileList.options, ...options}); super(listElement, {...CorpusFileList.options, ...options});
this.corpusId = listElement.dataset.corpusId; this.corpusId = listElement.dataset.corpusId;
@ -44,94 +71,59 @@ class CorpusFileList extends RessourceList {
this._init(user.corpora[this.corpusId].files); this._init(user.corpora[this.corpusId].files);
} }
onclick(event) { onClick(event) {
let action; let actionButtonElement = event.target.closest('.action-button');
let actionButtonElement; let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let corpusFileElement; let corpusFileElement = event.target.closest('tr');
let corpusFileId; let corpusFileId = corpusFileElement.dataset.id;
let deleteModal;
let deleteModalElement;
let tmp;
corpusFileElement = event.target.closest('tr[data-id]');
if (corpusFileElement === null) {return;}
corpusFileId = corpusFileElement.dataset.id;
actionButtonElement = event.target.closest('.action-button[data-action]');
action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
switch (action) { switch (action) {
case 'delete': case 'delete': {
tmp = document.createElement('div'); Utils.deleteCorpusFileRequest(this.userId, this.corpusId, corpusFileId);
tmp.innerHTML = `
<div class="modal">
<div class="modal-content">
<h4>Confirm corpus deletion</h4>
<p>Do you really want to delete the corpus file <b>${app.data.users[this.userId].corpora[this.corpusId].files[corpusFileId].filename}</b>? It will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="/corpora/${this.corpusId}/files/${corpusFileId}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
`.trim();
deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
deleteModal = M.Modal.init(
deleteModalElement,
{
onCloseEnd: () => {
deleteModal.destroy();
deleteModalElement.remove();
}
}
);
deleteModal.open();
break; break;
case 'download': }
case 'download': {
window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}/download`; window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}/download`;
break; break;
case 'view': }
case 'view': {
window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}`; window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}`;
break; break;
default: }
default: {
break; break;
}
} }
} }
onPATCH(patch) { onPatch(patch) {
if (!this.isInitialized) {return;} let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
let corpusFileId; for (let operation of filteredPatch) {
let filteredPatch;
let match;
let operation;
let re;
let valueName;
re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`);
filteredPatch = patch.filter(operation => re.test(operation.path));
for (operation of filteredPatch) {
switch(operation.op) { switch(operation.op) {
case 'add': case 'add': {
re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`); let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {this.add(operation.value);}
this.add(operation.value);
}
break; break;
case 'remove': }
re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`); case 'remove': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {
[match, corpusFileId] = operation.path.match(re); let [match, corpusFileId] = operation.path.match(re);
this.remove(corpusFileId); this.remove(corpusFileId);
} }
break; break;
case 'replace': }
re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`); case 'replace': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {
[match, corpusFileId, valueName] = operation.path.match(re); let [match, corpusFileId, valueName] = operation.path.match(re);
this.replace(corpusFileId, valueName.replace('_', '-'), operation.value); this.replace(corpusFileId, valueName.replace('_', '-'), operation.value);
} }
break; break;
default: }
default: {
break; break;
}
} }
} }
} }

View File

@ -1,17 +1,44 @@
class CorpusList extends RessourceList { class CorpusList extends RessourceList {
static autoInit() {
for (let corpusListElement of document.querySelectorAll('.corpus-list:not(.no-autoinit)')) {
new CorpusList(corpusListElement);
}
}
static options = { static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search corpus</label>
</div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: ` item: `
<tr class="hoverable"> <tr class="clickable hoverable">
<td><a class="btn-floating disabled"><i class="material-icons service-color darken" data-service="corpus-analysis">book</i></a></td> <td><a class="btn-floating disabled"><i class="material-icons service-color darken" data-service="corpus-analysis">book</i></a></td>
<td><b class="title"></b><br><i class="description"></i></td> <td><b class="title"></b><br><i class="description"></i></td>
<td><span class="status badge new corpus-status-color corpus-status-text" data-badge-caption=""></span></td> <td><span class="status badge new corpus-status-color corpus-status-text" data-badge-caption=""></span></td>
<td class="right-align"> <td class="right-align">
<a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a> <a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating nopaque-service-color darken tooltipped waves-effect waves-light" data-action="view" data-position="top" data-service="corpus-analysis" data-tooltip="View"><i class="material-icons">send</i></a> <a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(), `.trim(),
ressourceMapper: corpus => { ressourceMapper: (corpus) => {
return { return {
'id': corpus.id, 'id': corpus.id,
'creation-date': corpus.creation_date, 'creation-date': corpus.creation_date,
@ -20,7 +47,7 @@ class CorpusList extends RessourceList {
'title': corpus.title 'title': corpus.title
}; };
}, },
sortValueName: 'creation-date', sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [ valueNames: [
{data: ['id']}, {data: ['id']},
{data: ['creation-date']}, {data: ['creation-date']},
@ -30,98 +57,63 @@ class CorpusList extends RessourceList {
] ]
}; };
constructor(listElement, options = {}) { constructor(listElement, options = {}) {
super(listElement, {...CorpusList.options, ...options}); super(listElement, {...CorpusList.options, ...options});
} }
init(user) { init(user) {
super._init(user.corpora); this._init(user.corpora);
} }
onclick(event) { onClick(event) {
let action; let actionButtonElement = event.target.closest('.action-button');
let actionButtonElement; let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let corpusElement; let corpusElement = event.target.closest('tr');
let corpusId; let corpusId = corpusElement.dataset.id;
let deleteModal;
let deleteModalElement;
let tmp;
corpusElement = event.target.closest('tr[data-id]');
if (corpusElement === null) {return;}
corpusId = corpusElement.dataset.id;
actionButtonElement = event.target.closest('.action-button[data-action]');
action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
switch (action) { switch (action) {
case 'delete': case 'delete-request': {
tmp = document.createElement('div'); Utils.deleteCorpusRequest(this.userId, corpusId);
tmp.innerHTML = `
<div class="modal">
<div class="modal-content">
<h4>Confirm corpus deletion</h4>
<p>Do you really want to delete the corpus <b>${app.data.users[this.userId].corpora[corpusId].title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="/corpora/${corpusId}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
`.trim();
deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
deleteModal = M.Modal.init(
deleteModalElement,
{
onCloseEnd: () => {
deleteModal.destroy();
deleteModalElement.remove();
}
}
);
deleteModal.open();
break; break;
case 'view': }
case 'view': {
window.location.href = `/corpora/${corpusId}`; window.location.href = `/corpora/${corpusId}`;
break; break;
default: }
default: {
break; break;
}
} }
} }
onPATCH(patch) { onPatch(patch) {
if (!this.isInitialized) {return;} let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
let corpusId; for (let operation of filteredPatch) {
let filteredPatch;
let match;
let operation;
let re;
let valueName;
re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`);
filteredPatch = patch.filter(operation => re.test(operation.path));
for (operation of filteredPatch) {
switch(operation.op) { switch(operation.op) {
case 'add': case 'add': {
re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`); let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);} if (re.test(operation.path)) {this.add(operation.value);}
break; break;
case 'remove': }
re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`); case 'remove': {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {
[match, corpusId] = operation.path.match(re); let [match, corpusId] = operation.path.match(re);
this.remove(corpusId); this.remove(corpusId);
} }
break; break;
case 'replace': }
re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`); case 'replace': {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {
[match, corpusId, valueName] = operation.path.match(re); let [match, corpusId, valueName] = operation.path.match(re);
this.replace(corpusId, valueName, operation.value); this.replace(corpusId, valueName, operation.value);
} }
break; break;
default: }
default: {
break; break;
}
} }
} }
} }

View File

@ -1,21 +1,46 @@
class JobInputList extends RessourceList { class JobInputList extends RessourceList {
static autoInit() {
for (let jobInputListElement of document.querySelectorAll('.job-input-list:not(.no-autoinit)')) {
new JobInputList(jobInputListElement);
}
}
static options = { static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search job input</label>
</div>
<table>
<thead>
<tr>
<th>Filename</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: ` item: `
<tr class="hoverable"> <tr class="clickable hoverable">
<td><span class="filename"></span></td> <td><span class="filename"></span></td>
<td class="right-align"> <td class="right-align">
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="download" data-position="top" data-tooltip="View"><i class="material-icons">file_download</i></a> <a class="action-button btn-floating waves-effect waves-light" data-action="download"><i class="material-icons">file_download</i></a>
</td> </td>
</tr> </tr>
`.trim(), `.trim(),
ressourceMapper: jobInput => { ressourceMapper: (jobInput) => {
return { return {
'id': jobInput.id, 'id': jobInput.id,
'creation-date': jobInput.creation_date, 'creation-date': jobInput.creation_date,
'filename': jobInput.filename 'filename': jobInput.filename
}; };
}, },
sortValueName: 'creation-date', sortArgs: ['filename', {order: 'asc'}],
valueNames: [ valueNames: [
{data: ['id']}, {data: ['id']},
{data: ['creation-date']}, {data: ['creation-date']},
@ -23,7 +48,6 @@ class JobInputList extends RessourceList {
] ]
}; };
constructor(listElement, options = {}) { constructor(listElement, options = {}) {
super(listElement, {...JobInputList.options, ...options}); super(listElement, {...JobInputList.options, ...options});
this.jobId = listElement.dataset.jobId; this.jobId = listElement.dataset.jobId;
@ -33,26 +57,21 @@ class JobInputList extends RessourceList {
this._init(user.jobs[this.jobId].inputs); this._init(user.jobs[this.jobId].inputs);
} }
onclick(event) { onClick(event) {
let jobInputElement; let actionButtonElement = event.target.closest('.action-button');
let jobInputId; let action = actionButtonElement === null ? 'download' : actionButtonElement.dataset.action;
let action; let jobInputElement = event.target.closest('tr');
let actionButtonElement; let jobInputId = jobInputElement.dataset.id;
jobInputElement = event.target.closest('tr[data-id]');
if (jobInputElement === null) {return;}
jobInputId = jobInputElement.dataset.id;
actionButtonElement = event.target.closest('.action-button[data-action]');
if (actionButtonElement === null) {return;}
action = actionButtonElement.dataset.action;
switch (action) { switch (action) {
case 'download': case 'download': {
window.location.href = `/jobs/${this.jobId}/inputs/${jobInputId}/download`; window.location.href = `/jobs/${this.jobId}/inputs/${jobInputId}/download`;
break; break;
default: }
default: {
break; break;
}
} }
} }
onPATCH(patch) {return;} onPatch(patch) {return;}
} }

View File

@ -1,17 +1,44 @@
class JobList extends RessourceList { class JobList extends RessourceList {
static autoInit() {
for (let jobListElement of document.querySelectorAll('.job-list:not(.no-autoinit)')) {
new JobList(jobListElement);
}
}
static options = { static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search job</label>
</div>
<table>
<thead>
<tr>
<th>Service</th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: ` item: `
<tr class="hoverable service-color lighten"> <tr class="clickable hoverable service-color lighten">
<td><a class="btn-floating disabled"><i class="service-1 nopaque-icons service-color darken service-icon"></i></a></td> <td><a class="btn-floating disabled"><i class="service-1 nopaque-icons service-color darken service-icon"></i></a></td>
<td><b class="title"></b><br><i class="description"></i></td> <td><b class="title"></b><br><i class="description"></i></td>
<td><span class="status badge new job-status-color job-status-text" data-badge-caption=""></span></td> <td><span class="status badge new job-status-color job-status-text" data-badge-caption=""></span></td>
<td class="right-align"> <td class="right-align">
<a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a> <a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
<a class="service-2 action-button btn-floating nopaque-service-color darken tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a> <a class="action-button btn-floating service-color darken waves-effect waves-light service-2" data-action="view"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(), `.trim(),
ressourceMapper: job => { ressourceMapper: (job) => {
return { return {
'id': job.id, 'id': job.id,
'creation-date': job.creation_date, 'creation-date': job.creation_date,
@ -23,7 +50,7 @@ class JobList extends RessourceList {
'title': job.title 'title': job.title
}; };
}, },
sortValueName: 'creation-date', sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [ valueNames: [
{data: ['id']}, {data: ['id']},
{data: ['creation-date']}, {data: ['creation-date']},
@ -44,91 +71,55 @@ class JobList extends RessourceList {
this._init(user.jobs); this._init(user.jobs);
} }
onclick(event) { onClick(event) {
let action; let actionButtonElement = event.target.closest('.action-button');
let actionButtonElement; let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let deleteModal; let jobElement = event.target.closest('tr');
let deleteModalElement; let jobId = jobElement.dataset.id;
let jobElement;
let jobId;
let tmp;
jobElement = event.target.closest('tr[data-id]');
if (jobElement === null) {return;}
jobId = jobElement.dataset.id;
actionButtonElement = event.target.closest('.action-button[data-action]');
action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
switch (action) { switch (action) {
case 'delete': case 'delete-request': {
tmp = document.createElement('div'); Utils.deleteJobRequest(this.userId, jobId);
tmp.innerHTML = `
<div class="modal">
<div class="modal-content">
<h4>Confirm job deletion</h4>
<p>Do you really want to delete the job <b>${app.data.users[this.userId].jobs[jobId].title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="/jobs/${jobId}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
`.trim();
deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
deleteModal = M.Modal.init(
deleteModalElement,
{
onCloseEnd: () => {
deleteModal.destroy();
deleteModalElement.remove();
}
}
);
deleteModal.open();
break; break;
case 'view': }
case 'view': {
window.location.href = `/jobs/${jobId}`; window.location.href = `/jobs/${jobId}`;
break; break;
default: }
default: {
break; break;
}
} }
} }
onPATCH(patch) { onPatch(patch) {
if (!this.isInitialized) {return;} let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
let filteredPatch; for (let operation of filteredPatch) {
let jobId;
let match;
let operation;
let re;
let valueName;
re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`);
filteredPatch = patch.filter(operation => re.test(operation.path));
for (operation of filteredPatch) {
switch(operation.op) { switch(operation.op) {
case 'add': case 'add': {
re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`); let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {this.add(operation.value);}
this.add(operation.value);
}
break; break;
case 'remove': }
re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`); case 'remove': {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {
[match, jobId] = operation.path.match(re); let [match, jobId] = operation.path.match(re);
this.remove(jobId); this.remove(jobId);
} }
break; break;
case 'replace': }
re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`); case 'replace': {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {
[match, jobId, valueName] = operation.path.match(re); let [match, jobId, valueName] = operation.path.match(re);
this.replace(jobId, valueName, operation.value); this.replace(jobId, valueName, operation.value);
} }
break; break;
default: }
default: {
break; break;
}
} }
} }
} }

View File

@ -1,15 +1,41 @@
class JobResultList extends RessourceList { class JobResultList extends RessourceList {
static autoInit() {
for (let jobResultListElement of document.querySelectorAll('.job-result-list:not(.no-autoinit)')) {
new JobResultList(jobResultListElement);
}
}
static options = { static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search job result</label>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Filename</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: ` item: `
<tr class="hoverable"> <tr class="clickable hoverable">
<td><span class="description"></span></td> <td><span class="description"></span></td>
<td><span class="filename"></span></td> <td><span class="filename"></span></td>
<td class="right-align"> <td class="right-align">
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="download" data-position="top" data-tooltip="View"><i class="material-icons">file_download</i></a> <a class="action-button btn-floating waves-effect waves-light" data-action="download"><i class="material-icons">file_download</i></a>
</td> </td>
</tr> </tr>
`.trim(), `.trim(),
ressourceMapper: jobResult => { ressourceMapper: (jobResult) => {
return { return {
'id': jobResult.id, 'id': jobResult.id,
'creation-date': jobResult.creation_date, 'creation-date': jobResult.creation_date,
@ -17,7 +43,7 @@ class JobResultList extends RessourceList {
'filename': jobResult.filename 'filename': jobResult.filename
}; };
}, },
sortValueName: 'creation-date', sortArgs: ['filename', {order: 'asc'}],
valueNames: [ valueNames: [
{data: ['id']}, {data: ['id']},
{data: ['creation-date']}, {data: ['creation-date']},
@ -26,7 +52,6 @@ class JobResultList extends RessourceList {
] ]
}; };
constructor(listElement, options = {}) { constructor(listElement, options = {}) {
super(listElement, {...JobResultList.options, ...options}); super(listElement, {...JobResultList.options, ...options});
this.jobId = listElement.dataset.jobId; this.jobId = listElement.dataset.jobId;
@ -36,46 +61,35 @@ class JobResultList extends RessourceList {
super._init(user.jobs[this.jobId].results); super._init(user.jobs[this.jobId].results);
} }
onclick(event) { onClick(event) {
let action; let actionButtonElement = event.target.closest('.action-button');
let actionButtonElement; let action = actionButtonElement === null ? 'download' : actionButtonElement.dataset.action;
let jobResultElement; let jobResultElement = event.target.closest('tr');
let jobResultId; let jobResultId = jobResultElement.dataset.id;
jobResultElement = event.target.closest('tr[data-id]');
if (jobResultElement === null) {return;}
jobResultId = jobResultElement.dataset.id;
actionButtonElement = event.target.closest('.action-button[data-action]');
if (actionButtonElement === null) {return;}
action = actionButtonElement.dataset.action;
switch (action) { switch (action) {
case 'download': case 'download': {
window.location.href = `/jobs/${this.jobId}/results/${jobResultId}/download`; window.location.href = `/jobs/${this.jobId}/results/${jobResultId}/download`;
break; break;
default: }
default: {
break; break;
}
} }
} }
onPATCH(patch) { onPatch(patch) {
if (!this.isInitialized) {return;} let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
let filteredPatch; for (let operation of filteredPatch) {
let operation;
let re;
re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`);
filteredPatch = patch.filter(operation => re.test(operation.path));
for (operation of filteredPatch) {
switch(operation.op) { switch(operation.op) {
case 'add': case 'add': {
re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`); let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) { if (re.test(operation.path)) {this.add(operation.value);}
this.add(operation.value);
}
break; break;
default: }
default: {
break; break;
}
} }
} }
} }

View File

@ -1,12 +1,18 @@
class QueryResultList extends RessourceList { class QueryResultList extends RessourceList {
static autoInit() {
for (let queryResultListElement of document.querySelectorAll('.query-result-list:not(.no-autoinit)')) {
new QueryResultList(queryResultListElement);
}
}
static options = { static options = {
item: ` item: `
<tr class="hoverable"> <tr class="hoverable">
<td><b class="title"></b><br><i class="description"></i><br></td> <td><b class="title"></b><br><i class="description"></i><br></td>
<td><span class="corpus-title"></span><br><span class="query"></span></td> <td><span class="corpus-title"></span><br><span class="query"></span></td>
<td class="right-align"> <td class="right-align">
<a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a> <a class="action-button btn-floating red waves-effect waves-light" data-action="delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a> <a class="action-button btn-floating waves-effect waves-light" data-action="view"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(), `.trim(),
@ -20,7 +26,7 @@ class QueryResultList extends RessourceList {
'title': queryResult.title 'title': queryResult.title
}; };
}, },
sortValueName: 'creation-date', sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [ valueNames: [
{data: ['id']}, {data: ['id']},
{data: ['creation-date']}, {data: ['creation-date']},
@ -31,7 +37,6 @@ class QueryResultList extends RessourceList {
] ]
}; };
constructor(listElement, options = {}) { constructor(listElement, options = {}) {
super(listElement, {...QueryResultList.options, ...options}); super(listElement, {...QueryResultList.options, ...options});
} }

View File

@ -3,45 +3,22 @@ class RessourceList {
* This class is not meant to be used directly, instead it should be used as * This class is not meant to be used directly, instead it should be used as
* a base class for concrete ressource list implementations. * a base class for concrete ressource list implementations.
*/ */
static autoInit() {
const nopaqueRessourceListElements = document.querySelectorAll('.nopaque-ressource-list[data-ressource-type]:not(.no-autoinit)');
let nopaqueRessourceListElement;
for (nopaqueRessourceListElement of nopaqueRessourceListElements) { static autoInit() {
switch (nopaqueRessourceListElement.dataset.ressourceType) { CorpusList.autoInit();
case 'Corpus': CorpusFileList.autoInit();
new CorpusList(nopaqueRessourceListElement); JobList.autoInit();
break; JobInputList.autoInit();
case 'CorpusFile': JobResultList.autoInit();
new CorpusFileList(nopaqueRessourceListElement); QueryResultList.autoInit();
break; UserList.autoInit();
case 'Job':
new JobList(nopaqueRessourceListElement);
break;
case 'JobInput':
new JobInputList(nopaqueRessourceListElement);
break;
case 'JobResult':
new JobResultList(nopaqueRessourceListElement);
break;
case 'QueryResult':
new QueryResultList(nopaqueRessourceListElement);
break;
case 'User':
new UserList(nopaqueRessourceListElement);
break;
default:
break;
}
}
} }
static options = {page: 5, pagination: {innerWindow: 4, outerWindow: 1}}; static options = {page: 5, pagination: {innerWindow: 4, outerWindow: 1}};
constructor(listElement, options = {}) { constructor(listElement, options = {}) {
let i;
if (!(listElement.hasAttribute('id'))) { if (!(listElement.hasAttribute('id'))) {
let i;
for (i = 0; true; i++) { for (i = 0; true; i++) {
if (document.querySelector(`#ressource-list-${i}`)) {continue;} if (document.querySelector(`#ressource-list-${i}`)) {continue;}
listElement.id = `ressource-list-${i}`; listElement.id = `ressource-list-${i}`;
@ -56,9 +33,14 @@ class RessourceList {
this.ressourceMapper = options.ressourceMapper; this.ressourceMapper = options.ressourceMapper;
delete options.ressourceMapper; delete options.ressourceMapper;
} }
if ('sortValueName' in options) { if ('initialHtmlGenerator' in options) {
this.sortValueName = options.sortValueName; this.initialHtmlGenerator = options.initialHtmlGenerator;
delete options.sortValueName; listElement.innerHTML = this.initialHtmlGenerator(listElement.id);
delete options.initialHtmlGenerator;
}
if ('sortArgs' in options) {
this.sortArgs = options.sortArgs;
delete options.sortArgs;
} }
this.listjs = new List(listElement, {...RessourceList.options, ...options}); this.listjs = new List(listElement, {...RessourceList.options, ...options});
this.listjs.list.innerHTML = ` this.listjs.list.innerHTML = `
@ -87,50 +69,54 @@ class RessourceList {
</td> </td>
</tr> </tr>
`.trim(); `.trim();
this.listjs.list.style.cursor = 'pointer';
this.userId = this.listjs.listContainer.dataset.userId; this.userId = this.listjs.listContainer.dataset.userId;
this.listjs.list.addEventListener('click', event => this.onclick(event)); this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false; this.isInitialized = false;
if (this.userId) { if (this.userId) {
app.subscribeUser(this.userId).then((response) => { app.subscribeUser(this.userId)
app.socket.on('PATCH', (patch) => {this.onPATCH(patch);}); .then((response) => {
}); app.socket.on('PATCH', (patch) => {
app.getUser(this.userId).then((user) => { if (this.isInitialized) {this.onPatch(patch);}
this.init(user); });
this.isInitialized = true; });
}); app.getUser(this.userId)
.then((user) => {
this.init(user);
this.isInitialized = true;
});
} }
} }
_init(ressources) { _init(ressources) {
this.listjs.clear(); this.listjs.clear();
this.add(Object.values(ressources)); this.add(Object.values(ressources));
let emptyListElementHTML = ` this.listjs.list.insertAdjacentHTML(
<tr class="show-if-only-child"> 'afterbegin',
<td colspan="100%"> `
<span class="card-title"><i class="left material-icons" style="font-size: inherit;">file_download</i>Nothing here...</span> <tr class="show-if-only-child">
<p>No ressource available.</p> <td colspan="100%">
</td> <span class="card-title"><i class="left material-icons" style="font-size: inherit;">file_download</i>Nothing here...</span>
</tr> <p>No ressource available.</p>
`.trim(); </td>
this.listjs.list.insertAdjacentHTML('afterbegin', emptyListElementHTML); </tr>
`.trim()
);
} }
init(user) {throw 'Not implemented';} init(user) {throw 'Not implemented';}
onclick(event) {throw 'Not implemented';} onClick(event) {throw 'Not implemented';}
onPATCH(patch) {throw 'Not implemented';} onPatch(patch) {throw 'Not implemented';}
add(ressources) { add(ressources) {
let values = Array.isArray(ressources) ? ressources : [ressources]; let values = Array.isArray(ressources) ? ressources : [ressources];
if ('ressourceMapper' in this) { if ('ressourceMapper' in this) {
values = values.map(value => this.ressourceMapper(value)); values = values.map((value) => {return this.ressourceMapper(value);});
} }
this.listjs.add(values, () => { this.listjs.add(values, () => {
if ('sortValueName' in this) { if ('sortArgs' in this) {
this.listjs.sort(this.sortValueName, {order: 'desc'}); this.listjs.sort(...this.sortArgs);
} }
}); });
} }
@ -140,6 +126,6 @@ class RessourceList {
} }
replace(id, valueName, newValue) { replace(id, valueName, newValue) {
this.listjs.get('id', id)[0].values({[valueName]: newValue}); this.listjs.get('id', id)[0].values({[valueName]: newValue});
} }
} }

View File

@ -1,20 +1,49 @@
class UserList extends RessourceList { class UserList extends RessourceList {
static autoInit() {
for (let userListElement of document.querySelectorAll('.user-list:not(.no-autoinit)')) {
new UserList(userListElement);
}
}
static options = { static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search user</label>
</div>
<table>
<thead>
<tr>
<th>Id</th>
<th>Username</th>
<th>Email</th>
<th>Last seen</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: ` item: `
<tr class="hoverable"> <tr class="clickable hoverable">
<td><span class="id-1"></span></td> <td><span class="id-1"></span></td>
<td><span class="username"></span></td> <td><span class="username"></span></td>
<td><span class="email"></span></td> <td><span class="email"></span></td>
<td><span class="last-seen"></span></td> <td><span class="last-seen"></span></td>
<td><span class="role"></span></td> <td><span class="role"></span></td>
<td class="right-align"> <td class="right-align">
<a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a> <a class="action-button btn-floating red waves-effect waves-light" data-action="delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="edit" data-position="top" data-tooltip="Edit"><i class="material-icons">edit</i></a> <a class="action-button btn-floating waves-effect waves-light" data-action="edit"><i class="material-icons">edit</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a> <a class="action-button btn-floating waves-effect waves-light" data-action="view"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(), `.trim(),
ressourceMapper: user => { ressourceMapper: (user) => {
return { return {
'id': user.id, 'id': user.id,
'id-1': user.id, 'id-1': user.id,
@ -25,7 +54,7 @@ class UserList extends RessourceList {
'role': user.role.name 'role': user.role.name
}; };
}, },
sortValueName: 'member-since', sortArgs: ['member-since', {order: 'desc'}],
valueNames: [ valueNames: [
{data: ['id']}, {data: ['id']},
{data: ['member-since']}, {data: ['member-since']},
@ -37,8 +66,6 @@ class UserList extends RessourceList {
] ]
}; };
constructor(listElement, options = {}) { constructor(listElement, options = {}) {
super(listElement, {...UserList.options, ...options}); super(listElement, {...UserList.options, ...options});
} }
@ -47,55 +74,28 @@ class UserList extends RessourceList {
super._init(Object.values(users)); super._init(Object.values(users));
} }
onclick(event) { onClick(event) {
let action; let actionButtonElement = event.target.closest('.action-button');
let actionButtonElement; let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let deleteModal; let userElement = event.target.closest('tr');
let deleteModalElement; let userId = userElement.dataset.id;
let tmp;
let userElement;
let userId;
userElement = event.target.closest('tr[data-id]');
if (userElement === null) {return;}
userId = userElement.dataset.id;
actionButtonElement = event.target.closest('.action-button[data-action]');
action = (actionButtonElement === null) ? 'view' : actionButtonElement.dataset.action;
switch (action) { switch (action) {
case 'delete': case 'delete': {
tmp = document.createElement('div'); Utils.deleteUserRequest(userId);
tmp.innerHTML = ` if (userId === currentUserId) {window.location.href = '/';}
<div class="modal">
<div class="modal-content">
<h4>Confirm user deletion</h4>
<p>Do you really want to delete user <b>${userId}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="/admin/users/${userId}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
`.trim();
deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
deleteModal = M.Modal.init(
deleteModalElement,
{
onCloseEnd: () => {
deleteModal.destroy();
deleteModalElement.remove();
}
}
);
deleteModal.open();
break; break;
case 'edit': }
case 'edit': {
window.location.href = `/admin/users/${userId}/edit`; window.location.href = `/admin/users/${userId}/edit`;
break; break;
case 'view': }
case 'view': {
window.location.href = `/admin/users/${userId}`; window.location.href = `/admin/users/${userId}`;
break; break;
default: }
default: {
break; break;
}
} }
} }
} }

View File

@ -1,125 +0,0 @@
class UploadForm {
static autoInit() {
const nopaqueSubmitForms = document.querySelectorAll('.nopaque-upload-form');
let nopaqueSubmitForm;
for (nopaqueSubmitForm of nopaqueSubmitForms) {
new UploadForm(nopaqueSubmitForm);
}
}
constructor(formElement) {
this.formElement = formElement;
this.request = new XMLHttpRequest();
this.formElement.addEventListener('submit', (event) => {
event.preventDefault();
this.submit();
});
}
submit() {
const selectElements = this.formElement.querySelectorAll('select');
let abortElement;
let helperTextElement;
let helperTextElements;
let inputFieldElement;
let modal;
let modalElement;
let progressElement;
let selectElement;
let tmp;
// Check if select elements are filled out properly
for (selectElement of selectElements) {
if (selectElement.value === '') {
inputFieldElement = selectElement.closest('.input-field');
inputFieldElement.querySelector('.select-dropdown').classList.add('invalid');
helperTextElements = inputFieldElement.querySelectorAll('.helper-text');
for (helperTextElement of helperTextElements) {
helperTextElement.remove();
}
inputFieldElement.insertAdjacentHTML(
'beforeend',
'<span class="helper-text error-color-text">Please select an option.</span>'
);
return;
}
}
// Setup modal
tmp = document.createElement('div');
tmp.innerHTML = `
<div class="modal">
<div class="modal-content">
<h4><i class="material-icons left">file_upload</i>Uploading files...</h4>
<div class="progress">
<div class="determinate" style="width: 0%"></div>
</div>
</div>
<div class="modal-footer">
<a href="#!" class="btn red waves-effect waves-light abort">Cancel</a>
</div>
</div>
`.trim();
modalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
modal = M.Modal.init(
modalElement,
{
dismissible: false,
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
modal.open();
// Setup abort handling
abortElement = modalElement.querySelector('.abort');
abortElement.addEventListener('click', event => {this.request.abort();});
this.request.addEventListener('abort', event => {
this.request.abort();
modal.close();
});
// Setup load handling (after the request completed)
this.request.addEventListener('load', event => {
const response = JSON.parse(this.request.responseText);
let inputError;
let inputErrors;
let inputFieldElement;
let inputName;
if (this.request.status === 201) {
window.location.href = response.redirect_url;
}
if (this.request.status === 400) {
for ([inputName, inputErrors] of Object.entries(response)) {
inputFieldElement = this.formElement.querySelector(`input[name="${inputName}"], select[name="${inputName}"]`).closest('.input-field');
for (inputError of inputErrors) {
inputFieldElement.insertAdjacentHTML(
'beforeend',
`<span class="helper-text red-text">${inputError}</span>`
);
}
}
}
if (this.request.status === 500) {
location.reload();
}
modal.close();
});
// Setup progress handling
progressElement = modalElement.querySelector('.progress > .determinate');
this.request.upload.addEventListener('progress', event => {
const progress = Math.floor(100 * event.loaded / event.total);
progressElement.style.width = `${progress}%`;
});
this.request.open('POST', window.location.href);
this.request.send(new FormData(this.formElement));
}
}

326
app/static/js/Utils.js Normal file
View File

@ -0,0 +1,326 @@
class Utils {
static elementFromString(string) {
let tmpElement = document.createElement('div');
tmpElement.innerHTML = string.trim();
return tmpElement.firstChild;
}
static buildCorpusRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus = app.data.users[userId].corpora[corpusId];
fetch(`/corpora/${corpus.id}/build`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
app.flash(`Corpus "${corpus.title}" marked for building`, 'corpus');
resolve(response);
},
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error');}
if (response.status === 404) {app.flash('Not Found', 'error');}
if (response.status === 409) {app.flash('Conflict', 'error');}
reject(response);
}
);
});
}
static deleteCorpusRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus = app.data.users[userId].corpora[corpusId];
let modalElement = Utils.elementFromString(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm job deletion</h4>
<p>Do you really want to delete the job <b>${corpus.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
</div>
</div>
`
);
document.querySelector('#modals').appendChild(modalElement);
let modal = M.Modal.init(
modalElement,
{
dismissible: false,
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let corpusTitle = corpus.title;
fetch(`/corpora/${corpus.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
app.flash(`Corpus "${corpusTitle}" marked for deletion`, 'corpus');
resolve(response);
},
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error');}
if (response.status === 404) {app.flash('Not Found', 'error');}
reject(response);
}
);
});
modal.open();
});
}
static deleteCorpusFileRequest(userId, corpusId, corpusFileId) {
return new Promise((resolve, reject) => {
let corpus = app.data.users[userId].corpora[corpusId];
let corpusFile = corpus.files[corpusFileId];
let modalElement = Utils.elementFromString(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm job deletion</h4>
<p>Do you really want to delete the job <b>${corpusFile.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
</div>
</div>
`
);
document.querySelector('#modals').appendChild(modalElement);
let modal = M.Modal.init(
modalElement,
{
dismissible: false,
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let corpusFileTitle = corpusFile.title;
fetch(`/corpora/${corpusId}/files/${corpusFileId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
app.flash(`Corpus file "${corpusFileTitle}" marked for deletion`, 'corpus');
resolve(response);
},
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error');}
if (response.status === 404) {app.flash('Not Found', 'error');}
reject(response);
}
);
});
modal.open();
});
}
static deleteJobRequest(userId, jobId) {
return new Promise((resolve, reject) => {
let job = app.data.users[userId].jobs[jobId];
let modalElement = Utils.elementFromString(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm job deletion</h4>
<p>Do you really want to delete the job <b>${job.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
</div>
</div>
`
);
document.querySelector('#modals').appendChild(modalElement);
let modal = M.Modal.init(
modalElement,
{
dismissible: false,
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let jobTitle = job.title;
fetch(`/jobs/${job.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
app.flash(`Job "${jobTitle}" marked for deletion`, 'job');
resolve(response);
},
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error');}
if (response.status === 404) {app.flash('Not Found', 'error');}
reject(response);
}
);
});
modal.open();
});
}
static getJobLogRequest(userId, jobId) {
return new Promise((resolve, reject) => {
let job = app.data.users[userId].jobs[jobId];
fetch(`/jobs/${job.id}/log`, {method: 'GET', headers: {Accept: 'application/json, text/plain'}})
.then(
(response) => {
resolve(response);
return response.text();
},
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error');}
if (response.status === 404) {app.flash('Not Found', 'error');}
reject(response);
}
)
.then(
(text) => {
let modalElement = Utils.elementFromString(
`
<div class="modal">
<div class="modal-content">
<h4>Job logs</h4>
<pre><code>${text}</code></pre>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">Close</a>
</div>
</div>
`
);
document.querySelector('#modals').appendChild(modalElement);
let modal = M.Modal.init(
modalElement,
{
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
modal.open();
}
);
});
}
static restartJobRequest(userId, jobId) {
return new Promise((resolve, reject) => {
let job = app.data.users[userId].jobs[jobId];
let modalElement = Utils.elementFromString(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm job restart</h4>
<p>Do you really want to restart the job <b>${job.title}</b>? All log and result files will be permanently deleted.</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Restart</a>
</div>
</div>
`
);
document.querySelector('#modals').appendChild(modalElement);
let modal = M.Modal.init(
modalElement,
{
dismissible: false,
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let jobTitle = job.title;
fetch(`/jobs/${job.id}/restart`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
app.flash(`Job "${jobTitle}" restarted.`, 'job');
resolve(response);
},
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error');}
if (response.status === 404) {app.flash('Not Found', 'error');}
if (response.status === 409) {app.flash('Conflict', 'error');}
reject(response);
}
);
});
modal.open();
});
}
static deleteUserRequest(userId) {
return new Promise((resolve, reject) => {
let user = app.data.users[userId];
let modalElement = Utils.elementFromString(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm job deletion</h4>
<p>Do you really want to delete the user <b>${user.username}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
</div>
</div>
`
);
document.querySelector('#modals').appendChild(modalElement);
let modal = M.Modal.init(
modalElement,
{
dismissible: false,
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let userName = user.username;
fetch(`/users/${user.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
app.flash(`User "${userName}" marked for deletion`);
resolve(response);
},
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error');}
if (response.status === 404) {app.flash('Not Found', 'error');}
reject(response);
}
);
});
modal.open();
});
}
}

View File

@ -29,7 +29,7 @@
<ul class="dropdown-content" id="nav-more-dropdown"> <ul class="dropdown-content" id="nav-more-dropdown">
<li><a href="{{ url_for('main.user_manual') }}"><i class="material-icons left">help</i>Manual</a></li> <li><a href="{{ url_for('main.user_manual') }}"><i class="material-icons left">help</i>Manual</a></li>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li><a href="{{ url_for('settings.index') }}"><i class="material-icons left">settings</i>Settings</a></li> <li><a href="{{ url_for('settings.settings') }}"><i class="material-icons left">settings</i>Settings</a></li>
<li class="divider" tabindex="-1"></li> <li class="divider" tabindex="-1"></li>
<li><a href="{{ url_for('auth.logout') }}">Log out</a></li> <li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
{% else %} {% else %}

View File

@ -11,19 +11,19 @@
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
<li class="tab"><a{%if request.path == url_for('services.spacy_nlp_pipeline') %} class="active"{% endif %} href="{{ url_for('services.spacy_nlp_pipeline') }}" target="_self">NLP</a></li> <li class="tab"><a{%if request.path == url_for('services.spacy_nlp_pipeline') %} class="active"{% endif %} href="{{ url_for('services.spacy_nlp_pipeline') }}" target="_self">NLP</a></li>
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
<li class="tab"><a{%if request.path == url_for('corpora.add_corpus') %} class="active"{% endif %} href="{{ url_for('corpora.add_corpus') }}" target="_self">Add corpus</a></li> <li class="tab"><a{%if request.path == url_for('corpora.create_corpus') %} class="active"{% endif %} href="{{ url_for('corpora.create_corpus') }}" target="_self">Create corpus</a></li>
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
{% if corpus %} {% if corpus %}
<li class="tab"><a{%if request.path == url_for('corpora.add_corpus_file', corpus_id=corpus.id) %} class="active"{% endif %} href="{{ url_for('corpora.add_corpus_file', corpus_id=corpus.id) }}" target="_self">Add corpus file(s)</a></li> <li class="tab"><a{%if request.path == url_for('corpora.create_corpus_file', corpus_id=corpus.id) %} class="active"{% endif %} href="{{ url_for('corpora.create_corpus_file', corpus_id=corpus.id) }}" target="_self">Create corpus file(s)</a></li>
{% else %} {% else %}
<li class="tab disabled tooltipped" data-tooltip="Select a corpus first" target="_self"><a>Add corpus file(s)</a></li> <li class="tab disabled tooltipped" data-tooltip="Select a corpus first" target="_self"><a>Create corpus file(s)</a></li>
{% endif %} {% endif %}
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
{% if corpus %} {% if corpus %}
{% if corpus.files.all() %} {% if corpus.files.all() %}
<li class="tab"><a{%if request.path == url_for('corpora.analyse_corpus', corpus_id=corpus.id) %} class="active"{% endif %} href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}" target="_self">Corpus analysis</a></li> <li class="tab"><a{%if request.path == url_for('corpora.analyse_corpus', corpus_id=corpus.id) %} class="active"{% endif %} href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}" target="_self">Corpus analysis</a></li>
{% else %} {% else %}
<li class="tab disabled tooltipped" data-tooltip="Add at least one corpus file first"><a>Corpus analysis</a></li> <li class="tab disabled tooltipped" data-tooltip="Create at least one corpus file first"><a>Corpus analysis</a></li>
{% endif %} {% endif %}
{% else %} {% else %}
<li class="tab disabled tooltipped" data-tooltip="Select a corpus first"><a>Corpus analysis</a></li> <li class="tab disabled tooltipped" data-tooltip="Select a corpus first"><a>Corpus analysis</a></li>

View File

@ -5,11 +5,14 @@
filters='rjsmin', filters='rjsmin',
output='gen/app.%(version)s.js', output='gen/app.%(version)s.js',
'js/App.js', 'js/App.js',
'js/Utils.js',
'js/Forms/Form.js',
'js/Forms/CreateCorpusFileForm.js',
'js/Forms/CreateJobForm.js',
'js/CorpusAnalysis/CQiClient.js', 'js/CorpusAnalysis/CQiClient.js',
'js/CorpusAnalysis/CorpusAnalysisApp.js', 'js/CorpusAnalysis/CorpusAnalysisApp.js',
'js/CorpusAnalysis/CorpusAnalysisConcordance.js', 'js/CorpusAnalysis/CorpusAnalysisConcordance.js',
'js/CorpusAnalysis/CorpusAnalysisReader.js', 'js/CorpusAnalysis/CorpusAnalysisReader.js',
'js/JobStatusNotifier.js',
'js/RessourceDisplays/RessourceDisplay.js', 'js/RessourceDisplays/RessourceDisplay.js',
'js/RessourceDisplays/CorpusDisplay.js', 'js/RessourceDisplays/CorpusDisplay.js',
'js/RessourceDisplays/JobDisplay.js', 'js/RessourceDisplays/JobDisplay.js',
@ -20,8 +23,7 @@
'js/RessourceLists/JobInputList.js', 'js/RessourceLists/JobInputList.js',
'js/RessourceLists/JobResultList.js', 'js/RessourceLists/JobResultList.js',
'js/RessourceLists/QueryResultList.js', 'js/RessourceLists/QueryResultList.js',
'js/RessourceLists/UserList.js', 'js/RessourceLists/UserList.js'
'js/UploadForm.js'
%} %}
<script src="{{ ASSET_URL }}"></script> <script src="{{ ASSET_URL }}"></script>
{%- endassets %} {%- endassets %}
@ -29,10 +31,10 @@
const app = new App(); const app = new App();
{%- if current_user.is_authenticated %} {%- if current_user.is_authenticated %}
const currentUserId = {{ current_user.hashid|tojson }}; const currentUserId = {{ current_user.hashid|tojson }};
const jobStatusNotifier = new JobStatusNotifier(currentUserId);
// Initialize components for current user // Initialize components for current user
app.subscribeUser(currentUserId).catch((error) => {throw JSON.stringify(error);}); app.subscribeUser(currentUserId).catch((error) => {throw JSON.stringify(error);});
app.getUser(currentUserId, true, true);
{%- endif %} {%- endif %}
// Disable all option elements with no value // Disable all option elements with no value
@ -53,7 +55,7 @@
{alignment: 'right', constrainWidth: false, coverTrigger: false} {alignment: 'right', constrainWidth: false, coverTrigger: false}
); );
RessourceList.autoInit(); RessourceList.autoInit();
UploadForm.autoInit(); Form.autoInit();
// Display flashed messages // Display flashed messages
for (let flashedMessage of {{ get_flashed_messages(with_categories=True)|tojson }}) { for (let flashedMessage of {{ get_flashed_messages(with_categories=True)|tojson }}) {

View File

@ -23,7 +23,7 @@
<li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icon" data-service="corpus-analysis"></i>Corpus analysis</a></li> <li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icon" data-service="corpus-analysis"></i>Corpus analysis</a></li>
<li><div class="divider"></div></li> <li><div class="divider"></div></li>
<li><a class="subheader">Account</a></li> <li><a class="subheader">Account</a></li>
<li><a href="{{ url_for('settings.index') }}"><i class="material-icons">settings</i>Settings</a></li> <li><a href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>Settings</a></li>
<li><a href="{{ url_for('auth.logout') }}">Log out</a></li> <li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
{% if current_user.can(Permission.ADMINISTRATE) or current_user.can(Permission.USE_API) %} {% if current_user.can(Permission.ADMINISTRATE) or current_user.can(Permission.USE_API) %}
<li><div class="divider"></div></li> <li><div class="divider"></div></li>
@ -32,9 +32,6 @@
<li><a href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a></li> <li><a href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a></li>
{% endif %} {% endif %}
{% if current_user.can(Permission.CONTRIBUTE) %} {% if current_user.can(Permission.CONTRIBUTE) %}
<li><a href="{{ url_for('contributions.index') }}"><i class="material-icons">new_label</i>Contribute</a></li> <li><a href="{{ url_for('contributions.contributions') }}"><i class="material-icons">new_label</i>Contribute</a></li>
{% endif %}
{% if current_user.can(Permission.USE_API) %}
<li><a href="{{ url_for('api.doc') }}"><i class="material-icons">api</i>API</a></li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -8,6 +8,7 @@
filters='pyscss', filters='pyscss',
output='gen/app.%(version)s.css', output='gen/app.%(version)s.css',
'css/colors.scss', 'css/colors.scss',
'css/helpers.scss',
'css/style.css' 'css/style.css'
%} %}
<link href="{{ ASSET_URL }}" media="screen,projection" rel="stylesheet"> <link href="{{ ASSET_URL }}" media="screen,projection" rel="stylesheet">

View File

@ -15,8 +15,8 @@
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<span class="card-title">General settings</span> <span class="card-title">General settings</span>
{{ wtf.render_field(edit_general_settings_form.username, data_length='64', material_icon='person') }} {{ wtf.render_field(edit_general_settings_form.username, material_icon='person') }}
{{ wtf.render_field(edit_general_settings_form.email, data_length='254', material_icon='email') }} {{ wtf.render_field(edit_general_settings_form.email, material_icon='email') }}
</div> </div>
<div class="card-action"> <div class="card-action">
<div class="right-align"> <div class="right-align">

View File

@ -37,52 +37,20 @@
</div> </div>
</div> </div>
<div class="col s12 l6 nopaque-ressource-list" data-ressource-type="Corpus" data-user-id="{{ user.hashid }}"> <div class="col s12 l6">
<h3>Corpora</h3> <h3>Corpora</h3>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="input-field"> <div class="corpus-list" data-user-id="{{ user.hashid }}"></div>
<i class="material-icons prefix">search</i>
<input id="search-corpus" class="search" type="search"></input>
<label for="search-corpus">Search corpus</label>
</div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div> </div>
</div> </div>
</div> </div>
<div class="col s12 l6 nopaque-ressource-list" data-ressource-type="Job" data-user-id="{{ user.hashid }}"> <div class="col s12 l6">
<h3>Jobs</h3> <h3>Jobs</h3>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="input-field"> <div class="job-list" data-user-id="{{ user.hashid }}"></div>
<i class="material-icons prefix">search</i>
<input id="search-job" class="search" type="search"></input>
<label for="search-job">Search job</label>
</div>
<table>
<thead>
<tr>
<th>Service</th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,28 +8,10 @@
<h1 id="title">{{ title }}</h1> <h1 id="title">{{ title }}</h1>
</div> </div>
<div class="col s12 nopaque-ressource-list no-autoinit" data-ressource-type="User" id="users"> <div class="col s12">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="input-field"> <div class="user-list no-autoinit"></div>
<i class="material-icons prefix">search</i>
<input id="search-user" class="search" type="text"></input>
<label for="search-user">Search user</label>
</div>
<table>
<thead>
<tr>
<th>Id</th>
<th>Username</th>
<th>Email</th>
<th>Last seen</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div> </div>
</div> </div>
</div> </div>
@ -40,7 +22,11 @@
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script> <script>
let userList = new UserList(document.querySelector('#users')); for (let user of {{ json_users|tojson }}) {
userList.init({{ dict_users|tojson }}); if (user.id in app.data.users) {continue;}
app.data.users[user.id] = user;
}
let userList = new UserList(document.querySelector('.user-list'));
userList.init(app.data.users);
</script> </script>
{% endblock scripts %} {% endblock scripts %}

View File

@ -2,53 +2,30 @@
{% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %} {% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
{% import "materialize/wtf.html.j2" as wtf %} {% import "materialize/wtf.html.j2" as wtf %}
{% block styles %}
{{ super() }}
<style>
main {
background-image: url("{{ url_for('static', filename='images/parallax_lq/04_german_text_book_paper.jpg') }}");
background-repeat: no-repeat;
background-size: cover;
}
</style>
{% endblock styles %}
{% block page_content %} {% block page_content %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col s12 m4"> <div class="col s12 m8 offset-m2">
<div class="card medium"> <h1 id="title">{{ title }}</h1>
<div class="card-content"> <p>Want to boost your research and get going? Nopaque is free and no download is needed. <a href="{{ url_for('.register') }}">Register now</a>!</p>
<h1 id="title">{{ title }}</h1>
<p>Want to boost your research and get going? nopaque is free and no download is needed. Register now!</p>
</div>
<div class="card-action right-align">
<a class="btn" href="{{ url_for('.register') }}"><i class="material-icons left">person_add</i>Register</a>
</div>
</div>
</div>
<div class="col s12 m8"> <form method="POST">
<div class="card medium"> <div class="card-panel">
<form method="POST"> {{ form.hidden_tag() }}
<div class="card-content"> {{ wtf.render_field(form.user, material_icon='person') }}
{{ form.hidden_tag() }} {{ wtf.render_field(form.password, material_icon='vpn_key') }}
{{ wtf.render_field(form.user, material_icon='person') }} <div class="row">
{{ wtf.render_field(form.password, material_icon='vpn_key') }} <div class="col s6 left-align">
<div class="row" style="margin-bottom: 0;"> <a href="{{ url_for('.reset_password_request') }}">Forgot your password?</a>
<div class="col s6 left-align"> </div>
<a href="{{ url_for('.reset_password_request') }}">Forgot your password?</a> <div class="col s6 right-align">
</div> {{ wtf.render_field(form.remember_me) }}
<div class="col s6 right-align">
{{ wtf.render_field(form.remember_me) }}
</div>
</div> </div>
</div> </div>
<div class="card-action right-align"> {{ wtf.render_field(form.submit, material_icon='send', class_='width-100') }}
{{ wtf.render_field(form.submit, material_icon='send') }} </div>
</div> </form>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,47 +2,31 @@
{% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %} {% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
{% import "materialize/wtf.html.j2" as wtf %} {% import "materialize/wtf.html.j2" as wtf %}
{% block styles %}
{{ super() }}
<style>
main {
background-image: url("{{ url_for('static', filename='images/parallax_lq/02_concept_document_focus_letter.jpg') }}");
background-repeat: no-repeat;
background-size: cover;
}
</style>
{% endblock styles %}
{% block page_content %} {% block page_content %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col s12 m4"> <div class="col s12 m8 offset-m2">
<div class="card medium"> <h1 id="title">{{ title }}</h1>
<div class="card-content"> <p>
<h1 id="title">{{ title }}</h1> Simply enter a username and password to receive your registration email.
<p>Simply enter a username and password to receive your registration email. After that you can start right away.</p> After that you can start right away. It goes without saying that the
<p>It goes without saying that the <a href="{{ url_for('main.privacy_policy') }}">General Data Protection Regulation</a> applies, only necessary data is stored.</p> <a href="{{ url_for('main.privacy_policy') }}">General Data Protection Regulation</a>
<p>Please also read our <a href="{{ url_for('main.terms_of_use') }}">terms of use</a> before signing up for nopaque!</p> applies, only necessary data is stored. Please also read our
</div> <a href="{{ url_for('main.terms_of_use') }}">terms of use</a> before
</div> signing up for nopaque!
</div> </p>
<div class="col s12 m8"> <form method="POST">
<div class="card medium"> <div class="card-panel">
<form method="POST"> {{ form.hidden_tag() }}
<div class="card-content"> {{ wtf.render_field(form.username, material_icon='person') }}
{{ form.hidden_tag() }} {{ wtf.render_field(form.password, material_icon='vpn_key') }}
{{ wtf.render_field(form.username, material_icon='person') }} {{ wtf.render_field(form.password_2, material_icon='vpn_key') }}
{{ wtf.render_field(form.password, material_icon='vpn_key') }} {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }}
{{ wtf.render_field(form.password_confirmation, material_icon='vpn_key') }} {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }}
{{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }} </div>
</div> </form>
<div class="card-action right-align">
{{ wtf.render_field(form.submit, material_icon='send') }}
</div>
</form>
</div>
</div>
</div> </div>
</div> </div>
{% endblock page_content %} {% endblock page_content %}

View File

@ -5,27 +5,18 @@
{% block page_content %} {% block page_content %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12 m8 offset-m2">
<h1 id="title">{{ title }}</h1> <h1 id="title">{{ title }}</h1>
</div>
<div class="col s12 m4">
<p>Enter a new password and confirm it! After that, the entered password is your new one!</p> <p>Enter a new password and confirm it! After that, the entered password is your new one!</p>
</div>
<div class="col s12 m8"> <form method="POST">
<div class="card"> <div class="card-panel">
<form method="POST"> {{ form.hidden_tag() }}
<div class="card-content"> {{ wtf.render_field(form.password) }}
{{ form.hidden_tag() }} {{ wtf.render_field(form.password_2) }}
{{ wtf.render_field(form.password) }} {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }}
{{ wtf.render_field(form.password_confirmation) }} </div>
</div> </form>
<div class="card-action right-align">
{{ wtf.render_field(form.submit, material_icon='send') }}
</div>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,26 +5,17 @@
{% block page_content %} {% block page_content %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12 m8 offset-m2">
<h1 id="title">{{ title }}</h1> <h1 id="title">{{ title }}</h1>
</div>
<div class="col s12 m4">
<p>After entering your email address you will receive instructions on how to reset your password.</p> <p>After entering your email address you will receive instructions on how to reset your password.</p>
</div>
<div class="col s12 m8"> <form method="POST">
<div class="card"> <div class="card-panel">
<form method="POST"> {{ form.hidden_tag() }}
<div class="card-content"> {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }}
{{ form.hidden_tag() }} {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }}
{{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }} </div>
</div> </form>
<div class="card-action right-align">
{{ wtf.render_field(form.submit, material_icon='send') }}
</div>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,20 +6,13 @@
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12">
<h1 id="title">{{ title }}</h1> <h1 id="title">{{ title }}</h1>
</div> <p>Hello, <b>{{ current_user.username }}</b>.</p>
<p>
<div class="col s12"> You have not confirmed your account yet. Before you can access this
<div class="card"> site you need to confirm your account. Check your inbox, you should
<div class="card-content"> have received an email with a confirmation link.
<span class="card-title">Hello, {{ current_user.username }}!</span> </p>
<p><b>You have not confirmed your account yet.</b></p> <p>Need another confirmation email? <a href="{{ url_for('.confirm_request') }}">Get a new one</a>.</p>
<p>Before you can access this site you need to confirm your account. Check your inbox, you should have received an email with a confirmation link.</p>
<p>Need another confirmation email? Click the button below!</p>
</div>
<div class="card-action right-align">
<a class="btn" href="{{ url_for('.resend_confirmation') }}">Resend confirmation mail</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,8 +2,8 @@
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
<li class="tab"><a href="{{ url_for('main.dashboard', _anchor='jobs') }}" target="_self">My corpora</a></li> <li class="tab"><a href="{{ url_for('main.dashboard', _anchor='jobs') }}" target="_self">My corpora</a></li>
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
{% if request.path == url_for('.add_corpus') %} {% if request.path == url_for('.create_corpus') %}
<li class="tab"><a class="active" href="{{ url_for('.add_corpus') }}" target="_self">{{ title }}</a></li> <li class="tab"><a class="active" href="{{ url_for('.create_corpus') }}" target="_self">{{ title }}</a></li>
{% elif request.path == url_for('.import_corpus') %} {% elif request.path == url_for('.import_corpus') %}
<li class="tab"><a class="active" href="{{ url_for('.import_corpus') }}" target="_self">{{ title }}</a></li> <li class="tab"><a class="active" href="{{ url_for('.import_corpus') }}" target="_self">{{ title }}</a></li>
{% elif request.path == url_for('.corpus', corpus_id=corpus.id) %} {% elif request.path == url_for('.corpus', corpus_id=corpus.id) %}
@ -12,12 +12,12 @@
<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li> <li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
<li class="tab"><a class="active" href="{{ url_for('.analyse_corpus', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li> <li class="tab"><a class="active" href="{{ url_for('.analyse_corpus', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li>
{% elif request.path == url_for('.add_corpus_file', corpus_id=corpus.id) %} {% elif request.path == url_for('.create_corpus_file', corpus_id=corpus.id) %}
<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li> <li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id, _anchor='files') }}" target="_self">Corpus files</a></li> <li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id, _anchor='files') }}" target="_self">Corpus files</a></li>
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
<li class="tab"><a class="active" href="{{ url_for('.add_corpus_file', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li> <li class="tab"><a class="active" href="{{ url_for('.create_corpus_file', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li>
{% elif request.path == url_for('.corpus_file', corpus_file_id=corpus_file.id, corpus_id=corpus.id) %} {% elif request.path == url_for('.corpus_file', corpus_file_id=corpus_file.id, corpus_id=corpus.id) %}
<li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li> <li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>

View File

@ -65,38 +65,21 @@
</div> </div>
<div class="card-action right-align"> <div class="card-action right-align">
<a class="btn corpus-analyse-trigger disabled waves-effect waves-light" href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">search</i>Analyze</a> <a class="btn corpus-analyse-trigger disabled waves-effect waves-light" href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">search</i>Analyze</a>
<a class="btn corpus-build-trigger disabled waves-effect waves-light" href="{{ url_for('corpora.build_corpus', corpus_id=corpus.id) }}"><i class="nopaque-icons left">K</i>Build</a> <a class="action-button btn disabled waves-effect waves-light" data-action="build-request"><i class="nopaque-icons left">K</i>Build</a>
<a class="btn disabled export-corpus-trigger waves-effect waves-light" href="{{ url_for('corpora.export_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">import_export</i>Export</a> <a class="btn disabled export-corpus-trigger waves-effect waves-light" href="{{ url_for('corpora.export_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">import_export</i>Export</a>
<a class="btn modal-trigger red waves-effect waves-light" data-target="delete-corpus-modal"><i class="material-icons left">delete</i>Delete</a> <a class="action-button btn red waves-effect waves-light" data-action="delete-request"><i class="material-icons left">delete</i>Delete</a>
</div> </div>
</div> </div>
</div> </div>
<div class="col s12 nopaque-ressource-list" data-corpus-id="{{ corpus.hashid }}" data-ressource-type="CorpusFile" data-user-id="{{ corpus.user.hashid }}"> <div class="col s12">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<span class="card-title" id="files">Corpus files</span> <span class="card-title" id="files">Corpus files</span>
<div class="input-field"> <div class="corpus-file-list" data-user-id="{{ corpus.user.hashid }}" data-corpus-id="{{ corpus.hashid }}"></div>
<i class="material-icons prefix">search</i>
<input class="search" id="search-corpus-files" type="search"></input>
<label for="search-corpus-files">Search corpus files</label>
</div>
<table>
<thead>
<tr>
<th>Filename</th>
<th>Author</th>
<th>Title</th>
<th>Publishing year</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div> </div>
<div class="card-action right-align"> <div class="card-action right-align">
<a href="{{ url_for('corpora.add_corpus_file', corpus_id=corpus.id) }}" class="btn waves-effect waves-light"><i class="material-icons left">add</i>Add corpus file</a> <a href="{{ url_for('corpora.create_corpus_file', corpus_id=corpus.id) }}" class="btn waves-effect waves-light"><i class="material-icons left">add</i>Add corpus file</a>
</div> </div>
</div> </div>
</div> </div>
@ -104,20 +87,6 @@
</div> </div>
{% endblock page_content %} {% endblock page_content %}
{% block modals %}
{{ super() }}
<div id="delete-corpus-modal" class="modal">
<div class="modal-content">
<h4>Confirm corpus deletion</h4>
<p>Do you really want to delete the corpus <span class="corpus-title"></span>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light" href="#!">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="{{ url_for('corpora.delete_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
{% endblock modals %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script> <script>

View File

@ -13,11 +13,10 @@
<div class="col s12 m4"> <div class="col s12 m4">
<p>Fill out the following form to add a corpus file in verticalized text format (.vrt).</p> <p>Fill out the following form to add a corpus file in verticalized text format (.vrt).</p>
<p><b>Do not use the .stand-off.vrt file!</b></p>
</div> </div>
<div class="col s12 m8"> <div class="col s12 m8">
<form class="nopaque-upload-form" data-progress-modal="progress-modal"> <form class="create-corpus-file-form" enctype="multipart/form-data" method="POST">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
@ -52,39 +51,8 @@
</div> </div>
</li> </li>
</ul> </ul>
<br>
<ul class="collapsible hoverable">
<li>
<div class="collapsible-header"><i class="material-icons">add</i>Add metadata with BibTex</div>
<div class="collapsible-body">
<span>
<div class="row">
<div class="col s12">
</div>
</div>
</span>
</div>
</li>
</ul>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
{% endblock page_content %} {% endblock page_content %}
{% block modals %}
{{ super() }}
<div id="progress-modal" class="modal">
<div class="modal-content">
<h4><i class="material-icons left">file_upload</i>Uploading files...</h4>
<div class="progress">
<div class="determinate" style="width: 0%"></div>
</div>
</div>
<div class="modal-footer">
<a href="#!" class="modal-close waves-effect waves-light btn red abort-request">Cancel</a>
</div>
</div>
{% endblock modals %}

View File

@ -1,19 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<h1 id="title">{{ title }}</h1>
<p class="light">{{ request.path }}</p>
<p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
</div>
<div class="modal" id="more-information-modal">
<div class="modal-content">
<h2>About the "{{ title }}" error</h2>
<p>The request contained valid data and was understood by the server, but the server is refusing action. This may be due to the user not having the necessary permissions for a resource or needing an account of some sort, or attempting a prohibited action (e.g. creating a duplicate record where only one is allowed). This code is also typically used if the request provided authentication by answering the WWW-Authenticate header field challenge, but the server did not accept that authentication. The request should not be repeated.</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
</div>
</div>
{% endblock page_content %}

View File

@ -1,19 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<h1 id="title">{{ title }}</h1>
<p class="light">{{ request.path }}</p>
<p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
</div>
<div class="modal" id="more-information-modal">
<div class="modal-content">
<h2>About the "{{ title }}" error</h2>
<p>The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
</div>
</div>
{% endblock page_content %}

View File

@ -1,19 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<h1 id="title">{{ title }}</h1>
<p class="light">{{ request.path }}</p>
<p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
</div>
<div class="modal" id="more-information-modal">
<div class="modal-content">
<h2>About the "{{ title }}" error</h2>
<p>The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large".</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
</div>
</div>
{% endblock page_content %}

View File

@ -1,19 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<h1 id="title">{{ title }}</h1>
<p class="light">{{ request.path }}</p>
<p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
</div>
<div class="modal" id="more-information-modal">
<div class="modal-content">
<h2>About the "{{ title }}" error</h2>
<p>A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
</div>
</div>
{% endblock page_content %}

View File

@ -1,19 +0,0 @@
{% extends "base.html.j2" %}
{% block page_content %}
<div class="container">
<h1 id="title">{{ title }}</h1>
<p class="light">{{ request.path }}</p>
<p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
</div>
<div class="modal" id="more-information-modal">
<div class="modal-content">
<h2>About the "{{ title }}" error</h2>
<p>The server cannot handle the request (because it is overloaded or down for maintenance). Generally, this is a temporary state.</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
</div>
</div>
{% endblock page_content %}

View File

@ -0,0 +1,10 @@
{% extends "base.html.j2" %}
{% set title = error.name %}
{% block page_content %}
<div class="container">
<h1 id="title">{{ error.name }}</h1>
<p>{{ error.description }}</p>
</div>
{% endblock page_content %}

View File

@ -79,61 +79,31 @@
</div> </div>
<div class="card-action right-align"> <div class="card-action right-align">
{% if current_user.is_administrator() %} {% if current_user.is_administrator() %}
<a class="btn hide modal-trigger job-log-trigger waves-effect waves-light" data-target="job-log-modal"><i class="material-icons left">repeat</i>Log</a> <a class="action-button btn disabled waves-effect waves-light" data-action="get-log-request"><i class="material-icons left">text_snippet</i>Log</a>
<a class="btn hide modal-trigger restart-job-trigger waves-effect waves-light" data-target="restart-job-modal"><i class="material-icons left">repeat</i>Restart</a>
{% endif %} {% endif %}
<!-- <a href="#" class="btn disabled waves-effect waves-light"><i class="material-icons left">settings</i>Export Parameters</a> --> <a class="action-button btn disabled waves-effect waves-light" data-action="restart-request"><i class="material-icons left">repeat</i>Restart</a>
<a class="btn modal-trigger red waves-effect waves-light" data-target="delete-job-modal"><i class="material-icons left">delete</i>Delete</a> <a class="action-button btn red waves-effect waves-light" data-action="delete-request"><i class="material-icons left">delete</i>Delete</a>
</div> </div>
</div> </div>
</div> </div>
<div class="col s12 nopaque-ressource-list" data-job-id="{{ job.hashid }}" data-ressource-type="JobInput" data-user-id="{{ job.user.hashid }}"> <div class="col s12">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="row"> <div class="row">
<div class="col s12 m2"> <span class="card-title"><i class="left material-icons" style="font-size: inherit;">input</i>Inputs</span>
<span class="card-title"><i class="left material-icons" style="font-size: inherit;">input</i>Inputs</span> <div class="job-input-list" data-user-id="{{ job.user.hashid }}" data-job-id="{{ job.hashid }}"></div>
<p>Original input files.</p>
</div>
<div class="col s12 m10">
<table>
<thead>
<tr>
<th>Filename</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col s12 nopaque-ressource-list" data-job-id="{{ job.hashid }}" data-ressource-type="JobResult" data-user-id="{{ job.user.hashid }}"> <div class="col s12">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="row"> <div class="row">
<div class="col s12 m2"> <span class="card-title"><i class="left material-icons" style="font-size: inherit;">done</i>Results</span>
<span class="card-title"><i class="left material-icons" style="font-size: inherit;">done</i>Results</span> <div class="job-result-list" data-user-id="{{ job.user.hashid }}" data-job-id="{{ job.hashid }}"></div>
<p>Processed result files.</p>
</div>
<div class="col s12 m10">
<table>
<thead>
<tr>
<th>Description</th>
<th>Filename</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -142,42 +112,6 @@
</div> </div>
{% endblock page_content %} {% endblock page_content %}
{% block modals %}
{{ super() }}
<div id="job-log-modal" class="modal">
<div class="modal-content">
<h4>Job logs</h4>
<iframe src="{{ url_for('jobs.job_log', job_id=job.id) }}" style="border: 0; width: 100%; height: 450px;"></iframe>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Close</a>
</div>
</div>
<div id="delete-job-modal" class="modal">
<div class="modal-content">
<h4>Confirm deletion</h4>
<p>Do you really want to delete the job <span class="job-title"></span>? All associated files will be permanently deleted.</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="{{ url_for('jobs.delete_job', job_id=job.id) }}"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
{% if current_user.is_administrator() %}
<div id="restart-job-modal" class="modal">
<div class="modal-content">
<h4>Confirm restart</h4>
<p>Do you really want to restart the job <span class="job-title"></span>? All log and result files will be permanently deleted.</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="{{ url_for('jobs.restart', job_id=job.id) }}"><i class="material-icons left">restart</i>Restart</a>
</div>
</div>
{% endif %}
{% endblock modals %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}

View File

@ -6,75 +6,32 @@
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12">
<h1 id="title">{{ title }}</h1> <h1 id="title">{{ title }}</h1>
</div>
<div class="col s12">
<h3>My Corpora and Query results</h3> <h3>My Corpora and Query results</h3>
<p>Create a corpus to interactively perform linguistic analysis or import query results to save interesting passages.</p> <p>Create a corpus to interactively perform linguistic analysis or import query results to save interesting passages.</p>
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12">
<ul class="tabs"> <ul class="tabs">
<li class="tab col s6"><a class="active" href="#corpora">Corpora</a></li> <li class="tab col s6"><a class="active" href="#corpora">Corpora</a></li>
<li class="tab col s6"><a href="#query-results">Query results</a></li> <li class="tab col s6 disabled"><a href="#query-results">Query results</a></li>
</ul> </ul>
</div> </div>
<div class="col s12 nopaque-ressource-list" data-ressource-type="Corpus" data-user-id="{{ current_user.hashid }}" id="corpora"> <div class="col s12" id="corpora">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="input-field"> <div class="corpus-list" data-user-id="{{ current_user.hashid }}"></div>
<i class="material-icons prefix">search</i>
<input id="search-corpus" class="search" type="search"></input>
<label for="search-corpus">Search corpus</label>
</div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div> </div>
<div class="card-action right-align"> <div class="card-action right-align">
<a class="btn waves-effect waves-light" href="{{ url_for('corpora.import_corpus') }}"><i class="material-icons right">import_export</i>Import Corpus</a> <a class="btn disabled waves-effect waves-light" href="{{ url_for('corpora.import_corpus') }}">Import Corpus<i class="material-icons right">import_export</i></a>
<a class="btn waves-effect waves-light" href="{{ url_for('corpora.add_corpus') }}">New corpus<i class="material-icons right">add</i></a> <a class="btn waves-effect waves-light" href="{{ url_for('corpora.create_corpus') }}">Create corpus<i class="material-icons right">add</i></a>
</div>
</div>
</div>
{# <div class="col s12 nopaque-ressource-list" data-ressource-type="QueryResult" data-user-id="{{ current_user.hashid }}" id="query-results"> #}
<div class="col s12" id="query-results">
<div class="card">
<div class="card-content">
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="search-query-results" class="search" type="search"></input>
<label for="search-query-results">Search query result</label>
</div>
<table>
<thead>
<tr>
<th>Title and Description</th>
<th>Corpus and Query</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div>
<div class="card-action right-align">
<a class="waves-effect waves-light btn disabled">Add query result<i class="material-icons right">file_upload</i></a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col s12 nopaque-ressource-list" data-ressource-type="Job" data-user-id="{{ current_user.hashid }}" id="jobs"> <div class="col s12" id="jobs">
<h3>My Jobs</h3> <h3>My Jobs</h3>
<p> <p>
A job is the execution of a service provided by nopaque. You can A job is the execution of a service provided by nopaque. You can
@ -87,26 +44,10 @@
<p><b>Where is my Job data?</b> Don't worry, please read <a href="{{ url_for('main.news', _anchor='april-2022-update') }}">this news</a> entry</p> <p><b>Where is my Job data?</b> Don't worry, please read <a href="{{ url_for('main.news', _anchor='april-2022-update') }}">this news</a> entry</p>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="input-field"> <div class="job-list" data-user-id="{{ current_user.hashid }}"></div>
<i class="material-icons prefix">search</i>
<input id="search-job" class="search" type="search"></input>
<label for="search-job">Search job</label>
</div>
<table>
<thead>
<tr>
<th>Service</th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div> </div>
<div class="card-action right-align"> <div class="card-action right-align">
<p><a class="modal-trigger waves-effect waves-light btn" href="#" data-target="new-job-modal"><i class="material-icons left">add</i>New job</a></p> <p><a class="btn modal-trigger waves-effect waves-light" data-target="create-job-modal"><i class="material-icons left">add</i>Create job</a></p>
</div> </div>
</div> </div>
</div> </div>
@ -116,7 +57,7 @@
{% block modals %} {% block modals %}
{{ super() }} {{ super() }}
<div id="new-job-modal" class="modal"> <div id="create-job-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h4>Select a service</h4> <h4>Select a service</h4>
<p>&nbsp;</p> <p>&nbsp;</p>
@ -160,7 +101,7 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<a href="#!" class="modal-close waves-effect waves-light btn-flat">Close</a> <a class="btn-flat modal-close waves-effect waves-light">Close</a>
</div> </div>
</div> </div>
{% endblock modals %} {% endblock modals %}

View File

@ -22,36 +22,20 @@
<p>Nopaque lets you create and upload as many text corpora as you want. It makes use of CQP Query Language, which allows for complex search requests with the aid of metadata and NLP tags. The results can either be displayed as text or abstract visualizations.</p> <p>Nopaque lets you create and upload as many text corpora as you want. It makes use of CQP Query Language, which allows for complex search requests with the aid of metadata and NLP tags. The results can either be displayed as text or abstract visualizations.</p>
</div> </div>
<div class="col s12 nopaque-ressource-list" data-ressource-type="Corpus" data-user-id="{{ current_user.hashid }}" id="corpora"> <div class="col s12" id="corpora">
<h2>My Corpora</h2> <h2>My Corpora</h2>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="input-field"> <div class="corpus-list" data-user-id="{{ current_user.hashid }}"></div>
<i class="material-icons prefix">search</i>
<input id="search-corpus" class="search" type="search"></input>
<label for="search-corpus">Search corpus</label>
</div>
<table class="highlight">
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div> </div>
<div class="card-action right-align"> <div class="card-action right-align">
<a class="waves-effect waves-light btn" href="{{ url_for('corpora.import_corpus') }}"><i class="material-icons right">import_export</i>Import Corpus</a> <a class="waves-effect waves-light btn" href="{{ url_for('corpora.import_corpus') }}">Import Corpus<i class="material-icons right">import_export</i></a>
<a class="btn waves-effect waves-light" href="{{ url_for('corpora.add_corpus') }}">New corpus<i class="material-icons right">add</i></a> <a class="btn waves-effect waves-light" href="{{ url_for('corpora.create_corpus') }}">Create corpus<i class="material-icons right">add</i></a>
</div> </div>
</div> </div>
</div> </div>
<div class="col s12 nopaque-ressource-list" data-ressource-type="QueryResult" data-user-id="{{ current_user.hashid }}" id="query-results"> <div class="col s12 query-result-list" data-user-id="{{ current_user.hashid }}" id="query-results">
<h2>My query results</h2> <h2>My query results</h2>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">

View File

@ -39,7 +39,7 @@
<div class="col s12"> <div class="col s12">
<h2>Submit a job</h2> <h2>Submit a job</h2>
<div class="card"> <div class="card">
<form class="nopaque-upload-form" data-progress-modal="progress-modal"> <form class="create-job-form" enctype="multipart/form-data" method="POST">
<div class="card-content"> <div class="card-content">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row"> <div class="row">

View File

@ -57,7 +57,7 @@
<div class="col s12"> <div class="col s12">
<h2>Submit a job</h2> <h2>Submit a job</h2>
<div class="card"> <div class="card">
<form class="nopaque-upload-form" data-progress-modal="progress-modal"> <form class="create-job-form" enctype="multipart/form-data" method="POST">
<div class="card-content"> <div class="card-content">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row"> <div class="row">

View File

@ -39,7 +39,7 @@
<div class="col s12"> <div class="col s12">
<h2>Submit a job</h2> <h2>Submit a job</h2>
<div class="card"> <div class="card">
<form class="nopaque-upload-form" data-progress-modal="progress-modal"> <form class="create-job-form" enctype="multipart/form-data" method="POST">
<div class="card-content"> <div class="card-content">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row"> <div class="row">
@ -178,28 +178,4 @@
<a href="#!" class="modal-close waves-effect waves-light btn">Close</a> <a href="#!" class="modal-close waves-effect waves-light btn">Close</a>
</div> </div>
</div> </div>
<div id="progress-modal" class="modal">
<div class="modal-content">
<h4><i class="material-icons left">file_upload</i>Uploading files...</h4>
<div class="progress">
<div class="determinate" style="width: 0%"></div>
</div>
</div>
<div class="modal-footer">
<a href="#!" class="modal-close waves-effect waves-light btn red abort-request">Cancel</a>
</div>
</div>
{% endblock modals %} {% endblock modals %}
{% block scripts %}
{{ super() }}
<script>
let versionField = document.querySelector('#add-job-form-version');
versionField.addEventListener('change', (event) => {
let url = new URL(window.location.href);
url.search = `?version=${event.target.value}`;
window.location.href = url.toString();
});
</script>
{% endblock scripts %}

View File

@ -44,7 +44,7 @@
<div class="col s12"> <div class="col s12">
<h2>Submit a job</h2> <h2>Submit a job</h2>
<div class="card"> <div class="card">
<form class="nopaque-upload-form" data-progress-modal="progress-modal"> <form class="create-job-form" enctype="multipart/form-data" method="POST">
<div class="card-content"> <div class="card-content">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row"> <div class="row">

View File

@ -1,6 +1,6 @@
{% set breadcrumbs %} {% set breadcrumbs %}
<li class="tab disabled"><i class="material-icons">navigate_next</i></li> <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
{% if request.path == url_for('settings.index') %} {% if request.path == url_for('settings.settings') %}
<li class="tab"><a{%if request.path == url_for('settings.index') %} class="active"{% endif %} href="{{ url_for('settings.index') }}" target="_self">Settings</a></li> <li class="tab"><a{%if request.path == url_for('settings.settings') %} class="active"{% endif %} href="{{ url_for('settings.settings') }}" target="_self">Settings</a></li>
{% endif %} {% endif %}
{% endset %} {% endset %}

View File

@ -81,7 +81,7 @@
<span class="card-title">Change Password</span> <span class="card-title">Change Password</span>
{{ wtf.render_field(change_password_form.password, material_icon='vpn_key') }} {{ wtf.render_field(change_password_form.password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password, material_icon='vpn_key') }} {{ wtf.render_field(change_password_form.new_password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password_confirmation, material_icon='vpn_key') }} {{ wtf.render_field(change_password_form.new_password_2, material_icon='vpn_key') }}
</div> </div>
<div class="card-action"> <div class="card-action">
<div class="right-align"> <div class="right-align">
@ -101,23 +101,19 @@
</ul> </ul>
</div> </div>
<div class="card-action right-align"> <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> <a class="btn red waves-effect waves-light" id="delete-user"><i class="material-icons left">delete</i>Delete</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock page_content %} {% endblock page_content %}
{% block modals %} {% block scripts %}
{{ super() }} {{ super() }}
<div class="modal" id="delete-account-modal"> <script>
<div class="modal-content"> document.querySelector('#delete-user').addEventListener('click', (event) => {
<h4>Confirm deletion</h4> Utils.deleteUserRequest(currentUserId)
<p>Do you really want to delete your account and all associated data? All associated corpora, jobs and files will be permanently deleted!</p> .then((response) => {window.location.href = '/';});
</div> });
<div class="modal-footer"> </script>
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a> {% endblock scripts %}
<a href="{{ url_for('.delete') }}" class="btn red waves-effect waves-light"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
{% endblock modals %}

View File

@ -2,4 +2,4 @@ from flask import Blueprint
bp = Blueprint('users', __name__) bp = Blueprint('users', __name__)
from . import events from . import events, routes

View File

@ -1,21 +1,8 @@
from flask_login import current_user
from flask_socketio import join_room, leave_room
from app import hashids, socketio from app import hashids, socketio
from app.decorators import socketio_login_required from app.decorators import socketio_login_required
from app.models import User from app.models import User
from flask_login import current_user
from flask_socketio import join_room, leave_room
@socketio.on('GET /users/<user_id>')
@socketio_login_required
def get_user(user_hashid):
user_id = hashids.decode(user_hashid)
user = User.query.get(user_id)
if user is None:
return {'code': 404, 'msg': 'Not found'}
if not (user == current_user or current_user.is_administrator):
return {'code': 403, 'msg': 'Forbidden'}
dict_user = user.to_dict(backrefs=True, relationships=True)
return {'code': 200, 'msg': 'OK', 'payload': dict_user}
@socketio.on('SUBSCRIBE /users/<user_id>') @socketio.on('SUBSCRIBE /users/<user_id>')

38
app/users/routes.py Normal file
View File

@ -0,0 +1,38 @@
from flask import abort, current_app, request
from flask_login import current_user, login_required
from threading import Thread
from app import db
from app.models import User
from . import bp
@bp.route('/<hashid:user_id>')
@login_required
def user(user_id):
user = User.query.get_or_404(user_id)
if not (user == current_user or current_user.is_administrator()):
abort(403)
backrefs = request.args.get('backrefs', 'false').lower() == 'true'
relationships = (
request.args.get('relationships', 'false').lower() == 'true')
return user.to_json(backrefs=backrefs, relationships=relationships), 200
@bp.route('/<hashid:user_id>', methods=['DELETE'])
@login_required
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)
)
thread.start()
return {}, 202

View File

@ -1,4 +1,4 @@
"""empty message """Initial database setup
Revision ID: 9e8d7d15d950 Revision ID: 9e8d7d15d950
Revises: Revises:

View File

@ -0,0 +1,31 @@
"""Remove token entries for rudimentary API authentication mechanism
Revision ID: f9070ff1fa4a
Revises: 9e8d7d15d950
Create Date: 2022-09-01 13:46:47.425268
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f9070ff1fa4a'
down_revision = '9e8d7d15d950'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_users_token', table_name='users')
op.drop_column('users', 'token')
op.drop_column('users', 'token_expiration')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('token_expiration', sa.DateTime(), autoincrement=False, nullable=True))
op.add_column('users', sa.Column('token', sa.VARCHAR(length=32), autoincrement=False, nullable=True))
op.create_index('ix_users_token', 'users', ['token'], unique=False)
# ### end Alembic commands ###

View File

@ -1,7 +1,8 @@
apifairy
cqi cqi
docker docker
eventlet eventlet
Flask Flask==2.1.3
Flask-APScheduler Flask-APScheduler
Flask-Assets Flask-Assets
Flask-Hashids Flask-Hashids
@ -10,12 +11,12 @@ Flask-Login
Flask-Mail Flask-Mail
Flask-Migrate Flask-Migrate
Flask-Paranoid Flask-Paranoid
Flask-RESTX
Flask-SocketIO Flask-SocketIO
Flask-SQLAlchemy Flask-SQLAlchemy
Flask-WTF Flask-WTF
hiredis hiredis
MarkupSafe==2.0.1 MarkupSafe==2.0.1
marshmallow-sqlalchemy
psycopg2 psycopg2
PyJWT PyJWT
pyScss pyScss