First working mail notification system

This commit is contained in:
Stephan Porada 2020-05-14 15:30:13 +02:00
parent 2b9d098857
commit 7a6cbccbd2
8 changed files with 302 additions and 73 deletions

View File

@ -8,11 +8,11 @@ from . import db, login_manager
class Permission: class Permission:
""" '''
Defines User permissions as integers by the power of 2. User 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 can be evaluated using the bitwise operator &. 3 equals to CREATE_JOB and
DELETE_JOB and so on. DELETE_JOB and so on.
""" '''
MANAGE_CORPORA = 1 MANAGE_CORPORA = 1
MANAGE_JOBS = 2 MANAGE_JOBS = 2
# PERMISSION_NAME = 4 # PERMISSION_NAME = 4
@ -21,10 +21,10 @@ class Permission:
class Role(db.Model): class Role(db.Model):
""" '''
Model for the different roles Users can have. Is a one-to-many Model for the different roles Users can have. Is a one-to-many
relationship. A Role can be associated with many User rows. relationship. A Role can be associated with many User rows.
""" '''
__tablename__ = 'roles' __tablename__ = 'roles'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -47,46 +47,46 @@ class Role(db.Model):
self.permissions = 0 self.permissions = 0
def __repr__(self): def __repr__(self):
""" '''
String representation of the Role. For human readability. String representation of the Role. For human readability.
""" '''
return '<Role %r>' % self.name return '<Role %r>' % self.name
def add_permission(self, perm): def add_permission(self, perm):
""" '''
Add new permission to Role. Input is a Permission. Add new permission to Role. Input is a Permission.
""" '''
if not self.has_permission(perm): if not self.has_permission(perm):
self.permissions += perm self.permissions += perm
def remove_permission(self, perm): def remove_permission(self, perm):
""" '''
Removes permission from a Role. Input a Permission. Removes permission from a Role. Input a Permission.
""" '''
if self.has_permission(perm): if self.has_permission(perm):
self.permissions -= perm self.permissions -= perm
def reset_permissions(self): def reset_permissions(self):
""" '''
Resets permissions to zero. Zero equals no permissions at all. Resets permissions to zero. Zero equals no permissions at all.
""" '''
self.permissions = 0 self.permissions = 0
def has_permission(self, perm): def has_permission(self, perm):
""" '''
Checks if a Role has a specific Permission. Does this with the bitwise Checks if a Role has a specific Permission. Does this with the bitwise
operator. operator.
""" '''
return self.permissions & perm == perm return self.permissions & perm == perm
@staticmethod @staticmethod
def insert_roles(): def insert_roles():
""" '''
Inserts roles into the database. This has to be executed befor Users Inserts roles into the database. This has to be executed befor Users
are added to the database. Otherwiese Users will not have a Role are added to the database. Otherwiese Users will not have a Role
assigned to them. Order of the roles dictionary determines the ID of assigned to them. Order of the roles dictionary determines the ID of
each role. Users have the ID 1 and Administrators have the ID 2. each role. Users have the ID 1 and Administrators have the ID 2.
""" '''
roles = {'User': [Permission.MANAGE_CORPORA, Permission.MANAGE_JOBS], roles = {'User': [Permission.MANAGE_CORPORA, Permission.MANAGE_JOBS],
'Administrator': [Permission.MANAGE_CORPORA, 'Administrator': [Permission.MANAGE_CORPORA,
Permission.MANAGE_JOBS, Permission.ADMIN]} Permission.MANAGE_JOBS, Permission.ADMIN]}
@ -104,9 +104,9 @@ class Role(db.Model):
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
""" '''
Model for Users that are registered to Opaque. Model for Users that are registered to Opaque.
""" '''
__tablename__ = 'users' __tablename__ = 'users'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -148,9 +148,9 @@ class User(UserMixin, db.Model):
'jobs': {job.id: job.to_dict() for job in self.jobs}} 'jobs': {job.id: job.to_dict() for job in self.jobs}}
def __repr__(self): def __repr__(self):
""" '''
String representation of the User. For human readability. String representation of the User. For human readability.
""" '''
return '<User %r>' % self.username return '<User %r>' % self.username
def __init__(self, **kwargs): def __init__(self, **kwargs):
@ -162,25 +162,25 @@ class User(UserMixin, db.Model):
self.role = Role.query.filter_by(default=True).first() self.role = Role.query.filter_by(default=True).first()
def generate_confirmation_token(self, expiration=3600): def generate_confirmation_token(self, expiration=3600):
""" '''
Generates a confirmation token for user confirmation via email. Generates a confirmation token for user confirmation via email.
""" '''
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'],
expiration) expiration)
return s.dumps({'confirm': self.id}).decode('utf-8') return s.dumps({'confirm': self.id}).decode('utf-8')
def generate_reset_token(self, expiration=3600): def generate_reset_token(self, expiration=3600):
""" '''
Generates a reset token for password reset via email. Generates a reset token for password reset via email.
""" '''
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'],
expiration) expiration)
return s.dumps({'reset': self.id}).decode('utf-8') return s.dumps({'reset': self.id}).decode('utf-8')
def confirm(self, token): def confirm(self, token):
""" '''
Confirms User if the given token is valid and not expired. Confirms User if the given token is valid and not expired.
""" '''
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
try: try:
data = s.loads(token.encode('utf-8')) data = s.loads(token.encode('utf-8'))
@ -194,9 +194,9 @@ class User(UserMixin, db.Model):
@staticmethod @staticmethod
def reset_password(token, new_password): def reset_password(token, new_password):
""" '''
Resets password for User if the given token is valid and not expired. Resets password for User if the given token is valid and not expired.
""" '''
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
try: try:
data = s.loads(token.encode('utf-8')) data = s.loads(token.encode('utf-8'))
@ -221,16 +221,16 @@ class User(UserMixin, db.Model):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
def can(self, perm): def can(self, perm):
""" '''
Checks if a User with its current role can doe something. Checks if the Checks if a User with its current role can doe something. Checks if the
associated role actually has the needed Permission. associated role actually has the needed Permission.
""" '''
return self.role is not None and self.role.has_permission(perm) return self.role is not None and self.role.has_permission(perm)
def is_administrator(self): def is_administrator(self):
""" '''
Checks if User has Admin permissions. Checks if User has Admin permissions.
""" '''
return self.can(Permission.ADMIN) return self.can(Permission.ADMIN)
def ping(self): def ping(self):
@ -238,9 +238,9 @@ class User(UserMixin, db.Model):
db.session.add(self) db.session.add(self)
def delete(self): def delete(self):
""" '''
Delete the user and its corpora and jobs from database and filesystem. Delete the user and its corpora and jobs from database and filesystem.
""" '''
for job in self.jobs: for job in self.jobs:
job.delete() job.delete()
for corpus in self.corpora: for corpus in self.corpora:
@ -250,9 +250,9 @@ class User(UserMixin, db.Model):
class AnonymousUser(AnonymousUserMixin): class AnonymousUser(AnonymousUserMixin):
""" '''
Model replaces the default AnonymousUser. Model replaces the default AnonymousUser.
""" '''
def can(self, permissions): def can(self, permissions):
return False return False
@ -262,9 +262,9 @@ class AnonymousUser(AnonymousUserMixin):
class JobInput(db.Model): class JobInput(db.Model):
""" '''
Class to define JobInputs. Class to define JobInputs.
""" '''
__tablename__ = 'job_inputs' __tablename__ = 'job_inputs'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -275,9 +275,9 @@ class JobInput(db.Model):
filename = db.Column(db.String(255)) filename = db.Column(db.String(255))
def __repr__(self): def __repr__(self):
""" '''
String representation of the JobInput. For human readability. String representation of the JobInput. For human readability.
""" '''
return '<JobInput %r>' % self.filename return '<JobInput %r>' % self.filename
def to_dict(self): def to_dict(self):
@ -287,9 +287,9 @@ class JobInput(db.Model):
class JobResult(db.Model): class JobResult(db.Model):
""" '''
Class to define JobResults. Class to define JobResults.
""" '''
__tablename__ = 'job_results' __tablename__ = 'job_results'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -300,9 +300,9 @@ class JobResult(db.Model):
filename = db.Column(db.String(255)) filename = db.Column(db.String(255))
def __repr__(self): def __repr__(self):
""" '''
String representation of the JobResult. For human readability. String representation of the JobResult. For human readability.
""" '''
return '<JobResult %r>' % self.filename return '<JobResult %r>' % self.filename
def to_dict(self): def to_dict(self):
@ -312,9 +312,9 @@ class JobResult(db.Model):
class Job(db.Model): class Job(db.Model):
""" '''
Class to define Jobs. Class to define Jobs.
""" '''
__tablename__ = 'jobs' __tablename__ = 'jobs'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -341,31 +341,34 @@ class Job(db.Model):
cascade='save-update, merge, delete') cascade='save-update, merge, delete')
results = db.relationship('JobResult', backref='job', lazy='dynamic', results = db.relationship('JobResult', backref='job', lazy='dynamic',
cascade='save-update, merge, delete') cascade='save-update, merge, delete')
notifications_data = db.relationship('NotificationData', notification_data = db.relationship('NotificationData',
backref='job', cascade='save-update, merge, delete',
lazy='dynamic', uselist=False,
cascade='save-update, merge, delete') back_populates='job') # One-to-One relationship
notification_email_data = db.relationship('NotificationEmailData',
cascade='save-update, merge, delete',
back_populates='job')
def __repr__(self): def __repr__(self):
""" '''
String representation of the Job. For human readability. String representation of the Job. For human readability.
""" '''
return '<Job %r>' % self.title return '<Job %r>' % self.title
def create_secure_filename(self): def create_secure_filename(self):
""" '''
Takes the job.title string nad cratesa a secure filename from this. Takes the job.title string nad cratesa a secure filename from this.
""" '''
self.secure_filename = secure_filename(self.title) self.secure_filename = secure_filename(self.title)
def delete(self): def delete(self):
""" '''
Delete the job and its inputs and results from the database. Delete the job and its inputs and results from the database.
""" '''
for input in self.inputs: for input in self.inputs:
db.session.delete(input) db.session.delete(input)
for result in self.results: for result in self.results:
db.session.delete(result) db.session.delete(result) # TODO: shouldn't this happen through the cascade option?
db.session.delete(self) db.session.delete(self)
db.session.commit() db.session.commit()
@ -389,27 +392,53 @@ class Job(db.Model):
class NotificationData(db.Model): class NotificationData(db.Model):
""" '''
Class to define notification data used for sending a notification mail with Class to define notification data used for sending a notification mail with
nopaque_notify. nopaque_notify.
""" '''
__tablename__ = 'notifications_data' __tablename__ = 'notification_data'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# Foreign Key # Foreign Key
job_id = db.Column(db.Integer, db.ForeignKey('jobs.id')) job_id = db.Column(db.Integer, db.ForeignKey('jobs.id'))
# relationships
job = db.relationship('Job', back_populates='notification_data')
# Fields # Fields
notified_on_submitted = db.Column(db.Boolean, default=False) notified_on = db.Column(db.String(16), default=None)
notified_on_queued = db.Column(db.Boolean, default=False)
notified_on_running = db.Column(db.Boolean, default=False) def __repr__(self):
notified_on_complete = db.Column(db.Boolean, default=False) '''
notified_on_canceling = db.Column(db.Boolean, default=False) String representation of the NotificationData. For human readability.
'''
return '<NotificationData %r>' % self.id # TODO: Why not .format()?
def to_dict(self):
return {'id': self.id,
'job_id': self.job_id,
'job': self.job,
'notified': self.notified}
class NotificationEmailData(db.Model):
'''
Class to define data that will be used to send a corresponding Notification
via email.
'''
__tablename__ = 'notification_email_data'
# Primary Key
id = db.Column(db.Integer, primary_key=True)
# Foreign Key
job_id = db.Column(db.Integer, db.ForeignKey('jobs.id'))
# relationships
job = db.relationship('Job', back_populates='notification_email_data')
notify_status = db.Column(db.String(16), default=None)
creation_date = db.Column(db.DateTime(), default=datetime.utcnow)
class CorpusFile(db.Model): class CorpusFile(db.Model):
""" '''
Class to define Files. Class to define Files.
""" '''
__tablename__ = 'corpus_files' __tablename__ = 'corpus_files'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -455,9 +484,9 @@ class CorpusFile(db.Model):
class Corpus(db.Model): class Corpus(db.Model):
""" '''
Class to define a corpus. Class to define a corpus.
""" '''
__tablename__ = 'corpora' __tablename__ = 'corpora'
# Primary key # Primary key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -492,9 +521,9 @@ class Corpus(db.Model):
db.session.commit() db.session.commit()
def __repr__(self): def __repr__(self):
""" '''
String representation of the corpus. For human readability. String representation of the corpus. For human readability.
""" '''
return '<Corpus %r>' % self.title return '<Corpus %r>' % self.title

View File

@ -0,0 +1,30 @@
"""empty message
Revision ID: 10a92d8f4616
Revises: 4638e6509e13
Create Date: 2020-05-12 11:24:46.022674
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '10a92d8f4616'
down_revision = '4638e6509e13'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notifications_data', sa.Column('notified_on', sa.String(length=16), nullable=True))
op.drop_column('notifications_data', 'notified')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notifications_data', sa.Column('notified', sa.BOOLEAN(), autoincrement=False, nullable=True))
op.drop_column('notifications_data', 'notified_on')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""empty message
Revision ID: 3d9a20b8b26c
Revises: 421ba4373e50
Create Date: 2020-05-14 09:30:56.266381
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3d9a20b8b26c'
down_revision = '421ba4373e50'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notification_email_data', sa.Column('notify_status', sa.String(length=16), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('notification_email_data', 'notify_status')
# ### end Alembic commands ###

View File

@ -0,0 +1,33 @@
"""empty message
Revision ID: 421ba4373e50
Revises: 5ba6786a847e
Create Date: 2020-05-14 08:35:47.367125
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '421ba4373e50'
down_revision = '5ba6786a847e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notification_email_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('job_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notification_email_data')
# ### end Alembic commands ###

View File

@ -0,0 +1,38 @@
"""empty message
Revision ID: 4638e6509e13
Revises: 471aa04c1a92
Create Date: 2020-05-12 06:42:04.475585
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4638e6509e13'
down_revision = '471aa04c1a92'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notifications_data', sa.Column('notified', sa.Boolean(), nullable=True))
op.drop_column('notifications_data', 'notified_on_queued')
op.drop_column('notifications_data', 'notified_on_running')
op.drop_column('notifications_data', 'notified_on_submitted')
op.drop_column('notifications_data', 'notified_on_complete')
op.drop_column('notifications_data', 'notified_on_canceling')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notifications_data', sa.Column('notified_on_canceling', sa.BOOLEAN(), autoincrement=False, nullable=True))
op.add_column('notifications_data', sa.Column('notified_on_complete', sa.BOOLEAN(), autoincrement=False, nullable=True))
op.add_column('notifications_data', sa.Column('notified_on_submitted', sa.BOOLEAN(), autoincrement=False, nullable=True))
op.add_column('notifications_data', sa.Column('notified_on_running', sa.BOOLEAN(), autoincrement=False, nullable=True))
op.add_column('notifications_data', sa.Column('notified_on_queued', sa.BOOLEAN(), autoincrement=False, nullable=True))
op.drop_column('notifications_data', 'notified')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""empty message
Revision ID: 4886241e0f5d
Revises: 3d9a20b8b26c
Create Date: 2020-05-14 11:58:08.498454
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4886241e0f5d'
down_revision = '3d9a20b8b26c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notification_email_data', sa.Column('creation_date', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('notification_email_data', 'creation_date')
# ### end Alembic commands ###

View File

@ -0,0 +1,42 @@
"""empty message
Revision ID: 5ba6786a847e
Revises: 10a92d8f4616
Create Date: 2020-05-12 13:15:56.265610
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5ba6786a847e'
down_revision = '10a92d8f4616'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notification_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('job_id', sa.Integer(), nullable=True),
sa.Column('notified_on', sa.String(length=16), nullable=True),
sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.drop_table('notifications_data')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notifications_data',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('job_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('notified_on', sa.VARCHAR(length=16), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], name='notifications_data_job_id_fkey'),
sa.PrimaryKeyConstraint('id', name='notifications_data_pkey')
)
op.drop_table('notification_data')
# ### end Alembic commands ###

View File

@ -1,7 +1,7 @@
import eventlet import eventlet
eventlet.monkey_patch() # noqa eventlet.monkey_patch() # noqa
from app import create_app, db, socketio from app import create_app, db, socketio
from app.models import Corpus, Job, Role, User from app.models import Corpus, Job, Role, User, NotificationData
from flask_migrate import Migrate, upgrade from flask_migrate import Migrate, upgrade
import os import os
@ -15,7 +15,8 @@ def make_shell_context():
return {'db': db, return {'db': db,
'Corpus': Corpus, 'Corpus': Corpus,
'Job': Job, 'Job': Job,
'User': User} 'User': User,
'NotificationData': NotificationData}
@app.cli.command() @app.cli.command()