Merge branch 'development'

This commit is contained in:
Inga Kirschnick 2023-06-07 15:14:57 +02:00
commit baf70750e8
194 changed files with 6490 additions and 3934 deletions

2
.gitignore vendored
View File

@ -6,6 +6,8 @@ logs/
!logs/dummy !logs/dummy
*.env *.env
*.pjentsch-testing
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

24
.vscode/settings.json vendored
View File

@ -1 +1,23 @@
{} {
"editor.rulers": [79],
"files.insertFinalNewline": true,
"python.terminal.activateEnvironment": false,
"[css]": {
"editor.tabSize": 2
},
"[scss]": {
"editor.tabSize": 2
},
"[html]": {
"editor.tabSize": 2
},
"[javascript]": {
"editor.tabSize": 2
},
"[jinja-html]": {
"editor.tabSize": 2
},
"[jinja-js]": {
"editor.tabSize": 2
}
}

View File

@ -4,6 +4,7 @@ from docker import DockerClient
from flask import Flask from flask import Flask
from flask_apscheduler import APScheduler from flask_apscheduler import APScheduler
from flask_assets import Environment from flask_assets import Environment
from flask_breadcrumbs import Breadcrumbs, default_breadcrumb_root
from flask_login import LoginManager from flask_login import LoginManager
from flask_mail import Mail from flask_mail import Mail
from flask_marshmallow import Marshmallow from flask_marshmallow import Marshmallow
@ -12,10 +13,12 @@ from flask_paranoid import Paranoid
from flask_socketio import SocketIO from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_hashids import Hashids from flask_hashids import Hashids
from werkzeug.exceptions import HTTPException
apifairy = APIFairy() apifairy = APIFairy()
assets = Environment() assets = Environment()
breadcrumbs = Breadcrumbs()
db = SQLAlchemy() db = SQLAlchemy()
docker_client = DockerClient() docker_client = DockerClient()
hashids = Hashids() hashids = Hashids()
@ -33,7 +36,7 @@ socketio = SocketIO()
def create_app(config: Config = Config) -> Flask: def create_app(config: Config = Config) -> Flask:
''' Creates an initialized Flask (WSGI Application) object. ''' ''' Creates an initialized Flask (WSGI Application) object. '''
app: Flask = Flask(__name__) app = Flask(__name__)
app.config.from_object(config) app.config.from_object(config)
config.init_app(app) config.init_app(app)
docker_client.login( docker_client.login(
@ -44,6 +47,7 @@ def create_app(config: Config = Config) -> Flask:
apifairy.init_app(app) apifairy.init_app(app)
assets.init_app(app) assets.init_app(app)
breadcrumbs.init_app(app)
db.init_app(app) db.init_app(app)
hashids.init_app(app) hashids.init_app(app)
login.init_app(app) login.init_app(app)
@ -55,36 +59,45 @@ def create_app(config: Config = Config) -> Flask:
socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI']) # noqa socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI']) # noqa
from .admin import bp as admin_blueprint from .admin import bp as admin_blueprint
default_breadcrumb_root(admin_blueprint, '.admin')
app.register_blueprint(admin_blueprint, url_prefix='/admin') app.register_blueprint(admin_blueprint, url_prefix='/admin')
from .api import bp as api_blueprint from .api import bp as api_blueprint
app.register_blueprint(api_blueprint, url_prefix='/api') 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') default_breadcrumb_root(auth_blueprint, '.')
app.register_blueprint(auth_blueprint)
from .contributions import bp as contributions_blueprint from .contributions import bp as contributions_blueprint
default_breadcrumb_root(contributions_blueprint, '.contributions')
app.register_blueprint(contributions_blueprint, url_prefix='/contributions') app.register_blueprint(contributions_blueprint, url_prefix='/contributions')
from .corpora import bp as corpora_blueprint from .corpora import bp as corpora_blueprint
app.register_blueprint(corpora_blueprint, url_prefix='/corpora') default_breadcrumb_root(corpora_blueprint, '.corpora')
app.register_blueprint(corpora_blueprint, cli_group='corpus', url_prefix='/corpora')
from .errors import bp as errors_blueprint from .errors import bp as errors_bp
app.register_blueprint(errors_blueprint) app.register_blueprint(errors_bp)
from .jobs import bp as jobs_blueprint from .jobs import bp as jobs_blueprint
default_breadcrumb_root(jobs_blueprint, '.jobs')
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, url_prefix='/') default_breadcrumb_root(main_blueprint, '.')
app.register_blueprint(main_blueprint, cli_group=None)
from .services import bp as services_blueprint from .services import bp as services_blueprint
default_breadcrumb_root(services_blueprint, '.services')
app.register_blueprint(services_blueprint, url_prefix='/services') app.register_blueprint(services_blueprint, url_prefix='/services')
from .settings import bp as settings_blueprint from .settings import bp as settings_blueprint
default_breadcrumb_root(settings_blueprint, '.settings')
app.register_blueprint(settings_blueprint, url_prefix='/settings') app.register_blueprint(settings_blueprint, url_prefix='/settings')
from .users import bp as users_blueprint from .users import bp as users_blueprint
default_breadcrumb_root(users_blueprint, '.users')
app.register_blueprint(users_blueprint, url_prefix='/users') app.register_blueprint(users_blueprint, url_prefix='/users')
return app return app

View File

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

View File

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

23
app/admin/json_routes.py Normal file
View File

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

View File

@ -1,111 +1,146 @@
from flask import current_app, flash, redirect, render_template, url_for from flask import abort, flash, redirect, render_template, url_for
from flask_login import login_required from flask_breadcrumbs import register_breadcrumb
from threading import Thread
from app import db, hashids from app import db, hashids
from app.decorators import admin_required from app.models import Avatar, Corpus, Role, User
from app.models import Role, User, UserSettingJobStatusMailNotificationLevel from app.users.settings.forms import (
from app.settings.forms import ( UpdateAvatarForm,
EditNotificationSettingsForm UpdatePasswordForm,
UpdateNotificationsForm,
UpdateAccountInformationForm,
UpdateProfileInformationForm
) )
from app.users.forms import EditProfileSettingsForm
from . import bp from . import bp
from .forms import AdminEditUserForm from .forms import UpdateUserForm
from app.users.utils import (
user_endpoint_arguments_constructor as user_eac,
@bp.before_request user_dynamic_list_constructor as user_dlc
@login_required )
@admin_required
def before_request():
'''
Ensures that the routes in this package can be visited only by users with
administrator privileges (login_required and admin_required).
'''
pass
@bp.route('') @bp.route('')
def index(): @register_breadcrumb(bp, '.', '<i class="material-icons left">admin_panel_settings</i>Administration')
return redirect(url_for('.users')) def admin():
return render_template(
'admin/admin.html.j2',
title='Administration'
)
@bp.route('/corpora')
@register_breadcrumb(bp, '.corpora', 'Corpora')
def corpora():
corpora = Corpus.query.all()
return render_template(
'admin/corpora.html.j2',
title='Corpora',
corpora=corpora
)
@bp.route('/users') @bp.route('/users')
@register_breadcrumb(bp, '.users', '<i class="material-icons left">group</i>Users')
def users(): def users():
users = [x.to_json_serializeable(backrefs=True) for x in User.query.all()] users = User.query.all()
return render_template( return render_template(
'admin/users.html.j2', 'admin/users.html.j2',
users=users, title='Users',
title='Users' users=users
) )
@bp.route('/users/<hashid:user_id>') @bp.route('/users/<hashid:user_id>')
@register_breadcrumb(bp, '.users.entity', '', dynamic_list_constructor=user_dlc)
def user(user_id): def user(user_id):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
return render_template('admin/user.html.j2', title='User', user=user) corpora = Corpus.query.filter(Corpus.user == user).all()
return render_template(
'admin/user.html.j2',
title=user.username,
user=user,
corpora=corpora
)
@bp.route('/users/<hashid:user_id>/edit', methods=['GET', 'POST']) @bp.route('/users/<hashid:user_id>/settings', methods=['GET', 'POST'])
def edit_user(user_id): @register_breadcrumb(bp, '.users.entity.settings', '<i class="material-icons left">settings</i>Settings')
def user_settings(user_id):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
admin_edit_user_form = AdminEditUserForm( update_account_information_form = UpdateAccountInformationForm(user)
data={'confirmed': user.confirmed, 'role': user.role.hashid}, update_profile_information_form = UpdateProfileInformationForm(user)
prefix='admin-edit-user-form' update_avatar_form = UpdateAvatarForm()
update_password_form = UpdatePasswordForm(user)
update_notifications_form = UpdateNotificationsForm(user)
update_user_form = UpdateUserForm(user)
# region handle update profile information form
if update_profile_information_form.submit.data and update_profile_information_form.validate():
user.about_me = update_profile_information_form.about_me.data
user.location = update_profile_information_form.location.data
user.organization = update_profile_information_form.organization.data
user.website = update_profile_information_form.website.data
user.full_name = update_profile_information_form.full_name.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update profile information form
# region handle update avatar form
if update_avatar_form.submit.data and update_avatar_form.validate():
try:
Avatar.create(
update_avatar_form.avatar.data,
user=user
) )
edit_profile_settings_form = EditProfileSettingsForm( except (AttributeError, OSError):
user, abort(500)
data=user.to_json_serializeable(), db.session.commit()
prefix='edit-profile-settings-form' flash('Your changes have been saved')
) return redirect(url_for('.user_settings', user_id=user.id))
edit_notification_settings_form = EditNotificationSettingsForm( # endregion handle update avatar form
data=user.to_json_serializeable(),
prefix='edit-notification-settings-form' # region handle update account information form
) if update_account_information_form.submit.data and update_account_information_form.validate():
if (admin_edit_user_form.submit.data user.email = update_account_information_form.email.data
and admin_edit_user_form.validate()): user.username = update_account_information_form.username.data
user.confirmed = admin_edit_user_form.confirmed.data db.session.commit()
role_id = hashids.decode(admin_edit_user_form.role.data) flash('Profile settings updated')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update account information form
# region handle update password form
if update_password_form.submit.data and update_password_form.validate():
user.password = update_password_form.new_password.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update password form
# region handle update notifications form
if update_notifications_form.submit.data and update_notifications_form.validate():
user.setting_job_status_mail_notification_level = \
update_notifications_form.job_status_mail_notification_level.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user_settings', user_id=user.id))
# endregion handle update notifications form
# region handle update user form
if update_user_form.submit.data and update_user_form.validate():
role_id = hashids.decode(update_user_form.role.data)
user.role = Role.query.get(role_id) user.role = Role.query.get(role_id)
db.session.commit() db.session.commit()
flash('Your changes have been saved') flash('Your changes have been saved')
return redirect(url_for('.edit_user', user_id=user.id)) return redirect(url_for('.user_settings', user_id=user.id))
if (edit_profile_settings_form.submit.data # endregion handle update user form
and edit_profile_settings_form.validate()):
user.email = edit_profile_settings_form.email.data
user.username = edit_profile_settings_form.username.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.edit_user', user_id=user.id))
if (edit_notification_settings_form.submit.data
and edit_notification_settings_form.validate()):
user.setting_job_status_mail_notification_level = \
UserSettingJobStatusMailNotificationLevel[
edit_notification_settings_form.job_status_mail_notification_level.data # noqa
]
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.edit_user', user_id=user.id))
return render_template( return render_template(
'admin/edit_user.html.j2', 'admin/user_settings.html.j2',
admin_edit_user_form=admin_edit_user_form, title='Settings',
edit_profile_settings_form=edit_profile_settings_form, update_account_information_form=update_account_information_form,
edit_notification_settings_form=edit_notification_settings_form, update_avatar_form=update_avatar_form,
title='Edit user', update_notifications_form=update_notifications_form,
update_password_form=update_password_form,
update_profile_information_form=update_profile_information_form,
update_user_form=update_user_form,
user=user user=user
) )
@bp.route('/users/<hashid:user_id>/delete', methods=['DELETE'])
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.query.get_or_404(user_id)
thread = Thread(
target=_delete_user,
args=(current_app._get_current_object(), user_id)
)
thread.start()
return {}, 202

View File

@ -2,7 +2,6 @@ from apifairy.fields import FileField
from marshmallow import validate, validates, ValidationError from marshmallow import validate, validates, ValidationError
from marshmallow.decorators import post_dump from marshmallow.decorators import post_dump
from app import ma from app import ma
from app.auth import USERNAME_REGEX
from app.models import ( from app.models import (
Job, Job,
JobStatus, JobStatus,
@ -142,7 +141,10 @@ class UserSchema(ma.SQLAlchemySchema):
username = ma.auto_field( username = ma.auto_field(
validate=[ validate=[
validate.Length(min=1, max=64), validate.Length(min=1, max=64),
validate.Regexp(USERNAME_REGEX, error='Usernames must have only letters, numbers, dots or underscores') validate.Regexp(
User.username_pattern,
error='Usernames must have only letters, numbers, dots or underscores'
)
] ]
) )
email = ma.auto_field(validate=validate.Email()) email = ma.auto_field(validate=validate.Email())

View File

@ -1,8 +1,5 @@
from flask import Blueprint from flask import Blueprint
USERNAME_REGEX = '^[A-Za-zÄÖÜäöüß0-9_.]*$'
bp = Blueprint('auth', __name__) bp = Blueprint('auth', __name__)
from . import routes from . import routes

View File

@ -8,7 +8,6 @@ from wtforms import (
) )
from wtforms.validators import InputRequired, Email, EqualTo, Length, Regexp from wtforms.validators import InputRequired, Email, EqualTo, Length, Regexp
from app.models import User from app.models import User
from . import USERNAME_REGEX
class RegistrationForm(FlaskForm): class RegistrationForm(FlaskForm):
@ -22,7 +21,7 @@ class RegistrationForm(FlaskForm):
InputRequired(), InputRequired(),
Length(max=64), Length(max=64),
Regexp( Regexp(
USERNAME_REGEX, User.username_pattern,
message=( message=(
'Usernames must have only letters, numbers, dots or ' 'Usernames must have only letters, numbers, dots or '
'underscores' 'underscores'
@ -44,8 +43,17 @@ class RegistrationForm(FlaskForm):
EqualTo('password', message='Passwords must match') EqualTo('password', message='Passwords must match')
] ]
) )
terms_of_use_accepted = BooleanField(
'I have read and accept the terms of use',
validators=[InputRequired()]
)
submit = SubmitField() submit = SubmitField()
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'registration-form'
super().__init__(*args, **kwargs)
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():
raise ValidationError('Email already registered') raise ValidationError('Email already registered')
@ -61,11 +69,21 @@ class LoginForm(FlaskForm):
remember_me = BooleanField('Keep me logged in') remember_me = BooleanField('Keep me logged in')
submit = SubmitField() submit = SubmitField()
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'login-form'
super().__init__(*args, **kwargs)
class ResetPasswordRequestForm(FlaskForm): class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[InputRequired(), Email()]) email = StringField('Email', validators=[InputRequired(), Email()])
submit = SubmitField() submit = SubmitField()
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'reset-password-request-form'
super().__init__(*args, **kwargs)
class ResetPasswordForm(FlaskForm): class ResetPasswordForm(FlaskForm):
password = PasswordField( password = PasswordField(
@ -83,3 +101,8 @@ class ResetPasswordForm(FlaskForm):
] ]
) )
submit = SubmitField() submit = SubmitField()
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'reset-password-form'
super().__init__(*args, **kwargs)

View File

@ -1,11 +1,5 @@
from flask import ( from flask import abort, flash, redirect, render_template, request, url_for
abort, from flask_breadcrumbs import register_breadcrumb
flash,
redirect,
render_template,
request,
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 app import db from app import db
from app.email import create_message, send from app.email import create_message, send
@ -36,16 +30,18 @@ def before_request():
@bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.register', 'Register')
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()
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
user = User.create( user = User.create(
email=form.email.data.lower(), email=form.email.data.lower(),
password=form.password.data, password=form.password.data,
username=form.username.data username=form.username.data,
terms_of_use_accepted=form.terms_of_use_accepted.data
) )
except OSError: except OSError:
flash('Internal Server Error', category='error') flash('Internal Server Error', category='error')
@ -65,16 +61,17 @@ def register():
return redirect(url_for('.login')) return redirect(url_for('.login'))
return render_template( return render_template(
'auth/register.html.j2', 'auth/register.html.j2',
form=form, title='Register',
title='Register' form=form
) )
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.login', 'Login')
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
form = LoginForm(prefix='login-form') form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first() 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): if user and user.verify_password(form.password.data):
@ -85,7 +82,11 @@ def login():
flash('You have been logged in') flash('You have been logged in')
return redirect(next) return redirect(next)
flash('Invalid email/username or password', category='error') flash('Invalid email/username or password', category='error')
return render_template('auth/login.html.j2', form=form, title='Log in') return render_template(
'auth/login.html.j2',
title='Log in',
form=form
)
@bp.route('/logout') @bp.route('/logout')
@ -97,14 +98,18 @@ def logout():
@bp.route('/unconfirmed') @bp.route('/unconfirmed')
@register_breadcrumb(bp, '.unconfirmed', 'Unconfirmed')
@login_required @login_required
def unconfirmed(): def unconfirmed():
if current_user.confirmed: if 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-request')
@login_required @login_required
def confirm_request(): def confirm_request():
if current_user.confirmed: if current_user.confirmed:
@ -135,11 +140,12 @@ def confirm(token):
return redirect(url_for('.unconfirmed')) return redirect(url_for('.unconfirmed'))
@bp.route('/reset_password', methods=['GET', 'POST']) @bp.route('/reset-password-request', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.reset_password_request', 'Password Reset')
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'))
form = ResetPasswordRequestForm(prefix='reset-password-request-form') form = ResetPasswordRequestForm()
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:
@ -159,16 +165,17 @@ def reset_password_request():
return redirect(url_for('.login')) return redirect(url_for('.login'))
return render_template( return render_template(
'auth/reset_password_request.html.j2', 'auth/reset_password_request.html.j2',
form=form, title='Password Reset',
title='Password Reset' form=form
) )
@bp.route('/reset_password/<token>', methods=['GET', 'POST']) @bp.route('/reset-password/<token>', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.reset_password', 'Password Reset')
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'))
form = ResetPasswordForm(prefix='reset-password-form') form = ResetPasswordForm()
if form.validate_on_submit(): if form.validate_on_submit():
if User.reset_password(token, form.password.data): if User.reset_password(token, form.password.data):
db.session.commit() db.session.commit()
@ -177,7 +184,7 @@ def reset_password(token):
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,
title='Password Reset', title='Password Reset',
form=form,
token=token token=token
) )

View File

@ -1,72 +0,0 @@
from flask import current_app
from flask_migrate import upgrade
import click
import os
from app.models import (
Role,
User,
TesseractOCRPipelineModel,
SpaCyNLPPipelineModel
)
def _make_default_dirs():
base_dir = current_app.config['NOPAQUE_DATA_DIR']
default_directories = [
os.path.join(base_dir, 'tmp'),
os.path.join(base_dir, 'users')
]
for directory in default_directories:
if os.path.exists(directory):
if not os.path.isdir(directory):
raise NotADirectoryError(f'{directory} is not a directory')
else:
os.mkdir(directory)
def register(app):
@app.cli.command()
def deploy():
''' Run deployment tasks. '''
# Make default directories
_make_default_dirs()
# migrate database to latest revision
upgrade()
# Insert/Update default database values
current_app.logger.info('Insert/Update default roles')
Role.insert_defaults()
current_app.logger.info('Insert/Update default users')
User.insert_defaults()
current_app.logger.info('Insert/Update default SpaCyNLPPipelineModels')
SpaCyNLPPipelineModel.insert_defaults()
current_app.logger.info('Insert/Update default TesseractOCRPipelineModels')
TesseractOCRPipelineModel.insert_defaults()
@app.cli.group()
def converter():
''' Converter commands. '''
pass
@converter.command()
@click.argument('json_db')
@click.argument('data_dir')
def sandpaper(json_db, data_dir):
''' Sandpaper converter '''
from app.converters.sandpaper import convert
convert(json_db, data_dir)
@app.cli.group()
def test():
''' Test commands. '''
pass
@test.command('run')
def run_test():
''' Run unit tests. '''
from unittest import TestLoader, TextTestRunner
from unittest.suite import TestSuite
tests: TestSuite = TestLoader().discover('tests')
TextTestRunner(verbosity=2).run(tests)

View File

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

View File

@ -1,16 +1,11 @@
from flask import current_app
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired
from wtforms import ( from wtforms import (
BooleanField,
StringField, StringField,
SubmitField, SubmitField,
SelectMultipleField, SelectMultipleField,
IntegerField, IntegerField
ValidationError
) )
from wtforms.validators import InputRequired, Length from wtforms.validators import InputRequired, Length
from app.services import SERVICES
class ContributionBaseForm(FlaskForm): class ContributionBaseForm(FlaskForm):
@ -48,74 +43,5 @@ class ContributionBaseForm(FlaskForm):
submit = SubmitField() submit = SubmitField()
class CreateTesseractOCRPipelineModelForm(ContributionBaseForm): class UpdateContributionBaseForm(ContributionBaseForm):
tesseract_model_file = FileField(
'File',
validators=[FileRequired()]
)
def validate_tesseract_model_file(self, field):
if not field.data.filename.lower().endswith('.traineddata'):
raise ValidationError('traineddata files only!')
def __init__(self, *args, **kwargs):
service_manifest = SERVICES['tesseract-ocr-pipeline']
super().__init__(*args, **kwargs)
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
spacy_model_file = FileField(
'File',
validators=[FileRequired()]
)
pipeline_name = StringField(
'Pipeline name',
validators=[InputRequired(), Length(max=64)]
)
def validate_spacy_model_file(self, field):
if not field.data.filename.lower().endswith('.tar.gz'):
raise ValidationError('.tar.gz files only!')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
service_manifest = SERVICES['spacy-nlp-pipeline']
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class EditContributionBaseForm(ContributionBaseForm):
pass pass
class EditTesseractOCRPipelineModelForm(EditContributionBaseForm):
def __init__(self, *args, **kwargs):
service_manifest = SERVICES['tesseract-ocr-pipeline']
super().__init__(*args, **kwargs)
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class EditSpaCyNLPPipelineModelForm(EditContributionBaseForm):
pipeline_name = StringField(
'Pipeline name',
validators=[InputRequired(), Length(max=64)]
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
service_manifest = SERVICES['spacy-nlp-pipeline']
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''

View File

@ -1,233 +1,9 @@
from flask import ( from flask import redirect, url_for
abort, from flask_breadcrumbs import register_breadcrumb
current_app,
flash,
Markup,
redirect,
render_template,
url_for
)
from flask_login import login_required, current_user
from threading import Thread
from app import db
from app.decorators import permission_required
from app.models import (
Permission,
SpaCyNLPPipelineModel,
TesseractOCRPipelineModel
)
from . import bp from . import bp
from .forms import (
CreateSpaCyNLPPipelineModelForm,
CreateTesseractOCRPipelineModelForm,
EditSpaCyNLPPipelineModelForm,
EditTesseractOCRPipelineModelForm
)
@bp.before_request @bp.route('')
@login_required @register_breadcrumb(bp, '.', '<i class="material-icons left">new_label</i>My Contributions')
def before_request():
pass
@bp.route('/')
def contributions(): def contributions():
return render_template( return redirect(url_for('main.dashboard', _anchor='contributions'))
'contributions/contributions.html.j2',
title='Contributions'
)
@bp.route('/tesseract-ocr-pipeline-models')
def tesseract_ocr_pipeline_models():
return render_template(
'contributions/tesseract_ocr_pipeline_models.html.j2',
title='Tesseract OCR Pipeline Models'
)
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
form = EditTesseractOCRPipelineModelForm(
data=tesseract_ocr_pipeline_model.to_json_serializeable(),
prefix='edit-tesseract-ocr-pipeline-model-form'
)
if form.validate_on_submit():
form.populate_obj(tesseract_ocr_pipeline_model)
if db.session.is_modified(tesseract_ocr_pipeline_model):
message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model.url}">{tesseract_ocr_pipeline_model.title}</a>" updated')
flash(message)
db.session.commit()
return redirect(url_for('.tesseract_ocr_pipeline_models'))
return render_template(
'contributions/tesseract_ocr_pipeline_model.html.j2',
form=form,
tesseract_ocr_pipeline_model=tesseract_ocr_pipeline_model,
title=f'{tesseract_ocr_pipeline_model.title} {tesseract_ocr_pipeline_model.version}'
)
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
with app.app_context():
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
tesseract_ocr_pipeline_model.delete()
db.session.commit()
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (tesseract_ocr_pipeline_model.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_tesseract_ocr_pipeline_model,
args=(current_app._get_current_object(), tesseract_ocr_pipeline_model_id)
)
thread.start()
return {}, 202
@bp.route('/tesseract-ocr-pipeline-models/create', methods=['GET', 'POST'])
def create_tesseract_ocr_pipeline_model():
form = CreateTesseractOCRPipelineModelForm(prefix='create-tesseract-ocr-pipeline-model-form')
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
return response, 400
try:
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.create(
form.tesseract_model_file.data,
compatible_service_versions=form.compatible_service_versions.data,
description=form.description.data,
publisher=form.publisher.data,
publisher_url=form.publisher_url.data,
publishing_url=form.publishing_url.data,
publishing_year=form.publishing_year.data,
is_public=False,
title=form.title.data,
version=form.version.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
tesseract_ocr_pipeline_model_url = url_for(
'.tesseract_ocr_pipeline_model',
tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id
)
message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model_url}">{tesseract_ocr_pipeline_model.title}</a>" created')
flash(message)
return {}, 201, {'Location': tesseract_ocr_pipeline_model_url}
return render_template(
'contributions/create_tesseract_ocr_pipeline_model.html.j2',
form=form,
title='Create Tesseract OCR Pipeline Model'
)
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>/toggle-public-status', methods=['POST'])
@permission_required(Permission.CONTRIBUTE)
def toggle_tesseract_ocr_pipeline_model_public_status(tesseract_ocr_pipeline_model_id):
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (tesseract_ocr_pipeline_model.user == current_user or current_user.is_administrator()):
abort(403)
tesseract_ocr_pipeline_model.is_public = not tesseract_ocr_pipeline_model.is_public
db.session.commit()
return {}, 201
@bp.route('/spacy-nlp-pipeline-models')
def spacy_nlp_pipeline_models():
return render_template(
'contributions/spacy_nlp_pipeline_models.html.j2',
title='SpaCy NLP Pipeline Models'
)
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
form = EditSpaCyNLPPipelineModelForm(
data=spacy_nlp_pipeline_model.to_json_serializeable(),
prefix='edit-spacy-nlp-pipeline-model-form'
)
if form.validate_on_submit():
form.populate_obj(spacy_nlp_pipeline_model)
if db.session.is_modified(spacy_nlp_pipeline_model):
message = Markup(f'SpaCy NLP Pipeline model "<a href="{spacy_nlp_pipeline_model.url}">{spacy_nlp_pipeline_model.title}</a>" updated')
flash(message)
db.session.commit()
return redirect(url_for('.spacy_nlp_pipeline_models'))
return render_template(
'contributions/spacy_nlp_pipeline_model.html.j2',
form=form,
spacy_nlp_pipeline_model=spacy_nlp_pipeline_model,
title=f'{spacy_nlp_pipeline_model.title} {spacy_nlp_pipeline_model.version}'
)
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
def delete_spacy_model(spacy_nlp_pipeline_model_id):
def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
with app.app_context():
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
spacy_nlp_pipeline_model.delete()
db.session.commit()
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (spacy_nlp_pipeline_model.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_spacy_model,
args=(current_app._get_current_object(), spacy_nlp_pipeline_model_id)
)
thread.start()
return {}, 202
@bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
def create_spacy_nlp_pipeline_model():
form = CreateSpaCyNLPPipelineModelForm(prefix='create-spacy-nlp-pipeline-model-form')
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
return response, 400
try:
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.create(
form.spacy_model_file.data,
compatible_service_versions=form.compatible_service_versions.data,
description=form.description.data,
pipeline_name=form.pipeline_name.data,
publisher=form.publisher.data,
publisher_url=form.publisher_url.data,
publishing_url=form.publishing_url.data,
publishing_year=form.publishing_year.data,
is_public=False,
title=form.title.data,
version=form.version.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
spacy_nlp_pipeline_model_url = url_for(
'.spacy_nlp_pipeline_model',
spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id
)
message = Markup(f'SpaCy NLP Pipeline model "<a href="{spacy_nlp_pipeline_model_url}">{spacy_nlp_pipeline_model.title}</a>" created')
flash(message)
return {}, 201, {'Location': spacy_nlp_pipeline_model_url}
return render_template(
'contributions/create_spacy_nlp_pipeline_model.html.j2',
form=form,
title='Create SpaCy NLP Pipeline Model'
)
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>/toggle-public-status', methods=['POST'])
@permission_required(Permission.CONTRIBUTE)
def toggle_spacy_nlp_pipeline_model_public_status(spacy_nlp_pipeline_model_id):
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (spacy_nlp_pipeline_model.user == current_user or current_user.is_administrator()):
abort(403)
spacy_nlp_pipeline_model.is_public = not spacy_nlp_pipeline_model.is_public
db.session.commit()
return {}, 201

View File

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

View File

@ -0,0 +1,48 @@
from flask_wtf.file import FileField, FileRequired
from wtforms import StringField, ValidationError
from wtforms.validators import InputRequired, Length
from app.services import SERVICES
from ..forms import ContributionBaseForm, UpdateContributionBaseForm
class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
spacy_model_file = FileField(
'File',
validators=[FileRequired()]
)
pipeline_name = StringField(
'Pipeline name',
validators=[InputRequired(), Length(max=64)]
)
def validate_spacy_model_file(self, field):
if not field.data.filename.lower().endswith('.tar.gz'):
raise ValidationError('.tar.gz files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-spacy-nlp-pipeline-model-form'
super().__init__(*args, **kwargs)
service_manifest = SERVICES['spacy-nlp-pipeline']
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class UpdateSpaCyNLPPipelineModelForm(UpdateContributionBaseForm):
pipeline_name = StringField(
'Pipeline name',
validators=[InputRequired(), Length(max=64)]
)
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'edit-spacy-nlp-pipeline-model-form'
super().__init__(*args, **kwargs)
service_manifest = SERVICES['spacy-nlp-pipeline']
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''

View File

@ -0,0 +1,52 @@
from flask import abort, current_app, request
from flask_login import current_user
from threading import Thread
from app import db
from app.decorators import content_negotiation, permission_required
from app.models import SpaCyNLPPipelineModel
from .. import bp
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
@content_negotiation(produces='application/json')
def delete_spacy_model(spacy_nlp_pipeline_model_id):
def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
with app.app_context():
snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
snpm.delete()
db.session.commit()
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_spacy_model,
args=(current_app._get_current_object(), snpm.id)
)
thread.start()
response_data = {
'message': \
f'SpaCy NLP Pipeline Model "{snpm.title}" marked for deletion'
}
return response_data, 202
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
@permission_required('CONTRIBUTE')
@content_negotiation(consumes='application/json', produces='application/json')
def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
is_public = request.json
if not isinstance(is_public, bool):
abort(400)
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator()):
abort(403)
snpm.is_public = is_public
db.session.commit()
response_data = {
'message': (
f'SpaCy NLP Pipeline Model "{snpm.title}"'
f' is now {"public" if is_public else "private"}'
)
}
return response_data, 200

View File

@ -0,0 +1,77 @@
from flask import abort, flash, redirect, render_template, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user
from app import db
from app.models import SpaCyNLPPipelineModel
from . import bp
from .forms import (
CreateSpaCyNLPPipelineModelForm,
UpdateSpaCyNLPPipelineModelForm
)
from .utils import (
spacy_nlp_pipeline_model_dlc as spacy_nlp_pipeline_model_dlc
)
@bp.route('/spacy-nlp-pipeline-models')
@register_breadcrumb(bp, '.spacy_nlp_pipeline_models', 'SpaCy NLP Pipeline Models')
def spacy_nlp_pipeline_models():
return render_template(
'contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_models.html.j2',
title='SpaCy NLP Pipeline Models'
)
@bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.spacy_nlp_pipeline_models.create', 'Create')
def create_spacy_nlp_pipeline_model():
form = CreateSpaCyNLPPipelineModelForm()
if form.is_submitted():
if not form.validate():
return {'errors': form.errors}, 400
try:
snpm = SpaCyNLPPipelineModel.create(
form.spacy_model_file.data,
compatible_service_versions=form.compatible_service_versions.data,
description=form.description.data,
pipeline_name=form.pipeline_name.data,
publisher=form.publisher.data,
publisher_url=form.publisher_url.data,
publishing_url=form.publishing_url.data,
publishing_year=form.publishing_year.data,
is_public=False,
title=form.title.data,
version=form.version.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
flash(f'SpaCy NLP Pipeline model "{snpm.title}" created')
return {}, 201, {'Location': url_for('.spacy_nlp_pipeline_models')}
return render_template(
'contributions/spacy_nlp_pipeline_models/create.html.j2',
title='Create SpaCy NLP Pipeline Model',
form=form
)
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.spacy_nlp_pipeline_models.entity', '', dynamic_list_constructor=spacy_nlp_pipeline_model_dlc)
def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator()):
abort(403)
form = UpdateSpaCyNLPPipelineModelForm(data=snpm.to_json_serializeable())
if form.validate_on_submit():
form.populate_obj(snpm)
if db.session.is_modified(snpm):
flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated')
db.session.commit()
return redirect(url_for('.spacy_nlp_pipeline_models'))
return render_template(
'contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2',
title=f'{snpm.title} {snpm.version}',
form=form,
spacy_nlp_pipeline_model=snpm
)

View File

@ -0,0 +1,13 @@
from flask import request, url_for
from app.models import SpaCyNLPPipelineModel
def spacy_nlp_pipeline_model_dlc():
snpm_id = request.view_args['spacy_nlp_pipeline_model_id']
snpm = SpaCyNLPPipelineModel.query.get_or_404(snpm_id)
return [
{
'text': f'{snpm.title} {snpm.version}',
'url': url_for('.spacy_nlp_pipeline_model', spacy_nlp_pipeline_model_id=snpm_id)
}
]

View File

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

View File

@ -0,0 +1,39 @@
from flask_wtf.file import FileField, FileRequired
from wtforms import ValidationError
from app.services import SERVICES
from ..forms import ContributionBaseForm, UpdateContributionBaseForm
class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
tesseract_model_file = FileField(
'File',
validators=[FileRequired()]
)
def validate_tesseract_model_file(self, field):
if not field.data.filename.lower().endswith('.traineddata'):
raise ValidationError('traineddata files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-tesseract-ocr-pipeline-model-form'
service_manifest = SERVICES['tesseract-ocr-pipeline']
super().__init__(*args, **kwargs)
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class UpdateTesseractOCRPipelineModelForm(UpdateContributionBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'edit-tesseract-ocr-pipeline-model-form'
service_manifest = SERVICES['tesseract-ocr-pipeline']
super().__init__(*args, **kwargs)
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''

View File

@ -0,0 +1,52 @@
from flask import abort, current_app, request
from flask_login import current_user
from threading import Thread
from app import db
from app.decorators import content_negotiation, permission_required
from app.models import TesseractOCRPipelineModel
from . import bp
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
@content_negotiation(produces='application/json')
def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
with app.app_context():
topm = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
topm.delete()
db.session.commit()
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_tesseract_ocr_pipeline_model,
args=(current_app._get_current_object(), topm.id)
)
thread.start()
response_data = {
'message': \
f'Tesseract OCR Pipeline Model "{topm.title}" marked for deletion'
}
return response_data, 202
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
@permission_required('CONTRIBUTE')
@content_negotiation(consumes='application/json', produces='application/json')
def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id):
is_public = request.json
if not isinstance(is_public, bool):
abort(400)
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator()):
abort(403)
topm.is_public = is_public
db.session.commit()
response_data = {
'message': (
f'Tesseract OCR Pipeline Model "{topm.title}"'
f' is now {"public" if is_public else "private"}'
)
}
return response_data, 200

View File

@ -0,0 +1,76 @@
from flask import abort, flash, redirect, render_template, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user
from app import db
from app.models import TesseractOCRPipelineModel
from . import bp
from .forms import (
CreateTesseractOCRPipelineModelForm,
UpdateTesseractOCRPipelineModelForm
)
from .utils import (
tesseract_ocr_pipeline_model_dlc as tesseract_ocr_pipeline_model_dlc
)
@bp.route('/tesseract-ocr-pipeline-models')
@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models', 'Tesseract OCR Pipeline Models')
def tesseract_ocr_pipeline_models():
return render_template(
'contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_models.html.j2',
title='Tesseract OCR Pipeline Models'
)
@bp.route('/tesseract-ocr-pipeline-models/create', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models.create', 'Create')
def create_tesseract_ocr_pipeline_model():
form = CreateTesseractOCRPipelineModelForm()
if form.is_submitted():
if not form.validate():
return {'errors': form.errors}, 400
try:
topm = TesseractOCRPipelineModel.create(
form.tesseract_model_file.data,
compatible_service_versions=form.compatible_service_versions.data,
description=form.description.data,
publisher=form.publisher.data,
publisher_url=form.publisher_url.data,
publishing_url=form.publishing_url.data,
publishing_year=form.publishing_year.data,
is_public=False,
title=form.title.data,
version=form.version.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
flash(f'Tesseract OCR Pipeline model "{topm.title}" created')
return {}, 201, {'Location': url_for('.tesseract_ocr_pipeline_models')}
return render_template(
'contributions/tesseract_ocr_pipeline_models/create.html.j2',
title='Create Tesseract OCR Pipeline Model',
form=form
)
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models.entity', '', dynamic_list_constructor=tesseract_ocr_pipeline_model_dlc)
def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator()):
abort(403)
form = UpdateTesseractOCRPipelineModelForm(data=topm.to_json_serializeable())
if form.validate_on_submit():
form.populate_obj(topm)
if db.session.is_modified(topm):
flash(f'Tesseract OCR Pipeline model "{topm.title}" updated')
db.session.commit()
return redirect(url_for('.tesseract_ocr_pipeline_models'))
return render_template(
'contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2',
title=f'{topm.title} {topm.version}',
form=form,
tesseract_ocr_pipeline_model=topm
)

View File

@ -0,0 +1,13 @@
from flask import request, url_for
from app.models import TesseractOCRPipelineModel
def tesseract_ocr_pipeline_model_dlc():
topm_id = request.view_args['tesseract_ocr_pipeline_model_id']
topm = TesseractOCRPipelineModel.query.get_or_404(topm_id)
return [
{
'text': f'{topm.title} {topm.version}',
'url': url_for('.tesseract_ocr_pipeline_model', tesseract_ocr_pipeline_model_id=topm_id)
}
]

View File

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

View File

@ -0,0 +1,7 @@
from flask import abort
from . import bp
@bp.route('/transkribus_htr_pipeline_models')
def transkribus_htr_pipeline_models():
return abort(503)

22
app/converters/cli.py Normal file
View File

@ -0,0 +1,22 @@
import click
from . import bp
from .sandpaper import SandpaperConverter
@bp.cli.group('converter')
def converter():
''' Converter commands. '''
pass
@converter.group('sandpaper')
def sandpaper_converter():
''' Sandpaper converter commands. '''
pass
@sandpaper_converter.command('run')
@click.argument('json_db_file')
@click.argument('data_dir')
def run_sandpaper_converter(json_db_file, data_dir):
''' Run the sandpaper converter. '''
sandpaper_converter = SandpaperConverter(json_db_file, data_dir)
sandpaper_converter.run()

View File

@ -7,20 +7,25 @@ import os
import shutil import shutil
def convert(json_db_file, data_dir): class SandpaperConverter:
with open(json_db_file, 'r') as f: def __init__(self, json_db_file, data_dir):
self.json_db_file = json_db_file
self.data_dir = data_dir
def run(self):
with open(self.json_db_file, 'r') as f:
json_db = json.loads(f.read()) json_db = json.loads(f.read())
for json_user in json_db: for json_user in json_db:
if not json_user['confirmed']: if not json_user['confirmed']:
current_app.logger.info(f'Skip unconfirmed user {json_user["username"]}') current_app.logger.info(f'Skip unconfirmed user {json_user["username"]}')
continue continue
user_dir = os.path.join(data_dir, str(json_user['id'])) user_dir = os.path.join(self.data_dir, str(json_user['id']))
convert_user(json_user, user_dir) self.convert_user(json_user, user_dir)
db.session.commit() db.session.commit()
def convert_user(json_user, user_dir): def convert_user(self, json_user, user_dir):
current_app.logger.info(f'Create User {json_user["username"]}...') current_app.logger.info(f'Create User {json_user["username"]}...')
user = User( user = User(
confirmed=json_user['confirmed'], confirmed=json_user['confirmed'],
@ -44,11 +49,11 @@ def convert_user(json_user, user_dir):
current_app.logger.info(f'Skip empty corpus {json_corpus["title"]}') current_app.logger.info(f'Skip empty corpus {json_corpus["title"]}')
continue continue
corpus_dir = os.path.join(user_dir, 'corpora', str(json_corpus['id'])) corpus_dir = os.path.join(user_dir, 'corpora', str(json_corpus['id']))
convert_corpus(json_corpus, user, corpus_dir) self.convert_corpus(json_corpus, user, corpus_dir)
current_app.logger.info('Done') current_app.logger.info('Done')
def convert_corpus(json_corpus, user, corpus_dir): def convert_corpus(self, json_corpus, user, corpus_dir):
current_app.logger.info(f'Create Corpus {json_corpus["title"]}...') current_app.logger.info(f'Create Corpus {json_corpus["title"]}...')
corpus = Corpus( corpus = Corpus(
user=user, user=user,
@ -66,11 +71,11 @@ def convert_corpus(json_corpus, user, corpus_dir):
db.session.rollback() db.session.rollback()
raise Exception('Internal Server Error') raise Exception('Internal Server Error')
for json_corpus_file in json_corpus['files'].values(): for json_corpus_file in json_corpus['files'].values():
convert_corpus_file(json_corpus_file, corpus, corpus_dir) self.convert_corpus_file(json_corpus_file, corpus, corpus_dir)
current_app.logger.info('Done') current_app.logger.info('Done')
def convert_corpus_file(json_corpus_file, corpus, corpus_dir): def convert_corpus_file(self, json_corpus_file, corpus, corpus_dir):
current_app.logger.info(f'Create CorpusFile {json_corpus_file["title"]}...') current_app.logger.info(f'Create CorpusFile {json_corpus_file["title"]}...')
corpus_file = CorpusFile( corpus_file = CorpusFile(
corpus=corpus, corpus=corpus,

View File

@ -1,5 +1,19 @@
from flask import Blueprint from flask import Blueprint
from flask_login import login_required
bp = Blueprint('corpora', __name__) bp = Blueprint('corpora', __name__)
from . import cqi_over_socketio, routes # noqa bp.cli.short_help = 'Corpus commands.'
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import cli, cqi_over_socketio, files, followers, routes, json_routes

24
app/corpora/cli.py Normal file
View File

@ -0,0 +1,24 @@
from app.models import Corpus, CorpusStatus
import os
import shutil
from app import db
from . import bp
@bp.cli.command('reset')
def reset():
''' Reset built corpora. '''
status = [
CorpusStatus.QUEUED,
CorpusStatus.BUILDING,
CorpusStatus.BUILT,
CorpusStatus.STARTING_ANALYSIS_SESSION,
CorpusStatus.RUNNING_ANALYSIS_SESSION,
CorpusStatus.CANCELING_ANALYSIS_SESSION
]
for corpus in [x for x in Corpus.query.all() if x.status in status]:
print(f'Resetting corpus {corpus}')
shutil.rmtree(os.path.join(corpus.path, 'cwb'), ignore_errors=True)
corpus.status = CorpusStatus.UNPREPARED
corpus.num_analysis_sessions = 0
db.session.commit()

View File

@ -38,7 +38,7 @@ def cqi_corpora_corpus_query(cqi_client: cqi.CQiClient, corpus_name: str, subcor
@cqi_over_socketio @cqi_over_socketio
def cqi_corpora_corpus_update_db(cqi_client: cqi.CQiClient, corpus_name: str): def cqi_corpora_corpus_update_db(cqi_client: cqi.CQiClient, corpus_name: str):
corpus = Corpus.query.get(session['d']['corpus_id']) corpus = Corpus.query.get(session['d']['corpus_id'])
corpus.num_tokens = cqi_client.corpora.get('CORPUS').attrs['size'] corpus.num_tokens = cqi_client.corpora.get(corpus_name).attrs['size']
db.session.commit() db.session.commit()

33
app/corpora/decorators.py Normal file
View File

@ -0,0 +1,33 @@
from flask import abort
from flask_login import current_user
from functools import wraps
from app.models import Corpus, CorpusFollowerAssociation
def corpus_follower_permission_required(*permissions):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
corpus_id = kwargs.get('corpus_id')
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if cfa is None:
abort(403)
if not all([cfa.role.has_permission(p) for p in permissions]):
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def corpus_owner_or_admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
corpus_id = kwargs.get('corpus_id')
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
abort(403)
return f(*args, **kwargs)
return decorated_function

45
app/corpora/events.py Normal file
View File

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

View File

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

View File

@ -0,0 +1,54 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired
from wtforms import (
StringField,
SubmitField,
ValidationError,
IntegerField
)
from wtforms.validators import InputRequired, Length
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):
if not field.data.filename.lower().endswith('.vrt'):
raise ValidationError('VRT files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-corpus-file-form'
super().__init__(*args, **kwargs)
class UpdateCorpusFileForm(CorpusFileBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-corpus-file-form'
super().__init__(*args, **kwargs)

View File

@ -0,0 +1,30 @@
from flask import abort, current_app
from threading import Thread
from app import db
from app.decorators import content_negotiation
from app.models import CorpusFile
from ..decorators import corpus_follower_permission_required
from . import bp
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['DELETE'])
@corpus_follower_permission_required('MANAGE_FILES')
@content_negotiation(produces='application/json')
def delete_corpus_file(corpus_id, corpus_file_id):
def _delete_corpus_file(app, corpus_file_id):
with app.app_context():
corpus_file = CorpusFile.query.get(corpus_file_id)
corpus_file.delete()
db.session.commit()
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
thread = Thread(
target=_delete_corpus_file,
args=(current_app._get_current_object(), corpus_file.id)
)
thread.start()
response_data = {
'message': f'Corpus File "{corpus_file.title}" marked for deletion',
'category': 'corpus'
}
return response_data, 202

100
app/corpora/files/routes.py Normal file
View File

@ -0,0 +1,100 @@
from flask import (
abort,
flash,
redirect,
render_template,
send_from_directory,
url_for
)
from flask_breadcrumbs import register_breadcrumb
import os
from app import db
from app.models import Corpus, CorpusFile, CorpusStatus
from ..decorators import corpus_follower_permission_required
from ..utils import corpus_endpoint_arguments_constructor as corpus_eac
from . import bp
from .forms import CreateCorpusFileForm, UpdateCorpusFileForm
from .utils import corpus_file_dynamic_list_constructor as corpus_file_dlc
@bp.route('/<hashid:corpus_id>/files')
@register_breadcrumb(bp, '.entity.files', 'Files', endpoint_arguments_constructor=corpus_eac)
def corpus_files(corpus_id):
return redirect(url_for('.corpus', _anchor='files', corpus_id=corpus_id))
@bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.entity.files.create', 'Create', endpoint_arguments_constructor=corpus_eac)
@corpus_follower_permission_required('MANAGE_FILES')
def create_corpus_file(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
form = CreateCorpusFileForm()
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
return response, 400
try:
corpus_file = CorpusFile.create(
form.vrt.data,
address=form.address.data,
author=form.author.data,
booktitle=form.booktitle.data,
chapter=form.chapter.data,
editor=form.editor.data,
institution=form.institution.data,
journal=form.journal.data,
pages=form.pages.data,
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 (AttributeError, OSError):
abort(500)
corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
flash(f'Corpus File "{corpus_file.filename}" added', category='corpus')
return '', 201, {'Location': corpus.url}
return render_template(
'corpora/files/create.html.j2',
title='Add corpus file',
form=form,
corpus=corpus
)
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.entity.files.entity', '', dynamic_list_constructor=corpus_file_dlc)
@corpus_follower_permission_required('MANAGE_FILES')
def corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
form = UpdateCorpusFileForm(data=corpus_file.to_json_serializeable())
if form.validate_on_submit():
form.populate_obj(corpus_file)
if db.session.is_modified(corpus_file):
corpus_file.corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
flash(f'Corpus file "{corpus_file.filename}" updated', category='corpus')
return redirect(corpus_file.corpus.url)
return render_template(
'corpora/files/corpus_file.html.j2',
title='Edit corpus file',
form=form,
corpus=corpus_file.corpus,
corpus_file=corpus_file
)
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
@corpus_follower_permission_required('VIEW')
def download_corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
return send_from_directory(
os.path.dirname(corpus_file.path),
os.path.basename(corpus_file.path),
as_attachment=True,
attachment_filename=corpus_file.filename,
mimetype=corpus_file.mimetype
)

View File

@ -0,0 +1,15 @@
from flask import request, url_for
from app.models import CorpusFile
from ..utils import corpus_endpoint_arguments_constructor as corpus_eac
def corpus_file_dynamic_list_constructor():
corpus_id = request.view_args['corpus_id']
corpus_file_id = request.view_args['corpus_file_id']
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
return [
{
'text': f'{corpus_file.author}: {corpus_file.title} ({corpus_file.publishing_year})',
'url': url_for('.corpus_file', corpus_id=corpus_id, corpus_file_id=corpus_file_id)
}
]

View File

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

View File

@ -0,0 +1,76 @@
from flask import abort, flash, jsonify, make_response, request
from flask_login import current_user
from app import db
from app.decorators import content_negotiation
from app.models import (
Corpus,
CorpusFollowerAssociation,
CorpusFollowerRole,
User
)
from ..decorators import corpus_follower_permission_required
from . import bp
# @bp.route('/<hashid:corpus_id>/followers', methods=['POST'])
# @corpus_follower_permission_required('MANAGE_FOLLOWERS')
# @content_negotiation(consumes='application/json', produces='application/json')
# def create_corpus_followers(corpus_id):
# usernames = request.json
# if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)):
# abort(400)
# corpus = Corpus.query.get_or_404(corpus_id)
# for username in usernames:
# user = User.query.filter_by(username=username, is_public=True).first_or_404()
# user.follow_corpus(corpus)
# db.session.commit()
# response_data = {
# 'message': f'Users are now following "{corpus.title}"',
# 'category': 'corpus'
# }
# return response_data, 200
# @bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/role', methods=['PUT'])
# @corpus_follower_permission_required('MANAGE_FOLLOWERS')
# @content_negotiation(consumes='application/json', produces='application/json')
# def update_corpus_follower_role(corpus_id, follower_id):
# role_name = request.json
# if not isinstance(role_name, str):
# abort(400)
# cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
# if cfr is None:
# abort(400)
# cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
# cfa.role = cfr
# db.session.commit()
# response_data = {
# 'message': f'User "{cfa.follower.username}" is now {cfa.role.name}',
# 'category': 'corpus'
# }
# return response_data, 200
# @bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>', methods=['DELETE'])
# def delete_corpus_follower(corpus_id, follower_id):
# cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
# if not (
# current_user.id == follower_id
# or current_user == cfa.corpus.user
# or CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first().role.has_permission('MANAGE_FOLLOWERS')
# or current_user.is_administrator()):
# abort(403)
# if current_user.id == follower_id:
# flash(f'You are no longer following "{cfa.corpus.title}"', 'corpus')
# response = make_response()
# response.status_code = 204
# else:
# response_data = {
# 'message': f'"{cfa.follower.username}" is not following "{cfa.corpus.title}" anymore',
# 'category': 'corpus'
# }
# response = jsonify(response_data)
# response.status_code = 200
# cfa.follower.unfollow_corpus(cfa.corpus)
# db.session.commit()
# return response

View File

@ -1,13 +1,5 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired from wtforms import StringField, SubmitField, TextAreaField
from wtforms import (
BooleanField,
StringField,
SubmitField,
TextAreaField,
ValidationError,
IntegerField
)
from wtforms.validators import InputRequired, Length from wtforms.validators import InputRequired, Length
@ -34,50 +26,8 @@ class UpdateCorpusForm(CorpusBaseForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
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 __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-corpus-file-form'
super().__init__(*args, **kwargs)
def validate_vrt(self, field):
if not field.data.filename.lower().endswith('.vrt'):
raise ValidationError('VRT files only!')
class UpdateCorpusFileForm(CorpusFileBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-corpus-file-form'
super().__init__(*args, **kwargs)
class ImportCorpusForm(FlaskForm): class ImportCorpusForm(FlaskForm):
pass def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'import-corpus-form'
super().__init__(*args, **kwargs)

111
app/corpora/json_routes.py Normal file
View File

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

View File

@ -1,139 +1,30 @@
from datetime import datetime from flask import abort, flash, redirect, render_template, url_for
from flask import ( from flask_breadcrumbs import register_breadcrumb
abort, from flask_login import current_user
current_app, from app import db
flash, from app.models import (
Markup, Corpus,
redirect, CorpusFollowerAssociation,
render_template, CorpusFollowerRole,
request, User
send_from_directory,
url_for
) )
from flask_login import current_user, login_required
from threading import Thread
import jwt
import os
from app import db, hashids
from app.models import Corpus, CorpusFile, CorpusStatus, User
from . import bp from . import bp
from .forms import ( from .decorators import corpus_follower_permission_required
CreateCorpusFileForm, from .forms import CreateCorpusForm
CreateCorpusForm, from .utils import (
UpdateCorpusFileForm corpus_endpoint_arguments_constructor as corpus_eac,
corpus_dynamic_list_constructor as corpus_dlc
) )
# @bp.route('/share/<token>', methods=['GET', 'POST']) @bp.route('')
# def share_corpus(token): @register_breadcrumb(bp, '.', '<i class="nopaque-icons left">I</i>My Corpora')
# try: def corpora():
# payload = jwt.decode( return redirect(url_for('main.dashboard', _anchor='corpora'))
# token,
# current_app.config['SECRET_KEY'],
# algorithms=['HS256'],
# issuer=current_app.config['SERVER_NAME'],
# options={'require': ['iat', 'iss', 'sub']}
# )
# except jwt.PyJWTError:
# return False
# corpus_hashid = payload.get('sub')
# corpus_id = hashids.decode(corpus_hashid)
# return redirect(url_for('.corpus', corpus_id=corpus_id))
@bp.route('/<hashid:corpus_id>/enable_is_public', methods=['POST'])
@login_required
def enable_corpus_is_public(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
abort(403)
corpus.is_public = True
db.session.commit()
return '', 204
@bp.route('/<hashid:corpus_id>/disable_is_public', methods=['POST'])
@login_required
def disable_corpus_is_public(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
abort(403)
corpus.is_public = False
db.session.commit()
return '', 204
# @bp.route('/<hashid:corpus_id>/follow', methods=['GET', 'POST'])
# @login_required
# def follow_corpus(corpus_id):
# corpus = Corpus.query.get_or_404(corpus_id)
# user_hashid = request.args.get('user_id')
# if user_hashid is None:
# user = current_user
# else:
# if not current_user.is_administrator():
# abort(403)
# else:
# user_id = hashids.decode(user_hashid)
# user = User.query.get_or_404(user_id)
# if not user.is_following_corpus(corpus):
# user.follow_corpus(corpus)
# db.session.commit()
# flash(f'You are following {corpus.title} now', category='corpus')
# return {}, 202
@bp.route('/<hashid:corpus_id>/unfollow', methods=['GET', 'POST'])
@login_required
def unfollow_corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
user_hashid = request.args.get('user_id')
if user_hashid is None:
user = current_user
elif current_user.is_administrator():
user_id = hashids.decode(user_hashid)
user = User.query.get_or_404(user_id)
else:
abort(403)
if user.is_following_corpus(corpus):
user.unfollow_corpus(corpus)
db.session.commit()
flash(f'You are not following {corpus.title} anymore', category='corpus')
return {}, 202
# @bp.route('/add_permission/<hashid:corpus_id>/<hashid:user_id>/<int:permission>')
# def add_permission(corpus_id, user_id, permission):
# a = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404()
# a.add_permission(permission)
# db.session.commit()
# return 'ok'
# @bp.route('/remove_permission/<hashid:corpus_id>/<hashid:user_id>/<int:permission>')
# def remove_permission(corpus_id, user_id, permission):
# a = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404()
# a.remove_permission(permission)
# db.session.commit()
# return 'ok'
@bp.route('/public')
@login_required
def public_corpora():
corpora = [
c.to_json_serializeable()
for c in Corpus.query.filter(Corpus.is_public == True).all()
]
return render_template(
'corpora/public_corpora.html.j2',
corpora=corpora,
title='Corpora'
)
@bp.route('/create', methods=['GET', 'POST']) @bp.route('/create', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.create', 'Create')
def create_corpus(): def create_corpus():
form = CreateCorpusForm() form = CreateCorpusForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -146,224 +37,85 @@ def create_corpus():
except OSError: except OSError:
abort(500) abort(500)
db.session.commit() db.session.commit()
message = Markup( flash(f'Corpus "{corpus.title}" created', 'corpus')
f'Corpus "<a href="{corpus.url}">{corpus.title}</a>" created'
)
flash(message, 'corpus')
return redirect(corpus.url) return redirect(corpus.url)
return render_template( return render_template(
'corpora/create_corpus.html.j2', 'corpora/create.html.j2',
form=form, title='Create corpus',
title='Create corpus' form=form
) )
@bp.route('/<hashid:corpus_id>', methods=['GET', 'POST']) @bp.route('/<hashid:corpus_id>')
@login_required @register_breadcrumb(bp, '.entity', '', dynamic_list_constructor=corpus_dlc)
def corpus(corpus_id): def corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id) corpus = Corpus.query.get_or_404(corpus_id)
cfrs = CorpusFollowerRole.query.all()
# TODO: Better solution for filtering admin
users = User.query.filter(User.is_public == True, User.id != current_user.id, User.id != corpus.user.id, User.role_id < 4).all()
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if cfa is None:
if corpus.user == current_user or current_user.is_administrator():
cfr = CorpusFollowerRole.query.filter_by(name='Administrator').first()
else:
cfr = CorpusFollowerRole.query.filter_by(name='Anonymous').first()
else:
cfr = cfa.role
if corpus.user == current_user or current_user.is_administrator(): if corpus.user == current_user or current_user.is_administrator():
# now = datetime.utcnow()
# payload = {
# 'exp': now + timedelta(weeks=1),
# 'iat': now,
# 'iss': current_app.config['SERVER_NAME'],
# 'sub': corpus.hashid
# }
# token = jwt.encode(
# payload,
# current_app.config['SECRET_KEY'],
# algorithm='HS256'
# )
return render_template( return render_template(
'corpora/corpus.html.j2', 'corpora/corpus.html.j2',
title=corpus.title,
corpus=corpus, corpus=corpus,
# token=token, cfr=cfr,
title='Corpus' cfrs=cfrs,
) users = users
if current_user.is_following_corpus(corpus) or corpus.is_public:
corpus_files = [x.to_json_serializeable() for x in corpus.files]
return render_template(
'corpora/public_corpus.html.j2',
corpus=corpus,
corpus_files=corpus_files,
title='Corpus'
) )
if (current_user.is_following_corpus(corpus) or corpus.is_public):
abort(404)
# cfas = CorpusFollowerAssociation.query.filter(Corpus.id == corpus_id, CorpusFollowerAssociation.follower_id != corpus.user.id).all()
# return render_template(
# 'corpora/public_corpus.html.j2',
# title=corpus.title,
# corpus=corpus,
# cfrs=cfrs,
# cfr=cfr,
# cfas=cfas,
# users = users
# )
abort(403) abort(403)
@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()
@bp.route('/<hashid:corpus_id>/analysis')
@corpus_follower_permission_required('VIEW')
@register_breadcrumb(bp, '.entity.analysis', 'Analysis', endpoint_arguments_constructor=corpus_eac)
def analysis(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()):
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')
@login_required
def analyse_corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user
or current_user.is_administrator()
or current_user.is_following_corpus(corpus)):
abort(403)
return render_template( return render_template(
'corpora/analyse_corpus.html.j2', 'corpora/analysis.html.j2',
corpus=corpus, corpus=corpus,
title=f'Analyse Corpus {corpus.title}' title=f'Analyse Corpus {corpus.title}'
) )
@bp.route('/<hashid:corpus_id>/build', methods=['POST']) # @bp.route('/<hashid:corpus_id>/follow/<token>')
@login_required # def follow_corpus(corpus_id, token):
def build_corpus(corpus_id): # corpus = Corpus.query.get_or_404(corpus_id)
def _build_corpus(app, corpus_id): # if current_user.follow_corpus_by_token(token):
with app.app_context(): # db.session.commit()
corpus = Corpus.query.get(corpus_id) # flash(f'You are following "{corpus.title}" now', category='corpus')
corpus.build() # return redirect(url_for('corpora.corpus', corpus_id=corpus_id))
db.session.commit() # abort(403)
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
abort(403)
# Check if the corpus has corpus files
if not corpus.files.all():
response = {'errors': {'message': 'Corpus file(s) required'}}
return response, 409
thread = Thread(
target=_build_corpus,
args=(current_app._get_current_object(), corpus_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
@login_required
def create_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 = CreateCorpusFileForm()
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
return response, 400
try:
corpus_file = CorpusFile.create(
form.vrt.data,
address=form.address.data,
author=form.author.data,
booktitle=form.booktitle.data,
chapter=form.chapter.data,
editor=form.editor.data,
institution=form.institution.data,
journal=form.journal.data,
pages=form.pages.data,
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 (AttributeError, 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'])
@login_required
def corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
abort(403)
form = UpdateCorpusFileForm(data=corpus_file.to_json_serializeable())
if form.validate_on_submit():
form.populate_obj(corpus_file)
if db.session.is_modified(corpus_file):
corpus_file.corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
message = Markup(f'Corpus file "<a href="{corpus_file.url}">{corpus_file.filename}</a>" updated')
flash(message, category='corpus')
return redirect(corpus_file.corpus.url)
return render_template(
'corpora/corpus_file.html.j2',
corpus=corpus_file.corpus,
corpus_file=corpus_file,
form=form,
title='Edit corpus file'
)
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['DELETE'])
@login_required
def delete_corpus_file(corpus_id, corpus_file_id):
def _delete_corpus_file(app, corpus_file_id):
with app.app_context():
corpus_file = CorpusFile.query.get(corpus_file_id)
corpus_file.delete()
db.session.commit()
corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_corpus_file,
args=(current_app._get_current_object(), corpus_file_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
@login_required
def download_corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
abort(403)
return send_from_directory(
os.path.dirname(corpus_file.path),
os.path.basename(corpus_file.path),
as_attachment=True,
attachment_filename=corpus_file.filename,
mimetype=corpus_file.mimetype
)
@bp.route('/import', methods=['GET', 'POST']) @bp.route('/import', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.import', 'Import')
def import_corpus(): def import_corpus():
abort(503) abort(503)
@bp.route('/<hashid:corpus_id>/export') @bp.route('/<hashid:corpus_id>/export')
@login_required @corpus_follower_permission_required('VIEW')
@register_breadcrumb(bp, '.entity.export', 'Export', endpoint_arguments_constructor=corpus_eac)
def export_corpus(corpus_id): def export_corpus(corpus_id):
abort(503) abort(503)

17
app/corpora/utils.py Normal file
View File

@ -0,0 +1,17 @@
from flask import request, url_for
from app.models import Corpus
def corpus_endpoint_arguments_constructor():
return {'corpus_id': request.view_args['corpus_id']}
def corpus_dynamic_list_constructor():
corpus_id = request.view_args['corpus_id']
corpus = Corpus.query.get_or_404(corpus_id)
return [
{
'text': f'<i class="material-icons left">book</i>{corpus.title}',
'url': url_for('.corpus', corpus_id=corpus_id)
}
]

View File

@ -1,7 +1,9 @@
from flask import abort, current_app from flask import abort, current_app, request
from flask_login import current_user from flask_login import current_user
from functools import wraps from functools import wraps
from threading import Thread from threading import Thread
from typing import List, Union
from werkzeug.exceptions import NotAcceptable
from app.models import Permission from app.models import Permission
@ -61,3 +63,37 @@ def background(f):
thread.start() thread.start()
return thread return thread
return wrapped return wrapped
def content_negotiation(
produces: Union[str, List[str], None] = None,
consumes: Union[str, List[str], None] = None
):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
provided = request.mimetype
if consumes is None:
consumeables = None
elif isinstance(consumes, str):
consumeables = {consumes}
elif isinstance(consumes, list) and all(isinstance(x, str) for x in consumes):
consumeables = {*consumes}
else:
raise TypeError()
accepted = {*request.accept_mimetypes.values()}
if produces is None:
produceables = None
elif isinstance(produces, str):
produceables = {produces}
elif isinstance(produces, list) and all(isinstance(x, str) for x in produces):
produceables = {*produces}
else:
raise TypeError()
if produceables is not None and len(produceables & accepted) == 0:
raise NotAcceptable()
if consumeables is not None and provided not in consumeables:
raise NotAcceptable()
return f(*args, **kwargs)
return decorated_function
return decorator

View File

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

View File

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

74
app/jobs/json_routes.py Normal file
View File

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

View File

@ -1,93 +1,40 @@
from flask import ( from flask import (
abort, abort,
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_breadcrumbs import register_breadcrumb
from threading import Thread from flask_login import current_user
import os import os
from app import db from app.models import Job, JobInput, JobResult
from app.decorators import admin_required
from app.models import Job, JobInput, JobResult, JobStatus
from . import bp from . import bp
from .utils import job_dynamic_list_constructor as job_dlc
@bp.route('')
@register_breadcrumb(bp, '.', '<i class="nopaque-icons left">J</i>My Jobs')
def corpora():
return redirect(url_for('main.dashboard', _anchor='jobs'))
@bp.route('/<hashid:job_id>') @bp.route('/<hashid:job_id>')
@login_required @register_breadcrumb(bp, '.entity', '', dynamic_list_constructor=job_dlc)
def job(job_id): def job(job_id):
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)
return render_template( return render_template(
'jobs/job.html.j2', 'jobs/job.html.j2',
job=job, title='Job',
title='Job' job=job
) )
@bp.route('/<hashid:job_id>', methods=['DELETE'])
@login_required
def delete_job(job_id):
def _delete_job(app, job_id):
with app.app_context():
job = Job.query.get(job_id)
job.delete()
db.session.commit()
job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_job,
args=(current_app._get_current_object(), job_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:job_id>/log')
@login_required
@admin_required
def job_log(job_id):
job = Job.query.get_or_404(job_id)
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
response = {'errors': {'message': 'Job status is not completed or failed'}}
return response, 409
with open(os.path.join(job.path, 'pipeline_data', 'logs', 'pyflow_log.txt')) as log_file:
log = log_file.read()
return log, 200, {'Content-Type': 'text/plain; charset=utf-8'}
@bp.route('/<hashid:job_id>/restart', methods=['POST'])
@login_required
def restart_job(job_id):
def _restart_job(app, job_id):
with app.app_context():
job = Job.query.get(job_id)
job.restart()
db.session.commit()
job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator()):
abort(403)
if job.status == JobStatus.FAILED:
response = {'errors': {'message': 'Job status is not "failed"'}}
return response, 409
thread = Thread(
target=_restart_job,
args=(current_app._get_current_object(), job_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download') @bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
@login_required
def download_job_input(job_id, job_input_id): def download_job_input(job_id, job_input_id):
job_input = JobInput.query.get_or_404(job_input_id) job_input = JobInput.query.filter_by(job_id=job_id, id=job_input_id).first_or_404()
if job_input.job.id != job_id:
abort(404)
if not (job_input.job.user == current_user or current_user.is_administrator()): if not (job_input.job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
return send_from_directory( return send_from_directory(
@ -100,11 +47,8 @@ def download_job_input(job_id, job_input_id):
@bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download') @bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download')
@login_required
def download_job_result(job_id, job_result_id): def download_job_result(job_id, job_result_id):
job_result = JobResult.query.get_or_404(job_result_id) job_result = JobResult.query.filter_by(job_id=job_id, id=job_result_id).first_or_404()
if job_result.job.id != job_id:
abort(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(

13
app/jobs/utils.py Normal file
View File

@ -0,0 +1,13 @@
from flask import request, url_for
from app.models import Job
def job_dynamic_list_constructor():
job_id = request.view_args['job_id']
job = Job.query.get_or_404(job_id)
return [
{
'text': f'<i class="nopaque-icons left service-icons" data-service="{job.service}"></i>{job.title}',
'url': url_for('.job', job_id=job_id)
}
]

View File

@ -1,5 +1,5 @@
from flask import Blueprint from flask import Blueprint
bp = Blueprint('main', __name__) bp = Blueprint('main', __name__, cli_group=None)
from . import routes from . import cli, routes

45
app/main/cli.py Normal file
View File

@ -0,0 +1,45 @@
from flask import current_app
from flask_migrate import upgrade
import os
from app.models import (
CorpusFollowerRole,
Role,
SpaCyNLPPipelineModel,
TesseractOCRPipelineModel,
User
)
from . import bp
@bp.cli.command('deploy')
def deploy():
''' Run deployment tasks. '''
# Make default directories
print('Make default directories')
base_dir = current_app.config['NOPAQUE_DATA_DIR']
default_dirs = [
os.path.join(base_dir, 'tmp'),
os.path.join(base_dir, 'users')
]
for dir in default_dirs:
if os.path.exists(dir):
if not os.path.isdir(dir):
raise NotADirectoryError(f'{dir} is not a directory')
else:
os.mkdir(dir)
# migrate database to latest revision
print('Migrate database to latest revision')
upgrade()
# Insert/Update default database values
print('Insert/Update default Roles')
Role.insert_defaults()
print('Insert/Update default Users')
User.insert_defaults()
print('Insert/Update default CorpusFollowerRoles')
CorpusFollowerRole.insert_defaults()
print('Insert/Update default SpaCyNLPPipelineModels')
SpaCyNLPPipelineModel.insert_defaults()
print('Insert/Update default TesseractOCRPipelineModels')
TesseractOCRPipelineModel.insert_defaults()

View File

@ -1,13 +1,16 @@
from flask import flash, redirect, render_template, url_for from flask import flash, redirect, render_template, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user, login_required, login_user from flask_login import current_user, login_required, login_user
from app.auth.forms import LoginForm from app.auth.forms import LoginForm
from app.models import Corpus, User from app.models import Corpus, User
from sqlalchemy import or_
from . import bp from . import bp
@bp.route('', methods=['GET', 'POST']) @bp.route('/', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.', '<i class="material-icons">home</i>')
def index(): def index():
form = LoginForm(prefix='login-form') form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first() 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): if user and user.verify_password(form.password.data):
@ -16,54 +19,74 @@ def index():
return redirect(url_for('.dashboard')) return redirect(url_for('.dashboard'))
flash('Invalid email/username or password', category='error') flash('Invalid email/username or password', category='error')
redirect(url_for('.index')) redirect(url_for('.index'))
return render_template('main/index.html.j2', form=form, title='nopaque')
@bp.route('/faq')
def faq():
return render_template('main/faq.html.j2', title='Frequently Asked Questions')
@bp.route('/dashboard')
@login_required
def dashboard():
# users = [
# u.to_json_serializeable(filter_by_privacy_settings=True) for u
# in User.query.filter(User.is_public == True, User.id != current_user.id).all()
# ]
# corpora = [
# c.to_json_serializeable() for c
# in Corpus.query.filter(Corpus.is_public == True, Corpus.user != current_user).all()
# ]
return render_template( return render_template(
'main/dashboard.html.j2', 'main/index.html.j2',
title='Dashboard', title='nopaque',
# users=users, form=form
# corpora=corpora
) )
@bp.route('/dashboard2') @bp.route('/faq')
@register_breadcrumb(bp, '.faq', 'Frequently Asked Questions')
def faq():
return render_template(
'main/faq.html.j2',
title='Frequently Asked Questions'
)
@bp.route('/dashboard')
@register_breadcrumb(bp, '.dashboard', '<i class="material-icons left">dashboard</i>Dashboard')
@login_required @login_required
def dashboard2(): def dashboard():
return render_template('main/dashboard2.html.j2', title='Dashboard') return render_template(
'main/dashboard.html.j2',
title='Dashboard'
)
@bp.route('/user_manual') # @bp.route('/user_manual')
def user_manual(): # @register_breadcrumb(bp, '.user_manual', '<i class="material-icons left">help</i>User manual')
return render_template('main/user_manual.html.j2', title='User manual') # def user_manual():
# return render_template('main/user_manual.html.j2', title='User manual')
@bp.route('/news') @bp.route('/news')
@register_breadcrumb(bp, '.news', '<i class="material-icons left">email</i>News')
def news(): def news():
return render_template('main/news.html.j2', title='News') return render_template(
'main/news.html.j2',
title='News'
)
@bp.route('/privacy_policy') @bp.route('/privacy_policy')
@register_breadcrumb(bp, '.privacy_policy', 'Private statement (GDPR)')
def privacy_policy(): def privacy_policy():
return render_template('main/privacy_policy.html.j2', title='Privacy statement (GDPR)') return render_template(
'main/privacy_policy.html.j2',
title='Privacy statement (GDPR)'
)
@bp.route('/terms_of_use') @bp.route('/terms_of_use')
@register_breadcrumb(bp, '.terms_of_use', 'Terms of Use')
def terms_of_use(): def terms_of_use():
return render_template('main/terms_of_use.html.j2', title='Terms of Use') return render_template(
'main/terms_of_use.html.j2',
title='Terms of Use'
)
# @bp.route('/social-area')
# @register_breadcrumb(bp, '.social_area', '<i class="material-icons left">group</i>Social Area')
# @login_required
# def social_area():
# corpora = Corpus.query.filter(Corpus.is_public == True, Corpus.user != current_user).all()
# users = User.query.filter(User.is_public == True, User.id != current_user.id).all()
# return render_template(
# 'main/social_area.html.j2',
# title='Social Area',
# corpora=corpora,
# users=users
# )

View File

@ -1,16 +1,18 @@
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 abort, current_app, url_for
from flask_hashids import HashidMixin from flask_hashids import HashidMixin
from flask_login import UserMixin from flask_login import UserMixin
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from time import sleep from time import sleep
from tqdm import tqdm from tqdm import tqdm
from typing import Union
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import json import json
import jwt import jwt
import os import os
import re
import requests import requests
import secrets import secrets
import shutil import shutil
@ -36,6 +38,16 @@ class CorpusStatus(IntEnum):
RUNNING_ANALYSIS_SESSION = 8 RUNNING_ANALYSIS_SESSION = 8
CANCELING_ANALYSIS_SESSION = 9 CANCELING_ANALYSIS_SESSION = 9
@staticmethod
def get(corpus_status: Union['CorpusStatus', int, str]) -> 'CorpusStatus':
if isinstance(corpus_status, CorpusStatus):
return corpus_status
if isinstance(corpus_status, int):
return CorpusStatus(corpus_status)
if isinstance(corpus_status, str):
return CorpusStatus[corpus_status]
raise TypeError('corpus_status must be CorpusStatus, int, or str')
class JobStatus(IntEnum): class JobStatus(IntEnum):
INITIALIZING = 1 INITIALIZING = 1
@ -47,6 +59,16 @@ class JobStatus(IntEnum):
COMPLETED = 7 COMPLETED = 7
FAILED = 8 FAILED = 8
@staticmethod
def get(job_status: Union['JobStatus', int, str]) -> 'JobStatus':
if isinstance(job_status, JobStatus):
return job_status
if isinstance(job_status, int):
return JobStatus(job_status)
if isinstance(job_status, str):
return JobStatus[job_status]
raise TypeError('job_status must be JobStatus, int, or str')
class Permission(IntEnum): class Permission(IntEnum):
''' '''
@ -57,6 +79,16 @@ class Permission(IntEnum):
CONTRIBUTE = 2 CONTRIBUTE = 2
USE_API = 4 USE_API = 4
@staticmethod
def get(permission: Union['Permission', int, str]) -> 'Permission':
if isinstance(permission, Permission):
return permission
if isinstance(permission, int):
return Permission(permission)
if isinstance(permission, str):
return Permission[permission]
raise TypeError('permission must be Permission, int, or str')
class UserSettingJobStatusMailNotificationLevel(IntEnum): class UserSettingJobStatusMailNotificationLevel(IntEnum):
NONE = 1 NONE = 1
@ -69,10 +101,31 @@ class ProfilePrivacySettings(IntEnum):
SHOW_LAST_SEEN = 2 SHOW_LAST_SEEN = 2
SHOW_MEMBER_SINCE = 4 SHOW_MEMBER_SINCE = 4
class CorpusFollowPermission(IntEnum): @staticmethod
def get(profile_privacy_setting: Union['ProfilePrivacySettings', int, str]) -> 'ProfilePrivacySettings':
if isinstance(profile_privacy_setting, ProfilePrivacySettings):
return profile_privacy_setting
if isinstance(profile_privacy_setting, int):
return ProfilePrivacySettings(profile_privacy_setting)
if isinstance(profile_privacy_setting, str):
return ProfilePrivacySettings[profile_privacy_setting]
raise TypeError('profile_privacy_setting must be ProfilePrivacySettings, int, or str')
class CorpusFollowerPermission(IntEnum):
VIEW = 1 VIEW = 1
CONTRIBUTE = 2 MANAGE_FILES = 2
ADMINISTRATE = 4 MANAGE_FOLLOWERS = 4
MANAGE_CORPUS = 8
@staticmethod
def get(corpus_follower_permission: Union['CorpusFollowerPermission', int, str]) -> 'CorpusFollowerPermission':
if isinstance(corpus_follower_permission, CorpusFollowerPermission):
return corpus_follower_permission
if isinstance(corpus_follower_permission, int):
return CorpusFollowerPermission(corpus_follower_permission)
if isinstance(corpus_follower_permission, str):
return CorpusFollowerPermission[corpus_follower_permission]
raise TypeError('corpus_follower_permission must be CorpusFollowerPermission, int, or str')
# endregion enums # endregion enums
@ -180,16 +233,19 @@ class Role(HashidMixin, db.Model):
def __repr__(self): def __repr__(self):
return f'<Role {self.name}>' return f'<Role {self.name}>'
def add_permission(self, permission): def has_permission(self, permission: Union[Permission, int, str]):
if not self.has_permission(permission): p = Permission.get(permission)
self.permissions += permission return self.permissions & p.value == p.value
def has_permission(self, permission): def add_permission(self, permission: Union[Permission, int, str]):
return self.permissions & permission == permission p = Permission.get(permission)
if not self.has_permission(p):
self.permissions += p.value
def remove_permission(self, permission): def remove_permission(self, permission: Union[Permission, int, str]):
if self.has_permission(permission): p = Permission.get(permission)
self.permissions -= permission if self.has_permission(p):
self.permissions -= p.value
def reset_permissions(self): def reset_permissions(self):
self.permissions = 0 self.permissions = 0
@ -199,8 +255,13 @@ class Role(HashidMixin, db.Model):
'id': self.hashid, 'id': self.hashid,
'default': self.default, 'default': self.default,
'name': self.name, 'name': self.name,
'permissions': self.permissions 'permissions': [
x.name for x in Permission
if self.has_permission(x.value)
]
} }
if backrefs:
pass
if relationships: if relationships:
json_serializeable['users'] = { json_serializeable['users'] = {
x.hashid: x.to_json_serializeable(relationships=True) x.hashid: x.to_json_serializeable(relationships=True)
@ -252,6 +313,27 @@ class Token(db.Model):
self.access_expiration = datetime.utcnow() self.access_expiration = datetime.utcnow()
self.refresh_expiration = datetime.utcnow() self.refresh_expiration = datetime.utcnow()
def to_json_serializeable(self, backrefs=False, relationships=False):
json_serializeable = {
'id': self.hashid,
'access_token': self.access_token,
'access_expiration': (
None if self.access_expiration is None
else f'{self.access_expiration.isoformat()}Z'
),
'refresh_token': self.refresh_token,
'refresh_expiration': (
None if self.refresh_expiration is None
else f'{self.refresh_expiration.isoformat()}Z'
)
}
if backrefs:
json_serializeable['user'] = \
self.user.to_json_serializeable(backrefs=True)
if relationships:
pass
return json_serializeable
@staticmethod @staticmethod
def clean(): def clean():
"""Remove any tokens that have been expired for more than a day.""" """Remove any tokens that have been expired for more than a day."""
@ -284,35 +366,143 @@ class Avatar(HashidMixin, FileMixin, db.Model):
'id': self.hashid, 'id': self.hashid,
**self.file_mixin_to_json_serializeable() **self.file_mixin_to_json_serializeable()
} }
if backrefs:
json_serializeable['user'] = \
self.user.to_json_serializeable(backrefs=True)
if relationships:
pass
return json_serializeable return json_serializeable
class CorpusFollowerAssociation(db.Model): class CorpusFollowerRole(HashidMixin, db.Model):
__tablename__ = 'corpus_follower_roles'
# Primary key
id = db.Column(db.Integer, primary_key=True)
# Fields
name = db.Column(db.String(64), unique=True)
default = db.Column(db.Boolean, default=False, index=True)
permissions = db.Column(db.Integer, default=0)
# Relationships
corpus_follower_associations = db.relationship(
'CorpusFollowerAssociation',
back_populates='role'
)
def __repr__(self):
return f'<CorpusFollowerRole {self.name}>'
def has_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
perm = CorpusFollowerPermission.get(permission)
return self.permissions & perm.value == perm.value
def add_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
perm = CorpusFollowerPermission.get(permission)
if not self.has_permission(perm):
self.permissions += perm.value
def remove_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
perm = CorpusFollowerPermission.get(permission)
if self.has_permission(perm):
self.permissions -= perm.value
def reset_permissions(self):
self.permissions = 0
def to_json_serializeable(self, backrefs=False, relationships=False):
json_serializeable = {
'id': self.hashid,
'default': self.default,
'name': self.name,
'permissions': [
x.name
for x in CorpusFollowerPermission
if self.has_permission(x)
]
}
if backrefs:
pass
if relationships:
json_serializeable['corpus_follower_association'] = {
x.hashid: x.to_json_serializeable(relationships=True)
for x in self.corpus_follower_association
}
return json_serializeable
@staticmethod
def insert_defaults():
roles = {
'Anonymous': [],
'Viewer': [
CorpusFollowerPermission.VIEW
],
'Contributor': [
CorpusFollowerPermission.VIEW,
CorpusFollowerPermission.MANAGE_FILES
],
'Administrator': [
CorpusFollowerPermission.VIEW,
CorpusFollowerPermission.MANAGE_FILES,
CorpusFollowerPermission.MANAGE_FOLLOWERS,
CorpusFollowerPermission.MANAGE_CORPUS
]
}
default_role_name = 'Viewer'
for role_name, permissions in roles.items():
role = CorpusFollowerRole.query.filter_by(name=role_name).first()
if role is None:
role = CorpusFollowerRole(name=role_name)
role.reset_permissions()
for permission in permissions:
role.add_permission(permission)
role.default = role.name == default_role_name
db.session.add(role)
db.session.commit()
class CorpusFollowerAssociation(HashidMixin, db.Model):
__tablename__ = 'corpus_follower_associations' __tablename__ = 'corpus_follower_associations'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# Foreign keys # Foreign keys
following_user_id = db.Column(db.Integer, db.ForeignKey('users.id')) corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id'))
followed_corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) follower_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# Fields role_id = db.Column(db.Integer, db.ForeignKey('corpus_follower_roles.id'))
permissions = db.Column(db.Integer, default=0, nullable=False)
# Relationships # Relationships
followed_corpus = db.relationship('Corpus', back_populates='following_user_associations') corpus = db.relationship(
following_user = db.relationship('User', back_populates='followed_corpus_associations') 'Corpus',
back_populates='corpus_follower_associations'
)
follower = db.relationship(
'User',
back_populates='corpus_follower_associations'
)
role = db.relationship(
'CorpusFollowerRole',
back_populates='corpus_follower_associations'
)
def __init__(self, **kwargs):
if 'role' not in kwargs:
kwargs['role'] = CorpusFollowerRole.query.filter_by(default=True).first()
super().__init__(**kwargs)
def __repr__(self): def __repr__(self):
return f'<CorpusFollowerAssociation {self.following_user.__repr__()} ~ {self.followed_corpus.__repr__()}>' return f'<CorpusFollowerAssociation {self.follower.__repr__()} ~ {self.role.__repr__()} ~ {self.corpus.__repr__()}>'
def has_permission(self, permission): def to_json_serializeable(self, backrefs=False, relationships=False):
return self.permissions & permission == permission json_serializeable = {
'id': self.hashid,
'corpus': self.corpus.to_json_serializeable(backrefs=True),
'follower': self.follower.to_json_serializeable(),
'role': self.role.to_json_serializeable()
}
if backrefs:
pass
if relationships:
pass
return json_serializeable
def add_permission(self, permission):
if not self.has_permission(permission):
self.permissions += permission
def remove_permission(self, permission):
if self.has_permission(permission):
self.permissions -= permission
class User(HashidMixin, UserMixin, db.Model): class User(HashidMixin, UserMixin, db.Model):
__tablename__ = 'users' __tablename__ = 'users'
@ -323,8 +513,10 @@ class User(HashidMixin, UserMixin, db.Model):
# Fields # Fields
email = db.Column(db.String(254), index=True, unique=True) email = db.Column(db.String(254), index=True, unique=True)
username = db.Column(db.String(64), index=True, unique=True) username = db.Column(db.String(64), index=True, unique=True)
username_pattern = re.compile(r'^[A-Za-zÄÖÜäöüß0-9_.]*$')
password_hash = db.Column(db.String(128)) password_hash = db.Column(db.String(128))
confirmed = db.Column(db.Boolean, default=False) confirmed = db.Column(db.Boolean, default=False)
terms_of_use_accepted = db.Column(db.Boolean, default=False)
member_since = db.Column(db.DateTime(), default=datetime.utcnow) member_since = db.Column(db.DateTime(), default=datetime.utcnow)
setting_job_status_mail_notification_level = db.Column( setting_job_status_mail_notification_level = db.Column(
IntEnumColumn(UserSettingJobStatusMailNotificationLevel), IntEnumColumn(UserSettingJobStatusMailNotificationLevel),
@ -351,14 +543,15 @@ class User(HashidMixin, UserMixin, db.Model):
cascade='all, delete-orphan', cascade='all, delete-orphan',
lazy='dynamic' lazy='dynamic'
) )
followed_corpus_associations = db.relationship( corpus_follower_associations = db.relationship(
'CorpusFollowerAssociation', 'CorpusFollowerAssociation',
back_populates='following_user' back_populates='follower',
cascade='all, delete-orphan'
) )
followed_corpora = association_proxy( followed_corpora = association_proxy(
'followed_corpus_associations', 'corpus_follower_associations',
'followed_corpus', 'corpus',
creator=lambda c: CorpusFollowerAssociation(followed_corpus=c) creator=lambda c: CorpusFollowerAssociation(corpus=c)
) )
jobs = db.relationship( jobs = db.relationship(
'Job', 'Job',
@ -390,13 +583,13 @@ class User(HashidMixin, UserMixin, db.Model):
) )
def __init__(self, **kwargs): def __init__(self, **kwargs):
if 'role' not in kwargs:
kwargs['role'] = (
Role.query.filter_by(name='Administrator').first()
if kwargs['email'] == current_app.config['NOPAQUE_ADMIN']
else Role.query.filter_by(default=True).first()
)
super().__init__(**kwargs) super().__init__(**kwargs)
if self.role is not None:
return
if self.email == current_app.config['NOPAQUE_ADMIN']:
self.role = Role.query.filter_by(name='Administrator').first()
else:
self.role = Role.query.filter_by(default=True).first()
def __repr__(self): def __repr__(self):
return f'<User {self.username}>' return f'<User {self.username}>'
@ -495,7 +688,7 @@ class User(HashidMixin, UserMixin, db.Model):
db.session.commit() db.session.commit()
def can(self, permission): def can(self, permission):
return self.role.has_permission(permission) return self.role is not None and self.role.has_permission(permission)
def confirm(self, confirmation_token): def confirm(self, confirmation_token):
try: try:
@ -506,7 +699,6 @@ class User(HashidMixin, UserMixin, db.Model):
issuer=current_app.config['SERVER_NAME'], issuer=current_app.config['SERVER_NAME'],
options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']} options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
) )
current_app.logger.warning(payload)
except jwt.PyJWTError: except jwt.PyJWTError:
return False return False
if payload.get('purpose') != 'user.confirm': if payload.get('purpose') != 'user.confirm':
@ -577,42 +769,97 @@ class User(HashidMixin, UserMixin, db.Model):
#region Profile Privacy settings #region Profile Privacy settings
def has_profile_privacy_setting(self, setting): def has_profile_privacy_setting(self, setting):
return self.profile_privacy_settings & setting == setting s = ProfilePrivacySettings.get(setting)
return self.profile_privacy_settings & s.value == s.value
def add_profile_privacy_setting(self, setting): def add_profile_privacy_setting(self, setting):
if not self.has_profile_privacy_setting(setting): s = ProfilePrivacySettings.get(setting)
self.profile_privacy_settings += setting if not self.has_profile_privacy_setting(s):
self.profile_privacy_settings += s.value
def remove_profile_privacy_setting(self, setting): def remove_profile_privacy_setting(self, setting):
if self.has_profile_privacy_setting(setting): s = ProfilePrivacySettings.get(setting)
self.profile_privacy_settings -= setting if self.has_profile_privacy_setting(s):
self.profile_privacy_settings -= s.value
def reset_profile_privacy_settings(self): def reset_profile_privacy_settings(self):
self.profile_privacy_settings = 0 self.profile_privacy_settings = 0
#endregion Profile Privacy settings #endregion Profile Privacy settings
def follow_corpus(self, corpus): def follow_corpus(self, corpus, role=None):
if not self.is_following_corpus(corpus): if role is None:
self.followed_corpora.append(corpus) cfr = CorpusFollowerRole.query.filter_by(default=True).first()
else:
cfr = role
if self.is_following_corpus(corpus):
cfa = CorpusFollowerAssociation.query.filter_by(corpus=corpus, follower=self).first()
if cfa.role != cfr:
cfa.role = cfr
else:
cfa = CorpusFollowerAssociation(corpus=corpus, role=cfr, follower=self)
db.session.add(cfa)
def unfollow_corpus(self, corpus): def unfollow_corpus(self, corpus):
if self.is_following_corpus(corpus): if not self.is_following_corpus(corpus):
return
self.followed_corpora.remove(corpus) self.followed_corpora.remove(corpus)
def is_following_corpus(self, corpus): def is_following_corpus(self, corpus):
return corpus in self.followed_corpora return corpus in self.followed_corpora
def generate_follow_corpus_token(self, corpus_hashid, role_name, expiration=7):
now = datetime.utcnow()
payload = {
'exp': expiration,
'iat': now,
'iss': current_app.config['SERVER_NAME'],
'purpose': 'User.follow_corpus',
'role_name': role_name,
'sub': corpus_hashid
}
return jwt.encode(
payload,
current_app.config['SECRET_KEY'],
algorithm='HS256'
)
def follow_corpus_by_token(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', 'role_name', 'sub']}
)
except jwt.PyJWTError:
return False
if payload.get('purpose') != 'User.follow_corpus':
return False
corpus_hashid = payload.get('sub')
corpus_id = hashids.decode(corpus_hashid)
corpus = Corpus.query.get_or_404(corpus_id)
if corpus is None:
return False
role_name = payload.get('role_name')
role = CorpusFollowerRole.query.filter_by(name=role_name).first()
if role is None:
return False
self.follow_corpus(corpus, role)
# db.session.add(self)
return True
def to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False): def to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False):
json_serializeable = { json_serializeable = {
'id': self.hashid, 'id': self.hashid,
'confirmed': self.confirmed, 'confirmed': self.confirmed,
# 'avatar': url_for('users.user_avatar', user_id=self.id),
'email': self.email, 'email': self.email,
'last_seen': ( 'last_seen': (
None if self.last_seen is None None if self.last_seen is None
else self.last_seen.strftime('%Y-%m-%d %H:%M') else f'{self.last_seen.isoformat()}Z'
), ),
'member_since': self.member_since.strftime('%Y-%m-%d'), 'member_since': f'{self.member_since.isoformat()}Z',
'username': self.username, 'username': self.username,
'full_name': self.full_name, 'full_name': self.full_name,
'about_me': self.about_me, 'about_me': self.about_me,
@ -621,19 +868,21 @@ class User(HashidMixin, UserMixin, db.Model):
'organization': self.organization, 'organization': self.organization,
'job_status_mail_notification_level': \ 'job_status_mail_notification_level': \
self.setting_job_status_mail_notification_level.name, self.setting_job_status_mail_notification_level.name,
'profile_privacy_settings': {
'is_public': self.is_public, 'is_public': self.is_public,
'show_email': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL), 'show_email': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL),
'show_last_seen': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_LAST_SEEN), 'show_last_seen': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_LAST_SEEN),
'show_member_since': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_MEMBER_SINCE) 'show_member_since': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_MEMBER_SINCE)
} }
json_serializeable['avatar'] = ( }
None if self.avatar is None
else self.avatar.to_json_serializeable(relationships=True)
)
if backrefs: if backrefs:
json_serializeable['role'] = \ json_serializeable['role'] = \
self.role.to_json_serializeable(backrefs=True) self.role.to_json_serializeable(backrefs=True)
if relationships: if relationships:
json_serializeable['corpus_follower_associations'] = {
x.hashid: x.to_json_serializeable()
for x in self.corpus_follower_associations
}
json_serializeable['corpora'] = { json_serializeable['corpora'] = {
x.hashid: x.to_json_serializeable(relationships=True) x.hashid: x.to_json_serializeable(relationships=True)
for x in self.corpora for x in self.corpora
@ -650,10 +899,6 @@ class User(HashidMixin, UserMixin, db.Model):
x.hashid: x.to_json_serializeable(relationships=True) x.hashid: x.to_json_serializeable(relationships=True)
for x in self.spacy_nlp_pipeline_models for x in self.spacy_nlp_pipeline_models
} }
json_serializeable['followed_corpora'] = {
x.hashid: x.to_json_serializeable(relationships=True)
for x in self.followed_corpora
}
if filter_by_privacy_settings: if filter_by_privacy_settings:
if not self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL): if not self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL):
@ -786,6 +1031,8 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model):
if backrefs: if backrefs:
json_serializeable['user'] = \ json_serializeable['user'] = \
self.user.to_json_serializeable(backrefs=True) self.user.to_json_serializeable(backrefs=True)
if relationships:
pass
return json_serializeable return json_serializeable
@ -912,7 +1159,10 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model):
**self.file_mixin_to_json_serializeable() **self.file_mixin_to_json_serializeable()
} }
if backrefs: if backrefs:
json_serializeable['user'] = self.user.to_json_serializeable(backrefs=True) json_serializeable['user'] = \
self.user.to_json_serializeable(backrefs=True)
if relationships:
pass
return json_serializeable return json_serializeable
@ -971,6 +1221,8 @@ class JobInput(FileMixin, HashidMixin, db.Model):
if backrefs: if backrefs:
json_serializeable['job'] = \ json_serializeable['job'] = \
self.job.to_json_serializeable(backrefs=True) self.job.to_json_serializeable(backrefs=True)
if relationships:
pass
return json_serializeable return json_serializeable
@ -1035,6 +1287,8 @@ class JobResult(FileMixin, HashidMixin, db.Model):
if backrefs: if backrefs:
json_serializeable['job'] = \ json_serializeable['job'] = \
self.job.to_json_serializeable(backrefs=True) self.job.to_json_serializeable(backrefs=True)
if relationships:
pass
return json_serializeable return json_serializeable
@ -1114,7 +1368,6 @@ class Job(HashidMixin, db.Model):
raise e raise e
return job 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
@ -1159,8 +1412,7 @@ class Job(HashidMixin, db.Model):
'service_args': self.service_args, 'service_args': self.service_args,
'service_version': self.service_version, 'service_version': self.service_version,
'status': self.status.name, 'status': self.status.name,
'title': self.title, 'title': self.title
'url': self.url
} }
if backrefs: if backrefs:
json_serializeable['user'] = \ json_serializeable['user'] = \
@ -1246,9 +1498,9 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
def to_json_serializeable(self, backrefs=False, relationships=False): def to_json_serializeable(self, backrefs=False, relationships=False):
json_serializeable = { json_serializeable = {
'id': self.hashid, 'id': self.hashid,
'url': self.url,
'address': self.address, 'address': self.address,
'author': self.author, 'author': self.author,
'description': self.description,
'booktitle': self.booktitle, 'booktitle': self.booktitle,
'chapter': self.chapter, 'chapter': self.chapter,
'editor': self.editor, 'editor': self.editor,
@ -1267,6 +1519,8 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
if backrefs: if backrefs:
json_serializeable['corpus'] = \ json_serializeable['corpus'] = \
self.corpus.to_json_serializeable(backrefs=True) self.corpus.to_json_serializeable(backrefs=True)
if relationships:
pass
return json_serializeable return json_serializeable
@ -1297,14 +1551,15 @@ class Corpus(HashidMixin, db.Model):
lazy='dynamic', lazy='dynamic',
cascade='all, delete-orphan' cascade='all, delete-orphan'
) )
following_user_associations = db.relationship( corpus_follower_associations = db.relationship(
'CorpusFollowerAssociation', 'CorpusFollowerAssociation',
back_populates='followed_corpus' back_populates='corpus',
cascade='all, delete-orphan'
) )
following_users = association_proxy( followers = association_proxy(
'following_user_associations', 'corpus_follower_associations',
'following_user', 'follower',
creator=lambda u: CorpusFollowerAssociation(following_user=u) creator=lambda u: CorpusFollowerAssociation(follower=u)
) )
user = db.relationship('User', back_populates='corpora') user = db.relationship('User', back_populates='corpora')
# "static" attributes # "static" attributes
@ -1315,7 +1570,7 @@ class Corpus(HashidMixin, db.Model):
@property @property
def analysis_url(self): def analysis_url(self):
return url_for('corpora.analyse_corpus', corpus_id=self.id) return url_for('corpora.analysis', corpus_id=self.id)
@property @property
def jsonpatch_path(self): def jsonpatch_path(self):
@ -1403,8 +1658,13 @@ class Corpus(HashidMixin, db.Model):
'is_public': self.is_public 'is_public': self.is_public
} }
if backrefs: if backrefs:
json_serializeable['user'] = self.user.to_json_serializeable(backrefs=True) json_serializeable['user'] = \
self.user.to_json_serializeable(backrefs=True)
if relationships: if relationships:
json_serializeable['corpus_follower_associations'] = {
x.hashid: x.to_json_serializeable()
for x in self.corpus_follower_associations
}
json_serializeable['files'] = { json_serializeable['files'] = {
x.hashid: x.to_json_serializeable(relationships=True) x.hashid: x.to_json_serializeable(relationships=True)
for x in self.files for x in self.files
@ -1424,11 +1684,27 @@ class Corpus(HashidMixin, db.Model):
@db.event.listens_for(JobResult, 'after_delete') @db.event.listens_for(JobResult, 'after_delete')
@db.event.listens_for(SpaCyNLPPipelineModel, 'after_delete') @db.event.listens_for(SpaCyNLPPipelineModel, 'after_delete')
@db.event.listens_for(TesseractOCRPipelineModel, 'after_delete') @db.event.listens_for(TesseractOCRPipelineModel, 'after_delete')
def ressource_after_delete(mapper, connection, ressource): def resource_after_delete(mapper, connection, resource):
jsonpatch = [{'op': 'remove', 'path': ressource.jsonpatch_path}] jsonpatch = [
room = f'users.{ressource.user_hashid}' {
socketio.emit('users.patch', jsonpatch, room=room) 'op': 'remove',
room = f'/users/{ressource.user_hashid}' 'path': resource.jsonpatch_path
}
]
room = f'/users/{resource.user_hashid}'
socketio.emit('PATCH', jsonpatch, room=room)
@db.event.listens_for(CorpusFollowerAssociation, 'after_delete')
def cfa_after_delete_handler(mapper, connection, cfa):
jsonpatch_path = f'/users/{cfa.corpus.user.hashid}/corpora/{cfa.corpus.hashid}/corpus_follower_associations/{cfa.hashid}'
jsonpatch = [
{
'op': 'remove',
'path': jsonpatch_path
}
]
room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('PATCH', jsonpatch, room=room) socketio.emit('PATCH', jsonpatch, room=room)
@ -1439,14 +1715,33 @@ def ressource_after_delete(mapper, connection, ressource):
@db.event.listens_for(JobResult, 'after_insert') @db.event.listens_for(JobResult, 'after_insert')
@db.event.listens_for(SpaCyNLPPipelineModel, 'after_insert') @db.event.listens_for(SpaCyNLPPipelineModel, 'after_insert')
@db.event.listens_for(TesseractOCRPipelineModel, 'after_insert') @db.event.listens_for(TesseractOCRPipelineModel, 'after_insert')
def ressource_after_insert_handler(mapper, connection, ressource): def resource_after_insert_handler(mapper, connection, resource):
value = ressource.to_json_serializeable() jsonpatch_value = resource.to_json_serializeable()
for attr in mapper.relationships: for attr in mapper.relationships:
value[attr.key] = {} jsonpatch_value[attr.key] = {}
jsonpatch = [ jsonpatch = [
{'op': 'add', 'path': ressource.jsonpatch_path, 'value': value} {
'op': 'add',
'path': resource.jsonpatch_path,
'value': jsonpatch_value
}
] ]
room = f'/users/{ressource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('PATCH', jsonpatch, room=room)
@db.event.listens_for(CorpusFollowerAssociation, 'after_insert')
def cfa_after_insert_handler(mapper, connection, cfa):
jsonpatch_value = cfa.to_json_serializeable()
jsonpatch_path = f'/users/{cfa.corpus.user.hashid}/corpora/{cfa.corpus.hashid}/corpus_follower_associations/{cfa.hashid}'
jsonpatch = [
{
'op': 'add',
'path': jsonpatch_path,
'value': jsonpatch_value
}
]
room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('PATCH', jsonpatch, room=room) socketio.emit('PATCH', jsonpatch, room=room)
@ -1457,28 +1752,29 @@ def ressource_after_insert_handler(mapper, connection, ressource):
@db.event.listens_for(JobResult, 'after_update') @db.event.listens_for(JobResult, 'after_update')
@db.event.listens_for(SpaCyNLPPipelineModel, 'after_update') @db.event.listens_for(SpaCyNLPPipelineModel, 'after_update')
@db.event.listens_for(TesseractOCRPipelineModel, 'after_update') @db.event.listens_for(TesseractOCRPipelineModel, 'after_update')
def ressource_after_update_handler(mapper, connection, ressource): def resource_after_update_handler(mapper, connection, resource):
jsonpatch = [] jsonpatch = []
for attr in db.inspect(ressource).attrs: for attr in db.inspect(resource).attrs:
if attr.key in mapper.relationships: if attr.key in mapper.relationships:
continue continue
if not attr.load_history().has_changes(): if not attr.load_history().has_changes():
continue continue
jsonpatch_path = f'{resource.jsonpatch_path}/{attr.key}'
if isinstance(attr.value, datetime): if isinstance(attr.value, datetime):
value = f'{attr.value.isoformat()}Z' jsonpatch_value = f'{attr.value.isoformat()}Z'
elif isinstance(attr.value, Enum): elif isinstance(attr.value, Enum):
value = attr.value.name jsonpatch_value = attr.value.name
else: else:
value = attr.value jsonpatch_value = attr.value
jsonpatch.append( jsonpatch.append(
{ {
'op': 'replace', 'op': 'replace',
'path': f'{ressource.jsonpatch_path}/{attr.key}', 'path': jsonpatch_path,
'value': value 'value': jsonpatch_value
} }
) )
if jsonpatch: if jsonpatch:
room = f'/users/{ressource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('PATCH', jsonpatch, room=room) socketio.emit('PATCH', jsonpatch, room=room)

View File

@ -1,4 +1,5 @@
from flask import Blueprint from flask import Blueprint
from flask_login import login_required
import os import os
import yaml import yaml
@ -9,4 +10,16 @@ with open(services_file, 'r') as f:
SERVICES = yaml.safe_load(f) SERVICES = yaml.safe_load(f)
bp = Blueprint('services', __name__) bp = Blueprint('services', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import routes # noqa from . import routes # noqa

View File

@ -1,12 +1,17 @@
from flask_login import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_login import current_user
from flask_wtf.file import FileField, FileRequired from flask_wtf.file import FileField, FileRequired
from wtforms import (BooleanField, DecimalRangeField, MultipleFileField, from wtforms import (
SelectField, StringField, SubmitField, ValidationError) BooleanField,
DecimalRangeField,
MultipleFileField,
SelectField,
StringField,
SubmitField,
ValidationError
)
from wtforms.validators import InputRequired, Length from wtforms.validators import InputRequired, Length
from app.models import SpaCyNLPPipelineModel, TesseractOCRPipelineModel from app.models import SpaCyNLPPipelineModel, TesseractOCRPipelineModel
from . import SERVICES from . import SERVICES
@ -33,6 +38,8 @@ class CreateFileSetupPipelineJobForm(CreateJobBaseForm):
raise ValidationError('JPEG, PNG and TIFF files only!') raise ValidationError('JPEG, PNG and TIFF files only!')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-file-setup-pipeline-job-form'
service_manifest = SERVICES['file-setup-pipeline'] service_manifest = SERVICES['file-setup-pipeline']
version = kwargs.pop('version', service_manifest['latest_version']) version = kwargs.pop('version', service_manifest['latest_version'])
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -60,6 +67,8 @@ class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
raise ValidationError('PDF files only!') raise ValidationError('PDF files only!')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-tesseract-ocr-pipeline-job-form'
service_manifest = SERVICES['tesseract-ocr-pipeline'] service_manifest = SERVICES['tesseract-ocr-pipeline']
version = kwargs.pop('version', service_manifest['latest_version']) version = kwargs.pop('version', service_manifest['latest_version'])
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -75,12 +84,18 @@ class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
del self.binarization.render_kw['disabled'] del self.binarization.render_kw['disabled']
if 'ocropus_nlbin_threshold' in service_info['methods']: if 'ocropus_nlbin_threshold' in service_info['methods']:
del self.ocropus_nlbin_threshold.render_kw['disabled'] del self.ocropus_nlbin_threshold.render_kw['disabled']
user_models = [
x for x in current_user.tesseract_ocr_pipeline_models.order_by(TesseractOCRPipelineModel.title).all()
]
models = [ models = [
x for x in TesseractOCRPipelineModel.query.order_by(TesseractOCRPipelineModel.title).all() x for x in TesseractOCRPipelineModel.query.order_by(TesseractOCRPipelineModel.title).all()
if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user) if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user)
] ]
self.model.choices = [('', 'Choose your option')] self.model.choices = {
self.model.choices += [(x.hashid, f'{x.title} [{x.version}]') for x in models] '': [('', 'Choose your option')],
'Your models': [(x.hashid, f'{x.title} [{x.version}]') for x in user_models] if user_models else [(0, 'Nothing here yet...')],
'Public models': [(x.hashid, f'{x.title} [{x.version}]') for x in models]
}
self.model.default = '' self.model.default = ''
self.version.choices = [(x, x) for x in service_manifest['versions']] self.version.choices = [(x, x) for x in service_manifest['versions']]
self.version.data = version self.version.data = version
@ -106,6 +121,8 @@ class CreateTranskribusHTRPipelineJobForm(CreateJobBaseForm):
raise ValidationError('PDF files only!') raise ValidationError('PDF files only!')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-transkribus-htr-pipeline-job-form'
transkribus_htr_pipeline_models = kwargs.pop('transkribus_htr_pipeline_models', []) transkribus_htr_pipeline_models = kwargs.pop('transkribus_htr_pipeline_models', [])
service_manifest = SERVICES['transkribus-htr-pipeline'] service_manifest = SERVICES['transkribus-htr-pipeline']
version = kwargs.pop('version', service_manifest['latest_version']) version = kwargs.pop('version', service_manifest['latest_version'])
@ -144,6 +161,8 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
raise ValidationError('Plain text files only!') raise ValidationError('Plain text files only!')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-spacy-nlp-pipeline-job-form'
service_manifest = SERVICES['spacy-nlp-pipeline'] service_manifest = SERVICES['spacy-nlp-pipeline']
version = kwargs.pop('version', service_manifest['latest_version']) version = kwargs.pop('version', service_manifest['latest_version'])
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -155,12 +174,18 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
if 'methods' in service_info: if 'methods' in service_info:
if 'encoding_detection' in service_info['methods']: if 'encoding_detection' in service_info['methods']:
del self.encoding_detection.render_kw['disabled'] del self.encoding_detection.render_kw['disabled']
models = [ user_models = [
x for x in SpaCyNLPPipelineModel.query.order_by(SpaCyNLPPipelineModel.title).all() x for x in current_user.spacy_nlp_pipeline_models.order_by(SpaCyNLPPipelineModel.title).all()
if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user)
] ]
self.model.choices = [('', 'Choose your option')] models = [
self.model.choices += [(x.hashid, f'{x.title} [{x.version}]') for x in models] x for x in SpaCyNLPPipelineModel.query.filter(SpaCyNLPPipelineModel.user != current_user, SpaCyNLPPipelineModel.is_public == True).order_by(SpaCyNLPPipelineModel.title).all()
if version in x.compatible_service_versions
]
self.model.choices = {
'': [('', 'Choose your option')],
'Your models': [(x.hashid, f'{x.title} [{x.version}]') for x in user_models] if user_models else [(0, 'Nothing here yet...')],
'Public models': [(x.hashid, f'{x.title} [{x.version}]') for x in models]
}
self.model.default = '' self.model.default = ''
self.version.choices = [(x, x) for x in service_manifest['versions']] self.version.choices = [(x, x) for x in service_manifest['versions']]
self.version.data = version self.version.data = version

View File

@ -1,5 +1,6 @@
from flask import abort, current_app, flash, make_response, Markup, render_template, request from flask import abort, current_app, flash, Markup, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user
import requests import requests
from app import db, hashids from app import db, hashids
from app.models import ( from app.models import (
@ -18,8 +19,14 @@ from .forms import (
) )
@bp.route('/services')
@register_breadcrumb(bp, '.', 'Services')
def services():
return redirect(url_for('main.dashboard'))
@bp.route('/file-setup-pipeline', methods=['GET', 'POST']) @bp.route('/file-setup-pipeline', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.file_setup_pipeline', '<i class="nopaque-icons service-icons left" data-service="file-setup-pipeline"></i>File Setup')
def file_setup_pipeline(): def file_setup_pipeline():
service = 'file-setup-pipeline' service = 'file-setup-pipeline'
service_manifest = SERVICES[service] service_manifest = SERVICES[service]
@ -54,13 +61,13 @@ def file_setup_pipeline():
return {}, 201, {'Location': job.url} 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, title=service_manifest['name'],
title=service_manifest['name'] form=form
) )
@bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST']) @bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.tesseract_ocr_pipeline', '<i class="nopaque-icons service-icons left" data-service="tesseract-ocr-pipeline"></i>Tesseract OCR Pipeline')
def tesseract_ocr_pipeline(): def tesseract_ocr_pipeline():
service_name = 'tesseract-ocr-pipeline' service_name = 'tesseract-ocr-pipeline'
service_manifest = SERVICES[service_name] service_manifest = SERVICES[service_name]
@ -100,16 +107,18 @@ def tesseract_ocr_pipeline():
x for x in TesseractOCRPipelineModel.query.all() x for x in TesseractOCRPipelineModel.query.all()
if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user) if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user)
] ]
user_tesseract_ocr_pipeline_models_count = len(current_user.tesseract_ocr_pipeline_models.all())
return render_template( return render_template(
'services/tesseract_ocr_pipeline.html.j2', 'services/tesseract_ocr_pipeline.html.j2',
title=service_manifest['name'],
form=form, form=form,
tesseract_ocr_pipeline_models=tesseract_ocr_pipeline_models, tesseract_ocr_pipeline_models=tesseract_ocr_pipeline_models,
title=service_manifest['name'] user_tesseract_ocr_pipeline_models_count=user_tesseract_ocr_pipeline_models_count
) )
@bp.route('/transkribus-htr-pipeline', methods=['GET', 'POST']) @bp.route('/transkribus-htr-pipeline', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.transkribus_htr_pipeline', '<i class="nopaque-icons service-icons left" data-service="transkribus-htr-pipeline"></i>Transkribus HTR Pipeline')
def transkribus_htr_pipeline(): def transkribus_htr_pipeline():
if not current_app.config.get('NOPAQUE_TRANSKRIBUS_ENABLED'): if not current_app.config.get('NOPAQUE_TRANSKRIBUS_ENABLED'):
abort(404) abort(404)
@ -126,10 +135,9 @@ def transkribus_htr_pipeline():
abort(500) abort(500)
transkribus_htr_pipeline_models = r.json()['trpModelMetadata'] transkribus_htr_pipeline_models = r.json()['trpModelMetadata']
transkribus_htr_pipeline_models.append({'modelId': 48513, 'name': 'Caroline Minuscle', 'language': 'lat', 'isoLanguages': ['lat']}) transkribus_htr_pipeline_models.append({'modelId': 48513, 'name': 'Caroline Minuscle', 'language': 'lat', 'isoLanguages': ['lat']})
print(transkribus_htr_pipeline_models[len(transkribus_htr_pipeline_models)-1])
form = CreateTranskribusHTRPipelineJobForm( form = CreateTranskribusHTRPipelineJobForm(
transkribus_htr_pipeline_models=transkribus_htr_pipeline_models,
prefix='create-job-form', prefix='create-job-form',
transkribus_htr_pipeline_models=transkribus_htr_pipeline_models,
version=version version=version
) )
if form.is_submitted(): if form.is_submitted():
@ -161,14 +169,14 @@ def transkribus_htr_pipeline():
return {}, 201, {'Location': job.url} return {}, 201, {'Location': job.url}
return render_template( return render_template(
'services/transkribus_htr_pipeline.html.j2', 'services/transkribus_htr_pipeline.html.j2',
form=form,
title=service_manifest['name'], title=service_manifest['name'],
form=form,
transkribus_htr_pipeline_models=transkribus_htr_pipeline_models transkribus_htr_pipeline_models=transkribus_htr_pipeline_models
) )
@bp.route('/spacy-nlp-pipeline', methods=['GET', 'POST']) @bp.route('/spacy-nlp-pipeline', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.spacy_nlp_pipeline', '<i class="nopaque-icons service-icons left" data-service="spacy-nlp-pipeline"></i>SpaCy NLP Pipeline')
def spacy_nlp_pipeline(): def spacy_nlp_pipeline():
service = 'spacy-nlp-pipeline' service = 'spacy-nlp-pipeline'
service_manifest = SERVICES[service] service_manifest = SERVICES[service]
@ -177,6 +185,7 @@ def spacy_nlp_pipeline():
abort(404) abort(404)
form = CreateSpacyNLPPipelineJobForm(prefix='create-job-form', version=version) form = CreateSpacyNLPPipelineJobForm(prefix='create-job-form', version=version)
spacy_nlp_pipeline_models = SpaCyNLPPipelineModel.query.all() spacy_nlp_pipeline_models = SpaCyNLPPipelineModel.query.all()
user_spacy_nlp_pipeline_models_count = len(current_user.spacy_nlp_pipeline_models.all())
if form.is_submitted(): if form.is_submitted():
if not form.validate(): if not form.validate():
response = {'errors': form.errors} response = {'errors': form.errors}
@ -206,16 +215,17 @@ def spacy_nlp_pipeline():
return {}, 201, {'Location': job.url} return {}, 201, {'Location': job.url}
return render_template( return render_template(
'services/spacy_nlp_pipeline.html.j2', 'services/spacy_nlp_pipeline.html.j2',
title=service_manifest['name'],
form=form, form=form,
spacy_nlp_pipeline_models=spacy_nlp_pipeline_models, spacy_nlp_pipeline_models=spacy_nlp_pipeline_models,
title=service_manifest['name'] user_spacy_nlp_pipeline_models_count=user_spacy_nlp_pipeline_models_count
) )
@bp.route('/corpus-analysis') @bp.route('/corpus-analysis')
@login_required @register_breadcrumb(bp, '.corpus_analysis', '<i class="nopaque-icons service-icons left" data-service="corpus-analysis"></i>Corpus Analysis')
def corpus_analysis(): def corpus_analysis():
return render_template( return render_template(
'services/corpus_analysis.html.j2', 'services/corpus_analysis.html.j2',
title='Corpus analysis' title='Corpus Analysis'
) )

View File

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

View File

@ -1,43 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import PasswordField, SelectField, SubmitField, ValidationError
from wtforms.validators import DataRequired, EqualTo
from app.models import UserSettingJobStatusMailNotificationLevel
class ChangePasswordForm(FlaskForm):
password = PasswordField('Old password', validators=[DataRequired()])
new_password = PasswordField(
'New password',
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):
super().__init__(*args, **kwargs)
self.user = user
def validate_current_password(self, field):
if not self.user.verify_password(field.data):
raise ValidationError('Invalid password')
class EditNotificationSettingsForm(FlaskForm):
job_status_mail_notification_level = SelectField(
'Job status mail notification level',
choices=[
(x.name, x.name.capitalize())
for x in UserSettingJobStatusMailNotificationLevel
],
validators=[DataRequired()]
)
submit = SubmitField()

View File

@ -1,39 +1,12 @@
from flask import flash, redirect, render_template, url_for from flask import g, url_for
from flask_login import current_user, login_required from flask_breadcrumbs import register_breadcrumb
from app import db from flask_login import current_user
from app.models import UserSettingJobStatusMailNotificationLevel from app.users.settings.routes import settings as settings_route
from . import bp from . import bp
from .forms import ChangePasswordForm, EditNotificationSettingsForm
@bp.route('', methods=['GET', 'POST']) @bp.route('/settings', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.', '<i class="material-icons left">settings</i>Settings')
def settings(): def settings():
change_password_form = ChangePasswordForm( g._nopaque_redirect_location_on_post = url_for('.settings')
current_user, return settings_route(current_user.id)
prefix='change-password-form'
)
edit_notification_settings_form = EditNotificationSettingsForm(
data=current_user.to_json_serializeable(),
prefix='edit-notification-settings-form'
)
# region handle change_password_form POST
if change_password_form.submit.data and change_password_form.validate():
current_user.password = change_password_form.new_password.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.settings'))
# endregion handle change_password_form POST
# region handle edit_notification_settings_form POST
if edit_notification_settings_form.submit and edit_notification_settings_form.validate():
current_user.setting_job_status_mail_notification_level = edit_notification_settings_form.job_status_mail_notification_level.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.settings'))
# endregion handle edit_notification_settings_form POST
return render_template(
'settings/settings.html.j2',
change_password_form=change_password_form,
edit_notification_settings_form=edit_notification_settings_form,
title='Settings'
)

View File

@ -22,6 +22,11 @@ $color: (
"surface": #ffffff, "surface": #ffffff,
"error": #b00020 "error": #b00020
), ),
"social-area": (
"base": #d6ae86,
"darken": #C98536,
"lighten": #EAE2DB
),
"service": ( "service": (
"corpus-analysis": ( "corpus-analysis": (
"base": #aa9cc9, "base": #aa9cc9,
@ -108,6 +113,16 @@ $color: (
} }
} }
@each $key, $color-code in map-get($color, "social-area") {
.social-area-color-#{$key} {
background-color: $color-code !important;
}
.social-area-color-border-#{$key} {
border-color: $color-code !important;
}
}
@each $service-name, $color-palette in map-get($color, "service") { @each $service-name, $color-palette in map-get($color, "service") {
.service-color[data-service="#{$service-name}"] { .service-color[data-service="#{$service-name}"] {
background-color: map-get($color-palette, "base") !important; background-color: map-get($color-palette, "base") !important;

View File

@ -1,3 +1,8 @@
.parallax-container .parallax { .parallax-container .parallax {
z-index: 0; z-index: 0;
} }
.autocomplete-content {
width: 100% !important;
left: 0 !important;
}

View File

@ -19,6 +19,10 @@
height: 30px !important; height: 30px !important;
} }
#manual-modal .manual-chapter-title {
display: none;
}
.show-if-only-child:not(:only-child) { .show-if-only-child:not(:only-child) {
display: none !important; display: none !important;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -60,6 +60,10 @@ class App {
iconPrefix = '<i class="left nopaque-icons">J</i>'; iconPrefix = '<i class="left nopaque-icons">J</i>';
break; break;
} }
case 'settings': {
iconPrefix = '<i class="left material-icons">settings</i>';
break;
}
default: { default: {
iconPrefix = '<i class="left material-icons">notifications</i>'; iconPrefix = '<i class="left material-icons">notifications</i>';
break; break;
@ -91,7 +95,7 @@ class App {
.filter((operation) => {return subRegExp.test(operation.path);}); .filter((operation) => {return subRegExp.test(operation.path);});
for (let operation of subFilteredPatch) { for (let operation of subFilteredPatch) {
let [match, userId, jobId] = operation.path.match(subRegExp); 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'); this.flash(`[<a href="/jobs/${jobId}">${this.data.users[userId].jobs[jobId].title}</a>] New status: <span class="job-status-text" data-status="${operation.value}"></span>`, 'job');
} }
// Apply Patch // Apply Patch

View File

@ -7,8 +7,6 @@ class CorpusAnalysisApp {
container: document.querySelector('#corpus-analysis-app-container'), container: document.querySelector('#corpus-analysis-app-container'),
extensionTabs: document.querySelector('#corpus-analysis-app-extension-tabs'), extensionTabs: document.querySelector('#corpus-analysis-app-extension-tabs'),
initModal: document.querySelector('#corpus-analysis-app-init-modal'), initModal: document.querySelector('#corpus-analysis-app-init-modal'),
initError: document.querySelector('#corpus-analysis-app-init-error'),
initProgress: document.querySelector('#corpus-analysis-app-init-progress'),
overview: document.querySelector('#corpus-analysis-app-overview') overview: document.querySelector('#corpus-analysis-app-overview')
}; };
// Materialize elements // Materialize elements
@ -27,6 +25,7 @@ class CorpusAnalysisApp {
init() { init() {
this.disableActionElements(); this.disableActionElements();
this.elements.m.initModal.open(); this.elements.m.initModal.open();
// Init data // Init data
this.data.cQiClient = new CQiClient(this.settings.corpusId); this.data.cQiClient = new CQiClient(this.settings.corpusId);
this.data.cQiClient.connect() this.data.cQiClient.connect()
@ -43,14 +42,17 @@ class CorpusAnalysisApp {
this.elements.m.initModal.close(); this.elements.m.initModal.close();
}, },
cQiError => { cQiError => {
this.elements.initError.innerText = JSON.stringify(cQiError); let errorsElement = this.elements.initModal.querySelector('.errors');
this.elements.initError.classList.remove('hide'); let progressElement = this.elements.initModal.querySelector('.progress');
this.elements.initProgress.classList.add('hide'); errorsElement.innerText = JSON.stringify(cQiError);
errorsElement.classList.remove('hide');
progressElement.classList.add('hide');
if ('payload' in cQiError && 'code' in cQiError.payload && 'msg' in cQiError.payload) { if ('payload' in cQiError && 'code' in cQiError.payload && 'msg' in cQiError.payload) {
app.flash(`${cQiError.payload.code}: ${cQiError.payload.msg}`, 'error'); app.flash(`${cQiError.payload.code}: ${cQiError.payload.msg}`, 'error');
} }
} }
); );
// Add event listeners // Add event listeners
for (let extensionSelectorElement of this.elements.overview.querySelectorAll('.extension-selector')) { for (let extensionSelectorElement of this.elements.overview.querySelectorAll('.extension-selector')) {
extensionSelectorElement.addEventListener('click', () => { extensionSelectorElement.addEventListener('click', () => {

View File

@ -106,41 +106,102 @@ class CorpusAnalysisReader {
renderCorpusPagination() { renderCorpusPagination() {
this.clearCorpusPagination(); this.clearCorpusPagination();
if (this.data.corpus.p.pages === 0) {return;} if (this.data.corpus.p.pages === 0) {return;}
this.elements.corpusPagination.innerHTML += ` let pageElement;
// First page button. Disables first page button if on first page
pageElement = Utils.HTMLToElement(
`
<li class="${this.data.corpus.p.page === 1 ? 'disabled' : 'waves-effect'}"> <li class="${this.data.corpus.p.page === 1 ? 'disabled' : 'waves-effect'}">
<a class="corpus-analysis-action pagination-trigger" ${this.data.corpus.p.page === 1 ? '' : 'data-target="1"'}> <a class="corpus-analysis-action pagination-trigger" ${this.data.corpus.p.page === 1 ? '' : 'data-target="1"'}>
<i class="material-icons">first_page</i> <i class="material-icons">first_page</i>
</a> </a>
</li> </li>
`.trim(); `
this.elements.corpusPagination.innerHTML += ` );
this.elements.corpusPagination.appendChild(pageElement);
// Previous page button. Disables previous page button if on first page
pageElement = Utils.HTMLToElement(
`
<li class="${this.data.corpus.p.has_prev ? 'waves-effect' : 'disabled'}"> <li class="${this.data.corpus.p.has_prev ? 'waves-effect' : 'disabled'}">
<a class="corpus-analysis-action pagination-trigger" ${this.data.corpus.p.has_prev ? 'data-target="' + this.data.corpus.p.prev_num + '"' : ''}> <a class="corpus-analysis-action pagination-trigger" ${this.data.corpus.p.has_prev ? 'data-target="' + this.data.corpus.p.prev_num + '"' : ''}>
<i class="material-icons">chevron_left</i> <i class="material-icons">chevron_left</i>
</a> </a>
</li> </li>
`.trim(); `
for (let i = 1; i <= this.data.corpus.p.pages; i++) { );
this.elements.corpusPagination.innerHTML += ` this.elements.corpusPagination.appendChild(pageElement);
// First page as number. Hides first page button if on first page
if (this.data.corpus.p.page > 6) {
pageElement = Utils.HTMLToElement(
`
<li class="waves-effect">
<a class="corpus-analysis-action pagination-trigger" data-target="1">1</a>
</li>
`
);
this.elements.corpusPagination.appendChild(pageElement);
pageElement = Utils.HTMLToElement("<li style='margin-top: 5px;'>&hellip;</li>");
this.elements.corpusPagination.appendChild(pageElement);
}
// render page buttons (5 before and 5 after current page)
for (let i = this.data.corpus.p.page -5; i <= this.data.corpus.p.page; i++) {
if (i <= 0) {continue;}
pageElement = Utils.HTMLToElement(
`
<li class="${i === this.data.corpus.p.page ? 'active' : 'waves-effect'}"> <li class="${i === this.data.corpus.p.page ? 'active' : 'waves-effect'}">
<a class="corpus-analysis-action pagination-trigger" ${i === this.data.corpus.p.page ? '' : 'data-target="' + i + '"'}>${i}</a> <a class="corpus-analysis-action pagination-trigger" ${i === this.data.corpus.p.page ? '' : 'data-target="' + i + '"'}>${i}</a>
</li> </li>
`.trim(); `
);
this.elements.corpusPagination.appendChild(pageElement);
};
for (let i = this.data.corpus.p.page +1; i <= this.data.corpus.p.page +5; i++) {
if (i > this.data.corpus.p.pages) {break;}
pageElement = Utils.HTMLToElement(
`
<li class="${i === this.data.corpus.p.page ? 'active' : 'waves-effect'}">
<a class="corpus-analysis-action pagination-trigger" ${i === this.data.corpus.p.page ? '' : 'data-target="' + i + '"'}>${i}</a>
</li>
`
);
this.elements.corpusPagination.appendChild(pageElement);
};
// Last page as number. Hides last page button if on last page
if (this.data.corpus.p.page < this.data.corpus.p.pages - 6) {
pageElement = Utils.HTMLToElement("<li style='margin-top: 5px;'>&hellip;</li>");
this.elements.corpusPagination.appendChild(pageElement);
pageElement = Utils.HTMLToElement(
`
<li class="waves-effect">
<a class="corpus-analysis-action pagination-trigger" data-target="${this.data.corpus.p.pages}">${this.data.corpus.p.pages}</a>
</li>
`
);
this.elements.corpusPagination.appendChild(pageElement);
} }
this.elements.corpusPagination.innerHTML += ` // Next page button. Disables next page button if on last page
pageElement = Utils.HTMLToElement(
`
<li class="${this.data.corpus.p.has_next ? 'waves-effect' : 'disabled'}"> <li class="${this.data.corpus.p.has_next ? 'waves-effect' : 'disabled'}">
<a class="corpus-analysis-action pagination-trigger" ${this.data.corpus.p.has_next ? 'data-target="' + this.data.corpus.p.next_num + '"' : ''}> <a class="corpus-analysis-action pagination-trigger" ${this.data.corpus.p.has_next ? 'data-target="' + this.data.corpus.p.next_num + '"' : ''}>
<i class="material-icons">chevron_right</i> <i class="material-icons">chevron_right</i>
</a> </a>
</li> </li>
`.trim(); `
this.elements.corpusPagination.innerHTML += ` );
this.elements.corpusPagination.appendChild(pageElement);
// Last page button. Disables last page button if on last page
pageElement = Utils.HTMLToElement(
`
<li class="${this.data.corpus.p.page === this.data.corpus.p.pages ? 'disabled' : 'waves-effect'}"> <li class="${this.data.corpus.p.page === this.data.corpus.p.pages ? 'disabled' : 'waves-effect'}">
<a class="corpus-analysis-action pagination-trigger" ${this.data.corpus.p.page === this.data.corpus.p.pages ? '' : 'data-target="' + this.data.corpus.p.pages + '"'}> <a class="corpus-analysis-action pagination-trigger" ${this.data.corpus.p.page === this.data.corpus.p.pages ? '' : 'data-target="' + this.data.corpus.p.pages + '"'}>
<i class="material-icons">last_page</i> <i class="material-icons">last_page</i>
</a> </a>
</li> </li>
`.trim(); `
);
this.elements.corpusPagination.appendChild(pageElement);
for (let paginateTriggerElement of this.elements.corpusPagination.querySelectorAll('.pagination-trigger[data-target]')) { for (let paginateTriggerElement of this.elements.corpusPagination.querySelectorAll('.pagination-trigger[data-target]')) {
paginateTriggerElement.addEventListener('click', event => { paginateTriggerElement.addEventListener('click', event => {
event.preventDefault(); event.preventDefault();
@ -182,6 +243,7 @@ class CorpusAnalysisReader {
return; return;
} }
this.app.disableActionElements(); this.app.disableActionElements();
window.scrollTo(top);
this.elements.progress.classList.remove('hide'); this.elements.progress.classList.remove('hide');
this.data.corpus.o.paginate(pageNum, this.settings.perPage) this.data.corpus.o.paginate(pageNum, this.settings.perPage)
.then( .then(

View File

@ -561,7 +561,6 @@ class ConcordanceQueryBuilder {
if (tokenIsEmpty === false) { if (tokenIsEmpty === false) {
tokenQueryText = '[' + tokenQueryText + ']'; tokenQueryText = '[' + tokenQueryText + ']';
} }
console.log(tokenQueryText);
this.queryChipFactory('token', tokenQueryContent, tokenQueryText); this.queryChipFactory('token', tokenQueryContent, tokenQueryText);
this.hideEverything(); this.hideEverything();
this.elements.positionalAttrArea.classList.add('hide'); this.elements.positionalAttrArea.classList.add('hide');

View File

@ -92,7 +92,6 @@ class Form {
} }
if (request.status === 400) { if (request.status === 400) {
let responseJson = JSON.parse(request.responseText); let responseJson = JSON.parse(request.responseText);
console.log(responseJson);
for (let [inputName, inputErrors] of Object.entries(responseJson.errors)) { for (let [inputName, inputErrors] of Object.entries(responseJson.errors)) {
let inputFieldElement = this.formElement let inputFieldElement = this.formElement
.querySelector(`input[name$="${inputName}"], select[name$="${inputName}"]`) .querySelector(`input[name$="${inputName}"], select[name$="${inputName}"]`)
@ -122,10 +121,11 @@ class Form {
request.setRequestHeader('Accept', 'application/json'); request.setRequestHeader('Accept', 'application/json');
let formData = new FormData(this.formElement); let formData = new FormData(this.formElement);
switch (this.formElement.enctype) { switch (this.formElement.enctype) {
case 'application/x-www-form-urlencoded': case 'application/x-www-form-urlencoded': {
let urlSearchParams = new URLSearchParams(formData); let urlSearchParams = new URLSearchParams(formData);
request.send(urlSearchParams); request.send(urlSearchParams);
break; break;
}
case 'multipart/form-data': { case 'multipart/form-data': {
request.send(formData); request.send(formData);
break; break;

View File

@ -0,0 +1,40 @@
let Requests = {};
Requests.JSONfetch = (input, init={}) => {
return new Promise((resolve, reject) => {
let fixedInit = {};
fixedInit.headers = {};
fixedInit.headers['Accept'] = 'application/json';
if (init.hasOwnProperty('body')) {
fixedInit.headers['Content-Type'] = 'application/json';
}
fetch(input, Utils.mergeObjectsDeep(init, fixedInit))
.then(
(response) => {
if (response.ok) {
resolve(response.clone());
} else {
reject(response);
}
if (response.status === 204) {
return;
}
response.json()
.then(
(json) => {
let message = json.message || json;
let category = json.category || 'message';
app.flash(message, category);
},
(error) => {
app.flash(`[${response.status}]: ${response.statusText}`, 'error');
}
);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
};

View File

@ -0,0 +1,20 @@
/*****************************************************************************
* Admin *
* Fetch requests for /admin routes *
*****************************************************************************/
Requests.admin = {};
Requests.admin.users = {};
Requests.admin.users.entity = {};
Requests.admin.users.entity.confirmed = {};
Requests.admin.users.entity.confirmed.update = (userId, value) => {
let input = `/admin/users/${userId}/confirmed`;
let init = {
method: 'PUT',
body: JSON.stringify(value)
};
return Requests.JSONfetch(input, init);
};

View File

@ -0,0 +1,5 @@
/*****************************************************************************
* Contributions *
* Fetch requests for /contributions routes *
*****************************************************************************/
Requests.contributions = {};

View File

@ -0,0 +1,26 @@
/*****************************************************************************
* SpaCy NLP Pipeline Models *
* Fetch requests for /contributions/spacy-nlp-pipeline-models routes *
*****************************************************************************/
Requests.contributions.spacy_nlp_pipeline_models = {};
Requests.contributions.spacy_nlp_pipeline_models.entity = {};
Requests.contributions.spacy_nlp_pipeline_models.entity.delete = (spacyNlpPipelineModelId) => {
let input = `/contributions/spacy-nlp-pipeline-models/${spacyNlpPipelineModelId}`;
let init = {
method: 'DELETE'
};
return Requests.JSONfetch(input, init);
};
Requests.contributions.spacy_nlp_pipeline_models.entity.isPublic = {};
Requests.contributions.spacy_nlp_pipeline_models.entity.isPublic.update = (spacyNlpPipelineModelId, value) => {
let input = `/contributions/spacy-nlp-pipeline-models/${spacyNlpPipelineModelId}/is_public`;
let init = {
method: 'PUT',
body: JSON.stringify(value)
};
return Requests.JSONfetch(input, init);
};

View File

@ -0,0 +1,26 @@
/*****************************************************************************
* Tesseract OCR Pipeline Models *
* Fetch requests for /contributions/tesseract-ocr-pipeline-models routes *
*****************************************************************************/
Requests.contributions.tesseract_ocr_pipeline_models = {};
Requests.contributions.tesseract_ocr_pipeline_models.entity = {};
Requests.contributions.tesseract_ocr_pipeline_models.entity.delete = (tesseractOcrPipelineModelId) => {
let input = `/contributions/tesseract-ocr-pipeline-models/${tesseractOcrPipelineModelId}`;
let init = {
method: 'DELETE'
};
return Requests.JSONfetch(input, init);
};
Requests.contributions.tesseract_ocr_pipeline_models.entity.isPublic = {};
Requests.contributions.tesseract_ocr_pipeline_models.entity.isPublic.update = (tesseractOcrPipelineModelId, value) => {
let input = `/contributions/tesseract-ocr-pipeline-models/${tesseractOcrPipelineModelId}/is_public`;
let init = {
method: 'PUT',
body: JSON.stringify(value)
};
return Requests.JSONfetch(input, init);
};

View File

@ -0,0 +1,46 @@
/*****************************************************************************
* Corpora *
* Fetch requests for /corpora routes *
*****************************************************************************/
Requests.corpora = {};
Requests.corpora.entity = {};
Requests.corpora.entity.delete = (corpusId) => {
let input = `/corpora/${corpusId}`;
let init = {
method: 'DELETE'
};
return Requests.JSONfetch(input, init);
};
Requests.corpora.entity.build = (corpusId) => {
let input = `/corpora/${corpusId}/build`;
let init = {
method: 'POST',
};
return Requests.JSONfetch(input, init);
};
Requests.corpora.entity.generateShareLink = (corpusId, role, expiration) => {
let input = `/corpora/${corpusId}/generate-share-link`;
let init = {
method: 'POST',
body: JSON.stringify({role: role, expiration: expiration})
};
return Requests.JSONfetch(input, init);
};
Requests.corpora.entity.isPublic = {};
Requests.corpora.entity.isPublic.update = (corpusId, isPublic) => {
let input = `/corpora/${corpusId}/is_public`;
let init = {
method: 'PUT',
body: JSON.stringify(isPublic)
};
return Requests.JSONfetch(input, init);
};

View File

@ -0,0 +1,15 @@
/*****************************************************************************
* Corpora *
* Fetch requests for /corpora/<entity>/files routes *
*****************************************************************************/
Requests.corpora.entity.files = {};
Requests.corpora.entity.files.ent = {};
Requests.corpora.entity.files.ent.delete = (corpusId, corpusFileId) => {
let input = `/corpora/${corpusId}/files/${corpusFileId}`;
let init = {
method: 'DELETE',
};
return Requests.JSONfetch(input, init);
};

View File

@ -0,0 +1,35 @@
/*****************************************************************************
* Corpora *
* Fetch requests for /corpora/<entity>/followers routes *
*****************************************************************************/
Requests.corpora.entity.followers = {};
Requests.corpora.entity.followers.add = (corpusId, usernames) => {
let input = `/corpora/${corpusId}/followers`;
let init = {
method: 'POST',
body: JSON.stringify(usernames)
};
return Requests.JSONfetch(input, init);
};
Requests.corpora.entity.followers.entity = {};
Requests.corpora.entity.followers.entity.delete = (corpusId, followerId) => {
let input = `/corpora/${corpusId}/followers/${followerId}`;
let init = {
method: 'DELETE',
};
return Requests.JSONfetch(input, init);
};
Requests.corpora.entity.followers.entity.role = {};
Requests.corpora.entity.followers.entity.role.update = (corpusId, followerId, value) => {
let input = `/corpora/${corpusId}/followers/${followerId}/role`;
let init = {
method: 'PUT',
body: JSON.stringify(value)
};
return Requests.JSONfetch(input, init);
};

View File

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

View File

@ -0,0 +1,17 @@
/*****************************************************************************
* Settings *
* Fetch requests for /users/<entity>/settings routes *
*****************************************************************************/
Requests.users.entity.settings = {};
Requests.users.entity.settings.profilePrivacy = {};
Requests.users.entity.settings.profilePrivacy.update = (userId, profilePrivacySetting, enabled) => {
let input = `/users/${userId}/settings/profile-privacy/${profilePrivacySetting}`;
let init = {
method: 'PUT',
body: JSON.stringify(enabled)
};
return Requests.JSONfetch(input, init);
};

View File

@ -0,0 +1,35 @@
/*****************************************************************************
* Users *
* Fetch requests for /users routes *
*****************************************************************************/
Requests.users = {};
Requests.users.entity = {};
Requests.users.entity.delete = (userId) => {
let input = `/users/${userId}`;
let init = {
method: 'DELETE'
};
return Requests.JSONfetch(input, init);
};
Requests.users.entity.acceptTermsOfUse = () => {
let input = `/users/accept-terms-of-use`;
let init = {
method: 'POST'
};
return Requests.JSONfetch(input, init);
};
Requests.users.entity.avatar = {};
Requests.users.entity.avatar.delete = (userId) => {
let input = `/users/${userId}/avatar`;
let init = {
method: 'DELETE'
};
return Requests.JSONfetch(input, init);
}

View File

@ -1,26 +1,11 @@
class CorpusDisplay extends RessourceDisplay { class CorpusDisplay extends ResourceDisplay {
constructor(displayElement) { constructor(displayElement) {
super(displayElement); super(displayElement);
this.corpusId = displayElement.dataset.corpusId; this.corpusId = displayElement.dataset.corpusId;
this.displayElement this.displayElement
.querySelector('.action-button[data-action="build-request"]') .querySelector('.action-button[data-action="build-request"]')
.addEventListener('click', (event) => { .addEventListener('click', (event) => {
Utils.buildCorpusRequest(this.userId, this.corpusId); Requests.corpora.entity.build(this.corpusId);
});
this.displayElement
.querySelector('.action-button[data-action="delete-request"]')
.addEventListener('click', (event) => {
Utils.deleteCorpusRequest(this.userId, this.corpusId);
});
this.displayElement
.querySelector('.action-switch[data-action="toggle-is-public"]')
.addEventListener('click', (event) => {
if (event.target.tagName !== 'INPUT') {return;}
if (event.target.checked) {
Utils.enableCorpusIsPublicRequest(this.userId, this.corpusId);
} else {
Utils.disableCorpusIsPublicRequest(this.userId, this.corpusId);
}
}); });
} }
@ -81,7 +66,7 @@ class CorpusDisplay extends RessourceDisplay {
} }
setStatus(status) { setStatus(status) {
let elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger') let elements = this.displayElement.querySelectorAll('.action-button[data-action="analyze"]');
for (let element of elements) { for (let 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');

View File

@ -1,22 +1,7 @@
class JobDisplay extends RessourceDisplay { class JobDisplay extends ResourceDisplay {
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) {

View File

@ -1,4 +1,4 @@
class RessourceDisplay { class ResourceDisplay {
constructor(displayElement) { constructor(displayElement) {
this.displayElement = displayElement; this.displayElement = displayElement;
this.userId = this.displayElement.dataset.userId; this.userId = this.displayElement.dataset.userId;

View File

@ -8,9 +8,15 @@ class CorpusFileList extends ResourceList {
constructor(listContainerElement, options = {}) { constructor(listContainerElement, options = {}) {
super(listContainerElement, options); super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
document.querySelectorAll('.selection-action-trigger[data-selection-action]').forEach((element) => {
element.addEventListener('click', (event) => {this.onSelectionAction(event)});
});
this.isInitialized = false; this.isInitialized = false;
this.selectedItemIds = new Set();
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
this.corpusId = listContainerElement.dataset.corpusId; this.corpusId = listContainerElement.dataset.corpusId;
this.hasPermissionView = listContainerElement.dataset?.hasPermissionView == 'true' || false;
this.hasPermissionManageFiles = listContainerElement.dataset?.hasPermissionManageFiles == 'true' || false;
if (this.userId === undefined || this.corpusId === undefined) {return;} if (this.userId === undefined || this.corpusId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => { app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => { app.socket.on('PATCH', (patch) => {
@ -24,20 +30,28 @@ class CorpusFileList extends ResourceList {
} }
get item() { get item() {
return (values) => {
return ` return `
<tr class="list-item clickable hoverable"> <tr class="list-item clickable hoverable">
<td>
<label class="list-action-trigger ${this.hasPermissionView ? '' : 'hide'}" data-list-action="select">
<input class="select-checkbox" type="checkbox">
<span class="disable-on-click"></span>
</label>
</td>
<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="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete"><i class="material-icons">delete</i></a> <a class="list-action-trigger btn-floating red waves-effect waves-light ${this.hasPermissionManageFiles ? '' : 'hide'}" data-list-action="delete"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="download" data-service="corpus-analysis"><i class="material-icons">file_download</i></a> <a class="list-action-trigger btn-floating service-color darken waves-effect waves-light ${this.hasPermissionView ? '' : 'hide'}" data-list-action="download" data-service="corpus-analysis"><i class="material-icons">file_download</i></a>
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a> <a class="list-action-trigger btn-floating service-color darken waves-effect waves-light ${this.hasPermissionManageFiles ? '' : 'hide'}" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(); `.trim();
} }
}
get valueNames() { get valueNames() {
return [ return [
@ -64,11 +78,20 @@ class CorpusFileList extends ResourceList {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>
<label class="disable-on-click selection-action-trigger ${this.listContainerElement.dataset?.hasPermissionView == 'true' ? '' : 'hide'}" data-selection-action="select-all">
<input class="select-all-checkbox" type="checkbox">
<span class="disable-on-click"></span>
</label>
</th>
<th>Filename</th> <th>Filename</th>
<th>Author</th> <th>Author</th>
<th>Title</th> <th>Title</th>
<th>Publishing year</th> <th>Publishing year</th>
<th></th> <th class="right-align">
<a class="selection-action-trigger btn-floating red waves-effect waves-light hide" data-selection-action="delete"><i class="material-icons">delete</i></a>
<a class="selection-action-trigger btn-floating service-color darken waves-effect waves-light hide" data-selection-action="download" data-service="corpus-analysis"><i class="material-icons">file_download</i></a>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="list"></tbody> <tbody class="list"></tbody>
@ -93,6 +116,7 @@ class CorpusFileList extends ResourceList {
} }
onClick(event) { onClick(event) {
if (event.target.closest('.disable-on-click') !== null) {return;}
let listItemElement = event.target.closest('.list-item[data-id]'); let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;} if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id; let itemId = listItemElement.dataset.id;
@ -100,7 +124,44 @@ class CorpusFileList extends ResourceList {
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction; let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) { switch (listAction) {
case 'delete': { case 'delete': {
Utils.deleteCorpusFileRequest(this.userId, this.corpusId, itemId); let values = this.listjs.get('id', itemId)[0].values();
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus File deletion</h4>
<p>Do you really want to delete the Corpus File <b>${values.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">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) => {
if (currentUserId != this.userId) {
Requests.corpora.entity.files.ent.delete(this.corpusId, itemId)
.then(() => {
window.location.reload();
});
} else {
Requests.corpora.entity.files.ent.delete(this.corpusId, itemId)
}
});
modal.open();
break; break;
} }
case 'download': { case 'download': {
@ -111,12 +172,171 @@ class CorpusFileList extends ResourceList {
window.location.href = `/corpora/${this.corpusId}/files/${itemId}`; window.location.href = `/corpora/${this.corpusId}/files/${itemId}`;
break; break;
} }
case 'select': {
if (event.target.checked) {
this.selectedItemIds.add(itemId);
} else {
this.selectedItemIds.delete(itemId);
}
this.renderingItemSelection();
break;
}
default: { default: {
break; break;
} }
} }
} }
onSelectionAction(event) {
let selectionActionElement = event.target.closest('.selection-action-trigger[data-selection-action]');
let selectionAction = selectionActionElement.dataset.selectionAction;
let items = this.listjs.items;
let selectableItems = Array.from(items)
.filter(item => item.elm)
.map(item => item.elm.querySelector('.select-checkbox[type="checkbox"]'));
switch (selectionAction) {
case 'select-all': {
let selectedIds = new Set(Array.from(items)
.map(item => item.values().id))
if (event.target.checked !== undefined) {
if (event.target.checked) {
selectableItems.forEach(selectableItem => selectableItem.checked = true);
this.selectedItemIds = selectedIds;
} else {
selectableItems.forEach(checkbox => checkbox.checked = false);
this.selectedItemIds = new Set([...this.selectedItemIds].filter(id => !selectedIds.has(id)));
}
this.renderingItemSelection();
}
break;
}
case 'delete': {
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus File deletion</h4>
<p>Do you really want to delete the Corpus Files?</p>
<ul id="selected-items-list"></ul>
<p>All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">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 itemList = document.querySelector('#selected-items-list');
this.selectedItemIds.forEach(selectedItemId => {
let listItem = this.listjs.get('id', selectedItemId)[0].elm;
let values = this.listjs.get('id', listItem.dataset.id)[0].values();
let itemElement = Utils.HTMLToElement(`<li> - ${values.title}</li>`);
itemList.appendChild(itemElement);
});
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) => {
this.selectedItemIds.forEach(selectedItemId => {
if (currentUserId != this.userId) {
Requests.corpora.entity.files.ent.delete(this.corpusId, selectedItemId)
.then(() => {
window.location.reload();
});
} else {
Requests.corpora.entity.files.ent.delete(this.corpusId, selectedItemId);
}
});
this.selectedItemIds.clear();
this.renderingItemSelection();
});
modal.open();
break;
}
case 'download': {
this.selectedItemIds.forEach(selectedItemId => {
let downloadLink = document.createElement('a');
downloadLink.href = `/corpora/${this.corpusId}/files/${selectedItemId}/download`;
downloadLink.download = '';
downloadLink.click();
});
selectableItems.forEach(checkbox => checkbox.checked = false);
this.selectedItemIds.clear();
this.renderingItemSelection();
break;
}
default: {
break;
}
}
}
renderingItemSelection() {
let selectionActionButtons;
if (this.hasPermissionManageFiles) {
selectionActionButtons = document.querySelectorAll('.selection-action-trigger:not([data-selection-action="select-all"])');
} else if (this.hasPermissionView) {
selectionActionButtons = document.querySelectorAll('.selection-action-trigger:not([data-selection-action="select-all"]):not([data-selection-action="delete"])');
}
let selectableItems = this.listjs.items;
let actionButtons = [];
Object.values(selectableItems).forEach(selectableItem => {
if (selectableItem.elm) {
let checkbox = selectableItem.elm.querySelector('.select-checkbox[type="checkbox"]');
if (checkbox.checked) {
selectableItem.elm.classList.add('grey', 'lighten-3');
} else {
selectableItem.elm.classList.remove('grey', 'lighten-3');
}
let itemActionButtons = [];
if (this.hasPermissionManageFiles) {
itemActionButtons = selectableItem.elm.querySelectorAll('.list-action-trigger:not([data-list-action="select"])');
} else if (this.hasPermissionView) {
itemActionButtons = selectableItem.elm.querySelectorAll('.list-action-trigger:not([data-list-action="select"]):not([data-list-action="delete"]):not([data-list-action="view"])');
}
itemActionButtons.forEach(itemActionButton => {
actionButtons.push(itemActionButton);
});
}
});
// Hide item action buttons if > 0 item is selected and show selection action buttons
if (this.selectedItemIds.size > 0) {
selectionActionButtons.forEach(selectionActionButton => {
selectionActionButton.classList.remove('hide');
});
actionButtons.forEach(actionButton => {
actionButton.classList.add('hide');
});
} else {
selectionActionButtons.forEach(selectionActionButton => {
selectionActionButton.classList.add('hide');
});
actionButtons.forEach(actionButton => {
actionButton.classList.remove('hide');
});
}
// Check select all checkbox if all items are selected
let selectAllCheckbox = document.querySelector('.select-all-checkbox[type="checkbox"]');
if (selectableItems.length === this.selectedItemIds.size && selectAllCheckbox.checked === false) {
selectAllCheckbox.checked = true;
} else if (selectableItems.length !== this.selectedItemIds.size && selectAllCheckbox.checked === true) {
selectAllCheckbox.checked = false;
}
}
onPatch(patch) { onPatch(patch) {
let 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]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path)); let filteredPatch = patch.filter(operation => re.test(operation.path));

View File

@ -0,0 +1,199 @@
class CorpusFollowerList extends ResourceList {
static autoInit() {
for (let corpusFollowerListElement of document.querySelectorAll('.corpus-follower-list:not(.no-autoinit)')) {
new CorpusFollowerList(corpusFollowerListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.on('updated', () => {
M.FormSelect.init(this.listjs.list.querySelectorAll('.list-item select'));
});
this.listjs.list.addEventListener('change', (event) => {this.onChange(event)});
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
this.corpusId = listContainerElement.dataset.corpusId;
if (this.userId === undefined || this.corpusId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => {
let corpusFollowerAssociations = Object.values(user.corpora[this.corpusId].corpus_follower_associations);
// let filteredList = corpusFollowerAssociations.filter(association => association.follower.id != currentUserId);
// this.add(filteredList);
this.add(Object.values(user.corpora[this.corpusId].corpus_follower_associations));
this.isInitialized = true;
});
}
get item() {
return (values) => {
return `
<tr class="list-item clickable hoverable">
<td><img alt="follower-avatar" class="circle responsive-img follower-avatar" style="width:50%"></td>
<td><b class="follower-username"><b></td>
<td>
<span class="follower-full-name"></span>
<br>
<i class="follower-about-me"></i>
</td>
<td>
<div class="input-field disable-on-click list-action-trigger" data-list-action="update-role">
<select ${values['follower-id'] === currentUserId ? 'disabled' : ''}>
<option value="Viewer" ${values['role-name'] === 'Viewer' ? 'selected' : ''}>Viewer</option>
<option value="Contributor" ${values['role-name'] === 'Contributor' ? 'selected' : ''}>Contributor</option>
<option value="Administrator" ${values['role-name'] === 'Administrator' ? 'selected' : ''}>Administrator</option>
</select>
</div>
</td>
<td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="unfollow-request"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating darken waves-effect waves-light" data-list-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
}
}
get valueNames() {
return [
{data: ['id']},
{data: ['follower-id']},
{name: 'follower-avatar', attr: 'src'},
'follower-username',
'follower-about-me',
'follower-full-name'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('corpus-follower-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search corpus follower</label>
</div>
<table>
<thead>
<tr>
<th style="width:15%;"></th>
<th>Username</th>
<th>User details</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(corpusFollowerAssociation) {
return {
'id': corpusFollowerAssociation.id,
'follower-id': corpusFollowerAssociation.follower.id,
'follower-avatar': corpusFollowerAssociation.follower.avatar ? `/users/${corpusFollowerAssociation.follower.id}/avatar` : '/static/images/user_avatar.png',
'follower-username': corpusFollowerAssociation.follower.username,
'follower-full-name': corpusFollowerAssociation.follower.full_name ? corpusFollowerAssociation.follower.full_name : '',
'follower-about-me': corpusFollowerAssociation.follower.about_me ? corpusFollowerAssociation.follower.about_me : '',
'role-name': corpusFollowerAssociation.role.name
};
}
sort() {
this.listjs.sort('username', {order: 'desc'});
}
onChange(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
if (listActionElement === null) {return;}
let listAction = listActionElement.dataset.listAction;
switch (listAction) {
case 'update-role': {
let followerId = listItemElement.dataset.followerId;
let roleName = event.target.value;
Requests.corpora.entity.followers.entity.role.update(this.corpusId, followerId, roleName);
break;
}
default: {
break;
}
}
}
onClick(event) {
if (event.target.closest('.disable-on-click') !== null) {return;}
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) {
case 'unfollow-request': {
let followerId = listItemElement.dataset.followerId;
if (currentUserId != this.userId) {
Requests.corpora.entity.followers.entity.delete(this.corpusId, followerId)
.then(() => {
window.location.reload();
});
} else {
Requests.corpora.entity.followers.entity.delete(this.corpusId, followerId);
}
break;
}
case 'view': {
let followerId = listItemElement.dataset.followerId;
window.location.href = `/users/${followerId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, jobId] = operation.path.match(re);
this.remove(jobId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)/role$`);
if (re.test(operation.path)) {
let [match, jobId, valueName] = operation.path.match(re);
this.replace(jobId, valueName, operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -8,7 +8,11 @@ class CorpusList extends ResourceList {
constructor(listContainerElement, options = {}) { constructor(listContainerElement, options = {}) {
super(listContainerElement, options); super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
document.querySelectorAll('.corpus-list-selection-action-trigger[data-selection-action]').forEach((element) => {
element.addEventListener('click', (event) => {this.onSelectionAction(event)});
});
this.isInitialized = false this.isInitialized = false
this.selectedItemIds = new Set();
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;} if (this.userId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => { app.subscribeUser(this.userId).then((response) => {
@ -17,24 +21,66 @@ class CorpusList extends ResourceList {
}); });
}); });
app.getUser(this.userId).then((user) => { app.getUser(this.userId).then((user) => {
this.add(Object.values(user.corpora)); this.add(this.aggregateData(user));
this.isInitialized = true; this.isInitialized = true;
}); });
} }
aggregateData(user) {
const aggregatedData = [];
for (let corpus of Object.values(user.corpora)) {
aggregatedData.push(
{
'id': corpus.id,
'creation-date': corpus.creation_date,
'description': corpus.description,
'status': corpus.status,
'title': corpus.title,
'owner': user.username,
'is-owner': true,
'current-user-is-following': false
}
);
}
for (let cfa of Object.values(user.corpus_follower_associations)) {
aggregatedData.push(
{
'id': cfa.corpus.id,
'creation-date': cfa.corpus.creation_date,
'description': cfa.corpus.description,
'status': cfa.corpus.status,
'title': cfa.corpus.title,
'owner': cfa.corpus.user.username,
'is-owner': false,
'current-user-is-following': true
}
);
}
return aggregatedData;
}
// #region Mandatory getters and methods to implement // #region Mandatory getters and methods to implement
get item() { get item() {
return (values) => {
return ` return `
<tr class="list-item clickable hoverable"> <tr class="list-item clickable hoverable">
<td><a class="btn-floating disabled"><i class="material-icons service-color darken" data-service="corpus-analysis">book</i></a></td> <td>
<label class="list-action-trigger" data-list-action="select">
<input class="select-checkbox" type="checkbox">
<span class="disable-on-click"></span>
</label>
</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="owner"></span></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>${values['current-user-is-following'] ? '<span><i class="left material-icons">visibility</i>Following</span>' : ''}</td>
<td class="right-align"> <td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a> <a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a> <a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(); `.trim();
};
} }
get valueNames() { get valueNames() {
@ -43,7 +89,9 @@ class CorpusList extends ResourceList {
{data: ['creation-date']}, {data: ['creation-date']},
{name: 'status', attr: 'data-status'}, {name: 'status', attr: 'data-status'},
'description', 'description',
'title' 'title',
'owner',
'current-user-is-following'
]; ];
} }
@ -56,15 +104,24 @@ class CorpusList extends ResourceList {
<div class="input-field"> <div class="input-field">
<i class="material-icons prefix">search</i> <i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input> <input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search corpus</label> <label for="${listSearchElementId}">Search Corpus</label>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th></th> <th>
<label class="corpus-list-selection-action-trigger" data-selection-action="select-all">
<input class="corpus-list-select-all-checkbox" type="checkbox">
<span></span>
</label>
</th>
<th>Title and Description</th> <th>Title and Description</th>
<th>Owner</th>
<th>Status</th> <th>Status</th>
<th></th> <th></th>
<th class="right-align">
<a class="corpus-list-selection-action-trigger btn-floating red waves-effect waves-light hide" data-selection-action="delete"><i class="material-icons">delete</i></a>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="list"></tbody> <tbody class="list"></tbody>
@ -73,16 +130,6 @@ class CorpusList extends ResourceList {
`.trim(); `.trim();
} }
mapResourceToValue(corpus) {
return {
'id': corpus.id,
'creation-date': corpus.creation_date,
'description': corpus.description,
'status': corpus.status,
'title': corpus.title
};
}
sort() { sort() {
this.listjs.sort('creation-date', {order: 'desc'}); this.listjs.sort('creation-date', {order: 'desc'});
} }
@ -95,19 +142,202 @@ class CorpusList extends ResourceList {
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction; let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) { switch (listAction) {
case 'delete-request': { case 'delete-request': {
Utils.deleteCorpusRequest(this.userId, itemId); let values = this.listjs.get('id', itemId)[0].values();
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus deletion</h4>
<p>Do you really want to ${values['is-owner'] ? 'delete' : 'unfollow'} the Corpus <b>${values.title}</b>? ${values['is-owner'] ? 'All files will be permanently deleted!' : ''}</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">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) => {
if (!values['is-owner']) {
Requests.corpora.entity.followers.entity.delete(itemId, currentUserId)
.then((response) => {
window.location.reload();
});
} else {
Requests.corpora.entity.delete(itemId);
}
});
modal.open();
break; break;
} }
case 'view': { case 'view': {
window.location.href = `/corpora/${itemId}`; window.location.href = `/corpora/${itemId}`;
break; break;
} }
case 'select': {
if (event.target.checked) {
this.selectedItemIds.add(itemId);
} else {
this.selectedItemIds.delete(itemId);
}
this.renderingItemSelection();
}
default: { default: {
break; break;
} }
} }
} }
onSelectionAction(event) {
let selectionActionElement = event.target.closest('.corpus-list-selection-action-trigger[data-selection-action]');
let selectionAction = selectionActionElement.dataset.selectionAction;
let items = Array.from(this.listjs.items);
let selectableItems = Array.from(items)
.filter(item => item.elm)
.map(item => item.elm.querySelector('.select-checkbox[type="checkbox"]'));
switch (selectionAction) {
case 'select-all': {
let selectedIds = new Set(Array.from(items)
.map(item => item.values().id))
if (event.target.checked !== undefined) {
if (event.target.checked) {
selectableItems.forEach(selectableItem => selectableItem.checked = true);
this.selectedItemIds = selectedIds;
} else {
selectableItems.forEach(checkbox => checkbox.checked = false);
this.selectedItemIds = new Set([...this.selectedItemIds].filter(id => !selectedIds.has(id)));
}
this.renderingItemSelection();
}
break;
}
case 'delete': {
// Saved for future use:
// <p class="hide">Do you really want to unfollow this Corpora?</p>
// <ul id="selected-unfollow-items-list"></ul>
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus deletion</h4>
<p>Do you really want to delete this Corpora? <i>All corpora will be permanently deleted!</i></p>
<ul id="selected-deletion-items-list"></ul>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">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 itemDeletionList = document.querySelector('#selected-deletion-items-list');
// let itemUnfollowList = document.querySelector('#selected-unfollow-items-list');
this.selectedItemIds.forEach(selectedItemId => {
let listItem = this.listjs.get('id', selectedItemId)[0].elm;
let values = this.listjs.get('id', listItem.dataset.id)[0].values();
let itemElement = Utils.HTMLToElement(`<li> - ${values.title}</li>`);
// if (!values['is-owner']) {
// itemUnfollowList.appendChild(itemElement);
// } else {
itemDeletionList.appendChild(itemElement);
// }
});
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) => {
this.selectedItemIds.forEach(selectedItemId => {
let listItem = this.listjs.get('id', selectedItemId)[0].elm;
let values = this.listjs.get('id', listItem.dataset.id)[0].values();
if (values['is-owner']) {
Requests.corpora.entity.delete(selectedItemId);
} else {
Requests.corpora.entity.followers.entity.delete(selectedItemId, currentUserId);
setTimeout(() => {
window.location.reload();
}, 1000);
}
});
this.selectedItemIds.clear();
this.renderingItemSelection();
});
modal.open();
break;
}
default: {
break;
}
}
}
renderingItemSelection() {
let selectionActionButtons = document.querySelectorAll('.corpus-list-selection-action-trigger:not([data-selection-action="select-all"])');
let selectableItems = this.listjs.items;
let actionButtons = [];
Object.values(selectableItems).forEach(selectableItem => {
if (selectableItem.elm) {
let checkbox = selectableItem.elm.querySelector('.select-checkbox[type="checkbox"]');
if (checkbox.checked) {
selectableItem.elm.classList.add('grey', 'lighten-3');
} else {
selectableItem.elm.classList.remove('grey', 'lighten-3');
}
let itemActionButtons = selectableItem.elm.querySelectorAll('.list-action-trigger:not([data-list-action="select"])');
itemActionButtons.forEach(itemActionButton => {
actionButtons.push(itemActionButton);
});
}
});
// Hide item action buttons if > 0 item is selected and show selection action buttons
if (this.selectedItemIds.size > 0) {
selectionActionButtons.forEach(selectionActionButton => {
selectionActionButton.classList.remove('hide');
});
actionButtons.forEach(actionButton => {
actionButton.classList.add('hide');
});
} else {
selectionActionButtons.forEach(selectionActionButton => {
selectionActionButton.classList.add('hide');
});
actionButtons.forEach(actionButton => {
actionButton.classList.remove('hide');
});
}
// Check select all checkbox if all items are selected
let selectAllCheckbox = document.querySelector('.corpus-list-select-all-checkbox[type="checkbox"]');
if (selectableItems.length === this.selectedItemIds.size && selectAllCheckbox.checked === false) {
selectAllCheckbox.checked = true;
} else if (selectableItems.length !== this.selectedItemIds.size && selectAllCheckbox.checked === true) {
selectAllCheckbox.checked = false;
}
}
onPatch(patch) { onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`); let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path)); let filteredPatch = patch.filter(operation => re.test(operation.path));

View File

@ -0,0 +1,71 @@
class DetailledPublicCorpusList extends CorpusList {
get item() {
return (values) => {
return `
<tr class="list-item clickable hoverable">
<td></td>
<td><b class="title"></b><br><i class="description"></i></td>
<td><span class="owner"></span></td>
<td><span class="status badge new corpus-status-color corpus-status-text" data-badge-caption=""></span></td>
<td>${values['current-user-is-following'] ? '<span><i class="left material-icons">visibility</i>Following</span>' : ''}</td>
<td class="right-align">
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
};
}
get valueNames() {
return [
{data: ['id']},
{data: ['creation-date']},
{name: 'status', attr: 'data-status'},
'description',
'title',
'owner',
'current-user-is-following'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('corpus-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search Corpus</label>
</div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Owner</th>
<th>Status</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(corpus) {
return {
'id': corpus.id,
'creation-date': corpus.creation_date,
'description': corpus.description,
'status': corpus.status,
'title': corpus.title,
'owner': corpus.user.username,
'is-owner': corpus.user.id === this.userId,
'current-user-is-following': Object.values(corpus.corpus_follower_associations).some(association => association.follower.id === currentUserId)
};
}
}

View File

@ -12,11 +12,7 @@ class JobInputList extends ResourceList {
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
this.jobId = listContainerElement.dataset.jobId; this.jobId = listContainerElement.dataset.jobId;
if (this.userId === undefined || this.jobId === undefined) {return;} if (this.userId === undefined || this.jobId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => { app.subscribeUser(this.userId);
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => { app.getUser(this.userId).then((user) => {
this.add(Object.values(user.jobs[this.jobId].inputs)); this.add(Object.values(user.jobs[this.jobId].inputs));
this.isInitialized = true; this.isInitialized = true;

View File

@ -7,8 +7,13 @@ class JobList extends ResourceList {
constructor(listContainerElement, options = {}) { constructor(listContainerElement, options = {}) {
super(listContainerElement, options); super(listContainerElement, options);
this.documentJobArea = document.querySelector('#jobs');
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
document.querySelectorAll('.job-list-selection-action-trigger[data-selection-action]').forEach((element) => {
element.addEventListener('click', (event) => {this.onSelectionAction(event)});
});
this.isInitialized = false; this.isInitialized = false;
this.selectedItemIds = new Set();
this.userId = listContainerElement.dataset.userId; this.userId = listContainerElement.dataset.userId;
if (this.userId === undefined) {return;} if (this.userId === undefined) {return;}
app.subscribeUser(this.userId).then((response) => { app.subscribeUser(this.userId).then((response) => {
@ -24,7 +29,13 @@ class JobList extends ResourceList {
get item() { get item() {
return ` return `
<tr class="list-item clickable hoverable service-scheme"> <tr class="list-item service-scheme">
<td>
<label class="list-action-trigger" data-list-action="select">
<input class="select-checkbox" type="checkbox">
<span class="disable-on-click"></span>
</label>
</td>
<td><a class="btn-floating"><i class="nopaque-icons service-icons" data-service="inherit"></i></a></td> <td><a class="btn-floating"><i class="nopaque-icons service-icons" data-service="inherit"></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="badge new job-status-color job-status-text status" data-badge-caption=""></span></td> <td><span class="badge new job-status-color job-status-text status" data-badge-caption=""></span></td>
@ -56,15 +67,23 @@ class JobList extends ResourceList {
<div class="input-field"> <div class="input-field">
<i class="material-icons prefix">search</i> <i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input> <input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search job</label> <label for="${listSearchElementId}">Search Job</label>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>
<label class="job-list-selection-action-trigger" data-selection-action="select-all">
<input class="job-list-select-all-checkbox" type="checkbox">
<span class="disable-on-click"></span>
</label>
</th>
<th>Service</th> <th>Service</th>
<th>Title and Description</th> <th>Title and Description</th>
<th>Status</th> <th>Status</th>
<th></th> <th class="right-align">
<a class="job-list-selection-action-trigger btn-floating red waves-effect waves-light hide" data-selection-action="delete"><i class="material-icons">delete</i></a>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="list"></tbody> <tbody class="list"></tbody>
@ -93,22 +112,185 @@ class JobList extends ResourceList {
if (listItemElement === null) {return;} if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id; let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]'); let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction; let listAction = listActionElement === null ? '' : listActionElement.dataset.listAction;
switch (listAction) { switch (listAction) {
case 'delete-request': { case 'delete-request': {
Utils.deleteJobRequest(this.userId, itemId); let values = this.listjs.get('id', itemId)[0].values();
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Job deletion</h4>
<p>Do you really want to delete the Job <b>${values.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">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) => {
Requests.jobs.entity.delete(itemId);
});
modal.open();
break; break;
} }
case 'view': { case 'view': {
window.location.href = `/jobs/${itemId}`; window.location.href = `/jobs/${itemId}`;
break; break;
} }
case 'select': {
if (event.target.checked) {
this.selectedItemIds.add(itemId);
} else {
this.selectedItemIds.delete(itemId);
}
this.renderingItemSelection();
break;
}
default: { default: {
break; break;
} }
} }
} }
onSelectionAction(event) {
let selectionActionElement = event.target.closest('.job-list-selection-action-trigger[data-selection-action]');
let selectionAction = selectionActionElement.dataset.selectionAction;
let items = this.listjs.items;
let selectableItems = Array.from(items)
.filter(item => item.elm)
.map(item => item.elm.querySelector('.select-checkbox[type="checkbox"]'));
switch (selectionAction) {
case 'select-all': {
let selectedIds = new Set(Array.from(items)
.map(item => item.values().id))
if (event.target.checked !== undefined) {
if (event.target.checked) {
selectableItems.forEach(selectableItem => selectableItem.checked = true);
this.selectedItemIds = selectedIds;
} else {
selectableItems.forEach(checkbox => checkbox.checked = false);
this.selectedItemIds = new Set([...this.selectedItemIds].filter(id => !selectedIds.has(id)));
}
this.renderingItemSelection();
}
break;
}
case 'delete': {
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus File deletion</h4>
<p>Do you really want to delete the Jobs?</p>
<ul id="selected-items-list"></ul>
<p>All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">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 itemList = document.querySelector('#selected-items-list');
this.selectedItemIds.forEach(selectedItemId => {
let listItem = this.listjs.get('id', selectedItemId)[0].elm;
let values = this.listjs.get('id', listItem.dataset.id)[0].values();
let itemElement = Utils.HTMLToElement(`<li> - ${values.title}</li>`);
itemList.appendChild(itemElement);
});
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) => {
this.selectedItemIds.forEach(selectedItemId => {
Requests.jobs.entity.delete(selectedItemId);
});
this.selectedItemIds.clear();
this.renderingItemSelection();
});
modal.open();
break;
}
default: {
break;
}
}
}
renderingItemSelection() {
let selectionActionButtons = document.querySelectorAll('.job-list-selection-action-trigger:not([data-selection-action="select-all"])');
let selectableItems = this.listjs.items;
let actionButtons = [];
Object.values(selectableItems).forEach(selectableItem => {
if (selectableItem.elm) {
let checkbox = selectableItem.elm.querySelector('.select-checkbox[type="checkbox"]');
if (checkbox.checked) {
selectableItem.elm.classList.add('grey', 'lighten-3');
} else {
selectableItem.elm.classList.remove('grey', 'lighten-3');
}
let itemActionButtons = selectableItem.elm.querySelectorAll('.list-action-trigger:not([data-list-action="select"])');
itemActionButtons.forEach(itemActionButton => {
actionButtons.push(itemActionButton);
});
}
});
// Hide item action buttons if > 0 item is selected and show selection action buttons
if (this.selectedItemIds.size > 0) {
selectionActionButtons.forEach(selectionActionButton => {
selectionActionButton.classList.remove('hide');
});
actionButtons.forEach(actionButton => {
actionButton.classList.add('hide');
});
} else {
selectionActionButtons.forEach(selectionActionButton => {
selectionActionButton.classList.add('hide');
});
actionButtons.forEach(actionButton => {
actionButton.classList.remove('hide');
});
}
// Check select all checkbox if all items are selected
let selectAllCheckbox = document.querySelector('.job-list-select-all-checkbox[type="checkbox"]');
if (selectableItems.length === this.selectedItemIds.size && selectAllCheckbox.checked === false) {
selectAllCheckbox.checked = true;
} else if (selectableItems.length !== this.selectedItemIds.size && selectAllCheckbox.checked === true) {
selectAllCheckbox.checked = false;
}
}
onPatch(patch) { onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`); let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path)); let filteredPatch = patch.filter(operation => re.test(operation.path));

View File

@ -1,15 +0,0 @@
class PublicCorpusFileList extends CorpusFileList {
get item() {
return `
<tr class="list-item clickable hoverable">
<td><span class="filename"></span></td>
<td><span class="author"></span></td>
<td><span class="title"></span></td>
<td><span class="publishing-year"></span></td>
<td class="right-align">
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
}
}

View File

@ -1,14 +1,55 @@
class PublicCorpusList extends CorpusList { class PublicCorpusList extends CorpusList {
get item() { get item() {
return (values) => {
return ` return `
<tr class="list-item clickable hoverable"> <tr class="list-item clickable hoverable">
<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="owner"></span></td>
<td>${values['current-user-is-following'] ? '<span><i class="left material-icons">visibility</i></span>' : ''}</td>
<td class="right-align"> <td class="right-align">
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a> <a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(); `.trim();
};
}
mapResourceToValue(corpus) {
return {
'id': corpus.id,
'creation-date': corpus.creation_date,
'description': corpus.description,
'status': corpus.status,
'title': corpus.title,
'owner': corpus.user.username,
'is-owner': corpus.user.id === this.userId ? true : false,
'current-user-is-following': Object.values(corpus.corpus_follower_associations).some(association => association.follower.id === currentUserId)
};
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('corpus-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search Corpus</label>
</div>
<table>
<thead>
<tr>
<th>Title and Description</th>
<th>Owner</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
} }
} }

View File

@ -14,6 +14,7 @@ class ResourceList {
TesseractOCRPipelineModelList.autoInit(); TesseractOCRPipelineModelList.autoInit();
UserList.autoInit(); UserList.autoInit();
AdminUserList.autoInit(); AdminUserList.autoInit();
CorpusFollowerList.autoInit();
} }
static defaultOptions = { static defaultOptions = {
@ -42,7 +43,8 @@ class ResourceList {
} }
add(resources, callback) { add(resources, callback) {
let values = resources.map((resource) => { let tmp = Array.isArray(resources) ? resources : [resources];
let values = tmp.map((resource) => {
return this.mapResourceToValue(resource); return this.mapResourceToValue(resource);
}); });
this.listjs.add(values, (items) => { this.listjs.add(values, (items) => {

View File

@ -30,14 +30,12 @@ class SpaCyNLPPipelineModelList extends ResourceList {
<td><b><span class="title"></span> <span class="version"></span></b><br><i><span class="description"></span></i></td> <td><b><span class="title"></span> <span class="version"></span></b><br><i><span class="description"></span></i></td>
<td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>)<br><a class="publishing-url publishing-url-2"></a></td> <td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>)<br><a class="publishing-url publishing-url-2"></a></td>
<td> <td>
<div class="list-action-trigger switch center-align" data-list-action="share-request"> <span class="disable-on-click">
<span class="share"></span>
<label> <label>
<input class="is-public" ${values['is-public'] ? 'checked' : ''} type="checkbox"> <input ${values['is-public'] ? 'checked' : ''} class="is-public list-action-trigger" data-list-action="toggle-is-public" type="checkbox">
<span class="lever"></span> <span>Public</span>
public
</label> </label>
</div> </span>
</td> </td>
<td class="right-align"> <td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a> <a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a>
@ -80,6 +78,7 @@ class SpaCyNLPPipelineModelList extends ResourceList {
<tr> <tr>
<th>Title and Description</th> <th>Title and Description</th>
<th>Publisher</th> <th>Publisher</th>
<th>Availability</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -111,6 +110,7 @@ class SpaCyNLPPipelineModelList extends ResourceList {
} }
onChange(event) { onChange(event) {
if (event.target.tagName !== 'INPUT') {return;}
let listItemElement = event.target.closest('.list-item[data-id]'); let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;} if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id; let itemId = listItemElement.dataset.id;
@ -118,8 +118,12 @@ class SpaCyNLPPipelineModelList extends ResourceList {
if (listActionElement === null) {return;} if (listActionElement === null) {return;}
let listAction = listActionElement.dataset.listAction; let listAction = listActionElement.dataset.listAction;
switch (listAction) { switch (listAction) {
case 'share-request': { case 'toggle-is-public': {
Utils.spaCyNLPPipelineModelToggleIsPublicRequest(this.userId, itemId); let newIsPublicValue = listActionElement.checked;
Requests.contributions.spacy_nlp_pipeline_models.entity.isPublic.update(itemId, newIsPublicValue)
.catch((response) => {
listActionElement.checked = !newIsPublicValue;
});
break; break;
} }
default: { default: {
@ -129,19 +133,45 @@ class SpaCyNLPPipelineModelList extends ResourceList {
} }
onClick(event) { onClick(event) {
if (event.target.closest('.disable-on-click') !== null) {return;}
let listItemElement = event.target.closest('.list-item[data-id]'); let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;} if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id; let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]'); let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
// ignore switch clicks, handle them by the onChange method instead
if (listActionElement.classList.contains('switch')) {
event.preventDefault();
this.onChange(event);
}
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction; let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) { switch (listAction) {
case 'delete-request': { case 'delete-request': {
Utils.deleteSpaCyNLPPipelineModelRequest(this.userId, itemId); let values = this.listjs.get('id', itemId)[0].values();
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm SpaCy NLP Pipeline Model deletion</h4>
<p>Do you really want to delete the SpaCy NLP Pipeline Model <b>${values.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">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) => {
Requests.contributions.spacy_nlp_pipeline_models.entity.delete(itemId);
});
modal.open();
break; break;
} }
case 'view': { case 'view': {

View File

@ -38,14 +38,12 @@ class TesseractOCRPipelineModelList extends ResourceList {
<td><b><span class="title"></span> <span class="version"></span></b><br><i><span class="description"></span></i></td> <td><b><span class="title"></span> <span class="version"></span></b><br><i><span class="description"></span></i></td>
<td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>)<br><a class="publishing-url"><span class="publishing-url-2"></span></a></td> <td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>)<br><a class="publishing-url"><span class="publishing-url-2"></span></a></td>
<td> <td>
<div class="list-action-trigger switch center-align" data-list-action="share-request"> <span class="disable-on-click">
<span class="share"></span>
<label> <label>
<input ${values['is-public'] ? 'checked' : ''} class="is-public" type="checkbox"> <input ${values['is-public'] ? 'checked' : ''} class="is-public list-action-trigger" data-list-action="toggle-is-public" type="checkbox">
<span class="lever"></span> <span>Public</span>
public
</label> </label>
</div> </span>
</td> </td>
<td class="right-align"> <td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a> <a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a>
@ -89,6 +87,7 @@ class TesseractOCRPipelineModelList extends ResourceList {
<tr> <tr>
<th>Title and Description</th> <th>Title and Description</th>
<th>Publisher</th> <th>Publisher</th>
<th>Availability</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -120,6 +119,7 @@ class TesseractOCRPipelineModelList extends ResourceList {
} }
onChange(event) { onChange(event) {
if (event.target.tagName !== 'INPUT') {return;}
let listItemElement = event.target.closest('.list-item[data-id]'); let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;} if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id; let itemId = listItemElement.dataset.id;
@ -127,8 +127,12 @@ class TesseractOCRPipelineModelList extends ResourceList {
if (listActionElement === null) {return;} if (listActionElement === null) {return;}
let listAction = listActionElement.dataset.listAction; let listAction = listActionElement.dataset.listAction;
switch (listAction) { switch (listAction) {
case 'share-request': { case 'toggle-is-public': {
Utils.tesseractOCRPipelineModelToggleIsPublicRequest(this.userId, itemId); let newIsPublicValue = listActionElement.checked;
Requests.contributions.tesseract_ocr_pipeline_models.entity.isPublic.update(itemId, newIsPublicValue)
.catch((response) => {
listActionElement.checked = !newIsPublicValue;
});
break; break;
} }
default: { default: {
@ -138,19 +142,45 @@ class TesseractOCRPipelineModelList extends ResourceList {
} }
onClick(event) { onClick(event) {
if (event.target.closest('.disable-on-click') !== null) {return;}
let listItemElement = event.target.closest('.list-item[data-id]'); let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;} if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id; let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]'); let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
// ignore switch clicks, handle them by the onChange method instead
if (listActionElement.classList.contains('switch')) {
event.preventDefault();
this.onChange(event);
}
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction; let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) { switch (listAction) {
case 'delete-request': { case 'delete-request': {
Utils.deleteTesseractOCRPipelineModelRequest(this.userId, itemId); let values = this.listjs.get('id', itemId)[0].values();
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Tesseract OCR Pipeline Model deletion</h4>
<p>Do you really want to delete the Tesseract OCR Pipeline Model <b>${values.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="btn modal-close waves-effect waves-light">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) => {
Requests.contributions.tesseract_ocr_pipeline_models.entity.delete(itemId);
});
modal.open();
break; break;
} }
case 'view': { case 'view': {

View File

@ -13,14 +13,14 @@ class UserList extends ResourceList {
get item() { get item() {
return ` return `
<tr class="list-item clickable hoverable"> <tr class="list-item clickable hoverable">
<td><img alt="user-image" class="circle responsive-img avatar" style="width:50%"></td> <td><img alt="user-image" class="circle responsive-img avatar" style="width:25%"></td>
<td><b><span class="username"></span><b></td> <td><b><span class="username"></span><b></td>
<td><span class="full-name"></span></td> <td><span class="full-name"></span></td>
<td><span class="location"></span></td> <td><span class="location"></span></td>
<td><span class="organization"></span></td> <td><span class="organization"></span></td>
<td><span class="corpora-online"></span></td> <td><span class="corpora-online"></span></td>
<td class="right-align"> <td class="right-align">
<a class="list-action-trigger btn-floating waves-effect waves-light" data-list-action="view"><i class="material-icons">send</i></a> <a class="list-action-trigger btn-floating waves-effect waves-light social-area-color-darken" data-list-action="view"><i class="material-icons">send</i></a>
</td> </td>
</tr> </tr>
`.trim(); `.trim();
@ -72,12 +72,12 @@ class UserList extends ResourceList {
return { return {
'id': user.id, 'id': user.id,
'member-since': user.member_since, 'member-since': user.member_since,
'avatar': user.avatar ? `/users/${user.id}/avatar` : '/static/images/user_avatar.png', 'avatar': user.avatar,
'username': user.username, 'username': user.username,
'full-name': user.full_name ? user.full_name : '', 'full-name': user.full_name ? user.full_name : '',
'location': user.location ? user.location : '', 'location': user.location ? user.location : '',
'organization': user.organization ? user.organization : '', 'organization': user.organization ? user.organization : '',
'corpora-online': '-' 'corpora-online': Object.values(user.corpora).filter((corpus) => corpus.is_public).length
}; };
}; };

View File

@ -69,648 +69,4 @@ class Utils {
return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2));
} }
static enableCorpusIsPublicRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus;
try {
corpus = app.data.users[userId].corpora[corpusId];
} catch (error) {
corpus = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Hier könnte eine Warnung stehen</h4>
<p></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">Confirm</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/${corpusId}/enable_is_public`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Corpus "${corpusTitle}" is public now`, 'corpus');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static disableCorpusIsPublicRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus;
try {
corpus = app.data.users[userId].corpora[corpusId];
} catch (error) {
corpus = {};
}
let corpusTitle = corpus?.title;
fetch(`/corpora/${corpusId}/disable_is_public`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Corpus "${corpusTitle}" is private now`, 'corpus');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
}
static buildCorpusRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus;
try {
corpus = app.data.users[userId].corpora[corpusId];
} catch (error) {
corpus = {};
}
fetch(`/corpora/${corpusId}/build`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
if (response.status === 409) {app.flash('Conflict', 'error'); reject(response);}
app.flash(`Corpus "${corpus?.title}" marked for building`, 'corpus');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
}
static deleteCorpusRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus;
try {
corpus = app.data.users[userId].corpora[corpusId];
} catch (error) {
corpus = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus deletion</h4>
<p>Do you really want to delete the Corpus <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/${corpusId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Corpus "${corpusTitle}" marked for deletion`, 'corpus');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static deleteCorpusFileRequest(userId, corpusId, corpusFileId) {
return new Promise((resolve, reject) => {
let corpusFile;
try {
corpusFile = app.data.users[userId].corpora[corpusId].files[corpusFileId];
} catch (error) {
corpusFile = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus File deletion</h4>
<p>Do you really want to delete the Corpus File <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) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Corpus File "${corpusFileTitle}" deleted`, 'corpus');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static deleteSpaCyNLPPipelineModelRequest(userId, spaCyNLPPipelineModelId) {
return new Promise((resolve, reject) => {
let spaCyNLPPipelineModel;
try {
spaCyNLPPipelineModel = app.data.users[userId].spacy_nlp_pipeline_models[spaCyNLPPipelineModelId];
} catch (error) {
spaCyNLPPipelineModel = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm SpaCy NLP Pipeline Model deletion</h4>
<p>Do you really want to delete the SpaCy NLP Pipeline Model <b>${spaCyNLPPipelineModel?.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 spaCyNLPPipelineModelTitle = spaCyNLPPipelineModel?.title;
fetch(`/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModelId}`, {method: 'DELETE'})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`SpaCy NLP Pipeline Model "${spaCyNLPPipelineModelTitle}" marked for deletion`);
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static deleteTesseractOCRPipelineModelRequest(userId, tesseractOCRPipelineModelId) {
return new Promise((resolve, reject) => {
let tesseractOCRPipelineModel;
try {
tesseractOCRPipelineModel = app.data.users[userId].tesseract_ocr_pipeline_models[tesseractOCRPipelineModelId];
} catch (error) {
tesseractOCRPipelineModel = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Tesseract OCR Pipeline Model deletion</h4>
<p>Do you really want to delete the Tesseract OCR Pipeline Model <b>${tesseractOCRPipelineModel?.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 tesseractOCRPipelineModelTitle = tesseractOCRPipelineModel?.title;
fetch(`/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}`, {method: 'DELETE'})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Tesseract OCR Pipeline Model "${tesseractOCRPipelineModelTitle}" marked for deletion`);
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static deleteProfileAvatarRequest(userId) {
return new Promise((resolve, reject) => {
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Avatar deletion</h4>
<p>Do you really want to delete your Avatar?</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) => {
fetch(`/users/${userId}/avatar`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Avatar marked for deletion`);
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static deleteJobRequest(userId, jobId) {
return new Promise((resolve, reject) => {
let job;
try {
job = app.data.users[userId].jobs[jobId];
} catch (error) {
job = {};
}
let modalElement = Utils.HTMLToElement(
`
<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/${jobId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Job "${jobTitle}" marked for deletion`, 'job');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static getJobLogRequest(userId, jobId) {
return new Promise((resolve, reject) => {
fetch(`/jobs/${jobId}/log`, {method: 'GET', headers: {Accept: 'application/json, text/plain'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
return response.text();
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
)
.then(
(text) => {
let modalElement = Utils.HTMLToElement(
`
<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();
resolve(text);
}
);
});
}
static restartJobRequest(userId, jobId) {
return new Promise((resolve, reject) => {
let job;
try {
job = app.data.users[userId].jobs[jobId];
} catch (error) {
job = {};
}
let modalElement = Utils.HTMLToElement(
`
<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 Job Results 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/${jobId}/restart`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
if (response.status === 409) {app.flash('Conflict', 'error'); reject(response);}
app.flash(`Job "${jobTitle}" restarted.`, 'job');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static deleteUserRequest(userId) {
return new Promise((resolve, reject) => {
let user;
try {
user = app.data.users[userId];
} catch (error) {
user = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm User 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/${userId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`User "${userName}" marked for deletion`);
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static tesseractOCRPipelineModelToggleIsPublicRequest(userId, tesseractOCRPipelineModelId, is_public) {
return new Promise((resolve, reject) => {
let tesseractOCRPipelineModel;
try {
tesseractOCRPipelineModel = app.data.users[userId].tesseract_ocr_pipeline_models[tesseractOCRPipelineModelId];
} catch (error) {
tesseractOCRPipelineModel = {};
}
fetch(`/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}/toggle-public-status`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {
app.flash('Forbidden', 'error');
reject(response);
}
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
}
static spaCyNLPPipelineModelToggleIsPublicRequest(userId, spaCyNLPPipelineModelId) {
return new Promise((resolve, reject) => {
let spaCyNLPPipelineModel;
try {
spaCyNLPPipelineModel = app.data.users[userId].spacy_nlp_pipeline_models[spaCyNLPPipelineModelId];
} catch (error) {
spaCyNLPPipelineModel = {};
}
fetch(`/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModelId}/toggle-public-status`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {
app.flash('Forbidden', 'error');
reject(response);
}
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
}
} }

View File

@ -8,29 +8,32 @@
<img class="hide-on-small-only" src="{{ url_for('static', filename='images/nopaque_-_logo_name_slogan.svg') }}" style="height: 128px; margin-top: -32px; margin-left: -32px;"> <img class="hide-on-small-only" src="{{ url_for('static', filename='images/nopaque_-_logo_name_slogan.svg') }}" style="height: 128px; margin-top: -32px; margin-left: -32px;">
<img class="hide-on-med-and-up" src="{{ url_for('static', filename='images/nopaque_-_logo.svg') }}" style="height: 128px; margin-top: -32px; margin-left: -32px;"> <img class="hide-on-med-and-up" src="{{ url_for('static', filename='images/nopaque_-_logo.svg') }}" style="height: 128px; margin-top: -32px; margin-left: -32px;">
</a> </a>
<ul class="right"> <ul class="right hide-on-med-and-down">
<li><a href="{{ url_for('main.news') }}"><i class="material-icons left">email</i>News</a></li>
<li><a class="dropdown-trigger no-autoinit" data-target="nav-more-dropdown" href="#!" id="nav-more-dropdown-trigger"><i class="material-icons">more_vert</i></a></li> <li><a class="dropdown-trigger no-autoinit" data-target="nav-more-dropdown" href="#!" id="nav-more-dropdown-trigger"><i class="material-icons">more_vert</i></a></li>
</ul> </ul>
</div> </div>
<div class="nav-content primary-variant-color"> <div class="nav-content primary-variant-color">
<ul class="tabs tabs-transparent"> <ul class="tabs tabs-transparent">
<li class="tab"><a href="{{ url_for('main.index') }}" target="_self"><i class="material-icons">home</i></a></li> {%- for breadcrumb in breadcrumbs -%}
{% if breadcrumbs is defined %} <li class="tab"><a {{ 'class="active"' if loop.last }} href="{{ breadcrumb.url }}" target="_self">{{ breadcrumb.text }}</a></li>
{{ breadcrumbs }} {% if not loop.last %}
<li class="tab disabled"><i class="material-icons">navigate_next</i></li>
{% endif %} {% endif %}
{%- endfor -%}
</ul> </ul>
{% if current_user.is_authenticated %} {# {% if current_user.is_authenticated %}
<a class="btn-floating btn-large halfway-fab modal-trigger pink tooltipped waves-effect waves-light" data-tooltip="Roadmap" href="#roadmap-modal"><i class="material-icons">explore</i></a> <a class="btn-floating btn-large halfway-fab modal-trigger pink tooltipped waves-effect waves-light" data-tooltip="Roadmap" href="#roadmap-modal"><i class="material-icons">explore</i></a>
{% endif %} {% endif %} #}
<a class="btn-floating btn-large halfway-fab modal-trigger pink tooltipped waves-effect waves-light" data-tooltip="Manual" href="#manual-modal"><i class="material-icons">help</i></a>
</div> </div>
</nav> </nav>
</div> </div>
<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.settings') }}"><i class="material-icons left">settings</i>General settings</a></li> <li><a href="{{ url_for('settings.settings') }}"><i class="material-icons left">settings</i>Settings</a></li>
<li><a href="{{ url_for('users.edit_profile', user_id=current_user.id) }}"><i class="material-icons left">contact_page</i>Profile 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

@ -21,7 +21,7 @@
<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.analysis', corpus_id=corpus.id) %} class="active"{% endif %} href="{{ url_for('corpora.analysis', corpus_id=corpus.id) }}" target="_self">Corpus analysis</a></li>
{% else %} {% else %}
<li class="tab disabled tooltipped" data-tooltip="Create 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 %}

View File

@ -6,21 +6,39 @@
output='gen/app.%(version)s.js', output='gen/app.%(version)s.js',
'js/App.js', 'js/App.js',
'js/Utils.js', 'js/Utils.js',
'js/Forms/Form.js',
'js/Forms/CreateCorpusFileForm.js',
'js/Forms/CreateJobForm.js',
'js/Forms/CreateContributionForm.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/CorpusAnalysis/QueryBuilder.js', 'js/CorpusAnalysis/QueryBuilder.js',
'js/RessourceDisplays/RessourceDisplay.js', 'js/XMLtoObject.js'
'js/RessourceDisplays/CorpusDisplay.js', %}
'js/RessourceDisplays/JobDisplay.js', <script src="{{ ASSET_URL }}"></script>
{%- endassets %}
{%- assets
filters='rjsmin',
output='gen/Forms.%(version)s.js',
'js/Forms/Form.js',
'js/Forms/CreateCorpusFileForm.js',
'js/Forms/CreateJobForm.js',
'js/Forms/CreateContributionForm.js'
%}
<script src="{{ ASSET_URL }}"></script>
{%- endassets %}
{%- assets
filters='rjsmin',
output='gen/ResourceDisplays.%(version)s.js',
'js/ResourceDisplays/ResourceDisplay.js',
'js/ResourceDisplays/CorpusDisplay.js',
'js/ResourceDisplays/JobDisplay.js'
%}
<script src="{{ ASSET_URL }}"></script>
{%- endassets %}
{%- assets
filters='rjsmin',
output='gen/ResourceLists.%(version)s.js',
'js/ResourceLists/ResourceList.js', 'js/ResourceLists/ResourceList.js',
'js/ResourceLists/CorpusFileList.js', 'js/ResourceLists/CorpusFileList.js',
'js/ResourceLists/PublicCorpusFileList.js',
'js/ResourceLists/CorpusList.js', 'js/ResourceLists/CorpusList.js',
'js/ResourceLists/PublicCorpusList.js', 'js/ResourceLists/PublicCorpusList.js',
'js/ResourceLists/JobList.js', 'js/ResourceLists/JobList.js',
@ -30,7 +48,25 @@
'js/ResourceLists/TesseractOCRPipelineModelList.js', 'js/ResourceLists/TesseractOCRPipelineModelList.js',
'js/ResourceLists/UserList.js', 'js/ResourceLists/UserList.js',
'js/ResourceLists/AdminUserList.js', 'js/ResourceLists/AdminUserList.js',
'js/XMLtoObject.js' 'js/ResourceLists/CorpusFollowerList.js',
'js/ResourceLists/DetailledPublicCorpusList.js'
%}
<script src="{{ ASSET_URL }}"></script>
{%- endassets %}
{%- assets
filters='rjsmin',
output='gen/Requests.%(version)s.js',
'js/Requests/Requests.js',
'js/Requests/admin/admin.js',
'js/Requests/contributions/contributions.js',
'js/Requests/contributions/spacy_nlp_pipeline_models.js',
'js/Requests/contributions/tesseract_ocr_pipeline_models.js',
'js/Requests/corpora/corpora.js',
'js/Requests/corpora/files.js',
'js/Requests/corpora/followers.js',
'js/Requests/jobs/jobs.js',
'js/Requests/users/users.js',
'js/Requests/users/settings.js'
%} %}
<script src="{{ ASSET_URL }}"></script> <script src="{{ ASSET_URL }}"></script>
{%- endassets %} {%- endassets %}
@ -50,7 +86,13 @@
for (let optionElement of document.querySelectorAll('option[value=""]')) { for (let optionElement of document.querySelectorAll('option[value=""]')) {
optionElement.disabled = true; optionElement.disabled = true;
} }
for (let optgroupElement of document.querySelectorAll('optgroup[label=""]')) {
for (let c of optgroupElement.children) {
optgroupElement.parentElement.insertAdjacentElement('afterbegin', c);
}
optgroupElement.remove();
}
// Set the data-length attribute on textareas/inputs with the maxlength attribute // Set the data-length attribute on textareas/inputs with the maxlength attribute
for (let inputElement of document.querySelectorAll('textarea[maxlength], input[maxlength]')) { for (let inputElement of document.querySelectorAll('textarea[maxlength], input[maxlength]')) {
inputElement.dataset.length = inputElement.getAttribute('maxlength'); inputElement.dataset.length = inputElement.getAttribute('maxlength');
@ -70,4 +112,35 @@
for (let [category, message] of {{ get_flashed_messages(with_categories=True)|tojson }}) { for (let [category, message] of {{ get_flashed_messages(with_categories=True)|tojson }}) {
app.flash(message, message); app.flash(message, message);
} }
// Initialize manual modal
let manualModalTableOfContentsElement = document.querySelector('#manual-modal-table-of-contents');
let manualModalTableOfContents = M.Tabs.init(manualModalTableOfContentsElement);
let manualModalElement = document.querySelector('#manual-modal');
let manualModal = M.Modal.init(
manualModalElement,
{
onOpenStart: (manualModalElement, modalTriggerElement) => {
if ('manualModalChapter' in modalTriggerElement.dataset) {
manualModalTableOfContents.select(modalTriggerElement.dataset.manualModalChapter);
}
}
}
);
// Initialize terms of use modal
const termsOfUseModal = document.getElementById('terms-of-use-modal');
M.Modal.init(
termsOfUseModal,
{
dismissible: false,
onCloseEnd: () => {
Requests.users.entity.acceptTermsOfUse();
}
}
);
{% if current_user.is_authenticated and not current_user.terms_of_use_accepted %}
termsOfUseModal.M_Modal.open();
{% endif %}
</script> </script>

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