diff --git a/web/app/__init__.py b/web/app/__init__.py index b25e7cd43552bb695f5209ef7523ee6f18e4a77c..7592345e4015a98540dc3fc65326844019e7d97a 100644 --- a/web/app/__init__.py +++ b/web/app/__init__.py @@ -55,4 +55,7 @@ def create_app(config_name): from .services import services as services_blueprint app.register_blueprint(services_blueprint, url_prefix='/services') + from .results import results as results_blueprint + app.register_blueprint(results_blueprint, url_prefix='/results') + return app diff --git a/web/app/admin/tables.py b/web/app/admin/tables.py index 4045227893220127bde912b90b0ccbd522d89f62..521967846118cb2bea4bfdf0d0ccb2cd82803993 100644 --- a/web/app/admin/tables.py +++ b/web/app/admin/tables.py @@ -2,9 +2,9 @@ from flask_table import Table, Col, LinkCol class AdminUserTable(Table): - """ + ''' Declares the table describing colum by column. - """ + ''' classes = ['highlight', 'responsive-table'] username = Col('Username', column_html_attrs={'class': 'username'}, th_html_attrs={'class': 'sort', @@ -28,9 +28,9 @@ class AdminUserTable(Table): class AdminUserItem(object): - """ + ''' Describes one item like one row per table. - """ + ''' def __init__(self, username, email, role_id, confirmed, id): self.username = username diff --git a/web/app/models.py b/web/app/models.py index 43f9e90d73202e5f8e190e7c2651f3e4b196e929..5d0b7ff5764c83829f2639c767decfbbd14b5338 100644 --- a/web/app/models.py +++ b/web/app/models.py @@ -129,6 +129,8 @@ class User(UserMixin, db.Model): cascade='save-update, merge, delete') jobs = db.relationship('Job', backref='creator', lazy='dynamic', cascade='save-update, merge, delete') + results = db.relationship('Result', backref='creator', lazy='dynamic', + cascade='save-update, merge, delete') def to_dict(self): return {'id': self.id, @@ -532,6 +534,34 @@ class Corpus(db.Model): return '<Corpus {corpus_title}>'.format(corpus_title=self.title) +class Result (db.Model): + ''' + Class to define a result set of one query. + ''' + __tablename__ = 'results' + id = db.Column(db.Integer, primary_key=True) + # Foreign keys + user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + # Relationships' + corpus_metadata = db.Column(db.JSON()) + file = db.relationship('ResultFile', backref='result', lazy='dynamic', + cascade='save-update, merge, delete') + + +class ResultFile(db.Model): + ''' + Class to define a ResultFile + ''' + __tablename__ = 'result_files' + # Primary key + id = db.Column(db.Integer, primary_key=True) + # Foreign keys + result_id = db.Column(db.Integer, db.ForeignKey('results.id')) + # Fields + filename = db.Column(db.String(255)) + dir = db.Column(db.String(255)) + + ''' ' Flask-Login is told to use the application’s custom anonymous user by setting ' its class in the login_manager.anonymous_user attribute. diff --git a/web/app/results/__init__.py b/web/app/results/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2f1f59a7c10787fc5c0f8d9b7a6107bfff9fe16d --- /dev/null +++ b/web/app/results/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + + +results = Blueprint('results', __name__) +from . import views # noqa \ No newline at end of file diff --git a/web/app/results/forms.py b/web/app/results/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/web/app/results/tables.py b/web/app/results/tables.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/web/app/results/views.py b/web/app/results/views.py new file mode 100644 index 0000000000000000000000000000000000000000..0a8af940fb7732e0e79121a6a3a14e92abd67f1f --- /dev/null +++ b/web/app/results/views.py @@ -0,0 +1,46 @@ +from . import results +from ..models import Result +from flask import abort, render_template, current_app, request +from flask_login import current_user, login_required +from ..corpora.forms import DisplayOptionsForm +import json +import os + + +@results.route('/<int:result_id>/details') +@login_required +def result_details(result_id): + ''' + View to show metadate and details about on imported result file. + ''' + result = Result.query.get_or_404(result_id) + if not (result.creator == current_user or current_user.is_administrator()): + abort(403) + return render_template('results/result_details.html.j2', + result=result, + title='Result Details') + + +@results.route('/<int:result_id>/inspect') +@login_required +def result_inspect(result_id): + ''' + View to inspect one importe result file in a corpus analysis like interface + ''' + display_options_form = DisplayOptionsForm( + prefix='display-options-form', + result_context=request.args.get('context', 20), + results_per_page=request.args.get('results_per_page', 30)) + result = Result.query.get_or_404(result_id) + result_file_path = os.path.join(current_app.config['NOPAQUE_STORAGE'], + result.file[0].dir, + result.file[0].filename) + with open(result_file_path, 'r') as result_json: + result_json = json.load(result_json) + if not (result.creator == current_user or current_user.is_administrator()): + abort(403) + return render_template('results/result_inspect.html.j2', + display_options_form=display_options_form, + result=result, + result_json=result_json, + title='Result Insepct') diff --git a/web/app/services/forms.py b/web/app/services/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..13533ca6d3df76298ce46411bbe39f5e84de1509 --- /dev/null +++ b/web/app/services/forms.py @@ -0,0 +1,18 @@ +from flask_wtf import FlaskForm +from werkzeug.utils import secure_filename +from wtforms import FileField, SubmitField, ValidationError +from wtforms.validators import DataRequired + + +class ImportResultsForm(FlaskForm): + ''' + Form used to import one result json file. + ''' + file = FileField('File', validators=[DataRequired()]) + 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/services/tables.py b/web/app/services/tables.py new file mode 100644 index 0000000000000000000000000000000000000000..e9d9389c3269fac10cf4c4513fb8b986ecb0fbbf --- /dev/null +++ b/web/app/services/tables.py @@ -0,0 +1,65 @@ +from flask_table import Table, Col, DatetimeCol, LinkCol + + +class ResultTable(Table): + ''' + Declares the Table showing results. Declaration is column by column. + ''' + classes = ['highlight', 'responsive-table'] + query = Col('Query', column_html_attrs={'class': 'query'}, + th_html_attrs={'class': 'sort', + 'data-sort': 'query'}) + match_count = Col('Match count', column_html_attrs={'class': + 'match-count'}, + th_html_attrs={'class': 'sort', + 'data-sort': 'match-count'}) + corpus_name = Col('Corpus name', column_html_attrs={'class': + 'corpus-name'}, + th_html_attrs={'class': 'sort', + 'data-sort': 'corpus-name'}) + corpus_creation_date = DatetimeCol('Corpus creation date', + column_html_attrs={'class': + 'corpus-creation- date'}, # noqa + th_html_attrs={'class': 'sort', + 'data-sort': + 'corpus-creation-date'}, + datetime_format='dd/MM/yyyy, HH:mm:ss a') # noqa + corpus_analysis_date = DatetimeCol('Date of result creation', + column_html_attrs={'class': + 'corpus-analysis-data'}, # noqa + th_html_attrs={'class': 'sort', + 'data-sort': + 'corpus-analysis-data'}, + datetime_format='dd/MM/yyyy, HH:mm:ss a') # noqa + corpus_type = Col('Result Type', + column_html_attrs={'class': + 'corpus-type'}, + th_html_attrs={'class': 'sort', + 'data-sort': + 'corpus-type'}) + details = LinkCol('Details', 'results.result_details', + url_kwargs=dict(result_id='id'), + anchor_attrs={'class': 'waves-effect waves-light btn-floating'}, # noqa + text_fallback='<i class="material-icons">info_outline</i>') # noqa + inspect = LinkCol('Inspect', 'results.result_inspect', + url_kwargs=dict(result_id='id'), + anchor_attrs={'class': 'waves-effect waves-light btn-floating'}, # noqa + text_fallback='<i class="material-icons">search</i>') # noqa + # TODO: Maybe somehow fix taht there are two columns fpr two action buttons + # Or maybe just get rid of flask tables? + + +class ResultItem(object): + ''' + Describes one result item row. + ''' + + def __init__(self, query, match_count, corpus_name, corpus_creation_date, + corpus_analysis_date, corpus_type, id): + self.query = query + self.match_count = match_count + self.corpus_name = corpus_name + self.corpus_creation_date = corpus_creation_date + self.corpus_analysis_date = corpus_analysis_date + self.corpus_type = corpus_type + self.id = id diff --git a/web/app/services/views.py b/web/app/services/views.py index 3c8d0b08097f409ec98974c7ad5868f5dbda0b46..2647f3a8fde837472dcb44b174f79734f7ea2ec4 100644 --- a/web/app/services/views.py +++ b/web/app/services/views.py @@ -1,13 +1,17 @@ from flask import (abort, current_app, flash, make_response, render_template, url_for) from flask_login import current_user, login_required +from .forms import ImportResultsForm from werkzeug.utils import secure_filename from . import services from .. import db from ..jobs.forms import AddFileSetupJobForm, AddNLPJobForm, AddOCRJobForm -from ..models import Job, JobInput +from ..models import Job, JobInput, Result, ResultFile, User +from .tables import ResultTable, ResultItem import json import os +import html +from datetime import datetime SERVICES = {'corpus_analysis': {'name': 'Corpus analysis'}, @@ -81,3 +85,84 @@ def service(service): return render_template('services/{}.html.j2'.format(service), title=SERVICES[service]['name'], add_job_form=add_job_form) + + +@services.route('/import_results', methods=['GET', 'POST']) +@login_required +def import_results(): + ''' + View to import one json result file. Uses the ImportReultFileForm. + ''' + # TODO: Build in a check if uploaded json is actually a result file and + # not something different + # Add the possibility to add several result files at once. + import_results_form = ImportResultsForm(prefix='add-result-file-form') + if import_results_form.is_submitted(): + if not import_results_form.validate(): + return make_response(import_results_form.errors, 400) + # Save the file + # result creation only happens on file save to avoid creating a result + # object in the db everytime by just visiting the import_results page + result = Result(user_id=current_user.id) + db.session.add(result) + db.session.commit() + if not (result.creator == current_user + or current_user.is_administrator()): + abort(403) + dir = os.path.join(str(result.user_id), + 'results', + 'corpus_analysis_results', + str(result.id)) + abs_dir = os.path.join(current_app.config['NOPAQUE_STORAGE'], dir) + abs_file_path = os.path.join(abs_dir, + import_results_form.file.data.filename) + os.makedirs(abs_dir) + import_results_form.file.data.save(abs_file_path) + # Saves all needed metadata entries in one json field + with open(abs_file_path, 'r') as f: + corpus_metadata = json.load(f) + del corpus_metadata['matches'] + del corpus_metadata['cpos_lookup'] + result_file = ResultFile( + result_id=result.id, + dir=dir, + filename=import_results_form.file.data.filename) + result.corpus_metadata = corpus_metadata + db.session.add(result_file) + db.session.commit() + flash('Result file added!', 'result') + return make_response( + {'redirect_url': url_for('services.results')}, + 201) + return render_template('services/import_results.html.j2', + import_results_form=import_results_form, + title='Add corpus file') + + +@services.route('/results') +@login_required +def results(): + ''' + Shows an overview of imported results. + ''' + # get all results of current user + results = User.query.get(current_user.id).results + # create table row for every result# + + def __p_time(time_str): + return datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%S.%f') + + items = [ResultItem(r.corpus_metadata['query'], + r.corpus_metadata['match_count'], + r.corpus_metadata['corpus_name'], + __p_time(r.corpus_metadata['corpus_creation_date']), + __p_time(r.corpus_metadata['corpus_analysis_date']), + r.corpus_metadata['corpus_type'], + r.id) for r in results] + # create table with items and save it as html + table = html.unescape(ResultTable(items).__html__()) + # add class=list to table body with string replacement + table = table.replace('tbody', 'tbody class=list', 1) + return render_template('services/results.html.j2', + title='Imported Results', + table=table) diff --git a/web/app/static/js/nopaque.InteractionElement.js b/web/app/static/js/nopaque.InteractionElement.js index b3f8ec606c3a5cb8837c13306bf2e8eacef573d6..6e85b005a6166b80a381635d3bb10f69208960bf 100644 --- a/web/app/static/js/nopaque.InteractionElement.js +++ b/web/app/static/js/nopaque.InteractionElement.js @@ -29,4 +29,32 @@ class InteractionElement { let boundedCallback = callback["function"].bind(callback.bindThis); return boundedCallback; } -} \ No newline at end of file + + static onChangeExecute(interactionElements) { + // checks if a change for every interactionElement happens and executes + // the callbacks accordingly + // TODO: This function scould be a static function of the Class InteractionElements + // This class does not exist yet. The Class InteractionElements should hold + // a list of InteractionElement objects. onChangeExecute loops over InteractionElements + // and executes the callbacks as mentioned accordingly. An additional + // InteractionElements Class is logically right but also makes things a little more + // complex. It is not yet decided. + for (let interaction of interactionElements) { + if (interaction.checkStatus) { + interaction.element.addEventListener("change", (event) => { + if (event.target.checked) { + let f_on = interaction.bindThisToCallback("on"); + let args_on = interaction.callbacks.on.args; + f_on(...args_on); + } else if (!event.target.checked){ + let f_off = interaction.bindThisToCallback("off"); + let args_off = interaction.callbacks.off.args; + f_off(...args_off); + } + }); + } else { + continue + } + }; + } +} diff --git a/web/app/static/js/nopaque.callbacks.js b/web/app/static/js/nopaque.callbacks.js index 45ee321ef139191123a026026b6bb513ef7ffe8d..1b3cba22cbfe3f6da53f8e057a95e14b67097836 100644 --- a/web/app/static/js/nopaque.callbacks.js +++ b/web/app/static/js/nopaque.callbacks.js @@ -63,7 +63,7 @@ function querySetup(payload) { // This callback is called on socket.on "query_results" // this handels the incoming result chunks -function queryRenderResults(payload) { +function queryRenderResults(payload, imported=false) { let resultItems; // array of built html result items row element // This is called when results are transmitted and being recieved console.log("Current recieved chunk:", payload.chunk); @@ -102,18 +102,23 @@ function queryRenderResults(payload) { console.log("Results recieved:", results.data); // upate progress status progress = payload.progress; // global declaration - if (progress === 100) { + if (progress === 100 && !imported) { queryResultsProgressElement.classList.add("hide"); queryResultsUserFeedbackElement.classList.add("hide"); queryResultsExportElement.classList.remove("disabled"); addToSubResultsElement.removeAttribute("disabled"); - results.jsList.activateInspect(); // inital expert mode check and sub results activation + results.jsList.activateInspect(); if (addToSubResultsElement.checked) { results.jsList.activateAddToSubResults(); } if (expertModeSwitchElement.checked) { results.jsList.expertModeOn("query-display"); } + } else if (imported) { + results.jsList.activateInspect(); + if (expertModeSwitchElement.checked) { + results.jsList.expertModeOn("query-display"); + } } } \ No newline at end of file diff --git a/web/app/templates/corpora/analyse_corpus.html.j2 b/web/app/templates/corpora/analyse_corpus.html.j2 index cbf8ae77c8648a77a16de7fef9af4fad577504cb..d417d60bc329abfb99d06f7659ea562f757e7375 100644 --- a/web/app/templates/corpora/analyse_corpus.html.j2 +++ b/web/app/templates/corpora/analyse_corpus.html.j2 @@ -200,7 +200,6 @@ </tbody> </table> <ul class="pagination paginationBottom"></ul> - </div> </div> </div> @@ -567,23 +566,7 @@ // checks if a change for every interactionElement happens and executes // the callbacks accordingly - for (let interaction of interactionElements) { - if (interaction.checkStatus) { - interaction.element.addEventListener("change", (event) => { - if (event.target.checked) { - let f_on = interaction.bindThisToCallback("on"); - let args_on = interaction.callbacks.on.args; - f_on(...args_on); - } else if (!event.target.checked){ - let f_off = interaction.bindThisToCallback("off"); - let args_off = interaction.callbacks.off.args; - f_off(...args_off); - } - }); - } else { - continue - } - }; + 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 diff --git a/web/app/templates/corpora/corpus.html.j2 b/web/app/templates/corpora/corpus.html.j2 index 3789af0cbf0bdf39ab5ac301977f7023f29e06e2..0a0a8c76ec6ef01d38354def5a94ff979bfac4d4 100644 --- a/web/app/templates/corpora/corpus.html.j2 +++ b/web/app/templates/corpora/corpus.html.j2 @@ -27,13 +27,13 @@ <div class="row"> <div class="col s12 m6"> <div class="input-field"> - <input disabled value="{{ corpus.creation_date.strftime('%m/%d/%Y, %H:%M:%S %p') }}" id="creation-date" type="text" class="validate"> + <input disabled value="{{ corpus.creation_date.strftime('%d/%m/%Y, %H:%M:%S %p') }}" id="creation-date" type="text" class="validate"> <label for="creation-date">Creation date</label> </div> </div> <div class="col s12 m6"> <div class="input-field"> - <input disabled value="{{ corpus.last_edited_date.strftime('%m/%d/%Y, %H:%M:%S %p') }}" id="last_edited_date" type="text" class="validate"> + <input disabled value="{{ corpus.last_edited_date.strftime('%d/%m/%Y, %H:%M:%S %p') }}" id="last_edited_date" type="text" class="validate"> <label for="creation-date">Last edited</label> </div> </div> diff --git a/web/app/templates/jobs/job.html.j2 b/web/app/templates/jobs/job.html.j2 index e26448a05ff14b433d4eb967f6479ff8ca4ecd5c..3978183dee616ae06dfb9baf967ebe9d5bdefedc 100644 --- a/web/app/templates/jobs/job.html.j2 +++ b/web/app/templates/jobs/job.html.j2 @@ -36,7 +36,7 @@ <div class="col s12 m6"> <div class="input-field"> - <input disabled id="creation-date" type="text" value="{{ job.creation_date.strftime('%m/%d/%Y, %H:%M:%S %p') }}"> + <input disabled id="creation-date" type="text" value="{{ job.creation_date.strftime('%d/%m/%Y, %H:%M:%S %p') }}"> <label for="creation-date">Creation date</label> </div> </div> diff --git a/web/app/templates/main/dashboard.html.j2 b/web/app/templates/main/dashboard.html.j2 index 0bd1eafb4d07c35f226af49305a001a5e204188a..45569e6fb621c478485f3afa0700fe67e367ac4c 100644 --- a/web/app/templates/main/dashboard.html.j2 +++ b/web/app/templates/main/dashboard.html.j2 @@ -28,6 +28,7 @@ <ul class="pagination"></ul> </div> <div class="card-action right-align"> + <a class="waves-effect waves-light btn" href="{{ url_for('services.results') }}">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> diff --git a/web/app/templates/results/result_details.html.j2 b/web/app/templates/results/result_details.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..73fe0f0a37b9bd083e3625817b8d5788df1ba631 --- /dev/null +++ b/web/app/templates/results/result_details.html.j2 @@ -0,0 +1,118 @@ +{% extends "nopaque.html.j2" %} + +{% block page_content %} + + +<div class="col s12"> + <p>Below the metadata for the results from the Corpus + <i>{{ result.corpus_metadata.corpus_name }}</i> generated with the query + <i>{{ result.corpus_metadata.query }}</i> are shown. + </p> + <p>{{ texts_metadata }}</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 result.corpus_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" href="{{ url_for('services.import_results') }}">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 = {{ result.corpus_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/results/result_inspect.html.j2 b/web/app/templates/results/result_inspect.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..73910f8251c005df132e128aa74659131878e249 --- /dev/null +++ b/web/app/templates/results/result_inspect.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/services/corpus_analysis.html.j2 b/web/app/templates/services/corpus_analysis.html.j2 index 4512ef6ff321780973a052a96b08cb593c1a8f7a..2d6344eeb4657c15c5151ce2553df02e9de506b1 100644 --- a/web/app/templates/services/corpus_analysis.html.j2 +++ b/web/app/templates/services/corpus_analysis.html.j2 @@ -36,6 +36,7 @@ <ul class="pagination"></ul> </div> <div class="card-action right-align"> + <a class="waves-effect waves-light btn" href="{{ url_for('services.results') }}">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> diff --git a/web/app/templates/services/import_results.html.j2 b/web/app/templates/services/import_results.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..f1e1d41fd9f41b5a5b2b4fc458ce6d3124a0e969 --- /dev/null +++ b/web/app/templates/services/import_results.html.j2 @@ -0,0 +1,39 @@ +{% extends "nopaque.html.j2" %} + +{% block page_content %} +<div class="col s12 m4"> + <p>Fill out the following form to upload and view Results and Sub Results + exported from the Corpus analsis Tool.</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"> + {{ import_results_form.hidden_tag() }} + <div class="row"> + <div class="col s12"> + {{ M.render_field(import_results_form.file, accept='.json', placeholder='Choose your .json file') }} + </div> + </div> + </div> + <div class="card-action right-align"> + {{ M.render_field(import_results_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/services/results.html.j2 b/web/app/templates/services/results.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..2b01d6fdb86529fc176bb2904c80b0c61f77ba8a --- /dev/null +++ b/web/app/templates/services/results.html.j2 @@ -0,0 +1,52 @@ +{% extends "nopaque.html.j2" %} + +{% 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 }} + <ul class="pagination paginationBottom"></ul> + <ul class="pagination"></ul> + </div> + <div class="card-action right-align"> + <a class="waves-effect waves-light btn" href="{{ url_for('services.import_results') }}">Import Results<i class="material-icons right">file_upload</i></a> + </div> + </div> +</div> + +<script> +var options = {page: 10, + pagination: [ + { + name: "paginationTop", + paginationClass: "paginationTop", + innerWindow: 8, + outerWindow: 1 + }, + { + paginationClass: "paginationBottom", + innerWindow: 8, + outerWindow: 1 + } + ], + valueNames: ['query', + 'match-count', + 'corpus-name', + 'corpus-creation-date', + 'corpus-analysis-date', + 'corpus-type'] + }; +var resultsList = new List('results', options); +</script> +{% endblock %} diff --git a/web/migrations/versions/0d7aed934679_.py b/web/migrations/versions/0d7aed934679_.py new file mode 100644 index 0000000000000000000000000000000000000000..3c45d90ca8b4d94caa8f7c159043246882e58d55 --- /dev/null +++ b/web/migrations/versions/0d7aed934679_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 0d7aed934679 +Revises: b15366b25bea +Create Date: 2020-06-30 13:57:48.782173 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0d7aed934679' +down_revision = 'b15366b25bea' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('result_files', sa.Column('corpus_metadata', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('result_files', 'corpus_metadata') + # ### end Alembic commands ### diff --git a/web/migrations/versions/318074622d14_.py b/web/migrations/versions/318074622d14_.py new file mode 100644 index 0000000000000000000000000000000000000000..84dba226e3e6ecf43136b0e576a649b3c44db452 --- /dev/null +++ b/web/migrations/versions/318074622d14_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 318074622d14 +Revises: 0d7aed934679 +Create Date: 2020-06-30 14:00:18.968769 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '318074622d14' +down_revision = '0d7aed934679' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('result_files', 'corpus_metadata') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('result_files', sa.Column('corpus_metadata', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/web/migrations/versions/389bcf564726_.py b/web/migrations/versions/389bcf564726_.py new file mode 100644 index 0000000000000000000000000000000000000000..4244fcc11796c9132415c59c6b76b7c7c136c7f1 --- /dev/null +++ b/web/migrations/versions/389bcf564726_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 389bcf564726 +Revises: 318074622d14 +Create Date: 2020-06-30 14:03:33.384379 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '389bcf564726' +down_revision = '318074622d14' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('result_files', sa.Column('corpus_metadata', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('result_files', 'corpus_metadata') + # ### end Alembic commands ### diff --git a/web/migrations/versions/b15366b25bea_.py b/web/migrations/versions/b15366b25bea_.py new file mode 100644 index 0000000000000000000000000000000000000000..2e90d9b00ffd5b17fcf825549f82472d45bca821 --- /dev/null +++ b/web/migrations/versions/b15366b25bea_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: b15366b25bea +Revises: 4886241e0f5d +Create Date: 2020-06-29 13:41:14.394680 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b15366b25bea' +down_revision = '4886241e0f5d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('results', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('result_files', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('result_id', sa.Integer(), nullable=True), + sa.Column('filename', sa.String(length=255), nullable=True), + sa.Column('dir', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['result_id'], ['results.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('result_files') + op.drop_table('results') + # ### end Alembic commands ### diff --git a/web/migrations/versions/e256f5cac75d_.py b/web/migrations/versions/e256f5cac75d_.py new file mode 100644 index 0000000000000000000000000000000000000000..3e810f2b5ffda9bf71fdf8874828eeb3f640ce99 --- /dev/null +++ b/web/migrations/versions/e256f5cac75d_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: e256f5cac75d +Revises: 389bcf564726 +Create Date: 2020-07-01 07:45:24.637861 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e256f5cac75d' +down_revision = '389bcf564726' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('result_files', 'corpus_metadata') + op.add_column('results', sa.Column('corpus_metadata', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('results', 'corpus_metadata') + op.add_column('result_files', sa.Column('corpus_metadata', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/web/nopaque.py b/web/nopaque.py index ee20b8f744ae003d7b733abfe148c8d7476a4fa2..ed8ae04ba3338501438bd3bbb66cda0629422170 100644 --- a/web/nopaque.py +++ b/web/nopaque.py @@ -2,11 +2,11 @@ import eventlet eventlet.monkey_patch() # noqa from app import create_app, db, socketio from app.models import (Corpus, CorpusFile, Job, JobInput, JobResult, - NotificationData, NotificationEmailData, Role, User) + NotificationData, NotificationEmailData, Result, + ResultFile, Role, User) from flask_migrate import Migrate, upgrade import os - app = create_app(os.getenv('FLASK_CONFIG') or 'default') migrate = Migrate(app, db) @@ -21,6 +21,8 @@ def make_shell_context(): 'JobResult': JobResult, 'NotificationData': NotificationData, 'NotificationEmailData': NotificationEmailData, + 'Result': Result, + 'ResultFile': ResultFile, 'Role': Role, 'User': User}