diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 4fc1d46cda..2310d71d75 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -219,7 +219,9 @@ def test_api_set_session_video_url(self): event = doc.latest_event() self.assertEqual(event.by, recman) - def test_api_add_session_attendees(self): + def test_api_add_session_attendees_deprecated(self): + # Deprecated test - should be removed when we stop accepting a simple list of user PKs in + # the add_session_attendees() view url = urlreverse('ietf.meeting.views.api_add_session_attendees') otherperson = PersonFactory() recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') @@ -285,6 +287,120 @@ def test_api_add_session_attendees(self): self.assertTrue(session.attended_set.filter(person=recman).exists()) self.assertTrue(session.attended_set.filter(person=otherperson).exists()) + def test_api_add_session_attendees(self): + url = urlreverse("ietf.meeting.views.api_add_session_attendees") + otherperson = PersonFactory() + recmanrole = RoleFactory(group__type_id="ietf", name_id="recman") + recman = recmanrole.person + meeting = MeetingFactory(type_id="ietf") + session = SessionFactory(group__type_id="wg", meeting=meeting) + apikey = PersonalApiKey.objects.create(endpoint=url, person=recman) + + badrole = RoleFactory(group__type_id="ietf", name_id="ad") + badapikey = PersonalApiKey.objects.create(endpoint=url, person=badrole.person) + badrole.person.user.last_login = timezone.now() + badrole.person.user.save() + + # Improper credentials, or method + r = self.client.post(url, {}) + self.assertContains(r, "Missing apikey parameter", status_code=400) + + r = self.client.post(url, {"apikey": badapikey.hash()}) + self.assertContains(r, "Restricted to role: Recording Manager", status_code=403) + + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Too long since last regular login", status_code=400) + + recman.user.last_login = timezone.now() - datetime.timedelta(days=365) + recman.user.save() + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Too long since last regular login", status_code=400) + + recman.user.last_login = timezone.now() + recman.user.save() + r = self.client.get(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Method not allowed", status_code=405) + + recman.user.last_login = timezone.now() + recman.user.save() + + # Malformed requests + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Missing attended parameter", status_code=400) + + for baddict in ( + "{}", + '{"bogons;drop table":"bogons;drop table"}', + '{"session_id":"Not an integer;drop table"}', + f'{{"session_id":{session.pk},"attendees":"not a list;drop table"}}', + f'{{"session_id":{session.pk},"attendees":"not a list;drop table"}}', + f'{{"session_id":{session.pk},"attendees":[1,2,"not an int;drop table",4]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk}]}}', # no join_time + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time;drop table":"2024-01-01T00:00:00Z]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time":"not a time;drop table"]}}', + # next has no time zone indicator + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time":"2024-01-01T00:00:00"]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":"not an int; drop table","join_time":"2024-01-01T00:00:00Z"]}}', + # Uncomment the next one when the _deprecated version of this test is retired + # f'{{"session_id":{session.pk},"attendees":[{recman.user.pk}, {otherperson.user.pk}]}}', + ): + r = self.client.post(url, {"apikey": apikey.hash(), "attended": baddict}) + self.assertContains(r, "Malformed post", status_code=400) + + bad_session_id = Session.objects.order_by("-pk").first().pk + 1 + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": f'{{"session_id":{bad_session_id},"attendees":[]}}', + }, + ) + self.assertContains(r, "Invalid session", status_code=400) + bad_user_id = User.objects.order_by("-pk").first().pk + 1 + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": f'{{"session_id":{session.pk},"attendees":[{{"user_id":{bad_user_id}, "join_time":"2024-01-01T00:00:00Z"}}]}}', + }, + ) + self.assertContains(r, "Invalid attendee", status_code=400) + + # Reasonable request + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": json.dumps( + { + "session_id": session.pk, + "attendees": [ + { + "user_id": recman.user.pk, + "join_time": "2023-09-03T12:34:56Z", + }, + { + "user_id": otherperson.user.pk, + "join_time": "2023-09-03T03:00:19Z", + }, + ], + } + ), + }, + ) + + self.assertEqual(session.attended_set.count(), 2) + self.assertTrue(session.attended_set.filter(person=recman).exists()) + self.assertEqual( + session.attended_set.get(person=recman).time, + datetime.datetime(2023, 9, 3, 12, 34, 56, tzinfo=datetime.timezone.utc), + ) + self.assertTrue(session.attended_set.filter(person=otherperson).exists()) + self.assertEqual( + session.attended_set.get(person=otherperson).time, + datetime.datetime(2023, 9, 3, 3, 0, 19, tzinfo=datetime.timezone.utc), + ) + def test_api_upload_polls_and_chatlog(self): recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') recmanrole.person.user.last_login = timezone.now() diff --git a/ietf/meeting/migrations/0007_attended_origin_attended_time.py b/ietf/meeting/migrations/0007_attended_origin_attended_time.py new file mode 100644 index 0000000000..09a8d90e07 --- /dev/null +++ b/ietf/meeting/migrations/0007_attended_origin_attended_time.py @@ -0,0 +1,26 @@ +# Copyright The IETF Trust 2024, All Rights Reserved + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("meeting", "0006_alter_sessionpresentation_document_and_session"), + ] + + 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( + blank=True, default=django.utils.timezone.now, null=True + ), + ), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 4689495e0b..dd6e2db6c5 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1147,7 +1147,6 @@ def can_manage_materials(self, user): return can_manage_materials(user,self.group) def is_material_submission_cutoff(self): - debug.say("is_material_submission_cutoff got called") return date_today(datetime.timezone.utc) > self.meeting.get_submission_correction_date() def joint_with_groups_acronyms(self): @@ -1427,6 +1426,8 @@ class Meta: class Attended(models.Model): person = ForeignKey(Person) session = ForeignKey(Session) + time = models.DateTimeField(default=timezone.now, 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 e2abcede8c..5df341fd38 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 @@ -12,7 +12,7 @@ import requests_mock from unittest import skipIf -from mock import patch, PropertyMock +from mock import call, patch, PropertyMock from pyquery import PyQuery from lxml.etree import tostring from io import StringIO, BytesIO @@ -38,16 +38,16 @@ 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.utils import create_recording, get_next_sequence, bluesheet_data from ietf.meeting.views import session_draft_list, parse_agenda_filter_params, sessions_post_save, agenda_extract_schedule from ietf.meeting.views import get_summary_by_area, get_summary_by_type, get_summary_by_purpose from ietf.name.models import SessionStatusName, ImportantDateName, RoleName, ProceedingsMaterialTypeName @@ -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( @@ -5997,6 +5997,34 @@ def test_finalize_proceedings(self): self.assertEqual(meeting.proceedings_final,True) self.assertEqual(meeting.session_set.filter(group__acronym="mars").first().presentations.filter(document__type="draft").first().rev,'00') + @patch("ietf.meeting.utils.generate_bluesheet") + def test_bluesheet_generation(self, mock): + meeting = MeetingFactory(type_id="ietf", number="107") # number where generate_bluesheets should not be called + SessionFactory.create_batch(5, meeting=meeting) + url = urlreverse("ietf.meeting.views.finalize_proceedings", kwargs={"num": meeting.number}) + self.client.login(username="secretary", password="secretary+password") + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertFalse(mock.called) + r = self.client.post(url,{'finalize': 1}) + self.assertEqual(r.status_code, 302) + self.assertFalse(mock.called) + + meeting = MeetingFactory(type_id="ietf", number="108") # number where generate_bluesheets should be called + SessionFactory.create_batch(5, meeting=meeting) + url = urlreverse("ietf.meeting.views.finalize_proceedings", kwargs={"num": meeting.number}) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertFalse(mock.called) + r = self.client.post(url,{'finalize': 1}) + self.assertEqual(r.status_code, 302) + self.assertTrue(mock.called) + self.assertCountEqual( + [call_args[0][1] for call_args in mock.call_args_list], + [sess for sess in meeting.session_set.all()], + ) + + class MaterialsTests(TestCase): settings_temp_path_overrides = TestCase.settings_temp_path_overrides + [ 'AGENDA_PATH', @@ -7784,7 +7812,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') @@ -7905,7 +7933,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') @@ -7930,9 +7957,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') @@ -8335,3 +8367,127 @@ 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) + + # 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 + 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. + 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) + + # 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) + 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 + _test_button(persons[0], False) + # person3 attests he was there + persons.append(MeetingRegistrationFactory(meeting=meeting).person) + # 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) + 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') + 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.presentations.filter(document__type_id='bluesheets').first().document + self.assertEqual(doc.rev,'00') + text = doc.text() + self.assertIn('4 attendees', text) + for person in persons: + self.assertIn(person.name, text) + 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()) + 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') + 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) + doc = session.presentations.filter(document__type_id='bluesheets').first().document + self.assertEqual(doc.rev,'00') + 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()) + + def test_bluesheet_data(self): + session = SessionFactory(meeting__type_id="ietf") + attended_with_affil = MeetingRegistrationFactory(meeting=session.meeting, affiliation="Somewhere") + AttendedFactory(session=session, person=attended_with_affil.person, time="2023-03-13T01:24:00") # joined 2nd + attended_no_affil = MeetingRegistrationFactory(meeting=session.meeting) + AttendedFactory(session=session, person=attended_no_affil.person, time="2023-03-13T01:23:00") # joined 1st + MeetingRegistrationFactory(meeting=session.meeting) # did not attend + + data = bluesheet_data(session) + self.assertEqual( + data, + [ + {"name": attended_no_affil.person.plain_name(), "affiliation": ""}, + {"name": attended_with_affil.person.plain_name(), "affiliation": "Somewhere"}, + ] + ) + diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 7c79a80257..5c39c0f990 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-2024, 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 9fb062b02c..046b0fa56c 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -1,10 +1,11 @@ -# Copyright The IETF Trust 2016-2020, All Rights Reserved +# Copyright The IETF Trust 2016-2024, All Rights Reserved # -*- coding: utf-8 -*- import datetime import itertools import os import pytz import subprocess +import tempfile from collections import defaultdict from pathlib import Path @@ -12,6 +13,7 @@ from django.conf import settings from django.contrib import messages from django.db.models import Q +from django.template.loader import render_to_string from django.utils import timezone from django.utils.encoding import smart_str @@ -26,6 +28,7 @@ from ietf.group.utils import can_manage_materials from ietf.name.models import SessionStatusName, ConstraintName, DocTypeName from ietf.person.models import Person +from ietf.stats.models import MeetingRegistration from ietf.utils.html import sanitize_document from ietf.utils.log import log from ietf.utils.timezone import date_today @@ -144,7 +147,84 @@ def create_proceedings_templates(meeting): meeting.overview = template meeting.save() -def finalize(meeting): + +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) + reg = MeetingRegistration.objects.filter(q).exclude(affiliation="").first() + return reg.affiliation if reg else "" + + attendance = Attended.objects.filter(session=session).order_by("time") + meeting = session.meeting + return [ + { + "name": attended.person.plain_name(), + "affiliation": affiliation(meeting, attended.person), + } + for attended in attendance + ] + + +def save_bluesheet(request, session, file, encoding='utf-8'): + bluesheet_sp = session.presentations.filter(document__type='bluesheets').first() + _, ext = os.path.splitext(file.name) + + if bluesheet_sp: + doc = bluesheet_sp.document + doc.rev = '%02d' % (int(doc.rev)+1) + bluesheet_sp.rev = doc.rev + bluesheet_sp.save() + else: + ota = session.official_timeslotassignment() + sess_time = ota and ota.timeslot.time + + if session.meeting.type_id=='ietf': + name = 'bluesheets-%s-%s-%s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%Y%m%d%H%M")) + title = 'Bluesheets IETF%s: %s : %s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%a %H:%M")) + else: + name = 'bluesheets-%s-%s' % (session.meeting.number, sess_time.strftime("%Y%m%d%H%M")) + title = 'Bluesheets %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) + doc = Document.objects.create( + name = name, + type_id = 'bluesheets', + title = title, + group = session.group, + rev = '00', + ) + doc.states.add(State.objects.get(type_id='bluesheets',slug='active')) + session.presentations.create(document=doc,rev='00') + filename = '%s-%s%s'% ( doc.name, doc.rev, ext) + doc.uploaded_filename = filename + e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev) + save_error = handle_upload_file(file, filename, session.meeting, 'bluesheets', request=request, encoding=encoding) + if not save_error: + doc.save_with_history([e]) + return save_error + + +def generate_bluesheet(request, session): + data = bluesheet_data(session) + if not data: + return + 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 finalize(request, meeting): end_date = meeting.end_date() end_time = meeting.tz().localize( datetime.datetime.combine( @@ -160,6 +240,12 @@ def finalize(meeting): else: sp.rev = '00' sp.save() + + # Don't try to generate a bluesheet if it's before we had Attended records. + if int(meeting.number) >= 108: + 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 1171f7b0b4..4fc99798be 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -19,6 +19,7 @@ from calendar import timegm from collections import OrderedDict, Counter, deque, defaultdict, namedtuple from functools import partialmethod +import jsonschema from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit from tempfile import mkstemp from wsgiref.handlers import format_date_time @@ -54,7 +55,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 ) @@ -84,7 +85,7 @@ from ietf.meeting.utils import preprocess_meeting_important_dates from ietf.meeting.utils import new_doc_for_session, write_doc_for_session from ietf.meeting.utils import get_activity_stats, post_process, create_recording -from ietf.meeting.utils import participants_for_meeting +from ietf.meeting.utils import participants_for_meeting, generate_bluesheet, bluesheet_data, save_bluesheet from ietf.message.utils import infer_message from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName from ietf.stats.models import MeetingRegistration @@ -2427,8 +2428,19 @@ 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.presentations.filter(document__type__slug__in=['agenda','minutes','narrativeminutes', 'bluesheets'])) - session.filtered_artifacts.sort(key=lambda d:['agenda','minutes', 'narrativeminutes', 'bluesheets'].index(d.document.type.slug)) + if session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final: + artifact_types = ['agenda','minutes','narrativeminutes'] + 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 = 'Attendance IETF%s: %s : %s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%a %H:%M")) + else: + artifact_types = ['agenda','minutes','narrativeminutes','bluesheets'] + session.filtered_artifacts = list(session.presentations.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.presentations.filter(document__type__slug='slides').order_by('order') session.filtered_drafts = session.presentations.filter(document__type__slug='draft') session.filtered_chatlog_and_polls = session.presentations.filter(document__type__slug__in=('chatlog', 'polls')).order_by('document__type__slug') @@ -2517,6 +2529,66 @@ def add_session_drafts(request, session_id, num): }) +def session_attendance(request, session_id, num): + """Session attendance view + + GET - retrieve the current session attendance or redirect to the published bluesheet if finalized + + POST - self-attest attendance for logged-in user; falls through to GET for AnonymousUser or invalid request + """ + # 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 != "ietf" or session.meeting.proceedings_final: + bluesheets = session.presentations.filter( + document__type_id="bluesheets" + ) + if bluesheets: + bluesheet = bluesheets[0].document + return redirect(bluesheet.get_href(session.meeting)) + else: + raise Http404("Bluesheets not found") + + cor_cut_off_date = session.meeting.get_submission_correction_date() + today_utc = date_today(datetime.timezone.utc) + was_there = False + can_add = False + if request.user.is_authenticated: + # use getattr() instead of request.user.person because it's a reverse OneToOne field + person = getattr(request.user, "person", None) + # Consider allowing self-declared attendance if we have a person and at least one Attended instance exists. + # The latter condition will be satisfied when Meetecho pushes their attendee records - assuming that at least + # one person will have accessed the meeting tool. This prevents people from self-declaring before they are + # marked as attending if they did log in to the meeting tool (except for a tiny window while records are + # being processed). + if person is not None and Attended.objects.filter(session=session).exists(): + 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 not was_there + ) + if can_add and request.method == "POST": + 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 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 session = get_object_or_404(Session,pk=session_id) @@ -2564,47 +2636,6 @@ def upload_session_bluesheets(request, session_id, num): }) -def save_bluesheet(request, session, file, encoding='utf-8'): - bluesheet_sp = session.presentations.filter(document__type='bluesheets').first() - _, ext = os.path.splitext(file.name) - - if bluesheet_sp: - doc = bluesheet_sp.document - doc.rev = '%02d' % (int(doc.rev)+1) - bluesheet_sp.rev = doc.rev - bluesheet_sp.save() - else: - ota = session.official_timeslotassignment() - sess_time = ota and ota.timeslot.time - - if session.meeting.type_id=='ietf': - name = 'bluesheets-%s-%s-%s' % (session.meeting.number, - session.group.acronym, - sess_time.strftime("%Y%m%d%H%M")) - title = 'Bluesheets IETF%s: %s : %s' % (session.meeting.number, - session.group.acronym, - sess_time.strftime("%a %H:%M")) - else: - name = 'bluesheets-%s-%s' % (session.meeting.number, sess_time.strftime("%Y%m%d%H%M")) - title = 'Bluesheets %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) - doc = Document.objects.create( - name = name, - type_id = 'bluesheets', - title = title, - group = session.group, - rev = '00', - ) - doc.states.add(State.objects.get(type_id='bluesheets',slug='active')) - session.presentations.create(document=doc,rev='00') - filename = '%s-%s%s'% ( doc.name, doc.rev, ext) - doc.uploaded_filename = filename - e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev) - save_error = handle_upload_file(file, filename, session.meeting, 'bluesheets', request=request, encoding=encoding) - if not save_error: - doc.save_with_history([e]) - return save_error - - def upload_session_minutes(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure session = get_object_or_404(Session,pk=session_id) @@ -3791,6 +3822,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: @@ -3885,12 +3918,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,}) @@ -4094,37 +4126,111 @@ def err(code, text): return HttpResponse("Done", status=200, content_type='text/plain') + @require_api_key @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": [ + {"user_id": user-pk-1, "join_time": "2024-02-21T18:00:00Z"}, + {"user_id": user-pk-2, "join_time": "2024-02-21T18:00:01Z"}, + {"user_id": user-pk-3, "join_time": "2024-02-21T18:00:02Z"}, + ... + ] + } + """ + json_validator = jsonschema.Draft202012Validator( + schema={ + "type": "object", + "properties": { + "session_id": {"type": "integer"}, + "attendees": { + # Allow either old or new format until after IETF 119 + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, # old: array of user PKs + { + # new: array of user_id / join_time objects + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": {"type": "integer", }, + "join_time": {"type": "string", "format": "date-time"} + }, + "required": ["user_id", "join_time"], + }, + }, + ], + } + }, + "required": ["session_id", "attendees"], + }, + format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER, # format-checks disabled by default + ) def err(code, text): - return HttpResponse(text, status=code, content_type='text/plain') + return HttpResponse(text, status=code, content_type="text/plain") - if request.method != 'POST': + if request.method != "POST": return err(405, "Method not allowed") - attended_post = request.POST.get('attended') + attended_post = request.POST.get("attended") if not attended_post: return err(400, "Missing attended parameter") + + # Validate the request payload try: - attended = json.loads(attended_post) - except json.decoder.JSONDecodeError: - return err(400, "Malformed post") - if not ( 'session_id' in attended and type(attended['session_id']) is int ): - return err(400, "Malformed post") - session_id = attended['session_id'] - if not ( 'attendees' in attended and type(attended['attendees']) is list and all([type(el) is int for el in attended['attendees']]) ): + payload = json.loads(attended_post) + json_validator.validate(payload) + except (json.decoder.JSONDecodeError, jsonschema.exceptions.ValidationError): return err(400, "Malformed post") + + session_id = payload["session_id"] session = Session.objects.filter(pk=session_id).first() if not session: return err(400, "Invalid session") - users = User.objects.filter(pk__in=attended['attendees']) - if users.count() != len(attended['attendees']): - return err(400, "Invalid attendee") - for user in users: - session.attended_set.get_or_create(person=user.person) - return HttpResponse("Done", status=200, content_type='text/plain') + + attendees = payload["attendees"] + if len(attendees) > 0: + # Check whether we have old or new format + if type(attendees[0]) == int: + # it's the old format + users = User.objects.filter(pk__in=attendees) + if users.count() != len(payload["attendees"]): + return err(400, "Invalid attendee") + for user in users: + session.attended_set.get_or_create(person=user.person) + else: + # it's the new format + join_time_by_pk = { + att["user_id"]: datetime.datetime.fromisoformat( + att["join_time"].replace("Z", "+00:00") # Z not understood until py311 + ) + for att in attendees + } + persons = list(Person.objects.filter(user__pk__in=join_time_by_pk)) + if len(persons) != len(join_time_by_pk): + return err(400, "Invalid attendee") + to_create = [ + Attended(session=session, person=person, time=join_time_by_pk[person.user_id]) + for person in persons + ] + # Create in bulk, ignoring any that already exist + Attended.objects.bulk_create(to_create, ignore_conflicts=True) + + if session.meeting.type_id == "interim": + save_error = generate_bluesheet(request, session) + if save_error: + return err(400, save_error) + + return HttpResponse("Done", status=200, content_type="text/plain") + @require_api_key @role_required('Recording Manager') diff --git a/ietf/stats/models.py b/ietf/stats/models.py index 6993343922..66e359f50c 100644 --- a/ietf/stats/models.py +++ b/ietf/stats/models.py @@ -66,7 +66,10 @@ class MeetingRegistration(models.Model): email = models.EmailField(blank=True, null=True) reg_type = models.CharField(blank=True, max_length=255) ticket_type = models.CharField(blank=True, max_length=255) + # attended was used prior to the introduction of the ietf.meeting.Attended model and is still used by + # Meeting.get_attendance() for older meetings. It should not be used except for dealing with legacy data. attended = models.BooleanField(default=False) + # checkedin indicates that the badge was picked up checkedin = models.BooleanField(default=False) def __str__(self): diff --git a/ietf/templates/meeting/attendance.html b/ietf/templates/meeting/attendance.html new file mode 100644 index 0000000000..5a9aa2dce2 --- /dev/null +++ b/ietf/templates/meeting/attendance.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2024, All Rights Reserved #} +{% load origin %} +{% block title %}Bluesheet for {{session}}{% endblock %} +{% block content %} + {% origin %} +

+ 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 %} + {% 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 %} +
+

+ {{ data|length }} attendees. +

+ + + + + + + + + {% for item in data %} + + + + + {% endfor %} + +
NameAffiliation
{{ item.name }}{{ item.affiliation }}
+ {% if can_add %} +
+ {% csrf_token %} + +
+ {% endif %} +{% endblock %} \ No newline at end of file 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 666685000d..95d6dc5da9 100644 --- a/ietf/templates/meeting/group_proceedings.html +++ b/ietf/templates/meeting/group_proceedings.html @@ -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 %} {% for chatlog in entry.chatlogs %} Chatlog diff --git a/ietf/templates/meeting/session_details_panel.html b/ietf/templates/meeting/session_details_panel.html index 0e30050186..3d2ba54f9d 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 }} + +
diff --git a/requirements.txt b/requirements.txt index fd750ec704..c27b3adce4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,6 +37,7 @@ html2text>=2020.1.16 # Used only to clean comment field of secr/sreq html5lib>=1.1 # Only used in tests inflect>= 6.0.2 jsonfield>=3.1.0 # for SubmissionCheck. This is https://github.com/bradjasper/django-jsonfield/. +jsonschema[format]>=4.2.1 jwcrypto>=1.2 # for signed notifications - this is aspirational, and is not really used. logging_tree>=1.9 # Used only by the showloggers management command lxml>=4.8.0,<5