From 45ed2c5a2ccf14d95a5c05caefa17cf73cdd1a0b Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 30 Jun 2020 16:55:24 +0000 Subject: [PATCH] Add support in the new meeting schedule editor for making a tombstone session when rescheduling a session after the schedule is made the official meeting schedule. Show both cancelled and rescheduled sessions as tombstones in the new meeting schedule editor. Add support for showing rescheduled tombstones in the meeting agenda views. Adjust the Secretariat session tool so that it's not possible to (re)cancel cancelled or rescheduled tombstones. - Legacy-Id: 18108 --- ietf/meeting/helpers.py | 5 + .../migrations/0029_session_tombstone_for.py | 19 ++ ietf/meeting/models.py | 2 + ietf/meeting/tests_js.py | 10 +- ietf/meeting/tests_views.py | 51 +++- ietf/meeting/views.py | 256 +++++++++++------- ietf/name/fixtures/names.json | 10 + .../0013_add_rescheduled_session_name.py | 24 ++ ietf/secr/meetings/views.py | 16 +- ietf/secr/templates/meetings/sessions.html | 12 +- ietf/static/ietf/css/ietf.css | 5 + ietf/static/ietf/js/edit-meeting-schedule.js | 40 ++- ietf/templates/meeting/agenda.html | 18 +- ietf/templates/meeting/agenda.txt | 2 +- 14 files changed, 332 insertions(+), 138 deletions(-) create mode 100644 ietf/meeting/migrations/0029_session_tombstone_for.py create mode 100644 ietf/name/migrations/0013_add_rescheduled_session_name.py diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py index c4f3e72c4..18644f46e 100644 --- a/ietf/meeting/helpers.py +++ b/ietf/meeting/helpers.py @@ -212,10 +212,15 @@ def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefe parents = Group.objects.filter(pk__in=parent_id_set) parent_replacements = find_history_replacements_active_at(parents, meeting_time) + timeslot_by_session_pk = {a.session_id: a.timeslot for a in assignments} + for a in assignments: if a.session and a.session.historic_group and a.session.historic_group.parent_id: a.session.historic_group.historic_parent = parent_replacements.get(a.session.historic_group.parent_id) + if a.session.current_status == 'resched': + a.session.rescheduled_to = timeslot_by_session_pk.get(a.session.tombstone_for_id) + for d in a.session.prefetched_active_materials: # make sure these are precomputed with the meeting instead # of having to look it up diff --git a/ietf/meeting/migrations/0029_session_tombstone_for.py b/ietf/meeting/migrations/0029_session_tombstone_for.py new file mode 100644 index 000000000..0b765c533 --- /dev/null +++ b/ietf/meeting/migrations/0029_session_tombstone_for.py @@ -0,0 +1,19 @@ +# Copyright The IETF Trust 2020, All Rights Reserved + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('meeting', '0028_auto_20200501_0139'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='tombstone_for', + field=models.ForeignKey(blank=True, help_text='This session is the tombstone for a session that was rescheduled', null=True, on_delete=django.db.models.deletion.CASCADE, to='meeting.Session'), + ), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index e4483e144..0038ca1ac 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -923,6 +923,8 @@ class Session(models.Model): modified = models.DateTimeField(auto_now=True) remote_instructions = models.CharField(blank=True,max_length=1024) + tombstone_for = models.ForeignKey('Session', blank=True, null=True, help_text="This session is the tombstone for a session that was rescheduled", on_delete=models.CASCADE) + materials = models.ManyToManyField(Document, through=SessionPresentation, blank=True) resources = models.ManyToManyField(ResourceAssociation, blank=True) diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index 4c6d04f34..7b5b5b924 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -16,9 +16,11 @@ import debug # pyflakes:ignore from ietf.doc.factories import DocumentFactory from ietf.group import colors +from ietf.person.models import Person from ietf.meeting.factories import SessionFactory from ietf.meeting.test_data import make_meeting_test_data from ietf.meeting.models import Schedule, SchedTimeSessAssignment, Session, Room, TimeSlot, Constraint, ConstraintName +from ietf.meeting.models import SchedulingEvent, SessionStatusName from ietf.utils.test_runner import IetfLiveServerTestCase from ietf.utils.pipe import pipe from ietf import settings @@ -107,6 +109,12 @@ class EditMeetingScheduleTests(IetfLiveServerTestCase): s2b = Session.objects.create(meeting=meeting, group=s2.group, attendees=10, requested_duration=datetime.timedelta(minutes=60), type_id='regular') + SchedulingEvent.objects.create( + session=s2b, + status=SessionStatusName.objects.get(slug='appr'), + by=Person.objects.get(name='(System)'), + ) + Constraint.objects.create( meeting=meeting, source=s1.group, @@ -226,7 +234,7 @@ class EditMeetingScheduleTests(IetfLiveServerTestCase): self.assertTrue(s1_element.is_displayed()) # hide timeslots - self.driver.find_element_by_css_selector(".timeslot-group-toggles button".format(s1.group.parent.acronym)).click() + self.driver.find_element_by_css_selector(".timeslot-group-toggles button").click() self.assertTrue(self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal").is_displayed()) self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal [value=\"{}\"]".format("ts-group-{}-{}".format(slot2.time.strftime("%Y%m%d-%H%M"), int(slot2.duration.total_seconds() / 60)))).click() self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal [data-dismiss=\"modal\"]").click() diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 00909d18e..914a50eae 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -1049,10 +1049,14 @@ class EditTests(TestCase): self.assertEqual(r.status_code, 403) # turn us into owner - meeting.schedule.owner = Person.objects.get(user__username="secretary") - meeting.schedule.save() + schedule = meeting.schedule + schedule.owner = Person.objects.get(user__username="secretary") + schedule.save() - url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name)) + meeting.schedule = None + meeting.save() + + url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, owner=schedule.owner_email(), name=schedule.name)) r = self.client.get(url) q = PyQuery(r.content) self.assertTrue(not q("em:contains(\"You can't edit this schedule\")")) @@ -1065,25 +1069,52 @@ class EditTests(TestCase): 'timeslot': timeslots[0].pk, 'session': s1.pk, }) - self.assertEqual(r.content, b"OK") - self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=s1).timeslot, timeslots[0]) + self.assertEqual(json.loads(r.content)['success'], True) + self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=schedule, session=s1).timeslot, timeslots[0]) - # move assignment + # move assignment on unofficial schedule r = self.client.post(url, { 'action': 'assign', 'timeslot': timeslots[1].pk, 'session': s1.pk, }) - self.assertEqual(r.content, b"OK") - self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=s1).timeslot, timeslots[1]) + self.assertEqual(json.loads(r.content)['success'], True) + self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=schedule, session=s1).timeslot, timeslots[1]) + + # move assignment on official schedule, leaving tombstone + meeting.schedule = schedule + meeting.save() + SchedulingEvent.objects.create( + session=s1, + status=SessionStatusName.objects.get(slug='sched'), + by=Person.objects.get(name='(System)') + ) + r = self.client.post(url, { + 'action': 'assign', + 'timeslot': timeslots[0].pk, + 'session': s1.pk, + }) + json_content = json.loads(r.content) + self.assertEqual(json_content['success'], True) + self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=schedule, session=s1).timeslot, timeslots[0]) + + sessions_for_group = Session.objects.filter(group=s1.group, meeting=meeting) + self.assertEqual(len(sessions_for_group), 2) + s_tombstone = [s for s in sessions_for_group if s != s1][0] + self.assertEqual(s_tombstone.tombstone_for, s1) + tombstone_event = SchedulingEvent.objects.get(session=s_tombstone) + self.assertEqual(tombstone_event.status_id, 'resched') + + self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=schedule, session=s_tombstone).timeslot, timeslots[1]) + self.assertTrue(PyQuery(json_content['tombstone'])("#session{}.tombstone".format(s_tombstone.pk)).html()) # unassign r = self.client.post(url, { 'action': 'unassign', 'session': s1.pk, }) - self.assertEqual(r.content, b"OK") - self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule, session=s1)), []) + self.assertEqual(json.loads(r.content)['success'], True) + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s1)), []) def test_copy_meeting_schedule(self): diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index d927afcb1..9e023ada5 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -26,14 +26,14 @@ import debug # pyflakes:ignore from django import forms from django.shortcuts import render, redirect, get_object_or_404 -from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden, Http404 +from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden, Http404, JsonResponse from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.urls import reverse,reverse_lazy -from django.db.models import F, Min, Max, Prefetch, Q +from django.db.models import F, Min, Max, Q from django.forms.models import modelform_factory, inlineformset_factory from django.template import TemplateDoesNotExist from django.template.loader import render_to_string @@ -51,7 +51,6 @@ from ietf.doc.models import Document, State, DocEvent, NewRevisionDocEvent, DocA from ietf.group.models import Group from ietf.group.utils import can_manage_session_materials, can_manage_some_groups, can_manage_group from ietf.person.models import Person -from ietf.person.name import plain_name from ietf.ietfauth.utils import role_required, has_role from ietf.mailtrigger.utils import gather_address_lists from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission @@ -459,7 +458,8 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): assignments = get_all_assignments_from_schedule(schedule) 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('location', 'time', 'name') + + tombstone_states = ['canceled', 'canceledpa', 'resched'] sessions = add_event_info_to_session_qs( Session.objects.filter( @@ -469,45 +469,14 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): ).order_by('pk'), requested_time=True, requested_by=True, - ).exclude(current_status__in=['notmeet', 'disappr', 'deleted', 'apprw']).prefetch_related( + ).filter( + Q(current_status__in=['appr', 'schedw', 'scheda', 'sched']) + | Q(current_status__in=tombstone_states, pk__in={a.session_id for a in assignments}) + ).prefetch_related( 'resources', 'group', 'group__parent', 'group__type', 'joint_with_groups', ) - 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 layout + timeslots_qs = meeting.timeslot_set.filter(type='regular').prefetch_related('type', 'sessions').order_by('location', 'time', 'name') min_duration = min(t.duration for t in timeslots_qs) max_duration = max(t.duration for t in timeslots_qs) @@ -525,6 +494,119 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): scale = (capped_timedelta - capped_min_d) / (capped_max_d - capped_min_d) if capped_min_d != capped_max_d else 1 return min_d_css_rems + (max_d_css_rems - min_d_css_rems) * scale + def prepare_sessions_for_display(sessions): + # requesters + requested_by_lookup = {p.pk: p for p in Person.objects.filter(pk__in=set(s.requested_by for s in sessions if s.requested_by))} + + # constraints + constraints_for_sessions, formatted_constraints_for_sessions, constraint_names = preprocess_constraints_for_meeting_schedule_editor(meeting, sessions) + + sessions_for_group = defaultdict(list) + for s in sessions: + sessions_for_group[s.group_id].append(s) + + for s in sessions: + s.requested_by_person = requested_by_lookup.get(s.requested_by) + + s.scheduling_label = "???" + if s.group: + s.scheduling_label = s.group.acronym + elif s.name: + s.scheduling_label = s.name + + s.requested_duration_in_hours = round(s.requested_duration.seconds / 60.0 / 60.0, 1) + + session_layout_margin = 0.2 + s.layout_width = timedelta_to_css_ems(s.requested_duration) - 2 * session_layout_margin + s.parent_acronym = s.group.parent.acronym if s.group and s.group.parent else "" + + # compress the constraints, so similar constraint labels are + # shared between the conflicting sessions they cover - the JS + # then simply has to detect violations and show the + # preprocessed labels + constrained_sessions_grouped_by_label = defaultdict(set) + for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]): + ts = list(ts) + session_pks = (t[1] for t in ts) + constraint_name = constraint_names[name_id] + if "{count}" in constraint_name.formatted_editor_label: + for session_pk, grouped_session_pks in itertools.groupby(session_pks): + count = sum(1 for i in grouped_session_pks) + constrained_sessions_grouped_by_label[format_html(constraint_name.formatted_editor_label, count=count)].add(session_pk) + + else: + constrained_sessions_grouped_by_label[constraint_name.formatted_editor_label].update(session_pks) + + s.constrained_sessions = list(constrained_sessions_grouped_by_label.items()) + s.formatted_constraints = formatted_constraints_for_sessions.get(s.pk, {}) + + s.other_sessions = [s_other for s_other in sessions_for_group.get(s.group_id) if s != s_other] + + s.is_tombstone = s.current_status in tombstone_states + + + if request.method == 'POST': # handle ajax requests + 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']) + + tombstone_session = None + + existing_assignments = SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule) + if existing_assignments: + if schedule.pk == meeting.schedule_id and session.current_status == 'sched': + old_timeslot = existing_assignments[0].timeslot + # clone session and leave it as a tombstone + tombstone_session = session + tombstone_session.tombstone_for_id = session.pk + tombstone_session.pk = None + tombstone_session.save() + + session = None + + SchedulingEvent.objects.create( + session=tombstone_session, + status=SessionStatusName.objects.get(slug='resched'), + by=request.user.person, + ) + + tombstone_session.current_status = 'resched' # rematerialize status for the rendering + + SchedTimeSessAssignment.objects.create( + session=tombstone_session, + schedule=schedule, + timeslot=old_timeslot, + ) + + existing_assignments.update(timeslot=timeslot, modified=datetime.datetime.now()) + else: + SchedTimeSessAssignment.objects.create( + session=session, + schedule=schedule, + timeslot=timeslot, + ) + + r = {'success': True} + if tombstone_session: + prepare_sessions_for_display([tombstone_session]) + r['tombstone'] = render_to_string("meeting/edit_meeting_schedule_session.html", {'session': tombstone_session}) + return JsonResponse(r) + + 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 JsonResponse({'success': True}) + + return HttpResponse("Invalid parameters", status_code=400) + + # prepare timeslot layout + timeslots_by_room_and_day = defaultdict(list) room_has_timeslots = set() for t in timeslots_qs: @@ -559,10 +641,28 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): timeslot_groups[ts.time.date()].add((ts.time, ts.end_time(), ts.start_end_group)) # prepare sessions + prepare_sessions_for_display(sessions) + for ts in timeslots_qs: ts.session_assignments = [] timeslots_by_pk = {ts.pk: ts for ts in timeslots_qs} + assignments_by_session = defaultdict(list) + for a in assignments: + assignments_by_session[a.session_id].append(a) + + unassigned_sessions = [] + for s in sessions: + assigned = 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)) + assigned = True + + if not assigned: + unassigned_sessions.append(s) + # group parent colors def cubehelix(i, total, hue=1.2, start_angle=0.5): # theory in https://arxiv.org/pdf/1108.5083.pdf @@ -586,64 +686,6 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): p.scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round(x * 255)) for x in rgb_color)) p.light_scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round((0.9 + 0.1 * x) * 255)) for x in rgb_color)) - # requesters - requested_by_lookup = {p.pk: p for p in Person.objects.filter(pk__in=set(s.requested_by for s in sessions if s.requested_by))} - - # constraints - constraints_for_sessions, formatted_constraints_for_sessions, constraint_names = preprocess_constraints_for_meeting_schedule_editor(meeting, sessions) - - sessions_for_group = defaultdict(list) - for s in sessions: - sessions_for_group[s.group_id].append(s) - - unassigned_sessions = [] - for s in sessions: - s.requested_by_person = requested_by_lookup.get(s.requested_by) - - s.scheduling_label = "???" - if s.group: - s.scheduling_label = s.group.acronym - elif s.name: - s.scheduling_label = s.name - - s.requested_duration_in_hours = round(s.requested_duration.seconds / 60.0 / 60.0, 1) - - session_layout_margin = 0.2 - s.layout_width = timedelta_to_css_ems(s.requested_duration) - 2 * session_layout_margin - s.parent_acronym = s.group.parent.acronym if s.group and s.group.parent else "" - - # compress the constraints, so similar constraint labels are - # shared between the conflicting sessions they cover - the JS - # then simply has to detect violations and show the - # preprocessed labels - constrained_sessions_grouped_by_label = defaultdict(set) - for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]): - ts = list(ts) - session_pks = (t[1] for t in ts) - constraint_name = constraint_names[name_id] - if "{count}" in constraint_name.formatted_editor_label: - for session_pk, grouped_session_pks in itertools.groupby(session_pks): - count = sum(1 for i in grouped_session_pks) - constrained_sessions_grouped_by_label[format_html(constraint_name.formatted_editor_label, count=count)].add(session_pk) - - else: - constrained_sessions_grouped_by_label[constraint_name.formatted_editor_label].update(session_pks) - - s.constrained_sessions = list(constrained_sessions_grouped_by_label.items()) - s.formatted_constraints = formatted_constraints_for_sessions.get(s.pk, {}) - - s.other_sessions = [s_other for s_other in sessions_for_group.get(s.group_id) if s != s_other] - - assigned = 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)) - assigned = True - - if not assigned: - unassigned_sessions.append(s) - js_data = { 'can_edit': can_edit, 'urls': { @@ -1214,9 +1256,19 @@ def room_view(request, num=None, name=None, owner=None): template = "meeting/room-view.html" return render(request, template,{"meeting":meeting,"schedule":schedule,"unavailable":unavailable,"assignments":assignments,"rooms":rooms,"days":days}) -def ical_session_status(session_with_current_status): - if session_with_current_status == 'canceled': +def ical_session_status(assignment): + if assignment.session.current_status == 'canceled': return "CANCELLED" + elif assignment.session.current_status == 'resched': + t = "RESCHEDULED" + if assignment.session.tombstone_for_id is not None: + other_assignment = SchedTimeSessAssignment.objects.filter(schedule=assignment.schedule_id, session=assignment.session.tombstone_for_id).first() + if other_assignment: + t = "RESCHEDULED TO {}-{}".format( + other_assignment.timeslot.time.strftime("%A %H:%M").upper(), + other_assignment.timeslot.end_time().strftime("%H:%M") + ) + return t else: return "CONFIRMED" @@ -1268,7 +1320,7 @@ def ical_agenda(request, num=None, name=None, acronym=None, session_id=None): for a in assignments: if a.session: - a.session.ical_status = ical_session_status(a.session.current_status) + a.session.ical_status = ical_session_status(a) return render(request, "meeting/agenda.ics", { "schedule": schedule, @@ -2655,7 +2707,7 @@ def upcoming_ical(request): for a in assignments: if a.session_id is not None: a.session = sessions.get(a.session_id) or a.session - a.session.ical_status = ical_session_status(a.session.current_status) + a.session.ical_status = ical_session_status(a) # gather vtimezones vtimezones = set() diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index c678b951d..4c87b0bb0 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -11653,6 +11653,16 @@ "model": "name.sessionstatusname", "pk": "schedw" }, + { + "fields": { + "desc": "", + "name": "Rescheduled", + "order": 0, + "used": true + }, + "model": "name.sessionstatusname", + "pk": "resched" + }, { "fields": { "desc": "", diff --git a/ietf/name/migrations/0013_add_rescheduled_session_name.py b/ietf/name/migrations/0013_add_rescheduled_session_name.py new file mode 100644 index 000000000..c3ffe692e --- /dev/null +++ b/ietf/name/migrations/0013_add_rescheduled_session_name.py @@ -0,0 +1,24 @@ +# Copyright The IETF Trust 2020, All Rights Reserved + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0012_update_constraintname_order_and_label'), + ] + + def add_rescheduled_session_status_name(apps, schema_editor): + SessionStatusName = apps.get_model('name', 'SessionStatusName') + SessionStatusName.objects.get_or_create( + slug='resched', + name="Rescheduled", + ) + + def noop(apps, schema_editor): + pass + + operations = [ + migrations.RunPython(add_rescheduled_session_status_name, noop, elidable=True), + ] diff --git a/ietf/secr/meetings/views.py b/ietf/secr/meetings/views.py index 815b16121..8e79efdb6 100644 --- a/ietf/secr/meetings/views.py +++ b/ietf/secr/meetings/views.py @@ -659,17 +659,21 @@ def regular_sessions(request, meeting_id, schedule_name): if 'cancel' in request.POST: pk = request.POST.get('pk') session = get_object_or_404(sessions, pk=pk) - SchedulingEvent.objects.create( - session=session, - status=SessionStatusName.objects.get(slug='canceled'), - by=request.user.person, - ) - messages.success(request, 'Session cancelled') + if session.current_status not in ['canceled', 'resched']: + SchedulingEvent.objects.create( + session=session, + status=SessionStatusName.objects.get(slug='canceled'), + by=request.user.person, + ) + messages.success(request, 'Session cancelled') + + return redirect('ietf.secr.meetings.views.regular_sessions', meeting_id=meeting_id, schedule_name=schedule_name) status_names = {n.slug: n.name for n in SessionStatusName.objects.all()} for s in sessions: s.current_status_name = status_names.get(s.current_status, s.current_status) + s.can_cancel = s.current_status not in ['canceled', 'resched'] return render(request, 'meetings/sessions.html', { 'meeting': meeting, diff --git a/ietf/secr/templates/meetings/sessions.html b/ietf/secr/templates/meetings/sessions.html index 2e6638fa0..558cd43e1 100644 --- a/ietf/secr/templates/meetings/sessions.html +++ b/ietf/secr/templates/meetings/sessions.html @@ -36,11 +36,13 @@ {{ session.current_status_name }} Edit -
- {% csrf_token %} - - -
+ {% if session.can_cancel %} +
+ {% csrf_token %} + + +
+ {% endif %} {% endfor %} diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 8d5f65829..0d1457e6a 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -1118,6 +1118,11 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { cursor: default; } +.edit-meeting-schedule .session.tombstone { + cursor: default; + background-color: #ddd; +} + .edit-meeting-schedule .session.selected .session-label { font-weight: bold; } diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index 711502ee0..a7fe03ca6 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -1,14 +1,14 @@ jQuery(document).ready(function () { let content = jQuery(".edit-meeting-schedule"); - function failHandler(xhr, textStatus, error) { - let errorText = error; + function reportServerError(xhr, textStatus, error) { + let errorText = error || textStatus; if (xhr && xhr.responseText) errorText += "\n\n" + xhr.responseText; alert("Error: " + errorText); } - let sessions = content.find(".session"); + let sessions = content.find(".session").not(".tombstone"); let timeslots = content.find(".timeslot"); let days = content.find(".day-flow .day"); @@ -130,7 +130,6 @@ jQuery(document).ready(function () { }); sessions.on("dragend", function () { jQuery(this).removeClass("dragging"); - }); sessions.prop('draggable', true); @@ -161,31 +160,50 @@ jQuery(document).ready(function () { }); dropElements.on('drop', function (event) { - jQuery(this).parent().removeClass("dropping"); + let dropElement = jQuery(this); let sessionId = event.originalEvent.dataTransfer.getData("text/plain"); - if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") + if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") { + dropElement.parent().removeClass("dropping"); return; + } let sessionElement = sessions.filter("#" + sessionId); - if (sessionElement.length == 0) + if (sessionElement.length == 0) { + dropElement.parent().removeClass("dropping"); return; + } event.preventDefault(); // prevent opening as link - if (sessionElement.parent().is(this)) + let dragParent = sessionElement.parent(); + if (dragParent.is(this)) { + dropElement.parent().removeClass("dropping"); return; + } - let dropElement = jQuery(this); let dropParent = dropElement.parent(); + function failHandler(xhr, textStatus, error) { + dropElement.parent().removeClass("dropping"); + console.log("xhr", xhr) + console.log("textstatus", textStatus) + console.log("error", error) + reportServerError(xhr, textStatus, error); + } + function done(response) { - if (response != "OK") { - failHandler(null, null, response); + dropElement.parent().removeClass("dropping"); + + if (!response.success) { + reportServerError(null, null, response); return; } dropElement.append(sessionElement); // move element + if (response.tombstone) + dragParent.append(response.tombstone); + updateCurrentSchedulingHints(); if (dropParent.hasClass("unassigned-sessions")) sortUnassigned(); diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index 8897ae8e0..6a8d9ab84 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -234,7 +234,7 @@