From 3c850d5435952b549e18a236439bbe92a0ea6325 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 14 Jan 2022 19:35:36 +0100 Subject: [PATCH 01/96] Add Notification model --- lifemonitor/auth/models.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 8d28e8d80..98f402f94 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -62,6 +62,7 @@ class User(db.Model, UserMixin): cascade="all, delete-orphan") subscriptions = db.relationship("Subscription", cascade="all, delete-orphan") + notifications = db.relationship("Notification", cascade="all, delete-orphan") def __init__(self, username=None) -> None: super().__init__() @@ -277,6 +278,38 @@ def __init__(self, resource: Resource, user: User) -> None: self.user = user +class Notification(db.Model, ModelMixin): + + class Types(Enum): + BUILD_FAILED = 0 + BUILD_RECOVERED = 1 + + id = db.Column(db.Integer, primary_key=True) + created = db.Column(db.DateTime, default=datetime.datetime.utcnow) + emailed = db.Column(db.DateTime, default=None, nullable=True) + read = db.Column(db.DateTime, default=None, nullable=True) + name = db.Column("name", db.String, nullable=True) + _type = db.Column("type", db.String, nullable=False) + _data = db.Column("data", JSON, nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + user: User = db.relationship("User", uselist=False, + back_populates="notifications", foreign_keys=[user_id]) + + def __init__(self, user: User, type: str, name: str, data: object) -> None: + self.user = user + self.name = name + self._type = type + self._data = data + + @property + def type(self) -> str: + return self._type + + @property + def data(self) -> object: + return self._data + + class HostingService(Resource): id = db.Column(db.Integer, db.ForeignKey(Resource.id), primary_key=True) From 95699aad0d212a944b9d62769ebe6c06cbbff289 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 14 Jan 2022 19:37:06 +0100 Subject: [PATCH 02/96] Add migration to handle 'Notification' model --- ...fcb14537658_add_user_notification_model.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 migrations/versions/3fcb14537658_add_user_notification_model.py diff --git a/migrations/versions/3fcb14537658_add_user_notification_model.py b/migrations/versions/3fcb14537658_add_user_notification_model.py new file mode 100644 index 000000000..28f9de45e --- /dev/null +++ b/migrations/versions/3fcb14537658_add_user_notification_model.py @@ -0,0 +1,42 @@ +"""Add user notification model + +Revision ID: 3fcb14537658 +Revises: f4cbfe20075f +Create Date: 2022-01-14 18:26:49.429440 + +""" +from alembic import op +import sqlalchemy as sa +from lifemonitor.models import JSON + + +# revision identifiers, used by Alembic. +revision = '3fcb14537658' +down_revision = 'f4cbfe20075f' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('notification', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created', sa.DateTime(), nullable=True), + sa.Column('emailed', sa.DateTime(), nullable=True), + sa.Column('read', sa.DateTime(), nullable=True), + sa.Column('name', sa.String(), nullable=True), + sa.Column('type', sa.String(), nullable=False), + sa.Column('data', JSON(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('user', sa.Column('email', sa.String(), nullable=True)) + op.add_column('user', sa.Column('email_verification_hash', sa.String(length=256), nullable=True)) + op.add_column('user', sa.Column('email_verified', sa.Boolean(), nullable=True)) + + +def downgrade(): + op.drop_column('user', 'email_verified') + op.drop_column('user', 'email_verification_hash') + op.drop_column('user', 'email') + op.drop_table('notification') From 3944e4163cb6ccce1bad62e5c6f599c306b2f699 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 14 Jan 2022 19:38:37 +0100 Subject: [PATCH 03/96] Add serialisation schema for the Notification model --- lifemonitor/auth/serializers.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/lifemonitor/auth/serializers.py b/lifemonitor/auth/serializers.py index 5361947b5..b85f683de 100644 --- a/lifemonitor/auth/serializers.py +++ b/lifemonitor/auth/serializers.py @@ -22,7 +22,9 @@ import logging -from lifemonitor.serializers import (BaseSchema, ListOfItems, +from marshmallow.decorators import post_dump + +from lifemonitor.serializers import (BaseSchema, ListOfItems, MetadataSchema, ResourceMetadataSchema, ma) from marshmallow import fields @@ -128,3 +130,29 @@ def get_resource(self, obj: models.Subscription): class ListOfSubscriptions(ListOfItems): __item_scheme__ = SubscriptionSchema + + +class NotificationSchema(MetadataSchema): + __envelope__ = {"single": None, "many": "items"} + __model__ = models.Notification + + class Meta: + model = models.Notification + + created = fields.DateTime(attribute='created') + emailed = fields.DateTime(attribute='emailed') + read = fields.DateTime(attribute='read') + name = fields.String(attribute="name") + type = fields.String(attribute="type") + data = fields.Dict(attribute="data") + + @post_dump + def remove_skip_values(self, data, **kwargs): + return { + key: value for key, value in data.items() + if value is not None + } + + +class ListOfNotifications(ListOfItems): + __item_scheme__ = NotificationSchema From 3ae87cbabc9d8bc041241b88e37c0624eba3ad40 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 17 Jan 2022 16:04:47 +0100 Subject: [PATCH 04/96] Make "User<->Notification" a many-to-many relationship --- lifemonitor/auth/models.py | 58 ++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 98f402f94..f9893dc7b 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -21,17 +21,23 @@ from __future__ import annotations import abc +import base64 import datetime +import json import logging +import random +import string import uuid as _uuid +from enum import Enum from typing import List from authlib.integrations.sqla_oauth2 import OAuth2TokenMixin from flask_bcrypt import check_password_hash, generate_password_hash from flask_login import AnonymousUserMixin, UserMixin +from lifemonitor import exceptions as lm_exceptions from lifemonitor import utils as lm_utils from lifemonitor.db import db -from lifemonitor.models import UUID, ModelMixin +from lifemonitor.models import JSON, UUID, ModelMixin from sqlalchemy.ext.hybrid import hybrid_property # Set the module level logger @@ -62,7 +68,8 @@ class User(db.Model, UserMixin): cascade="all, delete-orphan") subscriptions = db.relationship("Subscription", cascade="all, delete-orphan") - notifications = db.relationship("Notification", cascade="all, delete-orphan") + + notifications: List[UserNotification] = db.relationship("UserNotification", back_populates="user") def __init__(self, username=None) -> None: super().__init__() @@ -286,20 +293,18 @@ class Types(Enum): id = db.Column(db.Integer, primary_key=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) - emailed = db.Column(db.DateTime, default=None, nullable=True) - read = db.Column(db.DateTime, default=None, nullable=True) name = db.Column("name", db.String, nullable=True) _type = db.Column("type", db.String, nullable=False) _data = db.Column("data", JSON, nullable=True) - user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - user: User = db.relationship("User", uselist=False, - back_populates="notifications", foreign_keys=[user_id]) - def __init__(self, user: User, type: str, name: str, data: object) -> None: - self.user = user + users: List[UserNotification] = db.relationship("UserNotification", back_populates="notification") + + def __init__(self, type: str, name: str, data: object, users: List[User]) -> None: self.name = name self._type = type self._data = data + for u in users: + self.add_user(u) @property def type(self) -> str: @@ -309,6 +314,41 @@ def type(self) -> str: def data(self) -> object: return self._data + def add_user(self, user: User): + if user and user not in self.users: + UserNotification(user, self) + + def remove_user(self, user: User): + self.users.remove(user) + +class UserNotification(db.Model): + + emailed = db.Column(db.DateTime, default=None, nullable=True) + read = db.Column(db.DateTime, default=None, nullable=True) + + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) + + notification_id = db.Column(db.Integer, db.ForeignKey("notification.id"), nullable=False, primary_key=True) + + user: User = db.relationship("User", uselist=False, + back_populates="notifications", foreign_keys=[user_id]) + + notification: Notification = db.relationship("Notification", uselist=False, + back_populates="users", + foreign_keys=[notification_id]) + + def __init__(self, user: User, notification: Notification) -> None: + self.user = user + self.notification = notification + + def save(self): + db.session.add(self) + db.session.commit() + + def delete(self): + db.session.delete(self) + db.session.commit() + class HostingService(Resource): From cd0e06a366f3823ec202ccb58b8638115c4e6bc6 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 17 Jan 2022 16:27:25 +0100 Subject: [PATCH 05/96] Update migration to support the notification model --- ...7e77a9c44b2_add_user_notification_model.py} | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) rename migrations/versions/{3fcb14537658_add_user_notification_model.py => 97e77a9c44b2_add_user_notification_model.py} (76%) diff --git a/migrations/versions/3fcb14537658_add_user_notification_model.py b/migrations/versions/97e77a9c44b2_add_user_notification_model.py similarity index 76% rename from migrations/versions/3fcb14537658_add_user_notification_model.py rename to migrations/versions/97e77a9c44b2_add_user_notification_model.py index 28f9de45e..51ebf5790 100644 --- a/migrations/versions/3fcb14537658_add_user_notification_model.py +++ b/migrations/versions/97e77a9c44b2_add_user_notification_model.py @@ -1,8 +1,8 @@ """Add user notification model -Revision ID: 3fcb14537658 +Revision ID: 97e77a9c44b2 Revises: f4cbfe20075f -Create Date: 2022-01-14 18:26:49.429440 +Create Date: 2022-01-17 13:21:37.565495 """ from alembic import op @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. -revision = '3fcb14537658' +revision = '97e77a9c44b2' down_revision = 'f4cbfe20075f' branch_labels = None depends_on = None @@ -21,14 +21,19 @@ def upgrade(): op.create_table('notification', sa.Column('id', sa.Integer(), nullable=False), sa.Column('created', sa.DateTime(), nullable=True), - sa.Column('emailed', sa.DateTime(), nullable=True), - sa.Column('read', sa.DateTime(), nullable=True), sa.Column('name', sa.String(), nullable=True), sa.Column('type', sa.String(), nullable=False), sa.Column('data', JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_notification', + sa.Column('emailed', sa.DateTime(), nullable=True), + sa.Column('read', sa.DateTime(), nullable=True), sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('notification_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['notification_id'], ['notification.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('user_id', 'notification_id') ) op.add_column('user', sa.Column('email', sa.String(), nullable=True)) op.add_column('user', sa.Column('email_verification_hash', sa.String(length=256), nullable=True)) @@ -39,4 +44,5 @@ def downgrade(): op.drop_column('user', 'email_verified') op.drop_column('user', 'email_verification_hash') op.drop_column('user', 'email') + op.drop_table('user_notification') op.drop_table('notification') From 79a0be2a661b91a033705fb809839420b8fa08f8 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 17 Jan 2022 16:31:09 +0100 Subject: [PATCH 06/96] Update serialization schema for the Notification model --- lifemonitor/auth/serializers.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lifemonitor/auth/serializers.py b/lifemonitor/auth/serializers.py index b85f683de..cb571425e 100644 --- a/lifemonitor/auth/serializers.py +++ b/lifemonitor/auth/serializers.py @@ -22,14 +22,13 @@ import logging -from marshmallow.decorators import post_dump - from lifemonitor.serializers import (BaseSchema, ListOfItems, MetadataSchema, ResourceMetadataSchema, ma) from marshmallow import fields +from marshmallow.decorators import post_dump -from . import models from ..utils import get_external_server_url +from . import models # Config a module level logger logger = logging.getLogger(__name__) @@ -132,19 +131,19 @@ class ListOfSubscriptions(ListOfItems): __item_scheme__ = SubscriptionSchema -class NotificationSchema(MetadataSchema): +class NotificationSchema(ResourceMetadataSchema): __envelope__ = {"single": None, "many": "items"} - __model__ = models.Notification + __model__ = models.UserNotification class Meta: - model = models.Notification + model = models.UserNotification - created = fields.DateTime(attribute='created') + created = fields.DateTime(attribute='notification.created') emailed = fields.DateTime(attribute='emailed') read = fields.DateTime(attribute='read') - name = fields.String(attribute="name") - type = fields.String(attribute="type") - data = fields.Dict(attribute="data") + name = fields.String(attribute="notification.name") + type = fields.String(attribute="notification.type") + data = fields.Dict(attribute="notification.data") @post_dump def remove_skip_values(self, data, **kwargs): From a5b15bf48f41db65348c5502663b0ad9c21d21d3 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 17 Jan 2022 16:47:16 +0100 Subject: [PATCH 07/96] Add finders for notifications not read or not sent --- lifemonitor/auth/models.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index f9893dc7b..015b9e6d8 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -321,6 +321,17 @@ def add_user(self, user: User): def remove_user(self, user: User): self.users.remove(user) + @classmethod + def not_read(cls) -> List[Notification]: + return cls.query.join(UserNotification, UserNotification.notification_id == cls.id)\ + .filter(UserNotification.read == None).all() + + @classmethod + def not_emailed(cls) -> List[Notification]: + return cls.query.join(UserNotification, UserNotification.notification_id == cls.id)\ + .filter(UserNotification.emailed == None).all() + + class UserNotification(db.Model): emailed = db.Column(db.DateTime, default=None, nullable=True) From c2ea32af88b6469db63d81ea225955ce883c7cc3 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 09:49:03 +0100 Subject: [PATCH 08/96] Ignore .sync folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 034bc54b5..5071ad7f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Created by .ignore support plugin (hsz.mobi) .DS_Store +.sync .idea .vscode .cache From 369a459e51ca1811143aacb74b2553810dcc97fc Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 09:49:23 +0100 Subject: [PATCH 09/96] Fix imports --- lifemonitor/auth/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/auth/serializers.py b/lifemonitor/auth/serializers.py index cb571425e..56ab39599 100644 --- a/lifemonitor/auth/serializers.py +++ b/lifemonitor/auth/serializers.py @@ -22,7 +22,7 @@ import logging -from lifemonitor.serializers import (BaseSchema, ListOfItems, MetadataSchema, +from lifemonitor.serializers import (BaseSchema, ListOfItems, ResourceMetadataSchema, ma) from marshmallow import fields from marshmallow.decorators import post_dump From 7748bff1d3a09053daf0218be50eca2b6bc3eb14 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 09:58:07 +0100 Subject: [PATCH 10/96] Fix queries with null values --- lifemonitor/auth/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 015b9e6d8..25196ed82 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -38,6 +38,7 @@ from lifemonitor import utils as lm_utils from lifemonitor.db import db from lifemonitor.models import JSON, UUID, ModelMixin +from sqlalchemy import null from sqlalchemy.ext.hybrid import hybrid_property # Set the module level logger @@ -324,12 +325,12 @@ def remove_user(self, user: User): @classmethod def not_read(cls) -> List[Notification]: return cls.query.join(UserNotification, UserNotification.notification_id == cls.id)\ - .filter(UserNotification.read == None).all() + .filter(UserNotification.read == null()).all() @classmethod def not_emailed(cls) -> List[Notification]: return cls.query.join(UserNotification, UserNotification.notification_id == cls.id)\ - .filter(UserNotification.emailed == None).all() + .filter(UserNotification.emailed == null()).all() class UserNotification(db.Model): From cfd85f4fadc82f7dd4eb6b4799054fa2c6bba732 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 11:47:53 +0100 Subject: [PATCH 11/96] Initialise mail service --- lifemonitor/app.py | 3 +++ lifemonitor/mail.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ settings.conf | 9 +++++++++ 3 files changed, 56 insertions(+) create mode 100644 lifemonitor/mail.py diff --git a/lifemonitor/app.py b/lifemonitor/app.py index 0f5633167..40fb7a158 100644 --- a/lifemonitor/app.py +++ b/lifemonitor/app.py @@ -32,6 +32,7 @@ from lifemonitor.tasks.task_queue import init_task_queue from . import commands +from .mail import init_mail from .cache import init_cache from .db import db from .exceptions import handle_exception @@ -130,6 +131,8 @@ def initialize_app(app, app_context, prom_registry=None): commands.register_commands(app) # init scheduler/worker for async tasks init_task_queue(app) + # init mail system + init_mail(app) # configure prometheus exporter # must be configured after the routes are registered diff --git a/lifemonitor/mail.py b/lifemonitor/mail.py new file mode 100644 index 000000000..ee936cdb1 --- /dev/null +++ b/lifemonitor/mail.py @@ -0,0 +1,44 @@ +# Copyright (c) 2020-2021 CRS4 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import json +import logging +from datetime import datetime + +from flask import Flask +from flask_mail import Mail, Message + +from lifemonitor.auth.models import Notification + +# set logger +logger = logging.getLogger(__name__) + +# instantiate the mail class +mail = Mail() + + +def init_mail(app: Flask): + mail_server = app.config.get('MAIL_SERVER', None) + if mail_server: + mail.init_app(app) + logger.info("Mail service bound to server '%s'", mail_server) + mail.disabled = False + else: + mail.disabled = True diff --git a/settings.conf b/settings.conf index 33119552e..b072b107d 100644 --- a/settings.conf +++ b/settings.conf @@ -54,6 +54,15 @@ REDIS_HOST=redis REDIS_PASSWORD=foobar REDIS_PORT_NUMBER=6379 +# Email settings +MAIL_SERVER = '' +MAIL_PORT = 465 +MAIL_USERNAME = '' +MAIL_PASSWORD = '' +MAIL_USE_TLS = False +MAIL_USE_SSL = True +MAIL_DEFAULT_SENDER = '' + # Cache settings CACHE_REDIS_DB=0 CACHE_DEFAULT_TIMEOUT=300 From a7cc402e56df06f010c4979c382194c765fd4ebe Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 11:49:22 +0100 Subject: [PATCH 12/96] Extend User model with email attribute --- lifemonitor/auth/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 25196ed82..1511da9f2 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -62,7 +62,10 @@ class User(db.Model, UserMixin): username = db.Column(db.String(256), unique=True, nullable=False) password_hash = db.Column(db.LargeBinary, nullable=True) picture = db.Column(db.String(), nullable=True) - + _email = db.Column("email", db.String(), nullable=True) + _email_verification_code = None + _email_verification_hash = db.Column("email_verification_hash", db.String(256), nullable=True) + _email_verified = db.Column("email_verified", db.Boolean, nullable=True, default=False) permissions = db.relationship("Permission", back_populates="user", cascade="all, delete-orphan") authorizations = db.relationship("ExternalServiceAccessAuthorization", From 1d104dea68cc7113a861738b96b62a69b56d50fa Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 11:52:32 +0100 Subject: [PATCH 13/96] Add logic to verify user's email address --- lifemonitor/auth/models.py | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 1511da9f2..943ac6779 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -118,6 +118,76 @@ def password(self): def has_password(self): return bool(self.password_hash) + def _generate_random_code(self, chars=string.ascii_uppercase + string.digits): + return base64.b64encode( + json.dumps( + { + "email": self._email, + "code": ''.join(random.choice(chars) for _ in range(16)), + "expires": (datetime.datetime.now() + datetime.timedelta(hours=1)).timestamp() + } + ).encode('ascii') + ).decode() + + @staticmethod + def _decode_random_code(code): + try: + code = code.encode() if isinstance(code, str) else code + return json.loads(base64.b64decode(code.decode('ascii'))) + except Exception as e: + logger.debug(e) + return None + + @property + def email(self) -> str: + return self._email + + @email.setter + def email(self, email: str): + if email and email != self._email: + self._email = email + self._email_verified = False + code = self._generate_random_code() + self._email_verification_code = code + self._email_verification_hash = generate_password_hash(code) + + @email.deleter + def email(self): + self._email = None + self._email_verified = False + + @property + def email_verification_code(self) -> str: + return self._email_verification_code + + @property + def email_verified(self) -> bool: + return self._email_verified + + def verify_email(self, code): + if not self._email: + raise lm_exceptions.IllegalStateException(detail="No notification email found") + # verify integrity + if not code or \ + not check_password_hash( + self._email_verification_hash, code): + raise lm_exceptions.LifeMonitorException(detail="Invalid verification code") + try: + data = self._decode_random_code(code) + except Exception as e: + logger.debug(e) + raise lm_exceptions.LifeMonitorException(detail="Invalid verification code") + if data['email'] != self._email: + raise lm_exceptions.LifeMonitorException(detail="Notification email not valid") + if data['expires'] < datetime.datetime.now().timestamp(): + raise lm_exceptions.LifeMonitorException(detail="Verification code expired") + self._email_verified = True + return True + + def remove_notification(self, n: Notification): + if n is not None: + n.remove_user(n) + def has_permission(self, resource: Resource) -> bool: return self.get_permission(resource) is not None From aa3deff002761a7fd62ddea448e9132d1bae3589 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 11:52:51 +0100 Subject: [PATCH 14/96] Reformat --- lifemonitor/auth/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 943ac6779..4c7030a9d 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -118,6 +118,9 @@ def password(self): def has_password(self): return bool(self.password_hash) + def verify_password(self, password): + return check_password_hash(self.password_hash, password) + def _generate_random_code(self, chars=string.ascii_uppercase + string.digits): return base64.b64encode( json.dumps( @@ -194,9 +197,6 @@ def has_permission(self, resource: Resource) -> bool: def get_permission(self, resource: Resource) -> Permission: return next((p for p in self.permissions if p.resource == resource), None) - def verify_password(self, password): - return check_password_hash(self.password_hash, password) - def get_subscription(self, resource: Resource) -> Subscription: return next((s for s in self.subscriptions if s.resource == resource), None) From 7f6ff4cc54361318c41c7662f350f282244baf94 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:10:34 +0100 Subject: [PATCH 15/96] Fix serialization: cast TestingService uuid to string --- lifemonitor/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/api/serializers.py b/lifemonitor/api/serializers.py index 0162ec5da..9a1419823 100644 --- a/lifemonitor/api/serializers.py +++ b/lifemonitor/api/serializers.py @@ -267,7 +267,7 @@ def get_testing_service(self, obj): logger.debug("Test current obj: %r", obj) assert obj.testing_service, "Missing testing service" return { - 'uuid': obj.testing_service.uuid, + 'uuid': str(obj.testing_service.uuid), 'url': obj.testing_service.url, 'type': obj.testing_service._type.replace('_testing_service', '') } From 1f3f092dbd13811e89dd0daf503ff6d810b719fc Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:17:29 +0100 Subject: [PATCH 16/96] Initialise function send to notifications by email --- lifemonitor/mail.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lifemonitor/mail.py b/lifemonitor/mail.py index ee936cdb1..54de391aa 100644 --- a/lifemonitor/mail.py +++ b/lifemonitor/mail.py @@ -21,6 +21,7 @@ import json import logging from datetime import datetime +from typing import List, Optional from flask import Flask from flask_mail import Mail, Message @@ -42,3 +43,23 @@ def init_mail(app: Flask): mail.disabled = False else: mail.disabled = True + + +def send_notification(n: Notification, recipients: List[str]) -> Optional[datetime]: + if mail.disabled: + logger.info("Mail notifications are disabled") + else: + with mail.connect() as conn: + logger.debug("Mail recipients for notification '%r': %r", n.id, recipients) + if len(recipients) > 0: + # FIXME: reformat mail subject + msg = Message( + f'LifeMonitor notification: {n.type}', + bcc=recipients, + reply_to="noreply-lifemonitor@crs4.it" + ) + # TODO: format body using a proper template + msg.body = f"{n.id}
{json.dumps(n.data)}
" + conn.send(msg) + return datetime.utcnow() + return None From f38bcf4f9dfafc28a1538fbc04844a41eef0cea6 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:19:45 +0100 Subject: [PATCH 17/96] Allow to query notifications by name --- lifemonitor/auth/models.py | 6 ++++- ...51_add_name_index_on_notification_model.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/d1387a6fe551_add_name_index_on_notification_model.py diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 4c7030a9d..734480556 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -367,7 +367,7 @@ class Types(Enum): id = db.Column(db.Integer, primary_key=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) - name = db.Column("name", db.String, nullable=True) + name = db.Column("name", db.String, nullable=True, index=True) _type = db.Column("type", db.String, nullable=False) _data = db.Column("data", JSON, nullable=True) @@ -395,6 +395,10 @@ def add_user(self, user: User): def remove_user(self, user: User): self.users.remove(user) + @classmethod + def find_by_name(cls, name: str) -> List[Notification]: + return cls.query.filter(cls.name == name).all() + @classmethod def not_read(cls) -> List[Notification]: return cls.query.join(UserNotification, UserNotification.notification_id == cls.id)\ diff --git a/migrations/versions/d1387a6fe551_add_name_index_on_notification_model.py b/migrations/versions/d1387a6fe551_add_name_index_on_notification_model.py new file mode 100644 index 000000000..fd2840bb3 --- /dev/null +++ b/migrations/versions/d1387a6fe551_add_name_index_on_notification_model.py @@ -0,0 +1,24 @@ +"""Add name index on notification model + +Revision ID: d1387a6fe551 +Revises: 97e77a9c44b2 +Create Date: 2022-01-18 15:41:53.769530 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd1387a6fe551' +down_revision = '97e77a9c44b2' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_index(op.f('ix_notification_name'), 'notification', ['name'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_notification_name'), table_name='notification') From 17d6b2bc7b1316248f3aff68e716ce6b1c9f868a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:25:43 +0100 Subject: [PATCH 18/96] Use async task to generate notifications for build failures --- lifemonitor/tasks/tasks.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index 047133a3b..d1957f3eb 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -97,7 +97,18 @@ def check_last_build(): logger.info("Updating latest builds: %r", builds) for b in builds: logger.info("Updating build: %r", i.get_test_build(b.id)) - logger.info("Updating latest build: %r", i.last_test_build) + last_build = i.last_test_build + logger.info("Updating latest build: %r", last_build) + if last_build.status == BuildStatus.FAILED: + # TODO: allow to esclude users which have notifications disabled + notification_name = f"{last_build} FAILED" + if len(Notification.find_by_name(notification_name)) == 0: + users = [w.latest_version.submitter] + n = Notification(Notification.Types.BUILD_FAILED.name, + notification_name, + {'build': BuildSummarySchema().dump(last_build)}, + users) + n.save() except Exception as e: logger.error("Error when executing task 'check_last_build': %s", str(e)) if logger.isEnabledFor(logging.DEBUG): From 32a0eb8e3946ed60a2c0a983d728b2c4de908a0a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:26:41 +0100 Subject: [PATCH 19/96] Add async task to send notifications by email --- lifemonitor/tasks/tasks.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index d1957f3eb..8858ed0af 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -114,3 +114,26 @@ def check_last_build(): if logger.isEnabledFor(logging.DEBUG): logger.exception(e) logger.info("Checking last build: DONE!") + + +@schedule(IntervalTrigger(seconds=60)) +@dramatiq.actor(max_retries=0) +def send_email_notifications(): + notifications = Notification.not_emailed() + logger.info("Found %r notifications to send by email", len(notifications)) + count = 0 + for n in notifications: + logger.debug("Processing notification %r ...", n) + recipients = [u.user.email for u in n.users + if u.emailed is None and u.user.email is not None] + sent = send_notification(n, recipients) + logger.debug("Notification email sent: %r", sent is not None) + if sent: + logger.debug("Notification '%r' sent by email @ %r", n.id, sent) + for u in n.users: + if u.user.email in recipients: + u.emailed = sent + n.save() + count += 1 + logger.debug("Processing notification %r ... DONE", n) + logger.info("%r notifications sent by email", count) From ac396f311763c094d5bdb33515c987886dfba35a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:26:57 +0100 Subject: [PATCH 20/96] Fix imports --- lifemonitor/tasks/tasks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index 8858ed0af..2cf3f5c57 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -5,7 +5,11 @@ import flask from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger +from lifemonitor.api.models.testsuites.testbuild import BuildStatus +from lifemonitor.api.serializers import BuildSummarySchema +from lifemonitor.auth.models import Notification from lifemonitor.cache import Timeout +from lifemonitor.mail import send_notification # set module level logger logger = logging.getLogger(__name__) From 7ae7cdd1666c95f5660c7be47f5e267321e37c69 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:27:35 +0100 Subject: [PATCH 21/96] Fix missing exception --- lifemonitor/exceptions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lifemonitor/exceptions.py b/lifemonitor/exceptions.py index f0cda481b..4d124317e 100644 --- a/lifemonitor/exceptions.py +++ b/lifemonitor/exceptions.py @@ -181,6 +181,13 @@ def __init__(self, detail=None, detail=detail, status=status, **kwargs) +class IllegalStateException(LifeMonitorException): + def __init__(self, detail=None, + type="about:blank", status=403, instance=None, **kwargs): + super().__init__(title="Illegal State Exception", + detail=detail, status=status, **kwargs) + + def handle_exception(e: Exception): """Return JSON instead of HTML for HTTP errors.""" # start with the correct headers and status code from the error From dae7f6ab1a230d6177a13a9b54e42bf16ac7dce2 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:37:42 +0100 Subject: [PATCH 22/96] Convert 'mail' module to package --- lifemonitor/{mail.py => mail/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lifemonitor/{mail.py => mail/__init__.py} (100%) diff --git a/lifemonitor/mail.py b/lifemonitor/mail/__init__.py similarity index 100% rename from lifemonitor/mail.py rename to lifemonitor/mail/__init__.py From 50cd1fff6636392ebc71ec5fee47bb8b46d2275f Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 17:39:27 +0100 Subject: [PATCH 23/96] Remove unused import --- .../d1387a6fe551_add_name_index_on_notification_model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/migrations/versions/d1387a6fe551_add_name_index_on_notification_model.py b/migrations/versions/d1387a6fe551_add_name_index_on_notification_model.py index fd2840bb3..7629c681d 100644 --- a/migrations/versions/d1387a6fe551_add_name_index_on_notification_model.py +++ b/migrations/versions/d1387a6fe551_add_name_index_on_notification_model.py @@ -6,7 +6,6 @@ """ from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. From 57393e01f9213a331f70635cf2a08d2747727ea0 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 18 Jan 2022 18:14:08 +0100 Subject: [PATCH 24/96] Revert "Convert 'mail' module to package" This reverts commit dae7f6ab1a230d6177a13a9b54e42bf16ac7dce2. --- lifemonitor/{mail/__init__.py => mail.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lifemonitor/{mail/__init__.py => mail.py} (100%) diff --git a/lifemonitor/mail/__init__.py b/lifemonitor/mail.py similarity index 100% rename from lifemonitor/mail/__init__.py rename to lifemonitor/mail.py From ce78bea6f9f0b4d183a7b265e031251e1b8897d5 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 15:41:23 +0100 Subject: [PATCH 25/96] Add utility class to base64-encode files and objects --- lifemonitor/utils.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lifemonitor/utils.py b/lifemonitor/utils.py index 78f124375..e5815ca2e 100644 --- a/lifemonitor/utils.py +++ b/lifemonitor/utils.py @@ -468,3 +468,30 @@ def get_class(self, concrete_type): def get_classes(self): return [_[0] for _ in self._concrete_types.values()] + + +class Base64Encoder(object): + + _cache = {} + + @classmethod + def encode_file(cls, file: str) -> str: + data = cls._cache.get(file, None) + if data is None: + with open(file, "rb") as f: + data = base64.b64encode(f.read()) + cls._cache[file] = data + return data.decode() + + @classmethod + def encode_object(cls, obj: object) -> str: + key = hash(frozenset(obj.items())) + data = cls._cache.get(key, None) + if data is None: + data = base64.b64encode(json.dumps(obj).encode()) + cls._cache[key] = data + return data.decode() + + @classmethod + def decode(cls, data: str) -> object: + return base64.b64decode(data.encode()) From a6fe05cc5c00793bcb4d7f500e885b1c5b28f462 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 15:44:17 +0100 Subject: [PATCH 26/96] Add WEBAPP_URL on settings --- lifemonitor/config.py | 2 ++ lifemonitor/mail.py | 1 + settings.conf | 3 +++ 3 files changed, 6 insertions(+) diff --git a/lifemonitor/config.py b/lifemonitor/config.py index c5318d842..24fca28ff 100644 --- a/lifemonitor/config.py +++ b/lifemonitor/config.py @@ -79,6 +79,8 @@ class BaseConfig: CACHE_DEFAULT_TIMEOUT = 60 # Workflow Data Folder DATA_WORKFLOWS = "./data" + # Base URL of the LifeMonitor web app associated with this back-end instance + WEBAPP_URL = "https://app.lifemonitor.eu" class DevelopmentConfig(BaseConfig): diff --git a/lifemonitor/mail.py b/lifemonitor/mail.py index 54de391aa..8d4d81b5f 100644 --- a/lifemonitor/mail.py +++ b/lifemonitor/mail.py @@ -41,6 +41,7 @@ def init_mail(app: Flask): mail.init_app(app) logger.info("Mail service bound to server '%s'", mail_server) mail.disabled = False + mail.webapp_url = app.config.get('WEBAPP_URL') else: mail.disabled = True diff --git a/settings.conf b/settings.conf index b072b107d..2591c6f87 100644 --- a/settings.conf +++ b/settings.conf @@ -16,6 +16,9 @@ FLASK_ENV=development # used as base_url on all the links returned by the API #EXTERNAL_SERVER_URL=https://lifemonitor.eu +# Base URL of the LifeMonitor web app associated with this back-end instance +WEBAPP_URL=https://app.lifemonitor.eu + # Normally, OAuthLib will raise an InsecureTransportError if you attempt to use OAuth2 over HTTP, # rather than HTTPS. Setting this environment variable will prevent this error from being raised. # This is mostly useful for local testing, or automated tests. Never set this variable in production. From d4257167dcad55863155dae00fbcaadf8c47bfab Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 15:57:22 +0100 Subject: [PATCH 27/96] Expose testing service type as string property --- lifemonitor/api/models/services/service.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lifemonitor/api/models/services/service.py b/lifemonitor/api/models/services/service.py index e0634883b..5b37c61da 100644 --- a/lifemonitor/api/models/services/service.py +++ b/lifemonitor/api/models/services/service.py @@ -128,6 +128,10 @@ def base_url(self): def api_base_url(self): return self.url + @property + def type(self): + return self._type.replace('_testing_service', '').capitalize() + @property def token(self) -> TestingServiceToken: if not self._token: From 013f5b0384b3841bc6432c2ef0058b3ccaab9844 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 15:58:53 +0100 Subject: [PATCH 28/96] Set expiration time of send_email task --- lifemonitor/tasks/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index 2cf3f5c57..772f84b44 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -121,7 +121,7 @@ def check_last_build(): @schedule(IntervalTrigger(seconds=60)) -@dramatiq.actor(max_retries=0) +@dramatiq.actor(max_retries=0, max_age=30000) def send_email_notifications(): notifications = Notification.not_emailed() logger.info("Found %r notifications to send by email", len(notifications)) From 4cdfae7c9e2182970d6690e718290e9764489576 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 16:12:48 +0100 Subject: [PATCH 29/96] Use a template to format email notification of build failures --- lifemonitor/mail.py | 48 ++++++--- .../mail/build_failure_notification.j2 | 100 ++++++++++++++++++ 2 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 lifemonitor/templates/mail/build_failure_notification.j2 diff --git a/lifemonitor/mail.py b/lifemonitor/mail.py index 8d4d81b5f..f518b8968 100644 --- a/lifemonitor/mail.py +++ b/lifemonitor/mail.py @@ -18,15 +18,19 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import json + import logging from datetime import datetime from typing import List, Optional -from flask import Flask +from flask import Flask, render_template from flask_mail import Mail, Message +from sqlalchemy.exc import InternalError +from lifemonitor.api.models import TestInstance from lifemonitor.auth.models import Notification +from lifemonitor.db import db +from lifemonitor.utils import Base64Encoder # set logger logger = logging.getLogger(__name__) @@ -53,14 +57,34 @@ def send_notification(n: Notification, recipients: List[str]) -> Optional[dateti with mail.connect() as conn: logger.debug("Mail recipients for notification '%r': %r", n.id, recipients) if len(recipients) > 0: - # FIXME: reformat mail subject - msg = Message( - f'LifeMonitor notification: {n.type}', - bcc=recipients, - reply_to="noreply-lifemonitor@crs4.it" - ) - # TODO: format body using a proper template - msg.body = f"{n.id}
{json.dumps(n.data)}
" - conn.send(msg) - return datetime.utcnow() + build_data = n.data['build'] + try: + i = TestInstance.find_by_uuid(build_data['instance']['uuid']) + if i is not None: + wv = i.test_suite.workflow_version + b = i.last_test_build + suite = i.test_suite + logo = Base64Encoder.encode_file('lifemonitor/static/img/logo/lm/LifeMonitorLogo.png') + icon = Base64Encoder.encode_file('lifemonitor/static/img/icons/times-circle-solid.svg') + suite.url_param = Base64Encoder.encode_object({ + 'workflow': str(wv.workflow.uuid), + 'suite': str(suite.uuid) + }) + msg = Message( + f'Workflow "{wv.name} ({wv.version})": some builds were not successful', + bcc=recipients, + reply_to="noreply-lifemonitor@crs4.it" + ) + msg.html = render_template("mail/build_failure_notification.j2", + webapp_url=mail.webapp_url, + workflow_version=wv, build=b, + test_instance=i, + suite=suite, + json_data=build_data, + logo=logo, icon=icon) + conn.send(msg) + return datetime.utcnow() + except InternalError as e: + logger.debug(e) + db.session.rollback() return None diff --git a/lifemonitor/templates/mail/build_failure_notification.j2 b/lifemonitor/templates/mail/build_failure_notification.j2 new file mode 100644 index 000000000..6164007f0 --- /dev/null +++ b/lifemonitor/templates/mail/build_failure_notification.j2 @@ -0,0 +1,100 @@ + + + + + + + +
+ My Image + +

+ {{workflow_version.name}}
+ (version {{workflow_version.version}}) +

+ +

+ Some builds were not successful +

+ +
+ triangle with all three sides equal +
+ +

+ + Test Build #{{build.id}} + failed !!! +

+ +
+ test suite + + {{suite.name}} + + running on the + + {{test_instance.testing_service.type}} + service +
+ through the + + {{test_instance.name}} + + instance +
+ + +
+ + From 307a07375878a76d9fe6f1c59ea14eaa40a5e673 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 16:13:20 +0100 Subject: [PATCH 30/96] Add missing image --- lifemonitor/static/img/icons/times-circle-solid.svg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 lifemonitor/static/img/icons/times-circle-solid.svg diff --git a/lifemonitor/static/img/icons/times-circle-solid.svg b/lifemonitor/static/img/icons/times-circle-solid.svg new file mode 100644 index 000000000..81c289fe1 --- /dev/null +++ b/lifemonitor/static/img/icons/times-circle-solid.svg @@ -0,0 +1,3 @@ + \ No newline at end of file From 4c0b055429a4e7f921431b5c940e491abc384424 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 16:16:34 +0100 Subject: [PATCH 31/96] Fix missing settings on test env --- tests/settings.conf | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/settings.conf b/tests/settings.conf index 77c5aa288..46f4fd4e5 100644 --- a/tests/settings.conf +++ b/tests/settings.conf @@ -8,6 +8,9 @@ # It is only used to build the links returned by the API #EXTERNAL_ACCESS_BASE_URL="https://api.lifemonitor.eu" +# Base URL of the LifeMonitor web app associated with this back-end instance +# WEBAPP_URL=https://app.lifemonitor.eu + # Normally, OAuthLib will raise an InsecureTransportError if you attempt to use OAuth2 over HTTP, # rather than HTTPS. Setting this environment variable will prevent this error from being raised. # This is mostly useful for local testing, or automated tests. Never set this variable in production. @@ -43,6 +46,15 @@ CACHE_SESSION_TIMEOUT=3600 CACHE_WORKFLOW_TIMEOUT=1800 CACHE_BUILD_TIMEOUT=84600 +# Email settings +MAIL_SERVER = '' +MAIL_PORT = 465 +MAIL_USERNAME = '' +MAIL_PASSWORD = '' +MAIL_USE_TLS = False +MAIL_USE_SSL = True +MAIL_DEFAULT_SENDER = '' + # PostgreSQL DBMS settings #POSTGRESQL_HOST=0.0.0.0 #POSTGRESQL_PORT=5432 From 0ab6946fce5e40767d98ed14b1824a0f12c6e92a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 16:52:15 +0100 Subject: [PATCH 32/96] Fix settings --- settings.conf | 14 +++++++------- tests/settings.conf | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/settings.conf b/settings.conf index 2591c6f87..fdaa9c056 100644 --- a/settings.conf +++ b/settings.conf @@ -58,13 +58,13 @@ REDIS_PASSWORD=foobar REDIS_PORT_NUMBER=6379 # Email settings -MAIL_SERVER = '' -MAIL_PORT = 465 -MAIL_USERNAME = '' -MAIL_PASSWORD = '' -MAIL_USE_TLS = False -MAIL_USE_SSL = True -MAIL_DEFAULT_SENDER = '' +MAIL_SERVER='' +MAIL_PORT=465 +MAIL_USERNAME='' +MAIL_PASSWORD='' +MAIL_USE_TLS=False +MAIL_USE_SSL=True +MAIL_DEFAULT_SENDER='' # Cache settings CACHE_REDIS_DB=0 diff --git a/tests/settings.conf b/tests/settings.conf index 46f4fd4e5..b2732fdaf 100644 --- a/tests/settings.conf +++ b/tests/settings.conf @@ -47,13 +47,13 @@ CACHE_WORKFLOW_TIMEOUT=1800 CACHE_BUILD_TIMEOUT=84600 # Email settings -MAIL_SERVER = '' -MAIL_PORT = 465 -MAIL_USERNAME = '' -MAIL_PASSWORD = '' -MAIL_USE_TLS = False -MAIL_USE_SSL = True -MAIL_DEFAULT_SENDER = '' +MAIL_SERVER='' +MAIL_PORT=465 +MAIL_USERNAME='' +MAIL_PASSWORD='' +MAIL_USE_TLS=False +MAIL_USE_SSL=True +MAIL_DEFAULT_SENDER='' # PostgreSQL DBMS settings #POSTGRESQL_HOST=0.0.0.0 From 7e98d2d211373b86ca303693c69eaea8e5b3c7ee Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 19 Jan 2022 17:15:25 +0100 Subject: [PATCH 33/96] Fix mising requirement --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 45fdeb9c9..14b532e83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ flask-wtf~=0.15.1 Flask-APScheduler==1.12.2 Flask-SQLAlchemy==2.5.1 Flask-Migrate==3.1.0 +Flask-Mail~=0.9.1 Flask>=1.1.4,<2.0.0 gunicorn~=20.1.0 jwt==1.2.0 From e9a9393abc770d1ccc695267bdcc22ab160c5f6d Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 11:56:31 +0100 Subject: [PATCH 34/96] Fix email hash --- lifemonitor/auth/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 734480556..daa6cdc15 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -147,12 +147,12 @@ def email(self) -> str: @email.setter def email(self, email: str): - if email and email != self._email: + if email and (email != self._email or not self.email_verified): self._email = email self._email_verified = False code = self._generate_random_code() self._email_verification_code = code - self._email_verification_hash = generate_password_hash(code) + self._email_verification_hash = generate_password_hash(code).decode('utf-8') @email.deleter def email(self): From e828efb23cacfe7c70e15e47f0d53cd13cca797c Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 15:47:21 +0100 Subject: [PATCH 35/96] Extend user model with the 'email_notifications' flag --- lifemonitor/auth/models.py | 12 +++++++++ ...a38a6a_add_flag_to_enable_disable_mail_.py | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 migrations/versions/d5da43a38a6a_add_flag_to_enable_disable_mail_.py diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index daa6cdc15..6cbed13fb 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -62,6 +62,8 @@ class User(db.Model, UserMixin): username = db.Column(db.String(256), unique=True, nullable=False) password_hash = db.Column(db.LargeBinary, nullable=True) picture = db.Column(db.String(), nullable=True) + _email_notifications_enabled = db.Column("email_notifications", db.Boolean, + nullable=False, default=True) _email = db.Column("email", db.String(), nullable=True) _email_verification_code = None _email_verification_hash = db.Column("email_verification_hash", db.String(256), nullable=True) @@ -141,6 +143,16 @@ def _decode_random_code(code): logger.debug(e) return None + @property + def email_notifications_enabled(self): + return self._email_notifications_enabled + + def disable_email_notifications(self): + self._email_notifications_enabled = False + + def enable_email_notifications(self): + self._email_notifications_enabled = True + @property def email(self) -> str: return self._email diff --git a/migrations/versions/d5da43a38a6a_add_flag_to_enable_disable_mail_.py b/migrations/versions/d5da43a38a6a_add_flag_to_enable_disable_mail_.py new file mode 100644 index 000000000..9004634a4 --- /dev/null +++ b/migrations/versions/d5da43a38a6a_add_flag_to_enable_disable_mail_.py @@ -0,0 +1,26 @@ +"""Add flag to enable/disable mail notifications + +Revision ID: d5da43a38a6a +Revises: d1387a6fe551 +Create Date: 2022-01-20 13:27:41.016651 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd5da43a38a6a' +down_revision = 'd1387a6fe551' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('user', sa.Column('email_notifications', sa.Boolean())) + op.execute("UPDATE \"user\" SET email_notifications = true") + op.alter_column('user', 'email_notifications', nullable=False) + + +def downgrade(): + op.drop_column('user', 'email_notifications') From d452248a0f569f940dc59b42eee034afe5044303 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 15:49:38 +0100 Subject: [PATCH 36/96] Expose method to gerenate the code to verify user's email --- lifemonitor/auth/models.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 6cbed13fb..76b060aea 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -159,18 +159,22 @@ def email(self) -> str: @email.setter def email(self, email: str): - if email and (email != self._email or not self.email_verified): + if email and email != self._email: self._email = email - self._email_verified = False - code = self._generate_random_code() - self._email_verification_code = code - self._email_verification_hash = generate_password_hash(code).decode('utf-8') + self.generate_email_verification_code() @email.deleter def email(self): self._email = None self._email_verified = False + def generate_email_verification_code(self): + self._email_verified = False + code = self._generate_random_code() + self._email_verification_code = code + self._email_verification_hash = generate_password_hash(code).decode('utf-8') + return self._email_verification_code + @property def email_verification_code(self) -> str: return self._email_verification_code From ebbf2792723c9a55fe80ca9960bfd783424c31df Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 15:51:41 +0100 Subject: [PATCH 37/96] Add template of verification email --- lifemonitor/templates/mail/validate_email.j2 | 86 ++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 lifemonitor/templates/mail/validate_email.j2 diff --git a/lifemonitor/templates/mail/validate_email.j2 b/lifemonitor/templates/mail/validate_email.j2 new file mode 100644 index 000000000..285d1fed4 --- /dev/null +++ b/lifemonitor/templates/mail/validate_email.j2 @@ -0,0 +1,86 @@ + + + + + + + +
+ My Image + +

+ email updated +

+ +

+ Hi, {{user.username}} +

+ +
+ Click the button below to confirm your email address + +
+ + +
+ or copy and paste the following link into your browser + {{confirmation_address}} +
+
+ + From a0248b58dbfa1983a2f02f166b90a9ce8d6e220c Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 15:52:30 +0100 Subject: [PATCH 38/96] Reformat 'build_failure_notification' template --- .../mail/build_failure_notification.j2 | 182 +++++++++--------- 1 file changed, 95 insertions(+), 87 deletions(-) diff --git a/lifemonitor/templates/mail/build_failure_notification.j2 b/lifemonitor/templates/mail/build_failure_notification.j2 index 6164007f0..9a86300b8 100644 --- a/lifemonitor/templates/mail/build_failure_notification.j2 +++ b/lifemonitor/templates/mail/build_failure_notification.j2 @@ -1,100 +1,108 @@ - - - - - - -
- My Image - -

- {{workflow_version.name}}
- (version {{workflow_version.version}}) -

+ div.details-box { + width: 400px; + margin: 25px auto; + padding: 10px; + background: whitesmoke; + border-radius: 10px; + } -

- Some builds were not successful -

+ + + +
+ My Image -
- triangle with all three sides equal -
+

+ {{workflow_version.name}}
+ (version {{workflow_version.version}}) +

-

- - Test Build #{{build.id}} - failed !!! -

- -
- test suite - - {{suite.name}} - - running on the - - {{test_instance.testing_service.type}} - service -
- through the - - {{test_instance.name}} - - instance -
+

+ Some builds were not successful +

- +
+ triangle with all three sides equal +
+ +

+ + Test Build #{{build.id}} + failed !!! +

+ +
+ test suite + + {{suite.name}} + + running on the + + {{test_instance.testing_service.type}} + service +
+ through the + + {{test_instance.name}} + + instance +
+ +
- + From 13537b1f7fcefde51f273d183f4142689a0f3d48 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 15:53:04 +0100 Subject: [PATCH 39/96] Fix macro to render custom input fields --- lifemonitor/auth/templates/auth/macros.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/auth/templates/auth/macros.j2 b/lifemonitor/auth/templates/auth/macros.j2 index 15eb4fbd6..7cbddace2 100644 --- a/lifemonitor/auth/templates/auth/macros.j2 +++ b/lifemonitor/auth/templates/auth/macros.j2 @@ -113,7 +113,7 @@
{% if field.name == 'username' %} - {% elif field.name == 'email' %} + {% elif field.name == 'email' or field.name == 'repeat_email' %} {% elif field.name == 'password' %} From 374278a126f6d74ab64fc94e9aca45395cc1abfd Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 15:55:29 +0100 Subject: [PATCH 40/96] Add "notifications" tab to the user profile pane --- lifemonitor/auth/templates/auth/base.j2 | 3 + .../auth/templates/auth/notifications.j2 | 70 +++++++++++++++++++ lifemonitor/auth/templates/auth/profile.j2 | 7 ++ 3 files changed, 80 insertions(+) create mode 100644 lifemonitor/auth/templates/auth/notifications.j2 diff --git a/lifemonitor/auth/templates/auth/base.j2 b/lifemonitor/auth/templates/auth/base.j2 index 3c388e855..0f85f53cc 100644 --- a/lifemonitor/auth/templates/auth/base.j2 +++ b/lifemonitor/auth/templates/auth/base.j2 @@ -44,6 +44,7 @@ + {% endblock stylesheets %} @@ -76,6 +77,8 @@ + + {# Enable notifications #} {{ macros.messages() }} diff --git a/lifemonitor/auth/templates/auth/notifications.j2 b/lifemonitor/auth/templates/auth/notifications.j2 new file mode 100644 index 000000000..3d42a86b2 --- /dev/null +++ b/lifemonitor/auth/templates/auth/notifications.j2 @@ -0,0 +1,70 @@ +{% import 'auth/macros.j2' as macros %} + +
+ {{ notificationsForm.hidden_tag() }} +
+
+
+ + +
+
+
+ + +
+ +
+

Email

+
+ +
+ {% if not current_user.email_verified %} +
+
+
+
+ +
+
+
+ Your current email address {{ current_user.email }} is not validated! + Check your email inbox or click here + if you do not have received the verification email. +
+
+
+ {% endif %} + +
+ Use the form below to + {% if not current_user.email %}set{% endif %} + {% if current_user.email %}update{% endif %} + your email. +
+ +
+ {{ emailForm.hidden_tag() }} +
+ + {{ macros.render_custom_field(emailForm.email, value=current_user.email) }} +
+
+ + {{ macros.render_custom_field(emailForm.repeat_email) }} +
+
+ +
+
+
diff --git a/lifemonitor/auth/templates/auth/profile.j2 b/lifemonitor/auth/templates/auth/profile.j2 index f7d9278ae..58aaa0324 100644 --- a/lifemonitor/auth/templates/auth/profile.j2 +++ b/lifemonitor/auth/templates/auth/profile.j2 @@ -53,6 +53,10 @@ OAuth Apps +
@@ -71,6 +75,9 @@ {% include 'auth/oauth2_clients_tab.j2' %}
+
+ {% include 'auth/notifications.j2' %} +
From b48917b28e2947950847c943fa47b43f8e44c5b0 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 15:56:15 +0100 Subject: [PATCH 41/96] Add function to send verification mail --- lifemonitor/mail.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lifemonitor/mail.py b/lifemonitor/mail.py index f518b8968..c1f2f2e7d 100644 --- a/lifemonitor/mail.py +++ b/lifemonitor/mail.py @@ -28,9 +28,9 @@ from sqlalchemy.exc import InternalError from lifemonitor.api.models import TestInstance -from lifemonitor.auth.models import Notification +from lifemonitor.auth.models import Notification, User from lifemonitor.db import db -from lifemonitor.utils import Base64Encoder +from lifemonitor.utils import Base64Encoder, get_external_server_url # set logger logger = logging.getLogger(__name__) @@ -50,6 +50,24 @@ def init_mail(app: Flask): mail.disabled = True +def send_email_validation_message(user: User): + if mail.disabled: + logger.info("Mail notifications are disabled") + if user is None or user.is_anonymous: + logger.warning("An authenticated user is required") + with mail.connect() as conn: + confirmation_address = f"{get_external_server_url()}/validate_email?code={user.email_verification_code}" + logo = Base64Encoder.encode_file('lifemonitor/static/img/logo/lm/LifeMonitorLogo.png') + msg = Message( + f'Confirm your email address', + recipients=[user.email], + reply_to="noreply-lifemonitor@crs4.it" + ) + msg.html = render_template("mail/validate_email.j2", + confirmation_address=confirmation_address, user=user, logo=logo) + conn.send(msg) + + def send_notification(n: Notification, recipients: List[str]) -> Optional[datetime]: if mail.disabled: logger.info("Mail notifications are disabled") From cdb96d7f576d105ef01d73a145d87450593a9e20 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 16:01:01 +0100 Subject: [PATCH 42/96] Extend controller to support notification settings --- lifemonitor/auth/controllers.py | 81 +++++++++++++++++++++++++++++++-- lifemonitor/auth/forms.py | 23 +++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index e92e39538..bb2ebbe19 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -23,14 +23,15 @@ import flask from flask import flash, redirect, render_template, request, session, url_for from flask_login import login_required, login_user, logout_user -from lifemonitor.cache import cached, Timeout, clear_cache +from lifemonitor.cache import Timeout, cached, clear_cache from lifemonitor.utils import (NextRouteRegistry, next_route_aware, split_by_crlf) from .. import exceptions from ..utils import OpenApiSpecs from . import serializers -from .forms import LoginForm, Oauth2ClientForm, RegisterForm, SetPasswordForm +from .forms import (EmailForm, LoginForm, NotificationsForm, Oauth2ClientForm, + RegisterForm, SetPasswordForm) from .models import db from .oauth2.client.services import (get_current_user_identity, get_providers, merge_users, save_current_user_identity) @@ -97,7 +98,8 @@ def index(): @blueprint.route("/profile", methods=("GET",)) -def profile(form=None, passwordForm=None, currentView=None): +def profile(form=None, passwordForm=None, currentView=None, + emailForm=None, notificationsForm=None): currentView = currentView or request.args.get("currentView", 'accountsTab') logger.debug(OpenApiSpecs.get_instance().authorization_code_scopes) back_param = request.args.get('back', None) @@ -111,6 +113,8 @@ def profile(form=None, passwordForm=None, currentView=None): logger.debug("detected back param: %s", back_param) return render_template("auth/profile.j2", passwordForm=passwordForm or SetPasswordForm(), + emailForm=emailForm or EmailForm(), + notificationsForm=notificationsForm or NotificationsForm(), oauth2ClientForm=form or Oauth2ClientForm(), providers=get_providers(), currentView=currentView, oauth2_generic_client_scopes=OpenApiSpecs.get_instance().authorization_code_scopes, @@ -197,6 +201,77 @@ def set_password(): return profile(passwordForm=form) +@blueprint.route("/set_email", methods=("GET", "POST")) +@login_required +def set_email(): + form = EmailForm() + if request.method == "GET": + return profile(emailForm=form, currentView='notificationsTab') + if form.validate_on_submit(): + if form.email.data == current_user.email: + flash("email address not changed") + else: + current_user.email = form.email.data + db.session.add(current_user) + db.session.commit() + from lifemonitor.mail import send_email_validation_message + send_email_validation_message(current_user) + flash("email address registered") + return redirect(url_for("auth.profile", emailForm=form, currentView='notificationsTab')) + return profile(emailForm=form, currentView='notificationsTab') + + +@blueprint.route("/send_verification_email", methods=("GET", "POST")) +@login_required +def send_verification_email(): + try: + current_user.generate_email_verification_code() + from lifemonitor.mail import send_email_validation_message + send_email_validation_message(current_user) + current_user.save() + flash("Confirmation email sent") + logger.info("Confirmation email sent %r", current_user.id) + except Exception as e: + logger.error("An error occurred when sending email verification message for user %r", + current_user.id) + logger.debug(e) + return redirect(url_for("auth.profile", currentView='notificationsTab')) + + +@blueprint.route("/validate_email", methods=("GET", "POST")) +@login_required +def validate_email(): + validated = False + try: + code = request.args.get("code", None) + current_user.verify_email(code) + current_user.save() + flash("Email address validated") + except exceptions.LifeMonitorException as e: + logger.debug(e) + logger.info("Email validated for user %r: %r", current_user.id, validated) + return redirect(url_for("auth.profile", currentView='notificationsTab')) + + +@blueprint.route("/update_notifications_switch", methods=("GET", "POST")) +@login_required +def update_notifications_switch(): + logger.debug("Updating notifications") + form = NotificationsForm() + if request.method == "GET": + return redirect(url_for('auth.profile', notificationsForm=form, currentView='notificationsTab')) + enable_notifications = form.enable_notifications.data + logger.debug("Enable notifications: %r", enable_notifications) + if enable_notifications: + current_user.enable_email_notifications() + else: + current_user.disable_email_notifications() + current_user.save() + enabled_str = "enabled" if current_user.email_notifications_enabled else "disabled" + flash(f"email notifications {enabled_str}") + return redirect(url_for("auth.profile", notificationsForm=form, currentView='notificationsTab')) + + @blueprint.route("/merge", methods=("GET", "POST")) @login_required def merge(): diff --git a/lifemonitor/auth/forms.py b/lifemonitor/auth/forms.py index 4ae88053a..a162183f2 100644 --- a/lifemonitor/auth/forms.py +++ b/lifemonitor/auth/forms.py @@ -29,7 +29,7 @@ from sqlalchemy.exc import IntegrityError from wtforms import (BooleanField, HiddenField, PasswordField, SelectField, SelectMultipleField, StringField) -from wtforms.validators import URL, DataRequired, EqualTo, Optional +from wtforms.validators import URL, DataRequired, Email, EqualTo, Optional, NoneOf from .models import User, db @@ -108,6 +108,27 @@ class SetPasswordForm(FlaskForm): repeat_password = PasswordField("Repeat Password") +class NotificationsForm(FlaskForm): + enable_notifications = BooleanField( + "enable_notifications", + validators=[ + DataRequired() + ], + ) + + +class EmailForm(FlaskForm): + email = StringField( + "Email", + validators=[ + DataRequired(), + Email(), + EqualTo("repeat_email", message="email addresses do not match"), + ], + ) + repeat_email = StringField("Repeat Email") + + class Oauth2ClientForm(FlaskForm): clientId = HiddenField("clientId") name = StringField("Client Name", validators=[DataRequired()]) From 1bfed2873a686c933b9d7dd6653c8adbd5891b8b Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 16:06:20 +0100 Subject: [PATCH 43/96] Fix flake8 issues --- lifemonitor/auth/forms.py | 2 +- lifemonitor/mail.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lifemonitor/auth/forms.py b/lifemonitor/auth/forms.py index a162183f2..8062970b5 100644 --- a/lifemonitor/auth/forms.py +++ b/lifemonitor/auth/forms.py @@ -29,7 +29,7 @@ from sqlalchemy.exc import IntegrityError from wtforms import (BooleanField, HiddenField, PasswordField, SelectField, SelectMultipleField, StringField) -from wtforms.validators import URL, DataRequired, Email, EqualTo, Optional, NoneOf +from wtforms.validators import URL, DataRequired, Email, EqualTo, Optional from .models import User, db diff --git a/lifemonitor/mail.py b/lifemonitor/mail.py index c1f2f2e7d..b60ab0f48 100644 --- a/lifemonitor/mail.py +++ b/lifemonitor/mail.py @@ -59,7 +59,7 @@ def send_email_validation_message(user: User): confirmation_address = f"{get_external_server_url()}/validate_email?code={user.email_verification_code}" logo = Base64Encoder.encode_file('lifemonitor/static/img/logo/lm/LifeMonitorLogo.png') msg = Message( - f'Confirm your email address', + 'Confirm your email address', recipients=[user.email], reply_to="noreply-lifemonitor@crs4.it" ) From be92c2808ef2a63a89022014b12b5249aa672b4e Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 16:42:48 +0100 Subject: [PATCH 44/96] Don't display verification warning if no email is set --- lifemonitor/auth/templates/auth/notifications.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/auth/templates/auth/notifications.j2 b/lifemonitor/auth/templates/auth/notifications.j2 index 3d42a86b2..742d26c86 100644 --- a/lifemonitor/auth/templates/auth/notifications.j2 +++ b/lifemonitor/auth/templates/auth/notifications.j2 @@ -29,7 +29,7 @@
- {% if not current_user.email_verified %} + {% if current_user.email and not current_user.email_verified %}
From 42f46ec2f35e62f7fddc6ada86e39ba862f1d9a4 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 17:32:05 +0100 Subject: [PATCH 45/96] Force values to boolean --- lifemonitor/mail.py | 4 +++- lifemonitor/utils.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lifemonitor/mail.py b/lifemonitor/mail.py index b60ab0f48..e137c79de 100644 --- a/lifemonitor/mail.py +++ b/lifemonitor/mail.py @@ -30,7 +30,7 @@ from lifemonitor.api.models import TestInstance from lifemonitor.auth.models import Notification, User from lifemonitor.db import db -from lifemonitor.utils import Base64Encoder, get_external_server_url +from lifemonitor.utils import Base64Encoder, get_external_server_url, boolean_value # set logger logger = logging.getLogger(__name__) @@ -42,6 +42,8 @@ def init_mail(app: Flask): mail_server = app.config.get('MAIL_SERVER', None) if mail_server: + app.config['MAIL_USE_TLS'] = boolean_value(app.config.get('MAIL_USE_TLS', False)) + app.config['MAIL_USE_SSL'] = boolean_value(app.config.get('MAIL_USE_SSL', False)) mail.init_app(app) logger.info("Mail service bound to server '%s'", mail_server) mail.disabled = False diff --git a/lifemonitor/utils.py b/lifemonitor/utils.py index e5815ca2e..da0868932 100644 --- a/lifemonitor/utils.py +++ b/lifemonitor/utils.py @@ -85,6 +85,16 @@ def values_as_string(values, in_separator='\\s?,\\s?|\\s+', out_separator=" "): raise ValueError("Invalid format") +def boolean_value(value) -> bool: + if value is None or value == "": + return False + if isinstance(value, bool): + return value + if isinstance(value, str): + return bool_from_string(value) + raise ValueError(f"Invalid value for boolean. Got '{value}'") + + def bool_from_string(s) -> bool: if s is None or s == "": return None From 777bbc4618e76873b2664289f0b91b6f11a72293 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 20 Jan 2022 19:31:35 +0100 Subject: [PATCH 46/96] Extend notifications to subscribers --- lifemonitor/tasks/tasks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index 772f84b44..cfd6f7acd 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -104,10 +104,11 @@ def check_last_build(): last_build = i.last_test_build logger.info("Updating latest build: %r", last_build) if last_build.status == BuildStatus.FAILED: - # TODO: allow to esclude users which have notifications disabled notification_name = f"{last_build} FAILED" if len(Notification.find_by_name(notification_name)) == 0: - users = [w.latest_version.submitter] + users = {s.user for s in w.subscriptions if s.user.email_notifications_enabled} + users.update({v.submitter for v in w.versions.values() if v.submitter.email_notifications_enabled}) + users.update({s.user for v in w.versions.values() for s in v.subscriptions if s.user.email_notifications_enabled}) n = Notification(Notification.Types.BUILD_FAILED.name, notification_name, {'build': BuildSummarySchema().dump(last_build)}, From 834db11ce1f30d287c896b0709c7278ae977f43b Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 21 Jan 2022 09:36:41 +0100 Subject: [PATCH 47/96] Update k8s settings --- k8s/templates/secret.yaml | 12 ++++++++++++ k8s/values.yaml | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/k8s/templates/secret.yaml b/k8s/templates/secret.yaml index 73af94f14..5bee20989 100644 --- a/k8s/templates/secret.yaml +++ b/k8s/templates/secret.yaml @@ -17,6 +17,9 @@ stringData: EXTERNAL_SERVER_URL={{ .Values.externalServerName }} {{- end }} + # Base URL of the LifeMonitor web app associated with this back-end instance + WEBAPP_URL={{ .Values.webappUrl }} + # Normally, OAuthLib will raise an InsecureTransportError if you attempt to use OAuth2 over HTTP, # rather than HTTPS. Setting this environment variable will prevent this error from being raised. # This is mostly useful for local testing, or automated tests. Never set this variable in production. @@ -57,6 +60,15 @@ stringData: CACHE_WORKFLOW_TIMEOUT={{ .Values.cache.timeout.workflow }} CACHE_BUILD_TIMEOUT={{ .Values.cache.timeout.build }} + # Email sender + MAIL_SERVER={{ .Values.mail.server }} + MAIL_PORT={{ .Values.mail.port }} + MAIL_USERNAME={{ .Values.mail.username }} + MAIL_PASSWORD={{ .Values.mail.password }} + MAIL_USE_TLS={{- if .Values.mail.tls -}}True{{- else -}}False{{- end }} + MAIL_USE_SSL={{- if .Values.mail.ssl -}}True{{- else -}}False{{- end }} + MAIL_DEFAULT_SENDER={{ .Values.mail.default_sender }} + # Set admin credentials LIFEMONITOR_ADMIN_PASSWORD={{ .Values.lifemonitor.administrator.password }} diff --git a/k8s/values.yaml b/k8s/values.yaml index 151902555..4a4fdd7ef 100644 --- a/k8s/values.yaml +++ b/k8s/values.yaml @@ -10,6 +10,9 @@ fullnameOverride: "" # used as base_url on all the links returned by the API externalServerName: &hostname api.lifemonitor.eu +# Base URL of the LifeMonitor web app associated with this back-end instance +webappUrl: &webapp_url https://app.lifemonitor.eu + # global storage class storageClass: &storageClass "-" @@ -68,6 +71,16 @@ cache: workflow: 1800 build: 84600 +# Email settings +mail: + server: "" + port: 465 + username: "" + password: "" + tls: false + ssl: true + default_sender: "" + lifemonitor: replicaCount: 1 From 54f04133df83550595957661e5dfa4a9ff2aa63d Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 24 Jan 2022 19:00:43 +0100 Subject: [PATCH 48/96] Add custom datatype to store persist sets --- lifemonitor/models.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/lifemonitor/models.py b/lifemonitor/models.py index b38ac3863..4bbecf841 100644 --- a/lifemonitor/models.py +++ b/lifemonitor/models.py @@ -23,9 +23,10 @@ import uuid from typing import List -from lifemonitor.db import db +from sqlalchemy import VARCHAR, types + from lifemonitor.cache import CacheMixin -from sqlalchemy import types +from lifemonitor.db import db class ModelMixin(CacheMixin): @@ -98,3 +99,39 @@ def load_dialect_impl(self, dialect): return dialect.type_descriptor(JSONB()) else: return dialect.type_descriptor(types.JSON()) + + +class CustomSet(types.TypeDecorator): + """Represents an immutable structure as a json-encoded string.""" + impl = VARCHAR + + def process_bind_param(self, value, dialect): + if value is not None: + if not isinstance(value, set): + raise ValueError("Invalid value type. Got %r", type(value)) + value = ",".join(value) + return value + + def process_result_value(self, value, dialect): + return set() if value is None or len(value) == 0 \ + else set(value.split(',')) + + +class StringSet(CustomSet): + """Represents an immutable structure as a json-encoded string.""" + pass + + +class IntegerSet(CustomSet): + """Represents an immutable structure as a json-encoded string.""" + + def process_bind_param(self, value, dialect): + if value is not None: + if not isinstance(value, set): + raise ValueError("Invalid value type. Got %r", type(value)) + value = ",".join([str(_) for _ in value]) + return value + + def process_result_value(self, value, dialect): + return set() if value is None or len(value) == 0 \ + else set({int(_) for _ in value.split(',')}) From 3f47cac994bbacec2af82e6e60ee062c4c243aec Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 24 Jan 2022 19:04:20 +0100 Subject: [PATCH 49/96] Add enum of some event types --- lifemonitor/auth/models.py | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 76b060aea..adace96fa 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -297,6 +297,45 @@ def all(cls) -> List[ApiKey]: return cls.query.all() +class EventType(Enum): + ALL = 0 + BUILD_FAILED = 1 + BUILD_RECOVERED = 2 + + @classmethod + def list(cls): + return list(map(lambda c: c, cls)) + + @classmethod + def names(cls): + return list(map(lambda c: c.name, cls)) + + @classmethod + def values(cls): + return list(map(lambda c: c.value, cls)) + + @classmethod + def to_string(cls, event: EventType) -> str: + return event.name if event else None + + @classmethod + def to_strings(cls, event_list: List[EventType]) -> List[str]: + return [cls.to_string(_) for _ in event_list if _] if event_list else [] + + @classmethod + def from_string(cls, event_name: str) -> EventType: + try: + return cls[event_name] + except KeyError: + raise ValueError("'%s' is not a valid EventType", event_name) + + @classmethod + def from_strings(cls, event_name_list: List[str]) -> List[EventType]: + if not event_name_list: + return [] + return [cls.from_string(_) for _ in event_name_list] + + class Resource(db.Model, ModelMixin): id = db.Column('id', db.Integer, primary_key=True) From f61d8f22b4d4538ccd8894d871a1ee04ce22fc28 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 24 Jan 2022 19:05:59 +0100 Subject: [PATCH 50/96] Model events associated to a resource subscription --- lifemonitor/auth/models.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index adace96fa..24b247394 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -37,9 +37,10 @@ from lifemonitor import exceptions as lm_exceptions from lifemonitor import utils as lm_utils from lifemonitor.db import db -from lifemonitor.models import JSON, UUID, ModelMixin +from lifemonitor.models import JSON, UUID, IntegerSet, ModelMixin from sqlalchemy import null from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.ext.mutable import MutableSet # Set the module level logger logger = logging.getLogger(__name__) @@ -408,11 +409,33 @@ class Subscription(db.Model, ModelMixin): resource: Resource = db.relationship("Resource", uselist=False, backref=db.backref("subscriptions", cascade="all, delete-orphan"), foreign_keys=[resource_id]) + _events = db.Column("events", MutableSet.as_mutable(IntegerSet()), default={0}) def __init__(self, resource: Resource, user: User) -> None: self.resource = resource self.user = user + def __get_events(self): + if self._events is None: + self._events = {0} + return self._events + + @property + def events(self) -> set: + return [EventType(e) for e in self.__get_events()] + + @events.setter + def events(self, events: List[EventType]): + self.__get_events().clear() + if events: + for e in events: + self.__get_events().add(e.value) + + def has_event(self, event: EventType) -> bool: + return False if event is None else \ + EventType.ALL.value in self.__get_events() or \ + event.value in self.__get_events() + class Notification(db.Model, ModelMixin): From 6926505e6595c5ba38a3ddc53f6e1f8b77520dd0 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 24 Jan 2022 19:09:51 +0100 Subject: [PATCH 51/96] Represent the event which a notification is related to --- lifemonitor/auth/models.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 24b247394..f0618b249 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -439,28 +439,24 @@ def has_event(self, event: EventType) -> bool: class Notification(db.Model, ModelMixin): - class Types(Enum): - BUILD_FAILED = 0 - BUILD_RECOVERED = 1 - id = db.Column(db.Integer, primary_key=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) name = db.Column("name", db.String, nullable=True, index=True) - _type = db.Column("type", db.String, nullable=False) + _event = db.Column("event", db.Integer, nullable=False) _data = db.Column("data", JSON, nullable=True) users: List[UserNotification] = db.relationship("UserNotification", back_populates="notification") - def __init__(self, type: str, name: str, data: object, users: List[User]) -> None: + def __init__(self, event: EventType, name: str, data: object, users: List[User]) -> None: self.name = name - self._type = type + self._event = event.value self._data = data for u in users: self.add_user(u) @property - def type(self) -> str: - return self._type + def event(self) -> EventType: + return EventType(self._event) @property def data(self) -> object: From 750d09fd9e9e9d4fcce71fecbce70100bcb297ce Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 24 Jan 2022 19:11:16 +0100 Subject: [PATCH 52/96] Add some helpers to find subscribers/subscrptions --- lifemonitor/auth/models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index f0618b249..d1f8ff671 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -217,6 +217,9 @@ def get_permission(self, resource: Resource) -> Permission: def get_subscription(self, resource: Resource) -> Subscription: return next((s for s in self.subscriptions if s.resource == resource), None) + def is_subscribed_to(self, resource: Resource) -> bool: + return self.get_subscription(resource) is not None + def subscribe(self, resource: Resource) -> Subscription: s = self.get_subscription(resource) if not s: @@ -386,6 +389,11 @@ def get_authorization(self, user: User): def find_by_uuid(cls, uuid): return cls.query.filter(cls.uuid == lm_utils.uuid_param(uuid)).first() + def get_subscribers(self, event: EventType = EventType.ALL) -> List[User]: + users = {s.user for s in self.subscriptions if s.has_event(event)} + users.update({s.user for s in self.subscriptions if s.has_event(event)}) + return users + resource_authorization_table = db.Table( 'resource_authorization', db.Model.metadata, From 6e9198f4609c3b8fc5989095b8b8206e9a3acb55 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 24 Jan 2022 19:12:27 +0100 Subject: [PATCH 53/96] Update serialisation schema of Subscription to include events --- lifemonitor/auth/serializers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lifemonitor/auth/serializers.py b/lifemonitor/auth/serializers.py index 56ab39599..81450d678 100644 --- a/lifemonitor/auth/serializers.py +++ b/lifemonitor/auth/serializers.py @@ -119,6 +119,7 @@ class Meta: modified = fields.String(attribute='modified') resource = fields.Method("get_resource") + events = fields.Method("get_events") def get_resource(self, obj: models.Subscription): return { @@ -126,6 +127,9 @@ def get_resource(self, obj: models.Subscription): 'type': obj.resource.type } + def get_events(self, obj: models.Subscription): + return models.EventType.to_strings(obj.events) + class ListOfSubscriptions(ListOfItems): __item_scheme__ = SubscriptionSchema From 90ac058411d8f91fe29b8aea9b36b2d8436ff20b Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 26 Jan 2022 10:28:26 +0100 Subject: [PATCH 54/96] Fix cache key with wildcards --- lifemonitor/cache.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lifemonitor/cache.py b/lifemonitor/cache.py index 68db70f63..c8adadec1 100644 --- a/lifemonitor/cache.py +++ b/lifemonitor/cache.py @@ -266,7 +266,9 @@ def __init__(self, parent: Cache = None) -> None: @classmethod def _make_key(cls, key: str, prefix: str = CACHE_PREFIX) -> str: if cls._hash_function: - key = cls._hash_function(key.encode()).hexdigest() + parts = key.split("::") + if len(parts) > 1 and parts[1] != '*': + key = f"{parts[0]}::{cls._hash_function(parts[1].encode()).hexdigest()}" return f"{prefix}{key}" @classmethod From 20a2fd303da2abe664fc8c48d9e9dca94025d007 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:00:00 +0100 Subject: [PATCH 55/96] Fix user-notifications relationship --- lifemonitor/auth/models.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index d1f8ff671..8d54c5df4 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -76,7 +76,9 @@ class User(db.Model, UserMixin): subscriptions = db.relationship("Subscription", cascade="all, delete-orphan") - notifications: List[UserNotification] = db.relationship("UserNotification", back_populates="user") + notifications: List[UserNotification] = db.relationship("UserNotification", + back_populates="user", + cascade="all, delete-orphan") def __init__(self, username=None) -> None: super().__init__() @@ -453,7 +455,8 @@ class Notification(db.Model, ModelMixin): _event = db.Column("event", db.Integer, nullable=False) _data = db.Column("data", JSON, nullable=True) - users: List[UserNotification] = db.relationship("UserNotification", back_populates="notification") + users: List[UserNotification] = db.relationship("UserNotification", + back_populates="notification", cascade="all, delete-orphan") def __init__(self, event: EventType, name: str, data: object, users: List[User]) -> None: self.name = name @@ -502,11 +505,13 @@ class UserNotification(db.Model): notification_id = db.Column(db.Integer, db.ForeignKey("notification.id"), nullable=False, primary_key=True) user: User = db.relationship("User", uselist=False, - back_populates="notifications", foreign_keys=[user_id]) + back_populates="notifications", foreign_keys=[user_id], + cascade="save-update") notification: Notification = db.relationship("Notification", uselist=False, back_populates="users", - foreign_keys=[notification_id]) + foreign_keys=[notification_id], + cascade="save-update") def __init__(self, user: User, notification: Notification) -> None: self.user = user From 5e0ebd82aba1d82d3650bbc8606f1fd1d4c5bf4d Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:08:51 +0100 Subject: [PATCH 56/96] Fix notification schema: add list of events to the subscription model --- ...add_list_of_events_to_the_subscription_.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 migrations/versions/24c34681f538_add_list_of_events_to_the_subscription_.py diff --git a/migrations/versions/24c34681f538_add_list_of_events_to_the_subscription_.py b/migrations/versions/24c34681f538_add_list_of_events_to_the_subscription_.py new file mode 100644 index 000000000..4d091cf15 --- /dev/null +++ b/migrations/versions/24c34681f538_add_list_of_events_to_the_subscription_.py @@ -0,0 +1,25 @@ +"""Add list of events to the subscription model + +Revision ID: 24c34681f538 +Revises: d5da43a38a6a +Create Date: 2022-01-21 15:29:19.648485 + +""" +from alembic import op +import sqlalchemy as sa +from lifemonitor.models import IntegerSet + +# revision identifiers, used by Alembic. +revision = '24c34681f538' +down_revision = 'd5da43a38a6a' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('subscription', sa.Column('events', IntegerSet(), nullable=True)) + op.execute("UPDATE subscription SET events = '0'") + + +def downgrade(): + op.drop_column('subscription', 'events') From fda85fd6110ab9b4f0de2ca6c1be3cf2a562fbd8 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:09:40 +0100 Subject: [PATCH 57/96] Fix notification schema: replace property type with event --- ...bedbbf_change_notification_type_str_to_.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 migrations/versions/a46c90bedbbf_change_notification_type_str_to_.py diff --git a/migrations/versions/a46c90bedbbf_change_notification_type_str_to_.py b/migrations/versions/a46c90bedbbf_change_notification_type_str_to_.py new file mode 100644 index 000000000..00c4e0e97 --- /dev/null +++ b/migrations/versions/a46c90bedbbf_change_notification_type_str_to_.py @@ -0,0 +1,30 @@ +"""Change Notification.type::str to Notification.event::EventType + +Revision ID: a46c90bedbbf +Revises: 24c34681f538 +Create Date: 2022-01-24 09:27:57.815975 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a46c90bedbbf' +down_revision = '24c34681f538' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('notification', sa.Column('event', sa.Integer(), nullable=False)) + op.drop_column('notification', 'type') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('notification', sa.Column('type', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.drop_column('notification', 'event') + # ### end Alembic commands ### From d03ec713193b837b49cc0525112644ebea0368fa Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:10:38 +0100 Subject: [PATCH 58/96] Update notification schema: add UUID property --- lifemonitor/auth/models.py | 1 + ...add_uuid_property_to_notification_model.py | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 migrations/versions/505e4e6976de_add_uuid_property_to_notification_model.py diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 8d54c5df4..13954064c 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -450,6 +450,7 @@ def has_event(self, event: EventType) -> bool: class Notification(db.Model, ModelMixin): id = db.Column(db.Integer, primary_key=True) + uuid = db.Column(UUID, default=_uuid.uuid4, nullable=False, index=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) name = db.Column("name", db.String, nullable=True, index=True) _event = db.Column("event", db.Integer, nullable=False) diff --git a/migrations/versions/505e4e6976de_add_uuid_property_to_notification_model.py b/migrations/versions/505e4e6976de_add_uuid_property_to_notification_model.py new file mode 100644 index 000000000..7122b57b5 --- /dev/null +++ b/migrations/versions/505e4e6976de_add_uuid_property_to_notification_model.py @@ -0,0 +1,36 @@ +"""Add uuid property to notification model + +Revision ID: 505e4e6976de +Revises: 296634f13bc4 +Create Date: 2022-01-27 11:43:27.119562 + +""" +from alembic import op +import sqlalchemy as sa +from lifemonitor.models import UUID +import uuid as _uuid + +# revision identifiers, used by Alembic. +revision = '505e4e6976de' +down_revision = '296634f13bc4' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + notifications = bind.execute("SELECT id FROM notification") + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('notification', sa.Column('uuid', UUID(), nullable=True)) + for n in notifications: + bind.execute(f"UPDATE notification SET uuid = '{_uuid.uuid4()}' WHERE id = {n[0]}") + op.alter_column('notification', 'uuid', nullable=False) + op.create_index(op.f('ix_notification_uuid'), 'notification', ['uuid'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_notification_uuid'), table_name='notification') + op.drop_column('notification', 'uuid') + # ### end Alembic commands ### From a3f4d6de71e9eef70ee63146e622be49e6160ee4 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:11:47 +0100 Subject: [PATCH 59/96] Add model methods to handle user notifications --- lifemonitor/auth/models.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 13954064c..f00d0f928 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -206,9 +206,25 @@ def verify_email(self, code): self._email_verified = True return True - def remove_notification(self, n: Notification): - if n is not None: - n.remove_user(n) + def get_user_notification(self, notification_uuid: str) -> UserNotification: + return next((n for n in self.notifications if str(n.notification.uuid) == notification_uuid), None) + + def get_notification(self, notification_uuid: str) -> Notification: + user_notification = self.get_user_notification(notification_uuid) + return None if not user_notification else user_notification.notification + + def remove_notification(self, n: Notification | UserNotification): + user_notification = None + try: + user_notification = self.get_user_notification(n.uuid) + if user_notification is None: + raise ValueError(f"notification {n.uuid} not associated to this user") + except Exception: + user_notification = n + if n is None: + raise ValueError("notification cannot be None") + self.notifications.remove(user_notification) + logger.debug("User notification %r removed", user_notification) def has_permission(self, resource: Resource) -> bool: return self.get_permission(resource) is not None From b2b25efd32e8e743d826110efc818cf987108019 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:12:57 +0100 Subject: [PATCH 60/96] Reformat --- lifemonitor/api/serializers.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lifemonitor/api/serializers.py b/lifemonitor/api/serializers.py index 9a1419823..1f1237fe2 100644 --- a/lifemonitor/api/serializers.py +++ b/lifemonitor/api/serializers.py @@ -280,6 +280,14 @@ def remove_skip_values(self, data, **kwargs): } +def format_availability_issues(status: models.WorkflowStatus): + issues = status.availability_issues + logger.info(issues) + if 'not_available' == status.aggregated_status and len(issues) > 0: + return ', '.join([f"{i['issue']}: Unable to get resource '{i['resource']}' from service '{i['service']}'" if 'service' in i and 'resource' in i else i['issue'] for i in issues]) + return None + + class BuildSummarySchema(ResourceMetadataSchema): __envelope__ = {"single": None, "many": None} __model__ = models.TestBuild @@ -304,14 +312,6 @@ def get_links(self, obj): return links -def format_availability_issues(status: models.WorkflowStatus): - issues = status.availability_issues - logger.info(issues) - if 'not_available' == status.aggregated_status and len(issues) > 0: - return ', '.join([f"{i['issue']}: Unable to get resource '{i['resource']}' from service '{i['service']}'" if 'service' in i and 'resource' in i else i['issue'] for i in issues]) - return None - - class WorkflowStatusSchema(WorkflowVersionSchema): __envelope__ = {"single": None, "many": "items"} __model__ = models.WorkflowStatus From 484bbb2bb513f82f0d399377cd5321870cbb82a5 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:14:04 +0100 Subject: [PATCH 61/96] Auto register submitter to workflow events --- lifemonitor/api/services.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index 4c37af45e..d89f11f2d 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -133,6 +133,8 @@ def register_workflow(cls, roc_link, workflow_submitter: User, workflow_version, if workflow_submitter: wv.permissions.append(Permission(user=workflow_submitter, roles=[RoleType.owner])) + # automatically register submitter's subscription to workflow events + workflow_submitter.subscribe(w) if authorization: auth = ExternalServiceAuthorizationHeader(workflow_submitter, header=authorization) auth.resources.append(wv) From 2d36bfd6075c0c6e971bf5e44577a7bccdb7d92e Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:19:41 +0100 Subject: [PATCH 62/96] Allow to set subscription events via LM service --- lifemonitor/api/services.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index d89f11f2d..09e1b2f82 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -232,10 +232,12 @@ def _init_test_suite_from_json(wv: models.WorkflowVersion, submitter: models.Use raise lm_exceptions.SpecificationNotValidException(f"Missing property: {e}") @staticmethod - def subscribe_user_resource(user: User, resource: Resource) -> Subscription: + def subscribe_user_resource(user: User, resource: Resource, events: List[str] = None) -> Subscription: assert user and not user.is_anonymous, "Invalid user" assert resource, "Invalid resource" subscription = user.subscribe(resource) + if events: + subscription.events = events user.save() return subscription From f9c1bb884ad0ee4312e1ecf1939fd51f047a777c Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:23:01 +0100 Subject: [PATCH 63/96] Update specs and controller to support subscription events --- lifemonitor/api/controllers.py | 19 ++++++++++++ specs/api.yaml | 56 +++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lifemonitor/api/controllers.py b/lifemonitor/api/controllers.py index 097e66c29..9687ef74b 100644 --- a/lifemonitor/api/controllers.py +++ b/lifemonitor/api/controllers.py @@ -276,6 +276,25 @@ def user_workflow_subscribe(wf_uuid): return auth_serializers.SubscriptionSchema(exclude=('meta', 'links')).dump(subscription), 201 +@authorized +def user_workflow_subscribe_events(wf_uuid, body): + workflow_version = _get_workflow_or_problem(wf_uuid) + events = body + if events is None or not isinstance(events, list): + return lm_exceptions.report_problem(400, "Bad request", + detail=messages.unexpected_registry_uri) + if isinstance(workflow_version, Response): + return workflow_version + subscribed = current_user.is_subscribed_to(workflow_version.workflow) + subscription = lm.subscribe_user_resource(current_user, workflow_version.workflow, events=events) + logger.debug("Updated subscription events: %r", subscription) + clear_cache() + if not subscribed: + return auth_serializers.SubscriptionSchema(exclude=('meta', 'links')).dump(subscription), 201 + else: + return connexion.NoContent, 204 + + @authorized def user_workflow_unsubscribe(wf_uuid): response = _get_workflow_or_problem(wf_uuid) diff --git a/specs/api.yaml b/specs/api.yaml index d69518bc1..dc6b79ede 100644 --- a/specs/api.yaml +++ b/specs/api.yaml @@ -474,7 +474,7 @@ paths: tags: ["Users"] x-openapi-router-controller: lifemonitor.auth.controllers operationId: "user_subscriptions_get" - summary: "List all subscriptions for the current user" + summary: "List subscriptions for the current user" description: | List all subscriptions for the current user security: @@ -625,6 +625,42 @@ paths: "404": $ref: "#/components/responses/NotFound" + put: + summary: Subscribe user to workflow events + description: "Subscribe the current (authenticated) user to a list of events for the specified workflow" + x-openapi-router-controller: lifemonitor.api.controllers + operationId: "user_workflow_subscribe_events" + tags: ["Users"] + security: + - apiKey: ["workflow.write"] + - AuthorizationCodeFlow: ["workflow.write"] + - RegistryCodeFlow: ["workflow.write"] + parameters: + - $ref: "#/components/parameters/wf_uuid" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListOfEvents" + responses: + "201": + description: "Subscription to events created" + content: + application/json: + schema: + $ref: "#/components/schemas/Subscription" + "204": + description: "Subscription updated with events" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /workflows/{wf_uuid}/unsubscribe: post: summary: Unsubscribe user from a workflow @@ -1462,11 +1498,29 @@ components: description: | A timestamp for the modification time of the subscription example: 1616427512.0 + events: + $ref: "#/components/schemas/ListOfEvents" required: - user - resource - created - modified + - events + + EventType: + type: string + enum: + - ALL + - BUILD_FAILED + - BUILD_RECOVERED + + ListOfEvents: + type: array + items: + $ref: "#/components/schemas/EventType" + description: | + List of events related to the specified workflow + example: ["all"] ListOfSubscriptions: type: object From b6b666a14615b6fed894ca1bcc8f1886c499987e Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:24:09 +0100 Subject: [PATCH 64/96] Update serialisation schema of Notification model --- lifemonitor/auth/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lifemonitor/auth/serializers.py b/lifemonitor/auth/serializers.py index 81450d678..7f41a1ab1 100644 --- a/lifemonitor/auth/serializers.py +++ b/lifemonitor/auth/serializers.py @@ -142,11 +142,12 @@ class NotificationSchema(ResourceMetadataSchema): class Meta: model = models.UserNotification + uuid = fields.String(attribute='notification.uuid') created = fields.DateTime(attribute='notification.created') emailed = fields.DateTime(attribute='emailed') read = fields.DateTime(attribute='read') name = fields.String(attribute="notification.name") - type = fields.String(attribute="notification.type") + event = fields.String(attribute="notification.event.name") data = fields.Dict(attribute="notification.data") @post_dump From 370b0ca0f02f6977166cb2cea38eac35c4a14c3f Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 20:26:49 +0100 Subject: [PATCH 65/96] Add migration to register submitter to events of existing workflows --- ...13bc4_automatically_register_submitter_.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 migrations/versions/296634f13bc4_automatically_register_submitter_.py diff --git a/migrations/versions/296634f13bc4_automatically_register_submitter_.py b/migrations/versions/296634f13bc4_automatically_register_submitter_.py new file mode 100644 index 000000000..b085e5ede --- /dev/null +++ b/migrations/versions/296634f13bc4_automatically_register_submitter_.py @@ -0,0 +1,35 @@ +"""Automatically register submitter subscription to workflows + +Revision ID: 296634f13bc4 +Revises: a46c90bedbbf +Create Date: 2022-01-24 14:07:00.098626 + +""" +import logging +from datetime import datetime + +from alembic import op + +# set logger +logger = logging.getLogger('alembic.env') + + +# revision identifiers, used by Alembic. +revision = '296634f13bc4' +down_revision = 'a46c90bedbbf' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + res = bind.execute('SELECT * from workflow_version WHERE workflow_id NOT IN (SELECT resource_id FROM subscription)') + logger.info(res) + for wdata in res: + logger.info("(v_id,submitter,wf_id)=(%d,%d,%d)", wdata[0], wdata[1], wdata[2]) + now = datetime.utcnow() + bind.execute(f"INSERT INTO subscription (user_id,created,modified,resource_id,events) VALUES ({wdata[1]},TIMESTAMP '{now}',TIMESTAMP '{now}',{wdata[2]},'0')") + + +def downgrade(): + pass From fb13a1a36f7d9c6f85d895bd67241d138fddcfd1 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 21:17:03 +0100 Subject: [PATCH 66/96] Add support for notification API --- lifemonitor/api/services.py | 25 +++++- lifemonitor/auth/controllers.py | 49 ++++++++++++ specs/api.yaml | 137 +++++++++++++++++++++++++++++++- 3 files changed, 207 insertions(+), 4 deletions(-) diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index 09e1b2f82..c5d66cd57 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -21,13 +21,14 @@ from __future__ import annotations import logging +from datetime import datetime from typing import List, Optional, Union import lifemonitor.exceptions as lm_exceptions from lifemonitor.api import models from lifemonitor.auth.models import (ExternalServiceAuthorizationHeader, - Permission, Resource, RoleType, - Subscription, User) + Notification, Permission, Resource, + RoleType, Subscription, User) from lifemonitor.auth.oauth2.client import providers from lifemonitor.auth.oauth2.client.models import OAuthIdentity from lifemonitor.auth.oauth2.server import server @@ -499,3 +500,23 @@ def get_workflow_registries() -> models.WorkflowRegistry: @staticmethod def get_workflow_registry(uuid) -> models.WorkflowRegistry: return models.WorkflowRegistry.find_by_uuid(uuid) + + @staticmethod + def setUserNotificationReadingTime(user: User, notifications: List[dict]): + for n in notifications: + un = user.get_user_notification(n['uuid']) + if un is None: + return lm_exceptions.EntityNotFoundException(Notification, entity_id=n['uuid']) + un.read = datetime.utcnow() + user.save() + + @staticmethod + def deleteUserNotifications(user: User, list_of_uuids: List[str]): + for n_uuid in list_of_uuids: + logger.debug("Searching notification %r ...", n_uuid) + n = user.get_user_notification(n_uuid) + logger.debug("Search result notification %r ...", n) + if n is None: + return lm_exceptions.EntityNotFoundException(Notification, entity_id=n_uuid) + user.notifications.remove(n) + user.save() diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index bb2ebbe19..76c35c6e5 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -19,11 +19,15 @@ # SOFTWARE. import logging +from datetime import datetime +import connexion import flask from flask import flash, redirect, render_template, request, session, url_for from flask_login import login_required, login_user, logout_user +from lifemonitor.api.services import LifeMonitor from lifemonitor.cache import Timeout, cached, clear_cache +from lifemonitor.lang import messages as lm_messages from lifemonitor.utils import (NextRouteRegistry, next_route_aware, split_by_crlf) @@ -39,6 +43,9 @@ from .services import (authorized, current_registry, current_user, delete_api_key, generate_new_api_key, login_manager) +# Initialize a reference to the LifeMonitor instance +lm = LifeMonitor.get_instance() + # Config a module level logger logger = logging.getLogger(__name__) @@ -61,6 +68,48 @@ def show_current_user_profile(): return exceptions.report_problem_from_exception(e) +@authorized +@cached(timeout=Timeout.REQUEST) +def user_notifications_get(): + try: + if current_user and not current_user.is_anonymous: + return serializers.ListOfNotifications().dump(current_user.notifications) + raise exceptions.Forbidden(detail="Client type unknown") + except Exception as e: + return exceptions.report_problem_from_exception(e) + + +@authorized +@cached(timeout=Timeout.REQUEST) +def user_notifications_put(body): + try: + if not current_user or current_user.is_anonymous: + raise exceptions.Forbidden(detail="Client type unknown") + lm.setUserNotificationReadingTime(current_user, body.get('items', [])) + clear_cache() + return connexion.NoContent, 204 + except Exception as e: + logger.debug(e) + return exceptions.report_problem_from_exception(e) + + +@authorized +@cached(timeout=Timeout.REQUEST) +def user_notifications_patch(body): + try: + if not current_user or current_user.is_anonymous: + raise exceptions.Forbidden(detail="Client type unknown") + logger.debug("PATCH BODY: %r", body) + lm.deleteUserNotifications(current_user, body) + clear_cache() + return connexion.NoContent, 204 + except exceptions.EntityNotFoundException as e: + return exceptions.report_problem_from_exception(e) + except Exception as e: + logger.debug(e) + return exceptions.report_problem_from_exception(e) + + @authorized def user_subscriptions_get(): return serializers.ListOfSubscriptions().dump(current_user.subscriptions) diff --git a/specs/api.yaml b/specs/api.yaml index dc6b79ede..6a3947288 100644 --- a/specs/api.yaml +++ b/specs/api.yaml @@ -170,6 +170,96 @@ paths: "404": $ref: "#/components/responses/NotFound" + /users/current/notifications: + get: + tags: ["Users"] + x-openapi-router-controller: lifemonitor.auth.controllers + operationId: "user_notifications_get" + summary: List notifications for the current user + description: | + List all notifications for the current (authenticated) user + security: + - apiKey: ["user.profile"] + - RegistryCodeFlow: ["user.profile"] + - AuthorizationCodeFlow: ["user.profile"] + responses: + "200": + description: User profile + content: + application/json: + schema: + $ref: "#/components/schemas/ListOfNotifications" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + put: + tags: ["Users"] + x-openapi-router-controller: lifemonitor.auth.controllers + operationId: "user_notifications_put" + summary: Mark as read notifications for the current user + description: | + Mark as read notifications for the current (authenticated) user + security: + - apiKey: ["user.profile"] + - RegistryCodeFlow: ["user.profile"] + - AuthorizationCodeFlow: ["user.profile"] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListOfNotifications" + responses: + "204": + description: "Notifications updated" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + patch: + tags: ["Users"] + x-openapi-router-controller: lifemonitor.auth.controllers + operationId: "user_notifications_patch" + summary: Delete notifications for the current user + description: | + Delete notifications for the current (authenticated) user + security: + - apiKey: ["user.profile"] + - RegistryCodeFlow: ["user.profile"] + - AuthorizationCodeFlow: ["user.profile"] + requestBody: + required: true + content: + application/json: + schema: + description: List of UUIDs + type: array + items: + type: string + example: "21ac72ec-b9a5-49e0-b5a6-1322b8b54552" + responses: + "204": + description: "Notifications updated" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /registries/current: get: tags: ["Registry Client Operations"] @@ -1532,6 +1622,49 @@ components: required: - items + Notification: + type: object + description: A notification for the current user. + properties: + uuid: + type: string + description: Universal unique identifier of notification + example: aa16c6a9-571f-4a62-976f-60ea514ad2c1 + data: + type: object + description: "Notification payload" + readOnly: true + created: + type: string + description: | + A timestamp for the creation time of the notification + example: 1616427012.0 + readOnly: true + read: + type: string + description: | + A timestamp for the read time of the notification + example: 1616427012.0 + emailed: + type: string + description: | + A timestamp for the time when the email was sent + example: 1616427012.0 + readOnly: true + required: + - uuid + - created + + ListOfNotifications: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Notification" + required: + - items + IdentityProviderType: type: string enum: @@ -1692,13 +1825,13 @@ components: latest_version: type: string description: The workflow identifier on the registry - example: '1.0' + example: "1.0" versions: description: The list of workflow versions type: array items: type: string - example: ['1.0', "1.0-dev"] + example: ["1.0", "1.0-dev"] ListOfRegistryWorkflows: type: object From d01bf5dd0401e705a3e6b830edaded95bc138cfe Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 21:17:31 +0100 Subject: [PATCH 67/96] Fix missing message --- lifemonitor/lang/messages.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lifemonitor/lang/messages.py b/lifemonitor/lang/messages.py index 36246be48..ae1aadf23 100644 --- a/lifemonitor/lang/messages.py +++ b/lifemonitor/lang/messages.py @@ -56,3 +56,4 @@ "to start the authorization flow") invalid_log_offset = "Invalid offset: it should be a positive integer" invalid_log_limit = "Invalid limit: it should be a positive integer" +notification_not_found = "Notification '{}' not found" \ No newline at end of file From bbd5eecc854655f335ef8dc97ab2e07e794829bf Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 21:23:55 +0100 Subject: [PATCH 68/96] Set expiration time for async tasks --- lifemonitor/tasks/tasks.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index cfd6f7acd..3782108b1 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -14,6 +14,9 @@ # set module level logger logger = logging.getLogger(__name__) +# set expiration time (in msec) of tasks +TASK_EXPIRATION_TIME = 30000 + def schedule(trigger): """ @@ -42,13 +45,13 @@ def decorator(actor): @schedule(CronTrigger(second=0)) -@dramatiq.actor(max_retries=3) +@dramatiq.actor(max_retries=3, max_age=TASK_EXPIRATION_TIME) def heartbeat(): logger.info("Heartbeat!") @schedule(IntervalTrigger(seconds=Timeout.WORKFLOW * 3 / 4)) -@dramatiq.actor(max_retries=3) +@dramatiq.actor(max_retries=3, max_age=TASK_EXPIRATION_TIME) def check_workflows(): from flask import current_app from lifemonitor.api.controllers import workflows_rocrate_download @@ -86,7 +89,7 @@ def check_workflows(): @schedule(IntervalTrigger(seconds=Timeout.BUILD * 3 / 4)) -@dramatiq.actor(max_retries=3) +@dramatiq.actor(max_retries=3, max_age=TASK_EXPIRATION_TIME) def check_last_build(): from lifemonitor.api.models import Workflow @@ -122,7 +125,7 @@ def check_last_build(): @schedule(IntervalTrigger(seconds=60)) -@dramatiq.actor(max_retries=0, max_age=30000) +@dramatiq.actor(max_retries=0, max_age=TASK_EXPIRATION_TIME) def send_email_notifications(): notifications = Notification.not_emailed() logger.info("Found %r notifications to send by email", len(notifications)) From 48af70e275c0e3211bebbf7c78d64d5b61b90f2f Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 21:25:54 +0100 Subject: [PATCH 69/96] Update logic to identify users to be notified --- lifemonitor/tasks/tasks.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index 3782108b1..3dbcbdb51 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -7,7 +7,7 @@ from apscheduler.triggers.interval import IntervalTrigger from lifemonitor.api.models.testsuites.testbuild import BuildStatus from lifemonitor.api.serializers import BuildSummarySchema -from lifemonitor.auth.models import Notification +from lifemonitor.auth.models import EventType, Notification from lifemonitor.cache import Timeout from lifemonitor.mail import send_notification @@ -96,7 +96,8 @@ def check_last_build(): logger.info("Starting 'check_last build' task...") for w in Workflow.all(): try: - for s in w.latest_version.test_suites: + latest_version = w.latest_version + for s in latest_version.test_suites: logger.info("Updating workflow: %r", w) for i in s.test_instances: with i.cache.transaction(str(i)): @@ -109,10 +110,8 @@ def check_last_build(): if last_build.status == BuildStatus.FAILED: notification_name = f"{last_build} FAILED" if len(Notification.find_by_name(notification_name)) == 0: - users = {s.user for s in w.subscriptions if s.user.email_notifications_enabled} - users.update({v.submitter for v in w.versions.values() if v.submitter.email_notifications_enabled}) - users.update({s.user for v in w.versions.values() for s in v.subscriptions if s.user.email_notifications_enabled}) - n = Notification(Notification.Types.BUILD_FAILED.name, + users = latest_version.workflow.get_subscribers() + n = Notification(EventType.BUILD_FAILED, notification_name, {'build': BuildSummarySchema().dump(last_build)}, users) @@ -133,7 +132,8 @@ def send_email_notifications(): for n in notifications: logger.debug("Processing notification %r ...", n) recipients = [u.user.email for u in n.users - if u.emailed is None and u.user.email is not None] + if u.emailed is None and + u.user.email_notifications_enabled and u.user.email is not None] sent = send_notification(n, recipients) logger.debug("Notification email sent: %r", sent is not None) if sent: From 445baa7613e03a5729cd1ed029d4bee9b6bc6f6b Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 22:32:40 +0100 Subject: [PATCH 70/96] Fix import --- lifemonitor/auth/controllers.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index 76c35c6e5..4c60a15b9 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -25,7 +25,7 @@ import flask from flask import flash, redirect, render_template, request, session, url_for from flask_login import login_required, login_user, logout_user -from lifemonitor.api.services import LifeMonitor + from lifemonitor.cache import Timeout, cached, clear_cache from lifemonitor.lang import messages as lm_messages from lifemonitor.utils import (NextRouteRegistry, next_route_aware, @@ -43,9 +43,6 @@ from .services import (authorized, current_registry, current_user, delete_api_key, generate_new_api_key, login_manager) -# Initialize a reference to the LifeMonitor instance -lm = LifeMonitor.get_instance() - # Config a module level logger logger = logging.getLogger(__name__) @@ -57,6 +54,11 @@ login_manager.login_view = "auth.login" +def __lifemonitor_service__(): + from lifemonitor.api.services import LifeMonitor + return LifeMonitor.get_instance() + + @authorized @cached(timeout=Timeout.SESSION) def show_current_user_profile(): @@ -85,7 +87,7 @@ def user_notifications_put(body): try: if not current_user or current_user.is_anonymous: raise exceptions.Forbidden(detail="Client type unknown") - lm.setUserNotificationReadingTime(current_user, body.get('items', [])) + __lifemonitor_service__().setUserNotificationReadingTime(current_user, body.get('items', [])) clear_cache() return connexion.NoContent, 204 except Exception as e: @@ -100,7 +102,7 @@ def user_notifications_patch(body): if not current_user or current_user.is_anonymous: raise exceptions.Forbidden(detail="Client type unknown") logger.debug("PATCH BODY: %r", body) - lm.deleteUserNotifications(current_user, body) + __lifemonitor_service__().deleteUserNotifications(current_user, body) clear_cache() return connexion.NoContent, 204 except exceptions.EntityNotFoundException as e: From 0bb79ba3288a3846e5f5c9f4558ed3905f49e635 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 22:34:15 +0100 Subject: [PATCH 71/96] Update tests --- .../controllers/test_user_subscriptions.py | 47 ++++++++++++++++++- tests/unit/api/models/test_subscriptions.py | 47 +++++++++++++++---- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/tests/integration/api/controllers/test_user_subscriptions.py b/tests/integration/api/controllers/test_user_subscriptions.py index 9236fb441..2d29a5293 100644 --- a/tests/integration/api/controllers/test_user_subscriptions.py +++ b/tests/integration/api/controllers/test_user_subscriptions.py @@ -74,6 +74,25 @@ def test_user_subscribe_workflow(app_client, client_auth_method, user1, user1_au logger.debug(data) +@pytest.mark.parametrize("client_auth_method", [ + # ClientAuthenticationMethod.BASIC, + ClientAuthenticationMethod.API_KEY, + ClientAuthenticationMethod.AUTHORIZATION_CODE, + ClientAuthenticationMethod.REGISTRY_CODE_FLOW +], indirect=True) +@pytest.mark.parametrize("user1", [True], indirect=True) +def test_user_subscribe_workflow_events(app_client, client_auth_method, user1, user1_auth, valid_workflow): + workflow = utils.pick_workflow(user1, valid_workflow) + logger.debug("User1 Auth Headers: %r", user1_auth) + enable_auto_login(user1['user']) + r = app_client.post( + utils.build_workflow_path(workflow, include_version=False, subpath='subscribe'), headers=user1_auth + ) + assert r.status_code == 201, f"Error when subscribing to the workflow {workflow}" + data = json.loads(r.data.decode()) + logger.debug(data) + + @pytest.mark.parametrize("client_auth_method", [ # ClientAuthenticationMethod.BASIC, ClientAuthenticationMethod.API_KEY, @@ -88,8 +107,25 @@ def test_user_unsubscribe_workflow(app_client, client_auth_method, user1, user1_ # register a subscription workflow = lm.get_workflow(wdata['uuid']) assert workflow, "Invalid workflow" - lm.subscribe_user_resource(user, workflow) - assert len(user.subscriptions) == 1, "Invalid number of subscriptions" + subscription = user.get_subscription(workflow) + assert subscription, "User should be subscribed to the workflow" + + # check number of subscriptions before unsubscribing + r = app_client.get('/users/current/subscriptions', headers=user1_auth) + assert r.status_code == 200, f"Error when getting the list of subscriptions to the workflow {workflow}" + data = r.get_json() + logger.debug("Current list of subscriptions: %r", data) + assert 'items' in data, "Unexpected response type: missing 'items' property" + number_of_subscriptions = len(data['items']) + logger.debug("Current number of subscriptions: %r", number_of_subscriptions) + + # check if there exists a subscription for the workflow + found = False + for s in data['items']: + if s['resource']['uuid'] == str(workflow.uuid): + found = True + break + assert found, "Unable to find the workflow among subscriptions" # try to delete the subscription via API r = app_client.post( @@ -97,6 +133,13 @@ def test_user_unsubscribe_workflow(app_client, client_auth_method, user1, user1_ ) assert r.status_code == 204, f"Error when unsubscribing to the workflow {workflow}" + # check number of subscriptions after unsubscribing + r = app_client.get('/users/current/subscriptions', headers=user1_auth) + assert r.status_code == 200, f"Error when getting the list of subscriptions to the workflow {workflow}" + data = r.get_json() + assert 'items' in data, "Unexpected response type: missing 'items' property" + assert len(data['items']) == number_of_subscriptions - 1, "Unexpected number of subscriptions" + @pytest.mark.parametrize("client_auth_method", [ # ClientAuthenticationMethod.BASIC, diff --git a/tests/unit/api/models/test_subscriptions.py b/tests/unit/api/models/test_subscriptions.py index c864d3bd5..acf99c031 100644 --- a/tests/unit/api/models/test_subscriptions.py +++ b/tests/unit/api/models/test_subscriptions.py @@ -22,21 +22,48 @@ import logging -from lifemonitor.auth.models import Subscription, User +from lifemonitor.auth.models import Subscription, User, EventType from tests import utils logger = logging.getLogger() def test_workflow_subscription(user1: dict, valid_workflow: str): - _, workflow = utils.pick_and_register_workflow(user1, valid_workflow) + _, workflow_version = utils.pick_and_register_workflow(user1, valid_workflow) user: User = user1['user'] - s: Subscription = user.subscribe(workflow) - logger.debug("Subscription: %r", s) - assert s, "Subscription should not be empty" - assert len(user.subscriptions) == 1, "Unexpected number of subscriptions" - - s: Subscription = user.unsubscribe(workflow) - logger.debug("Subscription: %r", s) - assert s, "Subscription should not be empty" + + # check default subscription + s = user.get_subscription(workflow_version.workflow) + assert s, "The submitter subscription should be automatically registered" + # check default events + assert len(s.events) == 1, "Invalid number of events" + assert s.has_event(EventType.ALL), f"Event '{EventType.ALL.name}' not registered on the subscription" + for event in EventType.list(): + assert s.has_event(event), f"Event '{event.name}' should be included" + + # check delete all events + s.events = None + assert len(s.events) == 0, "Invalid number of events" + s.save() + + # check event udpate + s.events = [EventType.BUILD_FAILED, EventType.BUILD_RECOVERED] + s.save() + assert len(s.events) == 2, "Invalid number of events" + assert not s.has_event(EventType.ALL), f"Event '{EventType.ALL.name}' should not be registered on the subscription" + + +def test_workflow_unsubscription(user1: dict, valid_workflow: str): + _, workflow_version = utils.pick_and_register_workflow(user1, valid_workflow) + user: User = user1['user'] + + # check default subscription + s = user.get_subscription(workflow_version.workflow) + assert s, "The submitter subscription should be automatically registered" + + # test unsubscription + user.unsubscribe(workflow_version.workflow) + user.save() assert len(user.subscriptions) == 0, "Unexpected number of subscriptions" + s = user.get_subscription(workflow_version.workflow) + assert s is None, "Subscription should be empty" From 852cd1351adc1108f06a53fcd58fa1c4161fcc0d Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 27 Jan 2022 22:39:57 +0100 Subject: [PATCH 72/96] Fix flake8 issues --- lifemonitor/auth/controllers.py | 3 --- lifemonitor/lang/messages.py | 2 +- lifemonitor/tasks/tasks.py | 7 ++++--- tests/unit/api/models/test_subscriptions.py | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index 4c60a15b9..d6fd7ac4b 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -19,15 +19,12 @@ # SOFTWARE. import logging -from datetime import datetime import connexion import flask from flask import flash, redirect, render_template, request, session, url_for from flask_login import login_required, login_user, logout_user - from lifemonitor.cache import Timeout, cached, clear_cache -from lifemonitor.lang import messages as lm_messages from lifemonitor.utils import (NextRouteRegistry, next_route_aware, split_by_crlf) diff --git a/lifemonitor/lang/messages.py b/lifemonitor/lang/messages.py index ae1aadf23..284f51ba9 100644 --- a/lifemonitor/lang/messages.py +++ b/lifemonitor/lang/messages.py @@ -56,4 +56,4 @@ "to start the authorization flow") invalid_log_offset = "Invalid offset: it should be a positive integer" invalid_log_limit = "Invalid limit: it should be a positive integer" -notification_not_found = "Notification '{}' not found" \ No newline at end of file +notification_not_found = "Notification '{}' not found" diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index 3dbcbdb51..cb2b386b1 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -131,9 +131,10 @@ def send_email_notifications(): count = 0 for n in notifications: logger.debug("Processing notification %r ...", n) - recipients = [u.user.email for u in n.users - if u.emailed is None and - u.user.email_notifications_enabled and u.user.email is not None] + recipients = [ + u.user.email for u in n.users + if u.emailed is None and u.user.email_notifications_enabled and u.user.email is not None + ] sent = send_notification(n, recipients) logger.debug("Notification email sent: %r", sent is not None) if sent: diff --git a/tests/unit/api/models/test_subscriptions.py b/tests/unit/api/models/test_subscriptions.py index acf99c031..b610c6789 100644 --- a/tests/unit/api/models/test_subscriptions.py +++ b/tests/unit/api/models/test_subscriptions.py @@ -22,7 +22,7 @@ import logging -from lifemonitor.auth.models import Subscription, User, EventType +from lifemonitor.auth.models import User, EventType from tests import utils logger = logging.getLogger() From e0c0df1db851a836c4e52b667d02d80157bba4db Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:04:10 +0100 Subject: [PATCH 73/96] Update methods to get list of event types --- lifemonitor/auth/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index f00d0f928..12b171647 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -325,15 +325,15 @@ class EventType(Enum): BUILD_RECOVERED = 2 @classmethod - def list(cls): + def all(cls): return list(map(lambda c: c, cls)) @classmethod - def names(cls): + def all_names(cls): return list(map(lambda c: c.name, cls)) @classmethod - def values(cls): + def all_values(cls): return list(map(lambda c: c.value, cls)) @classmethod From 96216bf898568fdb70ddc23223b0b25fe426b868 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:05:24 +0100 Subject: [PATCH 74/96] Allow to check list of events on subscription model --- lifemonitor/auth/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 12b171647..02e7c4967 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -462,6 +462,13 @@ def has_event(self, event: EventType) -> bool: EventType.ALL.value in self.__get_events() or \ event.value in self.__get_events() + def has_events(self, events: List[EventType]) -> bool: + if events: + for e in events: + if not self.has_event(e): + return False + return True + class Notification(db.Model, ModelMixin): From 736de3df4a26b90769de9613ff35dd31af07b610 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:07:13 +0100 Subject: [PATCH 75/96] Check event type when updating subscription events --- lifemonitor/auth/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index 02e7c4967..a460c5b55 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -455,6 +455,8 @@ def events(self, events: List[EventType]): self.__get_events().clear() if events: for e in events: + if not isinstance(e, EventType): + raise ValueError(f"Not valid event value: expected {EventType.__class__}, got {type(e)}") self.__get_events().add(e.value) def has_event(self, event: EventType) -> bool: From 969fe3e5fa6abaea43db2423a93a0a08b131b552 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:11:54 +0100 Subject: [PATCH 76/96] Fix status code: return 204 when subscription exists --- lifemonitor/api/controllers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lifemonitor/api/controllers.py b/lifemonitor/api/controllers.py index 9687ef74b..c76d5239a 100644 --- a/lifemonitor/api/controllers.py +++ b/lifemonitor/api/controllers.py @@ -270,10 +270,14 @@ def user_workflow_subscribe(wf_uuid): response = _get_workflow_or_problem(wf_uuid) if isinstance(response, Response): return response + subscribed = current_user.is_subscribed_to(response.workflow) subscription = lm.subscribe_user_resource(current_user, response.workflow) logger.debug("Created new subscription: %r", subscription) clear_cache() - return auth_serializers.SubscriptionSchema(exclude=('meta', 'links')).dump(subscription), 201 + if not subscribed: + return auth_serializers.SubscriptionSchema(exclude=('meta', 'links')).dump(subscription), 201 + else: + return connexion.NoContent, 204 @authorized From 426a316d0a481448310835e9a63fbc364fe5c604 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:15:33 +0100 Subject: [PATCH 77/96] Update subscribe to events. Check event type and fix status code --- lifemonitor/api/controllers.py | 38 +++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/lifemonitor/api/controllers.py b/lifemonitor/api/controllers.py index c76d5239a..f5d23687c 100644 --- a/lifemonitor/api/controllers.py +++ b/lifemonitor/api/controllers.py @@ -18,9 +18,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import os import base64 import logging +import os import tempfile import connexion @@ -29,8 +29,10 @@ from flask import Response, request from lifemonitor.api import serializers from lifemonitor.api.services import LifeMonitor -from lifemonitor.auth import authorized, current_registry, current_user +from lifemonitor.auth import (EventType, authorized, current_registry, + current_user) from lifemonitor.auth import serializers as auth_serializers +from lifemonitor.auth.models import Subscription from lifemonitor.auth.oauth2.client.models import \ OAuthIdentityNotFoundException from lifemonitor.cache import Timeout, cached, clear_cache @@ -283,20 +285,28 @@ def user_workflow_subscribe(wf_uuid): @authorized def user_workflow_subscribe_events(wf_uuid, body): workflow_version = _get_workflow_or_problem(wf_uuid) - events = body - if events is None or not isinstance(events, list): + + if body is None or not isinstance(body, list): return lm_exceptions.report_problem(400, "Bad request", - detail=messages.unexpected_registry_uri) - if isinstance(workflow_version, Response): - return workflow_version - subscribed = current_user.is_subscribed_to(workflow_version.workflow) - subscription = lm.subscribe_user_resource(current_user, workflow_version.workflow, events=events) - logger.debug("Updated subscription events: %r", subscription) - clear_cache() - if not subscribed: + detail=messages.invalid_event_type.format(EventType.all_names())) + try: + events = EventType.from_strings(body) + if isinstance(workflow_version, Response): + return workflow_version + subscription: Subscription = current_user.get_subscription(workflow_version.workflow) + if subscription and subscription.events == events: + return connexion.NoContent, 204 + subscription = lm.subscribe_user_resource(current_user, workflow_version.workflow, events=events) + logger.debug("Updated subscription events: %r", subscription) + clear_cache() return auth_serializers.SubscriptionSchema(exclude=('meta', 'links')).dump(subscription), 201 - else: - return connexion.NoContent, 204 + except ValueError as e: + logger.debug(e) + return lm_exceptions.report_problem(400, "Bad request", + detail=messages.invalid_event_type.format(EventType.all_names())) + except Exception as e: + logger.debug(e) + return lm_exceptions.report_problem_from_exception(e) @authorized From 9d05680de94cb2dfdca1d8bb06b36fc05a74e6d0 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:17:12 +0100 Subject: [PATCH 78/96] Fix missing models on auth package --- lifemonitor/auth/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lifemonitor/auth/__init__.py b/lifemonitor/auth/__init__.py index 54d06aa3c..601d78574 100644 --- a/lifemonitor/auth/__init__.py +++ b/lifemonitor/auth/__init__.py @@ -22,6 +22,7 @@ import lifemonitor.auth.oauth2 as oauth2 +from .models import User, UserNotification, Notification, EventType from .controllers import blueprint as auth_blueprint from .services import (NotAuthorizedException, authorized, current_registry, current_user, login_manager, login_registry, login_user, @@ -40,6 +41,7 @@ def register_api(app, specs_dir): __all__ = [ + User, UserNotification, Notification, EventType, register_api, current_user, current_registry, authorized, login_user, logout_user, login_registry, logout_registry, NotAuthorizedException From ac04f27b36e40fd65993f79e5dcdd0979b7639cd Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:18:35 +0100 Subject: [PATCH 79/96] Fix signature of service method --- lifemonitor/api/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index c5d66cd57..2194a8093 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -26,7 +26,7 @@ import lifemonitor.exceptions as lm_exceptions from lifemonitor.api import models -from lifemonitor.auth.models import (ExternalServiceAuthorizationHeader, +from lifemonitor.auth.models import (EventType, ExternalServiceAuthorizationHeader, Notification, Permission, Resource, RoleType, Subscription, User) from lifemonitor.auth.oauth2.client import providers @@ -233,7 +233,7 @@ def _init_test_suite_from_json(wv: models.WorkflowVersion, submitter: models.Use raise lm_exceptions.SpecificationNotValidException(f"Missing property: {e}") @staticmethod - def subscribe_user_resource(user: User, resource: Resource, events: List[str] = None) -> Subscription: + def subscribe_user_resource(user: User, resource: Resource, events: List[EventType] = None) -> Subscription: assert user and not user.is_anonymous, "Invalid user" assert resource, "Invalid resource" subscription = user.subscribe(resource) From d9145ffcd565f762037c65a84657c05dc072f4b1 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:18:53 +0100 Subject: [PATCH 80/96] Add missing message --- lifemonitor/lang/messages.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lifemonitor/lang/messages.py b/lifemonitor/lang/messages.py index 284f51ba9..a4df4be7c 100644 --- a/lifemonitor/lang/messages.py +++ b/lifemonitor/lang/messages.py @@ -56,4 +56,5 @@ "to start the authorization flow") invalid_log_offset = "Invalid offset: it should be a positive integer" invalid_log_limit = "Invalid limit: it should be a positive integer" +invalid_event_type = "Invalid event type. Accepted values are {}" notification_not_found = "Notification '{}' not found" From 315f373e4ee0817e5bb598cbf46a7add11fbce58 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 10:20:01 +0100 Subject: [PATCH 81/96] Update tests --- .../controllers/test_user_subscriptions.py | 69 ++++++++++++++++--- .../services/test_workflow_subscriptions.py | 46 +++++++++++-- tests/unit/api/models/test_subscriptions.py | 2 +- 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/tests/integration/api/controllers/test_user_subscriptions.py b/tests/integration/api/controllers/test_user_subscriptions.py index 2d29a5293..275b91e12 100644 --- a/tests/integration/api/controllers/test_user_subscriptions.py +++ b/tests/integration/api/controllers/test_user_subscriptions.py @@ -23,7 +23,7 @@ import pytest from lifemonitor.api.services import LifeMonitor -from lifemonitor.auth.models import User +from lifemonitor.auth.models import EventType, User from tests import utils from tests.conftest_helpers import enable_auto_login from tests.conftest_types import ClientAuthenticationMethod @@ -62,12 +62,34 @@ def test_user_unsubscribe_not_authorized(app_client, client_auth_method, user1, ClientAuthenticationMethod.REGISTRY_CODE_FLOW ], indirect=True) @pytest.mark.parametrize("user1", [True], indirect=True) -def test_user_subscribe_workflow(app_client, client_auth_method, user1, user1_auth, valid_workflow): +def test_submitter_subscribe_workflow(app_client, client_auth_method, + user1, user1_auth, valid_workflow): workflow = utils.pick_workflow(user1, valid_workflow) logger.debug("User1 Auth Headers: %r", user1_auth) enable_auto_login(user1['user']) r = app_client.post( - utils.build_workflow_path(workflow, include_version=False, subpath='subscribe'), headers=user1_auth + utils.build_workflow_path(workflow, include_version=False, subpath='subscribe'), + headers=user1_auth + ) + assert r.status_code == 204, f"Error when subscribing to the workflow {workflow}" + + +@pytest.mark.parametrize("client_auth_method", [ + # ClientAuthenticationMethod.BASIC, + ClientAuthenticationMethod.API_KEY, + ClientAuthenticationMethod.AUTHORIZATION_CODE, + ClientAuthenticationMethod.REGISTRY_CODE_FLOW +], indirect=True) +@pytest.mark.parametrize("user1", [True], indirect=True) +def test_user_subscribe_workflow(app_client, client_auth_method, + user1, user1_auth, user2, user2_auth, + valid_workflow): + workflow = utils.pick_workflow(user1, valid_workflow) + logger.debug("User1 Auth Headers: %r", user1_auth) + enable_auto_login(user1['user']) + r = app_client.post( + utils.build_workflow_path(workflow, include_version=False, subpath='subscribe'), + headers=user2_auth ) assert r.status_code == 201, f"Error when subscribing to the workflow {workflow}" data = json.loads(r.data.decode()) @@ -81,12 +103,37 @@ def test_user_subscribe_workflow(app_client, client_auth_method, user1, user1_au ClientAuthenticationMethod.REGISTRY_CODE_FLOW ], indirect=True) @pytest.mark.parametrize("user1", [True], indirect=True) -def test_user_subscribe_workflow_events(app_client, client_auth_method, user1, user1_auth, valid_workflow): +def test_user_subscribe_workflow_old_events(app_client, client_auth_method, + user1, user1_auth, valid_workflow): workflow = utils.pick_workflow(user1, valid_workflow) logger.debug("User1 Auth Headers: %r", user1_auth) enable_auto_login(user1['user']) - r = app_client.post( - utils.build_workflow_path(workflow, include_version=False, subpath='subscribe'), headers=user1_auth + body = [EventType.ALL.name] + r = app_client.put( + utils.build_workflow_path(workflow, include_version=False, subpath='subscribe'), + headers=user1_auth, + json=body + ) + assert r.status_code == 204, f"Error when subscribing to the workflow {workflow}" + + +@pytest.mark.parametrize("client_auth_method", [ + # ClientAuthenticationMethod.BASIC, + ClientAuthenticationMethod.API_KEY, + ClientAuthenticationMethod.AUTHORIZATION_CODE, + ClientAuthenticationMethod.REGISTRY_CODE_FLOW +], indirect=True) +@pytest.mark.parametrize("user1", [True], indirect=True) +def test_user_subscribe_workflow_new_events(app_client, client_auth_method, + user1, user1_auth, valid_workflow): + workflow = utils.pick_workflow(user1, valid_workflow) + logger.debug("User1 Auth Headers: %r", user1_auth) + enable_auto_login(user1['user']) + body = [EventType.BUILD_FAILED.name, EventType.BUILD_RECOVERED.name] + r = app_client.put( + utils.build_workflow_path(workflow, include_version=False, subpath='subscribe'), + headers=user1_auth, + json=body ) assert r.status_code == 201, f"Error when subscribing to the workflow {workflow}" data = json.loads(r.data.decode()) @@ -148,9 +195,13 @@ def test_user_unsubscribe_workflow(app_client, client_auth_method, user1, user1_ ClientAuthenticationMethod.REGISTRY_CODE_FLOW ], indirect=True) @pytest.mark.parametrize("user1", [True], indirect=True) -def test_user_subscriptions(app_client, client_auth_method, user1, user1_auth, valid_workflow, lm: LifeMonitor): +def test_user_subscriptions(app_client, client_auth_method, + user1, user1_auth, user2, user2_auth, + valid_workflow, lm: LifeMonitor): + # pick user1 workflow wdata = utils.pick_workflow(user1, valid_workflow) - user: User = user1['user'] + # set user2 as current user + user: User = user2['user'] enable_auto_login(user) # register a subscription workflow = lm.get_workflow(wdata['uuid']) @@ -160,7 +211,7 @@ def test_user_subscriptions(app_client, client_auth_method, user1, user1_auth, v # get subscriptions of the current user r = app_client.get( - '/users/current/subscriptions', headers=user1_auth + '/users/current/subscriptions', headers=user2_auth ) assert r.status_code == 200, "Error when trying to get user subscriptions" data = json.loads(r.data.decode()) diff --git a/tests/integration/api/services/test_workflow_subscriptions.py b/tests/integration/api/services/test_workflow_subscriptions.py index 84fbcffa2..df9f237ae 100644 --- a/tests/integration/api/services/test_workflow_subscriptions.py +++ b/tests/integration/api/services/test_workflow_subscriptions.py @@ -20,25 +20,63 @@ import logging -from lifemonitor.auth.models import User, Subscription +from lifemonitor.auth.models import EventType, User, Subscription from lifemonitor.api.services import LifeMonitor from tests import utils logger = logging.getLogger() -def test_user_workflow_subscription(app_client, lm: LifeMonitor, user1: dict, valid_workflow: str): - _, workflow = utils.pick_and_register_workflow(user1, valid_workflow) +def test_submitter_workflow_subscription(app_client, lm: LifeMonitor, user1: dict, valid_workflow: str): + _, workflow_version = utils.pick_and_register_workflow(user1, valid_workflow) + user: User = user1['user'] + # check number of subscriptions + assert len(user.subscriptions) == 1, "Unexpected number of subscriptions" + # subscribe to the workflow + s: Subscription = user.subscriptions[0] + assert s, "Subscription should not be empty" + assert s.resource.uuid == workflow_version.workflow.uuid, "Unexpected resource UUID" + assert s.resource == workflow_version.workflow, "Unexpected resource instance" + assert len(s.events) == 1, "Unexpected number of subscription events" + + +def test_submitter_workflow_unsubscription(app_client, lm: LifeMonitor, user1: dict, valid_workflow: str): + _, workflow_version = utils.pick_and_register_workflow(user1, valid_workflow) user: User = user1['user'] # check number of subscriptions + assert len(user.subscriptions) == 1, "Unexpected number of subscriptions" + # unsubscribe to workflow + s: Subscription = lm.unsubscribe_user_resource(user, workflow_version.workflow) + assert s, "Subscription should not be empty" + # check number of subscriptions + assert len(user.subscriptions) == 0, "Unexpected number of subscriptions" + + +def test_user_workflow_subscription(app_client, lm: LifeMonitor, user1: dict, user2: dict, valid_workflow: str): + _, workflow = utils.pick_and_register_workflow(user1, valid_workflow) + user: User = user2['user'] + # check number of subscriptions assert len(user.subscriptions) == 0, "Unexpected number of subscriptions" + # subscribe to the workflow - s: Subscription = lm.subscribe_user_resource(user, workflow) + events = [EventType.BUILD_FAILED, EventType.BUILD_RECOVERED] + s: Subscription = lm.subscribe_user_resource(user, workflow, events) assert s, "Subscription should not be empty" assert s.resource.uuid == workflow.uuid, "Unexpected resource UUID" assert s.resource == workflow, "Unexpected resource instance" + assert len(s.events) == len(events), "Unexpected number of subscription events" + for e in events: + assert s.has_event(e), f"Subscription should be have event {e}" # check number of subscriptions assert len(user.subscriptions) == 1, "Unexpected number of subscriptions" + + # update subscription events + events = [EventType.BUILD_FAILED] + s: Subscription = lm.subscribe_user_resource(user, workflow, events) + assert len(s.events) == len(events), "Unexpected number of subscription events" + for e in events: + assert s.has_event(e), f"Subscription should be have event {e}" + # unsubscribe to workflow s: Subscription = lm.unsubscribe_user_resource(user, workflow) assert s, "Subscription should not be empty" diff --git a/tests/unit/api/models/test_subscriptions.py b/tests/unit/api/models/test_subscriptions.py index b610c6789..b373e6d76 100644 --- a/tests/unit/api/models/test_subscriptions.py +++ b/tests/unit/api/models/test_subscriptions.py @@ -38,7 +38,7 @@ def test_workflow_subscription(user1: dict, valid_workflow: str): # check default events assert len(s.events) == 1, "Invalid number of events" assert s.has_event(EventType.ALL), f"Event '{EventType.ALL.name}' not registered on the subscription" - for event in EventType.list(): + for event in EventType.all(): assert s.has_event(event), f"Event '{event.name}' should be included" # check delete all events From 1aa6a0b16c33c8e3e088c96c1b38f33edc7344e1 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 11:29:04 +0100 Subject: [PATCH 82/96] Update serialisation schema of test build. Allow to optionally nest suite/workflow data on a build serialisation. --- lifemonitor/api/serializers.py | 13 ++++++++++++- lifemonitor/tasks/tasks.py | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lifemonitor/api/serializers.py b/lifemonitor/api/serializers.py index 1f1237fe2..31bf3d23a 100644 --- a/lifemonitor/api/serializers.py +++ b/lifemonitor/api/serializers.py @@ -295,13 +295,24 @@ class BuildSummarySchema(ResourceMetadataSchema): class Meta: model = models.TestBuild + def __init__(self, *args, self_link: bool = True, exclude_nested=True, **kwargs): + exclude = set(kwargs.pop('exclude', ())) + if exclude_nested: + exclude = exclude.union(('suite', 'workflow')) + super().__init__(*args, self_link=self_link, exclude=tuple(exclude), **kwargs) + build_id = fields.String(attribute="id") suite_uuid = fields.String(attribute="test_instance.test_suite.uuid") status = fields.String() - instance = ma.Nested(TestInstanceSchema(self_link=False, exclude=('meta',)), attribute="test_instance") + instance = ma.Nested(TestInstanceSchema(self_link=False, exclude=('meta',)), + attribute="test_instance") timestamp = fields.String() duration = fields.Integer() links = fields.Method('get_links') + suite = ma.Nested(TestInstanceSchema(self_link=False, + only=('uuid', 'name')), attribute="test_instance.test_suite") + workflow = ma.Nested(WorkflowVersionSchema(self_link=False, only=('uuid', 'name', 'version')), + attribute="test_instance.test_suite.workflow_version") def get_links(self, obj): links = { diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index cb2b386b1..9fcaa2866 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -113,7 +113,7 @@ def check_last_build(): users = latest_version.workflow.get_subscribers() n = Notification(EventType.BUILD_FAILED, notification_name, - {'build': BuildSummarySchema().dump(last_build)}, + {'build': BuildSummarySchema(exclude_nested=False).dump(last_build)}, users) n.save() except Exception as e: From a17d0e4870ec3abc34be45cc05a1c26fbd95db49 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 12:24:27 +0100 Subject: [PATCH 83/96] Add task to automatically delete old notifications --- lifemonitor/tasks/tasks.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index 9fcaa2866..42e059c27 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -1,4 +1,5 @@ +import datetime import logging import dramatiq @@ -146,3 +147,21 @@ def send_email_notifications(): count += 1 logger.debug("Processing notification %r ... DONE", n) logger.info("%r notifications sent by email", count) + + +@schedule(CronTrigger(minute=0, hour=1)) +@dramatiq.actor(max_retries=0, max_age=TASK_EXPIRATION_TIME) +def cleanup_notifications(): + logger.info("Starting notification cleanup") + count = 0 + current_time = datetime.datetime.utcnow() + one_week_ago = current_time - datetime.timedelta(days=0) + notifications = Notification.older_than(one_week_ago) + for n in notifications: + try: + n.delete() + count += 1 + except Exception as e: + logger.debug(e) + logger.error("Error when deleting notification %r", n) + logger.info("Notification cleanup completed: deleted %r notifications", count) From b942f7d3bdc996e5904450e87957b28b0b3a76fb Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 28 Jan 2022 12:28:16 +0100 Subject: [PATCH 84/96] Bump version number --- k8s/Chart.yaml | 2 +- lifemonitor/static/src/package.json | 2 +- specs/api.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/k8s/Chart.yaml b/k8s/Chart.yaml index c17577b74..e88587ad6 100644 --- a/k8s/Chart.yaml +++ b/k8s/Chart.yaml @@ -12,7 +12,7 @@ version: 0.5.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. -appVersion: 0.5.1 +appVersion: 0.6.0 # Chart dependencies dependencies: diff --git a/lifemonitor/static/src/package.json b/lifemonitor/static/src/package.json index d2bbda102..6bed5ba02 100644 --- a/lifemonitor/static/src/package.json +++ b/lifemonitor/static/src/package.json @@ -1,7 +1,7 @@ { "name": "lifemonitor", "description": "Workflow Testing Service", - "version": "0.5.1", + "version": "0.6.0", "license": "MIT", "author": "CRS4", "main": "../dist/js/lifemonitor.min.js", diff --git a/specs/api.yaml b/specs/api.yaml index 6a3947288..fedfb4ba6 100644 --- a/specs/api.yaml +++ b/specs/api.yaml @@ -3,7 +3,7 @@ openapi: "3.0.0" info: - version: "0.5.1" + version: "0.6.0" title: "Life Monitor API" description: | *Workflow sustainability service* @@ -18,7 +18,7 @@ info: servers: - url: / description: > - Version 0.5.1 of API. + Version 0.6.0 of API. tags: - name: Registries From 4c3aa2153a28b44d76b2b16ba1f0c15ee3d46d98 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 1 Feb 2022 09:27:27 +0100 Subject: [PATCH 85/96] Add method to delete a single notification --- lifemonitor/api/services.py | 10 ++++++++++ lifemonitor/auth/controllers.py | 16 ++++++++++++++++ specs/api.yaml | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index 2194a8093..5e74da156 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -510,6 +510,16 @@ def setUserNotificationReadingTime(user: User, notifications: List[dict]): un.read = datetime.utcnow() user.save() + @staticmethod + def deleteUserNotification(user: User, notitification_uuid: str): + if notitification_uuid is not None: + n = user.get_user_notification(notitification_uuid) + logger.debug("Search result notification %r ...", n) + if n is None: + return lm_exceptions.EntityNotFoundException(Notification, entity_id=n_uuid) + user.notifications.remove(n) + user.save() + @staticmethod def deleteUserNotifications(user: User, list_of_uuids: List[str]): for n_uuid in list_of_uuids: diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index d6fd7ac4b..ad5e7d59b 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -109,6 +109,22 @@ def user_notifications_patch(body): return exceptions.report_problem_from_exception(e) +@authorized +@cached(timeout=Timeout.REQUEST) +def user_notifications_delete(notification_uuid): + try: + if not current_user or current_user.is_anonymous: + raise exceptions.Forbidden(detail="Client type unknown") + __lifemonitor_service__().deleteUserNotification(current_user, notification_uuid) + clear_cache() + return connexion.NoContent, 204 + except exceptions.EntityNotFoundException as e: + return exceptions.report_problem_from_exception(e) + except Exception as e: + logger.debug(e) + return exceptions.report_problem_from_exception(e) + + @authorized def user_subscriptions_get(): return serializers.ListOfSubscriptions().dump(current_user.subscriptions) diff --git a/specs/api.yaml b/specs/api.yaml index fedfb4ba6..f45de7ef0 100644 --- a/specs/api.yaml +++ b/specs/api.yaml @@ -260,6 +260,32 @@ paths: "404": $ref: "#/components/responses/NotFound" + /users/current/notifications/{notification_uuid}: + delete: + tags: ["Users"] + x-openapi-router-controller: lifemonitor.auth.controllers + operationId: "user_notifications_delete" + summary: Delete notification for the current user + description: | + Delete notification for the current (authenticated) user + security: + - apiKey: ["user.profile"] + - RegistryCodeFlow: ["user.profile"] + - AuthorizationCodeFlow: ["user.profile"] + parameters: + - $ref: "#/components/parameters/notification_uuid" + responses: + "204": + description: "Notifications updated" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /registries/current: get: tags: ["Registry Client Operations"] @@ -1416,6 +1442,14 @@ components: minimum: 1 default: 10 description: "Maximum number of items to retrieve" + notification_uuid: + name: "notification_uuid" + description: "Universal unique identifier of the user notification" + in: path + schema: + type: string + required: true + example: 123e4567-e89b-12d3-a456-426614174000 responses: NotFound: From e87884a54bde227c094e77c8564e56f3b682b38d Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 1 Feb 2022 09:34:20 +0100 Subject: [PATCH 86/96] Fix var name on debug message --- lifemonitor/api/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index 5e74da156..3dae799cf 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -516,7 +516,7 @@ def deleteUserNotification(user: User, notitification_uuid: str): n = user.get_user_notification(notitification_uuid) logger.debug("Search result notification %r ...", n) if n is None: - return lm_exceptions.EntityNotFoundException(Notification, entity_id=n_uuid) + return lm_exceptions.EntityNotFoundException(Notification, entity_id=notitification_uuid) user.notifications.remove(n) user.save() From 74f022c00f8afb660f76606d50fbba2a7bd723b5 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 2 Feb 2022 11:07:25 +0100 Subject: [PATCH 87/96] Build notifications only for state transitions of test instances --- lifemonitor/tasks/tasks.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lifemonitor/tasks/tasks.py b/lifemonitor/tasks/tasks.py index 42e059c27..f5e0ab4c3 100644 --- a/lifemonitor/tasks/tasks.py +++ b/lifemonitor/tasks/tasks.py @@ -107,12 +107,15 @@ def check_last_build(): for b in builds: logger.info("Updating build: %r", i.get_test_build(b.id)) last_build = i.last_test_build - logger.info("Updating latest build: %r", last_build) - if last_build.status == BuildStatus.FAILED: - notification_name = f"{last_build} FAILED" + # check state transition + failed = last_build.status == BuildStatus.FAILED + if len(builds) == 1 and failed or \ + len(builds) > 1 and builds[1].status != last_build.status: + logger.info("Updating latest build: %r", last_build) + notification_name = f"{last_build} {'FAILED' if failed else 'RECOVERED'}" if len(Notification.find_by_name(notification_name)) == 0: users = latest_version.workflow.get_subscribers() - n = Notification(EventType.BUILD_FAILED, + n = Notification(EventType.BUILD_FAILED if failed else EventType.BUILD_RECOVERED, notification_name, {'build': BuildSummarySchema(exclude_nested=False).dump(last_build)}, users) From f8c3b9e642b6f54386c6931641e3a044d08cfc95 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 2 Feb 2022 11:11:26 +0100 Subject: [PATCH 88/96] Update mail template. Add support for email that notify recovery of test instances --- lifemonitor/mail.py | 15 ++++++++++----- ...ication.j2 => instance_status_notification.j2} | 8 ++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) rename lifemonitor/templates/mail/{build_failure_notification.j2 => instance_status_notification.j2} (89%) diff --git a/lifemonitor/mail.py b/lifemonitor/mail.py index e137c79de..8f4404cda 100644 --- a/lifemonitor/mail.py +++ b/lifemonitor/mail.py @@ -28,7 +28,7 @@ from sqlalchemy.exc import InternalError from lifemonitor.api.models import TestInstance -from lifemonitor.auth.models import Notification, User +from lifemonitor.auth.models import EventType, Notification, User from lifemonitor.db import db from lifemonitor.utils import Base64Encoder, get_external_server_url, boolean_value @@ -82,20 +82,25 @@ def send_notification(n: Notification, recipients: List[str]) -> Optional[dateti i = TestInstance.find_by_uuid(build_data['instance']['uuid']) if i is not None: wv = i.test_suite.workflow_version - b = i.last_test_build + b = i.get_test_build(build_data['build_id']) suite = i.test_suite logo = Base64Encoder.encode_file('lifemonitor/static/img/logo/lm/LifeMonitorLogo.png') - icon = Base64Encoder.encode_file('lifemonitor/static/img/icons/times-circle-solid.svg') + icon_path = 'lifemonitor/static/img/icons/' \ + + ('times-circle-solid.svg' + if n.event == EventType.BUILD_FAILED else 'check-circle-solid.svg') + icon = Base64Encoder.encode_file(icon_path) suite.url_param = Base64Encoder.encode_object({ 'workflow': str(wv.workflow.uuid), 'suite': str(suite.uuid) }) + instance_status = "is failing" \ + if n.event == EventType.BUILD_FAILED else "has recovered" msg = Message( - f'Workflow "{wv.name} ({wv.version})": some builds were not successful', + f'Workflow "{wv.name} ({wv.version})": test instance {i.name} {instance_status}', bcc=recipients, reply_to="noreply-lifemonitor@crs4.it" ) - msg.html = render_template("mail/build_failure_notification.j2", + msg.html = render_template("mail/instance_status_notification.j2", webapp_url=mail.webapp_url, workflow_version=wv, build=b, test_instance=i, diff --git a/lifemonitor/templates/mail/build_failure_notification.j2 b/lifemonitor/templates/mail/instance_status_notification.j2 similarity index 89% rename from lifemonitor/templates/mail/build_failure_notification.j2 rename to lifemonitor/templates/mail/instance_status_notification.j2 index 9a86300b8..4fc674643 100644 --- a/lifemonitor/templates/mail/build_failure_notification.j2 +++ b/lifemonitor/templates/mail/instance_status_notification.j2 @@ -65,7 +65,11 @@

- Some builds were not successful + {% if build.status == 'failed' %} + Some builds on instance {{test_instance.name}} were not successful + {% else %} + Test instance {{test_instance.name}} has recovered + {% endif %}

@@ -75,7 +79,7 @@

Test Build #{{build.id}} - failed !!! + {{ 'failed' if build.status == 'failed' else 'passed' }} !!!

From bfb335306a51ace32e0c297f1da8ddfd2bf12ee2 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 1 Feb 2022 07:48:36 +0100 Subject: [PATCH 89/96] Report connection error for Jenkins service --- lifemonitor/api/models/services/jenkins.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lifemonitor/api/models/services/jenkins.py b/lifemonitor/api/models/services/jenkins.py index 842080c77..9de557862 100644 --- a/lifemonitor/api/models/services/jenkins.py +++ b/lifemonitor/api/models/services/jenkins.py @@ -27,8 +27,8 @@ import lifemonitor.api.models as models import lifemonitor.exceptions as lm_exceptions - from lifemonitor.lang import messages +from requests.exceptions import ConnectionError import jenkins @@ -114,6 +114,8 @@ def get_project_metadata(self, test_instance: models.TestInstance, fetch_all_bui self.get_job_name(test_instance.resource), fetch_all_builds=fetch_all_builds) except jenkins.JenkinsException as e: raise lm_exceptions.TestingServiceException(f"{self}: {e}") + except ConnectionError as e: + raise lm_exceptions.TestingServiceException(f"Unable to connect to {self}", detail=str(e)) return test_instance._raw_metadata def get_test_builds(self, test_instance: models.TestInstance, limit=10) -> list: From 879e7c404369373a7a7bbf9bc56b8ff36474ea42 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 1 Feb 2022 07:49:29 +0100 Subject: [PATCH 90/96] Collect issues when computing status --- lifemonitor/api/models/status.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lifemonitor/api/models/status.py b/lifemonitor/api/models/status.py index a4854497a..ffacd6bd3 100644 --- a/lifemonitor/api/models/status.py +++ b/lifemonitor/api/models/status.py @@ -106,6 +106,14 @@ def check_status(suites): "issue": str(e) }) logger.exception(e) + except Exception as e: + availability_issues.append({ + "service": test_instance.testing_service.url, + "resource": test_instance.resource, + "issue": str(e) + }) + logger.exception(e) + # update the current status return status, latest_builds, availability_issues From f08e89464117cc731a8ba7c74f3f2c88c326cd24 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 1 Feb 2022 07:50:45 +0100 Subject: [PATCH 91/96] Catch exceptions during serialisation --- lifemonitor/api/controllers.py | 2 +- lifemonitor/api/serializers.py | 88 +++++++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/lifemonitor/api/controllers.py b/lifemonitor/api/controllers.py index f5d23687c..6977cb3db 100644 --- a/lifemonitor/api/controllers.py +++ b/lifemonitor/api/controllers.py @@ -546,7 +546,7 @@ def suites_get_by_uuid(suite_uuid): def suites_get_status(suite_uuid): response = _get_suite_or_problem(suite_uuid) return response if isinstance(response, Response) \ - else serializers.SuiteStatusSchema().dump(response.status) + else serializers.SuiteStatusSchema().dump(response) @cached(timeout=Timeout.REQUEST) diff --git a/lifemonitor/api/serializers.py b/lifemonitor/api/serializers.py index 31bf3d23a..ce6f28fe2 100644 --- a/lifemonitor/api/serializers.py +++ b/lifemonitor/api/serializers.py @@ -330,13 +330,36 @@ class WorkflowStatusSchema(WorkflowVersionSchema): class Meta: model = models.WorkflowStatus - aggregate_test_status = fields.String(attribute="status.aggregated_status") - latest_builds = ma.Nested(BuildSummarySchema(exclude=('meta', 'links')), - attribute="status.latest_builds", many=True) + _errors = [] + + aggregate_test_status = fields.Method("get_aggregate_test_status") + latest_builds = fields.Method("get_latest_builds") reason = fields.Method("get_reason") + def get_aggregate_test_status(self, workflow_version): + try: + return workflow_version.status.aggregated_status + except Exception as e: + logger.debug(e) + self._errors.append(str(e)) + return "not_available" + + def get_latest_builds(self, workflow_version): + try: + return BuildSummarySchema(exclude=('meta', 'links'), many=True).dump( + workflow_version.status.latest_builds) + except Exception as e: + logger.debug(e) + self._errors.append(str(e)) + return [] + def get_reason(self, workflow_version): - return format_availability_issues(workflow_version.status) + try: + if(len(self._errors) > 0): + return ', '.join([str(i) for i in self._errors]) + return format_availability_issues(workflow_version.status) + except Exception as e: + return str(e) @post_dump def remove_skip_values(self, data, **kwargs): @@ -372,15 +395,26 @@ def get_status(self, workflow): logger.debug(e) return { "aggregate_test_status": "not_available", - "latest_build": [], + "latest_build": None, + "reason": str(e) + } + except Exception as e: + logger.debug(e) + return { + "aggregate_test_status": "not_available", + "latest_build": None, "reason": str(e) } def get_latest_build(self, workflow): - latest_builds = workflow.latest_version.status.latest_builds - if latest_builds and len(latest_builds) > 0: - return BuildSummarySchema(exclude=('meta', 'links')).dump(latest_builds[0]) - return None + try: + latest_builds = workflow.latest_version.status.latest_builds + if latest_builds and len(latest_builds) > 0: + return BuildSummarySchema(exclude=('meta', 'links')).dump(latest_builds[0]) + return None + except Exception as e: + logger.debug(e) + return None def get_subscriptions(self, w: models.Workflow): result = [] @@ -445,18 +479,40 @@ class ListOfSuites(ListOfItems): class SuiteStatusSchema(ResourceMetadataSchema): __envelope__ = {"single": None, "many": "items"} - __model__ = models.SuiteStatus + __model__ = models.TestSuite class Meta: - model = models.SuiteStatus + model = models.TestSuite - suite_uuid = fields.String(attribute="suite.uuid") - status = fields.String(attribute="aggregated_status") - latest_builds = fields.Nested(BuildSummarySchema(exclude=('meta', 'links')), many=True) + suite_uuid = fields.String(attribute="uuid") + status = fields.Method("get_aggregated_status") + latest_builds = fields.Method("get_builds") reason = fields.Method("get_reason") + _errors = [] - def get_reason(self, status): - return format_availability_issues(status) + def get_builds(self, suite): + try: + return BuildSummarySchema( + exclude=('meta', 'links'), many=True).dump(suite.status.latest_builds) + except Exception as e: + self._errors.append(str(e)) + logger.debug(e) + return [] + + def get_reason(self, suite): + if(len(self._errors) > 0): + return ", ".join(self._errors) + try: + return format_availability_issues(suite.status) + except Exception as e: + return str(e) + + def get_aggregated_status(self, suite): + try: + return suite.status.aggregated_status + except Exception as e: + self._errors.append(str(e)) + return 'not_available' @post_dump def remove_skip_values(self, data, **kwargs): From 64580fe38871f935d814e97db6e069f3562de740 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 2 Feb 2022 13:26:03 +0100 Subject: [PATCH 92/96] Update "missing name" property error message --- lifemonitor/api/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index 3dae799cf..92cf7d041 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -143,8 +143,8 @@ def register_workflow(cls, roc_link, workflow_submitter: User, workflow_version, if name is None: if wv.workflow_name is None: raise lm_exceptions.LifeMonitorException(title="Missing attribute 'name'", - detail="Attribute 'name' is not defined and it cannot be retrieved ' \ - 'from the workflow RO-Crate (name of 'mainEntity' and '/' dataset not set)", + detail="Attribute 'name' is not defined and it cannot be retrieved \ + from the workflow RO-Crate (name of 'mainEntity' not found)", status=400) w.name = wv.workflow_name wv.name = wv.workflow_name From b04d94c451bcca82dbc36ae004dfa44036266336 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 2 Feb 2022 13:28:24 +0100 Subject: [PATCH 93/96] Fix detection of mainEntity name --- lifemonitor/api/models/rocrate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lifemonitor/api/models/rocrate.py b/lifemonitor/api/models/rocrate.py index 6f512208c..75d102060 100644 --- a/lifemonitor/api/models/rocrate.py +++ b/lifemonitor/api/models/rocrate.py @@ -100,7 +100,8 @@ def dataset_name(self): @property def main_entity_name(self): - return self._roc_helper.mainEntity['name'] + mainEntity = self._roc_helper.mainEntity + return mainEntity['name'] if mainEntity and 'name' in mainEntity else None @property def _roc_helper(self): From 829307c14a23177012f4196770f8ebc735f4b3a7 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 3 Feb 2022 09:54:09 +0100 Subject: [PATCH 94/96] Accept property ID as fallback for the mainEntity.name property --- lifemonitor/api/models/rocrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/api/models/rocrate.py b/lifemonitor/api/models/rocrate.py index 75d102060..0cdc4ae9b 100644 --- a/lifemonitor/api/models/rocrate.py +++ b/lifemonitor/api/models/rocrate.py @@ -101,7 +101,7 @@ def dataset_name(self): @property def main_entity_name(self): mainEntity = self._roc_helper.mainEntity - return mainEntity['name'] if mainEntity and 'name' in mainEntity else None + return mainEntity.get("name", mainEntity.id) if mainEntity else None @property def _roc_helper(self): From c9687d37b55adba4cd77671ef6f6e68c0a485a9e Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 3 Feb 2022 15:05:34 +0100 Subject: [PATCH 95/96] Allow users to override UUID of registry workflows --- lifemonitor/api/models/workflows.py | 15 +++++++++------ lifemonitor/api/services.py | 7 +++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lifemonitor/api/models/workflows.py b/lifemonitor/api/models/workflows.py index a85c8d146..7c60650d5 100644 --- a/lifemonitor/api/models/workflows.py +++ b/lifemonitor/api/models/workflows.py @@ -49,6 +49,7 @@ class Workflow(Resource): public = db.Column(db.Boolean, nullable=True, default=False) external_ns = "external-id:" + _uuidAutoGenerated = True __mapper_args__ = { 'polymorphic_identity': 'workflow' @@ -59,6 +60,7 @@ def __init__(self, uri=None, uuid=None, identifier=None, super().__init__(uri=uri or f"{self.external_ns}", uuid=uuid, version=version, name=name) self.public = public + self._uuidAutoGenerated = uuid is None if identifier is not None: self.external_id = identifier @@ -82,12 +84,13 @@ def latest_version(self) -> WorkflowVersion: def add_version(self, version, uri, submitter: User, uuid=None, name=None, hosting_service: models.WorkflowRegistry = None): if hosting_service: - if self.external_id and hasattr(hosting_service, 'get_external_uuid'): - try: - self.uuid = hosting_service.get_external_uuid(self.external_id, version, submitter) - except RuntimeError as e: - raise lm_exceptions.NotAuthorizedException(details=str(e)) - elif not self.external_id and hasattr(hosting_service, 'get_external_id'): + if self.external_id: + if self._uuidAutoGenerated and hasattr(hosting_service, 'get_external_uuid'): + try: + self.uuid = hosting_service.get_external_uuid(self.external_id, version, submitter) + except RuntimeError as e: + raise lm_exceptions.NotAuthorizedException(details=str(e)) + elif hasattr(hosting_service, 'get_external_id'): try: self.external_id = hosting_service.get_external_id(self.uuid, version, submitter) except lm_exceptions.EntityNotFoundException: diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index 92cf7d041..80800dcbb 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -107,10 +107,13 @@ def register_workflow(cls, roc_link, workflow_submitter: User, workflow_version, workflow_uuid=None, workflow_identifier=None, workflow_registry: Optional[models.WorkflowRegistry] = None, authorization=None, name=None, public=False): - # find or create a user workflow + w = None if workflow_registry: - w = workflow_registry.get_workflow(workflow_uuid or workflow_identifier) + if workflow_uuid: + w = workflow_registry.get_workflow(workflow_uuid) + else: + w = workflow_registry.get_workflow(workflow_identifier) else: w = models.Workflow.get_user_workflow(workflow_submitter, workflow_uuid) if not w: From 7bdd6b48d25b21768003006f5a8d8f18edd5abad Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 4 Feb 2022 13:44:44 +0100 Subject: [PATCH 96/96] Update chart version number --- k8s/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/Chart.yaml b/k8s/Chart.yaml index e88587ad6..e0715ded5 100644 --- a/k8s/Chart.yaml +++ b/k8s/Chart.yaml @@ -7,7 +7,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.5.0 +version: 0.6.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to