diff --git a/app/__init__.py b/app/__init__.py index de64a195..1a749ed4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -51,9 +51,6 @@ def create_app(config: Config = Config) -> Flask: from .admin import bp as admin_blueprint app.register_blueprint(admin_blueprint, url_prefix='/admin') - from .api import bp as api_blueprint - app.register_blueprint(api_blueprint, url_prefix='/api') - from .auth import bp as auth_blueprint app.register_blueprint(auth_blueprint, url_prefix='/auth') @@ -70,7 +67,7 @@ def create_app(config: Config = Config) -> Flask: app.register_blueprint(jobs_blueprint, url_prefix='/jobs') from .main import bp as main_blueprint - app.register_blueprint(main_blueprint) + app.register_blueprint(main_blueprint, url_prefix='/') from .services import bp as services_blueprint app.register_blueprint(services_blueprint, url_prefix='/services') diff --git a/app/auth/forms.py b/app/auth/forms.py index 8d47e6b1..6917b78b 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -1,4 +1,3 @@ -from app.models import User from flask_wtf import FlaskForm from wtforms import ( BooleanField, @@ -7,32 +6,45 @@ from wtforms import ( SubmitField, ValidationError ) -from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp +from wtforms.validators import InputRequired, Email, EqualTo, Length, Regexp +from app.models import User from . import USERNAME_REGEX -class LoginForm(FlaskForm): - user = StringField('Email or username', validators=[DataRequired()]) - password = PasswordField('Password', validators=[DataRequired()]) - remember_me = BooleanField('Keep me logged in') - submit = SubmitField('Log In') - - class RegistrationForm(FlaskForm): - email = StringField('Email', validators=[DataRequired(), Email()]) - username = StringField('Username', + email = StringField( + 'Email', + validators=[InputRequired(), Email(), Length(max=254)] + ) + username = StringField( + 'Username', validators=[ InputRequired(), - Length(1, 64), + Length(max=64), Regexp( USERNAME_REGEX, - message='Usernames must have only letters, numbers, dots or underscores' # noqa - ) + message=( + 'Usernames must have only letters, numbers, dots or ' + 'underscores' + ) + ) ] ) - password = PasswordField('Password', validators=[DataRequired(), EqualTo('password_confirmation', message='Passwords must match')]) - password_confirmation = PasswordField('Password confirmation', validators=[DataRequired(), EqualTo('password', message='Passwords must match')]) - submit = SubmitField('Register') + password = PasswordField( + 'Password', + validators=[ + InputRequired(), + EqualTo('password_2', message='Passwords must match') + ] + ) + password_2 = PasswordField( + 'Password confirmation', + validators=[ + InputRequired(), + EqualTo('password', message='Passwords must match') + ] + ) + submit = SubmitField() def validate_email(self, field): if User.query.filter_by(email=field.data.lower()).first(): @@ -43,12 +55,31 @@ class RegistrationForm(FlaskForm): raise ValidationError('Username already in use') -class ResetPasswordForm(FlaskForm): - password = PasswordField('New password', validators=[DataRequired(), EqualTo('password_confirmation', message='Passwords must match')]) - password_confirmation = PasswordField('Password confirmation', validators=[DataRequired(), EqualTo('password', message='Passwords must match')]) - submit = SubmitField('Reset Password') +class LoginForm(FlaskForm): + user = StringField('Email or username', validators=[InputRequired()]) + password = PasswordField('Password', validators=[InputRequired()]) + remember_me = BooleanField('Keep me logged in') + submit = SubmitField() class ResetPasswordRequestForm(FlaskForm): - email = StringField('Email', validators=[DataRequired(), Email()]) - submit = SubmitField('Reset Password') + email = StringField('Email', validators=[InputRequired(), Email()]) + submit = SubmitField() + + +class ResetPasswordForm(FlaskForm): + password = PasswordField( + 'New password', + validators=[ + InputRequired(), + EqualTo('password_2', message='Passwords must match') + ] + ) + password_2 = PasswordField( + 'New password confirmation', + validators=[ + InputRequired(), + EqualTo('password', message='Passwords must match') + ] + ) + submit = SubmitField() diff --git a/app/auth/routes.py b/app/auth/routes.py index 6897b088..5655d0dc 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -1,10 +1,5 @@ -from app import db -from app.email import create_message, send -from app.models import User -from datetime import datetime from flask import ( abort, - current_app, flash, redirect, render_template, @@ -12,7 +7,9 @@ from flask import ( url_for ) from flask_login import current_user, login_user, login_required, logout_user -from sqlalchemy import or_ +from app import db +from app.email import create_message, send +from app.models import User from . import bp from .forms import ( LoginForm, @@ -29,69 +26,32 @@ def before_request(): unconfirmed view if user is unconfirmed. """ if current_user.is_authenticated: - current_user.last_seen = datetime.utcnow() + current_user.ping() db.session.commit() - if ( - not current_user.confirmed - and request.endpoint - and request.blueprint != 'auth' - and request.endpoint != 'static' - ): + if (not current_user.confirmed + and request.endpoint + and request.blueprint != 'auth' + and request.endpoint != 'static'): return redirect(url_for('auth.unconfirmed')) -@bp.route('/login', methods=['GET', 'POST']) -def login(): - if current_user.is_authenticated: - return redirect(url_for('main.dashboard')) - form = LoginForm(prefix='login-form') - if form.validate_on_submit(): - user = User.query.filter( - or_( - User.username == form.user.data, - User.email == form.user.data.lower() - ) - ).first() - if user and user.verify_password(form.password.data): - login_user(user, form.remember_me.data) - next = request.args.get('next') - if next is None or not next.startswith('/'): - next = url_for('main.dashboard') - return redirect(next) - flash('Invalid email/username or password', category='error') - return render_template('auth/login.html.j2', form=form, title='Log in') - - -@bp.route('/logout') -@login_required -def logout(): - logout_user() - flash('You have been logged out') - return redirect(url_for('main.index')) - - @bp.route('/register', methods=['GET', 'POST']) def register(): if current_user.is_authenticated: return redirect(url_for('main.dashboard')) form = RegistrationForm(prefix='registration-form') if form.validate_on_submit(): - user = User( - email=form.email.data.lower(), - password=form.password.data, - username=form.username.data - ) - db.session.add(user) - db.session.flush(objects=[user]) - db.session.refresh(user) try: - user.makedirs() - except OSError as e: - current_app.logger.error(e) - db.session.rollback() + user = User.create( + email=form.email.data.lower(), + password=form.password.data, + username=form.username.data + ) + except OSError: flash('Internal Server Error', category='error') abort(500) - token = user.generate_confirm_user_token() + flash(f'User "{user.username}" created') + token = user.generate_confirm_token() msg = create_message( user.email, 'Confirm Your Account', @@ -110,36 +70,46 @@ def register(): ) -@bp.route('/confirm/') +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.dashboard')) + form = LoginForm(prefix='login-form') + if form.validate_on_submit(): + user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first() + if user and user.verify_password(form.password.data): + login_user(user, form.remember_me.data) + next = request.args.get('next') + if next is None or not next.startswith('/'): + next = url_for('main.dashboard') + flash('You have been logged in') + return redirect(next) + flash('Invalid email/username or password', category='error') + return render_template('auth/login.html.j2', form=form, title='Log in') + + +@bp.route('/logout') @login_required -def confirm(token): - if current_user.confirmed: - return redirect(url_for('main.dashboard')) - if current_user.confirm_user(token): - db.session.commit() - flash('You have confirmed your account') - return redirect(url_for('main.dashboard')) - else: - flash( - 'The confirmation link is invalid or has expired', - category='error' - ) - return redirect(url_for('.unconfirmed')) +def logout(): + logout_user() + flash('You have been logged out') + return redirect(url_for('main.index')) @bp.route('/unconfirmed') +@login_required def unconfirmed(): - if current_user.is_anonymous: - return redirect(url_for('main.index')) - elif current_user.confirmed: + if current_user.confirmed: return redirect(url_for('main.dashboard')) return render_template('auth/unconfirmed.html.j2', title='Unconfirmed') @bp.route('/confirm') @login_required -def resend_confirmation(): - token = current_user.generate_confirm_user_token() +def confirm_request(): + if current_user.confirmed: + return redirect(url_for('main.dashboard')) + token = current_user.generate_confirm_token() msg = create_message( current_user.email, 'Confirm Your Account', @@ -149,10 +119,23 @@ def resend_confirmation(): ) send(msg) flash('A new confirmation email has been sent to you by email') - return redirect(url_for('auth.unconfirmed')) + return redirect(url_for('.unconfirmed')) -@bp.route('/reset', methods=['GET', 'POST']) +@bp.route('/confirm/') +@login_required +def confirm(token): + if current_user.confirmed: + return redirect(url_for('main.dashboard')) + if current_user.confirm(token): + db.session.commit() + flash('You have confirmed your account') + return redirect(url_for('main.dashboard')) + flash('The confirmation link is invalid or has expired', category='error') + return redirect(url_for('.unconfirmed')) + + +@bp.route('/reset_password', methods=['GET', 'POST']) def reset_password_request(): if current_user.is_authenticated: return redirect(url_for('main.dashboard')) @@ -160,7 +143,7 @@ def reset_password_request(): if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data.lower()).first() if user is not None: - token = user.generate_password_reset_token() + token = user.generate_reset_password_token() msg = create_message( user.email, 'Reset Your Password', @@ -170,7 +153,8 @@ def reset_password_request(): ) send(msg) flash( - 'An email with instructions to reset your password has been sent to you' # noqa + 'An email with instructions to reset your password has been sent ' + 'to you' ) return redirect(url_for('.login')) return render_template( @@ -180,7 +164,7 @@ def reset_password_request(): ) -@bp.route('/reset/', methods=['GET', 'POST']) +@bp.route('/reset_password/', methods=['GET', 'POST']) def reset_password(token): if current_user.is_authenticated: return redirect(url_for('main.dashboard')) @@ -190,8 +174,7 @@ def reset_password(token): db.session.commit() flash('Your password has been updated') return redirect(url_for('.login')) - else: - return redirect(url_for('main.index')) + return redirect(url_for('main.index')) return render_template( 'auth/reset_password.html.j2', form=form, diff --git a/app/contributions/forms.py b/app/contributions/forms.py deleted file mode 100644 index 205f1740..00000000 --- a/app/contributions/forms.py +++ /dev/null @@ -1,15 +0,0 @@ -from app.models import User -from flask_wtf import FlaskForm -from wtforms import ( - BooleanField, - PasswordField, - StringField, - SubmitField, - ValidationError -) -from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp -from . import USERNAME_REGEX - - -class ContributeTesseractOCRModel(FlaskForm): - pass diff --git a/app/corpora/forms.py b/app/corpora/forms.py index 26105a13..73002edc 100644 --- a/app/corpora/forms.py +++ b/app/corpora/forms.py @@ -1,80 +1,67 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired -from werkzeug.utils import secure_filename -from wtforms import ( - StringField, - SubmitField, - ValidationError, - IntegerField -) -from wtforms.validators import DataRequired, InputRequired, Length +from wtforms import StringField, SubmitField, ValidationError, IntegerField +from wtforms.validators import InputRequired, Length -class AddCorpusFileForm(FlaskForm): - ''' - Form to add a .vrt corpus file to the current corpus. - ''' - # Required fields - author = StringField('Author', validators=[InputRequired(), Length(1, 255)]) - publishing_year = IntegerField('Publishing year', validators=[InputRequired()]) - title = StringField('Title', validators=[InputRequired(), Length(1, 255)]) - vrt = FileField('File', validators=[FileRequired()]) - # Optional fields - address = StringField('Adress', validators=[Length(0, 255)]) - booktitle = StringField('Booktitle', validators=[Length(0, 255)]) - chapter = StringField('Chapter', validators=[Length(0, 255)]) - editor = StringField('Editor', validators=[Length(0, 255)]) - institution = StringField('Institution', validators=[Length(0, 255)]) - journal = StringField('Journal', validators=[Length(0, 255)]) - pages = StringField('Pages', validators=[Length(0, 255)]) - publisher = StringField('Publisher', validators=[Length(0, 255)]) - school = StringField('School', validators=[Length(0, 255)]) +class CreateCorpusForm(FlaskForm): + description = StringField( + 'Description', + validators=[InputRequired(), Length(max=255)] + ) + title = StringField('Title', validators=[InputRequired(), Length(max=32)]) submit = SubmitField() + +class CorpusFileBaseForm(FlaskForm): + author = StringField( + 'Author', + validators=[InputRequired(), Length(max=255)] + ) + publishing_year = IntegerField( + 'Publishing year', + validators=[InputRequired()] + ) + title = StringField( + 'Title', + validators=[InputRequired(), Length(max=255)] + ) + address = StringField('Adress', validators=[Length(max=255)]) + booktitle = StringField('Booktitle', validators=[Length(max=255)]) + chapter = StringField('Chapter', validators=[Length(max=255)]) + editor = StringField('Editor', validators=[Length(max=255)]) + institution = StringField('Institution', validators=[Length(max=255)]) + journal = StringField('Journal', validators=[Length(max=255)]) + pages = StringField('Pages', validators=[Length(max=255)]) + publisher = StringField('Publisher', validators=[Length(max=255)]) + school = StringField('School', validators=[Length(max=255)]) + submit = SubmitField() + + +class CreateCorpusFileForm(CorpusFileBaseForm): + vrt = FileField('File', validators=[FileRequired()]) + def validate_vrt(self, field): if not field.data.filename.lower().endswith('.vrt'): raise ValidationError('VRT files only!') -class EditCorpusFileForm(FlaskForm): - ''' - Form to edit meta data of one corpus file. - ''' - # Required fields - author = StringField('Author', validators=[InputRequired(), Length(1, 255)]) - publishing_year = IntegerField('Publishing year', validators=[InputRequired()]) - title = StringField('Title', validators=[InputRequired(), Length(1, 255)]) - # Optional fields - address = StringField('Adress', validators=[Length(0, 255)]) - booktitle = StringField('Booktitle', validators=[Length(0, 255)]) - chapter = StringField('Chapter', validators=[Length(0, 255)]) - editor = StringField('Editor', validators=[Length(0, 255)]) - institution = StringField('Institution', validators=[Length(0, 255)]) - journal = StringField('Journal', validators=[Length(0, 255)]) - pages = StringField('Pages', validators=[Length(0, 255)]) - publisher = StringField('Publisher', validators=[Length(0, 255)]) - school = StringField('School', validators=[Length(0, 255)]) - submit = SubmitField() - -class AddCorpusForm(FlaskForm): - ''' - Form to add a a new corpus. - ''' - description = StringField('Description', validators=[InputRequired(), Length(1, 255)]) - title = StringField('Title', validators=[InputRequired(), Length(1, 32)]) - submit = SubmitField() +class EditCorpusFileForm(CorpusFileBaseForm): + def prefill(self, corpus_file): + ''' Pre-fill the form with data of an exististing corpus file ''' + self.address.data = corpus_file.address + self.author.data = corpus_file.author + self.booktitle.data = corpus_file.booktitle + self.chapter.data = corpus_file.chapter + self.editor.data = corpus_file.editor + self.institution.data = corpus_file.institution + self.journal.data = corpus_file.journal + self.pages.data = corpus_file.pages + self.publisher.data = corpus_file.publisher + self.publishing_year.data = corpus_file.publishing_year + self.school.data = corpus_file.school + self.title.data = corpus_file.title class ImportCorpusForm(FlaskForm): - ''' - Form to import a corpus. - ''' - description = StringField('Description', validators=[InputRequired(), Length(1, 255)]) - archive = FileField('File', validators=[FileRequired()]) - title = StringField('Title', validators=[InputRequired(), Length(1, 32)]) - submit = SubmitField() - - def validate_archive(self, field): - valid_mimetypes = ['application/zip', 'application/x-zip', 'application/x-zip-compressed'] - if field.data.mimetype not in valid_mimetypes: - raise ValidationError('ZIP files only!') + pass diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 532f0249..36e19e2b 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -1,140 +1,44 @@ -from app import db -from app.models import Corpus, CorpusFile, CorpusStatus from flask import ( abort, current_app, flash, - make_response, + Markup, redirect, render_template, - url_for, send_from_directory ) from flask_login import current_user, login_required -from . import bp -from . import tasks -from .forms import ( - AddCorpusFileForm, - AddCorpusForm, - EditCorpusFileForm, - ImportCorpusForm -) +from threading import Thread import os -import shutil -import tempfile -import xml.etree.ElementTree as ET +from app import db +from app.models import Corpus, CorpusFile, CorpusStatus +from . import bp +from .forms import CreateCorpusFileForm, CreateCorpusForm, EditCorpusFileForm -@bp.route('/add', methods=['GET', 'POST']) +@bp.route('/create', methods=['GET', 'POST']) @login_required -def add_corpus(): - form = AddCorpusForm(prefix='add-corpus-form') +def create_corpus(): + form = CreateCorpusForm(prefix='create-corpus-form') if form.validate_on_submit(): - corpus = Corpus( - user=current_user, - description=form.description.data, - title=form.title.data - ) - db.session.add(corpus) - db.session.flush() - db.session.refresh(corpus) try: - corpus.makedirs() - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', category='error') + corpus = Corpus.create( + title=form.title.data, + description=form.description.data, + user=current_user + ) + except OSError: abort(500) db.session.commit() - flash(f'Corpus "{corpus.title}" added', category='corpus') - return redirect(url_for('.corpus', corpus_id=corpus.id)) - return render_template( - 'corpora/add_corpus.html.j2', - form=form, - title='Add corpus' - ) - - -@bp.route('/import', methods=['GET', 'POST']) -@login_required -def import_corpus(): - form = ImportCorpusForm(prefix='import-corpus-form') - if form.is_submitted(): - if not form.validate(): - return make_response(form.errors, 400) - corpus = Corpus( - user=current_user, - description=form.description.data, - title=form.title.data + message = Markup( + f'Corpus "{corpus.title}" created' ) - db.session.add(corpus) - db.session.flush(objects=[corpus]) - db.session.refresh(corpus) - try: - corpus.makedirs() - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', category='error') - return make_response({'redirect_url': url_for('.import_corpus')}, 500) # noqa - # Save the uploaded zip file in a temporary directory - tmp_dir_base = os.path.join(current_app.config['NOPAQUE_DATA_DIR'], 'tmp') # noqa - with tempfile.TemporaryDirectory(dir=tmp_dir_base) as tmp_dir: - archive_file = os.path.join(tmp_dir, 'corpus.zip') - try: - form.archive.data.save(archive_file) - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error1', category='error') - return make_response({'redirect_url': url_for('.import_corpus')}, 500) # noqa - shutil.unpack_archive(archive_file, extract_dir=tmp_dir) - for vrt_filename in [x for x in os.listdir(tmp_dir) if x.endswith('.vrt')]: - vrt_file = os.path.join(tmp_dir, vrt_filename) - element_tree = ET.parse(vrt_file) - text_node = element_tree.find('text') - corpus_file = CorpusFile( - author=text_node.get('author'), - corpus=corpus, - filename=vrt_filename, - mimetype='application/vrt+xml', - publishing_year=int(text_node.get('publishing_year')), - title=text_node.get('title') - ) - if 'address' not in text_node.attrib: - corpus_file.address = text_node.get('address') - if 'booktitle' not in text_node.attrib: - corpus_file.booktitle = text_node.get('booktitle') - if 'chapter' not in text_node.attrib: - corpus_file.chapter = text_node.get('chapter') - if 'editor' not in text_node.attrib: - corpus_file.editor = text_node.get('editor') - if 'institution' not in text_node.attrib: - corpus_file.institution = text_node.get('institution') - if 'journal' not in text_node.attrib: - corpus_file.journal = text_node.get('journal') - if 'pages' not in text_node.attrib: - corpus_file.pages = text_node.get('pages') - if 'publisher' not in text_node.attrib: - corpus_file.publisher = text_node.get('publisher') - if 'school' not in text_node.attrib: - corpus_file.school = text_node.get('school') - db.session.add(corpus_file) - db.session.flush(objects=[corpus_file]) - db.session.refresh(corpus) - try: - shutil.copy2(vrt_file, corpus_file.path) - except Exception as e: - db.session.rollback() - flash('Internal Server Error2', category='error') - return make_response({'redirect_url': url_for('.import_corpus')}, 500) # noqa - db.session.commit() - flash(f'Corpus "{corpus.title}" imported', 'corpus') - return make_response({'redirect_url': url_for('.corpus', corpus_id=corpus.id)}, 201) + flash(message, 'corpus') + return redirect(corpus.url) return render_template( - 'corpora/import_corpus.html.j2', + 'corpora/create_corpus.html.j2', form=form, - title='Import Corpus' + title='Create corpus' ) @@ -151,6 +55,26 @@ def corpus(corpus_id): ) +@bp.route('/', methods=['DELETE']) +@login_required +def delete_corpus(corpus_id): + def _delete_corpus(app, corpus_id): + with app.app_context(): + corpus = Corpus.query.get(corpus_id) + corpus.delete() + db.session.commit() + + corpus = Corpus.query.get_or_404(corpus_id) + if not (corpus.user == current_user or current_user.is_administrator()): + abort(403) + thread = Thread( + target=_delete_corpus, + args=(current_app._get_current_object(), corpus_id) + ) + thread.start() + return {}, 202 + + @bp.route('//analyse') @login_required def analyse_corpus(corpus_id): @@ -162,95 +86,132 @@ def analyse_corpus(corpus_id): ) -@bp.route('//build') +@bp.route('//build', methods=['POST']) @login_required 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 not (corpus.user == current_user or current_user.is_administrator()): abort(403) - if corpus.files.all(): - tasks.build_corpus(corpus_id) - flash( - f'Corpus "{corpus.title}" marked for building', - category='corpus' - ) - else: - flash( - f'Can\'t build corpus "{corpus.title}": No corpus file(s)', - category='error' - ) - return redirect(url_for('.corpus', corpus_id=corpus_id)) + # 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('//delete') +@bp.route('//files/create', methods=['GET', 'POST']) @login_required -def delete_corpus(corpus_id): +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) - flash(f'Corpus "{corpus.title}" marked for deletion', 'corpus') - tasks.delete_corpus(corpus_id) - return redirect(url_for('main.dashboard')) - - -@bp.route('//export') -@login_required -def export_corpus(corpus_id): - abort(503) - corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.user == current_user or current_user.is_administrator()): - abort(403) - return send_from_directory( - as_attachment=True, - directory=os.path.join(corpus.user.path, 'corpora'), - filename=corpus.archive_file, - mimetype='zip' + form = CreateCorpusFileForm(prefix='create-corpus-file-form') + 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 OSError: + abort(500) + corpus.status = CorpusStatus.UNPREPARED + db.session.commit() + message = Markup( + 'Corpus file' + f'"{corpus_file.filename}" 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('//files/', methods=['GET', 'POST']) # noqa +@bp.route('//files/', + methods=['GET', 'POST']) @login_required def corpus_file(corpus_id, corpus_file_id): - corpus_file = CorpusFile.query.filter( - CorpusFile.corpus_id == corpus_id, - CorpusFile.id == corpus_file_id - ).first_or_404() - if not ( - corpus_file.corpus.user == current_user - or current_user.is_administrator() - ): + corpus_file = CorpusFile.query.get_or_404(corpus_file_id) + if corpus_file.corpus.id != corpus_id: + abort(404) + if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): abort(403) form = EditCorpusFileForm(prefix='edit-corpus-file-form') if form.validate_on_submit(): - corpus_file.address = form.address.data - corpus_file.author = form.author.data - corpus_file.booktitle = form.booktitle.data - corpus_file.chapter = form.chapter.data - corpus_file.editor = form.editor.data - corpus_file.institution = form.institution.data - corpus_file.journal = form.journal.data - corpus_file.pages = form.pages.data - corpus_file.publisher = form.publisher.data - corpus_file.publishing_year = form.publishing_year.data - corpus_file.school = form.school.data - corpus_file.title = form.title.data - corpus_file.corpus.status = CorpusStatus.UNPREPARED + has_changes = False + if corpus_file.address != form.address.data: + corpus_file.address = form.address.data + has_changes = True + if corpus_file.author != form.author.data: + corpus_file.author = form.author.data + has_changes = True + if corpus_file.booktitle != form.booktitle.data: + corpus_file.booktitle = form.booktitle.data + has_changes = True + if corpus_file.chapter != form.chapter.data: + corpus_file.chapter = form.chapter.data + has_changes = True + if corpus_file.editor != form.editor.data: + corpus_file.editor = form.editor.data + has_changes = True + if corpus_file.institution != form.institution.data: + corpus_file.institution = form.institution.data + has_changes = True + if corpus_file.journal != form.journal.data: + corpus_file.journal = form.journal.data + has_changes = True + if corpus_file.pages != form.pages.data: + corpus_file.pages = form.pages.data + has_changes = True + if corpus_file.publisher != form.publisher.data: + corpus_file.publisher = form.publisher.data + has_changes = True + if corpus_file.publishing_year != form.publishing_year.data: + corpus_file.publishing_year = form.publishing_year.data + has_changes = True + if corpus_file.school != form.school.data: + corpus_file.school = form.school.data + has_changes = True + if corpus_file.title != form.title.data: + corpus_file.title = form.title.data + has_changes = True + if has_changes: + corpus_file.corpus.status = CorpusStatus.UNPREPARED db.session.commit() - flash(f'Corpus file "{corpus_file.filename}" edited', category='corpus') # noqa - return redirect(url_for('.corpus', corpus_id=corpus_id)) - # If no form is submitted or valid, fill out fields with current values - form.address.data = corpus_file.address - form.author.data = corpus_file.author - form.booktitle.data = corpus_file.booktitle - form.chapter.data = corpus_file.chapter - form.editor.data = corpus_file.editor - form.institution.data = corpus_file.institution - form.journal.data = corpus_file.journal - form.pages.data = corpus_file.pages - form.publisher.data = corpus_file.publisher - form.publishing_year.data = corpus_file.publishing_year - form.school.data = corpus_file.school - form.title.data = corpus_file.title + message = Markup(f'Corpus file "{corpus_file.filename}" updated') + flash(message, category='corpus') + return redirect(corpus_file.corpus.url) + form.prefill(corpus_file) return render_template( 'corpora/corpus_file.html.j2', corpus=corpus_file.corpus, @@ -260,91 +221,52 @@ def corpus_file(corpus_id, corpus_file_id): ) -@bp.route('//files/add', methods=['GET', 'POST']) -@login_required -def add_corpus_file(corpus_id): - corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.user == current_user or current_user.is_administrator()): - abort(403) - form = AddCorpusFileForm(prefix='add-corpus-file-form') - if form.is_submitted(): - if not form.validate(): - return make_response(form.errors, 400) - # Save the file - corpus_file = CorpusFile( - address=form.address.data, - author=form.author.data, - booktitle=form.booktitle.data, - chapter=form.chapter.data, - corpus=corpus, - editor=form.editor.data, - filename=form.vrt.data.filename, - institution=form.institution.data, - journal=form.journal.data, - mimetype='application/vrt+xml', - pages=form.pages.data, - publisher=form.publisher.data, - publishing_year=form.publishing_year.data, - school=form.school.data, - title=form.title.data - ) - db.session.add(corpus_file) - db.session.flush(objects=[corpus_file]) - db.session.refresh(corpus_file) - try: - form.vrt.data.save(corpus_file.path) - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', category='error') - return make_response({'redirect_url': url_for('.add_corpus_file', corpus_id=corpus.id)}, 500) # noqa - corpus.status = CorpusStatus.UNPREPARED - db.session.commit() - flash(f'Corpus file "{corpus_file.filename}" added', category='corpus') - return make_response({'redirect_url': url_for('.corpus', corpus_id=corpus.id)}, 201) # noqa - return render_template( - 'corpora/add_corpus_file.html.j2', - corpus=corpus, - form=form, - title='Add corpus file' - ) - - -@bp.route('//files//delete') +@bp.route('//files/', methods=['DELETE']) @login_required def delete_corpus_file(corpus_id, corpus_file_id): - corpus_file = CorpusFile.query.filter( - CorpusFile.corpus_id == corpus_id, - CorpusFile.id == corpus_file_id - ).first_or_404() - if not ( - corpus_file.corpus.user == current_user - or current_user.is_administrator() - ): + 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.get_or_404(corpus_file_id) + if corpus_file.corpus.id != corpus_id: + abort(404) + if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): abort(403) - flash( - f'Corpus file "{corpus_file.filename}" marked for deletion', - category='corpus' + thread = Thread( + target=_delete_corpus_file, + args=(current_app._get_current_object(), corpus_file_id) ) - tasks.delete_corpus_file(corpus_file_id) - return redirect(url_for('.corpus', corpus_id=corpus_id)) + thread.start() + return {}, 202 @bp.route('//files//download') @login_required def download_corpus_file(corpus_id, corpus_file_id): - corpus_file = CorpusFile.query.filter( - CorpusFile.corpus_id == corpus_id, - CorpusFile.id == corpus_file_id - ).first_or_404() - if not ( - corpus_file.corpus.user == current_user - or current_user.is_administrator() - ): + corpus_file = CorpusFile.query.get_or_404(corpus_file_id) + if corpus_file.corpus.id != corpus_id: + abort(404) + if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): abort(403) return send_from_directory( + os.path.dirname(corpus_file.path), + os.path.basename(corpus_file.path), as_attachment=True, attachment_filename=corpus_file.filename, - directory=os.path.dirname(corpus_file.path), - filename=os.path.basename(corpus_file.path) + mimetype=corpus_file.mimetype ) + + +@bp.route('/import', methods=['GET', 'POST']) +@login_required +def import_corpus(): + abort(503) + + +@bp.route('//export') +@login_required +def export_corpus(corpus_id): + abort(503) diff --git a/app/corpora/tasks.py b/app/corpora/tasks.py deleted file mode 100644 index c914a25a..00000000 --- a/app/corpora/tasks.py +++ /dev/null @@ -1,34 +0,0 @@ -from app import db -from app.decorators import background -from app.models import Corpus, CorpusFile - - -@background -def build_corpus(corpus_id, *args, **kwargs): - app = kwargs['app'] - with app.app_context(): - corpus = Corpus.query.get(corpus_id) - if corpus is None: - raise Exception(f'Corpus {corpus_id} not found') - corpus.build() - db.session.commit() - - -@background -def delete_corpus(corpus_id, *args, **kwargs): - with kwargs['app'].app_context(): - corpus = Corpus.query.get(corpus_id) - if corpus is None: - raise Exception(f'Corpus {corpus_id} not found') - corpus.delete() - db.session.commit() - - -@background -def delete_corpus_file(corpus_file_id, *args, **kwargs): - with kwargs['app'].app_context(): - corpus_file = CorpusFile.query.get(corpus_file_id) - if corpus_file is None: - raise Exception(f'Corpus file {corpus_file_id} not found') - corpus_file.delete() - db.session.commit() diff --git a/app/daemon/job_utils.py b/app/daemon/job_utils.py index 02f6bb9e..38d6c48b 100644 --- a/app/daemon/job_utils.py +++ b/app/daemon/job_utils.py @@ -1,4 +1,4 @@ -from app import db, docker_client +from app import db, docker_client, hashids from app.models import ( Job, JobResult, @@ -89,7 +89,14 @@ def _create_job_service(job): input_mount = f'{input_mount_source}:{input_mount_target}:ro' mounts.append(input_mount) if job.service == 'tesseract-ocr-pipeline': - model = TesseractOCRModel.query.get(job.service_args['model']) + if isinstance(job.service_args['model'], str): + model_id = hashids.decode(job.service_args['model']) + elif isinstance(job.service_args['model'], int): + model_id = job.service_args['model'] + else: + job.status = JobStatus.FAILED + return + model = TesseractOCRModel.query.get(model_id) if model is None: job.status = JobStatus.FAILED return diff --git a/app/errors/handlers.py b/app/errors/handlers.py index a5e49f90..cc6c9268 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -1,52 +1,11 @@ -from flask import render_template, request, jsonify +from flask import render_template, request +from werkzeug.exceptions import HTTPException from . import bp -@bp.app_errorhandler(403) -def forbidden(e): +@bp.errorhandler(HTTPException) +def generic_error_handler(e): if (request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html): - response = jsonify({'error': 'forbidden'}) - response.status_code = 403 - return response - return render_template('errors/403.html.j2', title='Forbidden'), 403 - - -@bp.app_errorhandler(404) -def not_found(e): - if (request.accept_mimetypes.accept_json - and not request.accept_mimetypes.accept_html): - response = jsonify({'error': 'not found'}) - response.status_code = 404 - return response - return render_template('errors/404.html.j2', title='Not Found'), 404 - - -@bp.app_errorhandler(413) -def payload_too_large(e): - if (request.accept_mimetypes.accept_json - and not request.accept_mimetypes.accept_html): - response = jsonify({'error': 'payload too large'}) - response.status_code = 413 - return response - return render_template('errors/413.html.j2', title='Payload Too Large'), 413 - - -@bp.app_errorhandler(500) -def internal_server_error(e): - if (request.accept_mimetypes.accept_json - and not request.accept_mimetypes.accept_html): - response = jsonify({'error': 'internal server error'}) - response.status_code = 500 - return response - return render_template('errors/500.html.j2', title='Internal Server Error'), 500 - - -@bp.app_errorhandler(503) -def service_unavailable_error(e): - if (request.accept_mimetypes.accept_json - and not request.accept_mimetypes.accept_html): - response = jsonify({'error': 'service unavailable'}) - response.status_code = 503 - return response - return render_template('errors/503.html.j2', title='Service Unavailable'), 503 + return {'errors': {'message': e.description}}, e.code + return render_template('errors/error.html.j2', error=e), e.code diff --git a/app/jobs/routes.py b/app/jobs/routes.py index 8d78aa6b..7dae80e1 100644 --- a/app/jobs/routes.py +++ b/app/jobs/routes.py @@ -1,17 +1,16 @@ -from app.decorators import admin_required -from app.models import Job, JobInput, JobResult, JobStatus from flask import ( abort, - flash, - redirect, + current_app, render_template, - send_from_directory, - url_for + send_from_directory ) from flask_login import current_user, login_required -from . import bp -from . import tasks +from threading import Thread import os +from app import db +from app.decorators import admin_required +from app.models import Job, JobInput, JobResult, JobStatus +from . import bp @bp.route('/') @@ -27,35 +26,24 @@ def job(job_id): ) -@bp.route('//delete') +@bp.route('/', 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) - tasks.delete_job(job_id) - flash(f'Job "{job.title}" marked for deletion', 'job') - return redirect(url_for('main.dashboard')) - - -@bp.route('//inputs//download') -@login_required -def download_job_input(job_id, job_input_id): - job_input = JobInput.query.filter( - JobInput.job_id == job_id, - JobInput.id == job_input_id - ).first_or_404() - if not ( - job_input.job.user == current_user - or current_user.is_administrator() - ): - abort(403) - return send_from_directory( - as_attachment=True, - attachment_filename=job_input.filename, - directory=os.path.dirname(job_input.path), - filename=os.path.basename(job_input.path) + thread = Thread( + target=_delete_job, + args=(current_app._get_current_object(), job_id) ) + thread.start() + return {}, 202 @bp.route('//log') @@ -64,48 +52,65 @@ def download_job_input(job_id, job_input_id): def job_log(job_id): job = Job.query.get_or_404(job_id) if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: - flash( - f'Can\'t restart job "{job.title}": Status is not "Completed/Failed"', # noqa - category='error' - ) - return send_from_directory( - attachment_filename=f'job_{job.hashid}_log.txt', - directory=os.path.join(job.path, 'pipeline_data'), - filename=os.path.join('logs', 'pyflow_log.txt') - ) + 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('//restart') +@bp.route('//restart', methods=['POST']) @login_required -@admin_required -def restart(job_id): +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 job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: - flash( - f'Can\'t restart job "{job.title}": Status is not "Completed/Failed"', # noqa - category='error' - ) - else: - tasks.restart_job(job_id) - flash(f'Job "{job.title}" marked to get restarted', category='job') - return redirect(url_for('.job', job_id=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('//inputs//download') +@login_required +def download_job_input(job_id, job_input_id): + job_input = JobInput.query.get_or_404(job_input_id) + if job_input.job.id != job_id: + abort(404) + if not (job_input.job.user == current_user or current_user.is_administrator()): + abort(403) + return send_from_directory( + os.path.dirname(job_input.path), + os.path.basename(job_input.path), + as_attachment=True, + attachment_filename=job_input.filename, + mimetype=job_input.mimetype + ) @bp.route('//results//download') @login_required def download_job_result(job_id, job_result_id): - job_result = JobResult.query.filter( - JobResult.job_id == job_id, - JobResult.id == job_result_id - ).first_or_404() - if not ( - job_result.job.user == current_user - or current_user.is_administrator() - ): + job_result = JobResult.query.get_or_404(job_result_id) + if job_result.job.id != job_id: + abort(404) + if not (job_result.job.user == current_user or current_user.is_administrator()): abort(403) return send_from_directory( + os.path.dirname(job_result.path), + os.path.basename(job_result.path), as_attachment=True, attachment_filename=job_result.filename, - directory=os.path.dirname(job_result.path), - filename=os.path.basename(job_result.path) + mimetype=job_result.mimetype ) diff --git a/app/jobs/tasks.py b/app/jobs/tasks.py deleted file mode 100644 index 1738b0cd..00000000 --- a/app/jobs/tasks.py +++ /dev/null @@ -1,27 +0,0 @@ -from app import db -from app.decorators import background -from app.models import Job - - -@background -def delete_job(job_id, *args, **kwargs): - with kwargs['app'].app_context(): - job = Job.query.get(job_id) - if job is None: - raise Exception(f'Job {job_id} not found') - job.delete() - db.session.commit() - - -@background -def restart_job(job_id, *args, **kwargs): - with kwargs['app'].app_context(): - job = Job.query.get(job_id) - if job is None: - raise Exception(f'Job {job_id} not found') - try: - job.restart() - except Exception: - pass - else: - db.session.commit() diff --git a/app/main/routes.py b/app/main/routes.py index cf87f0b5..1e7665a3 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -1,30 +1,27 @@ -from app.auth.forms import LoginForm -from app.models import User from flask import flash, redirect, render_template, url_for from flask_login import login_required, login_user +from app.auth.forms import LoginForm +from app.models import User from . import bp -@bp.route('/', methods=['GET', 'POST']) +@bp.route('', methods=['GET', 'POST']) def index(): form = LoginForm(prefix='login-form') if form.validate_on_submit(): - user = User.query.filter_by(username=form.user.data).first() - if user is None: - user = User.query.filter_by(email=form.user.data.lower()).first() - if user is not None and user.verify_password(form.password.data): + user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first() + if user and user.verify_password(form.password.data): login_user(user, form.remember_me.data) + flash('You have been logged in') return redirect(url_for('.dashboard')) - flash('Invalid email/username or password.') + flash('Invalid email/username or password', category='error') + redirect(url_for('.index')) return render_template('main/index.html.j2', form=form, title='nopaque') @bp.route('/faq') def faq(): - return render_template( - 'main/faq.html.j2', - title='Frequently Asked Questions' - ) + return render_template('main/faq.html.j2', title='Frequently Asked Questions') @bp.route('/dashboard') @@ -45,10 +42,7 @@ def news(): @bp.route('/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') diff --git a/app/models.py b/app/models.py index f65cf84f..9ae31b1c 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,3 @@ -from app import db, hashids, login, mail, socketio -from app.converters.vrt import normalize_vrt_file -from app.email import create_message from datetime import datetime, timedelta from enum import Enum, IntEnum from flask import current_app, url_for @@ -9,7 +6,7 @@ from flask_login import UserMixin from time import sleep from tqdm import tqdm from werkzeug.security import generate_password_hash, check_password_hash -import base64 +from werkzeug.utils import secure_filename import json import jwt import os @@ -17,6 +14,9 @@ import requests import shutil import xml.etree.ElementTree as ET import yaml +from app import db, hashids, login, mail, socketio +from app.converters.vrt import normalize_vrt_file +from app.email import create_message TRANSKRIBUS_HTR_MODELS = \ @@ -77,14 +77,17 @@ class FileMixin: ''' creation_date = db.Column(db.DateTime, default=datetime.utcnow) filename = db.Column(db.String(255)) - last_edited_date = db.Column(db.DateTime, default=datetime.utcnow) + last_edited_date = db.Column(db.DateTime) mimetype = db.Column(db.String(255)) - def file_mixin_to_dict(self, backrefs=False, relationships=False): + def file_mixin_to_json(self, backrefs=False, relationships=False): return { - 'creation_date': self.creation_date.isoformat() + 'Z', + 'creation_date': f'{self.creation_date.isoformat()}Z', 'filename': self.filename, - 'last_edited_date': self.last_edited_date.isoformat() + 'Z', + 'last_edited_date': ( + None if self.last_edited_date is None + else f'{self.last_edited_date.isoformat()}Z' + ), 'mimetype': self.mimetype } # endregion mixins @@ -123,10 +126,8 @@ class ContainerColumn(db.TypeDecorator): def process_bind_param(self, value, dialect): if isinstance(value, self.container_type): return json.dumps(value) - elif ( - isinstance(value, str) - and isinstance(json.loads(value), self.container_type) - ): + elif (isinstance(value, str) + and isinstance(json.loads(value), self.container_type)): return value else: return TypeError() @@ -145,17 +146,12 @@ class Role(HashidMixin, db.Model): # Primary key id = db.Column(db.Integer, primary_key=True) # Fields - default = db.Column(db.Boolean, default=False, index=True) name = db.Column(db.String(64), unique=True) - permissions = db.Column(db.Integer) + default = db.Column(db.Boolean, default=False, index=True) + permissions = db.Column(db.Integer, default=0) # Relationships users = db.relationship('User', backref='role', lazy='dynamic') - def __init__(self, **kwargs): - super().__init__(**kwargs) - if self.permissions is None: - self.permissions = 0 - def __repr__(self): return f'' @@ -173,19 +169,19 @@ class Role(HashidMixin, db.Model): def reset_permissions(self): self.permissions = 0 - def to_dict(self, backrefs=False, relationships=False): - dict_role = { + def to_json(self, backrefs=False, relationships=False): + _json = { 'id': self.hashid, 'default': self.default, 'name': self.name, 'permissions': self.permissions } if relationships: - dict_role['users'] = { - x.hashid: x.to_dict(backrefs=False, relationships=True) + _json['users'] = { + x.hashid: x.to_json(relationships=True) for x in self.users } - return dict_role + return _json @staticmethod def insert_defaults(): @@ -197,7 +193,8 @@ class Role(HashidMixin, db.Model): Permission.ADMINISTRATE, Permission.CONTRIBUTE, Permission.USE_API - ] + ], + 'System user': [] } default_role_name = 'User' for role_name, permissions in roles.items(): @@ -212,29 +209,6 @@ class Role(HashidMixin, db.Model): db.session.commit() -class Token(db.Model): - __tablename__ = 'tokens' - # Primary key - id = db.Column(db.Integer, primary_key=True) - # Foreign keys - user_id = db.Column(db.Integer, db.ForeignKey('users.id')) - # Fields - access_token = db.Column(db.String(64), nullable=False, index=True) - access_expiration = db.Column(db.DateTime, nullable=False) - refresh_token = db.Column(db.String(64), nullable=False, index=True) - refresh_expiration = db.Column(db.DateTime, nullable=False) - - # def generate(self): - # header = {'alg': 'HS256', 'exp': int(time.time()) + expiration} - # payload = {'confirm': self.hashid} - # return jwt.encode(header, payload, current_app.config['SECRET_KEY']) - # self.access_token = secrets.token_urlsafe() - # self.access_expiration = datetime.utcnow() + \ - # timedelta(minutes=current_app.config['ACCESS_TOKEN_MINUTES']) - # self.refresh_token = secrets.token_urlsafe() - # self.refresh_expiration = datetime.utcnow() + \ - # timedelta(days=current_app.config['REFRESH_TOKEN_DAYS']) - class User(HashidMixin, UserMixin, db.Model): __tablename__ = 'users' # Primary key @@ -242,19 +216,17 @@ class User(HashidMixin, UserMixin, db.Model): # Foreign keys role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) # Fields - confirmed = db.Column(db.Boolean, default=False) - email = db.Column(db.String(254), unique=True, index=True) - last_seen = db.Column(db.DateTime(), default=datetime.utcnow) - member_since = db.Column(db.DateTime(), default=datetime.utcnow) + email = db.Column(db.String(254), index=True, unique=True) + username = db.Column(db.String(64), index=True, unique=True) password_hash = db.Column(db.String(128)) - token = db.Column(db.String(32), index=True, unique=True) - token_expiration = db.Column(db.DateTime) - username = db.Column(db.String(64), unique=True, index=True) + confirmed = db.Column(db.Boolean, default=False) + member_since = db.Column(db.DateTime(), default=datetime.utcnow) setting_dark_mode = db.Column(db.Boolean, default=False) setting_job_status_mail_notification_level = db.Column( IntEnumColumn(UserSettingJobStatusMailNotificationLevel), default=UserSettingJobStatusMailNotificationLevel.END ) + last_seen = db.Column(db.DateTime()) # Backrefs: role: Role # Relationships tesseract_ocr_models = db.relationship( @@ -311,131 +283,35 @@ class User(HashidMixin, UserMixin, db.Model): return os.path.join( current_app.config.get('NOPAQUE_DATA_DIR'), 'users', str(self.id)) - def can(self, permission): - return self.role.has_permission(permission) - - def confirm_user(self, token): - try: - payload = jwt.decode( - token, - current_app.config['SECRET_KEY'], - algorithms=['HS256'], - issuer=current_app.config['SERVER_NAME'], - options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']} - ) - except jwt.PyJWTError: - return False - if payload.get('purpose') != 'confirm_user': - return False - if payload.get('sub') != self.hashid: - return False - self.confirmed = True - db.session.add(self) - return True - - def delete(self): - shutil.rmtree(self.path, ignore_errors=True) - db.session.delete(self) - - def generate_confirm_user_token(self, expiration=3600): - utc_now = datetime.utcnow() - payload = { - 'exp': utc_now + timedelta(seconds=expiration), - 'iat': utc_now, - 'iss': current_app.config['SERVER_NAME'], - 'purpose': 'confirm_user', - 'sub': self.hashid - } - return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') - - def generate_password_reset_token(self, expiration=3600): - utc_now = datetime.utcnow() - payload = { - 'exp': utc_now + timedelta(seconds=expiration), - 'iat': utc_now, - 'iss': current_app.config['SERVER_NAME'], - 'purpose': 'reset_password', - 'sub': self.hashid - } - return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') - - def get_token(self, expires_in=3600): - now = datetime.utcnow() - if self.token and self.token_expiration > now + timedelta(seconds=60): - return self.token - self.token = base64.b64encode(os.urandom(24)).decode('utf-8') - self.token_expiration = now + timedelta(seconds=expires_in) - db.session.add(self) - return self.token - - def is_administrator(self): - return self.can(Permission.ADMINISTRATE) - - def makedirs(self): - os.mkdir(self.path) - os.mkdir(os.path.join(self.path, 'tesseract_ocr_models')) - os.mkdir(os.path.join(self.path, 'corpora')) - os.mkdir(os.path.join(self.path, 'jobs')) - - def revoke_token(self): - self.token_expiration = datetime.utcnow() - timedelta(seconds=1) - - def to_dict(self, backrefs=False, relationships=False): - dict_user = { - 'id': self.hashid, - 'role_id': self.role.hashid, - 'confirmed': self.confirmed, - 'email': self.email, - 'last_seen': self.last_seen.isoformat() + 'Z', - 'member_since': self.member_since.isoformat() + 'Z', - 'username': self.username, - 'settings': { - 'dark_mode': self.setting_dark_mode, - 'job_status_mail_notification_level': - self.setting_job_status_mail_notification_level.name - } - } - if backrefs: - dict_user['role'] = self.role.to_dict( - backrefs=True, relationships=False) - if relationships: - dict_user['corpora'] = { - x.hashid: x.to_dict(backrefs=False, relationships=True) - for x in self.corpora - } - dict_user['jobs'] = { - x.hashid: x.to_dict(backrefs=False, relationships=True) - for x in self.jobs - } - dict_user['tesseract_ocr_models'] = { - x.hashid: x.to_dict(backrefs=False, relationships=True) - for x in self.tesseract_ocr_models - } - return dict_user - - def verify_password(self, password): - return check_password_hash(self.password_hash, password) - @staticmethod - def check_token(token): - user = User.query.filter_by(token=token).first() - if user is None or user.token_expiration < datetime.utcnow(): - return None - return user - - @staticmethod - def insert_defaults(): - if User.query.filter_by(username='nopaque').first() is not None: - return - user = User(username='nopaque') + def create(**kwargs): + user = User(**kwargs) db.session.add(user) db.session.flush(objects=[user]) db.session.refresh(user) try: - user.makedirs() + os.mkdir(user.path) + os.mkdir(os.path.join(user.path, 'tesseract_ocr_models')) + os.mkdir(os.path.join(user.path, 'corpora')) + os.mkdir(os.path.join(user.path, 'jobs')) except OSError as e: current_app.logger.error(e) db.session.rollback() + raise e + return user + + @staticmethod + def insert_defaults(): + nopaque_user = User.query.filter_by(username='nopaque').first() + system_user_role = Role.query.filter_by(name='System user').first() + if nopaque_user is None: + nopaque_user = User.create( + username='nopaque', + role=system_user_role + ) + db.session.add(nopaque_user) + elif nopaque_user.role != system_user_role: + nopaque_user.role = system_user_role db.session.commit() @staticmethod @@ -450,11 +326,9 @@ class User(HashidMixin, UserMixin, db.Model): ) except jwt.PyJWTError: return False - if payload.get('purpose') != 'reset_password': + if payload.get('purpose') != 'User.reset_password': return False user_hashid = payload.get('sub') - if user_hashid is None: - return False user_id = hashids.decode(user_hashid) user = User.query.get(user_id) if user is None: @@ -463,6 +337,107 @@ class User(HashidMixin, UserMixin, db.Model): db.session.add(user) return True + def can(self, permission): + return self.role.has_permission(permission) + + def confirm(self, confirmation_token): + try: + payload = jwt.decode( + confirmation_token, + current_app.config['SECRET_KEY'], + algorithms=['HS256'], + issuer=current_app.config['SERVER_NAME'], + options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']} + ) + current_app.logger.warning(payload) + except jwt.PyJWTError: + return False + if payload.get('purpose') != 'user.confirm': + return False + if payload.get('sub') != self.hashid: + return False + self.confirmed = True + db.session.add(self) + return True + + def delete(self): + shutil.rmtree(self.path, ignore_errors=True) + db.session.delete(self) + + def generate_confirm_token(self, expiration=3600): + now = datetime.utcnow() + payload = { + 'exp': now + timedelta(seconds=expiration), + 'iat': now, + 'iss': current_app.config['SERVER_NAME'], + 'purpose': 'user.confirm', + 'sub': self.hashid + } + return jwt.encode( + payload, + current_app.config['SECRET_KEY'], + algorithm='HS256' + ) + + def generate_reset_password_token(self, expiration=3600): + now = datetime.utcnow() + payload = { + 'exp': now + timedelta(seconds=expiration), + 'iat': now, + 'iss': current_app.config['SERVER_NAME'], + 'purpose': 'User.reset_password', + 'sub': self.hashid + } + return jwt.encode( + payload, + current_app.config['SECRET_KEY'], + algorithm='HS256' + ) + + def is_administrator(self): + return self.can(Permission.ADMINISTRATE) + + def ping(self): + self.last_seen = datetime.utcnow() + + def verify_password(self, password): + if self.role.name == 'System user': + return False + return check_password_hash(self.password_hash, password) + + def to_json(self, backrefs=False, relationships=False): + _json = { + 'id': self.hashid, + 'confirmed': self.confirmed, + 'email': self.email, + 'last_seen': ( + None if self.last_seen is None + else f'{self.last_seen.isoformat()}Z' + ), + 'member_since': f'{self.member_since.isoformat()}Z', + 'username': self.username, + 'settings': { + 'dark_mode': self.setting_dark_mode, + 'job_status_mail_notification_level': \ + self.setting_job_status_mail_notification_level.name + } + } + if backrefs: + _json['role'] = self.role.to_json(backrefs=True) + if relationships: + _json['corpora'] = { + x.hashid: x.to_json(relationships=True) + for x in self.corpora + } + _json['jobs'] = { + x.hashid: x.to_json(relationships=True) + for x in self.jobs + } + _json['tesseract_ocr_models'] = { + x.hashid: x.to_json(relationships=True) + for x in self.tesseract_ocr_models + } + return _json class TesseractOCRModel(FileMixin, HashidMixin, db.Model): __tablename__ = 'tesseract_ocr_models' @@ -471,15 +446,15 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model): # Foreign keys user_id = db.Column(db.Integer, db.ForeignKey('users.id')) # Fields - compatible_service_versions = db.Column(ContainerColumn(list, 255)) + title = db.Column(db.String(64)) description = db.Column(db.String(255)) + version = db.Column(db.String(16)) + compatible_service_versions = db.Column(ContainerColumn(list, 255)) publisher = db.Column(db.String(128)) publisher_url = db.Column(db.String(512)) publishing_url = db.Column(db.String(512)) publishing_year = db.Column(db.Integer) shared = db.Column(db.Boolean, default=False) - title = db.Column(db.String(64)) - version = db.Column(db.String(16)) # Backrefs: user: User @property @@ -490,30 +465,9 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model): str(self.id) ) - def to_dict(self, backrefs=False, relationships=False): - dict_tesseract_ocr_model = { - 'id': self.hashid, - 'user_id': self.user.hashid, - 'compatible_service_versions': self.compatible_service_versions, - 'description': self.description, - 'publisher': self.publisher, - 'publisher_url': self.publisher_url, - 'publishing_url': self.publishing_url, - 'publishing_year': self.publishing_year, - 'shared': self.shared, - 'title': self.title, - **self.file_mixin_to_dict() - } - if backrefs: - dict_tesseract_ocr_model['user'] = self.user.to_dict( - backrefs=True, relationships=False) - if relationships: - pass - return dict_tesseract_ocr_model - @staticmethod def insert_defaults(): - user = User.query.filter_by(username='nopaque').first() + nopaque_user = User.query.filter_by(username='nopaque').first() defaults_file = os.path.join( os.path.dirname(os.path.abspath(__file__)), 'TesseractOCRModel.defaults.yml' @@ -542,7 +496,7 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model): publishing_year=m['publishing_year'], shared=True, title=m['title'], - user=user, + user=nopaque_user, version=m['version'] ) db.session.add(model) @@ -566,6 +520,23 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model): pbar.close() db.session.commit() + def to_json(self, backrefs=False, relationships=False): + _json = { + 'id': self.hashid, + 'compatible_service_versions': self.compatible_service_versions, + 'description': self.description, + 'publisher': self.publisher, + 'publisher_url': self.publisher_url, + 'publishing_url': self.publishing_url, + 'publishing_year': self.publishing_year, + 'shared': self.shared, + 'title': self.title, + **self.file_mixin_to_json() + } + if backrefs: + _json['user'] = self.user.to_json(backrefs=True) + return _json + class TranskribusHTRModel(HashidMixin, db.Model): __tablename__ = 'transkribus_htr_models' @@ -579,23 +550,9 @@ class TranskribusHTRModel(HashidMixin, db.Model): transkribus_name = db.Column(db.String(64)) # Backrefs: user: User - def to_dict(self, backrefs=False, relationships=False): - dict_tesseract_ocr_model = { - 'id': self.hashid, - 'user_id': self.user.hashid, - 'shared': self.shared, - 'transkribus_model_id': self.transkribus_model_id, - } - if backrefs: - dict_tesseract_ocr_model['user'] = \ - self.user.to_dict(backrefs=True, relationships=False) - if relationships: - pass - return dict_tesseract_ocr_model - @staticmethod def insert_defaults(): - user = User.query.filter_by(username='nopaque').first() + nopaque_user = User.query.filter_by(username='nopaque').first() # models = [ # m for m in TRANSKRIBUS_HTR_MODELS if True # and 'creator' in m and m['creator'] == 'Transkribus Team' @@ -610,11 +567,22 @@ class TranskribusHTRModel(HashidMixin, db.Model): model = TranskribusHTRModel( shared=True, transkribus_model_id=m['modelId'], - user=user, + user=nopaque_user, ) db.session.add(model) db.session.commit() + def to_json(self, backrefs=False, relationships=False): + _json = { + 'id': self.hashid, + 'user_id': self.user.hashid, + 'shared': self.shared, + 'transkribus_model_id': self.transkribus_model_id, + } + if backrefs: + _json['user'] = self.user.to_json(backrefs=True) + return _json + class JobInput(FileMixin, HashidMixin, db.Model): __tablename__ = 'job_inputs' @@ -628,7 +596,7 @@ class JobInput(FileMixin, HashidMixin, db.Model): return f'' @property - def download_url(self): + def content_url(self): return url_for( 'jobs.download_job_input', job_id=self.job.id, @@ -643,19 +611,6 @@ class JobInput(FileMixin, HashidMixin, db.Model): def path(self): return os.path.join(self.job.path, 'inputs', str(self.id)) - def to_dict(self, backrefs=False, relationships=False): - dict_job_input = { - 'id': self.hashid, - 'job_id': self.job.hashid, - 'download_url': self.download_url, - 'url': self.url, - **self.file_mixin_to_dict() - } - if backrefs: - dict_job_input['job'] = self.job.to_dict( - backrefs=True, relationships=False) - return dict_job_input - @property def url(self): return url_for( @@ -672,6 +627,35 @@ class JobInput(FileMixin, HashidMixin, db.Model): def user_id(self): return self.job.user_id + @staticmethod + def create(input_file, **kwargs): + filename = kwargs.get('filename', input_file.filename) + mimetype = kwargs.get('mimetype', input_file.mimetype) + job_input = JobInput( + filename=secure_filename(filename), + mimetype=mimetype, + **kwargs + ) + db.session.add(job_input) + db.session.flush(objects=[job_input]) + db.session.refresh(job_input) + try: + input_file.save(job_input.path) + except OSError as e: + current_app.logger.error(e) + db.session.rollback() + raise e + return job_input + + def to_json(self, backrefs=False, relationships=False): + _json = { + 'id': self.hashid, + **self.file_mixin_to_json() + } + if backrefs: + _json['job'] = self.job.to_json(backrefs=True) + return _json + class JobResult(FileMixin, HashidMixin, db.Model): __tablename__ = 'job_results' @@ -702,21 +686,6 @@ class JobResult(FileMixin, HashidMixin, db.Model): def path(self): return os.path.join(self.job.path, 'results', str(self.id)) - def to_dict(self, backrefs=False, relationships=False): - dict_job_result = { - 'id': self.hashid, - 'job_id': self.job.hashid, - 'description': self.description, - 'download_url': self.download_url, - 'url': self.url, - **self.file_mixin_to_dict( - backrefs=backrefs, relationships=relationships) - } - if backrefs: - dict_job_result['job'] = self.job.to_dict( - backrefs=True, relationships=False) - return dict_job_result - @property def url(self): return url_for( @@ -733,6 +702,39 @@ class JobResult(FileMixin, HashidMixin, db.Model): def user_id(self): return self.job.user_id + @staticmethod + def create(input_file, **kwargs): + filename = kwargs.get('filename', input_file.filename) + mimetype = kwargs.get('mimetype', input_file.mimetype) + job_result = JobResult( + filename=secure_filename(filename), + mimetype=mimetype, + **kwargs + ) + db.session.add(job_result) + db.session.flush(objects=[job_result]) + db.session.refresh(job_result) + try: + input_file.save(job_result.path) + except OSError as e: + current_app.logger.error(e) + db.session.rollback() + raise e + return job_result + + def to_json(self, backrefs=False, relationships=False): + _json = { + 'id': self.hashid, + 'description': self.description, + **self.file_mixin_to_json( + backrefs=backrefs, + relationships=relationships + ) + } + if backrefs: + _json['job'] = self.job.to_json(backrefs=True) + return _json + class Job(HashidMixin, db.Model): ''' @@ -744,7 +746,8 @@ class Job(HashidMixin, db.Model): # Foreign keys user_id = db.Column(db.Integer, db.ForeignKey('users.id')) # Fields - creation_date = db.Column(db.DateTime(), default=datetime.utcnow) + creation_date = \ + db.Column(db.DateTime(), default=datetime.utcnow) description = db.Column(db.String(255)) end_date = db.Column(db.DateTime()) service = db.Column(db.String(64)) @@ -789,10 +792,26 @@ class Job(HashidMixin, db.Model): def user_hashid(self): return self.user.hashid + @staticmethod + def create(**kwargs): + job = Job(**kwargs) + db.session.add(job) + db.session.flush(objects=[job]) + db.session.refresh(job) + try: + os.mkdir(job.path) + os.mkdir(os.path.join(job.path, 'inputs')) + os.mkdir(os.path.join(job.path, 'pipeline_data')) + os.mkdir(os.path.join(job.path, 'results')) + except OSError as e: + current_app.logger.error(e) + db.session.rollback() + raise e + return job + + def delete(self): - ''' - 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 self.status = JobStatus.CANCELING db.session.commit() @@ -803,36 +822,34 @@ class Job(HashidMixin, db.Model): db.session.commit() sleep(1) db.session.refresh(self) - shutil.rmtree(self.path, ignore_errors=True) + try: + shutil.rmtree(self.path) + except OSError as e: + current_app.logger.error(e) + db.session.rollback() + raise e db.session.delete(self) - def makedirs(self): - os.mkdir(self.path) - os.mkdir(os.path.join(self.path, 'inputs')) - os.mkdir(os.path.join(self.path, 'pipeline_data')) - os.mkdir(os.path.join(self.path, 'results')) - def restart(self): - ''' - Restart a job - only if the status is complete or failed - ''' - - if self.status not in [JobStatus.COMPLETED, JobStatus.FAILED]: # noqa - raise Exception('Could not restart job: status is not "completed/failed"') # noqa + ''' Restart a job - only if the status is failed ''' + if self.status != JobStatus.FAILED: + raise Exception('Job status is not "failed"') shutil.rmtree(os.path.join(self.path, 'results'), ignore_errors=True) - shutil.rmtree(os.path.join(self.path, 'pyflow.data'), ignore_errors=True) # noqa + shutil.rmtree(os.path.join(self.path, 'pyflow.data'), ignore_errors=True) for result in self.results: db.session.delete(result) self.end_date = None self.status = JobStatus.SUBMITTED - def to_dict(self, backrefs=False, relationships=False): - dict_job = { + def to_json(self, backrefs=False, relationships=False): + _json = { 'id': self.hashid, - 'user_id': self.user.hashid, - 'creation_date': self.creation_date.isoformat() + 'Z', + 'creation_date': f'{self.creation_date.isoformat()}Z', 'description': self.description, - 'end_date': None if self.end_date is None else f'{self.end_date.isoformat()}Z', # noqa + 'end_date': ( + None if self.end_date is None + else f'{self.end_date.isoformat()}Z' + ), 'service': self.service, 'service_args': self.service_args, 'service_version': self.service_version, @@ -841,18 +858,17 @@ class Job(HashidMixin, db.Model): 'url': self.url } if backrefs: - dict_job['user'] = self.user.to_dict( - backrefs=True, relationships=False) + _json['user'] = self.user.to_json(backrefs=True) if relationships: - dict_job['inputs'] = { - x.hashid: x.to_dict(backrefs=False, relationships=True) + _json['inputs'] = { + x.hashid: x.to_json(relationships=True) for x in self.inputs } - dict_job['results'] = { - x.hashid: x.to_dict(backrefs=False, relationships=True) + _json['results'] = { + x.hashid: x.to_json(relationships=True) for x in self.results } - return dict_job + return _json class CorpusFile(FileMixin, HashidMixin, db.Model): @@ -862,8 +878,10 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): # Foreign keys corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) # Fields - address = db.Column(db.String(255)) author = db.Column(db.String(255)) + publishing_year = db.Column(db.Integer) + title = db.Column(db.String(255)) + address = db.Column(db.String(255)) booktitle = db.Column(db.String(255)) chapter = db.Column(db.String(255)) editor = db.Column(db.String(255)) @@ -871,9 +889,7 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): journal = db.Column(db.String(255)) pages = db.Column(db.String(255)) publisher = db.Column(db.String(255)) - publishing_year = db.Column(db.Integer) school = db.Column(db.String(255)) - title = db.Column(db.String(255)) # Backrefs: corpus: Corpus @property @@ -919,11 +935,9 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): db.session.delete(self) self.corpus.status = CorpusStatus.UNPREPARED - def to_dict(self, backrefs=False, relationships=False): - dict_corpus_file = { + def to_json(self, backrefs=False, relationships=False): + _json = { 'id': self.hashid, - 'corpus_id': self.corpus.hashid, - 'download_url': self.download_url, 'url': self.url, 'address': self.address, 'author': self.author, @@ -937,14 +951,34 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): 'publishing_year': self.publishing_year, 'school': self.school, 'title': self.title, - **self.file_mixin_to_dict( - backrefs=backrefs, relationships=relationships) + **self.file_mixin_to_json( + backrefs=backrefs, + relationships=relationships + ) } if backrefs: - dict_corpus_file['corpus'] = self.corpus.to_dict( - backrefs=True, relationships=False) - return dict_corpus_file + _json['corpus'] = self.corpus.to_json(backrefs=True) + return _json + @staticmethod + def create(input_file, **kwargs): + filename = kwargs.pop('filename', input_file.filename) + mimetype = kwargs.pop('mimetype', input_file.mimetype) + corpus_file = CorpusFile( + filename=secure_filename(filename), + mimetype=mimetype, + **kwargs, + ) + db.session.add(corpus_file) + db.session.flush(objects=[corpus_file]) + db.session.refresh(corpus_file) + try: + input_file.save(corpus_file.path) + except OSError as e: + current_app.logger.error(e) + db.session.rollback() + raise e + return corpus_file class Corpus(HashidMixin, db.Model): ''' @@ -958,7 +992,7 @@ class Corpus(HashidMixin, db.Model): # Fields creation_date = db.Column(db.DateTime(), default=datetime.utcnow) description = db.Column(db.String(255)) - last_edited_date = db.Column(db.DateTime(), default=datetime.utcnow) + last_edited_date = db.Column(db.DateTime()) status = db.Column( IntEnumColumn(CorpusStatus), default=CorpusStatus.UNPREPARED @@ -1000,6 +1034,24 @@ class Corpus(HashidMixin, db.Model): def user_hashid(self): return self.user.hashid + @staticmethod + def create(**kwargs): + corpus = Corpus(**kwargs) + db.session.add(corpus) + db.session.flush(objects=[corpus]) + db.session.refresh(corpus) + try: + os.mkdir(corpus.path) + os.mkdir(os.path.join(corpus.path, 'files')) + os.mkdir(os.path.join(corpus.path, 'cwb')) + os.mkdir(os.path.join(corpus.path, 'cwb', 'data')) + os.mkdir(os.path.join(corpus.path, 'cwb', 'registry')) + except OSError as e: + current_app.logger.error(e) + db.session.rollback() + raise e + return corpus + def build(self): corpus_element = ET.fromstring('\n') for corpus_file in self.files: @@ -1011,18 +1063,21 @@ class Corpus(HashidMixin, db.Model): return element_tree = ET.parse(normalized_vrt_path) text_element = element_tree.getroot() - text_element.set('address', corpus_file.address or 'NULL') text_element.set('author', corpus_file.author) + text_element.set('title', corpus_file.title) + text_element.set( + 'publishing_year', + f'{corpus_file.publishing_year}' + ) + text_element.set('address', corpus_file.address or 'NULL') text_element.set('booktitle', corpus_file.booktitle or 'NULL') text_element.set('chapter', corpus_file.chapter or 'NULL') text_element.set('editor', corpus_file.editor or 'NULL') text_element.set('institution', corpus_file.institution or 'NULL') text_element.set('journal', corpus_file.journal or 'NULL') - text_element.set('pages', corpus_file.pages or 'NULL') + text_element.set('pages', f'{corpus_file.pages}' or 'NULL') text_element.set('publisher', corpus_file.publisher or 'NULL') - text_element.set('publishing_year', str(corpus_file.publishing_year)) # noqa text_element.set('school', corpus_file.school or 'NULL') - text_element.set('title', corpus_file.title) text_element.tail = '\n' # corpus_element.insert(1, text_element) corpus_element.append(text_element) @@ -1037,39 +1092,29 @@ class Corpus(HashidMixin, db.Model): shutil.rmtree(self.path, ignore_errors=True) db.session.delete(self) - def makedirs(self): - os.mkdir(self.path) - os.mkdir(os.path.join(self.path, 'files')) - os.mkdir(os.path.join(self.path, 'cwb')) - os.mkdir(os.path.join(self.path, 'cwb', 'data')) - os.mkdir(os.path.join(self.path, 'cwb', 'registry')) - - def to_dict(self, backrefs=False, relationships=False): - dict_corpus = { + def to_json(self, backrefs=False, relationships=False): + _json = { 'id': self.hashid, - 'user_id': self.user.hashid, - 'analysis_url': self.analysis_url, - 'url': self.url, - 'creation_date': self.creation_date.isoformat() + 'Z', + 'creation_date': f'{self.creation_date.isoformat()}Z', 'description': self.description, 'max_num_tokens': self.max_num_tokens, 'num_analysis_sessions': self.num_analysis_sessions, 'num_tokens': self.num_tokens, 'status': self.status.name, - 'last_edited_date': self.last_edited_date.isoformat() + 'Z', + 'last_edited_date': ( + None if self.last_edited_date is None + else f'{self.last_edited_date.isoformat()}Z' + ), 'title': self.title } if backrefs: - dict_corpus['user'] = self.user.to_dict( - backrefs=True, - relationships=False - ) + _json['user'] = self.user.to_json(backrefs=True) if relationships: - dict_corpus['files'] = { - x.hashid: x.to_dict(backrefs=False, relationships=True) + _json['files'] = { + x.hashid: x.to_json(relationships=True) for x in self.files } - return dict_corpus + return _json # endregion models @@ -1077,6 +1122,8 @@ class Corpus(HashidMixin, db.Model): # event_handlers # ############################################################################## # region event_handlers + + @db.event.listens_for(Corpus, 'after_delete') @db.event.listens_for(CorpusFile, 'after_delete') @db.event.listens_for(Job, 'after_delete') @@ -1096,7 +1143,7 @@ def ressource_after_delete(mapper, connection, ressource): @db.event.listens_for(JobInput, 'after_insert') @db.event.listens_for(JobResult, 'after_insert') def ressource_after_insert_handler(mapper, connection, ressource): - value = ressource.to_dict(backrefs=False, relationships=False) + value = ressource.to_json() for attr in mapper.relationships: value[attr.key] = {} jsonpatch = [ @@ -1119,7 +1166,7 @@ def ressource_after_update_handler(mapper, connection, ressource): if not attr.load_history().has_changes(): continue if isinstance(attr.value, datetime): - value = attr.value.isoformat() + 'Z' + value = f'{attr.value.isoformat()}Z' elif isinstance(attr.value, Enum): value = attr.value.name else: diff --git a/app/query_results_models.py b/app/query_results_models.py index 102d2825..132a4cc3 100644 --- a/app/query_results_models.py +++ b/app/query_results_models.py @@ -42,21 +42,17 @@ class QueryResult(FileMixin, HashidMixin, db.Model): shutil.rmtree(self.path, ignore_errors=True) db.session.delete(self) - def to_dict(self, backrefs=False, relationships=False): - dict_query_result = { + def to_json(self, backrefs=False, relationships=False): + _json = { 'id': self.hashid, - 'user_id': self.user.hashid, - 'download_url': self.download_url, - 'url': self.url, 'corpus_title': self.query_metadata['corpus_name'], 'description': self.description, 'filename': self.filename, 'query': self.query_metadata['query'], 'query_metadata': self.query_metadata, 'title': self.title, - **self.file_mixin_to_dict( + **self.file_mixin_to_json( backrefs=backrefs, relationships=relationships) } if backrefs: - dict_query_result['user'] = self.user.to_dict( - backrefs=True, relationships=False) + _json['user'] = self.user.to_json(backrefs=True, relationships=False) diff --git a/app/services/forms.py b/app/services/forms.py index 8261d2ab..106c0f7f 100644 --- a/app/services/forms.py +++ b/app/services/forms.py @@ -1,4 +1,3 @@ -from app.models import TesseractOCRModel, TranskribusHTRModel from flask_login import current_user from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired @@ -10,19 +9,26 @@ from wtforms import ( SubmitField, ValidationError ) -from wtforms.validators import DataRequired, InputRequired, Length +from wtforms.validators import InputRequired, Length +from app.models import TesseractOCRModel, TranskribusHTRModel from . import SERVICES -class AddJobForm(FlaskForm): - description = StringField('Description', validators=[InputRequired(), Length(1, 255)]) - title = StringField('Title', validators=[InputRequired(), Length(1, 32)]) - version = SelectField('Version', validators=[DataRequired()]) +class CreateJobBaseForm(FlaskForm): + description = StringField( + 'Description', + validators=[InputRequired(), Length(max=255)] + ) + title = StringField( + 'Title', + validators=[InputRequired(), Length(max=32)] + ) + version = SelectField('Version', validators=[InputRequired()]) submit = SubmitField() -class AddFileSetupPipelineJobForm(AddJobForm): - images = MultipleFileField('File(s)', validators=[DataRequired()]) +class CreateFileSetupPipelineJobForm(CreateJobBaseForm): + images = MultipleFileField('File(s)', validators=[InputRequired()]) def validate_images(form, field): valid_mimetypes = ['image/jpeg', 'image/png', 'image/tiff'] @@ -39,18 +45,15 @@ class AddFileSetupPipelineJobForm(AddJobForm): self.version.default = service_manifest['latest_version'] -class AddTesseractOCRPipelineJobForm(AddJobForm): +class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm): binarization = BooleanField('Binarization') pdf = FileField('File', validators=[FileRequired()]) - model = SelectField('Model', validators=[DataRequired()]) + model = SelectField('Model', validators=[InputRequired()]) def validate_binarization(self, field): service_info = SERVICES['tesseract-ocr-pipeline']['versions'][self.version.data] if field.data: - if( - 'methods' not in service_info - or 'binarization' not in service_info['methods'] - ): + if not('methods' in service_info and 'binarization' in service_info['methods']): raise ValidationError('Binarization is not available') def validate_pdf(self, field): @@ -81,10 +84,10 @@ class AddTesseractOCRPipelineJobForm(AddJobForm): self.version.default = service_manifest['latest_version'] -class AddTranskribusHTRPipelineJobForm(AddJobForm): +class CreateTranskribusHTRPipelineJobForm(CreateJobBaseForm): binarization = BooleanField('Binarization') pdf = FileField('File', validators=[FileRequired()]) - model = SelectField('Model', validators=[DataRequired()]) + model = SelectField('Model', validators=[InputRequired()]) def validate_binarization(self, field): service_info = SERVICES['transkribus-htr-pipeline']['versions'][self.version.data] @@ -123,10 +126,10 @@ class AddTranskribusHTRPipelineJobForm(AddJobForm): self.version.default = service_manifest['latest_version'] -class AddSpacyNLPPipelineJobForm(AddJobForm): +class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm): encoding_detection = BooleanField('Encoding detection', render_kw={'disabled': True}) txt = FileField('File', validators=[FileRequired()]) - model = SelectField('Model', validators=[DataRequired()]) + model = SelectField('Model', validators=[InputRequired()]) def validate_encoding_detection(self, field): service_info = SERVICES['spacy-nlp-pipeline']['versions'][self.version.data] diff --git a/app/services/routes.py b/app/services/routes.py index 805dc9f4..9f5c81ef 100644 --- a/app/services/routes.py +++ b/app/services/routes.py @@ -1,3 +1,5 @@ +from flask import abort, current_app, flash, Markup, render_template, request +from flask_login import current_user, login_required from app import db, hashids from app.models import ( Job, @@ -7,26 +9,13 @@ from app.models import ( TRANSKRIBUS_HTR_MODELS, TranskribusHTRModel ) -from flask import ( - abort, - current_app, - flash, - make_response, - render_template, - request, - url_for -) -from flask_login import current_user, login_required -from werkzeug.utils import secure_filename -from . import bp -from . import SERVICES +from . import bp, SERVICES from .forms import ( - AddFileSetupPipelineJobForm, - AddTesseractOCRPipelineJobForm, - AddTranskribusHTRPipelineJobForm, - AddSpacyNLPPipelineJobForm + CreateFileSetupPipelineJobForm, + CreateTesseractOCRPipelineJobForm, + CreateTranskribusHTRPipelineJobForm, + CreateSpacyNLPPipelineJobForm ) -import json @bp.route('/file-setup-pipeline', methods=['GET', 'POST']) @@ -37,49 +26,32 @@ def file_setup_pipeline(): version = request.args.get('version', service_manifest['latest_version']) if version not in service_manifest['versions']: abort(404) - form = AddFileSetupPipelineJobForm(prefix='add-job-form', version=version) + form = CreateFileSetupPipelineJobForm(prefix='create-job-form', version=version) if form.is_submitted(): if not form.validate(): - return make_response(form.errors, 400) - service_args = {} - job = Job( - user=current_user, - description=form.description.data, - service=service, - service_args=service_args, - service_version=form.version.data, - title=form.title.data - ) - db.session.add(job) - db.session.flush(objects=[job]) - db.session.refresh(job) + response = {'errors': form.errors} + return response, 400 try: - job.makedirs() - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', 'error') - return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa - for image_file in form.images.data: - job_input = JobInput( - filename=secure_filename(image_file.filename), - job=job, - mimetype=image_file.mimetype + job = Job.create( + title=form.title.data, + description=form.description.data, + service=service, + service_args={}, + service_version=form.version.data, + user=current_user ) - db.session.add(job_input) - db.session.flush(objects=[job_input]) - db.session.refresh(job_input) + except OSError: + abort(500) + for input_file in form.images.data: try: - image_file.save(job_input.path) - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', 'error') - return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa + JobInput.create(input_file, job=job) + except OSError: + abort(500) job.status = JobStatus.SUBMITTED db.session.commit() - flash(f'Job "{job.title}" added', 'job') - return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201) # noqa + message = Markup(f'Job "{job.title}" created') + flash(message, 'job') + return {}, 201, {'Location': job.url} return render_template( 'services/file_setup_pipeline.html.j2', form=form, @@ -90,58 +62,41 @@ def file_setup_pipeline(): @bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST']) @login_required def tesseract_ocr_pipeline(): - service = 'tesseract-ocr-pipeline' - service_manifest = SERVICES[service] + service_name = 'tesseract-ocr-pipeline' + service_manifest = SERVICES[service_name] version = request.args.get('version', service_manifest['latest_version']) if version not in service_manifest['versions']: abort(404) - form = AddTesseractOCRPipelineJobForm(prefix='add-job-form', version=version) + form = CreateTesseractOCRPipelineJobForm(prefix='create-job-form', version=version) if form.is_submitted(): if not form.validate(): - return make_response(form.errors, 400) - service_args = {} - service_args['model'] = hashids.decode(form.model.data) - if form.binarization.data: - service_args['binarization'] = True - job = Job( - user=current_user, - description=form.description.data, - service=service, - service_args=service_args, - service_version=form.version.data, - title=form.title.data - ) - db.session.add(job) - db.session.flush(objects=[job]) - db.session.refresh(job) + response = {'errors': form.errors} + return response, 400 try: - job.makedirs() - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', 'error') - return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa - job_input = JobInput( - filename=secure_filename(form.pdf.data.filename), - job=job, - mimetype=form.pdf.data.mimetype - ) - db.session.add(job_input) - db.session.flush(objects=[job_input]) - db.session.refresh(job_input) + job = Job.create( + title=form.title.data, + description=form.description.data, + service=service_name, + service_args={ + 'binarization': form.binarization.data, + 'model': hashids.decode(form.model.data) + }, + service_version=form.version.data, + user=current_user + ) + except OSError: + abort(500) try: - form.pdf.data.save(job_input.path) - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', 'error') - return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa + JobInput.create(form.pdf.data, job=job) + except OSError: + abort(500) job.status = JobStatus.SUBMITTED db.session.commit() - flash(f'Job "{job.title}" added', 'job') - return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201) # noqa + message = Markup(f'Job "{job.title}" created') + flash(message, 'job') + return {}, 201, {'Location': job.url} tesseract_ocr_models = [ - x for x in TesseractOCRModel.query.filter().all() + x for x in TesseractOCRModel.query.all() if version in x.compatible_service_versions and (x.shared == True or x.user == current_user) ] return render_template( @@ -162,57 +117,40 @@ def transkribus_htr_pipeline(): version = request.args.get('version', service_manifest['latest_version']) if version not in service_manifest['versions']: abort(404) - form = AddTranskribusHTRPipelineJobForm(prefix='add-job-form', version=version) + form = CreateTranskribusHTRPipelineJobForm(prefix='create-job-form', version=version) if form.is_submitted(): if not form.validate(): - return make_response(form.errors, 400) - service_args = {} - service_args['model'] = hashids.decode(form.model.data) - if form.binarization.data: - service_args['binarization'] = True - job = Job( - user=current_user, - description=form.description.data, - service=service, - service_args=service_args, - service_version=form.version.data, - title=form.title.data - ) - db.session.add(job) - db.session.flush(objects=[job]) - db.session.refresh(job) + response = {'errors': form.errors} + return response, 400 try: - job.makedirs() - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', 'error') - return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa - job_input = JobInput( - filename=secure_filename(form.pdf.data.filename), - job=job, - mimetype=form.pdf.data.mimetype - ) - db.session.add(job_input) - db.session.flush(objects=[job_input]) - db.session.refresh(job_input) + job = Job.create( + title=form.title.data, + description=form.description.data, + service=service, + service_args={ + 'binarization': form.binarization.data, + 'model': hashids.decode(form.model.data) + }, + service_version=form.version.data, + user=current_user + ) + except OSError: + abort(500) try: - form.pdf.data.save(job_input.path) - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', 'error') - return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa + JobInput.create(form.pdf.data, job=job) + except OSError: + abort(500) job.status = JobStatus.SUBMITTED db.session.commit() - flash(f'Job "{job.title}" added', 'job') - return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201) # noqa + message = Markup(f'Job "{job.title}" created') + flash(message, 'job') + return {}, 201, {'Location': job.url} transkribus_htr_models = [ - x for x in TranskribusHTRModel.query.filter().all() + x for x in TranskribusHTRModel.query.all() if x.shared == True or x.user == current_user ] return render_template( - f'services/transkribus_htr_pipeline.html.j2', + 'services/transkribus_htr_pipeline.html.j2', form=form, title=service_manifest['name'], TRANSKRIBUS_HTR_MODELS=TRANSKRIBUS_HTR_MODELS, @@ -228,51 +166,34 @@ def spacy_nlp_pipeline(): version = request.args.get('version', SERVICES[service]['latest_version']) if version not in service_manifest['versions']: abort(404) - form = AddSpacyNLPPipelineJobForm(prefix='add-job-form', version=version) + form = CreateSpacyNLPPipelineJobForm(prefix='create-job-form', version=version) if form.is_submitted(): if not form.validate(): - return make_response(form.errors, 400) - service_args = {} - service_args['model'] = form.model.data - if form.encoding_detection.data: - service_args['encoding_detection'] = True - job = Job( - user=current_user, - description=form.description.data, - service=service, - service_args=service_args, - service_version=form.version.data, - title=form.title.data - ) - db.session.add(job) - db.session.flush(objects=[job]) - db.session.refresh(job) + response = {'errors': form.errors} + return response, 400 try: - job.makedirs() - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', 'error') - return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa - job_input = JobInput( - filename=secure_filename(form.txt.data.filename), - job=job, - mimetype=form.txt.data.mimetype - ) - db.session.add(job_input) - db.session.flush(objects=[job_input]) - db.session.refresh(job_input) + job = Job.create( + title=form.title.data, + description=form.description.data, + service=service, + service_args={ + 'encoding_detection': form.encoding_detection.data, + 'model': form.model.data + }, + service_version=form.version.data, + user=current_user + ) + except OSError: + abort(500) try: - form.txt.data.save(job_input.path) - except OSError as e: - current_app.logger.error(e) - db.session.rollback() - flash('Internal Server Error', 'error') - return make_response({'redirect_url': url_for('.service', service=service)}, 500) # noqa + JobInput.create(form.txt.data, job=job) + except OSError: + abort(500) job.status = JobStatus.SUBMITTED db.session.commit() - flash(f'Job "{job.title}" added', 'job') - return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201) # noqa + message = Markup(f'Job "{job.title}" created') + flash(message, 'job') + return {}, 201, {'Location': job.url} return render_template( 'services/spacy_nlp_pipeline.html.j2', form=form, diff --git a/app/services/services.yml b/app/services/services.yml index 2979539c..b7a49473 100644 --- a/app/services/services.yml +++ b/app/services/services.yml @@ -17,6 +17,11 @@ tesseract-ocr-pipeline: - 'binarization' publishing_year: 2022 url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.0' + 0.1.1: + methods: + - 'binarization' + publishing_year: 2022 + url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.1' transkribus-htr-pipeline: name: 'Transkribus HTR Pipeline' publisher: 'Bielefeld University - CRC 1288 - INF' @@ -47,4 +52,4 @@ spacy-nlp-pipeline: ru: 'Russian' zh: 'Chinese' publishing_year: 2022 - url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.0' \ No newline at end of file + url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.0' diff --git a/app/settings/forms.py b/app/settings/forms.py index cfc6918c..3bd3b5ab 100644 --- a/app/settings/forms.py +++ b/app/settings/forms.py @@ -1,5 +1,3 @@ -from app.auth import USERNAME_REGEX -from app.models import User, UserSettingJobStatusMailNotificationLevel from flask_wtf import FlaskForm from wtforms import ( BooleanField, @@ -9,14 +7,35 @@ from wtforms import ( SubmitField, ValidationError ) -from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp +from wtforms.validators import ( + DataRequired, + InputRequired, + Email, + EqualTo, + Length, + Regexp +) +from app.models import User, UserSettingJobStatusMailNotificationLevel +from app.auth import USERNAME_REGEX class ChangePasswordForm(FlaskForm): password = PasswordField('Old password', validators=[DataRequired()]) - new_password = PasswordField('New password', validators=[DataRequired(), EqualTo('new_password_confirmation', message='Passwords must match')]) - new_password_confirmation = PasswordField('Confirm new password', validators=[DataRequired(), EqualTo('new_password', message='Passwords must match')]) - submit = SubmitField('Submit') + 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) @@ -28,43 +47,51 @@ class ChangePasswordForm(FlaskForm): class EditGeneralSettingsForm(FlaskForm): - email = StringField('E-Mail', validators=[DataRequired(), Length(1, 254), Email()]) + email = StringField( + 'E-Mail', + validators=[InputRequired(), Length(max=254), Email()] + ) username = StringField( 'Username', validators=[ InputRequired(), - Length(1, 64), + Length(max=64), Regexp( USERNAME_REGEX, - message='Usernames must have only letters, numbers, dots or underscores' # noqa + message=( + 'Usernames must have only letters, numbers, dots or ' + 'underscores' + ) ) ] ) - submit = SubmitField('Submit') + submit = SubmitField() def __init__(self, user, *args, **kwargs): super().__init__(*args, **kwargs) self.user = user + def prefill(self, user): + self.email.data = user.email + self.username.data = user.username + def validate_email(self, field): - if ( - field.data != self.user.email - and User.query.filter_by(email=field.data).first() - ): + if (field.data != self.user.email + and User.query.filter_by(email=field.data).first()): raise ValidationError('Email already registered') def validate_username(self, field): - if ( - field.data != self.user.username - and User.query.filter_by(username=field.data).first() - ): + if (field.data != self.user.username + and User.query.filter_by(username=field.data).first()): raise ValidationError('Username already in use') class EditInterfaceSettingsForm(FlaskForm): dark_mode = BooleanField('Dark mode') - submit = SubmitField('Submit') + submit = SubmitField() + def prefill(self, user): + self.dark_mode.data = user.setting_dark_mode class EditNotificationSettingsForm(FlaskForm): job_status_mail_notification_level = SelectField( @@ -72,11 +99,15 @@ class EditNotificationSettingsForm(FlaskForm): choices=[('', 'Choose your option')], validators=[DataRequired()] ) - submit = SubmitField('Submit') + submit = SubmitField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.job_status_mail_notification_level.choices += [ - (enum_member.name, enum_member.name.capitalize()) - for enum_member in UserSettingJobStatusMailNotificationLevel + (x.name, x.name.capitalize()) + for x in UserSettingJobStatusMailNotificationLevel ] + + def prefill(self, user): + self.job_status_mail_notification_level.data = \ + user.setting_job_status_mail_notification_level.name diff --git a/app/settings/routes.py b/app/settings/routes.py index 8d828e33..eb2636e8 100644 --- a/app/settings/routes.py +++ b/app/settings/routes.py @@ -1,32 +1,32 @@ from flask import flash, redirect, render_template, url_for -from flask_login import current_user, login_required, logout_user -from . import bp, tasks +from flask_login import current_user, login_required +from app import db +from app.models import UserSettingJobStatusMailNotificationLevel +from . import bp from .forms import ( ChangePasswordForm, EditGeneralSettingsForm, EditInterfaceSettingsForm, EditNotificationSettingsForm ) -from .. import db -from ..models import UserSettingJobStatusMailNotificationLevel @bp.route('', methods=['GET', 'POST']) @login_required -def index(): +def settings(): change_password_form = ChangePasswordForm( - current_user._get_current_object(), - prefix='change_password_form' + current_user, + prefix='change-password-form' ) edit_general_settings_form = EditGeneralSettingsForm( - current_user._get_current_object(), - prefix='edit_general_settings_form' + current_user, + prefix='edit-general-settings-form' ) edit_interface_settings_form = EditInterfaceSettingsForm( - prefix='edit_interface_settings_form' + prefix='edit-interface-settings-form' ) edit_notification_settings_form = EditNotificationSettingsForm( - prefix='edit_notification_settings_form' + prefix='edit-notification-settings-form' ) if change_password_form.submit.data and change_password_form.validate(): @@ -34,58 +34,38 @@ def index(): db.session.commit() flash('Your changes have been saved') return redirect(url_for('.index')) - if ( - edit_general_settings_form.submit.data - and edit_general_settings_form.validate() - ): + if (edit_general_settings_form.submit.data + and edit_general_settings_form.validate()): current_user.email = edit_general_settings_form.email.data current_user.username = edit_general_settings_form.username.data db.session.commit() flash('Your changes have been saved') - return redirect(url_for('.index')) - if ( - edit_interface_settings_form.submit.data - and edit_interface_settings_form.validate() - ): - current_user.setting_dark_mode = \ - edit_interface_settings_form.dark_mode.data + return redirect(url_for('.settings')) + if (edit_interface_settings_form.submit.data + and edit_interface_settings_form.validate()): + current_user.setting_dark_mode = ( + edit_interface_settings_form.dark_mode.data) db.session.commit() flash('Your changes have been saved') - return redirect(url_for('.index')) - if ( - edit_notification_settings_form.submit.data - and edit_notification_settings_form.validate() - ): - current_user.setting_job_status_mail_notification_level = \ + return redirect(url_for('.settings')) + if (edit_notification_settings_form.submit.data + and edit_notification_settings_form.validate()): + current_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('.index')) - edit_general_settings_form.email.data = current_user.email - edit_general_settings_form.username.data = current_user.username - edit_interface_settings_form.dark_mode.data = \ - current_user.setting_dark_mode - edit_notification_settings_form.job_status_mail_notification_level.data = \ - current_user.setting_job_status_mail_notification_level.name + return redirect(url_for('.settings')) + edit_general_settings_form.prefill(current_user) + edit_interface_settings_form.prefill(current_user) + edit_notification_settings_form.prefill(current_user) return render_template( - 'settings/index.html.j2', + 'settings/settings.html.j2', change_password_form=change_password_form, edit_general_settings_form=edit_general_settings_form, edit_interface_settings_form=edit_interface_settings_form, edit_notification_settings_form=edit_notification_settings_form, title='Settings' ) - - -@bp.route('/delete') -@login_required -def delete(): - """ - View to delete current_user and all associated data. - """ - tasks.delete_user(current_user.id) - logout_user() - flash('Your account has been marked for deletion') - return redirect(url_for('main.index')) diff --git a/app/settings/tasks.py b/app/settings/tasks.py deleted file mode 100644 index 2bd82ca9..00000000 --- a/app/settings/tasks.py +++ /dev/null @@ -1,13 +0,0 @@ -from app import db -from app.decorators import background -from app.models import User - - -@background -def delete_user(user_id, *args, **kwargs): - with kwargs['app'].app_context(): - user = User.query.get(user_id) - if user is None: - raise Exception(f'User {user_id} not found') - user.delete() - db.session.commit() diff --git a/app/static/js/App.js b/app/static/js/App.js index 27ddb0eb..e20b30f2 100644 --- a/app/static/js/App.js +++ b/app/static/js/App.js @@ -5,12 +5,7 @@ class App { users: {}, }; this.socket = io({transports: ['websocket'], upgrade: false}); - this.socket.on('PATCH', (patch) => { - const re = new RegExp(`^/users/(${Object.keys(this.data.users).join('|')})`); - const filteredPatch = patch.filter(operation => re.test(operation.path)); - - jsonpatch.applyPatch(this.data, filteredPatch); - }); + this.socket.on('PATCH', (patch) => {this.onPatch(patch);}); } getUser(userId) { @@ -19,14 +14,25 @@ class App { } this.data.promises.getUser[userId] = new Promise((resolve, reject) => { - this.socket.emit('GET /users/', userId, (response) => { - if (response.code === 200) { - this.data.users[userId] = response.payload; - resolve(this.data.users[userId]); - } else { - reject(response); - } - }); + fetch(`/users/${userId}?backrefs=true&relationships=true`, {headers: {Accept: 'application/json'}}) + .then( + (response) => {return response.json();}, + (response) => { + if (response.status === 403) {this.flash('Forbidden', 'error');} + if (response.status === 404) {this.flash('Not Found', 'error');} + reject(response); + } + ) + .then( + (user) => { + this.data.users[userId] = user; + resolve(this.data.users[userId]); + }, + (error) => { + console.error(error, 'error'); + reject(error); + } + ); }); return this.data.promises.getUser[userId]; @@ -51,35 +57,55 @@ class App { } flash(message, category) { - let iconPrefix; - let toast; - let toastCloseActionElement; - + let iconPrefix = ''; switch (category) { - case 'corpus': + case 'corpus': { iconPrefix = 'book'; break; - case 'error': + } + case 'error': { iconPrefix = 'error'; break; - case 'job': + } + case 'job': { iconPrefix = 'J'; break; - default: + } + default: { iconPrefix = 'notifications'; break; + } } - toast = M.toast( + let toast = M.toast( { html: ` ${iconPrefix}${message} - `.trim() } ); - toastCloseActionElement = toast.el.querySelector('.toast-action[data-action="close"]'); + let toastCloseActionElement = toast.el.querySelector('.action-button[data-action="close"]'); toastCloseActionElement.addEventListener('click', () => {toast.dismiss();}); } + + onPatch(patch) { + // Filter Patch to only include operations on users that are initialized + let regExp = new RegExp(`^/users/(${Object.keys(this.data.users).join('|')})`); + let filteredPatch = patch.filter(operation => regExp.test(operation.path)); + + // Handle job status updates + let subRegExp = new RegExp(`^/users/([A-Za-z0-9]*)/jobs/([A-Za-z0-9]*)/status$`); + let subFilteredPatch = filteredPatch + .filter((operation) => {return operation.op === 'replace';}) + .filter((operation) => {return subRegExp.test(operation.path);}); + for (let operation of subFilteredPatch) { + let [match, userId, jobId] = operation.path.match(subRegExp); + this.flash(`[${this.data.users[userId].jobs[jobId].title}] New status: `, 'job'); + } + + // Apply Patch + jsonpatch.applyPatch(this.data, filteredPatch); + } } diff --git a/app/static/js/JobStatusNotifier.js b/app/static/js/JobStatusNotifier.js deleted file mode 100644 index bef2e5c7..00000000 --- a/app/static/js/JobStatusNotifier.js +++ /dev/null @@ -1,31 +0,0 @@ -class JobStatusNotifier { - constructor(userId) { - this.userId = userId; - this.isInitialized = false; - app.subscribeUser(this.userId).then((response) => { - app.socket.on('PATCH', (patch) => {this.onPATCH(patch);}); - }); - app.getUser(this.userId).then((user) => { - this.isInitialized = true; - }); - } - - onPATCH(patch) { - if (!this.isInitialized) {return;} - - let filteredPatch; - let jobId; - let match; - let operation; - let re; - - re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/status$`); - filteredPatch = patch - .filter((operation) => {return operation.op === 'replace';}) - .filter((operation) => {return re.test(operation.path);}); - for (operation of filteredPatch) { - [match, jobId] = operation.path.match(re); - app.flash(`[${app.data.users[this.userId].jobs[jobId].title}] New status: `, 'job'); - } - } -} diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/RessourceDisplays/CorpusDisplay.js index 3fe7e96e..9bdc4800 100644 --- a/app/static/js/RessourceDisplays/CorpusDisplay.js +++ b/app/static/js/RessourceDisplays/CorpusDisplay.js @@ -2,11 +2,20 @@ class CorpusDisplay extends RessourceDisplay { constructor(displayElement) { super(displayElement); this.corpusId = displayElement.dataset.corpusId; + this.displayElement + .querySelector('.action-button[data-action="build-request"]') + .addEventListener('click', (event) => { + Utils.buildCorpusRequest(this.userId, this.corpusId); + }); + this.displayElement + .querySelector('.action-button[data-action="delete-request"]') + .addEventListener('click', (event) => { + Utils.deleteCorpusRequest(this.userId, this.corpusId); + }); } init(user) { - const corpus = user.corpora[this.corpusId]; - + let corpus = user.corpora[this.corpusId]; this.setCreationDate(corpus.creation_date); this.setDescription(corpus.description); this.setLastEditedDate(corpus.last_edited_date); @@ -15,20 +24,20 @@ class CorpusDisplay extends RessourceDisplay { this.setNumTokens(corpus.num_tokens); } - onPATCH(patch) { - if (!this.isInitialized) {return;} - - let filteredPatch; - let operation; - let re; - - re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`); - filteredPatch = patch.filter(operation => re.test(operation.path)); - - for (operation of filteredPatch) { + onPatch(patch) { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { - case 'replace': - re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/last_edited_date$`); + case 'remove': { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}$`); + if (re.test(operation.path)) { + window.location.href = '/dashboard#corpora'; + } + break; + } + case 'replace': { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/last_edited_date$`); if (re.test(operation.path)) { this.setLastEditedDate(operation.value); break; @@ -44,8 +53,10 @@ class CorpusDisplay extends RessourceDisplay { break; } break; - default: + } + default: { break; + } } } } @@ -66,19 +77,16 @@ class CorpusDisplay extends RessourceDisplay { } setStatus(status) { - let element; - let elements; - - elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger') - for (element of elements) { + let elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger') + for (let element of elements) { if (['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) { element.classList.remove('disabled'); } else { element.classList.add('disabled'); } } - elements = this.displayElement.querySelectorAll('.corpus-build-trigger'); - for (element of elements) { + elements = this.displayElement.querySelectorAll('.action-button[data-action="build-request"]'); + for (let element of elements) { if (['UNPREPARED', 'FAILED'].includes(status) && Object.values(app.data.users[this.userId].corpora[this.corpusId].files).length > 0) { element.classList.remove('disabled'); } else { @@ -86,11 +94,11 @@ class CorpusDisplay extends RessourceDisplay { } } elements = this.displayElement.querySelectorAll('.corpus-status'); - for (element of elements) { + for (let element of elements) { element.dataset.corpusStatus = status; } elements = this.displayElement.querySelectorAll('.corpus-status-spinner'); - for (element of elements) { + for (let element of elements) { if (['SUBMITTED', 'QUEUED', 'BUILDING', 'STARTING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) { element.classList.remove('hide'); } else { diff --git a/app/static/js/RessourceDisplays/JobDisplay.js b/app/static/js/RessourceDisplays/JobDisplay.js index dc1bd777..d6669450 100644 --- a/app/static/js/RessourceDisplays/JobDisplay.js +++ b/app/static/js/RessourceDisplays/JobDisplay.js @@ -2,11 +2,25 @@ class JobDisplay extends RessourceDisplay { constructor(displayElement) { super(displayElement); 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) { - const job = user.jobs[this.jobId]; - + let job = user.jobs[this.jobId]; this.setCreationDate(job.creation_date); this.setEndDate(job.creation_date); this.setDescription(job.description); @@ -17,20 +31,20 @@ class JobDisplay extends RessourceDisplay { this.setTitle(job.title); } - onPATCH(patch) { - if (!this.isInitialized) {return;} - - let filteredPatch; - let operation; - let re; - - re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`); - filteredPatch = patch.filter(operation => re.test(operation.path)); - - for (operation of filteredPatch) { + onPatch(patch) { + let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { - case 'replace': - re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/end_date$`); + case 'remove': { + let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}$`); + if (re.test(operation.path)) { + window.location.href = '/dashboard#jobs'; + } + break; + } + case 'replace': { + let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/end_date$`); if (re.test(operation.path)) { this.setEndDate(operation.value); break; @@ -41,8 +55,10 @@ class JobDisplay extends RessourceDisplay { break; } break; - default: + } + default: { break; + } } } } @@ -56,15 +72,12 @@ class JobDisplay extends RessourceDisplay { } setStatus(status) { - let element; - let elements; - - elements = this.displayElement.querySelectorAll('.job-status'); - for (element of elements) { + let elements = this.displayElement.querySelectorAll('.job-status'); + for (let element of elements) { element.dataset.jobStatus = status; } elements = this.displayElement.querySelectorAll('.job-status-spinner'); - for (element of elements) { + for (let element of elements) { if (['COMPLETED', 'FAILED'].includes(status)) { element.classList.add('hide'); } else { @@ -72,19 +85,27 @@ class JobDisplay extends RessourceDisplay { } } elements = this.displayElement.querySelectorAll('.job-log-trigger'); - for (element of elements) { + for (let element of elements) { if (['COMPLETED', 'FAILED'].includes(status)) { element.classList.remove('hide'); } else { element.classList.add('hide'); } } - elements = this.displayElement.querySelectorAll('.job-restart-trigger'); - for (element of elements) { + elements = this.displayElement.querySelectorAll('.action-button[data-action="get-log-request"]'); + for (let element of elements) { if (['COMPLETED', 'FAILED'].includes(status)) { - element.classList.remove('hide'); + element.classList.remove('disabled'); } else { - element.classList.add('hide'); + element.classList.add('disabled'); + } + } + elements = this.displayElement.querySelectorAll('.action-button[data-action="restart-request"]'); + for (let element of elements) { + if (status === 'FAILED') { + element.classList.remove('disabled'); + } else { + element.classList.add('disabled'); } } } diff --git a/app/static/js/RessourceDisplays/RessourceDisplay.js b/app/static/js/RessourceDisplays/RessourceDisplay.js index 0fde4640..a07c2163 100644 --- a/app/static/js/RessourceDisplays/RessourceDisplay.js +++ b/app/static/js/RessourceDisplays/RessourceDisplay.js @@ -4,36 +4,40 @@ class RessourceDisplay { this.userId = this.displayElement.dataset.userId; this.isInitialized = false; if (this.userId) { - app.subscribeUser(this.userId).then((response) => { - app.socket.on('PATCH', (patch) => {this.onPATCH(patch);}); - }); - app.getUser(this.userId).then((user) => { - this.init(user); - this.isInitialized = true; - }); + app.subscribeUser(this.userId) + .then((response) => { + app.socket.on('PATCH', (patch) => { + if (this.isInitialized) {this.onPatch(patch);} + }); + }); + app.getUser(this.userId) + .then((user) => { + this.init(user); + this.isInitialized = true; + }); } } init(user) {throw 'Not implemented';} - onPATCH(patch) {throw 'Not implemented';} + onPatch(patch) {throw 'Not implemented';} setElement(element, value) { switch (element.tagName) { - case 'INPUT': + case 'INPUT': { element.value = value; M.updateTextFields(); break; - default: + } + default: { element.innerText = value; break; + } } } setElements(elements, value) { - let element; - - for (element of elements) { + for (let element of elements) { this.setElement(element, value); } } diff --git a/app/static/js/RessourceLists/CorpusFileList.js b/app/static/js/RessourceLists/CorpusFileList.js index 7ffd18b6..a24fcf7e 100644 --- a/app/static/js/RessourceLists/CorpusFileList.js +++ b/app/static/js/RessourceLists/CorpusFileList.js @@ -1,19 +1,47 @@ class CorpusFileList extends RessourceList { + static autoInit() { + for (let corpusFileListElement of document.querySelectorAll('.corpus-file-list:not(.no-autoinit)')) { + new CorpusFileList(corpusFileListElement); + } + } + static options = { + initialHtmlGenerator: (id) => { + return ` +
+ search + + +
+ + + + + + + + + + + +
FilenameAuthorTitlePublishing year
+
    + `.trim(); + }, item: ` - + - delete - file_download - send + delete + file_download + send `.trim(), - ressourceMapper: corpusFile => { + ressourceMapper: (corpusFile) => { return { 'id': corpusFile.id, 'author': corpusFile.author, @@ -23,7 +51,7 @@ class CorpusFileList extends RessourceList { 'title': corpusFile.title }; }, - sortValueName: 'creation-date', + sortArgs: ['creation-date', {order: 'desc'}], valueNames: [ {data: ['id']}, {data: ['creation-date']}, @@ -34,7 +62,6 @@ class CorpusFileList extends RessourceList { ] }; - constructor(listElement, options = {}) { super(listElement, {...CorpusFileList.options, ...options}); this.corpusId = listElement.dataset.corpusId; @@ -44,94 +71,59 @@ class CorpusFileList extends RessourceList { this._init(user.corpora[this.corpusId].files); } - onclick(event) { - let action; - let actionButtonElement; - let corpusFileElement; - let corpusFileId; - let deleteModal; - let deleteModalElement; - let tmp; - - corpusFileElement = event.target.closest('tr[data-id]'); - if (corpusFileElement === null) {return;} - corpusFileId = corpusFileElement.dataset.id; - actionButtonElement = event.target.closest('.action-button[data-action]'); - action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + onClick(event) { + let actionButtonElement = event.target.closest('.action-button'); + let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + let corpusFileElement = event.target.closest('tr'); + let corpusFileId = corpusFileElement.dataset.id; switch (action) { - case 'delete': - tmp = document.createElement('div'); - tmp.innerHTML = ` - - `.trim(); - deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild); - deleteModal = M.Modal.init( - deleteModalElement, - { - onCloseEnd: () => { - deleteModal.destroy(); - deleteModalElement.remove(); - } - } - ); - deleteModal.open(); + case 'delete': { + Utils.deleteCorpusFileRequest(this.userId, this.corpusId, corpusFileId); break; - case 'download': + } + case 'download': { window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}/download`; break; - case 'view': + } + case 'view': { window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}`; break; - default: + } + default: { break; + } } } - onPATCH(patch) { - if (!this.isInitialized) {return;} - - let corpusFileId; - let filteredPatch; - let match; - let operation; - let re; - let valueName; - - re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`); - filteredPatch = patch.filter(operation => re.test(operation.path)); - for (operation of filteredPatch) { + onPatch(patch) { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { - case 'add': - re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`); - if (re.test(operation.path)) { - this.add(operation.value); - } + case 'add': { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`); + if (re.test(operation.path)) {this.add(operation.value);} break; - case 'remove': - re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`); + } + case 'remove': { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`); if (re.test(operation.path)) { - [match, corpusFileId] = operation.path.match(re); + let [match, corpusFileId] = operation.path.match(re); this.remove(corpusFileId); } break; - case 'replace': - re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`); + } + case 'replace': { + let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`); if (re.test(operation.path)) { - [match, corpusFileId, valueName] = operation.path.match(re); + let [match, corpusFileId, valueName] = operation.path.match(re); this.replace(corpusFileId, valueName.replace('_', '-'), operation.value); } break; - default: + } + default: { break; + } } } } diff --git a/app/static/js/RessourceLists/CorpusList.js b/app/static/js/RessourceLists/CorpusList.js index b2727737..0721a807 100644 --- a/app/static/js/RessourceLists/CorpusList.js +++ b/app/static/js/RessourceLists/CorpusList.js @@ -1,17 +1,44 @@ class CorpusList extends RessourceList { + static autoInit() { + for (let corpusListElement of document.querySelectorAll('.corpus-list:not(.no-autoinit)')) { + new CorpusList(corpusListElement); + } + } + static options = { + initialHtmlGenerator: (id) => { + return ` +
    + search + + +
    + + + + + + + + + + +
    Title and DescriptionStatus
    +
      + `.trim(); + }, item: ` - + book
      - delete - send + delete + send `.trim(), - ressourceMapper: corpus => { + ressourceMapper: (corpus) => { return { 'id': corpus.id, 'creation-date': corpus.creation_date, @@ -20,7 +47,7 @@ class CorpusList extends RessourceList { 'title': corpus.title }; }, - sortValueName: 'creation-date', + sortArgs: ['creation-date', {order: 'desc'}], valueNames: [ {data: ['id']}, {data: ['creation-date']}, @@ -30,98 +57,63 @@ class CorpusList extends RessourceList { ] }; - constructor(listElement, options = {}) { super(listElement, {...CorpusList.options, ...options}); } init(user) { - super._init(user.corpora); + this._init(user.corpora); } - onclick(event) { - let action; - let actionButtonElement; - let corpusElement; - let corpusId; - let deleteModal; - let deleteModalElement; - let tmp; - - corpusElement = event.target.closest('tr[data-id]'); - if (corpusElement === null) {return;} - corpusId = corpusElement.dataset.id; - actionButtonElement = event.target.closest('.action-button[data-action]'); - action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + onClick(event) { + let actionButtonElement = event.target.closest('.action-button'); + let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + let corpusElement = event.target.closest('tr'); + let corpusId = corpusElement.dataset.id; switch (action) { - case 'delete': - tmp = document.createElement('div'); - tmp.innerHTML = ` - - `.trim(); - deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild); - deleteModal = M.Modal.init( - deleteModalElement, - { - onCloseEnd: () => { - deleteModal.destroy(); - deleteModalElement.remove(); - } - } - ); - deleteModal.open(); + case 'delete-request': { + Utils.deleteCorpusRequest(this.userId, corpusId); break; - case 'view': + } + case 'view': { window.location.href = `/corpora/${corpusId}`; break; - default: + } + default: { break; + } } } - onPATCH(patch) { - if (!this.isInitialized) {return;} - - let corpusId; - let filteredPatch; - let match; - let operation; - let re; - let valueName; - - re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`); - filteredPatch = patch.filter(operation => re.test(operation.path)); - for (operation of filteredPatch) { + onPatch(patch) { + let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { - case 'add': - re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`); + case 'add': { + let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`); if (re.test(operation.path)) {this.add(operation.value);} break; - case 'remove': - re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`); + } + case 'remove': { + let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`); if (re.test(operation.path)) { - [match, corpusId] = operation.path.match(re); + let [match, corpusId] = operation.path.match(re); this.remove(corpusId); } break; - case 'replace': - re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`); + } + case 'replace': { + let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`); if (re.test(operation.path)) { - [match, corpusId, valueName] = operation.path.match(re); + let [match, corpusId, valueName] = operation.path.match(re); this.replace(corpusId, valueName, operation.value); } break; - default: + } + default: { break; + } } } } diff --git a/app/static/js/RessourceLists/JobInputList.js b/app/static/js/RessourceLists/JobInputList.js index d86ff8ca..2cd14aa9 100644 --- a/app/static/js/RessourceLists/JobInputList.js +++ b/app/static/js/RessourceLists/JobInputList.js @@ -1,21 +1,46 @@ class JobInputList extends RessourceList { + static autoInit() { + for (let jobInputListElement of document.querySelectorAll('.job-input-list:not(.no-autoinit)')) { + new JobInputList(jobInputListElement); + } + } + static options = { + initialHtmlGenerator: (id) => { + return ` +
      + search + + +
      + + + + + + + + +
      Filename
      +
        + `.trim(); + }, item: ` - + - file_download + file_download `.trim(), - ressourceMapper: jobInput => { + ressourceMapper: (jobInput) => { return { 'id': jobInput.id, 'creation-date': jobInput.creation_date, 'filename': jobInput.filename }; }, - sortValueName: 'creation-date', + sortArgs: ['filename', {order: 'asc'}], valueNames: [ {data: ['id']}, {data: ['creation-date']}, @@ -23,7 +48,6 @@ class JobInputList extends RessourceList { ] }; - constructor(listElement, options = {}) { super(listElement, {...JobInputList.options, ...options}); this.jobId = listElement.dataset.jobId; @@ -33,26 +57,21 @@ class JobInputList extends RessourceList { this._init(user.jobs[this.jobId].inputs); } - onclick(event) { - let jobInputElement; - let jobInputId; - let action; - let actionButtonElement; - - jobInputElement = event.target.closest('tr[data-id]'); - if (jobInputElement === null) {return;} - jobInputId = jobInputElement.dataset.id; - actionButtonElement = event.target.closest('.action-button[data-action]'); - if (actionButtonElement === null) {return;} - action = actionButtonElement.dataset.action; + onClick(event) { + let actionButtonElement = event.target.closest('.action-button'); + let action = actionButtonElement === null ? 'download' : actionButtonElement.dataset.action; + let jobInputElement = event.target.closest('tr'); + let jobInputId = jobInputElement.dataset.id; switch (action) { - case 'download': + case 'download': { window.location.href = `/jobs/${this.jobId}/inputs/${jobInputId}/download`; break; - default: + } + default: { break; + } } } - onPATCH(patch) {return;} + onPatch(patch) {return;} } diff --git a/app/static/js/RessourceLists/JobList.js b/app/static/js/RessourceLists/JobList.js index 97895a0e..d6fa7894 100644 --- a/app/static/js/RessourceLists/JobList.js +++ b/app/static/js/RessourceLists/JobList.js @@ -1,17 +1,44 @@ class JobList extends RessourceList { + static autoInit() { + for (let jobListElement of document.querySelectorAll('.job-list:not(.no-autoinit)')) { + new JobList(jobListElement); + } + } + static options = { + initialHtmlGenerator: (id) => { + return ` +
        + search + + +
        + + + + + + + + + + +
        ServiceTitle and DescriptionStatus
        +
          + `.trim(); + }, item: ` - +
          - delete - send + delete + send `.trim(), - ressourceMapper: job => { + ressourceMapper: (job) => { return { 'id': job.id, 'creation-date': job.creation_date, @@ -23,7 +50,7 @@ class JobList extends RessourceList { 'title': job.title }; }, - sortValueName: 'creation-date', + sortArgs: ['creation-date', {order: 'desc'}], valueNames: [ {data: ['id']}, {data: ['creation-date']}, @@ -44,91 +71,55 @@ class JobList extends RessourceList { this._init(user.jobs); } - onclick(event) { - let action; - let actionButtonElement; - let deleteModal; - let deleteModalElement; - let jobElement; - let jobId; - let tmp; - - jobElement = event.target.closest('tr[data-id]'); - if (jobElement === null) {return;} - jobId = jobElement.dataset.id; - actionButtonElement = event.target.closest('.action-button[data-action]'); - action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + onClick(event) { + let actionButtonElement = event.target.closest('.action-button'); + let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + let jobElement = event.target.closest('tr'); + let jobId = jobElement.dataset.id; switch (action) { - case 'delete': - tmp = document.createElement('div'); - tmp.innerHTML = ` - - `.trim(); - deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild); - deleteModal = M.Modal.init( - deleteModalElement, - { - onCloseEnd: () => { - deleteModal.destroy(); - deleteModalElement.remove(); - } - } - ); - deleteModal.open(); + case 'delete-request': { + Utils.deleteJobRequest(this.userId, jobId); break; - case 'view': + } + case 'view': { window.location.href = `/jobs/${jobId}`; break; - default: + } + default: { break; + } } } - onPATCH(patch) { - if (!this.isInitialized) {return;} - - let filteredPatch; - let jobId; - let match; - let operation; - let re; - let valueName; - - re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`); - filteredPatch = patch.filter(operation => re.test(operation.path)); - for (operation of filteredPatch) { + onPatch(patch) { + let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { - case 'add': - re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`); - if (re.test(operation.path)) { - this.add(operation.value); - } + case 'add': { + let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`); + if (re.test(operation.path)) {this.add(operation.value);} break; - case 'remove': - re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`); + } + case 'remove': { + let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`); if (re.test(operation.path)) { - [match, jobId] = operation.path.match(re); + let [match, jobId] = operation.path.match(re); this.remove(jobId); } break; - case 'replace': - re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`); + } + case 'replace': { + let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`); if (re.test(operation.path)) { - [match, jobId, valueName] = operation.path.match(re); + let [match, jobId, valueName] = operation.path.match(re); this.replace(jobId, valueName, operation.value); } break; - default: + } + default: { break; + } } } } diff --git a/app/static/js/RessourceLists/JobResultList.js b/app/static/js/RessourceLists/JobResultList.js index 16c390df..3623363a 100644 --- a/app/static/js/RessourceLists/JobResultList.js +++ b/app/static/js/RessourceLists/JobResultList.js @@ -1,15 +1,41 @@ class JobResultList extends RessourceList { + static autoInit() { + for (let jobResultListElement of document.querySelectorAll('.job-result-list:not(.no-autoinit)')) { + new JobResultList(jobResultListElement); + } + } + static options = { + initialHtmlGenerator: (id) => { + return ` +
          + search + + +
          + + + + + + + + + +
          DescriptionFilename
          +
            + `.trim(); + }, item: ` - + - file_download + file_download `.trim(), - ressourceMapper: jobResult => { + ressourceMapper: (jobResult) => { return { 'id': jobResult.id, 'creation-date': jobResult.creation_date, @@ -17,7 +43,7 @@ class JobResultList extends RessourceList { 'filename': jobResult.filename }; }, - sortValueName: 'creation-date', + sortArgs: ['filename', {order: 'asc'}], valueNames: [ {data: ['id']}, {data: ['creation-date']}, @@ -26,7 +52,6 @@ class JobResultList extends RessourceList { ] }; - constructor(listElement, options = {}) { super(listElement, {...JobResultList.options, ...options}); this.jobId = listElement.dataset.jobId; @@ -36,46 +61,35 @@ class JobResultList extends RessourceList { super._init(user.jobs[this.jobId].results); } - onclick(event) { - let action; - let actionButtonElement; - let jobResultElement; - let jobResultId; - - jobResultElement = event.target.closest('tr[data-id]'); - if (jobResultElement === null) {return;} - jobResultId = jobResultElement.dataset.id; - actionButtonElement = event.target.closest('.action-button[data-action]'); - if (actionButtonElement === null) {return;} - action = actionButtonElement.dataset.action; + onClick(event) { + let actionButtonElement = event.target.closest('.action-button'); + let action = actionButtonElement === null ? 'download' : actionButtonElement.dataset.action; + let jobResultElement = event.target.closest('tr'); + let jobResultId = jobResultElement.dataset.id; switch (action) { - case 'download': + case 'download': { window.location.href = `/jobs/${this.jobId}/results/${jobResultId}/download`; break; - default: + } + default: { break; + } } } - onPATCH(patch) { - if (!this.isInitialized) {return;} - - let filteredPatch; - let operation; - let re; - - re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`); - filteredPatch = patch.filter(operation => re.test(operation.path)); - for (operation of filteredPatch) { + onPatch(patch) { + let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`); + let filteredPatch = patch.filter(operation => re.test(operation.path)); + for (let operation of filteredPatch) { switch(operation.op) { - case 'add': - re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`); - if (re.test(operation.path)) { - this.add(operation.value); - } + case 'add': { + let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`); + if (re.test(operation.path)) {this.add(operation.value);} break; - default: + } + default: { break; + } } } } diff --git a/app/static/js/RessourceLists/QueryResultList.js b/app/static/js/RessourceLists/QueryResultList.js index 8d0c1329..cffc4318 100644 --- a/app/static/js/RessourceLists/QueryResultList.js +++ b/app/static/js/RessourceLists/QueryResultList.js @@ -1,12 +1,18 @@ class QueryResultList extends RessourceList { + static autoInit() { + for (let queryResultListElement of document.querySelectorAll('.query-result-list:not(.no-autoinit)')) { + new QueryResultList(queryResultListElement); + } + } + static options = { item: `


            - delete - send + delete + send `.trim(), @@ -20,7 +26,7 @@ class QueryResultList extends RessourceList { 'title': queryResult.title }; }, - sortValueName: 'creation-date', + sortArgs: ['creation-date', {order: 'desc'}], valueNames: [ {data: ['id']}, {data: ['creation-date']}, @@ -31,7 +37,6 @@ class QueryResultList extends RessourceList { ] }; - constructor(listElement, options = {}) { super(listElement, {...QueryResultList.options, ...options}); } diff --git a/app/static/js/RessourceLists/RessourceList.js b/app/static/js/RessourceLists/RessourceList.js index 29a94663..824db3d1 100644 --- a/app/static/js/RessourceLists/RessourceList.js +++ b/app/static/js/RessourceLists/RessourceList.js @@ -3,45 +3,22 @@ class RessourceList { * This class is not meant to be used directly, instead it should be used as * a base class for concrete ressource list implementations. */ - static autoInit() { - const nopaqueRessourceListElements = document.querySelectorAll('.nopaque-ressource-list[data-ressource-type]:not(.no-autoinit)'); - let nopaqueRessourceListElement; - for (nopaqueRessourceListElement of nopaqueRessourceListElements) { - switch (nopaqueRessourceListElement.dataset.ressourceType) { - case 'Corpus': - new CorpusList(nopaqueRessourceListElement); - break; - case 'CorpusFile': - new CorpusFileList(nopaqueRessourceListElement); - break; - case 'Job': - new JobList(nopaqueRessourceListElement); - break; - case 'JobInput': - new JobInputList(nopaqueRessourceListElement); - break; - case 'JobResult': - new JobResultList(nopaqueRessourceListElement); - break; - case 'QueryResult': - new QueryResultList(nopaqueRessourceListElement); - break; - case 'User': - new UserList(nopaqueRessourceListElement); - break; - default: - break; - } - } + static autoInit() { + CorpusList.autoInit(); + CorpusFileList.autoInit(); + JobList.autoInit(); + JobInputList.autoInit(); + JobResultList.autoInit(); + QueryResultList.autoInit(); + UserList.autoInit(); } + static options = {page: 5, pagination: {innerWindow: 4, outerWindow: 1}}; - constructor(listElement, options = {}) { - let i; - if (!(listElement.hasAttribute('id'))) { + let i; for (i = 0; true; i++) { if (document.querySelector(`#ressource-list-${i}`)) {continue;} listElement.id = `ressource-list-${i}`; @@ -56,9 +33,14 @@ class RessourceList { this.ressourceMapper = options.ressourceMapper; delete options.ressourceMapper; } - if ('sortValueName' in options) { - this.sortValueName = options.sortValueName; - delete options.sortValueName; + if ('initialHtmlGenerator' in options) { + this.initialHtmlGenerator = options.initialHtmlGenerator; + listElement.innerHTML = this.initialHtmlGenerator(listElement.id); + delete options.initialHtmlGenerator; + } + if ('sortArgs' in options) { + this.sortArgs = options.sortArgs; + delete options.sortArgs; } this.listjs = new List(listElement, {...RessourceList.options, ...options}); this.listjs.list.innerHTML = ` @@ -87,50 +69,54 @@ class RessourceList { `.trim(); - this.listjs.list.style.cursor = 'pointer'; this.userId = this.listjs.listContainer.dataset.userId; - this.listjs.list.addEventListener('click', event => this.onclick(event)); + this.listjs.list.addEventListener('click', (event) => {this.onClick(event)}); this.isInitialized = false; if (this.userId) { - app.subscribeUser(this.userId).then((response) => { - app.socket.on('PATCH', (patch) => {this.onPATCH(patch);}); - }); - app.getUser(this.userId).then((user) => { - this.init(user); - this.isInitialized = true; - }); + app.subscribeUser(this.userId) + .then((response) => { + app.socket.on('PATCH', (patch) => { + if (this.isInitialized) {this.onPatch(patch);} + }); + }); + app.getUser(this.userId) + .then((user) => { + this.init(user); + this.isInitialized = true; + }); } } _init(ressources) { this.listjs.clear(); this.add(Object.values(ressources)); - let emptyListElementHTML = ` - - - file_downloadNothing here... -

            No ressource available.

            - - - `.trim(); - this.listjs.list.insertAdjacentHTML('afterbegin', emptyListElementHTML); + this.listjs.list.insertAdjacentHTML( + 'afterbegin', + ` + + + file_downloadNothing here... +

            No ressource available.

            + + + `.trim() + ); } init(user) {throw 'Not implemented';} - onclick(event) {throw 'Not implemented';} + onClick(event) {throw 'Not implemented';} - onPATCH(patch) {throw 'Not implemented';} + onPatch(patch) {throw 'Not implemented';} add(ressources) { let values = Array.isArray(ressources) ? ressources : [ressources]; - if ('ressourceMapper' in this) { - values = values.map(value => this.ressourceMapper(value)); + values = values.map((value) => {return this.ressourceMapper(value);}); } this.listjs.add(values, () => { - if ('sortValueName' in this) { - this.listjs.sort(this.sortValueName, {order: 'desc'}); + if ('sortArgs' in this) { + this.listjs.sort(...this.sortArgs); } }); } @@ -140,6 +126,6 @@ class RessourceList { } replace(id, valueName, newValue) { - this.listjs.get('id', id)[0].values({[valueName]: newValue}); + this.listjs.get('id', id)[0].values({[valueName]: newValue}); } } diff --git a/app/static/js/RessourceLists/UserList.js b/app/static/js/RessourceLists/UserList.js index f1f7e42a..986685ba 100644 --- a/app/static/js/RessourceLists/UserList.js +++ b/app/static/js/RessourceLists/UserList.js @@ -1,20 +1,49 @@ class UserList extends RessourceList { + static autoInit() { + for (let userListElement of document.querySelectorAll('.user-list:not(.no-autoinit)')) { + new UserList(userListElement); + } + } + static options = { + initialHtmlGenerator: (id) => { + return ` +
            + search + + +
            + + + + + + + + + + + + +
            IdUsernameEmailLast seenRole
            +
              + `.trim(); + }, item: ` - + - delete - edit - send + delete + edit + send `.trim(), - ressourceMapper: user => { + ressourceMapper: (user) => { return { 'id': user.id, 'id-1': user.id, @@ -25,7 +54,7 @@ class UserList extends RessourceList { 'role': user.role.name }; }, - sortValueName: 'member-since', + sortArgs: ['member-since', {order: 'desc'}], valueNames: [ {data: ['id']}, {data: ['member-since']}, @@ -37,8 +66,6 @@ class UserList extends RessourceList { ] }; - - constructor(listElement, options = {}) { super(listElement, {...UserList.options, ...options}); } @@ -47,55 +74,28 @@ class UserList extends RessourceList { super._init(Object.values(users)); } - onclick(event) { - let action; - let actionButtonElement; - let deleteModal; - let deleteModalElement; - let tmp; - let userElement; - let userId; - - userElement = event.target.closest('tr[data-id]'); - if (userElement === null) {return;} - userId = userElement.dataset.id; - actionButtonElement = event.target.closest('.action-button[data-action]'); - action = (actionButtonElement === null) ? 'view' : actionButtonElement.dataset.action; + onClick(event) { + let actionButtonElement = event.target.closest('.action-button'); + let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + let userElement = event.target.closest('tr'); + let userId = userElement.dataset.id; switch (action) { - case 'delete': - tmp = document.createElement('div'); - tmp.innerHTML = ` - - `.trim(); - deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild); - deleteModal = M.Modal.init( - deleteModalElement, - { - onCloseEnd: () => { - deleteModal.destroy(); - deleteModalElement.remove(); - } - } - ); - deleteModal.open(); + case 'delete': { + Utils.deleteUserRequest(userId); + if (userId === currentUserId) {window.location.href = '/';} break; - case 'edit': + } + case 'edit': { window.location.href = `/admin/users/${userId}/edit`; break; - case 'view': + } + case 'view': { window.location.href = `/admin/users/${userId}`; break; - default: + } + default: { break; + } } } } diff --git a/app/static/js/UploadForm.js b/app/static/js/UploadForm.js deleted file mode 100644 index 2a1df808..00000000 --- a/app/static/js/UploadForm.js +++ /dev/null @@ -1,125 +0,0 @@ -class UploadForm { - static autoInit() { - const nopaqueSubmitForms = document.querySelectorAll('.nopaque-upload-form'); - let nopaqueSubmitForm; - - for (nopaqueSubmitForm of nopaqueSubmitForms) { - new UploadForm(nopaqueSubmitForm); - } - } - - - constructor(formElement) { - this.formElement = formElement; - this.request = new XMLHttpRequest(); - - this.formElement.addEventListener('submit', (event) => { - event.preventDefault(); - this.submit(); - }); - } - - submit() { - const selectElements = this.formElement.querySelectorAll('select'); - let abortElement; - let helperTextElement; - let helperTextElements; - let inputFieldElement; - let modal; - let modalElement; - let progressElement; - let selectElement; - let tmp; - - // Check if select elements are filled out properly - for (selectElement of selectElements) { - if (selectElement.value === '') { - inputFieldElement = selectElement.closest('.input-field'); - inputFieldElement.querySelector('.select-dropdown').classList.add('invalid'); - helperTextElements = inputFieldElement.querySelectorAll('.helper-text'); - for (helperTextElement of helperTextElements) { - helperTextElement.remove(); - } - inputFieldElement.insertAdjacentHTML( - 'beforeend', - 'Please select an option.' - ); - return; - } - } - - // Setup modal - tmp = document.createElement('div'); - tmp.innerHTML = ` - - `.trim(); - modalElement = document.querySelector('#modals').appendChild(tmp.firstChild); - modal = M.Modal.init( - modalElement, - { - dismissible: false, - onCloseEnd: () => { - modal.destroy(); - modalElement.remove(); - } - } - ); - modal.open(); - - // Setup abort handling - abortElement = modalElement.querySelector('.abort'); - abortElement.addEventListener('click', event => {this.request.abort();}); - this.request.addEventListener('abort', event => { - this.request.abort(); - modal.close(); - }); - - // Setup load handling (after the request completed) - this.request.addEventListener('load', event => { - const response = JSON.parse(this.request.responseText); - let inputError; - let inputErrors; - let inputFieldElement; - let inputName; - - if (this.request.status === 201) { - window.location.href = response.redirect_url; - } - if (this.request.status === 400) { - for ([inputName, inputErrors] of Object.entries(response)) { - inputFieldElement = this.formElement.querySelector(`input[name="${inputName}"], select[name="${inputName}"]`).closest('.input-field'); - for (inputError of inputErrors) { - inputFieldElement.insertAdjacentHTML( - 'beforeend', - `${inputError}` - ); - } - } - } - if (this.request.status === 500) { - location.reload(); - } - modal.close(); - }); - - // Setup progress handling - progressElement = modalElement.querySelector('.progress > .determinate'); - this.request.upload.addEventListener('progress', event => { - const progress = Math.floor(100 * event.loaded / event.total); - progressElement.style.width = `${progress}%`; - }); - - this.request.open('POST', window.location.href); - this.request.send(new FormData(this.formElement)); - } -} diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js new file mode 100644 index 00000000..2e7cbd4c --- /dev/null +++ b/app/static/js/Utils.js @@ -0,0 +1,326 @@ +class Utils { + static elementFromString(string) { + let tmpElement = document.createElement('div'); + tmpElement.innerHTML = string.trim(); + return tmpElement.firstChild; + } + + static buildCorpusRequest(userId, corpusId) { + return new Promise((resolve, reject) => { + let corpus = app.data.users[userId].corpora[corpusId]; + + fetch(`/corpora/${corpus.id}/build`, {method: 'POST', headers: {Accept: 'application/json'}}) + .then( + (response) => { + app.flash(`Corpus "${corpus.title}" marked for building`, 'corpus'); + resolve(response); + }, + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error');} + if (response.status === 404) {app.flash('Not Found', 'error');} + if (response.status === 409) {app.flash('Conflict', 'error');} + reject(response); + } + ); + }); + } + + static deleteCorpusRequest(userId, corpusId) { + return new Promise((resolve, reject) => { + let corpus = app.data.users[userId].corpora[corpusId]; + + let modalElement = Utils.elementFromString( + ` + + ` + ); + document.querySelector('#modals').appendChild(modalElement); + let modal = M.Modal.init( + modalElement, + { + dismissible: false, + onCloseEnd: () => { + modal.destroy(); + modalElement.remove(); + } + } + ); + + let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); + confirmElement.addEventListener('click', (event) => { + let corpusTitle = corpus.title; + fetch(`/corpora/${corpus.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}}) + .then( + (response) => { + app.flash(`Corpus "${corpusTitle}" marked for deletion`, 'corpus'); + resolve(response); + }, + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error');} + if (response.status === 404) {app.flash('Not Found', 'error');} + reject(response); + } + ); + }); + modal.open(); + }); + } + + static deleteCorpusFileRequest(userId, corpusId, corpusFileId) { + return new Promise((resolve, reject) => { + let corpus = app.data.users[userId].corpora[corpusId]; + let corpusFile = corpus.files[corpusFileId]; + + let modalElement = Utils.elementFromString( + ` + + ` + ); + document.querySelector('#modals').appendChild(modalElement); + let modal = M.Modal.init( + modalElement, + { + dismissible: false, + onCloseEnd: () => { + modal.destroy(); + modalElement.remove(); + } + } + ); + + let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); + confirmElement.addEventListener('click', (event) => { + let corpusFileTitle = corpusFile.title; + fetch(`/corpora/${corpusId}/files/${corpusFileId}`, {method: 'DELETE', headers: {Accept: 'application/json'}}) + .then( + (response) => { + app.flash(`Corpus file "${corpusFileTitle}" marked for deletion`, 'corpus'); + resolve(response); + }, + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error');} + if (response.status === 404) {app.flash('Not Found', 'error');} + reject(response); + } + ); + }); + modal.open(); + }); + } + + static deleteJobRequest(userId, jobId) { + return new Promise((resolve, reject) => { + let job = app.data.users[userId].jobs[jobId]; + + let modalElement = Utils.elementFromString( + ` + + ` + ); + document.querySelector('#modals').appendChild(modalElement); + let modal = M.Modal.init( + modalElement, + { + dismissible: false, + onCloseEnd: () => { + modal.destroy(); + modalElement.remove(); + } + } + ); + + let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); + confirmElement.addEventListener('click', (event) => { + let jobTitle = job.title; + fetch(`/jobs/${job.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}}) + .then( + (response) => { + app.flash(`Job "${jobTitle}" marked for deletion`, 'job'); + resolve(response); + }, + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error');} + if (response.status === 404) {app.flash('Not Found', 'error');} + reject(response); + } + ); + }); + modal.open(); + }); + } + + static getJobLogRequest(userId, jobId) { + return new Promise((resolve, reject) => { + let job = app.data.users[userId].jobs[jobId]; + + fetch(`/jobs/${job.id}/log`, {method: 'GET', headers: {Accept: 'application/json, text/plain'}}) + .then( + (response) => { + resolve(response); + return response.text(); + }, + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error');} + if (response.status === 404) {app.flash('Not Found', 'error');} + reject(response); + } + ) + .then( + (text) => { + let modalElement = Utils.elementFromString( + ` + + ` + ); + document.querySelector('#modals').appendChild(modalElement); + let modal = M.Modal.init( + modalElement, + { + onCloseEnd: () => { + modal.destroy(); + modalElement.remove(); + } + } + ); + modal.open(); + } + ); + }); + } + + static restartJobRequest(userId, jobId) { + return new Promise((resolve, reject) => { + let job = app.data.users[userId].jobs[jobId]; + + let modalElement = Utils.elementFromString( + ` + + ` + ); + document.querySelector('#modals').appendChild(modalElement); + let modal = M.Modal.init( + modalElement, + { + dismissible: false, + onCloseEnd: () => { + modal.destroy(); + modalElement.remove(); + } + } + ); + + let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); + confirmElement.addEventListener('click', (event) => { + let jobTitle = job.title; + fetch(`/jobs/${job.id}/restart`, {method: 'POST', headers: {Accept: 'application/json'}}) + .then( + (response) => { + app.flash(`Job "${jobTitle}" restarted.`, 'job'); + resolve(response); + }, + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error');} + if (response.status === 404) {app.flash('Not Found', 'error');} + if (response.status === 409) {app.flash('Conflict', 'error');} + reject(response); + } + ); + }); + modal.open(); + }); + } + + static deleteUserRequest(userId) { + return new Promise((resolve, reject) => { + let user = app.data.users[userId]; + + let modalElement = Utils.elementFromString( + ` + + ` + ); + document.querySelector('#modals').appendChild(modalElement); + let modal = M.Modal.init( + modalElement, + { + dismissible: false, + onCloseEnd: () => { + modal.destroy(); + modalElement.remove(); + } + } + ); + + let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); + confirmElement.addEventListener('click', (event) => { + let userName = user.username; + fetch(`/users/${user.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}}) + .then( + (response) => { + app.flash(`User "${userName}" marked for deletion`); + resolve(response); + }, + (response) => { + if (response.status === 403) {app.flash('Forbidden', 'error');} + if (response.status === 404) {app.flash('Not Found', 'error');} + reject(response); + } + ); + }); + modal.open(); + }); + } +} diff --git a/app/templates/_navbar.html.j2 b/app/templates/_navbar.html.j2 index 63631a01..e2d4db64 100644 --- a/app/templates/_navbar.html.j2 +++ b/app/templates/_navbar.html.j2 @@ -29,7 +29,7 @@ diff --git a/app/templates/_styles.html.j2 b/app/templates/_styles.html.j2 index de2bff7a..2c1ea8f8 100644 --- a/app/templates/_styles.html.j2 +++ b/app/templates/_styles.html.j2 @@ -8,6 +8,7 @@ filters='pyscss', output='gen/app.%(version)s.css', 'css/colors.scss', + 'css/helpers.scss', 'css/style.css' %} diff --git a/app/templates/admin/edit_user.html.j2 b/app/templates/admin/edit_user.html.j2 index f44ccb7c..45c27c6a 100644 --- a/app/templates/admin/edit_user.html.j2 +++ b/app/templates/admin/edit_user.html.j2 @@ -15,8 +15,8 @@
              General settings - {{ wtf.render_field(edit_general_settings_form.username, data_length='64', material_icon='person') }} - {{ wtf.render_field(edit_general_settings_form.email, data_length='254', material_icon='email') }} + {{ wtf.render_field(edit_general_settings_form.username, material_icon='person') }} + {{ wtf.render_field(edit_general_settings_form.email, material_icon='email') }}
              diff --git a/app/templates/admin/user.html.j2 b/app/templates/admin/user.html.j2 index 9af7e4e0..b4b0e303 100644 --- a/app/templates/admin/user.html.j2 +++ b/app/templates/admin/user.html.j2 @@ -37,52 +37,20 @@
              -
              +

              Corpora

              -
              - search - - -
              - - - - - - - - - - -
              Title and DescriptionStatus
              -
                +
                -
                +

                Jobs

                -
                - search - - -
                - - - - - - - - - - -
                ServiceTitle and DescriptionStatus
                -
                  +
                  diff --git a/app/templates/admin/users.html.j2 b/app/templates/admin/users.html.j2 index 37899866..75254b0e 100644 --- a/app/templates/admin/users.html.j2 +++ b/app/templates/admin/users.html.j2 @@ -8,28 +8,10 @@

                  {{ title }}

                  -
                  +
                  -
                  - search - - -
                  - - - - - - - - - - - - -
                  IdUsernameEmailLast seenRole
                  -
                    +
                    @@ -40,7 +22,11 @@ {% block scripts %} {{ super() }} {% endblock scripts %} diff --git a/app/templates/auth/login.html.j2 b/app/templates/auth/login.html.j2 index aea71382..213c0a5f 100644 --- a/app/templates/auth/login.html.j2 +++ b/app/templates/auth/login.html.j2 @@ -2,53 +2,30 @@ {% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %} {% import "materialize/wtf.html.j2" as wtf %} -{% block styles %} -{{ super() }} - -{% endblock styles %} {% block page_content %}
                    -
                    -
                    -
                    -

                    {{ title }}

                    -

                    Want to boost your research and get going? nopaque is free and no download is needed. Register now!

                    -
                    - -
                    -
                    +
                    +

                    {{ title }}

                    +

                    Want to boost your research and get going? Nopaque is free and no download is needed. Register now!

                    -
                    -
                    -
                    -
                    - {{ form.hidden_tag() }} - {{ wtf.render_field(form.user, material_icon='person') }} - {{ wtf.render_field(form.password, material_icon='vpn_key') }} -
                    - -
                    - {{ wtf.render_field(form.remember_me) }} -
                    + +
                    + {{ form.hidden_tag() }} + {{ wtf.render_field(form.user, material_icon='person') }} + {{ wtf.render_field(form.password, material_icon='vpn_key') }} +
                    + +
                    + {{ wtf.render_field(form.remember_me) }}
                    -
                    - {{ wtf.render_field(form.submit, material_icon='send') }} -
                    - -
                    + {{ wtf.render_field(form.submit, material_icon='send', class_='width-100') }} +
                    +
                    diff --git a/app/templates/auth/register.html.j2 b/app/templates/auth/register.html.j2 index 5c195e56..69a01912 100644 --- a/app/templates/auth/register.html.j2 +++ b/app/templates/auth/register.html.j2 @@ -2,47 +2,31 @@ {% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %} {% import "materialize/wtf.html.j2" as wtf %} -{% block styles %} -{{ super() }} - -{% endblock styles %} {% block page_content %}
                    -
                    -
                    -
                    -

                    {{ title }}

                    -

                    Simply enter a username and password to receive your registration email. After that you can start right away.

                    -

                    It goes without saying that the General Data Protection Regulation applies, only necessary data is stored.

                    -

                    Please also read our terms of use before signing up for nopaque!

                    -
                    -
                    -
                    +
                    +

                    {{ title }}

                    +

                    + Simply enter a username and password to receive your registration email. + After that you can start right away. It goes without saying that the + General Data Protection Regulation + applies, only necessary data is stored. Please also read our + terms of use before + signing up for nopaque! +

                    -
                    -
                    -
                    -
                    - {{ form.hidden_tag() }} - {{ wtf.render_field(form.username, material_icon='person') }} - {{ wtf.render_field(form.password, material_icon='vpn_key') }} - {{ wtf.render_field(form.password_confirmation, material_icon='vpn_key') }} - {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }} -
                    -
                    - {{ wtf.render_field(form.submit, material_icon='send') }} -
                    -
                    -
                    -
                    +
                    +
                    + {{ form.hidden_tag() }} + {{ wtf.render_field(form.username, material_icon='person') }} + {{ wtf.render_field(form.password, material_icon='vpn_key') }} + {{ wtf.render_field(form.password_2, material_icon='vpn_key') }} + {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }} + {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }} +
                    +
                    {% endblock page_content %} diff --git a/app/templates/auth/reset_password.html.j2 b/app/templates/auth/reset_password.html.j2 index a0f4c32b..06f11059 100644 --- a/app/templates/auth/reset_password.html.j2 +++ b/app/templates/auth/reset_password.html.j2 @@ -5,27 +5,18 @@ {% block page_content %}
                    -
                    +

                    {{ title }}

                    -
                    - -

                    Enter a new password and confirm it! After that, the entered password is your new one!

                    -
                    -
                    -
                    -
                    -
                    - {{ form.hidden_tag() }} - {{ wtf.render_field(form.password) }} - {{ wtf.render_field(form.password_confirmation) }} -
                    -
                    - {{ wtf.render_field(form.submit, material_icon='send') }} -
                    -
                    -
                    +
                    +
                    + {{ form.hidden_tag() }} + {{ wtf.render_field(form.password) }} + {{ wtf.render_field(form.password_2) }} + {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }} +
                    +
                    diff --git a/app/templates/auth/reset_password_request.html.j2 b/app/templates/auth/reset_password_request.html.j2 index b91cd59b..a94d18da 100644 --- a/app/templates/auth/reset_password_request.html.j2 +++ b/app/templates/auth/reset_password_request.html.j2 @@ -5,26 +5,17 @@ {% block page_content %}
                    -
                    +

                    {{ title }}

                    -
                    - -

                    After entering your email address you will receive instructions on how to reset your password.

                    -
                    -
                    -
                    -
                    -
                    - {{ form.hidden_tag() }} - {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }} -
                    -
                    - {{ wtf.render_field(form.submit, material_icon='send') }} -
                    -
                    -
                    +
                    +
                    + {{ form.hidden_tag() }} + {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }} + {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }} +
                    +
                    diff --git a/app/templates/auth/unconfirmed.html.j2 b/app/templates/auth/unconfirmed.html.j2 index db9bf8c4..26384ecc 100644 --- a/app/templates/auth/unconfirmed.html.j2 +++ b/app/templates/auth/unconfirmed.html.j2 @@ -6,20 +6,13 @@

                    {{ title }}

                    -
                    - -
                    -
                    -
                    - Hello, {{ current_user.username }}! -

                    You have not confirmed your account yet.

                    -

                    Before you can access this site you need to confirm your account. Check your inbox, you should have received an email with a confirmation link.

                    -

                    Need another confirmation email? Click the button below!

                    -
                    - -
                    +

                    Hello, {{ current_user.username }}.

                    +

                    + You have not confirmed your account yet. Before you can access this + site you need to confirm your account. Check your inbox, you should + have received an email with a confirmation link. +

                    +

                    Need another confirmation email? Get a new one.

                    diff --git a/app/templates/corpora/_breadcrumbs.html.j2 b/app/templates/corpora/_breadcrumbs.html.j2 index d91bc8c3..af6d2b78 100644 --- a/app/templates/corpora/_breadcrumbs.html.j2 +++ b/app/templates/corpora/_breadcrumbs.html.j2 @@ -2,8 +2,8 @@
                  • navigate_next
                  • My corpora
                  • navigate_next
                  • -{% if request.path == url_for('.add_corpus') %} -
                  • {{ title }}
                  • +{% if request.path == url_for('.create_corpus') %} +
                  • {{ title }}
                  • {% elif request.path == url_for('.import_corpus') %}
                  • {{ title }}
                  • {% elif request.path == url_for('.corpus', corpus_id=corpus.id) %} @@ -12,12 +12,12 @@
                  • {{ corpus.title }}
                  • navigate_next
                  • {{ title }}
                  • -{% elif request.path == url_for('.add_corpus_file', corpus_id=corpus.id) %} +{% elif request.path == url_for('.create_corpus_file', corpus_id=corpus.id) %}
                  • {{ corpus.title }}
                  • navigate_next
                  • Corpus files
                  • navigate_next
                  • -
                  • {{ title }}
                  • +
                  • {{ title }}
                  • {% elif request.path == url_for('.corpus_file', corpus_file_id=corpus_file.id, corpus_id=corpus.id) %}
                  • {{ corpus.title }}
                  • navigate_next
                  • diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 177d83b4..d64b7084 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -65,38 +65,21 @@
                    -
                    +
                    Corpus files -
                    - search - - -
                    - - - - - - - - - - - -
                    FilenameAuthorTitlePublishing year
                    -
                      +
                      @@ -104,20 +87,6 @@
                      {% endblock page_content %} -{% block modals %} -{{ super() }} - -{% endblock modals %} - {% block scripts %} {{ super() }} -{% endblock scripts %} diff --git a/app/templates/services/transkribus_htr_pipeline.html.j2 b/app/templates/services/transkribus_htr_pipeline.html.j2 index 7708e8d8..a73839b5 100644 --- a/app/templates/services/transkribus_htr_pipeline.html.j2 +++ b/app/templates/services/transkribus_htr_pipeline.html.j2 @@ -44,7 +44,7 @@

                      Submit a job

                      -
                      +
                      {{ form.hidden_tag() }}
                      diff --git a/app/templates/settings/_breadcrumbs.html.j2 b/app/templates/settings/_breadcrumbs.html.j2 index 33b8984c..3b5077bf 100644 --- a/app/templates/settings/_breadcrumbs.html.j2 +++ b/app/templates/settings/_breadcrumbs.html.j2 @@ -1,6 +1,6 @@ {% set breadcrumbs %}
                    • navigate_next
                    • -{% if request.path == url_for('settings.index') %} -
                    • Settings
                    • +{% if request.path == url_for('settings.settings') %} +
                    • Settings
                    • {% endif %} {% endset %} diff --git a/app/templates/settings/index.html.j2 b/app/templates/settings/settings.html.j2 similarity index 83% rename from app/templates/settings/index.html.j2 rename to app/templates/settings/settings.html.j2 index 0a814ea2..441cd367 100644 --- a/app/templates/settings/index.html.j2 +++ b/app/templates/settings/settings.html.j2 @@ -81,7 +81,7 @@ Change Password {{ wtf.render_field(change_password_form.password, material_icon='vpn_key') }} {{ wtf.render_field(change_password_form.new_password, material_icon='vpn_key') }} - {{ wtf.render_field(change_password_form.new_password_confirmation, material_icon='vpn_key') }} + {{ wtf.render_field(change_password_form.new_password_2, material_icon='vpn_key') }}
                      @@ -101,23 +101,19 @@
                      {% endblock page_content %} -{% block modals %} +{% block scripts %} {{ super() }} - -{% endblock modals %} + +{% endblock scripts %} diff --git a/app/users/__init__.py b/app/users/__init__.py index 9f43097d..885cdbe2 100644 --- a/app/users/__init__.py +++ b/app/users/__init__.py @@ -2,4 +2,4 @@ from flask import Blueprint bp = Blueprint('users', __name__) -from . import events +from . import events, routes diff --git a/app/users/events.py b/app/users/events.py index a47db136..7cab2199 100644 --- a/app/users/events.py +++ b/app/users/events.py @@ -1,21 +1,8 @@ +from flask_login import current_user +from flask_socketio import join_room, leave_room from app import hashids, socketio from app.decorators import socketio_login_required from app.models import User -from flask_login import current_user -from flask_socketio import join_room, leave_room - - -@socketio.on('GET /users/') -@socketio_login_required -def get_user(user_hashid): - user_id = hashids.decode(user_hashid) - user = User.query.get(user_id) - if user is None: - return {'code': 404, 'msg': 'Not found'} - if not (user == current_user or current_user.is_administrator): - return {'code': 403, 'msg': 'Forbidden'} - dict_user = user.to_dict(backrefs=True, relationships=True) - return {'code': 200, 'msg': 'OK', 'payload': dict_user} @socketio.on('SUBSCRIBE /users/') diff --git a/app/users/routes.py b/app/users/routes.py new file mode 100644 index 00000000..f2f8abf2 --- /dev/null +++ b/app/users/routes.py @@ -0,0 +1,38 @@ +from flask import abort, current_app, request +from flask_login import current_user, login_required +from threading import Thread +from app import db +from app.models import User +from . import bp + + +@bp.route('/') +@login_required +def user(user_id): + user = User.query.get_or_404(user_id) + if not (user == current_user or current_user.is_administrator()): + abort(403) + backrefs = request.args.get('backrefs', 'false').lower() == 'true' + relationships = ( + request.args.get('relationships', 'false').lower() == 'true') + return user.to_json(backrefs=backrefs, relationships=relationships), 200 + + +@bp.route('/', methods=['DELETE']) +@login_required +def delete_user(user_id): + def _delete_user(app, user_id): + with app.app_context(): + user = User.query.get(user_id) + user.delete() + db.session.commit() + + user = User.query.get_or_404(user_id) + if not (user == current_user or current_user.is_administrator()): + abort(403) + thread = Thread( + target=_delete_user, + args=(current_app._get_current_object(), user_id) + ) + thread.start() + return {}, 202 diff --git a/migrations/versions/9e8d7d15d950_.py b/migrations/versions/9e8d7d15d950_.py index b76a490e..9d59da39 100644 --- a/migrations/versions/9e8d7d15d950_.py +++ b/migrations/versions/9e8d7d15d950_.py @@ -1,4 +1,4 @@ -"""empty message +"""Initial database setup Revision ID: 9e8d7d15d950 Revises: diff --git a/migrations/versions/f9070ff1fa4a_.py b/migrations/versions/f9070ff1fa4a_.py new file mode 100644 index 00000000..a0cfb00f --- /dev/null +++ b/migrations/versions/f9070ff1fa4a_.py @@ -0,0 +1,31 @@ +"""Remove token entries for rudimentary API authentication mechanism + +Revision ID: f9070ff1fa4a +Revises: 9e8d7d15d950 +Create Date: 2022-09-01 13:46:47.425268 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'f9070ff1fa4a' +down_revision = '9e8d7d15d950' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_users_token', table_name='users') + op.drop_column('users', 'token') + op.drop_column('users', 'token_expiration') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('token_expiration', sa.DateTime(), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('token', sa.VARCHAR(length=32), autoincrement=False, nullable=True)) + op.create_index('ix_users_token', 'users', ['token'], unique=False) + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index f2962f70..40206acb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ +apifairy cqi docker eventlet -Flask +Flask==2.1.3 Flask-APScheduler Flask-Assets Flask-Hashids @@ -10,12 +11,12 @@ Flask-Login Flask-Mail Flask-Migrate Flask-Paranoid -Flask-RESTX Flask-SocketIO Flask-SQLAlchemy Flask-WTF hiredis MarkupSafe==2.0.1 +marshmallow-sqlalchemy psycopg2 PyJWT pyScss