from datetime import datetime from flask import current_app from flask_login import UserMixin, AnonymousUserMixin from itsdangerous import BadSignature, TimedJSONWebSignatureSerializer from werkzeug.security import generate_password_hash, check_password_hash from . import db from . import login_manager import os import shutil import logging import xml.etree.ElementTree as ET class Permission: """ Defines User permissions as integers by the power of 2. User permission can be evaluated using the bitwise operator &. 3 equals to CREATE_JOB and DELETE_JOB and so on. """ CREATE_JOB = 1 DELETE_JOB = 2 # WRITE = 4 # MODERATE = 8 ADMIN = 16 class Role(db.Model): """ Model for the different roles Users can have. Is a one-to-many relationship. A Role can be associated with many User rows. """ __tablename__ = 'roles' # Primary key id = db.Column(db.Integer, primary_key=True) default = db.Column(db.Boolean, default=False, index=True) name = db.Column(db.String(64), unique=True) permissions = db.Column(db.Integer) # Relationships users = db.relationship('User', backref='role', lazy='dynamic') def __init__(self, **kwargs): super(Role, self).__init__(**kwargs) if self.permissions is None: self.permissions = 0 def __repr__(self): """ String representation of the Role. For human readability. """ return '' % self.name def add_permission(self, perm): """ Add new permission to Role. Input is a Permission. """ if not self.has_permission(perm): self.permissions += perm def remove_permission(self, perm): """ Removes permission from a Role. Input a Permission. """ if self.has_permission(perm): self.permissions -= perm def reset_permissions(self): """ Resets permissions to zero. Zero equals no permissions at all. """ self.permissions = 0 def has_permission(self, perm): """ Checks if a Role has a specific Permission. Does this with the bitwise operator. """ return self.permissions & perm == perm @staticmethod def insert_roles(): """ Inserts roles into the databes. This has to be executed befor Users are added to the database. Otherwiese Users will not have a Role assigned to them. Order of the roles dictionary determines the ID of each role. User hast the ID 1 and Administrator has the ID 2. """ roles = { 'User': [Permission.CREATE_JOB], 'Administrator': [Permission.ADMIN, Permission.CREATE_JOB, Permission.DELETE_JOB] } default_role = 'User' for r in roles: role = Role.query.filter_by(name=r).first() if role is None: role = Role(name=r) role.reset_permissions() for perm in roles[r]: role.add_permission(perm) role.default = (role.name == default_role) db.session.add(role) db.session.commit() class User(UserMixin, db.Model): """ Model for Users that are registered to Opaque. """ __tablename__ = 'users' # Primary key id = db.Column(db.Integer, primary_key=True) confirmed = db.Column(db.Boolean, default=False) email = db.Column(db.String(254), unique=True, index=True) password_hash = db.Column(db.String(128)) registration_date = db.Column(db.DateTime(), default=datetime.utcnow) role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) username = db.Column(db.String(64), unique=True, index=True) # Relationships corpora = db.relationship('Corpus', backref='creator', lazy='dynamic', cascade='save-update, merge, delete') jobs = db.relationship('Job', backref='creator', lazy='dynamic', cascade='save-update, merge, delete') is_dark = db.Column(db.Boolean, default=False) def __repr__(self): """ String representation of the User. For human readability. """ return '' % self.username def __init__(self, **kwargs): super(User, self).__init__(**kwargs) if self.role is None: if self.email == current_app.config['OPAQUE_ADMIN']: self.role = Role.query.filter_by(name='Administrator').first() if self.role is None: self.role = Role.query.filter_by(default=True).first() def generate_confirmation_token(self, expiration=3600): """ Generates a confirmation token for user confirmation via email. """ s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], expiration) return s.dumps({'confirm': self.id}).decode('utf-8') def generate_reset_token(self, expiration=3600): """ Generates a reset token for password reset via email. """ s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], expiration) return s.dumps({'reset': self.id}).decode('utf-8') def confirm(self, token): """ Confirms User if the given token is valid and not expired. """ s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) try: data = s.loads(token.encode('utf-8')) except BadSignature: return False if data.get('confirm') != self.id: return False self.confirmed = True db.session.add(self) return True @staticmethod def reset_password(token, new_password): """ Resets password for User if the given token is valid and not expired. """ s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) try: data = s.loads(token.encode('utf-8')) except BadSignature: return False user = User.query.get(data.get('reset')) if user is None: return False user.password = new_password db.session.add(user) return True @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) def verify_password(self, password): return check_password_hash(self.password_hash, password) def can(self, perm): """ Checks if a User with its current role can doe something. Checks if the associated role actually has the needed Permission. """ return self.role is not None and self.role.has_permission(perm) def is_administrator(self): """ Checks if User has Admin permissions. """ return self.can(Permission.ADMIN) def corpora_as_dict(self): corpora = {} for corpus in self.corpora: corpora[str(corpus.id)] = corpus.to_dict() return corpora def jobs_as_dict(self): jobs = {} for job in self.jobs: jobs[str(job.id)] = job.to_dict() return jobs def delete_user(self): """ Delete user from database. Also delete all associated jobs and corpora files. """ logger = logging.getLogger(__name__) delete_path = os.path.join('/mnt/opaque/', str(self.id)) logger.warning('Delete path for user is: {}'.format(delete_path)) while os.path.exists(delete_path): try: shutil.rmtree(delete_path, ignore_errors=True) logger.warning('Path does still exist.') except OSError: pass db.session.delete(self) db.session.commit() class AnonymousUser(AnonymousUserMixin): """ Model replaces the default AnonymousUser. """ def can(self, permissions): return False def is_administrator(self): return False class JobInput(db.Model): """ Class to define JobInputs. """ __tablename__ = 'job_inputs' # Primary key id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(255)) dir = db.Column(db.String(255)) job_id = db.Column(db.Integer, db.ForeignKey('jobs.id')) # Relationships results = db.relationship('JobResult', backref='job_input', lazy='dynamic', cascade='save-update, merge, delete') def __repr__(self): """ String representation of the JobInput. For human readability. """ return '' % self.filename def to_dict(self): return {'id': self.id, 'dir': self.dir, 'filename': self.filename, 'job_id': self.job_id, 'results': [result.to_dict() for result in self.results]} class JobResult(db.Model): """ Class to define JobResults. """ __tablename__ = 'job_results' # Primary key id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(255)) dir = db.Column(db.String(255)) job_id = db.Column(db.Integer, db.ForeignKey('jobs.id')) job_input_id = db.Column(db.Integer, db.ForeignKey('job_inputs.id')) def __repr__(self): """ String representation of the JobResult. For human readability. """ return '' % self.filename def to_dict(self): return {'id': self.id, 'dir': self.dir, 'filename': self.filename, 'job_id': self.job_id, 'job_input_id': self.job_input_id} class Job(db.Model): """ Class to define Jobs. """ __tablename__ = 'jobs' # Primary key id = db.Column(db.Integer, primary_key=True) creation_date = db.Column(db.DateTime(), default=datetime.utcnow) description = db.Column(db.String(255)) end_date = db.Column(db.DateTime()) mem_mb = db.Column(db.Integer) n_cores = db.Column(db.Integer) service = db.Column(db.String(64)) ''' ' Service specific arguments as string list. ' Example: ["-l eng", "--keep-intermediates", "--skip-binarization"] ''' service_args = db.Column(db.String(255)) service_version = db.Column(db.String(16)) status = db.Column(db.String(16)) title = db.Column(db.String(32)) user_id = db.Column(db.Integer, db.ForeignKey('users.id')) # Relationships inputs = db.relationship('JobInput', backref='job', lazy='dynamic', cascade='save-update, merge, delete') results = db.relationship('JobResult', backref='job', lazy='dynamic', cascade='save-update, merge, delete') def __init__(self, **kwargs): super(Job, self).__init__(**kwargs) def __repr__(self): """ String representation of the Job. For human readability. """ return '' % self.title def to_dict(self): return {'id': self.id, 'creation_date': self.creation_date.timestamp(), 'description': self.description, 'end_date': (self.end_date.timestamp() if self.end_date else None), 'inputs': [input.to_dict() for input in self.inputs], 'mem_mb': self.mem_mb, 'n_cores': self.n_cores, 'results': [result.to_dict() for result in self.results], 'service': self.service, 'service_args': self.service_args, 'service_version': self.service_version, 'status': self.status, 'title': self.title, 'user_id': self.user_id} def flag_for_stop(self): """ Flag running or failed job (anything that is not completed) with stopping. Opaque daemon will end services flaged with 'stopping'. """ self.status = 'stopping' db.session.commit() def delete_job(self): """ Delete job with given job id from database. Also delete associated job files. Contianers are still running for a few seconds after the associated service has been removed. This is the reason for the while loop. The loop checks if the file path to all the job files still exists and removes it again and again till the container did shutdown for good. See: https://docs.docker.com/engine/swarm/swarm-tutorial/delete-service/ """ logger = logging.getLogger(__name__) delete_path = os.path.join('/mnt/opaque/', str(self.user_id), 'jobs', str(self.id)) logger.warning('Delete path is: {}'.format(delete_path)) while os.path.exists(delete_path): try: shutil.rmtree(delete_path, ignore_errors=True) logger.warning('Path does still exist.') except OSError: pass db.session.delete(self) db.session.commit() class CorpusFile(db.Model): """ Class to define Files. """ __tablename__ = 'corpus_files' # Primary key id = db.Column(db.Integer, primary_key=True) author = db.Column(db.String(64)) dir = db.Column(db.String(255)) filename = db.Column(db.String(255)) publishing_year = db.Column(db.Integer) title = db.Column(db.String(64)) corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) def delete(self): logger = logging.getLogger(__name__) logger.warning('Called CorpusFile.delete') path = os.path.join(current_app.config['OPAQUE_STORAGE_DIRECTORY'], self.dir, self.filename) try: os.remove(path) except: logger.warning('[ERROR] CorpusFile.delete') return db.session.delete(self) db.session.commit() def insert_metadata(self): file = os.path.join(current_app.config['OPAQUE_STORAGE_DIRECTORY'], self.dir, self.filename) element_tree = ET.parse(file) text_node = element_tree.find('text') text_node.set('author', self.author) text_node.set('publishing_year', str(self.publishing_year)) text_node.set('title', self.title) element_tree.write(file) class Corpus(db.Model): """ Class to define a corpus. """ __tablename__ = 'corpora' # Primary key id = db.Column(db.Integer, primary_key=True) creation_date = db.Column(db.DateTime(), default=datetime.utcnow) description = db.Column(db.String(255)) status = db.Column(db.String(16)) title = db.Column(db.String(32)) user_id = db.Column(db.Integer, db.ForeignKey('users.id')) # Relationships files = db.relationship('CorpusFile', backref='corpus', lazy='dynamic', cascade='save-update, merge, delete') def __repr__(self): """ String representation of the corpus. For human readability. """ return '' % self.title def to_dict(self): return {'id': self.id, 'creation_date': self.creation_date.timestamp(), 'description': self.description, 'title': self.title, 'user_id': self.user_id} def delete(self): logger = logging.getLogger(__name__) logger.warning('Called Corpus.delete') for corpus_file in self.files: corpus_file.delete() logger.warning('bis hierhin und nicht weiter') logger.warning('base_dir: {}'.format(current_app.config['OPAQUE_STORAGE_DIRECTORY'])) logger.warning('user_id: {}'.format(self.user_id)) logger.warning('id: {}'.format(self.id)) path = os.path.join(current_app.config['OPAQUE_STORAGE_DIRECTORY'], str(self.user_id), 'corpora', str(self.id)) logger.warning(path) try: logger.warning('Try to remove {}'.format(path)) shutil.rmtree(path) except: logger.warning('[ERROR] Corpus.delete') return db.session.delete(self) db.session.commit() def prepare(self): pass ''' ' Flask-Login is told to use the application’s custom anonymous user by setting ' its class in the login_manager.anonymous_user attribute. ''' login_manager.anonymous_user = AnonymousUser @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id))