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..17e81d205c --- /dev/null +++ b/ietf/meeting/migrations/0005_attended_origin_attended_time.py @@ -0,0 +1,22 @@ +# Copyright The IETF Trust 2024, 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 de9613de10..68f0123820 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-2024, All Rights Reserved # -*- coding: utf-8 -*- @@ -1407,6 +1407,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 a57fcf63c1..63cb99b6bc 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 @@ -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 @@ -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') @@ -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,109 @@ 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.sessionpresentation_set.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.sessionpresentation_set.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()) 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 416e9c61fe..6d1909e666 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-2024, 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,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 eae9b145e8..07f4044bca 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 -*- @@ -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 ) @@ -2427,8 +2427,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.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 = '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)) + 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') @@ -2516,6 +2527,65 @@ 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.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 + session = get_object_or_404(Session, pk=session_id) + 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 + 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) + 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): + 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 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 @@ -3790,6 +3860,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: @@ -3884,12 +3956,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,}) @@ -4097,6 +4168,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') @@ -4123,6 +4201,12 @@ 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': + 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 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 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 #} 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 }} + +