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