From 97bba2498bc65ab01c9102acd634c45b4f0dbd1c Mon Sep 17 00:00:00 2001
From: Patrick Jentsch <p.jentsch@uni-bielefeld.de>
Date: Mon, 23 Jan 2023 11:50:12 +0100
Subject: [PATCH] Add m2m corpus-follower table

---
 app/models.py                        | 162 ++++++++++++++-------------
 migrations/versions/4aa88f253dab_.py |  31 -----
 migrations/versions/5fe6a6c7870c_.py |  36 ++++++
 migrations/versions/7d51bc4b6079_.py |  31 -----
 nopaque.py                           |   2 +
 5 files changed, 122 insertions(+), 140 deletions(-)
 delete mode 100644 migrations/versions/4aa88f253dab_.py
 create mode 100644 migrations/versions/5fe6a6c7870c_.py
 delete mode 100644 migrations/versions/7d51bc4b6079_.py

diff --git a/app/models.py b/app/models.py
index 4281fc55..af3d1cfc 100644
--- a/app/models.py
+++ b/app/models.py
@@ -3,6 +3,7 @@ from enum import Enum, IntEnum
 from flask import current_app, url_for
 from flask_hashids import HashidMixin
 from flask_login import UserMixin
+from sqlalchemy.ext.associationproxy import association_proxy
 from time import sleep
 from tqdm import tqdm
 from werkzeug.security import generate_password_hash, check_password_hash
@@ -169,7 +170,7 @@ class Role(HashidMixin, db.Model):
     default = db.Column(db.Boolean, default=False, index=True)
     permissions = db.Column(db.Integer, default=0)
     # Relationships
-    users = db.relationship('User', backref='role', lazy='dynamic')
+    users = db.relationship('User', back_populates='role', lazy='dynamic')
 
     def __repr__(self):
         return f'<Role {self.name}>'
@@ -239,7 +240,8 @@ class Token(db.Model):
     access_expiration = db.Column(db.DateTime)
     refresh_token = db.Column(db.String(64), index=True)
     refresh_expiration = db.Column(db.DateTime)
-    # Backrefs: user: User
+    # Relationships
+    user = db.relationship('User', back_populates='tokens')
 
     def expire(self):
         self.access_expiration = datetime.utcnow()
@@ -258,7 +260,9 @@ class Avatar(HashidMixin, FileMixin, db.Model):
     id = db.Column(db.Integer, primary_key=True)
     # Foreign keys
     user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
-    
+    # Relationships
+    user = db.relationship('User', back_populates='avatar')
+
     @property
     def path(self):
         return os.path.join(self.user.path, 'avatar')
@@ -278,19 +282,22 @@ class Avatar(HashidMixin, FileMixin, db.Model):
         return json_serializeable
 
 
-corpus_followers = db.Table(
-    'corpus_followers',
-    db.Model.metadata,
-    db.Column('user_id', db.ForeignKey('users.id'), primary_key=True),
-    db.Column('corpus_id', db.ForeignKey('corpora.id'), primary_key=True)
-)
+class CorpusFollowerAssociation(db.Model):
+    __tablename__ = 'corpus_follower_associations'
+    # Primary key
+    id = db.Column(db.Integer, primary_key=True)
+    # Foreign keys
+    following_user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
+    followed_corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id'))
+    # Fields
+    permissions = db.Column(db.Integer, default=0, nullable=False)
+    # Relationships
+    followed_corpus = db.relationship('Corpus', back_populates='following_user_associations')
+    following_user = db.relationship('User', back_populates='followed_corpus_associations')
+
+    def __repr__(self):
+        return f'<CorpusFollowerAssociation {self.following_user.__repr__()} ~ {self.followed_corpus.__repr__()}>'
 
-user_followers = db.Table(
-    'user_followers',
-    db.Model.metadata,
-    db.Column('follower_user_id', db.ForeignKey('users.id'), primary_key=True),
-    db.Column('followed_user_id', db.ForeignKey('users.id'), primary_key=True)
-)
 
 class User(HashidMixin, UserMixin, db.Model):
     __tablename__ = 'users'
@@ -316,56 +323,53 @@ class User(HashidMixin, UserMixin, db.Model):
     organization = db.Column(db.String(128))
     is_public = db.Column(db.Boolean, default=False)
     profile_privacy_settings = db.Column(db.Integer(), default=0)
-    # Backrefs: role: Role
     # Relationships
     avatar = db.relationship(
         'Avatar',
-        backref='user',
+        back_populates='user',
         cascade='all, delete-orphan',
         uselist=False
     )
-    tesseract_ocr_pipeline_models = db.relationship(
-        'TesseractOCRPipelineModel',
-        backref='user',
+    corpora = db.relationship(
+        'Corpus',
+        back_populates='user',
         cascade='all, delete-orphan',
         lazy='dynamic'
     )
-    spacy_nlp_pipeline_models = db.relationship(
-        'SpaCyNLPPipelineModel',
-        backref='user',
-        cascade='all, delete-orphan',
-        lazy='dynamic'
+    followed_corpus_associations = db.relationship(
+        'CorpusFollowerAssociation',
+        back_populates='following_user'
     )
-    corpora = db.relationship(
-        'Corpus',
-        backref='user',
+    followed_corpora = association_proxy(
+        'followed_corpus_associations',
+        'followed_corpus',
+        creator=lambda c: CorpusFollowerAssociation(followed_corpus=c)
+    )
+    jobs = db.relationship(
+        'Job',
+        back_populates='user',
         cascade='all, delete-orphan',
         lazy='dynamic'
     )
-    followed_corpora = db.relationship(
-        'Corpus',
-        secondary=corpus_followers,
-        primaryjoin=(corpus_followers.c.user_id == id),
-        backref=db.backref('followers', lazy='dynamic'),
-        lazy='dynamic'
+    role = db.relationship(
+        'Role',
+        back_populates='users'
     )
-    followed_users = db.relationship(
-        'User',
-        secondary=user_followers,
-        primaryjoin=(user_followers.c.follower_user_id == id),
-        secondaryjoin=(user_followers.c.followed_user_id == id),
-        backref=db.backref('followers', lazy='dynamic'),
+    spacy_nlp_pipeline_models = db.relationship(
+        'SpaCyNLPPipelineModel',
+        back_populates='user',
+        cascade='all, delete-orphan',
         lazy='dynamic'
     )
-    jobs = db.relationship(
-        'Job',
-        backref='user',
+    tesseract_ocr_pipeline_models = db.relationship(
+        'TesseractOCRPipelineModel',
+        back_populates='user',
         cascade='all, delete-orphan',
         lazy='dynamic'
     )
     tokens = db.relationship(
         'Token',
-        backref='user',
+        back_populates='user',
         cascade='all, delete-orphan',
         lazy='dynamic'
     )
@@ -572,30 +576,6 @@ class User(HashidMixin, UserMixin, db.Model):
         self.profile_privacy_settings = 0
     #endregion Profile Privacy settings
 
-    def follow_corpus(self, corpus):
-        if not self.is_following_corpus(corpus):
-            self.followed_corpora.append(corpus)
-
-    def unfollow_corpus(self, corpus):
-        if self.is_following_corpus(corpus):
-            self.followed_corpora.remove(corpus)
-    
-    def is_following_corpus(self, corpus):
-        return self.followed_corpora.filter(
-            corpus_followers.c.corpus_id == corpus.id).count() > 0
-
-    def follow_user(self, user):
-        if not self.is_following_user(user):
-            self.followed_users.append(user)
-
-    def unfollow_user(self, user):
-        if self.is_following_user(user):
-            self.followed_users.remove(user)
-    
-    def is_following_user(self, user):
-        return self.followed_users.filter(
-            user_followers.c.followed_user_id == user.id).count() > 0
-
     def to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False):
         json_serializeable = {
             'id': self.hashid,
@@ -670,7 +650,8 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model):
     publishing_url = db.Column(db.String(512))
     publishing_year = db.Column(db.Integer)
     is_public = db.Column(db.Boolean, default=False)
-    # Backrefs: user: User
+    # Relationships
+    user = db.relationship('User', back_populates='tesseract_ocr_pipeline_models')
 
     @property
     def path(self):
@@ -794,7 +775,8 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model):
     publishing_year = db.Column(db.Integer)
     pipeline_name = db.Column(db.String(64))
     is_public = db.Column(db.Boolean, default=False)
-    # Backrefs: user: User
+    # Relationships
+    user = db.relationship('User', back_populates='spacy_nlp_pipeline_models')
 
     @property
     def path(self):
@@ -909,7 +891,11 @@ class JobInput(FileMixin, HashidMixin, db.Model):
     id = db.Column(db.Integer, primary_key=True)
     # Foreign keys
     job_id = db.Column(db.Integer, db.ForeignKey('jobs.id'))
-    # Backrefs: job: Job
+    # Relationships
+    job = db.relationship(
+        'Job',
+        back_populates='inputs'
+    )
 
     def __repr__(self):
         return f'<JobInput {self.filename}>'
@@ -965,7 +951,11 @@ class JobResult(FileMixin, HashidMixin, db.Model):
     job_id = db.Column(db.Integer, db.ForeignKey('jobs.id'))
     # Fields
     description = db.Column(db.String(255))
-    # Backrefs: job: Job
+    # Relationships
+    job = db.relationship(
+        'Job',
+        back_populates='results'
+    )
 
     def __repr__(self):
         return f'<JobResult {self.filename}>'
@@ -1039,20 +1029,23 @@ class Job(HashidMixin, db.Model):
         default=JobStatus.INITIALIZING
     )
     title = db.Column(db.String(32))
-    # Backrefs: user: User
     # Relationships
     inputs = db.relationship(
         'JobInput',
-        backref='job',
+        back_populates='job',
         cascade='all, delete-orphan',
         lazy='dynamic'
     )
     results = db.relationship(
         'JobResult',
-        backref='job',
+        back_populates='job',
         cascade='all, delete-orphan',
         lazy='dynamic'
     )
+    user = db.relationship(
+        'User',
+        back_populates='jobs'
+    )
 
     def __repr__(self):
         return f'<Job {self.title}>'
@@ -1173,7 +1166,11 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
     pages = db.Column(db.String(255))
     publisher = db.Column(db.String(255))
     school = db.Column(db.String(255))
-    # Backrefs: corpus: Corpus
+    # Relationships
+    corpus = db.relationship(
+        'Corpus',
+        back_populates='files'
+    )
 
     @property
     def download_url(self):
@@ -1262,14 +1259,23 @@ class Corpus(HashidMixin, db.Model):
     num_analysis_sessions = db.Column(db.Integer, default=0)
     num_tokens = db.Column(db.Integer, default=0)
     is_public = db.Column(db.Boolean, default=False)
-    # Backrefs: user: User
     # Relationships
     files = db.relationship(
         'CorpusFile',
-        backref='corpus',
+        back_populates='corpus',
         lazy='dynamic',
         cascade='all, delete-orphan'
     )
+    following_user_associations = db.relationship(
+        'CorpusFollowerAssociation',
+        back_populates='followed_corpus'
+    )
+    following_users = association_proxy(
+        'following_user_associations',
+        'following_user',
+        creator=lambda u: CorpusFollowerAssociation(following_user=u)
+    )
+    user = db.relationship('User', back_populates='corpora')
     # "static" attributes
     max_num_tokens = 2_147_483_647
 
diff --git a/migrations/versions/4aa88f253dab_.py b/migrations/versions/4aa88f253dab_.py
deleted file mode 100644
index 2351532c..00000000
--- a/migrations/versions/4aa88f253dab_.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Add corpus_followers table
-
-Revision ID: 4aa88f253dab
-Revises: 5b2a6e590166
-Create Date: 2023-01-12 14:47:39.492875
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '4aa88f253dab'
-down_revision = '5b2a6e590166'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    op.create_table(
-        'corpus_followers',
-        sa.Column('user_id', sa.Integer(), nullable=False),
-        sa.Column('corpus_id', sa.Integer(), nullable=False),
-        sa.ForeignKeyConstraint(['corpus_id'], ['corpora.id'], ),
-        sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
-        sa.PrimaryKeyConstraint('user_id', 'corpus_id')
-    )
-
-
-def downgrade():
-    op.drop_table('corpus_followers')
diff --git a/migrations/versions/5fe6a6c7870c_.py b/migrations/versions/5fe6a6c7870c_.py
new file mode 100644
index 00000000..2bc02206
--- /dev/null
+++ b/migrations/versions/5fe6a6c7870c_.py
@@ -0,0 +1,36 @@
+"""empty message
+
+Revision ID: 5fe6a6c7870c
+Revises: 5b2a6e590166
+Create Date: 2023-01-23 08:27:10.169454
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '5fe6a6c7870c'
+down_revision = '5b2a6e590166'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('corpus_follower_associations',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('following_user_id', sa.Integer(), nullable=True),
+    sa.Column('followed_corpus_id', sa.Integer(), nullable=True),
+    sa.Column('permissions', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['followed_corpus_id'], ['corpora.id'], ),
+    sa.ForeignKeyConstraint(['following_user_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('corpus_follower_associations')
+    # ### end Alembic commands ###
diff --git a/migrations/versions/7d51bc4b6079_.py b/migrations/versions/7d51bc4b6079_.py
deleted file mode 100644
index df825ad1..00000000
--- a/migrations/versions/7d51bc4b6079_.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Add user_followers table
-
-Revision ID: 7d51bc4b6079
-Revises: 4aa88f253dab
-Create Date: 2023-01-17 12:48:33.261942
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '7d51bc4b6079'
-down_revision = '4aa88f253dab'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    op.create_table(
-        'user_followers',
-        sa.Column('follower_user_id', sa.Integer(), nullable=False),
-        sa.Column('followed_user_id', sa.Integer(), nullable=False),
-        sa.ForeignKeyConstraint(['followed_user_id'], ['users.id'], ),
-        sa.ForeignKeyConstraint(['follower_user_id'], ['users.id'], ),
-        sa.PrimaryKeyConstraint('follower_user_id', 'followed_user_id')
-    )
-
-
-def downgrade():
-    op.drop_table('user_followers')
diff --git a/nopaque.py b/nopaque.py
index 6ecb2cf0..602954cc 100644
--- a/nopaque.py
+++ b/nopaque.py
@@ -8,6 +8,7 @@ from app.models import (
     Avatar,
     Corpus,
     CorpusFile,
+    CorpusFollowerAssociation,
     Job,
     JobInput,
     JobResult,
@@ -38,6 +39,7 @@ def make_shell_context() -> Dict[str, Any]:
         'Avatar': Avatar,
         'Corpus': Corpus,
         'CorpusFile': CorpusFile,
+        'CorpusFollowerAssociation': CorpusFollowerAssociation,
         'db': db,
         'Job': Job,
         'JobInput': JobInput,
-- 
GitLab