Repository: DDMAL/CantusDB

Issue:
Automatically run populate_next_chant_fields in staging/production
We need to have some automation to run `python manage.py populate_next_chant_fields` from time to time on our servers; otherwise, the function in /next_chants.py will not work.


Discussion:
Since we're using Celery in CantusUltimus already and the two projects will eventually be merged, Dylan suggested we might use Celery Beat to accomplish this.

Resolving patch:
diff --git a/django/cantusdb_project/main_app/management/commands/populate_next_chant_fields.py b/django/cantusdb_project/main_app/management/commands/populate_next_chant_fields.py
--- a/django/cantusdb_project/main_app/management/commands/populate_next_chant_fields.py
+++ b/django/cantusdb_project/main_app/management/commands/populate_next_chant_fields.py
@@ -2,6 +2,17 @@
 from django.core.exceptions import ValidationError
 from django.core.management.base import BaseCommand
 
+# This script memoizes the result of Chant.get_next_chant(), which is expensive
+# to calculate on the fly, saving it as the Chant's .next_chant property.
+# This script populates the next_chant field for all chants in the database.
+# Once it has been calculated once (for example, after importing data
+# from OldCantus), it shouldn't need to be run again - whenever chants are
+# created, updated or deleted, the field should be recalculated for all chants
+# in the source by update_next_chant_fields() in main_app/signals.py.
+
+# to calculate all chants' next_chants from scratch: `python manage.py populate_next_chant_fields --overwrite`
+# to calculate next_chants for all chants that don't already have a next_chant specified: `python manage.py populate_next_chant_fields`
+
 class Command(BaseCommand):
     def add_arguments(self, parser):
         parser.add_argument(
diff --git a/django/cantusdb_project/main_app/signals.py b/django/cantusdb_project/main_app/signals.py
--- a/django/cantusdb_project/main_app/signals.py
+++ b/django/cantusdb_project/main_app/signals.py
@@ -45,3 +45,15 @@ def update_source_melody_count(instance, **kwargs):
     source = instance.source
     source.number_of_melodies = source.chant_set.filter(volpiano__isnull=False).count()
     source.save()
+
+@receiver(post_save, sender=Chant)
+@receiver(post_delete, sender=Chant)
+def update_next_chant_fields(instance, **kwargs):
+    """When saving or deleting a Chant, make sure the next_chant of each chant in the source is up-to-date"""
+    source = instance.source
+    for chant in source.chant_set.all():
+        next_chant = chant.get_next_chant()
+        # use .update() instead of .save() to prevent RecursionError
+        # (otherwise, saving would trigger @receiver(post_save, ...) again)
+        Chant.objects.filter(id=chant.id).update(next_chant=next_chant)
+


Test patch:
diff --git a/django/cantusdb_project/main_app/tests/test_views.py b/django/cantusdb_project/main_app/tests/test_views.py
--- a/django/cantusdb_project/main_app/tests/test_views.py
+++ b/django/cantusdb_project/main_app/tests/test_views.py
@@ -1046,6 +1046,16 @@ def test_template_used(self):
             self.assertTemplateUsed(response, "base.html")
             self.assertTemplateUsed(response, "chant_create.html")
 
+    def test_next_chant_signal(self):
+        source = make_fake_source()
+        chant_1 = make_fake_chant(source=source, folio="001r", sequence_number=1)
+        response = self.client.post(
+            reverse("chant-create", args=[source.id]), 
+            {"manuscript_full_text_std_spelling": "cantus secundus", "folio": "001r", "sequence_number": "2"})
+        chant_2 = Chant.objects.get(manuscript_full_text_std_spelling="cantus secundus")
+        chant_1.refresh_from_db()
+        self.assertEqual(chant_1.next_chant, chant_2)
+
 
 class ChantDeleteViewTest(TestCase):
     @classmethod
@@ -1087,6 +1097,18 @@ def test_non_existing_chant(self):
         response = self.client.post(reverse("chant-delete", args=[chant.id + 100]))
         self.assertEqual(response.status_code, 404)
 
+    def test_next_chant_signal(self):
+        chant_1 = make_fake_chant()
+        chant_2 = make_fake_chant(source=chant_1.source, folio=chant_1.folio, sequence_number=chant_1.sequence_number + 1)
+
+        chant_1.refresh_from_db()
+        self.assertEqual(chant_1.next_chant, chant_2)
+
+        response = self.client.post(reverse("chant-delete", args=[chant_2.id]))
+
+        chant_1.refresh_from_db()
+        self.assertIs(chant_1.next_chant, None)
+
 
 class ChantEditVolpianoViewTest(TestCase):
     @classmethod
@@ -1157,6 +1179,25 @@ def test_provide_suggested_fulltext(self):
         suggested_fulltext = response.context["suggested_fulltext"]
         self.assertEqual(suggested_fulltext, expected_suggestion)
 
+    def test_next_chant_signal(self):
+        source = make_fake_source()
+        chant_1 = make_fake_chant(source=source, folio="001r", sequence_number=1, manuscript_full_text_std_spelling="chant_1")
+        chant_2 = make_fake_chant(source=source, folio="002r", sequence_number=1, manuscript_full_text_std_spelling="chant_2")
+
+        response = self.client.post(
+            f"/edit-volpiano/{source.id}?pk={chant_2.id}&folio={chant_2.folio}",
+            {
+                "manuscript_full_text_std_spelling": "chant_2",
+                "folio": "001v",
+                "sequence_number": "1"
+            }
+        )
+
+        chant_1.refresh_from_db()
+
+        chant_2_updated = Chant.objects.get(manuscript_full_text_std_spelling="chant_2")
+        self.assertEqual(chant_1.next_chant, chant_2_updated)
+
 
 class ChantProofreadViewTest(TestCase):
     @classmethod
@@ -2654,26 +2695,32 @@ def test_existing_cantus_id(self):
         fake_source_1 = make_fake_source()
         fake_source_2 = make_fake_source()
 
-        fake_chant_2 = Chant.objects.create(
+        fake_chant_1 = Chant.objects.create(
             source = fake_source_1,
-            cantus_id = "2000"
+            cantus_id = "1000",
+            folio="001r",
+            sequence_number=1,
         )
 
-        fake_chant_1 = Chant.objects.create(
+        fake_chant_2 = Chant.objects.create(
             source = fake_source_1,
-            next_chant = fake_chant_2,
-            cantus_id = "1000"
+            cantus_id = "2000",
+            folio="001r",
+            sequence_number=2,
         )
-        
-        fake_chant_4 = Chant.objects.create(
+
+        fake_chant_3 = Chant.objects.create(
             source = fake_source_2,
-            cantus_id = "2000"
+            cantus_id = "1000",
+            folio="001r",
+            sequence_number=1,
         )
 
-        fake_chant_3 = Chant.objects.create(
+        fake_chant_4 = Chant.objects.create(
             source = fake_source_2,
-            next_chant = fake_chant_4,
-            cantus_id = "1000"
+            cantus_id = "2000",
+            folio="001r",
+            sequence_number=2,
         )
 
         path = reverse("json-nextchants", args=["1000"])
@@ -2712,26 +2759,32 @@ def test_published_vs_unpublished(self):
         fake_source_1 = make_fake_source(published=True)
         fake_source_2 = make_fake_source(published=False)
 
-        fake_chant_2 = Chant.objects.create(
+        fake_chant_1 = Chant.objects.create(
             source = fake_source_1,
-            cantus_id = "2000"
+            cantus_id = "1000",
+            folio="001r",
+            sequence_number=1,
         )
 
-        fake_chant_1 = Chant.objects.create(
+        fake_chant_2 = Chant.objects.create(
             source = fake_source_1,
-            next_chant = fake_chant_2,
-            cantus_id = "1000"
+            cantus_id = "2000",
+            folio="001r",
+            sequence_number=2,
         )
-        
-        fake_chant_4 = Chant.objects.create(
+
+        fake_chant_3 = Chant.objects.create(
             source = fake_source_2,
-            cantus_id = "2000"
+            cantus_id = "1000",
+            folio="001r",
+            sequence_number=1,
         )
 
-        fake_chant_3 = Chant.objects.create(
+        fake_chant_4 = Chant.objects.create(
             source = fake_source_2,
-            next_chant = fake_chant_4,
-            cantus_id = "1000"
+            cantus_id = "2000",
+            folio="001r",
+            sequence_number=2,
         )
 
         path = reverse("json-nextchants", args=["1000"])