From 6760061d53c7591a0a6ada7a24aaec296d6cd2b9 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch <pjentsch@sfb1288inf-Laptop.fritz.box> Date: Mon, 13 Jul 2020 15:33:00 +0200 Subject: [PATCH] Add a parallel package for query results. --- web/app/__init__.py | 4 + web/app/models.py | 45 +++- web/app/query_results/__init__.py | 5 + web/app/query_results/forms.py | 21 ++ web/app/query_results/tasks.py | 13 ++ web/app/query_results/views.py | 144 ++++++++++++ web/app/static/js/nopaque.js | 14 ++ web/app/static/js/nopaque.lists.js | 82 ++++--- .../query_results/add_query_result.html.j2 | 44 ++++ .../inspect_query_result.html.j2 | 211 ++++++++++++++++++ .../query_results/query_result.html.j2 | 119 ++++++++++ .../templates/query_results/results.html.j2 | 71 ++++++ .../services/corpus_analysis.html.j2 | 44 +++- web/migrations/versions/33ec4d09b4ca_.py | 30 +++ web/migrations/versions/4cf5e5606a83_.py | 35 +++ web/nopaque.py | 5 +- 16 files changed, 852 insertions(+), 35 deletions(-) create mode 100644 web/app/query_results/__init__.py create mode 100644 web/app/query_results/forms.py create mode 100644 web/app/query_results/tasks.py create mode 100644 web/app/query_results/views.py create mode 100644 web/app/templates/query_results/add_query_result.html.j2 create mode 100644 web/app/templates/query_results/inspect_query_result.html.j2 create mode 100644 web/app/templates/query_results/query_result.html.j2 create mode 100644 web/app/templates/query_results/results.html.j2 create mode 100644 web/migrations/versions/33ec4d09b4ca_.py create mode 100644 web/migrations/versions/4cf5e5606a83_.py diff --git a/web/app/__init__.py b/web/app/__init__.py index 7592345e..f0e9a4ce 100644 --- a/web/app/__init__.py +++ b/web/app/__init__.py @@ -52,6 +52,10 @@ def create_app(config_name): from .profile import profile as profile_blueprint 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 app.register_blueprint(services_blueprint, url_prefix='/services') diff --git a/web/app/models.py b/web/app/models.py index c3fe91d2..a05d0444 100644 --- a/web/app/models.py +++ b/web/app/models.py @@ -135,6 +135,10 @@ class User(UserMixin, db.Model): cascade='save-update, merge, delete') results = db.relationship('Result', backref='creator', lazy='dynamic', cascade='save-update, merge, delete') + query_results = db.relationship('QueryResult', + backref='creator', + cascade='save-update, merge, delete', + lazy='dynamic') def to_dict(self): return {'id': self.id, @@ -151,7 +155,9 @@ class User(UserMixin, db.Model): self.setting_job_status_site_notifications}, 'corpora': {corpus.id: corpus.to_dict() 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): ''' @@ -616,6 +622,43 @@ class Corpus(db.Model): 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 to define a result set of one query. diff --git a/web/app/query_results/__init__.py b/web/app/query_results/__init__.py new file mode 100644 index 00000000..a9e7c358 --- /dev/null +++ b/web/app/query_results/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + + +query_results = Blueprint('query_results', __name__) +from . import views # noqa diff --git a/web/app/query_results/forms.py b/web/app/query_results/forms.py new file mode 100644 index 00000000..bb55e513 --- /dev/null +++ b/web/app/query_results/forms.py @@ -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) diff --git a/web/app/query_results/tasks.py b/web/app/query_results/tasks.py new file mode 100644 index 00000000..64f047ca --- /dev/null +++ b/web/app/query_results/tasks.py @@ -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() diff --git a/web/app/query_results/views.py b/web/app/query_results/views.py new file mode 100644 index 00000000..70c879f0 --- /dev/null +++ b/web/app/query_results/views.py @@ -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) diff --git a/web/app/static/js/nopaque.js b/web/app/static/js/nopaque.js index a8cad6ae..f418eefa 100644 --- a/web/app/static/js/nopaque.js +++ b/web/app/static/js/nopaque.js @@ -14,6 +14,7 @@ nopaque.user.settings = {}; nopaque.user.settings.darkMode = undefined; nopaque.corporaSubscribers = []; nopaque.jobsSubscribers = []; +nopaque.queryResultsSubscribers = []; // Foreign user (user inspected with admin credentials) data nopaque.foreignUser = {}; @@ -22,6 +23,7 @@ nopaque.foreignUser.settings = {}; nopaque.foreignUser.settings.darkMode = undefined; nopaque.foreignCorporaSubscribers = []; nopaque.foreignJobsSubscribers = []; +nopaque.foreignQueryResultsSubscribers = []; nopaque.flashedMessages = undefined; @@ -38,6 +40,9 @@ nopaque.socket.init = function() { for (let subscriber of nopaque.jobsSubscribers) { subscriber._init(nopaque.user.jobs); } + for (let subscriber of nopaque.queryResultsSubscribers) { + subscriber._init(nopaque.user.query_results); + } RessourceList.modifyTooltips(false) }); @@ -48,12 +53,16 @@ nopaque.socket.init = function() { nopaque.user = jsonpatch.apply_patch(nopaque.user, patch); corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); 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) { subscriber._update(corpora_patch); } for (let subscriber of nopaque.jobsSubscribers) { 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)) { for (operation of jobs_patch) { /* "/jobs/{jobId}/..." -> ["{jobId}", ...] */ @@ -74,6 +83,9 @@ nopaque.socket.init = function() { for (let subscriber of nopaque.foreignJobsSubscribers) { subscriber._init(nopaque.foreignUser.jobs); } + for (let subscriber of nopaque.foreignQueryResultsSubscribers) { + subscriber._init(nopaque.foreignUser.query_results); + } RessourceList.modifyTooltips(false) }); @@ -84,8 +96,10 @@ nopaque.socket.init = function() { nopaque.foreignUser = jsonpatch.apply_patch(nopaque.foreignUser, patch); corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); 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.foreignJobsSubscribers) {subscriber._update(jobs_patch);} + for (let subscriber of nopaque.foreignQueryResultsSubscribers) {subscriber._update(query_results_patch);} }); } diff --git a/web/app/static/js/nopaque.lists.js b/web/app/static/js/nopaque.lists.js index 88a4e09e..1e0af501 100644 --- a/web/app/static/js/nopaque.lists.js +++ b/web/app/static/js/nopaque.lists.js @@ -1,16 +1,15 @@ class RessourceList extends List { constructor(idOrElement, subscriberList, type, options={}) { - if (!["corpus", "job", "result", "user", "job_input", - "corpus_file"].includes(type)) { + if (!["corpus", "corpus_file", "job", "job_input", "query_result", "result", "user"].includes(type)) { console.error("Unknown Type!"); return; } if (subscriberList) { - super(idOrElement, {...RessourceList.options['common'], - ...RessourceList.options[type], - ...options}); - this.type = type; - subscriberList.push(this); + super(idOrElement, {...RessourceList.options['common'], + ...RessourceList.options[type], + ...options}); + this.type = type; + subscriberList.push(this); } else { super(idOrElement, {...RessourceList.options['extended'], ...RessourceList.options[type], @@ -81,8 +80,7 @@ class RessourceList extends List { RessourceList.dataMapper = { - // ### Mapping Genera Info - //The Mapping describes entitys rendered per row. One key value pair holds + // A data mapper describes entitys rendered per row. One key value pair holds // the data to be rendered in the list.js table. Key has to correspond // with the ValueNames defined below in RessourceList.options ValueNames. // 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` : "", "edit-link": `/corpora/${corpus.id}`, status: corpus.status, - title: corpus.title - }), + title: corpus.title}), // Mapping for corpus file entities shown in the corpus overview corpus_file: corpus_file => ({filename: corpus_file.filename, author: corpus_file.author, @@ -105,8 +102,7 @@ RessourceList.dataMapper = { publishing_year: corpus_file.publishing_year, "edit-link": `${corpus_file.corpus_id}/files/${corpus_file.id}/edit`, "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. job: job => ({creation_date: job.creation_date, description: job.description, @@ -114,34 +110,34 @@ RessourceList.dataMapper = { link: `/jobs/${job.id}`, service: job.service, status: job.status, - title: job.title - }), + title: job.title}), // Mapping for job input files shown in table on every job page job_input: job_input => ({filename: job_input.filename, 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. // Shown in imported results table - result: result => ({ query: result.query, - match_count: result.match_count, - corpus_name: result.corpus_name, - corpus_creation_date: result.corpus_creation_date, - corpus_analysis_date: result.corpus_analysis_date, - 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` - }), + query_result: query_result => ({description: query_result.description, + id: query_result.id, + link: `/query_results/${query_result.id}`, + title: query_result.title}), + result: result => ({query: result.query, + match_count: result.match_count, + corpus_name: result.corpus_name, + corpus_creation_date: result.corpus_creation_date, + corpus_analysis_date: result.corpus_analysis_date, + 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 user: user => ({username: user.username, email: user.email, role_id: user.role_id, confirmed: user.confirmed, id: user.id, - "profile-link": `user/${user.id}` - }) + "profile-link": `user/${user.id}`}) }; @@ -289,6 +285,32 @@ RessourceList.options = { "id", {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 // strucuture per entity per row // Link classes have to correspond with Links defined in the Mapping process diff --git a/web/app/templates/query_results/add_query_result.html.j2 b/web/app/templates/query_results/add_query_result.html.j2 new file mode 100644 index 00000000..e2f163fe --- /dev/null +++ b/web/app/templates/query_results/add_query_result.html.j2 @@ -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 %} diff --git a/web/app/templates/query_results/inspect_query_result.html.j2 b/web/app/templates/query_results/inspect_query_result.html.j2 new file mode 100644 index 00000000..73910f82 --- /dev/null +++ b/web/app/templates/query_results/inspect_query_result.html.j2 @@ -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 %} \ No newline at end of file diff --git a/web/app/templates/query_results/query_result.html.j2 b/web/app/templates/query_results/query_result.html.j2 new file mode 100644 index 00000000..1fe7cbb3 --- /dev/null +++ b/web/app/templates/query_results/query_result.html.j2 @@ -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 %} diff --git a/web/app/templates/query_results/results.html.j2 b/web/app/templates/query_results/results.html.j2 new file mode 100644 index 00000000..0577a1c8 --- /dev/null +++ b/web/app/templates/query_results/results.html.j2 @@ -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 %} diff --git a/web/app/templates/services/corpus_analysis.html.j2 b/web/app/templates/services/corpus_analysis.html.j2 index 0d0a2193..b5123a05 100644 --- a/web/app/templates/services/corpus_analysis.html.j2 +++ b/web/app/templates/services/corpus_analysis.html.j2 @@ -12,6 +12,7 @@ </div> <div class="col s12"> + <h3>My Corpora</h3> <div class="card"> <div class="card-content" id="corpora"> <div class="input-field"> @@ -36,15 +37,54 @@ <ul class="pagination"></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> - <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> </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> var corpusList = new RessourceList("corpora", nopaque.corporaSubscribers, "corpus", {page: 10}); + var queryResultList = new RessourceList("query-results", + nopaque.queryResultsSubscribers, + "query_result", {page: 10}); </script> {% endblock %} diff --git a/web/migrations/versions/33ec4d09b4ca_.py b/web/migrations/versions/33ec4d09b4ca_.py new file mode 100644 index 00000000..c5cd903e --- /dev/null +++ b/web/migrations/versions/33ec4d09b4ca_.py @@ -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 ### diff --git a/web/migrations/versions/4cf5e5606a83_.py b/web/migrations/versions/4cf5e5606a83_.py new file mode 100644 index 00000000..350b1172 --- /dev/null +++ b/web/migrations/versions/4cf5e5606a83_.py @@ -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 ### diff --git a/web/nopaque.py b/web/nopaque.py index ed8ae04b..c26dfa4c 100644 --- a/web/nopaque.py +++ b/web/nopaque.py @@ -2,8 +2,8 @@ import eventlet eventlet.monkey_patch() # noqa from app import create_app, db, socketio from app.models import (Corpus, CorpusFile, Job, JobInput, JobResult, - NotificationData, NotificationEmailData, Result, - ResultFile, Role, User) + NotificationData, NotificationEmailData, QueryResult, + Result, ResultFile, Role, User) from flask_migrate import Migrate, upgrade import os @@ -21,6 +21,7 @@ def make_shell_context(): 'JobResult': JobResult, 'NotificationData': NotificationData, 'NotificationEmailData': NotificationEmailData, + 'QueryResult': QueryResult, 'Result': Result, 'ResultFile': ResultFile, 'Role': Role, -- GitLab