Repository: mozilla/telemetry-analysis-service

Issue:
Scheduled jobs should have an option to be run "right now"
https://bugzilla.mozilla.org/show_bug.cgi?id=1309567


Discussion:

Resolving patch:
diff --git a/atmo/jobs/models.py b/atmo/jobs/models.py
--- a/atmo/jobs/models.py
+++ b/atmo/jobs/models.py
@@ -146,6 +146,9 @@ def download(self):
         def edit(self):
             return reverse('jobs-edit', kwargs={'id': self.id})
 
+        def run(self):
+            return reverse('jobs-run', kwargs={'id': self.id})
+
     __str__ = autostr('{self.identifier}')
 
     __repr__ = autorepr(['identifier', 'size', 'is_enabled'])
@@ -168,10 +171,6 @@ def schedule(self):
         from .schedules import SparkJobSchedule
         return SparkJobSchedule(self)
 
-    @property
-    def results(self):
-        return self.provisioner.results(self.identifier, self.is_public)
-
     def has_future_end_date(self, now):
         # no end date means it'll always be due
         if self.end_date is None:
@@ -250,6 +249,10 @@ def notebook_name(self):
     def notebook_s3_object(self):
         return self.provisioner.get(self.notebook_s3_key)
 
+    @cached_property
+    def results(self):
+        return self.provisioner.results(self.identifier, self.is_public)
+
     def get_latest_run(self):
         try:
             return self.runs.latest()
diff --git a/atmo/jobs/urls.py b/atmo/jobs/urls.py
--- a/atmo/jobs/urls.py
+++ b/atmo/jobs/urls.py
@@ -12,5 +12,6 @@
     url(r'^(?P<id>\d+)/delete/', views.delete_spark_job, name='jobs-delete'),
     url(r'^(?P<id>\d+)/download/', views.download_spark_job, name='jobs-download'),
     url(r'^(?P<id>\d+)/edit/', views.edit_spark_job, name='jobs-edit'),
+    url(r'^(?P<id>\d+)/run/', views.run_spark_job, name='jobs-run'),
     url(r'^(?P<id>\d+)/$', views.detail_spark_job, name='jobs-detail'),
 ]
diff --git a/atmo/jobs/views.py b/atmo/jobs/views.py
--- a/atmo/jobs/views.py
+++ b/atmo/jobs/views.py
@@ -4,12 +4,15 @@
 import logging
 
 from allauth.account.utils import user_display
+from botocore.exceptions import ClientError
 from django.contrib.auth.decorators import login_required
+from django.contrib import messages
 from django.http import (HttpResponse, HttpResponseNotFound,
                          StreamingHttpResponse)
 from django.shortcuts import redirect, render
 from django.template.response import TemplateResponse
 from django.utils import timezone
+from django.utils.safestring import mark_safe
 from django.utils.text import get_valid_filename
 
 from ..clusters.models import EMRRelease
@@ -133,3 +136,48 @@ def download_spark_job(request, id):
     )
     response['Content-Length'] = spark_job.notebook_s3_object['ContentLength']
     return response
+
+
+@login_required
+@view_permission_required(SparkJob)
+def run_spark_job(request, id):
+    spark_job = SparkJob.objects.get(pk=id)
+    if not spark_job.is_runnable:
+        messages.error(
+            request,
+            mark_safe(
+                '<h4>Run now unavailable.</h4>'
+                "The Spark job can't be run manually at this time. Please try again later."
+            )
+        )
+        return redirect(spark_job)
+
+    if request.method == 'POST':
+        if spark_job.latest_run:
+            try:
+                spark_job.latest_run.update_status()
+            except ClientError:
+                messages.error(
+                    request,
+                    mark_safe(
+                        '<h4>Spark job API error</h4>'
+                        "The Spark job can't be run at the moment since there was a "
+                        "problem with fetching the status of the previous job run. "
+                        "Please try again later."
+                    )
+                )
+                return redirect(spark_job)
+
+        spark_job.run()
+        latest_run = spark_job.get_latest_run()
+        if latest_run:
+            schedule_entry = spark_job.schedule.get()
+            schedule_entry.reschedule(
+                last_run_at=spark_job.latest_run.scheduled_date,
+            )
+        return redirect(spark_job)
+
+    context = {
+        'spark_job': spark_job,
+    }
+    return render(request, 'atmo/jobs/run.html', context=context)


Test patch:
diff --git a/tests/jobs/test_views.py b/tests/jobs/test_views.py
--- a/tests/jobs/test_views.py
+++ b/tests/jobs/test_views.py
@@ -3,6 +3,7 @@
 # file, you can obtain one at http://mozilla.org/MPL/2.0/.
 from datetime import datetime, timedelta
 
+from botocore.exceptions import ClientError
 from django.core.urlresolvers import reverse
 from django.utils import timezone
 from freezegun import freeze_time
@@ -290,3 +291,99 @@ def test_check_identifier_available(client, spark_job):
 
     response = client.get(available_url + '?identifier=completely-different')
     assert b'identifier available' in response.content
+
+
+def test_run_without_latest_run(client, messages, mocker, one_hour_ago, spark_job):
+    run = mocker.patch('atmo.jobs.models.SparkJob.run')
+    update_status = mocker.patch('atmo.jobs.models.SparkJobRun.update_status')
+    mocker.patch(
+        'atmo.jobs.models.SparkJob.results',
+        new_callable=mocker.PropertyMock,
+        return_valurn=[],
+    )
+    assert spark_job.is_runnable
+    response = client.get(spark_job.urls.run, follow=True)
+    assert response.status_code == 200
+    assert not response.redirect_chain
+    assert run.call_count == 0
+    assert update_status.call_count == 0
+
+    response = client.post(spark_job.urls.run, follow=True)
+    assert run.call_count == 1
+    assert update_status.call_count == 0
+    assert response.status_code == 200
+    assert response.redirect_chain[-1] == (spark_job.urls.detail, 302)
+
+
+def test_run_with_latest_run(client, messages, mocker, one_hour_ago, spark_job):
+    run = mocker.patch('atmo.jobs.models.SparkJob.run')
+    update_status = mocker.patch('atmo.jobs.models.SparkJobRun.update_status')
+    mocker.patch(
+        'atmo.jobs.models.SparkJob.results',
+        new_callable=mocker.PropertyMock,
+        return_valurn=[],
+    )
+    spark_job.runs.create(
+        jobflow_id='my-jobflow-id',
+        status=Cluster.STATUS_TERMINATED,
+        scheduled_date=one_hour_ago,
+    )
+    assert spark_job.is_runnable
+    response = client.get(spark_job.urls.run, follow=True)
+    assert response.status_code == 200
+    assert not response.redirect_chain
+    assert run.call_count == 0
+    assert update_status.call_count == 0
+
+    response = client.post(spark_job.urls.run, follow=True)
+    assert run.call_count == 1
+    assert update_status.call_count == 1
+    assert response.status_code == 200
+    assert response.redirect_chain[-1] == (spark_job.urls.detail, 302)
+
+
+def test_run_with_client_error(client, messages, mocker, one_hour_ago, spark_job):
+    run = mocker.patch('atmo.jobs.models.SparkJob.run')
+    update_status = mocker.patch(
+        'atmo.jobs.models.SparkJobRun.update_status',
+        side_effect=ClientError({
+            'Error': {
+                'Code': 'Code',
+                'Message': 'Message',
+            }
+        }, 'operation_name'),
+    )
+    mocker.patch(
+        'atmo.jobs.models.SparkJob.results',
+        new_callable=mocker.PropertyMock,
+        return_valurn=[],
+    )
+    spark_job.runs.create(
+        jobflow_id='my-jobflow-id',
+        status=Cluster.STATUS_TERMINATED,
+        scheduled_date=one_hour_ago,
+    )
+    response = client.post(spark_job.urls.run, follow=True)
+    assert run.call_count == 0
+    assert update_status.call_count == 1
+    assert response.status_code == 200
+    assert response.redirect_chain[-1] == (spark_job.urls.detail, 302)
+    messages.assert_message_contains(response, 'Spark job API error')
+
+
+def test_run_not_runnable(client, messages, mocker, now, spark_job):
+    results = mocker.patch(
+        'atmo.jobs.models.SparkJob.results',
+        new_callable=mocker.PropertyMock,
+        return_valurn=[],
+    )
+    spark_job.runs.create(
+        jobflow_id='my-jobflow-id',
+        status=Cluster.STATUS_RUNNING,
+        scheduled_date=now,
+    )
+    assert not spark_job.is_runnable
+    response = client.post(spark_job.urls.run, follow=True)
+    assert response.redirect_chain[-1] == (spark_job.urls.detail, 302)
+    messages.assert_message_contains(response, 'Run now unavailable')
+    assert results.call_count == 2