Repository: carpentries/amy

Issue:
Allow editing template of already scheduled emails
Feature: add editing of template for already scheduled jobs.


Discussion:

Resolving patch:
diff --git a/amy/autoemails/admin.py b/amy/autoemails/admin.py
--- a/amy/autoemails/admin.py
+++ b/amy/autoemails/admin.py
@@ -7,6 +7,7 @@
 from django.template.response import TemplateResponse
 from django.urls import path, reverse
 from django.utils.html import format_html
+from django.utils.decorators import method_decorator
 from django.views.decorators.http import require_POST
 import django_rq
 from markdownx.widgets import AdminMarkdownxWidget
@@ -14,7 +15,7 @@
 from rq.job import Job
 from rq_scheduler.utils import from_unix
 
-from autoemails.forms import RescheduleForm
+from autoemails.forms import RescheduleForm, TemplateForm
 from autoemails.models import EmailTemplate, Trigger, RQJob
 from autoemails.utils import (
     check_status,
@@ -132,6 +133,11 @@ def get_urls(self):
                 admin_required(self.admin_site.admin_view(self.cancel)),
                 name="autoemails_rqjob_cancel",
             ),
+            path(
+                "<path:object_id>/template/",
+                admin_required(self.admin_site.admin_view(self.edit_template)),
+                name="autoemails_rqjob_edit_template",
+            ),
         ]
         return new_urls + original_urls
 
@@ -173,13 +179,19 @@ def preview(self, request, object_id):
                 email = None
                 adn_context = None
 
-        form = None
+        reschedule_form = None
         if job and not job.is_failed:
             now_utc = datetime.utcnow() + timedelta(minutes=10)
-            form = RescheduleForm(
+            reschedule_form = RescheduleForm(
                 initial=dict(scheduled_execution=job_scheduled or now_utc)
             )
 
+        template_form = None
+        if instance and job and not job.is_failed:
+            template_form = TemplateForm(
+                initial=dict(template=instance.template.body_template)
+            )
+
         # find prev / next RQJob in the list
         previous = RQJob.objects.filter(pk__lt=rqjob.pk).order_by("id").last()
         next_ = RQJob.objects.filter(pk__gt=rqjob.pk).order_by("id").first()
@@ -197,12 +209,14 @@ def preview(self, request, object_id):
             template=template,
             email=email,
             adn_context=adn_context,
-            form=form,
+            reschedule_form=reschedule_form,
+            template_form=template_form,
             previous=previous,
             next=next_,
         )
         return TemplateResponse(request, "rqjob_preview.html", context)
 
+    @method_decorator(require_POST)
     def reschedule(self, request, object_id):
         """Change scheduled execution time to a different timestamp."""
         rqjob = get_object_or_404(RQJob, id=object_id)
@@ -250,6 +264,7 @@ def reschedule(self, request, object_id):
 
         return redirect(link)
 
+    @method_decorator(require_POST)
     def reschedule_now(self, request, object_id):
         """Reschedule an existing job so it executes now (+/- refresh time
         delta, about 1 minute in default settings)."""
@@ -291,6 +306,7 @@ def reschedule_now(self, request, object_id):
 
         return redirect(link)
 
+    @method_decorator(require_POST)
     def retry(self, request, object_id):
         """Fetch job and re-try to execute it."""
         rqjob = get_object_or_404(RQJob, id=object_id)
@@ -327,6 +343,7 @@ def retry(self, request, object_id):
 
         return redirect(link)
 
+    @method_decorator(require_POST)
     def cancel(self, request, object_id):
         """Fetch job and re-try to execute it."""
         rqjob = get_object_or_404(RQJob, id=object_id)
@@ -371,6 +388,55 @@ def cancel(self, request, object_id):
 
         return redirect(link)
 
+    @method_decorator(require_POST)
+    def edit_template(self, request, object_id):
+        """Edit email template of a scheduled job."""
+        rqjob = get_object_or_404(RQJob, id=object_id)
+
+        logger.debug(f"Editing job email template {rqjob.job_id}...")
+
+        link = reverse("admin:autoemails_rqjob_preview", args=[object_id])
+
+        # fetch job
+        try:
+            job = Job.fetch(rqjob.job_id, connection=scheduler.connection)
+            logger.debug(f"Job {rqjob.job_id} fetched")
+
+        except NoSuchJobError:
+            logger.debug(f"Job {rqjob.job_id} unavailable")
+            messages.warning(
+                request,
+                "The corresponding job in Redis was probably already executed.",
+            )
+            return redirect(link)
+
+        if request.method == "POST":
+            form = TemplateForm(request.POST)
+            if form.is_valid():
+                new_tmpl = form.cleaned_data["template"]
+
+                try:
+                    inst = job.instance
+                    inst.template.body_template = new_tmpl
+                    job.instance = inst
+                    job.save()
+                    logger.debug(f"Job {rqjob.job_id} template updated")
+                    messages.info(
+                        request,
+                        f"The job {rqjob.job_id} template was updated.",
+                    )
+
+                except AttributeError:
+                    logger.debug(f"Job {rqjob.job_id} template can't be updated.")
+                    messages.warning(
+                        request,
+                        f"The job {rqjob.job_id} template cannot be updated.",
+                    )
+            else:
+                messages.warning(request, "Please fix errors below.")
+
+        return redirect(link)
+
 
 admin.site.register(EmailTemplate, EmailTemplateAdmin)
 admin.site.register(Trigger, TriggerAdmin)
diff --git a/amy/autoemails/forms.py b/amy/autoemails/forms.py
--- a/amy/autoemails/forms.py
+++ b/amy/autoemails/forms.py
@@ -1,5 +1,7 @@
 from django import forms
 from django.contrib.admin.widgets import AdminSplitDateTime
+from markdownx.fields import MarkdownxFormField
+from markdownx.widgets import AdminMarkdownxWidget
 
 
 class RescheduleForm(forms.Form):
@@ -8,3 +10,11 @@ class RescheduleForm(forms.Form):
         label="New execution time",
         widget=AdminSplitDateTime(),
     )
+
+
+class TemplateForm(forms.Form):
+    template = MarkdownxFormField(
+        label="Markdown body",
+        widget=AdminMarkdownxWidget,
+        required=True,
+    )


Test patch:
diff --git a/amy/autoemails/tests/test_admin_cancel.py b/amy/autoemails/tests/test_admin_cancel.py
--- a/amy/autoemails/tests/test_admin_cancel.py
+++ b/amy/autoemails/tests/test_admin_cancel.py
@@ -35,9 +35,17 @@ def tearDown(self):
         # bring back saved scheduler
         admin.scheduler = self._saved_scheduler
 
-    def test_view_access_by_anonymous(self):
+    def test_view_doesnt_allow_GET(self):
+        # log admin user
+        self._logSuperuserIn()
+
         url = reverse('admin:autoemails_rqjob_cancel', args=[self.rqjob.pk])
         rv = self.client.get(url)
+        self.assertEqual(rv.status_code, 405)  # Method not allowed
+
+    def test_view_access_by_anonymous(self):
+        url = reverse('admin:autoemails_rqjob_cancel', args=[self.rqjob.pk])
+        rv = self.client.post(url)
         self.assertEqual(rv.status_code, 302)
         # cannot check by assertRedirect because there's additional `?next`
         # parameter
@@ -49,7 +57,7 @@ def test_view_access_by_admin(self):
 
         # try accessing the view again
         url = reverse('admin:autoemails_rqjob_cancel', args=[self.rqjob.pk])
-        rv = self.client.get(url)
+        rv = self.client.post(url)
         self.assertEqual(rv.status_code, 302)
         self.assertRedirects(rv, reverse('admin:autoemails_rqjob_preview',
                                          args=[self.rqjob.pk]))
@@ -62,7 +70,7 @@ def test_no_such_job(self):
             Job.fetch(self.rqjob.job_id, connection=self.scheduler.connection)
 
         url = reverse('admin:autoemails_rqjob_cancel', args=[self.rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             'The corresponding job in Redis was probably already executed',
             rv.content.decode('utf-8'),
@@ -87,7 +95,7 @@ def test_job_executed(self):
             )
 
         url = reverse('admin:autoemails_rqjob_cancel', args=[rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             'Job has unknown status or was already executed.',
             rv.content.decode('utf-8'),
@@ -125,7 +133,7 @@ def test_enqueued_job_cancelled(self):
 
         # cancel the job
         url = reverse('admin:autoemails_rqjob_cancel', args=[rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             f'The job {rqjob.job_id} was cancelled.',
             rv.content.decode('utf-8'),
@@ -168,7 +176,7 @@ def test_running_job_cancelled(self):
         self.assertTrue(job.is_started)
 
         url = reverse('admin:autoemails_rqjob_cancel', args=[rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             f'Job {rqjob.job_id} has started and cannot be cancelled.',
             rv.content.decode('utf-8'),
@@ -197,7 +205,7 @@ def test_other_status_job(self):
         self.assertTrue(job.is_deferred)
 
         url = reverse('admin:autoemails_rqjob_cancel', args=[rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             'Job has unknown status or was already executed.',
             rv.content.decode('utf-8'),
diff --git a/amy/autoemails/tests/test_admin_edit_template.py b/amy/autoemails/tests/test_admin_edit_template.py
new file mode 100644
--- /dev/null
+++ b/amy/autoemails/tests/test_admin_edit_template.py
@@ -0,0 +1,164 @@
+from datetime import date, datetime, timedelta, timezone
+
+from django.test import TestCase
+from django.urls import reverse
+from rq.exceptions import NoSuchJobError
+from rq.job import Job
+from rq_scheduler.utils import to_unix
+
+from autoemails import admin
+from autoemails.actions import NewInstructorAction
+from autoemails.models import EmailTemplate, Trigger, RQJob
+from autoemails.tests.base import FakeRedisTestCaseMixin, dummy_job
+from workshops.models import Event, Organization, Person, Role, Task
+from workshops.tests.base import SuperuserMixin
+
+
+class TestAdminJobReschedule(SuperuserMixin, FakeRedisTestCaseMixin, TestCase):
+    def setUp(self):
+        super().setUp()
+        self._setUpSuperuser()  # creates self.admin
+
+        # save scheduler and connection data
+        self._saved_scheduler = admin.scheduler
+        # overwrite
+        admin.scheduler = self.scheduler
+
+        # fake RQJob
+        self.email = EmailTemplate.objects.create(slug="test-1")
+        self.trigger = Trigger.objects.create(action="new-instructor",
+                                              template=self.email)
+        self.rqjob = RQJob.objects.create(job_id="fake-id",
+                                          trigger=self.trigger)
+
+        self.new_template = "Welcome to AMY!"
+
+        # test event and task
+        LC_org = Organization.objects.create(domain="librarycarpentry.org",
+                                             fullname="Library Carpentry")
+        self.event =Event.objects.create(
+            slug="test-event",
+            host=Organization.objects.first(),
+            administrator=LC_org,
+            start=date.today() + timedelta(days=7),
+            end=date.today() + timedelta(days=8),
+        )
+        p = Person.objects.create(personal="Harry", family="Potter", email="hp@magic.uk")
+        r = Role.objects.create(name="instructor")
+        self.task = Task.objects.create(event=self.event, person=p, role=r)
+
+    def tearDown(self):
+        super().tearDown()
+        # bring back saved scheduler
+        admin.scheduler = self._saved_scheduler
+
+    def test_view_doesnt_allow_GET(self):
+        # log admin user
+        self._logSuperuserIn()
+
+        url = reverse('admin:autoemails_rqjob_edit_template', args=[self.rqjob.pk])
+        rv = self.client.get(url)
+        self.assertEqual(rv.status_code, 405)  # Method not allowed
+
+    def test_view_access_by_anonymous(self):
+        url = reverse('admin:autoemails_rqjob_edit_template',
+                      args=[self.rqjob.pk])
+        rv = self.client.post(url)
+        self.assertEqual(rv.status_code, 302)
+        # cannot check by assertRedirect because there's additional `?next`
+        # parameter
+        self.assertTrue(rv.url.startswith(reverse('login')))
+
+    def test_view_access_by_admin(self):
+        # log admin user
+        self._logSuperuserIn()
+
+        # try accessing the view again
+        url = reverse('admin:autoemails_rqjob_edit_template',
+                      args=[self.rqjob.pk])
+        rv = self.client.post(url)
+        self.assertEqual(rv.status_code, 302)
+        self.assertRedirects(rv, reverse('admin:autoemails_rqjob_preview',
+                                         args=[self.rqjob.pk]))
+
+    def test_no_such_job(self):
+        # log admin user
+        self._logSuperuserIn()
+
+        with self.assertRaises(NoSuchJobError):
+            Job.fetch(self.rqjob.job_id, connection=self.scheduler.connection)
+
+        url = reverse('admin:autoemails_rqjob_edit_template', args=[self.rqjob.pk])
+        rv = self.client.post(url, follow=True)
+        self.assertIn(
+            'The corresponding job in Redis was probably already executed',
+            rv.content.decode('utf-8'),
+        )
+
+    def test_job_not_in_scheduled_jobs_queue(self):
+        # log admin user
+        self._logSuperuserIn()
+
+        # case 1: job didn't go through RQ-Scheduler, but directly to Queue
+        job1 = self.queue.enqueue(dummy_job)
+        rqjob1 = RQJob.objects.create(job_id=job1.id, trigger=self.trigger)
+        Job.fetch(job1.id, connection=self.scheduler.connection)  # no error
+        with self.connection.pipeline() as pipe:
+            pipe.watch(self.scheduler.scheduled_jobs_key)
+            self.assertIsNone(
+                pipe.zscore(
+                    self.scheduler.scheduled_jobs_key, job1.id
+                )
+            )
+        url = reverse('admin:autoemails_rqjob_edit_template', args=[rqjob1.pk])
+        payload = {
+            'template': self.new_template,
+        }
+        rv = self.client.post(url, payload, follow=True)
+        self.assertIn(
+            f"The job {job1.id} template cannot be updated.",
+            rv.content.decode('utf-8'),
+        )
+
+        # case 2: job is no longer in the RQ-Scheduler queue, but it was there!
+        job2 = self.scheduler.enqueue_in(
+            timedelta(minutes=5),
+            dummy_job,
+        )
+        rqjob2 = RQJob.objects.create(job_id=job2.id, trigger=self.trigger)
+
+        # move job to the queue so it's executed
+        self.scheduler.enqueue_job(job2)
+        Job.fetch(job2.id, connection=self.scheduler.connection)  # no error
+        url = reverse('admin:autoemails_rqjob_edit_template', args=[rqjob2.pk])
+        rv = self.client.post(url, payload, follow=True)
+        self.assertIn(
+            f"The job {job2.id} template cannot be updated.",
+            rv.content.decode('utf-8'),
+        )
+
+    def test_job_template_updated_correctly(self):
+        # log admin user
+        self._logSuperuserIn()
+
+        action = NewInstructorAction(
+            self.trigger,
+            objects={"event": self.event, "task": self.task},
+        )
+        job = self.scheduler.enqueue_in(
+            timedelta(minutes=60),
+            action,
+        )
+        rqjob = RQJob.objects.create(job_id=job.id, trigger=self.trigger)
+        Job.fetch(job.id, connection=self.scheduler.connection)  # no error
+        url = reverse('admin:autoemails_rqjob_edit_template', args=[rqjob.pk])
+        payload = {
+            'template': self.new_template,
+        }
+        rv = self.client.post(url, payload, follow=True)
+        self.assertIn(
+            f'The job {job.id} template was updated', rv.content.decode('utf-8'),
+        )
+
+        job.refresh()
+        self.assertEqual(job.instance.template.body_template, "Welcome to AMY!")
diff --git a/amy/autoemails/tests/test_admin_reschedule.py b/amy/autoemails/tests/test_admin_reschedule.py
--- a/amy/autoemails/tests/test_admin_reschedule.py
+++ b/amy/autoemails/tests/test_admin_reschedule.py
@@ -34,10 +34,18 @@ def tearDown(self):
         # bring back saved scheduler
         admin.scheduler = self._saved_scheduler
 
+    def test_view_doesnt_allow_GET(self):
+        # log admin user
+        self._logSuperuserIn()
+
+        url = reverse('admin:autoemails_rqjob_reschedule', args=[self.rqjob.pk])
+        rv = self.client.get(url)
+        self.assertEqual(rv.status_code, 405)  # Method not allowed
+
     def test_view_access_by_anonymous(self):
         url = reverse('admin:autoemails_rqjob_reschedule',
                       args=[self.rqjob.pk])
-        rv = self.client.get(url)
+        rv = self.client.post(url)
         self.assertEqual(rv.status_code, 302)
         # cannot check by assertRedirect because there's additional `?next`
         # parameter
@@ -50,7 +58,7 @@ def test_view_access_by_admin(self):
         # try accessing the view again
         url = reverse('admin:autoemails_rqjob_reschedule',
                       args=[self.rqjob.pk])
-        rv = self.client.get(url)
+        rv = self.client.post(url)
         self.assertEqual(rv.status_code, 302)
         self.assertRedirects(rv, reverse('admin:autoemails_rqjob_preview',
                                          args=[self.rqjob.pk]))
@@ -63,7 +71,7 @@ def test_no_such_job(self):
             Job.fetch(self.rqjob.job_id, connection=self.scheduler.connection)
 
         url = reverse('admin:autoemails_rqjob_reschedule', args=[self.rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             'The corresponding job in Redis was probably already executed',
             rv.content.decode('utf-8'),
diff --git a/amy/autoemails/tests/test_admin_retry.py b/amy/autoemails/tests/test_admin_retry.py
--- a/amy/autoemails/tests/test_admin_retry.py
+++ b/amy/autoemails/tests/test_admin_retry.py
@@ -39,9 +39,17 @@ def tearDown(self):
         # bring back saved scheduler
         admin.scheduler = self._saved_scheduler
 
-    def test_view_access_by_anonymous(self):
+    def test_view_doesnt_allow_GET(self):
+        # log admin user
+        self._logSuperuserIn()
+
         url = reverse('admin:autoemails_rqjob_retry', args=[self.rqjob.pk])
         rv = self.client.get(url)
+        self.assertEqual(rv.status_code, 405)  # Method not allowed
+
+    def test_view_access_by_anonymous(self):
+        url = reverse('admin:autoemails_rqjob_retry', args=[self.rqjob.pk])
+        rv = self.client.post(url)
         self.assertEqual(rv.status_code, 302)
         # cannot check by assertRedirect because there's additional `?next`
         # parameter
@@ -53,7 +61,7 @@ def test_view_access_by_admin(self):
 
         # try accessing the view again
         url = reverse('admin:autoemails_rqjob_retry', args=[self.rqjob.pk])
-        rv = self.client.get(url)
+        rv = self.client.post(url)
         self.assertEqual(rv.status_code, 302)
         self.assertRedirects(rv, reverse('admin:autoemails_rqjob_preview',
                                          args=[self.rqjob.pk]))
@@ -66,7 +74,7 @@ def test_no_such_job(self):
             Job.fetch(self.rqjob.job_id, connection=self.scheduler.connection)
 
         url = reverse('admin:autoemails_rqjob_retry', args=[self.rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             'The corresponding job in Redis was probably already executed',
             rv.content.decode('utf-8'),
@@ -88,7 +96,7 @@ def test_job_not_failed(self):
                 )
             )
         url = reverse('admin:autoemails_rqjob_retry', args=[rqjob1.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             "You cannot re-try a non-failed job.",
             rv.content.decode('utf-8'),
@@ -105,7 +113,7 @@ def test_job_not_failed(self):
         self.scheduler.enqueue_job(job2)
         Job.fetch(job2.id, connection=self.scheduler.connection)  # no error
         url = reverse('admin:autoemails_rqjob_retry', args=[rqjob2.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             "You cannot re-try a non-failed job.",
             rv.content.decode('utf-8'),
@@ -135,7 +143,7 @@ def test_job_retried_correctly(self):
         self.assertEqual(job.get_status(), 'failed')
 
         url = reverse('admin:autoemails_rqjob_retry', args=[rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             f'The job {job.id} was requeued. It will be run shortly.',
             rv.content.decode('utf-8'),
diff --git a/amy/autoemails/tests/test_admin_sendnow.py b/amy/autoemails/tests/test_admin_sendnow.py
--- a/amy/autoemails/tests/test_admin_sendnow.py
+++ b/amy/autoemails/tests/test_admin_sendnow.py
@@ -34,9 +34,17 @@ def tearDown(self):
         # bring back saved scheduler
         admin.scheduler = self._saved_scheduler
 
-    def test_view_access_by_anonymous(self):
+    def test_view_doesnt_allow_GET(self):
+        # log admin user
+        self._logSuperuserIn()
+
         url = reverse('admin:autoemails_rqjob_sendnow', args=[self.rqjob.pk])
         rv = self.client.get(url)
+        self.assertEqual(rv.status_code, 405)  # Method not allowed
+
+    def test_view_access_by_anonymous(self):
+        url = reverse('admin:autoemails_rqjob_sendnow', args=[self.rqjob.pk])
+        rv = self.client.post(url)
         self.assertEqual(rv.status_code, 302)
         # cannot check by assertRedirect because there's additional `?next`
         # parameter
@@ -48,7 +56,7 @@ def test_view_access_by_admin(self):
 
         # try accessing the view again
         url = reverse('admin:autoemails_rqjob_sendnow', args=[self.rqjob.pk])
-        rv = self.client.get(url)
+        rv = self.client.post(url)
         self.assertEqual(rv.status_code, 302)
         self.assertRedirects(rv, reverse('admin:autoemails_rqjob_preview',
                                          args=[self.rqjob.pk]))
@@ -61,7 +69,7 @@ def test_no_such_job(self):
             Job.fetch(self.rqjob.job_id, connection=self.scheduler.connection)
 
         url = reverse('admin:autoemails_rqjob_sendnow', args=[self.rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             'The corresponding job in Redis was probably already executed',
             rv.content.decode('utf-8'),
@@ -83,7 +91,7 @@ def test_job_not_in_scheduled_jobs_queue(self):
                 )
             )
         url = reverse('admin:autoemails_rqjob_sendnow', args=[rqjob1.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             f"The job {job1.id} was not rescheduled. It is probably "
             'already executing or has recently executed',
@@ -101,7 +109,7 @@ def test_job_not_in_scheduled_jobs_queue(self):
         self.scheduler.enqueue_job(job2)
         Job.fetch(job2.id, connection=self.scheduler.connection)  # no error
         url = reverse('admin:autoemails_rqjob_sendnow', args=[rqjob2.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             f"The job {job2.id} was not rescheduled. It is probably "
             'already executing or has recently executed',
@@ -119,7 +127,7 @@ def test_job_rescheduled_correctly(self):
         rqjob = RQJob.objects.create(job_id=job.id, trigger=self.trigger)
         Job.fetch(job.id, connection=self.scheduler.connection)  # no error
         url = reverse('admin:autoemails_rqjob_sendnow', args=[rqjob.pk])
-        rv = self.client.get(url, follow=True)
+        rv = self.client.post(url, follow=True)
         self.assertIn(
             f'The job {job.id} was rescheduled to now.',
             rv.content.decode('utf-8'),