Add a parallel package for query results.

This commit is contained in:
Patrick Jentsch 2020-07-13 15:33:00 +02:00
parent 2b3d08b277
commit 6760061d53
16 changed files with 852 additions and 35 deletions

View File

@ -52,6 +52,10 @@ def create_app(config_name):
from .profile import profile as profile_blueprint from .profile import profile as profile_blueprint
app.register_blueprint(profile_blueprint, url_prefix='/profile') app.register_blueprint(profile_blueprint, url_prefix='/profile')
from .query_results import query_results as query_results_blueprint
app.register_blueprint(query_results_blueprint,
url_prefix='/query_results')
from .services import services as services_blueprint from .services import services as services_blueprint
app.register_blueprint(services_blueprint, url_prefix='/services') app.register_blueprint(services_blueprint, url_prefix='/services')

View File

@ -135,6 +135,10 @@ class User(UserMixin, db.Model):
cascade='save-update, merge, delete') cascade='save-update, merge, delete')
results = db.relationship('Result', backref='creator', lazy='dynamic', results = db.relationship('Result', backref='creator', lazy='dynamic',
cascade='save-update, merge, delete') cascade='save-update, merge, delete')
query_results = db.relationship('QueryResult',
backref='creator',
cascade='save-update, merge, delete',
lazy='dynamic')
def to_dict(self): def to_dict(self):
return {'id': self.id, return {'id': self.id,
@ -151,7 +155,9 @@ class User(UserMixin, db.Model):
self.setting_job_status_site_notifications}, self.setting_job_status_site_notifications},
'corpora': {corpus.id: corpus.to_dict() 'corpora': {corpus.id: corpus.to_dict()
for corpus in self.corpora}, for corpus in self.corpora},
'jobs': {job.id: job.to_dict() for job in self.jobs}} 'jobs': {job.id: job.to_dict() for job in self.jobs},
'query_results': {query_result.id: query_result.to_dict()
for query_result in self.query_results}}
def __repr__(self): def __repr__(self):
''' '''
@ -616,6 +622,43 @@ class Corpus(db.Model):
return '<Corpus {corpus_title}>'.format(corpus_title=self.title) return '<Corpus {corpus_title}>'.format(corpus_title=self.title)
class QueryResult(db.Model):
'''
Class to define a corpus analysis result.
'''
__tablename__ = 'query_results'
# Primary key
id = db.Column(db.Integer, primary_key=True)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# Fields
description = db.Column(db.String(255))
filename = db.Column(db.String(255))
query_metadata = db.Column(db.JSON())
title = db.Column(db.String(32))
def delete(self):
query_result_dir = os.path.join(current_app.config['NOPAQUE_STORAGE'],
str(self.user_id),
'query_results',
str(self.id))
shutil.rmtree(query_result_dir, ignore_errors=True)
db.session.delete(self)
def to_dict(self):
return {'id': self.id,
'user_id': self.user_id,
'description': self.description,
'filename': self.filename,
'title': self.title}
def __repr__(self):
'''
String representation of the CorpusAnalysisResult. For human readability.
'''
return '<QueryResult {}>'.format(self.title)
class Result(db.Model): class Result(db.Model):
''' '''
Class to define a result set of one query. Class to define a result set of one query.

View File

@ -0,0 +1,5 @@
from flask import Blueprint
query_results = Blueprint('query_results', __name__)
from . import views # noqa

View File

@ -0,0 +1,21 @@
from flask_wtf import FlaskForm
from werkzeug.utils import secure_filename
from wtforms import FileField, StringField, SubmitField, ValidationError
from wtforms.validators import DataRequired, Length
class AddQueryResultForm(FlaskForm):
'''
Form used to import one result json file.
'''
description = StringField('Description',
validators=[DataRequired(), Length(1, 255)])
file = FileField('File', validators=[DataRequired()])
title = StringField('Title', validators=[DataRequired(), Length(1, 32)])
submit = SubmitField()
def validate_file(self, field):
if not field.data.filename.lower().endswith('.json'):
raise ValidationError('File does not have an approved extension: '
'.json')
field.data.filename = secure_filename(field.data.filename)

View File

@ -0,0 +1,13 @@
from .. import db
from ..decorators import background
from ..models import QueryResult
@background
def delete_query_result(query_result_id, *args, **kwargs):
with kwargs['app'].app_context():
query_result = QueryResult.query.get(query_result_id)
if query_result is None:
raise Exception('QueryResult {} not found'.format(query_result_id))
query_result.delete()
db.session.commit()

View File

@ -0,0 +1,144 @@
from . import query_results
from . import tasks
from .. import db
from ..corpora.forms import DisplayOptionsForm
from ..models import QueryResult
from .forms import AddQueryResultForm
from flask import (abort, current_app, flash, make_response, redirect,
render_template, request, send_from_directory, url_for)
from flask_login import current_user, login_required
import json
import os
from jsonschema import validate
@query_results.route('/add', methods=['GET', 'POST'])
@login_required
def add_query_result():
'''
View to import a result as a json file.
'''
add_query_result_form = AddQueryResultForm(prefix='add-query-result-form')
if add_query_result_form.is_submitted():
if not add_query_result_form.validate():
return make_response(add_query_result_form.errors, 400)
query_result = QueryResult(
creator=current_user,
description=add_query_result_form.description.data,
filename=add_query_result_form.file.data.filename,
title=add_query_result_form.title.data
)
db.session.add(query_result)
db.session.commit()
# create paths to save the uploaded json file
query_result_dir = os.path.join(current_app.config['NOPAQUE_STORAGE'],
str(current_user.id),
'query_results',
str(query_result.id))
try:
os.makedirs(query_result_dir)
except Exception:
db.session.delete(query_result)
db.session.commit()
flash('Internal Server Error', 'error')
redirect_url = url_for('query_results.add_query_result')
return make_response({'redirect_url': redirect_url}, 500)
# save the uploaded file
query_result_file_path = os.path.join(query_result_dir,
query_result.filename)
add_query_result_form.file.data.save(query_result_file_path)
# parse json from file
with open(query_result_file_path, 'r') as file:
query_result_file_content = json.load(file)
# parse json schema
with open('app/static/json_schema/nopaque_cqi_py_results_schema.json', 'r') as file: # noqa
schema = json.load(file)
try:
# validate imported json file
validate(instance=query_result_file_content, schema=schema)
except Exception:
tasks.delete_query_result(query_result.id)
flash('Uploaded file is invalid', 'result')
redirect_url = url_for('query_results.add_query_result')
return make_response({'redirect_url': redirect_url}, 201)
query_result_file_content.pop('matches')
query_result_file_content.pop('cpos_lookup')
query_result.query_metadata = query_result_file_content
db.session.commit()
flash('Query result added!', 'result')
redirect_url = url_for('query_results.query_result',
query_result_id=query_result.id)
return make_response({'redirect_url': redirect_url}, 201)
return render_template('query_results/add_query_result.html.j2',
add_query_result_form=add_query_result_form,
title='Add query result')
@query_results.route('/<int:query_result_id>')
@login_required
def query_result(query_result_id):
query_result = QueryResult.query.get_or_404(query_result_id)
if not (query_result.creator == current_user
or current_user.is_administrator()):
abort(403)
return render_template('query_results/query_result.html.j2',
query_result=query_result,
title='Query result')
@query_results.route('/<int:query_result_id>/inspect')
@login_required
def inspect_query_result(query_result_id):
'''
View to inspect one importe result file in a corpus analysis like interface
'''
query_result = QueryResult.query.get_or_404(query_result_id)
if not (query_result.creator == current_user
or current_user.is_administrator()):
abort(403)
display_options_form = DisplayOptionsForm(
prefix='display-options-form',
results_per_page=request.args.get('results_per_page', 30),
result_context=request.args.get('context', 20)
)
query_result_file_path = os.path.join(
current_app.config['NOPAQUE_STORAGE'],
str(current_user.id),
'query_results',
str(query_result.id),
query_result.filename
)
with open(query_result_file_path, 'r') as query_result_file:
query_result_content = json.load(query_result_file)
return render_template('query_results/inspect_query_result.html.j2',
display_options_form=display_options_form,
query_result_content=query_result_content,
title='Inspect query result')
@query_results.route('/<int:query_result_id>/delete')
@login_required
def delete_query_result(query_result_id):
query_result = QueryResult.query.get_or_404(query_result_id)
if not (query_result.creator == current_user
or current_user.is_administrator()):
abort(403)
tasks.delete_result(query_result_id)
flash('Query result deleted!', 'result')
return redirect(url_for('main.dashboard'))
@query_results.route('/<int:query_result_id>/download')
@login_required
def download_query_result(query_result_id):
query_result = QueryResult.query.get_or_404(query_result_id)
if not (query_result.creator == current_user
or current_user.is_administrator()):
abort(403)
query_result_dir = os.path.join(current_app.config['NOPAQUE_STORAGE'],
str(current_user.id),
'query_results',
str(query_result.id))
return send_from_directory(as_attachment=True,
directory=query_result_dir,
filename=query_result.filename)

View File

@ -14,6 +14,7 @@ nopaque.user.settings = {};
nopaque.user.settings.darkMode = undefined; nopaque.user.settings.darkMode = undefined;
nopaque.corporaSubscribers = []; nopaque.corporaSubscribers = [];
nopaque.jobsSubscribers = []; nopaque.jobsSubscribers = [];
nopaque.queryResultsSubscribers = [];
// Foreign user (user inspected with admin credentials) data // Foreign user (user inspected with admin credentials) data
nopaque.foreignUser = {}; nopaque.foreignUser = {};
@ -22,6 +23,7 @@ nopaque.foreignUser.settings = {};
nopaque.foreignUser.settings.darkMode = undefined; nopaque.foreignUser.settings.darkMode = undefined;
nopaque.foreignCorporaSubscribers = []; nopaque.foreignCorporaSubscribers = [];
nopaque.foreignJobsSubscribers = []; nopaque.foreignJobsSubscribers = [];
nopaque.foreignQueryResultsSubscribers = [];
nopaque.flashedMessages = undefined; nopaque.flashedMessages = undefined;
@ -38,6 +40,9 @@ nopaque.socket.init = function() {
for (let subscriber of nopaque.jobsSubscribers) { for (let subscriber of nopaque.jobsSubscribers) {
subscriber._init(nopaque.user.jobs); subscriber._init(nopaque.user.jobs);
} }
for (let subscriber of nopaque.queryResultsSubscribers) {
subscriber._init(nopaque.user.query_results);
}
RessourceList.modifyTooltips(false) RessourceList.modifyTooltips(false)
}); });
@ -48,12 +53,16 @@ nopaque.socket.init = function() {
nopaque.user = jsonpatch.apply_patch(nopaque.user, patch); nopaque.user = jsonpatch.apply_patch(nopaque.user, patch);
corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora"));
jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs")); jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs"));
query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results"));
for (let subscriber of nopaque.corporaSubscribers) { for (let subscriber of nopaque.corporaSubscribers) {
subscriber._update(corpora_patch); subscriber._update(corpora_patch);
} }
for (let subscriber of nopaque.jobsSubscribers) { for (let subscriber of nopaque.jobsSubscribers) {
subscriber._update(jobs_patch); subscriber._update(jobs_patch);
} }
for (let subscriber of nopaque.queryResultsSubscribers) {
subscriber._update(query_results_patch);
}
if (["all", "end"].includes(nopaque.user.settings.job_status_site_notifications)) { if (["all", "end"].includes(nopaque.user.settings.job_status_site_notifications)) {
for (operation of jobs_patch) { for (operation of jobs_patch) {
/* "/jobs/{jobId}/..." -> ["{jobId}", ...] */ /* "/jobs/{jobId}/..." -> ["{jobId}", ...] */
@ -74,6 +83,9 @@ nopaque.socket.init = function() {
for (let subscriber of nopaque.foreignJobsSubscribers) { for (let subscriber of nopaque.foreignJobsSubscribers) {
subscriber._init(nopaque.foreignUser.jobs); subscriber._init(nopaque.foreignUser.jobs);
} }
for (let subscriber of nopaque.foreignQueryResultsSubscribers) {
subscriber._init(nopaque.foreignUser.query_results);
}
RessourceList.modifyTooltips(false) RessourceList.modifyTooltips(false)
}); });
@ -84,8 +96,10 @@ nopaque.socket.init = function() {
nopaque.foreignUser = jsonpatch.apply_patch(nopaque.foreignUser, patch); nopaque.foreignUser = jsonpatch.apply_patch(nopaque.foreignUser, patch);
corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora"));
jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs")); jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs"));
query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results"));
for (let subscriber of nopaque.foreignCorporaSubscribers) {subscriber._update(corpora_patch);} for (let subscriber of nopaque.foreignCorporaSubscribers) {subscriber._update(corpora_patch);}
for (let subscriber of nopaque.foreignJobsSubscribers) {subscriber._update(jobs_patch);} for (let subscriber of nopaque.foreignJobsSubscribers) {subscriber._update(jobs_patch);}
for (let subscriber of nopaque.foreignQueryResultsSubscribers) {subscriber._update(query_results_patch);}
}); });
} }

View File

@ -1,16 +1,15 @@
class RessourceList extends List { class RessourceList extends List {
constructor(idOrElement, subscriberList, type, options={}) { constructor(idOrElement, subscriberList, type, options={}) {
if (!["corpus", "job", "result", "user", "job_input", if (!["corpus", "corpus_file", "job", "job_input", "query_result", "result", "user"].includes(type)) {
"corpus_file"].includes(type)) {
console.error("Unknown Type!"); console.error("Unknown Type!");
return; return;
} }
if (subscriberList) { if (subscriberList) {
super(idOrElement, {...RessourceList.options['common'], super(idOrElement, {...RessourceList.options['common'],
...RessourceList.options[type], ...RessourceList.options[type],
...options}); ...options});
this.type = type; this.type = type;
subscriberList.push(this); subscriberList.push(this);
} else { } else {
super(idOrElement, {...RessourceList.options['extended'], super(idOrElement, {...RessourceList.options['extended'],
...RessourceList.options[type], ...RessourceList.options[type],
@ -81,8 +80,7 @@ class RessourceList extends List {
RessourceList.dataMapper = { RessourceList.dataMapper = {
// ### Mapping Genera Info // A data mapper describes entitys rendered per row. One key value pair holds
//The Mapping describes entitys rendered per row. One key value pair holds
// the data to be rendered in the list.js table. Key has to correspond // the data to be rendered in the list.js table. Key has to correspond
// with the ValueNames defined below in RessourceList.options ValueNames. // with the ValueNames defined below in RessourceList.options ValueNames.
// Links are declared with double ticks(") around them. The key for links // Links are declared with double ticks(") around them. The key for links
@ -96,8 +94,7 @@ RessourceList.dataMapper = {
"analyse-link": ["analysing", "prepared", "start analysis"].includes(corpus.status) ? `/corpora/${corpus.id}/analyse` : "", "analyse-link": ["analysing", "prepared", "start analysis"].includes(corpus.status) ? `/corpora/${corpus.id}/analyse` : "",
"edit-link": `/corpora/${corpus.id}`, "edit-link": `/corpora/${corpus.id}`,
status: corpus.status, status: corpus.status,
title: corpus.title title: corpus.title}),
}),
// Mapping for corpus file entities shown in the corpus overview // Mapping for corpus file entities shown in the corpus overview
corpus_file: corpus_file => ({filename: corpus_file.filename, corpus_file: corpus_file => ({filename: corpus_file.filename,
author: corpus_file.author, author: corpus_file.author,
@ -105,8 +102,7 @@ RessourceList.dataMapper = {
publishing_year: corpus_file.publishing_year, publishing_year: corpus_file.publishing_year,
"edit-link": `${corpus_file.corpus_id}/files/${corpus_file.id}/edit`, "edit-link": `${corpus_file.corpus_id}/files/${corpus_file.id}/edit`,
"download-link": `${corpus_file.corpus_id}/files/${corpus_file.id}/download`, "download-link": `${corpus_file.corpus_id}/files/${corpus_file.id}/download`,
"delete-modal": `delete-corpus-file-${corpus_file.id}-modal` "delete-modal": `delete-corpus-file-${corpus_file.id}-modal`}),
}),
// Mapping for job entities shown in the dashboard table. // Mapping for job entities shown in the dashboard table.
job: job => ({creation_date: job.creation_date, job: job => ({creation_date: job.creation_date,
description: job.description, description: job.description,
@ -114,34 +110,34 @@ RessourceList.dataMapper = {
link: `/jobs/${job.id}`, link: `/jobs/${job.id}`,
service: job.service, service: job.service,
status: job.status, status: job.status,
title: job.title title: job.title}),
}),
// Mapping for job input files shown in table on every job page // Mapping for job input files shown in table on every job page
job_input: job_input => ({filename: job_input.filename, job_input: job_input => ({filename: job_input.filename,
id: job_input.job_id, id: job_input.job_id,
"download-link": `${job_input.job_id}/inputs/${job_input.id}/download` "download-link": `${job_input.job_id}/inputs/${job_input.id}/download`}),
}),
// Mapping for imported result entities from corpus analysis. // Mapping for imported result entities from corpus analysis.
// Shown in imported results table // Shown in imported results table
result: result => ({ query: result.query, query_result: query_result => ({description: query_result.description,
match_count: result.match_count, id: query_result.id,
corpus_name: result.corpus_name, link: `/query_results/${query_result.id}`,
corpus_creation_date: result.corpus_creation_date, title: query_result.title}),
corpus_analysis_date: result.corpus_analysis_date, result: result => ({query: result.query,
corpus_type : result.corpus_type, match_count: result.match_count,
"details-link": `${result.id}/details`, corpus_name: result.corpus_name,
"inspect-link": `${result.id}/inspect`, corpus_creation_date: result.corpus_creation_date,
"download-link": `${result.id}/file/${result.file_id}/download`, corpus_analysis_date: result.corpus_analysis_date,
"delete-modal": `delete-result-${result.id}-modal` corpus_type : result.corpus_type,
}), "details-link": `${result.id}/details`,
"inspect-link": `${result.id}/inspect`,
"download-link": `${result.id}/file/${result.file_id}/download`,
"delete-modal": `delete-result-${result.id}-modal`}),
// Mapping for user entities shown in admin table // Mapping for user entities shown in admin table
user: user => ({username: user.username, user: user => ({username: user.username,
email: user.email, email: user.email,
role_id: user.role_id, role_id: user.role_id,
confirmed: user.confirmed, confirmed: user.confirmed,
id: user.id, id: user.id,
"profile-link": `user/${user.id}` "profile-link": `user/${user.id}`})
})
}; };
@ -289,6 +285,32 @@ RessourceList.options = {
"id", "id",
{name: "download-link", attr: "href"}] {name: "download-link", attr: "href"}]
}, },
query_result: {item: `<tr>
<td>
<a class="btn-floating disabled">
<i class="material-icons service">book</i>
</a>
</td>
<td>
<b class="title"></b><br>
<i class="description"></i>
</td>
<td class="actions right-align">
<a class="btn-floating tooltipped link waves-effect
waves-light"
data-position="top"
data-tooltip="Go to query result">
<i class="material-icons">send</i>
</a>
</td>
</tr>`,
// Job Value Names per column. Have to correspond with the keys from the
// Mapping step above.
valueNames: ["description",
"title",
{data: ["id"]},
{name: "link", attr: "href"}]
},
// Result (imported from corpus analysis) entity blueprint setting html // Result (imported from corpus analysis) entity blueprint setting html
// strucuture per entity per row // strucuture per entity per row
// Link classes have to correspond with Links defined in the Mapping process // Link classes have to correspond with Links defined in the Mapping process

View File

@ -0,0 +1,44 @@
{% extends "nopaque.html.j2" %}
{% block page_content %}
<div class="col s12 m4">
<p>Fill out the following form to upload and view your exported query data from the corpus analsis.</p>
<a class="waves-effect waves-light btn" href="{{ url_for('main.dashboard') }}"><i class="material-icons left">arrow_back</i>Back to dashboard</a>
</div>
<div class="col s12 m8">
<form class="nopaque-submit-form" data-progress-modal="progress-modal">
<div class="card">
<div class="card-content">
{{ add_query_result_form.hidden_tag() }}
<div class="row">
<div class="col s12 m4">
{{ M.render_field(add_query_result_form.title, data_length='32', material_icon='title') }}
</div>
<div class="col s12 m8">
{{ M.render_field(add_query_result_form.description, data_length='255', material_icon='description') }}
</div>
<div class="col s12">
{{ M.render_field(add_query_result_form.file, accept='.json', placeholder='Choose your .json file') }}
</div>
</div>
</div>
<div class="card-action right-align">
{{ M.render_field(add_query_result_form.submit, material_icon='send') }}
</div>
</div>
</form>
</div>
<div id="progress-modal" class="modal">
<div class="modal-content">
<h4><i class="material-icons prefix">file_upload</i> Uploading file...</h4>
<div class="progress">
<div class="determinate" style="width: 0%"></div>
</div>
</div>
<div class="modal-footer">
<a href="#!" class="modal-close waves-effect waves-light btn red abort-request">Cancel</a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,211 @@
{% extends "nopaque.html.j2" %}
{% set headline = ' ' %}
{% set full_width = True %}
{% block page_content %}
<div class="col s12" id="query-display">
<div class="card">
<div class="card-content" id="result-list" style="overflow: hidden;">
<div class="row" style="margin-bottom: 0px;">
<div class="col s12 m3 l3" id="results-info">
<div class="row section">
<h6 style="margin-top: 0px;">Infos</h6>
<div class="divider" style="margin-bottom: 10px;"></div>
<div class="col" id="infos">
<p>
Displaying
<span id="received-match-count">
</span> of
<span id="match-count"></span>
matches.
<br>
Matches occured in
<span id="text-lookup-count"></span>
corpus files:
<br>
<span id=text-titles></span>
</p>
<div class="progress hide" id="query-results-progress">
<div class="determinate" id="query-results-determinate"></div>
</div>
</div>
</div>
</div>
<div class="col s12 m9 l9" id="actions-and-tools">
<div class="row section">
<div class="col s12 m3 l3" id="display">
<h6 style="margin-top: 0px;">Display</h6>
<div class="divider" style="margin-bottom: 10px;"></div>
<div class="row">
<div class="col s12">
<form id="display-options-form">
{{ M.render_field(display_options_form.results_per_page,
material_icon='format_list_numbered') }}
{{ M.render_field(display_options_form.result_context,
material_icon='short_text') }}
{{ M.render_field(display_options_form.expert_mode) }}
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Table showing the query results -->
<div class="col s12">
<ul class="pagination paginationTop"></ul>
<table class="responsive-table highlight">
<thead>
<tr>
<th style="width: 2%">Nr.</th>
<th style="width: 3%">Title</th>
<th style="width: 25%">Left context</th>
<th style="width: 35%">Match</th>
<th style="width: 10%">Actions</th>
<th style="width: 25%">Right Context</th>
</tr>
</thead>
<tbody class="list" id="query-results">
</tbody>
</table>
<ul class="pagination paginationBottom"></ul>
</div>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/nopaque.Results.js') }}">
</script>
<script src="{{ url_for('static', filename='js/nopaque.callbacks.js') }}">
</script>
<script src="{{ url_for('static', filename='js/nopaque.InteractionElement.js') }}">
</script>
<script>
// ###### global variables ######
var full_result_json;
var result_json;
var queryResultsDeterminateElement; // The progress bar for recieved results
var receivedMatchCountElement; // Nr. of loaded matches will be displayed in this element
var textLookupCountElement // Nr of texts the matches occured in will be shown in this element
var textTitlesElement; // matched text titles
var progress; // global progress value
var queryResultsProgressElement; // Div element holding the progress bar
var expertModeSwitchElement; // Expert mode switch Element
var matchCountElement; // Total nr. of matches will be displayed in this element
var interactionElements; // Interaction elements and their parameters
// ###### Defining local scope variables
let displayOptionsFormElement; // Form holding the display informations
let resultItems; // array of built html result items row element. This is called when results are transmitted and being recieved
let hitsPerPageInputElement;let contextPerItemElement; // Form Element for display option
let paginationElements;
// ###### Initializing variables ######
displayOptionsFormElement = document.getElementById("display-options-form");
resultItems = [];
queryResultsDeterminateElement = document.getElementById("query-results-determinate");
receivedMatchCountElement = document.getElementById("received-match-count");
textLookupCountElement = document.getElementById("text-lookup-count");
textTitlesElement = document.getElementById("text-titles");
queryResultsProgressElement = document.getElementById("query-results-progress");
expertModeSwitchElement = document.getElementById("display-options-form-expert_mode");
matchCountElement = document.getElementById("match-count");
hitsPerPageInputElement = document.getElementById("display-options-form-results_per_page");
contextPerItemElement = document.getElementById("display-options-form-result_context");
paginationElements = document.getElementsByClassName("pagination");
// js list options
displayOptionsData = ResultsList.getDisplayOptions(displayOptionsFormElement);
resultsListOptions = {page: displayOptionsData["resultsPerPage"],
pagination: [{
name: "paginationTop",
paginationClass: "paginationTop",
innerWindow: 8,
outerWindow: 1
}, {
paginationClass: "paginationBottom",
innerWindow: 8,
outerWindow: 1
}],
valueNames: ["titles", "lc", "c", "rc", {data: ["index"]}],
item: `<span></span>`
};
document.addEventListener("DOMContentLoaded", () => {
// ###### recreating chunk structure to reuse callback queryRenderResults()
full_result_json = {{ result_json|tojson|safe }};
result_json = {};
result_json.chunk = {};
result_json.chunk["cpos_lookup"] = full_result_json.cpos_lookup;
result_json.chunk["cpos_ranges"] = full_result_json.cpos_ranges;
result_json.chunk["matches"] = full_result_json.matches;
result_json.chunk["text_lookup"] = full_result_json.text_lookup;
// Init corpus analysis components
data = new Data();
resultsList = new ResultsList("result-list", resultsListOptions);
resultsMetaData = new MetaData();
results = new Results(data, resultsList, resultsMetaData);
results.clearAll(); // inits some object keys and values
// TODO: save metadate into results.metaData
// setting some initial values for user feedback
matchCountElement.innerText = full_result_json.match_count;
// Initialization of interactionElemnts
// An interactionElement is an object identifing a switch or button via
// htmlID. Callbacks are set for these elements which will be triggered on
// a pagination interaction by the user or if the status of the element has
// been altered. (Like the switche has ben turned on or off).
interactionElements = new Array();
let expertModeInteraction = new InteractionElement("display-options-form-expert_mode");
expertModeInteraction.setCallback("on",
results.jsList.expertModeOn,
results.jsList,
["query-display"])
expertModeInteraction.setCallback("off",
results.jsList.expertModeOff,
results.jsList,
["query-display"])
let activateInspectInteraction = new InteractionElement("inspect",
false);
activateInspectInteraction.setCallback("noCheck",
results.jsList.activateInspect,
results.jsList);
let changeContextInteraction = new InteractionElement("display-options-form-results_per_page",
false);
changeContextInteraction.setCallback("noCheck",
results.jsList.changeContext,
results.jsList)
interactionElements.push(expertModeInteraction, activateInspectInteraction, changeContextInteraction);
// checks if a change for every interactionElement happens and executes
// the callbacks accordingly
InteractionElement.onChangeExecute(interactionElements);
// eventListener if pagination is used to apply new context size to new page
// and also activate inspect match if progress is 100
// also adds more interaction buttons like add to sub results
for (let element of paginationElements) {
element.addEventListener("click", (event) => {
results.jsList.pageChangeEventInteractionHandler(interactionElements);
});
}
// render results in table imported parameter is true
queryRenderResults(result_json, true)
// live update of hits per page if hits per page value is changed
let changeHitsPerPageBind = results.jsList.changeHitsPerPage.bind(results.jsList);
hitsPerPageInputElement.onchange = changeHitsPerPageBind;
// live update of lr context per item if context value is changed
contextPerItemElement.onchange = results.jsList.changeContext;
});
</script>
{% endblock %}

View File

@ -0,0 +1,119 @@
{% extends "nopaque.html.j2" %}
{% block page_content %}
<div class="col s12">
<p>Below the metadata for the results from the Corpus
<i>{{ query_result.query_metadata.corpus_name }}</i> generated with the query
<i>{{ query_result.query_metadata.query }}</i> are shown.
</p>
</div>
<div class="col s12">
<div class="card">
<div class="card-content" id="results">
<table class="responsive-table highlight">
<thead>
<tr>
<th>Metadata Description</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for pair in query_result.query_metadata|dictsort %}
<tr>
<td>{{ pair[0] }}</td>
{% if pair[0] == 'corpus_all_texts'
or pair[0] == 'text_lookup' %}
<td>
<table>
{% for key, value in pair[1].items() %}
<tr style="border-bottom: none;">
<td>
<i>{{ value['title'] }}</i> written
by <i>{{ value['author'] }}</i>
in <i>{{ value['publishing_year'] }}</i>
<a class="waves-effect
waves-light
btn
right
more-text-detials"
data-metadata-key="{{ pair[0] }}"
data-text-key="{{ key }}"
href="#modal-text-details">More
<i class="material-icons right"
data-metadata-key="{{ pair[0] }}"
data-text-key="{{ key }}">
info_outline
</i>
</a>
</td>
</tr>
{% endfor %}
</table>
</td>
{% else %}
<td>{{ pair[1] }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-action right-align">
<a class="waves-effect waves-light btn left-align" href="{{ url_for('services.service', service='corpus_analysis') }}">Back To Overview<i class="material-icons right">arrow_back</i></a>
<a class="waves-effect waves-light btn" href="{{ url_for('query_results.inspect_query_result', query_result_id=query_result.id) }}">Inspect Results<i class="material-icons right">search</i></a>
</div>
</div>
</div>
<!-- Modal Structure -->
<div id="modal-text-details" class="modal modal-fixed-footer">
<div class="modal-content">
<h4>Bibliographic data</h4>
<p id="bibliographic-data"></p>
</div>
<div class="modal-footer">
<a href="#!" class="modal-close waves-effect waves-green red btn">Close</a>
</div>
</div>
<script>
var moreTextDetailsButtons;
moreTextDetailsButtons = document.getElementsByClassName("more-text-detials");
for (var btn of moreTextDetailsButtons) {
btn.onclick = () => {
let modal = document.getElementById("modal-text-details");
modal = M.Modal.init(modal, {"dismissible": true});
modal.open();
let metadataKey = event.target.dataset.metadataKey;
let textKey = event.target.dataset.textKey;
let textData = {{ query_result.query_metadata|tojson|safe }}[metadataKey][textKey];
console.log(textData);
let bibliographicData = document.getElementById("bibliographic-data");
bibliographicData.innerHTML = "";
let table = document.createElement("table");
for (let [key, value] of Object.entries(textData)) {
table.insertAdjacentHTML("afterbegin",
`
<tr>
<td>${key}</td>
<td>${value}</td>
</tr>
`);
}
table.insertAdjacentHTML("afterbegin",
`
<thead>
<th>Description</th>
<th>Value</th>
</thead>
`)
bibliographicData.appendChild(table);
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends "nopaque.html.j2" %}
{% set full_width = True %}
{% block page_content %}
<div class="col s12">
<p>This is an overview of all your imported results.</p>
</div>
<div class="col s12">
<div class="card">
<div class="card-content" id="results">
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="search-results" class="search" type="search"></input>
<label for="search-results">Search results</label>
</div>
<ul class="pagination paginationTop"></ul>
<table class="highlight responsive-table">
<thead>
<tr>
<th class="sort" data-sort="query">Query</th>
<th class="sort" data-sort="match_count">Match count</th>
<th class="sort" data-sort="corpus_name">Corpus name</th>
<th class="sort" data-sort="corpus_creation_date">Corpus creation date</th>
<th class="sort" data-sort="corpus_analysis_date">Corpus analysis date</th>
<th class="sort" data-sort="corpus_type">Corpus type</th>
<th>{# Actions #}</th>
</tr>
</thead>
<tbody class="list">
<tr class="show-if-only-child">
<td colspan="5">
<span class="card-title"><i class="material-icons left">folder</i>Nothing here...</span>
<p>No results yet imported.</p>
</td>
</tr>
</tbody>
</table>
<ul class="pagination paginationBottom"></ul>
</div>
<div class="card-action right-align">
<a class="waves-effect waves-light btn" href="{{ url_for('results.import_results') }}">Import Results<i class="material-icons right">file_upload</i></a>
</div>
</div>
</div>
{# Delete modals #}
{% for result in results %}
<div id="delete-result-{{ result.id }}-modal" class="modal">
<div class="modal-content">
<h4>Confirm result file deletion</h4>
<p>Do you really want to delete the result file created on <i>{{ result.corpus_analysis_date }}</i>?
<p>The file holds results for the query <i>{{ result.query }}</i>.</p>
<p>The file will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="{{ url_for('results.result_delete', result_id=result.id) }}"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
{% endfor %}
<script>
var ressources = {{ results|tojson|safe }};
var importedResultsList = new RessourceList("results", null, "result");
importedResultsList.addRessources(ressources);
RessourceList.modifyTooltips();
</script>
{% endblock %}

View File

@ -12,6 +12,7 @@
</div> </div>
<div class="col s12"> <div class="col s12">
<h3>My Corpora</h3>
<div class="card"> <div class="card">
<div class="card-content" id="corpora"> <div class="card-content" id="corpora">
<div class="input-field"> <div class="input-field">
@ -36,15 +37,54 @@
<ul class="pagination"></ul> <ul class="pagination"></ul>
</div> </div>
<div class="card-action right-align"> <div class="card-action right-align">
<a class="waves-effect waves-light btn" href="{{ url_for('results.import_results') }}">Import Results<i class="material-icons right">file_upload</i></a>
<a class="waves-effect waves-light btn" href="{{ url_for('results.results_overview') }}">Show Imported Results<i class="material-icons right">folder</i></a>
<a class="waves-effect waves-light btn" href="{{ url_for('corpora.add_corpus') }}">New corpus<i class="material-icons right">add</i></a> <a class="waves-effect waves-light btn" href="{{ url_for('corpora.add_corpus') }}">New corpus<i class="material-icons right">add</i></a>
</div> </div>
</div> </div>
</div> </div>
<div class="col s12">
<h3>My query results</h3>
<div class="card">
<div class="card-content" id="query-results">
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="search-query-results" class="search" type="search"></input>
<label for="search-query-results">Search query result</label>
</div>
<ul class="pagination paginationTop"></ul>
<table class="highlight responsive-table">
<thead>
<tr>
<th>{# Service #}</th>
<th>
<span class="sort" data-sort="title">Title</span>
<span class="sort" data-sort="description">Description</span>
</th>
<th>{# Actions #}</th>
</tr>
</thead>
<tbody class="list">
<tr class="show-if-only-child">
<td colspan="5">
<span class="card-title"><i class="material-icons left">folder</i>Nothing here...</span>
<p>No query results yet imported.</p>
</td>
</tr>
</tbody>
</table>
<ul class="pagination paginationBottom"></ul>
</div>
<div class="card-action right-align">
<a class="waves-effect waves-light btn" href="{{ url_for('query_results.add_query_result') }}">Add query result<i class="material-icons right">file_upload</i></a>
</div>
</div>
</div>
<script> <script>
var corpusList = new RessourceList("corpora", nopaque.corporaSubscribers, var corpusList = new RessourceList("corpora", nopaque.corporaSubscribers,
"corpus", {page: 10}); "corpus", {page: 10});
var queryResultList = new RessourceList("query-results",
nopaque.queryResultsSubscribers,
"query_result", {page: 10});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,30 @@
"""empty message
Revision ID: 33ec4d09b4ca
Revises: 4cf5e5606a83
Create Date: 2020-07-13 09:07:19.297185
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '33ec4d09b4ca'
down_revision = '4cf5e5606a83'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('query_results', sa.Column('description', sa.String(length=255), nullable=True))
op.add_column('query_results', sa.Column('title', sa.String(length=32), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('query_results', 'title')
op.drop_column('query_results', 'description')
# ### end Alembic commands ###

View File

@ -0,0 +1,35 @@
"""empty message
Revision ID: 4cf5e5606a83
Revises: e256f5cac75d
Create Date: 2020-07-13 08:30:57.369850
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4cf5e5606a83'
down_revision = 'e256f5cac75d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('query_results',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=True),
sa.Column('query_metadata', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('query_results')
# ### end Alembic commands ###

View File

@ -2,8 +2,8 @@ import eventlet
eventlet.monkey_patch() # noqa eventlet.monkey_patch() # noqa
from app import create_app, db, socketio from app import create_app, db, socketio
from app.models import (Corpus, CorpusFile, Job, JobInput, JobResult, from app.models import (Corpus, CorpusFile, Job, JobInput, JobResult,
NotificationData, NotificationEmailData, Result, NotificationData, NotificationEmailData, QueryResult,
ResultFile, Role, User) Result, ResultFile, Role, User)
from flask_migrate import Migrate, upgrade from flask_migrate import Migrate, upgrade
import os import os
@ -21,6 +21,7 @@ def make_shell_context():
'JobResult': JobResult, 'JobResult': JobResult,
'NotificationData': NotificationData, 'NotificationData': NotificationData,
'NotificationEmailData': NotificationEmailData, 'NotificationEmailData': NotificationEmailData,
'QueryResult': QueryResult,
'Result': Result, 'Result': Result,
'ResultFile': ResultFile, 'ResultFile': ResultFile,
'Role': Role, 'Role': Role,