3 Commits

461 changed files with 3836 additions and 1572832 deletions

View File

@ -5,9 +5,9 @@
!app !app
!migrations !migrations
!tests !tests
!.flaskenv
!boot.sh !boot.sh
!config.py !config.py
!docker-nopaque-entrypoint.sh !docker-nopaque-entrypoint.sh
!nopaque.py
!requirements.txt !requirements.txt
!requirements.freezed.txt
!wsgi.py

View File

@ -1,20 +1,32 @@
############################################################################## ##############################################################################
# Environment variables used by Docker Compose config files. # # Variables for use in Docker Compose YAML files #
############################################################################## ##############################################################################
# HINT: Use this bash command `id -u` # HINT: Use this bash command `id -u`
# NOTE: 0 (= root user) is not allowed # NOTE: 0 (= root user) is not allowed
HOST_UID= HOST_UID=
# HINT: Use this bash command `id -g` # HINT: Use this bash command `id -g`
# NOTE: 0 (= root group) is not allowed
HOST_GID= HOST_GID=
# HINT: Use this bash command `getent group docker | cut -d: -f3` # HINT: Use this bash command `getent group docker | cut -d: -f3`
HOST_DOCKER_GID= HOST_DOCKER_GID=
# DEFAULT: nopaque # DEFAULT: nopaque
NOPAQUE_DOCKER_NETWORK_NAME=nopaque # DOCKER_DEFAULT_NETWORK_NAME=
# DEFAULT: ./volumes/db/data
# NOTE: Use `.` as <project-basedir>
# DOCKER_DB_SERVICE_DATA_VOLUME_SOURCE_PATH=
# DEFAULT: ./volumes/mq/data
# NOTE: Use `.` as <project-basedir>
# DOCKER_MQ_SERVICE_DATA_VOLUME_SOURCE_PATH=
# NOTE: This must be a network share and it must be available on all # NOTE: This must be a network share and it must be available on all
# Docker Swarm nodes, mounted to the same path. # Docker Swarm nodes, mounted to the same path with the same
HOST_NOPAQUE_DATA_PATH=/mnt/nopaque # user and group ownership.
DOCKER_NOPAQUE_SERVICE_DATA_VOLUME_SOURCE_PATH=
# DEFAULT: ./volumes/nopaque/logs
# NOTE: Use `.` as <project-basedir>
# DOCKER_NOPAQUE_SERVICE_LOGS_VOLUME_SOURCE_PATH=.

1
.flaskenv Normal file
View File

@ -0,0 +1 @@
FLASK_APP=nopaque.py

2
.gitignore vendored
View File

@ -2,6 +2,8 @@
app/static/gen/ app/static/gen/
volumes/ volumes/
docker-compose.override.yml docker-compose.override.yml
logs/
!logs/dummy
*.env *.env
*.pjentsch-testing *.pjentsch-testing

17
.vscode/settings.json vendored
View File

@ -1,17 +1,9 @@
{ {
"editor.rulers": [79], "editor.rulers": [79],
"editor.tabSize": 4,
"emmet.includeLanguages": {
"jinja-html": "html"
},
"files.associations": {
".flaskenv": "env",
"*.env.tpl": "env",
"*.txt.j2": "jinja"
},
"files.insertFinalNewline": true, "files.insertFinalNewline": true,
"files.trimFinalNewlines": true, "[css]": {
"files.trimTrailingWhitespace": true, "editor.tabSize": 2
},
"[html]": { "[html]": {
"editor.tabSize": 2 "editor.tabSize": 2
}, },
@ -20,5 +12,8 @@
}, },
"[jinja-html]": { "[jinja-html]": {
"editor.tabSize": 2 "editor.tabSize": 2
},
"[scss]": {
"editor.tabSize": 2
} }
} }

View File

@ -35,17 +35,20 @@ ENV PATH="${NOPAQUE_PYTHON3_VENV_PATH}/bin:${PATH}"
# Install Python dependencies # Install Python dependencies
COPY --chown=nopaque:nopaque requirements.freezed.txt requirements.freezed.txt COPY --chown=nopaque:nopaque requirements.txt requirements.txt
RUN python3 -m pip install --requirement requirements.freezed.txt \ RUN python3 -m pip install --requirement requirements.txt \
&& rm requirements.freezed.txt && rm requirements.txt
# Install the application # Install the application
COPY docker-nopaque-entrypoint.sh /usr/local/bin/ COPY docker-nopaque-entrypoint.sh /usr/local/bin/
COPY --chown=nopaque:nopaque app app COPY --chown=nopaque:nopaque app app
COPY --chown=nopaque:nopaque migrations migrations COPY --chown=nopaque:nopaque migrations migrations
COPY --chown=nopaque:nopaque tests tests COPY --chown=nopaque:nopaque tests tests
COPY --chown=nopaque:nopaque boot.sh config.py wsgi.py ./ COPY --chown=nopaque:nopaque .flaskenv boot.sh config.py nopaque.py requirements.txt ./
RUN mkdir logs
EXPOSE 5000 EXPOSE 5000

View File

@ -35,7 +35,7 @@ username@hostname:~$ sudo mount --types cifs --options gid=${USER},password=nopa
# Clone the nopaque repository # Clone the nopaque repository
username@hostname:~$ git clone https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git username@hostname:~$ git clone https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
# Create data directories # Create data directories
username@hostname:~$ mkdir -p volumes/{db,mq} username@hostname:~$ mkdir data/{db,logs,mq}
username@hostname:~$ cp db.env.tpl db.env username@hostname:~$ cp db.env.tpl db.env
username@hostname:~$ cp .env.tpl .env username@hostname:~$ cp .env.tpl .env
# Fill out the variables within these files. # Fill out the variables within these files.

View File

@ -2,9 +2,9 @@ from apifairy import APIFairy
from config import Config from config import Config
from docker import DockerClient from docker import DockerClient
from flask import Flask from flask import Flask
from flask.logging import default_handler
from flask_apscheduler import APScheduler from flask_apscheduler import APScheduler
from flask_assets import Environment from flask_assets import Environment
from flask_breadcrumbs import Breadcrumbs, default_breadcrumb_root
from flask_login import LoginManager from flask_login import LoginManager
from flask_mail import Mail from flask_mail import Mail
from flask_marshmallow import Marshmallow from flask_marshmallow import Marshmallow
@ -13,139 +13,98 @@ from flask_paranoid import Paranoid
from flask_socketio import SocketIO from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_hashids import Hashids from flask_hashids import Hashids
from logging import Formatter, StreamHandler
from werkzeug.middleware.proxy_fix import ProxyFix
docker_client = DockerClient.from_env()
apifairy = APIFairy() apifairy = APIFairy()
assets = Environment() assets = Environment()
breadcrumbs = Breadcrumbs()
db = SQLAlchemy() db = SQLAlchemy()
docker_client = DockerClient()
hashids = Hashids() hashids = Hashids()
login = LoginManager() login = LoginManager()
login.login_view = 'auth.login'
login.login_message = 'Please log in to access this page.'
ma = Marshmallow() ma = Marshmallow()
mail = Mail() mail = Mail()
migrate = Migrate(compare_type=True) migrate = Migrate(compare_type=True)
paranoid = Paranoid() paranoid = Paranoid()
paranoid.redirect_view = '/'
scheduler = APScheduler() scheduler = APScheduler()
socketio = SocketIO() socketio = SocketIO()
def create_app(config: Config = Config) -> Flask: def create_app(config: Config = Config) -> Flask:
''' Creates an initialized Flask object. ''' ''' Creates an initialized Flask (WSGI Application) object. '''
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config) app.config.from_object(config)
config.init_app(app)
# region Logging
log_formatter = Formatter(
fmt=app.config['NOPAQUE_LOG_FORMAT'],
datefmt=app.config['NOPAQUE_LOG_DATE_FORMAT']
)
log_handler = StreamHandler()
log_handler.setFormatter(log_formatter)
log_handler.setLevel(app.config['NOPAQUE_LOG_LEVEL'])
app.logger.setLevel('DEBUG')
app.logger.removeHandler(default_handler)
app.logger.addHandler(log_handler)
# endregion Logging
# region Middlewares
if app.config['NOPAQUE_PROXY_FIX_ENABLED']:
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=app.config['NOPAQUE_PROXY_FIX_X_FOR'],
x_host=app.config['NOPAQUE_PROXY_FIX_X_HOST'],
x_port=app.config['NOPAQUE_PROXY_FIX_X_PORT'],
x_prefix=app.config['NOPAQUE_PROXY_FIX_X_PREFIX'],
x_proto=app.config['NOPAQUE_PROXY_FIX_X_PROTO']
)
# endregion Middlewares
# region Extensions
docker_client.login( docker_client.login(
app.config['NOPAQUE_DOCKER_REGISTRY_USERNAME'], app.config['NOPAQUE_DOCKER_REGISTRY_USERNAME'],
password=app.config['NOPAQUE_DOCKER_REGISTRY_PASSWORD'], password=app.config['NOPAQUE_DOCKER_REGISTRY_PASSWORD'],
registry=app.config['NOPAQUE_DOCKER_REGISTRY'] registry=app.config['NOPAQUE_DOCKER_REGISTRY']
) )
from .models import AnonymousUser, User
apifairy.init_app(app) apifairy.init_app(app)
assets.init_app(app) assets.init_app(app)
breadcrumbs.init_app(app)
db.init_app(app) db.init_app(app)
hashids.init_app(app) hashids.init_app(app)
login.init_app(app) login.init_app(app)
login.anonymous_user = AnonymousUser
login.login_view = 'auth.login'
login.user_loader(lambda user_id: User.query.get(int(user_id)))
ma.init_app(app) ma.init_app(app)
mail.init_app(app) mail.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
paranoid.init_app(app) paranoid.init_app(app)
paranoid.redirect_view = '/'
scheduler.init_app(app) scheduler.init_app(app)
socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI']) socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI']) # noqa
# endregion Extensions
# region Blueprints
from .blueprints.admin import bp as admin_blueprint
app.register_blueprint(admin_blueprint, url_prefix='/admin')
from .blueprints.api import bp as api_blueprint
app.register_blueprint(api_blueprint, url_prefix='/api')
from .blueprints.auth import bp as auth_blueprint
app.register_blueprint(auth_blueprint)
from .blueprints.contributions import bp as contributions_blueprint
app.register_blueprint(contributions_blueprint, url_prefix='/contributions')
from .blueprints.corpora import bp as corpora_blueprint
app.register_blueprint(corpora_blueprint, cli_group='corpus', url_prefix='/corpora')
from .blueprints.errors import bp as errors_bp
app.register_blueprint(errors_bp)
from .blueprints.jobs import bp as jobs_blueprint
app.register_blueprint(jobs_blueprint, url_prefix='/jobs')
from .blueprints.main import bp as main_blueprint
app.register_blueprint(main_blueprint, cli_group=None)
from .blueprints.services import bp as services_blueprint
app.register_blueprint(services_blueprint, url_prefix='/services')
from .blueprints.settings import bp as settings_blueprint
app.register_blueprint(settings_blueprint, url_prefix='/settings')
from .blueprints.users import bp as users_blueprint
app.register_blueprint(users_blueprint, cli_group='user', url_prefix='/users')
from .blueprints.workshops import bp as workshops_blueprint
app.register_blueprint(workshops_blueprint, url_prefix='/workshops')
# endregion Blueprints
# region SocketIO Namespaces
from .namespaces.cqi_over_sio import CQiOverSocketIONamespace
socketio.on_namespace(CQiOverSocketIONamespace('/cqi_over_sio'))
# endregion SocketIO Namespaces
# region Database event Listeners
from .models.event_listeners import register_event_listeners from .models.event_listeners import register_event_listeners
register_event_listeners() register_event_listeners()
# endregion Database event Listeners
# region Add scheduler jobs from .admin import bp as admin_blueprint
if app.config['NOPAQUE_IS_PRIMARY_INSTANCE']: default_breadcrumb_root(admin_blueprint, '.admin')
from .jobs import handle_corpora app.register_blueprint(admin_blueprint, url_prefix='/admin')
scheduler.add_job('handle_corpora', handle_corpora, seconds=3, trigger='interval')
from .jobs import handle_jobs from .api import bp as api_blueprint
scheduler.add_job('handle_jobs', handle_jobs, seconds=3, trigger='interval') app.register_blueprint(api_blueprint, url_prefix='/api')
# endregion Add scheduler jobs
from .auth import bp as auth_blueprint
default_breadcrumb_root(auth_blueprint, '.')
app.register_blueprint(auth_blueprint)
from .contributions import bp as contributions_blueprint
default_breadcrumb_root(contributions_blueprint, '.contributions')
app.register_blueprint(contributions_blueprint, url_prefix='/contributions')
from .corpora import bp as corpora_blueprint
from .corpora.cqi_over_sio import CQiNamespace
default_breadcrumb_root(corpora_blueprint, '.corpora')
app.register_blueprint(corpora_blueprint, cli_group='corpus', url_prefix='/corpora')
socketio.on_namespace(CQiNamespace('/cqi_over_sio'))
from .errors import bp as errors_bp
app.register_blueprint(errors_bp)
from .jobs import bp as jobs_blueprint
default_breadcrumb_root(jobs_blueprint, '.jobs')
app.register_blueprint(jobs_blueprint, url_prefix='/jobs')
from .main import bp as main_blueprint
default_breadcrumb_root(main_blueprint, '.')
app.register_blueprint(main_blueprint, cli_group=None)
from .services import bp as services_blueprint
default_breadcrumb_root(services_blueprint, '.services')
app.register_blueprint(services_blueprint, url_prefix='/services')
from .settings import bp as settings_blueprint
default_breadcrumb_root(settings_blueprint, '.settings')
app.register_blueprint(settings_blueprint, url_prefix='/settings')
from .users import bp as users_blueprint
default_breadcrumb_root(users_blueprint, '.users')
app.register_blueprint(users_blueprint, cli_group='user', url_prefix='/users')
from .workshops import bp as workshops_blueprint
app.register_blueprint(workshops_blueprint, url_prefix='/workshops')
return app return app

View File

@ -1,6 +1,6 @@
from flask import abort, request from flask import abort, request
from app.decorators import content_negotiation
from app import db from app import db
from app.decorators import content_negotiation
from app.models import User from app.models import User
from . import bp from . import bp

View File

@ -1,7 +1,8 @@
from flask import abort, flash, redirect, render_template, url_for from flask import abort, flash, redirect, render_template, url_for
from flask_breadcrumbs import register_breadcrumb
from app import db, hashids from app import db, hashids
from app.models import Avatar, Corpus, Role, User from app.models import Avatar, Corpus, Role, User
from app.blueprints.users.settings.forms import ( from app.users.settings.forms import (
UpdateAvatarForm, UpdateAvatarForm,
UpdatePasswordForm, UpdatePasswordForm,
UpdateNotificationsForm, UpdateNotificationsForm,
@ -10,9 +11,14 @@ from app.blueprints.users.settings.forms import (
) )
from . import bp from . import bp
from .forms import UpdateUserForm from .forms import UpdateUserForm
from app.users.utils import (
user_endpoint_arguments_constructor as user_eac,
user_dynamic_list_constructor as user_dlc
)
@bp.route('') @bp.route('')
@register_breadcrumb(bp, '.', '<i class="material-icons left">admin_panel_settings</i>Administration')
def admin(): def admin():
return render_template( return render_template(
'admin/admin.html.j2', 'admin/admin.html.j2',
@ -21,6 +27,7 @@ def admin():
@bp.route('/corpora') @bp.route('/corpora')
@register_breadcrumb(bp, '.corpora', 'Corpora')
def corpora(): def corpora():
corpora = Corpus.query.all() corpora = Corpus.query.all()
return render_template( return render_template(
@ -31,6 +38,7 @@ def corpora():
@bp.route('/users') @bp.route('/users')
@register_breadcrumb(bp, '.users', '<i class="material-icons left">group</i>Users')
def users(): def users():
users = User.query.all() users = User.query.all()
return render_template( return render_template(
@ -41,6 +49,7 @@ def users():
@bp.route('/users/<hashid:user_id>') @bp.route('/users/<hashid:user_id>')
@register_breadcrumb(bp, '.users.entity', '', dynamic_list_constructor=user_dlc)
def user(user_id): def user(user_id):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
corpora = Corpus.query.filter(Corpus.user == user).all() corpora = Corpus.query.filter(Corpus.user == user).all()
@ -53,6 +62,7 @@ def user(user_id):
@bp.route('/users/<hashid:user_id>/settings', methods=['GET', 'POST']) @bp.route('/users/<hashid:user_id>/settings', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.users.entity.settings', '<i class="material-icons left">settings</i>Settings')
def user_settings(user_id): def user_settings(user_id):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
update_account_information_form = UpdateAccountInformationForm(user) update_account_information_form = UpdateAccountInformationForm(user)

View File

@ -5,8 +5,8 @@ from flask import abort, Blueprint
from werkzeug.exceptions import InternalServerError from werkzeug.exceptions import InternalServerError
from app import db, hashids from app import db, hashids
from app.models import Job, JobInput, JobStatus, TesseractOCRPipelineModel from app.models import Job, JobInput, JobStatus, TesseractOCRPipelineModel
from .auth import auth_error_responses, token_auth
from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRPipelineModelSchema from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRPipelineModelSchema
from .auth import auth_error_responses, token_auth
bp = Blueprint('jobs', __name__) bp = Blueprint('jobs', __name__)
@ -77,7 +77,7 @@ def delete_job(job_id):
job = Job.query.get(job_id) job = Job.query.get(job_id)
if job is None: if job is None:
abort(404) abort(404)
if not (job.user == current_user or current_user.is_administrator): if not (job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
try: try:
job.delete() job.delete()
@ -97,6 +97,6 @@ def get_job(job_id):
job = Job.query.get(job_id) job = Job.query.get(job_id)
if job is None: if job is None:
abort(404) abort(404)
if not (job.user == current_user or current_user.is_administrator): if not (job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
return job return job

View File

@ -10,7 +10,7 @@ from app.models import (
User, User,
UserSettingJobStatusMailNotificationLevel UserSettingJobStatusMailNotificationLevel
) )
from app.blueprints.services import SERVICES from app.services import SERVICES

View File

@ -3,11 +3,11 @@ from apifairy import authenticate, body, response
from apifairy.decorators import other_responses from apifairy.decorators import other_responses
from flask import abort, Blueprint from flask import abort, Blueprint
from werkzeug.exceptions import InternalServerError from werkzeug.exceptions import InternalServerError
from app.email import create_message, send
from app import db from app import db
from app.email import create_message, send
from app.models import User from app.models import User
from .auth import auth_error_responses, token_auth
from .schemas import EmptySchema, UserSchema from .schemas import EmptySchema, UserSchema
from .auth import auth_error_responses, token_auth
bp = Blueprint('users', __name__) bp = Blueprint('users', __name__)
@ -60,7 +60,7 @@ def delete_user(user_id):
user = User.query.get(user_id) user = User.query.get(user_id)
if user is None: if user is None:
abort(404) abort(404)
if not (user == current_user or current_user.is_administrator): if not (user == current_user or current_user.is_administrator()):
abort(403) abort(403)
user.delete() user.delete()
db.session.commit() db.session.commit()
@ -78,7 +78,7 @@ def get_user(user_id):
user = User.query.get(user_id) user = User.query.get(user_id)
if user is None: if user is None:
abort(404) abort(404)
if not (user == current_user or current_user.is_administrator): if not (user == current_user or current_user.is_administrator()):
abort(403) abort(403)
return user return user
@ -94,6 +94,6 @@ def get_user_by_username(username):
user = User.query.filter(User.username == username).first() user = User.query.filter(User.username == username).first()
if user is None: if user is None:
abort(404) abort(404)
if not (user == current_user or current_user.is_administrator): if not (user == current_user or current_user.is_administrator()):
abort(403) abort(403)
return user return user

5
app/auth/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('auth', __name__)
from . import routes

View File

@ -60,11 +60,7 @@ class RegistrationForm(FlaskForm):
def validate_username(self, field): def validate_username(self, field):
if User.query.filter_by(username=field.data).first(): if User.query.filter_by(username=field.data).first():
raise ValidationError('Username already registered') raise ValidationError('Username already in use')
def validate_terms_of_use_accepted(self, field):
if not field.data:
raise ValidationError('Terms of Use not accepted')
class LoginForm(FlaskForm): class LoginForm(FlaskForm):

View File

@ -1,4 +1,5 @@
from flask import abort, flash, redirect, render_template, request, url_for from flask import abort, flash, redirect, render_template, request, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user, login_user, login_required, logout_user from flask_login import current_user, login_user, login_required, logout_user
from app import db from app import db
from app.email import create_message, send from app.email import create_message, send
@ -12,7 +13,24 @@ from .forms import (
) )
@bp.before_app_request
def before_request():
"""
Checks if a user is unconfirmed when visiting specific sites. Redirects to
unconfirmed view if user is unconfirmed.
"""
if current_user.is_authenticated:
current_user.ping()
db.session.commit()
if (not current_user.confirmed
and request.endpoint
and request.blueprint != 'auth'
and request.endpoint != 'static'):
return redirect(url_for('auth.unconfirmed'))
@bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.register', 'Register')
def register(): def register():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@ -49,6 +67,7 @@ def register():
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.login', 'Login')
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@ -79,6 +98,7 @@ def logout():
@bp.route('/unconfirmed') @bp.route('/unconfirmed')
@register_breadcrumb(bp, '.unconfirmed', 'Unconfirmed')
@login_required @login_required
def unconfirmed(): def unconfirmed():
if current_user.confirmed: if current_user.confirmed:
@ -121,6 +141,7 @@ def confirm(token):
@bp.route('/reset-password-request', methods=['GET', 'POST']) @bp.route('/reset-password-request', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.reset_password_request', 'Password Reset')
def reset_password_request(): def reset_password_request():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@ -150,6 +171,7 @@ def reset_password_request():
@bp.route('/reset-password/<token>', methods=['GET', 'POST']) @bp.route('/reset-password/<token>', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.reset_password', 'Password Reset')
def reset_password(token): def reset_password(token):
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))

View File

@ -1,29 +0,0 @@
from flask import Blueprint, redirect, request, url_for
from flask_login import current_user
from app import db
bp = Blueprint('auth', __name__)
@bp.before_app_request
def before_request():
if not current_user.is_authenticated:
return
current_user.ping()
db.session.commit()
if (
not current_user.confirmed
and request.endpoint
and request.blueprint != 'auth'
and request.endpoint != 'static'
):
return redirect(url_for('auth.unconfirmed'))
if not current_user.terms_of_use_accepted:
return redirect(url_for('main.terms_of_use'))
from . import routes

View File

@ -1,25 +0,0 @@
from flask import Blueprint
from flask_login import login_required
bp = Blueprint('contributions', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import routes
from .spacy_nlp_pipeline_models import bp as spacy_nlp_pipeline_models_bp
bp.register_blueprint(spacy_nlp_pipeline_models_bp, url_prefix='/spacy-nlp-pipeline-models')
from .tesseract_ocr_pipeline_models import bp as tesseract_ocr_pipeline_models_bp
bp.register_blueprint(tesseract_ocr_pipeline_models_bp, url_prefix='/tesseract-ocr-pipeline-models')

View File

@ -1,7 +0,0 @@
from flask import render_template
from . import bp
@bp.route('')
def index():
return render_template('contributions/index.html.j2', title='Contributions')

View File

@ -1,18 +0,0 @@
from flask import current_app, Blueprint
from flask_login import login_required
bp = Blueprint('spacy_nlp_pipeline_models', __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

View File

@ -1,18 +0,0 @@
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

View File

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

View File

@ -2,7 +2,7 @@ from flask import Blueprint
from flask_login import login_required from flask_login import login_required
bp = Blueprint('tesseract_ocr_pipeline_models', __name__) bp = Blueprint('contributions', __name__)
@bp.before_request @bp.before_request
@ -15,4 +15,9 @@ def before_request():
pass pass
from . import json_routes, routes from . import (
routes,
spacy_nlp_pipeline_models,
tesseract_ocr_pipeline_models,
transkribus_htr_pipeline_models
)

View File

@ -0,0 +1,9 @@
from flask import redirect, url_for
from flask_breadcrumbs import register_breadcrumb
from . import bp
@bp.route('')
@register_breadcrumb(bp, '.', '<i class="material-icons left">new_label</i>My Contributions')
def contributions():
return redirect(url_for('main.dashboard', _anchor='contributions'))

View File

@ -1,7 +1,7 @@
from flask_wtf.file import FileField, FileRequired from flask_wtf.file import FileField, FileRequired
from wtforms import StringField, ValidationError from wtforms import StringField, ValidationError
from wtforms.validators import InputRequired, Length from wtforms.validators import InputRequired, Length
from app.blueprints.services import SERVICES from app.services import SERVICES
from ..forms import ContributionBaseForm, UpdateContributionBaseForm from ..forms import ContributionBaseForm, UpdateContributionBaseForm

View File

@ -1,14 +1,13 @@
from flask import abort, current_app, request from flask import abort, current_app, request
from flask_login import current_user, login_required from flask_login import current_user
from threading import Thread from threading import Thread
from app import db from app import db
from app.decorators import content_negotiation, permission_required from app.decorators import content_negotiation, permission_required
from app.models import SpaCyNLPPipelineModel from app.models import SpaCyNLPPipelineModel
from . import bp from .. import bp
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE']) @bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
@login_required
@content_negotiation(produces='application/json') @content_negotiation(produces='application/json')
def delete_spacy_model(spacy_nlp_pipeline_model_id): def delete_spacy_model(spacy_nlp_pipeline_model_id):
def _delete_spacy_model(app, spacy_nlp_pipeline_model_id): def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
@ -16,9 +15,9 @@ def delete_spacy_model(spacy_nlp_pipeline_model_id):
snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id) snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
snpm.delete() snpm.delete()
db.session.commit() db.session.commit()
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id) snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator): if not (snpm.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
thread = Thread( thread = Thread(
target=_delete_spacy_model, target=_delete_spacy_model,
@ -32,7 +31,7 @@ def delete_spacy_model(spacy_nlp_pipeline_model_id):
return response_data, 202 return response_data, 202
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT']) @bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
@permission_required('CONTRIBUTE') @permission_required('CONTRIBUTE')
@content_negotiation(consumes='application/json', produces='application/json') @content_negotiation(consumes='application/json', produces='application/json')
def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id): def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
@ -40,7 +39,7 @@ def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
if not isinstance(is_public, bool): if not isinstance(is_public, bool):
abort(400) abort(400)
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id) snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator): if not (snpm.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
snpm.is_public = is_public snpm.is_public = is_public
db.session.commit() db.session.commit()

View File

@ -1,5 +1,6 @@
from flask import abort, flash, redirect, render_template, url_for from flask import abort, flash, redirect, render_template, url_for
from flask_login import current_user, login_required from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user
from app import db from app import db
from app.models import SpaCyNLPPipelineModel from app.models import SpaCyNLPPipelineModel
from . import bp from . import bp
@ -7,17 +8,23 @@ from .forms import (
CreateSpaCyNLPPipelineModelForm, CreateSpaCyNLPPipelineModelForm,
UpdateSpaCyNLPPipelineModelForm UpdateSpaCyNLPPipelineModelForm
) )
from .utils import (
spacy_nlp_pipeline_model_dlc as spacy_nlp_pipeline_model_dlc
)
@bp.route('/') @bp.route('/spacy-nlp-pipeline-models')
@login_required @register_breadcrumb(bp, '.spacy_nlp_pipeline_models', 'SpaCy NLP Pipeline Models')
def index(): def spacy_nlp_pipeline_models():
return redirect(url_for('contributions.index', _anchor='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('/create', methods=['GET', 'POST']) @bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.spacy_nlp_pipeline_models.create', 'Create')
def create(): def create_spacy_nlp_pipeline_model():
form = CreateSpaCyNLPPipelineModelForm() form = CreateSpaCyNLPPipelineModelForm()
if form.is_submitted(): if form.is_submitted():
if not form.validate(): if not form.validate():
@ -41,7 +48,7 @@ def create():
abort(500) abort(500)
db.session.commit() db.session.commit()
flash(f'SpaCy NLP Pipeline model "{snpm.title}" created') flash(f'SpaCy NLP Pipeline model "{snpm.title}" created')
return {}, 201, {'Location': url_for('.index')} return {}, 201, {'Location': url_for('.spacy_nlp_pipeline_models')}
return render_template( return render_template(
'contributions/spacy_nlp_pipeline_models/create.html.j2', 'contributions/spacy_nlp_pipeline_models/create.html.j2',
title='Create SpaCy NLP Pipeline Model', title='Create SpaCy NLP Pipeline Model',
@ -49,11 +56,11 @@ def create():
) )
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST']) @bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
@login_required @register_breadcrumb(bp, '.spacy_nlp_pipeline_models.entity', '', dynamic_list_constructor=spacy_nlp_pipeline_model_dlc)
def entity(spacy_nlp_pipeline_model_id): def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id) snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator): if not (snpm.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
form = UpdateSpaCyNLPPipelineModelForm(data=snpm.to_json_serializeable()) form = UpdateSpaCyNLPPipelineModelForm(data=snpm.to_json_serializeable())
if form.validate_on_submit(): if form.validate_on_submit():
@ -61,9 +68,9 @@ def entity(spacy_nlp_pipeline_model_id):
if db.session.is_modified(snpm): if db.session.is_modified(snpm):
flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated') flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated')
db.session.commit() db.session.commit()
return redirect(url_for('.index')) return redirect(url_for('.spacy_nlp_pipeline_models'))
return render_template( return render_template(
'contributions/spacy_nlp_pipeline_models/entity.html.j2', 'contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2',
title=f'{snpm.title} {snpm.version}', title=f'{snpm.title} {snpm.version}',
form=form, form=form,
spacy_nlp_pipeline_model=snpm spacy_nlp_pipeline_model=snpm

View File

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

View File

@ -1,6 +1,6 @@
from flask_wtf.file import FileField, FileRequired from flask_wtf.file import FileField, FileRequired
from wtforms import ValidationError from wtforms import ValidationError
from app.blueprints.services import SERVICES from app.services import SERVICES
from ..forms import ContributionBaseForm, UpdateContributionBaseForm from ..forms import ContributionBaseForm, UpdateContributionBaseForm
@ -9,7 +9,7 @@ class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
'File', 'File',
validators=[FileRequired()] validators=[FileRequired()]
) )
def validate_tesseract_model_file(self, field): def validate_tesseract_model_file(self, field):
if not field.data.filename.lower().endswith('.traineddata'): if not field.data.filename.lower().endswith('.traineddata'):
raise ValidationError('traineddata files only!') raise ValidationError('traineddata files only!')

View File

@ -7,7 +7,7 @@ from app.models import TesseractOCRPipelineModel
from . import bp from . import bp
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE']) @bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
@content_negotiation(produces='application/json') @content_negotiation(produces='application/json')
def delete_tesseract_model(tesseract_ocr_pipeline_model_id): def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id): def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
@ -17,7 +17,7 @@ def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
db.session.commit() db.session.commit()
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id) topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator): if not (topm.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
thread = Thread( thread = Thread(
target=_delete_tesseract_ocr_pipeline_model, target=_delete_tesseract_ocr_pipeline_model,
@ -31,7 +31,7 @@ def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
return response_data, 202 return response_data, 202
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT']) @bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
@permission_required('CONTRIBUTE') @permission_required('CONTRIBUTE')
@content_negotiation(consumes='application/json', produces='application/json') @content_negotiation(consumes='application/json', produces='application/json')
def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id): def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id):
@ -39,7 +39,7 @@ def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_i
if not isinstance(is_public, bool): if not isinstance(is_public, bool):
abort(400) abort(400)
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id) topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator): if not (topm.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
topm.is_public = is_public topm.is_public = is_public
db.session.commit() db.session.commit()

View File

@ -1,4 +1,5 @@
from flask import abort, flash, redirect, render_template, url_for from flask import abort, flash, redirect, render_template, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user from flask_login import current_user
from app import db from app import db
from app.models import TesseractOCRPipelineModel from app.models import TesseractOCRPipelineModel
@ -7,15 +8,23 @@ from .forms import (
CreateTesseractOCRPipelineModelForm, CreateTesseractOCRPipelineModelForm,
UpdateTesseractOCRPipelineModelForm UpdateTesseractOCRPipelineModelForm
) )
from .utils import (
tesseract_ocr_pipeline_model_dlc as tesseract_ocr_pipeline_model_dlc
)
@bp.route('/') @bp.route('/tesseract-ocr-pipeline-models')
def index(): @register_breadcrumb(bp, '.tesseract_ocr_pipeline_models', 'Tesseract OCR Pipeline Models')
return redirect(url_for('contributions.index', _anchor='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('/create', methods=['GET', 'POST']) @bp.route('/tesseract-ocr-pipeline-models/create', methods=['GET', 'POST'])
def create(): @register_breadcrumb(bp, '.tesseract_ocr_pipeline_models.create', 'Create')
def create_tesseract_ocr_pipeline_model():
form = CreateTesseractOCRPipelineModelForm() form = CreateTesseractOCRPipelineModelForm()
if form.is_submitted(): if form.is_submitted():
if not form.validate(): if not form.validate():
@ -38,7 +47,7 @@ def create():
abort(500) abort(500)
db.session.commit() db.session.commit()
flash(f'Tesseract OCR Pipeline model "{topm.title}" created') flash(f'Tesseract OCR Pipeline model "{topm.title}" created')
return {}, 201, {'Location': url_for('.index')} return {}, 201, {'Location': url_for('.tesseract_ocr_pipeline_models')}
return render_template( return render_template(
'contributions/tesseract_ocr_pipeline_models/create.html.j2', 'contributions/tesseract_ocr_pipeline_models/create.html.j2',
title='Create Tesseract OCR Pipeline Model', title='Create Tesseract OCR Pipeline Model',
@ -46,10 +55,11 @@ def create():
) )
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST']) @bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
def entity(tesseract_ocr_pipeline_model_id): @register_breadcrumb(bp, '.tesseract_ocr_pipeline_models.entity', '', dynamic_list_constructor=tesseract_ocr_pipeline_model_dlc)
def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id) topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator): if not (topm.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
form = UpdateTesseractOCRPipelineModelForm(data=topm.to_json_serializeable()) form = UpdateTesseractOCRPipelineModelForm(data=topm.to_json_serializeable())
if form.validate_on_submit(): if form.validate_on_submit():
@ -57,9 +67,9 @@ def entity(tesseract_ocr_pipeline_model_id):
if db.session.is_modified(topm): if db.session.is_modified(topm):
flash(f'Tesseract OCR Pipeline model "{topm.title}" updated') flash(f'Tesseract OCR Pipeline model "{topm.title}" updated')
db.session.commit() db.session.commit()
return redirect(url_for('.index')) return redirect(url_for('.tesseract_ocr_pipeline_models'))
return render_template( return render_template(
'contributions/tesseract_ocr_pipeline_models/entity.html.j2', 'contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2',
title=f'{topm.title} {topm.version}', title=f'{topm.title} {topm.version}',
form=form, form=form,
tesseract_ocr_pipeline_model=topm tesseract_ocr_pipeline_model=topm

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
from datetime import datetime
from flask import current_app from flask import current_app
from pathlib import Path
import json
import shutil
from app import db from app import db
from app.models import User, Corpus, CorpusFile from app.models import User, Corpus, CorpusFile
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import json
import shutil
class SandpaperConverter: class SandpaperConverter:
@ -14,7 +15,7 @@ class SandpaperConverter:
def run(self): def run(self):
with self.json_db_file.open('r') as f: with self.json_db_file.open('r') as f:
json_db: list[dict] = json.load(f) json_db: List[Dict] = json.load(f)
for json_user in json_db: for json_user in json_db:
if not json_user['confirmed']: if not json_user['confirmed']:
@ -25,7 +26,7 @@ class SandpaperConverter:
db.session.commit() db.session.commit()
def convert_user(self, json_user: dict, user_dir: Path): def convert_user(self, json_user: Dict, user_dir: Path):
current_app.logger.info(f'Create User {json_user["username"]}...') current_app.logger.info(f'Create User {json_user["username"]}...')
try: try:
user = User.create( user = User.create(
@ -47,7 +48,7 @@ class SandpaperConverter:
current_app.logger.info('Done') current_app.logger.info('Done')
def convert_corpus(self, json_corpus: dict, user: User, corpus_dir: Path): def convert_corpus(self, json_corpus: Dict, user: User, corpus_dir: Path):
current_app.logger.info(f'Create Corpus {json_corpus["title"]}...') current_app.logger.info(f'Create Corpus {json_corpus["title"]}...')
try: try:
corpus = Corpus.create( corpus = Corpus.create(
@ -63,7 +64,7 @@ class SandpaperConverter:
current_app.logger.info('Done') current_app.logger.info('Done')
def convert_corpus_file(self, json_corpus_file: dict, corpus: Corpus, corpus_dir: Path): def convert_corpus_file(self, json_corpus_file: Dict, corpus: Corpus, corpus_dir: Path):
current_app.logger.info(f'Create CorpusFile {json_corpus_file["title"]}...') current_app.logger.info(f'Create CorpusFile {json_corpus_file["title"]}...')
corpus_file = CorpusFile( corpus_file = CorpusFile(
corpus=corpus, corpus=corpus,

View File

@ -1,25 +1,69 @@
from flask import current_app from flask import current_app
from pathlib import Path
def normalize_vrt_file(input_file: Path, output_file: Path): def normalize_vrt_file(input_file, output_file):
def check_pos_attribute_order(vrt_lines):
# The following orders are possible:
# since 26.02.2019: 'word,lemma,simple_pos,pos,ner'
# since 26.03.2021: 'word,pos,lemma,simple_pos,ner'
# since 27.01.2022: 'word,pos,lemma,simple_pos'
# This Function tries to find out which order we have by looking at the
# number of attributes and the position of the simple_pos attribute
SIMPLE_POS_LABELS = [
'ADJ', 'ADP', 'ADV', 'AUX', 'CONJ',
'DET', 'INTJ', 'NOUN', 'NUM', 'PART',
'PRON', 'PROPN', 'PUNCT', 'SCONJ', 'SYM',
'VERB', 'X'
]
for line in vrt_lines:
if line.startswith('<'):
continue
pos_attrs = line.rstrip('\n').split('\t')
num_pos_attrs = len(pos_attrs)
if num_pos_attrs == 4:
if pos_attrs[3] in SIMPLE_POS_LABELS:
return ['word', 'pos', 'lemma', 'simple_pos']
continue
elif num_pos_attrs == 5:
if pos_attrs[2] in SIMPLE_POS_LABELS:
return ['word', 'lemma', 'simple_pos', 'pos', 'ner']
elif pos_attrs[3] in SIMPLE_POS_LABELS:
return ['word', 'pos', 'lemma', 'simple_pos', 'ner']
continue
return None
def check_has_ent_as_s_attr(vrt_lines):
for line in vrt_lines:
if line.startswith('<ent'):
return True
return False
def pos_attrs_to_string_1(pos_attrs):
return f'{pos_attrs[0]}\t{pos_attrs[3]}\t{pos_attrs[1]}\t{pos_attrs[2]}\n'
def pos_attrs_to_string_2(pos_attrs):
return f'{pos_attrs[0]}\t{pos_attrs[1]}\t{pos_attrs[2]}\t{pos_attrs[3]}\n'
current_app.logger.info(f'Converting {input_file}...') current_app.logger.info(f'Converting {input_file}...')
with input_file.open() as f: with open(input_file) as f:
input_vrt_lines = f.readlines() input_vrt_lines = f.readlines()
pos_attr_order = _check_pos_attribute_order(input_vrt_lines) pos_attr_order = check_pos_attribute_order(input_vrt_lines)
has_ent_as_s_attr = _check_has_ent_as_s_attr(input_vrt_lines) has_ent_as_s_attr = check_has_ent_as_s_attr(input_vrt_lines)
current_app.logger.info(f'Detected pos_attr_order: [{",".join(pos_attr_order)}]') current_app.logger.info(f'Detected pos_attr_order: [{",".join(pos_attr_order)}]')
current_app.logger.info(f'Detected has_ent_as_s_attr: {has_ent_as_s_attr}') current_app.logger.info(f'Detected has_ent_as_s_attr: {has_ent_as_s_attr}')
if pos_attr_order == ['word', 'lemma', 'simple_pos', 'pos', 'ner']: if pos_attr_order == ['word', 'lemma', 'simple_pos', 'pos', 'ner']:
pos_attrs_to_string_function = _pos_attrs_to_string_1 pos_attrs_to_string_function = pos_attrs_to_string_1
elif pos_attr_order == ['word', 'pos', 'lemma', 'simple_pos', 'ner']: elif pos_attr_order == ['word', 'pos', 'lemma', 'simple_pos', 'ner']:
pos_attrs_to_string_function = _pos_attrs_to_string_2 pos_attrs_to_string_function = pos_attrs_to_string_2
elif pos_attr_order == ['word', 'pos', 'lemma', 'simple_pos']: elif pos_attr_order == ['word', 'pos', 'lemma', 'simple_pos']:
pos_attrs_to_string_function = _pos_attrs_to_string_2 pos_attrs_to_string_function = pos_attrs_to_string_2
else: else:
raise Exception('Can not handle format') raise Exception('Can not handle format')
@ -69,49 +113,5 @@ def normalize_vrt_file(input_file: Path, output_file: Path):
current_ent = pos_attrs[4] current_ent = pos_attrs[4]
output_vrt += pos_attrs_to_string_function(pos_attrs) output_vrt += pos_attrs_to_string_function(pos_attrs)
with output_file.open(mode='w') as f: with open(output_file, 'w') as f:
f.write(output_vrt) f.write(output_vrt)
def _check_pos_attribute_order(vrt_lines: list[str]) -> list[str]:
# The following orders are possible:
# since 26.02.2019: 'word,lemma,simple_pos,pos,ner'
# since 26.03.2021: 'word,pos,lemma,simple_pos,ner'
# since 27.01.2022: 'word,pos,lemma,simple_pos'
# This Function tries to find out which order we have by looking at the
# number of attributes and the position of the simple_pos attribute
SIMPLE_POS_LABELS = [
'ADJ', 'ADP', 'ADV', 'AUX', 'CONJ', 'DET', 'INTJ', 'NOUN', 'NUM',
'PART', 'PRON', 'PROPN', 'PUNCT', 'SCONJ', 'SYM', 'VERB', 'X'
]
for line in vrt_lines:
if line.startswith('<'):
continue
pos_attrs = line.rstrip('\n').split('\t')
num_pos_attrs = len(pos_attrs)
if num_pos_attrs == 4:
if pos_attrs[3] in SIMPLE_POS_LABELS:
return ['word', 'pos', 'lemma', 'simple_pos']
continue
elif num_pos_attrs == 5:
if pos_attrs[2] in SIMPLE_POS_LABELS:
return ['word', 'lemma', 'simple_pos', 'pos', 'ner']
elif pos_attrs[3] in SIMPLE_POS_LABELS:
return ['word', 'pos', 'lemma', 'simple_pos', 'ner']
continue
# TODO: raise exception "can't determine attribute order"
def _check_has_ent_as_s_attr(vrt_lines: list[str]) -> bool:
for line in vrt_lines:
if line.startswith('<ent'):
return True
return False
def _pos_attrs_to_string_1(pos_attrs: list[str]) -> str:
return f'{pos_attrs[0]}\t{pos_attrs[3]}\t{pos_attrs[1]}\t{pos_attrs[2]}\n'
def _pos_attrs_to_string_2(pos_attrs: list[str]) -> str:
return f'{pos_attrs[0]}\t{pos_attrs[1]}\t{pos_attrs[2]}\t{pos_attrs[3]}\n'

View File

@ -1,16 +1,17 @@
from cqi import CQiClient from cqi import CQiClient
from cqi.errors import CQiException from cqi.errors import CQiException
from cqi.status import CQiStatus from cqi.status import CQiStatus
from flask import current_app from docker.models.containers import Container
from flask import current_app, session
from flask_login import current_user from flask_login import current_user
from flask_socketio import Namespace from flask_socketio import Namespace
from inspect import signature from inspect import signature
from threading import Lock from threading import Lock
from typing import Callable, Dict, List, Optional
from app import db, docker_client, hashids, socketio from app import db, docker_client, hashids, socketio
from app.decorators import socketio_login_required from app.decorators import socketio_login_required
from app.models import Corpus, CorpusStatus from app.models import Corpus, CorpusStatus
from . import cqi_extensions from . import extensions
from .utils import CQiOverSocketIOSessionManager
''' '''
@ -18,7 +19,7 @@ This package tunnels the Corpus Query interface (CQi) protocol through
Socket.IO (SIO) by tunneling CQi API calls through an event called "exec". Socket.IO (SIO) by tunneling CQi API calls through an event called "exec".
Basic concept: Basic concept:
1. A client connects to the namespace. 1. A client connects to the "/cqi_over_sio" namespace.
2. The client emits the "init" event and provides a corpus id for the corpus 2. The client emits the "init" event and provides a corpus id for the corpus
that should be analysed in this session. that should be analysed in this session.
1.1 The analysis session counter of the corpus is incremented. 1.1 The analysis session counter of the corpus is incremented.
@ -27,17 +28,17 @@ Basic concept:
1.4 Connect the CQiClient to the server. 1.4 Connect the CQiClient to the server.
1.5 Save the CQiClient, the Lock and the corpus id in the session for 1.5 Save the CQiClient, the Lock and the corpus id in the session for
subsequential use. subsequential use.
3. The client emits "exec" events, within which it provides the name of a CQi 2. The client emits the "exec" event provides the name of a CQi API function
API function and the corresponding arguments. arguments (optional).
3.1 The "exec" event handler will execute the function, make sure that - The event "exec" handler will execute the function, make sure that the
the result is serializable and returns the result back to the client. result is serializable and returns the result back to the client.
4. The client disconnects from the namespace 4. Wait for more events
4.1 The analysis session counter of the corpus is decremented. 5. The client disconnects from the "/cqi_over_sio" namespace
4.2 The CQiClient and (Mutex) Lock belonging to it are teared down. 1.1 The analysis session counter of the corpus is decremented.
1.2 The CQiClient and (Mutex) Lock belonging to it are teared down.
''' '''
CQI_API_FUNCTION_NAMES: List[str] = [
CQI_API_FUNCTION_NAMES = [
'ask_feature_cl_2_3', 'ask_feature_cl_2_3',
'ask_feature_cqi_1_0', 'ask_feature_cqi_1_0',
'ask_feature_cqp_2_3', 'ask_feature_cqp_2_3',
@ -85,80 +86,68 @@ CQI_API_FUNCTION_NAMES = [
] ]
class CQiOverSocketIONamespace(Namespace): class CQiNamespace(Namespace):
@socketio_login_required @socketio_login_required
def on_connect(self): def on_connect(self):
pass pass
@socketio_login_required @socketio_login_required
def on_init(self, corpus_hashid: str) -> dict: def on_init(self, db_corpus_hashid: str):
corpus_id = hashids.decode(corpus_hashid) db_corpus_id: int = hashids.decode(db_corpus_hashid)
db_corpus: Optional[Corpus] = Corpus.query.get(db_corpus_id)
if not isinstance(corpus_id, int): if db_corpus is None:
return {'code': 400, 'msg': 'Bad Request'}
corpus = Corpus.query.get(corpus_id)
if corpus is None:
return {'code': 404, 'msg': 'Not Found'} return {'code': 404, 'msg': 'Not Found'}
if not (db_corpus.user == current_user
if not ( or current_user.is_following_corpus(db_corpus)
corpus.user == current_user or current_user.is_administrator()):
or current_user.is_following_corpus(corpus)
or current_user.is_administrator
):
return {'code': 403, 'msg': 'Forbidden'} return {'code': 403, 'msg': 'Forbidden'}
if db_corpus.status not in [
if corpus.status not in [
CorpusStatus.BUILT, CorpusStatus.BUILT,
CorpusStatus.STARTING_ANALYSIS_SESSION, CorpusStatus.STARTING_ANALYSIS_SESSION,
CorpusStatus.RUNNING_ANALYSIS_SESSION, CorpusStatus.RUNNING_ANALYSIS_SESSION,
CorpusStatus.CANCELING_ANALYSIS_SESSION CorpusStatus.CANCELING_ANALYSIS_SESSION
]: ]:
return {'code': 424, 'msg': 'Failed Dependency'} return {'code': 424, 'msg': 'Failed Dependency'}
if db_corpus.num_analysis_sessions is None:
corpus.num_analysis_sessions = Corpus.num_analysis_sessions + 1 db_corpus.num_analysis_sessions = 0
db.session.commit()
db_corpus.num_analysis_sessions = Corpus.num_analysis_sessions + 1
db.session.commit() db.session.commit()
retry_counter = 20 retry_counter: int = 20
while corpus.status != CorpusStatus.RUNNING_ANALYSIS_SESSION: while db_corpus.status != CorpusStatus.RUNNING_ANALYSIS_SESSION:
if retry_counter == 0: if retry_counter == 0:
corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1 db_corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
db.session.commit() db.session.commit()
return {'code': 408, 'msg': 'Request Timeout'} return {'code': 408, 'msg': 'Request Timeout'}
socketio.sleep(3) socketio.sleep(3)
retry_counter -= 1 retry_counter -= 1
db.session.refresh(corpus) db.session.refresh(db_corpus)
# cqi_client: CQiClient = CQiClient(f'cqpserver_{db_corpus_id}')
cqpserver_container_name = f'nopaque-cqpserver-{corpus_id}' cqpserver_container_name: str = f'cqpserver_{db_corpus_id}'
cqpserver_container = docker_client.containers.get(cqpserver_container_name) cqpserver_container: Container = docker_client.containers.get(cqpserver_container_name)
cqpserver_ip_address = cqpserver_container.attrs['NetworkSettings']['Networks'][current_app.config['NOPAQUE_DOCKER_NETWORK_NAME']]['IPAddress'] cqpserver_host: str = cqpserver_container.attrs['NetworkSettings']['Networks'][current_app.config['NOPAQUE_DOCKER_NETWORK_NAME']]['IPAddress']
cqi_client = CQiClient(cqpserver_ip_address) cqi_client: CQiClient = CQiClient(cqpserver_host)
cqi_client_lock = Lock() session['cqi_over_sio'] = {
'cqi_client': cqi_client,
CQiOverSocketIOSessionManager.setup() 'cqi_client_lock': Lock(),
CQiOverSocketIOSessionManager.set_corpus_id(corpus_id) 'db_corpus_id': db_corpus_id
CQiOverSocketIOSessionManager.set_cqi_client(cqi_client) }
CQiOverSocketIOSessionManager.set_cqi_client_lock(cqi_client_lock)
return {'code': 200, 'msg': 'OK'} return {'code': 200, 'msg': 'OK'}
@socketio_login_required @socketio_login_required
def on_exec(self, fn_name: str, fn_args: dict = {}) -> dict: def on_exec(self, fn_name: str, fn_args: Dict = {}):
try: try:
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
cqi_client_lock = CQiOverSocketIOSessionManager.get_cqi_client_lock() cqi_client_lock: Lock = session['cqi_over_sio']['cqi_client_lock']
except KeyError: except KeyError:
return {'code': 424, 'msg': 'Failed Dependency'} return {'code': 424, 'msg': 'Failed Dependency'}
if fn_name in CQI_API_FUNCTION_NAMES: if fn_name in CQI_API_FUNCTION_NAMES:
fn = getattr(cqi_client.api, fn_name) fn: Callable = getattr(cqi_client.api, fn_name)
elif fn_name in cqi_extensions.CQI_EXTENSION_FUNCTION_NAMES: elif fn_name in extensions.CQI_EXTENSION_FUNCTION_NAMES:
fn = getattr(cqi_extensions, fn_name) fn: Callable = getattr(extensions, fn_name)
else: else:
return {'code': 400, 'msg': 'Bad Request'} return {'code': 400, 'msg': 'Bad Request'}
for param in signature(fn).parameters.values(): for param in signature(fn).parameters.values():
# Check if the parameter is optional or required
if param.default is param.empty: if param.default is param.empty:
if param.name not in fn_args: if param.name not in fn_args:
return {'code': 400, 'msg': 'Bad Request'} return {'code': 400, 'msg': 'Bad Request'}
@ -167,7 +156,6 @@ class CQiOverSocketIONamespace(Namespace):
continue continue
if type(fn_args[param.name]) is not param.annotation: if type(fn_args[param.name]) is not param.annotation:
return {'code': 400, 'msg': 'Bad Request'} return {'code': 400, 'msg': 'Bad Request'}
cqi_client_lock.acquire() cqi_client_lock.acquire()
try: try:
fn_return_value = fn(**fn_args) fn_return_value = fn(**fn_args)
@ -185,7 +173,6 @@ class CQiOverSocketIONamespace(Namespace):
} }
finally: finally:
cqi_client_lock.release() cqi_client_lock.release()
if isinstance(fn_return_value, CQiStatus): if isinstance(fn_return_value, CQiStatus):
payload = { payload = {
'code': fn_return_value.code, 'code': fn_return_value.code,
@ -193,31 +180,27 @@ class CQiOverSocketIONamespace(Namespace):
} }
else: else:
payload = fn_return_value payload = fn_return_value
return {'code': 200, 'msg': 'OK', 'payload': payload} return {'code': 200, 'msg': 'OK', 'payload': payload}
def on_disconnect(self): def on_disconnect(self):
try: try:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id() cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client_lock: Lock = session['cqi_over_sio']['cqi_client_lock']
cqi_client_lock = CQiOverSocketIOSessionManager.get_cqi_client_lock() db_corpus_id: int = session['cqi_over_sio']['db_corpus_id']
CQiOverSocketIOSessionManager.teardown()
except KeyError: except KeyError:
return return
cqi_client_lock.acquire() cqi_client_lock.acquire()
try:
session.pop('cqi_over_sio')
except KeyError:
pass
try: try:
cqi_client.api.ctrl_bye() cqi_client.api.ctrl_bye()
except (BrokenPipeError, CQiException): except (BrokenPipeError, CQiException):
pass pass
cqi_client_lock.release() cqi_client_lock.release()
db_corpus: Optional[Corpus] = Corpus.query.get(db_corpus_id)
corpus = Corpus.query.get(corpus_id) if db_corpus is None:
if corpus is None:
return return
db_corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
db.session.commit() db.session.commit()

View File

@ -1,17 +1,23 @@
from collections import Counter from collections import Counter
from cqi import CQiClient
from cqi.models.corpora import Corpus as CQiCorpus from cqi.models.corpora import Corpus as CQiCorpus
from cqi.models.subcorpora import Subcorpus as CQiSubcorpus 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 cqi.status import StatusOk as CQiStatusOk
from flask import current_app from flask import session
from typing import Dict, List
import gzip import gzip
import json import json
import math import math
from app import db from app import db
from app.models import Corpus from app.models import Corpus
from .utils import CQiOverSocketIOSessionManager from .utils import lookups_by_cpos, partial_export_subcorpus, export_subcorpus
CQI_EXTENSION_FUNCTION_NAMES = [ CQI_EXTENSION_FUNCTION_NAMES: List[str] = [
'ext_corpus_update_db', 'ext_corpus_update_db',
'ext_corpus_static_data', 'ext_corpus_static_data',
'ext_corpus_paginate_corpus', 'ext_corpus_paginate_corpus',
@ -22,28 +28,28 @@ CQI_EXTENSION_FUNCTION_NAMES = [
def ext_corpus_update_db(corpus: str) -> CQiStatusOk: def ext_corpus_update_db(corpus: str) -> CQiStatusOk:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id() cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() db_corpus_id: int = session['cqi_over_sio']['db_corpus_id']
db_corpus = Corpus.query.get(corpus_id) db_corpus: Corpus = Corpus.query.get(db_corpus_id)
cqi_corpus = cqi_client.corpora.get(corpus) cqi_corpus: CQiCorpus = cqi_client.corpora.get(corpus)
db_corpus.num_tokens = cqi_corpus.size db_corpus.num_tokens = cqi_corpus.size
db.session.commit() db.session.commit()
return CQiStatusOk() return CQiStatusOk()
def ext_corpus_static_data(corpus: str) -> dict: def ext_corpus_static_data(corpus: str) -> Dict:
corpus_id = CQiOverSocketIOSessionManager.get_corpus_id() db_corpus_id: int = session['cqi_over_sio']['db_corpus_id']
db_corpus = Corpus.query.get(corpus_id) db_corpus: Corpus = Corpus.query.get(db_corpus_id)
static_data_file_path = db_corpus.path / 'cwb' / 'static.json.gz' static_data_file_path = db_corpus.path / 'cwb' / 'static.json.gz'
if static_data_file_path.exists(): if static_data_file_path.exists():
with static_data_file_path.open('rb') as f: with static_data_file_path.open('rb') as f:
return f.read() return f.read()
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
cqi_corpus = cqi_client.corpora.get(corpus) cqi_corpus: CQiCorpus = cqi_client.corpora.get(corpus)
cqi_p_attrs = cqi_corpus.positional_attributes.list() cqi_p_attrs: List[CQiPositionalAttribute] = cqi_corpus.positional_attributes.list()
cqi_s_attrs = cqi_corpus.structural_attributes.list() cqi_s_attrs: List[CQiStructuralAttribute] = cqi_corpus.structural_attributes.list()
static_data = { static_data = {
'corpus': { 'corpus': {
@ -56,21 +62,21 @@ def ext_corpus_static_data(corpus: str) -> dict:
} }
for p_attr in cqi_p_attrs: for p_attr in cqi_p_attrs:
current_app.logger.info(f'corpus.freqs.{p_attr.name}') print(f'corpus.freqs.{p_attr.name}')
static_data['corpus']['freqs'][p_attr.name] = [] static_data['corpus']['freqs'][p_attr.name] = []
p_attr_id_list = list(range(p_attr.lexicon_size)) 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)) static_data['corpus']['freqs'][p_attr.name].extend(p_attr.freqs_by_ids(p_attr_id_list))
del p_attr_id_list del p_attr_id_list
current_app.logger.info(f'p_attrs.{p_attr.name}') print(f'p_attrs.{p_attr.name}')
static_data['p_attrs'][p_attr.name] = [] static_data['p_attrs'][p_attr.name] = []
cpos_list = list(range(cqi_corpus.size)) cpos_list: List[int] = list(range(cqi_corpus.size))
static_data['p_attrs'][p_attr.name].extend(p_attr.ids_by_cpos(cpos_list)) static_data['p_attrs'][p_attr.name].extend(p_attr.ids_by_cpos(cpos_list))
del cpos_list del cpos_list
current_app.logger.info(f'values.p_attrs.{p_attr.name}') print(f'values.p_attrs.{p_attr.name}')
static_data['values']['p_attrs'][p_attr.name] = [] static_data['values']['p_attrs'][p_attr.name] = []
p_attr_id_list = list(range(p_attr.lexicon_size)) 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)) static_data['values']['p_attrs'][p_attr.name].extend(p_attr.values_by_ids(p_attr_id_list))
del p_attr_id_list del p_attr_id_list
@ -86,9 +92,9 @@ def ext_corpus_static_data(corpus: str) -> dict:
# Note: Needs more testing, don't use it in production # # Note: Needs more testing, don't use it in production #
############################################################## ##############################################################
cqi_corpus.query('Last', f'<{s_attr.name}> []* </{s_attr.name}>;') cqi_corpus.query('Last', f'<{s_attr.name}> []* </{s_attr.name}>;')
cqi_subcorpus = cqi_corpus.subcorpora.get('Last') cqi_subcorpus: CQiSubcorpus = cqi_corpus.subcorpora.get('Last')
first_match = 0 first_match: int = 0
last_match = cqi_subcorpus.size - 1 last_match: int = cqi_subcorpus.size - 1
match_boundaries = zip( match_boundaries = zip(
range(first_match, last_match + 1), range(first_match, last_match + 1),
cqi_subcorpus.dump( cqi_subcorpus.dump(
@ -106,7 +112,7 @@ def ext_corpus_static_data(corpus: str) -> dict:
del cqi_subcorpus, first_match, last_match del cqi_subcorpus, first_match, last_match
for id, lbound, rbound in match_boundaries: for id, lbound, rbound in match_boundaries:
static_data['s_attrs'][s_attr.name]['lexicon'].append({}) static_data['s_attrs'][s_attr.name]['lexicon'].append({})
current_app.logger.info(f's_attrs.{s_attr.name}.lexicon.{id}.bounds') 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]['bounds'] = [lbound, rbound]
del match_boundaries del match_boundaries
@ -118,33 +124,33 @@ def ext_corpus_static_data(corpus: str) -> dict:
# This is a very slow operation, thats why we only use it for # This is a very slow operation, thats why we only use it for
# the text attribute # the text attribute
lbound, rbound = s_attr.cpos_by_id(id) lbound, rbound = s_attr.cpos_by_id(id)
current_app.logger.info(f's_attrs.{s_attr.name}.lexicon.{id}.bounds') 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]['bounds'] = [lbound, rbound]
static_data['s_attrs'][s_attr.name]['lexicon'][id]['freqs'] = {} static_data['s_attrs'][s_attr.name]['lexicon'][id]['freqs'] = {}
cpos_list = list(range(lbound, rbound + 1)) cpos_list: List[int] = list(range(lbound, rbound + 1))
for p_attr in cqi_p_attrs: for p_attr in cqi_p_attrs:
p_attr_ids = [] p_attr_ids: List[int] = []
p_attr_ids.extend(p_attr.ids_by_cpos(cpos_list)) p_attr_ids.extend(p_attr.ids_by_cpos(cpos_list))
current_app.logger.info(f's_attrs.{s_attr.name}.lexicon.{id}.freqs.{p_attr.name}') 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)) static_data['s_attrs'][s_attr.name]['lexicon'][id]['freqs'][p_attr.name] = dict(Counter(p_attr_ids))
del p_attr_ids del p_attr_ids
del cpos_list del cpos_list
sub_s_attrs = cqi_corpus.structural_attributes.list(filters={'part_of': s_attr}) sub_s_attrs: List[CQiStructuralAttribute] = cqi_corpus.structural_attributes.list(filters={'part_of': s_attr})
current_app.logger.info(f's_attrs.{s_attr.name}.values') print(f's_attrs.{s_attr.name}.values')
static_data['s_attrs'][s_attr.name]['values'] = [ static_data['s_attrs'][s_attr.name]['values'] = [
sub_s_attr.name[(len(s_attr.name) + 1):] sub_s_attr.name[(len(s_attr.name) + 1):]
for sub_s_attr in sub_s_attrs for sub_s_attr in sub_s_attrs
] ]
s_attr_id_list = list(range(s_attr.size)) s_attr_id_list: List[int] = list(range(s_attr.size))
sub_s_attr_values = [] sub_s_attr_values: List[str] = []
for sub_s_attr in sub_s_attrs: for sub_s_attr in sub_s_attrs:
tmp = [] tmp = []
tmp.extend(sub_s_attr.values_by_ids(s_attr_id_list)) tmp.extend(sub_s_attr.values_by_ids(s_attr_id_list))
sub_s_attr_values.append(tmp) sub_s_attr_values.append(tmp)
del tmp del tmp
del s_attr_id_list del s_attr_id_list
current_app.logger.info(f'values.s_attrs.{s_attr.name}') print(f'values.s_attrs.{s_attr.name}')
static_data['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] s_attr_value_name: sub_s_attr_values[s_attr_value_name_idx][s_attr_id]
@ -154,11 +160,11 @@ def ext_corpus_static_data(corpus: str) -> dict:
} for s_attr_id in range(0, s_attr.size) } for s_attr_id in range(0, s_attr.size)
] ]
del sub_s_attr_values del sub_s_attr_values
current_app.logger.info('Saving static data to file') print('Saving static data to file')
with gzip.open(static_data_file_path, 'wt') as f: with gzip.open(static_data_file_path, 'wt') as f:
json.dump(static_data, f) json.dump(static_data, f)
del static_data del static_data
current_app.logger.info('Sending static data to client') print('Sending static data to client')
with open(static_data_file_path, 'rb') as f: with open(static_data_file_path, 'rb') as f:
return f.read() return f.read()
@ -167,8 +173,8 @@ def ext_corpus_paginate_corpus(
corpus: str, corpus: str,
page: int = 1, page: int = 1,
per_page: int = 20 per_page: int = 20
) -> dict: ) -> Dict:
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
cqi_corpus = cqi_client.corpora.get(corpus) cqi_corpus = cqi_client.corpora.get(corpus)
# Sanity checks # Sanity checks
if ( if (
@ -183,7 +189,7 @@ def ext_corpus_paginate_corpus(
first_cpos = (page - 1) * per_page first_cpos = (page - 1) * per_page
last_cpos = min(cqi_corpus.size, first_cpos + per_page) last_cpos = min(cqi_corpus.size, first_cpos + per_page)
cpos_list = [*range(first_cpos, last_cpos)] cpos_list = [*range(first_cpos, last_cpos)]
lookups = _lookups_by_cpos(cqi_corpus, cpos_list) lookups = lookups_by_cpos(cqi_corpus, cpos_list)
payload = {} payload = {}
# the items for the current page # the items for the current page
payload['items'] = [cpos_list] payload['items'] = [cpos_list]
@ -213,9 +219,9 @@ def ext_cqp_paginate_subcorpus(
context: int = 50, context: int = 50,
page: int = 1, page: int = 1,
per_page: int = 20 per_page: int = 20
) -> dict: ) -> Dict:
corpus_name, subcorpus_name = subcorpus.split(':', 1) corpus_name, subcorpus_name = subcorpus.split(':', 1)
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
cqi_corpus = cqi_client.corpora.get(corpus_name) cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name) cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
# Sanity checks # Sanity checks
@ -230,7 +236,7 @@ def ext_cqp_paginate_subcorpus(
return {'code': 416, 'msg': 'Range Not Satisfiable'} return {'code': 416, 'msg': 'Range Not Satisfiable'}
offset = (page - 1) * per_page offset = (page - 1) * per_page
cutoff = per_page cutoff = per_page
cqi_results_export = _export_subcorpus( cqi_results_export = export_subcorpus(
cqi_subcorpus, context=context, cutoff=cutoff, offset=offset) cqi_subcorpus, context=context, cutoff=cutoff, offset=offset)
payload = {} payload = {}
# the items for the current page # the items for the current page
@ -260,147 +266,22 @@ def ext_cqp_partial_export_subcorpus(
subcorpus: str, subcorpus: str,
match_id_list: list, match_id_list: list,
context: int = 50 context: int = 50
) -> dict: ) -> Dict:
corpus_name, subcorpus_name = subcorpus.split(':', 1) corpus_name, subcorpus_name = subcorpus.split(':', 1)
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
cqi_corpus = cqi_client.corpora.get(corpus_name) cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name) cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
cqi_subcorpus_partial_export = _partial_export_subcorpus(cqi_subcorpus, match_id_list, context=context) cqi_subcorpus_partial_export = partial_export_subcorpus(cqi_subcorpus, match_id_list, context=context)
return cqi_subcorpus_partial_export return cqi_subcorpus_partial_export
def ext_cqp_export_subcorpus(subcorpus: str, context: int = 50) -> dict: def ext_cqp_export_subcorpus(
subcorpus: str,
context: int = 50
) -> Dict:
corpus_name, subcorpus_name = subcorpus.split(':', 1) corpus_name, subcorpus_name = subcorpus.split(':', 1)
cqi_client = CQiOverSocketIOSessionManager.get_cqi_client() cqi_client: CQiClient = session['cqi_over_sio']['cqi_client']
cqi_corpus = cqi_client.corpora.get(corpus_name) cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name) cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
cqi_subcorpus_export = _export_subcorpus(cqi_subcorpus, context=context) cqi_subcorpus_export = export_subcorpus(cqi_subcorpus, context=context)
return cqi_subcorpus_export return cqi_subcorpus_export
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 = 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 = 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 = 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}

View File

@ -0,0 +1,131 @@
from cqi.models.corpora import Corpus as CQiCorpus
from cqi.models.subcorpora import Subcorpus as CQiSubcorpus
from typing import Dict, List
def lookups_by_cpos(corpus: CQiCorpus, cpos_list: List[int]) -> Dict:
lookups = {}
lookups['cpos_lookup'] = {cpos: {} for cpos in cpos_list}
for attr in corpus.positional_attributes.list():
cpos_attr_values: List[str] = attr.values_by_cpos(cpos_list)
for i, cpos in enumerate(cpos_list):
lookups['cpos_lookup'][cpos][attr.name] = cpos_attr_values[i]
for attr in corpus.structural_attributes.list():
# We only want to iterate over non subattributes, identifiable by
# attr.has_values == False
if attr.has_values:
continue
cpos_attr_ids: List[int] = attr.ids_by_cpos(cpos_list)
for i, cpos in enumerate(cpos_list):
if cpos_attr_ids[i] == -1:
continue
lookups['cpos_lookup'][cpos][attr.name] = cpos_attr_ids[i]
occured_attr_ids = [x for x in set(cpos_attr_ids) if x != -1]
if len(occured_attr_ids) == 0:
continue
subattrs = corpus.structural_attributes.list(filters={'part_of': attr})
if len(subattrs) == 0:
continue
lookup_name: str = f'{attr.name}_lookup'
lookups[lookup_name] = {}
for attr_id in occured_attr_ids:
lookups[lookup_name][attr_id] = {}
for subattr in subattrs:
subattr_name = subattr.name[(len(attr.name) + 1):] # noqa
for i, subattr_value in enumerate(subattr.values_by_ids(occured_attr_ids)): # noqa
lookups[lookup_name][occured_attr_ids[i]][subattr_name] = subattr_value # noqa
return lookups
def partial_export_subcorpus(
subcorpus: CQiSubcorpus,
match_id_list: List[int],
context: int = 25
) -> Dict:
if subcorpus.size == 0:
return {"matches": []}
match_boundaries = []
for match_id in match_id_list:
if match_id < 0 or match_id >= subcorpus.size:
continue
match_boundaries.append(
(
match_id,
subcorpus.dump(subcorpus.fields['match'], match_id, match_id)[0],
subcorpus.dump(subcorpus.fields['matchend'], match_id, match_id)[0]
)
)
cpos_set = set()
matches = []
for match_boundary in match_boundaries:
match_num, match_start, match_end = match_boundary
c = (match_start, match_end)
if match_start == 0 or context == 0:
lc = None
cpos_list_lbound = match_start
else:
lc_lbound = max(0, (match_start - context))
lc_rbound = match_start - 1
lc = (lc_lbound, lc_rbound)
cpos_list_lbound = lc_lbound
if match_end == (subcorpus.collection.corpus.size - 1) or context == 0:
rc = None
cpos_list_rbound = match_end
else:
rc_lbound = match_end + 1
rc_rbound = min(
(match_end + context),
(subcorpus.collection.corpus.size - 1)
)
rc = (rc_lbound, rc_rbound)
cpos_list_rbound = rc_rbound
match = {'num': match_num, 'lc': lc, 'c': c, 'rc': rc}
matches.append(match)
cpos_set.update(range(cpos_list_lbound, cpos_list_rbound + 1))
lookups = lookups_by_cpos(subcorpus.collection.corpus, list(cpos_set))
return {'matches': matches, **lookups}
def export_subcorpus(
subcorpus: CQiSubcorpus,
context: int = 25,
cutoff: float = float('inf'),
offset: int = 0
) -> Dict:
if subcorpus.size == 0:
return {"matches": []}
first_match = max(0, offset)
last_match = min((offset + cutoff - 1), (subcorpus.size - 1))
match_boundaries = zip(
range(first_match, last_match + 1),
subcorpus.dump(subcorpus.fields['match'], first_match, last_match),
subcorpus.dump(subcorpus.fields['matchend'], first_match, last_match)
)
cpos_set = set()
matches = []
for match_num, match_start, match_end in match_boundaries:
c = (match_start, match_end)
if match_start == 0 or context == 0:
lc = None
cpos_list_lbound = match_start
else:
lc_lbound = max(0, (match_start - context))
lc_rbound = match_start - 1
lc = (lc_lbound, lc_rbound)
cpos_list_lbound = lc_lbound
if match_end == (subcorpus.collection.corpus.size - 1) or context == 0:
rc = None
cpos_list_rbound = match_end
else:
rc_lbound = match_end + 1
rc_rbound = min(
(match_end + context),
(subcorpus.collection.corpus.size - 1)
)
rc = (rc_lbound, rc_rbound)
cpos_list_rbound = rc_rbound
match = {'num': match_num, 'lc': lc, 'c': c, 'rc': rc}
matches.append(match)
cpos_set.update(range(cpos_list_lbound, cpos_list_rbound + 1))
lookups = lookups_by_cpos(subcorpus.collection.corpus, list(cpos_set))
return {'matches': matches, **lookups}

View File

@ -10,7 +10,7 @@ def corpus_follower_permission_required(*permissions):
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
corpus_id = kwargs.get('corpus_id') corpus_id = kwargs.get('corpus_id')
corpus = Corpus.query.get_or_404(corpus_id) corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator): 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() cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if cfa is None: if cfa is None:
abort(403) abort(403)
@ -26,7 +26,7 @@ def corpus_owner_or_admin_required(f):
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
corpus_id = kwargs.get('corpus_id') corpus_id = kwargs.get('corpus_id')
corpus = Corpus.query.get_or_404(corpus_id) corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator): if not (corpus.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function

View File

@ -15,7 +15,7 @@ def get_corpus(corpus_hashid):
if not ( if not (
corpus.is_public corpus.is_public
or corpus.user == current_user or corpus.user == current_user
or current_user.is_administrator or current_user.is_administrator()
): ):
return {'options': {'status': 403, 'statusText': 'Forbidden'}} return {'options': {'status': 403, 'statusText': 'Forbidden'}}
return { return {
@ -38,7 +38,7 @@ def subscribe_corpus(corpus_hashid):
if not ( if not (
corpus.is_public corpus.is_public
or corpus.user == current_user or corpus.user == current_user
or current_user.is_administrator or current_user.is_administrator()
): ):
return {'options': {'status': 403, 'statusText': 'Forbidden'}} return {'options': {'status': 403, 'statusText': 'Forbidden'}}
join_room(f'/corpora/{corpus.hashid}') join_room(f'/corpora/{corpus.hashid}')

View File

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

View File

@ -1,7 +1,7 @@
from flask import current_app from flask import abort, current_app
from threading import Thread from threading import Thread
from app.decorators import content_negotiation
from app import db from app import db
from app.decorators import content_negotiation
from app.models import CorpusFile from app.models import CorpusFile
from ..decorators import corpus_follower_permission_required from ..decorators import corpus_follower_permission_required
from . import bp from . import bp

View File

@ -6,19 +6,24 @@ from flask import (
send_from_directory, send_from_directory,
url_for url_for
) )
from flask_breadcrumbs import register_breadcrumb
from app import db from app import db
from app.models import Corpus, CorpusFile, CorpusStatus from app.models import Corpus, CorpusFile, CorpusStatus
from ..decorators import corpus_follower_permission_required from ..decorators import corpus_follower_permission_required
from ..utils import corpus_endpoint_arguments_constructor as corpus_eac
from . import bp from . import bp
from .forms import CreateCorpusFileForm, UpdateCorpusFileForm from .forms import CreateCorpusFileForm, UpdateCorpusFileForm
from .utils import corpus_file_dynamic_list_constructor as corpus_file_dlc
@bp.route('/<hashid:corpus_id>/files') @bp.route('/<hashid:corpus_id>/files')
@register_breadcrumb(bp, '.entity.files', 'Files', endpoint_arguments_constructor=corpus_eac)
def corpus_files(corpus_id): def corpus_files(corpus_id):
return redirect(url_for('.corpus', _anchor='files', corpus_id=corpus_id)) return redirect(url_for('.corpus', _anchor='files', corpus_id=corpus_id))
@bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST']) @bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.entity.files.create', 'Create', endpoint_arguments_constructor=corpus_eac)
@corpus_follower_permission_required('MANAGE_FILES') @corpus_follower_permission_required('MANAGE_FILES')
def create_corpus_file(corpus_id): def create_corpus_file(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id) corpus = Corpus.query.get_or_404(corpus_id)
@ -60,6 +65,7 @@ def create_corpus_file(corpus_id):
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST']) @bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.entity.files.entity', '', dynamic_list_constructor=corpus_file_dlc)
@corpus_follower_permission_required('MANAGE_FILES') @corpus_follower_permission_required('MANAGE_FILES')
def corpus_file(corpus_id, corpus_file_id): 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() corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
@ -88,6 +94,6 @@ def download_corpus_file(corpus_id, corpus_file_id):
corpus_file.path.parent, corpus_file.path.parent,
corpus_file.path.name, corpus_file.path.name,
as_attachment=True, as_attachment=True,
download_name=corpus_file.filename, attachment_filename=corpus_file.filename,
mimetype=corpus_file.mimetype mimetype=corpus_file.mimetype
) )

View File

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

View File

@ -58,7 +58,7 @@ def delete_corpus_follower(corpus_id, follower_id):
current_user.id == follower_id current_user.id == follower_id
or current_user == cfa.corpus.user 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 CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first().role.has_permission('MANAGE_FOLLOWERS')
or current_user.is_administrator): or current_user.is_administrator()):
abort(403) abort(403)
if current_user.id == follower_id: if current_user.id == follower_id:
flash(f'You are no longer following "{cfa.corpus.title}"', 'corpus') flash(f'You are no longer following "{cfa.corpus.title}"', 'corpus')

View File

@ -1,4 +1,5 @@
from flask import abort, flash, redirect, render_template, url_for from flask import abort, flash, redirect, render_template, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user from flask_login import current_user
from app import db from app import db
from app.models import ( from app.models import (
@ -10,14 +11,20 @@ from app.models import (
from . import bp from . import bp
from .decorators import corpus_follower_permission_required from .decorators import corpus_follower_permission_required
from .forms import CreateCorpusForm from .forms import CreateCorpusForm
from .utils import (
corpus_endpoint_arguments_constructor as corpus_eac,
corpus_dynamic_list_constructor as corpus_dlc
)
@bp.route('') @bp.route('')
@register_breadcrumb(bp, '.', '<i class="nopaque-icons left">I</i>My Corpora')
def corpora(): def corpora():
return redirect(url_for('main.dashboard', _anchor='corpora')) return redirect(url_for('main.dashboard', _anchor='corpora'))
@bp.route('/create', methods=['GET', 'POST']) @bp.route('/create', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.create', 'Create')
def create_corpus(): def create_corpus():
form = CreateCorpusForm() form = CreateCorpusForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -40,6 +47,7 @@ def create_corpus():
@bp.route('/<hashid:corpus_id>') @bp.route('/<hashid:corpus_id>')
@register_breadcrumb(bp, '.entity', '', dynamic_list_constructor=corpus_dlc)
def corpus(corpus_id): def corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id) corpus = Corpus.query.get_or_404(corpus_id)
cfrs = CorpusFollowerRole.query.all() cfrs = CorpusFollowerRole.query.all()
@ -47,13 +55,13 @@ def corpus(corpus_id):
users = User.query.filter(User.is_public == True, User.id != current_user.id, User.id != corpus.user.id, User.role_id < 4).all() 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() cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if cfa is None: if cfa is None:
if corpus.user == current_user or current_user.is_administrator: if corpus.user == current_user or current_user.is_administrator():
cfr = CorpusFollowerRole.query.filter_by(name='Administrator').first() cfr = CorpusFollowerRole.query.filter_by(name='Administrator').first()
else: else:
cfr = CorpusFollowerRole.query.filter_by(name='Anonymous').first() cfr = CorpusFollowerRole.query.filter_by(name='Anonymous').first()
else: else:
cfr = cfa.role cfr = cfa.role
if corpus.user == current_user or current_user.is_administrator: if corpus.user == current_user or current_user.is_administrator():
return render_template( return render_template(
'corpora/corpus.html.j2', 'corpora/corpus.html.j2',
title=corpus.title, title=corpus.title,
@ -79,6 +87,7 @@ def corpus(corpus_id):
@bp.route('/<hashid:corpus_id>/analysis') @bp.route('/<hashid:corpus_id>/analysis')
@corpus_follower_permission_required('VIEW') @corpus_follower_permission_required('VIEW')
@register_breadcrumb(bp, '.entity.analysis', 'Analysis', endpoint_arguments_constructor=corpus_eac)
def analysis(corpus_id): def analysis(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id) corpus = Corpus.query.get_or_404(corpus_id)
return render_template( return render_template(
@ -99,11 +108,13 @@ def follow_corpus(corpus_id, token):
@bp.route('/import', methods=['GET', 'POST']) @bp.route('/import', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.import', 'Import')
def import_corpus(): def import_corpus():
abort(503) abort(503)
@bp.route('/<hashid:corpus_id>/export') @bp.route('/<hashid:corpus_id>/export')
@corpus_follower_permission_required('VIEW') @corpus_follower_permission_required('VIEW')
@register_breadcrumb(bp, '.entity.export', 'Export', endpoint_arguments_constructor=corpus_eac)
def export_corpus(corpus_id): def export_corpus(corpus_id):
abort(503) abort(503)

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

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

11
app/daemon/__init__.py Normal file
View File

@ -0,0 +1,11 @@
from app import db
from flask import Flask
from .corpus_utils import check_corpora
from .job_utils import check_jobs
def daemon(app: Flask):
with app.app_context():
check_corpora()
check_jobs()
db.session.commit()

View File

@ -1,16 +1,12 @@
from app import docker_client
from app.models import Corpus, CorpusStatus
from flask import current_app from flask import current_app
import docker import docker
import os import os
import shutil import shutil
from app import db, docker_client, scheduler
from app.models import Corpus, CorpusStatus
def handle_corpora(): def check_corpora():
with scheduler.app.app_context():
_handle_corpora()
def _handle_corpora():
corpora = Corpus.query.all() corpora = Corpus.query.all()
for corpus in [x for x in corpora if x.status == CorpusStatus.SUBMITTED]: for corpus in [x for x in corpora if x.status == CorpusStatus.SUBMITTED]:
_create_build_corpus_service(corpus) _create_build_corpus_service(corpus)
@ -21,14 +17,13 @@ def _handle_corpora():
for corpus in [x for x in corpora if x.status == CorpusStatus.RUNNING_ANALYSIS_SESSION and x.num_analysis_sessions == 0]: for corpus in [x for x in corpora if x.status == CorpusStatus.RUNNING_ANALYSIS_SESSION and x.num_analysis_sessions == 0]:
corpus.status = CorpusStatus.CANCELING_ANALYSIS_SESSION corpus.status = CorpusStatus.CANCELING_ANALYSIS_SESSION
for corpus in [x for x in corpora if x.status == CorpusStatus.RUNNING_ANALYSIS_SESSION]: for corpus in [x for x in corpora if x.status == CorpusStatus.RUNNING_ANALYSIS_SESSION]:
_checkout_cqpserver_container(corpus) _checkout_analysing_corpus_container(corpus)
for corpus in [x for x in corpora if x.status == CorpusStatus.STARTING_ANALYSIS_SESSION]: for corpus in [x for x in corpora if x.status == CorpusStatus.STARTING_ANALYSIS_SESSION]:
_create_cqpserver_container(corpus) _create_cqpserver_container(corpus)
for corpus in [x for x in corpora if x.status == CorpusStatus.CANCELING_ANALYSIS_SESSION]: for corpus in [x for x in corpora if x.status == CorpusStatus.CANCELING_ANALYSIS_SESSION]:
_remove_cqpserver_container(corpus) _remove_cqpserver_container(corpus)
db.session.commit()
def _create_build_corpus_service(corpus: Corpus): def _create_build_corpus_service(corpus):
''' # Docker service settings # ''' ''' # Docker service settings # '''
''' ## Command ## ''' ''' ## Command ## '''
command = ['bash', '-c'] command = ['bash', '-c']
@ -50,10 +45,12 @@ def _create_build_corpus_service(corpus: Corpus):
''' ## Constraints ## ''' ''' ## Constraints ## '''
constraints = ['node.role==worker'] constraints = ['node.role==worker']
''' ## Image ## ''' ''' ## Image ## '''
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1887' image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1879'
''' ## Labels ## ''' ''' ## Labels ## '''
labels = { labels = {
'nopaque.server_name': current_app.config['SERVER_NAME'] 'origin': current_app.config['SERVER_NAME'],
'type': 'corpus.build',
'corpus_id': str(corpus.id)
} }
''' ## Mounts ## ''' ''' ## Mounts ## '''
mounts = [] mounts = []
@ -98,7 +95,7 @@ def _create_build_corpus_service(corpus: Corpus):
return return
corpus.status = CorpusStatus.QUEUED corpus.status = CorpusStatus.QUEUED
def _checkout_build_corpus_service(corpus: Corpus): def _checkout_build_corpus_service(corpus):
service_name = f'build-corpus_{corpus.id}' service_name = f'build-corpus_{corpus.id}'
try: try:
service = docker_client.services.get(service_name) service = docker_client.services.get(service_name)
@ -126,7 +123,8 @@ def _checkout_build_corpus_service(corpus: Corpus):
except docker.errors.DockerException as e: except docker.errors.DockerException as e:
current_app.logger.error(f'Remove service "{service_name}" failed: {e}') current_app.logger.error(f'Remove service "{service_name}" failed: {e}')
def _create_cqpserver_container(corpus: Corpus): def _create_cqpserver_container(corpus):
''' # Docker container settings # '''
''' ## Command ## ''' ''' ## Command ## '''
command = [] command = []
command.append( command.append(
@ -141,9 +139,9 @@ def _create_cqpserver_container(corpus: Corpus):
''' ## Entrypoint ## ''' ''' ## Entrypoint ## '''
entrypoint = ['bash', '-c'] entrypoint = ['bash', '-c']
''' ## Image ## ''' ''' ## Image ## '''
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1887' image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1879'
''' ## Name ## ''' ''' ## Name ## '''
name = f'nopaque-cqpserver-{corpus.id}' name = f'cqpserver_{corpus.id}'
''' ## Network ## ''' ''' ## Network ## '''
network = f'{current_app.config["NOPAQUE_DOCKER_NETWORK_NAME"]}' network = f'{current_app.config["NOPAQUE_DOCKER_NETWORK_NAME"]}'
''' ## Volumes ## ''' ''' ## Volumes ## '''
@ -200,8 +198,8 @@ def _create_cqpserver_container(corpus: Corpus):
return return
corpus.status = CorpusStatus.RUNNING_ANALYSIS_SESSION corpus.status = CorpusStatus.RUNNING_ANALYSIS_SESSION
def _checkout_cqpserver_container(corpus: Corpus): def _checkout_analysing_corpus_container(corpus):
container_name = f'nopaque-cqpserver-{corpus.id}' container_name = f'cqpserver_{corpus.id}'
try: try:
docker_client.containers.get(container_name) docker_client.containers.get(container_name)
except docker.errors.NotFound as e: except docker.errors.NotFound as e:
@ -211,8 +209,8 @@ def _checkout_cqpserver_container(corpus: Corpus):
except docker.errors.DockerException as e: except docker.errors.DockerException as e:
current_app.logger.error(f'Get container "{container_name}" failed: {e}') current_app.logger.error(f'Get container "{container_name}" failed: {e}')
def _remove_cqpserver_container(corpus: Corpus): def _remove_cqpserver_container(corpus):
container_name = f'nopaque-cqpserver-{corpus.id}' container_name = f'cqpserver_{corpus.id}'
try: try:
container = docker_client.containers.get(container_name) container = docker_client.containers.get(container_name)
except docker.errors.NotFound: except docker.errors.NotFound:

View File

@ -1,11 +1,4 @@
from datetime import datetime from app import db, docker_client, hashids
from flask import current_app
from werkzeug.utils import secure_filename
import docker
import json
import os
import shutil
from app import db, docker_client, hashids, scheduler
from app.models import ( from app.models import (
Job, Job,
JobResult, JobResult,
@ -13,13 +6,16 @@ from app.models import (
TesseractOCRPipelineModel, TesseractOCRPipelineModel,
SpaCyNLPPipelineModel SpaCyNLPPipelineModel
) )
from datetime import datetime
from flask import current_app
from werkzeug.utils import secure_filename
import docker
import json
import os
import shutil
def handle_jobs(): def check_jobs():
with scheduler.app.app_context():
_handle_jobs()
def _handle_jobs():
jobs = Job.query.all() jobs = Job.query.all()
for job in [x for x in jobs if x.status == JobStatus.SUBMITTED]: for job in [x for x in jobs if x.status == JobStatus.SUBMITTED]:
_create_job_service(job) _create_job_service(job)
@ -27,9 +23,8 @@ def _handle_jobs():
_checkout_job_service(job) _checkout_job_service(job)
for job in [x for x in jobs if x.status == JobStatus.CANCELING]: for job in [x for x in jobs if x.status == JobStatus.CANCELING]:
_remove_job_service(job) _remove_job_service(job)
db.session.commit()
def _create_job_service(job: Job): def _create_job_service(job):
''' # Docker service settings # ''' ''' # Docker service settings # '''
''' ## Service specific settings ## ''' ''' ## Service specific settings ## '''
if job.service == 'file-setup-pipeline': if job.service == 'file-setup-pipeline':
@ -86,7 +81,9 @@ def _create_job_service(job: Job):
constraints = ['node.role==worker'] constraints = ['node.role==worker']
''' ## Labels ## ''' ''' ## Labels ## '''
labels = { labels = {
'origin': current_app.config['SERVER_NAME'] 'origin': current_app.config['SERVER_NAME'],
'type': 'job',
'job_id': str(job.id)
} }
''' ## Mounts ## ''' ''' ## Mounts ## '''
mounts = [] mounts = []
@ -167,7 +164,7 @@ def _create_job_service(job: Job):
return return
job.status = JobStatus.QUEUED job.status = JobStatus.QUEUED
def _checkout_job_service(job: Job): def _checkout_job_service(job):
service_name = f'job_{job.id}' service_name = f'job_{job.id}'
try: try:
service = docker_client.services.get(service_name) service = docker_client.services.get(service_name)
@ -216,7 +213,7 @@ def _checkout_job_service(job: Job):
except docker.errors.DockerException as e: except docker.errors.DockerException as e:
current_app.logger.error(f'Remove service "{service_name}" failed: {e}') current_app.logger.error(f'Remove service "{service_name}" failed: {e}')
def _remove_job_service(job: Job): def _remove_job_service(job):
service_name = f'job_{job.id}' service_name = f'job_{job.id}'
try: try:
service = docker_client.services.get(service_name) service = docker_client.services.get(service_name)

View File

@ -1,7 +1,8 @@
from flask import abort, request from flask import abort, current_app, request
from flask_login import current_user from flask_login import current_user
from functools import wraps from functools import wraps
from typing import Optional from threading import Thread
from typing import List, Union
from werkzeug.exceptions import NotAcceptable from werkzeug.exceptions import NotAcceptable
from app.models import Permission from app.models import Permission
@ -23,21 +24,22 @@ def admin_required(f):
def socketio_login_required(f): def socketio_login_required(f):
@wraps(f) @wraps(f)
def wrapper(*args, **kwargs): def decorated_function(*args, **kwargs):
if current_user.is_authenticated: if current_user.is_authenticated:
return f(*args, **kwargs) return f(*args, **kwargs)
return {'code': 401, 'body': 'Unauthorized'} else:
return wrapper return {'code': 401, 'msg': 'Unauthorized'}
return decorated_function
def socketio_permission_required(permission): def socketio_permission_required(permission):
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
def wrapper(*args, **kwargs): def decorated_function(*args, **kwargs):
if not current_user.can(permission): if not current_user.can(permission):
return {'code': 403, 'body': 'Forbidden'} return {'code': 403, 'msg': 'Forbidden'}
return f(*args, **kwargs) return f(*args, **kwargs)
return wrapper return decorated_function
return decorator return decorator
@ -45,9 +47,27 @@ def socketio_admin_required(f):
return socketio_permission_required(Permission.ADMINISTRATE)(f) return socketio_permission_required(Permission.ADMINISTRATE)(f)
def background(f):
'''
' This decorator executes a function in a Thread.
' Decorated functions need to be executed within a code block where an
' app context exists.
'
' NOTE: An app object is passed as a keyword argument to the decorated
' function.
'''
@wraps(f)
def wrapped(*args, **kwargs):
kwargs['app'] = current_app._get_current_object()
thread = Thread(target=f, args=args, kwargs=kwargs)
thread.start()
return thread
return wrapped
def content_negotiation( def content_negotiation(
produces: Optional[str | list[str]] = None, produces: Union[str, List[str], None] = None,
consumes: Optional[str | list[str]] = None consumes: Union[str, List[str], None] = None
): ):
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)

View File

@ -1,32 +1,25 @@
from flask import current_app, Flask, render_template from flask import current_app, render_template
from flask_mail import Message from flask_mail import Message
from threading import Thread from threading import Thread
from app import mail from app import mail
def create_message( def create_message(recipient, subject, template, **kwargs):
recipient: str, subject_prefix: str = current_app.config['NOPAQUE_MAIL_SUBJECT_PREFIX']
subject: str, msg: Message = Message(
template: str, body=render_template(f'{template}.txt.j2', **kwargs),
**context html=render_template(f'{template}.html.j2', **kwargs),
) -> Message:
message = Message(
body=render_template(f'{template}.txt.j2', **context),
html=render_template(f'{template}.html.j2', **context),
recipients=[recipient], recipients=[recipient],
subject=f'[nopaque] {subject}' subject=f'{subject_prefix} {subject}'
) )
return message return msg
def send(message: Message) -> Thread: def send(msg, *args, **kwargs):
def _send(app: Flask, message: Message): def _send(app, msg):
with app.app_context(): with app.app_context():
mail.send(message) mail.send(msg)
thread = Thread( thread = Thread(target=_send, args=[current_app._get_current_object(), msg])
target=_send,
args=[current_app._get_current_object(), message]
)
thread.start() thread.start()
return thread return thread

View File

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

View File

@ -0,0 +1,21 @@
import json
from app import db
class ContainerColumn(db.TypeDecorator):
impl = db.String
def __init__(self, container_type, *args, **kwargs):
super().__init__(*args, **kwargs)
self.container_type = container_type
def process_bind_param(self, value, dialect):
if isinstance(value, self.container_type):
return json.dumps(value)
elif isinstance(value, str) and isinstance(json.loads(value), self.container_type):
return value
else:
return TypeError()
def process_result_value(self, value, dialect):
return json.loads(value)

View File

@ -1,26 +1,6 @@
import json
from app import db from app import db
class ContainerColumn(db.TypeDecorator):
impl = db.String
def __init__(self, container_type, *args, **kwargs):
super().__init__(*args, **kwargs)
self.container_type = container_type
def process_bind_param(self, value, dialect):
if isinstance(value, self.container_type):
return json.dumps(value)
elif isinstance(value, str) and isinstance(json.loads(value), self.container_type):
return value
else:
return TypeError()
def process_result_value(self, value, dialect):
return json.loads(value)
class IntEnumColumn(db.TypeDecorator): class IntEnumColumn(db.TypeDecorator):
impl = db.Integer impl = db.Integer

View File

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

View File

@ -1,2 +1,18 @@
from .handle_corpora import handle_corpora from flask import Blueprint
from .handle_jobs import handle_jobs 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

View File

@ -17,7 +17,7 @@ def delete_job(job_id):
db.session.commit() db.session.commit()
job = Job.query.get_or_404(job_id) job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator): if not (job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
thread = Thread( thread = Thread(
target=_delete_job, target=_delete_job,
@ -56,7 +56,7 @@ def restart_job(job_id):
db.session.commit() db.session.commit()
job = Job.query.get_or_404(job_id) job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator): if not (job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
if job.status == JobStatus.FAILED: if job.status == JobStatus.FAILED:
response = {'errors': {'message': 'Job status is not "failed"'}} response = {'errors': {'message': 'Job status is not "failed"'}}

View File

@ -5,20 +5,24 @@ from flask import (
send_from_directory, send_from_directory,
url_for url_for
) )
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user from flask_login import current_user
from app.models import Job, JobInput, JobResult from app.models import Job, JobInput, JobResult
from . import bp from . import bp
from .utils import job_dynamic_list_constructor as job_dlc
@bp.route('') @bp.route('')
def jobs(): @register_breadcrumb(bp, '.', '<i class="nopaque-icons left">J</i>My Jobs')
def corpora():
return redirect(url_for('main.dashboard', _anchor='jobs')) return redirect(url_for('main.dashboard', _anchor='jobs'))
@bp.route('/<hashid:job_id>') @bp.route('/<hashid:job_id>')
@register_breadcrumb(bp, '.entity', '', dynamic_list_constructor=job_dlc)
def job(job_id): def job(job_id):
job = Job.query.get_or_404(job_id) job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator): if not (job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
return render_template( return render_template(
'jobs/job.html.j2', 'jobs/job.html.j2',
@ -30,13 +34,13 @@ def job(job_id):
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download') @bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
def download_job_input(job_id, job_input_id): 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() 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): if not (job_input.job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
return send_from_directory( return send_from_directory(
job_input.path.parent, job_input.path.parent,
job_input.path.name, job_input.path.name,
as_attachment=True, as_attachment=True,
download_name=job_input.filename, attachment_filename=job_input.filename,
mimetype=job_input.mimetype mimetype=job_input.mimetype
) )
@ -44,12 +48,12 @@ def download_job_input(job_id, job_input_id):
@bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download') @bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download')
def download_job_result(job_id, job_result_id): 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() 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): if not (job_result.job.user == current_user or current_user.is_administrator()):
abort(403) abort(403)
return send_from_directory( return send_from_directory(
job_result.path.parent, job_result.path.parent,
job_result.path.name, job_result.path.name,
as_attachment=True, as_attachment=True,
download_name=job_result.filename, attachment_filename=job_result.filename,
mimetype=job_result.mimetype mimetype=job_result.mimetype
) )

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

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

View File

@ -1,9 +1,8 @@
from flask import current_app from flask import current_app
from flask_migrate import upgrade from flask_migrate import upgrade
from pathlib import Path from pathlib import Path
from app import db from typing import List
from app.models import ( from app.models import (
Corpus,
CorpusFollowerRole, CorpusFollowerRole,
Role, Role,
SpaCyNLPPipelineModel, SpaCyNLPPipelineModel,
@ -16,10 +15,10 @@ from . import bp
@bp.cli.command('deploy') @bp.cli.command('deploy')
def deploy(): def deploy():
''' Run deployment tasks. ''' ''' Run deployment tasks. '''
# Make default directories
print('Make default directories') print('Make default directories')
base_dir = current_app.config['NOPAQUE_DATA_DIR'] base_dir = current_app.config['NOPAQUE_DATA_DIR']
default_dirs: list[Path] = [ default_dirs: List[Path] = [
base_dir / 'tmp', base_dir / 'tmp',
base_dir / 'users' base_dir / 'users'
] ]
@ -29,9 +28,11 @@ def deploy():
if not default_dir.is_dir(): if not default_dir.is_dir():
raise NotADirectoryError(f'{default_dir} is not a directory') raise NotADirectoryError(f'{default_dir} is not a directory')
# migrate database to latest revision
print('Migrate database to latest revision') print('Migrate database to latest revision')
upgrade() upgrade()
# Insert/Update default database values
print('Insert/Update default Roles') print('Insert/Update default Roles')
Role.insert_defaults() Role.insert_defaults()
print('Insert/Update default Users') print('Insert/Update default Users')
@ -43,9 +44,4 @@ def deploy():
print('Insert/Update default TesseractOCRPipelineModels') print('Insert/Update default TesseractOCRPipelineModels')
TesseractOCRPipelineModel.insert_defaults() TesseractOCRPipelineModel.insert_defaults()
print('Stop running analysis sessions')
for corpus in Corpus.query.all():
corpus.num_analysis_sessions = 0
db.session.commit()
# TODO: Implement checks for if the nopaque network exists # TODO: Implement checks for if the nopaque network exists

View File

@ -1,11 +1,14 @@
from flask import flash, redirect, render_template, url_for from flask import flash, redirect, render_template, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user, login_required, login_user from flask_login import current_user, login_required, login_user
from app.blueprints.auth.forms import LoginForm from app.auth.forms import LoginForm
from app.models import Corpus, User from app.models import Corpus, User
from sqlalchemy import or_
from . import bp from . import bp
@bp.route('/', methods=['GET', 'POST']) @bp.route('/', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.', '<i class="material-icons">home</i>')
def index(): def index():
form = LoginForm() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -24,6 +27,7 @@ def index():
@bp.route('/faq') @bp.route('/faq')
@register_breadcrumb(bp, '.faq', 'Frequently Asked Questions')
def faq(): def faq():
return render_template( return render_template(
'main/faq.html.j2', 'main/faq.html.j2',
@ -32,6 +36,7 @@ def faq():
@bp.route('/dashboard') @bp.route('/dashboard')
@register_breadcrumb(bp, '.dashboard', '<i class="material-icons left">dashboard</i>Dashboard')
@login_required @login_required
def dashboard(): def dashboard():
return render_template( return render_template(
@ -40,15 +45,8 @@ def dashboard():
) )
@bp.route('/manual')
def manual():
return render_template(
'main/manual.html.j2',
title='Manual'
)
@bp.route('/news') @bp.route('/news')
@register_breadcrumb(bp, '.news', '<i class="material-icons left">email</i>News')
def news(): def news():
return render_template( return render_template(
'main/news.html.j2', 'main/news.html.j2',
@ -57,6 +55,7 @@ def news():
@bp.route('/privacy_policy') @bp.route('/privacy_policy')
@register_breadcrumb(bp, '.privacy_policy', 'Private statement (GDPR)')
def privacy_policy(): def privacy_policy():
return render_template( return render_template(
'main/privacy_policy.html.j2', 'main/privacy_policy.html.j2',
@ -65,6 +64,7 @@ def privacy_policy():
@bp.route('/terms_of_use') @bp.route('/terms_of_use')
@register_breadcrumb(bp, '.terms_of_use', 'Terms of Use')
def terms_of_use(): def terms_of_use():
return render_template( return render_template(
'main/terms_of_use.html.j2', 'main/terms_of_use.html.j2',
@ -72,14 +72,17 @@ def terms_of_use():
) )
@bp.route('/social') @bp.route('/social-area')
@register_breadcrumb(bp, '.social_area', '<i class="material-icons left">group</i>Social Area')
@login_required @login_required
def social(): def social_area():
print('test')
corpora = Corpus.query.filter(Corpus.is_public == True, Corpus.user != current_user).all() 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() users = User.query.filter(User.is_public == True, User.id != current_user.id).all()
return render_template( return render_template(
'main/social.html.j2', 'main/social_area.html.j2',
title='Social', title='Social Area',
corpora=corpora, corpora=corpora,
users=users users=users
) )

View File

@ -1,4 +1,3 @@
from .anonymous_user import *
from .avatar import * from .avatar import *
from .corpus_file import * from .corpus_file import *
from .corpus_follower_association import * from .corpus_follower_association import *
@ -12,3 +11,9 @@ from .spacy_nlp_pipeline_model import *
from .tesseract_ocr_pipeline_model import * from .tesseract_ocr_pipeline_model import *
from .token import * from .token import *
from .user import * from .user import *
from app import login
@login.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

View File

@ -1,10 +0,0 @@
from flask_login import AnonymousUserMixin
class AnonymousUser(AnonymousUserMixin):
def can(self, permissions):
return False
@property
def is_administrator(self):
return False

View File

@ -3,12 +3,13 @@ from enum import IntEnum
from flask import current_app, url_for from flask import current_app, url_for
from flask_hashids import HashidMixin from flask_hashids import HashidMixin
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from typing import Union
from pathlib import Path from pathlib import Path
import shutil import shutil
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from app import db from app import db
from app.converters.vrt import normalize_vrt_file from app.converters.vrt import normalize_vrt_file
from app.extensions.nopaque_sqlalchemy_extras import IntEnumColumn from app.ext.flask_sqlalchemy import IntEnumColumn
from .corpus_follower_association import CorpusFollowerAssociation from .corpus_follower_association import CorpusFollowerAssociation
@ -24,7 +25,7 @@ class CorpusStatus(IntEnum):
CANCELING_ANALYSIS_SESSION = 9 CANCELING_ANALYSIS_SESSION = 9
@staticmethod @staticmethod
def get(corpus_status: 'CorpusStatus | int | str') -> 'CorpusStatus': def get(corpus_status: Union['CorpusStatus', int, str]) -> 'CorpusStatus':
if isinstance(corpus_status, CorpusStatus): if isinstance(corpus_status, CorpusStatus):
return corpus_status return corpus_status
if isinstance(corpus_status, int): if isinstance(corpus_status, int):

View File

@ -1,5 +1,6 @@
from flask_hashids import HashidMixin from flask_hashids import HashidMixin
from enum import IntEnum from enum import IntEnum
from typing import Union
from app import db from app import db
@ -10,7 +11,7 @@ class CorpusFollowerPermission(IntEnum):
MANAGE_CORPUS = 8 MANAGE_CORPUS = 8
@staticmethod @staticmethod
def get(corpus_follower_permission: 'CorpusFollowerPermission | int | str') -> 'CorpusFollowerPermission': def get(corpus_follower_permission: Union['CorpusFollowerPermission', int, str]) -> 'CorpusFollowerPermission':
if isinstance(corpus_follower_permission, CorpusFollowerPermission): if isinstance(corpus_follower_permission, CorpusFollowerPermission):
return corpus_follower_permission return corpus_follower_permission
if isinstance(corpus_follower_permission, int): if isinstance(corpus_follower_permission, int):
@ -37,16 +38,16 @@ class CorpusFollowerRole(HashidMixin, db.Model):
def __repr__(self): def __repr__(self):
return f'<CorpusFollowerRole {self.name}>' return f'<CorpusFollowerRole {self.name}>'
def has_permission(self, permission: CorpusFollowerPermission | int | str): def has_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
perm = CorpusFollowerPermission.get(permission) perm = CorpusFollowerPermission.get(permission)
return self.permissions & perm.value == perm.value return self.permissions & perm.value == perm.value
def add_permission(self, permission: CorpusFollowerPermission | int | str): def add_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
perm = CorpusFollowerPermission.get(permission) perm = CorpusFollowerPermission.get(permission)
if not self.has_permission(perm): if not self.has_permission(perm):
self.permissions += perm.value self.permissions += perm.value
def remove_permission(self, permission: CorpusFollowerPermission | int | str): def remove_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
perm = CorpusFollowerPermission.get(permission) perm = CorpusFollowerPermission.get(permission)
if self.has_permission(perm): if self.has_permission(perm):
self.permissions -= perm.value self.permissions -= perm.value

View File

@ -10,7 +10,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Amharic' # - title: 'Amharic'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/amh.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/amh.traineddata'
@ -23,7 +22,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Arabic' - title: 'Arabic'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ara.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ara.traineddata'
@ -36,7 +34,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Assamese' # - title: 'Assamese'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/asm.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/asm.traineddata'
@ -49,7 +46,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Azerbaijani' # - title: 'Azerbaijani'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/aze.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/aze.traineddata'
@ -62,7 +58,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Azerbaijani - Cyrillic' # - title: 'Azerbaijani - Cyrillic'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/aze_cyrl.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/aze_cyrl.traineddata'
@ -75,7 +70,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Belarusian' # - title: 'Belarusian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/bel.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/bel.traineddata'
@ -88,7 +82,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Bengali' # - title: 'Bengali'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ben.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ben.traineddata'
@ -101,7 +94,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Tibetan' # - title: 'Tibetan'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/bod.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/bod.traineddata'
@ -114,7 +106,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Bosnian' # - title: 'Bosnian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/bos.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/bos.traineddata'
@ -127,7 +118,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Bulgarian' # - title: 'Bulgarian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/bul.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/bul.traineddata'
@ -140,7 +130,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Catalan; Valencian' # - title: 'Catalan; Valencian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/cat.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/cat.traineddata'
@ -153,7 +142,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Cebuano' # - title: 'Cebuano'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ceb.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ceb.traineddata'
@ -166,7 +154,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Czech' # - title: 'Czech'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ces.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ces.traineddata'
@ -179,7 +166,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Chinese - Simplified' # - title: 'Chinese - Simplified'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/chi_sim.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/chi_sim.traineddata'
@ -192,7 +178,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Chinese - Traditional' - title: 'Chinese - Traditional'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/chi_tra.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/chi_tra.traineddata'
@ -205,7 +190,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Cherokee' # - title: 'Cherokee'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/chr.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/chr.traineddata'
@ -218,7 +202,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Welsh' # - title: 'Welsh'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/cym.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/cym.traineddata'
@ -231,7 +214,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Danish' - title: 'Danish'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/dan.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/dan.traineddata'
@ -244,7 +226,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
- title: 'German' - title: 'German'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/deu.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/deu.traineddata'
@ -257,7 +238,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Dzongkha' # - title: 'Dzongkha'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/dzo.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/dzo.traineddata'
@ -270,7 +250,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Greek, Modern (1453-)' - title: 'Greek, Modern (1453-)'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ell.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ell.traineddata'
@ -283,7 +262,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
- title: 'English' - title: 'English'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/eng.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/eng.traineddata'
@ -296,7 +274,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
- title: 'English, Middle (1100-1500)' - title: 'English, Middle (1100-1500)'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/enm.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/enm.traineddata'
@ -309,7 +286,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Esperanto' # - title: 'Esperanto'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/epo.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/epo.traineddata'
@ -322,7 +298,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Estonian' # - title: 'Estonian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/est.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/est.traineddata'
@ -335,7 +310,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Basque' # - title: 'Basque'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/eus.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/eus.traineddata'
@ -348,7 +322,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Persian' # - title: 'Persian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/fas.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/fas.traineddata'
@ -361,7 +334,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Finnish' # - title: 'Finnish'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/fin.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/fin.traineddata'
@ -374,7 +346,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'French' - title: 'French'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/fra.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/fra.traineddata'
@ -387,7 +358,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
- title: 'German Fraktur' - title: 'German Fraktur'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/frk.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/frk.traineddata'
@ -400,7 +370,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
- title: 'French, Middle (ca. 1400-1600)' - title: 'French, Middle (ca. 1400-1600)'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/frm.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/frm.traineddata'
@ -413,7 +382,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Irish' # - title: 'Irish'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/gle.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/gle.traineddata'
@ -426,7 +394,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Galician' # - title: 'Galician'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/glg.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/glg.traineddata'
@ -439,7 +406,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Greek, Ancient (-1453)' - title: 'Greek, Ancient (-1453)'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/grc.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/grc.traineddata'
@ -452,7 +418,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Gujarati' # - title: 'Gujarati'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/guj.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/guj.traineddata'
@ -465,7 +430,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Haitian; Haitian Creole' # - title: 'Haitian; Haitian Creole'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/hat.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/hat.traineddata'
@ -478,7 +442,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Hebrew' # - title: 'Hebrew'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/heb.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/heb.traineddata'
@ -491,7 +454,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Hindi' # - title: 'Hindi'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/hin.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/hin.traineddata'
@ -504,7 +466,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Croatian' # - title: 'Croatian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/hrv.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/hrv.traineddata'
@ -517,7 +478,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Hungarian' # - title: 'Hungarian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/hun.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/hun.traineddata'
@ -530,7 +490,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Inuktitut' # - title: 'Inuktitut'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/iku.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/iku.traineddata'
@ -543,7 +502,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Indonesian' # - title: 'Indonesian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ind.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ind.traineddata'
@ -556,7 +514,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Icelandic' # - title: 'Icelandic'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/isl.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/isl.traineddata'
@ -569,7 +526,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Italian' - title: 'Italian'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ita.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ita.traineddata'
@ -582,7 +538,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
- title: 'Italian - Old' - title: 'Italian - Old'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ita_old.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ita_old.traineddata'
@ -595,7 +550,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Javanese' # - title: 'Javanese'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/jav.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/jav.traineddata'
@ -608,7 +562,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Japanese' # - title: 'Japanese'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/jpn.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/jpn.traineddata'
@ -621,7 +574,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Kannada' # - title: 'Kannada'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kan.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kan.traineddata'
@ -634,7 +586,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Georgian' # - title: 'Georgian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kat.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kat.traineddata'
@ -647,7 +598,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Georgian - Old' # - title: 'Georgian - Old'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kat_old.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kat_old.traineddata'
@ -660,7 +610,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Kazakh' # - title: 'Kazakh'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kaz.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kaz.traineddata'
@ -673,7 +622,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Central Khmer' # - title: 'Central Khmer'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/khm.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/khm.traineddata'
@ -686,7 +634,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Kirghiz; Kyrgyz' # - title: 'Kirghiz; Kyrgyz'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kir.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kir.traineddata'
@ -699,7 +646,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Korean' # - title: 'Korean'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kor.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kor.traineddata'
@ -712,7 +658,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Kurdish' # - title: 'Kurdish'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kur.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/kur.traineddata'
@ -725,7 +670,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Lao' # - title: 'Lao'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/lao.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/lao.traineddata'
@ -738,7 +682,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Latin' # - title: 'Latin'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/lat.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/lat.traineddata'
@ -751,7 +694,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Latvian' # - title: 'Latvian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/lav.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/lav.traineddata'
@ -764,7 +706,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Lithuanian' # - title: 'Lithuanian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/lit.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/lit.traineddata'
@ -777,7 +718,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Malayalam' # - title: 'Malayalam'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mal.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mal.traineddata'
@ -790,7 +730,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Marathi' # - title: 'Marathi'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mar.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mar.traineddata'
@ -803,7 +742,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Macedonian' # - title: 'Macedonian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mkd.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mkd.traineddata'
@ -816,7 +754,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Maltese' # - title: 'Maltese'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mlt.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mlt.traineddata'
@ -829,7 +766,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Malay' # - title: 'Malay'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/msa.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/msa.traineddata'
@ -842,7 +778,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Burmese' # - title: 'Burmese'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mya.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/mya.traineddata'
@ -855,7 +790,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Nepali' # - title: 'Nepali'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/nep.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/nep.traineddata'
@ -868,7 +802,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Dutch; Flemish' # - title: 'Dutch; Flemish'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/nld.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/nld.traineddata'
@ -881,7 +814,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Norwegian' # - title: 'Norwegian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/nor.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/nor.traineddata'
@ -894,7 +826,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Oriya' # - title: 'Oriya'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ori.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ori.traineddata'
@ -907,7 +838,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Panjabi; Punjabi' # - title: 'Panjabi; Punjabi'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/pan.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/pan.traineddata'
@ -920,7 +850,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Polish' # - title: 'Polish'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/pol.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/pol.traineddata'
@ -933,7 +862,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Portuguese' - title: 'Portuguese'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/por.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/por.traineddata'
@ -946,7 +874,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Pushto; Pashto' # - title: 'Pushto; Pashto'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/pus.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/pus.traineddata'
@ -959,7 +886,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Romanian; Moldavian; Moldovan' # - title: 'Romanian; Moldavian; Moldovan'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ron.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ron.traineddata'
@ -972,7 +898,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Russian' - title: 'Russian'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/rus.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/rus.traineddata'
@ -985,7 +910,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Sanskrit' # - title: 'Sanskrit'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/san.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/san.traineddata'
@ -998,7 +922,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Sinhala; Sinhalese' # - title: 'Sinhala; Sinhalese'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/sin.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/sin.traineddata'
@ -1011,7 +934,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Slovak' # - title: 'Slovak'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/slk.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/slk.traineddata'
@ -1024,7 +946,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Slovenian' # - title: 'Slovenian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/slv.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/slv.traineddata'
@ -1037,7 +958,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
- title: 'Spanish; Castilian' - title: 'Spanish; Castilian'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/spa.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/spa.traineddata'
@ -1050,7 +970,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
- title: 'Spanish; Castilian - Old' - title: 'Spanish; Castilian - Old'
description: '' description: ''
url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/spa_old.traineddata' url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/spa_old.traineddata'
@ -1063,7 +982,6 @@
- '0.1.0' - '0.1.0'
- '0.1.1' - '0.1.1'
- '0.1.2' - '0.1.2'
- '0.1.3b'
# - title: 'Albanian' # - title: 'Albanian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/sqi.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/sqi.traineddata'
@ -1076,7 +994,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Serbian' # - title: 'Serbian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/srp.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/srp.traineddata'
@ -1089,7 +1006,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Serbian - Latin' # - title: 'Serbian - Latin'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/srp_latn.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/srp_latn.traineddata'
@ -1102,7 +1018,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Swahili' # - title: 'Swahili'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/swa.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/swa.traineddata'
@ -1115,7 +1030,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Swedish' # - title: 'Swedish'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/swe.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/swe.traineddata'
@ -1128,7 +1042,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Syriac' # - title: 'Syriac'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/syr.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/syr.traineddata'
@ -1141,7 +1054,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Tamil' # - title: 'Tamil'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tam.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tam.traineddata'
@ -1154,7 +1066,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Telugu' # - title: 'Telugu'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tel.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tel.traineddata'
@ -1167,7 +1078,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Tajik' # - title: 'Tajik'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tgk.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tgk.traineddata'
@ -1180,7 +1090,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Tagalog' # - title: 'Tagalog'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tgl.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tgl.traineddata'
@ -1193,7 +1102,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Thai' # - title: 'Thai'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tha.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tha.traineddata'
@ -1206,7 +1114,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Tigrinya' # - title: 'Tigrinya'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tir.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tir.traineddata'
@ -1219,7 +1126,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Turkish' # - title: 'Turkish'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tur.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/tur.traineddata'
@ -1232,7 +1138,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Uighur; Uyghur' # - title: 'Uighur; Uyghur'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/uig.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/uig.traineddata'
@ -1245,7 +1150,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Ukrainian' # - title: 'Ukrainian'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ukr.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/ukr.traineddata'
@ -1258,7 +1162,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Urdu' # - title: 'Urdu'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/urd.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/urd.traineddata'
@ -1271,7 +1174,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Uzbek' # - title: 'Uzbek'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/uzb.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/uzb.traineddata'
@ -1284,7 +1186,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Uzbek - Cyrillic' # - title: 'Uzbek - Cyrillic'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/uzb_cyrl.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/uzb_cyrl.traineddata'
@ -1297,7 +1198,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Vietnamese' # - title: 'Vietnamese'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/vie.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/vie.traineddata'
@ -1310,7 +1210,6 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'
# - title: 'Yiddish' # - title: 'Yiddish'
# description: '' # description: ''
# url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/yid.traineddata' # url: 'https://github.com/tesseract-ocr/tessdata/raw/4.1.0/yid.traineddata'
@ -1323,4 +1222,3 @@
# - '0.1.0' # - '0.1.0'
# - '0.1.1' # - '0.1.1'
# - '0.1.2' # - '0.1.2'
# - '0.1.3b'

View File

@ -43,7 +43,7 @@ def resource_after_delete(mapper, connection, resource):
} }
] ]
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def cfa_after_delete(mapper, connection, cfa): def cfa_after_delete(mapper, connection, cfa):
@ -55,7 +55,7 @@ def cfa_after_delete(mapper, connection, cfa):
} }
] ]
room = f'/users/{cfa.corpus.user.hashid}' room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def resource_after_insert(mapper, connection, resource): def resource_after_insert(mapper, connection, resource):
@ -70,7 +70,7 @@ def resource_after_insert(mapper, connection, resource):
} }
] ]
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def cfa_after_insert(mapper, connection, cfa): def cfa_after_insert(mapper, connection, cfa):
@ -84,7 +84,7 @@ def cfa_after_insert(mapper, connection, cfa):
} }
] ]
room = f'/users/{cfa.corpus.user.hashid}' room = f'/users/{cfa.corpus.user.hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def resource_after_update(mapper, connection, resource): def resource_after_update(mapper, connection, resource):
@ -110,7 +110,7 @@ def resource_after_update(mapper, connection, resource):
) )
if jsonpatch: if jsonpatch:
room = f'/users/{resource.user_hashid}' room = f'/users/{resource.user_hashid}'
socketio.emit('users.patch', jsonpatch, room=room) socketio.emit('PATCH', jsonpatch, room=room)
def job_after_update(mapper, connection, job): def job_after_update(mapper, connection, job):

View File

@ -3,10 +3,11 @@ from enum import IntEnum
from flask import current_app, url_for from flask import current_app, url_for
from flask_hashids import HashidMixin from flask_hashids import HashidMixin
from time import sleep from time import sleep
from typing import Union
from pathlib import Path from pathlib import Path
import shutil import shutil
from app import db from app import db
from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn, IntEnumColumn from app.ext.flask_sqlalchemy import ContainerColumn, IntEnumColumn
class JobStatus(IntEnum): class JobStatus(IntEnum):
@ -20,7 +21,7 @@ class JobStatus(IntEnum):
FAILED = 8 FAILED = 8
@staticmethod @staticmethod
def get(job_status: 'JobStatus | int | str') -> 'JobStatus': def get(job_status: Union['JobStatus', int, str]) -> 'JobStatus':
if isinstance(job_status, JobStatus): if isinstance(job_status, JobStatus):
return job_status return job_status
if isinstance(job_status, int): if isinstance(job_status, int):

View File

@ -1,5 +1,6 @@
from enum import IntEnum from enum import IntEnum
from flask_hashids import HashidMixin from flask_hashids import HashidMixin
from typing import Union
from app import db from app import db
@ -13,7 +14,7 @@ class Permission(IntEnum):
USE_API = 4 USE_API = 4
@staticmethod @staticmethod
def get(permission: 'Permission | int | str') -> 'Permission': def get(permission: Union['Permission', int, str]) -> 'Permission':
if isinstance(permission, Permission): if isinstance(permission, Permission):
return permission return permission
if isinstance(permission, int): if isinstance(permission, int):
@ -37,16 +38,16 @@ class Role(HashidMixin, db.Model):
def __repr__(self): def __repr__(self):
return f'<Role {self.name}>' return f'<Role {self.name}>'
def has_permission(self, permission: Permission | int | str): def has_permission(self, permission: Union[Permission, int, str]):
p = Permission.get(permission) p = Permission.get(permission)
return self.permissions & p.value == p.value return self.permissions & p.value == p.value
def add_permission(self, permission: Permission | int | str): def add_permission(self, permission: Union[Permission, int, str]):
p = Permission.get(permission) p = Permission.get(permission)
if not self.has_permission(p): if not self.has_permission(p):
self.permissions += p.value self.permissions += p.value
def remove_permission(self, permission: Permission | int | str): def remove_permission(self, permission: Union[Permission, int, str]):
p = Permission.get(permission) p = Permission.get(permission)
if self.has_permission(p): if self.has_permission(p):
self.permissions -= p.value self.permissions -= p.value

View File

@ -5,7 +5,7 @@ from pathlib import Path
import requests import requests
import yaml import yaml
from app import db from app import db
from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn from app.ext.flask_sqlalchemy import ContainerColumn
from .file_mixin import FileMixin from .file_mixin import FileMixin
from .user import User from .user import User
@ -41,7 +41,7 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model):
@property @property
def url(self): def url(self):
return url_for( return url_for(
'contributions.spacy_nlp_pipeline_models.entity', 'contributions.spacy_nlp_pipeline_model',
spacy_nlp_pipeline_model_id=self.id spacy_nlp_pipeline_model_id=self.id
) )
@ -104,7 +104,7 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model):
f.write(chunk) f.write(chunk)
pbar.close() pbar.close()
db.session.commit() db.session.commit()
def delete(self): def delete(self):
try: try:
self.path.unlink(missing_ok=True) self.path.unlink(missing_ok=True)

View File

@ -5,7 +5,7 @@ from pathlib import Path
import requests import requests
import yaml import yaml
from app import db from app import db
from app.extensions.nopaque_sqlalchemy_extras import ContainerColumn from app.ext.flask_sqlalchemy import ContainerColumn
from .file_mixin import FileMixin from .file_mixin import FileMixin
from .user import User from .user import User
@ -40,7 +40,7 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model):
@property @property
def url(self): def url(self):
return url_for( return url_for(
'contributions.tesseract_ocr_pipeline_models.entity', 'contributions.tesseract_ocr_pipeline_model',
tesseract_ocr_pipeline_model_id=self.id tesseract_ocr_pipeline_model_id=self.id
) )

View File

@ -5,13 +5,14 @@ from flask_hashids import HashidMixin
from flask_login import UserMixin from flask_login import UserMixin
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from pathlib import Path from pathlib import Path
from typing import Union
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import jwt import jwt
import re import re
import secrets import secrets
import shutil import shutil
from app import db, hashids from app import db, hashids
from app.extensions.nopaque_sqlalchemy_extras import IntEnumColumn from app.ext.flask_sqlalchemy import IntEnumColumn
from .corpus import Corpus from .corpus import Corpus
from .corpus_follower_association import CorpusFollowerAssociation from .corpus_follower_association import CorpusFollowerAssociation
from .corpus_follower_role import CorpusFollowerRole from .corpus_follower_role import CorpusFollowerRole
@ -25,7 +26,7 @@ class ProfilePrivacySettings(IntEnum):
SHOW_MEMBER_SINCE = 4 SHOW_MEMBER_SINCE = 4
@staticmethod @staticmethod
def get(profile_privacy_setting: 'ProfilePrivacySettings | int | str') -> 'ProfilePrivacySettings': def get(profile_privacy_setting: Union['ProfilePrivacySettings', int, str]) -> 'ProfilePrivacySettings':
if isinstance(profile_privacy_setting, ProfilePrivacySettings): if isinstance(profile_privacy_setting, ProfilePrivacySettings):
return profile_privacy_setting return profile_privacy_setting
if isinstance(profile_privacy_setting, int): if isinstance(profile_privacy_setting, int):
@ -131,10 +132,6 @@ class User(HashidMixin, UserMixin, db.Model):
def __repr__(self): def __repr__(self):
return f'<User {self.username}>' return f'<User {self.username}>'
@property
def is_administrator(self):
return self.can(Permission.ADMINISTRATE)
@property @property
def jsonpatch_path(self): def jsonpatch_path(self):
return f'/users/{self.hashid}' return f'/users/{self.hashid}'
@ -145,8 +142,7 @@ class User(HashidMixin, UserMixin, db.Model):
@password.setter @password.setter
def password(self, password): def password(self, password):
#pbkdf2:sha256 self.password_hash = generate_password_hash(password)
self.password_hash = generate_password_hash(password, method='pbkdf2')
@property @property
def path(self) -> Path: def path(self) -> Path:
@ -298,6 +294,9 @@ class User(HashidMixin, UserMixin, db.Model):
algorithm='HS256' algorithm='HS256'
) )
def is_administrator(self):
return self.can(Permission.ADMINISTRATE)
def ping(self): def ping(self):
self.last_seen = datetime.utcnow() self.last_seen = datetime.utcnow()
@ -314,7 +313,7 @@ class User(HashidMixin, UserMixin, db.Model):
def has_profile_privacy_setting(self, setting): def has_profile_privacy_setting(self, setting):
s = ProfilePrivacySettings.get(setting) s = ProfilePrivacySettings.get(setting)
return self.profile_privacy_settings & s.value == s.value return self.profile_privacy_settings & s.value == s.value
def add_profile_privacy_setting(self, setting): def add_profile_privacy_setting(self, setting):
s = ProfilePrivacySettings.get(setting) s = ProfilePrivacySettings.get(setting)
if not self.has_profile_privacy_setting(s): if not self.has_profile_privacy_setting(s):
@ -349,7 +348,7 @@ class User(HashidMixin, UserMixin, db.Model):
def is_following_corpus(self, corpus): def is_following_corpus(self, corpus):
return corpus in self.followed_corpora return corpus in self.followed_corpora
def generate_follow_corpus_token(self, corpus_hashid, role_name, expiration=7): def generate_follow_corpus_token(self, corpus_hashid, role_name, expiration=7):
now = datetime.utcnow() now = datetime.utcnow()
payload = { payload = {
@ -365,7 +364,7 @@ class User(HashidMixin, UserMixin, db.Model):
current_app.config['SECRET_KEY'], current_app.config['SECRET_KEY'],
algorithm='HS256' algorithm='HS256'
) )
def follow_corpus_by_token(self, token): def follow_corpus_by_token(self, token):
try: try:
payload = jwt.decode( payload = jwt.decode(

View File

@ -1,37 +0,0 @@
from cqi import CQiClient
from threading import Lock
from flask import session
class CQiOverSocketIOSessionManager:
@staticmethod
def setup():
session['cqi_over_sio'] = {}
@staticmethod
def teardown():
session.pop('cqi_over_sio')
@staticmethod
def set_corpus_id(corpus_id: int):
session['cqi_over_sio']['corpus_id'] = corpus_id
@staticmethod
def get_corpus_id() -> int:
return session['cqi_over_sio']['corpus_id']
@staticmethod
def set_cqi_client(cqi_client: CQiClient):
session['cqi_over_sio']['cqi_client'] = cqi_client
@staticmethod
def get_cqi_client() -> CQiClient:
return session['cqi_over_sio']['cqi_client']
@staticmethod
def set_cqi_client_lock(cqi_client_lock: Lock):
session['cqi_over_sio']['cqi_client_lock'] = cqi_client_lock
@staticmethod
def get_cqi_client_lock() -> Lock:
return session['cqi_over_sio']['cqi_client_lock']

View File

@ -61,7 +61,7 @@ class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
if field.data: if field.data:
if not('methods' in service_info and 'binarization' in service_info['methods']): if not('methods' in service_info and 'binarization' in service_info['methods']):
raise ValidationError('Binarization is not available') raise ValidationError('Binarization is not available')
def validate_pdf(self, field): def validate_pdf(self, field):
if field.data.mimetype != 'application/pdf': if field.data.mimetype != 'application/pdf':
raise ValidationError('PDF files only!') raise ValidationError('PDF files only!')
@ -146,7 +146,7 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
encoding_detection = BooleanField('Encoding detection', render_kw={'disabled': True}) encoding_detection = BooleanField('Encoding detection', render_kw={'disabled': True})
txt = FileField('File', validators=[FileRequired()]) txt = FileField('File', validators=[FileRequired()])
model = SelectField('Model', validators=[InputRequired()]) model = SelectField('Model', validators=[InputRequired()])
def validate_encoding_detection(self, field): def validate_encoding_detection(self, field):
service_info = SERVICES['spacy-nlp-pipeline']['versions'][self.version.data] service_info = SERVICES['spacy-nlp-pipeline']['versions'][self.version.data]
if field.data: if field.data:
@ -167,6 +167,7 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
version = kwargs.pop('version', service_manifest['latest_version']) version = kwargs.pop('version', service_manifest['latest_version'])
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
service_info = service_manifest['versions'][version] service_info = service_manifest['versions'][version]
print(service_info)
if self.encoding_detection.render_kw is None: if self.encoding_detection.render_kw is None:
self.encoding_detection.render_kw = {} self.encoding_detection.render_kw = {}
self.encoding_detection.render_kw['disabled'] = True self.encoding_detection.render_kw['disabled'] = True

View File

@ -1,10 +1,12 @@
from flask import abort, current_app, flash, redirect, render_template, request, url_for from flask import abort, current_app, flash, Markup, redirect, render_template, request, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user from flask_login import current_user
import requests import requests
from app import db, hashids from app import db, hashids
from app.models import ( from app.models import (
Job, Job,
JobInput, JobInput,
JobResult,
JobStatus, JobStatus,
TesseractOCRPipelineModel, TesseractOCRPipelineModel,
SpaCyNLPPipelineModel SpaCyNLPPipelineModel
@ -19,11 +21,13 @@ from .forms import (
@bp.route('/services') @bp.route('/services')
@register_breadcrumb(bp, '.', 'Services')
def services(): def services():
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@bp.route('/file-setup-pipeline', methods=['GET', 'POST']) @bp.route('/file-setup-pipeline', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.file_setup_pipeline', '<i class="nopaque-icons service-icons left" data-service="file-setup-pipeline"></i>File Setup')
def file_setup_pipeline(): def file_setup_pipeline():
service = 'file-setup-pipeline' service = 'file-setup-pipeline'
service_manifest = SERVICES[service] service_manifest = SERVICES[service]
@ -53,7 +57,7 @@ def file_setup_pipeline():
abort(500) abort(500)
job.status = JobStatus.SUBMITTED job.status = JobStatus.SUBMITTED
db.session.commit() db.session.commit()
message = f'Job "<a href="{job.url}">{job.title}</a>" created' message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
flash(message, 'job') flash(message, 'job')
return {}, 201, {'Location': job.url} return {}, 201, {'Location': job.url}
return render_template( return render_template(
@ -64,12 +68,15 @@ def file_setup_pipeline():
@bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST']) @bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.tesseract_ocr_pipeline', '<i class="nopaque-icons service-icons left" data-service="tesseract-ocr-pipeline"></i>Tesseract OCR Pipeline')
def tesseract_ocr_pipeline(): def tesseract_ocr_pipeline():
service_name = 'tesseract-ocr-pipeline' service_name = 'tesseract-ocr-pipeline'
service_manifest = SERVICES[service_name] service_manifest = SERVICES[service_name]
version = request.args.get('version', service_manifest['latest_version']) version = request.args.get('version', service_manifest['latest_version'])
if version not in service_manifest['versions']: if version not in service_manifest['versions']:
abort(404) abort(404)
job_results = JobResult.query.all()
choosable_job_ids = [job_result.job.hashid for job_result in job_results if job_result.job.service == "file-setup-pipeline" and job_result.filename.endswith('.pdf')]
form = CreateTesseractOCRPipelineJobForm(prefix='create-job-form', version=version) form = CreateTesseractOCRPipelineJobForm(prefix='create-job-form', version=version)
if form.is_submitted(): if form.is_submitted():
if not form.validate(): if not form.validate():
@ -96,7 +103,7 @@ def tesseract_ocr_pipeline():
abort(500) abort(500)
job.status = JobStatus.SUBMITTED job.status = JobStatus.SUBMITTED
db.session.commit() db.session.commit()
message = f'Job "<a href="{job.url}">{job.title}</a>" created' message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
flash(message, 'job') flash(message, 'job')
return {}, 201, {'Location': job.url} return {}, 201, {'Location': job.url}
tesseract_ocr_pipeline_models = [ tesseract_ocr_pipeline_models = [
@ -107,6 +114,7 @@ def tesseract_ocr_pipeline():
return render_template( return render_template(
'services/tesseract_ocr_pipeline.html.j2', 'services/tesseract_ocr_pipeline.html.j2',
title=service_manifest['name'], title=service_manifest['name'],
choosable_job_ids=choosable_job_ids,
form=form, form=form,
tesseract_ocr_pipeline_models=tesseract_ocr_pipeline_models, tesseract_ocr_pipeline_models=tesseract_ocr_pipeline_models,
user_tesseract_ocr_pipeline_models_count=user_tesseract_ocr_pipeline_models_count user_tesseract_ocr_pipeline_models_count=user_tesseract_ocr_pipeline_models_count
@ -114,6 +122,7 @@ def tesseract_ocr_pipeline():
@bp.route('/transkribus-htr-pipeline', methods=['GET', 'POST']) @bp.route('/transkribus-htr-pipeline', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.transkribus_htr_pipeline', '<i class="nopaque-icons service-icons left" data-service="transkribus-htr-pipeline"></i>Transkribus HTR Pipeline')
def transkribus_htr_pipeline(): def transkribus_htr_pipeline():
if not current_app.config.get('NOPAQUE_TRANSKRIBUS_ENABLED'): if not current_app.config.get('NOPAQUE_TRANSKRIBUS_ENABLED'):
abort(404) abort(404)
@ -159,7 +168,7 @@ def transkribus_htr_pipeline():
abort(500) abort(500)
job.status = JobStatus.SUBMITTED job.status = JobStatus.SUBMITTED
db.session.commit() db.session.commit()
message = f'Job "<a href="{job.url}">{job.title}</a>" created' message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
flash(message, 'job') flash(message, 'job')
return {}, 201, {'Location': job.url} return {}, 201, {'Location': job.url}
return render_template( return render_template(
@ -171,6 +180,7 @@ def transkribus_htr_pipeline():
@bp.route('/spacy-nlp-pipeline', methods=['GET', 'POST']) @bp.route('/spacy-nlp-pipeline', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.spacy_nlp_pipeline', '<i class="nopaque-icons service-icons left" data-service="spacy-nlp-pipeline"></i>SpaCy NLP Pipeline')
def spacy_nlp_pipeline(): def spacy_nlp_pipeline():
service = 'spacy-nlp-pipeline' service = 'spacy-nlp-pipeline'
service_manifest = SERVICES[service] service_manifest = SERVICES[service]
@ -204,7 +214,7 @@ def spacy_nlp_pipeline():
abort(500) abort(500)
job.status = JobStatus.SUBMITTED job.status = JobStatus.SUBMITTED
db.session.commit() db.session.commit()
message = f'Job "<a href="{job.url}">{job.title}</a>" created' message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
flash(message, 'job') flash(message, 'job')
return {}, 201, {'Location': job.url} return {}, 201, {'Location': job.url}
return render_template( return render_template(
@ -217,6 +227,7 @@ def spacy_nlp_pipeline():
@bp.route('/corpus-analysis') @bp.route('/corpus-analysis')
@register_breadcrumb(bp, '.corpus_analysis', '<i class="nopaque-icons service-icons left" data-service="corpus-analysis"></i>Corpus Analysis')
def corpus_analysis(): def corpus_analysis():
return render_template( return render_template(
'services/corpus_analysis.html.j2', 'services/corpus_analysis.html.j2',

View File

@ -1,10 +1,12 @@
from flask import g, url_for from flask import g, url_for
from flask_breadcrumbs import register_breadcrumb
from flask_login import current_user from flask_login import current_user
from app.blueprints.users.settings.routes import settings as settings_route from app.users.settings.routes import settings as settings_route
from . import bp from . import bp
@bp.route('/settings', methods=['GET', 'POST']) @bp.route('/settings', methods=['GET', 'POST'])
@register_breadcrumb(bp, '.', '<i class="material-icons left">settings</i>Settings')
def settings(): def settings():
g._nopaque_redirect_location_on_post = url_for('.settings') g._nopaque_redirect_location_on_post = url_for('.settings')
return settings_route(current_user.id) return settings_route(current_user.id)

290
app/static/css/colors.scss Normal file
View File

@ -0,0 +1,290 @@
/// Map deep get
/// @author Kitty Giraudel
/// @access public
/// @param {Map} $map - Map
/// @param {Arglist} $keys - Key chain
/// @return {*} - Desired value
@function map-deep-get($map, $keys...) {
@each $key in $keys {
$map: map-get($map, $key);
}
@return $map;
}
$color: (
"baseline": (
"primary": #00426f,
"primary-variant": #1a5c89,
"secondary": #00426f,
"secondary-variant": #1a5c89,
"background": #ffffff,
"surface": #ffffff,
"error": #b00020
),
"social-area": (
"base": #d6ae86,
"darken": #C98536,
"lighten": #EAE2DB
),
"service": (
"corpus-analysis": (
"base": #aa9cc9,
"darken": #6b3f89,
"lighten": #ebe8f6
),
"file-setup-pipeline": (
"base": #d5dc95,
"darken": #a1b300,
"lighten": #f2f3e1
),
"spacy-nlp-pipeline": (
"base": #98acd2,
"darken": #0064a3,
"lighten": #e5e8f5
),
"tesseract-ocr-pipeline": (
"base": #a9d8c8,
"darken": #00a58b,
"lighten": #e7f4f1
),
"transkribus-htr-pipeline": (
"base": #607d8b,
"darken": #37474f,
"lighten": #cfd8dc
)
),
"status": (
"corpus": (
"UNPREPARED": #9e9e9e,
"QUEUED": #2196f3,
"BUILDING": #ffc107,
"BUILT": #4caf50,
"FAILED": #f44336,
"STARTING_ANALYSIS_SESSION": #2196f3,
"RUNNING_ANALYSIS_SESSION": #4caf50,
"CANCELING_ANALYSIS_SESSION": #ff5722
),
"job": (
"INITIALIZING": #9e9e9e,
"SUBMITTED": #9e9e9e,
"QUEUED": #2196f3,
"RUNNING": #ffc107,
"CANCELING": #ff5722,
"CANCELED": #ff5722,
"COMPLETED": #4caf50,
"FAILED": #f44336
)
),
"s-attr": (
"PERSON": #a6e22d,
"PER": #a6e22d,
"NORP": #ef60b4,
"FACILITY": #43c6fc,
"ORG": #43c6fc,
"GPE": #fd9720,
"LOC": #fd9720,
"PRODUCT": #a99dfb,
"MISC": #a99dfb,
"EVENT": #fc0,
"WORK_OF_ART": #fc0,
"LANGUAGE": #fc0,
"DATE": #2fbbab,
"TIME": #2fbbab,
"PERCENT": #bbb,
"MONEY": #bbb,
"QUANTITY": #bbb,
"ORDINAL": #bbb,
"CARDINAL": #bbb
)
);
@each $key, $color-code in map-get($color, "baseline") {
.#{$key}-color {
background-color: $color-code !important;
}
.#{$key}-color-border {
border-color: $color-code !important;
}
.#{$key}-color-text {
color: $color-code !important;
}
}
@each $key, $color-code in map-get($color, "social-area") {
.social-area-color-#{$key} {
background-color: $color-code !important;
}
.social-area-color-border-#{$key} {
border-color: $color-code !important;
}
}
@each $service-name, $color-palette in map-get($color, "service") {
.service-color[data-service="#{$service-name}"] {
background-color: map-get($color-palette, "base") !important;
&.darken {
background-color: map-get($color-palette, "darken") !important;
}
&.lighten {
background-color: map-get($color-palette, "lighten") !important;
}
}
.service-color-border[data-service="#{$service-name}"] {
border-color: map-get($color-palette, "base") !important;
&.border-darken {
border-color: map-get($color-palette, "darken") !important;
}
&.border-lighten {
border-color: map-get($color-palette, "lighten") !important;
}
}
.service-color-text[data-service="#{$service-name}"] {
color: map-get($color-palette, "base") !important;
&.text-darken {
color: map-get($color-palette, "darken") !important;
}
&.text-lighten {
color: map-get($color-palette, "lighten") !important;
}
}
.service-scheme[data-service="#{$service-name}"] {
background-color: map-get($color-palette, "lighten");
.btn, .btn-small, .btn-large, .btn-floating {
background-color: map-get($color-palette, "darken");
&:hover {
background-color: map-get($color-palette, "base");
}
}
.pagination {
li.active {
background-color: map-get($color-palette, "darken");
}
}
.table-of-contents {
a.active, a:hover {
border-color: map-get($color-palette, "darken");
}
}
.tabs {
.tab {
&.disabled {
a {
color: inherit;
&:hover {
color: change-color(map-get($color-palette, "darken"), $alpha: 0.15);
}
}
}
a {
color: inherit;
&:focus, &:hover, &.active {
color: map-get($color-palette, "darken");
}
&:focus, &.active, &.active:focus {
background-color: change-color(map-get($color-palette, "darken"), $alpha: 0.15);
}
}
}
.indicator {
background-color: map-get($color-palette, "darken");
}
}
}
}
@each $ressource-name, $color-palette in map-get($color, "status") {
@each $key, $color-code in $color-palette {
.#{$ressource-name}-status-color[data-status="#{$key}"] {
background-color: $color-code !important;
}
.#{$ressource-name}-status-color-border[data-status="#{$key}"] {
border-color: $color-code !important;
}
.#{$ressource-name}-status-color-text[data-status="#{$key}"] {
color: $color-code !important;
}
}
}
@each $key, $color-code in map-get($color, "s-attr") {
.chip.s-attr[data-s-attr-type="ent"][data-s-attr-ent-type="#{$key}"] {
background-color: $color-code !important;
}
}
main {
.btn, .btn-small, .btn-large, .btn-floating {
background-color: map-deep-get($color, "baseline", "secondary");
&:hover {
background-color: map-deep-get($color, "baseline", "secondary-variant");
}
}
.pagination {
li.active {
background-color: map-deep-get($color, "baseline", "secondary");
}
}
.table-of-contents {
a.active, a:hover {
border-color: map-deep-get($color, "baseline", "secondary");
}
}
.tabs {
.tab {
&.disabled {
a {
color: inherit;
&:hover {
color: change-color(map-deep-get($color, "baseline", "secondary"), $alpha: 0.15);
}
}
}
a {
color: inherit;
&:focus, &:hover, &.active {
color: map-deep-get($color, "baseline", "secondary");
}
&:focus, &.active, &.active:focus {
background-color: change-color(map-deep-get($color, "baseline", "secondary"), $alpha: 0.15);
}
}
}
.indicator {
background-color: map-deep-get($color, "baseline", "secondary");
}
}
}

View File

@ -1,43 +0,0 @@
[data-corpus-status="UNPREPARED"] {
--corpus-status-color: #9e9e9e;
}
[data-corpus-status="QUEUED"] {
--corpus-status-color: #2196f3;
}
[data-corpus-status="BUILDING"] {
--corpus-status-color: #ffc107;
}
[data-corpus-status="BUILT"] {
--corpus-status-color: #4caf50;
}
[data-corpus-status="FAILED"] {
--corpus-status-color: #f44336;
}
[data-corpus-status="STARTING_ANALYSIS_SESSION"] {
--corpus-status-color: #2196f3;
}
[data-corpus-status="RUNNING_ANALYSIS_SESSION"] {
--corpus-status-color: #4caf50;
}
[data-corpus-status="CANCELING_ANALYSIS_SESSION"] {
--corpus-status-color: #ff5722;
}
.corpus-status-color {
background-color: var(--corpus-status-color) !important;
}
.corpus-status-color-border {
border-color: var(--corpus-status-color) !important;
}
.corpus-status-color-text {
color: var(--corpus-status-color) !important;
}

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