Add new view for editing meeting schedules. This is a preliminary

basic version with timeslots in a fixed grid and drag and drop for
assigning and unassigning. Compared to the existing JS based view, it
is missing session details (attendences etc.), conflicts and other
warnings, toggling of sessions in areas, area coloring, extending to
next timeslot and probably more.

Add new auxiliary view to copy a schedule for the new schedule editor.
 - Legacy-Id: 17389
This commit is contained in:
Ole Laursen 2020-03-05 19:15:44 +00:00
parent de99911e38
commit 393ee64bec
8 changed files with 668 additions and 4 deletions

View file

@ -34,7 +34,7 @@ from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_r
from ietf.meeting.helpers import send_interim_approval_request
from ietf.meeting.helpers import send_interim_cancellation_notice
from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates
from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent
from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room
from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting
from ietf.meeting.utils import finalize, condition_slide_order
from ietf.meeting.utils import add_event_info_to_session_qs
@ -911,10 +911,104 @@ class EditTests(TestCase):
def test_edit_schedule(self):
meeting = make_meeting_test_data()
self.client.login(username="secretary", password="secretary+password")
r = self.client.get(urlreverse("ietf.meeting.views.edit_schedule", kwargs=dict(num=meeting.number)))
self.assertContains(r, "load_assignments")
def test_edit_meeting_schedule(self):
meeting = make_meeting_test_data()
self.client.login(username="secretary", password="secretary+password")
# check we have the grid and everything set up
url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number))
r = self.client.get(url)
q = PyQuery(r.content)
room = Room.objects.get(meeting=meeting, session_types='regular')
self.assertTrue(q("th:contains(\"{}\")".format(room.name)))
self.assertTrue(q("th:contains(\"{}\")".format(room.capacity)))
timeslots = TimeSlot.objects.filter(meeting=meeting, type='regular')
self.assertTrue(q("td:contains(\"{}\")".format(timeslots[0].time.strftime("%H:%M"))))
self.assertTrue(q("td.timeslot[data-timeslot=\"{}\"]".format(timeslots[0].pk)))
sessions = Session.objects.filter(meeting=meeting, type='regular')
for s in sessions:
self.assertIn(s.group.acronym, q("#session{}".format(s.pk)).text())
self.assertIn("You can't edit this schedule", r.content)
# can't change anything
r = self.client.post(url, {
'action': 'assign',
'timeslot': timeslots[0].pk,
'session': sessions[0].pk,
})
self.assertEqual(r.status_code, 403)
# turn us into owner
meeting.schedule.owner = Person.objects.get(user__username="secretary")
meeting.schedule.save()
url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name))
r = self.client.get(url)
self.assertNotIn("You can't edit this schedule", r.content)
SchedTimeSessAssignment.objects.filter(session=sessions[0]).delete()
# assign
r = self.client.post(url, {
'action': 'assign',
'timeslot': timeslots[0].pk,
'session': sessions[0].pk,
})
self.assertEqual(r.content, "OK")
self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=sessions[0]).timeslot, timeslots[0])
# move assignment
r = self.client.post(url, {
'action': 'assign',
'timeslot': timeslots[1].pk,
'session': sessions[0].pk,
})
self.assertEqual(r.content, "OK")
self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=sessions[0]).timeslot, timeslots[1])
# unassign
r = self.client.post(url, {
'action': 'unassign',
'session': sessions[0].pk,
})
self.assertEqual(r.content, "OK")
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule, session=sessions[0])), [])
def test_copy_meeting_schedule(self):
meeting = make_meeting_test_data()
self.client.login(username="secretary", password="secretary+password")
url = urlreverse("ietf.meeting.views.copy_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name))
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
# copy
r = self.client.post(url, {
'name': "newtest",
'public': "on",
})
self.assertNoFormPostErrors(r)
new_schedule = Schedule.objects.get(meeting=meeting, owner__user__username='secretary', name='newtest')
self.assertEqual(new_schedule.public, True)
self.assertEqual(new_schedule.visible, False)
old_assignments = {(a.session_id, a.timeslot_id) for a in SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule)}
for a in SchedTimeSessAssignment.objects.filter(schedule=new_schedule):
self.assertIn((a.session_id, a.timeslot_id), old_assignments)
# FIXME: test extendedfrom is copied correctly
def test_save_agenda_as_and_read_permissions(self):
meeting = make_meeting_test_data()

View file

@ -1,4 +1,4 @@
# Copyright The IETF Trust 2007-2019, All Rights Reserved
# Copyright The IETF Trust 2007-2020, All Rights Reserved
from django.conf.urls import include
from django.views.generic import RedirectView
@ -27,6 +27,7 @@ safe_for_all_meeting_types = [
type_ietf_only_patterns = [
url(r'^agenda/%(owner)s/%(schedule_name)s/edit$' % settings.URL_REGEXPS, views.edit_schedule),
url(r'^agenda/%(owner)s/%(schedule_name)s/edit/$' % settings.URL_REGEXPS, views.edit_meeting_schedule),
url(r'^agenda/%(owner)s/%(schedule_name)s/details$' % settings.URL_REGEXPS, views.edit_schedule_properties),
url(r'^agenda/%(owner)s/%(schedule_name)s/delete$' % settings.URL_REGEXPS, views.delete_schedule),
url(r'^agenda/%(owner)s/%(schedule_name)s/make_official$' % settings.URL_REGEXPS, views.make_schedule_official),
@ -40,6 +41,7 @@ type_ietf_only_patterns = [
url(r'^agenda/%(owner)s/%(schedule_name)s/session/(?P<assignment_id>\d+).json$' % settings.URL_REGEXPS, ajax.assignment_json),
url(r'^agenda/%(owner)s/%(schedule_name)s/sessions.json$' % settings.URL_REGEXPS, ajax.assignments_json),
url(r'^agenda/%(owner)s/%(schedule_name)s.json$' % settings.URL_REGEXPS, ajax.schedule_infourl),
url(r'^agenda/%(owner)s/%(schedule_name)s/copy/$' % settings.URL_REGEXPS, views.copy_meeting_schedule),
url(r'^agenda/by-room$', views.agenda_by_room),
url(r'^agenda/by-type$', views.agenda_by_type),
url(r'^agenda/by-type/(?P<type>[a-z]+)$', views.agenda_by_type),
@ -76,6 +78,7 @@ type_ietf_only_patterns_id_optional = [
url(r'^agenda(?P<ext>.txt)$', views.agenda),
url(r'^agenda(?P<ext>.csv)$', views.agenda),
url(r'^agenda/edit$', views.edit_schedule),
url(r'^agenda/edit/$', views.edit_meeting_schedule),
url(r'^requests$', views.meeting_requests),
url(r'^agenda/agenda\.ics$', views.ical_agenda),
url(r'^agenda\.ics$', views.ical_agenda),

View file

@ -4,6 +4,7 @@
from __future__ import absolute_import, print_function, unicode_literals
from collections import defaultdict
import csv
import datetime
import glob
@ -42,6 +43,7 @@ from django.template.loader import render_to_string
from django.utils.functional import curry
from django.views.decorators.cache import cache_page
from django.utils.text import slugify
from django.utils.html import escape
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
from django.views.generic import RedirectView
@ -349,6 +351,266 @@ def edit_timeslots(request, num=None):
"ts_list":ts_list,
})
class CopyScheduleForm(forms.ModelForm):
class Meta:
model = Schedule
fields = ['name', 'visible', 'public']
def __init__(self, schedule, new_owner, *args, **kwargs):
super(CopyScheduleForm, self).__init__(*args, **kwargs)
self.schedule = schedule
self.new_owner = new_owner
username = new_owner.user.username
name_suggestion = username
counter = 2
existing_names = set(Schedule.objects.filter(meeting=schedule.meeting_id, owner=new_owner).values_list('name', flat=True))
while name_suggestion in existing_names:
name_suggestion = username + str(counter)
counter += 1
self.fields['name'].initial = name_suggestion
self.fields['name'].label = "Name of new schedule"
def clean_name(self):
name = self.cleaned_data.get('name')
if name and Schedule.objects.filter(meeting=self.schedule.meeting_id, owner=self.new_owner, name=name):
raise forms.ValidationError("Schedule with this name already exists.")
return name
@role_required('Area Director','Secretariat')
def copy_meeting_schedule(request, num, owner, name):
meeting = get_meeting(num)
schedule = get_object_or_404(meeting.schedule_set, owner__email__address=owner, name=name)
if request.method == 'POST':
form = CopyScheduleForm(schedule, request.user.person, request.POST)
if form.is_valid():
new_schedule = form.save(commit=False)
new_schedule.meeting = schedule.meeting
new_schedule.owner = request.user.person
new_schedule.save()
# keep a mapping so that extendedfrom references can be chased
old_pk_to_new_pk = {}
extendedfroms = {}
for assignment in schedule.assignments.all():
extendedfrom_id = assignment.extendedfrom_id
# clone by resetting primary key
old_pk = assignment.pk
assignment.pk = None
assignment.schedule = new_schedule
assignment.extendedfrom = None
assignment.save()
old_pk_to_new_pk[old_pk] = assignment.pk
if extendedfrom_id is not None:
extendedfroms[assignment.pk] = extendedfrom_id
for pk, extendedfrom_id in extendedfroms.values():
if extendedfrom_id in old_pk_to_new_pk:
SchedTimeSessAssignment.objects.filter(pk=pk).update(extendedfrom=old_pk_to_new_pk[extendedfrom_id])
# now redirect to this new schedule
return redirect(edit_meeting_schedule, meeting.number, new_schedule.owner_email(), new_schedule.name)
else:
form = CopyScheduleForm(schedule, request.user.person)
return render(request, "meeting/copy_meeting_schedule.html", {
'meeting': meeting,
'schedule': schedule,
'form': form,
})
@ensure_csrf_cookie
def edit_meeting_schedule(request, num=None, owner=None, name=None):
meeting = get_meeting(num)
if name is None:
schedule = meeting.schedule
else:
schedule = get_schedule_by_name(meeting, get_person_by_email(owner), name)
if schedule is None:
raise Http404("No meeting information for meeting %s owner %s schedule %s available" % (num, owner, name))
can_see, can_edit, secretariat = schedule_permissions(meeting, schedule, request.user)
if not can_see:
if request.method == 'POST':
return HttpResponseForbidden("Can't view this schedule")
# FIXME: check this
return render(request, "meeting/private_schedule.html",
{"schedule":schedule,
"meeting": meeting,
"meeting_base_url": request.build_absolute_uri(meeting.base_url()),
"hide_menu": True
}, status=403, content_type="text/html")
assignments = get_all_assignments_from_schedule(schedule)
# FIXME
#areas = get_areas()
#ads = find_ads_for_meeting(meeting)
css_ems_per_hour = 1.5
rooms = meeting.room_set.filter(session_types__slug='regular').distinct().order_by("capacity")
timeslots_qs = meeting.timeslot_set.filter(type='regular').prefetch_related('type', 'sessions').order_by('time', 'location', 'name')
sessions = add_event_info_to_session_qs(
Session.objects.filter(
meeting=meeting,
# Restrict graphical scheduling to regular meeting requests (Sessions) for now
type='regular',
),
requested_time=True,
requested_by=True,
).exclude(current_status__in=['notmeet', 'disappr', 'deleted', 'apprw']).prefetch_related(
'resources', 'group', 'group__parent',
)
if request.method == 'POST':
if not can_edit:
return HttpResponseForbidden("Can't edit this schedule")
action = request.POST.get('action')
if action == 'assign' and request.POST.get('session', '').isdigit() and request.POST.get('timeslot', '').isdigit():
session = get_object_or_404(sessions, pk=request.POST['session'])
timeslot = get_object_or_404(timeslots_qs, pk=request.POST['timeslot'])
existing_assignments = SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule)
if existing_assignments:
existing_assignments.update(timeslot=timeslot, modified=datetime.datetime.now())
else:
SchedTimeSessAssignment.objects.create(
session=session,
schedule=schedule,
timeslot=timeslot,
)
return HttpResponse("OK")
elif action == 'unassign' and request.POST.get('session', '').isdigit():
session = get_object_or_404(sessions, pk=request.POST['session'])
SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule).delete()
return HttpResponse("OK")
return HttpResponse("Invalid parameters", status_code=400)
assignments_by_session = defaultdict(list)
for a in assignments:
assignments_by_session[a.session_id].append(a)
# prepare timeslot matrix
times = {} # start time -> end time
timeslots = {}
timeslots_by_pk = {}
for ts in timeslots_qs:
ts_end_time = ts.end_time()
if ts_end_time < times.get(ts.time, datetime.datetime.max):
times[ts.time] = ts_end_time
timeslots[(ts.location_id, ts.time)] = ts
timeslots_by_pk[ts.pk] = ts
ts.session_assignments = []
timeslot_matrix = [
(start_time, end_time, (end_time - start_time).seconds / 60.0 / 60.0 * css_ems_per_hour, [(r, timeslots.get((r.pk, start_time))) for r in rooms])
for start_time, end_time in sorted(times.items())
]
# prepare sessions
unassigned_sessions = []
session_data = []
for s in sessions:
d = {
'id': s.pk,
}
if s.requested_by:
d['requested_by'] = s.requested_by
if s.requested_time:
d['requested_time'] = s.requested_time.isoformat()
room_resources = s.resources.all()
if room_resources:
d['room_resources'] = [
{
'name': escape(r.name.name),
}
for r in room_resources
]
if s.group:
label = escape(s.group.acronym)
elif s.name:
label = escape(s.name)
else:
label = "???"
s.scheduling_label = label
if s.attendees is not None:
d['attendees'] = s.attendees
if s.group and s.group.is_bof():
d['bof'] = True
if s.group and s.group.parent:
d['group_parent'] = escape(s.group.parent.acronym.upper())
if s.comments:
d['comments'] = s.comments
s.requested_duration_in_hours = s.requested_duration.seconds / 60.0 / 60.0
s.scheduling_height = s.requested_duration_in_hours * css_ems_per_hour
scheduled = False
for a in assignments_by_session.get(s.pk, []):
timeslot = timeslots_by_pk.get(a.timeslot_id)
if timeslot:
timeslot.session_assignments.append((a, s))
scheduled = True
session_data.append(d)
if not scheduled:
unassigned_sessions.append(s)
schedule_data = {
'schedule': schedule.id,
'sessions': session_data,
'can_edit': can_edit,
'urls': {
'assign': request.get_full_path()
}
}
return render(request, "meeting/edit_meeting_schedule.html", {
'meeting': meeting,
'schedule': schedule,
'can_edit': can_edit,
'schedule_data': json.dumps(schedule_data, indent=2),
'rooms': rooms,
'timeslot_matrix': timeslot_matrix,
'unassigned_sessions': unassigned_sessions,
'timeslot_width': (100.0 - 10) / len(rooms),
#'areas': areas,
'hide_menu': True,
})
##############################################################################
#@role_required('Area Director','Secretariat')
# disable the above security for now, check it below.
@ -421,6 +683,7 @@ def edit_schedule(request, num=None, owner=None, name=None):
"hide_menu": True,
})
##############################################################################
# show the properties associated with a schedule (visible, public)
#
@ -446,7 +709,7 @@ def edit_schedule_properties(request, num=None, owner=None, name=None):
if form.is_valid():
form.save()
return HttpResponseRedirect(reverse('ietf.meeting.views.list_schedules',kwargs={'num': num}))
else:
else:
form = SchedulePropertiesForm(instance=schedule)
return render(request, "meeting/properties_edit.html",
{"schedule":schedule,

View file

@ -983,3 +983,80 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
padding: 8px;
border-top: 1px solid #ddd;
}
/* === Edit Meeting Schedule ====================================== */
.edit-meeting-schedule {
padding-bottom: 10em; /* ensure there's room for the scheduling panel */
}
.edit-meeting-schedule .session {
background-color: #fff;
padding: 0 0.4em;
margin: 0.2em 0.4em;
border-radius: 0.3em;
text-align: center;
}
.edit-meeting-schedule .session[draggable] {
cursor: grabbing;
}
.edit-meeting-schedule .session.dragging {
opacity: 0.3;
transition: opacity 0.4s;
}
.edit-meeting-schedule .session i.fa-comment-o {
width: 0; /* prevent icon from participating in text centering */
}
.edit-meeting-schedule .edit-meeting-grid th {
text-align: center;
}
.edit-meeting-schedule .edit-meeting-grid td.day {
padding-top: 1em;
}
.edit-meeting-schedule .edit-meeting-grid td.timeslot {
border: 2px solid #fff;
background-color: #f6f6f6;
padding: 1px;
}
.edit-meeting-schedule .edit-meeting-grid td.timeslot.disabled {
background-color: #fff;
}
.edit-meeting-schedule .edit-meeting-grid td.timeslot.dropping {
background-color: #f0f0f0;
transition: background-color 0.2s;
}
.edit-meeting-schedule .scheduling-panel {
position: fixed;
bottom: 0;
left: 0;
margin: 0;
padding: 0 1em;
width: 100%;
background-color: #fff;
opacity: 0.95;
z-index: 1;
}
.edit-meeting-schedule .unassigned-sessions {
min-height: 4em;
background-color: #f6f6f6;
}
.edit-meeting-schedule .unassigned-sessions.dropping {
background-color: #f0f0f0;
transition: background-color 0.2s;
}
.edit-meeting-schedule .unassigned-sessions .session {
display: inline-block;
min-width: 6em;
}

View file

@ -0,0 +1,94 @@
jQuery(document).ready(function () {
if (!ietfScheduleData.can_edit)
return;
var content = jQuery(".edit-meeting-schedule");
function failHandler(xhr, textStatus, error) {
alert("Error: " + error);
}
var sessions = content.find(".session");
// dragging
sessions.on("dragstart", function (event) {
event.originalEvent.dataTransfer.setData("text/plain", this.id);
jQuery(this).addClass("dragging");
});
sessions.on("dragend", function () {
jQuery(this).removeClass("dragging");
});
sessions.prop('draggable', true);
// dropping
var dropElements = content.find(".timeslot,.unassigned-sessions");
dropElements.on('dragenter', function (event) {
if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session")
return;
if (jQuery(this).hasClass("disabled"))
return;
event.preventDefault(); // default action is signalling that this is not a valid target
jQuery(this).addClass("dropping");
});
dropElements.on('dragover', function (event) {
// we don't actually need this event, except we need to signal
// that this is a valid drop target, by cancelling the default
// action
event.preventDefault();
});
dropElements.on('dragleave', function (event) {
jQuery(this).removeClass("dropping");
});
dropElements.on('drop', function (event) {
jQuery(this).removeClass("dropping");
var sessionId = event.originalEvent.dataTransfer.getData("text/plain");
if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session")
return;
var sessionElement = sessions.filter("#" + sessionId);
if (sessionElement.length == 0)
return;
event.preventDefault(); // prevent opening as link
if (sessionElement.parent().is(this))
return;
var dropElement = jQuery(this);
function done() {
dropElement.append(sessionElement); // move element
}
if (dropElement.hasClass("unassigned-sessions")) {
jQuery.ajax({
url: ietfScheduleData.urls.assign,
method: "post",
data: {
action: "unassign",
session: sessionId.slice("session".length)
}
}).fail(failHandler).done(done);
}
else {
jQuery.ajax({
url: ietfScheduleData.urls.assign,
method: "post",
data: {
action: "assign",
session: sessionId.slice("session".length),
timeslot: dropElement.data("timeslot")
}
}).fail(failHandler).done(done);
}
});
});

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015-2020, All Rights Reserved #}
{% load origin %}
{% load staticfiles %}
{% load ietf_filters %}
{% load bootstrap3 %}
{% block content %}
{% origin %}
<h1>{% block title %}Copy schedule {{ schedule.name }}{% endblock %}</h1>
<form class="form-horizontal" method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-default">Create schedule</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,110 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015-2020, All Rights Reserved #}
{% load origin %}
{% load staticfiles %}
{% load ietf_filters %}
{% block morecss %}
{# FIXME #}
{% for area in area_list %}
.group-colored-{{ area.upcase_acronym}} {
border-color: {{ area.fg_color}};
color:{{ area.fg_color }};
background-color: {{ area.bg_color }}
}
{% endfor %}
{% endblock morecss %}
{% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting schedule{% endblock %}
{% block js %}
<script>
jQuery.ajaxSetup({
crossDomain: false, // obviates need for sameOrigin test
beforeSend: function(xhr, settings) {
if (!csrfSafeMethod(settings.type)) {
xhr.setRequestHeader("X-CSRFToken", $.cookie('csrftoken'));
}
}
});
</script>
<script type="text/javascript" src="{% static 'ietf/js/edit-meeting-schedule.js' %}"></script>
<script type='text/javascript'>
var ietfScheduleData = {{ schedule_data|safe }};
</script>
{% endblock js %}
{% block content %}
{% origin %}
<div class="edit-meeting-schedule">
<p class="pull-right">
<a href="{% url "ietf.meeting.views.copy_meeting_schedule" num=meeting.number owner=schedule.owner_email name=schedule.name %}">Copy schedule</a>
&middot;
<a href="{% url "ietf.meeting.views.list_schedules" num=meeting.number %}">All schedules for meeting</a>
</p>
<p>
Schedule name: {{ schedule.name }}
&middot;
Owner: {{ schedule.owner }}
{% if not can_edit %}
&middot;
<em>You can't edit this schedule. Take a copy first.</em>
{% endif %}
</p>
<table class="table edit-meeting-grid">
<thead>
<tr>
<th></th>
{% for r in rooms %}
<th>{{ r.name }}{% if r.capacity %} - {{ r.capacity }} <i class="fa fa-user-o"></i>{% endif %}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for start_time, end_time, hours, room_timeslots in timeslot_matrix %}
{% ifchanged %}
<tr>
<td class="day" colspan="1000">
<strong>{{ start_time|date:"l, F j, Y" }}</strong>
</td>
</tr>
{% endifchanged %}
<tr>
<td>
<div class="period" style="min-height:{{ hours }}em;">{{ start_time|date:"G:i" }}-{{ end_time|date:"G:i" }}</div>
</td>
{% for r, timeslot in room_timeslots %}
<td class="timeslot {% if not timeslot %}disabled{% endif %}" {% if timeslot %}data-timeslot="{{ timeslot.pk }}"{% endif %} style="width:{{ timeslot_width }}%">
{% for assignment, session in timeslot.session_assignments %}
{% include "meeting/edit_meeting_schedule_session.html" %}
{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<div class="scheduling-panel">
<h4>Unscheduled</h4>
<div class="unassigned-sessions">
{% for session in unassigned_sessions %}
{% include "meeting/edit_meeting_schedule_session.html" %}
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,3 @@
<div id="session{{ session.pk }}" class="session" style="min-height:{{ session.scheduling_height }}em;">
{{ session.scheduling_label }} {% if session.comments %}<i class="fa fa-comment-o"></i>{% endif %}
</div>