Skip to content
Snippets Groups Projects
models.py 48.7 KiB
Newer Older
from datetime import datetime, timedelta
from enum import Enum, IntEnum
from flask import current_app, url_for
from flask_hashids import HashidMixin
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
Patrick Jentsch's avatar
Patrick Jentsch committed
from werkzeug.utils import secure_filename
import jwt
Patrick Jentsch's avatar
Patrick Jentsch committed
import secrets
Patrick Jentsch's avatar
Patrick Jentsch committed
from app import db, hashids, login, mail, socketio
from app.converters.vrt import normalize_vrt_file
from app.email import create_message
##############################################################################
# enums                                                                      #
##############################################################################
# region enums
class CorpusStatus(IntEnum):
    UNPREPARED = 1
    SUBMITTED = 2
    QUEUED = 3
    BUILDING = 4
    BUILT = 5
    FAILED = 6
    STARTING_ANALYSIS_SESSION = 7
    RUNNING_ANALYSIS_SESSION = 8
    CANCELING_ANALYSIS_SESSION = 9


class JobStatus(IntEnum):
    INITIALIZING = 1
    SUBMITTED = 2
    QUEUED = 3
    RUNNING = 4
    CANCELING = 5
    CANCELED = 6
    COMPLETED = 7
    FAILED = 8


class Permission(IntEnum):
    '''
    Defines User permissions as integers by the power of 2. User permission
    can be evaluated using the bitwise operator &.
    '''
    ADMINISTRATE = 1
    CONTRIBUTE = 2
    USE_API = 4


class UserSettingJobStatusMailNotificationLevel(IntEnum):
    NONE = 1
    END = 2
    ALL = 3


class ProfilePrivacySettings(IntEnum):
    SHOW_EMAIL = 1
    SHOW_LAST_SEEN = 2
    SHOW_MEMBER_SINCE = 4
# endregion enums


##############################################################################
# mixins                                                                     #
##############################################################################
# region mixins
class FileMixin:
Patrick Jentsch's avatar
Patrick Jentsch committed
    '''
    Mixin for db.Model classes. All file related models should use this.
    '''
    creation_date = db.Column(db.DateTime, default=datetime.utcnow)
    mimetype = db.Column(db.String(255))

    def file_mixin_to_json_serializeable(self, backrefs=False, relationships=False):
        return {
Patrick Jentsch's avatar
Patrick Jentsch committed
            'creation_date': f'{self.creation_date.isoformat()}Z',
            'filename': self.filename,
            'mimetype': self.mimetype
        }
Patrick Jentsch's avatar
Patrick Jentsch committed
    
    @classmethod
    def create(cls, file_storage, **kwargs):
        filename = kwargs.pop('filename', file_storage.filename)
        mimetype = kwargs.pop('mimetype', file_storage.mimetype)
        obj = cls(
            filename=secure_filename(filename),
            mimetype=mimetype,
            **kwargs
        )
        db.session.add(obj)
        db.session.flush(objects=[obj])
        db.session.refresh(obj)
        try:
            file_storage.save(obj.path)
        except (AttributeError, OSError) as e:
            current_app.logger.error(e)
            db.session.rollback()
            raise e
        return obj
##############################################################################
# type_decorators                                                            #
##############################################################################
# region type_decorators
class IntEnumColumn(db.TypeDecorator):
    impl = db.Integer
    def __init__(self, enum_type, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.enum_type = enum_type
    def process_bind_param(self, value, dialect):
        if isinstance(value, self.enum_type) and isinstance(value.value, int):
            return value.value
        elif isinstance(value, int):
            return self.enum_type(value).value
Patrick Jentsch's avatar
Patrick Jentsch committed
        elif isinstance(value, str):
            return self.enum_type[value].value
        else:
            return TypeError()

    def process_result_value(self, value, dialect):
        return self.enum_type(value)


class ContainerColumn(db.TypeDecorator):
    impl = db.String

    def __init__(self, container_type, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.container_type = container_type

    def process_bind_param(self, value, dialect):
        if isinstance(value, self.container_type):
            return json.dumps(value)
Patrick Jentsch's avatar
Patrick Jentsch committed
        elif isinstance(value, str) and isinstance(json.loads(value), self.container_type):
            return value
        else:
            return TypeError()

    def process_result_value(self, value, dialect):
        return json.loads(value)
# endregion type_decorators


##############################################################################
# Models                                                                     #
##############################################################################
# region models
class Role(HashidMixin, db.Model):
Patrick Jentsch's avatar
Patrick Jentsch committed
    # Primary key
    id = db.Column(db.Integer, primary_key=True)
Stephan Porada's avatar
Stephan Porada committed
    # Fields
Patrick Jentsch's avatar
Patrick Jentsch committed
    name = db.Column(db.String(64), unique=True)
Patrick Jentsch's avatar
Patrick Jentsch committed
    default = db.Column(db.Boolean, default=False, index=True)
    permissions = db.Column(db.Integer, default=0)
Patrick Jentsch's avatar
Patrick Jentsch committed
    # Relationships
    users = db.relationship('User', backref='role', lazy='dynamic')

        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 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': self.permissions
        }
        if relationships:
            json_serializeable['users'] = {
                x.hashid: x.to_json_serializeable(relationships=True)
                for x in self.users
            }
        return json_serializeable
        roles = {
            'User': [],
            'API user': [Permission.USE_API],
            'Contributor': [Permission.CONTRIBUTE],
            'Administrator': [
                Permission.ADMINISTRATE,
                Permission.CONTRIBUTE,
                Permission.USE_API
Patrick Jentsch's avatar
Patrick Jentsch committed
            ],
            'System user': []
        }
        default_role_name = 'User'
        for role_name, permissions in roles.items():
            role = Role.query.filter_by(name=role_name).first()
                role = Role(name=role_name)
            for permission in permissions:
                role.add_permission(permission)
            role.default = role.name == default_role_name
Patrick Jentsch's avatar
Patrick Jentsch committed

Patrick Jentsch's avatar
Patrick Jentsch committed
class Token(db.Model):
    __tablename__ = 'tokens'
    # Primary key
    id = db.Column(db.Integer, primary_key=True)
    # Foreign keys
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    # Fields
    access_token = db.Column(db.String(64), index=True)
    access_expiration = db.Column(db.DateTime)
    refresh_token = db.Column(db.String(64), index=True)
    refresh_expiration = db.Column(db.DateTime)
    # Backrefs: user: User

    def expire(self):
        self.access_expiration = datetime.utcnow()
        self.refresh_expiration = datetime.utcnow()

    @staticmethod
    def clean():
        """Remove any tokens that have been expired for more than a day."""
        yesterday = datetime.utcnow() - timedelta(days=1)
        Token.query.filter(Token.refresh_expiration < yesterday).delete()

Patrick Jentsch's avatar
Patrick Jentsch committed

class Avatar(HashidMixin, FileMixin, db.Model):
    __tablename__ = 'avatars'
    # Primary key
    id = db.Column(db.Integer, primary_key=True)
    # Foreign keys
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    
    @property
    def path(self):
        return os.path.join(self.user.path, 'avatar')
Patrick Jentsch's avatar
Patrick Jentsch committed

    def delete(self):
        try:
            os.remove(self.path)
        except OSError as e:
            current_app.logger.error(e)
        db.session.delete(self)
Patrick Jentsch's avatar
Patrick Jentsch committed

Inga Kirschnick's avatar
Inga Kirschnick committed
    def to_json_serializeable(self, backrefs=False, relationships=False):
        json_serializeable = {
            'id': self.hashid,
            **self.file_mixin_to_json_serializeable()
Inga Kirschnick's avatar
Inga Kirschnick committed
        }
        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 User(HashidMixin, UserMixin, db.Model):
Patrick Jentsch's avatar
Patrick Jentsch committed
    # Primary key
    id = db.Column(db.Integer, primary_key=True)
    # Foreign keys
    role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
Stephan Porada's avatar
Stephan Porada committed
    # Fields
Patrick Jentsch's avatar
Patrick Jentsch committed
    email = db.Column(db.String(254), index=True, unique=True)
    username = db.Column(db.String(64), index=True, unique=True)
    password_hash = db.Column(db.String(128))
Patrick Jentsch's avatar
Patrick Jentsch committed
    confirmed = db.Column(db.Boolean, default=False)
    member_since = db.Column(db.DateTime(), default=datetime.utcnow)
    setting_job_status_mail_notification_level = db.Column(
        IntEnumColumn(UserSettingJobStatusMailNotificationLevel),
        default=UserSettingJobStatusMailNotificationLevel.END
Patrick Jentsch's avatar
Patrick Jentsch committed
    last_seen = db.Column(db.DateTime())
Inga Kirschnick's avatar
Inga Kirschnick committed
    full_name = db.Column(db.String(64))
    about_me = db.Column(db.String(256))
    location = db.Column(db.String(64))
    website = db.Column(db.String(128))
    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
Patrick Jentsch's avatar
Patrick Jentsch committed
    # Relationships
    avatar = db.relationship(
        'Avatar',
        backref='user',
        cascade='all, delete-orphan',
        uselist=False
    )
    tesseract_ocr_pipeline_models = db.relationship(
        'TesseractOCRPipelineModel',
        backref='user',
        cascade='all, delete-orphan',
        lazy='dynamic'
    )
    spacy_nlp_pipeline_models = db.relationship(
        'SpaCyNLPPipelineModel',
        backref='user',
        cascade='all, delete-orphan',
        lazy='dynamic'
    )
    corpora = db.relationship(
        'Corpus',
        backref='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'
    )
    jobs = db.relationship(
        'Job',
        backref='user',
        cascade='all, delete-orphan',
        lazy='dynamic'
    )
Patrick Jentsch's avatar
Patrick Jentsch committed
    tokens = db.relationship(
        'Token',
        backref='user',
        cascade='all, delete-orphan',
        lazy='dynamic'
    )
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if self.role is not None:
            return
        if self.email == current_app.config['NOPAQUE_ADMIN']:
            self.role = Role.query.filter_by(name='Administrator').first()
        else:
            self.role = Role.query.filter_by(default=True).first()

    def __repr__(self):
        return f'<User {self.username}>'
    def jsonpatch_path(self):
        return f'/users/{self.hashid}'

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute')

    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password)

    @property
    def path(self):
        return os.path.join(
            current_app.config.get('NOPAQUE_DATA_DIR'), 'users', str(self.id))

Patrick Jentsch's avatar
Patrick Jentsch committed
    @staticmethod
    def create(**kwargs):
        user = User(**kwargs)
        db.session.add(user)
        db.session.flush(objects=[user])
        db.session.refresh(user)
        try:
            os.mkdir(user.path)
            os.mkdir(os.path.join(user.path, 'spacy_nlp_pipeline_models'))
            os.mkdir(os.path.join(user.path, 'tesseract_ocr_pipeline_models'))
Patrick Jentsch's avatar
Patrick Jentsch committed
            os.mkdir(os.path.join(user.path, 'corpora'))
            os.mkdir(os.path.join(user.path, 'jobs'))
        except OSError as e:
            current_app.logger.error(e)
            db.session.rollback()
            raise e
        return user

    @staticmethod
    def insert_defaults():
        nopaque_user = User.query.filter_by(username='nopaque').first()
        system_user_role = Role.query.filter_by(name='System user').first()
        if nopaque_user is None:
            nopaque_user = User.create(
                username='nopaque',
                role=system_user_role
            )
            db.session.add(nopaque_user)
        elif nopaque_user.role != system_user_role:
            nopaque_user.role = system_user_role
        db.session.commit()

    @staticmethod
    def reset_password(token, new_password):
        try:
            payload = jwt.decode(
                token,
                current_app.config['SECRET_KEY'],
                algorithms=['HS256'],
                issuer=current_app.config['SERVER_NAME'],
                options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
            )
        except jwt.PyJWTError:
            return False
        if payload.get('purpose') != 'User.reset_password':
            return False
        user_hashid = payload.get('sub')
        user_id = hashids.decode(user_hashid)
        user = User.query.get(user_id)
        if user is None:
            return False
        user.password = new_password
        db.session.add(user)
        return True

Patrick Jentsch's avatar
Patrick Jentsch committed
    @staticmethod
    def verify_access_token(access_token, refresh_token=None):
        token = Token.query.filter(Token.access_token == access_token).first()
        if token is not None:
            if token.access_expiration > datetime.utcnow():
                token.user.ping()
                db.session.commit()
                if token.user.role.name != 'System user':
                    return token.user

    @staticmethod
    def verify_refresh_token(refresh_token, access_token):
        token = Token.query.filter((Token.refresh_token == refresh_token) & (Token.access_token == access_token)).first()
        if token is not None:
            if token.refresh_expiration > datetime.utcnow():
                return token
            # someone tried to refresh with an expired token
            # revoke all tokens from this user as a precaution
            token.user.revoke_auth_tokens()
            db.session.commit()

    def can(self, permission):
        return self.role.has_permission(permission)
Patrick Jentsch's avatar
Patrick Jentsch committed
    def confirm(self, confirmation_token):
Stephan Porada's avatar
Stephan Porada committed
        try:
            payload = jwt.decode(
Patrick Jentsch's avatar
Patrick Jentsch committed
                confirmation_token,
                current_app.config['SECRET_KEY'],
                algorithms=['HS256'],
                issuer=current_app.config['SERVER_NAME'],
                options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
            )
Patrick Jentsch's avatar
Patrick Jentsch committed
            current_app.logger.warning(payload)
        except jwt.PyJWTError:
Stephan Porada's avatar
Stephan Porada committed
            return False
Patrick Jentsch's avatar
Patrick Jentsch committed
        if payload.get('purpose') != 'user.confirm':
            return False
Patrick Jentsch's avatar
Patrick Jentsch committed
        if payload.get('sub') != self.hashid:
Stephan Porada's avatar
Stephan Porada committed
            return False
        self.confirmed = True
        db.session.add(self)
        return True

Patrick Jentsch's avatar
Patrick Jentsch committed
    def delete(self):
        shutil.rmtree(self.path, ignore_errors=True)
        db.session.delete(self)
Patrick Jentsch's avatar
Patrick Jentsch committed
    def generate_auth_token(self):
        return Token(
            access_token=secrets.token_urlsafe(),
            access_expiration=datetime.utcnow() + timedelta(minutes=15),
            refresh_token=secrets.token_urlsafe(),
            refresh_expiration=datetime.utcnow() + timedelta(days=7),
            user=self
        )

Patrick Jentsch's avatar
Patrick Jentsch committed
    def generate_confirm_token(self, expiration=3600):
        now = datetime.utcnow()
        payload = {
Patrick Jentsch's avatar
Patrick Jentsch committed
            'exp': now + timedelta(seconds=expiration),
            'iat': now,
            'iss': current_app.config['SERVER_NAME'],
Patrick Jentsch's avatar
Patrick Jentsch committed
            'purpose': 'user.confirm',
Patrick Jentsch's avatar
Patrick Jentsch committed
            'sub': self.hashid
Patrick Jentsch's avatar
Patrick Jentsch committed
        return jwt.encode(
            payload,
            current_app.config['SECRET_KEY'],
            algorithm='HS256'
        )
Patrick Jentsch's avatar
Patrick Jentsch committed
    def generate_reset_password_token(self, expiration=3600):
        now = datetime.utcnow()
        payload = {
Patrick Jentsch's avatar
Patrick Jentsch committed
            'exp': now + timedelta(seconds=expiration),
            'iat': now,
            'iss': current_app.config['SERVER_NAME'],
Patrick Jentsch's avatar
Patrick Jentsch committed
            'purpose': 'User.reset_password',
Patrick Jentsch's avatar
Patrick Jentsch committed
            'sub': self.hashid
Patrick Jentsch's avatar
Patrick Jentsch committed
        return jwt.encode(
            payload,
            current_app.config['SECRET_KEY'],
            algorithm='HS256'
        )
    def is_administrator(self):
        return self.can(Permission.ADMINISTRATE)

Patrick Jentsch's avatar
Patrick Jentsch committed
    def ping(self):
        self.last_seen = datetime.utcnow()
Patrick Jentsch's avatar
Patrick Jentsch committed
    def revoke_auth_tokens(self):
        for token in self.tokens:
            db.session.delete(token)

Patrick Jentsch's avatar
Patrick Jentsch committed
    def verify_password(self, password):
        if self.role.name == 'System user':
            return False
        return check_password_hash(self.password_hash, password)
    #region Profile Privacy settings
    def has_profile_privacy_setting(self, setting):
        return self.profile_privacy_settings & setting == setting
    
    def add_profile_privacy_setting(self, setting):
        if not self.has_profile_privacy_setting(setting):
            self.profile_privacy_settings += setting

    def remove_profile_privacy_setting(self, setting):
        if self.has_profile_privacy_setting(setting):
            self.profile_privacy_settings -= setting

    def reset_profile_privacy_settings(self):
        self.profile_privacy_settings = 0
    #endregion Profile Privacy settings

    def follow_corpus(self, corpus):
        if not self.is_following(corpus):
            self.followed_corpora.append(corpus)

    def unfollow_corpus(self, corpus):
        if self.is_following(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 to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False):
        json_serializeable = {
            'id': self.hashid,
            'confirmed': self.confirmed,
            'email': self.email,
Patrick Jentsch's avatar
Patrick Jentsch committed
            'last_seen': (
                None if self.last_seen is None
                else self.last_seen.strftime('%Y-%m-%d %H:%M')
Patrick Jentsch's avatar
Patrick Jentsch committed
            ),
            'member_since': self.member_since.strftime('%Y-%m-%d'),
            'username': self.username,
            'full_name': self.full_name,
            'about_me': self.about_me,
            'website': self.website,
            'location': self.location,
            'organization': self.organization,
            'job_status_mail_notification_level': \
                    self.setting_job_status_mail_notification_level.name,
            'is_public': self.is_public,
            'show_email': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL),
            'show_last_seen': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_LAST_SEEN),
            'show_member_since': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_MEMBER_SINCE)
        json_serializeable['avatar'] = (
            None if self.avatar is None
            else self.avatar.to_json_serializeable(relationships=True)
        )
        if backrefs:
            json_serializeable['role'] = \
                self.role.to_json_serializeable(backrefs=True)
        if relationships:
            json_serializeable['corpora'] = {
                x.hashid: x.to_json_serializeable(relationships=True)
                for x in self.corpora
            }
            json_serializeable['jobs'] = {
                x.hashid: x.to_json_serializeable(relationships=True)
                for x in self.jobs
Loading
Loading full blame...