diff --git a/wafer/schedule/admin.py b/wafer/schedule/admin.py index f49f6410..56b3e002 100644 --- a/wafer/schedule/admin.py +++ b/wafer/schedule/admin.py @@ -106,6 +106,29 @@ def find_invalid_venues(all_items): return venues.items() +def find_speaker_clashes(all_items): + """Find items that have the same speaker and also have overlapping + slots""" + clashes = {} + seen_slots_speakers = {} + for item in all_items: + if item.talk: + speakers = list(item.talk.authors.all()) + elif item.page: + speakers = list(item.page.people.all()) + for speaker in speakers: + for slot in item.slots.all(): + candidate = (slot, speaker) + if candidate in seen_slots_speakers: + if seen_slots_speakers[candidate] not in clashes: + clashes[candidate] = [seen_slots_speakers[candidate]] + clashes[candidate].append(item) + else: + seen_slots_speakers[candidate] = item + # We return a list, to match other validators + return clashes.items() + + # Helper methods for calling the validators def prefetch_schedule_items(): """Prefetch all schedule items and related objects.""" @@ -153,6 +176,9 @@ def register_schedule_item_validator(function, err_type, msg): register_schedule_item_validator( find_invalid_venues, 'venues', _('Invalid venues found in schedule.')) +register_schedule_item_validator( + find_speaker_clashes, 'speaker_clashes', + _('Common speaker in simultaneous schedule items')) # Utility functions for checking the schedule state diff --git a/wafer/schedule/templates/admin/scheduleitem_list.html b/wafer/schedule/templates/admin/scheduleitem_list.html index 5ab75507..cc7d9bc6 100644 --- a/wafer/schedule/templates/admin/scheduleitem_list.html +++ b/wafer/schedule/templates/admin/scheduleitem_list.html @@ -57,6 +57,18 @@

{% trans "Venues assigned on days they are not available" %}

{% endfor %} {% endif %} + {% if errors.speaker_clashes %} +

{% trans "Common speaker in simultaneous schedule items" %}

+ + {% endif %} {% endblock %} {% endif %} diff --git a/wafer/schedule/tests/test_validation.py b/wafer/schedule/tests/test_validation.py new file mode 100644 index 00000000..a1f34dac --- /dev/null +++ b/wafer/schedule/tests/test_validation.py @@ -0,0 +1,293 @@ +import datetime as D +from django.utils import timezone + +from django.test import TestCase + +from wafer.tests.utils import create_user +from wafer.talks.tests.fixtures import create_talk +from wafer.talks.models import ACCEPTED, CANCELLED, REJECTED + +from wafer.schedule.models import ScheduleBlock, Slot, ScheduleItem, Venue +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 + + +def get_new_result(result, old_result): + """Helper method so we're robust against the influence of database + ordering on the results""" + old_keys = set(x[0] for x in old_result) + return [x for x in result if x[0] not in old_keys] + + +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') + 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.block2 = ScheduleBlock.objects.create( + start_time=D.datetime(2013, 9, 23, 9, 0, 0, + tzinfo=D.timezone.utc), + end_time=D.datetime(2013, 9, 23, 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.venue3 = Venue.objects.create(order=3, name='Venue 3') + self.venue1.blocks.add(self.block1) + self.venue2.blocks.add(self.block1) + self.venue3.blocks.add(self.block2) + + 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) + start4 = D.datetime(2013, 9, 22, 13, 0, 0, + tzinfo=D.timezone.utc) + start5 = D.datetime(2013, 9, 22, 14, 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.slots.append(Slot.objects.create(start_time=start3, + end_time=start4)) + self.slots.append(Slot.objects.create(start_time=start4, + end_time=start5)) + self.pages = make_pages(8) + venues = [self.venue1, self.venue2] * 4 + 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.author3 = create_user('Author 3') + self.author4 = create_user('Author 4') + self.author5 = create_user('Author 5') + + 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.author3) + self.talk4 = create_talk('Talk 4', ACCEPTED, user=self.author4) + self.talk5 = create_talk('Talk 5', ACCEPTED, user=self.author5) + self.talk6 = create_talk('Talk 6', ACCEPTED, user=self.author1) + + def test_invalid_venue(self): + """Test that the `find_invalid_venues` validator detects broken slots""" + # Assert that everything is OK at the start + all_items = prefetch_schedule_items() + self.assertEqual(list(find_invalid_venues(all_items)), []) + # Create an invalid slot/venue combination + wrong_venue_item = ScheduleItem.objects.create(venue=self.venue3, + details="Invalid venue item", + page_id=self.pages[0].pk) + wrong_venue_item.slots.add(self.slots[1]) + all_items = prefetch_schedule_items() + result = list(find_invalid_venues(all_items)) + self.assertEqual(len(result), 1) + self.assertEqual(self.venue3, result[0][0]) + self.assertIn(wrong_venue_item, result[0][1]) + + def test_find_clashes(self): + """Test that the `find_clashes` validator finds items that clash""" + # Assert that everything is OK at the start + all_items = prefetch_schedule_items() + self.assertEqual(list(find_clashes(all_items)), []) + # 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]) + all_items = prefetch_schedule_items() + result = list(find_clashes(all_items)) + self.assertEqual(len(result), 1) + self.assertIn(self.venue2, result[0][0]) + self.assertIn(self.slots[1], result[0][0]) + # We should have 2 duplicate items listed + self.assertEqual(len(result[0][1]), 2) + self.assertIn(duplicate_item, result[0][1]) + + def test_duplicate_schedule_items(self): + """Test that the `find_duplicate_schedule_items` validator finds + talks that are assigned to multiple schedule items""" + all_items = prefetch_schedule_items() + self.assertEqual(list(find_duplicate_schedule_items(all_items)), []) + # Create a clash assigning a talk to 2 items + self.items[0].page_id = None + self.items[0].talk_id = self.talk1.pk + self.items[0].save() + + self.items[6].page_id = None + self.items[6].talk_id = self.talk1.pk + self.items[6].save() + + all_items = prefetch_schedule_items() + result = list(find_duplicate_schedule_items(all_items)) + self.assertEqual(len(result), 2) + self.assertIn(self.items[0], result) + self.assertIn(self.items[6], result) + + def test_validate_items(self): + """Test that the `validate_items` check works""" + rejected_talk = create_talk('Talk Rejected', REJECTED, user=self.author1) + cancelled_talk = create_talk('Talk Rejected', CANCELLED, user=self.author1) + + all_items = prefetch_schedule_items() + self.assertEqual(list(validate_items(all_items)), []) + + # Check that a cancelled talk doesn't fail + self.items[7].page_id = None + self.items[7].talk_id = cancelled_talk.pk + self.items[7].save() + all_items = prefetch_schedule_items() + self.assertEqual(list(validate_items(all_items)), []) + + # Test schedule item with no talk or page fail + self.items[0].page_id = None + self.items[0].save() + all_items = prefetch_schedule_items() + result = list(validate_items(all_items)) + self.assertEqual(len(result), 1) + self.assertIn(self.items[0], result) + + # Test that schedule item with both a talk and page fails + self.items[1].page_id = self.pages[0].pk + self.items[1].talk_id = self.talk1.pk + self.items[1].save() + all_items = prefetch_schedule_items() + result = list(validate_items(all_items)) + self.assertEqual(len(result), 2) + self.assertIn(self.items[0], result) + self.assertIn(self.items[1], result) + + # Test that a rejected talk on the schedule fails + self.items[2].page_id = None + self.items[2].talk_id = rejected_talk.pk + self.items[2].save() + all_items = prefetch_schedule_items() + result = list(validate_items(all_items)) + self.assertEqual(len(result), 3) + self.assertIn(self.items[0], result) + self.assertIn(self.items[1], result) + self.assertIn(self.items[2], result) + + def test_find_speaker_clashes(self): + """Test the the `find_spekaer_clashes` check works""" + # Replace some pages with talks and check that it is valid + self.items[0].page_id = None + self.items[0].talk_id = self.talk1.pk + self.items[0].save() + + self.items[1].page_id = None + self.items[1].talk_id = self.talk2.pk + self.items[1].save() + + self.items[2].page_id = None + self.items[2].talk_id = self.talk3.pk + self.items[2].save() + + self.items[3].page_id = None + self.items[3].talk_id = self.talk4.pk + self.items[3].save() + + self.items[4].page_id = None + self.items[4].talk_id = self.talk5.pk + self.items[4].save() + + all_items = prefetch_schedule_items() + self.assertEqual(list(find_speaker_clashes(all_items)), []) + # Check two talks witht the same author at the same time fails + self.items[1].talk_id = self.talk6.pk + self.items[1].save() + + all_items = prefetch_schedule_items() + result = list(find_speaker_clashes(all_items)) + self.assertEqual(len(result), 1) + self.assertIn(self.author1, result[0][0]) + self.assertIn(self.slots[0], result[0][0]) + self.assertIn(self.items[0], result[0][1]) + self.assertIn(self.items[1], result[0][1]) + old_result = result + + # Check that it also fails if the one talk has multiple authors, and + # the common speaker is not the primary author + + self.talk4.authors.add(self.author3) + self.talk4.save() + + all_items = prefetch_schedule_items() + result = list(find_speaker_clashes(all_items)) + + self.assertEqual(len(result), 2) + new_result = get_new_result(result, old_result) + self.assertIn(self.author3, new_result[0][0]) + self.assertIn(self.slots[1], new_result[0][0]) + self.assertIn(self.items[2], new_result[0][1]) + self.assertIn(self.items[3], new_result[0][1]) + old_result = result + + # Check that it also fails if the speaker is assigned to a page and a talk + + self.pages[5].people.add(self.author5) + self.pages[5].save() + + all_items = prefetch_schedule_items() + result = list(find_speaker_clashes(all_items)) + + self.assertEqual(len(result), 3) + new_result = get_new_result(result, old_result) + self.assertIn(self.author5, new_result[0][0]) + self.assertIn(self.slots[2], new_result[0][0]) + self.assertIn(self.items[4], new_result[0][1]) + self.assertIn(self.items[5], new_result[0][1]) + old_result = result + + # Check that it also fails in the case of 2 pages + + self.pages[6].people.add(self.author4) + self.pages[6].people.add(self.author1) + self.pages[6].save() + + self.pages[7].people.add(self.author4) + 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)) + + self.assertEqual(len(result), 4) + new_result = get_new_result(result, old_result) + self.assertIn(self.author4, new_result[0][0]) + 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]) + old_result = result + + # Also check that multiple clashing speakers are reported + + self.pages[6].people.add(self.author2) + self.pages[6].save() + + all_items = prefetch_schedule_items() + result = list(find_speaker_clashes(all_items)) + + self.assertEqual(len(result), 5) + new_result = get_new_result(result, old_result) + self.assertIn(self.author2, new_result[0][0]) + 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]) diff --git a/wafer/schedule/tests/test_views.py b/wafer/schedule/tests/test_views.py index 1d4c7055..691e80c4 100644 --- a/wafer/schedule/tests/test_views.py +++ b/wafer/schedule/tests/test_views.py @@ -1091,7 +1091,8 @@ def test_view_hidden(self): assert response.context['active'] is False assert 'draft_warning' not in response.context - # Check that a user with elevated privileges sees validation errors + # Check that a user with elevated privileges sees the schedule + # and the appropriate warning admin = create_client('admin', True) response = admin.get('/schedule/')