From cd72614c0fd0226a2285d5a50918068d50667026 Mon Sep 17 00:00:00 2001
From: Patrick Jentsch <p.jentsch@uni-bielefeld.de>
Date: Tue, 15 Nov 2022 15:11:16 +0100
Subject: [PATCH] Contributions update revised

---
 app/contributions/forms.py                    |  61 ++++---
 app/contributions/routes.py                   | 162 +++++++++---------
 app/models.py                                 |   2 +
 app/services/services.yml                     |   4 +-
 app/static/js/RessourceLists/RessourceList.js |   2 +
 .../js/RessourceLists/SpacyNLPModelList.js    |  76 --------
 .../SpacyNLPPipelineModelList.js              |  99 +++++++++++
 .../RessourceLists/TesseractOCRModelList.js   |  76 --------
 .../TesseractOCRPipelineModelList.js          |  99 +++++++++++
 app/static/js/Utils.js                        | 100 ++++++++++-
 app/templates/_scripts.html.j2                |   4 +-
 .../contributions/_breadcrumbs.html.j2        |   8 +-
 .../contribution_overview.html.j2             | 129 --------------
 .../contributions/contributions.html.j2       |  51 ++++++
 ...> create_spacy_nlp_pipeline_model.html.j2} |  41 +----
 ...eate_tesseract_ocr_pipeline_model.html.j2} |   7 +-
 .../spacy_nlp_pipeline_model.html.j2          |   2 +-
 .../tesseract_ocr_pipeline_model.html.j2      |   2 +-
 18 files changed, 481 insertions(+), 444 deletions(-)
 delete mode 100644 app/static/js/RessourceLists/SpacyNLPModelList.js
 create mode 100644 app/static/js/RessourceLists/SpacyNLPPipelineModelList.js
 delete mode 100644 app/static/js/RessourceLists/TesseractOCRModelList.js
 create mode 100644 app/static/js/RessourceLists/TesseractOCRPipelineModelList.js
 delete mode 100644 app/templates/contributions/contribution_overview.html.j2
 create mode 100644 app/templates/contributions/contributions.html.j2
 rename app/templates/contributions/{contribute_spacy_nlp_models.html.j2 => create_spacy_nlp_pipeline_model.html.j2} (70%)
 rename app/templates/contributions/{contribute_tesseract_ocr_models.html.j2 => create_tesseract_ocr_pipeline_model.html.j2} (91%)

diff --git a/app/contributions/forms.py b/app/contributions/forms.py
index 8eb44842..04030fd0 100644
--- a/app/contributions/forms.py
+++ b/app/contributions/forms.py
@@ -12,6 +12,7 @@ from wtforms import (
 from wtforms.validators import InputRequired, Length
 from app.services import SERVICES
 
+
 class CreateContributionBaseForm(FlaskForm):
     title = StringField(
         'Title',
@@ -46,31 +47,8 @@ class CreateContributionBaseForm(FlaskForm):
     )
     submit = SubmitField()
 
-class EditForm(CreateContributionBaseForm):
-    def prefill(self, model_file):
-        ''' Pre-fill the form with data of an exististing corpus file '''
-        self.title.data = model_file.title
-        self.description.data = model_file.description
-        self.publisher.data = model_file.publisher
-        self.publishing_year.data = model_file.publishing_year
-        self.publisher_url.data = model_file.publisher_url
-        self.publishing_url.data = model_file.publishing_url
-        self.version.data = model_file.version
-        self.shared.data = model_file.shared
-
-class EditTesseractOCRModelForm(EditForm):
-    pass
-
-class EditSpaCyNLPPipelineModelForm(EditForm):
-    pipeline_name = StringField(
-        'Pipeline name',
-        validators=[InputRequired(), Length(max=64)]
-    )
-    def prefill(self, model_file):
-        super().prefill(model_file)
-        self.pipeline_name.data = model_file.pipeline_name
 
-class TesseractOCRModelContributionForm(CreateContributionBaseForm):
+class CreateTesseractOCRPipelineModelForm(CreateContributionBaseForm):
     tesseract_model_file = FileField(
         'File',
         validators=[FileRequired()]
@@ -78,6 +56,7 @@ class TesseractOCRModelContributionForm(CreateContributionBaseForm):
     compatible_service_versions = SelectMultipleField(
         'Compatible service versions'
     )
+
     def validate_tesseract_model_file(self, field):
         current_app.logger.warning(field.data.filename)
         if not field.data.filename.lower().endswith('.traineddata'):
@@ -92,7 +71,8 @@ class TesseractOCRModelContributionForm(CreateContributionBaseForm):
         ]
         self.compatible_service_versions.default = ''
 
-class SpacyNLPModelContributionForm(CreateContributionBaseForm):
+
+class CreateSpaCyNLPPipelineModelForm(CreateContributionBaseForm):
     spacy_model_file = FileField(
         'File',
         validators=[FileRequired()]
@@ -104,16 +84,45 @@ class SpacyNLPModelContributionForm(CreateContributionBaseForm):
         'Pipeline name',
         validators=[InputRequired(), Length(max=64)]
     )
+
     def validate_spacy_model_file(self, field):
         current_app.logger.warning(field.data.filename)
         if not field.data.filename.lower().endswith('.tar.gz'):
             raise ValidationError('.tar.gz files only!')
 
     def __init__(self, *args, **kwargs):
-        service_manifest = SERVICES['spacy-nlp-pipeline']
         super().__init__(*args, **kwargs)
+        service_manifest = SERVICES['spacy-nlp-pipeline']
         self.compatible_service_versions.choices = [('', 'Choose your option')]
         self.compatible_service_versions.choices += [
             (x, x) for x in service_manifest['versions'].keys()
         ]
         self.compatible_service_versions.default = ''
+
+
+class EditContributionBaseForm(CreateContributionBaseForm):
+    def prefill(self, model_file):
+        ''' Pre-fill the form with data of an exististing corpus file '''
+        self.title.data = model_file.title
+        self.description.data = model_file.description
+        self.publisher.data = model_file.publisher
+        self.publishing_year.data = model_file.publishing_year
+        self.publisher_url.data = model_file.publisher_url
+        self.publishing_url.data = model_file.publishing_url
+        self.version.data = model_file.version
+        self.shared.data = model_file.shared
+
+
+class EditTesseractOCRPipelineModelForm(EditContributionBaseForm):
+    pass
+
+
+class EditSpaCyNLPPipelineModelForm(EditContributionBaseForm):
+    pipeline_name = StringField(
+        'Pipeline name',
+        validators=[InputRequired(), Length(max=64)]
+    )
+
+    def prefill(self, model_file):
+        super().prefill(model_file)
+        self.pipeline_name.data = model_file.pipeline_name
diff --git a/app/contributions/routes.py b/app/contributions/routes.py
index 33355b2d..121fc101 100644
--- a/app/contributions/routes.py
+++ b/app/contributions/routes.py
@@ -2,10 +2,19 @@ from flask import abort, current_app, flash, Markup, render_template, url_for
 from flask_login import login_required, current_user
 from threading import Thread
 from app import db
-from app.decorators import admin_required, permission_required 
-from app.models import Permission, SpaCyNLPPipelineModel, TesseractOCRPipelineModel
+from app.decorators import permission_required 
+from app.models import (
+    Permission,
+    SpaCyNLPPipelineModel,
+    TesseractOCRPipelineModel
+)
 from . import bp
-from .forms import TesseractOCRModelContributionForm, EditSpaCyNLPPipelineModelForm, EditTesseractOCRModelForm, SpacyNLPModelContributionForm
+from .forms import (
+    CreateSpaCyNLPPipelineModelForm,
+    CreateTesseractOCRPipelineModelForm,
+    EditSpaCyNLPPipelineModelForm,
+    EditTesseractOCRPipelineModelForm
+)
 
 
 @bp.before_request
@@ -16,30 +25,17 @@ def before_request():
 
 
 @bp.route('/')
-@login_required
-@admin_required
 def contributions():
-    tesseract_ocr_user_models = [
-        x for x in current_user.tesseract_ocr_pipeline_models
-    ]
-    spacy_nlp_user_models = [
-        x for x in current_user.spacy_nlp_pipeline_models
-    ]
     return render_template(
-        'contributions/contribution_overview.html.j2',
-        tesseract_ocr_user_models=tesseract_ocr_user_models,
-        spacy_nlp_user_models=spacy_nlp_user_models,
-        userId = current_user.hashid,
-        title='Contribution Overview'
+        'contributions/contributions.html.j2',
+        title='Contributions'
     )
 
-@bp.route('/edit-tesseract-model/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
-@login_required
+
+@bp.route('/tesseract_ocr_pipeline_models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
 def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
-    tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(
-        tesseract_ocr_pipeline_model_id
-    )
-    form = EditTesseractOCRModelForm(prefix='tesseract-ocr-model-edit-form')
+    tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
+    form = EditTesseractOCRPipelineModelForm(prefix='edit-tesseract-ocr-pipeline-model-form')
     if form.validate_on_submit():
         if tesseract_ocr_pipeline_model.title != form.title.data:
             tesseract_ocr_pipeline_model.title = form.title.data
@@ -58,47 +54,50 @@ def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
         if tesseract_ocr_pipeline_model.shared != form.shared.data:
             tesseract_ocr_pipeline_model.shared = form.shared.data
         db.session.commit()
-        message = Markup(f'Model "<a href="contribute/{tesseract_ocr_pipeline_model.hashid}">{tesseract_ocr_pipeline_model.title}</a>" updated')
-        flash(message, category='corpus')
-        return {}, 201, {'Location': url_for('contributions.contributions')}
+        tesseract_ocr_pipeline_model_url = url_for(
+            '.tesseract_ocr_pipeline_model',
+            tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id
+        )
+        message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model_url}">{tesseract_ocr_pipeline_model.title}</a>" updated')
+        flash(message)
+        return {}, 201, {'Location': tesseract_ocr_pipeline_model_url}
     form.prefill(tesseract_ocr_pipeline_model)
     return render_template(
         'contributions/tesseract_ocr_pipeline_model.html.j2',
-        tesseract_ocr_pipeline_model=tesseract_ocr_pipeline_model,
         form=form,
-        title='Edit your Tesseract OCR model'
+        tesseract_ocr_pipeline_model=tesseract_ocr_pipeline_model,
+        title='Edit Tesseract OCR Pipeline Model'
     )
 
-@bp.route('/edit-tesseract-model/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
-@login_required
+
+@bp.route('/tesseract_ocr_pipeline_models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
 def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
-    def _delete_tesseract_model(app, tesseract_ocr_pipeline_model_id):
+    def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
         with app.app_context():
-            model = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
-            model.delete()
+            tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
+            tesseract_ocr_pipeline_model.delete()
             db.session.commit()
-    
-    model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
-    if not (model.user == current_user or current_user.is_administrator()):
+
+    tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
+    if not (tesseract_ocr_pipeline_model.user == current_user or current_user.is_administrator()):
         abort(403)
     thread = Thread(
-        target=_delete_tesseract_model,
+        target=_delete_tesseract_ocr_pipeline_model,
         args=(current_app._get_current_object(), tesseract_ocr_pipeline_model_id)
     )
     thread.start()
     return {}, 202
 
-@bp.route('/add-tesseract-ocr-pipeline-model', methods=['GET', 'POST'])
-def add_tesseract_ocr_pipeline_model():
-    form = TesseractOCRModelContributionForm(
-        prefix='contribute-tesseract-ocr-pipeline-model-form'
-    )
+
+@bp.route('/tesseract_ocr_pipeline_models/create', methods=['GET', 'POST'])
+def create_tesseract_ocr_pipeline_model():
+    form = CreateTesseractOCRPipelineModelForm(prefix='create-tesseract-ocr-pipeline-model-form')
     if form.is_submitted():
         if not form.validate():
             response = {'errors': form.errors}
             return response, 400
         try:
-            tesseract_ocr_model = TesseractOCRPipelineModel.create(
+            tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.create(
                 form.tesseract_model_file.data,
                 compatible_service_versions=form.compatible_service_versions.data,
                 description=form.description.data,
@@ -114,27 +113,24 @@ def add_tesseract_ocr_pipeline_model():
         except OSError:
             abort(500)
         db.session.commit()
-        message = Markup(f'Model "{tesseract_ocr_model.title}" created')
+        tesseract_ocr_pipeline_model_url = url_for(
+            '.tesseract_ocr_pipeline_model',
+            tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id
+        )
+        message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model_url}">{tesseract_ocr_pipeline_model.title}</a>" created')
         flash(message)
-        return {}, 201, {'Location': url_for('contributions.contributions')}
-    tesseract_ocr_pipeline_models = [
-        x for x in TesseractOCRPipelineModel.query.all()
-    ]
-    
+        return {}, 201, {'Location': tesseract_ocr_pipeline_model_url}
     return render_template(
-        'contributions/contribute_tesseract_ocr_models.html.j2',
+        'contributions/create_tesseract_ocr_pipeline_model.html.j2',
         form=form,
-        tesseract_ocr_pipeline_models=tesseract_ocr_pipeline_models,
-        title='Tesseract OCR Model Contribution'
+        title='Create Tesseract OCR Pipeline Model'
     )
 
-@bp.route('/edit-spacy-model//<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
-@login_required
+
+@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
 def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
-    spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(
-        spacy_nlp_pipeline_model_id
-    )
-    form = EditSpaCyNLPPipelineModelForm(prefix='spacy-nlp-model-edit-form')
+    spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
+    form = EditSpaCyNLPPipelineModelForm(prefix='edit-spacy-nlp-pipeline-model-form')
     if form.validate_on_submit():
         if spacy_nlp_pipeline_model.title != form.title.data:
             spacy_nlp_pipeline_model.title = form.title.data
@@ -154,30 +150,33 @@ def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
             spacy_nlp_pipeline_model.version = form.version.data
         if spacy_nlp_pipeline_model.shared != form.shared.data:
             spacy_nlp_pipeline_model.shared = form.shared.data
+        current_app.logger.warning(db.session.dirty)
         db.session.commit()
-        message = Markup(f'Model "<a href="contribute/{spacy_nlp_pipeline_model.hashid}">{spacy_nlp_pipeline_model.title}</a>" updated')
-        flash(message, category='corpus')
-        return {}, 201, {'Location': url_for('contributions.contributions')}
-    print(spacy_nlp_pipeline_model.to_json())
+        spacy_nlp_pipeline_model_url = url_for(
+            '.spacy_nlp_pipeline_model',
+            spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id
+        )
+        message = Markup(f'SpaCy NLP Pipeline model "<a href="{spacy_nlp_pipeline_model_url}">{spacy_nlp_pipeline_model.title}</a>" updated')
+        flash(message)
+        return {}, 201, {'Location': url_for('.contributions')}
     form.prefill(spacy_nlp_pipeline_model)
     return render_template(
         'contributions/spacy_nlp_pipeline_model.html.j2',
-        spacy_nlp_pipeline_model=spacy_nlp_pipeline_model,
         form=form,
-        title='Edit your spaCy NLP model'
+        spacy_nlp_pipeline_model=spacy_nlp_pipeline_model,
+        title=f'{spacy_nlp_pipeline_model.title} [{spacy_nlp_pipeline_model.version}]'
     )
 
-@bp.route('/edit-spacy-model/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
-@login_required
+@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
 def delete_spacy_model(spacy_nlp_pipeline_model_id):
     def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
         with app.app_context():
-            model = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
-            model.delete()
+            spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
+            spacy_nlp_pipeline_model.delete()
             db.session.commit()
     
-    model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
-    if not (model.user == current_user or current_user.is_administrator()):
+    spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
+    if not (spacy_nlp_pipeline_model.user == current_user or current_user.is_administrator()):
         abort(403)
     thread = Thread(
         target=_delete_spacy_model,
@@ -186,15 +185,16 @@ def delete_spacy_model(spacy_nlp_pipeline_model_id):
     thread.start()
     return {}, 202
 
-@bp.route('/add-spacy-nlp-pipeline-model', methods=['GET', 'POST'])
-def add_spacy_nlp_pipeline_model():
-    form = SpacyNLPModelContributionForm(prefix='contribute-spacy-nlp-pipeline-model-form')
+
+@bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
+def create_spacy_nlp_pipeline_model():
+    form = CreateSpaCyNLPPipelineModelForm(prefix='create-spacy-nlp-pipeline-model-form')
     if form.is_submitted():
         if not form.validate():
             response = {'errors': form.errors}
             return response, 400
         try:
-            spacy_nlp_model = SpaCyNLPPipelineModel.create(
+            spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.create(
                 form.spacy_model_file.data,
                 compatible_service_versions=form.compatible_service_versions.data,
                 description=form.description.data,
@@ -211,15 +211,15 @@ def add_spacy_nlp_pipeline_model():
         except OSError:
             abort(500)
         db.session.commit()
-        message = Markup(f'Model "{spacy_nlp_model.title}" created')
+        spacy_nlp_pipeline_model_url = url_for(
+            '.spacy_nlp_pipeline_model',
+            spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id
+        )
+        message = Markup(f'SpaCy NLP Pipeline model "<a href="{spacy_nlp_pipeline_model_url}">{spacy_nlp_pipeline_model.title}</a>" created')
         flash(message)
-        return {}, 201, {'Location': url_for('contributions.contributions')}
-    spacy_nlp_pipeline_models = [
-        x for x in SpaCyNLPPipelineModel.query.all()
-    ]
+        return {}, 201, {'Location': spacy_nlp_pipeline_model_url}
     return render_template(
-        'contributions/contribute_spacy_nlp_models.html.j2',
+        'contributions/create_spacy_nlp_pipeline_model.html.j2',
         form=form,
-        spacy_nlp_pipeline_models=spacy_nlp_pipeline_models,
-        title='spaCy NLP Model Contribution'
+        title='Create SpaCy NLP Pipeline Model'
     )
diff --git a/app/models.py b/app/models.py
index 93a23461..3bf7f8a7 100644
--- a/app/models.py
+++ b/app/models.py
@@ -625,6 +625,7 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model):
             'publishing_year': self.publishing_year,
             'shared': self.shared,
             'title': self.title,
+            'version': self.version,
             **self.file_mixin_to_json()
         }
         if backrefs:
@@ -735,6 +736,7 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model):
             'pipeline_name': self.pipeline_name,
             'shared': self.shared,
             'title': self.title,
+            'version': self.version,
             **self.file_mixin_to_json()
         }
         if backrefs:
diff --git a/app/services/services.yml b/app/services/services.yml
index c9d61e08..9d99d688 100644
--- a/app/services/services.yml
+++ b/app/services/services.yml
@@ -1,6 +1,6 @@
 # TODO: This could also be done via GitLab/GitHub APIs
 file-setup-pipeline:
-  name: 'File setup pipeline'
+  name: 'File Setup Pipeline'
   publisher: 'Bielefeld University - CRC 1288 - INF'
   latest_version: '0.1.0'
   versions:
@@ -38,7 +38,7 @@ transkribus-htr-pipeline:
       publishing_year: 2022
       url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/transkribus-htr-pipeline/-/releases/v0.1.1'
 spacy-nlp-pipeline:
-  name: 'spaCy NLP Pipeline'
+  name: 'SpaCy NLP Pipeline'
   publisher: 'Bielefeld University - CRC 1288 - INF'
   latest_version: '0.1.0'
   versions:
diff --git a/app/static/js/RessourceLists/RessourceList.js b/app/static/js/RessourceLists/RessourceList.js
index 824db3d1..a2242054 100644
--- a/app/static/js/RessourceLists/RessourceList.js
+++ b/app/static/js/RessourceLists/RessourceList.js
@@ -10,6 +10,8 @@ class RessourceList {
     JobList.autoInit();
     JobInputList.autoInit();
     JobResultList.autoInit();
+    SpacyNLPPipelineModelList.autoInit();
+    TesseractOCRPipelineModelList.autoInit();
     QueryResultList.autoInit();
     UserList.autoInit();
   }
diff --git a/app/static/js/RessourceLists/SpacyNLPModelList.js b/app/static/js/RessourceLists/SpacyNLPModelList.js
deleted file mode 100644
index 0e20191b..00000000
--- a/app/static/js/RessourceLists/SpacyNLPModelList.js
+++ /dev/null
@@ -1,76 +0,0 @@
-class SpacyNLPModelList {
-  constructor () {
-    
-    this.elements =  {
-      spacyNLPModelList: document.querySelector('#spacy-nlp-model-list'),
-      deleteButtons: document.querySelectorAll('.delete-spacy-model-button'),
-      editButtons: document.querySelectorAll('.edit-spacy-model-button'),
-      
-    }
-  }
-
-  init () {
-    let userId = this.elements.spacyNLPModelList.dataset.userId;
-    
-    for (let deleteButton of this.elements.deleteButtons) {
-      deleteButton.addEventListener('click', () => {this.deleteModel(deleteButton, userId);});
-    }
-
-    for (let editButton of this.elements.editButtons) {
-      editButton.addEventListener('click', () => {this.editModel(editButton);});
-    }
-  }
-
-  deleteModel(deleteButton, userId) {
-    return new Promise((resolve, reject) => {
-      let modelId = deleteButton.dataset.modelId;
-      let model = app.data.users[userId].spacy_nlp_pipeline_models[modelId];
-      let modalElement = Utils.elementFromString(
-        `
-          <div class="modal">
-            <div class="modal-content">
-              <h4>Confirm job deletion</h4>
-              <p>Do you really want to delete <b>${model.title}</b>? All files will be permanently deleted!</p>
-            </div>
-            <div class="modal-footer">
-              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
-              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
-            </div>
-          </div>
-        `
-      );
-      document.querySelector('#modals').appendChild(modalElement);
-      let modal = M.Modal.init(
-        modalElement,
-        {
-          dismissible: false,
-          onCloseEnd: () => {
-            modal.destroy();
-            modalElement.remove();
-          }
-        }
-      );
-      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
-      confirmElement.addEventListener('click', (event) => {
-        let modelTitle = model.title;
-        fetch(`/contributions/edit-spacy-model/${modelId}`, {method: 'DELETE'})
-          .then(
-            (response) => {
-              app.flash(`Model "${modelTitle}" marked for deletion`, 'corpus');
-              resolve(response);
-            },
-            (response) => {
-              if (response.status === 403) {app.flash('Forbidden', 'error');}
-              if (response.status === 404) {app.flash('Not Found', 'error');}
-              reject(response);
-            }
-          );
-      });
-      modal.open();
-    });
-  }
-
-  editModel(editButton) {
-    window.location.href = `/contributions/edit-spacy-model/${editButton.dataset.modelId}`;
-  }
-}
diff --git a/app/static/js/RessourceLists/SpacyNLPPipelineModelList.js b/app/static/js/RessourceLists/SpacyNLPPipelineModelList.js
new file mode 100644
index 00000000..fcc68a0d
--- /dev/null
+++ b/app/static/js/RessourceLists/SpacyNLPPipelineModelList.js
@@ -0,0 +1,99 @@
+class SpacyNLPPipelineModelList extends RessourceList {
+  static autoInit() {
+    for (let spaCyNLPPipelineModelListElement of document.querySelectorAll('.spacy-nlp-pipeline-model-list:not(.no-autoinit)')) {
+      new SpacyNLPPipelineModelList(spaCyNLPPipelineModelListElement);
+    }
+  }
+
+  static options = {
+    initialHtmlGenerator: (id) => {
+      return `
+        <div class="input-field">
+          <i class="material-icons prefix">search</i>
+          <input id="${id}-search" class="search" type="search"></input>
+          <label for="${id}-search">Search SpaCy NLP Pipeline Model</label>
+        </div>
+        <table>
+          <thead>
+            <tr>
+              <th>Title</th>
+              <th>Description</th>
+              <th>Biblio</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody class="list"></tbody>
+        </table>
+        <ul class="pagination"></ul>
+      `.trim();
+    },
+    item: `
+      <tr class="clickable hoverable">
+        <td><span class="title"></span></td>
+        <td><span class="description"></span></td>
+        <td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>), <span class="title-2"></span> <span class="version"></span>, <a class="publishing-url"><span class="publishing-url-2"></span></a></td>
+        <td class="right-align">
+          <a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
+          <a class="action-button btn-floating service-color darken waves-effect waves-light service-2" data-action="view"><i class="material-icons">send</i></a>
+        </td>
+      </tr>
+    `.trim(),
+    ressourceMapper: (spaCyNLPPipelineModel) => {
+      return {
+        'id': spaCyNLPPipelineModel.id,
+        'creation-date': spaCyNLPPipelineModel.creation_date,
+        'description': spaCyNLPPipelineModel.description,
+        'publisher': spaCyNLPPipelineModel.publisher,
+        'publisher-url': spaCyNLPPipelineModel.publisher_url,
+        'publishing-url': spaCyNLPPipelineModel.publishing_url,
+        'publishing-url-2': spaCyNLPPipelineModel.publishing_url,
+        'publishing-year': spaCyNLPPipelineModel.publishing_year,
+        'title': spaCyNLPPipelineModel.title,
+        'title-2': spaCyNLPPipelineModel.title,
+        'version': spaCyNLPPipelineModel.version
+      };
+    },
+    sortArgs: ['creation-date', {order: 'desc'}],
+    valueNames: [
+      {data: ['id']},
+      {data: ['creation-date']},
+      {name: 'publisher-url', attr: 'href'},
+      {name: 'publishing-url', attr: 'href'},
+      'description',
+      'publisher',
+      'publishing-url-2',
+      'publishing-year',
+      'title',
+      'title-2',
+      'version'
+    ]
+  };
+
+  constructor(listElement, options = {}) {
+    super(listElement, {...SpacyNLPPipelineModelList.options, ...options});
+  }
+
+  init (user) {
+    this._init(user.spacy_nlp_pipeline_models);
+  }
+
+  onClick(event) {
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+    let spaCyNLPPipelineModelElement = event.target.closest('tr');
+    let spaCyNLPPipelineModelId = spaCyNLPPipelineModelElement.dataset.id;
+    switch (action) {
+      case 'delete-request': {
+        Utils.deleteSpaCyNLPPipelineModelRequest(this.userId, spaCyNLPPipelineModelId);
+        break;
+      }
+      case 'view': {
+        window.location.href = `/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModelId}`;
+        break;
+      }
+      default: {
+        break;
+      }
+    }
+  }
+}
diff --git a/app/static/js/RessourceLists/TesseractOCRModelList.js b/app/static/js/RessourceLists/TesseractOCRModelList.js
deleted file mode 100644
index 782f5d7e..00000000
--- a/app/static/js/RessourceLists/TesseractOCRModelList.js
+++ /dev/null
@@ -1,76 +0,0 @@
-class TesseractOCRModelList {
-  constructor () {
-    
-    this.elements =  {
-      tesseractOCRModelList: document.querySelector('#tesseract-ocr-model-list'),
-      deleteButtons: document.querySelectorAll('.delete-button'),
-      editButtons: document.querySelectorAll('.edit-button'),
-      
-    }
-  }
-
-  init () {
-    let userId = this.elements.tesseractOCRModelList.dataset.userId;
-    
-    for (let deleteButton of this.elements.deleteButtons) {
-      deleteButton.addEventListener('click', () => {this.deleteModel(deleteButton, userId);});
-    }
-
-    for (let editButton of this.elements.editButtons) {
-      editButton.addEventListener('click', () => {this.editModel(editButton);});
-    }
-  }
-
-  deleteModel(deleteButton, userId) {
-    return new Promise((resolve, reject) => {
-      let modelId = deleteButton.dataset.modelId;
-      let model = app.data.users[userId].tesseract_ocr_pipeline_models[modelId];
-      let modalElement = Utils.elementFromString(
-        `
-          <div class="modal">
-            <div class="modal-content">
-              <h4>Confirm job deletion</h4>
-              <p>Do you really want to delete? All files will be permanently deleted!</p>
-            </div>
-            <div class="modal-footer">
-              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
-              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
-            </div>
-          </div>
-        `
-      );
-      document.querySelector('#modals').appendChild(modalElement);
-      let modal = M.Modal.init(
-        modalElement,
-        {
-          dismissible: false,
-          onCloseEnd: () => {
-            modal.destroy();
-            modalElement.remove();
-          }
-        }
-      );
-      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
-      confirmElement.addEventListener('click', (event) => {
-        let modelTitle = model.title;
-        fetch(`/contributions/edit-tesseract-model/${modelId}`, {method: 'DELETE'})
-          .then(
-            (response) => {
-              app.flash(`Model "${modelTitle}" marked for deletion`, 'corpus');
-              resolve(response);
-            },
-            (response) => {
-              if (response.status === 403) {app.flash('Forbidden', 'error');}
-              if (response.status === 404) {app.flash('Not Found', 'error');}
-              reject(response);
-            }
-          );
-      });
-      modal.open();
-    });
-  }
-
-  editModel(editButton) {
-    window.location.href = `/contributions/edit-tesseract-model/${editButton.dataset.modelId}`;
-  }
-}
diff --git a/app/static/js/RessourceLists/TesseractOCRPipelineModelList.js b/app/static/js/RessourceLists/TesseractOCRPipelineModelList.js
new file mode 100644
index 00000000..36dea105
--- /dev/null
+++ b/app/static/js/RessourceLists/TesseractOCRPipelineModelList.js
@@ -0,0 +1,99 @@
+class TesseractOCRPipelineModelList extends RessourceList {
+  static autoInit() {
+    for (let tesseractOCRPipelineModelListElement of document.querySelectorAll('.tesseract-ocr-pipeline-model-list:not(.no-autoinit)')) {
+      new TesseractOCRPipelineModelList(tesseractOCRPipelineModelListElement);
+    }
+  }
+
+  static options = {
+    initialHtmlGenerator: (id) => {
+      return `
+        <div class="input-field">
+          <i class="material-icons prefix">search</i>
+          <input id="${id}-search" class="search" type="search"></input>
+          <label for="${id}-search">Search Tesseract OCR Pipeline Model</label>
+        </div>
+        <table>
+          <thead>
+            <tr>
+              <th>Title</th>
+              <th>Description</th>
+              <th>Biblio</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody class="list"></tbody>
+        </table>
+        <ul class="pagination"></ul>
+      `.trim();
+    },
+    item: `
+      <tr class="clickable hoverable">
+        <td><span class="title"></span></td>
+        <td><span class="description"></span></td>
+        <td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>), <span class="title-2"></span> <span class="version"></span>, <a class="publishing-url"><span class="publishing-url-2"></span></a></td>
+        <td class="right-align">
+          <a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
+          <a class="action-button btn-floating service-color darken waves-effect waves-light service-2" data-action="view"><i class="material-icons">send</i></a>
+        </td>
+      </tr>
+    `.trim(),
+    ressourceMapper: (tesseractOCRPipelineModel) => {
+      return {
+        'id': tesseractOCRPipelineModel.id,
+        'creation-date': tesseractOCRPipelineModel.creation_date,
+        'description': tesseractOCRPipelineModel.description,
+        'publisher': tesseractOCRPipelineModel.publisher,
+        'publisher-url': tesseractOCRPipelineModel.publisher_url,
+        'publishing-url': tesseractOCRPipelineModel.publishing_url,
+        'publishing-url-2': tesseractOCRPipelineModel.publishing_url,
+        'publishing-year': tesseractOCRPipelineModel.publishing_year,
+        'title': tesseractOCRPipelineModel.title,
+        'title-2': tesseractOCRPipelineModel.title,
+        'version': tesseractOCRPipelineModel.version
+      };
+    },
+    sortArgs: ['creation-date', {order: 'desc'}],
+    valueNames: [
+      {data: ['id']},
+      {data: ['creation-date']},
+      {name: 'publisher-url', attr: 'href'},
+      {name: 'publishing-url', attr: 'href'},
+      'description',
+      'publisher',
+      'publishing-url-2',
+      'publishing-year',
+      'title',
+      'title-2',
+      'version'
+    ]
+  };
+
+  constructor(listElement, options = {}) {
+    super(listElement, {...TesseractOCRPipelineModelList.options, ...options});
+  }
+
+  init (user) {
+    this._init(user.tesseract_ocr_pipeline_models);
+  }
+
+  onClick(event) {
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+    let tesseractOCRPipelineModelElement = event.target.closest('tr');
+    let tesseractOCRPipelineModelId = tesseractOCRPipelineModelElement.dataset.id;
+    switch (action) {
+      case 'delete-request': {
+        Utils.deleteTesseractOCRPipelineModelRequest(this.userId, tesseractOCRPipelineModelId);
+        break;
+      }
+      case 'view': {
+        window.location.href = `/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}`;
+        break;
+      }
+      default: {
+        break;
+      }
+    }
+  }
+}
diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js
index 2e7cbd4c..e6076e43 100644
--- a/app/static/js/Utils.js
+++ b/app/static/js/Utils.js
@@ -84,8 +84,8 @@ class Utils {
         `
           <div class="modal">
             <div class="modal-content">
-              <h4>Confirm job deletion</h4>
-              <p>Do you really want to delete the job <b>${corpusFile.title}</b>? All files will be permanently deleted!</p>
+              <h4>Confirm Corpus File deletion</h4>
+              <p>Do you really want to delete the Corpus File <b>${corpusFile.title}</b>? All files will be permanently deleted!</p>
             </div>
             <div class="modal-footer">
               <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
@@ -126,6 +126,102 @@ class Utils {
     });
   }
 
+  static deleteSpaCyNLPPipelineModelRequest(userId, spaCyNLPPipelineModelId) {
+    return new Promise((resolve, reject) => {
+      let spaCyNLPPipelineModel = app.data.users[userId].spacy_nlp_pipeline_models[spaCyNLPPipelineModelId];
+      let modalElement = Utils.elementFromString(
+        `
+          <div class="modal">
+            <div class="modal-content">
+              <h4>Confirm SpaCy NLP Pipeline Model deletion</h4>
+              <p>Do you really want to delete the SpaCy NLP Pipeline Model <b>${spaCyNLPPipelineModel.title}</b>? All files will be permanently deleted!</p>
+            </div>
+            <div class="modal-footer">
+              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
+              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
+            </div>
+          </div>
+        `
+      );
+      document.querySelector('#modals').appendChild(modalElement);
+      let modal = M.Modal.init(
+        modalElement,
+        {
+          dismissible: false,
+          onCloseEnd: () => {
+            modal.destroy();
+            modalElement.remove();
+          }
+        }
+      );
+      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+      confirmElement.addEventListener('click', (event) => {
+        let spaCyNLPPipelineModelTitle = spaCyNLPPipelineModel.title;
+        fetch(`/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModelId}`, {method: 'DELETE'})
+          .then(
+            (response) => {
+              app.flash(`SpaCy NLP Pipeline Model "${spaCyNLPPipelineModelTitle}" marked for deletion`);
+              resolve(response);
+            },
+            (response) => {
+              if (response.status === 403) {app.flash('Forbidden', 'error');}
+              if (response.status === 404) {app.flash('Not Found', 'error');}
+              reject(response);
+            }
+          );
+      });
+      modal.open();
+    });
+  }
+
+  static deleteTesseractOCRPipelineModelRequest(userId, tesseractOCRPipelineModelId) {
+    return new Promise((resolve, reject) => {
+      let tesseractOCRPipelineModel = app.data.users[userId].tesseract_ocr_pipeline_models[tesseractOCRPipelineModelId];
+      let modalElement = Utils.elementFromString(
+        `
+          <div class="modal">
+            <div class="modal-content">
+              <h4>Confirm Tesseract OCR Pipeline Model deletion</h4>
+              <p>Do you really want to delete the Tesseract OCR Pipeline Model <b>${tesseractOCRPipelineModel.title}</b>? All files will be permanently deleted!</p>
+            </div>
+            <div class="modal-footer">
+              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
+              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
+            </div>
+          </div>
+        `
+      );
+      document.querySelector('#modals').appendChild(modalElement);
+      let modal = M.Modal.init(
+        modalElement,
+        {
+          dismissible: false,
+          onCloseEnd: () => {
+            modal.destroy();
+            modalElement.remove();
+          }
+        }
+      );
+      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+      confirmElement.addEventListener('click', (event) => {
+        let tesseractOCRPipelineModelTitle = tesseractOCRPipelineModel.title;
+        fetch(`/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}`, {method: 'DELETE'})
+          .then(
+            (response) => {
+              app.flash(`Tesseract OCR Pipeline Model "${tesseractOCRPipelineModelTitle}" marked for deletion`);
+              resolve(response);
+            },
+            (response) => {
+              if (response.status === 403) {app.flash('Forbidden', 'error');}
+              if (response.status === 404) {app.flash('Not Found', 'error');}
+              reject(response);
+            }
+          );
+      });
+      modal.open();
+    });
+  }
+
   static deleteJobRequest(userId, jobId) {
     return new Promise((resolve, reject) => {
       let job = app.data.users[userId].jobs[jobId];
diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2
index 798d2848..49a4d69f 100644
--- a/app/templates/_scripts.html.j2
+++ b/app/templates/_scripts.html.j2
@@ -24,9 +24,9 @@
   'js/RessourceLists/JobList.js',
   'js/RessourceLists/JobInputList.js',
   'js/RessourceLists/JobResultList.js',
+  'js/RessourceLists/SpacyNLPPipelineModelList.js',
+  'js/RessourceLists/TesseractOCRPipelineModelList.js',
   'js/RessourceLists/QueryResultList.js',
-  'js/RessourceLists/SpacyNLPModelList.js',
-  'js/RessourceLists/TesseractOCRModelList.js',
   'js/RessourceLists/UserList.js'
 %}
 <script src="{{ ASSET_URL }}"></script>
diff --git a/app/templates/contributions/_breadcrumbs.html.j2 b/app/templates/contributions/_breadcrumbs.html.j2
index 327d0578..21f27789 100644
--- a/app/templates/contributions/_breadcrumbs.html.j2
+++ b/app/templates/contributions/_breadcrumbs.html.j2
@@ -3,14 +3,14 @@
 {% if request.path == url_for('.contributions') %}
 <li class="tab"><a class="active" href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
 
-{% elif request.path == url_for('.add_tesseract_ocr_pipeline_model') %}
+{% elif request.path == url_for('.create_tesseract_ocr_pipeline_model') %}
 <li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.add_tesseract_ocr_pipeline_model') }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.add_spacy_nlp_pipeline_model') %}
+<li class="tab"><a class="active" href="{{ url_for('.create_tesseract_ocr_pipeline_model') }}" target="_self">{{ title }}</a></li>
+{% elif request.path == url_for('.create_spacy_nlp_pipeline_model') %}
 <li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.add_spacy_nlp_pipeline_model') }}" target="_self">{{ title }}</a></li>
+<li class="tab"><a class="active" href="{{ url_for('.create_spacy_nlp_pipeline_model') }}" target="_self">{{ title }}</a></li>
 {% elif tesseract_ocr_pipeline_model and request.path == url_for('.tesseract_ocr_pipeline_model', tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id) %}
 <li class="tab"><a href="{{ url_for('.contributions') }}" target="_self">Contributions Overview</a></li>
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
diff --git a/app/templates/contributions/contribution_overview.html.j2 b/app/templates/contributions/contribution_overview.html.j2
deleted file mode 100644
index c6facf9d..00000000
--- a/app/templates/contributions/contribution_overview.html.j2
+++ /dev/null
@@ -1,129 +0,0 @@
-{% extends "base.html.j2" %}
-{% import "materialize/wtf.html.j2" as wtf %}
-{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
-
-{% block page_content %}
-<div class="container">
-  <div class="row">
-    <div class="col s12">
-      <h1 id="title">{{ title }}</h1>
-
-      {# Tesseract OCR Models #}
-      <div>
-        <h3>My Tesseract OCR Pipeline Models</h3>
-        <p>Here you can see and edit the models that you have created. You can also create new models.</p>
-
-        <div class="row">
-          <div class="col s12">
-            <div class="card">
-              <div class="card-content">
-                <div id="tesseract-ocr-model-list" data-user-id="{{ userId }}">
-                  <table>
-                    <thead>
-                      <tr>
-                        <th>Title</th>
-                        <th>Description</th>
-                        <th>Biblio</th>
-                        <th></th>
-                      </tr>
-                    </thead>
-                    <tbody>
-                      {% if tesseract_ocr_user_models|length > 0 %}
-                        {% for m in tesseract_ocr_user_models %}
-                          <tr id="tesseract-ocr-pipeline-model-{{ m.hashid }}">
-                            <td>{{ m.title }}</td>
-                            {% if m.description == '' %}
-                            <td>Description is not available.</td>
-                            {% else %}
-                            <td>{{ m.description }}</td>
-                            {% endif %}
-                            <td><a href="{{ m.publisher_url }}">{{ m.publisher }}</a> ({{ m.publishing_year }}), {{ m.title }} {{ m.version}}, <a href="{{ m.publishing_url }}">{{ m.publishing_url }}</a></td>
-                            <td class="right-align">
-                              <a class="delete-button btn-floating red waves-effect waves-light" data-model-id="{{ m.hashid }}"><i class="material-icons">delete</i></a>
-                              <a class="edit-button btn-floating service-color darken waves-effect waves-light" data-model-id="{{ m.hashid }}"><i class="material-icons">edit</i></a>
-                            </td>
-                          </tr>
-                        {% endfor %}
-                      {% else %}
-                        <tr>
-                          <td colspan="4">No models available.</td>
-                        </tr>
-                      {% endif %}
-                    </tbody>
-                  </table>
-                </div>
-              </div>
-              <div class="card-action right-align">
-                <a href="{{ url_for('contributions.add_tesseract_ocr_pipeline_model') }}" class="btn waves-effect waves-light"><i class="material-icons left">add</i>Add model file</a>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-      
-      {# spaCy NLP Models #}
-      <div>
-        <h3>My spaCy NLP Pipeline Models</h3>
-        <p>Here you can see and edit the models that you have created. You can also create new models.</p>
-
-        <div class="row">
-          <div class="col s12">
-            <div class="card">
-              <div class="card-content">
-                <div id="spacy-nlp-model-list" data-user-id="{{ userId }}" data-user-models="{{ spacy_nlp_user_models }}">
-                  <table>
-                    <thead>
-                      <tr>
-                        <th>Title</th>
-                        <th>Description</th>
-                        <th>Biblio</th>
-                        <th></th>
-                      </tr>
-                    </thead>
-                    <tbody>
-                      {% if spacy_nlp_user_models|length > 0 %}
-                        {% for m in spacy_nlp_user_models %}
-                          <tr id="spacy_nlp-pipeline-model-{{ m.hashid }}">
-                            <td>{{ m.title }}</td>
-                            {% if m.description == '' %}
-                            <td>Description is not available.</td>
-                            {% else %}
-                            <td>{{ m.description }}</td>
-                            {% endif %}
-                            <td><a href="{{ m.publisher_url }}">{{ m.publisher }}</a> ({{ m.publishing_year }}), {{ m.title }} {{ m.version}}, <a href="{{ m.publishing_url }}">{{ m.publishing_url }}</a></td>
-                            <td class="right-align">
-                              <a class="delete-spacy-model-button btn-floating red waves-effect waves-light" data-model-id="{{ m.hashid }}"><i class="material-icons">delete</i></a>
-                              <a class="edit-spacy-model-button btn-floating service-color darken waves-effect waves-light" data-model-id="{{ m.hashid }}"><i class="material-icons">edit</i></a>
-                            </td>
-                          </tr>
-                        {% endfor %}
-                      {% else %}
-                        <tr>
-                          <td colspan="4">No models available.</td>
-                        </tr>
-                      {% endif %}
-                    </tbody>
-                  </table>
-                </div>
-              </div>
-              <div class="card-action right-align">
-                <a href="{{ url_for('contributions.add_spacy_nlp_pipeline_model') }}" class="btn waves-effect waves-light"><i class="material-icons left">add</i>Add model file</a>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
-{% endblock page_content %}
-
-{% block scripts %}
-{{ super() }}
-<script>
-const tesseractOCRModelList = new TesseractOCRModelList();
-tesseractOCRModelList.init();
-const spacyNLPModelList = new SpacyNLPModelList();
-spacyNLPModelList.init();
-</script>
-{% endblock scripts %}
diff --git a/app/templates/contributions/contributions.html.j2 b/app/templates/contributions/contributions.html.j2
new file mode 100644
index 00000000..da2cdcad
--- /dev/null
+++ b/app/templates/contributions/contributions.html.j2
@@ -0,0 +1,51 @@
+{% extends "base.html.j2" %}
+{% import "materialize/wtf.html.j2" as wtf %}
+{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
+
+{% block page_content %}
+<div class="container">
+  <div class="row">
+    <div class="col s12">
+      <h1 id="title">{{ title }}</h1>
+
+      {# Tesseract OCR Models #}
+      <div>
+        <h3>My Tesseract OCR Pipeline Models</h3>
+        <p>Here you can see and edit the models that you have created. You can also create new models.</p>
+
+        <div class="row">
+          <div class="col s12">
+            <div class="card">
+              <div class="card-content">
+                <div class="tesseract-ocr-pipeline-model-list" data-user-id="{{ current_user.hashid }}"></div>
+              </div>
+              <div class="card-action right-align">
+                <a href="{{ url_for('.create_tesseract_ocr_pipeline_model') }}" class="btn waves-effect waves-light"><i class="material-icons left">add</i>Create</a>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+      
+      {# spaCy NLP Models #}
+      <div>
+        <h3>My spaCy NLP Pipeline Models</h3>
+        <p>Here you can see and edit the models that you have created. You can also create new models.</p>
+
+        <div class="row">
+          <div class="col s12">
+            <div class="card">
+              <div class="card-content">
+                <div class="spacy-nlp-pipeline-model-list" data-user-id="{{ current_user.hashid }}"></div>
+              </div>
+              <div class="card-action right-align">
+                <a href="{{ url_for('.create_spacy_nlp_pipeline_model') }}" class="btn waves-effect waves-light"><i class="material-icons left">add</i>Create</a>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+{% endblock page_content %}
diff --git a/app/templates/contributions/contribute_spacy_nlp_models.html.j2 b/app/templates/contributions/create_spacy_nlp_pipeline_model.html.j2
similarity index 70%
rename from app/templates/contributions/contribute_spacy_nlp_models.html.j2
rename to app/templates/contributions/create_spacy_nlp_pipeline_model.html.j2
index d1e3cd11..9a0bcddd 100644
--- a/app/templates/contributions/contribute_spacy_nlp_models.html.j2
+++ b/app/templates/contributions/create_spacy_nlp_pipeline_model.html.j2
@@ -27,10 +27,8 @@
           <div class="row">
             <div class="col s12">
               <div class="card-panel z-depth-0">
-                <span class="card-title"><i class="left material-icons">layers</i>spaCy NLP Models</span>
-                <p>You can add more spaCy NLP models using the form below. They will automatically appear in the list of usable models.</p>
-                <p><a href="">Edit already uploaded models</a></p>
-                <p><a class="modal-trigger" href="#models-modal">Information about the already existing models.</a></p>
+                <span class="card-title"><i class="left material-icons">layers</i>SpaCy NLP Pipeline Model</span>
+                <p>You can create a new SpaCy NLP Pipeline Model using the form below. They will automatically appear in the list of usable models on the <a href="{{ url_for('services.spacy_nlp_pipeline') }}">SpaCy NLP Pipeline</a> service page.</p>
               </div>
             </div>
           </div>
@@ -39,7 +37,6 @@
     </div>
 
     <div class="col s12">
-      <h2>Add a model</h2>
       <div class="card">
         <form class="create-contribution-form" enctype="multipart/form-data" method="POST">
           <div class="card-content">
@@ -91,37 +88,3 @@
   </div>
 </div>
 {% endblock page_content %}
-
-{% block modals %}
-{{ super() }}
-<div id="models-modal" class="modal">
-  <div class="modal-content">
-    <h4>spaCy NLP Pipeline models</h4>
-    <table>
-      <thead>
-        <tr>
-          <th>Title</th>
-          <th>Description</th>
-          <th>Biblio</th>
-        </tr>
-      </thead>
-      <tbody>
-        {% for m in spacy_nlp_pipeline_models %}
-        <tr id="spacy-nlp-pipeline-model-{{ m.hashid }}">
-          <td>{{ m.title }}</td>
-          {% if m.description == '' %}
-          <td>Description is not available.</td>
-          {% else %}
-          <td>{{ m.description }}</td>
-          {% endif %}
-          <td><a href="{{ m.publisher_url }}">{{ m.publisher }}</a> ({{ m.publishing_year }}), {{ m.title }} {{ m.version}}, <a href="{{ m.publishing_url }}">{{ m.publishing_url }}</a></td>
-        </tr>
-        {% endfor %}
-      </tbody>
-    </table>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="modal-close waves-effect waves-light btn">Close</a>
-  </div>
-</div>
-{% endblock modals %}
diff --git a/app/templates/contributions/contribute_tesseract_ocr_models.html.j2 b/app/templates/contributions/create_tesseract_ocr_pipeline_model.html.j2
similarity index 91%
rename from app/templates/contributions/contribute_tesseract_ocr_models.html.j2
rename to app/templates/contributions/create_tesseract_ocr_pipeline_model.html.j2
index 1e50585a..9a04b535 100644
--- a/app/templates/contributions/contribute_tesseract_ocr_models.html.j2
+++ b/app/templates/contributions/create_tesseract_ocr_pipeline_model.html.j2
@@ -27,10 +27,8 @@
           <div class="row">
             <div class="col s12">
               <div class="card-panel z-depth-0">
-                <span class="card-title"><i class="left material-icons">layers</i>Tesseract OCR Models</span>
-                <p>You can add more Tesseract OCR models using the form below. They will automatically appear in the list of usable models.</p>
-                <p><a class="modal-trigger" href="#models-modal">Information about the already existing models.</a></p>
-                <p><a href="">Edit already uploaded models</a></p>
+                <span class="card-title"><i class="left material-icons">layers</i>Tesseract OCR Pipeline Model</span>
+                <p>You can create a new Tesseract OCR Pipeline Model using the form below. They will automatically appear in the list of usable models on the <a href="{{ url_for('services.tesseract_ocr_pipeline') }}">Tesseract OCR Pipeline</a> service page.</p>
               </div>
             </div>
           </div>
@@ -39,7 +37,6 @@
     </div>
 
     <div class="col s12">
-      <h2>Add a model</h2>
       <div class="card">
         <form class="create-contribution-form" enctype="multipart/form-data" method="POST">
           <div class="card-content">
diff --git a/app/templates/contributions/spacy_nlp_pipeline_model.html.j2 b/app/templates/contributions/spacy_nlp_pipeline_model.html.j2
index 37248448..951b12a6 100644
--- a/app/templates/contributions/spacy_nlp_pipeline_model.html.j2
+++ b/app/templates/contributions/spacy_nlp_pipeline_model.html.j2
@@ -13,7 +13,7 @@
 
     <div class="col s12">
       <div class="card">
-        <form class="create-contribution-form" enctype="multipart/form-data" method="POST">
+        <form method="POST">
           <div class="card-content">
             {{ form.hidden_tag() }}
             <div class="row">
diff --git a/app/templates/contributions/tesseract_ocr_pipeline_model.html.j2 b/app/templates/contributions/tesseract_ocr_pipeline_model.html.j2
index 4db82349..c979f3ab 100644
--- a/app/templates/contributions/tesseract_ocr_pipeline_model.html.j2
+++ b/app/templates/contributions/tesseract_ocr_pipeline_model.html.j2
@@ -13,7 +13,7 @@
 
     <div class="col s12">
       <div class="card">
-        <form class="create-contribution-form" enctype="multipart/form-data" method="POST">
+        <form method="POST">
           <div class="card-content">
             {{ form.hidden_tag() }}
             <div class="row">
-- 
GitLab