Merge pull request #827 from dod-ccpo/stig-notifications
Create Notification System
This commit is contained in:
commit
32df561c6d
34
alembic/versions/404bb5bb3a0e_add_notification_recipients.py
Normal file
34
alembic/versions/404bb5bb3a0e_add_notification_recipients.py
Normal 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 ###
|
@ -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(
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
|
10
atst/models/notification_recipient.py
Normal file
10
atst/models/notification_recipient.py
Normal 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)
|
@ -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()
|
||||||
|
@ -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)
|
||||||
|
20
atst/utils/notification_sender.py
Normal file
20
atst/utils/notification_sender.py
Normal 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()
|
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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()
|
||||||
|
@ -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)
|
||||||
|
27
tests/utils/test_notification_sender.py
Normal file
27
tests/utils/test_notification_sender.py
Normal 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
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user