From 1be098e47d5b7408381413986fe227359c5e6848 Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Tue, 30 Jan 2024 19:27:58 -0500 Subject: [PATCH 01/10] feat: Show bluesheets using Attended tables (#6898) --- ietf/meeting/tests_views.py | 89 +++++++++++++++++-- ietf/meeting/urls.py | 3 +- ietf/meeting/utils.py | 7 +- ietf/meeting/views.py | 70 +++++++++++++-- ietf/templates/meeting/bluesheet.html | 3 + .../meeting/session_details_panel.html | 9 +- 6 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 ietf/templates/meeting/bluesheet.html diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index a57fcf63c1..a61f3b5a6b 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -38,14 +38,14 @@ from ietf.doc.models import Document, NewRevisionDocEvent from ietf.group.models import Group, Role, GroupFeatures from ietf.group.utils import can_manage_group -from ietf.person.models import Person +from ietf.person.models import Person, PersonalApiKey from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_request, preprocess_assignments_for_agenda from ietf.meeting.helpers import send_interim_approval_request, AgendaKeywordTagger 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 -from ietf.meeting.utils import finalize, condition_slide_order +from ietf.meeting.utils import condition_slide_order from ietf.meeting.utils import add_event_info_to_session_qs, participants_for_meeting from ietf.meeting.utils import create_recording, get_next_sequence from ietf.meeting.views import session_draft_list, parse_agenda_filter_params, sessions_post_save, agenda_extract_schedule @@ -7895,7 +7895,6 @@ def test_proceedings_attendees(self): - prefer onsite checkedin=True to remote attended when same person has both """ - make_meeting_test_data() meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number="118") person_a = PersonFactory(name='Person A') person_b = PersonFactory(name='Person B') @@ -7920,9 +7919,14 @@ def test_proceedings_overview(self): '''Test proceedings IETF Overview page. Note: old meetings aren't supported so need to add a new meeting then test. ''' - make_meeting_test_data() - meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97") - finalize(meeting) + meeting = make_meeting_test_data(meeting=MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97")) + + # finalize meeting + url = urlreverse('ietf.meeting.views.finalize_proceedings',kwargs={'num':meeting.number}) + login_testing_unauthorized(self,"secretary",url) + r = self.client.post(url,{'finalize':1}) + self.assertEqual(r.status_code, 302) + url = urlreverse('ietf.meeting.views.proceedings_overview',kwargs={'num':97}) response = self.client.get(url) self.assertContains(response, 'The Internet Engineering Task Force') @@ -8325,3 +8329,76 @@ def test_participants_for_meeting(self): self.assertTrue(person_b.pk not in checked_in) self.assertTrue(person_c.pk in attended) self.assertTrue(person_d.pk not in attended) + + def test_session_attendance(self): + meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number='118') + make_meeting_test_data(meeting=meeting) + session = Session.objects.filter(meeting=meeting, group__acronym='mars').first() + regs = MeetingRegistrationFactory.create_batch(3, meeting=meeting) + persons = [reg.person for reg in regs] + self.assertEqual(session.attended_set.count(), 0) + + add_attendees_url = urlreverse('ietf.meeting.views.api_add_session_attendees') + recmanrole = RoleFactory(group__type_id='ietf', name_id='recman', person__user__last_login=timezone.now()) + recman = recmanrole.person + apikey = PersonalApiKey.objects.create(endpoint=add_attendees_url, person=recman) + attendees = [person.user.pk for person in persons] + self.client.login(username='recman', password='recman+password') + r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(session.attended_set.count(), 3) + + # Before a meeting is finalized, session_attendance renders a live + # view of the Attended records for the session. + attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, '3 attendees') + for person in persons: + self.assertContains(r, person.name) + + session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) + r = self.client.get(session_url) + self.assertContains(r, attendance_url) + + # When the meeting is finalized, a bluesheet file is generated, + # and session_attendance redirects to the file. + self.client.login(username='secretary',password='secretary+password') + finalize_url = urlreverse('ietf.meeting.views.finalize_proceedings', kwargs={'num':meeting.number}) + r = self.client.post(finalize_url, {'finalize':1}) + self.assertRedirects(r, urlreverse('ietf.meeting.views.proceedings', kwargs={'num':meeting.number})) + doc = session.sessionpresentation_set.filter(document__type_id='bluesheets').first().document + self.assertEqual(doc.rev,'00') + text = doc.text() + self.assertIn('3 attendees', text) + for person in persons: + self.assertIn(person.name, text) + + r = self.client.get(attendance_url) + self.assertEqual(r.status_code,302) + self.assertEqual(r['Location'],doc.get_href()) + + r = self.client.get(session_url) + self.assertContains(r, doc.get_href()) + self.assertNotContains(r, attendance_url) + + # An interim meeting is considered finalized immediately. + meeting = make_interim_meeting(group=GroupFactory(acronym='mars'), date=date_today()) + session = Session.objects.filter(meeting=meeting, group__acronym='mars').first() + attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) + self.assertEqual(session.attended_set.count(), 0) + self.client.login(username='recman', password='recman+password') + r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(session.attended_set.count(), 3) + + doc = session.sessionpresentation_set.filter(document__type_id='bluesheets').first().document + self.assertEqual(doc.rev,'00') + r = self.client.get(attendance_url) + self.assertEqual(r.status_code,302) + self.assertEqual(r['Location'],doc.get_href()) + + session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) + r = self.client.get(session_url) + self.assertContains(r, doc.get_href()) + self.assertNotContains(r, attendance_url) diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 7c79a80257..66943ae6b6 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2020, All Rights Reserved +# Copyright The IETF Trust 2007-2023, All Rights Reserved from django.conf import settings from django.urls import include @@ -16,6 +16,7 @@ def get_redirect_url(self, *args, **kwargs): safe_for_all_meeting_types = [ url(r'^session/(?P[-a-z0-9]+)/?$', views.session_details), url(r'^session/(?P\d+)/drafts$', views.add_session_drafts), + url(r'^session/(?P\d+)/attendance$', views.session_attendance), url(r'^session/(?P\d+)/bluesheets$', views.upload_session_bluesheets), url(r'^session/(?P\d+)/minutes$', views.upload_session_minutes), url(r'^session/(?P\d+)/agenda$', views.upload_session_agenda), diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 416e9c61fe..df5d24b8da 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2016-2020, All Rights Reserved +# Copyright The IETF Trust 2016-2023, All Rights Reserved # -*- coding: utf-8 -*- import datetime import itertools @@ -139,7 +139,8 @@ def create_proceedings_templates(meeting): meeting.overview = template meeting.save() -def finalize(meeting): +def finalize(request, meeting): + from ietf.meeting.views import generate_bluesheet end_date = meeting.end_date() end_time = meeting.tz().localize( datetime.datetime.combine( @@ -155,6 +156,8 @@ def finalize(meeting): else: sp.rev = '00' sp.save() + if meeting.number >= '118': + generate_bluesheet(request, session) create_proceedings_templates(meeting) meeting.proceedings_final = True diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 9d07df103e..1bd3c7ded0 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -54,7 +54,7 @@ from ietf.person.models import Person, User from ietf.ietfauth.utils import role_required, has_role, user_is_person from ietf.mailtrigger.utils import gather_address_lists -from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission +from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission, Attended from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName from ietf.meeting.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm, ImportMinutesForm, TimeSlotCreateForm, TimeSlotEditForm, SessionCancelForm, SessionEditForm ) @@ -2426,8 +2426,17 @@ def session_details(request, num, acronym): 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'])) - session.filtered_artifacts.sort(key=lambda d:['agenda','minutes','bluesheets'].index(d.document.type.slug)) + if session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final: + artifact_types = ['agenda','minutes'] + if Attended.objects.filter(session=session).exists(): + session.type_counter.update(['bluesheets']) + ota = session.official_timeslotassignment() + sess_time = ota and ota.timeslot.time + session.bluesheet_title = 'Bluesheets %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) + else: + artifact_types = ['agenda','minutes','bluesheets'] + session.filtered_artifacts = list(session.sessionpresentation_set.filter(document__type__slug__in=artifact_types)) + session.filtered_artifacts.sort(key=lambda d:artifact_types.index(d.document.type.slug)) session.filtered_slides = session.sessionpresentation_set.filter(document__type__slug='slides').order_by('order') session.filtered_drafts = session.sessionpresentation_set.filter(document__type__slug='draft') session.filtered_chatlog_and_polls = session.sessionpresentation_set.filter(document__type__slug__in=('chatlog', 'polls')).order_by('document__type__slug') @@ -2515,6 +2524,47 @@ def add_session_drafts(request, session_id, num): 'form': form, }) +def bluesheet_data(session): + def affiliation(meeting, person): + # from OidcExtraScopeClaims.scope_registration() + email_list = person.email_set.values_list('address') + q = Q(person=person, meeting=meeting) | Q(email__in=email_list, meeting=meeting) + regs = MeetingRegistration.objects.filter(q).distinct() + return ([reg.affiliation for reg in regs if reg.affiliation] or [''])[0] + + attendance = Attended.objects.filter(session=session) + meeting = session.meeting + return [{'name':attended.person.name, 'affiliation':affiliation(meeting, attended.person)} for attended in attendance] + +def session_attendance(request, session_id, num): + # num is redundant, but we're dragging it along as an artifact of where we are in the current URL structure + session = get_object_or_404(Session, pk=session_id) + if session.meeting.type_id=='interim' or session.meeting.proceedings_final: + bluesheets = session.sessionpresentation_set.filter(document__type_id='bluesheets') + if bluesheets: + bluesheet = bluesheets[0].document + return redirect(bluesheet.get_href(session.meeting)) + else: + raise Http404('Bluesheets not found') + + data = bluesheet_data(session) + return render(request, "meeting/bluesheet.html", { + 'session': session, + 'data': data, + }) + +def generate_bluesheet(request, session): + data = bluesheet_data(session) + text = render_to_string('meeting/bluesheet.txt', { + 'session': session, + 'data': data, + }) + fd, name = tempfile.mkstemp(suffix=".txt", text=True) + os.close(fd) + with open(name, "w") as file: + file.write(text) + with open(name, "br") as file: + return save_bluesheet(request, session, file) def upload_session_bluesheets(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure @@ -3883,12 +3933,11 @@ def proceedings(request, num=None): def finalize_proceedings(request, num=None): meeting = get_meeting(num) - if (meeting.number.isdigit() and int(meeting.number) <= 64) or not meeting.schedule or not meeting.schedule.assignments.exists() or meeting.proceedings_final: raise Http404 if request.method=='POST': - finalize(meeting) + finalize(request, meeting) return HttpResponseRedirect(reverse('ietf.meeting.views.proceedings',kwargs={'num':meeting.number})) return render(request, "meeting/finalize.html", {'meeting':meeting,}) @@ -4096,6 +4145,13 @@ def err(code, text): @role_required('Recording Manager') # TODO : Rework how Meetecho interacts via APIs. There may be better paths to pursue than Personal API keys as they are currently defined. @csrf_exempt def api_add_session_attendees(request): + """Upload attendees for one or more sessions + + parameters: + apikey: the poster's personal API key + attended: json blob with + [{'session_id': session pk, 'attendees': [list of user pks]}, ...] + """ def err(code, text): return HttpResponse(text, status=code, content_type='text/plain') @@ -4122,6 +4178,10 @@ def err(code, text): return err(400, "Invalid attendee") for user in users: session.attended_set.get_or_create(person=user.person) + + if session.meeting.type_id == 'interim': + generate_bluesheet(request, session) + return HttpResponse("Done", status=200, content_type='text/plain') @require_api_key diff --git a/ietf/templates/meeting/bluesheet.html b/ietf/templates/meeting/bluesheet.html new file mode 100644 index 0000000000..d86c749af3 --- /dev/null +++ b/ietf/templates/meeting/bluesheet.html @@ -0,0 +1,3 @@ +{% load origin %} +{% origin %} +
{% include 'meeting/bluesheet.txt' %}
\ No newline at end of file diff --git a/ietf/templates/meeting/session_details_panel.html b/ietf/templates/meeting/session_details_panel.html index 3ff09fc33b..f9713e36e2 100644 --- a/ietf/templates/meeting/session_details_panel.html +++ b/ietf/templates/meeting/session_details_panel.html @@ -62,7 +62,7 @@

{% endif %}

Agenda, Minutes, and Bluesheets

- {% if session.filtered_artifacts %} + {% if session.filtered_artifacts or session.bluesheet_title %} {% for pres in session.filtered_artifacts %} @@ -89,6 +89,13 @@

Agenda, Minutes, and Bluesheets

{% endfor %} + {% if session.bluesheet_title %} + + {% endif %} {% endif %}
+ + {{ session.bluesheet_title }} + +
From 8e140993bf285161705e954ea9886905a0551e5f Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Tue, 30 Jan 2024 22:42:27 -0500 Subject: [PATCH 02/10] feat: Allow users to add themselves to session attendance (#6454) --- .../0005_attended_origin_attended_time.py | 22 +++++++++++++++++ ietf/meeting/models.py | 4 +++- ietf/meeting/tests_views.py | 24 +++++++++++++++++-- ietf/meeting/views.py | 6 +++++ ietf/templates/meeting/bluesheet.html | 9 ++++++- 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 ietf/meeting/migrations/0005_attended_origin_attended_time.py diff --git a/ietf/meeting/migrations/0005_attended_origin_attended_time.py b/ietf/meeting/migrations/0005_attended_origin_attended_time.py new file mode 100644 index 0000000000..457e349339 --- /dev/null +++ b/ietf/meeting/migrations/0005_attended_origin_attended_time.py @@ -0,0 +1,22 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("meeting", "0004_session_chat_room"), + ] + + operations = [ + migrations.AddField( + model_name="attended", + name="origin", + field=models.CharField(default="datatracker", max_length=32), + ), + migrations.AddField( + model_name="attended", + name="time", + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 20fa9cf1bb..c43e5eed35 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2022, All Rights Reserved +# Copyright The IETF Trust 2007-2023, All Rights Reserved # -*- coding: utf-8 -*- @@ -1479,6 +1479,8 @@ class Meta: class Attended(models.Model): person = ForeignKey(Person) session = ForeignKey(Session) + time = models.DateTimeField(auto_now_add=True, null=True, blank=True) + origin = models.CharField(max_length=32, default='datatracker') class Meta: unique_together = (('person', 'session'),) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index a61f3b5a6b..bb218cc681 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -8361,6 +8361,26 @@ def test_session_attendance(self): r = self.client.get(session_url) self.assertContains(r, attendance_url) + # Test for the "I was there" button. + def _test_button(person, expected): + username = person.user.username + self.client.login(username=username, password=f'{username}+password') + r = self.client.get(attendance_url) + q = PyQuery(r.content) + self.assertEqual(bool(q('button')), expected) + # recman isn't registered for the meeting + _test_button(recman, False) + # person0 is already on the bluesheet + _test_button(persons[0], False) + # person3 attests he was there + persons.append(MeetingRegistrationFactory(meeting=meeting).person) + attendees.append(persons[3].user.pk) + _test_button(persons[3], True) + r = self.client.post(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, persons[3].name) + self.assertEqual(session.attended_set.count(), 4) + # When the meeting is finalized, a bluesheet file is generated, # and session_attendance redirects to the file. self.client.login(username='secretary',password='secretary+password') @@ -8370,7 +8390,7 @@ def test_session_attendance(self): doc = session.sessionpresentation_set.filter(document__type_id='bluesheets').first().document self.assertEqual(doc.rev,'00') text = doc.text() - self.assertIn('3 attendees', text) + self.assertIn('4 attendees', text) for person in persons: self.assertIn(person.name, text) @@ -8390,7 +8410,7 @@ def test_session_attendance(self): self.client.login(username='recman', password='recman+password') r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) self.assertEqual(r.status_code, 200) - self.assertEqual(session.attended_set.count(), 3) + self.assertEqual(session.attended_set.count(), 4) doc = session.sessionpresentation_set.filter(document__type_id='bluesheets').first().document self.assertEqual(doc.rev,'00') diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 1bd3c7ded0..c714a09a5f 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2547,10 +2547,16 @@ def session_attendance(request, session_id, num): else: raise Http404('Bluesheets not found') + person = request.user.person + if request.method=='POST': + session.attended_set.get_or_create(person=person, origin="self declared") + data = bluesheet_data(session) + can_add = MeetingRegistration.objects.filter(meeting=session.meeting, person=person).exists() and not Attended.objects.filter(session=session, person=person).exists() return render(request, "meeting/bluesheet.html", { 'session': session, 'data': data, + 'can_add': can_add, }) def generate_bluesheet(request, session): diff --git a/ietf/templates/meeting/bluesheet.html b/ietf/templates/meeting/bluesheet.html index d86c749af3..9126da3373 100644 --- a/ietf/templates/meeting/bluesheet.html +++ b/ietf/templates/meeting/bluesheet.html @@ -1,3 +1,10 @@ {% load origin %} {% origin %} -
{% include 'meeting/bluesheet.txt' %}
\ No newline at end of file +
{% include 'meeting/bluesheet.txt' %}
+ + {% if can_add %} +
+ {% csrf_token %} + +
+ {% endif %} \ No newline at end of file From adb5f50029eadc85e2f1915979c8799bc524ef88 Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Wed, 31 Jan 2024 14:15:07 -0500 Subject: [PATCH 03/10] chore: Correct copyright year --- ietf/meeting/migrations/0005_attended_origin_attended_time.py | 2 +- ietf/meeting/models.py | 2 +- ietf/meeting/tests_views.py | 2 +- ietf/meeting/views.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ietf/meeting/migrations/0005_attended_origin_attended_time.py b/ietf/meeting/migrations/0005_attended_origin_attended_time.py index 457e349339..17e81d205c 100644 --- a/ietf/meeting/migrations/0005_attended_origin_attended_time.py +++ b/ietf/meeting/migrations/0005_attended_origin_attended_time.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2023, All Rights Reserved +# Copyright The IETF Trust 2024, All Rights Reserved from django.db import migrations, models diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index c43e5eed35..47f1f9d4fe 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2023, All Rights Reserved +# Copyright The IETF Trust 2007-2024, All Rights Reserved # -*- coding: utf-8 -*- diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index bb218cc681..43a2dc2b8e 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2009-2023, All Rights Reserved +# Copyright The IETF Trust 2009-2024, All Rights Reserved # -*- coding: utf-8 -*- import datetime import io diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index c714a09a5f..7d9bc7584a 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2023, All Rights Reserved +# Copyright The IETF Trust 2007-2024, All Rights Reserved # -*- coding: utf-8 -*- From 1e7995c67cf9541e40af359d5ce1848d7ae61600 Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Wed, 31 Jan 2024 15:52:43 -0500 Subject: [PATCH 04/10] fix: Address review comments --- ietf/meeting/utils.py | 4 +++- ietf/meeting/views.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index df5d24b8da..729488eb13 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -156,7 +156,9 @@ def finalize(request, meeting): else: sp.rev = '00' sp.save() - if meeting.number >= '118': + + # Don't try to generate a bluesheet if it's before we had Attended records. + if int(meeting.number) >= 108: generate_bluesheet(request, session) create_proceedings_templates(meeting) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 7d9bc7584a..13314be74a 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2548,11 +2548,13 @@ def session_attendance(request, session_id, num): raise Http404('Bluesheets not found') person = request.user.person - if request.method=='POST': - session.attended_set.get_or_create(person=person, origin="self declared") + can_add = MeetingRegistration.objects.filter(meeting=session.meeting, person=person).exists() and not Attended.objects.filter(session=session, person=person).exists() + + if request.method=='POST' and can_add: + session.attended_set.get_or_create(person=person, defaults={"origin":"self declared"}) + can_add = False data = bluesheet_data(session) - can_add = MeetingRegistration.objects.filter(meeting=session.meeting, person=person).exists() and not Attended.objects.filter(session=session, person=person).exists() return render(request, "meeting/bluesheet.html", { 'session': session, 'data': data, From aecfe42bf0b0082245c1d367664164f43136a2e2 Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Wed, 31 Jan 2024 18:23:29 -0500 Subject: [PATCH 05/10] fix: Don't try to generate empty bluesheets --- ietf/meeting/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 13314be74a..27ea31f231 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2563,6 +2563,8 @@ def session_attendance(request, session_id, num): def generate_bluesheet(request, session): data = bluesheet_data(session) + if not data: + return text = render_to_string('meeting/bluesheet.txt', { 'session': session, 'data': data, From 9d256aeae4628a945976ddcff2dcbb1ac949c544 Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Wed, 31 Jan 2024 18:24:16 -0500 Subject: [PATCH 06/10] refactor: Complete rewrite of bluesheet.html --- ietf/meeting/tests_views.py | 3 +-- ietf/templates/meeting/bluesheet.html | 33 +++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 43a2dc2b8e..739740a30f 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -8366,8 +8366,7 @@ def _test_button(person, expected): username = person.user.username self.client.login(username=username, password=f'{username}+password') r = self.client.get(attendance_url) - q = PyQuery(r.content) - self.assertEqual(bool(q('button')), expected) + self.assertEqual(b"I was there" in r.content, expected) # recman isn't registered for the meeting _test_button(recman, False) # person0 is already on the bluesheet diff --git a/ietf/templates/meeting/bluesheet.html b/ietf/templates/meeting/bluesheet.html index 9126da3373..54788f403b 100644 --- a/ietf/templates/meeting/bluesheet.html +++ b/ietf/templates/meeting/bluesheet.html @@ -1,10 +1,35 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2024, All Rights Reserved #} {% load origin %} -{% origin %} -
{% include 'meeting/bluesheet.txt' %}
- +{% block title %}Bluesheet for {{session}}{% endblock %} +{% block content %} + {% origin %} +

+ Bluesheet for {{session}} +

+

+ {{ data|length }} attendees. +

+ + + + + + + + + {% for item in data %} + + + + + {% endfor %} + +
NameAffiliation
{{ item.name }}{{ item.affiliation }}
{% if can_add %}
{% csrf_token %}
- {% endif %} \ No newline at end of file + {% endif %} +{% endblock %} \ No newline at end of file From 90feb2b05d7bfe0b53789010e872894aa87a70d4 Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Fri, 9 Feb 2024 23:03:35 -0500 Subject: [PATCH 07/10] refactor: Fill in a few gaps, close a few holes - Rename the live "bluesheet" to "attendance", add some explanatory text. - Add attendance links in materials view and pre-finalized proceedings view. - Don't allow users to add themselves after the corrections cutoff date. --- ietf/meeting/tests_views.py | 16 +++++++++-- ietf/meeting/urls.py | 2 +- ietf/meeting/utils.py | 2 +- ietf/meeting/views.py | 14 +++++++--- .../{bluesheet.html => attendance.html} | 8 +++++- ietf/templates/meeting/group_materials.html | 18 ++++++------ ietf/templates/meeting/group_proceedings.html | 28 +++++++++++++------ 7 files changed, 60 insertions(+), 28 deletions(-) rename ietf/templates/meeting/{bluesheet.html => attendance.html} (75%) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 739740a30f..3c4548642d 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -517,7 +517,7 @@ def test_named_session(self): group = GroupFactory() plain_session = SessionFactory(meeting=meeting, group=group) named_session = SessionFactory(meeting=meeting, group=group, name='I Got a Name') - for doc_type_id in ('agenda', 'minutes', 'bluesheets', 'slides', 'draft'): + for doc_type_id in ('agenda', 'minutes', 'slides', 'draft'): # Set up sessions materials that will have distinct URLs for each session. # This depends on settings.MEETING_DOC_HREFS and may need updating if that changes. SessionPresentationFactory( @@ -7774,7 +7774,7 @@ def test_proceedings(self): def test_named_session(self): """Session with a name should appear separately in the proceedings""" - meeting = MeetingFactory(type_id='ietf', number='100') + meeting = MeetingFactory(type_id='ietf', number='100', proceedings_final=True) group = GroupFactory() plain_session = SessionFactory(meeting=meeting, group=group) named_session = SessionFactory(meeting=meeting, group=group, name='I Got a Name') @@ -8373,7 +8373,16 @@ def _test_button(person, expected): _test_button(persons[0], False) # person3 attests he was there persons.append(MeetingRegistrationFactory(meeting=meeting).person) - attendees.append(persons[3].user.pk) + # button isn't shown if we're outside the corrections windows + meeting.importantdate_set.create(name_id='revsub',date=date_today() - datetime.timedelta(days=20)) + _test_button(persons[3], False) + # attempt to POST anyway is ignored + r = self.client.post(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, persons[3].name) + self.assertEqual(session.attended_set.count(), 3) + # button is shown, and POST is accepted + meeting.importantdate_set.update(name_id='revsub',date=date_today() + datetime.timedelta(days=20)) _test_button(persons[3], True) r = self.client.post(attendance_url) self.assertEqual(r.status_code, 200) @@ -8407,6 +8416,7 @@ def _test_button(person, expected): attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) self.assertEqual(session.attended_set.count(), 0) self.client.login(username='recman', password='recman+password') + attendees = [person.user.pk for person in persons] r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) self.assertEqual(r.status_code, 200) self.assertEqual(session.attended_set.count(), 4) diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 66943ae6b6..5c39c0f990 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2023, All Rights Reserved +# Copyright The IETF Trust 2007-2024, All Rights Reserved from django.conf import settings from django.urls import include diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 729488eb13..ed84b4ab15 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2016-2023, All Rights Reserved +# Copyright The IETF Trust 2016-2024, All Rights Reserved # -*- coding: utf-8 -*- import datetime import itertools diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 47c697cb1d..c78eeff500 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2433,7 +2433,9 @@ def session_details(request, num, acronym): session.type_counter.update(['bluesheets']) ota = session.official_timeslotassignment() sess_time = ota and ota.timeslot.time - session.bluesheet_title = 'Bluesheets %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) + session.bluesheet_title = 'Attendance IETF%s: %s : %s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%a %H:%M")) else: artifact_types = ['agenda','minutes','bluesheets'] session.filtered_artifacts = list(session.sessionpresentation_set.filter(document__type__slug__in=artifact_types)) @@ -2540,7 +2542,7 @@ def affiliation(meeting, person): def session_attendance(request, session_id, num): # num is redundant, but we're dragging it along as an artifact of where we are in the current URL structure session = get_object_or_404(Session, pk=session_id) - if session.meeting.type_id=='interim' or session.meeting.proceedings_final: + if session.meeting.type_id != 'ietf' or session.meeting.proceedings_final: bluesheets = session.sessionpresentation_set.filter(document__type_id='bluesheets') if bluesheets: bluesheet = bluesheets[0].document @@ -2548,15 +2550,17 @@ def session_attendance(request, session_id, num): else: raise Http404('Bluesheets not found') + cor_cut_off_date = session.meeting.get_submission_correction_date() + today_utc = date_today(datetime.timezone.utc) person = request.user.person - can_add = MeetingRegistration.objects.filter(meeting=session.meeting, person=person).exists() and not Attended.objects.filter(session=session, person=person).exists() + can_add = today_utc <= cor_cut_off_date and MeetingRegistration.objects.filter(meeting=session.meeting, person=person).exists() and not Attended.objects.filter(session=session, person=person).exists() if request.method=='POST' and can_add: session.attended_set.get_or_create(person=person, defaults={"origin":"self declared"}) can_add = False data = bluesheet_data(session) - return render(request, "meeting/bluesheet.html", { + return render(request, "meeting/attendance.html", { 'session': session, 'data': data, 'can_add': can_add, @@ -3850,6 +3854,8 @@ def _format_materials(items): 'drafts': _format_materials((s, s.drafts()) for s in ss), 'last_update': session.last_update if hasattr(session, 'last_update') else None } + if session and session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final: + entry['attendances'] = _format_materials((s, s) for s in ss if Attended.objects.filter(session=s).exists()) if is_meeting: meeting_groups.append(entry) else: diff --git a/ietf/templates/meeting/bluesheet.html b/ietf/templates/meeting/attendance.html similarity index 75% rename from ietf/templates/meeting/bluesheet.html rename to ietf/templates/meeting/attendance.html index 54788f403b..e64d3e76ba 100644 --- a/ietf/templates/meeting/bluesheet.html +++ b/ietf/templates/meeting/attendance.html @@ -5,8 +5,14 @@ {% block content %} {% origin %}

- Bluesheet for {{session}} + Attendance for {{session}}

+
+ This list will be used to generate the official bluesheet for this session. + {% if can_add %} + If you attended this session, you can use the "I was there" button at the bottom to add yourself. + {% endif %} +

{{ data|length }} attendees.

diff --git a/ietf/templates/meeting/group_materials.html b/ietf/templates/meeting/group_materials.html index aea20827db..74ed8bafca 100644 --- a/ietf/templates/meeting/group_materials.html +++ b/ietf/templates/meeting/group_materials.html @@ -1,4 +1,4 @@ -{# Copyright The IETF Trust 2015-2019, All Rights Reserved #} +{# Copyright The IETF Trust 2015-2024, All Rights Reserved #} {% load origin %} {% origin %} {% load ietf_filters proceedings_filters managed_groups %} @@ -45,16 +45,14 @@ {% if show_agenda == "True" %}No minutes{% endif %} {% endfor %} {% if entry.session.type_id == 'regular' and show_agenda == "True" %} - {% for bluesheet in entry.bluesheets %} - - Bluesheets - {% if bluesheet.time %} -
{{ bluesheet.time|date:"D G:i" }} - {% endif %} -
+ {% for attendance in entry.attendances %} + {% with session=attendance.material %} + + Attendance + {% if attendance.time %}{{ attendance.time|date:"D G:i" }}{% endif %} + + {% endwith %}
- {% empty %} - No bluesheets {% endfor %} {% endif %} diff --git a/ietf/templates/meeting/group_proceedings.html b/ietf/templates/meeting/group_proceedings.html index 618c28164c..1063c20e33 100644 --- a/ietf/templates/meeting/group_proceedings.html +++ b/ietf/templates/meeting/group_proceedings.html @@ -1,4 +1,4 @@ -{# Copyright The IETF Trust 2015, All Rights Reserved #} +{# Copyright The IETF Trust 2015-2024, All Rights Reserved #} {% load origin %} {% origin %} {% load ietf_filters %} @@ -47,13 +47,25 @@

{{ entry.group }}


{% endif %} {% endfor %} - {% for bs in entry.bluesheets %} - - Bluesheets - {% if bs.time %}{{ bs.time|date:"D G:i" }}{% endif %} - -
- {% endfor %} + {% if not meeting.proceedings_final %} + {% for attendance in entry.attendances %} + {% with session=attendance.material %} + + Attendance + {% if attendance.time %}{{ attendance.time|date:"D G:i" }}{% endif %} + + {% endwith %} +
+ {% endfor %} + {% else %} + {% for bs in entry.bluesheets %} + + Bluesheets + {% if bs.time %}{{ bs.time|date:"D G:i" }}{% endif %} + +
+ {% endfor %} + {% endif %} {# recordings #} From 315776d50677751fd2b822b110b2bb0eaf320e97 Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Mon, 12 Feb 2024 16:46:10 -0500 Subject: [PATCH 08/10] fix: Report file-save errors to caller --- ietf/meeting/utils.py | 4 +++- ietf/meeting/views.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index ed84b4ab15..6d1909e666 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -159,7 +159,9 @@ def finalize(request, meeting): # Don't try to generate a bluesheet if it's before we had Attended records. if int(meeting.number) >= 108: - generate_bluesheet(request, session) + save_error = generate_bluesheet(request, session) + if save_error: + messages.error(request, save_error) create_proceedings_templates(meeting) meeting.proceedings_final = True diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index c78eeff500..36483afd44 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -4197,7 +4197,9 @@ def err(code, text): session.attended_set.get_or_create(person=user.person) if session.meeting.type_id == 'interim': - generate_bluesheet(request, session) + save_error = generate_bluesheet(request, session) + if save_error: + return err(400, save_error) return HttpResponse("Done", status=200, content_type='text/plain') From df3a646a5acb9eea8751acf03db3bcd7e117c38c Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Wed, 14 Feb 2024 18:03:14 -0500 Subject: [PATCH 09/10] fix: Address review comments --- ietf/meeting/tests_views.py | 34 ++++++++++++++------------ ietf/meeting/views.py | 12 ++++++--- ietf/templates/meeting/attendance.html | 5 +++- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 3c4548642d..63cb99b6bc 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -8338,6 +8338,17 @@ def test_session_attendance(self): persons = [reg.person for reg in regs] self.assertEqual(session.attended_set.count(), 0) + # If there are no attendees, the link isn't offered, and getting + # the page directly returns an empty list. + session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) + attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) + r = self.client.get(session_url) + self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, '0 attendees') + + # Add some attendees add_attendees_url = urlreverse('ietf.meeting.views.api_add_session_attendees') recmanrole = RoleFactory(group__type_id='ietf', name_id='recman', person__user__last_login=timezone.now()) recman = recmanrole.person @@ -8350,17 +8361,14 @@ def test_session_attendance(self): # Before a meeting is finalized, session_attendance renders a live # view of the Attended records for the session. - attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) + r = self.client.get(session_url) + self.assertContains(r, attendance_url) r = self.client.get(attendance_url) self.assertEqual(r.status_code, 200) self.assertContains(r, '3 attendees') for person in persons: self.assertContains(r, person.name) - session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) - r = self.client.get(session_url) - self.assertContains(r, attendance_url) - # Test for the "I was there" button. def _test_button(person, expected): username = person.user.username @@ -8401,14 +8409,12 @@ def _test_button(person, expected): self.assertIn('4 attendees', text) for person in persons: self.assertIn(person.name, text) - - r = self.client.get(attendance_url) - self.assertEqual(r.status_code,302) - self.assertEqual(r['Location'],doc.get_href()) - r = self.client.get(session_url) self.assertContains(r, doc.get_href()) self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code,302) + self.assertEqual(r['Location'],doc.get_href()) # An interim meeting is considered finalized immediately. meeting = make_interim_meeting(group=GroupFactory(acronym='mars'), date=date_today()) @@ -8420,14 +8426,12 @@ def _test_button(person, expected): r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) self.assertEqual(r.status_code, 200) self.assertEqual(session.attended_set.count(), 4) - doc = session.sessionpresentation_set.filter(document__type_id='bluesheets').first().document self.assertEqual(doc.rev,'00') - r = self.client.get(attendance_url) - self.assertEqual(r.status_code,302) - self.assertEqual(r['Location'],doc.get_href()) - session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) r = self.client.get(session_url) self.assertContains(r, doc.get_href()) self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code,302) + self.assertEqual(r['Location'],doc.get_href()) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 36483afd44..7e1dc82dbb 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2537,7 +2537,7 @@ def affiliation(meeting, person): attendance = Attended.objects.filter(session=session) meeting = session.meeting - return [{'name':attended.person.name, 'affiliation':affiliation(meeting, attended.person)} for attended in attendance] + return [{'name':attended.person.plain_name, 'affiliation':affiliation(meeting, attended.person)} for attended in attendance] def session_attendance(request, session_id, num): # num is redundant, but we're dragging it along as an artifact of where we are in the current URL structure @@ -2552,18 +2552,24 @@ def session_attendance(request, session_id, num): cor_cut_off_date = session.meeting.get_submission_correction_date() today_utc = date_today(datetime.timezone.utc) - person = request.user.person - can_add = today_utc <= cor_cut_off_date and MeetingRegistration.objects.filter(meeting=session.meeting, person=person).exists() and not Attended.objects.filter(session=session, person=person).exists() + try: + person = request.user.person + was_there = Attended.objects.filter(session=session, person=person).exists() + can_add = today_utc <= cor_cut_off_date and MeetingRegistration.objects.filter(meeting=session.meeting, person=person).exists() and Attended.objects.filter(session=session).exists() and not was_there + except AttributeError: + was_there = can_add = False if request.method=='POST' and can_add: session.attended_set.get_or_create(person=person, defaults={"origin":"self declared"}) can_add = False + was_there = True data = bluesheet_data(session) return render(request, "meeting/attendance.html", { 'session': session, 'data': data, 'can_add': can_add, + 'was_there': was_there, }) def generate_bluesheet(request, session): diff --git a/ietf/templates/meeting/attendance.html b/ietf/templates/meeting/attendance.html index e64d3e76ba..5a9aa2dce2 100644 --- a/ietf/templates/meeting/attendance.html +++ b/ietf/templates/meeting/attendance.html @@ -10,7 +10,10 @@

This list will be used to generate the official bluesheet for this session. {% if can_add %} - If you attended this session, you can use the "I was there" button at the bottom to add yourself. +
If you attended this session, you can use the "I was there" button at the bottom to add yourself. + {% endif %} + {% if was_there %} +
If the affiliation listed here needs to be updated, request the change using support@ietf.org. Note which sessions you are wanting to change in your request. {% endif %}

From 8cddcdf5319d0512e6b4f763114c3f0f3afee055 Mon Sep 17 00:00:00 2001 From: Paul Selkirk Date: Wed, 14 Feb 2024 18:41:35 -0500 Subject: [PATCH 10/10] fix: typo --- ietf/meeting/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 7e1dc82dbb..07f4044bca 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2537,7 +2537,7 @@ def affiliation(meeting, person): attendance = Attended.objects.filter(session=session) meeting = session.meeting - return [{'name':attended.person.plain_name, 'affiliation':affiliation(meeting, attended.person)} for attended in attendance] + return [{'name':attended.person.plain_name(), 'affiliation':affiliation(meeting, attended.person)} for attended in attendance] def session_attendance(request, session_id, num): # num is redundant, but we're dragging it along as an artifact of where we are in the current URL structure