diff --git a/alembic/versions/404bb5bb3a0e_add_notification_recipients.py b/alembic/versions/404bb5bb3a0e_add_notification_recipients.py new file mode 100644 index 00000000..b8127705 --- /dev/null +++ b/alembic/versions/404bb5bb3a0e_add_notification_recipients.py @@ -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 ### diff --git a/atst/app.py b/atst/app.py index ef5ff09c..c729c65a 100644 --- a/atst/app.py +++ b/atst/app.py @@ -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( { diff --git a/atst/models/__init__.py b/atst/models/__init__.py index 8a3cdbfa..e3fc7e1c 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -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 diff --git a/atst/models/notification_recipient.py b/atst/models/notification_recipient.py new file mode 100644 index 00000000..72b55cbe --- /dev/null +++ b/atst/models/notification_recipient.py @@ -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) diff --git a/atst/queue.py b/atst/queue.py index befe45b0..3f2d3042 100644 --- a/atst/queue.py +++ b/atst/queue.py @@ -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() diff --git a/atst/routes/errors.py b/atst/routes/errors.py index c129e08d..cc3f5f21 100644 --- a/atst/routes/errors.py +++ b/atst/routes/errors.py @@ -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) diff --git a/atst/utils/notification_sender.py b/atst/utils/notification_sender.py new file mode 100644 index 00000000..cfd8c8d1 --- /dev/null +++ b/atst/utils/notification_sender.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 6faaf715..cede1f3c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/factories.py b/tests/factories.py index 8bdf9310..d9de9c97 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -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") diff --git a/tests/routes/test_errors.py b/tests/routes/test_errors.py index 0ffff430..f5b0289e 100644 --- a/tests/routes/test_errors.py +++ b/tests/routes/test_errors.py @@ -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() diff --git a/tests/utils.py b/tests/utils.py index a8d7522a..913a80e7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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) diff --git a/tests/utils/test_notification_sender.py b/tests/utils/test_notification_sender.py new file mode 100644 index 00000000..5aec0081 --- /dev/null +++ b/tests/utils/test_notification_sender.py @@ -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 + )