Merge pull request #51 from pyohio/pyohio-schedule-builder

Schedule Builder
This commit is contained in:
Patrick Altman 2014-07-31 23:37:01 -05:00
commit 3029878925
8 changed files with 349 additions and 1 deletions

View file

@ -19,6 +19,7 @@ Apps:
sponsorship
speakers
proposals
schedule
Indices and tables

49
docs/schedule.rst Normal file
View file

@ -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).

View file

@ -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.'

View file

View file

@ -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"
1 date time_start time_end kind room
2 12/12/2013 10:00 AM 11:00 AM plenary Room2
3 12/12/2013 10:00 AM 11:00 AM plenary Room1
4 12/12/2013 11:00 AM 12:00 PM talk Room1
5 12/12/2013 11:00 AM 12:00 PM talk Room2
6 12/12/2013 12:00 PM 12:45 PM plenary Room1
7 12/12/2013 12:00 PM 12:45 PM plenary Room2
8 12/13/2013 10:00 AM 11:00 AM plenary Room2
9 12/13/2013 10:00 AM 11:00 AM plenary Room1
10 12/13/2013 11:00 AM 12:00 PM talk Room1
11 12/13/2013 11:00 AM 12:00 PM talk Room2
12 12/13/2013 12:00 PM 12:45 PM plenary Room1
13 12/13/2013 12:00 PM 12:45 PM plenary Room2

View file

@ -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"
1 date time_start time_end kind room
2 12/12/2013 10:00 AM 11:00 AM plenary Room2
3 12/12/2013 10:00 AM 11:00 AM plenary Room1
4 12/12/2013 11:00 AM 12:00 PM talk Room1
5 12/12/2013 11:00 AM 12:00 PM talk Room2
6 12/12/2013 12:00 PM 12:45 PM plenary Room1
7 12/12/2013 12:00 PM 12:45 PM plenary Room2
8 12/13/2013 10:00 AM 11:00 AM plenary Room2
9 12/13/2013 10:00 AM 11:00 AM plenary Room1
10 12/13/2013 11:00 AM 12:00 PM talk Room1
11 12/13/2013 11:00 AM 12:00 PM talk Room2
12 12/13/2013 12:00 PM 12:45 PM plenary Room1
13 12/13/2013 12:00 PM 12:45 PM plenary Room2
14 12/13/2013 12:00 PM 12:45 PM plenary Room2

View file

@ -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())

View file

@ -24,7 +24,11 @@
{% include "schedule/_edit_grid.html" %}
{% endfor %}
</div>
<form id="schedule-builder" action="." method="post" enctype="multipart/form-data">{% csrf_token %}
{{ form.as_p }}
<input type="submit" name="submit" value="Submit" />
<input type="submit" id="delete" name="delete" value="Delete Schedule" />
</form>
<div class="modal fade hide in" id="slotEditModal"></div>
</div>
{% endblock %}