Compare commits

..

6 Commits

Author SHA1 Message Date
Patrick Jentsch
d4cd313940 Implement /admin using flask-admin. Overall cleanup 2024-12-16 15:37:19 +01:00
Patrick Jentsch
c405061574 Restructure corpora blueprint 2024-12-16 11:39:54 +01:00
Patrick Jentsch
6c1f48eb2f Update corpora package 2024-12-16 10:09:54 +01:00
Patrick Jentsch
cda28910f5 Update settings 2024-12-16 10:07:21 +01:00
Patrick Jentsch
9a805b9d14 Add value to submit button 2024-12-16 09:57:18 +01:00
Patrick Jentsch
16bf891654 fix wtf macros 2024-12-16 09:45:19 +01:00
50 changed files with 894 additions and 1518 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ class UpdateAccountInformationForm(FlaskForm):
) )
submit = SubmitField() submit = SubmitField()
def __init__(self, user, *args, **kwargs): def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs: if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable() kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs: if 'prefix' not in kwargs:
@ -89,7 +89,7 @@ class UpdateProfileInformationForm(FlaskForm):
) )
submit = SubmitField() submit = SubmitField()
def __init__(self, user, *args, **kwargs): def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs: if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable() kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs: if 'prefix' not in kwargs:
@ -130,7 +130,7 @@ class UpdatePasswordForm(FlaskForm):
) )
submit = SubmitField() submit = SubmitField()
def __init__(self, user, *args, **kwargs): def __init__(self, user: User, *args, **kwargs):
if 'prefix' not in kwargs: if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-password-form' kwargs['prefix'] = 'update-password-form'
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -152,7 +152,7 @@ class UpdateNotificationsForm(FlaskForm):
) )
submit = SubmitField() submit = SubmitField()
def __init__(self, user, *args, **kwargs): def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs: if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable() kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs: if 'prefix' not in kwargs:

View File

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

View File

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

View File

@ -0,0 +1,58 @@
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
@socketio.on('SUBSCRIBE User')
@socketio_login_required
def subscribe(user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
join_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}
@socketio.on('UNSUBSCRIBE User')
@socketio_login_required
def unsubscribe(user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
leave_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,9 +42,8 @@ def resource_after_delete(mapper, connection, resource):
'path': resource.jsonpatch_path 'path': resource.jsonpatch_path
} }
] ]
namespace = '/users'
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('patch', jsonpatch, namespace=namespace, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def cfa_after_delete(mapper, connection, cfa): def cfa_after_delete(mapper, connection, cfa):
@ -55,9 +54,8 @@ def cfa_after_delete(mapper, connection, cfa):
'path': jsonpatch_path 'path': jsonpatch_path
} }
] ]
namespace = '/users'
room = f'/users/{cfa.corpus.user.hashid}' room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('patch', jsonpatch, namespace=namespace, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def resource_after_insert(mapper, connection, resource): def resource_after_insert(mapper, connection, resource):
@ -71,9 +69,8 @@ def resource_after_insert(mapper, connection, resource):
'value': jsonpatch_value 'value': jsonpatch_value
} }
] ]
namespace = '/users'
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('patch', jsonpatch, namespace=namespace, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def cfa_after_insert(mapper, connection, cfa): def cfa_after_insert(mapper, connection, cfa):
@ -86,9 +83,8 @@ def cfa_after_insert(mapper, connection, cfa):
'value': jsonpatch_value 'value': jsonpatch_value
} }
] ]
namespace = '/users'
room = f'/users/{cfa.corpus.user.hashid}' room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('patch', jsonpatch, namespace=namespace, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def resource_after_update(mapper, connection, resource): def resource_after_update(mapper, connection, resource):
@ -113,9 +109,8 @@ def resource_after_update(mapper, connection, resource):
} }
) )
if jsonpatch: if jsonpatch:
namespace = '/users'
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('patch', jsonpatch, namespace=namespace, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def job_after_update(mapper, connection, job): def job_after_update(mapper, connection, job):

View File

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

View File

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

View File

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

View File

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

View File

@ -1,215 +0,0 @@
from datetime import datetime
from flask import current_app, Flask, url_for
from flask_login import current_user
from flask_socketio import Namespace
from string import punctuation
import nltk
from app import db, hashids, socketio
from app.decorators import socketio_login_required
from app.models import Corpus, CorpusFollowerAssociation, CorpusFollowerRole
def _delete_corpus(app: Flask, corpus_id: int):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.delete()
db.session.commit()
def _build_corpus(app: Flask, corpus_id: int):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.build()
db.session.commit()
class CorporaNamespace(Namespace):
@socketio_login_required
def on_delete(self, corpus_hashid: str) -> dict:
if not isinstance(corpus_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
corpus_id = hashids.decode(corpus_hashid)
if not isinstance(corpus_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
corpus.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
socketio.start_background_task(
_delete_corpus,
current_app._get_current_object(),
corpus_id
)
return {
'body': f'Corpus "{corpus.title}" marked for deletion',
'status': 202,
'statusText': 'Accepted'
}
@socketio_login_required
def on_build(self, corpus_hashid: str) -> dict:
if not isinstance(corpus_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
corpus_id = hashids.decode(corpus_hashid)
if not isinstance(corpus_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'status': 404, 'statusText': 'Not Found'}
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if not (
cfa is not None and cfa.role.has_permission('MANAGE_FILES')
or corpus.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
if len(corpus.files.all()) == 0:
return {'status': 409, 'statusText': 'Conflict'}
socketio.start_background_task(
_build_corpus,
current_app._get_current_object(),
corpus_id
)
return {
'body': f'Corpus "{corpus.title}" marked for building',
'status': 202,
'statusText': 'Accepted'
}
# TODO: Think about where to place this, as this does not belong here...
@socketio_login_required
def on_get_stopwords(self):
languages = [
'german',
'english',
'catalan',
'greek',
'spanish',
'french',
'italian',
'russian',
'chinese'
]
nltk.download('stopwords', quiet=True)
stopwords = {
language: nltk.corpus.stopwords.words(language)
for language in languages
}
stopwords['punctuation'] = list(punctuation)
stopwords['punctuation'] += ['', '|', '', '', '', '--']
stopwords['user_stopwords'] = []
return {
'body': stopwords,
'status': 200,
'statusText': 'OK'
}
@socketio_login_required
def on_create_share_link(self, corpus_hashid: str, expiration_date: str, role_name: str) -> dict:
if not isinstance(corpus_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
if not isinstance(expiration_date, str):
return {'status': 400, 'statusText': 'Bad Request'}
if not isinstance(role_name, str):
return {'status': 400, 'statusText': 'Bad Request'}
print(corpus_hashid, expiration_date, role_name)
corpus_id = hashids.decode(corpus_hashid)
if not isinstance(corpus_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'status': 404, 'statusText': 'Not Found'}
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if not (
cfa is not None and cfa.role.has_permission('MANAGE_FOLLOWERS')
or corpus.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
_expiration_date = datetime.strptime(expiration_date, '%b %d, %Y')
cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
if cfr is None:
return {'status': 400, 'statusText': 'Bad Request'}
token = current_user.generate_follow_corpus_token(
corpus.hashid,
role_name,
_expiration_date
)
corpus_share_link = url_for(
'corpora.follow_corpus',
corpus_id=corpus_id,
token=token,
_external=True
)
return {
'body': corpus_share_link,
'status': 200,
'statusText': 'OK'
}
@socketio_login_required
def on_set_is_public(corpus_hashid: str, new_value: bool) -> dict:
if not isinstance(corpus_id, str):
return {'status': 400, 'statusText': 'Bad Request'}
if not isinstance(new_value, bool):
return {'status': 400, 'statusText': 'Bad Request'}
corpus_id = hashids.decode(corpus_hashid)
if not isinstance(corpus_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
corpus.user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
corpus.is_public = new_value
db.session.commit()
return {
'body': f'Corpus "{corpus.title}" is now {"public" if new_value else "private"}',
'status': 200,
'statusText': 'OK'
}

View File

@ -1,128 +0,0 @@
from flask import current_app, Flask
from flask_login import current_user
from flask_socketio import join_room, leave_room, Namespace
from app import db, hashids, socketio
from app.decorators import socketio_login_required
from app.models import User
def _delete_user(app: Flask, user_id: int):
with app.app_context():
user = User.query.get(user_id)
user.delete()
db.session.commit()
class UsersNamespace(Namespace):
@socketio_login_required
def on_get(self, user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
return {
'body': user.to_json_serializeable(
backrefs=True,
relationships=True
),
'status': 200,
'statusText': 'OK'
}
@socketio_login_required
def on_subscribe(self, user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
join_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}
@socketio_login_required
def on_unsubscribe(self, user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
leave_room(f'/users/{user.hashid}')
return {'status': 200, 'statusText': 'OK'}
@socketio_login_required
def on_delete(self, user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {'status': 400, 'statusText': 'Bad Request'}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {'status': 400, 'statusText': 'Bad Request'}
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not Found'}
if not (
user == current_user
or current_user.is_administrator
):
return {'status': 403, 'statusText': 'Forbidden'}
socketio.start_background_task(
_delete_user,
current_app._get_current_object(),
user.id
)
return {
'body': f'User "{user.username}" marked for deletion',
'status': 202,
'statusText': 'Accepted'
}

View File

@ -5,6 +5,7 @@ nopaque.app.Client = class Client {
// Endpoints // Endpoints
this.corpora = new nopaque.app.endpoints.Corpora(this); this.corpora = new nopaque.app.endpoints.Corpora(this);
this.jobs = new nopaque.app.endpoints.Jobs(this); this.jobs = new nopaque.app.endpoints.Jobs(this);
this.settings = new nopaque.app.endpoints.Settings(this);
this.users = new nopaque.app.endpoints.Users(this); this.users = new nopaque.app.endpoints.Users(this);
// Extensions // Extensions

View File

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

View File

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

View File

@ -1,43 +1,52 @@
nopaque.app.endpoints.Users = class Users { nopaque.app.endpoints.Users = class Users {
constructor(app) { constructor(app) {
this.app = app; this.app = app;
this.socket = io('/users', {transports: ['websocket'], upgrade: false});
} }
async get(id) { async get(userId) {
const response = await this.socket.emitWithAck('get', id); const options = {
headers: {
Accept: 'application/json'
}
};
if (response.status !== 200) { const response = await fetch(`/users/${userId}`, options);
throw new Error(`[${response.status}] ${response.statusText}`); const data = await response.json();
if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
} }
return response.body; async subscribe(userId) {
} const response = await this.app.socket.emitWithAck('SUBSCRIBE User', userId);
async subscribe(id) {
const response = await this.socket.emitWithAck('subscribe', id);
if (response.status != 200) { if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`); throw new Error(`[${response.status}] ${response.statusText}`);
} }
} }
async unsubscribe(id) { async unsubscribe(userId) {
const response = await this.socket.emitWithAck('unsubscribe', id); const response = await this.app.socket.emitWithAck('UNSUBSCRIBE User', userId);
if (response.status != 200) { if (response.status != 200) {
throw new Error(`[${response.status}] ${response.statusText}`); throw new Error(`[${response.status}] ${response.statusText}`);
} }
} }
async delete(id) { async delete(userId) {
const response = await this.socket.emitWithAck('delete', id); const options = {
headers: {
Accept: 'application/json'
},
method: 'DELETE'
};
if (response.status != 202) { const response = await fetch(`/users/${userId}`, options);
throw new Error(`[${response.status}] ${response.statusText}`); const data = await response.json();
}
return response.body; if (!response.ok) {throw new Error(`${data.name}: ${data.description}`);}
return data;
} }
} }

View File

@ -13,7 +13,7 @@ nopaque.app.extensions.UserHub = class UserHub extends EventTarget {
} }
init() { init() {
this.app.users.socket.on('patch', (patch) => {this.#onPatch(patch)}); this.app.socket.on('PATCH', (patch) => {this.#onPatch(patch)});
} }
add(userId) { add(userId) {

View File

@ -73,7 +73,7 @@ nopaque.corpus_analysis.StaticVisualizationExtension = class StaticVisualization
} }
async getStopwords() { async getStopwords() {
const stopwords = await app.corpora.getStopwords(); const stopwords = await app.corpora.getStopwords(this.app.corpusId);
this.data.originalStopwords = structuredClone(stopwords); this.data.originalStopwords = structuredClone(stopwords);
this.data.stopwords = structuredClone(stopwords); this.data.stopwords = structuredClone(stopwords);
return stopwords; return stopwords;

View File

@ -44,8 +44,8 @@
Your profile Your profile
</a> </a>
</li> </li>
<li {% if request.path == url_for('settings.settings') %}class="active"{% endif %}> <li {% if request.path == url_for('settings.index') %}class="active"{% endif %}>
<a href="{{ url_for('settings.settings') }}"> <a href="{{ url_for('settings.index') }}">
<i class="material-icons">settings</i> <i class="material-icons">settings</i>
Settings Settings
</a> </a>
@ -68,8 +68,8 @@
</li> </li>
{% endif %} {% endif %}
{% if current_user.is_administrator %} {% if current_user.is_administrator %}
<li {% if request.path == url_for('admin.admin') %}class="active"{% endif %}> <li>
<a href="{{ url_for('admin.admin') }}"> <a href="{{ url_for('admin.index') }}">
<i class="material-icons left">admin_panel_settings</i> <i class="material-icons left">admin_panel_settings</i>
Administration Administration
</a> </a>

View File

@ -13,6 +13,7 @@
'js/app/endpoints/index.js', 'js/app/endpoints/index.js',
'js/app/endpoints/corpora.js', 'js/app/endpoints/corpora.js',
'js/app/endpoints/jobs.js', 'js/app/endpoints/jobs.js',
'js/app/endpoints/settings.js',
'js/app/endpoints/users.js', 'js/app/endpoints/users.js',
'js/app/extensions/index.js', 'js/app/extensions/index.js',
'js/app/extensions/toaster.js', 'js/app/extensions/toaster.js',

View File

@ -85,8 +85,8 @@
</li> </li>
{# settings #} {# settings #}
<li {% if request.path == url_for('settings.settings') %}class="active"{% endif %}> <li {% if request.path == url_for('settings.index') %}class="active"{% endif %}>
<a class="waves-effect" href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>Settings</a> <a class="waves-effect" href="{{ url_for('settings.index') }}"><i class="material-icons">settings</i>Settings</a>
</li> </li>
{# log out #} {# log out #}
@ -118,8 +118,8 @@
{% if current_user.is_administrator %} {% if current_user.is_administrator %}
{# Administration #} {# Administration #}
<li {% if request.path == url_for('admin.admin') %}class="active"{% endif %}> <li>
<a class="waves-effect" href="{{ url_for('admin.admin') }}"><i class="material-icons">admin_panel_settings</i>Administration</a> <a class="waves-effect" href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -246,7 +246,7 @@
let publishingModalIsPublicSwitchElement = document.querySelector('#publishing-modal-is-public-switch'); let publishingModalIsPublicSwitchElement = document.querySelector('#publishing-modal-is-public-switch');
publishingModalIsPublicSwitchElement.addEventListener('change', (event) => { publishingModalIsPublicSwitchElement.addEventListener('change', (event) => {
let newIsPublic = publishingModalIsPublicSwitchElement.checked; let newIsPublic = publishingModalIsPublicSwitchElement.checked;
app.corpora.setIsPublic.update({{ corpus.hashid|tojson }}, newIsPublic) app.corpora.updateIsPublic({{ corpus.hashid|tojson }}, newIsPublic)
.catch((response) => { .catch((response) => {
publishingModalIsPublicSwitchElement.checked = !newIsPublic; publishingModalIsPublicSwitchElement.checked = !newIsPublic;
}); });

View File

@ -46,19 +46,19 @@
<div class="row" style="margin-left: 24px;"> <div class="row" style="margin-left: 24px;">
<div class="col s12 l3"> <div class="col s12 l3">
<label> <label>
<input {% if user.has_profile_privacy_setting('SHOW_EMAIL') %}checked{% endif %} class="profile-privacy-setting-checkbox" data-profile-privacy-setting-name="SHOW_EMAIL" {% if not user.is_public %}disabled{% endif %} type="checkbox"> <input {% if user.has_profile_privacy_setting('SHOW_EMAIL') %}checked{% endif %} class="profile-privacy-setting-checkbox" data-profile-privacy-setting-name="ProfileShowEmail" {% if not user.is_public %}disabled{% endif %} type="checkbox">
<span>Email</span> <span>Email</span>
</label> </label>
</div> </div>
<div class="col s12 l3"> <div class="col s12 l3">
<label> <label>
<input {% if user.has_profile_privacy_setting('SHOW_LAST_SEEN') %}checked{% endif %} class="profile-privacy-setting-checkbox" data-profile-privacy-setting-name="SHOW_LAST_SEEN" {% if not user.is_public %}disabled{% endif %} type="checkbox"> <input {% if user.has_profile_privacy_setting('SHOW_LAST_SEEN') %}checked{% endif %} class="profile-privacy-setting-checkbox" data-profile-privacy-setting-name="ProfileShowLastSeen" {% if not user.is_public %}disabled{% endif %} type="checkbox">
<span>Last seen</span> <span>Last seen</span>
</label> </label>
</div> </div>
<div class="col s12 l3"> <div class="col s12 l3">
<label> <label>
<input {% if user.has_profile_privacy_setting('SHOW_MEMBER_SINCE') %}checked{% endif %} class="profile-privacy-setting-checkbox" data-profile-privacy-setting-name="SHOW_MEMBER_SINCE" {% if not user.is_public %}disabled{% endif %} type="checkbox"> <input {% if user.has_profile_privacy_setting('SHOW_MEMBER_SINCE') %}checked{% endif %} class="profile-privacy-setting-checkbox" data-profile-privacy-setting-name="ProfileShowMemberSince" {% if not user.is_public %}disabled{% endif %} type="checkbox">
<span>Member since</span> <span>Member since</span>
</label> </label>
</div> </div>
@ -74,7 +74,7 @@
<form method="POST"> <form method="POST">
{{ update_profile_information_form.hidden_tag() }} {{ update_profile_information_form.hidden_tag() }}
{{ wtf.render_field(update_profile_information_form.full_name, material_icon='badge') }} {{ wtf.render_field(update_profile_information_form.full_name, material_icon='badge') }}
{{ wtf.render_field(update_profile_information_form.about_me, material_icon='description', id='about-me-textfield') }} {{ wtf.render_field(update_profile_information_form.about_me, material_icon='description') }}
{{ wtf.render_field(update_profile_information_form.website, material_icon='laptop') }} {{ wtf.render_field(update_profile_information_form.website, material_icon='laptop') }}
{{ wtf.render_field(update_profile_information_form.organization, material_icon='business') }} {{ wtf.render_field(update_profile_information_form.organization, material_icon='business') }}
{{ wtf.render_field(update_profile_information_form.location, material_icon='location_on') }} {{ wtf.render_field(update_profile_information_form.location, material_icon='location_on') }}
@ -172,8 +172,6 @@
</li> </li>
</ul> </ul>
</div> </div>
{% block admin_settings %}{% endblock admin_settings %}
</div> </div>
</div> </div>
{% endblock page_content %} {% endblock page_content %}
@ -252,28 +250,28 @@ for (let collapsibleElement of document.querySelectorAll('.collapsible.no-autoin
// #region Profile Privacy settings // #region Profile Privacy settings
let profileIsPublicSwitchElement = document.querySelector('#profile-is-public-switch'); let profileIsPublicSwitchElement = document.querySelector('#profile-is-public-switch');
let profilePrivacySettingCheckboxElements = document.querySelectorAll('.profile-privacy-setting-checkbox'); let profilePrivacySettingCheckboxElements = document.querySelectorAll('.profile-privacy-setting-checkbox');
profileIsPublicSwitchElement.addEventListener('change', (event) => { profileIsPublicSwitchElement.addEventListener('change', async (event) => {
let newEnabled = profileIsPublicSwitchElement.checked; const newEnabled = profileIsPublicSwitchElement.checked;
nopaque.requests.users.entity.settings.profilePrivacy.update({{ user.hashid|tojson }}, 'is-public', newEnabled) try {
.then( const message = await app.settings.updateProfileIsPublic(newEnabled);
(response) => { for (let profilePrivacySettingCheckboxElement of profilePrivacySettingCheckboxElements) {
for (let profilePrivacySettingCheckboxElement of document.querySelectorAll('.profile-privacy-setting-checkbox')) {
profilePrivacySettingCheckboxElement.disabled = !newEnabled; profilePrivacySettingCheckboxElement.disabled = !newEnabled;
} }
}, app.ui.flash(message);
(response) => { } catch (e) {
profileIsPublicSwitchElement.checked = !newEnabled; profileIsPublicSwitchElement.checked = !newEnabled;
app.ui.flash(e.message, 'error');
} }
);
}); });
for (let profilePrivacySettingCheckboxElement of profilePrivacySettingCheckboxElements) { for (let profilePrivacySettingCheckboxElement of profilePrivacySettingCheckboxElements) {
profilePrivacySettingCheckboxElement.addEventListener('change', (event) => { profilePrivacySettingCheckboxElement.addEventListener('change', async (event) => {
let newEnabled = profilePrivacySettingCheckboxElement.checked; const newEnabled = profilePrivacySettingCheckboxElement.checked;
let valueName = profilePrivacySettingCheckboxElement.dataset.profilePrivacySettingName; const valueName = profilePrivacySettingCheckboxElement.dataset.profilePrivacySettingName;
nopaque.requests.users.entity.settings.profilePrivacy.update({{ user.hashid|tojson }}, valueName, newEnabled) try {
.catch((response) => { app.settings[`update${valueName}`](newEnabled)
} catch (error) {
profilePrivacySettingCheckboxElement.checked = !newEnabled; profilePrivacySettingCheckboxElement.checked = !newEnabled;
}); }
}); });
} }
// #endregion Profile Privacy settings // #endregion Profile Privacy settings

View File

@ -7,6 +7,8 @@
{{ render_integer_field(field, *args, **kwargs) }} {{ render_integer_field(field, *args, **kwargs) }}
{% elif field.type == 'MultipleFileField' %} {% elif field.type == 'MultipleFileField' %}
{{ render_multiple_file_field(field, *args, **kwargs) }} {{ render_multiple_file_field(field, *args, **kwargs) }}
{% elif field.type == 'StringField' %}
{{ render_string_field(field, *args, **kwargs) }}
{% elif field.type == 'SubmitField' %} {% elif field.type == 'SubmitField' %}
{{ render_submit_field(field, *args, **kwargs) }} {{ render_submit_field(field, *args, **kwargs) }}
{% elif field.type == 'TextAreaField' %} {% elif field.type == 'TextAreaField' %}
@ -20,7 +22,7 @@
{% macro render_boolean_field(field) %} {% macro render_boolean_field(field) %}
<div> <div>
<label> <label>
<input id="{{ field.id }}" name="{{ field.name }}" type="checkbox"> {{ field(*args, **kwargs) }}
<span>{{ field.label.text }}</span> <span>{{ field.label.text }}</span>
{% for error in field.errors %} {% for error in field.errors %}
<span class="helper-text error-color-text">{{ error }}</span> <span class="helper-text error-color-text">{{ error }}</span>
@ -36,25 +38,7 @@
<div class="file-field input-field"> <div class="file-field input-field">
<div class="btn"> <div class="btn">
<span>{{ field.label.text }}</span> <span>{{ field.label.text }}</span>
<input id="{{ field.id }}" name="{{ field.name }}" type="file"> {{ field(*args, **kwargs) }}
</div>
<div class="file-path-wrapper">
<input class="file-path validate" type="text" placeholder="{{ placeholder }}">
</div>
{% for error in field.errors %}
<span class="helper-text error-color-text">{{ error }}</span>
{% endfor %}
</div>
{% endmacro %}
{% macro render_multiple_file_field(field) %}
{% set placeholder = kwargs.pop('placeholder', '') %}
<div class="file-field input-field">
<div class="btn">
<span>{{ field.label.text }}</span>
<input id="{{ field.id }}" name="{{ field.name }}" type="file" multiple>
</div> </div>
<div class="file-path-wrapper"> <div class="file-path-wrapper">
<input class="file-path validate" type="text" placeholder="{{ placeholder }}"> <input class="file-path validate" type="text" placeholder="{{ placeholder }}">
@ -67,12 +51,29 @@
{% macro render_integer_field(field) %} {% macro render_integer_field(field) %}
<div class="input-field"> {% set classes = kwargs.pop('class_', '').split(' ') %}
{% if 'material_icon' in kwargs %}
<i class="material-icons prefix">{{ kwargs.pop('material_icon') }}</i> {% if 'validate' not in classes %}
{% set _ = classes.append('validate') %}
{% endif %} {% endif %}
<input class="validate" id="{{ field.id }}" name="{{ field.name }}" type="number">
<label for="{{ field.id }}">{{ field.label.text }}</label> {% set _ = kwargs.update({'class_': ' '.join(classes)}) %}
{{ render_generic_field(field, *args, **kwargs) }}
{% endmacro %}
{% macro render_multiple_file_field(field) %}
{% set placeholder = kwargs.pop('placeholder', '') %}
<div class="file-field input-field">
<div class="btn">
<span>{{ field.label.text }}</span>
{{ field(*args, **kwargs) }}
</div>
<div class="file-path-wrapper">
<input class="file-path validate" type="text" placeholder="{{ placeholder }}">
</div>
{% for error in field.errors %} {% for error in field.errors %}
<span class="helper-text error-color-text">{{ error }}</span> <span class="helper-text error-color-text">{{ error }}</span>
{% endfor %} {% endfor %}
@ -80,8 +81,21 @@
{% endmacro %} {% endmacro %}
{% macro render_string_field(field) %}
{% set classes = kwargs.pop('class_', '').split(' ') %}
{% if 'validate' not in classes %}
{% set _ = classes.append('validate') %}
{% endif %}
{% set _ = kwargs.update({'class_': ' '.join(classes)}) %}
{{ render_generic_field(field, *args, **kwargs) }}
{% endmacro %}
{% macro render_submit_field(field) %} {% macro render_submit_field(field) %}
<button class="btn waves-effect waves-light" id="{{ field.id }}" name="{{ field.name }}" type="submit"> <button class="btn waves-effect waves-light" id="{{ field.id }}" name="{{ field.name }}" type="submit" value="Submit">
{{ field.label.text }} {{ field.label.text }}
{% if 'material_icon' in kwargs %} {% if 'material_icon' in kwargs %}
<i class="material-icons right">{{ kwargs.pop('material_icon') }}</i> <i class="material-icons right">{{ kwargs.pop('material_icon') }}</i>
@ -91,26 +105,23 @@
{% macro render_text_area_field(field) %} {% macro render_text_area_field(field) %}
<div class="input-field"> {% set classes = kwargs.pop('class_', '').split(' ') %}
{% if 'material_icon' in kwargs %}
<i class="material-icons prefix">{{ kwargs.pop('material_icon') }}</i> {% if 'materialize-textarea' not in classes %}
{% set _ = classes.append('materialize-textarea') %}
{% endif %} {% endif %}
<textarea class="materialize-textarea validate" id="{{ field.id }}" name="{{ field.name }}"></textarea>
<label for="{{ field.id }}">{{ field.label.text }}</label> {% if 'validate' not in classes %}
{% for error in field.errors %} {% set _ = classes.append('validate') %}
<span class="helper-text error-color-text">{{ error }}</span> {% endif %}
{% endfor %}
</div> {% set _ = kwargs.update({'class_': ' '.join(classes)}) %}
{{ render_generic_field(field, *args, **kwargs) }}
{% endmacro %} {% endmacro %}
{% macro render_generic_field(field) %} {% macro render_generic_field(field) %}
{% set classes_ = kwargs.pop('class_', '').split(' ') %}
{% if 'validate' not in classes_ %}
{% set _ = classes_.append('validate') %}
{% endif %}
{% set _ = kwargs.update({'class_': ' '.join(classes_)}) %}
<div class="input-field"> <div class="input-field">
{% if 'material_icon' in kwargs %} {% if 'material_icon' in kwargs %}
<i class="material-icons prefix">{{ kwargs.pop('material_icon') }}</i> <i class="material-icons prefix">{{ kwargs.pop('material_icon') }}</i>

View File

@ -14,6 +14,7 @@ docker==7.0.0
email_validator==2.1.1 email_validator==2.1.1
eventlet==0.34.2 eventlet==0.34.2
Flask==2.3.3 Flask==2.3.3
Flask-Admin==1.6.1
Flask-APScheduler==1.13.1 Flask-APScheduler==1.13.1
Flask-Assets==2.1.0 Flask-Assets==2.1.0
Flask-Hashids==1.0.3 Flask-Hashids==1.0.3

View File

@ -4,6 +4,7 @@ dnspython==2.5.0
docker docker
eventlet==0.34.2 eventlet==0.34.2
Flask==2.3.3 Flask==2.3.3
Flask-Admin==1.6.1
Flask-APScheduler Flask-APScheduler
Flask-Assets Flask-Assets
Flask-Hashids Flask-Hashids