Merge pull request #827 from dod-ccpo/stig-notifications

Create Notification System
This commit is contained in:
richard-dds 2019-05-20 09:51:09 -04:00 committed by GitHub
commit 32df561c6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 159 additions and 7 deletions

View File

@ -0,0 +1,34 @@
"""add_notification_recipients
Revision ID: 404bb5bb3a0e
Revises: 432c5287256d
Create Date: 2019-05-10 15:25:22.627996
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '404bb5bb3a0e'
down_revision = '432c5287256d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notification_recipients',
sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notification_recipients')
# ### end Alembic commands ###

View File

@ -29,6 +29,7 @@ from atst.utils import mailer
from atst.utils.form_cache import FormCache from atst.utils.form_cache import FormCache
from atst.utils.json import CustomJSONEncoder from atst.utils.json import CustomJSONEncoder
from atst.queue import queue from atst.queue import queue
from atst.utils.notification_sender import NotificationSender
from logging.config import dictConfig from logging.config import dictConfig
from atst.utils.logging import JsonFormatter, RequestContextFilter from atst.utils.logging import JsonFormatter, RequestContextFilter
@ -63,6 +64,7 @@ def make_app(config):
make_csp_provider(app) make_csp_provider(app)
make_crl_validator(app) make_crl_validator(app)
make_mailer(app) make_mailer(app)
make_notification_sender(app)
queue.init_app(app) queue.init_app(app)
db.init_app(app) db.init_app(app)
@ -247,6 +249,10 @@ def make_mailer(app):
app.mailer = mailer.Mailer(mailer_connection, sender) app.mailer = mailer.Mailer(mailer_connection, sender)
def make_notification_sender(app):
app.notification_sender = NotificationSender(queue)
def apply_json_logger(): def apply_json_logger():
dictConfig( dictConfig(
{ {

View File

@ -17,5 +17,6 @@ from .portfolio_invitation import PortfolioInvitation
from .application_invitation import ApplicationInvitation from .application_invitation import ApplicationInvitation
from .task_order import TaskOrder from .task_order import TaskOrder
from .dd_254 import DD254 from .dd_254 import DD254
from .notification_recipient import NotificationRecipient
from .mixins.invites import Status as InvitationStatus from .mixins.invites import Status as InvitationStatus

View File

@ -0,0 +1,10 @@
from sqlalchemy import String, Column
from atst.models import Base, types, mixins
class NotificationRecipient(Base, mixins.TimestampsMixin):
__tablename__ = "notification_recipients"
id = types.Id()
email = Column(String, nullable=False)

View File

@ -30,6 +30,9 @@ class ATSTQueue(RQ):
def send_mail(self, recipients, subject, body): def send_mail(self, recipients, subject, body):
self._queue_job(ATSTQueue._send_mail, recipients, subject, body) self._queue_job(ATSTQueue._send_mail, recipients, subject, body)
def send_notification_mail(self, recipients, subject, body):
self._queue_job(ATSTQueue._send_notification_mail, recipients, subject, body)
# pylint: disable=pointless-string-statement # pylint: disable=pointless-string-statement
"""Class methods to actually perform the work. """Class methods to actually perform the work.
@ -41,5 +44,14 @@ class ATSTQueue(RQ):
def _send_mail(self, recipients, subject, body): def _send_mail(self, recipients, subject, body):
app.mailer.send(recipients, subject, body) app.mailer.send(recipients, subject, body)
@classmethod
def _send_notification_mail(self, recipients, subject, body):
app.logger.info(
"Sending a notification to these recipients: {}\n\n{}".format(
recipients, body
)
)
app.mailer.send(recipients, subject, body)
queue = ATSTQueue() queue = ATSTQueue()

View File

@ -12,14 +12,22 @@ from atst.domain.authnid.crl import CRLInvalidException
from atst.domain.portfolios import PortfolioError from atst.domain.portfolios import PortfolioError
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
NO_NOTIFY_STATUS_CODES = set([404, 401])
def log_error(e): def log_error(e):
error_message = e.message if hasattr(e, "message") else str(e) error_message = e.message if hasattr(e, "message") else str(e)
current_app.logger.exception(error_message) current_app.logger.exception(error_message)
def notify(e, message, code):
if code not in NO_NOTIFY_STATUS_CODES:
current_app.notification_sender.send(message)
def handle_error(e, message="Not Found", code=404): def handle_error(e, message="Not Found", code=404):
log_error(e) log_error(e)
notify(e, message, code)
return render_template("error.html", message=message), code return render_template("error.html", message=message), code
@ -56,13 +64,9 @@ def make_error_pages(app):
@app.errorhandler(Exception) @app.errorhandler(Exception)
# pylint: disable=unused-variable # pylint: disable=unused-variable
def exception(e): def exception(e):
log_error(e)
if current_app.debug: if current_app.debug:
raise e raise e
return ( return handle_error(e, message="An Unexpected Error Occurred", code=500)
render_template("error.html", message="An Unexpected Error Occurred"),
500,
)
@app.errorhandler(InvitationError) @app.errorhandler(InvitationError)
@app.errorhandler(InvitationWrongUserError) @app.errorhandler(InvitationWrongUserError)

View File

@ -0,0 +1,20 @@
from sqlalchemy import select
from atst.queue import ATSTQueue
from atst.database import db
from atst.models import NotificationRecipient
class NotificationSender(object):
EMAIL_SUBJECT = "ATST notification"
def __init__(self, queue: ATSTQueue):
self.queue = queue
def send(self, body, type_=None):
recipients = self._get_recipients(type_)
self.queue.send_notification_mail(recipients, self.EMAIL_SUBJECT, body)
def _get_recipients(self, type_):
query = select([NotificationRecipient.email])
return db.session.execute(query).fetchone()

View File

@ -12,9 +12,9 @@ from atst.database import db as _db
from atst.queue import queue as atst_queue from atst.queue import queue as atst_queue
import tests.factories as factories import tests.factories as factories
from tests.mocks import PDF_FILENAME, PDF_FILENAME2 from tests.mocks import PDF_FILENAME, PDF_FILENAME2
from tests.utils import FakeLogger from tests.utils import FakeLogger, FakeNotificationSender
from datetime import datetime, timezone, timedelta from datetime import datetime, timedelta
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
@ -307,3 +307,13 @@ def mock_logger(app):
yield app.logger yield app.logger
app.logger = real_logger app.logger = real_logger
@pytest.fixture(scope="function", autouse=True)
def notification_sender(app):
real_notification_sender = app.notification_sender
app.notification_sender = FakeNotificationSender()
yield app.notification_sender
app.notification_sender = real_notification_sender

View File

@ -318,3 +318,10 @@ class DD254Factory(Base):
required_distribution = factory.LazyFunction( required_distribution = factory.LazyFunction(
lambda: [random_choice(data.REQUIRED_DISTRIBUTIONS)] lambda: [random_choice(data.REQUIRED_DISTRIBUTIONS)]
) )
class NotificationRecipientFactory(Base):
class Meta:
model = NotificationRecipient
email = factory.Faker("email")

View File

@ -1,5 +1,7 @@
import pytest import pytest
from flask import url_for from flask import url_for
from copy import copy
from tests.factories import UserFactory
@pytest.fixture @pytest.fixture
@ -20,3 +22,16 @@ def test_csrf_error(csrf_enabled_app, client):
body = response.data.decode() body = response.data.decode()
assert "Session Expired" in body assert "Session Expired" in body
assert "Log in required" in body assert "Log in required" in body
def test_errors_generate_notifications(app, client, user_session, notification_sender):
user_session(UserFactory.create())
new_app = copy(app)
@new_app.route("/throw")
def throw():
raise ValueError()
new_app.test_client().get("/throw")
notification_sender.send.assert_called_once()

View File

@ -1,5 +1,8 @@
from flask import template_rendered from flask import template_rendered
from contextlib import contextmanager from contextlib import contextmanager
from unittest.mock import Mock
from atst.utils.notification_sender import NotificationSender
@contextmanager @contextmanager
@ -37,3 +40,6 @@ class FakeLogger:
self.messages.append(msg) self.messages.append(msg)
if "extra" in kwargs: if "extra" in kwargs:
self.extras.append(kwargs["extra"]) self.extras.append(kwargs["extra"])
FakeNotificationSender = lambda: Mock(spec=NotificationSender)

View File

@ -0,0 +1,27 @@
import pytest
from unittest.mock import Mock
from tests.factories import NotificationRecipientFactory
from atst.utils.notification_sender import NotificationSender
@pytest.fixture
def mock_queue(queue):
return Mock(spec=queue)
@pytest.fixture
def notification_sender(mock_queue):
return NotificationSender(mock_queue)
def test_can_send_notification(mock_queue, notification_sender):
recipient_email = "test@example.com"
email_body = "This is a test"
NotificationRecipientFactory.create(email=recipient_email)
notification_sender.send(email_body)
mock_queue.send_notification_mail.assert_called_once_with(
("test@example.com",), notification_sender.EMAIL_SUBJECT, email_body
)