diff --git a/ietf/meeting/factories.py b/ietf/meeting/factories.py index ef91afcb5..f872ed64d 100644 --- a/ietf/meeting/factories.py +++ b/ietf/meeting/factories.py @@ -131,6 +131,14 @@ class RoomFactory(factory.DjangoModelFactory): meeting = factory.SubFactory(MeetingFactory) name = factory.Faker('name') + @factory.post_generation + def session_types(obj, create, extracted, **kwargs): # pylint: disable=no-self-argument + """Prep session types m2m relationship for room, defaulting to 'regular'""" + if create: + session_types = extracted if extracted is not None else ['regular'] + for st in session_types: + obj.session_types.add(st) + class TimeSlotFactory(factory.DjangoModelFactory): class Meta: diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index 3087c8597..b9af23065 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -24,7 +24,7 @@ from ietf.group import colors from ietf.person.models import Person from ietf.group.models import Group from ietf.group.factories import GroupFactory -from ietf.meeting.factories import MeetingFactory, SessionFactory, TimeSlotFactory +from ietf.meeting.factories import MeetingFactory, RoomFactory, SessionFactory, TimeSlotFactory from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting from ietf.meeting.models import (Schedule, SchedTimeSessAssignment, Session, Room, TimeSlot, Constraint, ConstraintName, @@ -273,6 +273,9 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): # Create an IETF meeting... meeting = MeetingFactory(type_id='ietf') + # ...add a room that has no timeslots to be sure it's handled... + RoomFactory(meeting=meeting) + # ...and sessions for the groups. Use durations that are in a different order than # area or name. The wgs list is in ascending acronym order, so use descending durations. sessions = [] @@ -297,7 +300,6 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): self.login('secretary') self.driver.get(url) - select = self.driver.find_element_by_name('sort_unassigned') options = { opt.get_attribute('value'): opt diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index df042bb48..d303ace4e 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -49,7 +49,7 @@ from ietf.utils.text import xslugify from ietf.person.factories import PersonFactory from ietf.group.factories import GroupFactory, GroupEventFactory, RoleFactory from ietf.meeting.factories import ( SessionFactory, SessionPresentationFactory, ScheduleFactory, - MeetingFactory, FloorPlanFactory, TimeSlotFactory, SlideSubmissionFactory ) + MeetingFactory, FloorPlanFactory, TimeSlotFactory, SlideSubmissionFactory, RoomFactory ) from ietf.doc.factories import DocumentFactory, WgDraftFactory from ietf.submit.tests import submission_file from ietf.utils.test_utils import assert_ical_response_is_valid @@ -891,6 +891,86 @@ class MeetingTests(TestCase): self.assertFalse(q('ul li a:contains("%s")' % slide.title)) +class EditMeetingScheduleTests(TestCase): + """Tests of the meeting editor view + + This has tests in tests_js.py as well. + """ + def test_room_grouping(self): + """Blocks of rooms in the editor should have identical timeslots""" + # set up a meeting, but we'll construct our own timeslots/rooms + meeting = MeetingFactory(type_id='ietf', populate_schedule=False) + sched = ScheduleFactory(meeting=meeting) + + # Make groups of rooms with timeslots identical within a group, distinct between groups + times = [ + [datetime.time(11,0), datetime.time(12,0), datetime.time(13,0)], + [datetime.time(11,0), datetime.time(12,0), datetime.time(13,0)], # same times, but durations will differ + [datetime.time(11,30), datetime.time(12, 0), datetime.time(13,0)], # different time + [datetime.time(12,0)], # different number of timeslots + ] + durations = [ + [30, 60, 90], + [60, 60, 90], + [30, 60, 90], + [60], + ] + # check that times and durations are same-sized arrays + self.assertEqual(len(times), len(durations)) + for time_row, duration_row in zip(times, durations): + self.assertEqual(len(time_row), len(duration_row)) + + # Create an array of room groups, each with rooms_per_group Rooms in it. + # Assign TimeSlots according to the times/durations above to each Room. + room_groups = [] + rooms_in_group = 1 # will be incremented with each group + for time_row, duration_row in zip(times, durations): + room_groups.append(RoomFactory.create_batch(rooms_in_group, meeting=meeting)) + rooms_in_group += 1 # put a different number of rooms in each group to help identify errors in grouping + for time, duration in zip(time_row, duration_row): + for room in room_groups[-1]: + TimeSlotFactory( + meeting=meeting, + location=room, + time=datetime.datetime.combine(meeting.date, time), + duration=datetime.timedelta(minutes=duration), + ) + + # Now retrieve the edit meeting schedule page + url = urlreverse('ietf.meeting.views.edit_meeting_schedule', + kwargs=dict(num=meeting.number, owner=sched.owner.email(), name=sched.name)) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + q = PyQuery(r.content) + day_divs = q('div.day') + # There's only one day with TimeSlots. This means there will be two divs with class 'day': + # the first is the room label column, the second is the TimeSlot grid. + # Using eq() instead of [] gives us PyQuery objects instead of Elements + label_divs = day_divs.eq(0).find('div.room-group') + self.assertEqual(len(label_divs), len(room_groups)) + room_group_divs = day_divs.eq(1).find('div.room-group') + self.assertEqual(len(room_group_divs), len(room_groups)) + for rg, l_div, rg_div in zip( + room_groups, + label_divs.items(), # items() gives us PyQuery objects + room_group_divs.items(), # items() gives us PyQuery objects + ): + # Check that room labels are correctly grouped + self.assertCountEqual( + [div.text() for div in l_div.find('div.room-name').items()], + [room.name for room in rg], + ) + + # And that the time labels are correct. Just check that the individual timeslot labels agree with + # the time-header above each room group. + time_header_labels = rg_div.find('div.time-header div.time-label').text() + timeslot_rows = rg_div.find('div.timeslots') + for row in timeslot_rows.items(): + time_labels = row.find('div.time-label').text() + self.assertEqual(time_labels, time_header_labels) + + class ReorderSlidesTests(TestCase): def test_add_slides_to_session(self): diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 79493cd74..52ed79502 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -16,7 +16,6 @@ import tarfile import tempfile import markdown2 - from calendar import timegm from collections import OrderedDict, Counter, deque, defaultdict from urllib.parse import unquote @@ -495,8 +494,6 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): for a in assignments: assignments_by_session[a.session_id].append(a) - rooms = meeting.room_set.filter(session_types__slug='regular').distinct().order_by("capacity") - tombstone_states = ['canceled', 'canceledpa', 'resched'] sessions = add_event_info_to_session_qs( @@ -581,6 +578,145 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): s.readonly = s.current_status in tombstone_states or any(a.schedule_id != schedule.pk for a in assignments_by_session.get(s.pk, [])) + def prepare_timeslots_for_display(timeslots, rooms): + """Prepare timeslot data for template + + Prepares timeslots for display by sorting into groups in a structure + that can be rendered by the template and by adding some data to the timeslot + instances. Currently adds a 'layout_width' property to each timeslot instance. + The layout_width is the width, in em, that should be used to style the timeslot's + width. + + Rooms are partitioned into groups that have identical sets of timeslots + for the entire meeting. + + The result of this method is an OrderedDict, days, keyed by the Date + of each day that has at least one timeslot. The value of days[day] is a + list with one entry for each group of rooms. Each entry is a list of + dicts with keys 'room' and 'timeslots'. The 'room' value is the room + instance and 'timeslots' is a list of timeslot instances for that room. + + The format is more easily illustrated than explained: + + days = OrderedDict( + Date(2021, 5, 27): [ + [ # room group 1 + {'room': <room1>, 'timeslots': [<room1 timeslot1>, <room1 timeslot2>]}, + {'room': <room2>, 'timeslots': [<room2 timeslot1>, <room2 timeslot2>]}, + {'room': <room3>, 'timeslots': [<room3 timeslot1>, <room3 timeslot2>]}, + ], + [ # room group 2 + {'room': <room4>, 'timeslots': [<room4 timeslot1>]}, + ], + ], + Date(2021, 5, 28): [ + [ # room group 1 + {'room': <room1>, 'timeslots': [<room1 timeslot3>]}, + {'room': <room2>, 'timeslots': [<room2 timeslot3>]}, + {'room': <room3>, 'timeslots': [<room3 timeslot3>]}, + ], + [ # room group 2 + {'room': <room4>, 'timeslots': []}, + ], + ], + ) + """ + + # Populate room_data. This collects the timeslots for each room binned by + # day, plus data needed for sorting the rooms for display. + room_data = dict() + all_days = set() + # timeslots_qs is already sorted by location, name, and time + for t in timeslots: + if t.location not in rooms: + continue + + t.layout_width = timedelta_to_css_ems(t.duration) + if t.location_id not in room_data: + room_data[t.location_id] = dict( + timeslots_by_day=dict(), + timeslot_count=0, + start_and_duration=[], + first_timeslot = t, + ) + rd = room_data[t.location_id] + rd['timeslot_count'] += 1 + rd['start_and_duration'].append((t.time, t.duration)) + ttd = t.time.date() + all_days.add(ttd) + if ttd not in rd['timeslots_by_day']: + rd['timeslots_by_day'][ttd] = [] + rd['timeslots_by_day'][ttd].append(t) + + all_days = sorted(all_days) # changes set to a list + # Note the maximum timeslot count for any room + max_timeslots = max(rd['timeslot_count'] for rd in room_data.values()) + + # Partition rooms into groups with identical timeslot arrangements. + # Start by discarding any roos that have no timeslots. + rooms_with_timeslots = [r for r in rooms if r.pk in room_data] + # Then sort the remaining rooms. + sorted_rooms = sorted( + rooms_with_timeslots, + key=lambda room: ( + # First, sort regular session rooms ahead of others - these will usually + # have more timeslots than other room types. + 0 if room_data[room.pk]['timeslot_count'] == max_timeslots else 1, + # Sort rooms with earlier timeslots ahead of later + room_data[room.pk]['first_timeslot'].time, + # Sort rooms with more sessions ahead of rooms with fewer + 0 - room_data[room.pk]['timeslot_count'], + # Sort by list of starting time and duration so that groups with identical + # timeslot structure will be neighbors. The grouping algorithm relies on this! + room_data[room.pk]['start_and_duration'], + # Within each group, sort higher capacity rooms first. + room.capacity, + # Finally, sort alphabetically by name + room.name + ) + ) + + # Rooms are now ordered so rooms with identical timeslot arrangements are neighbors. + # Walk the list, splitting these into groups. + room_groups = [] + last_start_and_duration = None # Used to watch for changes in start_and_duration + for room in sorted_rooms: + if last_start_and_duration != room_data[room.pk]['start_and_duration']: + room_groups.append([]) # start a new room_group + last_start_and_duration = room_data[room.pk]['start_and_duration'] + room_groups[-1].append(room) + + # Next, build the structure that will hold the data for the view. This makes it + # easier to arrange that every room has an entry for every day, even if there is + # no timeslot for that day. This makes the HTML template much easier to write. + # Use OrderedDicts instead of lists so that we can easily put timeslot data in the + # right place. + days = OrderedDict( + ( + day, # key in the Ordered Dict + [ + # each value is an OrderedDict of room group data + OrderedDict( + (room.pk, dict(room=room, timeslots=[])) + for room in rg + ) for rg in room_groups + ] + ) for day in all_days + ) + + # With the structure's skeleton built, now fill in the data. The loops must + # preserve the order of room groups and rooms within each group. + for rg_num, rgroup in enumerate(room_groups): + for room in rgroup: + for day, ts_for_day in room_data[room.pk]['timeslots_by_day'].items(): + days[day][rg_num][room.pk]['timeslots'] = ts_for_day + + # Now convert the OrderedDict entries into lists since we don't need to + # do lookup by pk any more. + for day in days.keys(): + days[day] = [list(rg.values()) for rg in days[day]] + + return days if request.method == 'POST': if not can_edit: @@ -660,34 +796,11 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): return HttpResponse("Invalid parameters", status=400) - # prepare timeslot layout + # Show only rooms that have regular sessions + rooms = meeting.room_set.filter(session_types__slug='regular') - timeslots_by_room_and_day = defaultdict(list) - room_has_timeslots = set() - for t in timeslots_qs: - room_has_timeslots.add(t.location_id) - timeslots_by_room_and_day[(t.location_id, t.time.date())].append(t) - - days = [] - for day in sorted(set(t.time.date() for t in timeslots_qs)): - room_timeslots = [] - for r in rooms: - if r.pk not in room_has_timeslots: - continue - - timeslots = [] - for t in timeslots_by_room_and_day.get((r.pk, day), []): - t.layout_width = timedelta_to_css_ems(t.end_time() - t.time) - timeslots.append(t) - - room_timeslots.append((r, timeslots)) - - days.append({ - 'day': day, - 'room_timeslots': room_timeslots, - }) - - room_labels = [[r for r in rooms if r.pk in room_has_timeslots] for i in range(len(days))] + # Construct timeslot data for the template to render + days = prepare_timeslots_for_display(timeslots_qs, rooms) # possible timeslot start/ends timeslot_groups = defaultdict(set) @@ -761,7 +874,6 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): 'can_edit_properties': can_edit or secretariat, 'secretariat': secretariat, 'days': days, - 'room_labels': room_labels, 'timeslot_groups': sorted((d, list(sorted(t_groups))) for d, t_groups in timeslot_groups.items()), 'unassigned_sessions': unassigned_sessions, 'session_parents': session_parents, diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index e49e6df17..96c40efee 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -1067,6 +1067,30 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { justify-content: flex-start; } +.edit-meeting-schedule .edit-grid .room-group:not(:last-child) { + margin-bottom: 1em; +} + +.edit-meeting-schedule .edit-grid .time-header { + position: relative; + height: 1.5em; + padding-bottom: 0.15em; +} + +.edit-meeting-schedule .edit-grid .time-header .time-label { + display: inline-block; + position: relative; + width: 100%; + align-items: center; +} + +.edit-meeting-schedule .edit-grid .time-header .time-label span { + display: inline-block; + width: 100%; + text-align: center; + color: #444444; +} + .edit-meeting-schedule .edit-grid .timeslots { position: relative; height: 4.5em; diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index 3daced86f..6e451f836 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -60,19 +60,24 @@ {# using the same markup in both room labels and the actual days ensures they are aligned #} <div class="room-label-column"> - {% for labels in room_labels %} + {% for day_data in days.values %} <div class="day"> <div class="day-label"> <strong> </strong><br> </div> - {% for room in labels %} - <div class="timeslots"> - <div class="room-name"> - <strong>{{ room.name }}</strong><br> - {% if room.capacity %}{{ room.capacity }} <i class="fa fa-user-o"></i>{% endif %} - </div> + {% for rgroup in day_data %} + <div class="room-group"> + <div class="time-header"><div class="time-label"></div></div> + {% for room_data in rgroup %}{% with room_data.room as room %} + <div class="timeslots"> + <div class="room-name"> + <strong>{{ room.name }}</strong><br> + {% if room.capacity %}{{ room.capacity }} <i class="fa fa-user-o"></i>{% endif %} + </div> + </div> + {% endwith %}{% endfor %} </div> {% endfor %} </div> @@ -80,29 +85,38 @@ </div> <div class="day-flow"> - {% for day in days %} + {% for day, day_data in days.items %} <div class="day"> <div class="day-label"> - <strong>{{ day.day|date:"l" }}</strong> <i class="fa fa-exchange swap-days" data-dayid="{{ day.day.isoformat }}" data-toggle="modal" data-target="#swap-days-modal"></i><br> - {{ day.day|date:"N j, Y" }} + <strong>{{ day|date:"l" }}</strong> <i class="fa fa-exchange swap-days" data-dayid="{{ day.isoformat }}" data-toggle="modal" data-target="#swap-days-modal"></i><br> + {{ day|date:"N j, Y" }} </div> - {% for room, timeslots in day.room_timeslots %} - <div class="timeslots" data-roomcapacity="{{ room.capacity }}"> + {% for rgroup in day_data %} + <div class="room-group"> + <div class="time-header"> + {# All rooms in a group have same timeslots; grab the first for the labels #} + {% for t in rgroup.0.timeslots %} + <div class="time-label" style="width: {{ t.layout_width }}rem"><span>{{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }}</span></div> + {% endfor %} + </div> + {% for room_data in rgroup %}{% with room_data.room as room %} + <div class="timeslots" data-roomcapacity="{{ room.capacity }}"> + {% for t in room_data.timeslots %} + <div id="timeslot{{ t.pk }}" class="timeslot {{ t.start_end_group }}" data-start="{{ t.time.isoformat }}" data-end="{{ t.end_time.isoformat }}" data-duration="{{ t.duration.total_seconds }}" data-scheduledatlabel="{{ t.time|date:"l G:i" }}-{{ t.end_time|date:"G:i" }}" style="width: {{ t.layout_width }}rem;"> + <div class="time-label"> + {{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }} + </div> - {% for t in timeslots %} - <div id="timeslot{{ t.pk }}" class="timeslot {{ t.start_end_group }}" data-start="{{ t.time.isoformat }}" data-end="{{ t.end_time.isoformat }}" data-duration="{{ t.duration.total_seconds }}" data-scheduledatlabel="{{ t.time|date:"l G:i" }}-{{ t.end_time|date:"G:i" }}" style="width: {{ t.layout_width }}rem;"> - <div class="time-label"> - {{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }} - </div> - - <div class="drop-target"> - {% for assignment, session in t.session_assignments %} - {% include "meeting/edit_meeting_schedule_session.html" %} - {% endfor %} - </div> + <div class="drop-target"> + {% for assignment, session in t.session_assignments %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %} + </div> + </div> + {% endfor %} </div> - {% endfor %} + {% endwith %}{% endfor %} </div> {% endfor %} </div> @@ -192,7 +206,7 @@ <div class="modal-body"> {% for day in days %} <label> - <input type="radio" name="target_day" value="{{ day.day.isoformat }}"> {{ day.day|date:"l, N j, Y" }} + <input type="radio" name="target_day" value="{{ day.isoformat }}"> {{ day|date:"l, N j, Y" }} </label> {% endfor %} </div>