diff --git a/app/dao/jobs_dao.py b/app/dao/jobs_dao.py index e22c1c709..7cdca7da7 100644 --- a/app/dao/jobs_dao.py +++ b/app/dao/jobs_dao.py @@ -2,6 +2,7 @@ import uuid from datetime import datetime, timedelta from flask import current_app +from notifications_utils.letter_timings import letter_can_be_cancelled, CANCELLABLE_JOB_LETTER_STATUSES from notifications_utils.statsd_decorators import statsd from sqlalchemy import ( asc, @@ -10,17 +11,23 @@ from sqlalchemy import ( ) from app import db +from app.dao.dao_utils import transactional +from app.dao.templates_dao import dao_get_template_by_id from app.utils import midnight_n_days_ago + from app.models import ( Job, + JOB_STATUS_FINISHED, JOB_STATUS_PENDING, JOB_STATUS_SCHEDULED, LETTER_TYPE, Notification, Template, - ServiceDataRetention + ServiceDataRetention, + NOTIFICATION_CREATED, + NOTIFICATION_CANCELLED, + JOB_STATUS_CANCELLED ) -from app.variables import LETTER_TEST_API_FILENAME @statsd(namespace="dao") @@ -148,16 +155,35 @@ def dao_get_jobs_older_than_data_retention(notification_types): return jobs -def dao_get_all_letter_jobs(): - return db.session.query( - Job - ).join( - Job.template - ).filter( - Template.template_type == LETTER_TYPE, - # test letter jobs (or from research mode services) are created with a different filename, - # exclude them so we don't see them on the send to CSV - Job.original_file_name != LETTER_TEST_API_FILENAME - ).order_by( - desc(Job.created_at) +@transactional +def dao_cancel_letter_job(job): + number_of_notifications_cancelled = Notification.query.filter( + Notification.job_id == job.id + ).update({'status': NOTIFICATION_CANCELLED, + 'updated_at': datetime.utcnow(), + 'billable_units': 0}) + job.job_status = JOB_STATUS_CANCELLED + dao_update_job(job) + return number_of_notifications_cancelled + + +def can_letter_job_be_cancelled(job): + template = dao_get_template_by_id(job.template_id) + if template.template_type != LETTER_TYPE: + return False, "Only letter jobs can be cancelled through this endpoint. This is not a letter job." + + notifications = Notification.query.filter( + Notification.job_id == job.id ).all() + count_notifications = len(notifications) + if job.job_status != JOB_STATUS_FINISHED or count_notifications != job.notification_count: + return False, "This job is still being processed. Wait a couple of minutes and try again." + count_cancellable_notifications = len([ + n for n in notifications if n.status in CANCELLABLE_JOB_LETTER_STATUSES + ]) + if count_cancellable_notifications != job.notification_count or not letter_can_be_cancelled( + NOTIFICATION_CREATED, job.created_at + ): + return False, "Sorry, it's too late, letters have already been sent." + + return True, None diff --git a/app/job/rest.py b/app/job/rest.py index 20f37efed..145f3c004 100644 --- a/app/job/rest.py +++ b/app/job/rest.py @@ -13,7 +13,10 @@ from app.dao.jobs_dao import ( dao_get_job_by_service_id_and_job_id, dao_get_jobs_by_service_id, dao_get_future_scheduled_job_by_id_and_service_id, - dao_get_notification_outcomes_for_job) + dao_get_notification_outcomes_for_job, + dao_cancel_letter_job, + can_letter_job_be_cancelled +) from app.dao.fact_notification_status_dao import fetch_notification_statuses_for_job from app.dao.services_dao import dao_fetch_service_by_id from app.dao.templates_dao import dao_get_template_by_id @@ -60,6 +63,17 @@ def cancel_job(service_id, job_id): return get_job_by_service_and_job_id(service_id, job_id) +@job_blueprint.route('//cancel-letter-job', methods=['POST']) +def cancel_letter_job(service_id, job_id): + job = dao_get_job_by_service_id_and_job_id(service_id, job_id) + can_we_cancel, errors = can_letter_job_be_cancelled(job) + if can_we_cancel: + data = dao_cancel_letter_job(job) + return jsonify(data), 200 + else: + return jsonify(message=errors), 400 + + @job_blueprint.route('//notifications', methods=['GET']) def get_all_notifications_for_service_job(service_id, job_id): data = notifications_filter_schema.load(request.args).data diff --git a/requirements-app.txt b/requirements-app.txt index 460c9bf4c..542c10758 100644 --- a/requirements-app.txt +++ b/requirements-app.txt @@ -29,6 +29,6 @@ awscli-cwlogs>=1.4,<1.5 # Putting upgrade on hold due to v1.0.0 using sha512 instead of sha1 by default itsdangerous==0.24 # pyup: <1.0.0 -git+https://github.com/alphagov/notifications-utils.git@33.0.0#egg=notifications-utils==33.0.0 +git+https://github.com/alphagov/notifications-utils.git@33.1.0#egg=notifications-utils==33.1.0 git+https://github.com/alphagov/boto.git@2.43.0-patch3#egg=boto==2.43.0-patch3 diff --git a/requirements.txt b/requirements.txt index d3348afc0..0bc17e2d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,21 +31,21 @@ awscli-cwlogs>=1.4,<1.5 # Putting upgrade on hold due to v1.0.0 using sha512 instead of sha1 by default itsdangerous==0.24 # pyup: <1.0.0 -git+https://github.com/alphagov/notifications-utils.git@33.0.0#egg=notifications-utils==33.0.0 +git+https://github.com/alphagov/notifications-utils.git@33.1.0#egg=notifications-utils==33.1.0 git+https://github.com/alphagov/boto.git@2.43.0-patch3#egg=boto==2.43.0-patch3 ## The following requirements were added by pip freeze: -alembic==1.0.10 +alembic==1.0.11 amqp==1.4.9 anyjson==0.3.3 attrs==19.1.0 -awscli==1.16.185 +awscli==1.16.188 bcrypt==3.1.7 billiard==3.3.0.23 bleach==3.1.0 boto3==1.6.16 -botocore==1.12.175 +botocore==1.12.178 certifi==2019.6.16 chardet==3.0.4 Click==7.0 diff --git a/tests/app/dao/test_jobs_dao.py b/tests/app/dao/test_jobs_dao.py index 524d17f8e..2a9cc8a4d 100644 --- a/tests/app/dao/test_jobs_dao.py +++ b/tests/app/dao/test_jobs_dao.py @@ -6,14 +6,16 @@ import pytest from freezegun import freeze_time from app.dao.jobs_dao import ( - dao_get_job_by_service_id_and_job_id, + can_letter_job_be_cancelled, + dao_cancel_letter_job, dao_create_job, - dao_update_job, - dao_get_jobs_by_service_id, - dao_set_scheduled_jobs_to_pending, dao_get_future_scheduled_job_by_id_and_service_id, - dao_get_notification_outcomes_for_job, + dao_get_job_by_service_id_and_job_id, + dao_get_jobs_by_service_id, dao_get_jobs_older_than_data_retention, + dao_get_notification_outcomes_for_job, + dao_set_scheduled_jobs_to_pending, + dao_update_job, ) from app.models import ( Job, @@ -308,3 +310,84 @@ def assert_job_stat(job, result, sent, delivered, failed): assert result.sent == sent assert result.delivered == delivered assert result.failed == failed + + +@freeze_time('2019-06-13 13:00') +def test_dao_cancel_letter_job_cancels_job_and_returns_number_of_cancelled_notifications( + sample_letter_template, admin_request +): + job = create_job(template=sample_letter_template, notification_count=1, job_status='finished') + notification = create_notification(template=job.template, job=job, status='created') + result = dao_cancel_letter_job(job) + assert result == 1 + assert notification.status == 'cancelled' + assert job.job_status == 'cancelled' + + +@freeze_time('2019-06-13 13:00') +def test_can_letter_job_be_cancelled_returns_true_if_job_can_be_cancelled(sample_letter_template, admin_request): + job = create_job(template=sample_letter_template, notification_count=1, job_status='finished') + create_notification(template=job.template, job=job, status='created') + result, errors = can_letter_job_be_cancelled(job) + assert result + assert not errors + + +@freeze_time('2019-06-13 13:00') +def test_can_letter_job_be_cancelled_returns_false_and_error_message_if_notification_status_sending( + sample_letter_template, admin_request +): + job = create_job(template=sample_letter_template, notification_count=2, job_status='finished') + create_notification(template=job.template, job=job, status='sending') + create_notification(template=job.template, job=job, status='created') + result, errors = can_letter_job_be_cancelled(job) + assert not result + assert errors == "Sorry, it's too late, letters have already been sent." + + +def test_can_letter_job_be_cancelled_returns_false_and_error_message_if_letters_already_sent_to_dvla( + sample_letter_template, admin_request +): + with freeze_time('2019-06-13 13:00'): + job = create_job(template=sample_letter_template, notification_count=1, job_status='finished') + letter = create_notification(template=job.template, job=job, status='created') + + with freeze_time('2019-06-13 17:32'): + result, errors = can_letter_job_be_cancelled(job) + assert not result + assert errors == "Sorry, it's too late, letters have already been sent." + assert letter.status == 'created' + assert job.job_status == 'finished' + + +@freeze_time('2019-06-13 13:00') +def test_can_letter_job_be_cancelled_returns_false_and_error_message_if_not_a_letter_job( + sample_template, admin_request +): + job = create_job(template=sample_template, notification_count=1, job_status='finished') + create_notification(template=job.template, job=job, status='created') + result, errors = can_letter_job_be_cancelled(job) + assert not result + assert errors == "Only letter jobs can be cancelled through this endpoint. This is not a letter job." + + +@freeze_time('2019-06-13 13:00') +def test_can_letter_job_be_cancelled_returns_false_and_error_message_if_job_not_finished( + sample_letter_template, admin_request +): + job = create_job(template=sample_letter_template, notification_count=1, job_status="in progress") + create_notification(template=job.template, job=job, status='created') + result, errors = can_letter_job_be_cancelled(job) + assert not result + assert errors == "This job is still being processed. Wait a couple of minutes and try again." + + +@freeze_time('2019-06-13 13:00') +def test_can_letter_job_be_cancelled_returns_false_and_error_message_if_notifications_not_in_db_yet( + sample_letter_template, admin_request +): + job = create_job(template=sample_letter_template, notification_count=2, job_status='finished') + create_notification(template=job.template, job=job, status='created') + result, errors = can_letter_job_be_cancelled(job) + assert not result + assert errors == "This job is still being processed. Wait a couple of minutes and try again." diff --git a/tests/app/job/test_rest.py b/tests/app/job/test_rest.py index 03d4a289e..d81210491 100644 --- a/tests/app/job/test_rest.py +++ b/tests/app/job/test_rest.py @@ -71,6 +71,59 @@ def test_cant_cancel_normal_job(client, sample_job, mocker): assert mock_update.call_count == 0 +@freeze_time('2019-06-13 13:00') +def test_cancel_letter_job_updates_notifications_and_job_to_cancelled(sample_letter_template, admin_request, mocker): + job = create_job(template=sample_letter_template, notification_count=1, job_status='finished') + create_notification(template=job.template, job=job, status='created') + + mock_get_job = mocker.patch('app.job.rest.dao_get_job_by_service_id_and_job_id', return_value=job) + mock_can_letter_job_be_cancelled = mocker.patch( + 'app.job.rest.can_letter_job_be_cancelled', return_value=(True, None) + ) + mock_dao_cancel_letter_job = mocker.patch('app.job.rest.dao_cancel_letter_job', return_value=1) + + response = admin_request.post( + 'job.cancel_letter_job', + service_id=job.service_id, + job_id=job.id, + ) + + mock_get_job.assert_called_once_with(job.service_id, str(job.id)) + mock_can_letter_job_be_cancelled.assert_called_once_with(job) + mock_dao_cancel_letter_job.assert_called_once_with(job) + + assert response == 1 + + +@freeze_time('2019-06-13 13:00') +def test_cancel_letter_job_does_not_call_cancel_if_can_letter_job_be_cancelled_returns_False( + sample_letter_template, admin_request, mocker +): + job = create_job(template=sample_letter_template, notification_count=2, job_status='finished') + create_notification(template=job.template, job=job, status='sending') + create_notification(template=job.template, job=job, status='created') + + mock_get_job = mocker.patch('app.job.rest.dao_get_job_by_service_id_and_job_id', return_value=job) + error_message = "Sorry, it's too late, letters have already been sent." + mock_can_letter_job_be_cancelled = mocker.patch( + 'app.job.rest.can_letter_job_be_cancelled', return_value=(False, error_message) + ) + mock_dao_cancel_letter_job = mocker.patch('app.job.rest.dao_cancel_letter_job') + + response = admin_request.post( + 'job.cancel_letter_job', + service_id=job.service_id, + job_id=job.id, + _expected_status=400 + ) + + mock_get_job.assert_called_once_with(job.service_id, str(job.id)) + mock_can_letter_job_be_cancelled.assert_called_once_with(job) + mock_dao_cancel_letter_job.assert_not_called + + assert response["message"] == "Sorry, it's too late, letters have already been sent." + + def test_create_unscheduled_job(client, sample_template, mocker, fake_uuid): mocker.patch('app.celery.tasks.process_job.apply_async') mocker.patch('app.job.rest.get_job_metadata_from_s3', return_value={