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.json import CustomJSONEncoder
|
||||
from atst.queue import queue
|
||||
from atst.utils.notification_sender import NotificationSender
|
||||
|
||||
from logging.config import dictConfig
|
||||
from atst.utils.logging import JsonFormatter, RequestContextFilter
|
||||
@ -63,6 +64,7 @@ def make_app(config):
|
||||
make_csp_provider(app)
|
||||
make_crl_validator(app)
|
||||
make_mailer(app)
|
||||
make_notification_sender(app)
|
||||
queue.init_app(app)
|
||||
|
||||
db.init_app(app)
|
||||
@ -247,6 +249,10 @@ def make_mailer(app):
|
||||
app.mailer = mailer.Mailer(mailer_connection, sender)
|
||||
|
||||
|
||||
def make_notification_sender(app):
|
||||
app.notification_sender = NotificationSender(queue)
|
||||
|
||||
|
||||
def apply_json_logger():
|
||||
dictConfig(
|
||||
{
|
||||
|
@ -17,5 +17,6 @@ from .portfolio_invitation import PortfolioInvitation
|
||||
from .application_invitation import ApplicationInvitation
|
||||
from .task_order import TaskOrder
|
||||
from .dd_254 import DD254
|
||||
from .notification_recipient import NotificationRecipient
|
||||
|
||||
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):
|
||||
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
|
||||
"""Class methods to actually perform the work.
|
||||
|
||||
@ -41,5 +44,14 @@ class ATSTQueue(RQ):
|
||||
def _send_mail(self, 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()
|
||||
|
@ -12,14 +12,22 @@ from atst.domain.authnid.crl import CRLInvalidException
|
||||
from atst.domain.portfolios import PortfolioError
|
||||
from atst.utils.flash import formatted_flash as flash
|
||||
|
||||
NO_NOTIFY_STATUS_CODES = set([404, 401])
|
||||
|
||||
|
||||
def log_error(e):
|
||||
error_message = e.message if hasattr(e, "message") else str(e)
|
||||
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):
|
||||
log_error(e)
|
||||
notify(e, message, code)
|
||||
return render_template("error.html", message=message), code
|
||||
|
||||
|
||||
@ -56,13 +64,9 @@ def make_error_pages(app):
|
||||
@app.errorhandler(Exception)
|
||||
# pylint: disable=unused-variable
|
||||
def exception(e):
|
||||
log_error(e)
|
||||
if current_app.debug:
|
||||
raise e
|
||||
return (
|
||||
render_template("error.html", message="An Unexpected Error Occurred"),
|
||||
500,
|
||||
)
|
||||
return handle_error(e, message="An Unexpected Error Occurred", code=500)
|
||||
|
||||
@app.errorhandler(InvitationError)
|
||||
@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
|
||||
import tests.factories as factories
|
||||
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 import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
@ -307,3 +307,13 @@ def mock_logger(app):
|
||||
yield app.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(
|
||||
lambda: [random_choice(data.REQUIRED_DISTRIBUTIONS)]
|
||||
)
|
||||
|
||||
|
||||
class NotificationRecipientFactory(Base):
|
||||
class Meta:
|
||||
model = NotificationRecipient
|
||||
|
||||
email = factory.Faker("email")
|
||||
|
@ -1,5 +1,7 @@
|
||||
import pytest
|
||||
from flask import url_for
|
||||
from copy import copy
|
||||
from tests.factories import UserFactory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -20,3 +22,16 @@ def test_csrf_error(csrf_enabled_app, client):
|
||||
body = response.data.decode()
|
||||
assert "Session Expired" 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 contextlib import contextmanager
|
||||
from unittest.mock import Mock
|
||||
|
||||
from atst.utils.notification_sender import NotificationSender
|
||||
|
||||
|
||||
@contextmanager
|
||||
@ -37,3 +40,6 @@ class FakeLogger:
|
||||
self.messages.append(msg)
|
||||
if "extra" in kwargs:
|
||||
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