mirror of
https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
synced 2025-06-16 02:50:40 +00:00
move blueprints in dedicated folder
This commit is contained in:
20
app/blueprints/admin/__init__.py
Normal file
20
app/blueprints/admin/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
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
|
49
app/blueprints/admin/events.py
Normal file
49
app/blueprints/admin/events.py
Normal file
@ -0,0 +1,49 @@
|
||||
from flask_login import current_user
|
||||
from flask_socketio import disconnect, Namespace
|
||||
from app import db, hashids
|
||||
from app.decorators import socketio_admin_required
|
||||
from app.models import User
|
||||
|
||||
|
||||
class AdminNamespace(Namespace):
|
||||
def on_connect(self):
|
||||
# Check if the user is authenticated and is an administrator
|
||||
if not (current_user.is_authenticated and current_user.is_administrator):
|
||||
disconnect()
|
||||
|
||||
|
||||
@socketio_admin_required
|
||||
def on_set_user_confirmed(self, user_hashid: str, confirmed_value: bool):
|
||||
# Decode the user hashid
|
||||
user_id = hashids.decode(user_hashid)
|
||||
|
||||
# Validate user_id
|
||||
if not isinstance(user_id, int):
|
||||
return {
|
||||
'code': 400,
|
||||
'body': 'user_id is invalid'
|
||||
}
|
||||
|
||||
# Validate confirmed_value
|
||||
if not isinstance(confirmed_value, bool):
|
||||
return {
|
||||
'code': 400,
|
||||
'body': 'confirmed_value is invalid'
|
||||
}
|
||||
|
||||
# Load user from database
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
return {
|
||||
'code': 404,
|
||||
'body': 'User not found'
|
||||
}
|
||||
|
||||
# Update user confirmed status
|
||||
user.confirmed = confirmed_value
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'code': 200,
|
||||
'body': f'User "{user.username}" is now {"confirmed" if confirmed_value else "unconfirmed"}'
|
||||
}
|
16
app/blueprints/admin/forms.py
Normal file
16
app/blueprints/admin/forms.py
Normal file
@ -0,0 +1,16 @@
|
||||
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()]
|
23
app/blueprints/admin/json_routes.py
Normal file
23
app/blueprints/admin/json_routes.py
Normal file
@ -0,0 +1,23 @@
|
||||
from flask import abort, request
|
||||
from app.decorators import content_negotiation
|
||||
from app import db
|
||||
from app.models import User
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/users/<hashid:user_id>/confirmed', methods=['PUT'])
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_user_role(user_id):
|
||||
confirmed = request.json
|
||||
if not isinstance(confirmed, bool):
|
||||
abort(400)
|
||||
user = User.query.get_or_404(user_id)
|
||||
user.confirmed = confirmed
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': (
|
||||
f'User "{user.username}" is now '
|
||||
f'{"confirmed" if confirmed else "unconfirmed"}'
|
||||
)
|
||||
}
|
||||
return response_data, 200
|
136
app/blueprints/admin/routes.py
Normal file
136
app/blueprints/admin/routes.py
Normal file
@ -0,0 +1,136 @@
|
||||
from flask import abort, flash, redirect, render_template, url_for
|
||||
from app import db, hashids
|
||||
from app.models import Avatar, Corpus, Role, User
|
||||
from app.blueprints.users.settings.forms import (
|
||||
UpdateAvatarForm,
|
||||
UpdatePasswordForm,
|
||||
UpdateNotificationsForm,
|
||||
UpdateAccountInformationForm,
|
||||
UpdateProfileInformationForm
|
||||
)
|
||||
from . import bp
|
||||
from .forms import UpdateUserForm
|
||||
|
||||
|
||||
@bp.route('')
|
||||
def admin():
|
||||
return render_template(
|
||||
'admin/admin.html.j2',
|
||||
title='Administration'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/corpora')
|
||||
def corpora():
|
||||
corpora = Corpus.query.all()
|
||||
return render_template(
|
||||
'admin/corpora.html.j2',
|
||||
title='Corpora',
|
||||
corpora=corpora
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/users')
|
||||
def users():
|
||||
users = User.query.all()
|
||||
return render_template(
|
||||
'admin/users.html.j2',
|
||||
title='Users',
|
||||
users=users
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/users/<hashid:user_id>')
|
||||
def user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
corpora = Corpus.query.filter(Corpus.user == user).all()
|
||||
return render_template(
|
||||
'admin/user.html.j2',
|
||||
title=user.username,
|
||||
user=user,
|
||||
corpora=corpora
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/users/<hashid:user_id>/settings', methods=['GET', 'POST'])
|
||||
def user_settings(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
update_account_information_form = UpdateAccountInformationForm(user)
|
||||
update_profile_information_form = UpdateProfileInformationForm(user)
|
||||
update_avatar_form = UpdateAvatarForm()
|
||||
update_password_form = UpdatePasswordForm(user)
|
||||
update_notifications_form = UpdateNotificationsForm(user)
|
||||
update_user_form = UpdateUserForm(user)
|
||||
|
||||
# region handle update profile information form
|
||||
if update_profile_information_form.submit.data and update_profile_information_form.validate():
|
||||
user.about_me = update_profile_information_form.about_me.data
|
||||
user.location = update_profile_information_form.location.data
|
||||
user.organization = update_profile_information_form.organization.data
|
||||
user.website = update_profile_information_form.website.data
|
||||
user.full_name = update_profile_information_form.full_name.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.user_settings', user_id=user.id))
|
||||
# endregion handle update profile information form
|
||||
|
||||
# region handle update avatar form
|
||||
if update_avatar_form.submit.data and update_avatar_form.validate():
|
||||
try:
|
||||
Avatar.create(
|
||||
update_avatar_form.avatar.data,
|
||||
user=user
|
||||
)
|
||||
except (AttributeError, OSError):
|
||||
abort(500)
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.user_settings', user_id=user.id))
|
||||
# endregion handle update avatar form
|
||||
|
||||
# region handle update account information form
|
||||
if update_account_information_form.submit.data and update_account_information_form.validate():
|
||||
user.email = update_account_information_form.email.data
|
||||
user.username = update_account_information_form.username.data
|
||||
db.session.commit()
|
||||
flash('Profile settings updated')
|
||||
return redirect(url_for('.user_settings', user_id=user.id))
|
||||
# endregion handle update account information form
|
||||
|
||||
# region handle update password form
|
||||
if update_password_form.submit.data and update_password_form.validate():
|
||||
user.password = update_password_form.new_password.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.user_settings', user_id=user.id))
|
||||
# endregion handle update password form
|
||||
|
||||
# region handle update notifications form
|
||||
if update_notifications_form.submit.data and update_notifications_form.validate():
|
||||
user.setting_job_status_mail_notification_level = \
|
||||
update_notifications_form.job_status_mail_notification_level.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.user_settings', user_id=user.id))
|
||||
# endregion handle update notifications form
|
||||
|
||||
# region handle update user form
|
||||
if update_user_form.submit.data and update_user_form.validate():
|
||||
role_id = hashids.decode(update_user_form.role.data)
|
||||
user.role = Role.query.get(role_id)
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(url_for('.user_settings', user_id=user.id))
|
||||
# endregion handle update user form
|
||||
|
||||
return render_template(
|
||||
'admin/user_settings.html.j2',
|
||||
title='Settings',
|
||||
update_account_information_form=update_account_information_form,
|
||||
update_avatar_form=update_avatar_form,
|
||||
update_notifications_form=update_notifications_form,
|
||||
update_password_form=update_password_form,
|
||||
update_profile_information_form=update_profile_information_form,
|
||||
update_user_form=update_user_form,
|
||||
user=user
|
||||
)
|
14
app/blueprints/api/__init__.py
Normal file
14
app/blueprints/api/__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('api', __name__)
|
||||
|
||||
|
||||
from .tokens import bp as tokens_blueprint
|
||||
bp.register_blueprint(tokens_blueprint, url_prefix='/tokens')
|
||||
|
||||
from .users import bp as users_blueprint
|
||||
bp.register_blueprint(users_blueprint, url_prefix='/users')
|
||||
|
||||
from .jobs import bp as jobs_blueprint
|
||||
bp.register_blueprint(jobs_blueprint, url_prefix='/jobs')
|
48
app/blueprints/api/auth.py
Normal file
48
app/blueprints/api/auth.py
Normal file
@ -0,0 +1,48 @@
|
||||
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
|
||||
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||
from app.models import User
|
||||
|
||||
|
||||
basic_auth = HTTPBasicAuth()
|
||||
token_auth = HTTPTokenAuth()
|
||||
auth_error_responses = {
|
||||
Unauthorized.code: Unauthorized.description,
|
||||
Forbidden.code: Forbidden.description
|
||||
}
|
||||
|
||||
@basic_auth.verify_password
|
||||
def verify_password(email_or_username, password):
|
||||
user = User.query.filter((User.email == email_or_username.lower()) | (User.username == email_or_username)).first()
|
||||
if user is not None and user.verify_password(password):
|
||||
return user
|
||||
|
||||
|
||||
@basic_auth.error_handler
|
||||
def basic_auth_error(status):
|
||||
error = (Forbidden if status == 403 else Unauthorized)()
|
||||
return {
|
||||
'code': error.code,
|
||||
'message': error.name,
|
||||
'description': error.description,
|
||||
}, error.code, {'WWW-Authenticate': 'Form'}
|
||||
|
||||
|
||||
@token_auth.verify_token
|
||||
def verify_token(token):
|
||||
return User.verify_access_token(token) if token else None
|
||||
|
||||
|
||||
@token_auth.error_handler
|
||||
def token_auth_error(status):
|
||||
error = (Forbidden if status == 403 else Unauthorized)()
|
||||
return {
|
||||
'code': error.code,
|
||||
'message': error.name,
|
||||
'description': error.description,
|
||||
}, error.code
|
||||
|
||||
|
||||
@basic_auth.get_user_roles
|
||||
@token_auth.get_user_roles
|
||||
def get_user_roles(user):
|
||||
return [user.role.name]
|
102
app/blueprints/api/jobs.py
Normal file
102
app/blueprints/api/jobs.py
Normal file
@ -0,0 +1,102 @@
|
||||
|
||||
from apifairy import authenticate, response
|
||||
from apifairy.decorators import body, other_responses
|
||||
from flask import abort, Blueprint
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
from app import db, hashids
|
||||
from app.models import Job, JobInput, JobStatus, TesseractOCRPipelineModel
|
||||
from .auth import auth_error_responses, token_auth
|
||||
from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRPipelineModelSchema
|
||||
|
||||
|
||||
bp = Blueprint('jobs', __name__)
|
||||
job_schema = JobSchema()
|
||||
jobs_schema = JobSchema(many=True)
|
||||
spacy_nlp_pipeline_job_schema = SpaCyNLPPipelineJobSchema()
|
||||
tesseract_ocr_pipeline_job_schema = TesseractOCRPipelineJobSchema()
|
||||
tesseract_ocr_pipeline_model_schema = TesseractOCRPipelineModelSchema()
|
||||
tesseract_ocr_pipeline_models_schema = TesseractOCRPipelineModelSchema(many=True)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@authenticate(token_auth, role='Administrator')
|
||||
@response(jobs_schema)
|
||||
@other_responses(auth_error_responses)
|
||||
def get_jobs():
|
||||
"""Get all jobs"""
|
||||
return Job.query.all()
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline', methods=['POST'])
|
||||
@authenticate(token_auth)
|
||||
@body(tesseract_ocr_pipeline_job_schema, location='form')
|
||||
@response(job_schema)
|
||||
@other_responses({**auth_error_responses, InternalServerError.code: InternalServerError.description})
|
||||
def create_tesseract_ocr_pipeline_job(args):
|
||||
"""Create a new Tesseract OCR Pipeline job"""
|
||||
current_user = token_auth.current_user()
|
||||
try:
|
||||
job = Job.create(
|
||||
title=args['title'],
|
||||
description=args['description'],
|
||||
service='tesseract-ocr-pipeline',
|
||||
service_args={
|
||||
'model': hashids.decode(args['model_id']),
|
||||
'binarization': args['binarization']
|
||||
},
|
||||
service_version=args['service_version'],
|
||||
user=current_user
|
||||
)
|
||||
except OSError:
|
||||
abort(500)
|
||||
try:
|
||||
JobInput.create(args['pdf'], job=job)
|
||||
except OSError:
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
return job, 201
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline/models', methods=['GET'])
|
||||
@authenticate(token_auth)
|
||||
@response(tesseract_ocr_pipeline_models_schema)
|
||||
@other_responses(auth_error_responses)
|
||||
def get_tesseract_ocr_models():
|
||||
"""Get all Tesseract OCR Models"""
|
||||
return TesseractOCRPipelineModel.query.all()
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>', methods=['DELETE'])
|
||||
@authenticate(token_auth)
|
||||
@response(EmptySchema, status_code=204)
|
||||
@other_responses(auth_error_responses)
|
||||
def delete_job(job_id):
|
||||
"""Delete a job by id"""
|
||||
current_user = token_auth.current_user()
|
||||
job = Job.query.get(job_id)
|
||||
if job is None:
|
||||
abort(404)
|
||||
if not (job.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
try:
|
||||
job.delete()
|
||||
except OSError as e:
|
||||
abort(500)
|
||||
db.session.commit()
|
||||
return {}, 204
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>', methods=['GET'])
|
||||
@authenticate(token_auth)
|
||||
@response(job_schema)
|
||||
@other_responses(auth_error_responses)
|
||||
def get_job(job_id):
|
||||
"""Get a job by id"""
|
||||
current_user = token_auth.current_user()
|
||||
job = Job.query.get(job_id)
|
||||
if job is None:
|
||||
abort(404)
|
||||
if not (job.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return job
|
173
app/blueprints/api/schemas.py
Normal file
173
app/blueprints/api/schemas.py
Normal file
@ -0,0 +1,173 @@
|
||||
from apifairy.fields import FileField
|
||||
from marshmallow import validate, validates, ValidationError
|
||||
from marshmallow.decorators import post_dump
|
||||
from app import ma
|
||||
from app.models import (
|
||||
Job,
|
||||
JobStatus,
|
||||
TesseractOCRPipelineModel,
|
||||
Token,
|
||||
User,
|
||||
UserSettingJobStatusMailNotificationLevel
|
||||
)
|
||||
from app.blueprints.services import SERVICES
|
||||
|
||||
|
||||
|
||||
class EmptySchema(ma.Schema):
|
||||
pass
|
||||
|
||||
|
||||
class TokenSchema(ma.SQLAlchemySchema):
|
||||
class Meta:
|
||||
model = Token
|
||||
ordered = True
|
||||
|
||||
access_token = ma.String(required=True)
|
||||
refresh_token = ma.String()
|
||||
|
||||
|
||||
class TesseractOCRPipelineModelSchema(ma.SQLAlchemySchema):
|
||||
class Meta:
|
||||
model = TesseractOCRPipelineModel
|
||||
ordered = True
|
||||
|
||||
hashid = ma.String(data_key='id', dump_only=True)
|
||||
user_hashid = ma.String(data_key='user_id', dump_only=True)
|
||||
title = ma.auto_field(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=64)
|
||||
)
|
||||
description = ma.auto_field(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=255)
|
||||
)
|
||||
version = ma.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=16)
|
||||
)
|
||||
compatible_service_versions = ma.List(
|
||||
ma.String(required=True, validate=validate.Length(min=1, max=16)),
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=255)
|
||||
)
|
||||
publisher = ma.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=128)
|
||||
)
|
||||
publisher_url = ma.String(
|
||||
validate=[validate.URL(), validate.Length(min=1, max=512)]
|
||||
)
|
||||
publishing_url = ma.String(
|
||||
required=True,
|
||||
validate=[validate.URL(), validate.Length(min=1, max=512)]
|
||||
)
|
||||
publishing_year = ma.Int(
|
||||
required=True
|
||||
)
|
||||
is_public = ma.Boolean(required=True)
|
||||
|
||||
|
||||
class JobSchema(ma.SQLAlchemySchema):
|
||||
class Meta:
|
||||
model = Job
|
||||
ordered = True
|
||||
|
||||
hashid = ma.String(data_key='id', dump_only=True)
|
||||
user_hashid = ma.String(data_key='user_id', dump_only=True)
|
||||
title = ma.auto_field(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=32)
|
||||
)
|
||||
description = ma.auto_field(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=255)
|
||||
)
|
||||
creation_date = ma.auto_field(dump_only=True)
|
||||
end_date = ma.auto_field(dump_only=True)
|
||||
service = ma.String(
|
||||
dump_only=True,
|
||||
validate=validate.OneOf(SERVICES.keys())
|
||||
)
|
||||
service_args = ma.Dict(dump_only=True)
|
||||
service_version = ma.String(dump_only=True)
|
||||
status = ma.String(
|
||||
dump_only=True,
|
||||
validate=validate.OneOf(list(JobStatus.__members__.keys()))
|
||||
)
|
||||
|
||||
@post_dump(pass_original=True)
|
||||
def post_dump(self, serialized_job, job, **kwargs):
|
||||
serialized_job['status'] = job.status.name
|
||||
return serialized_job
|
||||
|
||||
|
||||
class TesseractOCRPipelineJobSchema(JobSchema):
|
||||
binarization = ma.Boolean(load_only=True, missing=False)
|
||||
model_id = ma.String(required=True, load_only=True)
|
||||
service_version = ma.auto_field(
|
||||
required=True,
|
||||
validate=[validate.Length(min=1, max=16), validate.OneOf(list(SERVICES['tesseract-ocr-pipeline']['versions'].keys()))]
|
||||
)
|
||||
pdf = FileField()
|
||||
|
||||
@validates('pdf')
|
||||
def validate_pdf(self, value):
|
||||
if value.mimetype != 'application/pdf':
|
||||
raise ValidationError('PDF files only!')
|
||||
|
||||
|
||||
class SpaCyNLPPipelineJobSchema(JobSchema):
|
||||
binarization = ma.Boolean(load_only=True, missing=False)
|
||||
model_id = ma.String(required=True, load_only=True)
|
||||
service_version = ma.auto_field(
|
||||
required=True,
|
||||
validate=[validate.Length(min=1, max=16), validate.OneOf(list(SERVICES['tesseract-ocr-pipeline']['versions'].keys()))]
|
||||
)
|
||||
txt = FileField(required=True)
|
||||
|
||||
@validates('txt')
|
||||
def validate_txt(self, value):
|
||||
if value.mimetype != 'text/plain':
|
||||
raise ValidationError('Plain text files only!')
|
||||
|
||||
|
||||
class UserSchema(ma.SQLAlchemySchema):
|
||||
class Meta:
|
||||
model = User
|
||||
ordered = True
|
||||
|
||||
hashid = ma.String(data_key='id', dump_only=True)
|
||||
username = ma.auto_field(
|
||||
validate=[
|
||||
validate.Length(min=1, max=64),
|
||||
validate.Regexp(
|
||||
User.username_pattern,
|
||||
error='Usernames must have only letters, numbers, dots or underscores'
|
||||
)
|
||||
]
|
||||
)
|
||||
email = ma.auto_field(validate=validate.Email())
|
||||
member_since = ma.auto_field(dump_only=True)
|
||||
last_seen = ma.auto_field(dump_only=True)
|
||||
password = ma.String(load_only=True)
|
||||
last_seen = ma.auto_field(dump_only=True)
|
||||
setting_job_status_mail_notification_level = ma.String(
|
||||
validate=validate.OneOf(list(UserSettingJobStatusMailNotificationLevel.__members__.keys()))
|
||||
)
|
||||
|
||||
@validates('email')
|
||||
def validate_email(self, email):
|
||||
if User.query.filter(User.email == email).first():
|
||||
raise ValidationError('Email already registered')
|
||||
|
||||
@validates('username')
|
||||
def validate_username(self, username):
|
||||
if User.query.filter(User.username == username).first():
|
||||
raise ValidationError('Username already in use')
|
||||
|
||||
@post_dump(pass_original=True)
|
||||
def post_dump(self, serialized_user, user, **kwargs):
|
||||
serialized_user['setting_job_status_mail_notification_level'] = \
|
||||
user.setting_job_status_mail_notification_level.name
|
||||
return serialized_user
|
58
app/blueprints/api/tokens.py
Normal file
58
app/blueprints/api/tokens.py
Normal file
@ -0,0 +1,58 @@
|
||||
from apifairy import authenticate, body, response, other_responses
|
||||
from flask import Blueprint, request, abort
|
||||
from app import db
|
||||
from app.models import Token, User
|
||||
from .auth import basic_auth
|
||||
from .schemas import EmptySchema, TokenSchema
|
||||
|
||||
|
||||
bp = Blueprint('tokens', __name__)
|
||||
token_schema = TokenSchema()
|
||||
|
||||
|
||||
@bp.route('', methods=['DELETE'])
|
||||
@response(EmptySchema, status_code=204, description='Token revoked')
|
||||
@other_responses({401: 'Invalid access token'})
|
||||
def delete_token():
|
||||
"""Revoke an access token"""
|
||||
access_token = request.headers['Authorization'].split()[1]
|
||||
token = Token.query.filter(Token.access_token == access_token).first()
|
||||
if token is None: # pragma: no cover
|
||||
abort(401)
|
||||
token.expire()
|
||||
db.session.commit()
|
||||
return {}
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@authenticate(basic_auth)
|
||||
@response(token_schema)
|
||||
@other_responses({401: 'Invalid username or password'})
|
||||
def create_token():
|
||||
"""Create new access and refresh tokens"""
|
||||
user = basic_auth.current_user()
|
||||
token = user.generate_auth_token()
|
||||
db.session.add(token)
|
||||
Token.clean() # keep token table clean of old tokens
|
||||
db.session.commit()
|
||||
return token, 200
|
||||
|
||||
|
||||
@bp.route('', methods=['PUT'])
|
||||
@body(token_schema)
|
||||
@response(token_schema, description='Newly issued access and refresh tokens')
|
||||
@other_responses({401: 'Invalid access or refresh token'})
|
||||
def refresh_token(args):
|
||||
"""Refresh an access token"""
|
||||
access_token = args.get('access_token')
|
||||
refresh_token = args.get('refresh_token')
|
||||
if access_token is None or refresh_token is None:
|
||||
abort(401)
|
||||
token = User.verify_refresh_token(refresh_token, access_token)
|
||||
if token is None:
|
||||
abort(401)
|
||||
token.expire()
|
||||
new_token = token.user.generate_auth_token()
|
||||
db.session.add_all([token, new_token])
|
||||
db.session.commit()
|
||||
return new_token, 200
|
99
app/blueprints/api/users.py
Normal file
99
app/blueprints/api/users.py
Normal file
@ -0,0 +1,99 @@
|
||||
|
||||
from apifairy import authenticate, body, response
|
||||
from apifairy.decorators import other_responses
|
||||
from flask import abort, Blueprint
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
from app.email import create_message, send
|
||||
from app import db
|
||||
from app.models import User
|
||||
from .auth import auth_error_responses, token_auth
|
||||
from .schemas import EmptySchema, UserSchema
|
||||
|
||||
|
||||
bp = Blueprint('users', __name__)
|
||||
user_schema = UserSchema()
|
||||
users_schema = UserSchema(many=True)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@authenticate(token_auth, role='Administrator')
|
||||
@response(users_schema)
|
||||
@other_responses(auth_error_responses)
|
||||
def get_users():
|
||||
"""Get all users"""
|
||||
return User.query.all()
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@body(user_schema)
|
||||
@response(user_schema, 201)
|
||||
@other_responses({InternalServerError.code: InternalServerError.description})
|
||||
def create_user(args):
|
||||
"""Create a new user"""
|
||||
try:
|
||||
user = User.create(
|
||||
email=args['email'].lower(),
|
||||
password=args['password'],
|
||||
username=args['username']
|
||||
)
|
||||
except OSError:
|
||||
abort(500)
|
||||
msg = create_message(
|
||||
user.email,
|
||||
'Confirm Your Account',
|
||||
'auth/email/confirm',
|
||||
token=user.generate_confirm_token(),
|
||||
user=user
|
||||
)
|
||||
send(msg)
|
||||
db.session.commit()
|
||||
return user, 201
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>', methods=['DELETE'])
|
||||
@authenticate(token_auth)
|
||||
@response(EmptySchema, status_code=204)
|
||||
@other_responses(auth_error_responses)
|
||||
def delete_user(user_id):
|
||||
"""Delete a user by id"""
|
||||
current_user = token_auth.current_user()
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
user.delete()
|
||||
db.session.commit()
|
||||
return {}, 204
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>', methods=['GET'])
|
||||
@authenticate(token_auth)
|
||||
@response(user_schema)
|
||||
@other_responses(auth_error_responses)
|
||||
@other_responses({404: 'User not found'})
|
||||
def get_user(user_id):
|
||||
"""Retrieve a user by id"""
|
||||
current_user = token_auth.current_user()
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return user
|
||||
|
||||
|
||||
@bp.route('/<username>', methods=['GET'])
|
||||
@authenticate(token_auth)
|
||||
@response(user_schema)
|
||||
@other_responses(auth_error_responses)
|
||||
@other_responses({404: 'User not found'})
|
||||
def get_user_by_username(username):
|
||||
"""Retrieve a user by username"""
|
||||
current_user = token_auth.current_user()
|
||||
user = User.query.filter(User.username == username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return user
|
5
app/blueprints/auth/__init__.py
Normal file
5
app/blueprints/auth/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
from . import routes
|
108
app/blueprints/auth/forms.py
Normal file
108
app/blueprints/auth/forms.py
Normal file
@ -0,0 +1,108 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
PasswordField,
|
||||
StringField,
|
||||
SubmitField,
|
||||
ValidationError
|
||||
)
|
||||
from wtforms.validators import InputRequired, Email, EqualTo, Length, Regexp
|
||||
from app.models import User
|
||||
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
email = StringField(
|
||||
'Email',
|
||||
validators=[InputRequired(), Email(), Length(max=254)]
|
||||
)
|
||||
username = StringField(
|
||||
'Username',
|
||||
validators=[
|
||||
InputRequired(),
|
||||
Length(max=64),
|
||||
Regexp(
|
||||
User.username_pattern,
|
||||
message=(
|
||||
'Usernames must have only letters, numbers, dots or '
|
||||
'underscores'
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[
|
||||
InputRequired(),
|
||||
EqualTo('password_2', message='Passwords must match')
|
||||
]
|
||||
)
|
||||
password_2 = PasswordField(
|
||||
'Password confirmation',
|
||||
validators=[
|
||||
InputRequired(),
|
||||
EqualTo('password', message='Passwords must match')
|
||||
]
|
||||
)
|
||||
terms_of_use_accepted = BooleanField(
|
||||
'I have read and accept the terms of use',
|
||||
validators=[InputRequired()]
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'registration-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate_email(self, field):
|
||||
if User.query.filter_by(email=field.data.lower()).first():
|
||||
raise ValidationError('Email already registered')
|
||||
|
||||
def validate_username(self, field):
|
||||
if User.query.filter_by(username=field.data).first():
|
||||
raise ValidationError('Username already in use')
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
user = StringField('Email or username', validators=[InputRequired()])
|
||||
password = PasswordField('Password', validators=[InputRequired()])
|
||||
remember_me = BooleanField('Keep me logged in')
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'login-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ResetPasswordRequestForm(FlaskForm):
|
||||
email = StringField('Email', validators=[InputRequired(), Email()])
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'reset-password-request-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
password = PasswordField(
|
||||
'New password',
|
||||
validators=[
|
||||
InputRequired(),
|
||||
EqualTo('password_2', message='Passwords must match')
|
||||
]
|
||||
)
|
||||
password_2 = PasswordField(
|
||||
'New password confirmation',
|
||||
validators=[
|
||||
InputRequired(),
|
||||
EqualTo('password', message='Passwords must match')
|
||||
]
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'reset-password-form'
|
||||
super().__init__(*args, **kwargs)
|
188
app/blueprints/auth/routes.py
Normal file
188
app/blueprints/auth/routes.py
Normal file
@ -0,0 +1,188 @@
|
||||
from flask import abort, flash, redirect, render_template, request, url_for
|
||||
from flask_login import current_user, login_user, login_required, logout_user
|
||||
from app import db
|
||||
from app.email import create_message, send
|
||||
from app.models import User
|
||||
from . import bp
|
||||
from .forms import (
|
||||
LoginForm,
|
||||
ResetPasswordForm,
|
||||
ResetPasswordRequestForm,
|
||||
RegistrationForm
|
||||
)
|
||||
|
||||
|
||||
@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 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'):
|
||||
return redirect(url_for('auth.unconfirmed'))
|
||||
if not current_user.terms_of_use_accepted:
|
||||
return redirect(url_for('main.terms_of_use'))
|
||||
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
form = RegistrationForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
user = User.create(
|
||||
email=form.email.data.lower(),
|
||||
password=form.password.data,
|
||||
username=form.username.data,
|
||||
terms_of_use_accepted=form.terms_of_use_accepted.data
|
||||
)
|
||||
except OSError:
|
||||
flash('Internal Server Error', category='error')
|
||||
abort(500)
|
||||
flash(f'User "{user.username}" created')
|
||||
token = user.generate_confirm_token()
|
||||
msg = create_message(
|
||||
user.email,
|
||||
'Confirm Your Account',
|
||||
'auth/email/confirm',
|
||||
token=token,
|
||||
user=user
|
||||
)
|
||||
send(msg)
|
||||
flash('A confirmation email has been sent to you by email')
|
||||
db.session.commit()
|
||||
return redirect(url_for('.login'))
|
||||
return render_template(
|
||||
'auth/register.html.j2',
|
||||
title='Register',
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
|
||||
if user and user.verify_password(form.password.data):
|
||||
login_user(user, form.remember_me.data)
|
||||
next = request.args.get('next')
|
||||
if next is None or not next.startswith('/'):
|
||||
next = url_for('main.dashboard')
|
||||
flash('You have been logged in')
|
||||
return redirect(next)
|
||||
flash('Invalid email/username or password', category='error')
|
||||
return render_template(
|
||||
'auth/login.html.j2',
|
||||
title='Log in',
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash('You have been logged out')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
|
||||
@bp.route('/unconfirmed')
|
||||
@login_required
|
||||
def unconfirmed():
|
||||
if current_user.confirmed:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return render_template(
|
||||
'auth/unconfirmed.html.j2',
|
||||
title='Unconfirmed'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/confirm-request')
|
||||
@login_required
|
||||
def confirm_request():
|
||||
if current_user.confirmed:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
token = current_user.generate_confirm_token()
|
||||
msg = create_message(
|
||||
current_user.email,
|
||||
'Confirm Your Account',
|
||||
'auth/email/confirm',
|
||||
token=token,
|
||||
user=current_user
|
||||
)
|
||||
send(msg)
|
||||
flash('A new confirmation email has been sent to you by email')
|
||||
return redirect(url_for('.unconfirmed'))
|
||||
|
||||
|
||||
@bp.route('/confirm/<token>')
|
||||
@login_required
|
||||
def confirm(token):
|
||||
if current_user.confirmed:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
if current_user.confirm(token):
|
||||
db.session.commit()
|
||||
flash('You have confirmed your account')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
flash('The confirmation link is invalid or has expired', category='error')
|
||||
return redirect(url_for('.unconfirmed'))
|
||||
|
||||
|
||||
@bp.route('/reset-password-request', methods=['GET', 'POST'])
|
||||
def reset_password_request():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
form = ResetPasswordRequestForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data.lower()).first()
|
||||
if user is not None:
|
||||
token = user.generate_reset_password_token()
|
||||
msg = create_message(
|
||||
user.email,
|
||||
'Reset Your Password',
|
||||
'auth/email/reset_password',
|
||||
token=token,
|
||||
user=user
|
||||
)
|
||||
send(msg)
|
||||
flash(
|
||||
'An email with instructions to reset your password has been sent '
|
||||
'to you'
|
||||
)
|
||||
return redirect(url_for('.login'))
|
||||
return render_template(
|
||||
'auth/reset_password_request.html.j2',
|
||||
title='Password Reset',
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||
def reset_password(token):
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
form = ResetPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
if User.reset_password(token, form.password.data):
|
||||
db.session.commit()
|
||||
flash('Your password has been updated')
|
||||
return redirect(url_for('.login'))
|
||||
return redirect(url_for('main.index'))
|
||||
return render_template(
|
||||
'auth/reset_password.html.j2',
|
||||
title='Password Reset',
|
||||
form=form,
|
||||
token=token
|
||||
)
|
23
app/blueprints/contributions/__init__.py
Normal file
23
app/blueprints/contributions/__init__.py
Normal file
@ -0,0 +1,23 @@
|
||||
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
|
||||
)
|
47
app/blueprints/contributions/forms.py
Normal file
47
app/blueprints/contributions/forms.py
Normal file
@ -0,0 +1,47 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField,
|
||||
SubmitField,
|
||||
SelectMultipleField,
|
||||
IntegerField
|
||||
)
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
|
||||
class ContributionBaseForm(FlaskForm):
|
||||
title = StringField(
|
||||
'Title',
|
||||
validators=[InputRequired(), Length(max=64)]
|
||||
)
|
||||
description = StringField(
|
||||
'Description',
|
||||
validators=[InputRequired(), Length(max=255)]
|
||||
)
|
||||
version = StringField(
|
||||
'Version',
|
||||
validators=[InputRequired(), Length(max=16)]
|
||||
)
|
||||
publisher = StringField(
|
||||
'Publisher',
|
||||
validators=[InputRequired(), Length(max=128)]
|
||||
)
|
||||
publisher_url = StringField(
|
||||
'Publisher URL',
|
||||
validators=[InputRequired(), Length(max=512)]
|
||||
)
|
||||
publishing_url = StringField(
|
||||
'Publishing URL',
|
||||
validators=[InputRequired(), Length(max=512)]
|
||||
)
|
||||
publishing_year = IntegerField(
|
||||
'Publishing year',
|
||||
validators=[InputRequired()]
|
||||
)
|
||||
compatible_service_versions = SelectMultipleField(
|
||||
'Compatible service versions'
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
|
||||
class UpdateContributionBaseForm(ContributionBaseForm):
|
||||
pass
|
7
app/blueprints/contributions/routes.py
Normal file
7
app/blueprints/contributions/routes.py
Normal file
@ -0,0 +1,7 @@
|
||||
from flask import redirect, url_for
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
def contributions():
|
||||
return redirect(url_for('main.dashboard', _anchor='contributions'))
|
@ -0,0 +1,2 @@
|
||||
from .. import bp
|
||||
from . import json_routes, routes
|
@ -0,0 +1,48 @@
|
||||
from flask_wtf.file import FileField, FileRequired
|
||||
from wtforms import StringField, ValidationError
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from app.blueprints.services import SERVICES
|
||||
from ..forms import ContributionBaseForm, UpdateContributionBaseForm
|
||||
|
||||
|
||||
class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
|
||||
spacy_model_file = FileField(
|
||||
'File',
|
||||
validators=[FileRequired()]
|
||||
)
|
||||
pipeline_name = StringField(
|
||||
'Pipeline name',
|
||||
validators=[InputRequired(), Length(max=64)]
|
||||
)
|
||||
|
||||
def validate_spacy_model_file(self, field):
|
||||
if not field.data.filename.lower().endswith(('.tar.gz', ('.whl'))):
|
||||
raise ValidationError('.tar.gz or .whl files only!')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'create-spacy-nlp-pipeline-model-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
service_manifest = SERVICES['spacy-nlp-pipeline']
|
||||
self.compatible_service_versions.choices = [('', 'Choose your option')]
|
||||
self.compatible_service_versions.choices += [
|
||||
(x, x) for x in service_manifest['versions'].keys()
|
||||
]
|
||||
self.compatible_service_versions.default = ''
|
||||
|
||||
|
||||
class UpdateSpaCyNLPPipelineModelForm(UpdateContributionBaseForm):
|
||||
pipeline_name = StringField(
|
||||
'Pipeline name',
|
||||
validators=[InputRequired(), Length(max=64)]
|
||||
)
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'edit-spacy-nlp-pipeline-model-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
service_manifest = SERVICES['spacy-nlp-pipeline']
|
||||
self.compatible_service_versions.choices = [('', 'Choose your option')]
|
||||
self.compatible_service_versions.choices += [
|
||||
(x, x) for x in service_manifest['versions'].keys()
|
||||
]
|
||||
self.compatible_service_versions.default = ''
|
@ -0,0 +1,52 @@
|
||||
from flask import abort, current_app, request
|
||||
from flask_login import current_user
|
||||
from threading import Thread
|
||||
from app import db
|
||||
from app.decorators import content_negotiation, permission_required
|
||||
from app.models import SpaCyNLPPipelineModel
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
|
||||
@content_negotiation(produces='application/json')
|
||||
def delete_spacy_model(spacy_nlp_pipeline_model_id):
|
||||
def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
|
||||
with app.app_context():
|
||||
snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
|
||||
snpm.delete()
|
||||
db.session.commit()
|
||||
|
||||
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
|
||||
if not (snpm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
thread = Thread(
|
||||
target=_delete_spacy_model,
|
||||
args=(current_app._get_current_object(), snpm.id)
|
||||
)
|
||||
thread.start()
|
||||
response_data = {
|
||||
'message': \
|
||||
f'SpaCy NLP Pipeline Model "{snpm.title}" marked for deletion'
|
||||
}
|
||||
return response_data, 202
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
|
||||
@permission_required('CONTRIBUTE')
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
|
||||
is_public = request.json
|
||||
if not isinstance(is_public, bool):
|
||||
abort(400)
|
||||
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
|
||||
if not (snpm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
snpm.is_public = is_public
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': (
|
||||
f'SpaCy NLP Pipeline Model "{snpm.title}"'
|
||||
f' is now {"public" if is_public else "private"}'
|
||||
)
|
||||
}
|
||||
return response_data, 200
|
@ -0,0 +1,70 @@
|
||||
from flask import abort, flash, redirect, render_template, url_for
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
from app.models import SpaCyNLPPipelineModel
|
||||
from . import bp
|
||||
from .forms import (
|
||||
CreateSpaCyNLPPipelineModelForm,
|
||||
UpdateSpaCyNLPPipelineModelForm
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models')
|
||||
def spacy_nlp_pipeline_models():
|
||||
return render_template(
|
||||
'contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_models.html.j2',
|
||||
title='SpaCy NLP Pipeline Models'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
|
||||
def create_spacy_nlp_pipeline_model():
|
||||
form = CreateSpaCyNLPPipelineModelForm()
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
return {'errors': form.errors}, 400
|
||||
try:
|
||||
snpm = SpaCyNLPPipelineModel.create(
|
||||
form.spacy_model_file.data,
|
||||
compatible_service_versions=form.compatible_service_versions.data,
|
||||
description=form.description.data,
|
||||
pipeline_name=form.pipeline_name.data,
|
||||
publisher=form.publisher.data,
|
||||
publisher_url=form.publisher_url.data,
|
||||
publishing_url=form.publishing_url.data,
|
||||
publishing_year=form.publishing_year.data,
|
||||
is_public=False,
|
||||
title=form.title.data,
|
||||
version=form.version.data,
|
||||
user=current_user
|
||||
)
|
||||
except OSError:
|
||||
abort(500)
|
||||
db.session.commit()
|
||||
flash(f'SpaCy NLP Pipeline model "{snpm.title}" created')
|
||||
return {}, 201, {'Location': url_for('.spacy_nlp_pipeline_models')}
|
||||
return render_template(
|
||||
'contributions/spacy_nlp_pipeline_models/create.html.j2',
|
||||
title='Create SpaCy NLP Pipeline Model',
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
|
||||
def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
|
||||
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
|
||||
if not (snpm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
form = UpdateSpaCyNLPPipelineModelForm(data=snpm.to_json_serializeable())
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(snpm)
|
||||
if db.session.is_modified(snpm):
|
||||
flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated')
|
||||
db.session.commit()
|
||||
return redirect(url_for('.spacy_nlp_pipeline_models'))
|
||||
return render_template(
|
||||
'contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2',
|
||||
title=f'{snpm.title} {snpm.version}',
|
||||
form=form,
|
||||
spacy_nlp_pipeline_model=snpm
|
||||
)
|
@ -0,0 +1,2 @@
|
||||
from .. import bp
|
||||
from . import json_routes, routes
|
@ -0,0 +1,39 @@
|
||||
from flask_wtf.file import FileField, FileRequired
|
||||
from wtforms import ValidationError
|
||||
from app.blueprints.services import SERVICES
|
||||
from ..forms import ContributionBaseForm, UpdateContributionBaseForm
|
||||
|
||||
|
||||
class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
|
||||
tesseract_model_file = FileField(
|
||||
'File',
|
||||
validators=[FileRequired()]
|
||||
)
|
||||
|
||||
def validate_tesseract_model_file(self, field):
|
||||
if not field.data.filename.lower().endswith('.traineddata'):
|
||||
raise ValidationError('traineddata files only!')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'create-tesseract-ocr-pipeline-model-form'
|
||||
service_manifest = SERVICES['tesseract-ocr-pipeline']
|
||||
super().__init__(*args, **kwargs)
|
||||
self.compatible_service_versions.choices = [('', 'Choose your option')]
|
||||
self.compatible_service_versions.choices += [
|
||||
(x, x) for x in service_manifest['versions'].keys()
|
||||
]
|
||||
self.compatible_service_versions.default = ''
|
||||
|
||||
|
||||
class UpdateTesseractOCRPipelineModelForm(UpdateContributionBaseForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'edit-tesseract-ocr-pipeline-model-form'
|
||||
service_manifest = SERVICES['tesseract-ocr-pipeline']
|
||||
super().__init__(*args, **kwargs)
|
||||
self.compatible_service_versions.choices = [('', 'Choose your option')]
|
||||
self.compatible_service_versions.choices += [
|
||||
(x, x) for x in service_manifest['versions'].keys()
|
||||
]
|
||||
self.compatible_service_versions.default = ''
|
@ -0,0 +1,52 @@
|
||||
from flask import abort, current_app, request
|
||||
from flask_login import current_user
|
||||
from threading import Thread
|
||||
from app import db
|
||||
from app.decorators import content_negotiation, permission_required
|
||||
from app.models import TesseractOCRPipelineModel
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
|
||||
@content_negotiation(produces='application/json')
|
||||
def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
|
||||
def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
|
||||
with app.app_context():
|
||||
topm = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
|
||||
topm.delete()
|
||||
db.session.commit()
|
||||
|
||||
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
|
||||
if not (topm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
thread = Thread(
|
||||
target=_delete_tesseract_ocr_pipeline_model,
|
||||
args=(current_app._get_current_object(), topm.id)
|
||||
)
|
||||
thread.start()
|
||||
response_data = {
|
||||
'message': \
|
||||
f'Tesseract OCR Pipeline Model "{topm.title}" marked for deletion'
|
||||
}
|
||||
return response_data, 202
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
|
||||
@permission_required('CONTRIBUTE')
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id):
|
||||
is_public = request.json
|
||||
if not isinstance(is_public, bool):
|
||||
abort(400)
|
||||
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
|
||||
if not (topm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
topm.is_public = is_public
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': (
|
||||
f'Tesseract OCR Pipeline Model "{topm.title}"'
|
||||
f' is now {"public" if is_public else "private"}'
|
||||
)
|
||||
}
|
||||
return response_data, 200
|
@ -0,0 +1,69 @@
|
||||
from flask import abort, flash, redirect, render_template, url_for
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
from app.models import TesseractOCRPipelineModel
|
||||
from . import bp
|
||||
from .forms import (
|
||||
CreateTesseractOCRPipelineModelForm,
|
||||
UpdateTesseractOCRPipelineModelForm
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models')
|
||||
def tesseract_ocr_pipeline_models():
|
||||
return render_template(
|
||||
'contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_models.html.j2',
|
||||
title='Tesseract OCR Pipeline Models'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models/create', methods=['GET', 'POST'])
|
||||
def create_tesseract_ocr_pipeline_model():
|
||||
form = CreateTesseractOCRPipelineModelForm()
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
return {'errors': form.errors}, 400
|
||||
try:
|
||||
topm = TesseractOCRPipelineModel.create(
|
||||
form.tesseract_model_file.data,
|
||||
compatible_service_versions=form.compatible_service_versions.data,
|
||||
description=form.description.data,
|
||||
publisher=form.publisher.data,
|
||||
publisher_url=form.publisher_url.data,
|
||||
publishing_url=form.publishing_url.data,
|
||||
publishing_year=form.publishing_year.data,
|
||||
is_public=False,
|
||||
title=form.title.data,
|
||||
version=form.version.data,
|
||||
user=current_user
|
||||
)
|
||||
except OSError:
|
||||
abort(500)
|
||||
db.session.commit()
|
||||
flash(f'Tesseract OCR Pipeline model "{topm.title}" created')
|
||||
return {}, 201, {'Location': url_for('.tesseract_ocr_pipeline_models')}
|
||||
return render_template(
|
||||
'contributions/tesseract_ocr_pipeline_models/create.html.j2',
|
||||
title='Create Tesseract OCR Pipeline Model',
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
|
||||
def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
|
||||
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
|
||||
if not (topm.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
form = UpdateTesseractOCRPipelineModelForm(data=topm.to_json_serializeable())
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(topm)
|
||||
if db.session.is_modified(topm):
|
||||
flash(f'Tesseract OCR Pipeline model "{topm.title}" updated')
|
||||
db.session.commit()
|
||||
return redirect(url_for('.tesseract_ocr_pipeline_models'))
|
||||
return render_template(
|
||||
'contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2',
|
||||
title=f'{topm.title} {topm.version}',
|
||||
form=form,
|
||||
tesseract_ocr_pipeline_model=topm
|
||||
)
|
@ -0,0 +1,2 @@
|
||||
from .. import bp
|
||||
from . import routes
|
@ -0,0 +1,7 @@
|
||||
from flask import abort
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/transkribus_htr_pipeline_models')
|
||||
def transkribus_htr_pipeline_models():
|
||||
return abort(503)
|
19
app/blueprints/corpora/__init__.py
Normal file
19
app/blueprints/corpora/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('corpora', __name__)
|
||||
bp.cli.short_help = 'Corpus commands.'
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@login_required
|
||||
def before_request():
|
||||
'''
|
||||
Ensures that the routes in this package can only be visited by users that
|
||||
are logged in.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
from . import cli, files, followers, routes, json_routes
|
34
app/blueprints/corpora/cli.py
Normal file
34
app/blueprints/corpora/cli.py
Normal file
@ -0,0 +1,34 @@
|
||||
from flask import current_app
|
||||
import shutil
|
||||
from app import db
|
||||
from app.models import Corpus, CorpusStatus
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.cli.command('reset')
|
||||
def reset():
|
||||
''' Reset built corpora. '''
|
||||
status = [
|
||||
CorpusStatus.QUEUED,
|
||||
CorpusStatus.BUILDING,
|
||||
CorpusStatus.BUILT,
|
||||
CorpusStatus.STARTING_ANALYSIS_SESSION,
|
||||
CorpusStatus.RUNNING_ANALYSIS_SESSION,
|
||||
CorpusStatus.CANCELING_ANALYSIS_SESSION
|
||||
]
|
||||
for corpus in [x for x in Corpus.query.all() if x.status in status]:
|
||||
print(f'Resetting corpus {corpus}')
|
||||
corpus_cwb_dir = corpus.path / 'cwb'
|
||||
corpus_cwb_data_dir = corpus_cwb_dir / 'data'
|
||||
corpus_cwb_registry_dir = corpus_cwb_dir / 'registry'
|
||||
try:
|
||||
shutil.rmtree(corpus.path / 'cwb', ignore_errors=True)
|
||||
corpus_cwb_dir.mkdir()
|
||||
corpus_cwb_data_dir.mkdir()
|
||||
corpus_cwb_registry_dir.mkdir()
|
||||
except OSError as e:
|
||||
current_app.logger.error(e)
|
||||
raise
|
||||
corpus.status = CorpusStatus.UNPREPARED
|
||||
corpus.num_analysis_sessions = 0
|
||||
db.session.commit()
|
206
app/blueprints/corpora/cqi_over_sio/__init__.py
Normal file
206
app/blueprints/corpora/cqi_over_sio/__init__.py
Normal file
@ -0,0 +1,206 @@
|
||||
from cqi import CQiClient
|
||||
from cqi.errors import CQiException
|
||||
from cqi.status import CQiStatus
|
||||
from docker.models.containers import Container
|
||||
from flask import current_app, session
|
||||
from flask_login import current_user
|
||||
from flask_socketio import Namespace
|
||||
from inspect import signature
|
||||
from threading import Lock
|
||||
from typing import Callable
|
||||
from app import db, docker_client, hashids, socketio
|
||||
from app.decorators import socketio_login_required
|
||||
from app.models import Corpus, CorpusStatus
|
||||
from . import extensions
|
||||
|
||||
|
||||
'''
|
||||
This package tunnels the Corpus Query interface (CQi) protocol through
|
||||
Socket.IO (SIO) by tunneling CQi API calls through an event called "exec".
|
||||
|
||||
Basic concept:
|
||||
1. A client connects to the namespace.
|
||||
2. The client emits the "init" event and provides a corpus id for the corpus
|
||||
that should be analysed in this session.
|
||||
1.1 The analysis session counter of the corpus is incremented.
|
||||
1.2 A CQiClient and a (Mutex) Lock belonging to it is created.
|
||||
1.3 Wait until the CQP server is running.
|
||||
1.4 Connect the CQiClient to the server.
|
||||
1.5 Save the CQiClient, the Lock and the corpus id in the session for
|
||||
subsequential use.
|
||||
3. The client emits "exec" events, within which it provides the name of a CQi
|
||||
API function and the corresponding arguments.
|
||||
3.1 The "exec" event handler will execute the function, make sure that
|
||||
the result is serializable and returns the result back to the client.
|
||||
4. The client disconnects from the namespace
|
||||
4.1 The analysis session counter of the corpus is decremented.
|
||||
4.2 The CQiClient and (Mutex) Lock belonging to it are teared down.
|
||||
'''
|
||||
|
||||
|
||||
CQI_API_FUNCTION_NAMES: list[str] = [
|
||||
'ask_feature_cl_2_3',
|
||||
'ask_feature_cqi_1_0',
|
||||
'ask_feature_cqp_2_3',
|
||||
'cl_alg2cpos',
|
||||
'cl_attribute_size',
|
||||
'cl_cpos2alg',
|
||||
'cl_cpos2id',
|
||||
'cl_cpos2lbound',
|
||||
'cl_cpos2rbound',
|
||||
'cl_cpos2str',
|
||||
'cl_cpos2struc',
|
||||
'cl_drop_attribute',
|
||||
'cl_id2cpos',
|
||||
'cl_id2freq',
|
||||
'cl_id2str',
|
||||
'cl_idlist2cpos',
|
||||
'cl_lexicon_size',
|
||||
'cl_regex2id',
|
||||
'cl_str2id',
|
||||
'cl_struc2cpos',
|
||||
'cl_struc2str',
|
||||
'corpus_alignment_attributes',
|
||||
'corpus_charset',
|
||||
'corpus_drop_corpus',
|
||||
'corpus_full_name',
|
||||
'corpus_info',
|
||||
'corpus_list_corpora',
|
||||
'corpus_positional_attributes',
|
||||
'corpus_properties',
|
||||
'corpus_structural_attribute_has_values',
|
||||
'corpus_structural_attributes',
|
||||
'cqp_drop_subcorpus',
|
||||
'cqp_dump_subcorpus',
|
||||
'cqp_fdist_1',
|
||||
'cqp_fdist_2',
|
||||
'cqp_list_subcorpora',
|
||||
'cqp_query',
|
||||
'cqp_subcorpus_has_field',
|
||||
'cqp_subcorpus_size',
|
||||
'ctrl_bye',
|
||||
'ctrl_connect',
|
||||
'ctrl_last_general_error',
|
||||
'ctrl_ping',
|
||||
'ctrl_user_abort'
|
||||
]
|
||||
|
||||
|
||||
class CQiOverSocketIO(Namespace):
|
||||
@socketio_login_required
|
||||
def on_connect(self):
|
||||
pass
|
||||
|
||||
@socketio_login_required
|
||||
def on_init(self, db_corpus_hashid: str):
|
||||
db_corpus_id: int = hashids.decode(db_corpus_hashid)
|
||||
db_corpus: Corpus | None = Corpus.query.get(db_corpus_id)
|
||||
if db_corpus is None:
|
||||
return {'code': 404, 'msg': 'Not Found'}
|
||||
if not (db_corpus.user == current_user
|
||||
or current_user.is_following_corpus(db_corpus)
|
||||
or current_user.is_administrator):
|
||||
return {'code': 403, 'msg': 'Forbidden'}
|
||||
if db_corpus.status not in [
|
||||
CorpusStatus.BUILT,
|
||||
CorpusStatus.STARTING_ANALYSIS_SESSION,
|
||||
CorpusStatus.RUNNING_ANALYSIS_SESSION,
|
||||
CorpusStatus.CANCELING_ANALYSIS_SESSION
|
||||
]:
|
||||
return {'code': 424, 'msg': 'Failed Dependency'}
|
||||
if db_corpus.num_analysis_sessions is None:
|
||||
db_corpus.num_analysis_sessions = 0
|
||||
db.session.commit()
|
||||
db_corpus.num_analysis_sessions = Corpus.num_analysis_sessions + 1
|
||||
db.session.commit()
|
||||
retry_counter: int = 20
|
||||
while db_corpus.status != CorpusStatus.RUNNING_ANALYSIS_SESSION:
|
||||
if retry_counter == 0:
|
||||
db_corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
|
||||
db.session.commit()
|
||||
return {'code': 408, 'msg': 'Request Timeout'}
|
||||
socketio.sleep(3)
|
||||
retry_counter -= 1
|
||||
db.session.refresh(db_corpus)
|
||||
# cqi_client: CQiClient = CQiClient(f'cqpserver_{db_corpus_id}')
|
||||
cqpserver_container_name: str = f'cqpserver_{db_corpus_id}'
|
||||
cqpserver_container: Container = docker_client.containers.get(cqpserver_container_name)
|
||||
cqpserver_host: str = cqpserver_container.attrs['NetworkSettings']['Networks'][current_app.config['NOPAQUE_DOCKER_NETWORK_NAME']]['IPAddress']
|
||||
cqi_client: CQiClient = CQiClient(cqpserver_host)
|
||||
session['cqi_over_sio'] = {
|
||||
'cqi_client': cqi_client,
|
||||
'cqi_client_lock': Lock(),
|
||||
'db_corpus_id': db_corpus_id
|
||||
}
|
||||
return {'code': 200, 'msg': 'OK'}
|
||||
|
||||
@socketio_login_required
|
||||
def on_exec(self, fn_name: str, fn_args: dict = {}):
|
||||
try:
|
||||
cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
|
||||
cqi_client_lock: Lock = session['cqi_over_sio']['cqi_client_lock']
|
||||
except KeyError:
|
||||
return {'code': 424, 'msg': 'Failed Dependency'}
|
||||
if fn_name in CQI_API_FUNCTION_NAMES:
|
||||
fn: Callable = getattr(cqi_client.api, fn_name)
|
||||
elif fn_name in extensions.CQI_EXTENSION_FUNCTION_NAMES:
|
||||
fn: Callable = getattr(extensions, fn_name)
|
||||
else:
|
||||
return {'code': 400, 'msg': 'Bad Request'}
|
||||
for param in signature(fn).parameters.values():
|
||||
if param.default is param.empty:
|
||||
if param.name not in fn_args:
|
||||
return {'code': 400, 'msg': 'Bad Request'}
|
||||
else:
|
||||
if param.name not in fn_args:
|
||||
continue
|
||||
if type(fn_args[param.name]) is not param.annotation:
|
||||
return {'code': 400, 'msg': 'Bad Request'}
|
||||
cqi_client_lock.acquire()
|
||||
try:
|
||||
fn_return_value = fn(**fn_args)
|
||||
except BrokenPipeError as e:
|
||||
return {'code': 500, 'msg': 'Internal Server Error'}
|
||||
except CQiException as e:
|
||||
return {
|
||||
'code': 502,
|
||||
'msg': 'Bad Gateway',
|
||||
'payload': {
|
||||
'code': e.code,
|
||||
'desc': e.description,
|
||||
'msg': e.__class__.__name__
|
||||
}
|
||||
}
|
||||
finally:
|
||||
cqi_client_lock.release()
|
||||
if isinstance(fn_return_value, CQiStatus):
|
||||
payload = {
|
||||
'code': fn_return_value.code,
|
||||
'msg': fn_return_value.__class__.__name__
|
||||
}
|
||||
else:
|
||||
payload = fn_return_value
|
||||
return {'code': 200, 'msg': 'OK', 'payload': payload}
|
||||
|
||||
def on_disconnect(self):
|
||||
try:
|
||||
cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
|
||||
cqi_client_lock: Lock = session['cqi_over_sio']['cqi_client_lock']
|
||||
db_corpus_id: int = session['cqi_over_sio']['db_corpus_id']
|
||||
except KeyError:
|
||||
return
|
||||
cqi_client_lock.acquire()
|
||||
try:
|
||||
session.pop('cqi_over_sio')
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
cqi_client.api.ctrl_bye()
|
||||
except (BrokenPipeError, CQiException):
|
||||
pass
|
||||
cqi_client_lock.release()
|
||||
db_corpus: Corpus | None = Corpus.query.get(db_corpus_id)
|
||||
if db_corpus is None:
|
||||
return
|
||||
db_corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
|
||||
db.session.commit()
|
286
app/blueprints/corpora/cqi_over_sio/extensions.py
Normal file
286
app/blueprints/corpora/cqi_over_sio/extensions.py
Normal file
@ -0,0 +1,286 @@
|
||||
from collections import Counter
|
||||
from cqi import CQiClient
|
||||
from cqi.models.corpora import Corpus as CQiCorpus
|
||||
from cqi.models.subcorpora import Subcorpus as CQiSubcorpus
|
||||
from cqi.models.attributes import (
|
||||
PositionalAttribute as CQiPositionalAttribute,
|
||||
StructuralAttribute as CQiStructuralAttribute
|
||||
)
|
||||
from cqi.status import StatusOk as CQiStatusOk
|
||||
from flask import session
|
||||
import gzip
|
||||
import json
|
||||
import math
|
||||
from app import db
|
||||
from app.models import Corpus
|
||||
from .utils import lookups_by_cpos, partial_export_subcorpus, export_subcorpus
|
||||
|
||||
|
||||
CQI_EXTENSION_FUNCTION_NAMES: list[str] = [
|
||||
'ext_corpus_update_db',
|
||||
'ext_corpus_static_data',
|
||||
'ext_corpus_paginate_corpus',
|
||||
'ext_cqp_paginate_subcorpus',
|
||||
'ext_cqp_partial_export_subcorpus',
|
||||
'ext_cqp_export_subcorpus',
|
||||
]
|
||||
|
||||
|
||||
def ext_corpus_update_db(corpus: str) -> CQiStatusOk:
|
||||
cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
|
||||
db_corpus_id: int = session['cqi_over_sio']['db_corpus_id']
|
||||
db_corpus: Corpus = Corpus.query.get(db_corpus_id)
|
||||
cqi_corpus: CQiCorpus = cqi_client.corpora.get(corpus)
|
||||
db_corpus.num_tokens = cqi_corpus.size
|
||||
db.session.commit()
|
||||
return CQiStatusOk()
|
||||
|
||||
|
||||
def ext_corpus_static_data(corpus: str) -> dict:
|
||||
db_corpus_id: int = session['cqi_over_sio']['db_corpus_id']
|
||||
db_corpus: Corpus = Corpus.query.get(db_corpus_id)
|
||||
|
||||
static_data_file_path = db_corpus.path / 'cwb' / 'static.json.gz'
|
||||
if static_data_file_path.exists():
|
||||
with static_data_file_path.open('rb') as f:
|
||||
return f.read()
|
||||
|
||||
cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
|
||||
cqi_corpus: CQiCorpus = cqi_client.corpora.get(corpus)
|
||||
cqi_p_attrs: list[CQiPositionalAttribute] = cqi_corpus.positional_attributes.list()
|
||||
cqi_s_attrs: list[CQiStructuralAttribute] = cqi_corpus.structural_attributes.list()
|
||||
|
||||
static_data = {
|
||||
'corpus': {
|
||||
'bounds': [0, cqi_corpus.size - 1],
|
||||
'freqs': {}
|
||||
},
|
||||
'p_attrs': {},
|
||||
's_attrs': {},
|
||||
'values': {'p_attrs': {}, 's_attrs': {}}
|
||||
}
|
||||
|
||||
for p_attr in cqi_p_attrs:
|
||||
print(f'corpus.freqs.{p_attr.name}')
|
||||
static_data['corpus']['freqs'][p_attr.name] = []
|
||||
p_attr_id_list: list[int] = list(range(p_attr.lexicon_size))
|
||||
static_data['corpus']['freqs'][p_attr.name].extend(p_attr.freqs_by_ids(p_attr_id_list))
|
||||
del p_attr_id_list
|
||||
|
||||
print(f'p_attrs.{p_attr.name}')
|
||||
static_data['p_attrs'][p_attr.name] = []
|
||||
cpos_list: list[int] = list(range(cqi_corpus.size))
|
||||
static_data['p_attrs'][p_attr.name].extend(p_attr.ids_by_cpos(cpos_list))
|
||||
del cpos_list
|
||||
|
||||
print(f'values.p_attrs.{p_attr.name}')
|
||||
static_data['values']['p_attrs'][p_attr.name] = []
|
||||
p_attr_id_list: list[int] = list(range(p_attr.lexicon_size))
|
||||
static_data['values']['p_attrs'][p_attr.name].extend(p_attr.values_by_ids(p_attr_id_list))
|
||||
del p_attr_id_list
|
||||
|
||||
for s_attr in cqi_s_attrs:
|
||||
if s_attr.has_values:
|
||||
continue
|
||||
|
||||
static_data['s_attrs'][s_attr.name] = {'lexicon': [], 'values': None}
|
||||
|
||||
if s_attr.name in ['s', 'ent']:
|
||||
##############################################################
|
||||
# A faster way to get cpos boundaries for smaller s_attrs #
|
||||
# Note: Needs more testing, don't use it in production #
|
||||
##############################################################
|
||||
cqi_corpus.query('Last', f'<{s_attr.name}> []* </{s_attr.name}>;')
|
||||
cqi_subcorpus: CQiSubcorpus = cqi_corpus.subcorpora.get('Last')
|
||||
first_match: int = 0
|
||||
last_match: int = cqi_subcorpus.size - 1
|
||||
match_boundaries = zip(
|
||||
range(first_match, last_match + 1),
|
||||
cqi_subcorpus.dump(
|
||||
cqi_subcorpus.fields['match'],
|
||||
first_match,
|
||||
last_match
|
||||
),
|
||||
cqi_subcorpus.dump(
|
||||
cqi_subcorpus.fields['matchend'],
|
||||
first_match,
|
||||
last_match
|
||||
)
|
||||
)
|
||||
cqi_subcorpus.drop()
|
||||
del cqi_subcorpus, first_match, last_match
|
||||
for id, lbound, rbound in match_boundaries:
|
||||
static_data['s_attrs'][s_attr.name]['lexicon'].append({})
|
||||
print(f's_attrs.{s_attr.name}.lexicon.{id}.bounds')
|
||||
static_data['s_attrs'][s_attr.name]['lexicon'][id]['bounds'] = [lbound, rbound]
|
||||
del match_boundaries
|
||||
|
||||
if s_attr.name != 'text':
|
||||
continue
|
||||
|
||||
for id in range(0, s_attr.size):
|
||||
static_data['s_attrs'][s_attr.name]['lexicon'].append({})
|
||||
# This is a very slow operation, thats why we only use it for
|
||||
# the text attribute
|
||||
lbound, rbound = s_attr.cpos_by_id(id)
|
||||
print(f's_attrs.{s_attr.name}.lexicon.{id}.bounds')
|
||||
static_data['s_attrs'][s_attr.name]['lexicon'][id]['bounds'] = [lbound, rbound]
|
||||
static_data['s_attrs'][s_attr.name]['lexicon'][id]['freqs'] = {}
|
||||
cpos_list: list[int] = list(range(lbound, rbound + 1))
|
||||
for p_attr in cqi_p_attrs:
|
||||
p_attr_ids: list[int] = []
|
||||
p_attr_ids.extend(p_attr.ids_by_cpos(cpos_list))
|
||||
print(f's_attrs.{s_attr.name}.lexicon.{id}.freqs.{p_attr.name}')
|
||||
static_data['s_attrs'][s_attr.name]['lexicon'][id]['freqs'][p_attr.name] = dict(Counter(p_attr_ids))
|
||||
del p_attr_ids
|
||||
del cpos_list
|
||||
|
||||
sub_s_attrs: list[CQiStructuralAttribute] = cqi_corpus.structural_attributes.list(filters={'part_of': s_attr})
|
||||
print(f's_attrs.{s_attr.name}.values')
|
||||
static_data['s_attrs'][s_attr.name]['values'] = [
|
||||
sub_s_attr.name[(len(s_attr.name) + 1):]
|
||||
for sub_s_attr in sub_s_attrs
|
||||
]
|
||||
s_attr_id_list: list[int] = list(range(s_attr.size))
|
||||
sub_s_attr_values: list[str] = []
|
||||
for sub_s_attr in sub_s_attrs:
|
||||
tmp = []
|
||||
tmp.extend(sub_s_attr.values_by_ids(s_attr_id_list))
|
||||
sub_s_attr_values.append(tmp)
|
||||
del tmp
|
||||
del s_attr_id_list
|
||||
print(f'values.s_attrs.{s_attr.name}')
|
||||
static_data['values']['s_attrs'][s_attr.name] = [
|
||||
{
|
||||
s_attr_value_name: sub_s_attr_values[s_attr_value_name_idx][s_attr_id]
|
||||
for s_attr_value_name_idx, s_attr_value_name in enumerate(
|
||||
static_data['s_attrs'][s_attr.name]['values']
|
||||
)
|
||||
} for s_attr_id in range(0, s_attr.size)
|
||||
]
|
||||
del sub_s_attr_values
|
||||
print('Saving static data to file')
|
||||
with gzip.open(static_data_file_path, 'wt') as f:
|
||||
json.dump(static_data, f)
|
||||
del static_data
|
||||
print('Sending static data to client')
|
||||
with open(static_data_file_path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def ext_corpus_paginate_corpus(
|
||||
corpus: str,
|
||||
page: int = 1,
|
||||
per_page: int = 20
|
||||
) -> dict:
|
||||
cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
|
||||
cqi_corpus = cqi_client.corpora.get(corpus)
|
||||
# Sanity checks
|
||||
if (
|
||||
per_page < 1
|
||||
or page < 1
|
||||
or (
|
||||
cqi_corpus.size > 0
|
||||
and page > math.ceil(cqi_corpus.size / per_page)
|
||||
)
|
||||
):
|
||||
return {'code': 416, 'msg': 'Range Not Satisfiable'}
|
||||
first_cpos = (page - 1) * per_page
|
||||
last_cpos = min(cqi_corpus.size, first_cpos + per_page)
|
||||
cpos_list = [*range(first_cpos, last_cpos)]
|
||||
lookups = lookups_by_cpos(cqi_corpus, cpos_list)
|
||||
payload = {}
|
||||
# the items for the current page
|
||||
payload['items'] = [cpos_list]
|
||||
# the lookups for the items
|
||||
payload['lookups'] = lookups
|
||||
# the total number of items matching the query
|
||||
payload['total'] = cqi_corpus.size
|
||||
# the number of items to be displayed on a page.
|
||||
payload['per_page'] = per_page
|
||||
# The total number of pages
|
||||
payload['pages'] = math.ceil(payload['total'] / payload['per_page'])
|
||||
# the current page number (1 indexed)
|
||||
payload['page'] = page if payload['pages'] > 0 else None
|
||||
# True if a previous page exists
|
||||
payload['has_prev'] = payload['page'] > 1 if payload['page'] else False
|
||||
# True if a next page exists.
|
||||
payload['has_next'] = payload['page'] < payload['pages'] if payload['page'] else False # noqa
|
||||
# Number of the previous page.
|
||||
payload['prev_num'] = payload['page'] - 1 if payload['has_prev'] else None
|
||||
# Number of the next page
|
||||
payload['next_num'] = payload['page'] + 1 if payload['has_next'] else None
|
||||
return payload
|
||||
|
||||
|
||||
def ext_cqp_paginate_subcorpus(
|
||||
subcorpus: str,
|
||||
context: int = 50,
|
||||
page: int = 1,
|
||||
per_page: int = 20
|
||||
) -> dict:
|
||||
corpus_name, subcorpus_name = subcorpus.split(':', 1)
|
||||
cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
|
||||
cqi_corpus = cqi_client.corpora.get(corpus_name)
|
||||
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
|
||||
# Sanity checks
|
||||
if (
|
||||
per_page < 1
|
||||
or page < 1
|
||||
or (
|
||||
cqi_subcorpus.size > 0
|
||||
and page > math.ceil(cqi_subcorpus.size / per_page)
|
||||
)
|
||||
):
|
||||
return {'code': 416, 'msg': 'Range Not Satisfiable'}
|
||||
offset = (page - 1) * per_page
|
||||
cutoff = per_page
|
||||
cqi_results_export = export_subcorpus(
|
||||
cqi_subcorpus, context=context, cutoff=cutoff, offset=offset)
|
||||
payload = {}
|
||||
# the items for the current page
|
||||
payload['items'] = cqi_results_export.pop('matches')
|
||||
# the lookups for the items
|
||||
payload['lookups'] = cqi_results_export
|
||||
# the total number of items matching the query
|
||||
payload['total'] = cqi_subcorpus.size
|
||||
# the number of items to be displayed on a page.
|
||||
payload['per_page'] = per_page
|
||||
# The total number of pages
|
||||
payload['pages'] = math.ceil(payload['total'] / payload['per_page'])
|
||||
# the current page number (1 indexed)
|
||||
payload['page'] = page if payload['pages'] > 0 else None
|
||||
# True if a previous page exists
|
||||
payload['has_prev'] = payload['page'] > 1 if payload['page'] else False
|
||||
# True if a next page exists.
|
||||
payload['has_next'] = payload['page'] < payload['pages'] if payload['page'] else False # noqa
|
||||
# Number of the previous page.
|
||||
payload['prev_num'] = payload['page'] - 1 if payload['has_prev'] else None
|
||||
# Number of the next page
|
||||
payload['next_num'] = payload['page'] + 1 if payload['has_next'] else None
|
||||
return payload
|
||||
|
||||
|
||||
def ext_cqp_partial_export_subcorpus(
|
||||
subcorpus: str,
|
||||
match_id_list: list,
|
||||
context: int = 50
|
||||
) -> dict:
|
||||
corpus_name, subcorpus_name = subcorpus.split(':', 1)
|
||||
cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
|
||||
cqi_corpus = cqi_client.corpora.get(corpus_name)
|
||||
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
|
||||
cqi_subcorpus_partial_export = partial_export_subcorpus(cqi_subcorpus, match_id_list, context=context)
|
||||
return cqi_subcorpus_partial_export
|
||||
|
||||
|
||||
def ext_cqp_export_subcorpus(
|
||||
subcorpus: str,
|
||||
context: int = 50
|
||||
) -> dict:
|
||||
corpus_name, subcorpus_name = subcorpus.split(':', 1)
|
||||
cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
|
||||
cqi_corpus = cqi_client.corpora.get(corpus_name)
|
||||
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
|
||||
cqi_subcorpus_export = export_subcorpus(cqi_subcorpus, context=context)
|
||||
return cqi_subcorpus_export
|
130
app/blueprints/corpora/cqi_over_sio/utils.py
Normal file
130
app/blueprints/corpora/cqi_over_sio/utils.py
Normal file
@ -0,0 +1,130 @@
|
||||
from cqi.models.corpora import Corpus as CQiCorpus
|
||||
from cqi.models.subcorpora import Subcorpus as CQiSubcorpus
|
||||
|
||||
|
||||
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}
|
33
app/blueprints/corpora/decorators.py
Normal file
33
app/blueprints/corpora/decorators.py
Normal file
@ -0,0 +1,33 @@
|
||||
from flask import abort
|
||||
from flask_login import current_user
|
||||
from functools import wraps
|
||||
from app.models import Corpus, CorpusFollowerAssociation
|
||||
|
||||
|
||||
def corpus_follower_permission_required(*permissions):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
corpus_id = kwargs.get('corpus_id')
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
if not (corpus.user == current_user or current_user.is_administrator):
|
||||
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
|
||||
if cfa is None:
|
||||
abort(403)
|
||||
if not all([cfa.role.has_permission(p) for p in permissions]):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def corpus_owner_or_admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
corpus_id = kwargs.get('corpus_id')
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
if not (corpus.user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
45
app/blueprints/corpora/events.py
Normal file
45
app/blueprints/corpora/events.py
Normal file
@ -0,0 +1,45 @@
|
||||
from flask_login import current_user
|
||||
from flask_socketio import join_room
|
||||
from app import hashids, socketio
|
||||
from app.decorators import socketio_login_required
|
||||
from app.models import Corpus
|
||||
|
||||
|
||||
@socketio.on('GET /corpora/<corpus_id>')
|
||||
@socketio_login_required
|
||||
def get_corpus(corpus_hashid):
|
||||
corpus_id = hashids.decode(corpus_hashid)
|
||||
corpus = Corpus.query.get(corpus_id)
|
||||
if corpus is None:
|
||||
return {'options': {'status': 404, 'statusText': 'Not found'}}
|
||||
if not (
|
||||
corpus.is_public
|
||||
or corpus.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
|
||||
return {
|
||||
'body': corpus.to_json_serializable(),
|
||||
'options': {
|
||||
'status': 200,
|
||||
'statusText': 'OK',
|
||||
'headers': {'Content-Type: application/json'}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@socketio.on('SUBSCRIBE /corpora/<corpus_id>')
|
||||
@socketio_login_required
|
||||
def subscribe_corpus(corpus_hashid):
|
||||
corpus_id = hashids.decode(corpus_hashid)
|
||||
corpus = Corpus.query.get(corpus_id)
|
||||
if corpus is None:
|
||||
return {'options': {'status': 404, 'statusText': 'Not found'}}
|
||||
if not (
|
||||
corpus.is_public
|
||||
or corpus.user == current_user
|
||||
or current_user.is_administrator
|
||||
):
|
||||
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
|
||||
join_room(f'/corpora/{corpus.hashid}')
|
||||
return {'options': {'status': 200, 'statusText': 'OK'}}
|
2
app/blueprints/corpora/files/__init__.py
Normal file
2
app/blueprints/corpora/files/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .. import bp
|
||||
from . import json_routes, routes
|
54
app/blueprints/corpora/files/forms.py
Normal file
54
app/blueprints/corpora/files/forms.py
Normal file
@ -0,0 +1,54 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileRequired
|
||||
from wtforms import (
|
||||
StringField,
|
||||
SubmitField,
|
||||
ValidationError,
|
||||
IntegerField
|
||||
)
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
|
||||
class CorpusFileBaseForm(FlaskForm):
|
||||
author = StringField(
|
||||
'Author',
|
||||
validators=[InputRequired(), Length(max=255)]
|
||||
)
|
||||
publishing_year = IntegerField(
|
||||
'Publishing year',
|
||||
validators=[InputRequired()]
|
||||
)
|
||||
title = StringField(
|
||||
'Title',
|
||||
validators=[InputRequired(), Length(max=255)]
|
||||
)
|
||||
address = StringField('Adress', validators=[Length(max=255)])
|
||||
booktitle = StringField('Booktitle', validators=[Length(max=255)])
|
||||
chapter = StringField('Chapter', validators=[Length(max=255)])
|
||||
editor = StringField('Editor', validators=[Length(max=255)])
|
||||
institution = StringField('Institution', validators=[Length(max=255)])
|
||||
journal = StringField('Journal', validators=[Length(max=255)])
|
||||
pages = StringField('Pages', validators=[Length(max=255)])
|
||||
publisher = StringField('Publisher', validators=[Length(max=255)])
|
||||
school = StringField('School', validators=[Length(max=255)])
|
||||
submit = SubmitField()
|
||||
|
||||
|
||||
class CreateCorpusFileForm(CorpusFileBaseForm):
|
||||
vrt = FileField('File', validators=[FileRequired()])
|
||||
|
||||
def validate_vrt(self, field):
|
||||
if not field.data.filename.lower().endswith('.vrt'):
|
||||
raise ValidationError('VRT files only!')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'create-corpus-file-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class UpdateCorpusFileForm(CorpusFileBaseForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'update-corpus-file-form'
|
||||
super().__init__(*args, **kwargs)
|
30
app/blueprints/corpora/files/json_routes.py
Normal file
30
app/blueprints/corpora/files/json_routes.py
Normal file
@ -0,0 +1,30 @@
|
||||
from flask import current_app
|
||||
from threading import Thread
|
||||
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
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['DELETE'])
|
||||
@corpus_follower_permission_required('MANAGE_FILES')
|
||||
@content_negotiation(produces='application/json')
|
||||
def delete_corpus_file(corpus_id, corpus_file_id):
|
||||
def _delete_corpus_file(app, corpus_file_id):
|
||||
with app.app_context():
|
||||
corpus_file = CorpusFile.query.get(corpus_file_id)
|
||||
corpus_file.delete()
|
||||
db.session.commit()
|
||||
|
||||
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
|
||||
thread = Thread(
|
||||
target=_delete_corpus_file,
|
||||
args=(current_app._get_current_object(), corpus_file.id)
|
||||
)
|
||||
thread.start()
|
||||
response_data = {
|
||||
'message': f'Corpus File "{corpus_file.title}" marked for deletion',
|
||||
'category': 'corpus'
|
||||
}
|
||||
return response_data, 202
|
93
app/blueprints/corpora/files/routes.py
Normal file
93
app/blueprints/corpora/files/routes.py
Normal file
@ -0,0 +1,93 @@
|
||||
from flask import (
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
send_from_directory,
|
||||
url_for
|
||||
)
|
||||
from app import db
|
||||
from app.models import Corpus, CorpusFile, CorpusStatus
|
||||
from ..decorators import corpus_follower_permission_required
|
||||
from . import bp
|
||||
from .forms import CreateCorpusFileForm, UpdateCorpusFileForm
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/files')
|
||||
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'])
|
||||
@corpus_follower_permission_required('MANAGE_FILES')
|
||||
def create_corpus_file(corpus_id):
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
form = CreateCorpusFileForm()
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
response = {'errors': form.errors}
|
||||
return response, 400
|
||||
try:
|
||||
corpus_file = CorpusFile.create(
|
||||
form.vrt.data,
|
||||
address=form.address.data,
|
||||
author=form.author.data,
|
||||
booktitle=form.booktitle.data,
|
||||
chapter=form.chapter.data,
|
||||
editor=form.editor.data,
|
||||
institution=form.institution.data,
|
||||
journal=form.journal.data,
|
||||
pages=form.pages.data,
|
||||
publisher=form.publisher.data,
|
||||
publishing_year=form.publishing_year.data,
|
||||
school=form.school.data,
|
||||
title=form.title.data,
|
||||
mimetype='application/vrt+xml',
|
||||
corpus=corpus
|
||||
)
|
||||
except (AttributeError, OSError):
|
||||
abort(500)
|
||||
corpus.status = CorpusStatus.UNPREPARED
|
||||
db.session.commit()
|
||||
flash(f'Corpus File "{corpus_file.filename}" added', category='corpus')
|
||||
return '', 201, {'Location': corpus.url}
|
||||
return render_template(
|
||||
'corpora/files/create.html.j2',
|
||||
title='Add corpus file',
|
||||
form=form,
|
||||
corpus=corpus
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST'])
|
||||
@corpus_follower_permission_required('MANAGE_FILES')
|
||||
def corpus_file(corpus_id, corpus_file_id):
|
||||
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
|
||||
form = UpdateCorpusFileForm(data=corpus_file.to_json_serializeable())
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(corpus_file)
|
||||
if db.session.is_modified(corpus_file):
|
||||
corpus_file.corpus.status = CorpusStatus.UNPREPARED
|
||||
db.session.commit()
|
||||
flash(f'Corpus file "{corpus_file.filename}" updated', category='corpus')
|
||||
return redirect(corpus_file.corpus.url)
|
||||
return render_template(
|
||||
'corpora/files/corpus_file.html.j2',
|
||||
title='Edit corpus file',
|
||||
form=form,
|
||||
corpus=corpus_file.corpus,
|
||||
corpus_file=corpus_file
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
|
||||
@corpus_follower_permission_required('VIEW')
|
||||
def download_corpus_file(corpus_id, corpus_file_id):
|
||||
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
|
||||
return send_from_directory(
|
||||
corpus_file.path.parent,
|
||||
corpus_file.path.name,
|
||||
as_attachment=True,
|
||||
download_name=corpus_file.filename,
|
||||
mimetype=corpus_file.mimetype
|
||||
)
|
2
app/blueprints/corpora/followers/__init__.py
Normal file
2
app/blueprints/corpora/followers/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .. import bp
|
||||
from . import json_routes
|
76
app/blueprints/corpora/followers/json_routes.py
Normal file
76
app/blueprints/corpora/followers/json_routes.py
Normal file
@ -0,0 +1,76 @@
|
||||
from flask import abort, flash, jsonify, make_response, request
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
from app.decorators import content_negotiation
|
||||
from app.models import (
|
||||
Corpus,
|
||||
CorpusFollowerAssociation,
|
||||
CorpusFollowerRole,
|
||||
User
|
||||
)
|
||||
from ..decorators import corpus_follower_permission_required
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/followers', methods=['POST'])
|
||||
@corpus_follower_permission_required('MANAGE_FOLLOWERS')
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def create_corpus_followers(corpus_id):
|
||||
usernames = request.json
|
||||
if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)):
|
||||
abort(400)
|
||||
corpus = Corpus.query.get_or_404(corpus_id)
|
||||
for username in usernames:
|
||||
user = User.query.filter_by(username=username, is_public=True).first_or_404()
|
||||
user.follow_corpus(corpus)
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': f'Users are now following "{corpus.title}"',
|
||||
'category': 'corpus'
|
||||
}
|
||||
return response_data, 200
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/role', methods=['PUT'])
|
||||
@corpus_follower_permission_required('MANAGE_FOLLOWERS')
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_corpus_follower_role(corpus_id, follower_id):
|
||||
role_name = request.json
|
||||
if not isinstance(role_name, str):
|
||||
abort(400)
|
||||
cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
|
||||
if cfr is None:
|
||||
abort(400)
|
||||
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
|
||||
cfa.role = cfr
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': f'User "{cfa.follower.username}" is now {cfa.role.name}',
|
||||
'category': 'corpus'
|
||||
}
|
||||
return response_data, 200
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>', methods=['DELETE'])
|
||||
def delete_corpus_follower(corpus_id, follower_id):
|
||||
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
|
||||
if not (
|
||||
current_user.id == follower_id
|
||||
or current_user == cfa.corpus.user
|
||||
or CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first().role.has_permission('MANAGE_FOLLOWERS')
|
||||
or current_user.is_administrator):
|
||||
abort(403)
|
||||
if current_user.id == follower_id:
|
||||
flash(f'You are no longer following "{cfa.corpus.title}"', 'corpus')
|
||||
response = make_response()
|
||||
response.status_code = 204
|
||||
else:
|
||||
response_data = {
|
||||
'message': f'"{cfa.follower.username}" is not following "{cfa.corpus.title}" anymore',
|
||||
'category': 'corpus'
|
||||
}
|
||||
response = jsonify(response_data)
|
||||
response.status_code = 200
|
||||
cfa.follower.unfollow_corpus(cfa.corpus)
|
||||
db.session.commit()
|
||||
return response
|
33
app/blueprints/corpora/forms.py
Normal file
33
app/blueprints/corpora/forms.py
Normal file
@ -0,0 +1,33 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, TextAreaField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
|
||||
class CorpusBaseForm(FlaskForm):
|
||||
description = TextAreaField(
|
||||
'Description',
|
||||
validators=[InputRequired(), Length(max=255)]
|
||||
)
|
||||
title = StringField('Title', validators=[InputRequired(), Length(max=32)])
|
||||
submit = SubmitField()
|
||||
|
||||
|
||||
class CreateCorpusForm(CorpusBaseForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'create-corpus-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class UpdateCorpusForm(CorpusBaseForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'update-corpus-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ImportCorpusForm(FlaskForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'import-corpus-form'
|
||||
super().__init__(*args, **kwargs)
|
125
app/blueprints/corpora/json_routes.py
Normal file
125
app/blueprints/corpora/json_routes.py
Normal file
@ -0,0 +1,125 @@
|
||||
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
|
109
app/blueprints/corpora/routes.py
Normal file
109
app/blueprints/corpora/routes.py
Normal file
@ -0,0 +1,109 @@
|
||||
from flask import abort, flash, redirect, render_template, url_for
|
||||
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
|
||||
|
||||
|
||||
@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):
|
||||
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')
|
||||
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'])
|
||||
def import_corpus():
|
||||
abort(503)
|
||||
|
||||
|
||||
@bp.route('/<hashid:corpus_id>/export')
|
||||
@corpus_follower_permission_required('VIEW')
|
||||
def export_corpus(corpus_id):
|
||||
abort(503)
|
5
app/blueprints/errors/__init__.py
Normal file
5
app/blueprints/errors/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('errors', __name__)
|
||||
from . import handlers
|
14
app/blueprints/errors/handlers.py
Normal file
14
app/blueprints/errors/handlers.py
Normal file
@ -0,0 +1,14 @@
|
||||
from flask import jsonify, render_template, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.app_errorhandler(HTTPException)
|
||||
def handle_http_exception(error):
|
||||
''' 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
|
18
app/blueprints/jobs/__init__.py
Normal file
18
app/blueprints/jobs/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
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
|
138
app/blueprints/jobs/events.py
Normal file
138
app/blueprints/jobs/events.py
Normal file
@ -0,0 +1,138 @@
|
||||
from flask import current_app
|
||||
from flask_login import current_user
|
||||
from flask_socketio import Namespace
|
||||
from app import db, hashids, socketio
|
||||
from app.extensions.flask_socketio import admin_required, login_required
|
||||
from app.models import Job, JobStatus
|
||||
|
||||
|
||||
class JobsNamespace(Namespace):
|
||||
@login_required
|
||||
def on_delete(self, job_hashid: str):
|
||||
# Decode the job hashid
|
||||
job_id = hashids.decode(job_hashid)
|
||||
|
||||
# Validate job_id
|
||||
if not isinstance(job_id, int):
|
||||
return {
|
||||
'code': 400,
|
||||
'body': 'job_id is invalid'
|
||||
}
|
||||
|
||||
# Load job from database
|
||||
job = Job.query.get(job_id)
|
||||
if job is None:
|
||||
return {
|
||||
'code': 404,
|
||||
'body': 'Job not found'
|
||||
}
|
||||
|
||||
# Check if the current user is allowed to delete the job
|
||||
if not (job.user == current_user or current_user.is_administrator):
|
||||
return {
|
||||
'code': 403,
|
||||
'body': 'Forbidden'
|
||||
}
|
||||
|
||||
# TODO: This should be a method in the Job model
|
||||
def _delete_job(app, job_id):
|
||||
with app.app_context():
|
||||
job = Job.query.get(job_id)
|
||||
job.delete()
|
||||
db.session.commit()
|
||||
|
||||
# Delete the job in a background task
|
||||
socketio.start_background_task(
|
||||
target=_delete_job,
|
||||
app=current_app._get_current_object(),
|
||||
job_id=job_id
|
||||
)
|
||||
|
||||
return {
|
||||
'code': 202,
|
||||
'body': f'Job "{job.title}" marked for deletion'
|
||||
}
|
||||
|
||||
|
||||
@admin_required
|
||||
def on_get_log(self, job_hashid: str):
|
||||
# Decode the job hashid
|
||||
job_id = hashids.decode(job_hashid)
|
||||
|
||||
# Validate job_id
|
||||
if not isinstance(job_id, int):
|
||||
return {
|
||||
'code': 400,
|
||||
'body': 'job_id is invalid'
|
||||
}
|
||||
|
||||
# Load job from database
|
||||
job = Job.query.get(job_id)
|
||||
if job is None:
|
||||
return {
|
||||
'code': 404,
|
||||
'body': 'Job not found'
|
||||
}
|
||||
|
||||
# Check if the job is already processed
|
||||
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
|
||||
return {
|
||||
'code': 409,
|
||||
'body': 'Job is not done processing'
|
||||
}
|
||||
|
||||
# Read the log file
|
||||
with open(job.path / 'pipeline_data' / 'logs' / 'pyflow_log.txt') as log_file:
|
||||
job_log = log_file.read()
|
||||
|
||||
return {
|
||||
'code': 200,
|
||||
'body': job_log
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def on_restart(self, job_hashid: str):
|
||||
# Decode the job hashid
|
||||
job_id = hashids.decode(job_hashid)
|
||||
|
||||
# Validate job_id
|
||||
if not isinstance(job_id, int):
|
||||
return {
|
||||
'code': 400,
|
||||
'body': 'job_id is invalid'
|
||||
}
|
||||
|
||||
# Load job from database
|
||||
job = Job.query.get(job_id)
|
||||
if job is None:
|
||||
return {
|
||||
'code': 404,
|
||||
'body': 'Job not found'
|
||||
}
|
||||
|
||||
# Check if the current user is allowed to restart the job
|
||||
if not (job.user == current_user or current_user.is_administrator):
|
||||
return {
|
||||
'code': 403,
|
||||
'body': 'Forbidden'
|
||||
}
|
||||
|
||||
# TODO: This should be a method in the Job model
|
||||
def _restart_job(app, job_id):
|
||||
with app.app_context():
|
||||
job = Job.query.get(job_id)
|
||||
job.restart()
|
||||
db.session.commit()
|
||||
|
||||
# Restart the job in a background task
|
||||
socketio.start_background_task(
|
||||
target=_restart_job,
|
||||
app=current_app._get_current_object(),
|
||||
job_id=job_id
|
||||
)
|
||||
|
||||
return {
|
||||
'code': 202,
|
||||
'body': f'Job "{job.title}" restarted'
|
||||
}
|
72
app/blueprints/jobs/json_routes.py
Normal file
72
app/blueprints/jobs/json_routes.py
Normal file
@ -0,0 +1,72 @@
|
||||
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
|
55
app/blueprints/jobs/routes.py
Normal file
55
app/blueprints/jobs/routes.py
Normal file
@ -0,0 +1,55 @@
|
||||
from flask import (
|
||||
abort,
|
||||
redirect,
|
||||
render_template,
|
||||
send_from_directory,
|
||||
url_for
|
||||
)
|
||||
from flask_login import current_user
|
||||
from app.models import Job, JobInput, JobResult
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
def jobs():
|
||||
return redirect(url_for('main.dashboard', _anchor='jobs'))
|
||||
|
||||
|
||||
@bp.route('/<hashid:job_id>')
|
||||
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,
|
||||
download_name=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,
|
||||
download_name=job_result.filename,
|
||||
mimetype=job_result.mimetype
|
||||
)
|
5
app/blueprints/main/__init__.py
Normal file
5
app/blueprints/main/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('main', __name__, cli_group=None)
|
||||
from . import cli, routes
|
51
app/blueprints/main/cli.py
Normal file
51
app/blueprints/main/cli.py
Normal file
@ -0,0 +1,51 @@
|
||||
from flask import current_app
|
||||
from flask_migrate import upgrade
|
||||
from pathlib import Path
|
||||
from app import db
|
||||
from app.models import (
|
||||
Corpus,
|
||||
CorpusFollowerRole,
|
||||
Role,
|
||||
SpaCyNLPPipelineModel,
|
||||
TesseractOCRPipelineModel,
|
||||
User
|
||||
)
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.cli.command('deploy')
|
||||
def deploy():
|
||||
''' Run deployment tasks. '''
|
||||
|
||||
print('Make default directories')
|
||||
base_dir = current_app.config['NOPAQUE_DATA_DIR']
|
||||
default_dirs: list[Path] = [
|
||||
base_dir / 'tmp',
|
||||
base_dir / 'users'
|
||||
]
|
||||
for default_dir in default_dirs:
|
||||
if not default_dir.exists():
|
||||
default_dir.mkdir()
|
||||
if not default_dir.is_dir():
|
||||
raise NotADirectoryError(f'{default_dir} is not a directory')
|
||||
|
||||
print('Migrate database to latest revision')
|
||||
upgrade()
|
||||
|
||||
print('Insert/Update default Roles')
|
||||
Role.insert_defaults()
|
||||
print('Insert/Update default Users')
|
||||
User.insert_defaults()
|
||||
print('Insert/Update default CorpusFollowerRoles')
|
||||
CorpusFollowerRole.insert_defaults()
|
||||
print('Insert/Update default SpaCyNLPPipelineModels')
|
||||
SpaCyNLPPipelineModel.insert_defaults()
|
||||
print('Insert/Update default TesseractOCRPipelineModels')
|
||||
TesseractOCRPipelineModel.insert_defaults()
|
||||
|
||||
print('Stop running analysis sessions')
|
||||
for corpus in Corpus.query.filter(Corpus.num_analysis_sessions > 0).all():
|
||||
corpus.num_analysis_sessions = 0
|
||||
db.session.commit()
|
||||
|
||||
# TODO: Implement checks for if the nopaque network exists
|
87
app/blueprints/main/routes.py
Normal file
87
app/blueprints/main/routes.py
Normal file
@ -0,0 +1,87 @@
|
||||
from flask import flash, redirect, render_template, url_for
|
||||
from flask_login import current_user, login_required, login_user
|
||||
from app.blueprints.auth.forms import LoginForm
|
||||
from app.models import Corpus, User
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/', methods=['GET', 'POST'])
|
||||
def index():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
|
||||
if user and user.verify_password(form.password.data):
|
||||
login_user(user, form.remember_me.data)
|
||||
flash('You have been logged in')
|
||||
return redirect(url_for('.dashboard'))
|
||||
flash('Invalid email/username or password', category='error')
|
||||
redirect(url_for('.index'))
|
||||
return render_template(
|
||||
'main/index.html.j2',
|
||||
title='nopaque',
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/faq')
|
||||
def faq():
|
||||
return render_template(
|
||||
'main/faq.html.j2',
|
||||
title='Frequently Asked Questions'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
return render_template(
|
||||
'main/dashboard.html.j2',
|
||||
title='Dashboard'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/manual')
|
||||
def manual():
|
||||
return render_template(
|
||||
'main/manual.html.j2',
|
||||
title='Manual'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/news')
|
||||
def news():
|
||||
return render_template(
|
||||
'main/news.html.j2',
|
||||
title='News'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/privacy_policy')
|
||||
def privacy_policy():
|
||||
return render_template(
|
||||
'main/privacy_policy.html.j2',
|
||||
title='Privacy statement (GDPR)'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/terms_of_use')
|
||||
def terms_of_use():
|
||||
return render_template(
|
||||
'main/terms_of_use.html.j2',
|
||||
title='Terms of Use'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/social-area')
|
||||
@login_required
|
||||
def social_area():
|
||||
print('test')
|
||||
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',
|
||||
corpora=corpora,
|
||||
users=users
|
||||
)
|
24
app/blueprints/services/__init__.py
Normal file
24
app/blueprints/services/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
|
||||
services_file = Path(__file__).parent / 'services.yml'
|
||||
with services_file.open('r') as f:
|
||||
SERVICES = yaml.safe_load(f)
|
||||
|
||||
bp = Blueprint('services', __name__)
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@login_required
|
||||
def before_request():
|
||||
'''
|
||||
Ensures that the routes in this package can only be visited by users that
|
||||
are logged in.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
from . import routes # noqa
|
192
app/blueprints/services/forms.py
Normal file
192
app/blueprints/services/forms.py
Normal file
@ -0,0 +1,192 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_login import current_user
|
||||
from flask_wtf.file import FileField, FileRequired
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
DecimalRangeField,
|
||||
MultipleFileField,
|
||||
SelectField,
|
||||
StringField,
|
||||
SubmitField,
|
||||
ValidationError
|
||||
)
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from app.models import SpaCyNLPPipelineModel, TesseractOCRPipelineModel
|
||||
from . import SERVICES
|
||||
|
||||
|
||||
class CreateJobBaseForm(FlaskForm):
|
||||
description = StringField(
|
||||
'Description',
|
||||
validators=[InputRequired(), Length(max=255)]
|
||||
)
|
||||
title = StringField(
|
||||
'Title',
|
||||
validators=[InputRequired(), Length(max=32)]
|
||||
)
|
||||
version = SelectField('Version', validators=[InputRequired()])
|
||||
submit = SubmitField()
|
||||
|
||||
|
||||
class CreateFileSetupPipelineJobForm(CreateJobBaseForm):
|
||||
images = MultipleFileField('File(s)', validators=[InputRequired()])
|
||||
|
||||
def validate_images(form, field):
|
||||
valid_mimetypes = ['image/jpeg', 'image/png', 'image/tiff']
|
||||
for image in field.data:
|
||||
if image.mimetype not in valid_mimetypes:
|
||||
raise ValidationError('JPEG, PNG and TIFF files only!')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'create-file-setup-pipeline-job-form'
|
||||
service_manifest = SERVICES['file-setup-pipeline']
|
||||
version = kwargs.pop('version', service_manifest['latest_version'])
|
||||
super().__init__(*args, **kwargs)
|
||||
self.version.choices = [(x, x) for x in service_manifest['versions']]
|
||||
self.version.data = version
|
||||
self.version.default = service_manifest['latest_version']
|
||||
|
||||
|
||||
class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
|
||||
binarization = BooleanField('Binarization')
|
||||
pdf = FileField('File', validators=[FileRequired()])
|
||||
model = SelectField('Model', validators=[InputRequired()])
|
||||
ocropus_nlbin_threshold = DecimalRangeField(
|
||||
render_kw={'min': 0, 'max': 1, 'step': 0.1, 'start': [0.5], 'disabled': True}
|
||||
)
|
||||
|
||||
def validate_binarization(self, field):
|
||||
service_info = SERVICES['tesseract-ocr-pipeline']['versions'][self.version.data]
|
||||
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!')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'create-tesseract-ocr-pipeline-job-form'
|
||||
service_manifest = SERVICES['tesseract-ocr-pipeline']
|
||||
version = kwargs.pop('version', service_manifest['latest_version'])
|
||||
super().__init__(*args, **kwargs)
|
||||
service_info = service_manifest['versions'][version]
|
||||
if self.binarization.render_kw is None:
|
||||
self.binarization.render_kw = {}
|
||||
self.binarization.render_kw['disabled'] = True
|
||||
if self.ocropus_nlbin_threshold.render_kw is None:
|
||||
self.ocropus_nlbin_threshold.render_kw = {}
|
||||
self.ocropus_nlbin_threshold.render_kw['disabled'] = True
|
||||
if 'methods' in service_info:
|
||||
if 'binarization' in service_info['methods']:
|
||||
del self.binarization.render_kw['disabled']
|
||||
if 'ocropus_nlbin_threshold' in service_info['methods']:
|
||||
del self.ocropus_nlbin_threshold.render_kw['disabled']
|
||||
user_models = [
|
||||
x for x in current_user.tesseract_ocr_pipeline_models.order_by(TesseractOCRPipelineModel.title).all()
|
||||
]
|
||||
models = [
|
||||
x for x in TesseractOCRPipelineModel.query.order_by(TesseractOCRPipelineModel.title).all()
|
||||
if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user)
|
||||
]
|
||||
self.model.choices = {
|
||||
'': [('', 'Choose your option')],
|
||||
'Your models': [(x.hashid, f'{x.title} [{x.version}]') for x in user_models] if user_models else [(0, 'Nothing here yet...')],
|
||||
'Public models': [(x.hashid, f'{x.title} [{x.version}]') for x in models]
|
||||
}
|
||||
self.model.default = ''
|
||||
self.version.choices = [(x, x) for x in service_manifest['versions']]
|
||||
self.version.data = version
|
||||
self.version.default = service_manifest['latest_version']
|
||||
|
||||
|
||||
class CreateTranskribusHTRPipelineJobForm(CreateJobBaseForm):
|
||||
binarization = BooleanField('Binarization')
|
||||
pdf = FileField('File', validators=[FileRequired()])
|
||||
model = SelectField('Model', validators=[InputRequired()])
|
||||
|
||||
def validate_binarization(self, field):
|
||||
service_info = SERVICES['transkribus-htr-pipeline']['versions'][self.version.data]
|
||||
if field.data:
|
||||
if(
|
||||
'methods' not in service_info
|
||||
or 'binarization' not 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!')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'create-transkribus-htr-pipeline-job-form'
|
||||
transkribus_htr_pipeline_models = kwargs.pop('transkribus_htr_pipeline_models', [])
|
||||
service_manifest = SERVICES['transkribus-htr-pipeline']
|
||||
version = kwargs.pop('version', service_manifest['latest_version'])
|
||||
super().__init__(*args, **kwargs)
|
||||
service_info = service_manifest['versions'][version]
|
||||
if self.binarization.render_kw is None:
|
||||
self.binarization.render_kw = {}
|
||||
self.binarization.render_kw['disabled'] = True
|
||||
if 'methods' in service_info:
|
||||
if 'binarization' in service_info['methods']:
|
||||
del self.binarization.render_kw['disabled']
|
||||
self.model.choices = [('', 'Choose your option')]
|
||||
self.model.choices += [(x['modelId'], x['name']) for x in transkribus_htr_pipeline_models]
|
||||
self.model.default = ''
|
||||
self.version.choices = [(x, x) for x in service_manifest['versions']]
|
||||
self.version.data = version
|
||||
self.version.default = service_manifest['latest_version']
|
||||
|
||||
|
||||
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:
|
||||
if(
|
||||
'methods' not in service_info
|
||||
or 'encoding_detection' not in service_info['methods']
|
||||
):
|
||||
raise ValidationError('Encoding detection is not available')
|
||||
|
||||
def validate_txt(form, field):
|
||||
if field.data.mimetype != 'text/plain':
|
||||
raise ValidationError('Plain text files only!')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'create-spacy-nlp-pipeline-job-form'
|
||||
service_manifest = SERVICES['spacy-nlp-pipeline']
|
||||
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
|
||||
if 'methods' in service_info:
|
||||
if 'encoding_detection' in service_info['methods']:
|
||||
del self.encoding_detection.render_kw['disabled']
|
||||
user_models = [
|
||||
x for x in current_user.spacy_nlp_pipeline_models.order_by(SpaCyNLPPipelineModel.title).all()
|
||||
]
|
||||
models = [
|
||||
x for x in SpaCyNLPPipelineModel.query.filter(SpaCyNLPPipelineModel.user != current_user, SpaCyNLPPipelineModel.is_public == True).order_by(SpaCyNLPPipelineModel.title).all()
|
||||
if version in x.compatible_service_versions
|
||||
]
|
||||
self.model.choices = {
|
||||
'': [('', 'Choose your option')],
|
||||
'Your models': [(x.hashid, f'{x.title} [{x.version}]') for x in user_models] if user_models else [(0, 'Nothing here yet...')],
|
||||
'Public models': [(x.hashid, f'{x.title} [{x.version}]') for x in models]
|
||||
}
|
||||
self.model.default = ''
|
||||
self.version.choices = [(x, x) for x in service_manifest['versions']]
|
||||
self.version.data = version
|
||||
self.version.default = version
|
224
app/blueprints/services/routes.py
Normal file
224
app/blueprints/services/routes.py
Normal file
@ -0,0 +1,224 @@
|
||||
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,
|
||||
JobStatus,
|
||||
TesseractOCRPipelineModel,
|
||||
SpaCyNLPPipelineModel
|
||||
)
|
||||
from . import bp, SERVICES
|
||||
from .forms import (
|
||||
CreateFileSetupPipelineJobForm,
|
||||
CreateTesseractOCRPipelineJobForm,
|
||||
CreateTranskribusHTRPipelineJobForm,
|
||||
CreateSpacyNLPPipelineJobForm
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/services')
|
||||
def services():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@bp.route('/file-setup-pipeline', methods=['GET', 'POST'])
|
||||
def file_setup_pipeline():
|
||||
service = 'file-setup-pipeline'
|
||||
service_manifest = SERVICES[service]
|
||||
version = request.args.get('version', service_manifest['latest_version'])
|
||||
if version not in service_manifest['versions']:
|
||||
abort(404)
|
||||
form = CreateFileSetupPipelineJobForm(prefix='create-job-form', version=version)
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
response = {'errors': form.errors}
|
||||
return response, 400
|
||||
try:
|
||||
job = Job.create(
|
||||
title=form.title.data,
|
||||
description=form.description.data,
|
||||
service=service,
|
||||
service_args={},
|
||||
service_version=form.version.data,
|
||||
user=current_user
|
||||
)
|
||||
except OSError:
|
||||
abort(500)
|
||||
for input_file in form.images.data:
|
||||
try:
|
||||
JobInput.create(input_file, job=job)
|
||||
except (AttributeError, OSError):
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
|
||||
flash(message, 'job')
|
||||
return {}, 201, {'Location': job.url}
|
||||
return render_template(
|
||||
'services/file_setup_pipeline.html.j2',
|
||||
title=service_manifest['name'],
|
||||
form=form
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST'])
|
||||
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)
|
||||
form = CreateTesseractOCRPipelineJobForm(prefix='create-job-form', version=version)
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
response = {'errors': form.errors}
|
||||
return response, 400
|
||||
try:
|
||||
job = Job.create(
|
||||
title=form.title.data,
|
||||
description=form.description.data,
|
||||
service=service_name,
|
||||
service_args={
|
||||
'binarization': form.binarization.data,
|
||||
'model': hashids.decode(form.model.data),
|
||||
'ocropus_nlbin_threshold': float(form.ocropus_nlbin_threshold.data)
|
||||
},
|
||||
service_version=form.version.data,
|
||||
user=current_user
|
||||
)
|
||||
except OSError:
|
||||
abort(500)
|
||||
try:
|
||||
JobInput.create(form.pdf.data, job=job)
|
||||
except (AttributeError, OSError):
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
|
||||
flash(message, 'job')
|
||||
return {}, 201, {'Location': job.url}
|
||||
tesseract_ocr_pipeline_models = [
|
||||
x for x in TesseractOCRPipelineModel.query.all()
|
||||
if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user)
|
||||
]
|
||||
user_tesseract_ocr_pipeline_models_count = len(current_user.tesseract_ocr_pipeline_models.all())
|
||||
return render_template(
|
||||
'services/tesseract_ocr_pipeline.html.j2',
|
||||
title=service_manifest['name'],
|
||||
form=form,
|
||||
tesseract_ocr_pipeline_models=tesseract_ocr_pipeline_models,
|
||||
user_tesseract_ocr_pipeline_models_count=user_tesseract_ocr_pipeline_models_count
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/transkribus-htr-pipeline', methods=['GET', 'POST'])
|
||||
def transkribus_htr_pipeline():
|
||||
if not current_app.config.get('NOPAQUE_TRANSKRIBUS_ENABLED'):
|
||||
abort(404)
|
||||
service = 'transkribus-htr-pipeline'
|
||||
service_manifest = SERVICES[service]
|
||||
version = request.args.get('version', service_manifest['latest_version'])
|
||||
if version not in service_manifest['versions']:
|
||||
abort(404)
|
||||
r = requests.get(
|
||||
'https://transkribus.eu/TrpServer/rest/models/text',
|
||||
headers={'Accept': 'application/json'}
|
||||
)
|
||||
if r.status_code != 200:
|
||||
abort(500)
|
||||
transkribus_htr_pipeline_models = r.json()['trpModelMetadata']
|
||||
transkribus_htr_pipeline_models.append({'modelId': 48513, 'name': 'Caroline Minuscle', 'language': 'lat', 'isoLanguages': ['lat']})
|
||||
form = CreateTranskribusHTRPipelineJobForm(
|
||||
prefix='create-job-form',
|
||||
transkribus_htr_pipeline_models=transkribus_htr_pipeline_models,
|
||||
version=version
|
||||
)
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
response = {'errors': form.errors}
|
||||
return response, 400
|
||||
try:
|
||||
job = Job.create(
|
||||
title=form.title.data,
|
||||
description=form.description.data,
|
||||
service=service,
|
||||
service_args={
|
||||
'binarization': form.binarization.data,
|
||||
'model': form.model.data
|
||||
},
|
||||
service_version=form.version.data,
|
||||
user=current_user
|
||||
)
|
||||
except OSError:
|
||||
abort(500)
|
||||
try:
|
||||
JobInput.create(form.pdf.data, job=job)
|
||||
except (AttributeError, OSError):
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
|
||||
flash(message, 'job')
|
||||
return {}, 201, {'Location': job.url}
|
||||
return render_template(
|
||||
'services/transkribus_htr_pipeline.html.j2',
|
||||
title=service_manifest['name'],
|
||||
form=form,
|
||||
transkribus_htr_pipeline_models=transkribus_htr_pipeline_models
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/spacy-nlp-pipeline', methods=['GET', 'POST'])
|
||||
def spacy_nlp_pipeline():
|
||||
service = 'spacy-nlp-pipeline'
|
||||
service_manifest = SERVICES[service]
|
||||
version = request.args.get('version', SERVICES[service]['latest_version'])
|
||||
if version not in service_manifest['versions']:
|
||||
abort(404)
|
||||
form = CreateSpacyNLPPipelineJobForm(prefix='create-job-form', version=version)
|
||||
spacy_nlp_pipeline_models = SpaCyNLPPipelineModel.query.all()
|
||||
user_spacy_nlp_pipeline_models_count = len(current_user.spacy_nlp_pipeline_models.all())
|
||||
if form.is_submitted():
|
||||
if not form.validate():
|
||||
response = {'errors': form.errors}
|
||||
return response, 400
|
||||
try:
|
||||
job = Job.create(
|
||||
title=form.title.data,
|
||||
description=form.description.data,
|
||||
service=service,
|
||||
service_args={
|
||||
'encoding_detection': form.encoding_detection.data,
|
||||
'model': form.model.data
|
||||
},
|
||||
service_version=form.version.data,
|
||||
user=current_user
|
||||
)
|
||||
except OSError:
|
||||
abort(500)
|
||||
try:
|
||||
JobInput.create(form.txt.data, job=job)
|
||||
except (AttributeError, OSError):
|
||||
abort(500)
|
||||
job.status = JobStatus.SUBMITTED
|
||||
db.session.commit()
|
||||
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
|
||||
flash(message, 'job')
|
||||
return {}, 201, {'Location': job.url}
|
||||
return render_template(
|
||||
'services/spacy_nlp_pipeline.html.j2',
|
||||
title=service_manifest['name'],
|
||||
form=form,
|
||||
spacy_nlp_pipeline_models=spacy_nlp_pipeline_models,
|
||||
user_spacy_nlp_pipeline_models_count=user_spacy_nlp_pipeline_models_count
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/corpus-analysis')
|
||||
def corpus_analysis():
|
||||
return render_template(
|
||||
'services/corpus_analysis.html.j2',
|
||||
title='Corpus Analysis'
|
||||
)
|
66
app/blueprints/services/services.yml
Normal file
66
app/blueprints/services/services.yml
Normal file
@ -0,0 +1,66 @@
|
||||
# TODO: This could also be done via GitLab/GitHub APIs
|
||||
file-setup-pipeline:
|
||||
name: 'File Setup Pipeline'
|
||||
publisher: 'Bielefeld University - CRC 1288 - INF'
|
||||
latest_version: '0.1.0'
|
||||
versions:
|
||||
0.1.0:
|
||||
publishing_year: 2022
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/file-setup-pipeline/-/releases/v0.1.0'
|
||||
tesseract-ocr-pipeline:
|
||||
name: 'Tesseract OCR Pipeline'
|
||||
publisher: 'Bielefeld University - CRC 1288 - INF'
|
||||
latest_version: '0.1.2'
|
||||
versions:
|
||||
0.1.0:
|
||||
methods:
|
||||
- 'binarization'
|
||||
publishing_year: 2022
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.0'
|
||||
0.1.1:
|
||||
methods:
|
||||
- 'binarization'
|
||||
- 'ocropus_nlbin_threshold'
|
||||
publishing_year: 2022
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.1'
|
||||
0.1.2:
|
||||
methods:
|
||||
- 'binarization'
|
||||
- 'ocropus_nlbin_threshold'
|
||||
publishing_year: 2023
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.2'
|
||||
transkribus-htr-pipeline:
|
||||
name: 'Transkribus HTR Pipeline'
|
||||
publisher: 'Bielefeld University - CRC 1288 - INF'
|
||||
latest_version: '0.1.1'
|
||||
versions:
|
||||
0.1.0:
|
||||
methods:
|
||||
- 'binarization'
|
||||
publishing_year: 2022
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/transkribus-htr-pipeline/-/releases/v0.1.0'
|
||||
0.1.1:
|
||||
methods:
|
||||
- 'binarization'
|
||||
publishing_year: 2022
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/transkribus-htr-pipeline/-/releases/v0.1.1'
|
||||
spacy-nlp-pipeline:
|
||||
name: 'SpaCy NLP Pipeline'
|
||||
publisher: 'Bielefeld University - CRC 1288 - INF'
|
||||
latest_version: '0.1.1'
|
||||
versions:
|
||||
0.1.0:
|
||||
methods:
|
||||
- 'encoding_detection'
|
||||
publishing_year: 2022
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.0'
|
||||
0.1.1:
|
||||
methods:
|
||||
- 'encoding_detection'
|
||||
publishing_year: 2022
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.1'
|
||||
0.1.2:
|
||||
methods:
|
||||
- 'encoding_detection'
|
||||
publishing_year: 2024
|
||||
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.2'
|
18
app/blueprints/settings/__init__.py
Normal file
18
app/blueprints/settings/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('settings', __name__)
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@login_required
|
||||
def before_request():
|
||||
'''
|
||||
Ensures that the routes in this package can only be visited by users that
|
||||
are logged in.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
from . import routes
|
10
app/blueprints/settings/routes.py
Normal file
10
app/blueprints/settings/routes.py
Normal file
@ -0,0 +1,10 @@
|
||||
from flask import g, url_for
|
||||
from flask_login import current_user
|
||||
from app.blueprints.users.settings.routes import settings as settings_route
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/settings', methods=['GET', 'POST'])
|
||||
def settings():
|
||||
g._nopaque_redirect_location_on_post = url_for('.settings')
|
||||
return settings_route(current_user.id)
|
18
app/blueprints/users/__init__.py
Normal file
18
app/blueprints/users/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('users', __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 cli, events, json_routes, routes, settings
|
12
app/blueprints/users/cli.py
Normal file
12
app/blueprints/users/cli.py
Normal file
@ -0,0 +1,12 @@
|
||||
from app.models import User
|
||||
from app import db
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.cli.command('reset')
|
||||
def reset():
|
||||
''' Reset terms of use accept '''
|
||||
for user in [x for x in User.query.all() if x.terms_of_use_accepted]:
|
||||
print(f'Resetting user {user.username}')
|
||||
user.terms_of_use_accepted = False
|
||||
db.session.commit()
|
47
app/blueprints/users/events.py
Normal file
47
app/blueprints/users/events.py
Normal file
@ -0,0 +1,47 @@
|
||||
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('GET /users/<user_id>')
|
||||
@socketio_login_required
|
||||
def get_user(user_hashid):
|
||||
user_id = hashids.decode(user_hashid)
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
return {'status': 404, 'statusText': 'Not found'}
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
return {'status': 403, 'statusText': 'Forbidden'}
|
||||
return {
|
||||
'body': user.to_json_serializeable(backrefs=True, relationships=True),
|
||||
'status': 200,
|
||||
'statusText': 'OK'
|
||||
}
|
||||
|
||||
|
||||
@socketio.on('SUBSCRIBE /users/<user_id>')
|
||||
@socketio_login_required
|
||||
def subscribe_user(user_hashid):
|
||||
user_id = hashids.decode(user_hashid)
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
return {'status': 404, 'statusText': 'Not found'}
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
return {'status': 403, 'statusText': 'Forbidden'}
|
||||
join_room(f'/users/{user.hashid}')
|
||||
return {'status': 200, 'statusText': 'OK'}
|
||||
|
||||
|
||||
@socketio.on('UNSUBSCRIBE /users/<user_id>')
|
||||
@socketio_login_required
|
||||
def unsubscribe_user(user_hashid):
|
||||
user_id = hashids.decode(user_hashid)
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
return {'status': 404, 'statusText': 'Not found'}
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
return {'status': 403, 'statusText': 'Forbidden'}
|
||||
leave_room(f'/users/{user.hashid}')
|
||||
return {'status': 200, 'statusText': 'OK'}
|
69
app/blueprints/users/json_routes.py
Normal file
69
app/blueprints/users/json_routes.py
Normal file
@ -0,0 +1,69 @@
|
||||
from flask import abort, current_app
|
||||
from flask_login import current_user, logout_user
|
||||
from threading import Thread
|
||||
from app import db
|
||||
from app.decorators import content_negotiation
|
||||
from app.models import Avatar, User
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>', methods=['DELETE'])
|
||||
@content_negotiation(produces='application/json')
|
||||
def delete_user(user_id):
|
||||
def _delete_user(app, user_id):
|
||||
with app.app_context():
|
||||
user = User.query.get(user_id)
|
||||
user.delete()
|
||||
db.session.commit()
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
thread = Thread(
|
||||
target=_delete_user,
|
||||
args=(current_app._get_current_object(), user.id)
|
||||
)
|
||||
if user == current_user:
|
||||
logout_user()
|
||||
thread.start()
|
||||
response_data = {
|
||||
'message': f'User "{user.username}" marked for deletion'
|
||||
}
|
||||
return response_data, 202
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>/avatar', methods=['DELETE'])
|
||||
@content_negotiation(produces='application/json')
|
||||
def delete_user_avatar(user_id):
|
||||
def _delete_avatar(app, avatar_id):
|
||||
with app.app_context():
|
||||
avatar = Avatar.query.get(avatar_id)
|
||||
avatar.delete()
|
||||
db.session.commit()
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
if user.avatar is None:
|
||||
abort(404)
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
thread = Thread(
|
||||
target=_delete_avatar,
|
||||
args=(current_app._get_current_object(), user.avatar.id)
|
||||
)
|
||||
thread.start()
|
||||
response_data = {
|
||||
'message': f'Avatar marked for deletion'
|
||||
}
|
||||
return response_data, 202
|
||||
|
||||
@bp.route('/accept-terms-of-use', methods=['POST'])
|
||||
@content_negotiation(produces='application/json')
|
||||
def accept_terms_of_use():
|
||||
if not (current_user.is_authenticated or current_user.confirmed):
|
||||
abort(403)
|
||||
current_user.terms_of_use_accepted = True
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': 'You accepted the terms of use',
|
||||
}
|
||||
return response_data, 202
|
114
app/blueprints/users/nevents.py
Normal file
114
app/blueprints/users/nevents.py
Normal file
@ -0,0 +1,114 @@
|
||||
from flask_login import current_user
|
||||
from flask_socketio import join_room
|
||||
from app import hashids, socketio
|
||||
from app.decorators import socketio_admin_required, socketio_login_required
|
||||
from app.models import User
|
||||
|
||||
|
||||
@socketio.on('GET /users')
|
||||
@socketio_admin_required
|
||||
def get_users():
|
||||
users = User.query.filter_by().all()
|
||||
return {
|
||||
'body': [user.to_json_serializable() for user in users],
|
||||
'options': {
|
||||
'status': 200,
|
||||
'statusText': 'OK',
|
||||
'headers': {'Content-Type: application/json'}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@socketio.on('SUBSCRIBE /users')
|
||||
@socketio_admin_required
|
||||
def subscribe_users():
|
||||
join_room('/users')
|
||||
return {'options': {'status': 200, 'statusText': 'OK'}}
|
||||
|
||||
|
||||
@socketio.on('GET /users/<user_id>')
|
||||
@socketio_login_required
|
||||
def get_user(user_hashid):
|
||||
user_id = hashids.decode(user_hashid)
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
return {'options': {'status': 404, 'statusText': 'Not found'}}
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
|
||||
return {
|
||||
'body': user.to_json_serializable(),
|
||||
'options': {
|
||||
'status': 200,
|
||||
'statusText': 'OK',
|
||||
'headers': {'Content-Type: application/json'}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@socketio.on('SUBSCRIBE /users/<user_id>')
|
||||
@socketio_login_required
|
||||
def subscribe_user(user_hashid):
|
||||
user_id = hashids.decode(user_hashid)
|
||||
user = User.query.get(user_id)
|
||||
if user is None:
|
||||
return {'options': {'status': 404, 'statusText': 'Not found'}}
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
|
||||
join_room(f'/users/{user.hashid}')
|
||||
return {'options': {'status': 200, 'statusText': 'OK'}}
|
||||
|
||||
|
||||
@socketio.on('GET /public_users')
|
||||
@socketio_login_required
|
||||
def get_public_users():
|
||||
users = User.query.filter_by(is_public=True).all()
|
||||
return {
|
||||
'body': [
|
||||
user.to_json_serializable(filter_by_privacy_settings=True)
|
||||
for user in users
|
||||
],
|
||||
'options': {
|
||||
'status': 200,
|
||||
'statusText': 'OK',
|
||||
'headers': {'Content-Type: application/json'}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@socketio.on('SUBSCRIBE /users')
|
||||
@socketio_admin_required
|
||||
def subscribe_users():
|
||||
join_room('/public_users')
|
||||
return {'options': {'status': 200, 'statusText': 'OK'}}
|
||||
|
||||
|
||||
@socketio.on('GET /public_users/<user_id>')
|
||||
@socketio_login_required
|
||||
def get_user(user_hashid):
|
||||
user_id = hashids.decode(user_hashid)
|
||||
user = User.query.filter_by(id=user_id, is_public=True).first()
|
||||
if user is None:
|
||||
return {'options': {'status': 404, 'statusText': 'Not found'}}
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
|
||||
return {
|
||||
'body': user.to_json_serializable(filter_by_privacy_settings=True),
|
||||
'options': {
|
||||
'status': 200,
|
||||
'statusText': 'OK',
|
||||
'headers': {'Content-Type: application/json'}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@socketio.on('SUBSCRIBE /public_users/<user_id>')
|
||||
@socketio_login_required
|
||||
def subscribe_user(user_hashid):
|
||||
user_id = hashids.decode(user_hashid)
|
||||
user = User.query.filter_by(id=user_id, is_public=True).first()
|
||||
if user is None:
|
||||
return {'options': {'status': 404, 'statusText': 'Not found'}}
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
return {'options': {'status': 403, 'statusText': 'Forbidden'}}
|
||||
join_room(f'/public_users/{user.hashid}')
|
||||
return {'options': {'status': 200, 'statusText': 'OK'}}
|
43
app/blueprints/users/routes.py
Normal file
43
app/blueprints/users/routes.py
Normal file
@ -0,0 +1,43 @@
|
||||
from flask import (
|
||||
abort,
|
||||
redirect,
|
||||
render_template,
|
||||
send_from_directory,
|
||||
url_for
|
||||
)
|
||||
from flask_login import current_user
|
||||
from app.models import User
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
def users():
|
||||
return redirect(url_for('main.social_area', _anchor='users'))
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>')
|
||||
def user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
if not (user.is_public or user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
return render_template(
|
||||
'users/user.html.j2',
|
||||
title=user.username,
|
||||
user=user
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>/avatar')
|
||||
def user_avatar(user_id):
|
||||
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
|
||||
)
|
2
app/blueprints/users/settings/__init__.py
Normal file
2
app/blueprints/users/settings/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .. import bp
|
||||
from . import json_routes, routes
|
160
app/blueprints/users/settings/forms.py
Normal file
160
app/blueprints/users/settings/forms.py
Normal file
@ -0,0 +1,160 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileRequired, FileSize
|
||||
from wtforms import (
|
||||
PasswordField,
|
||||
SelectField,
|
||||
StringField,
|
||||
SubmitField,
|
||||
TextAreaField,
|
||||
ValidationError
|
||||
)
|
||||
from wtforms.validators import (
|
||||
DataRequired,
|
||||
Email,
|
||||
EqualTo,
|
||||
Length,
|
||||
Regexp
|
||||
)
|
||||
from app.models import User, UserSettingJobStatusMailNotificationLevel
|
||||
|
||||
|
||||
class UpdateAccountInformationForm(FlaskForm):
|
||||
email = StringField(
|
||||
'E-Mail',
|
||||
validators=[DataRequired(), Length(max=254), Email()]
|
||||
)
|
||||
username = StringField(
|
||||
'Username',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Length(max=64),
|
||||
Regexp(
|
||||
User.username_pattern,
|
||||
message=(
|
||||
'Usernames must have only letters, numbers, dots or '
|
||||
'underscores'
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
if 'data' not in kwargs:
|
||||
kwargs['data'] = user.to_json_serializeable()
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'update-account-information-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
|
||||
def validate_email(self, field):
|
||||
if (field.data != self.user.email
|
||||
and User.query.filter_by(email=field.data).first()):
|
||||
raise ValidationError('Email already registered')
|
||||
|
||||
def validate_username(self, field):
|
||||
if (field.data != self.user.username
|
||||
and User.query.filter_by(username=field.data).first()):
|
||||
raise ValidationError('Username already in use')
|
||||
|
||||
|
||||
class UpdateProfileInformationForm(FlaskForm):
|
||||
full_name = StringField(
|
||||
'Full name',
|
||||
validators=[Length(max=128)]
|
||||
)
|
||||
about_me = TextAreaField(
|
||||
'About me',
|
||||
validators=[
|
||||
Length(max=254)
|
||||
]
|
||||
)
|
||||
website = StringField(
|
||||
'Website',
|
||||
validators=[
|
||||
Length(max=254)
|
||||
]
|
||||
)
|
||||
organization = StringField(
|
||||
'Organization',
|
||||
validators=[
|
||||
Length(max=128)
|
||||
]
|
||||
)
|
||||
location = StringField(
|
||||
'Location',
|
||||
validators=[
|
||||
Length(max=128)
|
||||
]
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
if 'data' not in kwargs:
|
||||
kwargs['data'] = user.to_json_serializeable()
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'update-profile-information-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class UpdateAvatarForm(FlaskForm):
|
||||
avatar = FileField('File', validators=[FileRequired(), FileSize(2_000_000)])
|
||||
submit = SubmitField()
|
||||
|
||||
def validate_avatar(self, field):
|
||||
valid_mimetypes = ['image/jpeg', 'image/png']
|
||||
if field.data.mimetype not in valid_mimetypes:
|
||||
raise ValidationError('JPEG and PNG files only!')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'update-avatar-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class UpdatePasswordForm(FlaskForm):
|
||||
password = PasswordField('Old password', validators=[DataRequired()])
|
||||
new_password = PasswordField(
|
||||
'New password',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
EqualTo('new_password_2', message='Passwords must match')
|
||||
]
|
||||
)
|
||||
new_password_2 = PasswordField(
|
||||
'New password confirmation',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
EqualTo('new_password', message='Passwords must match')
|
||||
]
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'update-password-form'
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
|
||||
def validate_current_password(self, field):
|
||||
if not self.user.verify_password(field.data):
|
||||
raise ValidationError('Invalid password')
|
||||
|
||||
|
||||
class UpdateNotificationsForm(FlaskForm):
|
||||
job_status_mail_notification_level = SelectField(
|
||||
'Job status mail notification level',
|
||||
choices=[
|
||||
(x.name, x.name.capitalize())
|
||||
for x in UserSettingJobStatusMailNotificationLevel
|
||||
],
|
||||
validators=[DataRequired()]
|
||||
)
|
||||
submit = SubmitField()
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
if 'data' not in kwargs:
|
||||
kwargs['data'] = user.to_json_serializeable()
|
||||
if 'prefix' not in kwargs:
|
||||
kwargs['prefix'] = 'update-notifications-form'
|
||||
super().__init__(*args, **kwargs)
|
49
app/blueprints/users/settings/json_routes.py
Normal file
49
app/blueprints/users/settings/json_routes.py
Normal file
@ -0,0 +1,49 @@
|
||||
from flask import abort, request
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
from app.decorators import content_negotiation
|
||||
from app.models import User, ProfilePrivacySettings
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>/settings/profile-privacy/is-public', methods=['PUT'])
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_user_profile_privacy_setting_is_public(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
enabled = request.json
|
||||
if not isinstance(enabled, bool):
|
||||
abort(400)
|
||||
user.is_public = enabled
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': 'Profile privacy settings updated',
|
||||
'category': 'settings'
|
||||
}
|
||||
return response_data, 200
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>/settings/profile-privacy/<string:profile_privacy_setting_name>', methods=['PUT'])
|
||||
@content_negotiation(consumes='application/json', produces='application/json')
|
||||
def update_user_profile_privacy_settings(user_id, profile_privacy_setting_name):
|
||||
user = User.query.get_or_404(user_id)
|
||||
try:
|
||||
profile_privacy_setting = ProfilePrivacySettings[profile_privacy_setting_name]
|
||||
except KeyError:
|
||||
abort(404)
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
enabled = request.json
|
||||
if not isinstance(enabled, bool):
|
||||
abort(400)
|
||||
if enabled:
|
||||
user.add_profile_privacy_setting(profile_privacy_setting)
|
||||
else:
|
||||
user.remove_profile_privacy_setting(profile_privacy_setting)
|
||||
db.session.commit()
|
||||
response_data = {
|
||||
'message': 'Profile privacy settings updated',
|
||||
'category': 'settings'
|
||||
}
|
||||
return response_data, 200
|
93
app/blueprints/users/settings/routes.py
Normal file
93
app/blueprints/users/settings/routes.py
Normal file
@ -0,0 +1,93 @@
|
||||
from flask import abort, flash, g, redirect, render_template, url_for
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
from app.models import Avatar, User
|
||||
from . import bp
|
||||
from .forms import (
|
||||
UpdateAvatarForm,
|
||||
UpdatePasswordForm,
|
||||
UpdateNotificationsForm,
|
||||
UpdateAccountInformationForm,
|
||||
UpdateProfileInformationForm
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<hashid:user_id>/settings', methods=['GET', 'POST'])
|
||||
def settings(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
if not (user == current_user or current_user.is_administrator):
|
||||
abort(403)
|
||||
|
||||
redirect_location_on_post = g.pop(
|
||||
'_nopaque_redirect_location_on_post',
|
||||
url_for('.settings', user_id=user_id)
|
||||
)
|
||||
|
||||
update_account_information_form = UpdateAccountInformationForm(user)
|
||||
update_profile_information_form = UpdateProfileInformationForm(user)
|
||||
update_avatar_form = UpdateAvatarForm()
|
||||
update_password_form = UpdatePasswordForm(user)
|
||||
update_notifications_form = UpdateNotificationsForm(user)
|
||||
|
||||
# region handle update profile information form
|
||||
if update_profile_information_form.submit.data and update_profile_information_form.validate():
|
||||
user.about_me = update_profile_information_form.about_me.data
|
||||
user.location = update_profile_information_form.location.data
|
||||
user.organization = update_profile_information_form.organization.data
|
||||
user.website = update_profile_information_form.website.data
|
||||
user.full_name = update_profile_information_form.full_name.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(redirect_location_on_post)
|
||||
# endregion handle update profile information form
|
||||
|
||||
# region handle update avatar form
|
||||
if update_avatar_form.submit.data and update_avatar_form.validate():
|
||||
try:
|
||||
Avatar.create(
|
||||
update_avatar_form.avatar.data,
|
||||
user=user
|
||||
)
|
||||
except (AttributeError, OSError):
|
||||
abort(500)
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(redirect_location_on_post)
|
||||
# endregion handle update avatar form
|
||||
|
||||
# region handle update account information form
|
||||
if update_account_information_form.submit.data and update_account_information_form.validate():
|
||||
user.email = update_account_information_form.email.data
|
||||
user.username = update_account_information_form.username.data
|
||||
db.session.commit()
|
||||
flash('Profile settings updated')
|
||||
return redirect(redirect_location_on_post)
|
||||
# endregion handle update account information form
|
||||
|
||||
# region handle update password form
|
||||
if update_password_form.submit.data and update_password_form.validate():
|
||||
user.password = update_password_form.new_password.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(redirect_location_on_post)
|
||||
# endregion handle update password form
|
||||
|
||||
# region handle update notifications form
|
||||
if update_notifications_form.submit.data and update_notifications_form.validate():
|
||||
user.setting_job_status_mail_notification_level = \
|
||||
update_notifications_form.job_status_mail_notification_level.data
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
return redirect(redirect_location_on_post)
|
||||
# endregion handle update notifications form
|
||||
|
||||
return render_template(
|
||||
'users/settings/settings.html.j2',
|
||||
title='Settings',
|
||||
update_account_information_form=update_account_information_form,
|
||||
update_avatar_form=update_avatar_form,
|
||||
update_notifications_form=update_notifications_form,
|
||||
update_password_form=update_password_form,
|
||||
update_profile_information_form=update_profile_information_form,
|
||||
user=user
|
||||
)
|
5
app/blueprints/workshops/__init__.py
Normal file
5
app/blueprints/workshops/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint('workshops', __name__)
|
||||
from . import routes
|
15
app/blueprints/workshops/routes.py
Normal file
15
app/blueprints/workshops/routes.py
Normal file
@ -0,0 +1,15 @@
|
||||
from flask import redirect, render_template, url_for
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route('')
|
||||
def workshops():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@bp.route('/fgho_sommerschule_2023')
|
||||
def fgho_sommerschule_2023():
|
||||
return render_template(
|
||||
'workshops/fgho_sommerschule_2023.html.j2',
|
||||
title='FGHO Sommerschule 2023',
|
||||
)
|
Reference in New Issue
Block a user