Repository: CTPUG/wafer

Issue:
Schedule Editor should report validation errors
The drag-n-drop schedule should report any validation errors that apply to the current schedule



Discussion:
I added a simple effort at this [here](https://github.com/CTPUG/wafer/pull/245/commits/26b77fa54a7269e6d7aab4b5e7e7c6574a55595d), however it should probably be its own PR.


Resolving patch:
diff --git a/wafer/schedule/urls.py b/wafer/schedule/urls.py
--- a/wafer/schedule/urls.py
+++ b/wafer/schedule/urls.py
@@ -4,7 +4,7 @@
 
 from wafer.schedule.views import (
     CurrentView, ScheduleView, ScheduleItemViewSet, ScheduleXmlView,
-    VenueView, ICalView, JsonDataView)
+    VenueView, ICalView, JsonDataView, get_validation_info)
 
 router = routers.DefaultRouter()
 router.register(r'scheduleitems', ScheduleItemViewSet)
@@ -17,5 +17,6 @@
         name='wafer_pentabarf_xml'),
     re_path(r'^schedule\.ics$', ICalView.as_view(), name="schedule.ics"),
     re_path(r'^schedule\.json$', JsonDataView.as_view(), name="schedule.json"),
+    re_path(r'^api/validate', get_validation_info),
     re_path(r'^api/', include(router.urls)),
 ]
diff --git a/wafer/schedule/views.py b/wafer/schedule/views.py
--- a/wafer/schedule/views.py
+++ b/wafer/schedule/views.py
@@ -20,6 +20,9 @@
 from bakery.views import BuildableDetailView, BuildableTemplateView, BuildableMixin
 from rest_framework import viewsets
 from rest_framework.permissions import IsAdminUser
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.response import Response
+
 from wafer import __version__
 from wafer.pages.models import Page
 from wafer.schedule.models import (
@@ -345,6 +348,15 @@ def get_context_data(self, **kwargs):
         return context
 
 
+@api_view(['GET'])
+@permission_classes([IsAdminUser])
+def get_validation_info(request):
+    """API wrapper around validate schedule for use in the schedule
+       editor"""
+    errors = validate_schedule()
+    return Response({'Validation Status': errors})
+
+
 class ScheduleItemViewSet(viewsets.ModelViewSet):
     """
     API endpoint that allows groups to be viewed or edited.


Test patch:
diff --git a/wafer/schedule/tests/test_schedule_editor.py b/wafer/schedule/tests/test_schedule_editor.py
--- a/wafer/schedule/tests/test_schedule_editor.py
+++ b/wafer/schedule/tests/test_schedule_editor.py
@@ -452,12 +452,9 @@ def test_drag_over_page(self):
             after.append(x.text)
         self.assertEqual(before, after)
 
-    @expectedFailure
     def test_adding_clash(self):
         """Test that introducing a speaker clash causes the
            error section to be updated"""
-        # Expected to fail -see https://github.com/CTPUG/wafer/issues/158
-        # Create initial schedule
         item1 = ScheduleItem.objects.create(venue=self.venues[0],
                                            talk_id=self.talk1.pk)
         item1.slots.add(self.block1_slots[0])
@@ -468,8 +465,8 @@ def test_adding_clash(self):
         item2.save()
         self._start()
         # Verify that there are no validation errors
-        with self.assertRaises(NoSuchElementException):
-            self.driver.find_element(By.TAG_NAME, "strong")
+        validation = self.driver.find_element(By.CLASS_NAME, "alert-danger")
+        self.assertFalse(validation.is_displayed())
         # Drag a talk into a clashing slot
         target = self.driver.find_element(By.ID, f"scheduleItem{item2.pk}")
         talks_link = self.driver.find_element(By.PARTIAL_LINK_TEXT, "Unassigned Talks")
@@ -495,21 +492,14 @@ def test_adding_clash(self):
         actions.drag_and_drop(source, target)
         actions.pause(0.5)
         actions.perform()
-        # Verify errors are present
-        # FIXME: The schedule editor doesn't currently update this
-        try:
-            vaidation = self.driver.find_element(By.TAG_NAME, "strong")
-        except NoSuchElementException:
-            vaidation = None
-        self.assertNotNone(vaidation)
-        self.assertIn('Validation errors:', validation.text)
+        # Verify error block is displayed
+        self.assertTrue(validation.is_displayed())
+        error_item = validation.find_element(By.TAG_NAME, "li")
+        self.assertIn('Common speaker', error_item.text)
 
-    @expectedFailure
     def test_removing_clash(self):
         """Test that removing a speaker clash causes the
            error section to be cleared"""
-        # Expected to fail -see https://github.com/CTPUG/wafer/issues/158
-        # Create initial schedule
         item1 = ScheduleItem.objects.create(venue=self.venues[0],
                                            talk_id=self.talk1.pk)
         item1.slots.add(self.block1_slots[0])
@@ -519,9 +509,11 @@ def test_removing_clash(self):
         item2.slots.add(self.block1_slots[0])
         item2.save()
         self._start()
-        # Verify that there are no validation errors
-        validation = self.driver.find_element(By.TAG_NAME, "strong")
-        self.assertIn('Validation errors:', validation.text)
+        # Verify that there are validation errors
+        validation = self.driver.find_element(By.CLASS_NAME, "alert-danger")
+        self.assertTrue(validation.is_displayed())
+        error_item = validation.find_element(By.TAG_NAME, "li")
+        self.assertIn('Common speaker', error_item.text)
         # Delete the clashing talk
         target = self.driver.find_element(By.ID, f"scheduleItem{item2.pk}")
         close = target.find_element(By.CLASS_NAME, 'close')
@@ -535,16 +527,16 @@ def test_removing_clash(self):
                 break
         # Verify we've deleted the clashing talk
         found = None
-        # Find the second talk and verify that talk 1 is not in
-        # the Unassigned Talk list
+        # Find the deleted talk in the Unassigned Talk list
         for x in tab_pane.find_elements(By.CLASS_NAME, 'draggable'):
             if self.talk4.title in x.text:
                 found = True
         self.assertTrue(found)
-        # Verify errors are gone
-        # FIXME: The schedule editor doesn't currently update this
+        # Verify errors are hidden
+        self.assertFalse(validation.is_displayed())
+        # Check that the list has been removed
         with self.assertRaises(NoSuchElementException):
-            self.driver.find_element(By.TAG_NAME, "strong")
+            validation.find_element(By.TAG_NAME,"li")
 
 
 class ChromeScheduleEditorTests(EditorTestsMixin, ChromeTestRunner):
diff --git a/wafer/schedule/tests/test_validation.py b/wafer/schedule/tests/test_validation.py
--- a/wafer/schedule/tests/test_validation.py
+++ b/wafer/schedule/tests/test_validation.py
@@ -11,7 +11,7 @@
 from wafer.schedule.admin import (find_clashes, find_invalid_venues, validate_items,
                                   find_duplicate_schedule_items, find_speaker_clashes,
                                   prefetch_schedule_items)
-from wafer.schedule.tests.test_views import make_pages, make_items
+from wafer.schedule.tests.test_views import make_pages, make_items, create_client
 
 
 def get_new_result(result, old_result):
@@ -24,7 +24,6 @@ def get_new_result(result, old_result):
 class ScheduleValidationTests(TestCase):
     """Test that various validators pick up the correct errors"""
 
-
     def setUp(self):
         """Create some blocks, slots and talks to work with"""
         timezone.activate('UTC')
@@ -265,7 +264,7 @@ def test_find_speaker_clashes(self):
         self.pages[7].people.add(self.author3)
         self.pages[7].people.add(self.author2)
         self.pages[7].save()
-        
+
         all_items = prefetch_schedule_items()
         result = list(find_speaker_clashes(all_items))
 
@@ -291,3 +290,96 @@ def test_find_speaker_clashes(self):
         self.assertIn(self.slots[3], new_result[0][0])
         self.assertIn(self.items[6], new_result[0][1])
         self.assertIn(self.items[7], new_result[0][1])
+
+
+class ScheduleValidationApiTests(TestCase):
+    """Test that validation status can be checked via the api"""
+
+    def setUp(self):
+        """Create some blocks, slots and talks to work with"""
+        timezone.activate('UTC')
+        self.block1 = ScheduleBlock.objects.create(
+                start_time=D.datetime(2013, 9, 22, 9, 0, 0,
+                                      tzinfo=D.timezone.utc),
+                end_time=D.datetime(2013, 9, 22, 19, 0, 0,
+                                    tzinfo=D.timezone.utc))
+
+        self.venue1 = Venue.objects.create(order=1, name='Venue 1')
+        self.venue2 = Venue.objects.create(order=2, name='Venue 2')
+        self.venue1.blocks.add(self.block1)
+        self.venue2.blocks.add(self.block1)
+
+        start1 = D.datetime(2013, 9, 22, 10, 0, 0,
+                            tzinfo=D.timezone.utc)
+        start2 = D.datetime(2013, 9, 22, 11, 0, 0,
+                            tzinfo=D.timezone.utc)
+        start3 = D.datetime(2013, 9, 22, 12, 0, 0,
+                            tzinfo=D.timezone.utc)
+        self.slots = []
+        self.slots.append(Slot.objects.create(start_time=start1,
+                                              end_time=start2))
+        self.slots.append(Slot.objects.create(start_time=start2,
+                                              end_time=start3))
+        self.pages = make_pages(4)
+        venues = [self.venue1, self.venue2] * 2
+        self.items = make_items(venues, self.pages)
+
+        for index, item in enumerate(self.items):
+            item.slots.add(self.slots[index // 2])
+
+        self.author1 = create_user('Author 1')
+        self.author2 = create_user('Author 2')
+
+        self.talk1 = create_talk('Talk 1', ACCEPTED, user=self.author1)
+        self.talk2 = create_talk('Talk 2', ACCEPTED, user=self.author2)
+        self.talk3 = create_talk('Talk 3', ACCEPTED, user=self.author1)
+
+    def test_normal_permissions(self):
+        """Check that we don't allow non-superuser access"""
+        c = create_client('ordinary', superuser=False)
+        response = c.get('/schedule/api/validate/')
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.data, {
+            "detail": "You do not have permission to perform this action.",
+        })
+
+    def test_no_errors(self):
+        """Check that we get no errors on a valid schedule"""
+        c = create_client('super', superuser=True)
+        response = c.get('/schedule/api/validate/')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data, {'Validation Status': []})
+
+    def test_find_duplicates(self):
+        """Test that duplicates are reported"""
+        # Create a duplicate item
+        duplicate_item = ScheduleItem.objects.create(venue=self.venue2,
+                                                     details="Duplicate item",
+                                                     page_id=self.pages[0].pk)
+        duplicate_item.slots.add(self.slots[1])
+        c = create_client('super', superuser=True)
+        response = c.get('/schedule/api/validate/')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.data['Validation Status']), 1)
+        self.assertIn('Clashes found in schedule', response.data['Validation Status'][0])
+
+    def test_find_speaker_clashes(self):
+        """Test that speaker clashes are reported"""
+        # Check two talks witht the same author at the same time fails
+        self.items[0].page_id = None
+        self.items[0].talk_id = self.talk1.pk
+        self.items[0].save()
+
+        self.items[2].page_id = None
+        self.items[2].talk_id = self.talk2.pk
+        self.items[2].save()
+
+        self.items[1].page_id = None
+        self.items[1].talk_id = self.talk3.pk
+        self.items[1].save()
+        c = create_client('super', superuser=True)
+        response = c.get('/schedule/api/validate/')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.data['Validation Status']), 1)
+        self.assertIn('Common speaker in simultaneous schedule items',
+                      response.data['Validation Status'][0])