From 79971e14c7e55d9908ffd9ede2aa2e73893be076 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 1 Dec 2020 17:37:09 +0000 Subject: [PATCH] Allow cancellation of individual sessions of multi-session interim meeting. Fixes #2959. Commit ready for merge. - Legacy-Id: 18724 --- ietf/meeting/ajax.py | 8 +- ietf/meeting/helpers.py | 41 +- ietf/meeting/models.py | 71 ++- ietf/meeting/tests_views.py | 584 ++++++++++++++++-- ietf/meeting/urls.py | 1 + ietf/meeting/utils.py | 44 +- ietf/meeting/views.py | 103 ++- ietf/secr/meetings/views.py | 5 +- .../meeting/interim_announcement.txt | 4 +- ietf/templates/meeting/interim_info.txt | 2 +- ...> interim_meeting_cancellation_notice.txt} | 0 .../meeting/interim_request_cancel.html | 7 +- .../meeting/interim_request_details.html | 29 +- .../interim_session_cancellation_notice.txt | 7 + 14 files changed, 789 insertions(+), 117 deletions(-) rename ietf/templates/meeting/{interim_cancellation_notice.txt => interim_meeting_cancellation_notice.txt} (100%) create mode 100644 ietf/templates/meeting/interim_session_cancellation_notice.txt diff --git a/ietf/meeting/ajax.py b/ietf/meeting/ajax.py index 44e838deb..d58d7a528 100644 --- a/ietf/meeting/ajax.py +++ b/ietf/meeting/ajax.py @@ -11,8 +11,6 @@ from ietf.ietfauth.utils import role_required, has_role from ietf.meeting.helpers import get_meeting, get_schedule, schedule_permissions, get_person_by_email, get_schedule_by_name from ietf.meeting.models import TimeSlot, Session, Schedule, Room, Constraint, SchedTimeSessAssignment, ResourceAssociation from ietf.meeting.views import edit_timeslots, edit_schedule -from ietf.meeting.utils import only_sessions_that_can_meet -from ietf.meeting.utils import add_event_info_to_session_qs import debug # pyflakes:ignore @@ -433,11 +431,7 @@ def session_json(request, num, sessionid): def sessions_json(request, num): meeting = get_meeting(num) - sessions = add_event_info_to_session_qs( - only_sessions_that_can_meet(meeting.session_set), - requested_time=True, - requested_by=True, - ) + sessions = meeting.session_set.that_can_meet().with_requested_time().with_requested_by() sess1_dict = [ x.json_dict(request.build_absolute_uri('/')) for x in sessions ] return HttpResponse(json.dumps(sess1_dict, sort_keys=True, indent=2), diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py index db766c5ab..d7a3a2a8c 100644 --- a/ietf/meeting/helpers.py +++ b/ietf/meeting/helpers.py @@ -467,7 +467,8 @@ def get_announcement_initial(meeting, is_change=False): type = 'BOF' assignments = SchedTimeSessAssignment.objects.filter( - schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None] + schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None], + session__in=meeting.session_set.not_canceled() ).order_by('timeslot__time') initial['subject'] = '{name} ({acronym}) {type} {desc} Meeting: {date}{change}'.format( @@ -614,7 +615,7 @@ def send_interim_announcement_request(meeting): context, cc_list) -def send_interim_cancellation_notice(meeting): +def send_interim_meeting_cancellation_notice(meeting): """Sends an email that a scheduled interim meeting has been cancelled.""" session = meeting.session_set.first() group = session.group @@ -627,9 +628,39 @@ def send_interim_cancellation_notice(meeting): date=meeting.date.strftime('%Y-%m-%d')) start_time = session.official_timeslotassignment().timeslot.time end_time = start_time + session.requested_duration - from ietf.meeting.utils import add_event_info_to_session_qs - is_multi_day = add_event_info_to_session_qs(meeting.session_set.all()).filter(current_status='sched').count() > 1 - template = 'meeting/interim_cancellation_notice.txt' + is_multi_day = session.meeting.session_set.with_current_status().filter(current_status='sched').count() > 1 + template = 'meeting/interim_meeting_cancellation_notice.txt' + context = locals() + send_mail(None, + to_email, + from_email, + subject, + template, + context, + cc=cc_list) + + +def send_interim_session_cancellation_notice(session): + """Sends an email that one session of a scheduled interim meeting has been cancelled.""" + group = session.group + start_time = session.official_timeslotassignment().timeslot.time + end_time = start_time + session.requested_duration + (to_email, cc_list) = gather_address_lists('interim_cancelled',group=group) + from_email = settings.INTERIM_ANNOUNCE_FROM_EMAIL_PROGRAM if group.type_id=='program' else settings.INTERIM_ANNOUNCE_FROM_EMAIL_DEFAULT + + if session.name: + description = '"%s" session' % session.name + else: + description = 'interim meeting session' + + subject = '{group} ({acronym}) {type} {description} cancelled (was {date})'.format( + group=group.name, + acronym=group.acronym, + type=group.type.slug.upper(), + description=description, + date=start_time.date().strftime('%Y-%m-%d')) + is_multi_day = session.meeting.session_set.with_current_status().filter(current_status='sched').count() > 1 + template = 'meeting/interim_session_cancellation_notice.txt' context = locals() send_mail(None, to_email, diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index dd3a976a7..6a49a0590 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -17,7 +17,8 @@ import debug # pyflakes:ignore from django.core.validators import MinValueValidator, RegexValidator from django.db import models -from django.db.models import Max +from django.db.models import Max, Subquery, OuterRef, TextField, Value +from django.db.models.functions import Coalesce from django.conf import settings # mostly used by json_dict() #from django.template.defaultfilters import slugify, date as date_format, time as time_format @@ -924,12 +925,78 @@ class SessionPresentation(models.Model): constraint_cache_uses = 0 constraint_cache_initials = 0 +class SessionQuerySet(models.QuerySet): + def with_current_status(self): + """Annotate session with its current status + + Adds current_status, containing the text representation of the status. + """ + return self.annotate( + # coalesce with '' to avoid nulls which give funny + # results, e.g. .exclude(current_status='canceled') also + # skips rows with null in them + current_status=Coalesce( + Subquery( + SchedulingEvent.objects.filter( + session=OuterRef('pk') + ).order_by( + '-time', '-id' + ).values('status')[:1]), + Value(''), + output_field=TextField()), + ) + + def with_requested_by(self): + """Annotate session with requested_by field + + Adds requested_by field - pk of the Person who made the request + """ + return self.annotate( + requested_by=Subquery( + SchedulingEvent.objects.filter( + session=OuterRef('pk') + ).order_by( + 'time', 'id' + ).values('by')[:1]), + ) + + def with_requested_time(self): + """Annotate session with requested_time field""" + return self.annotate( + requested_time=Subquery( + SchedulingEvent.objects.filter( + session=OuterRef('pk') + ).order_by( + 'time', 'id' + ).values('time')[:1]), + ) + + def not_canceled(self): + """Queryset containing all sessions not canceled + + Results annotated with current_status + """ + return self.with_current_status().exclude(current_status__in=Session.CANCELED_STATUSES) + + def that_can_meet(self): + """Queryset containing sessions that can meet + + Results annotated with current_status + """ + return self.with_current_status().exclude( + current_status__in=['notmeet', 'disappr', 'deleted', 'apprw'] + ).filter( + type__slug='regular' + ) + + class Session(models.Model): """Session records that a group should have a session on the meeting (time and location is stored in a TimeSlot) - if multiple timeslots are needed, multiple sessions will have to be created. Training sessions and similar are modeled by filling in a responsible group (e.g. Edu team) and filling in the name.""" + objects = SessionQuerySet.as_manager() # sets default query manager meeting = ForeignKey(Meeting) name = models.CharField(blank=True, max_length=255, help_text="Name of session, in case the session has a purpose rather than just being a group meeting.") short = models.CharField(blank=True, max_length=32, help_text="Short version of 'name' above, for use in filenames.") @@ -951,6 +1018,8 @@ class Session(models.Model): unique_constraints_dict = None + CANCELED_STATUSES = ['canceled', 'canceledpa'] + # Should work on how materials are captured so that deleted things are no longer associated with the session # (We can keep the information about something being added to and removed from a session in the document's history) def get_material(self, material_type, only_one): diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index d965106da..a3e9e0a91 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -33,7 +33,7 @@ from ietf.group.utils import can_manage_group from ietf.person.models import Person from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_request 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_meeting_cancellation_notice, send_interim_session_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, Room, Constraint, ConstraintName from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data @@ -1934,67 +1934,288 @@ class InterimTests(TestCase): # test_interim_announce subsumed by test_appears_on_announce - def test_interim_skip_announcement(self): + def do_interim_skip_announcement_test(self, base_session=False, extra_session=False, canceled_session=False): make_meeting_test_data() group = Group.objects.get(acronym='irg') date = datetime.date.today() + datetime.timedelta(days=30) meeting = make_interim_meeting(group=group, date=date, status='scheda') + session = meeting.session_set.first() + if base_session: + base_session = SessionFactory(meeting=meeting, status_id='apprw', add_to_schedule=False) + meeting.schedule.base = Schedule.objects.create( + meeting=meeting, owner=PersonFactory(), name="base", visible=True, public=True + ) + SchedTimeSessAssignment.objects.create( + timeslot=TimeSlotFactory.create(meeting=meeting), + session=base_session, + schedule=meeting.schedule.base, + ) + meeting.schedule.save() + if extra_session: + extra_session = SessionFactory(meeting=meeting, status_id='scheda') + if canceled_session: + canceled_session = SessionFactory(meeting=meeting, status_id='canceledpa') url = urlreverse("ietf.meeting.views.interim_skip_announcement", kwargs={'number': meeting.number}) login_testing_unauthorized(self, "secretary", url) r = self.client.get(url) self.assertEqual(r.status_code, 200) - + # check post len_before = len(outbox) r = self.client.post(url) self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_announce')) - self.assertEqual(add_event_info_to_session_qs(meeting.session_set).first().current_status, 'sched') + meeting_sessions = meeting.session_set.with_current_status() + self.assertEqual(meeting_sessions.get(pk=session.pk).current_status, 'sched') + if base_session: + self.assertEqual(meeting_sessions.get(pk=base_session.pk).current_status, 'sched') + if extra_session: + self.assertEqual(meeting_sessions.get(pk=extra_session.pk).current_status, 'sched') + if canceled_session: + self.assertEqual(meeting_sessions.get(pk=canceled_session.pk).current_status, 'canceledpa') self.assertEqual(len(outbox), len_before) - - def test_interim_send_announcement(self): + + def test_interim_skip_announcement(self): + """skip_announcement should move single session to sched state""" + self.do_interim_skip_announcement_test() + + def test_interim_skip_announcement_with_base_sched(self): + """skip_announcement should move single session to sched state""" + self.do_interim_skip_announcement_test(base_session=True) + + def test_interim_skip_announcement_with_extra_session(self): + """skip_announcement should move multiple sessions to sched state""" + self.do_interim_skip_announcement_test(extra_session=True) + + def test_interim_skip_announcement_with_extra_session_and_base_sched(self): + """skip_announcement should move multiple sessions to sched state""" + self.do_interim_skip_announcement_test(extra_session=True, base_session=True) + + def test_interim_skip_announcement_with_canceled_session(self): + """skip_announcement should not schedule a canceled session""" + self.do_interim_skip_announcement_test(canceled_session=True) + + def test_interim_skip_announcement_with_canceled_session_and_base_sched(self): + """skip_announcement should not schedule a canceled session""" + self.do_interim_skip_announcement_test(canceled_session=True, base_session=True) + + def test_interim_skip_announcement_with_extra_and_canceled_sessions(self): + """skip_announcement should schedule multiple sessions and leave canceled session alone""" + self.do_interim_skip_announcement_test(extra_session=True, canceled_session=True) + + def test_interim_skip_announcement_with_extra_and_canceled_sessions_and_base_sched(self): + """skip_announcement should schedule multiple sessions and leave canceled session alone""" + self.do_interim_skip_announcement_test(extra_session=True, canceled_session=True, base_session=True) + + def do_interim_send_announcement_test(self, base_session=False, extra_session=False, canceled_session=False): make_interim_test_data() - meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='apprw').first().meeting + session = Session.objects.with_current_status().filter( + meeting__type='interim', group__acronym='mars', current_status='apprw').first() + meeting = session.meeting meeting.time_zone = 'America/Los_Angeles' meeting.save() + if base_session: + base_session = SessionFactory(meeting=meeting, status_id='apprw', add_to_schedule=False) + meeting.schedule.base = Schedule.objects.create( + meeting=meeting, owner=PersonFactory(), name="base", visible=True, public=True + ) + SchedTimeSessAssignment.objects.create( + timeslot=TimeSlotFactory.create(meeting=meeting), + session=base_session, + schedule=meeting.schedule.base, + ) + meeting.schedule.save() + if extra_session: + extra_session = SessionFactory(meeting=meeting, status_id='apprw') + if canceled_session: + canceled_session = SessionFactory(meeting=meeting, status_id='canceledpa') + url = urlreverse("ietf.meeting.views.interim_send_announcement", kwargs={'number': meeting.number}) login_testing_unauthorized(self, "secretary", url) r = self.client.get(url) self.assertEqual(r.status_code, 200) initial = r.context['form'].initial + # send announcement len_before = len(outbox) r = self.client.post(url, initial) self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_announce')) self.assertEqual(len(outbox), len_before + 1) - self.assertIn('WG Virtual Meeting', outbox[-1]['Subject']) - self.assertIn('09:00 to 09:20 America/Los_Angeles', get_payload_text(outbox[-1])) + announcement_msg = outbox[-1] + announcement_text = get_payload_text(announcement_msg) + self.assertIn('WG Virtual Meeting', announcement_msg['Subject']) + self.assertIn('09:00 to 09:20 America/Los_Angeles', announcement_text) + for sess in [session, base_session, extra_session]: + if sess: + timeslot = sess.official_timeslotassignment().timeslot + self.assertIn(timeslot.time.strftime('%Y-%m-%d'), announcement_text) + self.assertIn( + '(%s to %s UTC)' % ( + timeslot.utc_start_time().strftime('%H:%M'),timeslot.utc_end_time().strftime('%H:%M') + ), announcement_text) + # Count number of sessions listed + if base_session and extra_session: + expected_session_matches = 3 + elif base_session or extra_session: + expected_session_matches = 2 + else: + expected_session_matches = 0 # no session list when only one session + session_matches = re.findall(r'Session \d+:', announcement_text) + self.assertEqual(len(session_matches), expected_session_matches) - timeslot = meeting.session_set.first().official_timeslotassignment().timeslot - self.assertIn('(%s to %s UTC)'%(timeslot.utc_start_time().strftime('%H:%M'),timeslot.utc_end_time().strftime('%H:%M')), get_payload_text(outbox[-1])) + meeting_sessions = meeting.session_set.with_current_status() + self.assertEqual(meeting_sessions.get(pk=session.pk).current_status, 'sched') + if base_session: + self.assertEqual(meeting_sessions.get(pk=base_session.pk).current_status, 'sched') + if extra_session: + self.assertEqual(meeting_sessions.get(pk=extra_session.pk).current_status, 'sched') + if canceled_session: + self.assertEqual(meeting_sessions.get(pk=canceled_session.pk).current_status, 'canceledpa') - def test_interim_approve_by_ad(self): + def test_interim_send_announcement(self): + self.do_interim_send_announcement_test() + + def test_interim_send_announcement_with_base_sched(self): + self.do_interim_send_announcement_test(base_session=True) + + def test_interim_send_announcement_with_extra_session(self): + self.do_interim_send_announcement_test(extra_session=True) + + def test_interim_send_announcement_with_extra_session_and_base_sched(self): + self.do_interim_send_announcement_test(extra_session=True, base_session=True) + + def test_interim_send_announcement_with_canceled_session(self): + self.do_interim_send_announcement_test(canceled_session=True) + + def test_interim_send_announcement_with_canceled_session_and_base_sched(self): + self.do_interim_send_announcement_test(canceled_session=True, base_session=True) + + def test_interim_send_announcement_with_extra_and_canceled_sessions(self): + self.do_interim_send_announcement_test(extra_session=True, canceled_session=True) + + def test_interim_send_announcement_with_extra_and_canceled_sessions_and_base_sched(self): + self.do_interim_send_announcement_test(extra_session=True, canceled_session=True, base_session=True) + + def do_interim_approve_by_ad_test(self, base_session=False, extra_session=False, canceled_session=False): make_interim_test_data() - meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='apprw').first().meeting + session = Session.objects.with_current_status().filter( + meeting__type='interim', group__acronym='mars', current_status='apprw').first() + meeting = session.meeting + + if base_session: + base_session = SessionFactory(meeting=meeting, status_id='apprw', add_to_schedule=False) + meeting.schedule.base = Schedule.objects.create( + meeting=meeting, owner=PersonFactory(), name="base", visible=True, public=True + ) + SchedTimeSessAssignment.objects.create( + timeslot=TimeSlotFactory.create(meeting=meeting), + session=base_session, + schedule=meeting.schedule.base, + ) + meeting.schedule.save() + if extra_session: + extra_session = SessionFactory(meeting=meeting, status_id='apprw') + if canceled_session: + canceled_session = SessionFactory(meeting=meeting, status_id='canceledpa') + url = urlreverse('ietf.meeting.views.interim_request_details', kwargs={'number': meeting.number}) length_before = len(outbox) login_testing_unauthorized(self, "ad", url) r = self.client.post(url, {'approve': 'approve'}) self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_pending')) - for session in add_event_info_to_session_qs(meeting.session_set.all()): - self.assertEqual(session.current_status, 'scheda') + + for sess in [session, base_session, extra_session]: + if sess: + self.assertEqual(Session.objects.with_current_status().get(pk=sess.pk).current_status, + 'scheda') + if canceled_session: + self.assertEqual(Session.objects.with_current_status().get(pk=canceled_session.pk).current_status, + 'canceledpa') self.assertEqual(len(outbox), length_before + 1) self.assertIn('ready for announcement', outbox[-1]['Subject']) - def test_interim_approve_by_secretariat(self): + def test_interim_approve_by_ad(self): + self.do_interim_approve_by_ad_test() + + def test_interim_approve_by_ad_with_base_sched(self): + self.do_interim_approve_by_ad_test(base_session=True) + + def test_interim_approve_by_ad_with_extra_session(self): + self.do_interim_approve_by_ad_test(extra_session=True) + + def test_interim_approve_by_ad_with_extra_session_and_base_sched(self): + self.do_interim_approve_by_ad_test(extra_session=True, base_session=True) + + def test_interim_approve_by_ad_with_canceled_session(self): + self.do_interim_approve_by_ad_test(canceled_session=True) + + def test_interim_approve_by_ad_with_canceled_session_and_base_sched(self): + self.do_interim_approve_by_ad_test(canceled_session=True, base_session=True) + + def test_interim_approve_by_ad_with_extra_and_canceled_sessions(self): + self.do_interim_approve_by_ad_test(extra_session=True, canceled_session=True) + + def test_interim_approve_by_ad_with_extra_and_canceled_sessions_and_base_sched(self): + self.do_interim_approve_by_ad_test(extra_session=True, canceled_session=True, base_session=True) + + def do_interim_approve_by_secretariat_test(self, base_session=False, extra_session=False, canceled_session=False): make_interim_test_data() - meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='apprw').first().meeting + session = Session.objects.with_current_status().filter( + meeting__type='interim', group__acronym='mars', current_status='apprw').first() + meeting = session.meeting + if base_session: + base_session = SessionFactory(meeting=meeting, status_id='apprw', add_to_schedule=False) + meeting.schedule.base = Schedule.objects.create( + meeting=meeting, owner=PersonFactory(), name="base", visible=True, public=True + ) + SchedTimeSessAssignment.objects.create( + timeslot=TimeSlotFactory.create(meeting=meeting), + session=base_session, + schedule=meeting.schedule.base, + ) + meeting.schedule.save() + if extra_session: + extra_session = SessionFactory(meeting=meeting, status_id='apprw') + if canceled_session: + canceled_session = SessionFactory(meeting=meeting, status_id='canceledpa') + url = urlreverse('ietf.meeting.views.interim_request_details', kwargs={'number': meeting.number}) + length_before = len(outbox) login_testing_unauthorized(self, "secretary", url) r = self.client.post(url, {'approve': 'approve'}) self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_send_announcement', kwargs={'number': meeting.number})) - for session in add_event_info_to_session_qs(meeting.session_set.all()): - self.assertEqual(session.current_status, 'scheda') + for sess in [session, base_session, extra_session]: + if sess: + self.assertEqual(Session.objects.with_current_status().get(pk=sess.pk).current_status, + 'scheda') + if canceled_session: + self.assertEqual(Session.objects.with_current_status().get(pk=canceled_session.pk).current_status, + 'canceledpa') + self.assertEqual(len(outbox), length_before) + + def test_interim_approve_by_secretariat(self): + self.do_interim_approve_by_secretariat_test() + + def test_interim_approve_by_secretariat_with_base_sched(self): + self.do_interim_approve_by_secretariat_test(base_session=True) + + def test_interim_approve_by_secretariat_with_extra_session(self): + self.do_interim_approve_by_secretariat_test(extra_session=True) + + def test_interim_approve_by_secretariat_with_extra_session_and_base_sched(self): + self.do_interim_approve_by_secretariat_test(extra_session=True, base_session=True) + + def test_interim_approve_by_secretariat_with_canceled_session(self): + self.do_interim_approve_by_secretariat_test(canceled_session=True) + + def test_interim_approve_by_secretariat_with_canceled_session_and_base_sched(self): + self.do_interim_approve_by_secretariat_test(canceled_session=True, base_session=True) + + def test_interim_approve_by_secretariat_with_extra_and_canceled_sessions(self): + self.do_interim_approve_by_secretariat_test(extra_session=True, canceled_session=True) + + def test_interim_approve_by_secretariat_with_extra_and_canceled_sessions_and_base_sched(self): + self.do_interim_approve_by_secretariat_test(extra_session=True, canceled_session=True, base_session=True) def test_past(self): today = datetime.date.today() @@ -2387,6 +2608,10 @@ class InterimTests(TestCase): meeting_count_before = Meeting.objects.filter(type='interim').count() date = datetime.date.today() + datetime.timedelta(days=30) date2 = date + datetime.timedelta(days=1) + # ensure dates are in the same year + if date.year != date2.year: + date += datetime.timedelta(days=1) + date2 += datetime.timedelta(days=1) time = datetime.datetime.now().time().replace(microsecond=0,second=0) dt = datetime.datetime.combine(date, time) dt2 = datetime.datetime.combine(date2, time) @@ -2536,7 +2761,8 @@ class InterimTests(TestCase): def test_interim_request_details(self): make_interim_test_data() - meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='apprw').first().meeting + meeting = Session.objects.with_current_status().filter( + meeting__type='interim', group__acronym='mars', current_status='apprw').first().meeting url = urlreverse('ietf.meeting.views.interim_request_details',kwargs={'number':meeting.number}) login_testing_unauthorized(self,"secretary",url) r = self.client.get(url) @@ -2568,51 +2794,317 @@ class InterimTests(TestCase): q = PyQuery(r.content) self.assertEqual(len(q("a.btn:contains('Announce')")),2) - def test_interim_request_disapprove(self): + def test_interim_request_details_cancel(self): + """Test access to cancel meeting / session features""" make_interim_test_data() - meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='apprw').first().meeting + mars_sessions = Session.objects.with_current_status( + ).filter( + meeting__type='interim', + group__acronym='mars', + ) + meeting_apprw = mars_sessions.filter(current_status='apprw').first().meeting + meeting_sched = mars_sessions.filter(current_status='sched').first().meeting + # All these roles should have access to cancel the request + usernames_and_passwords = ( + ('marschairman', 'marschairman+password'), + ('secretary', 'secretary+password') + ) + + # Start with one session - there should not be any cancel session buttons + for meeting in (meeting_apprw, meeting_sched): + url = urlreverse('ietf.meeting.views.interim_request_details', + kwargs={'number': meeting.number}) + + for username, password in usernames_and_passwords: + self.client.login(username=username, password=password) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + + cancel_meeting_btns = q("a.btn:contains('Cancel Meeting')") + self.assertEqual(len(cancel_meeting_btns), 1, + 'Should be exactly one cancel meeting button for user %s' % username) + self.assertEqual(cancel_meeting_btns.eq(0).attr('href'), + urlreverse('ietf.meeting.views.interim_request_cancel', + kwargs={'number': meeting.number}), + 'Cancel meeting points to wrong URL') + + self.assertEqual(len(q("a.btn:contains('Cancel Session')")), 0, + 'Should be no cancel session buttons for user %s' % username) + + # Add a second session + SessionFactory(meeting=meeting_apprw, status_id='apprw') + SessionFactory(meeting=meeting_sched, status_id='sched') + + for meeting in (meeting_apprw, meeting_sched): + url = urlreverse('ietf.meeting.views.interim_request_details', + kwargs={'number': meeting.number}) + + for username, password in usernames_and_passwords: + self.client.login(username=username, password=password) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + cancel_meeting_btns = q("a.btn:contains('Cancel Meeting')") + self.assertEqual(len(cancel_meeting_btns), 1, + 'Should be exactly one cancel meeting button for user %s' % username) + self.assertEqual(cancel_meeting_btns.eq(0).attr('href'), + urlreverse('ietf.meeting.views.interim_request_cancel', + kwargs={'number': meeting.number}), + 'Cancel meeting button points to wrong URL') + + cancel_session_btns = q("a.btn:contains('Cancel Session')") + self.assertEqual(len(cancel_session_btns), 2, + 'Should be two cancel session buttons for user %s' % username) + hrefs = [btn.attr('href') for btn in cancel_session_btns.items()] + for index, session in enumerate(meeting.session_set.all()): + self.assertIn(urlreverse('ietf.meeting.views.interim_request_session_cancel', + kwargs={'sessionid': session.pk}), + hrefs, + 'Session missing a link to its cancel URL') + + def test_interim_request_details_status(self): + """Test statuses on the interim request details page""" + make_interim_test_data() + some_person = PersonFactory() + self.client.login(username='marschairman', password='marschairman+password') + # These are the first sessions for each meeting - hang on to them + sessions = list( + Session.objects.with_current_status().filter(meeting__type='interim', group__acronym='mars') + ) + + # Hack: change the name for the 'canceled' session status so we can tell it apart + # from the 'canceledpa' session status more easily + canceled_status = SessionStatusName.objects.get(slug='canceled') + canceled_status.name = 'This is cancelled' + canceled_status.save() + canceledpa_status = SessionStatusName.objects.get(slug='canceledpa') + notmeet_status = SessionStatusName.objects.get(slug='notmeet') + + # Simplest case - single session for each meeting + for session in [Session.objects.with_current_status().get(pk=s.pk) for s in sessions]: + url = urlreverse('ietf.meeting.views.interim_request_details', + kwargs={'number': session.meeting.number}) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + status = SessionStatusName.objects.get(slug=session.current_status) + self.assertEqual( + len(q("dd:contains('%s')" % status.name)), + 1 # once - for the meeting status, no session status shown when only one session + ) + + # Now add a second session with a different status - it should not change meeting status + for session in [Session.objects.with_current_status().get(pk=s.pk) for s in sessions]: + SessionFactory(meeting=session.meeting, status_id=notmeet_status.pk) + url = urlreverse('ietf.meeting.views.interim_request_details', + kwargs={'number': session.meeting.number}) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + status = SessionStatusName.objects.get(slug=session.current_status) + self.assertEqual( + len(q("dd:contains('%s')" % status.name)), + 2 # twice - once as the meeting status, once as the session status + ) + self.assertEqual( + len(q("dd:contains('%s')" % notmeet_status.name)), + 1 # only for the session status + ) + + # Now cancel the first session - second meeting status should be shown for meeting + for session in [Session.objects.with_current_status().get(pk=s.pk) for s in sessions]: + # Use 'canceledpa' here and 'canceled' later + SchedulingEvent.objects.create(session=session, + status=canceledpa_status, + by=some_person) + url = urlreverse('ietf.meeting.views.interim_request_details', + kwargs={'number': session.meeting.number}) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual( + len(q("dd:contains('%s')" % canceledpa_status.name)), + 1 # only for the session status + ) + self.assertEqual( + len(q("dd:contains('%s')" % notmeet_status.name)), + 2 # twice - once as the meeting status, once as the session status + ) + + # Now cancel the second session - first meeting status should be shown for meeting again + for session in [Session.objects.with_current_status().get(pk=s.pk) for s in sessions]: + second_session = session.meeting.session_set.exclude(pk=session.pk).first() + # use canceled so we can differentiate between the first and second session statuses + SchedulingEvent.objects.create(session=second_session, + status=canceled_status, + by=some_person) + url = urlreverse('ietf.meeting.views.interim_request_details', + kwargs={'number': session.meeting.number}) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual( + len(q("dd:contains('%s')" % canceledpa_status.name)), + 2 # twice - once as the meeting status, once as the session status + ) + self.assertEqual( + len(q("dd:contains('%s')" % canceled_status.name)), + 1 # only as the session status + ) + + def do_interim_request_disapprove_test(self, extra_session=False, canceled_session=False): + make_interim_test_data() + session = Session.objects.with_current_status().filter( + meeting__type='interim', group__acronym='mars', current_status='apprw').first() + meeting = session.meeting + if extra_session: + extra_session = SessionFactory(meeting=meeting, status_id='apprw') + if canceled_session: + canceled_session = SessionFactory(meeting=meeting, status_id='canceledpa') + url = urlreverse('ietf.meeting.views.interim_request_details',kwargs={'number':meeting.number}) login_testing_unauthorized(self,"secretary",url) r = self.client.post(url,{'disapprove':'Disapprove'}) self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_pending')) - for session in add_event_info_to_session_qs(meeting.session_set.all()): - self.assertEqual(session.current_status,'disappr') + for sess in [session, extra_session]: + if sess: + self.assertEqual(Session.objects.with_current_status().get(pk=sess.pk).current_status, + 'disappr') + if canceled_session: + self.assertEqual(Session.objects.with_current_status().get(pk=canceled_session.pk).current_status, + 'canceledpa') + + def test_interim_request_disapprove(self): + self.do_interim_request_disapprove_test() + + def test_interim_request_disapprove_with_extra_session(self): + self.do_interim_request_disapprove_test(extra_session=True) + + def test_interim_request_disapprove_with_canceled_session(self): + self.do_interim_request_disapprove_test(canceled_session=True) + + def test_interim_request_disapprove_with_extra_and_canceled_sessions(self): + self.do_interim_request_disapprove_test(extra_session=True, canceled_session=True) def test_interim_request_cancel(self): + """Test that interim request cancel function works + + Does not test that UI buttons are present, that is handled elsewhere. + """ make_interim_test_data() - meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='apprw').first().meeting - url = urlreverse('ietf.meeting.views.interim_request_details', kwargs={'number': meeting.number}) - self.client.login(username="marschairman", password="marschairman+password") - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - q = PyQuery(r.content) - self.assertEqual(len(q("a.btn:contains('Cancel')")), 1) + meeting = Session.objects.with_current_status( + ).filter( + meeting__type='interim', + group__acronym='mars', + current_status='apprw', + ).first().meeting + # ensure fail unauthorized url = urlreverse('ietf.meeting.views.interim_request_cancel', kwargs={'number': meeting.number}) comments = 'Bob cannot make it' self.client.login(username="ameschairman", password="ameschairman+password") r = self.client.post(url, {'comments': comments}) self.assertEqual(r.status_code, 403) + # test cancelling before announcement self.client.login(username="marschairman", password="marschairman+password") length_before = len(outbox) r = self.client.post(url, {'comments': comments}) self.assertRedirects(r, urlreverse('ietf.meeting.views.upcoming')) - for session in add_event_info_to_session_qs(meeting.session_set.all()): + for session in meeting.session_set.with_current_status(): self.assertEqual(session.current_status,'canceledpa') self.assertEqual(session.agenda_note, comments) self.assertEqual(len(outbox), length_before) # no email notice + # test cancelling after announcement meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='sched').first().meeting url = urlreverse('ietf.meeting.views.interim_request_cancel', kwargs={'number': meeting.number}) r = self.client.post(url, {'comments': comments}) self.assertRedirects(r, urlreverse('ietf.meeting.views.upcoming')) - for session in add_event_info_to_session_qs(meeting.session_set.all()): + for session in meeting.session_set.with_current_status(): self.assertEqual(session.current_status,'canceled') self.assertEqual(session.agenda_note, comments) self.assertEqual(len(outbox), length_before + 1) self.assertIn('Interim Meeting Cancelled', outbox[-1]['Subject']) + def test_interim_request_session_cancel(self): + """Test that interim meeting session cancellation functions + + Does not test that UI buttons are present, that is handled elsewhere. + """ + make_interim_test_data() + session = Session.objects.with_current_status().filter( + meeting__type='interim', group__acronym='mars', current_status='apprw',).first() + meeting = session.meeting + comments = 'Bob cannot make it' + + # Should not be able to cancel when there is only one session + self.client.login(username="marschairman", password="marschairman+password") + url = urlreverse('ietf.meeting.views.interim_request_session_cancel', kwargs={'sessionid': session.pk}) + r = self.client.post(url, {'comments': comments}) + self.assertEqual(r.status_code, 409) + + # Add a second session + SessionFactory(meeting=meeting, status_id='apprw') + + # ensure fail unauthorized + url = urlreverse('ietf.meeting.views.interim_request_session_cancel', kwargs={'sessionid': session.pk}) + self.client.login(username="ameschairman", password="ameschairman+password") + r = self.client.post(url, {'comments': comments}) + self.assertEqual(r.status_code, 403) + + # test cancelling before announcement + self.client.login(username="marschairman", password="marschairman+password") + length_before = len(outbox) + canceled_count_before = meeting.session_set.with_current_status().filter( + current_status__in=['canceled', 'canceledpa']).count() + r = self.client.post(url, {'comments': comments}) + self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_request_details', + kwargs={'number': meeting.number})) + # This session should be canceled... + sessions = meeting.session_set.with_current_status() + session = sessions.filter(id=session.pk).first() # reload our session info + self.assertEqual(session.current_status, 'canceledpa') + self.assertEqual(session.agenda_note, comments) + # But others should not - count should have changed by only 1 + self.assertEqual( + sessions.filter(current_status__in=['canceled', 'canceledpa']).count(), + canceled_count_before + 1 + ) + self.assertEqual(len(outbox), length_before) # no email notice + + # test cancelling after announcement + session = Session.objects.with_current_status().filter( + meeting__type='interim', group__acronym='mars', current_status='sched').first() + meeting = session.meeting + + # Try to cancel when there's only one session in the meeting + url = urlreverse('ietf.meeting.views.interim_request_session_cancel', kwargs={'sessionid': session.pk}) + r = self.client.post(url, {'comments': comments}) + self.assertEqual(r.status_code, 409) + + # Add another session + SessionFactory(meeting=meeting, status_id='sched') # two sessions so canceling a session makes sense + + canceled_count_before = meeting.session_set.with_current_status().filter( + current_status__in=['canceled', 'canceledpa']).count() + r = self.client.post(url, {'comments': comments}) + self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_request_details', + kwargs={'number': meeting.number})) + # This session should be canceled... + sessions = meeting.session_set.with_current_status() + session = sessions.filter(id=session.pk).first() # reload our session info + self.assertEqual(session.current_status, 'canceled') + self.assertEqual(session.agenda_note, comments) + # But others should not - count should have changed by only 1 + self.assertEqual( + sessions.filter(current_status__in=['canceled', 'canceledpa']).count(), + canceled_count_before + 1 + ) + self.assertEqual(len(outbox), length_before + 1) # email notice sent + self.assertIn('session cancelled', outbox[-1]['Subject']) + def test_interim_request_edit_no_notice(self): '''Edit a request. No notice should go out if it hasn't been announced yet''' make_interim_test_data() @@ -2716,14 +3208,32 @@ class InterimTests(TestCase): self.assertEqual(len(outbox),length_before+1) self.assertIn('New Interim Meeting Request', outbox[-1]['Subject']) - def test_send_interim_cancellation_notice(self): + def test_send_interim_meeting_cancellation_notice(self): make_interim_test_data() - meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='sched').first().meeting + meeting = Session.objects.with_current_status( + ).filter( + meeting__type='interim', + group__acronym='mars', + current_status='sched', + ).first().meeting length_before = len(outbox) - send_interim_cancellation_notice(meeting=meeting) - self.assertEqual(len(outbox),length_before+1) + send_interim_meeting_cancellation_notice(meeting) + self.assertEqual(len(outbox),length_before + 1) self.assertIn('Interim Meeting Cancelled', outbox[-1]['Subject']) + def test_send_interim_session_cancellation_notice(self): + make_interim_test_data() + session = Session.objects.with_current_status( + ).filter( + meeting__type='interim', + group__acronym='mars', + current_status='sched', + ).first() + length_before = len(outbox) + send_interim_session_cancellation_notice(session) + self.assertEqual(len(outbox), length_before + 1) + self.assertIn('session cancelled', outbox[-1]['Subject']) + def test_send_interim_minutes_reminder(self): make_meeting_test_data() group = Group.objects.get(acronym='mars') diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index c914a5ceb..a8c627dc9 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -118,6 +118,7 @@ urlpatterns = [ url(r'^interim/request/(?P[A-Za-z0-9._+-]+)/?$', views.interim_request_details), url(r'^interim/request/(?P[A-Za-z0-9._+-]+)/edit/?$', views.interim_request_edit), url(r'^interim/request/(?P[A-Za-z0-9._+-]+)/cancel/?$', views.interim_request_cancel), + url(r'^interim/session/(?P[A-Za-z0-9._+-]+)/cancel/?$', views.interim_request_session_cancel), url(r'^interim/pending/?$', views.interim_pending), url(r'^requests.html$', RedirectView.as_view(url='/meeting/requests', permanent=True)), url(r'^past/?$', views.past), diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 27567541b..0ff7115ba 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -12,7 +12,6 @@ from urllib.error import HTTPError from django.conf import settings from django.template.loader import render_to_string -from django.db.models.expressions import Subquery, OuterRef from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -48,7 +47,7 @@ def session_requested_by(session): return None def current_session_status(session): - last_event = SchedulingEvent.objects.filter(session=session).order_by('-time', '-id').first() + last_event = SchedulingEvent.objects.filter(session=session).select_related('status').order_by('-time', '-id').first() if last_event: return last_event.status @@ -220,34 +219,18 @@ def condition_slide_order(session): def add_event_info_to_session_qs(qs, current_status=True, requested_by=False, requested_time=False): """Take a session queryset and add attributes computed from the scheduling events. A queryset is returned and the added attributes - can be further filtered on.""" - from django.db.models import TextField, Value - from django.db.models.functions import Coalesce + can be further filtered on. + + Treat this method as deprecated. Use the SessionQuerySet methods directly, chaining if needed. + """ if current_status: - qs = qs.annotate( - # coalesce with '' to avoid nulls which give funny - # results, e.g. .exclude(current_status='canceled') also - # skips rows with null in them - current_status=Coalesce(Subquery(SchedulingEvent.objects.filter(session=OuterRef('pk')).order_by('-time', '-id').values('status')[:1]), Value(''), output_field=TextField()), - ) + qs = qs.with_current_status() if requested_by: - qs = qs.annotate( - requested_by=Subquery(SchedulingEvent.objects.filter(session=OuterRef('pk')).order_by('time', 'id').values('by')[:1]), - ) + qs = qs.with_requested_by() if requested_time: - qs = qs.annotate( - requested_time=Subquery(SchedulingEvent.objects.filter(session=OuterRef('pk')).order_by('time', 'id').values('time')[:1]), - ) - - return qs - -def only_sessions_that_can_meet(session_qs): - qs = add_event_info_to_session_qs(session_qs).exclude(current_status__in=['notmeet', 'disappr', 'deleted', 'apprw']) - - # Restrict graphical scheduling to meeting requests (Sessions) of type 'regular' for now - qs = qs.filter(type__slug='regular') + qs = qs.with_requested_time() return qs @@ -279,9 +262,14 @@ def data_for_meetings_overview(meetings, interim_status=None): for m in meetings: m.sessions = [] - sessions = add_event_info_to_session_qs( - Session.objects.filter(meeting__in=meetings).order_by('meeting', 'pk') - ).select_related('group', 'group__parent') + sessions = Session.objects.filter( + meeting__in=meetings + ).order_by( + 'meeting', 'pk' + ).with_current_status( + ).select_related( + 'group', 'group__parent' + ) meeting_dict = {m.pk: m for m in meetings} for s in sessions.iterator(): diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 2ddb3d727..8c75c425f 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -72,7 +72,7 @@ from ietf.meeting.helpers import can_view_interim_request, can_approve_interim_r from ietf.meeting.helpers import can_edit_interim_request from ietf.meeting.helpers import can_request_interim_meeting, get_announcement_initial from ietf.meeting.helpers import sessions_post_save, is_interim_meeting_approved -from ietf.meeting.helpers import send_interim_cancellation_notice +from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice from ietf.meeting.helpers import send_interim_approval from ietf.meeting.helpers import send_interim_approval_request from ietf.meeting.helpers import send_interim_announcement_request @@ -2108,7 +2108,7 @@ def get_sessions(num, acronym): if not sessions: sessions = Session.objects.filter(meeting=meeting,short=acronym,type__in=['regular','plenary','other']) - sessions = add_event_info_to_session_qs(sessions) + sessions = sessions.with_current_status() return sorted(sessions, key=lambda s: session_time_for_sorting(s, use_meeting_date=False)) @@ -2142,15 +2142,15 @@ def session_details(request, num, acronym): session.times = [ x.timeslot.utc_start_time() for x in ss ] else: session.times = [ x.timeslot.local_start_time() for x in ss ] - session.cancelled = session.current_status == 'canceled' + session.cancelled = session.current_status in Session.CANCELED_STATUSES session.status = '' elif meeting.type_id=='interim': session.times = [ meeting.date ] - session.cancelled = session.current_status == 'canceled' + session.cancelled = session.current_status in Session.CANCELED_STATUSES session.status = '' else: session.times = [] - session.cancelled = session.current_status == 'canceled' + session.cancelled = session.current_status in Session.CANCELED_STATUSES session.status = status_names.get(session.current_status, session.current_status) session.filtered_artifacts = list(session.sessionpresentation_set.filter(document__type__slug__in=['agenda','minutes','bluesheets'])) @@ -2987,7 +2987,8 @@ def interim_send_announcement(request, number): if form.is_valid(): message = form.save(user=request.user) message.related_groups.add(group) - for session in meeting.session_set.all(): + for session in meeting.session_set.not_canceled(): + SchedulingEvent.objects.create( session=session, status=SessionStatusName.objects.get(slug='sched'), @@ -3012,7 +3013,7 @@ def interim_skip_announcement(request, number): meeting = get_object_or_404(Meeting, number=number) if request.method == 'POST': - for session in meeting.session_set.all(): + for session in meeting.session_set.not_canceled(): SchedulingEvent.objects.create( session=session, status=SessionStatusName.objects.get(slug='sched'), @@ -3156,7 +3157,7 @@ def interim_request_cancel(request, number): was_scheduled = session_status.slug == 'sched' result_status = SessionStatusName.objects.get(slug='canceled' if was_scheduled else 'canceledpa') - for session in meeting.session_set.all(): + for session in meeting.session_set.not_canceled(): SchedulingEvent.objects.create( session=session, status=result_status, @@ -3164,7 +3165,7 @@ def interim_request_cancel(request, number): ) if was_scheduled: - send_interim_cancellation_notice(meeting) + send_interim_meeting_cancellation_notice(meeting) messages.success(request, 'Interim meeting cancelled') return redirect(upcoming) @@ -3178,20 +3179,70 @@ def interim_request_cancel(request, number): }) +@login_required +def interim_request_session_cancel(request, sessionid): + '''View for cancelling an interim meeting request''' + session = get_object_or_404(Session, pk=sessionid) + group = session.group + if not can_manage_group(request.user, group): + permission_denied(request, "You do not have permissions to cancel this session") + session_status = current_session_status(session) + + if request.method == 'POST': + form = InterimCancelForm(request.POST) + if form.is_valid(): + remaining_sessions = session.meeting.session_set.with_current_status().exclude( + current_status__in=['canceled', 'canceledpa'] + ) + if remaining_sessions.count() <= 1: + return HttpResponse('Cannot cancel only remaining session. Cancel the request instead.', + status=409) + + if 'comments' in form.changed_data: + session.agenda_note=form.cleaned_data.get('comments') + session.save() + + was_scheduled = session_status.slug == 'sched' + + result_status = SessionStatusName.objects.get(slug='canceled' if was_scheduled else 'canceledpa') + SchedulingEvent.objects.create( + session=session, + status=result_status, + by=request.user.person, + ) + + if was_scheduled: + send_interim_session_cancellation_notice(session) + + messages.success(request, 'Interim meeting session cancelled') + return redirect(interim_request_details, number=session.meeting.number) + else: + session_time = session.official_timeslotassignment().timeslot.time + form = InterimCancelForm(initial={'group': group.acronym, 'date': session_time.date()}) + + return render(request, "meeting/interim_request_cancel.html", { + "form": form, + "session": session, + "session_status": session_status, + }) + + @login_required def interim_request_details(request, number): '''View details of an interim meeting request''' meeting = get_object_or_404(Meeting, number=number) - group = meeting.session_set.first().group + sessions_not_canceled = meeting.session_set.not_canceled() + first_session = meeting.session_set.first() # first, whether or not canceled + group = first_session.group + if not can_manage_group(request.user, group): permission_denied(request, "You do not have permissions to manage this meeting request") - sessions = meeting.session_set.all() can_edit = can_edit_interim_request(meeting, request.user) can_approve = can_approve_interim_request(meeting, request.user) if request.method == 'POST': if request.POST.get('approve') and can_approve_interim_request(meeting, request.user): - for session in meeting.session_set.all(): + for session in sessions_not_canceled: SchedulingEvent.objects.create( session=session, status=SessionStatusName.objects.get(slug='scheda'), @@ -3204,7 +3255,7 @@ def interim_request_details(request, number): send_interim_announcement_request(meeting) return redirect(interim_pending) if request.POST.get('disapprove') and can_approve_interim_request(meeting, request.user): - for session in meeting.session_set.all(): + for session in sessions_not_canceled: SchedulingEvent.objects.create( session=session, status=SessionStatusName.objects.get(slug='disappr'), @@ -3213,20 +3264,32 @@ def interim_request_details(request, number): messages.success(request, 'Interim meeting disapproved') return redirect(interim_pending) - first_session = sessions.first() - assignments = SchedTimeSessAssignment.objects.filter(schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None]) + # Determine meeting status from non-canceled sessions, if any. + # N.b., meeting_status may be None after either of these code paths, + # though I am not sure what circumstances would cause this. + if sessions_not_canceled.count() > 0: + meeting_status = current_session_status(sessions_not_canceled.first()) + else: + meeting_status = current_session_status(first_session) + + meeting_assignments = SchedTimeSessAssignment.objects.filter( + schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None] + ).select_related( + 'session', 'timeslot' + ) + for ma in meeting_assignments: + ma.status = current_session_status(ma.session) + ma.can_be_canceled = ma.status.slug in ('sched', 'scheda', 'apprw') return render(request, "meeting/interim_request_details.html", { "meeting": meeting, - "sessions": sessions, - "assignments": assignments, - "group": first_session.group, + "meeting_assignments": meeting_assignments, + "group": group, "requester": session_requested_by(first_session), - "session_status": current_session_status(first_session), + "meeting_status": meeting_status or SessionStatusName.objects.get(slug='canceled'), "can_edit": can_edit, "can_approve": can_approve}) - @login_required def interim_request_edit(request, number): '''Edit details of an interim meeting reqeust''' diff --git a/ietf/secr/meetings/views.py b/ietf/secr/meetings/views.py index bb599d0e0..0613b8c27 100644 --- a/ietf/secr/meetings/views.py +++ b/ietf/secr/meetings/views.py @@ -20,7 +20,6 @@ from ietf.meeting.forms import duration_string from ietf.meeting.helpers import get_meeting, make_materials_directories, populate_important_dates from ietf.meeting.models import Meeting, Session, Room, TimeSlot, SchedTimeSessAssignment, Schedule, SchedulingEvent from ietf.meeting.utils import add_event_info_to_session_qs -from ietf.meeting.utils import only_sessions_that_can_meet from ietf.name.models import SessionStatusName from ietf.group.models import Group, GroupEvent from ietf.secr.meetings.blue_sheets import create_blue_sheets @@ -662,9 +661,7 @@ def regular_sessions(request, meeting_id, schedule_name): meeting = get_object_or_404(Meeting, number=meeting_id) schedule = get_object_or_404(Schedule, meeting=meeting, name=schedule_name) - sessions = add_event_info_to_session_qs( - only_sessions_that_can_meet(meeting.session_set) - ).order_by('group__acronym') + sessions = meeting.session_set.that_can_meet().order_by('group__acronym') if request.method == 'POST': if 'cancel' in request.POST: diff --git a/ietf/templates/meeting/interim_announcement.txt b/ietf/templates/meeting/interim_announcement.txt index d832b7be7..074394099 100644 --- a/ietf/templates/meeting/interim_announcement.txt +++ b/ietf/templates/meeting/interim_announcement.txt @@ -1,11 +1,11 @@ {% load ietf_filters %}{% if is_change %}MEETING DETAILS HAVE CHANGED. SEE LATEST DETAILS BELOW. {% endif %}The {{ group.name }} ({{ group.acronym }}) {% if group.type.slug == 'wg' and group.state.slug == 'bof' %}BOF{% else %}{{group.type.name}}{% endif %} will hold -{% if meeting.session_set.count == 1 %}a{% if meeting.city %}n {% else %} virtual {% endif %}interim meeting on {{ meeting.date }} from {{ assignments.first.timeslot.time | date:"H:i" }} to {{ assignments.first.timeslot.end_time | date:"H:i" }} {{ meeting.time_zone}}{% if meeting.time_zone != 'UTC' %} ({{ assignments.first.timeslot.utc_start_time | date:"H:i" }} to {{ assignments.first.timeslot.utc_end_time | date:"H:i" }} UTC){% endif %}. +{% if assignments.count == 1 %}a{% if meeting.city %}n {% else %} virtual {% endif %}interim meeting on {{ meeting.date }} from {{ assignments.first.timeslot.time | date:"H:i" }} to {{ assignments.first.timeslot.end_time | date:"H:i" }} {{ meeting.time_zone}}{% if meeting.time_zone != 'UTC' %} ({{ assignments.first.timeslot.utc_start_time | date:"H:i" }} to {{ assignments.first.timeslot.utc_end_time | date:"H:i" }} UTC){% endif %}. {% else %}a multi-day {% if not meeting.city %}virtual {% endif %}interim meeting. {% for assignment in assignments %}Session {{ forloop.counter }}: -{{ assignment.timeslot.time | date:"Y-m-d" }} {{ assignment.timeslot.time | date:"H:i" }} to {{ assignment.timeslot.end_time | date:"H:i" }} {{ meeting.time_zone }}{% if meeting.time_zone != 'UTC' %}({{ assignment.timeslot.utc_start_time | date:"H:i" }} to {{ assignment.timeslot.utc_end_time | date:"H:i" }} UTC){% endif %} +{{ assignment.timeslot.time | date:"Y-m-d" }} {{ assignment.timeslot.time | date:"H:i" }} to {{ assignment.timeslot.end_time | date:"H:i" }} {{ meeting.time_zone }}{% if meeting.time_zone != 'UTC' %}({{ assignment.timeslot.utc_start_time | date:"H:i" }} to {{ assignment.timeslot.utc_end_time | date:"H:i" }} UTC){% endif %} {% endfor %}{% endif %} {% if meeting.city %}Meeting Location: {{ meeting.city }}, {{ meeting.country }} diff --git a/ietf/templates/meeting/interim_info.txt b/ietf/templates/meeting/interim_info.txt index 11903655d..4bb950b7d 100644 --- a/ietf/templates/meeting/interim_info.txt +++ b/ietf/templates/meeting/interim_info.txt @@ -8,7 +8,7 @@ Country: {{ meeting.country }} {% else %}Meeting Type: Virtual Meeting{% endif %} -{% for session in meeting.session_set.all %}Session {{ forloop.counter }}: +{% for session in meeting.session_set.not_canceled %}Session {{ forloop.counter }}: Date: {{ session.official_timeslotassignment.timeslot.time|date:"Y-m-d" }} Start Time: {{ session.official_timeslotassignment.timeslot.time|date:"H:i" }} {{ meeting.time_zone }} diff --git a/ietf/templates/meeting/interim_cancellation_notice.txt b/ietf/templates/meeting/interim_meeting_cancellation_notice.txt similarity index 100% rename from ietf/templates/meeting/interim_cancellation_notice.txt rename to ietf/templates/meeting/interim_meeting_cancellation_notice.txt diff --git a/ietf/templates/meeting/interim_request_cancel.html b/ietf/templates/meeting/interim_request_cancel.html index 95f8d3c21..9e2c265e1 100644 --- a/ietf/templates/meeting/interim_request_cancel.html +++ b/ietf/templates/meeting/interim_request_cancel.html @@ -13,17 +13,18 @@ {% block content %} {% origin %} -

{% block title %}Cancel Interim Meeting {% if session_status != "sched" %}Request{% endif %}{% endblock %}

+

{% block title %}Cancel Interim {% if meeting %}Meeting{% else %}Session{% endif %} {% if session_status != "sched" %}Request{% endif %}{% endblock %}

{% csrf_token %} {% bootstrap_form form layout='horizontal' %} -
{% buttons %} - Back + {% if meeting %}Back + {% else %}Back{% endif %} {% endbuttons %}
diff --git a/ietf/templates/meeting/interim_request_details.html b/ietf/templates/meeting/interim_request_details.html index 3dda8ecbb..286a96ec0 100644 --- a/ietf/templates/meeting/interim_request_details.html +++ b/ietf/templates/meeting/interim_request_details.html @@ -19,18 +19,21 @@
Requested By
{{ requester }}
Status
-
{{ session_status.name }}
+
{{ meeting_status.name }}
City
{{ meeting.city }}
Country
{{ meeting.country }}
Timezone
{{ meeting.time_zone }}
- {% for assignment in assignments %} + {% for assignment in meeting_assignments %}
+ {% if meeting_assignments|length > 1 %} +
Session
{{ assignment.status.name }}
+ {% endif %}
Date
{{ assignment.timeslot.time|date:"Y-m-d" }} -
Start Time
+
Start Time
{{ assignment.timeslot.time|date:"H:i" }} {% if meeting.time_zone != 'UTC' %}( {{ assignment.timeslot.utc_start_time|date:"H:i"}} UTC ){% endif %}
Duration
{{ assignment.session.requested_duration|format_timedelta }} @@ -38,36 +41,44 @@
{{ assignment.session.remote_instructions }}
Additional Info
{{ assignment.session.agenda_note }}
+ {% if meeting_assignments|length > 1 %} + {% if can_edit and assignment.can_be_canceled %} +
Actions
+
Cancel Session
+ {% endif %} + {% endif %} {% endfor %} {% csrf_token %} + {% with meeting_status.slug as status_slug %} {% if can_edit %} Edit {% endif %} - {% if can_approve and session_status.slug == 'apprw' %} + {% if can_approve and status_slug == 'apprw' %} {% endif %} - {% if user|has_role:"Secretariat" and session_status.slug == 'scheda' %} + {% if user|has_role:"Secretariat" and status_slug == 'scheda' %} Announce Skip Announcement {% endif %} {% if can_edit %} - {% if session_status.slug == 'apprw' or session_status.slug == 'scheda' or session_status.slug == 'sched' %} + {% if status_slug == 'apprw' or status_slug == 'scheda' or status_slug == 'sched' %} Cancel Meeting {% endif %} {% endif %} - {% if session_status.slug == "apprw" %} + {% if status_slug == "apprw" %} Back - {% elif session_status.slug == "scheda" %} + {% elif status_slug == "scheda" %} Back - {% elif session_status.slug == "sched" %} + {% elif status_slug == "sched" %} Back {% else %} Back {% endif %} + {% endwith %}
{% endblock %} diff --git a/ietf/templates/meeting/interim_session_cancellation_notice.txt b/ietf/templates/meeting/interim_session_cancellation_notice.txt new file mode 100644 index 000000000..7be4c612b --- /dev/null +++ b/ietf/templates/meeting/interim_session_cancellation_notice.txt @@ -0,0 +1,7 @@ +{% load ams_filters %} +{% if session.name %}The "{{ session.name }}"{% else %}A{% endif %} session of the {{ group.name }} ({{ group.acronym }}) {% if not session.meeting.city %}virtual {% endif %}{% if is_multi_day %}multi-day {% endif %} +interim meeting has been cancelled. This session had been scheduled for {{ meeting.date|date:"Y-m-d" }} from {{ start_time|time:"H:i" }} to {{ end_time|time:"H:i" }} {{ meeting.time_zone }}. + +{{ session.agenda_note }} + +