Skip to content

Commit 824b1b6

Browse files
committed
Add Schedule.origin to enable tracking where a schedule was copied
from. Add utility function and view for diff'ing two arbitrary schedules, and modify the schedule list page to show the number of differences between the origin of a schedule, linking to the diff, to make it easy to see what's changed between work-in-progress schedules. - Legacy-Id: 18337
1 parent 1476c1c commit 824b1b6

8 files changed

Lines changed: 289 additions & 11 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 2.0.13 on 2020-08-04 06:22
2+
3+
from django.db import migrations
4+
import django.db.models.deletion
5+
import ietf.utils.models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('meeting', '0030_schedule_notes'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='schedule',
17+
name='origin',
18+
field=ietf.utils.models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='meeting.Schedule'),
19+
),
20+
]

ietf/meeting/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ class Schedule(models.Model):
626626
public = models.BooleanField(default=True, help_text="Allow others to see this agenda.")
627627
badness = models.IntegerField(null=True, blank=True)
628628
notes = models.TextField(blank=True)
629-
# considering copiedFrom = ForeignKey('Schedule', blank=True, null=True)
629+
origin = ForeignKey('Schedule', blank=True, null=True, on_delete=models.SET_NULL)
630630

631631
def __str__(self):
632632
return u"%s:%s(%s)" % (self.meeting, self.name, self.owner)

ietf/meeting/tests_views.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,6 +1359,77 @@ def test_list_schedules(self):
13591359
r = self.client.get(url)
13601360
self.assertTrue(r.status_code, 200)
13611361

1362+
def test_diff_schedules(self):
1363+
meeting = make_meeting_test_data()
1364+
1365+
url = urlreverse('ietf.meeting.views.diff_schedules',kwargs={'num':meeting.number})
1366+
login_testing_unauthorized(self,"secretary", url)
1367+
r = self.client.get(url)
1368+
self.assertTrue(r.status_code, 200)
1369+
1370+
from_schedule = Schedule.objects.get(meeting=meeting, name="test-unofficial-schedule")
1371+
1372+
session1 = Session.objects.filter(meeting=meeting, group__acronym='mars').first()
1373+
session2 = Session.objects.filter(meeting=meeting, group__acronym='ames').first()
1374+
session3 = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym='mars'),
1375+
attendees=10, requested_duration=datetime.timedelta(minutes=70),
1376+
type_id='regular')
1377+
SchedulingEvent.objects.create(session=session3, status_id='schedw', by=Person.objects.first())
1378+
1379+
slot2 = TimeSlot.objects.filter(meeting=meeting, type='regular').order_by('-time').first()
1380+
slot3 = TimeSlot.objects.create(
1381+
meeting=meeting, type_id='regular', location=slot2.location,
1382+
duration=datetime.timedelta(minutes=60),
1383+
time=slot2.time + datetime.timedelta(minutes=60),
1384+
)
1385+
1386+
# copy
1387+
copy_url = urlreverse("ietf.meeting.views.copy_meeting_schedule", kwargs=dict(num=meeting.number, owner=from_schedule.owner_email(), name=from_schedule.name))
1388+
r = self.client.post(copy_url, {
1389+
'name': "newtest",
1390+
'public': "on",
1391+
})
1392+
self.assertNoFormPostErrors(r)
1393+
1394+
to_schedule = Schedule.objects.get(meeting=meeting, name='newtest')
1395+
1396+
# make some changes
1397+
1398+
edit_url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, owner=to_schedule.owner_email(), name=to_schedule.name))
1399+
1400+
# schedule
1401+
r = self.client.post(edit_url, {
1402+
'action': 'assign',
1403+
'timeslot': slot3.pk,
1404+
'session': session3.pk,
1405+
})
1406+
self.assertEqual(json.loads(r.content)['success'], True)
1407+
1408+
# unschedule
1409+
r = self.client.post(edit_url, {
1410+
'action': 'unassign',
1411+
'session': session1.pk,
1412+
})
1413+
self.assertEqual(json.loads(r.content)['success'], True)
1414+
1415+
# move
1416+
r = self.client.post(edit_url, {
1417+
'action': 'assign',
1418+
'timeslot': slot2.pk,
1419+
'session': session2.pk,
1420+
})
1421+
self.assertEqual(json.loads(r.content)['success'], True)
1422+
1423+
# get differences
1424+
r = self.client.get(url, {
1425+
'from_schedule': from_schedule.name,
1426+
'to_schedule': to_schedule.name,
1427+
})
1428+
self.assertTrue(r.status_code, 200)
1429+
1430+
q = PyQuery(r.content)
1431+
self.assertEqual(len(q(".schedule-diffs tr")), 3)
1432+
13621433
def test_delete_schedule(self):
13631434
url = urlreverse('ietf.meeting.views.delete_schedule',
13641435
kwargs={'num':self.mtg.number,

ietf/meeting/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
url(r'^agenda/by-type/(?P<type>[a-z]+)/ics$', views.agenda_by_type_ics),
4949
url(r'^agendas/list$', views.list_schedules),
5050
url(r'^agendas/edit$', RedirectView.as_view(pattern_name='ietf.meeting.views.list_schedules', permanent=True)),
51+
url(r'^agendas/diff/$', views.diff_schedules),
5152
url(r'^timeslots/edit$', views.edit_timeslots),
5253
url(r'^timeslot/(?P<slot_id>\d+)/edittype$', views.edit_timeslot_type),
5354
url(r'^rooms$', ajax.timeslot_roomsurl),

ietf/meeting/utils.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,80 @@ def format_constraint(c):
436436
formatted_constraints_for_sessions[s.pk][joint_with_groups_constraint_name] = [g.acronym for g in joint_groups]
437437

438438
return constraints_for_sessions, formatted_constraints_for_sessions, constraint_names
439+
440+
441+
def diff_meeting_schedules(from_schedule, to_schedule):
442+
"""Compute the difference between the two meeting schedules as a list
443+
describing the set of actions that will turn the schedule of from into
444+
the schedule of to, like:
445+
446+
[
447+
{'change': 'schedule', 'session': session_id, 'to': timeslot_id},
448+
{'change': 'move', 'session': session_id, 'from': timeslot_id, 'to': timeslot_id2},
449+
{'change': 'unschedule', 'session': session_id, 'from': timeslot_id},
450+
]
451+
452+
Uses .assignments.all() so that it can be prefetched.
453+
"""
454+
diffs = []
455+
456+
from_session_timeslots = {
457+
a.session_id: a.timeslot_id
458+
for a in from_schedule.assignments.all()
459+
}
460+
461+
session_ids_in_to = set()
462+
463+
for a in to_schedule.assignments.all():
464+
session_ids_in_to.add(a.session_id)
465+
466+
from_timeslot_id = from_session_timeslots.get(a.session_id)
467+
468+
if from_timeslot_id is None:
469+
diffs.append({'change': 'schedule', 'session': a.session_id, 'to': a.timeslot_id})
470+
elif a.timeslot_id != from_timeslot_id:
471+
diffs.append({'change': 'move', 'session': a.session_id, 'from': from_timeslot_id, 'to': a.timeslot_id})
472+
473+
for from_session_id, from_timeslot_id in from_session_timeslots.items():
474+
if from_session_id not in session_ids_in_to:
475+
diffs.append({'change': 'unschedule', 'session': from_session_id, 'from': from_timeslot_id})
476+
477+
return diffs
478+
479+
480+
def prefetch_schedule_diff_objects(diffs):
481+
session_ids = set()
482+
timeslot_ids = set()
483+
484+
for d in diffs:
485+
session_ids.add(d['session'])
486+
487+
if d['change'] == 'schedule':
488+
timeslot_ids.add(d['to'])
489+
elif d['change'] == 'move':
490+
timeslot_ids.add(d['from'])
491+
timeslot_ids.add(d['to'])
492+
elif d['change'] == 'unschedule':
493+
timeslot_ids.add(d['from'])
494+
495+
session_lookup = {s.pk: s for s in Session.objects.filter(pk__in=session_ids)}
496+
timeslot_lookup = {t.pk: t for t in TimeSlot.objects.filter(pk__in=timeslot_ids).prefetch_related('location')}
497+
498+
res = []
499+
for d in diffs:
500+
d_objs = {
501+
'change': d['change'],
502+
'session': session_lookup.get(d['session'])
503+
}
504+
505+
if d['change'] == 'schedule':
506+
d_objs['to'] = timeslot_lookup.get(d['to'])
507+
elif d['change'] == 'move':
508+
d_objs['from'] = timeslot_lookup.get(d['from'])
509+
d_objs['to'] = timeslot_lookup.get(d['to'])
510+
elif d['change'] == 'unschedule':
511+
d_objs['from'] = timeslot_lookup.get(d['from'])
512+
513+
res.append(d_objs)
514+
515+
return res

ietf/meeting/views.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
from ietf.meeting.utils import current_session_status
7878
from ietf.meeting.utils import data_for_meetings_overview
7979
from ietf.meeting.utils import preprocess_constraints_for_meeting_schedule_editor
80+
from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_objects
8081
from ietf.message.utils import infer_message
8182
from ietf.secr.proceedings.utils import handle_upload_file
8283
from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files,
@@ -825,7 +826,9 @@ def natural_sort_key(s): # from https://stackoverflow.com/questions/4836710/is-t
825826
def list_schedules(request, num):
826827
meeting = get_meeting(num)
827828

828-
schedules = Schedule.objects.filter(meeting=meeting).prefetch_related('owner').order_by('owner', '-name', '-public').distinct()
829+
schedules = Schedule.objects.filter(
830+
meeting=meeting
831+
).prefetch_related('owner', 'assignments', 'origin', 'origin__assignments').order_by('owner', '-name', '-public').distinct()
829832
if not has_role(request.user, 'Secretariat'):
830833
schedules = schedules.filter(Q(visible=True) | Q(owner=request.user.person))
831834

@@ -839,6 +842,9 @@ def list_schedules(request, num):
839842
for s in schedules:
840843
s.can_edit_properties = is_secretariat or user_is_person(request.user, s.owner)
841844

845+
if s.origin:
846+
s.changes_from_origin = len(diff_meeting_schedules(s.origin, s))
847+
842848
if s.pk == meeting.schedule_id:
843849
official_schedules.append(s)
844850
elif user_is_person(request.user, s.owner):
@@ -862,6 +868,58 @@ def list_schedules(request, num):
862868
'schedule_groups': schedule_groups,
863869
})
864870

871+
class DiffSchedulesForm(forms.Form):
872+
from_schedule = forms.ChoiceField()
873+
to_schedule = forms.ChoiceField()
874+
875+
def __init__(self, meeting, user, *args, **kwargs):
876+
super().__init__(*args, **kwargs)
877+
878+
qs = Schedule.objects.filter(meeting=meeting).prefetch_related('owner').order_by('-public').distinct()
879+
880+
if not has_role(user, 'Secretariat'):
881+
qs = qs.filter(Q(visible=True) | Q(owner__user=user))
882+
883+
sorted_schedules = sorted(qs, reverse=True, key=lambda s: natural_sort_key(s.name))
884+
885+
schedule_choices = [(schedule.name, "{} ({})".format(schedule.name, schedule.owner)) for schedule in sorted_schedules]
886+
887+
self.fields['from_schedule'].choices = schedule_choices
888+
self.fields['to_schedule'].choices = schedule_choices
889+
890+
@role_required('Area Director','Secretariat')
891+
def diff_schedules(request, num):
892+
meeting = get_meeting(num)
893+
894+
diffs = None
895+
from_schedule = None
896+
to_schedule = None
897+
898+
if 'from_schedule' in request.GET:
899+
form = DiffSchedulesForm(meeting, request.user, request.GET)
900+
if form.is_valid():
901+
from_schedule = get_object_or_404(Schedule, name=form.cleaned_data['from_schedule'], meeting=meeting)
902+
to_schedule = get_object_or_404(Schedule, name=form.cleaned_data['to_schedule'], meeting=meeting)
903+
raw_diffs = diff_meeting_schedules(from_schedule, to_schedule)
904+
905+
diffs = prefetch_schedule_diff_objects(raw_diffs)
906+
for d in diffs:
907+
s = d['session']
908+
s.session_label = s.short_name
909+
if s.requested_duration:
910+
s.session_label = "{} ({}h)".format(s.session_label, round(s.requested_duration.seconds / 60.0 / 60.0, 1))
911+
else:
912+
form = DiffSchedulesForm(meeting, request.user)
913+
914+
return render(request, "meeting/diff_schedules.html", {
915+
'meeting': meeting,
916+
'form': form,
917+
'diffs': diffs,
918+
'from_schedule': from_schedule,
919+
'to_schedule': to_schedule,
920+
})
921+
922+
865923
@ensure_csrf_cookie
866924
def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""):
867925
base = base if base else 'agenda'
@@ -2282,6 +2340,10 @@ def delete_schedule(request, num, owner, name):
22822340
return HttpResponseForbidden("You may not delete other user's schedules")
22832341

22842342
if request.method == 'POST':
2343+
# remove schedule from origin tree
2344+
replacement_origin = schedule.origin
2345+
Schedule.objects.filter(origin=schedule).update(origin=replacement_origin)
2346+
22852347
schedule.delete()
22862348
return HttpResponseRedirect(reverse('ietf.meeting.views.list_schedules',kwargs={'num':num}))
22872349

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{% extends "base.html" %}
2+
{# Copyright The IETF Trust 2020, All Rights Reserved #}
3+
{% load origin %}
4+
{% load ietf_filters %}
5+
{% load bootstrap3 %}
6+
7+
{% block content %}
8+
{% origin %}
9+
<h1>{% block title %}Differences between Meeting Agendas for IETF {{ meeting.number }}{% endblock %}</h1>
10+
11+
<form method="get">
12+
{% bootstrap_form form %}
13+
<button type="submit">Show differences</button>
14+
</form>
15+
16+
{% if diffs != None %}
17+
<h2>Differences from {{ from_schedule.name }} ({{ from_schedule.owner }}) to {{ to_schedule.name }} ({{ to_schedule.owner }}) </h2>
18+
19+
{% if diffs %}
20+
<table class="table table-condensed schedule-diffs">
21+
{% for d in diffs %}
22+
<tr>
23+
<td>
24+
{% if d.change == 'schedule' %}
25+
Scheduled <b>{{ d.session.session_label }}</b> to <b>{{ d.to.time|date:"l G:i" }} at {{ d.to.location.name }}</b>
26+
{% elif d.change == 'move' %}
27+
Moved <b>{{ d.session.session_label }}</b> from {{ d.from.time|date:"l G:i" }} at {{ d.from.location.name }} to <b>{{ d.to.time|date:"l G:i" }} at {{ d.to.location.name }}</b>
28+
{% elif d.change == 'unschedule' %}
29+
Unscheduled <b>{{ d.session.session_label }}</b> from {{ d.from.time|date:"l G:i" }} at {{ d.from.location.name }}
30+
{% endif %}
31+
</td>
32+
</tr>
33+
{% endfor %}
34+
</table>
35+
{% else %}
36+
No differences in scheduled sessions found!
37+
{% endif %}
38+
{% endif %}
39+
40+
{% endblock content %}

ietf/templates/meeting/schedule_list.html

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,28 @@ <h1>{% block title %}Possible Meeting Agendas for IETF {{ meeting.number }}{% en
2222
<tr>
2323
<th class="col-md-2">Name</th>
2424
<th class="col-md-2">Owner</th>
25-
<th class="col-md-5">Notes</th>
25+
<th class="col-md-1">Origin</th>
26+
<th class="col-md-4">Notes</th>
2627
<th class="col-md-1">Visible</th>
2728
<th class="col-md-1">Public</th>
2829
</tr>
2930
{% for schedule in schedules %}
30-
<tr>
31-
<td>
32-
<a href="{% url "ietf.meeting.views.edit_schedule" schedule.meeting.number schedule.owner_email schedule.name %}">{{ schedule.name }}</a>
33-
{% if schedule.can_edit_properties %}
34-
<a class="edit-schedule-properties" href="{% url "ietf.meeting.views.edit_schedule_properties" schedule.meeting.number schedule.owner_email schedule.name %}?next={{ request.get_full_path|urlencode }}">
35-
<i title="Edit agenda properties" class="fa fa-edit"></i>
36-
</a>
31+
<tr>
32+
<td>
33+
<a href="{% url "ietf.meeting.views.edit_schedule" meeting.number schedule.owner_email schedule.name %}">{{ schedule.name }}</a>
34+
{% if schedule.can_edit_properties %}
35+
<a class="edit-schedule-properties" href="{% url "ietf.meeting.views.edit_schedule_properties" meeting.number schedule.owner_email schedule.name %}?next={{ request.get_full_path|urlencode }}">
36+
<i title="Edit agenda properties" class="fa fa-edit"></i>
37+
</a>
38+
{% endif %}
39+
</td>
40+
<td>{{ schedule.owner }}</td>
41+
<td>
42+
{% if schedule.origin %}
43+
<a href="{% url "ietf.meeting.views.edit_schedule" meeting.number schedule.origin.owner_email schedule.name %}">{{ schedule.origin.name }}</a>
44+
<a href="{% url "ietf.meeting.views.diff_schedules" meeting.number %}?from_schedule={{ schedule.origin.name|urlencode }}&to_schedule={{ schedule.name|urlencode }}" title="{{ schedule.changes_from_origin }} change{{ schedule.changes_from_origin|pluralize }} from {{ schedule.origin.name }}">+{{ schedule.changes_from_origin }}</a>
3745
{% endif %}
3846
</td>
39-
<td>{{ schedule.owner }}</td>
4047
<td>{{ schedule.notes|linebreaksbr }}</td>
4148
<td>
4249
{% if schedule.visible %}

0 commit comments

Comments
 (0)