From 38d09a34904f1b323ecb4f5c9ded23674f448ad4 Mon Sep 17 00:00:00 2001
From: Patrick Jentsch <p.jentsch@uni-bielefeld.de>
Date: Thu, 23 Feb 2023 13:05:04 +0100
Subject: [PATCH] Integrate CorpusFollowerRoles

---
 app/cli.py                                    |  17 +-
 app/corpora/routes.py                         |  29 +--
 app/models.py                                 | 189 ++++++++++++++----
 .../js/ResourceLists/CorpusFollowerList.js    |  84 ++++----
 app/templates/_sidenav.html.j2                |   6 +-
 migrations/versions/1f77ce4346c6_.py          |  72 +++++++
 nopaque.py                                    |  11 +-
 7 files changed, 283 insertions(+), 125 deletions(-)
 create mode 100644 migrations/versions/1f77ce4346c6_.py

diff --git a/app/cli.py b/app/cli.py
index 826aa790..e59a080d 100644
--- a/app/cli.py
+++ b/app/cli.py
@@ -3,10 +3,11 @@ from flask_migrate import upgrade
 import click
 import os
 from app.models import (
+    CorpusFollowerRole,
     Role,
-    User,
+    SpaCyNLPPipelineModel,
     TesseractOCRPipelineModel,
-    SpaCyNLPPipelineModel
+    User
 )
 
 
@@ -30,19 +31,23 @@ def register(app):
     def deploy():
         ''' Run deployment tasks. '''
         # Make default directories
+        print('Make default directories')
         _make_default_dirs()
 
         # migrate database to latest revision
+        print('Migrate database to latest revision')
         upgrade()
 
         # Insert/Update default database values
-        current_app.logger.info('Insert/Update default roles')
+        print('Insert/Update default Roles')
         Role.insert_defaults()
-        current_app.logger.info('Insert/Update default users')
+        print('Insert/Update default Users')
         User.insert_defaults()
-        current_app.logger.info('Insert/Update default SpaCyNLPPipelineModels')
+        print('Insert/Update default CorpusFollowerRoles')
+        CorpusFollowerRole.insert_defaults()
+        print('Insert/Update default SpaCyNLPPipelineModels')
         SpaCyNLPPipelineModel.insert_defaults()
-        current_app.logger.info('Insert/Update default TesseractOCRPipelineModels')
+        print('Insert/Update default TesseractOCRPipelineModels')
         TesseractOCRPipelineModel.insert_defaults()
 
     @app.cli.group()
diff --git a/app/corpora/routes.py b/app/corpora/routes.py
index 2d59426b..ecfc1e84 100644
--- a/app/corpora/routes.py
+++ b/app/corpora/routes.py
@@ -21,6 +21,7 @@ from app.models import (
     CorpusFile,
     CorpusFollowerAssociation,
     CorpusFollowerPermission,
+    CorpusFollowerRole,
     CorpusStatus,
     User
 )
@@ -107,30 +108,16 @@ def current_user_unfollow_corpus(corpus_id):
     return '', 204
 
 
-@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/permissions/<permission_name>/add', methods=['POST'])
-def add_permission(corpus_id, follower_id, permission_name):
-    try:
-        permission = CorpusFollowerPermission[permission_name]
-    except KeyError:
-        abort(409)  # f'Permission "{permission_name}" does not exist'
+@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/role', methods=['POST'])
+def add_permission(corpus_id, follower_id):
     corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
     if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()):
         abort(403)
-    corpus_follower_association.add_permission(permission)
-    db.session.commit()
-    return '', 204
-
-
-@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/permissions/<permission_name>/remove', methods=['POST'])
-def remove_permission(corpus_id, follower_id, permission_name):
-    try:
-        permission = CorpusFollowerPermission[permission_name]
-    except KeyError:
-        return make_response(f'Permission "{permission_name}" does not exist', 409)
-    corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
-    if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()):
-        abort(403)
-    corpus_follower_association.remove_permission(permission)
+    role_name = request.json.get('role')
+    if role_name is None:
+        abort(400)
+    corpus_follower_role = CorpusFollowerRole.query.filter_by(name=role_name).first_or_404()
+    corpus_follower_association.role = corpus_follower_role
     db.session.commit()
     return '', 204
 
diff --git a/app/models.py b/app/models.py
index 79ee0ac7..2444589c 100644
--- a/app/models.py
+++ b/app/models.py
@@ -6,6 +6,7 @@ from flask_login import UserMixin
 from sqlalchemy.ext.associationproxy import association_proxy
 from time import sleep
 from tqdm import tqdm
+from typing import Union
 from werkzeug.security import generate_password_hash, check_password_hash
 from werkzeug.utils import secure_filename
 import json
@@ -57,6 +58,15 @@ class Permission(IntEnum):
     CONTRIBUTE = 2
     USE_API = 4
 
+    @staticmethod
+    def get(permission: Union['Permission', int, str]) -> 'Permission':
+        if isinstance(permission, Permission):
+            return permission
+        if isinstance(permission, int):
+            return Permission(permission)
+        if isinstance(permission, str):
+            return Permission[permission]
+        raise TypeError('permission must be Permission, int, or str')
 
 class UserSettingJobStatusMailNotificationLevel(IntEnum):
     NONE = 1
@@ -72,8 +82,22 @@ class ProfilePrivacySettings(IntEnum):
 
 class CorpusFollowerPermission(IntEnum):
     VIEW = 1
-    CONTRIBUTE = 2
-    ADMINISTRATE = 4
+    ADD_CORPUS_FILE = 2
+    UPDATE_CORPUS_FILE = 4
+    REMOVE_CORPUS_FILE = 8
+    GENERATE_SHARE_LINK = 16
+    REMOVE_FOLLOWER = 32
+    UPDATE_FOLLOWER = 64
+
+    @staticmethod
+    def get(permission: Union['CorpusFollowerPermission', int, str]) -> 'CorpusFollowerPermission':
+        if isinstance(permission, CorpusFollowerPermission):
+            return permission
+        if isinstance(permission, int):
+            return CorpusFollowerPermission(permission)
+        if isinstance(permission, str):
+            return CorpusFollowerPermission[permission]
+        raise TypeError('permission must be CorpusFollowerPermission, int, or str')
 # endregion enums
 
 
@@ -181,16 +205,19 @@ class Role(HashidMixin, db.Model):
     def __repr__(self):
         return f'<Role {self.name}>'
 
-    def add_permission(self, permission):
-        if not self.has_permission(permission):
-            self.permissions += permission
-
-    def has_permission(self, permission):
-        return self.permissions & permission == permission
-
-    def remove_permission(self, permission):
-        if self.has_permission(permission):
-            self.permissions -= permission
+    def has_permission(self, permission: Union[Permission, int, str]):
+        perm = Permission.get(permission)
+        return self.permissions & perm.value == perm.value
+    
+    def add_permission(self, permission: Union[Permission, int, str]):
+        perm = Permission.get(permission)
+        if not self.has_permission(perm):
+            self.permissions += perm.value
+    
+    def remove_permission(self, permission: Union[Permission, int, str]):
+        perm = Permission.get(permission)
+        if self.has_permission(perm):
+            self.permissions -= perm.value
 
     def reset_permissions(self):
         self.permissions = 0
@@ -319,48 +346,135 @@ class Avatar(HashidMixin, FileMixin, db.Model):
         return json_serializeable
 
 
-class CorpusFollowerAssociation(HashidMixin, db.Model):
-    __tablename__ = 'corpus_follower_associations'
+class CorpusFollowerRole(HashidMixin, db.Model):
+    __tablename__ = 'corpus_follower_roles'
     # Primary key
     id = db.Column(db.Integer, primary_key=True)
-    # Foreign keys
-    corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id'))
-    follower_id = db.Column(db.Integer, db.ForeignKey('users.id'))
     # Fields
-    permissions = db.Column(db.Integer, default=0, nullable=False)
+    name = db.Column(db.String(64), unique=True)
+    default = db.Column(db.Boolean, default=False, index=True)
+    permissions = db.Column(db.Integer, default=0)
     # Relationships
-    corpus = db.relationship('Corpus', back_populates='corpus_follower_associations')
-    follower = db.relationship('User', back_populates='corpus_follower_associations')
+    corpus_follower_associations = db.relationship(
+        'CorpusFollowerAssociation',
+        back_populates='role',
+        lazy='dynamic'
+    )
 
     def __repr__(self):
-        return f'<CorpusFollowerAssociation {self.follower.__repr__()} ~ {self.corpus.__repr__()}>'
+        return f'<CorpusFollowerRole {self.name}>'
 
-    def has_permission(self, permission: CorpusFollowerPermission):
-        return self.permissions & permission.value == permission.value
+    def has_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
+        perm = CorpusFollowerPermission.get(permission)
+        return self.permissions & perm.value == perm.value
     
-    def add_permission(self, permission: CorpusFollowerPermission):
-        if not self.has_permission(permission):
-            self.permissions += permission.value
+    def add_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
+        perm = CorpusFollowerPermission.get(permission)
+        if not self.has_permission(perm):
+            self.permissions += perm.value
     
-    def remove_permission(self, permission: CorpusFollowerPermission):
-        if self.has_permission(permission):
-            self.permissions -= permission.value
+    def remove_permission(self, permission: Union[CorpusFollowerPermission, int, str]):
+        perm = CorpusFollowerPermission.get(permission)
+        if self.has_permission(perm):
+            self.permissions -= perm.value
+
+    def reset_permissions(self):
+        self.permissions = 0
 
     def to_json_serializeable(self, backrefs=False, relationships=False):
         json_serializeable = {
             'id': self.hashid,
+            'default': self.default,
+            'name': self.name,
             'permissions': [
-                x.name for x in CorpusFollowerPermission
+                x.name
+                for x in CorpusFollowerPermission
                 if self.has_permission(x)
+            ]
+        }
+        if backrefs:
+            pass
+        if relationships:
+            json_serializeable['corpus_follower_association'] = {
+                x.hashid: x.to_json_serializeable(relationships=True)
+                for x in self.corpus_follower_association
+            }
+        return json_serializeable
+
+    @staticmethod
+    def insert_defaults():
+        roles = {
+            'Viewer': [
+                CorpusFollowerPermission.VIEW
+            ],
+            'Contributor': [
+                CorpusFollowerPermission.VIEW,
+                CorpusFollowerPermission.ADD_CORPUS_FILE,
+                CorpusFollowerPermission.UPDATE_CORPUS_FILE,
+                CorpusFollowerPermission.REMOVE_CORPUS_FILE
             ],
+            'Administrator': [
+                CorpusFollowerPermission.VIEW,
+                CorpusFollowerPermission.ADD_CORPUS_FILE,
+                CorpusFollowerPermission.UPDATE_CORPUS_FILE,
+                CorpusFollowerPermission.REMOVE_CORPUS_FILE,
+                CorpusFollowerPermission.GENERATE_SHARE_LINK,
+                CorpusFollowerPermission.REMOVE_FOLLOWER,
+                CorpusFollowerPermission.UPDATE_FOLLOWER
+            ]
+        }
+        default_role_name = 'Viewer'
+        for role_name, permissions in roles.items():
+            role = CorpusFollowerRole.query.filter_by(name=role_name).first()
+            if role is None:
+                role = CorpusFollowerRole(name=role_name)
+            role.reset_permissions()
+            for permission in permissions:
+                role.add_permission(permission)
+            role.default = role.name == default_role_name
+            db.session.add(role)
+        db.session.commit()
+
+
+class CorpusFollowerAssociation(HashidMixin, db.Model):
+    __tablename__ = 'corpus_follower_associations'
+    # Primary key
+    id = db.Column(db.Integer, primary_key=True)
+    # Foreign keys
+    corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id'))
+    follower_id = db.Column(db.Integer, db.ForeignKey('users.id'))
+    role_id = db.Column(db.Integer, db.ForeignKey('corpus_follower_roles.id'))
+    # Relationships
+    corpus = db.relationship(
+        'Corpus',
+        back_populates='corpus_follower_associations'
+    )
+    follower = db.relationship(
+        'User',
+        back_populates='corpus_follower_associations'
+    )
+    role = db.relationship(
+        'CorpusFollowerRole',
+        back_populates='corpus_follower_associations'
+    )
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        if self.role is None:
+            self.role = CorpusFollowerRole.query.filter_by(default=True).first()
+
+    def __repr__(self):
+        return f'<CorpusFollowerAssociation {self.follower.__repr__()} ~ {self.corpus.__repr__()}>'
+
+    def to_json_serializeable(self, backrefs=False, relationships=False):
+        json_serializeable = {
+            'id': self.hashid,
             'corpus': self.corpus.to_json_serializeable(),
-            'follower': self.follower.to_json_serializeable()
+            'follower': self.follower.to_json_serializeable(),
+            'role': self.role.to_json_serializeable()
         }
         if backrefs:
-            json_serializeable['corpus'] = \
-                self.corpus.to_json_serializeable(backrefs=True)
-            json_serializeable['follower'] = \
-                self.follower.to_json_serializeable(backrefs=True)
+            pass
         if relationships:
             pass
         return json_serializeable
@@ -559,7 +673,6 @@ class User(HashidMixin, UserMixin, db.Model):
                 issuer=current_app.config['SERVER_NAME'],
                 options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
             )
-            current_app.logger.warning(payload)
         except jwt.PyJWTError:
             return False
         if payload.get('purpose') != 'user.confirm':
@@ -687,7 +800,7 @@ class User(HashidMixin, UserMixin, db.Model):
                 self.role.to_json_serializeable(backrefs=True)
         if relationships:
             json_serializeable['corpus_follower_associations'] = {
-                x.hashid: x.to_json_serializeable(relationships=True)
+                x.hashid: x.to_json_serializeable()
                 for x in self.corpus_follower_associations
             }
             json_serializeable['corpora'] = {
@@ -1469,7 +1582,7 @@ class Corpus(HashidMixin, db.Model):
                 self.user.to_json_serializeable(backrefs=True)
         if relationships:
             json_serializeable['corpus_follower_associations'] = {
-                x.hashid: x.to_json_serializeable(relationships=True)
+                x.hashid: x.to_json_serializeable()
                 for x in self.corpus_follower_associations
             }
             json_serializeable['files'] = {
diff --git a/app/static/js/ResourceLists/CorpusFollowerList.js b/app/static/js/ResourceLists/CorpusFollowerList.js
index be7e46d9..cc94c568 100644
--- a/app/static/js/ResourceLists/CorpusFollowerList.js
+++ b/app/static/js/ResourceLists/CorpusFollowerList.js
@@ -7,6 +7,9 @@ class CorpusFollowerList extends ResourceList {
 
   constructor(listContainerElement, options = {}) {
     super(listContainerElement, options);
+    this.listjs.on('updated', () => {
+      M.FormSelect.init(this.listjs.list.querySelectorAll('.list-item select'));
+    });
     this.listjs.list.addEventListener('change', (event) => {this.onChange(event)});
     this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
     this.isInitialized = false;
@@ -28,30 +31,21 @@ class CorpusFollowerList extends ResourceList {
     return (values) => {
       return `
         <tr class="list-item clickable hoverable">
-          <td><img alt="user-image" class="circle responsive-img avatar" style="width:50%"></td>
-          <td><b class="username"><b></td>
-          <td><span class="full-name"></span><br><i class="about-me"></i></td>
+          <td><img alt="follower-avatar" class="circle responsive-img follower-avatar" style="width:50%"></td>
+          <td><b class="follower-username"><b></td>
           <td>
-            <span class="disable-on-click">
-              <label>
-                <input ${values['permission-can-VIEW'] ? 'checked' : ''} class="permission-can-VIEW list-action-trigger" data-list-action="toggle-permission" data-permission="VIEW" type="checkbox">
-                <span>View</span>
-              </label>
-            </span>
-            <br>
-            <span class="disable-on-click">
-              <label>
-                <input ${values['permission-can-CONTRIBUTE'] ? 'checked' : ''} class="permission-can-CONTRIBUTE list-action-trigger" data-list-action="toggle-permission" data-permission="CONTRIBUTE" type="checkbox">
-                <span>Contribute</span>
-              </label>
-            </span>
+            <span class="follower-full-name"></span>
             <br>
-            <span class="disable-on-click">
-              <label>
-                <input ${values['permission-can-ADMINISTRATE'] ? 'checked' : ''} class="permission-can-ADMINISTRATE list-action-trigger" data-list-action="toggle-permission" data-permission="ADMINISTRATE" type="checkbox">
-                <span>Administrate</span>
-              </label>
-            </span>
+            <i class="follower-about-me"></i>
+          </td>
+          <td>
+            <div class="input-field disable-on-click list-action-trigger" data-list-action="update-role">
+              <select>
+                <option value="Viewer" ${values['role.name'] === 'Viewer' ? 'selected' : ''}>Viewer</option>
+                <option value="Contributor" ${values['role.name'] === 'Contributor' ? 'selected' : ''}>Contributor</option>
+                <option value="Administrator" ${values['role.name'] === 'Administrator' ? 'selected' : ''}>Administrator</option>
+              </select>
+            </div>
           </td>
           <td class="right-align">
             <a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="unfollow-request"><i class="material-icons">delete</i></a>
@@ -66,10 +60,10 @@ class CorpusFollowerList extends ResourceList {
     return [
       {data: ['id']},
       {data: ['follower-id']},
-      {name: 'avatar', attr: 'src'},
-      'username',
-      'about-me',
-      'full-name'
+      {name: 'follower-avatar', attr: 'src'},
+      'follower-username',
+      'follower-about-me',
+      'follower-full-name'
     ];
   }
 
@@ -90,7 +84,7 @@ class CorpusFollowerList extends ResourceList {
             <th style="width:15%;"></th>
             <th>Username</th>
             <th>User details</th>
-            <th>Permissions</th>
+            <th>Role</th>
             <th></th>
           </tr>
         </thead>
@@ -104,13 +98,11 @@ class CorpusFollowerList extends ResourceList {
     return {
       'id': corpusFollowerAssociation.id,
       'follower-id': corpusFollowerAssociation.follower.id,
-      'avatar': corpusFollowerAssociation.follower.avatar ? `/users/${corpusFollowerAssociation.follower.id}/avatar` : '/static/images/user_avatar.png',
-      'username': corpusFollowerAssociation.follower.username,
-      'full-name': corpusFollowerAssociation.follower.full_name ? corpusFollowerAssociation.follower.full_name : '',
-      'about-me': corpusFollowerAssociation.follower.about_me ? corpusFollowerAssociation.follower.about_me : '',
-      'permission-can-VIEW': corpusFollowerAssociation.permissions.includes('VIEW'),
-      'permission-can-CONTRIBUTE': corpusFollowerAssociation.permissions.includes('CONTRIBUTE'),
-      'permission-can-ADMINISTRATE': corpusFollowerAssociation.permissions.includes('ADMINISTRATE')
+      'follower-avatar': corpusFollowerAssociation.follower.avatar ? `/users/${corpusFollowerAssociation.follower.id}/avatar` : '/static/images/user_avatar.png',
+      'follower-username': corpusFollowerAssociation.follower.username,
+      'follower-full-name': corpusFollowerAssociation.follower.full_name ? corpusFollowerAssociation.follower.full_name : '',
+      'follower-about-me': corpusFollowerAssociation.follower.about_me ? corpusFollowerAssociation.follower.about_me : '',
+      'role-name': corpusFollowerAssociation.role.name
     };
   }
 
@@ -119,7 +111,7 @@ class CorpusFollowerList extends ResourceList {
   }
 
   onChange(event) {
-    if (event.target.tagName !== 'INPUT') {return;}
+    console.log(event.target.tagName);
     let listItemElement = event.target.closest('.list-item[data-id]');
     if (listItemElement === null) {return;}
     let itemId = listItemElement.dataset.id;
@@ -127,16 +119,10 @@ class CorpusFollowerList extends ResourceList {
     if (listActionElement === null) {return;}
     let listAction = listActionElement.dataset.listAction;
     switch (listAction) {
-      case 'toggle-permission': {
+      case 'update-role': {
         let followerId = listItemElement.dataset.followerId;
-        let permission = listActionElement.dataset.permission;
-        if (event.target.checked) {
-          Utils.addCorpusFollowerPermissionRequest(this.corpusId, followerId, permission)
-            .catch((error) => {event.target.checked = !event.target.checked;});
-        } else {
-          Utils.removeCorpusFollowerPermissionRequest(this.corpusId, followerId, permission)
-            .catch((error) => {event.target.checked = !event.target.checked;});
-        }
+        let roleName = event.target.value;
+        Utils.updateCorpusFollowerRole(this.corpusId, followerId, roleName);
         break;
       }
       default: {
@@ -188,11 +174,11 @@ class CorpusFollowerList extends ResourceList {
           break;
         }
         case 'replace': {
-          // let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)/(service|status|description|title)$`);
-          // if (re.test(operation.path)) {
-          //   let [match, jobId, valueName] = operation.path.match(re);
-          //   this.replace(jobId, valueName, operation.value);
-          // }
+          let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/corpus_follower_associations/([A-Za-z0-9]*)/role$`);
+          if (re.test(operation.path)) {
+            let [match, jobId, valueName] = operation.path.match(re);
+            this.replace(jobId, valueName, operation.value);
+          }
           break;
         }
         default: {
diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2
index 676b7069..7fa34623 100644
--- a/app/templates/_sidenav.html.j2
+++ b/app/templates/_sidenav.html.j2
@@ -36,13 +36,13 @@
   <li><a href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>General Settings</a></li>
   <li><a href="{{ url_for('users.edit_profile', user_id=current_user.id) }}"><i class="material-icons left">contact_page</i>Profile settings</a></li>
   <li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
-  {% if current_user.can(Permission.ADMINISTRATE) or current_user.can(Permission.USE_API) %}
+  {% if current_user.can('ADMINISTRATE') or current_user.can('USE_API') %}
   <li><div class="divider"></div></li>
   {% endif %}
-  {% if current_user.can(Permission.ADMINISTRATE) %}
+  {% if current_user.can('ADMINISTRATE') %}
   <li><a href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a></li>
   {% endif %}
-  {% if current_user.can(Permission.USE_API) %}
+  {% if current_user.can('USE_API') %}
   <li><a href="{{ url_for('apifairy.docs') }}"><i class="material-icons">api</i>API</a></li>
   {% endif %}
 </ul>
diff --git a/migrations/versions/1f77ce4346c6_.py b/migrations/versions/1f77ce4346c6_.py
new file mode 100644
index 00000000..1d7e2160
--- /dev/null
+++ b/migrations/versions/1f77ce4346c6_.py
@@ -0,0 +1,72 @@
+"""empty message
+
+Revision ID: 1f77ce4346c6
+Revises: 03c7211f089d
+Create Date: 2023-02-22 12:56:30.176665
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '1f77ce4346c6'
+down_revision = '03c7211f089d'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.create_table(
+        'corpus_follower_roles',
+        sa.Column('id', sa.Integer(), nullable=False),
+        sa.Column('name', sa.String(length=64), nullable=True),
+        sa.Column('default', sa.Boolean(), nullable=True),
+        sa.Column('permissions', sa.Integer(), nullable=True),
+        sa.PrimaryKeyConstraint('id'),
+        sa.UniqueConstraint('name')
+    )
+    op.create_index(
+        op.f('ix_corpus_follower_roles_default'),
+        'corpus_follower_roles',
+        ['default'],
+        unique=False
+    )
+
+    op.add_column(
+        'corpus_follower_associations',
+        sa.Column('role_id', sa.Integer(), nullable=True)
+    )
+    op.create_foreign_key(
+        'fk_corpus_follower_associations_role_id_corpus_follower_roles',
+        'corpus_follower_associations',
+        'corpus_follower_roles',
+        ['role_id'],
+        ['id']
+    )
+    op.drop_column('corpus_follower_associations', 'permissions')
+
+
+def downgrade():
+    op.add_column(
+        'corpus_follower_associations',
+        sa.Column('permissions', sa.Integer(), nullable=True)
+    )
+    op.execute('UPDATE corpus_follower_associations SET permissions = 0;')
+    op.alter_column(
+        'corpus_follower_associations',
+        'permissions',
+        nullable=False
+    )
+    op.drop_constraint(
+        'fk_corpus_follower_associations_role_id_corpus_follower_roles',
+        'corpus_follower_associations',
+        type_='foreignkey'
+    )
+    op.drop_column('corpus_follower_associations', 'role_id')
+
+    op.drop_index(
+        op.f('ix_corpus_follower_roles_default'),
+        table_name='corpus_follower_roles'
+    )
+    op.drop_table('corpus_follower_roles')
diff --git a/nopaque.py b/nopaque.py
index 602954cc..bbcf799d 100644
--- a/nopaque.py
+++ b/nopaque.py
@@ -9,6 +9,7 @@ from app.models import (
     Corpus,
     CorpusFile,
     CorpusFollowerAssociation,
+    CorpusFollowerRole,
     Job,
     JobInput,
     JobResult,
@@ -26,25 +27,19 @@ app: Flask = create_app()
 cli.register(app)
 
 
-@app.context_processor
-def make_context() -> Dict[str, Any]:
-    ''' Adds variables to the template context. '''
-    return {'Permission': Permission}
-
-
 @app.shell_context_processor
 def make_shell_context() -> Dict[str, Any]:
     ''' Adds variables to the shell context. '''
     return {
+        'db': db,
         'Avatar': Avatar,
         'Corpus': Corpus,
         'CorpusFile': CorpusFile,
         'CorpusFollowerAssociation': CorpusFollowerAssociation,
-        'db': db,
+        'CorpusFollowerRole': CorpusFollowerRole,
         'Job': Job,
         'JobInput': JobInput,
         'JobResult': JobResult,
-        'Permission': Permission,
         'Role': Role,
         'TesseractOCRPipelineModel': TesseractOCRPipelineModel,
         'SpaCyNLPPipelineModel': SpaCyNLPPipelineModel,
-- 
GitLab