Repository: ietf-tools/datatracker

Issue:
Secretariat could not cancel TDD
The UI didn't give Liz the ability to cancel TDD at 114.
I added a SchedulingEvent manually through the admin.


Discussion:
cc @flynnliz
Looks like the edit/cancel buttons are disabled when session requests are closed. Should I open both of those for the secretariat?
@jennifer-richards I think I'm a little confused. Are we talking about canceling a session _request_ or canceling a session?

Secretariat should definitely be able to cancel/edit a session request while the tool is closed. But canceling a session request from /sreq disappears the session from the agenda completely, which is not always what we want. The issue with TDD is that it doesn't appear on the page where I normally cancel regular sessions (https://datatracker.ietf.org/secr/meetings/115/finalagenda/regularsessions/) -- pushing the Cancel button here flags the session as "cancelled" on the agenda, which is what we want. So the fix needed is not with the request tool but somewhere else that would allow me to trigger the "Canceled" flag on the agenda for a non-regular-WG session. 
(There is a similar button marked cancel for TDD on https://datatracker.ietf.org/secr/meetings/115/finalagenda/miscsessions/, but I don't think it actually works and I have been pretending like that whole Misc. Sessions tab doesn't exist because of the new way we're supposed to be adding these events.) I just tried cancelling TDD that way in the sandbox but the meeting agenda isn't loading so I can't tell if that is supposed to work or not.
Yes, cancelling sessions is different than cancelling sreqs.
sandbox will be fixed shortly.
I see, I'd gone down the wrong path. I'll detach that PR and deal with this separately

Resolving patch:
diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py
--- a/ietf/meeting/forms.py
+++ b/ietf/meeting/forms.py
@@ -773,6 +773,13 @@ def __init__(self, instance, *args, **kwargs):
         super().__init__(instance=instance, group=instance.group, *args, **kwargs)
 
 
+class SessionCancelForm(forms.Form):
+    confirmed = forms.BooleanField(
+        label='Cancel session?',
+        help_text='Confirm that you want to cancel this session.',
+    )
+
+
 class SessionDetailsInlineFormSet(forms.BaseInlineFormSet):
     def __init__(self, group, meeting, queryset=None, *args, **kwargs):
         self._meeting = meeting
diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py
--- a/ietf/meeting/urls.py
+++ b/ietf/meeting/urls.py
@@ -129,6 +129,7 @@ def get_redirect_url(self, *args, **kwargs):
     url(r'^upcoming\.ics/?$', views.upcoming_ical),
     url(r'^upcoming\.json/?$', views.upcoming_json),
     url(r'^session/(?P<session_id>\d+)/agenda_materials$', views.session_materials),
+    url(r'^session/(?P<session_id>\d+)/cancel/?', views.cancel_session),
     url(r'^session/(?P<session_id>\d+)/edit/?', views.edit_session),
     # Then patterns from more specific to less
     url(r'^(?P<num>interim-[a-z0-9-]+)/', include(type_interim_patterns)),
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -56,7 +56,7 @@
 from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission
 from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName
 from ietf.meeting.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm, ImportMinutesForm,
-                                 TimeSlotCreateForm, TimeSlotEditForm, SessionEditForm )
+                                 TimeSlotCreateForm, TimeSlotEditForm, SessionCancelForm, SessionEditForm )
 from ietf.meeting.helpers import get_person_by_email, get_schedule_by_name
 from ietf.meeting.helpers import get_meeting, get_ietf_meeting, get_current_ietf_meeting_num
 from ietf.meeting.helpers import get_schedule, schedule_permissions
@@ -4122,6 +4122,46 @@ def edit_session(request, session_id):
         {'session': session, 'form': form},
     )
 
+def _schedule_edit_url(meeting, schedule):
+    """Get the preferred URL to edit a schedule
+
+    Returns a link to the official schedule if schedule is None
+    """
+    url_args = {'num': meeting.number}
+    if schedule and not schedule.is_official:
+        url_args.update({
+            'name': schedule.name if schedule and not schedule.is_official else None,
+            'owner': schedule.owner_email() if schedule and not schedule.is_official else None,
+        })
+    return reverse('ietf.meeting.views.edit_meeting_schedule', kwargs=url_args)
+
+@role_required('Secretariat')
+def cancel_session(request, session_id):
+    session = get_object_or_404(Session.objects.with_current_status(), pk=session_id)
+    schedule = Schedule.objects.filter(pk=request.GET.get('sched', None)).first()
+    editor_url = _schedule_edit_url(session.meeting, schedule)
+    if session.current_status in Session.CANCELED_STATUSES:
+        messages.info(request, 'Session is already canceled.')
+        return HttpResponseRedirect(editor_url)
+    if request.method == 'POST':
+        form = SessionCancelForm(data=request.POST)
+        if form.is_valid():
+            SchedulingEvent.objects.create(
+                session=session,
+                status_id='canceled',
+                by=request.user.person,
+            )
+            messages.success(request, 'Session canceled.')
+            return HttpResponseRedirect(editor_url)
+    else:
+        form = SessionCancelForm()
+    return render(
+        request,
+        'meeting/cancel_session.html',
+        {'session': session, 'form': form, 'editor_url': editor_url},
+    )
+
+
 @role_required('Secretariat')
 def request_minutes(request, num=None):
     meeting = get_ietf_meeting(num)


Test patch:
diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py
--- a/ietf/meeting/tests_views.py
+++ b/ietf/meeting/tests_views.py
@@ -3109,6 +3109,19 @@ def test_edit_meeting_schedule(self):
         for s in [s1, s2]:
             e = q("#session{}".format(s.pk))
 
+            # should be link to edit/cancel session
+            self.assertTrue(
+                e.find('a[href="{}"]'.format(
+                    urlreverse('ietf.meeting.views.edit_session', kwargs={'session_id': s.pk}),
+                ))
+            )
+            self.assertTrue(
+                e.find('a[href="{}?sched={}"]'.format(
+                    urlreverse('ietf.meeting.views.cancel_session', kwargs={'session_id': s.pk}),
+                    meeting.schedule.pk,
+                ))
+            )
+
             # info in the item representing the session that can be moved around
             self.assertIn(s.group.acronym, e.find(".session-label").text())
             if s.comments:
@@ -3697,6 +3710,54 @@ def test_edit_session(self):
         self.assertEqual(session.attendees, 103)
         self.assertEqual(session.comments, 'So much to say')
 
+    def test_cancel_session(self):
+        # session for testing with official schedule
+        session = SessionFactory(meeting__type_id='ietf')
+        url = urlreverse('ietf.meeting.views.cancel_session', kwargs={'session_id': session.pk})
+        return_url = urlreverse('ietf.meeting.views.edit_meeting_schedule', kwargs={'num': session.meeting.number})
+        # session for testing with unofficial schedule
+        other_session = SessionFactory(meeting=session.meeting)
+        unofficial_schedule = ScheduleFactory(meeting=other_session.meeting)
+        url_unofficial = urlreverse(
+            'ietf.meeting.views.cancel_session',
+            kwargs={'session_id': other_session.pk},
+        ) + f'?sched={unofficial_schedule.pk}'
+        return_url_unofficial = urlreverse(
+            'ietf.meeting.views.edit_meeting_schedule',
+            kwargs={
+                'num': other_session.meeting.number,
+                'name': unofficial_schedule.name,
+                'owner': unofficial_schedule.owner_email(),
+            },
+        )
+
+        login_testing_unauthorized(self, 'secretary', url)
+        r = self.client.get(url)
+        self.assertContains(r, 'Cancel session', status_code=200)
+        self.assertIn(return_url, r.content.decode())
+        r = self.client.get(url_unofficial)
+        self.assertContains(r, 'Cancel session', status_code=200)
+        self.assertIn(return_url_unofficial, r.content.decode())
+
+        r = self.client.post(url, {})
+        self.assertFormError(r, 'form', 'confirmed', 'This field is required.')
+        r = self.client.post(url_unofficial, {})
+        self.assertFormError(r, 'form', 'confirmed', 'This field is required.')
+
+        r = self.client.post(url, {'confirmed': 'on'})
+        self.assertRedirects(r, return_url)
+        session = Session.objects.with_current_status().get(pk=session.pk)
+        self.assertEqual(session.current_status, 'canceled')
+        r = self.client.get(url)
+        self.assertRedirects(r, return_url)  # should redirect immediately when session is already canceled
+
+        r = self.client.post(url_unofficial, {'confirmed': 'on'})
+        self.assertRedirects(r, return_url_unofficial)
+        other_session = Session.objects.with_current_status().get(pk=other_session.pk)
+        self.assertEqual(other_session.current_status, 'canceled')
+        r = self.client.get(url_unofficial)
+        self.assertRedirects(r, return_url_unofficial)  # should redirect immediately when session is already canceled
+
     def test_edit_timeslots(self):
         meeting = make_meeting_test_data()