Merge pull request #51 from pyohio/pyohio-schedule-builder
Schedule Builder
This commit is contained in:
commit
3029878925
8 changed files with 349 additions and 1 deletions
|
@ -19,6 +19,7 @@ Apps:
|
||||||
sponsorship
|
sponsorship
|
||||||
speakers
|
speakers
|
||||||
proposals
|
proposals
|
||||||
|
schedule
|
||||||
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
|
|
49
docs/schedule.rst
Normal file
49
docs/schedule.rst
Normal 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).
|
|
@ -40,3 +40,114 @@ class SlotEditForm(forms.Form):
|
||||||
"initial": self.slot.content_override,
|
"initial": self.slot.content_override,
|
||||||
}
|
}
|
||||||
return forms.CharField(**kwargs)
|
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.'
|
||||||
|
|
0
symposion/schedule/tests/__init__.py
Normal file
0
symposion/schedule/tests/__init__.py
Normal file
13
symposion/schedule/tests/data/schedule.csv
Normal file
13
symposion/schedule/tests/data/schedule.csv
Normal 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"
|
|
14
symposion/schedule/tests/data/schedule_overlap.csv
Normal file
14
symposion/schedule/tests/data/schedule_overlap.csv
Normal 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"
|
|
156
symposion/schedule/tests/test_forms.py
Normal file
156
symposion/schedule/tests/test_forms.py
Normal 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())
|
|
@ -24,7 +24,11 @@
|
||||||
{% include "schedule/_edit_grid.html" %}
|
{% include "schedule/_edit_grid.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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 class="modal fade hide in" id="slotEditModal"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
Loading…
Reference in a new issue