Skip to content

Commit b42f1a2

Browse files
Display rooms in blocks with identical timeslots. Add a timeslot label row above each. Fixes ietf-tools#3220. Commit ready for merge.
- Legacy-Id: 19121
1 parent 66b9c41 commit b42f1a2

6 files changed

Lines changed: 300 additions & 60 deletions

File tree

ietf/meeting/factories.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ class Meta:
131131
meeting = factory.SubFactory(MeetingFactory)
132132
name = factory.Faker('name')
133133

134+
@factory.post_generation
135+
def session_types(obj, create, extracted, **kwargs): # pylint: disable=no-self-argument
136+
"""Prep session types m2m relationship for room, defaulting to 'regular'"""
137+
if create:
138+
session_types = extracted if extracted is not None else ['regular']
139+
for st in session_types:
140+
obj.session_types.add(st)
141+
134142

135143
class TimeSlotFactory(factory.DjangoModelFactory):
136144
class Meta:

ietf/meeting/tests_js.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from ietf.person.models import Person
2525
from ietf.group.models import Group
2626
from ietf.group.factories import GroupFactory
27-
from ietf.meeting.factories import MeetingFactory, SessionFactory, TimeSlotFactory
27+
from ietf.meeting.factories import MeetingFactory, RoomFactory, SessionFactory, TimeSlotFactory
2828
from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting
2929
from ietf.meeting.models import (Schedule, SchedTimeSessAssignment, Session,
3030
Room, TimeSlot, Constraint, ConstraintName,
@@ -273,6 +273,9 @@ def wait_for_order(sessions, expected_order, fail_message):
273273
# Create an IETF meeting...
274274
meeting = MeetingFactory(type_id='ietf')
275275

276+
# ...add a room that has no timeslots to be sure it's handled...
277+
RoomFactory(meeting=meeting)
278+
276279
# ...and sessions for the groups. Use durations that are in a different order than
277280
# area or name. The wgs list is in ascending acronym order, so use descending durations.
278281
sessions = []
@@ -297,7 +300,6 @@ def wait_for_order(sessions, expected_order, fail_message):
297300
self.login('secretary')
298301
self.driver.get(url)
299302

300-
301303
select = self.driver.find_element_by_name('sort_unassigned')
302304
options = {
303305
opt.get_attribute('value'): opt

ietf/meeting/tests_views.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from ietf.person.factories import PersonFactory
5050
from ietf.group.factories import GroupFactory, GroupEventFactory, RoleFactory
5151
from ietf.meeting.factories import ( SessionFactory, SessionPresentationFactory, ScheduleFactory,
52-
MeetingFactory, FloorPlanFactory, TimeSlotFactory, SlideSubmissionFactory )
52+
MeetingFactory, FloorPlanFactory, TimeSlotFactory, SlideSubmissionFactory, RoomFactory )
5353
from ietf.doc.factories import DocumentFactory, WgDraftFactory
5454
from ietf.submit.tests import submission_file
5555
from ietf.utils.test_utils import assert_ical_response_is_valid
@@ -891,6 +891,86 @@ def test_session_materials(self):
891891
self.assertFalse(q('ul li a:contains("%s")' % slide.title))
892892

893893

894+
class EditMeetingScheduleTests(TestCase):
895+
"""Tests of the meeting editor view
896+
897+
This has tests in tests_js.py as well.
898+
"""
899+
def test_room_grouping(self):
900+
"""Blocks of rooms in the editor should have identical timeslots"""
901+
# set up a meeting, but we'll construct our own timeslots/rooms
902+
meeting = MeetingFactory(type_id='ietf', populate_schedule=False)
903+
sched = ScheduleFactory(meeting=meeting)
904+
905+
# Make groups of rooms with timeslots identical within a group, distinct between groups
906+
times = [
907+
[datetime.time(11,0), datetime.time(12,0), datetime.time(13,0)],
908+
[datetime.time(11,0), datetime.time(12,0), datetime.time(13,0)], # same times, but durations will differ
909+
[datetime.time(11,30), datetime.time(12, 0), datetime.time(13,0)], # different time
910+
[datetime.time(12,0)], # different number of timeslots
911+
]
912+
durations = [
913+
[30, 60, 90],
914+
[60, 60, 90],
915+
[30, 60, 90],
916+
[60],
917+
]
918+
# check that times and durations are same-sized arrays
919+
self.assertEqual(len(times), len(durations))
920+
for time_row, duration_row in zip(times, durations):
921+
self.assertEqual(len(time_row), len(duration_row))
922+
923+
# Create an array of room groups, each with rooms_per_group Rooms in it.
924+
# Assign TimeSlots according to the times/durations above to each Room.
925+
room_groups = []
926+
rooms_in_group = 1 # will be incremented with each group
927+
for time_row, duration_row in zip(times, durations):
928+
room_groups.append(RoomFactory.create_batch(rooms_in_group, meeting=meeting))
929+
rooms_in_group += 1 # put a different number of rooms in each group to help identify errors in grouping
930+
for time, duration in zip(time_row, duration_row):
931+
for room in room_groups[-1]:
932+
TimeSlotFactory(
933+
meeting=meeting,
934+
location=room,
935+
time=datetime.datetime.combine(meeting.date, time),
936+
duration=datetime.timedelta(minutes=duration),
937+
)
938+
939+
# Now retrieve the edit meeting schedule page
940+
url = urlreverse('ietf.meeting.views.edit_meeting_schedule',
941+
kwargs=dict(num=meeting.number, owner=sched.owner.email(), name=sched.name))
942+
r = self.client.get(url)
943+
self.assertEqual(r.status_code, 200)
944+
945+
q = PyQuery(r.content)
946+
day_divs = q('div.day')
947+
# There's only one day with TimeSlots. This means there will be two divs with class 'day':
948+
# the first is the room label column, the second is the TimeSlot grid.
949+
# Using eq() instead of [] gives us PyQuery objects instead of Elements
950+
label_divs = day_divs.eq(0).find('div.room-group')
951+
self.assertEqual(len(label_divs), len(room_groups))
952+
room_group_divs = day_divs.eq(1).find('div.room-group')
953+
self.assertEqual(len(room_group_divs), len(room_groups))
954+
for rg, l_div, rg_div in zip(
955+
room_groups,
956+
label_divs.items(), # items() gives us PyQuery objects
957+
room_group_divs.items(), # items() gives us PyQuery objects
958+
):
959+
# Check that room labels are correctly grouped
960+
self.assertCountEqual(
961+
[div.text() for div in l_div.find('div.room-name').items()],
962+
[room.name for room in rg],
963+
)
964+
965+
# And that the time labels are correct. Just check that the individual timeslot labels agree with
966+
# the time-header above each room group.
967+
time_header_labels = rg_div.find('div.time-header div.time-label').text()
968+
timeslot_rows = rg_div.find('div.timeslots')
969+
for row in timeslot_rows.items():
970+
time_labels = row.find('div.time-label').text()
971+
self.assertEqual(time_labels, time_header_labels)
972+
973+
894974
class ReorderSlidesTests(TestCase):
895975

896976
def test_add_slides_to_session(self):

ietf/meeting/views.py

Lines changed: 143 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
import tempfile
1717
import markdown2
1818

19-
2019
from calendar import timegm
2120
from collections import OrderedDict, Counter, deque, defaultdict
2221
from urllib.parse import unquote
@@ -495,8 +494,6 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
495494
for a in assignments:
496495
assignments_by_session[a.session_id].append(a)
497496

498-
rooms = meeting.room_set.filter(session_types__slug='regular').distinct().order_by("capacity")
499-
500497
tombstone_states = ['canceled', 'canceledpa', 'resched']
501498

502499
sessions = add_event_info_to_session_qs(
@@ -581,6 +578,145 @@ def prepare_sessions_for_display(sessions):
581578

582579
s.readonly = s.current_status in tombstone_states or any(a.schedule_id != schedule.pk for a in assignments_by_session.get(s.pk, []))
583580

581+
def prepare_timeslots_for_display(timeslots, rooms):
582+
"""Prepare timeslot data for template
583+
584+
Prepares timeslots for display by sorting into groups in a structure
585+
that can be rendered by the template and by adding some data to the timeslot
586+
instances. Currently adds a 'layout_width' property to each timeslot instance.
587+
The layout_width is the width, in em, that should be used to style the timeslot's
588+
width.
589+
590+
Rooms are partitioned into groups that have identical sets of timeslots
591+
for the entire meeting.
592+
593+
The result of this method is an OrderedDict, days, keyed by the Date
594+
of each day that has at least one timeslot. The value of days[day] is a
595+
list with one entry for each group of rooms. Each entry is a list of
596+
dicts with keys 'room' and 'timeslots'. The 'room' value is the room
597+
instance and 'timeslots' is a list of timeslot instances for that room.
598+
599+
The format is more easily illustrated than explained:
600+
601+
days = OrderedDict(
602+
Date(2021, 5, 27): [
603+
[ # room group 1
604+
{'room': <room1>, 'timeslots': [<room1 timeslot1>, <room1 timeslot2>]},
605+
{'room': <room2>, 'timeslots': [<room2 timeslot1>, <room2 timeslot2>]},
606+
{'room': <room3>, 'timeslots': [<room3 timeslot1>, <room3 timeslot2>]},
607+
],
608+
[ # room group 2
609+
{'room': <room4>, 'timeslots': [<room4 timeslot1>]},
610+
],
611+
],
612+
Date(2021, 5, 28): [
613+
[ # room group 1
614+
{'room': <room1>, 'timeslots': [<room1 timeslot3>]},
615+
{'room': <room2>, 'timeslots': [<room2 timeslot3>]},
616+
{'room': <room3>, 'timeslots': [<room3 timeslot3>]},
617+
],
618+
[ # room group 2
619+
{'room': <room4>, 'timeslots': []},
620+
],
621+
],
622+
)
623+
"""
624+
625+
# Populate room_data. This collects the timeslots for each room binned by
626+
# day, plus data needed for sorting the rooms for display.
627+
room_data = dict()
628+
all_days = set()
629+
# timeslots_qs is already sorted by location, name, and time
630+
for t in timeslots:
631+
if t.location not in rooms:
632+
continue
633+
634+
t.layout_width = timedelta_to_css_ems(t.duration)
635+
if t.location_id not in room_data:
636+
room_data[t.location_id] = dict(
637+
timeslots_by_day=dict(),
638+
timeslot_count=0,
639+
start_and_duration=[],
640+
first_timeslot = t,
641+
)
642+
rd = room_data[t.location_id]
643+
rd['timeslot_count'] += 1
644+
rd['start_and_duration'].append((t.time, t.duration))
645+
ttd = t.time.date()
646+
all_days.add(ttd)
647+
if ttd not in rd['timeslots_by_day']:
648+
rd['timeslots_by_day'][ttd] = []
649+
rd['timeslots_by_day'][ttd].append(t)
650+
651+
all_days = sorted(all_days) # changes set to a list
652+
# Note the maximum timeslot count for any room
653+
max_timeslots = max(rd['timeslot_count'] for rd in room_data.values())
654+
655+
# Partition rooms into groups with identical timeslot arrangements.
656+
# Start by discarding any roos that have no timeslots.
657+
rooms_with_timeslots = [r for r in rooms if r.pk in room_data]
658+
# Then sort the remaining rooms.
659+
sorted_rooms = sorted(
660+
rooms_with_timeslots,
661+
key=lambda room: (
662+
# First, sort regular session rooms ahead of others - these will usually
663+
# have more timeslots than other room types.
664+
0 if room_data[room.pk]['timeslot_count'] == max_timeslots else 1,
665+
# Sort rooms with earlier timeslots ahead of later
666+
room_data[room.pk]['first_timeslot'].time,
667+
# Sort rooms with more sessions ahead of rooms with fewer
668+
0 - room_data[room.pk]['timeslot_count'],
669+
# Sort by list of starting time and duration so that groups with identical
670+
# timeslot structure will be neighbors. The grouping algorithm relies on this!
671+
room_data[room.pk]['start_and_duration'],
672+
# Within each group, sort higher capacity rooms first.
673+
room.capacity,
674+
# Finally, sort alphabetically by name
675+
room.name
676+
)
677+
)
678+
679+
# Rooms are now ordered so rooms with identical timeslot arrangements are neighbors.
680+
# Walk the list, splitting these into groups.
681+
room_groups = []
682+
last_start_and_duration = None # Used to watch for changes in start_and_duration
683+
for room in sorted_rooms:
684+
if last_start_and_duration != room_data[room.pk]['start_and_duration']:
685+
room_groups.append([]) # start a new room_group
686+
last_start_and_duration = room_data[room.pk]['start_and_duration']
687+
room_groups[-1].append(room)
688+
689+
# Next, build the structure that will hold the data for the view. This makes it
690+
# easier to arrange that every room has an entry for every day, even if there is
691+
# no timeslot for that day. This makes the HTML template much easier to write.
692+
# Use OrderedDicts instead of lists so that we can easily put timeslot data in the
693+
# right place.
694+
days = OrderedDict(
695+
(
696+
day, # key in the Ordered Dict
697+
[
698+
# each value is an OrderedDict of room group data
699+
OrderedDict(
700+
(room.pk, dict(room=room, timeslots=[]))
701+
for room in rg
702+
) for rg in room_groups
703+
]
704+
) for day in all_days
705+
)
706+
707+
# With the structure's skeleton built, now fill in the data. The loops must
708+
# preserve the order of room groups and rooms within each group.
709+
for rg_num, rgroup in enumerate(room_groups):
710+
for room in rgroup:
711+
for day, ts_for_day in room_data[room.pk]['timeslots_by_day'].items():
712+
days[day][rg_num][room.pk]['timeslots'] = ts_for_day
713+
714+
# Now convert the OrderedDict entries into lists since we don't need to
715+
# do lookup by pk any more.
716+
for day in days.keys():
717+
days[day] = [list(rg.values()) for rg in days[day]]
718+
719+
return days
584720

585721
if request.method == 'POST':
586722
if not can_edit:
@@ -660,34 +796,11 @@ def prepare_sessions_for_display(sessions):
660796

661797
return HttpResponse("Invalid parameters", status=400)
662798

663-
# prepare timeslot layout
664-
665-
timeslots_by_room_and_day = defaultdict(list)
666-
room_has_timeslots = set()
667-
for t in timeslots_qs:
668-
room_has_timeslots.add(t.location_id)
669-
timeslots_by_room_and_day[(t.location_id, t.time.date())].append(t)
670-
671-
days = []
672-
for day in sorted(set(t.time.date() for t in timeslots_qs)):
673-
room_timeslots = []
674-
for r in rooms:
675-
if r.pk not in room_has_timeslots:
676-
continue
677-
678-
timeslots = []
679-
for t in timeslots_by_room_and_day.get((r.pk, day), []):
680-
t.layout_width = timedelta_to_css_ems(t.end_time() - t.time)
681-
timeslots.append(t)
682-
683-
room_timeslots.append((r, timeslots))
684-
685-
days.append({
686-
'day': day,
687-
'room_timeslots': room_timeslots,
688-
})
799+
# Show only rooms that have regular sessions
800+
rooms = meeting.room_set.filter(session_types__slug='regular')
689801

690-
room_labels = [[r for r in rooms if r.pk in room_has_timeslots] for i in range(len(days))]
802+
# Construct timeslot data for the template to render
803+
days = prepare_timeslots_for_display(timeslots_qs, rooms)
691804

692805
# possible timeslot start/ends
693806
timeslot_groups = defaultdict(set)
@@ -761,7 +874,6 @@ def cubehelix(i, total, hue=1.2, start_angle=0.5):
761874
'can_edit_properties': can_edit or secretariat,
762875
'secretariat': secretariat,
763876
'days': days,
764-
'room_labels': room_labels,
765877
'timeslot_groups': sorted((d, list(sorted(t_groups))) for d, t_groups in timeslot_groups.items()),
766878
'unassigned_sessions': unassigned_sessions,
767879
'session_parents': session_parents,

ietf/static/ietf/css/ietf.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,30 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
10631063
justify-content: flex-start;
10641064
}
10651065

1066+
.edit-meeting-schedule .edit-grid .room-group:not(:last-child) {
1067+
margin-bottom: 1em;
1068+
}
1069+
1070+
.edit-meeting-schedule .edit-grid .time-header {
1071+
position: relative;
1072+
height: 1.5em;
1073+
padding-bottom: 0.15em;
1074+
}
1075+
1076+
.edit-meeting-schedule .edit-grid .time-header .time-label {
1077+
display: inline-block;
1078+
position: relative;
1079+
width: 100%;
1080+
align-items: center;
1081+
}
1082+
1083+
.edit-meeting-schedule .edit-grid .time-header .time-label span {
1084+
display: inline-block;
1085+
width: 100%;
1086+
text-align: center;
1087+
color: #444444;
1088+
}
1089+
10661090
.edit-meeting-schedule .edit-grid .timeslots {
10671091
position: relative;
10681092
height: 4.5em;

0 commit comments

Comments
 (0)