mirror of
https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
synced 2025-07-01 10:20:34 +00:00
Compare commits
106 Commits
access-pip
...
developmen
Author | SHA1 | Date | |
---|---|---|---|
56844e0898 | |||
c28d534942 | |||
80604bf8de | |||
d4cd313940 | |||
c405061574 | |||
6c1f48eb2f | |||
cda28910f5 | |||
9a805b9d14 | |||
16bf891654 | |||
cb53b27ebf | |||
6684257bc4 | |||
0d1805fb76 | |||
bb60a2ba67 | |||
328f85ba52 | |||
93344c9573 | |||
1372c86609 | |||
713a7645db | |||
0c64c07925 | |||
a6ddf4c980 | |||
cab5f7ea05 | |||
07f09cdbd9 | |||
c97b2a886e | |||
df2bffe0fd | |||
aafb3ca3ec | |||
12a3ac1d5d | |||
a2904caea2 | |||
e325552100 | |||
e269156925 | |||
9c9de242ca | |||
ec54fdc3bb | |||
2263a8d27d | |||
143cdd91f9 | |||
b5f7478e14 | |||
a95b8d979d | |||
18d5ab160e | |||
7439edacef | |||
99d7a8bdfc | |||
54c4295bf7 | |||
1e5c26b8e3 | |||
9f56647cf7 | |||
460257294d | |||
2c43333c94 | |||
fc8b11fa66 | |||
a8ab1bee71 | |||
ee7f64f5be | |||
6aacac2419 | |||
ce253f4a65 | |||
7b604ce4f2 | |||
98b20e5cab | |||
a322ffb2f1 | |||
29365984a3 | |||
bd0a9c60f8 | |||
d41ebc6efe | |||
63690222ed | |||
b4faa1c695 | |||
909b130285 | |||
c223f07289 | |||
fcb49025e9 | |||
191d7813a7 | |||
f255fef631 | |||
76171f306d | |||
5ea6d45f46 | |||
289a551122 | |||
2a28f19660 | |||
fc2ace4b9e | |||
a174bf968f | |||
551b928dca | |||
eeb5a280b3 | |||
5fc3015bf1 | |||
5f05cedf5e | |||
aabea234fe | |||
492fdc9d28 | |||
02e6c7c16c | |||
c7ca674b2f | |||
81c6f32a35 | |||
94548ac30c | |||
158190de1a | |||
13e4d461c7 | |||
e51dcafa6f | |||
f79c6d48b2 | |||
5ee9edef9f | |||
f1ccda6ad7 | |||
a65b1ff578 | |||
fe0fcb0e10 | |||
32fa632961 | |||
562b8d5ce0 | |||
cbd0a41bce | |||
c68286e010 | |||
4a29a52f2a | |||
991810cff5 | |||
6025a4a606 | |||
e1cfd394fa | |||
882987ba68 | |||
a03b5918d9 | |||
43b38b2216 | |||
543276d766 | |||
485a0155c6 | |||
c29c50feb9 | |||
c191e7bd4a | |||
8f960cf359 | |||
ccf484c9bc | |||
d0d2a8abd6 | |||
03876f6a39 | |||
cdf6f9fcfd | |||
268da220d2 | |||
84e1755a57 |
@ -5,9 +5,9 @@
|
||||
!app
|
||||
!migrations
|
||||
!tests
|
||||
!.flaskenv
|
||||
!boot.sh
|
||||
!config.py
|
||||
!docker-nopaque-entrypoint.sh
|
||||
!nopaque.py
|
||||
!requirements.txt
|
||||
!requirements.freezed.txt
|
||||
!wsgi.py
|
||||
|
22
.env.tpl
22
.env.tpl
@ -1,32 +1,20 @@
|
||||
##############################################################################
|
||||
# Variables for use in Docker Compose YAML files #
|
||||
# Environment variables used by Docker Compose config files. #
|
||||
##############################################################################
|
||||
# HINT: Use this bash command `id -u`
|
||||
# NOTE: 0 (= root user) is not allowed
|
||||
HOST_UID=
|
||||
|
||||
# HINT: Use this bash command `id -g`
|
||||
# NOTE: 0 (= root group) is not allowed
|
||||
HOST_GID=
|
||||
|
||||
# HINT: Use this bash command `getent group docker | cut -d: -f3`
|
||||
HOST_DOCKER_GID=
|
||||
|
||||
# DEFAULT: nopaque
|
||||
# DOCKER_DEFAULT_NETWORK_NAME=
|
||||
|
||||
# DEFAULT: ./volumes/db/data
|
||||
# NOTE: Use `.` as <project-basedir>
|
||||
# DOCKER_DB_SERVICE_DATA_VOLUME_SOURCE_PATH=
|
||||
|
||||
# DEFAULT: ./volumes/mq/data
|
||||
# NOTE: Use `.` as <project-basedir>
|
||||
# DOCKER_MQ_SERVICE_DATA_VOLUME_SOURCE_PATH=
|
||||
NOPAQUE_DOCKER_NETWORK_NAME=nopaque
|
||||
|
||||
# NOTE: This must be a network share and it must be available on all
|
||||
# Docker Swarm nodes, mounted to the same path with the same
|
||||
# user and group ownership.
|
||||
DOCKER_NOPAQUE_SERVICE_DATA_VOLUME_SOURCE_PATH=
|
||||
|
||||
# DEFAULT: ./volumes/nopaque/logs
|
||||
# NOTE: Use `.` as <project-basedir>
|
||||
# DOCKER_NOPAQUE_SERVICE_LOGS_VOLUME_SOURCE_PATH=.
|
||||
# Docker Swarm nodes, mounted to the same path.
|
||||
HOST_NOPAQUE_DATA_PATH=/mnt/nopaque
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,8 +2,6 @@
|
||||
app/static/gen/
|
||||
volumes/
|
||||
docker-compose.override.yml
|
||||
logs/
|
||||
!logs/dummy
|
||||
*.env
|
||||
|
||||
*.pjentsch-testing
|
||||
|
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@ -1,9 +1,17 @@
|
||||
{
|
||||
"editor.rulers": [79],
|
||||
"files.insertFinalNewline": true,
|
||||
"[css]": {
|
||||
"editor.tabSize": 2
|
||||
"editor.tabSize": 4,
|
||||
"emmet.includeLanguages": {
|
||||
"jinja-html": "html"
|
||||
},
|
||||
"files.associations": {
|
||||
".flaskenv": "env",
|
||||
"*.env.tpl": "env",
|
||||
"*.txt.j2": "jinja"
|
||||
},
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"[html]": {
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
@ -12,8 +20,5 @@
|
||||
},
|
||||
"[jinja-html]": {
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
}
|
||||
|
11
Dockerfile
11
Dockerfile
@ -35,20 +35,17 @@ ENV PATH="${NOPAQUE_PYTHON3_VENV_PATH}/bin:${PATH}"
|
||||
|
||||
|
||||
# Install Python dependencies
|
||||
COPY --chown=nopaque:nopaque requirements.txt requirements.txt
|
||||
RUN python3 -m pip install --requirement requirements.txt \
|
||||
&& rm requirements.txt
|
||||
COPY --chown=nopaque:nopaque requirements.freezed.txt requirements.freezed.txt
|
||||
RUN python3 -m pip install --requirement requirements.freezed.txt \
|
||||
&& rm requirements.freezed.txt
|
||||
|
||||
|
||||
# Install the application
|
||||
COPY docker-nopaque-entrypoint.sh /usr/local/bin/
|
||||
|
||||
COPY --chown=nopaque:nopaque app app
|
||||
COPY --chown=nopaque:nopaque migrations migrations
|
||||
COPY --chown=nopaque:nopaque tests tests
|
||||
COPY --chown=nopaque:nopaque .flaskenv boot.sh config.py nopaque.py requirements.txt ./
|
||||
|
||||
RUN mkdir logs
|
||||
COPY --chown=nopaque:nopaque boot.sh config.py wsgi.py ./
|
||||
|
||||
|
||||
EXPOSE 5000
|
||||
|
@ -35,7 +35,7 @@ username@hostname:~$ sudo mount --types cifs --options gid=${USER},password=nopa
|
||||
# Clone the nopaque repository
|
||||
username@hostname:~$ git clone https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
|
||||
# Create data directories
|
||||
username@hostname:~$ mkdir data/{db,logs,mq}
|
||||
username@hostname:~$ mkdir -p volumes/{db,mq}
|
||||
username@hostname:~$ cp db.env.tpl db.env
|
||||
username@hostname:~$ cp .env.tpl .env
|
||||
# Fill out the variables within these files.
|
||||
|
122
app/__init__.py
122
app/__init__.py
@ -2,9 +2,10 @@ from apifairy import APIFairy
|
||||
from config import Config
|
||||
from docker import DockerClient
|
||||
from flask import Flask
|
||||
from flask.logging import default_handler
|
||||
from flask_admin import Admin
|
||||
from flask_apscheduler import APScheduler
|
||||
from flask_assets import Environment
|
||||
from flask_breadcrumbs import Breadcrumbs, default_breadcrumb_root
|
||||
from flask_login import LoginManager
|
||||
from flask_mail import Mail
|
||||
from flask_marshmallow import Marshmallow
|
||||
@ -13,98 +14,143 @@ from flask_paranoid import Paranoid
|
||||
from flask_socketio import SocketIO
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_hashids import Hashids
|
||||
from logging import Formatter, StreamHandler
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from .extensions.nopaque_flask_admin_views import AdminIndexView, ModelView
|
||||
|
||||
|
||||
docker_client = DockerClient.from_env()
|
||||
|
||||
admin = Admin()
|
||||
apifairy = APIFairy()
|
||||
assets = Environment()
|
||||
breadcrumbs = Breadcrumbs()
|
||||
db = SQLAlchemy()
|
||||
docker_client = DockerClient()
|
||||
hashids = Hashids()
|
||||
login = LoginManager()
|
||||
login.login_view = 'auth.login'
|
||||
login.login_message = 'Please log in to access this page.'
|
||||
ma = Marshmallow()
|
||||
mail = Mail()
|
||||
migrate = Migrate(compare_type=True)
|
||||
paranoid = Paranoid()
|
||||
paranoid.redirect_view = '/'
|
||||
scheduler = APScheduler()
|
||||
socketio = SocketIO()
|
||||
|
||||
|
||||
def create_app(config: Config = Config) -> Flask:
|
||||
''' Creates an initialized Flask (WSGI Application) object. '''
|
||||
''' Creates an initialized Flask object. '''
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config)
|
||||
config.init_app(app)
|
||||
|
||||
# region Logging
|
||||
log_formatter = Formatter(
|
||||
fmt=app.config['NOPAQUE_LOG_FORMAT'],
|
||||
datefmt=app.config['NOPAQUE_LOG_DATE_FORMAT']
|
||||
)
|
||||
|
||||
log_handler = StreamHandler()
|
||||
log_handler.setFormatter(log_formatter)
|
||||
log_handler.setLevel(app.config['NOPAQUE_LOG_LEVEL'])
|
||||
|
||||
app.logger.setLevel('DEBUG')
|
||||
app.logger.removeHandler(default_handler)
|
||||
app.logger.addHandler(log_handler)
|
||||
# endregion Logging
|
||||
|
||||
# region Middlewares
|
||||
if app.config['NOPAQUE_PROXY_FIX_ENABLED']:
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=app.config['NOPAQUE_PROXY_FIX_X_FOR'],
|
||||
x_host=app.config['NOPAQUE_PROXY_FIX_X_HOST'],
|
||||
x_port=app.config['NOPAQUE_PROXY_FIX_X_PORT'],
|
||||
x_prefix=app.config['NOPAQUE_PROXY_FIX_X_PREFIX'],
|
||||
x_proto=app.config['NOPAQUE_PROXY_FIX_X_PROTO']
|
||||
)
|
||||
# endregion Middlewares
|
||||
|
||||
# region Extensions
|
||||
docker_client.login(
|
||||
app.config['NOPAQUE_DOCKER_REGISTRY_USERNAME'],
|
||||
password=app.config['NOPAQUE_DOCKER_REGISTRY_PASSWORD'],
|
||||
registry=app.config['NOPAQUE_DOCKER_REGISTRY']
|
||||
)
|
||||
|
||||
from .models import AnonymousUser, User
|
||||
|
||||
admin.init_app(app, index_view=AdminIndexView())
|
||||
apifairy.init_app(app)
|
||||
assets.init_app(app)
|
||||
breadcrumbs.init_app(app)
|
||||
db.init_app(app)
|
||||
hashids.init_app(app)
|
||||
login.init_app(app)
|
||||
login.anonymous_user = AnonymousUser
|
||||
login.login_view = 'auth.login'
|
||||
login.user_loader(lambda user_id: User.query.get(int(user_id)))
|
||||
ma.init_app(app)
|
||||
mail.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
paranoid.init_app(app)
|
||||
paranoid.redirect_view = '/'
|
||||
scheduler.init_app(app)
|
||||
socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI']) # noqa
|
||||
socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI'])
|
||||
# endregion Extensions
|
||||
|
||||
from .models.event_listeners import register_event_listeners
|
||||
register_event_listeners()
|
||||
|
||||
from .admin import bp as admin_blueprint
|
||||
default_breadcrumb_root(admin_blueprint, '.admin')
|
||||
app.register_blueprint(admin_blueprint, url_prefix='/admin')
|
||||
|
||||
from .api import bp as api_blueprint
|
||||
# region Blueprints
|
||||
from .blueprints.api import bp as api_blueprint
|
||||
app.register_blueprint(api_blueprint, url_prefix='/api')
|
||||
|
||||
from .auth import bp as auth_blueprint
|
||||
default_breadcrumb_root(auth_blueprint, '.')
|
||||
from .blueprints.auth import bp as auth_blueprint
|
||||
app.register_blueprint(auth_blueprint)
|
||||
|
||||
from .contributions import bp as contributions_blueprint
|
||||
default_breadcrumb_root(contributions_blueprint, '.contributions')
|
||||
from .blueprints.contributions import bp as contributions_blueprint
|
||||
app.register_blueprint(contributions_blueprint, url_prefix='/contributions')
|
||||
|
||||
from .corpora import bp as corpora_blueprint
|
||||
from .corpora.cqi_over_sio import CQiNamespace
|
||||
default_breadcrumb_root(corpora_blueprint, '.corpora')
|
||||
from .blueprints.corpora import bp as corpora_blueprint
|
||||
app.register_blueprint(corpora_blueprint, cli_group='corpus', url_prefix='/corpora')
|
||||
socketio.on_namespace(CQiNamespace('/cqi_over_sio'))
|
||||
|
||||
from .errors import bp as errors_bp
|
||||
from .blueprints.errors import bp as errors_bp
|
||||
app.register_blueprint(errors_bp)
|
||||
|
||||
from .jobs import bp as jobs_blueprint
|
||||
default_breadcrumb_root(jobs_blueprint, '.jobs')
|
||||
from .blueprints.jobs import bp as jobs_blueprint
|
||||
app.register_blueprint(jobs_blueprint, url_prefix='/jobs')
|
||||
|
||||
from .main import bp as main_blueprint
|
||||
default_breadcrumb_root(main_blueprint, '.')
|
||||
from .blueprints.main import bp as main_blueprint
|
||||
app.register_blueprint(main_blueprint, cli_group=None)
|
||||
|
||||
from .services import bp as services_blueprint
|
||||
default_breadcrumb_root(services_blueprint, '.services')
|
||||
from .blueprints.services import bp as services_blueprint
|
||||
app.register_blueprint(services_blueprint, url_prefix='/services')
|
||||
|
||||
from .settings import bp as settings_blueprint
|
||||
default_breadcrumb_root(settings_blueprint, '.settings')
|
||||
from .blueprints.settings import bp as settings_blueprint
|
||||
app.register_blueprint(settings_blueprint, url_prefix='/settings')
|
||||
|
||||
from .users import bp as users_blueprint
|
||||
default_breadcrumb_root(users_blueprint, '.users')
|
||||
from .blueprints.users import bp as users_blueprint
|
||||
app.register_blueprint(users_blueprint, cli_group='user', url_prefix='/users')
|
||||
|
||||
from .workshops import bp as workshops_blueprint
|
||||
from .blueprints.workshops import bp as workshops_blueprint
|
||||
app.register_blueprint(workshops_blueprint, url_prefix='/workshops')
|
||||
|
||||
from .models import _models
|
||||
for model in _models:
|
||||
admin.add_view(ModelView(model, db.session, category='Database'))
|
||||
# endregion Blueprints
|
||||
|
||||
# region SocketIO Namespaces
|
||||
from .namespaces.cqi_over_sio import CQiOverSocketIONamespace
|
||||
socketio.on_namespace(CQiOverSocketIONamespace('/cqi_over_sio'))
|
||||
# endregion SocketIO Namespaces
|
||||
|
||||
# region Database event Listeners
|
||||
from .models.event_listeners import register_event_listeners
|
||||
register_event_listeners()
|
||||
# endregion Database event Listeners
|
||||
|
||||
# region Add scheduler jobs
|
||||
if app.config['NOPAQUE_IS_PRIMARY_INSTANCE']:
|
||||
from .jobs import handle_corpora
|
||||
scheduler.add_job('handle_corpora', handle_corpora, seconds=3, trigger='interval')
|
||||
|
||||
from .jobs import handle_jobs
|
||||
scheduler.add_job('handle_jobs', handle_jobs, seconds=3, trigger='interval')
|
||||
# endregion Add scheduler jobs
|
||||
|
||||
return app
|
||||
|
@ -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
|
@ -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()]
|
@ -1,23 +0,0 @@
|
||||
from flask import abort, request
|
||||
from app import db
|
||||
from app.decorators import content_negotiation
|
||||
from app.models import User
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/users/<hashid:user_id>/confirmed', methods=['PUT'])
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_user_role(user_id):
|
||||
confirmed = request.json
|
||||
if not isinstance(confirmed, bool):
|
||||
abort(400)
|
||||
user = User.query.get_or_404(user_id)
|
||||
user.confirmed = confirmed
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': (
|
||||
f'User "{user.username}" is now '
|
||||
f'{"confirmed" if confirmed else "unconfirmed"}'
|
||||
)
|
||||
}
|
||||
return response_data, 200
|
@ -1,146 +0,0 @@
|
||||
from flask import abort, flash, redirect, render_template, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from app import db, hashids
|
||||
from app.models import Avatar, Corpus, Role, User
|
||||
from app.users.settings.forms import (
|
||||
UpdateAvatarForm,
|
||||
UpdatePasswordForm,
|
||||
UpdateNotificationsForm,
|
||||
UpdateAccountInformationForm,
|
||||
UpdateProfileInformationForm
|
||||
)
|
||||
from . import bp
|
||||
from .forms import UpdateUserForm
|
||||
from app.users.utils import (
|
||||
user_endpoint_arguments_constructor as user_eac,
|
||||
user_dynamic_list_constructor as user_dlc
|
||||
)
|
||||
|
||||
|
||||
@bp.route('')
|
||||
@register_breadcrumb(bp, '.', '<i class="material-icons left">admin_panel_settings</i>Administration')
|
||||
def admin():
|
||||
return render_template(
|
||||
'admin/admin.html.j2',
|
||||
title='Administration'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/corpora')
|
||||
@register_breadcrumb(bp, '.corpora', 'Corpora')
|
||||
def corpora():
|
||||
corpora = Corpus.query.all()
|
||||
return render_template(
|
||||
'admin/corpora.html.j2',
|
||||
title='Corpora',
|
||||
corpora=corpora
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/users')
|
||||
@register_breadcrumb(bp, '.users', '<i class="material-icons left">group</i>Users')
|
||||
def users():
|
||||
users = User.query.all()
|
||||
return render_template(
|
||||
'admin/users.html.j2',
|
||||
title='Users',
|
||||
users=users
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/users/<hashid:user_id>')
|
||||
@register_breadcrumb(bp, '.users.entity', '', dynamic_list_constructor=user_dlc)
|
||||
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'])
|
||||
@register_breadcrumb(bp, '.users.entity.settings', '<i class="material-icons left">settings</i>Settings')
|
||||
def user_settings(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
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
|
||||
)
|
@ -5,8 +5,8 @@ from flask import abort, Blueprint
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
from app import db, hashids
|
||||
from app.models import Job, JobInput, JobStatus, TesseractOCRPipelineModel
|
||||
from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRPipelineModelSchema
|
||||
from .auth import auth_error_responses, token_auth
|
||||
from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRPipelineModelSchema
|
||||
|
||||
|
||||
bp = Blueprint('jobs', __name__)
|
||||
@ -77,7 +77,7 @@ def delete_job(job_id):
|
||||
job = Job.query.get(job_id)
|
||||
if job is None:
|
||||
abort(404)
|
||||
if not (job.user == current_user or current_user.is_administrator()):
|
||||
if not (job.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
try:
|
||||
job.delete()
|
||||
@ -97,6 +97,6 @@ def get_job(job_id):
|
||||
job = Job.query.get(job_id)
|
||||
if job is None:
|
||||
abort(404)
|
||||
if not (job.user == current_user or current_user.is_administrator()):
|
||||
if not (job.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return job
|
@ -10,7 +10,7 @@ from app.models import (
|
||||
User,
|
||||
UserSettingJobStatusMailNotificationLevel
|
||||
)
|
||||
from app.services import SERVICES
|
||||
from app.blueprints.services import SERVICES
|
||||
|
||||
|
||||
|
@ -3,11 +3,11 @@ from apifairy import authenticate, body, response
|
||||
from apifairy.decorators import other_responses
|
||||
from flask import abort, Blueprint
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
from app import db
|
||||
from app.email import create_message, send
|
||||
from app import db
|
||||
from app.models import User
|
||||
from .schemas import EmptySchema, UserSchema
|
||||
from .auth import auth_error_responses, token_auth
|
||||
from .schemas import EmptySchema, UserSchema
|
||||
|
||||
|
||||
bp = Blueprint('users', __name__)
|
||||
@ -60,7 +60,7 @@ def delete_user(user_id):
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
if not (user == current_user or current_user.is_administrator()):
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
user.delete()
|
||||
db.session.commit()
|
||||
@ -78,7 +78,7 @@ def get_user(user_id):
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
if not (user == current_user or current_user.is_administrator()):
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return user
|
||||
|
||||
@ -94,6 +94,6 @@ def get_user_by_username(username):
|
||||
user = User.query.filter(User.username == username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
if not (user == current_user or current_user.is_administrator()):
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return user
|
27
app/blueprints/auth/__init__.py
Normal file
27
app/blueprints/auth/__init__.py
Normal file
@ -0,0 +1,27 @@
|
||||
from flask import Blueprint, redirect, request, url_for
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def before_request():
|
||||
if not current_user.is_authenticated:
|
||||
return
|
||||
|
||||
current_user.ping()
|
||||
db.session.commit()
|
||||
|
||||
if (
|
||||
not current_user.confirmed
|
||||
and request.endpoint
|
||||
and request.blueprint != 'auth'
|
||||
and request.endpoint != 'static'
|
||||
and request.endpoint != 'main.accept_terms_of_use'
|
||||
):
|
||||
return redirect(url_for('auth.unconfirmed'))
|
||||
|
||||
|
||||
from . import routes
|
@ -60,7 +60,11 @@ class RegistrationForm(FlaskForm):
|
||||
|
||||
def validate_username(self, field):
|
||||
if User.query.filter_by(username=field.data).first():
|
||||
raise ValidationError('Username already in use')
|
||||
raise ValidationError('Username already registered')
|
||||
|
||||
def validate_terms_of_use_accepted(self, field):
|
||||
if not field.data:
|
||||
raise ValidationError('Terms of Use not accepted')
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
@ -1,5 +1,4 @@
|
||||
from flask import abort, flash, redirect, render_template, request, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from flask_login import current_user, login_user, login_required, logout_user
|
||||
from app import db
|
||||
from app.email import create_message, send
|
||||
@ -13,24 +12,7 @@ from .forms import (
|
||||
)
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def before_request():
|
||||
"""
|
||||
Checks if a user is unconfirmed when visiting specific sites. Redirects to
|
||||
unconfirmed view if user is unconfirmed.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
current_user.ping()
|
||||
db.session.commit()
|
||||
if (not current_user.confirmed
|
||||
and request.endpoint
|
||||
and request.blueprint != 'auth'
|
||||
and request.endpoint != 'static'):
|
||||
return redirect(url_for('auth.unconfirmed'))
|
||||
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.register', 'Register')
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
@ -67,7 +49,6 @@ def register():
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.login', 'Login')
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
@ -98,7 +79,6 @@ def logout():
|
||||
|
||||
|
||||
@bp.route('/unconfirmed')
|
||||
@register_breadcrumb(bp, '.unconfirmed', 'Unconfirmed')
|
||||
@login_required
|
||||
def unconfirmed():
|
||||
if current_user.confirmed:
|
||||
@ -141,7 +121,6 @@ def confirm(token):
|
||||
|
||||
|
||||
@bp.route('/reset-password-request', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.reset_password_request', 'Password Reset')
|
||||
def reset_password_request():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
@ -171,7 +150,6 @@ def reset_password_request():
|
||||
|
||||
|
||||
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.reset_password', 'Password Reset')
|
||||
def reset_password(token):
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
25
app/blueprints/contributions/__init__.py
Normal file
25
app/blueprints/contributions/__init__.py
Normal file
@ -0,0 +1,25 @@
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('contributions', __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 .spacy_nlp_pipeline_models import bp as spacy_nlp_pipeline_models_bp
|
||||
bp.register_blueprint(spacy_nlp_pipeline_models_bp, url_prefix='/spacy-nlp-pipeline-models')
|
||||
|
||||
from .tesseract_ocr_pipeline_models import bp as tesseract_ocr_pipeline_models_bp
|
||||
bp.register_blueprint(tesseract_ocr_pipeline_models_bp, url_prefix='/tesseract-ocr-pipeline-models')
|
7
app/blueprints/contributions/routes.py
Normal file
7
app/blueprints/contributions/routes.py
Normal file
@ -0,0 +1,7 @@
|
||||
from flask import render_template
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
def index():
|
||||
return render_template('contributions/index.html.j2', title='Contributions')
|
@ -1,8 +1,8 @@
|
||||
from flask import Blueprint
|
||||
from flask import current_app, Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('users', __name__)
|
||||
bp = Blueprint('spacy_nlp_pipeline_models', __name__)
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@ -15,4 +15,4 @@ def before_request():
|
||||
pass
|
||||
|
||||
|
||||
from . import cli, events, json_routes, routes, settings
|
||||
from . import routes, json_routes
|
@ -1,7 +1,7 @@
|
||||
from flask_wtf.file import FileField, FileRequired
|
||||
from wtforms import StringField, ValidationError
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from app.services import SERVICES
|
||||
from app.blueprints.services import SERVICES
|
||||
from ..forms import ContributionBaseForm, UpdateContributionBaseForm
|
||||
|
||||
|
@ -1,13 +1,14 @@
|
||||
from flask import abort, current_app, request
|
||||
from flask_login import current_user
|
||||
from flask_login import current_user, login_required
|
||||
from threading import Thread
|
||||
from app import db
|
||||
from app.decorators import content_negotiation, permission_required
|
||||
from app.models import SpaCyNLPPipelineModel
|
||||
from .. import bp
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
|
||||
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
@content_negotiation(produces='application/json')
|
||||
def delete_spacy_model(spacy_nlp_pipeline_model_id):
|
||||
def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
|
||||
@ -15,9 +16,9 @@ def delete_spacy_model(spacy_nlp_pipeline_model_id):
|
||||
snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
|
||||
snpm.delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
|
||||
if not (snpm.user == current_user or current_user.is_administrator()):
|
||||
if not (snpm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
thread = Thread(
|
||||
target=_delete_spacy_model,
|
||||
@ -31,7 +32,7 @@ def delete_spacy_model(spacy_nlp_pipeline_model_id):
|
||||
return response_data, 202
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
|
||||
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
|
||||
@permission_required('CONTRIBUTE')
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
|
||||
@ -39,7 +40,7 @@ def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
|
||||
if not isinstance(is_public, bool):
|
||||
abort(400)
|
||||
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
|
||||
if not (snpm.user == current_user or current_user.is_administrator()):
|
||||
if not (snpm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
snpm.is_public = is_public
|
||||
db.session.commit()
|
@ -1,6 +1,5 @@
|
||||
from flask import abort, flash, redirect, render_template, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from flask_login import current_user
|
||||
from flask_login import current_user, login_required
|
||||
from app import db
|
||||
from app.models import SpaCyNLPPipelineModel
|
||||
from . import bp
|
||||
@ -8,23 +7,17 @@ from .forms import (
|
||||
CreateSpaCyNLPPipelineModelForm,
|
||||
UpdateSpaCyNLPPipelineModelForm
|
||||
)
|
||||
from .utils import (
|
||||
spacy_nlp_pipeline_model_dlc as spacy_nlp_pipeline_model_dlc
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models')
|
||||
@register_breadcrumb(bp, '.spacy_nlp_pipeline_models', 'SpaCy NLP Pipeline Models')
|
||||
def spacy_nlp_pipeline_models():
|
||||
return render_template(
|
||||
'contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_models.html.j2',
|
||||
title='SpaCy NLP Pipeline Models'
|
||||
)
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
return redirect(url_for('contributions.index', _anchor='spacy-nlp-pipeline-models'))
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.spacy_nlp_pipeline_models.create', 'Create')
|
||||
def create_spacy_nlp_pipeline_model():
|
||||
@bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
form = CreateSpaCyNLPPipelineModelForm()
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
@ -48,7 +41,7 @@ def create_spacy_nlp_pipeline_model():
|
||||
abort(500)
|
||||
db.session.commit()
|
||||
flash(f'SpaCy NLP Pipeline model "{snpm.title}" created')
|
||||
return {}, 201, {'Location': url_for('.spacy_nlp_pipeline_models')}
|
||||
return {}, 201, {'Location': url_for('.index')}
|
||||
return render_template(
|
||||
'contributions/spacy_nlp_pipeline_models/create.html.j2',
|
||||
title='Create SpaCy NLP Pipeline Model',
|
||||
@ -56,11 +49,11 @@ def create_spacy_nlp_pipeline_model():
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.spacy_nlp_pipeline_models.entity', '', dynamic_list_constructor=spacy_nlp_pipeline_model_dlc)
|
||||
def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
|
||||
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def entity(spacy_nlp_pipeline_model_id):
|
||||
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
|
||||
if not (snpm.user == current_user or current_user.is_administrator()):
|
||||
if not (snpm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
form = UpdateSpaCyNLPPipelineModelForm(data=snpm.to_json_serializeable())
|
||||
if form.validate_on_submit():
|
||||
@ -68,9 +61,9 @@ def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
|
||||
if db.session.is_modified(snpm):
|
||||
flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated')
|
||||
db.session.commit()
|
||||
return redirect(url_for('.spacy_nlp_pipeline_models'))
|
||||
return redirect(url_for('.index'))
|
||||
return render_template(
|
||||
'contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2',
|
||||
'contributions/spacy_nlp_pipeline_models/entity.html.j2',
|
||||
title=f'{snpm.title} {snpm.version}',
|
||||
form=form,
|
||||
spacy_nlp_pipeline_model=snpm
|
@ -2,7 +2,7 @@ from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('settings', __name__)
|
||||
bp = Blueprint('tesseract_ocr_pipeline_models', __name__)
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@ -15,4 +15,4 @@ def before_request():
|
||||
pass
|
||||
|
||||
|
||||
from . import routes
|
||||
from . import json_routes, routes
|
@ -1,6 +1,6 @@
|
||||
from flask_wtf.file import FileField, FileRequired
|
||||
from wtforms import ValidationError
|
||||
from app.services import SERVICES
|
||||
from app.blueprints.services import SERVICES
|
||||
from ..forms import ContributionBaseForm, UpdateContributionBaseForm
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
|
||||
'File',
|
||||
validators=[FileRequired()]
|
||||
)
|
||||
|
||||
|
||||
def validate_tesseract_model_file(self, field):
|
||||
if not field.data.filename.lower().endswith('.traineddata'):
|
||||
raise ValidationError('traineddata files only!')
|
@ -7,7 +7,7 @@ from app.models import TesseractOCRPipelineModel
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
|
||||
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
|
||||
@content_negotiation(produces='application/json')
|
||||
def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
|
||||
def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
|
||||
@ -17,7 +17,7 @@ def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
|
||||
db.session.commit()
|
||||
|
||||
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
|
||||
if not (topm.user == current_user or current_user.is_administrator()):
|
||||
if not (topm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
thread = Thread(
|
||||
target=_delete_tesseract_ocr_pipeline_model,
|
||||
@ -31,7 +31,7 @@ def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
|
||||
return response_data, 202
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
|
||||
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
|
||||
@permission_required('CONTRIBUTE')
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id):
|
||||
@ -39,7 +39,7 @@ def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_i
|
||||
if not isinstance(is_public, bool):
|
||||
abort(400)
|
||||
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
|
||||
if not (topm.user == current_user or current_user.is_administrator()):
|
||||
if not (topm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
topm.is_public = is_public
|
||||
db.session.commit()
|
@ -1,5 +1,4 @@
|
||||
from flask import abort, flash, redirect, render_template, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
from app.models import TesseractOCRPipelineModel
|
||||
@ -8,23 +7,15 @@ from .forms import (
|
||||
CreateTesseractOCRPipelineModelForm,
|
||||
UpdateTesseractOCRPipelineModelForm
|
||||
)
|
||||
from .utils import (
|
||||
tesseract_ocr_pipeline_model_dlc as tesseract_ocr_pipeline_model_dlc
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models')
|
||||
@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models', 'Tesseract OCR Pipeline Models')
|
||||
def tesseract_ocr_pipeline_models():
|
||||
return render_template(
|
||||
'contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_models.html.j2',
|
||||
title='Tesseract OCR Pipeline Models'
|
||||
)
|
||||
@bp.route('/')
|
||||
def index():
|
||||
return redirect(url_for('contributions.index', _anchor='tesseract-ocr-pipeline-models'))
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models/create', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models.create', 'Create')
|
||||
def create_tesseract_ocr_pipeline_model():
|
||||
@bp.route('/create', methods=['GET', 'POST'])
|
||||
def create():
|
||||
form = CreateTesseractOCRPipelineModelForm()
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
@ -47,7 +38,7 @@ def create_tesseract_ocr_pipeline_model():
|
||||
abort(500)
|
||||
db.session.commit()
|
||||
flash(f'Tesseract OCR Pipeline model "{topm.title}" created')
|
||||
return {}, 201, {'Location': url_for('.tesseract_ocr_pipeline_models')}
|
||||
return {}, 201, {'Location': url_for('.index')}
|
||||
return render_template(
|
||||
'contributions/tesseract_ocr_pipeline_models/create.html.j2',
|
||||
title='Create Tesseract OCR Pipeline Model',
|
||||
@ -55,11 +46,10 @@ def create_tesseract_ocr_pipeline_model():
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.tesseract_ocr_pipeline_models.entity', '', dynamic_list_constructor=tesseract_ocr_pipeline_model_dlc)
|
||||
def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
|
||||
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
|
||||
def entity(tesseract_ocr_pipeline_model_id):
|
||||
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
|
||||
if not (topm.user == current_user or current_user.is_administrator()):
|
||||
if not (topm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
form = UpdateTesseractOCRPipelineModelForm(data=topm.to_json_serializeable())
|
||||
if form.validate_on_submit():
|
||||
@ -67,9 +57,9 @@ def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
|
||||
if db.session.is_modified(topm):
|
||||
flash(f'Tesseract OCR Pipeline model "{topm.title}" updated')
|
||||
db.session.commit()
|
||||
return redirect(url_for('.tesseract_ocr_pipeline_models'))
|
||||
return redirect(url_for('.index'))
|
||||
return render_template(
|
||||
'contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2',
|
||||
'contributions/tesseract_ocr_pipeline_models/entity.html.j2',
|
||||
title=f'{topm.title} {topm.version}',
|
||||
form=form,
|
||||
tesseract_ocr_pipeline_model=topm
|
@ -16,4 +16,4 @@ def before_request():
|
||||
pass
|
||||
|
||||
|
||||
from . import cli, files, followers, routes, json_routes
|
||||
from . import cli, files, followers, routes
|
@ -10,7 +10,7 @@ def corpus_follower_permission_required(*permissions):
|
||||
def decorated_function(*args, **kwargs):
|
||||
corpus_id = kwargs.get('corpus_id')
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
if not (corpus.user == current_user or current_user.is_administrator()):
|
||||
if not (corpus.user == current_user or current_user.is_administrator):
|
||||
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
|
||||
if cfa is None:
|
||||
abort(403)
|
||||
@ -26,7 +26,7 @@ def corpus_owner_or_admin_required(f):
|
||||
def decorated_function(*args, **kwargs):
|
||||
corpus_id = kwargs.get('corpus_id')
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
if not (corpus.user == current_user or current_user.is_administrator()):
|
||||
if not (corpus.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
@ -1,7 +1,7 @@
|
||||
from flask import abort, current_app
|
||||
from flask import current_app
|
||||
from threading import Thread
|
||||
from app import db
|
||||
from app.decorators import content_negotiation
|
||||
from app import db
|
||||
from app.models import CorpusFile
|
||||
from ..decorators import corpus_follower_permission_required
|
||||
from . import bp
|
@ -6,24 +6,19 @@ from flask import (
|
||||
send_from_directory,
|
||||
url_for
|
||||
)
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from app import db
|
||||
from app.models import Corpus, CorpusFile, CorpusStatus
|
||||
from ..decorators import corpus_follower_permission_required
|
||||
from ..utils import corpus_endpoint_arguments_constructor as corpus_eac
|
||||
from . import bp
|
||||
from .forms import CreateCorpusFileForm, UpdateCorpusFileForm
|
||||
from .utils import corpus_file_dynamic_list_constructor as corpus_file_dlc
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/files')
|
||||
@register_breadcrumb(bp, '.entity.files', 'Files', endpoint_arguments_constructor=corpus_eac)
|
||||
def corpus_files(corpus_id):
|
||||
return redirect(url_for('.corpus', _anchor='files', corpus_id=corpus_id))
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.entity.files.create', 'Create', endpoint_arguments_constructor=corpus_eac)
|
||||
@corpus_follower_permission_required('MANAGE_FILES')
|
||||
def create_corpus_file(corpus_id):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
@ -65,7 +60,6 @@ def create_corpus_file(corpus_id):
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.entity.files.entity', '', dynamic_list_constructor=corpus_file_dlc)
|
||||
@corpus_follower_permission_required('MANAGE_FILES')
|
||||
def corpus_file(corpus_id, corpus_file_id):
|
||||
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
|
||||
@ -94,6 +88,6 @@ def download_corpus_file(corpus_id, corpus_file_id):
|
||||
corpus_file.path.parent,
|
||||
corpus_file.path.name,
|
||||
as_attachment=True,
|
||||
attachment_filename=corpus_file.filename,
|
||||
download_name=corpus_file.filename,
|
||||
mimetype=corpus_file.mimetype
|
||||
)
|
@ -58,7 +58,7 @@ def delete_corpus_follower(corpus_id, follower_id):
|
||||
current_user.id == follower_id
|
||||
or current_user == cfa.corpus.user
|
||||
or CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first().role.has_permission('MANAGE_FOLLOWERS')
|
||||
or current_user.is_administrator()):
|
||||
or current_user.is_administrator):
|
||||
abort(403)
|
||||
if current_user.id == follower_id:
|
||||
flash(f'You are no longer following "{cfa.corpus.title}"', 'corpus')
|
299
app/blueprints/corpora/routes.py
Normal file
299
app/blueprints/corpora/routes.py
Normal file
@ -0,0 +1,299 @@
|
||||
from datetime import datetime
|
||||
from flask import (
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
Flask,
|
||||
jsonify,
|
||||
redirect,
|
||||
request,
|
||||
render_template,
|
||||
url_for
|
||||
)
|
||||
from flask_login import current_user
|
||||
from string import punctuation
|
||||
from threading import Thread
|
||||
import nltk
|
||||
from app import db
|
||||
from app.models import (
|
||||
Corpus,
|
||||
CorpusFollowerAssociation,
|
||||
CorpusFollowerRole,
|
||||
User
|
||||
)
|
||||
from . import bp
|
||||
from .decorators import corpus_follower_permission_required
|
||||
from .forms import CreateCorpusForm
|
||||
|
||||
|
||||
|
||||
def _delete_corpus(app: Flask, corpus_id: int):
|
||||
with app.app_context():
|
||||
corpus: Corpus = Corpus.query.get(corpus_id)
|
||||
corpus.delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _build_corpus(app: Flask, corpus_id: int):
|
||||
with app.app_context():
|
||||
corpus = Corpus.query.get(corpus_id)
|
||||
corpus.build()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route('')
|
||||
def corpora():
|
||||
return redirect(url_for('main.dashboard', _anchor='corpora'))
|
||||
|
||||
|
||||
@bp.route('/create', methods=['GET', 'POST'])
|
||||
def create_corpus():
|
||||
form = CreateCorpusForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
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}" created', 'corpus')
|
||||
return redirect(corpus.url)
|
||||
|
||||
return render_template(
|
||||
'corpora/create.html.j2',
|
||||
title='Create corpus',
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>')
|
||||
def 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 cfa is None:
|
||||
if corpus.user == current_user or current_user.is_administrator:
|
||||
cfr = CorpusFollowerRole.query.filter_by(name='Administrator').first()
|
||||
else:
|
||||
cfr = CorpusFollowerRole.query.filter_by(name='Anonymous').first()
|
||||
else:
|
||||
cfr = cfa.role
|
||||
|
||||
cfrs = CorpusFollowerRole.query.all()
|
||||
|
||||
# TODO: Better solution for filtering admin
|
||||
users = User.query.filter(
|
||||
User.is_public == True,
|
||||
User.id != current_user.id,
|
||||
User.id != corpus.user.id,
|
||||
User.role_id < 4
|
||||
).all()
|
||||
|
||||
if (
|
||||
corpus.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
return render_template(
|
||||
'corpora/corpus.html.j2',
|
||||
title=corpus.title,
|
||||
corpus=corpus,
|
||||
cfr=cfr,
|
||||
cfrs=cfrs,
|
||||
users=users
|
||||
)
|
||||
|
||||
if (
|
||||
current_user.is_following_corpus(corpus)
|
||||
or corpus.is_public
|
||||
):
|
||||
cfas = CorpusFollowerAssociation.query.filter(
|
||||
Corpus.id == corpus_id,
|
||||
CorpusFollowerAssociation.follower_id != corpus.user.id
|
||||
).all()
|
||||
return render_template(
|
||||
'corpora/public_corpus.html.j2',
|
||||
title=corpus.title,
|
||||
corpus=corpus,
|
||||
cfrs=cfrs,
|
||||
cfr=cfr,
|
||||
cfas=cfas,
|
||||
users=users
|
||||
)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>', methods=['DELETE'])
|
||||
def delete_corpus(corpus_id: int):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
|
||||
if not (
|
||||
corpus.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
thread = Thread(
|
||||
target=_delete_corpus,
|
||||
args=(current_app._get_current_object(), corpus.id)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify(f'Corpus "{corpus.title}" marked for deletion.'), 202
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/build', methods=['POST'])
|
||||
def build_corpus(corpus_id: int):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
|
||||
cfa = CorpusFollowerAssociation.query.filter_by(
|
||||
corpus_id=corpus_id,
|
||||
follower_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not (
|
||||
cfa is not None and cfa.role.has_permission('MANAGE_FILES')
|
||||
or corpus.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
if len(corpus.files.all()) == 0:
|
||||
abort(409)
|
||||
|
||||
thread = Thread(
|
||||
target=_build_corpus,
|
||||
args=(current_app._get_current_object(), corpus.id)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify(f'Corpus "{corpus.title}" marked for building.'), 202
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/create-share-link', methods=['POST'])
|
||||
def create_share_link(corpus_id: int):
|
||||
data = request.json
|
||||
|
||||
expiration_date = data['expiration_date']
|
||||
if not isinstance(expiration_date, str):
|
||||
abort(400)
|
||||
|
||||
role_name = data['role_name']
|
||||
if not isinstance(role_name, str):
|
||||
abort(400)
|
||||
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
|
||||
cfa = CorpusFollowerAssociation.query.filter_by(
|
||||
corpus_id=corpus_id,
|
||||
follower_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not (
|
||||
cfa is not None and cfa.role.has_permission('MANAGE_FOLLOWERS')
|
||||
or corpus.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
_expiration_date = datetime.strptime(expiration_date, '%b %d, %Y')
|
||||
|
||||
cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
|
||||
if cfr is None:
|
||||
abort(400)
|
||||
|
||||
token = current_user.generate_follow_corpus_token(
|
||||
corpus.hashid,
|
||||
role_name,
|
||||
_expiration_date
|
||||
)
|
||||
|
||||
corpus_share_link = url_for(
|
||||
'corpora.follow_corpus',
|
||||
corpus_id=corpus_id,
|
||||
token=token,
|
||||
_external=True
|
||||
)
|
||||
|
||||
return jsonify(corpus_share_link)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/analysis')
|
||||
@corpus_follower_permission_required('VIEW')
|
||||
def analysis(corpus_id: int):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
|
||||
return render_template(
|
||||
'corpora/analysis.html.j2',
|
||||
corpus=corpus,
|
||||
title=f'Analyse Corpus {corpus.title}'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/analysis/stopwords')
|
||||
def get_stopwords(corpus_id: int):
|
||||
languages = [
|
||||
'german',
|
||||
'english',
|
||||
'catalan',
|
||||
'greek',
|
||||
'spanish',
|
||||
'french',
|
||||
'italian',
|
||||
'russian',
|
||||
'chinese'
|
||||
]
|
||||
|
||||
nltk.download('stopwords', quiet=True)
|
||||
stopwords = {
|
||||
language: nltk.corpus.stopwords.words(language)
|
||||
for language in languages
|
||||
}
|
||||
stopwords['punctuation'] = list(punctuation)
|
||||
stopwords['punctuation'] += ['—', '|', '–', '“', '„', '--']
|
||||
stopwords['user_stopwords'] = []
|
||||
|
||||
return jsonify(stopwords)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/follow/<token>')
|
||||
def follow_corpus(corpus_id: int, token: str):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
|
||||
if not current_user.follow_corpus_by_token(token):
|
||||
abort(403)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f'You are following "{corpus.title}" now', category='corpus')
|
||||
return redirect(corpus.url)
|
||||
|
||||
|
||||
@bp.route('/<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)
|
||||
|
||||
|
||||
corpus.is_public = new_value
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(f'Corpus "{corpus.title}" is now {"public" if new_value else "private"}'), 200
|
@ -4,11 +4,17 @@ from . import bp
|
||||
|
||||
|
||||
@bp.app_errorhandler(HTTPException)
|
||||
def handle_http_exception(error):
|
||||
def handle_http_exception(e: HTTPException):
|
||||
''' Generic HTTP exception handler '''
|
||||
accept_json = request.accept_mimetypes.accept_json
|
||||
accept_html = request.accept_mimetypes.accept_html
|
||||
|
||||
if accept_json and not accept_html:
|
||||
response = jsonify(str(error))
|
||||
return response, error.code
|
||||
return render_template('errors/error.html.j2', error=error), error.code
|
||||
error = {
|
||||
'code': e.code,
|
||||
'name': e.name,
|
||||
'description': e.description
|
||||
}
|
||||
return jsonify(error), e.code
|
||||
|
||||
return render_template('errors/error.html.j2', error=e), e.code
|
13
app/blueprints/jobs/__init__.py
Normal file
13
app/blueprints/jobs/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('jobs', __name__)
|
||||
|
||||
|
||||
from . import routes
|
||||
|
||||
from .inputs import bp as inputs_bp
|
||||
bp.register_blueprint(inputs_bp, url_prefix='/<hashid:job_id>/inputs')
|
||||
|
||||
from .results import bp as results_bp
|
||||
bp.register_blueprint(results_bp, url_prefix='/<hashid:job_id>/results')
|
@ -1,5 +1,7 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
bp = Blueprint('inputs', __name__)
|
||||
|
||||
|
||||
from . import routes
|
27
app/blueprints/jobs/inputs/routes.py
Normal file
27
app/blueprints/jobs/inputs/routes.py
Normal file
@ -0,0 +1,27 @@
|
||||
from flask import abort, send_from_directory
|
||||
from flask_login import current_user, login_required
|
||||
from app.models import JobInput
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_input_id>/download')
|
||||
@login_required
|
||||
def download_job_input(job_id: int, job_input_id: int):
|
||||
job_input = JobInput.query.filter_by(
|
||||
job_id=job_id,
|
||||
id=job_input_id
|
||||
).first_or_404()
|
||||
|
||||
if not (
|
||||
job_input.job.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
return send_from_directory(
|
||||
job_input.path.parent,
|
||||
job_input.path.name,
|
||||
as_attachment=True,
|
||||
download_name=job_input.filename,
|
||||
mimetype=job_input.mimetype
|
||||
)
|
7
app/blueprints/jobs/results/__init__.py
Normal file
7
app/blueprints/jobs/results/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('results', __name__)
|
||||
|
||||
|
||||
from . import routes
|
27
app/blueprints/jobs/results/routes.py
Normal file
27
app/blueprints/jobs/results/routes.py
Normal file
@ -0,0 +1,27 @@
|
||||
from flask import abort, send_from_directory
|
||||
from flask_login import current_user, login_required
|
||||
from app.models import JobResult
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_result_id>/download')
|
||||
@login_required
|
||||
def download_job_result(job_id: int, job_result_id: int):
|
||||
job_result = JobResult.query.filter_by(
|
||||
job_id=job_id,
|
||||
id=job_result_id
|
||||
).first_or_404()
|
||||
|
||||
if not (
|
||||
job_result.job.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
return send_from_directory(
|
||||
job_result.path.parent,
|
||||
job_result.path.name,
|
||||
as_attachment=True,
|
||||
download_name=job_result.filename,
|
||||
mimetype=job_result.mimetype
|
||||
)
|
111
app/blueprints/jobs/routes.py
Normal file
111
app/blueprints/jobs/routes.py
Normal file
@ -0,0 +1,111 @@
|
||||
from flask import (
|
||||
abort,
|
||||
current_app,
|
||||
Flask,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
url_for
|
||||
)
|
||||
from flask_login import current_user, login_required
|
||||
from threading import Thread
|
||||
from app import db
|
||||
from app.decorators import admin_required
|
||||
from app.models import Job, JobStatus
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
@login_required
|
||||
def index():
|
||||
return redirect(url_for('main.dashboard', _anchor='jobs'))
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>')
|
||||
@login_required
|
||||
def job(job_id: int):
|
||||
job = Job.query.get_or_404(job_id)
|
||||
|
||||
if not (
|
||||
job.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
return render_template(
|
||||
'jobs/job.html.j2',
|
||||
title='Job',
|
||||
job=job
|
||||
)
|
||||
|
||||
|
||||
def _delete_job(app: Flask, job_id: int):
|
||||
with app.app_context():
|
||||
job = Job.query.get(job_id)
|
||||
job.delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_job(job_id: int):
|
||||
job = Job.query.get_or_404(job_id)
|
||||
|
||||
if not (
|
||||
job.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
thread = Thread(
|
||||
target=_delete_job,
|
||||
args=(current_app._get_current_object(), job.id)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify(f'Job "{job.title}" marked for deletion.'), 202
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>/log')
|
||||
@admin_required
|
||||
def job_log(job_id: int):
|
||||
job = Job.query.get_or_404(job_id)
|
||||
|
||||
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
|
||||
abort(409)
|
||||
|
||||
log_file_path = job.path / 'pipeline_data' / 'logs' / 'pyflow_log.txt'
|
||||
with log_file_path.open() as log_file:
|
||||
log = log_file.read()
|
||||
|
||||
return jsonify(log)
|
||||
|
||||
|
||||
def _restart_job(app: Flask, job_id: int):
|
||||
with app.app_context():
|
||||
job = Job.query.get(job_id)
|
||||
job.restart()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>/restart', methods=['POST'])
|
||||
@login_required
|
||||
def restart_job(job_id: int):
|
||||
job = Job.query.get_or_404(job_id)
|
||||
|
||||
if not (
|
||||
job.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
if job.status != JobStatus.FAILED:
|
||||
abort(409)
|
||||
|
||||
thread = Thread(
|
||||
target=_restart_job,
|
||||
args=(current_app._get_current_object(), job.id)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify(f'Job "{job.title}" marked for restarting.'), 202
|
@ -1,8 +1,9 @@
|
||||
from flask import current_app
|
||||
from flask_migrate import upgrade
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from app import db
|
||||
from app.models import (
|
||||
Corpus,
|
||||
CorpusFollowerRole,
|
||||
Role,
|
||||
SpaCyNLPPipelineModel,
|
||||
@ -15,10 +16,10 @@ from . import bp
|
||||
@bp.cli.command('deploy')
|
||||
def deploy():
|
||||
''' Run deployment tasks. '''
|
||||
# Make default directories
|
||||
|
||||
print('Make default directories')
|
||||
base_dir = current_app.config['NOPAQUE_DATA_DIR']
|
||||
default_dirs: List[Path] = [
|
||||
default_dirs: list[Path] = [
|
||||
base_dir / 'tmp',
|
||||
base_dir / 'users'
|
||||
]
|
||||
@ -28,11 +29,9 @@ def deploy():
|
||||
if not default_dir.is_dir():
|
||||
raise NotADirectoryError(f'{default_dir} is not a directory')
|
||||
|
||||
# migrate database to latest revision
|
||||
print('Migrate database to latest revision')
|
||||
upgrade()
|
||||
|
||||
# Insert/Update default database values
|
||||
print('Insert/Update default Roles')
|
||||
Role.insert_defaults()
|
||||
print('Insert/Update default Users')
|
||||
@ -44,4 +43,9 @@ def deploy():
|
||||
print('Insert/Update default TesseractOCRPipelineModels')
|
||||
TesseractOCRPipelineModel.insert_defaults()
|
||||
|
||||
print('Stop running analysis sessions')
|
||||
for corpus in Corpus.query.all():
|
||||
corpus.num_analysis_sessions = 0
|
||||
db.session.commit()
|
||||
|
||||
# TODO: Implement checks for if the nopaque network exists
|
@ -1,14 +1,12 @@
|
||||
from flask import flash, redirect, render_template, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from flask import abort, flash, jsonify, redirect, render_template, url_for
|
||||
from flask_login import current_user, login_required, login_user
|
||||
from app.auth.forms import LoginForm
|
||||
from app.blueprints.auth.forms import LoginForm
|
||||
from app.models import Corpus, User
|
||||
from sqlalchemy import or_
|
||||
from . import bp
|
||||
from app import db
|
||||
|
||||
|
||||
@bp.route('/', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.', '<i class="material-icons">home</i>')
|
||||
def index():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
@ -27,7 +25,6 @@ def index():
|
||||
|
||||
|
||||
@bp.route('/faq')
|
||||
@register_breadcrumb(bp, '.faq', 'Frequently Asked Questions')
|
||||
def faq():
|
||||
return render_template(
|
||||
'main/faq.html.j2',
|
||||
@ -36,7 +33,6 @@ def faq():
|
||||
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@register_breadcrumb(bp, '.dashboard', '<i class="material-icons left">dashboard</i>Dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
return render_template(
|
||||
@ -45,8 +41,15 @@ def dashboard():
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/manual')
|
||||
def manual():
|
||||
return render_template(
|
||||
'main/manual.html.j2',
|
||||
title='Manual'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/news')
|
||||
@register_breadcrumb(bp, '.news', '<i class="material-icons left">email</i>News')
|
||||
def news():
|
||||
return render_template(
|
||||
'main/news.html.j2',
|
||||
@ -54,8 +57,7 @@ def news():
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/privacy_policy')
|
||||
@register_breadcrumb(bp, '.privacy_policy', 'Private statement (GDPR)')
|
||||
@bp.route('/privacy-policy')
|
||||
def privacy_policy():
|
||||
return render_template(
|
||||
'main/privacy_policy.html.j2',
|
||||
@ -63,26 +65,32 @@ def privacy_policy():
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/terms_of_use')
|
||||
@register_breadcrumb(bp, '.terms_of_use', 'Terms of Use')
|
||||
@bp.route('/terms-of-use')
|
||||
def terms_of_use():
|
||||
return render_template(
|
||||
'main/terms_of_use.html.j2',
|
||||
title='Terms of Use'
|
||||
title='Terms of use'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/social-area')
|
||||
@register_breadcrumb(bp, '.social_area', '<i class="material-icons left">group</i>Social Area')
|
||||
@bp.route('/accept-terms-of-use', methods=['POST'])
|
||||
@login_required
|
||||
def social_area():
|
||||
print('test')
|
||||
def accept_terms_of_use():
|
||||
current_user.terms_of_use_accepted = True
|
||||
db.session.commit()
|
||||
|
||||
return jsonify('You accepted the terms of use'), 202
|
||||
|
||||
|
||||
|
||||
@bp.route('/social')
|
||||
@login_required
|
||||
def social():
|
||||
corpora = Corpus.query.filter(Corpus.is_public == True, Corpus.user != current_user).all()
|
||||
print(corpora)
|
||||
users = User.query.filter(User.is_public == True, User.id != current_user.id).all()
|
||||
return render_template(
|
||||
'main/social_area.html.j2',
|
||||
title='Social Area',
|
||||
'main/social.html.j2',
|
||||
title='Social',
|
||||
corpora=corpora,
|
||||
users=users
|
||||
)
|
@ -61,7 +61,7 @@ class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
|
||||
if field.data:
|
||||
if not('methods' in service_info and 'binarization' in service_info['methods']):
|
||||
raise ValidationError('Binarization is not available')
|
||||
|
||||
|
||||
def validate_pdf(self, field):
|
||||
if field.data.mimetype != 'application/pdf':
|
||||
raise ValidationError('PDF files only!')
|
||||
@ -146,7 +146,7 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
|
||||
encoding_detection = BooleanField('Encoding detection', render_kw={'disabled': True})
|
||||
txt = FileField('File', validators=[FileRequired()])
|
||||
model = SelectField('Model', validators=[InputRequired()])
|
||||
|
||||
|
||||
def validate_encoding_detection(self, field):
|
||||
service_info = SERVICES['spacy-nlp-pipeline']['versions'][self.version.data]
|
||||
if field.data:
|
||||
@ -167,7 +167,6 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
|
||||
version = kwargs.pop('version', service_manifest['latest_version'])
|
||||
super().__init__(*args, **kwargs)
|
||||
service_info = service_manifest['versions'][version]
|
||||
print(service_info)
|
||||
if self.encoding_detection.render_kw is None:
|
||||
self.encoding_detection.render_kw = {}
|
||||
self.encoding_detection.render_kw['disabled'] = True
|
@ -1,12 +1,10 @@
|
||||
from flask import abort, current_app, flash, Markup, redirect, render_template, request, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from flask import abort, current_app, flash, redirect, render_template, request, url_for
|
||||
from flask_login import current_user
|
||||
import requests
|
||||
from app import db, hashids
|
||||
from app.models import (
|
||||
Job,
|
||||
JobInput,
|
||||
JobResult,
|
||||
JobStatus,
|
||||
TesseractOCRPipelineModel,
|
||||
SpaCyNLPPipelineModel
|
||||
@ -21,13 +19,11 @@ from .forms import (
|
||||
|
||||
|
||||
@bp.route('/services')
|
||||
@register_breadcrumb(bp, '.', 'Services')
|
||||
def services():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@bp.route('/file-setup-pipeline', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.file_setup_pipeline', '<i class="nopaque-icons service-icons left" data-service="file-setup-pipeline"></i>File Setup')
|
||||
def file_setup_pipeline():
|
||||
service = 'file-setup-pipeline'
|
||||
service_manifest = SERVICES[service]
|
||||
@ -57,7 +53,7 @@ def file_setup_pipeline():
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
|
||||
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
|
||||
flash(message, 'job')
|
||||
return {}, 201, {'Location': job.url}
|
||||
return render_template(
|
||||
@ -68,15 +64,12 @@ def file_setup_pipeline():
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.tesseract_ocr_pipeline', '<i class="nopaque-icons service-icons left" data-service="tesseract-ocr-pipeline"></i>Tesseract OCR Pipeline')
|
||||
def tesseract_ocr_pipeline():
|
||||
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)
|
||||
job_results = JobResult.query.all()
|
||||
choosable_job_ids = [job_result.job.hashid for job_result in job_results if job_result.job.service == "file-setup-pipeline" and job_result.filename.endswith('.pdf')]
|
||||
form = CreateTesseractOCRPipelineJobForm(prefix='create-job-form', version=version)
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
@ -103,7 +96,7 @@ def tesseract_ocr_pipeline():
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
|
||||
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
|
||||
flash(message, 'job')
|
||||
return {}, 201, {'Location': job.url}
|
||||
tesseract_ocr_pipeline_models = [
|
||||
@ -114,7 +107,6 @@ def tesseract_ocr_pipeline():
|
||||
return render_template(
|
||||
'services/tesseract_ocr_pipeline.html.j2',
|
||||
title=service_manifest['name'],
|
||||
choosable_job_ids=choosable_job_ids,
|
||||
form=form,
|
||||
tesseract_ocr_pipeline_models=tesseract_ocr_pipeline_models,
|
||||
user_tesseract_ocr_pipeline_models_count=user_tesseract_ocr_pipeline_models_count
|
||||
@ -122,7 +114,6 @@ def tesseract_ocr_pipeline():
|
||||
|
||||
|
||||
@bp.route('/transkribus-htr-pipeline', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.transkribus_htr_pipeline', '<i class="nopaque-icons service-icons left" data-service="transkribus-htr-pipeline"></i>Transkribus HTR Pipeline')
|
||||
def transkribus_htr_pipeline():
|
||||
if not current_app.config.get('NOPAQUE_TRANSKRIBUS_ENABLED'):
|
||||
abort(404)
|
||||
@ -168,7 +159,7 @@ def transkribus_htr_pipeline():
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
|
||||
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
|
||||
flash(message, 'job')
|
||||
return {}, 201, {'Location': job.url}
|
||||
return render_template(
|
||||
@ -180,7 +171,6 @@ def transkribus_htr_pipeline():
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.spacy_nlp_pipeline', '<i class="nopaque-icons service-icons left" data-service="spacy-nlp-pipeline"></i>SpaCy NLP Pipeline')
|
||||
def spacy_nlp_pipeline():
|
||||
service = 'spacy-nlp-pipeline'
|
||||
service_manifest = SERVICES[service]
|
||||
@ -214,7 +204,7 @@ def spacy_nlp_pipeline():
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
|
||||
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
|
||||
flash(message, 'job')
|
||||
return {}, 201, {'Location': job.url}
|
||||
return render_template(
|
||||
@ -227,7 +217,6 @@ def spacy_nlp_pipeline():
|
||||
|
||||
|
||||
@bp.route('/corpus-analysis')
|
||||
@register_breadcrumb(bp, '.corpus_analysis', '<i class="nopaque-icons service-icons left" data-service="corpus-analysis"></i>Corpus Analysis')
|
||||
def corpus_analysis():
|
||||
return render_template(
|
||||
'services/corpus_analysis.html.j2',
|
7
app/blueprints/settings/__init__.py
Normal file
7
app/blueprints/settings/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('settings', __name__)
|
||||
|
||||
|
||||
from . import routes
|
@ -1,6 +1,5 @@
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileRequired
|
||||
from flask_wtf.file import FileField, FileRequired, FileSize
|
||||
from wtforms import (
|
||||
PasswordField,
|
||||
SelectField,
|
||||
@ -17,7 +16,6 @@ from wtforms.validators import (
|
||||
Regexp
|
||||
)
|
||||
from app.models import User, UserSettingJobStatusMailNotificationLevel
|
||||
from app.wtforms.validators import FileSize
|
||||
|
||||
|
||||
class UpdateAccountInformationForm(FlaskForm):
|
||||
@ -40,8 +38,8 @@ class UpdateAccountInformationForm(FlaskForm):
|
||||
]
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
|
||||
def __init__(self, user: User, *args, **kwargs):
|
||||
if 'data' not in kwargs:
|
||||
kwargs['data'] = user.to_json_serializeable()
|
||||
if 'prefix' not in kwargs:
|
||||
@ -66,7 +64,7 @@ class UpdateProfileInformationForm(FlaskForm):
|
||||
validators=[Length(max=128)]
|
||||
)
|
||||
about_me = TextAreaField(
|
||||
'About me',
|
||||
'About me',
|
||||
validators=[
|
||||
Length(max=254)
|
||||
]
|
||||
@ -91,7 +89,7 @@ class UpdateProfileInformationForm(FlaskForm):
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
def __init__(self, user: User, *args, **kwargs):
|
||||
if 'data' not in kwargs:
|
||||
kwargs['data'] = user.to_json_serializeable()
|
||||
if 'prefix' not in kwargs:
|
||||
@ -100,7 +98,7 @@ class UpdateProfileInformationForm(FlaskForm):
|
||||
|
||||
|
||||
class UpdateAvatarForm(FlaskForm):
|
||||
avatar = FileField('File', validators=[FileRequired(), FileSize(2)])
|
||||
avatar = FileField('File', validators=[FileRequired(), FileSize(2_000_000)])
|
||||
submit = SubmitField()
|
||||
|
||||
def validate_avatar(self, field):
|
||||
@ -132,7 +130,7 @@ class UpdatePasswordForm(FlaskForm):
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
def __init__(self, user: User, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'update-password-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -154,7 +152,7 @@ class UpdateNotificationsForm(FlaskForm):
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
def __init__(self, user: User, *args, **kwargs):
|
||||
if 'data' not in kwargs:
|
||||
kwargs['data'] = user.to_json_serializeable()
|
||||
if 'prefix' not in kwargs:
|
158
app/blueprints/settings/routes.py
Normal file
158
app/blueprints/settings/routes.py
Normal file
@ -0,0 +1,158 @@
|
||||
from flask import (
|
||||
abort,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for
|
||||
)
|
||||
from flask_login import current_user, login_required
|
||||
from app import db
|
||||
from app.models import Avatar
|
||||
from . import bp
|
||||
from .forms import (
|
||||
UpdateAvatarForm,
|
||||
UpdatePasswordForm,
|
||||
UpdateNotificationsForm,
|
||||
UpdateAccountInformationForm,
|
||||
UpdateProfileInformationForm
|
||||
)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def index():
|
||||
update_account_information_form = UpdateAccountInformationForm(current_user)
|
||||
update_profile_information_form = UpdateProfileInformationForm(current_user)
|
||||
update_avatar_form = UpdateAvatarForm()
|
||||
update_password_form = UpdatePasswordForm(current_user)
|
||||
update_notifications_form = UpdateNotificationsForm(current_user)
|
||||
|
||||
# region handle update profile information form
|
||||
if update_profile_information_form.submit.data and update_profile_information_form.validate():
|
||||
current_user.about_me = update_profile_information_form.about_me.data
|
||||
current_user.location = update_profile_information_form.location.data
|
||||
current_user.organization = update_profile_information_form.organization.data
|
||||
current_user.website = update_profile_information_form.website.data
|
||||
current_user.full_name = update_profile_information_form.full_name.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.index'))
|
||||
# endregion handle update profile information form
|
||||
|
||||
# region handle update avatar form
|
||||
if update_avatar_form.submit.data and update_avatar_form.validate():
|
||||
try:
|
||||
Avatar.create(
|
||||
update_avatar_form.avatar.data,
|
||||
user=current_user
|
||||
)
|
||||
except (AttributeError, OSError):
|
||||
abort(500)
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.index'))
|
||||
# endregion handle update avatar form
|
||||
|
||||
# region handle update account information form
|
||||
if update_account_information_form.submit.data and update_account_information_form.validate():
|
||||
current_user.email = update_account_information_form.email.data
|
||||
current_user.username = update_account_information_form.username.data
|
||||
db.session.commit()
|
||||
flash('Profile settings updated')
|
||||
return redirect(url_for('.index'))
|
||||
# endregion handle update account information form
|
||||
|
||||
# region handle update password form
|
||||
if update_password_form.submit.data and update_password_form.validate():
|
||||
current_user.password = update_password_form.new_password.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.index'))
|
||||
# endregion handle update password form
|
||||
|
||||
# region handle update notifications form
|
||||
if update_notifications_form.submit.data and update_notifications_form.validate():
|
||||
current_user.setting_job_status_mail_notification_level = \
|
||||
update_notifications_form.job_status_mail_notification_level.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.index'))
|
||||
# endregion handle update notifications form
|
||||
|
||||
return render_template(
|
||||
'settings/index.html.j2',
|
||||
title='Settings',
|
||||
update_account_information_form=update_account_information_form,
|
||||
update_avatar_form=update_avatar_form,
|
||||
update_notifications_form=update_notifications_form,
|
||||
update_password_form=update_password_form,
|
||||
update_profile_information_form=update_profile_information_form,
|
||||
user=current_user
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/profile-is-public', methods=['PUT'])
|
||||
@login_required
|
||||
def update_profile_is_public():
|
||||
new_value = request.json
|
||||
|
||||
if not isinstance(new_value, bool):
|
||||
abort(400)
|
||||
|
||||
current_user.is_public = new_value
|
||||
db.session.commit()
|
||||
|
||||
return jsonify('Your changes have been saved'), 200
|
||||
|
||||
|
||||
@bp.route('/profile-show-email', methods=['PUT'])
|
||||
@login_required
|
||||
def update_profile_show_email():
|
||||
new_value = request.json
|
||||
|
||||
if not isinstance(new_value, bool):
|
||||
abort(400)
|
||||
|
||||
if new_value:
|
||||
current_user.add_profile_privacy_setting('SHOW_EMAIL')
|
||||
else:
|
||||
current_user.remove_profile_privacy_setting('SHOW_EMAIL')
|
||||
db.session.commit()
|
||||
|
||||
return jsonify('Your changes have been saved'), 200
|
||||
|
||||
|
||||
@bp.route('/profile-show-last-seen', methods=['PUT'])
|
||||
@login_required
|
||||
def update_profile_show_last_seen():
|
||||
new_value = request.json
|
||||
|
||||
if not isinstance(new_value, bool):
|
||||
abort(400)
|
||||
|
||||
if new_value:
|
||||
current_user.add_profile_privacy_setting('SHOW_LAST_SEEN')
|
||||
else:
|
||||
current_user.remove_profile_privacy_setting('SHOW_LAST_SEEN')
|
||||
db.session.commit()
|
||||
|
||||
return jsonify('Your changes have been saved'), 200
|
||||
|
||||
|
||||
@bp.route('/profile-show-member-since', methods=['PUT'])
|
||||
@login_required
|
||||
def update_profile_show_member_since():
|
||||
new_value = request.json
|
||||
|
||||
if not isinstance(new_value, bool):
|
||||
abort(400)
|
||||
|
||||
if new_value:
|
||||
current_user.add_profile_privacy_setting('SHOW_MEMBER_SINCE')
|
||||
else:
|
||||
current_user.remove_profile_privacy_setting('SHOW_MEMBER_SINCE')
|
||||
db.session.commit()
|
||||
|
||||
return jsonify('Your changes have been saved'), 200
|
7
app/blueprints/users/__init__.py
Normal file
7
app/blueprints/users/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('users', __name__)
|
||||
|
||||
|
||||
from . import cli, events, routes
|
91
app/blueprints/users/events.py
Normal file
91
app/blueprints/users/events.py
Normal file
@ -0,0 +1,91 @@
|
||||
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 {
|
||||
'code': 400,
|
||||
'name': 'Bad Request',
|
||||
'description': 'Invalid User ID.'
|
||||
}
|
||||
|
||||
user_id = hashids.decode(user_hashid)
|
||||
|
||||
if not isinstance(user_id, int):
|
||||
return {
|
||||
'code': 400,
|
||||
'name': 'Bad Request',
|
||||
'description': 'Invalid User ID.'
|
||||
}
|
||||
|
||||
user = User.query.get(user_id)
|
||||
|
||||
if user is None:
|
||||
return {
|
||||
'code': 404,
|
||||
'name': 'Not Found',
|
||||
'description': 'User not found.'
|
||||
}
|
||||
|
||||
if not (
|
||||
user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
return {
|
||||
'code': 403,
|
||||
'name': 'Forbidden',
|
||||
'description': 'Not allowed to subscribe to this user.'
|
||||
}
|
||||
|
||||
join_room(f'/users/{user.hashid}')
|
||||
|
||||
return {'code': 204, 'name': 'No Content'}
|
||||
|
||||
|
||||
@socketio.on('UNSUBSCRIBE User')
|
||||
@socketio_login_required
|
||||
def unsubscribe(user_hashid: str) -> dict:
|
||||
if not isinstance(user_hashid, str):
|
||||
return {
|
||||
'code': 400,
|
||||
'name': 'Bad Request',
|
||||
'description': 'Invalid User ID.'
|
||||
}
|
||||
|
||||
user_id = hashids.decode(user_hashid)
|
||||
|
||||
if not isinstance(user_id, int):
|
||||
return {
|
||||
'code': 400,
|
||||
'name': 'Bad Request',
|
||||
'description': 'Invalid User ID.'
|
||||
}
|
||||
|
||||
user = User.query.get(user_id)
|
||||
|
||||
if user is None:
|
||||
return {
|
||||
'code': 404,
|
||||
'name': 'Not Found',
|
||||
'description': 'User not found.'
|
||||
}
|
||||
|
||||
if not (
|
||||
user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
return {
|
||||
'code': 403,
|
||||
'name': 'Forbidden',
|
||||
'description': 'Not allowed to unsubscribe from this user.'
|
||||
}
|
||||
|
||||
leave_room(f'/users/{user.hashid}')
|
||||
|
||||
return {'code': 204, 'name': 'No Content'}
|
134
app/blueprints/users/routes.py
Normal file
134
app/blueprints/users/routes.py
Normal file
@ -0,0 +1,134 @@
|
||||
from flask import (
|
||||
abort,
|
||||
current_app,
|
||||
Flask,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
url_for
|
||||
)
|
||||
from flask_login import current_user, login_required, logout_user
|
||||
from threading import Thread
|
||||
from app import db
|
||||
from app.models import Avatar, User
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
@login_required
|
||||
def index():
|
||||
return redirect(url_for('main.social_area', _anchor='users'))
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>')
|
||||
@login_required
|
||||
def user(user_id: int):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if not (
|
||||
user.is_public
|
||||
or user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
accept_json = request.accept_mimetypes.accept_json
|
||||
accept_html = request.accept_mimetypes.accept_html
|
||||
|
||||
if accept_json and not accept_html:
|
||||
return user.to_json_serializeable(
|
||||
backrefs=True,
|
||||
relationships=True
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'users/user.html.j2',
|
||||
title=user.username,
|
||||
user=user
|
||||
)
|
||||
|
||||
|
||||
def _delete_user(app: Flask, user_id: int):
|
||||
with app.app_context():
|
||||
user = User.query.get(user_id)
|
||||
user.delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_user(user_id: int):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if not (
|
||||
user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
if user == current_user:
|
||||
logout_user()
|
||||
|
||||
thread = Thread(
|
||||
target=_delete_user,
|
||||
args=(current_app._get_current_object(), user.id)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify(f'User "{user.username}" marked for deletion'), 202
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>/avatar')
|
||||
@login_required
|
||||
def user_avatar(user_id: int):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if not (
|
||||
user.is_public
|
||||
or user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
if user.avatar is None:
|
||||
return redirect(url_for('static', filename='images/user_avatar.png'))
|
||||
|
||||
return send_from_directory(
|
||||
user.avatar.path.parent,
|
||||
user.avatar.path.name,
|
||||
as_attachment=True,
|
||||
download_name=user.avatar.filename,
|
||||
mimetype=user.avatar.mimetype
|
||||
)
|
||||
|
||||
|
||||
def _delete_avatar(app: Flask, avatar_id: int):
|
||||
with app.app_context():
|
||||
avatar = Avatar.query.get(avatar_id)
|
||||
avatar.delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>/avatar', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_user_avatar(user_id: int):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if user.avatar is None:
|
||||
abort(409)
|
||||
|
||||
if not (
|
||||
user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
abort(403)
|
||||
|
||||
thread = Thread(
|
||||
target=_delete_avatar,
|
||||
args=(current_app._get_current_object(), user.avatar.id)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify('Avatar marked for deletion'), 202
|
@ -1,16 +1,13 @@
|
||||
from flask import redirect, render_template, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
@register_breadcrumb(bp, '.', '<i class="material-icons left">business_center</i>Workshops')
|
||||
def workshops():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@bp.route('/fgho_sommerschule_2023')
|
||||
@register_breadcrumb(bp, '.fgho_sommerschule_2023', 'FGHO Sommerschule 2023')
|
||||
def fgho_sommerschule_2023():
|
||||
return render_template(
|
||||
'workshops/fgho_sommerschule_2023.html.j2',
|
@ -1,23 +0,0 @@
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('contributions', __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,
|
||||
spacy_nlp_pipeline_models,
|
||||
tesseract_ocr_pipeline_models,
|
||||
transkribus_htr_pipeline_models
|
||||
)
|
@ -1,9 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
@register_breadcrumb(bp, '.', '<i class="material-icons left">new_label</i>My Contributions')
|
||||
def contributions():
|
||||
return redirect(url_for('main.dashboard', _anchor='contributions'))
|
@ -1,13 +0,0 @@
|
||||
from flask import request, url_for
|
||||
from app.models import SpaCyNLPPipelineModel
|
||||
|
||||
|
||||
def spacy_nlp_pipeline_model_dlc():
|
||||
snpm_id = request.view_args['spacy_nlp_pipeline_model_id']
|
||||
snpm = SpaCyNLPPipelineModel.query.get_or_404(snpm_id)
|
||||
return [
|
||||
{
|
||||
'text': f'{snpm.title} {snpm.version}',
|
||||
'url': url_for('.spacy_nlp_pipeline_model', spacy_nlp_pipeline_model_id=snpm_id)
|
||||
}
|
||||
]
|
@ -1,2 +0,0 @@
|
||||
from .. import bp
|
||||
from . import json_routes, routes
|
@ -1,13 +0,0 @@
|
||||
from flask import request, url_for
|
||||
from app.models import TesseractOCRPipelineModel
|
||||
|
||||
|
||||
def tesseract_ocr_pipeline_model_dlc():
|
||||
topm_id = request.view_args['tesseract_ocr_pipeline_model_id']
|
||||
topm = TesseractOCRPipelineModel.query.get_or_404(topm_id)
|
||||
return [
|
||||
{
|
||||
'text': f'{topm.title} {topm.version}',
|
||||
'url': url_for('.tesseract_ocr_pipeline_model', tesseract_ocr_pipeline_model_id=topm_id)
|
||||
}
|
||||
]
|
@ -1,2 +0,0 @@
|
||||
from .. import bp
|
||||
from . import routes
|
@ -1,7 +0,0 @@
|
||||
from flask import abort
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/transkribus_htr_pipeline_models')
|
||||
def transkribus_htr_pipeline_models():
|
||||
return abort(503)
|
@ -1,11 +1,10 @@
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from app.models import User, Corpus, CorpusFile
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import json
|
||||
import shutil
|
||||
from app import db
|
||||
from app.models import User, Corpus, CorpusFile
|
||||
|
||||
|
||||
class SandpaperConverter:
|
||||
@ -15,7 +14,7 @@ class SandpaperConverter:
|
||||
|
||||
def run(self):
|
||||
with self.json_db_file.open('r') as f:
|
||||
json_db: List[Dict] = json.load(f)
|
||||
json_db: list[dict] = json.load(f)
|
||||
|
||||
for json_user in json_db:
|
||||
if not json_user['confirmed']:
|
||||
@ -26,7 +25,7 @@ class SandpaperConverter:
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def convert_user(self, json_user: Dict, user_dir: Path):
|
||||
def convert_user(self, json_user: dict, user_dir: Path):
|
||||
current_app.logger.info(f'Create User {json_user["username"]}...')
|
||||
try:
|
||||
user = User.create(
|
||||
@ -48,7 +47,7 @@ class SandpaperConverter:
|
||||
current_app.logger.info('Done')
|
||||
|
||||
|
||||
def convert_corpus(self, json_corpus: Dict, user: User, corpus_dir: Path):
|
||||
def convert_corpus(self, json_corpus: dict, user: User, corpus_dir: Path):
|
||||
current_app.logger.info(f'Create Corpus {json_corpus["title"]}...')
|
||||
try:
|
||||
corpus = Corpus.create(
|
||||
@ -64,7 +63,7 @@ class SandpaperConverter:
|
||||
current_app.logger.info('Done')
|
||||
|
||||
|
||||
def convert_corpus_file(self, json_corpus_file: Dict, corpus: Corpus, corpus_dir: Path):
|
||||
def convert_corpus_file(self, json_corpus_file: dict, corpus: Corpus, corpus_dir: Path):
|
||||
current_app.logger.info(f'Create CorpusFile {json_corpus_file["title"]}...')
|
||||
corpus_file = CorpusFile(
|
||||
corpus=corpus,
|
||||
|
@ -1,69 +1,25 @@
|
||||
from flask import current_app
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def normalize_vrt_file(input_file, output_file):
|
||||
def check_pos_attribute_order(vrt_lines):
|
||||
# The following orders are possible:
|
||||
# since 26.02.2019: 'word,lemma,simple_pos,pos,ner'
|
||||
# since 26.03.2021: 'word,pos,lemma,simple_pos,ner'
|
||||
# since 27.01.2022: 'word,pos,lemma,simple_pos'
|
||||
# This Function tries to find out which order we have by looking at the
|
||||
# number of attributes and the position of the simple_pos attribute
|
||||
SIMPLE_POS_LABELS = [
|
||||
'ADJ', 'ADP', 'ADV', 'AUX', 'CONJ',
|
||||
'DET', 'INTJ', 'NOUN', 'NUM', 'PART',
|
||||
'PRON', 'PROPN', 'PUNCT', 'SCONJ', 'SYM',
|
||||
'VERB', 'X'
|
||||
]
|
||||
for line in vrt_lines:
|
||||
if line.startswith('<'):
|
||||
continue
|
||||
pos_attrs = line.rstrip('\n').split('\t')
|
||||
num_pos_attrs = len(pos_attrs)
|
||||
if num_pos_attrs == 4:
|
||||
if pos_attrs[3] in SIMPLE_POS_LABELS:
|
||||
return ['word', 'pos', 'lemma', 'simple_pos']
|
||||
continue
|
||||
elif num_pos_attrs == 5:
|
||||
if pos_attrs[2] in SIMPLE_POS_LABELS:
|
||||
return ['word', 'lemma', 'simple_pos', 'pos', 'ner']
|
||||
elif pos_attrs[3] in SIMPLE_POS_LABELS:
|
||||
return ['word', 'pos', 'lemma', 'simple_pos', 'ner']
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def check_has_ent_as_s_attr(vrt_lines):
|
||||
for line in vrt_lines:
|
||||
if line.startswith('<ent'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pos_attrs_to_string_1(pos_attrs):
|
||||
return f'{pos_attrs[0]}\t{pos_attrs[3]}\t{pos_attrs[1]}\t{pos_attrs[2]}\n'
|
||||
|
||||
|
||||
def pos_attrs_to_string_2(pos_attrs):
|
||||
return f'{pos_attrs[0]}\t{pos_attrs[1]}\t{pos_attrs[2]}\t{pos_attrs[3]}\n'
|
||||
|
||||
def normalize_vrt_file(input_file: Path, output_file: Path):
|
||||
current_app.logger.info(f'Converting {input_file}...')
|
||||
|
||||
with open(input_file) as f:
|
||||
with input_file.open() as f:
|
||||
input_vrt_lines = f.readlines()
|
||||
|
||||
pos_attr_order = check_pos_attribute_order(input_vrt_lines)
|
||||
has_ent_as_s_attr = check_has_ent_as_s_attr(input_vrt_lines)
|
||||
pos_attr_order = _check_pos_attribute_order(input_vrt_lines)
|
||||
has_ent_as_s_attr = _check_has_ent_as_s_attr(input_vrt_lines)
|
||||
|
||||
current_app.logger.info(f'Detected pos_attr_order: [{",".join(pos_attr_order)}]')
|
||||
current_app.logger.info(f'Detected has_ent_as_s_attr: {has_ent_as_s_attr}')
|
||||
|
||||
if pos_attr_order == ['word', 'lemma', 'simple_pos', 'pos', 'ner']:
|
||||
pos_attrs_to_string_function = pos_attrs_to_string_1
|
||||
pos_attrs_to_string_function = _pos_attrs_to_string_1
|
||||
elif pos_attr_order == ['word', 'pos', 'lemma', 'simple_pos', 'ner']:
|
||||
pos_attrs_to_string_function = pos_attrs_to_string_2
|
||||
pos_attrs_to_string_function = _pos_attrs_to_string_2
|
||||
elif pos_attr_order == ['word', 'pos', 'lemma', 'simple_pos']:
|
||||
pos_attrs_to_string_function = pos_attrs_to_string_2
|
||||
pos_attrs_to_string_function = _pos_attrs_to_string_2
|
||||
else:
|
||||
raise Exception('Can not handle format')
|
||||
|
||||
@ -113,5 +69,49 @@ def normalize_vrt_file(input_file, output_file):
|
||||
current_ent = pos_attrs[4]
|
||||
output_vrt += pos_attrs_to_string_function(pos_attrs)
|
||||
|
||||
with open(output_file, 'w') as f:
|
||||
with output_file.open(mode='w') as f:
|
||||
f.write(output_vrt)
|
||||
|
||||
|
||||
def _check_pos_attribute_order(vrt_lines: list[str]) -> list[str]:
|
||||
# The following orders are possible:
|
||||
# since 26.02.2019: 'word,lemma,simple_pos,pos,ner'
|
||||
# since 26.03.2021: 'word,pos,lemma,simple_pos,ner'
|
||||
# since 27.01.2022: 'word,pos,lemma,simple_pos'
|
||||
# This Function tries to find out which order we have by looking at the
|
||||
# number of attributes and the position of the simple_pos attribute
|
||||
SIMPLE_POS_LABELS = [
|
||||
'ADJ', 'ADP', 'ADV', 'AUX', 'CONJ', 'DET', 'INTJ', 'NOUN', 'NUM',
|
||||
'PART', 'PRON', 'PROPN', 'PUNCT', 'SCONJ', 'SYM', 'VERB', 'X'
|
||||
]
|
||||
for line in vrt_lines:
|
||||
if line.startswith('<'):
|
||||
continue
|
||||
pos_attrs = line.rstrip('\n').split('\t')
|
||||
num_pos_attrs = len(pos_attrs)
|
||||
if num_pos_attrs == 4:
|
||||
if pos_attrs[3] in SIMPLE_POS_LABELS:
|
||||
return ['word', 'pos', 'lemma', 'simple_pos']
|
||||
continue
|
||||
elif num_pos_attrs == 5:
|
||||
if pos_attrs[2] in SIMPLE_POS_LABELS:
|
||||
return ['word', 'lemma', 'simple_pos', 'pos', 'ner']
|
||||
elif pos_attrs[3] in SIMPLE_POS_LABELS:
|
||||
return ['word', 'pos', 'lemma', 'simple_pos', 'ner']
|
||||
continue
|
||||
# TODO: raise exception "can't determine attribute order"
|
||||
|
||||
|
||||
def _check_has_ent_as_s_attr(vrt_lines: list[str]) -> bool:
|
||||
for line in vrt_lines:
|
||||
if line.startswith('<ent'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pos_attrs_to_string_1(pos_attrs: list[str]) -> str:
|
||||
return f'{pos_attrs[0]}\t{pos_attrs[3]}\t{pos_attrs[1]}\t{pos_attrs[2]}\n'
|
||||
|
||||
|
||||
def _pos_attrs_to_string_2(pos_attrs: list[str]) -> str:
|
||||
return f'{pos_attrs[0]}\t{pos_attrs[1]}\t{pos_attrs[2]}\t{pos_attrs[3]}\n'
|
||||
|
@ -1,131 +0,0 @@
|
||||
from cqi.models.corpora import Corpus as CQiCorpus
|
||||
from cqi.models.subcorpora import Subcorpus as CQiSubcorpus
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def lookups_by_cpos(corpus: CQiCorpus, cpos_list: List[int]) -> Dict:
|
||||
lookups = {}
|
||||
lookups['cpos_lookup'] = {cpos: {} for cpos in cpos_list}
|
||||
for attr in corpus.positional_attributes.list():
|
||||
cpos_attr_values: List[str] = attr.values_by_cpos(cpos_list)
|
||||
for i, cpos in enumerate(cpos_list):
|
||||
lookups['cpos_lookup'][cpos][attr.name] = cpos_attr_values[i]
|
||||
for attr in corpus.structural_attributes.list():
|
||||
# We only want to iterate over non subattributes, identifiable by
|
||||
# attr.has_values == False
|
||||
if attr.has_values:
|
||||
continue
|
||||
cpos_attr_ids: List[int] = attr.ids_by_cpos(cpos_list)
|
||||
for i, cpos in enumerate(cpos_list):
|
||||
if cpos_attr_ids[i] == -1:
|
||||
continue
|
||||
lookups['cpos_lookup'][cpos][attr.name] = cpos_attr_ids[i]
|
||||
occured_attr_ids = [x for x in set(cpos_attr_ids) if x != -1]
|
||||
if len(occured_attr_ids) == 0:
|
||||
continue
|
||||
subattrs = corpus.structural_attributes.list(filters={'part_of': attr})
|
||||
if len(subattrs) == 0:
|
||||
continue
|
||||
lookup_name: str = f'{attr.name}_lookup'
|
||||
lookups[lookup_name] = {}
|
||||
for attr_id in occured_attr_ids:
|
||||
lookups[lookup_name][attr_id] = {}
|
||||
for subattr in subattrs:
|
||||
subattr_name = subattr.name[(len(attr.name) + 1):] # noqa
|
||||
for i, subattr_value in enumerate(subattr.values_by_ids(occured_attr_ids)): # noqa
|
||||
lookups[lookup_name][occured_attr_ids[i]][subattr_name] = subattr_value # noqa
|
||||
return lookups
|
||||
|
||||
|
||||
def partial_export_subcorpus(
|
||||
subcorpus: CQiSubcorpus,
|
||||
match_id_list: List[int],
|
||||
context: int = 25
|
||||
) -> Dict:
|
||||
if subcorpus.size == 0:
|
||||
return {"matches": []}
|
||||
match_boundaries = []
|
||||
for match_id in match_id_list:
|
||||
if match_id < 0 or match_id >= subcorpus.size:
|
||||
continue
|
||||
match_boundaries.append(
|
||||
(
|
||||
match_id,
|
||||
subcorpus.dump(subcorpus.fields['match'], match_id, match_id)[0],
|
||||
subcorpus.dump(subcorpus.fields['matchend'], match_id, match_id)[0]
|
||||
)
|
||||
)
|
||||
cpos_set = set()
|
||||
matches = []
|
||||
for match_boundary in match_boundaries:
|
||||
match_num, match_start, match_end = match_boundary
|
||||
c = (match_start, match_end)
|
||||
if match_start == 0 or context == 0:
|
||||
lc = None
|
||||
cpos_list_lbound = match_start
|
||||
else:
|
||||
lc_lbound = max(0, (match_start - context))
|
||||
lc_rbound = match_start - 1
|
||||
lc = (lc_lbound, lc_rbound)
|
||||
cpos_list_lbound = lc_lbound
|
||||
if match_end == (subcorpus.collection.corpus.size - 1) or context == 0:
|
||||
rc = None
|
||||
cpos_list_rbound = match_end
|
||||
else:
|
||||
rc_lbound = match_end + 1
|
||||
rc_rbound = min(
|
||||
(match_end + context),
|
||||
(subcorpus.collection.corpus.size - 1)
|
||||
)
|
||||
rc = (rc_lbound, rc_rbound)
|
||||
cpos_list_rbound = rc_rbound
|
||||
match = {'num': match_num, 'lc': lc, 'c': c, 'rc': rc}
|
||||
matches.append(match)
|
||||
cpos_set.update(range(cpos_list_lbound, cpos_list_rbound + 1))
|
||||
lookups = lookups_by_cpos(subcorpus.collection.corpus, list(cpos_set))
|
||||
return {'matches': matches, **lookups}
|
||||
|
||||
|
||||
def export_subcorpus(
|
||||
subcorpus: CQiSubcorpus,
|
||||
context: int = 25,
|
||||
cutoff: float = float('inf'),
|
||||
offset: int = 0
|
||||
) -> Dict:
|
||||
if subcorpus.size == 0:
|
||||
return {"matches": []}
|
||||
first_match = max(0, offset)
|
||||
last_match = min((offset + cutoff - 1), (subcorpus.size - 1))
|
||||
match_boundaries = zip(
|
||||
range(first_match, last_match + 1),
|
||||
subcorpus.dump(subcorpus.fields['match'], first_match, last_match),
|
||||
subcorpus.dump(subcorpus.fields['matchend'], first_match, last_match)
|
||||
)
|
||||
cpos_set = set()
|
||||
matches = []
|
||||
for match_num, match_start, match_end in match_boundaries:
|
||||
c = (match_start, match_end)
|
||||
if match_start == 0 or context == 0:
|
||||
lc = None
|
||||
cpos_list_lbound = match_start
|
||||
else:
|
||||
lc_lbound = max(0, (match_start - context))
|
||||
lc_rbound = match_start - 1
|
||||
lc = (lc_lbound, lc_rbound)
|
||||
cpos_list_lbound = lc_lbound
|
||||
if match_end == (subcorpus.collection.corpus.size - 1) or context == 0:
|
||||
rc = None
|
||||
cpos_list_rbound = match_end
|
||||
else:
|
||||
rc_lbound = match_end + 1
|
||||
rc_rbound = min(
|
||||
(match_end + context),
|
||||
(subcorpus.collection.corpus.size - 1)
|
||||
)
|
||||
rc = (rc_lbound, rc_rbound)
|
||||
cpos_list_rbound = rc_rbound
|
||||
match = {'num': match_num, 'lc': lc, 'c': c, 'rc': rc}
|
||||
matches.append(match)
|
||||
cpos_set.update(range(cpos_list_lbound, cpos_list_rbound + 1))
|
||||
lookups = lookups_by_cpos(subcorpus.collection.corpus, list(cpos_set))
|
||||
return {'matches': matches, **lookups}
|
@ -1,45 +0,0 @@
|
||||
from flask_login import current_user
|
||||
from flask_socketio import join_room
|
||||
from app import hashids, socketio
|
||||
from app.decorators import socketio_login_required
|
||||
from app.models import Corpus
|
||||
|
||||
|
||||
@socketio.on('GET /corpora/<corpus_id>')
|
||||
@socketio_login_required
|
||||
def get_corpus(corpus_hashid):
|
||||
corpus_id = hashids.decode(corpus_hashid)
|
||||
corpus = Corpus.query.get(corpus_id)
|
||||
if corpus is None:
|
||||
return {'options': {'status': 404, 'statusText': 'Not found'}}
|
||||
if not (
|
||||
corpus.is_public
|
||||
or corpus.user == current_user
|
||||
or current_user.is_administrator()
|
||||
):
|
||||
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
|
||||
return {
|
||||
'body': corpus.to_json_serializable(),
|
||||
'options': {
|
||||
'status': 200,
|
||||
'statusText': 'OK',
|
||||
'headers': {'Content-Type: application/json'}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@socketio.on('SUBSCRIBE /corpora/<corpus_id>')
|
||||
@socketio_login_required
|
||||
def subscribe_corpus(corpus_hashid):
|
||||
corpus_id = hashids.decode(corpus_hashid)
|
||||
corpus = Corpus.query.get(corpus_id)
|
||||
if corpus is None:
|
||||
return {'options': {'status': 404, 'statusText': 'Not found'}}
|
||||
if not (
|
||||
corpus.is_public
|
||||
or corpus.user == current_user
|
||||
or current_user.is_administrator()
|
||||
):
|
||||
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
|
||||
join_room(f'/corpora/{corpus.hashid}')
|
||||
return {'options': {'status': 200, 'statusText': 'OK'}}
|
@ -1,2 +0,0 @@
|
||||
from .. import bp
|
||||
from . import json_routes, routes
|
@ -1,15 +0,0 @@
|
||||
from flask import request, url_for
|
||||
from app.models import CorpusFile
|
||||
from ..utils import corpus_endpoint_arguments_constructor as corpus_eac
|
||||
|
||||
|
||||
def corpus_file_dynamic_list_constructor():
|
||||
corpus_id = request.view_args['corpus_id']
|
||||
corpus_file_id = request.view_args['corpus_file_id']
|
||||
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
|
||||
return [
|
||||
{
|
||||
'text': f'{corpus_file.author}: {corpus_file.title} ({corpus_file.publishing_year})',
|
||||
'url': url_for('.corpus_file', corpus_id=corpus_id, corpus_file_id=corpus_file_id)
|
||||
}
|
||||
]
|
@ -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
|
@ -1,120 +0,0 @@
|
||||
from flask import abort, flash, redirect, render_template, url_for
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
from app.models import (
|
||||
Corpus,
|
||||
CorpusFollowerAssociation,
|
||||
CorpusFollowerRole,
|
||||
User
|
||||
)
|
||||
from . import bp
|
||||
from .decorators import corpus_follower_permission_required
|
||||
from .forms import CreateCorpusForm
|
||||
from .utils import (
|
||||
corpus_endpoint_arguments_constructor as corpus_eac,
|
||||
corpus_dynamic_list_constructor as corpus_dlc
|
||||
)
|
||||
|
||||
|
||||
@bp.route('')
|
||||
@register_breadcrumb(bp, '.', '<i class="nopaque-icons left">I</i>My Corpora')
|
||||
def corpora():
|
||||
return redirect(url_for('main.dashboard', _anchor='corpora'))
|
||||
|
||||
|
||||
@bp.route('/create', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.create', 'Create')
|
||||
def create_corpus():
|
||||
form = CreateCorpusForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
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}" created', 'corpus')
|
||||
return redirect(corpus.url)
|
||||
return render_template(
|
||||
'corpora/create.html.j2',
|
||||
title='Create corpus',
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>')
|
||||
@register_breadcrumb(bp, '.entity', '', dynamic_list_constructor=corpus_dlc)
|
||||
def corpus(corpus_id):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
cfrs = CorpusFollowerRole.query.all()
|
||||
# TODO: Better solution for filtering admin
|
||||
users = User.query.filter(User.is_public == True, User.id != current_user.id, User.id != corpus.user.id, User.role_id < 4).all()
|
||||
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
|
||||
if cfa is None:
|
||||
if corpus.user == current_user or current_user.is_administrator():
|
||||
cfr = CorpusFollowerRole.query.filter_by(name='Administrator').first()
|
||||
else:
|
||||
cfr = CorpusFollowerRole.query.filter_by(name='Anonymous').first()
|
||||
else:
|
||||
cfr = cfa.role
|
||||
if corpus.user == current_user or current_user.is_administrator():
|
||||
return render_template(
|
||||
'corpora/corpus.html.j2',
|
||||
title=corpus.title,
|
||||
corpus=corpus,
|
||||
cfr=cfr,
|
||||
cfrs=cfrs,
|
||||
users=users
|
||||
)
|
||||
if (current_user.is_following_corpus(corpus) or corpus.is_public):
|
||||
cfas = CorpusFollowerAssociation.query.filter(Corpus.id == corpus_id, CorpusFollowerAssociation.follower_id != corpus.user.id).all()
|
||||
return render_template(
|
||||
'corpora/public_corpus.html.j2',
|
||||
title=corpus.title,
|
||||
corpus=corpus,
|
||||
cfrs=cfrs,
|
||||
cfr=cfr,
|
||||
cfas=cfas,
|
||||
users=users
|
||||
)
|
||||
abort(403)
|
||||
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/analysis')
|
||||
@corpus_follower_permission_required('VIEW')
|
||||
@register_breadcrumb(bp, '.entity.analysis', 'Analysis', endpoint_arguments_constructor=corpus_eac)
|
||||
def analysis(corpus_id):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
return render_template(
|
||||
'corpora/analysis.html.j2',
|
||||
corpus=corpus,
|
||||
title=f'Analyse Corpus {corpus.title}'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/follow/<token>')
|
||||
def follow_corpus(corpus_id, token):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
if current_user.follow_corpus_by_token(token):
|
||||
db.session.commit()
|
||||
flash(f'You are following "{corpus.title}" now', category='corpus')
|
||||
return redirect(url_for('corpora.corpus', corpus_id=corpus_id))
|
||||
abort(403)
|
||||
|
||||
|
||||
@bp.route('/import', methods=['GET', 'POST'])
|
||||
@register_breadcrumb(bp, '.import', 'Import')
|
||||
def import_corpus():
|
||||
abort(503)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/export')
|
||||
@corpus_follower_permission_required('VIEW')
|
||||
@register_breadcrumb(bp, '.entity.export', 'Export', endpoint_arguments_constructor=corpus_eac)
|
||||
def export_corpus(corpus_id):
|
||||
abort(503)
|
@ -1,17 +0,0 @@
|
||||
from flask import request, url_for
|
||||
from app.models import Corpus
|
||||
|
||||
|
||||
def corpus_endpoint_arguments_constructor():
|
||||
return {'corpus_id': request.view_args['corpus_id']}
|
||||
|
||||
|
||||
def corpus_dynamic_list_constructor():
|
||||
corpus_id = request.view_args['corpus_id']
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
return [
|
||||
{
|
||||
'text': f'<i class="material-icons left">book</i>{corpus.title}',
|
||||
'url': url_for('.corpus', corpus_id=corpus_id)
|
||||
}
|
||||
]
|
@ -1,11 +0,0 @@
|
||||
from app import db
|
||||
from flask import Flask
|
||||
from .corpus_utils import check_corpora
|
||||
from .job_utils import check_jobs
|
||||
|
||||
|
||||
def daemon(app: Flask):
|
||||
with app.app_context():
|
||||
check_corpora()
|
||||
check_jobs()
|
||||
db.session.commit()
|
@ -1,8 +1,7 @@
|
||||
from flask import abort, current_app, request
|
||||
from flask import abort, request
|
||||
from flask_login import current_user
|
||||
from functools import wraps
|
||||
from threading import Thread
|
||||
from typing import List, Union
|
||||
from typing import Optional
|
||||
from werkzeug.exceptions import NotAcceptable
|
||||
from app.models import Permission
|
||||
|
||||
@ -24,22 +23,21 @@ def admin_required(f):
|
||||
|
||||
def socketio_login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
def wrapper(*args, **kwargs):
|
||||
if current_user.is_authenticated:
|
||||
return f(*args, **kwargs)
|
||||
else:
|
||||
return {'code': 401, 'msg': 'Unauthorized'}
|
||||
return decorated_function
|
||||
return {'status': 401, 'statusText': 'Unauthorized'}
|
||||
return wrapper
|
||||
|
||||
|
||||
def socketio_permission_required(permission):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.can(permission):
|
||||
return {'code': 403, 'msg': 'Forbidden'}
|
||||
return {'status': 403, 'statusText': 'Forbidden'}
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
@ -47,27 +45,9 @@ def socketio_admin_required(f):
|
||||
return socketio_permission_required(Permission.ADMINISTRATE)(f)
|
||||
|
||||
|
||||
def background(f):
|
||||
'''
|
||||
' This decorator executes a function in a Thread.
|
||||
' Decorated functions need to be executed within a code block where an
|
||||
' app context exists.
|
||||
'
|
||||
' NOTE: An app object is passed as a keyword argument to the decorated
|
||||
' function.
|
||||
'''
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
kwargs['app'] = current_app._get_current_object()
|
||||
thread = Thread(target=f, args=args, kwargs=kwargs)
|
||||
thread.start()
|
||||
return thread
|
||||
return wrapped
|
||||
|
||||
|
||||
def content_negotiation(
|
||||
produces: Union[str, List[str], None] = None,
|
||||
consumes: Union[str, List[str], None] = None
|
||||
produces: Optional[str | list[str]] = None,
|
||||
consumes: Optional[str | list[str]] = None
|
||||
):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
|
31
app/email.py
31
app/email.py
@ -1,25 +1,32 @@
|
||||
from flask import current_app, render_template
|
||||
from flask import current_app, Flask, render_template
|
||||
from flask_mail import Message
|
||||
from threading import Thread
|
||||
from app import mail
|
||||
|
||||
|
||||
def create_message(recipient, subject, template, **kwargs):
|
||||
subject_prefix: str = current_app.config['NOPAQUE_MAIL_SUBJECT_PREFIX']
|
||||
msg: Message = Message(
|
||||
body=render_template(f'{template}.txt.j2', **kwargs),
|
||||
html=render_template(f'{template}.html.j2', **kwargs),
|
||||
def create_message(
|
||||
recipient: str,
|
||||
subject: str,
|
||||
template: str,
|
||||
**context
|
||||
) -> Message:
|
||||
message = Message(
|
||||
body=render_template(f'{template}.txt.j2', **context),
|
||||
html=render_template(f'{template}.html.j2', **context),
|
||||
recipients=[recipient],
|
||||
subject=f'{subject_prefix} {subject}'
|
||||
subject=f'[nopaque] {subject}'
|
||||
)
|
||||
return msg
|
||||
return message
|
||||
|
||||
|
||||
def send(msg, *args, **kwargs):
|
||||
def _send(app, msg):
|
||||
def send(message: Message) -> Thread:
|
||||
def _send(app: Flask, message: Message):
|
||||
with app.app_context():
|
||||
mail.send(msg)
|
||||
mail.send(message)
|
||||
|
||||
thread = Thread(target=_send, args=[current_app._get_current_object(), msg])
|
||||
thread = Thread(
|
||||
target=_send,
|
||||
args=[current_app._get_current_object(), message]
|
||||
)
|
||||
thread.start()
|
||||
return thread
|
||||
|
@ -1,2 +0,0 @@
|
||||
from .container_column import ContainerColumn
|
||||
from .int_enum_column import IntEnumColumn
|
@ -1,21 +0,0 @@
|
||||
import json
|
||||
from app import db
|
||||
|
||||
|
||||
class ContainerColumn(db.TypeDecorator):
|
||||
impl = db.String
|
||||
|
||||
def __init__(self, container_type, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.container_type = container_type
|
||||
|
||||
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):
|
||||
return value
|
||||
else:
|
||||
return TypeError()
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return json.loads(value)
|
20
app/extensions/nopaque_flask_admin_views.py
Normal file
20
app/extensions/nopaque_flask_admin_views.py
Normal 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
|
@ -1,6 +1,26 @@
|
||||
import json
|
||||
from app import db
|
||||
|
||||
|
||||
class ContainerColumn(db.TypeDecorator):
|
||||
impl = db.String
|
||||
|
||||
def __init__(self, container_type, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.container_type = container_type
|
||||
|
||||
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):
|
||||
return value
|
||||
else:
|
||||
return TypeError()
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
class IntEnumColumn(db.TypeDecorator):
|
||||
impl = db.Integer
|
||||
|
@ -1,18 +1,2 @@
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('jobs', __name__)
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@login_required
|
||||
def before_request():
|
||||
'''
|
||||
Ensures that the routes in this package can only be visited by users that
|
||||
are logged in.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
from . import routes, json_routes
|
||||
from .handle_corpora import handle_corpora
|
||||
from .handle_jobs import handle_jobs
|
||||
|
@ -1,12 +1,16 @@
|
||||
from app import docker_client
|
||||
from app.models import Corpus, CorpusStatus
|
||||
from flask import current_app
|
||||
import docker
|
||||
import os
|
||||
import shutil
|
||||
from app import db, docker_client, scheduler
|
||||
from app.models import Corpus, CorpusStatus
|
||||
|
||||
|
||||
def check_corpora():
|
||||
def handle_corpora():
|
||||
with scheduler.app.app_context():
|
||||
_handle_corpora()
|
||||
|
||||
def _handle_corpora():
|
||||
corpora = Corpus.query.all()
|
||||
for corpus in [x for x in corpora if x.status == CorpusStatus.SUBMITTED]:
|
||||
_create_build_corpus_service(corpus)
|
||||
@ -17,13 +21,14 @@ def check_corpora():
|
||||
for corpus in [x for x in corpora if x.status == CorpusStatus.RUNNING_ANALYSIS_SESSION and x.num_analysis_sessions == 0]:
|
||||
corpus.status = CorpusStatus.CANCELING_ANALYSIS_SESSION
|
||||
for corpus in [x for x in corpora if x.status == CorpusStatus.RUNNING_ANALYSIS_SESSION]:
|
||||
_checkout_analysing_corpus_container(corpus)
|
||||
_checkout_cqpserver_container(corpus)
|
||||
for corpus in [x for x in corpora if x.status == CorpusStatus.STARTING_ANALYSIS_SESSION]:
|
||||
_create_cqpserver_container(corpus)
|
||||
for corpus in [x for x in corpora if x.status == CorpusStatus.CANCELING_ANALYSIS_SESSION]:
|
||||
_remove_cqpserver_container(corpus)
|
||||
db.session.commit()
|
||||
|
||||
def _create_build_corpus_service(corpus):
|
||||
def _create_build_corpus_service(corpus: Corpus):
|
||||
''' # Docker service settings # '''
|
||||
''' ## Command ## '''
|
||||
command = ['bash', '-c']
|
||||
@ -45,12 +50,10 @@ def _create_build_corpus_service(corpus):
|
||||
''' ## Constraints ## '''
|
||||
constraints = ['node.role==worker']
|
||||
''' ## Image ## '''
|
||||
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1879'
|
||||
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1887'
|
||||
''' ## Labels ## '''
|
||||
labels = {
|
||||
'origin': current_app.config['SERVER_NAME'],
|
||||
'type': 'corpus.build',
|
||||
'corpus_id': str(corpus.id)
|
||||
'nopaque.server_name': current_app.config['SERVER_NAME']
|
||||
}
|
||||
''' ## Mounts ## '''
|
||||
mounts = []
|
||||
@ -95,7 +98,7 @@ def _create_build_corpus_service(corpus):
|
||||
return
|
||||
corpus.status = CorpusStatus.QUEUED
|
||||
|
||||
def _checkout_build_corpus_service(corpus):
|
||||
def _checkout_build_corpus_service(corpus: Corpus):
|
||||
service_name = f'build-corpus_{corpus.id}'
|
||||
try:
|
||||
service = docker_client.services.get(service_name)
|
||||
@ -123,8 +126,7 @@ def _checkout_build_corpus_service(corpus):
|
||||
except docker.errors.DockerException as e:
|
||||
current_app.logger.error(f'Remove service "{service_name}" failed: {e}')
|
||||
|
||||
def _create_cqpserver_container(corpus):
|
||||
''' # Docker container settings # '''
|
||||
def _create_cqpserver_container(corpus: Corpus):
|
||||
''' ## Command ## '''
|
||||
command = []
|
||||
command.append(
|
||||
@ -139,9 +141,9 @@ def _create_cqpserver_container(corpus):
|
||||
''' ## Entrypoint ## '''
|
||||
entrypoint = ['bash', '-c']
|
||||
''' ## Image ## '''
|
||||
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1879'
|
||||
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1887'
|
||||
''' ## Name ## '''
|
||||
name = f'cqpserver_{corpus.id}'
|
||||
name = f'nopaque-cqpserver-{corpus.id}'
|
||||
''' ## Network ## '''
|
||||
network = f'{current_app.config["NOPAQUE_DOCKER_NETWORK_NAME"]}'
|
||||
''' ## Volumes ## '''
|
||||
@ -198,8 +200,8 @@ def _create_cqpserver_container(corpus):
|
||||
return
|
||||
corpus.status = CorpusStatus.RUNNING_ANALYSIS_SESSION
|
||||
|
||||
def _checkout_analysing_corpus_container(corpus):
|
||||
container_name = f'cqpserver_{corpus.id}'
|
||||
def _checkout_cqpserver_container(corpus: Corpus):
|
||||
container_name = f'nopaque-cqpserver-{corpus.id}'
|
||||
try:
|
||||
docker_client.containers.get(container_name)
|
||||
except docker.errors.NotFound as e:
|
||||
@ -209,8 +211,8 @@ def _checkout_analysing_corpus_container(corpus):
|
||||
except docker.errors.DockerException as e:
|
||||
current_app.logger.error(f'Get container "{container_name}" failed: {e}')
|
||||
|
||||
def _remove_cqpserver_container(corpus):
|
||||
container_name = f'cqpserver_{corpus.id}'
|
||||
def _remove_cqpserver_container(corpus: Corpus):
|
||||
container_name = f'nopaque-cqpserver-{corpus.id}'
|
||||
try:
|
||||
container = docker_client.containers.get(container_name)
|
||||
except docker.errors.NotFound:
|
@ -1,11 +1,3 @@
|
||||
from app import db, docker_client, hashids
|
||||
from app.models import (
|
||||
Job,
|
||||
JobResult,
|
||||
JobStatus,
|
||||
TesseractOCRPipelineModel,
|
||||
SpaCyNLPPipelineModel
|
||||
)
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
@ -13,9 +5,21 @@ import docker
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from app import db, docker_client, hashids, scheduler
|
||||
from app.models import (
|
||||
Job,
|
||||
JobResult,
|
||||
JobStatus,
|
||||
TesseractOCRPipelineModel,
|
||||
SpaCyNLPPipelineModel
|
||||
)
|
||||
|
||||
|
||||
def check_jobs():
|
||||
def handle_jobs():
|
||||
with scheduler.app.app_context():
|
||||
_handle_jobs()
|
||||
|
||||
def _handle_jobs():
|
||||
jobs = Job.query.all()
|
||||
for job in [x for x in jobs if x.status == JobStatus.SUBMITTED]:
|
||||
_create_job_service(job)
|
||||
@ -23,8 +27,9 @@ def check_jobs():
|
||||
_checkout_job_service(job)
|
||||
for job in [x for x in jobs if x.status == JobStatus.CANCELING]:
|
||||
_remove_job_service(job)
|
||||
db.session.commit()
|
||||
|
||||
def _create_job_service(job):
|
||||
def _create_job_service(job: Job):
|
||||
''' # Docker service settings # '''
|
||||
''' ## Service specific settings ## '''
|
||||
if job.service == 'file-setup-pipeline':
|
||||
@ -81,9 +86,7 @@ def _create_job_service(job):
|
||||
constraints = ['node.role==worker']
|
||||
''' ## Labels ## '''
|
||||
labels = {
|
||||
'origin': current_app.config['SERVER_NAME'],
|
||||
'type': 'job',
|
||||
'job_id': str(job.id)
|
||||
'origin': current_app.config['SERVER_NAME']
|
||||
}
|
||||
''' ## Mounts ## '''
|
||||
mounts = []
|
||||
@ -164,7 +167,7 @@ def _create_job_service(job):
|
||||
return
|
||||
job.status = JobStatus.QUEUED
|
||||
|
||||
def _checkout_job_service(job):
|
||||
def _checkout_job_service(job: Job):
|
||||
service_name = f'job_{job.id}'
|
||||
try:
|
||||
service = docker_client.services.get(service_name)
|
||||
@ -213,7 +216,7 @@ def _checkout_job_service(job):
|
||||
except docker.errors.DockerException as e:
|
||||
current_app.logger.error(f'Remove service "{service_name}" failed: {e}')
|
||||
|
||||
def _remove_job_service(job):
|
||||
def _remove_job_service(job: Job):
|
||||
service_name = f'job_{job.id}'
|
||||
try:
|
||||
service = docker_client.services.get(service_name)
|
@ -1,72 +0,0 @@
|
||||
from flask import abort, current_app
|
||||
from flask_login import current_user
|
||||
from threading import Thread
|
||||
from app import db
|
||||
from app.decorators import admin_required, content_negotiation
|
||||
from app.models import Job, JobStatus
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>', methods=['DELETE'])
|
||||
@content_negotiation(produces='application/json')
|
||||
def delete_job(job_id):
|
||||
def _delete_job(app, job_id):
|
||||
with app.app_context():
|
||||
job = Job.query.get(job_id)
|
||||
job.delete()
|
||||
db.session.commit()
|
||||
|
||||
job = Job.query.get_or_404(job_id)
|
||||
if not (job.user == current_user or current_user.is_administrator()):
|
||||
abort(403)
|
||||
thread = Thread(
|
||||
target=_delete_job,
|
||||
args=(current_app._get_current_object(), job_id)
|
||||
)
|
||||
thread.start()
|
||||
response_data = {
|
||||
'message': f'Job "{job.title}" marked for deletion'
|
||||
}
|
||||
return response_data, 202
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>/log')
|
||||
@admin_required
|
||||
@content_negotiation(produces='application/json')
|
||||
def job_log(job_id):
|
||||
job = Job.query.get_or_404(job_id)
|
||||
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
|
||||
response = {'errors': {'message': 'Job status is not completed or failed'}}
|
||||
return response, 409
|
||||
with open(job.path / 'pipeline_data' / 'logs' / 'pyflow_log.txt') as log_file:
|
||||
log = log_file.read()
|
||||
response_data = {
|
||||
'jobLog': log
|
||||
}
|
||||
return response_data, 200
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>/restart', methods=['POST'])
|
||||
@content_negotiation(produces='application/json')
|
||||
def restart_job(job_id):
|
||||
def _restart_job(app, job_id):
|
||||
with app.app_context():
|
||||
job = Job.query.get(job_id)
|
||||
job.restart()
|
||||
db.session.commit()
|
||||
|
||||
job = Job.query.get_or_404(job_id)
|
||||
if not (job.user == current_user or current_user.is_administrator()):
|
||||
abort(403)
|
||||
if job.status == JobStatus.FAILED:
|
||||
response = {'errors': {'message': 'Job status is not "failed"'}}
|
||||
return response, 409
|
||||
thread = Thread(
|
||||
target=_restart_job,
|
||||
args=(current_app._get_current_object(), job_id)
|
||||
)
|
||||
thread.start()
|
||||
response_data = {
|
||||
'message': f'Job "{job.title}" marked for restarting'
|
||||
}
|
||||
return response_data, 202
|
@ -1,59 +0,0 @@
|
||||
from flask import (
|
||||
abort,
|
||||
redirect,
|
||||
render_template,
|
||||
send_from_directory,
|
||||
url_for
|
||||
)
|
||||
from flask_breadcrumbs import register_breadcrumb
|
||||
from flask_login import current_user
|
||||
from app.models import Job, JobInput, JobResult
|
||||
from . import bp
|
||||
from .utils import job_dynamic_list_constructor as job_dlc
|
||||
|
||||
|
||||
@bp.route('')
|
||||
@register_breadcrumb(bp, '.', '<i class="nopaque-icons left">J</i>My Jobs')
|
||||
def corpora():
|
||||
return redirect(url_for('main.dashboard', _anchor='jobs'))
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>')
|
||||
@register_breadcrumb(bp, '.entity', '', dynamic_list_constructor=job_dlc)
|
||||
def job(job_id):
|
||||
job = Job.query.get_or_404(job_id)
|
||||
if not (job.user == current_user or current_user.is_administrator()):
|
||||
abort(403)
|
||||
return render_template(
|
||||
'jobs/job.html.j2',
|
||||
title='Job',
|
||||
job=job
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
|
||||
def download_job_input(job_id, job_input_id):
|
||||
job_input = JobInput.query.filter_by(job_id=job_id, id=job_input_id).first_or_404()
|
||||
if not (job_input.job.user == current_user or current_user.is_administrator()):
|
||||
abort(403)
|
||||
return send_from_directory(
|
||||
job_input.path.parent,
|
||||
job_input.path.name,
|
||||
as_attachment=True,
|
||||
attachment_filename=job_input.filename,
|
||||
mimetype=job_input.mimetype
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download')
|
||||
def download_job_result(job_id, job_result_id):
|
||||
job_result = JobResult.query.filter_by(job_id=job_id, id=job_result_id).first_or_404()
|
||||
if not (job_result.job.user == current_user or current_user.is_administrator()):
|
||||
abort(403)
|
||||
return send_from_directory(
|
||||
job_result.path.parent,
|
||||
job_result.path.name,
|
||||
as_attachment=True,
|
||||
attachment_filename=job_result.filename,
|
||||
mimetype=job_result.mimetype
|
||||
)
|
@ -1,13 +0,0 @@
|
||||
from flask import request, url_for
|
||||
from app.models import Job
|
||||
|
||||
|
||||
def job_dynamic_list_constructor():
|
||||
job_id = request.view_args['job_id']
|
||||
job = Job.query.get_or_404(job_id)
|
||||
return [
|
||||
{
|
||||
'text': f'<i class="nopaque-icons left service-icons" data-service="{job.service}"></i>{job.title}',
|
||||
'url': url_for('.job', job_id=job_id)
|
||||
}
|
||||
]
|
@ -1,19 +1,45 @@
|
||||
from .avatar import *
|
||||
from .corpus_file import *
|
||||
from .corpus_follower_association import *
|
||||
from .corpus_follower_role import *
|
||||
from .corpus import *
|
||||
from .job_input import *
|
||||
from .job_result import *
|
||||
from .job import *
|
||||
from .role import *
|
||||
from .spacy_nlp_pipeline_model import *
|
||||
from .tesseract_ocr_pipeline_model import *
|
||||
from .token import *
|
||||
from .user import *
|
||||
from app import login
|
||||
from .anonymous_user import AnonymousUser
|
||||
from .avatar import Avatar
|
||||
from .corpus_file import CorpusFile
|
||||
from .corpus_follower_association import CorpusFollowerAssociation
|
||||
from .corpus_follower_role import CorpusFollowerPermission, CorpusFollowerRole
|
||||
from .corpus import CorpusStatus, Corpus
|
||||
from .job_input import JobInput
|
||||
from .job_result import JobResult
|
||||
from .job import JobStatus, Job
|
||||
from .role import Permission, Role
|
||||
from .spacy_nlp_pipeline_model import SpaCyNLPPipelineModel
|
||||
from .tesseract_ocr_pipeline_model import TesseractOCRPipelineModel
|
||||
from .token import Token
|
||||
from .user import (
|
||||
ProfilePrivacySettings,
|
||||
UserSettingJobStatusMailNotificationLevel,
|
||||
User
|
||||
)
|
||||
|
||||
|
||||
@login.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
_models = [
|
||||
Avatar,
|
||||
CorpusFile,
|
||||
CorpusFollowerAssociation,
|
||||
CorpusFollowerRole,
|
||||
Corpus,
|
||||
JobInput,
|
||||
JobResult,
|
||||
Job,
|
||||
Role,
|
||||
SpaCyNLPPipelineModel,
|
||||
TesseractOCRPipelineModel,
|
||||
Token,
|
||||
User
|
||||
]
|
||||
|
||||
|
||||
_enums = [
|
||||
CorpusFollowerPermission,
|
||||
CorpusStatus,
|
||||
JobStatus,
|
||||
Permission,
|
||||
ProfilePrivacySettings,
|
||||
UserSettingJobStatusMailNotificationLevel
|
||||
]
|
||||
|
10
app/models/anonymous_user.py
Normal file
10
app/models/anonymous_user.py
Normal file
@ -0,0 +1,10 @@
|
||||
from flask_login import AnonymousUserMixin
|
||||
|
||||
|
||||
class AnonymousUser(AnonymousUserMixin):
|
||||
def can(self, permissions):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_administrator(self):
|
||||
return False
|
@ -3,13 +3,12 @@ from enum import IntEnum
|
||||
from flask import current_app, url_for
|
||||
from flask_hashids import HashidMixin
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from typing import Union
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
from app import db
|
||||
from app.converters.vrt import normalize_vrt_file
|
||||
from app.ext.flask_sqlalchemy import IntEnumColumn
|
||||
from app.extensions.nopaque_sqlalchemy_type_decorators import IntEnumColumn
|
||||
from .corpus_follower_association import CorpusFollowerAssociation
|
||||
|
||||
|
||||
@ -25,7 +24,7 @@ class CorpusStatus(IntEnum):
|
||||
CANCELING_ANALYSIS_SESSION = 9
|
||||
|
||||
@staticmethod
|
||||
def get(corpus_status: Union['CorpusStatus', int, str]) -> 'CorpusStatus':
|
||||
def get(corpus_status: 'CorpusStatus | int | str') -> 'CorpusStatus':
|
||||
if isinstance(corpus_status, CorpusStatus):
|
||||
return corpus_status
|
||||
if isinstance(corpus_status, int):
|
||||
|
@ -1,6 +1,5 @@
|
||||
from flask_hashids import HashidMixin
|
||||
from enum import IntEnum
|
||||
from typing import Union
|
||||
from app import db
|
||||
|
||||
|
||||
@ -11,7 +10,7 @@ class CorpusFollowerPermission(IntEnum):
|
||||
MANAGE_CORPUS = 8
|
||||
|
||||
@staticmethod
|
||||
def get(corpus_follower_permission: Union['CorpusFollowerPermission', int, str]) -> 'CorpusFollowerPermission':
|
||||
def get(corpus_follower_permission: 'CorpusFollowerPermission | int | str') -> 'CorpusFollowerPermission':
|
||||
if isinstance(corpus_follower_permission, CorpusFollowerPermission):
|
||||
return corpus_follower_permission
|
||||
if isinstance(corpus_follower_permission, int):
|
||||
@ -38,16 +37,16 @@ class CorpusFollowerRole(HashidMixin, db.Model):
|
||||
def __repr__(self):
|
||||
return f'<CorpusFollowerRole {self.name}>'
|
||||
|
||||
def has_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
|
||||
def has_permission(self, permission: CorpusFollowerPermission | int | str):
|
||||
perm = CorpusFollowerPermission.get(permission)
|
||||
return self.permissions & perm.value == perm.value
|
||||
|
||||
def add_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
|
||||
|
||||
def add_permission(self, permission: CorpusFollowerPermission | int | str):
|
||||
perm = CorpusFollowerPermission.get(permission)
|
||||
if not self.has_permission(perm):
|
||||
self.permissions += perm.value
|
||||
|
||||
def remove_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
|
||||
|
||||
def remove_permission(self, permission: CorpusFollowerPermission | int | str):
|
||||
perm = CorpusFollowerPermission.get(permission)
|
||||
if self.has_permission(perm):
|
||||
self.permissions -= perm.value
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user