diff --git a/docs/index.rst b/docs/index.rst index d8e9391e..732670ca 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ Apps: sponsorship speakers proposals + schedule Indices and tables diff --git a/docs/schedule.rst b/docs/schedule.rst new file mode 100644 index 00000000..15d37fa1 --- /dev/null +++ b/docs/schedule.rst @@ -0,0 +1,49 @@ +Schedule App +=========== + +The ``schedule`` app allows staff members to create the schedule for the +conference's presentations, breaks, lunches, etc. + +The ```schedule``` app has a number of models that facilitate building the +structured schedule: + + * Schedule: A high level container that maps to each Conference Section. + * Day: A Day associated with a Schedule. + * Room: A Room associated with a Schedule. + * Slot Kind: A type of Slot associated with a Schedule. + * Slot: A discrete time period for a Schedule. + * Slot Room: A mapping of a Room and Slot for a given Schedule. + * Presentation: A mapping of a Slot to an approved Proposal from the ```proposals``` app. + +Schedule Builder Form +--------------------- + +It can be cumbersome to generate a schedule through the admin. With that in mind, +a generic schedule builder is available via a Schedule's edit view. For instance, +if a Conference site has a Talks Section and Schedule, the form would be +available for Staff at:: + +/schedule/talks/edit + +The form consumes a structured CSV file, from which it will build the schedule. +Sample CSV data is included below:: + +"date","time_start","time_end","kind"," room " +"12/12/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/12/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room2" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room2" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room2" + +It is worth noting that this generates the **structure** of the schedule. It +does not create Presentation objects. This will need to be done manually. + +One can also **delete** an existing schedule via the delete action. This is +irreversible (save for a database restore). diff --git a/symposion/schedule/forms.py b/symposion/schedule/forms.py index 5a0f97c1..161f0396 100644 --- a/symposion/schedule/forms.py +++ b/symposion/schedule/forms.py @@ -40,3 +40,114 @@ class SlotEditForm(forms.Form): "initial": self.slot.content_override, } return forms.CharField(**kwargs) + + +class ScheduleSectionForm(forms.Form): + ROOM_KEY = 'room' + DATE_KEY = 'date' + START_KEY = 'time_start' + END_KEY = 'time_end' + KIND = 'kind' + + filename = forms.FileField( + label='Select a CSV file to import:', + required=False + ) + + def __init__(self, *args, **kwargs): + self.schedule = kwargs.pop("schedule") + super(ScheduleSectionForm, self).__init__(*args, **kwargs) + + def clean_filename(self): + if 'submit' in self.data: + fname = self.cleaned_data.get('filename') + if not fname or not fname.name.endswith('.csv'): + raise forms.ValidationError(u'Please upload a .csv file') + return fname + + def _get_start_end_times(self, data): + "Return start and end time objects" + times = [] + for x in [data[self.START_KEY], data[self.END_KEY]]: + try: + time_obj = time.strptime(x, '%I:%M %p') + except: + return messages.ERROR, u'Malformed time found: %s.' % x + time_obj = datetime(100, 1, 1, time_obj.tm_hour, time_obj.tm_min, 00) + times.append(time_obj.time()) + return times + + def _build_rooms(self, data): + "Get or Create Rooms based on schedule type and set of Tracks" + created_rooms = [] + rooms = sorted(set([x[self.ROOM_KEY] for x in data])) + for i, room in enumerate(rooms): + room, created = Room.objects.get_or_create( + schedule=self.schedule, name=room, order=i + ) + if created: + created_rooms.append(room) + return created_rooms + + def _build_days(self, data): + "Get or Create Days based on schedule type and set of Days" + created_days = [] + days = set([x[self.DATE_KEY] for x in data]) + for day in days: + try: + date = datetime.strptime(day, "%m/%d/%Y") + except ValueError: + [x.delete() for x in created_days] + return messages.ERROR, u'Malformed data found: %s.' % day + day, created = Day.objects.get_or_create( + schedule=self.schedule, date=date + ) + if created: + created_days.append(day) + return created_days + + def build_schedule(self): + created_items = [] + reader = csv.DictReader(self.cleaned_data.get('filename')) + data = [dict((k.strip(), v.strip()) for k, v in x.items()) for x in reader] + # build rooms + created_items.extend(self._build_rooms(data)) + # build_days + created_items.extend(self._build_days(data)) + # build Slot -> SlotRoom + for row in data: + room = Room.objects.get( + schedule=self.schedule, name=row[self.ROOM_KEY] + ) + date = datetime.strptime(row[self.DATE_KEY], "%m/%d/%Y") + day = Day.objects.get(schedule=self.schedule, date=date) + start, end = self._get_start_end_times(row) + slot_kind, created = SlotKind.objects.get_or_create( + label=row[self.KIND], schedule=self.schedule + ) + if created: + created_items.append(slot_kind) + if row[self.KIND] == 'plenary': + slot, created = Slot.objects.get_or_create( + kind=slot_kind, day=day, start=start, end=end + ) + if created: + created_items.append(slot) + else: + slot = Slot.objects.create( + kind=slot_kind, day=day, start=start, end=end + ) + created_items.append(slot) + try: + with transaction.atomic(): + SlotRoom.objects.create(slot=slot, room=room) + except IntegrityError: + # delete all created objects and report error + for x in created_items: + x.delete() + return messages.ERROR, u'An overlap occurred; the import was cancelled.' + return messages.SUCCESS, u'Your schedule has been imported.' + + def delete_schedule(self): + self.schedule.day_set.all().delete() + return messages.SUCCESS, u'Your schedule has been deleted.' diff --git a/symposion/schedule/tests/__init__.py b/symposion/schedule/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/symposion/schedule/tests/data/schedule.csv b/symposion/schedule/tests/data/schedule.csv new file mode 100644 index 00000000..66a87bdc --- /dev/null +++ b/symposion/schedule/tests/data/schedule.csv @@ -0,0 +1,13 @@ +"date","time_start","time_end","kind"," room " +"12/12/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/12/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room2" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room2" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room2" diff --git a/symposion/schedule/tests/data/schedule_overlap.csv b/symposion/schedule/tests/data/schedule_overlap.csv new file mode 100644 index 00000000..3c02ce2b --- /dev/null +++ b/symposion/schedule/tests/data/schedule_overlap.csv @@ -0,0 +1,14 @@ +"date","time_start","time_end","kind"," room " +"12/12/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/12/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room2" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room2" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room2" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room2" diff --git a/symposion/schedule/tests/test_forms.py b/symposion/schedule/tests/test_forms.py new file mode 100644 index 00000000..883d7ac0 --- /dev/null +++ b/symposion/schedule/tests/test_forms.py @@ -0,0 +1,156 @@ +import os + +from datetime import datetime, timedelta + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase + +from symposion.conference.models import Conference, Section + +from ..forms import ScheduleSectionForm +from ..models import Day, Room, Schedule, Slot, SlotKind + +DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') + + +class ScheduleSectionFormTests(TestCase): + + def setUp(self): + self.conference = Conference.objects.create(title='test') + self.section = Section.objects.create( + conference=self.conference, + name='test') + self.schedule = Schedule.objects.create(section=self.section) + self.today = datetime.now() + self.tomorrow = self.today + timedelta(days=1) + + def test_clean_filename(self): + """Ensure a file is provided if the submit action was utilized""" + data = {'submit': 'Submit'} + form = ScheduleSectionForm(data=data, schedule=self.schedule) + self.assertIn('filename', form.errors) + + def test_clean_filename_not_required(self): + """Ensure file is not required if the delete action was utilize""" + data = {'delete': 'Delete'} + form = ScheduleSectionForm(data=data, schedule=self.schedule) + self.assertTrue(form.is_valid()) + + def test_delete(self): + """Delete schedule (Days) for supplied section""" + Day.objects.create(schedule=self.schedule, date=self.today) + Day.objects.create(schedule=self.schedule, date=self.tomorrow) + other_section = Section.objects.create(conference=self.conference, name='other') + other_schedule = Schedule.objects.create(section=other_section) + other_day = Day.objects.create(schedule=other_schedule, date=self.tomorrow) + self.assertEqual(3, Day.objects.all().count()) + data = {'delete': 'Delete'} + form = ScheduleSectionForm(data=data, schedule=self.schedule) + form.delete_schedule() + days = Day.objects.all() + self.assertEqual(1, days.count()) + self.assertIn(other_day, days) + + def test_build_days(self): + """Test private method to build days based off ingested CSV""" + form = ScheduleSectionForm(schedule=self.schedule) + data = ( + {'date': datetime.strftime(self.today, "%m/%d/%Y")}, + {'date': datetime.strftime(self.today, "%m/%d/%Y")}, + {'date': datetime.strftime(self.tomorrow, "%m/%d/%Y")}, + ) + self.assertEqual(0, Day.objects.all().count()) + form._build_days(data) + self.assertEqual(2, Day.objects.all().count()) + + def test_build_days_malformed(self): + """Test failure for malformed date in CSV""" + form = ScheduleSectionForm(schedule=self.schedule) + data = ( + {'date': datetime.strftime(self.today, "%m/%d/%Y")}, + {'date': '12-12-12'} + ) + self.assertEqual(0, Day.objects.all().count()) + msg_type, msg = form._build_days(data) + self.assertEqual(0, Day.objects.all().count()) + self.assertEqual(40, msg_type) + self.assertIn('12-12-12', msg) + + def test_build_rooms(self): + """Test private method to build rooms based off ingested CSV""" + form = ScheduleSectionForm(schedule=self.schedule) + data = ( + {'room': 'foo'}, + {'room': 'bar'}, + {'room': 'foo'}, + ) + self.assertEqual(0, Room.objects.all().count()) + form._build_rooms(data) + self.assertEqual(2, Room.objects.all().count()) + + def test_get_start_end_times(self): + """ + Test private method to convert start and end times based off + ingested CSV + """ + form = ScheduleSectionForm(schedule=self.schedule) + start = '12:00 PM' + end = '01:00 PM' + data = {'time_start': start, 'time_end': end} + start_time, end_time = form._get_start_end_times(data) + self.assertEqual(start, start_time.strftime('%I:%M %p')) + self.assertEqual(end, end_time.strftime('%I:%M %p')) + + def test_get_start_end_times_malformed(self): + """ + Test private method for malformed time based off ingested CSV + """ + form = ScheduleSectionForm(schedule=self.schedule) + start = '12:00' + end = '01:00' + data = {'time_start': start, 'time_end': end} + msg_type, msg = form._get_start_end_times(data) + self.assertEqual(40, msg_type) + self.assertIn('Malformed', msg) + + def test_build_schedule(self): + """ + Test successful schedule build based off ingested CSV + """ + self.assertEqual(0, Day.objects.all().count()) + self.assertEqual(0, Room.objects.all().count()) + self.assertEqual(0, Slot.objects.all().count()) + self.assertEqual(0, SlotKind.objects.all().count()) + schedule_csv = open(os.path.join(DATA_DIR, 'schedule.csv'), 'rb') + file_data = {'filename': SimpleUploadedFile(schedule_csv.name, schedule_csv.read())} + data = {'submit': 'Submit'} + form = ScheduleSectionForm(data, file_data, schedule=self.schedule) + form.is_valid() + msg_type, msg = form.build_schedule() + self.assertEqual(25, msg_type) + self.assertIn('imported', msg) + self.assertEqual(2, Day.objects.all().count()) + self.assertEqual(2, Room.objects.all().count()) + self.assertEqual(8, Slot.objects.all().count()) + self.assertEqual(2, SlotKind.objects.all().count()) + + def test_build_schedule_overlap(self): + """ + Test rolledback schedule build based off ingested CSV with Slot overlap + """ + self.assertEqual(0, Day.objects.all().count()) + self.assertEqual(0, Room.objects.all().count()) + self.assertEqual(0, Slot.objects.all().count()) + self.assertEqual(0, SlotKind.objects.all().count()) + schedule_csv = open(os.path.join(DATA_DIR, 'schedule_overlap.csv'), 'rb') + file_data = {'filename': SimpleUploadedFile(schedule_csv.name, schedule_csv.read())} + data = {'submit': 'Submit'} + form = ScheduleSectionForm(data, file_data, schedule=self.schedule) + form.is_valid() + msg_type, msg = form.build_schedule() + self.assertEqual(40, msg_type) + self.assertIn('overlap', msg) + self.assertEqual(0, Day.objects.all().count()) + self.assertEqual(0, Room.objects.all().count()) + self.assertEqual(0, Slot.objects.all().count()) + self.assertEqual(0, SlotKind.objects.all().count()) diff --git a/symposion/templates/schedule/schedule_edit.html b/symposion/templates/schedule/schedule_edit.html index 5de11af2..b5335506 100644 --- a/symposion/templates/schedule/schedule_edit.html +++ b/symposion/templates/schedule/schedule_edit.html @@ -24,7 +24,11 @@ {% include "schedule/_edit_grid.html" %} {% endfor %} - +
{% endblock %}