From e6138ca126ac6f98024330350db35b4abfa15339 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Tue, 12 Mar 2024 10:22:24 -0500 Subject: [PATCH] feat: session apis (#7173) * feat: Show bluesheets using Attended tables (#7094) * feat: Show bluesheets using Attended tables (#6898) * feat: Allow users to add themselves to session attendance (#6454) * chore: Correct copyright year * fix: Address review comments * fix: Don't try to generate empty bluesheets * refactor: Complete rewrite of bluesheet.html * 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. * fix: Report file-save errors to caller * fix: Address review comments * fix: typo * refactor: if instead of except; refactor gently * refactor: Rearrange logic a little, add comment * style: Black * refactor: auto_now_add->default to allow override * refactor: jsonschema to validate API payload * feat: Handle new API data format Not yet tested except that it falls back when the old format is used. * test: Split test into deprecated/new version Have not yet touched the new version * style: Black * test: Test new add_session_attendees API * fix: Fix bug uncovered by test * refactor: Refactor affiliation lookup a bit * fix: Order bluesheet by Attended.time * refactor: Move helpers from views.py to utils.py * test: Test that finalize calls generate_bluesheets * test: test_bluesheet_data() * fix: Clean up merge * fix: Remove debug statement * chore: comments * refactor: Renumber migrations --------- Co-authored-by: Paul Selkirk * chore: Remove unused import * style: Black * feat: Stub session update notify API * feat: Add order & rev to slides JSON * style: Black * feat: Stub actual Meetecho slide deck mgmt API * refactor: Limit reordering to type="slides" * chore: Remove repository from meetecho API (API changed on their end) * feat: update Meetecho on slide reorder * refactor: drop pytz from meetecho.py * chore: Remove more repository refs * refactor: Eliminate more pytz * test: Test add_slide_deck api * fix: Allow 202 status code / absent Content-Type * test: Test delete_slide_deck api * test: Test update_slide_decks api * refactor: sessionpresentation_set -> presentations * test: Test send_update() * fix: Debug send_update() * test: ajax_reorder_slides calls Meetecho API * test: Test SldesManager.add() * feat: Implement SlidesManager.add() * test: Test that ajax_add_slides... calls API * feat: Call Meetecho API when slides added to session * test: Test SlidesManager.delete() * feat: Implement SlidesManager.delete() * test: ajax_remove_slides... calls Meetecho API * feat: Call Meetecho API when slides removed * chore: Update docstring * feat: rudimentary debug mode for Meetecho API * test: remove_sessionpresentation() calls Meetecho API * feat: Call Meetecho API from remove_sessionpresentation() * test: upload_slides() calls Meetecho API * style: Black * fix: Refactor/debug upload_session_slides Avoids double-save of a SessionPresentation for the session being updated and updates other sessions when apply_to_all is set (previously it only created ones that did not exist, so rev would never be updated). * test: Fix test bug * feat: Call Meetecho API when uploading session slides * fix: Only replace slides actually linked to session * fix: Delint Removed some type checking rather than debugging it * fix: Send get_versionless_href() as url for slides * test: TZ-aware timestamps, please * chore: Add comments * feat: Call Meetecho API in edit_sessionpresentation * feat: Call Meetecho API in remove_sessionpresentation * feat: Call Meetecho API from add_sessionpresentation * fix: Set order in add_sessionpresentation * fix: Restrict API calls to "slides" docs * feat: Call Meetecho API on title changes * test: Check meetecho API calls in test_revise() * fix: better Meetecho API "order" management * fix: no PUT if there are no slides after DELETE * feat: Catch exceptions from SlidesManager Don't let errors in the MeetEcho slides API interfere with the ability to modify slides for a session. * feat: Limit which sessions we send notifications for * fix: handle absence of request_timeout in api config * test: always send slide notifications in tests * fix: save slides before sending notification (#7172) * fix: save slides before sending notification * style: fix indentation It's not a bug, it's a flourish! --------- Co-authored-by: Jennifer Richards Co-authored-by: Paul Selkirk --- ietf/api/tests.py | 118 ++- ietf/doc/tests.py | 89 +- ietf/doc/tests_material.py | 51 +- ietf/doc/views_doc.py | 36 +- ietf/doc/views_material.py | 18 + .../0007_attended_origin_attended_time.py | 26 + ietf/meeting/models.py | 2 + ietf/meeting/tests_views.py | 378 +++++++- ietf/meeting/urls.py | 3 +- ietf/meeting/utils.py | 88 +- ietf/meeting/views.py | 815 +++++++++++++----- ietf/settings.py | 4 + ietf/stats/models.py | 3 + ietf/templates/meeting/attendance.html | 44 + ietf/templates/meeting/group_materials.html | 18 +- ietf/templates/meeting/group_proceedings.html | 26 +- .../meeting/session_details_panel.html | 9 +- ietf/utils/meetecho.py | 451 ++++++++-- ietf/utils/tests_meetecho.py | 307 ++++++- requirements.txt | 1 + 20 files changed, 2104 insertions(+), 383 deletions(-) create mode 100644 ietf/meeting/migrations/0007_attended_origin_attended_time.py create mode 100644 ietf/templates/meeting/attendance.html diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 4fc1d46cd..2310d71d7 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -219,7 +219,9 @@ class CustomApiTests(TestCase): 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 @@ class CustomApiTests(TestCase): 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/doc/tests.py b/ietf/doc/tests.py index c9e1c041d..0ad26b7ad 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -2594,37 +2594,68 @@ class DocumentMeetingTests(TestCase): self.assertFalse(q("#futuremeets a.btn:contains('Remove document')")) self.assertFalse(q("#pastmeets a.btn:contains('Remove document')")) - def test_edit_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_edit_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() sp = doc.presentations.create(session=self.future,rev=None) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name='no-such-doc',session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name=doc.name,session_id=0)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name=doc.name,session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=self.other_chair.user.username,password='%s+password'%self.other_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 404) - + self.assertFalse(mock_slides_manager_cls.called) + self.client.login(username=self.group_chair.user.username,password='%s+password'%self.group_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 200) q = PyQuery(response.content) self.assertEqual(2,len(q('select#id_version option'))) + self.assertFalse(mock_slides_manager_cls.called) + # edit draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'version':'00','save':''}) self.assertEqual(response.status_code, 302) self.assertEqual(doc.presentations.get(pk=sp.pk).rev,'00') self.assertEqual(2,doc.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + + # editing slides should call Meetecho API + slides = SessionPresentationFactory( + session=self.future, + document__type_id="slides", + document__rev="00", + rev=None, + order=1, + ).document + url = urlreverse( + "ietf.doc.views_doc.edit_sessionpresentation", + kwargs={"name": slides.name, "session_id": self.future.pk}, + ) + response = self.client.post(url, {"version": "00", "save": ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + mock.call(self.future), + ) def test_edit_document_session_after_proceedings_closed(self): doc = IndividualDraftFactory.create() @@ -2641,35 +2672,60 @@ class DocumentMeetingTests(TestCase): q=PyQuery(response.content) self.assertEqual(1,len(q(".alert-warning:contains('may affect published proceedings')"))) - def test_remove_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_remove_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() sp = doc.presentations.create(session=self.future,rev=None) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name='no-such-doc',session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name=doc.name,session_id=0)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name=doc.name,session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=self.other_chair.user.username,password='%s+password'%self.other_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 404) - + self.assertFalse(mock_slides_manager_cls.called) + self.client.login(username=self.group_chair.user.username,password='%s+password'%self.group_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) + # removing a draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'remove_session':''}) self.assertEqual(response.status_code, 302) self.assertFalse(doc.presentations.filter(pk=sp.pk).exists()) self.assertEqual(2,doc.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + + # removing slides should call Meetecho API + slides = SessionPresentationFactory(session=self.future, document__type_id="slides", order=1).document + url = urlreverse( + "ietf.doc.views_doc.remove_sessionpresentation", + kwargs={"name": slides.name, "session_id": self.future.pk}, + ) + response = self.client.post(url, {"remove_session": ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.delete.call_args, + mock.call(self.future, slides), + ) def test_remove_document_session_after_proceedings_closed(self): doc = IndividualDraftFactory.create() @@ -2686,28 +2742,49 @@ class DocumentMeetingTests(TestCase): q=PyQuery(response.content) self.assertEqual(1,len(q(".alert-warning:contains('may affect published proceedings')"))) - def test_add_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_add_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() url = urlreverse('ietf.doc.views_doc.add_sessionpresentation',kwargs=dict(name=doc.name)) login_testing_unauthorized(self,self.group_chair.user.username,url) response = self.client.get(url) self.assertEqual(response.status_code,200) - + self.assertFalse(mock_slides_manager_cls.called) + response = self.client.post(url,{'session':0,'version':'current'}) self.assertEqual(response.status_code,200) q=PyQuery(response.content) self.assertTrue(q('.form-select.is-invalid')) + self.assertFalse(mock_slides_manager_cls.called) response = self.client.post(url,{'session':self.future.pk,'version':'bogus version'}) self.assertEqual(response.status_code,200) q=PyQuery(response.content) self.assertTrue(q('.form-select.is-invalid')) + self.assertFalse(mock_slides_manager_cls.called) + # adding a draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'session':self.future.pk,'version':'current'}) self.assertEqual(response.status_code,302) self.assertEqual(2,doc.docevent_set.count()) + self.assertEqual(doc.presentations.get(session__pk=self.future.pk).order, 0) + self.assertFalse(mock_slides_manager_cls.called) + + # adding slides should set order / call Meetecho API + slides = DocumentFactory(type_id="slides") + url = urlreverse("ietf.doc.views_doc.add_sessionpresentation", kwargs=dict(name=slides.name)) + response = self.client.post(url, {"session": self.future.pk, "version": "current"}) + self.assertEqual(response.status_code,302) + self.assertEqual(slides.presentations.get(session__pk=self.future.pk).order, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.add.call_args, + mock.call(self.future, slides, order=1), + ) def test_get_related_meeting(self): """Should be able to retrieve related meeting""" diff --git a/ietf/doc/tests_material.py b/ietf/doc/tests_material.py index 2f057573b..065ff09a9 100644 --- a/ietf/doc/tests_material.py +++ b/ietf/doc/tests_material.py @@ -6,19 +6,21 @@ import os import shutil import io +from mock import call, patch from pathlib import Path from pyquery import PyQuery import debug # pyflakes:ignore from django.conf import settings +from django.test import override_settings from django.urls import reverse as urlreverse from django.utils import timezone from ietf.doc.models import Document, State, NewRevisionDocEvent from ietf.group.factories import RoleFactory from ietf.group.models import Group -from ietf.meeting.factories import MeetingFactory, SessionFactory +from ietf.meeting.factories import MeetingFactory, SessionFactory, SessionPresentationFactory from ietf.meeting.models import Meeting, SessionPresentation, SchedulingEvent from ietf.name.models import SessionStatusName from ietf.person.models import Person @@ -135,19 +137,47 @@ class GroupMaterialTests(TestCase): doc = Document.objects.get(name=doc.name) self.assertEqual(doc.get_state_slug(), "deleted") - def test_edit_title(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @patch("ietf.doc.views_material.SlidesManager") + def test_edit_title(self, mock_slides_manager_cls): doc = self.create_slides() url = urlreverse('ietf.doc.views_material.edit_material', kwargs=dict(name=doc.name, action="title")) login_testing_unauthorized(self, "secretary", url) + self.assertFalse(mock_slides_manager_cls.called) # post r = self.client.post(url, dict(title="New title")) self.assertEqual(r.status_code, 302) doc = Document.objects.get(name=doc.name) self.assertEqual(doc.title, "New title") + self.assertFalse(mock_slides_manager_cls.return_value.send_update.called) - def test_revise(self): + # assign to a session to see that it now sends updates to Meetecho + session = SessionPresentationFactory(session__group=doc.group, document=doc).session + + # Grab the title on the slides when the API call was made (to be sure it's not before it was updated) + titles_sent = [] + mock_slides_manager_cls.return_value.send_update.side_effect = lambda sess: titles_sent.extend( + list(sess.presentations.values_list("document__title", flat=True)) + ) + + r = self.client.post(url, dict(title="Newer title")) + self.assertEqual(r.status_code, 302) + doc = Document.objects.get(name=doc.name) + self.assertEqual(doc.title, "Newer title") + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + call(session), + ) + self.assertEqual(titles_sent, ["Newer title"]) + + @override_settings(MEETECHO_API_CONFIG="fake settings") + @patch("ietf.doc.views_material.SlidesManager") + def test_revise(self, mock_slides_manager_cls): doc = self.create_slides() session = SessionFactory( @@ -165,11 +195,18 @@ class GroupMaterialTests(TestCase): url = urlreverse('ietf.doc.views_material.edit_material', kwargs=dict(name=doc.name, action="revise")) login_testing_unauthorized(self, "secretary", url) + self.assertFalse(mock_slides_manager_cls.called) content = "some text" test_file = io.StringIO(content) test_file.name = "unnamed.txt" + # Grab the title on the slides when the API call was made (to be sure it's not before it was updated) + titles_sent = [] + mock_slides_manager_cls.return_value.send_update.side_effect = lambda sess: titles_sent.extend( + list(sess.presentations.values_list("document__title", flat=True)) + ) + # post r = self.client.post(url, dict(title="New title", abstract="New abstract", @@ -180,6 +217,14 @@ class GroupMaterialTests(TestCase): self.assertEqual(doc.rev, "02") self.assertEqual(doc.title, "New title") self.assertEqual(doc.get_state_slug(), "active") + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + call(session), + ) + self.assertEqual(titles_sent, ["New title"]) with io.open(os.path.join(doc.get_file_path(), doc.name + "-" + doc.rev + ".txt")) as f: self.assertEqual(f.read(), content) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 1a260ca97..551ec0cc5 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -42,6 +42,7 @@ import re from pathlib import Path +from django.db.models import Max from django.http import HttpResponse, Http404 from django.shortcuts import render, get_object_or_404, redirect from django.template.loader import render_to_string @@ -74,13 +75,14 @@ from ietf.utils.history import find_history_active_at from ietf.doc.forms import TelechatForm, NotifyForm, ActionHoldersForm, DocAuthorForm, DocAuthorChangeBasisForm from ietf.doc.mails import email_comment, email_remind_action_holders from ietf.mailtrigger.utils import gather_relevant_expansions -from ietf.meeting.models import Session +from ietf.meeting.models import Session, SessionPresentation from ietf.meeting.utils import group_sessions, get_upcoming_manageable_sessions, sort_sessions, add_event_info_to_session_qs from ietf.review.models import ReviewAssignment from ietf.review.utils import can_request_review_of_doc, review_assignments_to_list_for_docs, review_requests_to_list_for_docs from ietf.review.utils import no_review_from_teams_on_doc from ietf.utils import markup_txt, log, markdown from ietf.utils.draft import PlaintextDraft +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager from ietf.utils.response import permission_denied from ietf.utils.text import maybe_split from ietf.utils.timezone import date_today @@ -2070,6 +2072,12 @@ def edit_sessionpresentation(request,name,session_id): new_selection = form.cleaned_data['version'] if initial['version'] != new_selection: doc.presentations.filter(pk=sp.pk).update(rev=None if new_selection=='current' else new_selection) + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.send_update(sp.session) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.send_update(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "Revision for session %s changed to %s" % (sp.session,new_selection) c.save() @@ -2091,6 +2099,12 @@ def remove_sessionpresentation(request,name,session_id): if request.method == 'POST': doc.presentations.filter(pk=sp.pk).delete() + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(sp.session, doc) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.delete(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "Removed from session: %s" % (sp.session) c.save() @@ -2127,7 +2141,25 @@ def add_sessionpresentation(request,name): session_id = session_form.cleaned_data['session'] version = version_form.cleaned_data['version'] rev = None if version=='current' else version - doc.presentations.create(session_id=session_id,rev=rev) + if doc.type_id == "slides": + max_order = SessionPresentation.objects.filter( + document__type='slides', + session__pk=session_id, + ).aggregate(Max('order'))['order__max'] or 0 + order = max_order + 1 + else: + order = 0 + sp = doc.presentations.create( + session_id=session_id, + rev=rev, + order=order, + ) + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.add(sp.session, doc, order=sp.order) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.add(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "%s to session: %s" % ('Added -%s'%rev if rev else 'Added', Session.objects.get(pk=session_id)) c.save() diff --git a/ietf/doc/views_material.py b/ietf/doc/views_material.py index 19bc02cfd..b646ecf2f 100644 --- a/ietf/doc/views_material.py +++ b/ietf/doc/views_material.py @@ -8,6 +8,7 @@ import os import re from django import forms +from django.conf import settings from django.contrib.auth.decorators import login_required from django.http import Http404 from django.shortcuts import render, get_object_or_404, redirect @@ -21,7 +22,9 @@ from ietf.doc.models import NewRevisionDocEvent from ietf.doc.utils import add_state_change_event, check_common_doc_name_rules from ietf.group.models import Group from ietf.group.utils import can_manage_materials +from ietf.utils import log from ietf.utils.decorators import ignore_view_kwargs +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager from ietf.utils.response import permission_denied @login_required @@ -123,6 +126,8 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): if not can_manage_materials(request.user, group): permission_denied(request, "You don't have permission to access this view") + sessions_with_slide_title_updates = set() + if request.method == 'POST': form = UploadMaterialForm(document_type, action, group, doc, request.POST, request.FILES) @@ -175,6 +180,9 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): e.desc += " from %s" % prev_title e.save() events.append(e) + if doc.type_id == "slides": + for sp in doc.presentations.all(): + sessions_with_slide_title_updates.add(sp.session) if prev_abstract != doc.abstract: e = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type='changed_document') @@ -192,6 +200,16 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): if events: doc.save_with_history(events) + # Call Meetecho API if any session slides titles changed + if sessions_with_slide_title_updates and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + for session in sessions_with_slide_title_updates: + try: + # SessionPresentations are unique over (session, document) so there will be no duplicates + sm.send_update(session) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.send_update(): {err}") + return redirect("ietf.doc.views_doc.document_main", name=doc.name) else: form = UploadMaterialForm(document_type, action, group, doc) 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 000000000..09a8d90e0 --- /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 781ced787..dd6e2db6c 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1426,6 +1426,8 @@ class MeetingHost(models.Model): 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 092f8be89..8d355d97e 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.exceptions 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 @@ import debug # pyflakes:ignore 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 @@ class MeetingTests(BaseMeetingTestCase): 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( @@ -3020,7 +3020,9 @@ class EditTimeslotsTests(TestCase): class ReorderSlidesTests(TestCase): - def test_add_slides_to_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_add_slides_to_session(self, mock_slides_manager_cls): for type_id in ('ietf','interim'): chair_role = RoleFactory(name_id='chair') session = SessionFactory(group=chair_role.group, meeting__date=date_today() - datetime.timedelta(days=90), meeting__type_id=type_id) @@ -3031,6 +3033,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3038,6 +3041,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'order':0, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3047,49 +3051,62 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('No data',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'garbage':'garbage'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':0, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':2, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':'garbage', 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Invalid name r = self.client.post(url, {'order':1 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':1, 'name':'garbage' }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Valid post r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(session.presentations.count(),1) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session, slides=slides, order=1)) + mock_slides_manager_cls.reset_mock() # Ignore a request to add slides that are already in a session r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(session.presentations.count(),1) + self.assertFalse(mock_slides_manager_cls.called) session2 = SessionFactory(group=session.group, meeting=session.meeting) @@ -3108,6 +3125,11 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertEqual(session2.presentations.get(document=more_slides[0]).order,1) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,5))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[0], order=1)) + mock_slides_manager_cls.reset_mock() # Insert at end r = self.client.post(url, {'order':5, 'name':more_slides[1].name}) @@ -3115,6 +3137,11 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertEqual(session2.presentations.get(document=more_slides[1]).order,5) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,6))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[1], order=5)) + mock_slides_manager_cls.reset_mock() # Insert in middle r = self.client.post(url, {'order':3, 'name':more_slides[2].name}) @@ -3122,8 +3149,15 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertEqual(session2.presentations.get(document=more_slides[2]).order,3) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,7))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[2], order=3)) + mock_slides_manager_cls.reset_mock() - def test_remove_slides_from_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_remove_slides_from_session(self, mock_slides_manager_cls): for type_id in ['ietf','interim']: chair_role = RoleFactory(name_id='chair') session = SessionFactory(group=chair_role.group, meeting__date=date_today()-datetime.timedelta(days=90), meeting__type_id=type_id) @@ -3134,6 +3168,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3141,6 +3176,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'oldIndex':0, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3150,27 +3186,32 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('No data',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'garbage':'garbage'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':0, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':'garbage', 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) - + self.assertFalse(mock_slides_manager_cls.called) + # No matching thing to delete r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) session.presentations.create(document=slides, rev=slides.rev, order=1) @@ -3179,11 +3220,13 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':1, 'name':'garbage' }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) slides2 = DocumentFactory(type_id='slides') @@ -3192,18 +3235,25 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('SessionPresentation not found',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) session.presentations.create(document=slides2, rev=slides2.rev, order=2) r = self.client.post(url, {'oldIndex':1, 'name':slides2.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('Name does not match index',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # valid removal r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(session.presentations.count(),1) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session, slides=slides)) + mock_slides_manager_cls.reset_mock() session2 = SessionFactory(group=session.group, meeting=session.meeting) sp_list = SessionPresentationFactory.create_batch(5, document__type_id='slides', session=session2) @@ -3219,6 +3269,11 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertFalse(session2.presentations.filter(pk=sp_list[0].pk).exists()) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,5))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[0].document)) + mock_slides_manager_cls.reset_mock() # delete in middle of list r = self.client.post(url, {'oldIndex':4, 'name':sp_list[4].document.name }) @@ -3226,6 +3281,11 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertFalse(session2.presentations.filter(pk=sp_list[4].pk).exists()) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,4))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[4].document)) + mock_slides_manager_cls.reset_mock() # delete at end of list r = self.client.post(url, {'oldIndex':2, 'name':sp_list[2].document.name }) @@ -3233,11 +3293,15 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertFalse(session2.presentations.filter(pk=sp_list[2].pk).exists()) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,3))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[2].document)) + mock_slides_manager_cls.reset_mock() - - - - def test_reorder_slides_in_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_reorder_slides_in_session(self, mock_slides_manager_cls): def _sppk_at(sppk, positions): return [sppk[p-1] for p in positions] chair_role = RoleFactory(name_id='chair') @@ -3259,6 +3323,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'oldIndex':1, 'newIndex':2 }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3266,6 +3331,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'oldIndex':1, 'newIndex':2 }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3275,57 +3341,95 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':2, 'newIndex':6 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':2, 'newIndex':2 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Move from beginning r = self.client.post(url, {'oldIndex':1, 'newIndex':3}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,1,4,5])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move to beginning r = self.client.post(url, {'oldIndex':3, 'newIndex':1}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,3,4,5])) - + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() + # Move from end r = self.client.post(url, {'oldIndex':5, 'newIndex':3}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,5,3,4])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move to end r = self.client.post(url, {'oldIndex':3, 'newIndex':5}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,3,4,5])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move beginning to end r = self.client.post(url, {'oldIndex':1, 'newIndex':5}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,4,5,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move middle to middle r = self.client.post(url, {'oldIndex':3, 'newIndex':4}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,5,4,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() r = self.client.post(url, {'oldIndex':3, 'newIndex':2}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,5,3,4,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Reset for next iteration in the loop session.presentations.update(order=F('pk')) @@ -5997,6 +6101,34 @@ class FinalizeProceedingsTests(TestCase): 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', @@ -6300,7 +6432,9 @@ class MaterialsTests(TestCase): doc = Document.objects.get(pk=doc.pk) self.assertEqual(doc.rev,'02') - def test_upload_slides(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_upload_slides(self, mock_slides_manager_cls): session1 = SessionFactory(meeting__type_id='ietf') session2 = SessionFactory(meeting=session1.meeting,group=session1.group) @@ -6308,6 +6442,7 @@ class MaterialsTests(TestCase): login_testing_unauthorized(self,"secretary",url) r = self.client.get(url) self.assertEqual(r.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) q = PyQuery(r.content) self.assertIn('Upload', str(q("title"))) self.assertFalse(session1.presentations.filter(document__type_id='slides')) @@ -6320,6 +6455,18 @@ class MaterialsTests(TestCase): sp = session2.presentations.first() self.assertEqual(sp.document.name, 'slides-%s-%s-a-test-slide-file' % (session1.meeting.number,session1.group.acronym ) ) self.assertEqual(sp.order,1) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 2) + # don't care which order they were called in, just that both sessions were updated + self.assertCountEqual( + mock_slides_manager_cls.return_value.add.call_args_list, + [ + call(session=session1, slides=sp.document, order=1), + call(session=session2, slides=sp.document, order=1), + ], + ) + mock_slides_manager_cls.reset_mock() url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session2.meeting.number,'session_id':session2.id}) test_file = BytesIO(b'some other thing still not slidelike') @@ -6332,6 +6479,14 @@ class MaterialsTests(TestCase): self.assertEqual(sp.order,2) self.assertEqual(sp.rev,'00') self.assertEqual(sp.document.rev,'00') + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.add.call_args, + call(session=session2, slides=sp.document, order=2), + ) + mock_slides_manager_cls.reset_mock() url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session2.meeting.number,'session_id':session2.id,'name':session2.presentations.get(order=2).document.name}) r = self.client.get(url) @@ -6344,10 +6499,17 @@ class MaterialsTests(TestCase): self.assertEqual(r.status_code, 302) self.assertEqual(session1.presentations.count(),1) self.assertEqual(session2.presentations.count(),2) - sp = session2.presentations.get(order=2) - self.assertEqual(sp.rev,'01') - self.assertEqual(sp.document.rev,'01') - + replacement_sp = session2.presentations.get(order=2) + self.assertEqual(replacement_sp.rev,'01') + self.assertEqual(replacement_sp.document.rev,'01') + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.revise.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.revise.call_args, + call(session=session2, slides=sp.document), + ) + def test_upload_slide_title_bad_unicode(self): session1 = SessionFactory(meeting__type_id='ietf') url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session1.meeting.number,'session_id':session1.id}) @@ -6365,29 +6527,61 @@ class MaterialsTests(TestCase): self.assertTrue(q('form .is-invalid')) self.assertIn("Unicode BMP", q('form .is-invalid div').text()) - def test_remove_sessionpresentation(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_remove_sessionpresentation(self, mock_slides_manager_cls): session = SessionFactory(meeting__type_id='ietf') + agenda = DocumentFactory(type_id='agenda') doc = DocumentFactory(type_id='slides') + session.presentations.create(document=agenda) session.presentations.create(document=doc) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':session.id,'name':'no-such-doc'}) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':0,'name':doc.name}) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':session.id,'name':doc.name}) login_testing_unauthorized(self,"secretary",url) response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) - self.assertEqual(1,session.presentations.count()) + # Removing slides should remove the materials and call MeetechoAPI + self.assertEqual(2, session.presentations.count()) response = self.client.post(url,{'remove_session':''}) self.assertEqual(response.status_code, 302) - self.assertEqual(0,session.presentations.count()) - self.assertEqual(2,doc.docevent_set.count()) + self.assertEqual(1, session.presentations.count()) + self.assertEqual(2, doc.docevent_set.count()) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.delete.call_args, + call(session=session, slides=doc), + ) + mock_slides_manager_cls.reset_mock() + + # Removing non-slides should only remove the materials + url = urlreverse( + "ietf.meeting.views.remove_sessionpresentation", + kwargs={ + "num": session.meeting.number, + "session_id": session.id, + "name": agenda.name, + }, + ) + response = self.client.post(url, {"remove_session" : ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(0, session.presentations.count()) + self.assertEqual(2, agenda.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + def test_propose_session_slides(self): for type_id in ['ietf','interim']: @@ -7811,7 +8005,7 @@ class ProceedingsTests(BaseMeetingTestCase): 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') @@ -7932,7 +8126,6 @@ class ProceedingsTests(BaseMeetingTestCase): - 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') @@ -7957,9 +8150,14 @@ class ProceedingsTests(BaseMeetingTestCase): '''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') @@ -8362,3 +8560,127 @@ class ProceedingsTests(BaseMeetingTestCase): 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:00Z") # joined 2nd + attended_no_affil = MeetingRegistrationFactory(meeting=session.meeting) + AttendedFactory(session=session, person=attended_no_affil.person, time="2023-03-13T01:23:00Z") # 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 1c6e49213..26d3d93b2 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 @@ class AgendaRedirectView(RedirectView): 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+)/narrativeminutes$', views.upload_session_narrativeminutes), diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 6469dbfbb..8d44cb7c1 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -5,6 +5,7 @@ import itertools import os import pytz import subprocess +import tempfile from collections import defaultdict from pathlib import Path @@ -12,6 +13,7 @@ from pathlib import Path 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.models import Group 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 6de8360b6..5bb1a922b 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -19,6 +19,7 @@ import shutil 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.group.utils import can_manage_session_materials, can_manage_some_group 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,14 +85,15 @@ from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments, bulk_ 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 from ietf.utils import markdown from ietf.utils.decorators import require_api_key from ietf.utils.hedgedoc import Note, NoteError -from ietf.utils.log import assertion +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager +from ietf.utils.log import assertion, log from ietf.utils.mail import send_mail_message, send_mail_text from ietf.utils.mime import get_mime_type from ietf.utils.pipe import pipe @@ -1694,45 +1696,58 @@ def api_get_agenda_data (request, num=None): "floors": list(map(agenda_extract_floorplan, floors)) }) -def api_get_session_materials (request, session_id=None): - session = get_object_or_404(Session,pk=session_id) + +def api_get_session_materials(request, session_id=None): + session = get_object_or_404(Session, pk=session_id) minutes = session.minutes() slides_actions = [] if can_manage_session_materials(request.user, session.group, session): - slides_actions.append({ - 'label': 'Upload slides', - 'url': reverse( - 'ietf.meeting.views.upload_session_slides', - kwargs={'num': session.meeting.number, 'session_id': session.pk}, - ), - }) + slides_actions.append( + { + "label": "Upload slides", + "url": reverse( + "ietf.meeting.views.upload_session_slides", + kwargs={"num": session.meeting.number, "session_id": session.pk}, + ), + } + ) elif not session.is_material_submission_cutoff(): - slides_actions.append({ - 'label': 'Propose slides', - 'url': reverse( - 'ietf.meeting.views.propose_session_slides', - kwargs={'num': session.meeting.number, 'session_id': session.pk}, - ), - }) + slides_actions.append( + { + "label": "Propose slides", + "url": reverse( + "ietf.meeting.views.propose_session_slides", + kwargs={"num": session.meeting.number, "session_id": session.pk}, + ), + } + ) else: pass # no action available if it's past cutoff - - agenda = session.agenda() + + agenda = session.agenda() agenda_url = agenda.get_href() if agenda is not None else None - return JsonResponse({ - "url": agenda_url, - "slides": { - "decks": list(map(agenda_extract_slide, session.slides())), - "actions": slides_actions, - }, - "minutes": { - "id": minutes.id, - "title": minutes.title, - "url": minutes.get_href(), - "ext": minutes.file_extension() - } if minutes is not None else None - }) + return JsonResponse( + { + "url": agenda_url, + "slides": { + "decks": [ + agenda_extract_slide(slide) | {"order": order} # add "order" field + for order, slide in enumerate(session.slides()) + ], + "actions": slides_actions, + }, + "minutes": { + "id": minutes.id, + "title": minutes.title, + "url": minutes.get_href(), + "ext": minutes.file_extension(), + } + if minutes is not None + else None, + } + ) + def agenda_extract_schedule (item): return { @@ -1755,9 +1770,9 @@ def agenda_extract_schedule (item): "filterKeywords": item.filter_keywords, "groupAcronym": item.session.group_at_the_time().acronym, "groupName": item.session.group_at_the_time().name, - "groupParent": { + "groupParent": ({ "acronym": item.session.group_parent_at_the_time().acronym - } if item.session.group_parent_at_the_time() else {}, + } if item.session.group_parent_at_the_time() else {}), "note": item.session.agenda_note, "remoteInstructions": item.session.remote_instructions, "flags": { @@ -1790,7 +1805,8 @@ def agenda_extract_schedule (item): # } } -def agenda_extract_floorplan (item): + +def agenda_extract_floorplan(item): try: item.image.width except FileNotFoundError: @@ -1803,10 +1819,11 @@ def agenda_extract_floorplan (item): "short": item.short, "width": item.image.width, "height": item.image.height, - "rooms": list(map(agenda_extract_room, item.room_set.all())) + "rooms": list(map(agenda_extract_room, item.room_set.all())), } -def agenda_extract_room (item): + +def agenda_extract_room(item): return { "id": item.id, "name": item.name, @@ -1818,7 +1835,8 @@ def agenda_extract_room (item): "bottom": item.bottom() } -def agenda_extract_recording (item): + +def agenda_extract_recording(item): return { "id": item.id, "name": item.name, @@ -1826,14 +1844,17 @@ def agenda_extract_recording (item): "url": item.external_url } -def agenda_extract_slide (item): + +def agenda_extract_slide(item): return { "id": item.id, "title": item.title, + "rev": item.rev, "url": item.get_versionless_href(), - "ext": item.file_extension() + "ext": item.file_extension(), } + def agenda_csv(schedule, filtered_assignments, utc=False): encoding = 'utf-8' response = HttpResponse(content_type=f"text/csv; charset={encoding}") @@ -2428,8 +2449,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') @@ -2518,6 +2550,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) @@ -2565,47 +2657,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) @@ -2869,103 +2920,175 @@ def upload_session_agenda(request, session_id, num): def upload_session_slides(request, session_id, num, name=None): + """Upload new or replacement slides for a session + + If name is None or "", expects a new set of slides. Otherwise, replaces the named slides with a new rev. + """ # 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) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) session_number = None - sessions = get_sessions(session.meeting.number,session.group.acronym) - show_apply_to_all_checkbox = len(sessions) > 1 if session.type_id == 'regular' else False + sessions = get_sessions(session.meeting.number, session.group.acronym) + show_apply_to_all_checkbox = ( + len(sessions) > 1 if session.type_id == "regular" else False + ) if len(sessions) > 1: - session_number = 1 + sessions.index(session) + session_number = 1 + sessions.index(session) - slides = None - slides_sp = None + doc = None if name: - slides = Document.objects.filter(name=name).first() - if not (slides and slides.type_id=='slides'): - raise Http404 - slides_sp = session.presentations.filter(document=slides).first() - - if request.method == 'POST': - form = UploadSlidesForm(session, show_apply_to_all_checkbox,request.POST,request.FILES) + doc = get_object_or_404( + session.presentations, document__name=name, document__type_id="slides" + ).document + + if request.method == "POST": + form = UploadSlidesForm( + session, show_apply_to_all_checkbox, request.POST, request.FILES + ) if form.is_valid(): - file = request.FILES['file'] + file = request.FILES["file"] _, ext = os.path.splitext(file.name) - apply_to_all = session.type_id == 'regular' + apply_to_all = session.type_id == "regular" if show_apply_to_all_checkbox: - apply_to_all = form.cleaned_data['apply_to_all'] - if slides_sp: - doc = slides_sp.document - doc.rev = '%02d' % (int(doc.rev)+1) - doc.title = form.cleaned_data['title'] - slides_sp.rev = doc.rev - slides_sp.save() + apply_to_all = form.cleaned_data["apply_to_all"] + + # Handle creation / update of the Document (but do not save yet) + if doc is not None: + # This is a revision - bump the version and update the title. + doc.rev = "%02d" % (int(doc.rev) + 1) + doc.title = form.cleaned_data["title"] else: - title = form.cleaned_data['title'] - if session.meeting.type_id=='ietf': - name = 'slides-%s-%s' % (session.meeting.number, - session.group.acronym) + # This is a new slide deck - create a new doc unless one exists with that name + title = form.cleaned_data["title"] + if session.meeting.type_id == "ietf": + name = "slides-%s-%s" % ( + session.meeting.number, + session.group.acronym, + ) if not apply_to_all: - name += '-%s' % (session.docname_token(),) + name += "-%s" % (session.docname_token(),) else: - name = 'slides-%s-%s' % (session.meeting.number, session.docname_token()) - name = name + '-' + slugify(title).replace('_', '-')[:128] + name = "slides-%s-%s" % ( + session.meeting.number, + session.docname_token(), + ) + name = name + "-" + slugify(title).replace("_", "-")[:128] if Document.objects.filter(name=name).exists(): - doc = Document.objects.get(name=name) - doc.rev = '%02d' % (int(doc.rev)+1) - doc.title = form.cleaned_data['title'] + doc = Document.objects.get(name=name) + doc.rev = "%02d" % (int(doc.rev) + 1) + doc.title = form.cleaned_data["title"] else: doc = Document.objects.create( - name = name, - type_id = 'slides', - title = title, - group = session.group, - rev = '00', - ) - doc.states.add(State.objects.get(type_id='slides',slug='active')) - doc.states.add(State.objects.get(type_id='reuse_policy',slug='single')) - if session.presentations.filter(document=doc).exists(): - sp = session.presentations.get(document=doc) - sp.rev = doc.rev - sp.save() - else: - max_order = session.presentations.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 - session.presentations.create(document=doc,rev=doc.rev,order=max_order+1) - if apply_to_all: - for other_session in sessions: - if other_session != session and not other_session.presentations.filter(document=doc).exists(): - max_order = other_session.presentations.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 - other_session.presentations.create(document=doc,rev=doc.rev,order=max_order+1) - filename = '%s-%s%s'% ( doc.name, doc.rev, ext) + name=name, + type_id="slides", + title=title, + group=session.group, + rev="00", + ) + doc.states.add(State.objects.get(type_id="slides", slug="active")) + doc.states.add(State.objects.get(type_id="reuse_policy", slug="single")) + + # Now handle creation / update of the SessionPresentation(s) + sessions_to_apply = sessions if apply_to_all else [session] + added_presentations = [] + revised_presentations = [] + for sess in sessions_to_apply: + sp = sess.presentations.filter(document=doc).first() + if sp is not None: + sp.rev = doc.rev + sp.save() + revised_presentations.append(sp) + else: + max_order = ( + sess.presentations.filter(document__type="slides").aggregate( + Max("order") + )["order__max"] + or 0 + ) + sp = sess.presentations.create( + document=doc, rev=doc.rev, order=max_order + 1 + ) + added_presentations.append(sp) + + # Now handle the uploaded file + filename = "%s-%s%s" % (doc.name, doc.rev, ext) doc.uploaded_filename = filename - e = NewRevisionDocEvent.objects.create(doc=doc,by=request.user.person,type='new_revision',desc='New revision available: %s'%doc.rev,rev=doc.rev) + e = NewRevisionDocEvent.objects.create( + doc=doc, + by=request.user.person, + type="new_revision", + desc="New revision available: %s" % doc.rev, + rev=doc.rev, + ) # The way this function builds the filename it will never trigger the file delete in handle_file_upload. - save_error = handle_upload_file(file, filename, session.meeting, 'slides', request=request, encoding=form.file_encoding[file.name]) + save_error = handle_upload_file( + file, + filename, + session.meeting, + "slides", + request=request, + encoding=form.file_encoding[file.name], + ) if save_error: form.add_error(None, save_error) else: doc.save_with_history([e]) post_process(doc) + + # Send MeetEcho updates even if we had a problem saving - that will keep it in sync with the + # SessionPresentation, which was already saved regardless of problems saving the file. + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + for sp in added_presentations: + try: + sm.add(session=sp.session, slides=doc, order=sp.order) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.add(): {err}") + for sp in revised_presentations: + try: + sm.revise(session=sp.session, slides=doc) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.revise(): {err}") + + if not save_error: messages.success( request, - f'Successfully uploaded slides as revision {doc.rev} of {doc.name}.') - return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym) - else: + f"Successfully uploaded slides as revision {doc.rev} of {doc.name}.", + ) + return redirect( + "ietf.meeting.views.session_details", + num=num, + acronym=session.group.acronym, + ) + else: initial = {} - if slides: - initial = {'title':slides.title} + if doc is not None: + initial = {"title": doc.title} form = UploadSlidesForm(session, show_apply_to_all_checkbox, initial=initial) - return render(request, "meeting/upload_session_slides.html", - {'session': session, - 'session_number': session_number, - 'slides_sp' : slides_sp, - 'form': form, - }) + return render( + request, + "meeting/upload_session_slides.html", + { + "session": session, + "session_number": session_number, + "slides_sp": session.presentations.filter(document=doc).first() if doc else None, + "form": form, + }, + ) + + @login_required def propose_session_slides(request, session_id, num): session = get_object_or_404(Session,pk=session_id) @@ -3031,135 +3154,284 @@ def propose_session_slides(request, session_id, num): 'form': form, }) + def remove_sessionpresentation(request, session_id, num, name): - sp = get_object_or_404(SessionPresentation,session_id=session_id,document__name=name) + sp = get_object_or_404( + SessionPresentation, session_id=session_id, document__name=name + ) session = sp.session if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to manage materials for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") - if request.method == 'POST': + permission_denied( + request, "You don't have permission to manage materials for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) + if request.method == "POST": session.presentations.filter(pk=sp.pk).delete() - c = DocEvent(type="added_comment", doc=sp.document, rev=sp.document.rev, by=request.user.person) + c = DocEvent( + type="added_comment", + doc=sp.document, + rev=sp.document.rev, + by=request.user.person, + ) c.desc = "Removed from session: %s" % (session) c.save() - messages.success(request, f'Successfully removed {name}.') - return redirect('ietf.meeting.views.session_details', num=session.meeting.number, acronym=session.group.acronym) + messages.success(request, f"Successfully removed {name}.") + if sp.document.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(session=session, slides=sp.document) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.delete(): {err}") + + return redirect( + "ietf.meeting.views.session_details", + num=session.meeting.number, + acronym=session.group.acronym, + ) + + return render(request, "meeting/remove_sessionpresentation.html", {"sp": sp}) - return render(request,'meeting/remove_sessionpresentation.html', {'sp': sp }) def ajax_add_slides_to_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - order_str = request.POST.get('order', None) + order_str = request.POST.get("order", None) try: order = int(order_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied order is not valid' }),content_type='application/json') - if order < 1 or order > session.presentations.filter(document__type_id='slides').count() + 1 : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied order is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied order is not valid"}), + content_type="application/json", + ) + if ( + order < 1 + or order > session.presentations.filter(document__type_id="slides").count() + 1 + ): + return HttpResponse( + json.dumps({"success": False, "error": "Supplied order is not valid"}), + content_type="application/json", + ) - name = request.POST.get('name', None) + name = request.POST.get("name", None) doc = Document.objects.filter(name=name).first() if not doc: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied name is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied name is not valid"}), + content_type="application/json", + ) if not session.presentations.filter(document=doc).exists(): condition_slide_order(session) - session.presentations.filter(document__type_id='slides', order__gte=order).update(order=F('order')+1) - session.presentations.create(document=doc,rev=doc.rev,order=order) - DocEvent.objects.create(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person, desc="Added to session: %s" % session) + session.presentations.filter( + document__type_id="slides", order__gte=order + ).update(order=F("order") + 1) + session.presentations.create(document=doc, rev=doc.rev, order=order) + DocEvent.objects.create( + type="added_comment", + doc=doc, + rev=doc.rev, + by=request.user.person, + desc="Added to session: %s" % session, + ) - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + # Notify Meetecho of new slides if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.add(session=session, slides=doc, order=order) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.add(): {err}") + + return HttpResponse(json.dumps({"success": True}), content_type="application/json") def ajax_remove_slides_from_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - oldIndex_str = request.POST.get('oldIndex', None) + oldIndex_str = request.POST.get("oldIndex", None) try: oldIndex = int(oldIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if oldIndex < 1 or oldIndex > session.presentations.filter(document__type_id='slides').count() : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if ( + oldIndex < 1 + or oldIndex > session.presentations.filter(document__type_id="slides").count() + ): + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) - name = request.POST.get('name', None) + name = request.POST.get("name", None) doc = Document.objects.filter(name=name).first() if not doc: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied name is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied name is not valid"}), + content_type="application/json", + ) condition_slide_order(session) affected_presentations = session.presentations.filter(document=doc).first() if affected_presentations: if affected_presentations.order == oldIndex: affected_presentations.delete() - session.presentations.filter(document__type_id='slides', order__gt=oldIndex).update(order=F('order')-1) - DocEvent.objects.create(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person, desc="Removed from session: %s" % session) - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + session.presentations.filter( + document__type_id="slides", order__gt=oldIndex + ).update(order=F("order") - 1) + DocEvent.objects.create( + type="added_comment", + doc=doc, + rev=doc.rev, + by=request.user.person, + desc="Removed from session: %s" % session, + ) + # Notify Meetecho of removed slides if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(session=session, slides=doc) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.delete(): {err}") + # Report success + return HttpResponse( + json.dumps({"success": True}), content_type="application/json" + ) else: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Name does not match index' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Name does not match index"}), + content_type="application/json", + ) else: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'SessionPresentation not found' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "SessionPresentation not found"}), + content_type="application/json", + ) def ajax_reorder_slides_in_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - num_slides_in_session = session.presentations.filter(document__type_id='slides').count() - oldIndex_str = request.POST.get('oldIndex', None) + session_slides = session.presentations.filter(document__type_id="slides") + num_slides_in_session = session_slides.count() + oldIndex_str = request.POST.get("oldIndex", None) try: oldIndex = int(oldIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if oldIndex < 1 or oldIndex > num_slides_in_session : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if oldIndex < 1 or oldIndex > num_slides_in_session: + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) - newIndex_str = request.POST.get('newIndex', None) + newIndex_str = request.POST.get("newIndex", None) try: newIndex = int(newIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if newIndex < 1 or newIndex > num_slides_in_session : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if newIndex < 1 or newIndex > num_slides_in_session: + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) if newIndex == oldIndex: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) condition_slide_order(session) - sp = session.presentations.get(order=oldIndex) + sp = session_slides.get(order=oldIndex) if oldIndex < newIndex: - session.presentations.filter(order__gt=oldIndex, order__lte=newIndex).update(order=F('order')-1) + session_slides.filter(order__gt=oldIndex, order__lte=newIndex).update( + order=F("order") - 1 + ) else: - session.presentations.filter(order__gte=newIndex, order__lt=oldIndex).update(order=F('order')+1) + session_slides.filter(order__gte=newIndex, order__lt=oldIndex).update( + order=F("order") + 1 + ) sp.order = newIndex sp.save() - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + # Update slide order with Meetecho if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.send_update(session) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.send_update(): {err}") + + return HttpResponse(json.dumps({"success": True}), content_type="application/json") @role_required('Secretariat') @@ -3847,6 +4119,8 @@ def organize_proceedings_sessions(sessions): '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: @@ -3941,12 +4215,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,}) @@ -4150,37 +4423,111 @@ def deprecated_api_set_session_video_url(request): 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/settings.py b/ietf/settings.py index 57e6f20bd..dca3fb132 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -1173,6 +1173,10 @@ CELERY_TASK_IGNORE_RESULT = True # ignore results unless specifically enabled f # 'client_id': 'datatracker', # 'client_secret': 'some secret', # 'request_timeout': 3.01, # python-requests doc recommend slightly > a multiple of 3 seconds +# # How many minutes before/after session to enable slide update API. Defaults to 15. Set to None to disable, +# # or < 0 to _always_ send updates (useful for debugging) +# 'slides_notify_time': 15, +# 'debug': False, # if True, API calls will be echoed as debug instead of sent (only works for slides for now) # } # Meetecho URLs - instantiate with url.format(session=some_session) diff --git a/ietf/stats/models.py b/ietf/stats/models.py index 699334392..66e359f50 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 000000000..5a9aa2dce --- /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 aea20827d..74ed8bafc 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 666685000..95d6dc5da 100644 --- a/ietf/templates/meeting/group_proceedings.html +++ b/ietf/templates/meeting/group_proceedings.html @@ -47,13 +47,25 @@
{% 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 8cad89179..df0f57cae 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 %} @@ -91,6 +91,13 @@ {% endfor %} + {% if session.bluesheet_title %} + + {% endif %} {% endif %}
+ + {{ session.bluesheet_title }} + +
diff --git a/ietf/utils/meetecho.py b/ietf/utils/meetecho.py index 26ae93f03..e842ca012 100644 --- a/ietf/utils/meetecho.py +++ b/ietf/utils/meetecho.py @@ -1,9 +1,10 @@ -# Copyright The IETF Trust 2021, All Rights Reserved +# Copyright The IETF Trust 2021-2024, All Rights Reserved # """Meetecho interim meeting scheduling API Implements the v1 API described in email from alex@meetecho.com -on 2021-12-09. +on 2021-12-09, plus additional slide management API discussed via +IM in 2024 Feb. API methods return Python objects equivalent to the JSON structures specified in the API documentation. Times and durations are represented @@ -13,29 +14,36 @@ import requests import debug # pyflakes: ignore -from datetime import datetime, timedelta +import datetime from json import JSONDecodeError -from pytz import utc -from typing import Dict, Sequence, Union +from pprint import pformat +from typing import Sequence, TypedDict, TYPE_CHECKING, Union from urllib.parse import urljoin +# Guard against hypothetical cyclical import problems +if TYPE_CHECKING: + from ietf.doc.models import Document + from ietf.meeting.models import Session + class MeetechoAPI: - timezone = utc + timezone = datetime.timezone.utc - def __init__(self, api_base: str, client_id: str, client_secret: str, request_timeout=3.01): + def __init__( + self, api_base: str, client_id: str, client_secret: str, request_timeout=3.01 + ): self.client_id = client_id self.client_secret = client_secret self.request_timeout = request_timeout # python-requests doc recommend slightly > a multiple of 3 seconds self._session = requests.Session() # if needed, add a trailing slash so urljoin won't eat the trailing path component - self.api_base = api_base if api_base.endswith('/') else f'{api_base}/' + self.api_base = api_base if api_base.endswith("/") else f"{api_base}/" def _request(self, method, url, api_token=None, json=None): """Execute an API request""" - headers = {'Accept': 'application/json'} + headers = {"Accept": "application/json"} if api_token is not None: - headers['Authorization'] = f'bearer {api_token}' + headers["Authorization"] = f"bearer {api_token}" try: response = self._session.request( @@ -47,28 +55,31 @@ class MeetechoAPI: ) except requests.RequestException as err: raise MeetechoAPIError(str(err)) from err - if response.status_code != 200: - raise MeetechoAPIError(f'API request failed (HTTP status code = {response.status_code})') + if response.status_code not in (200, 202): + # Could be more selective about status codes, but not seeing an immediate need + raise MeetechoAPIError( + f"API request failed (HTTP status code = {response.status_code})" + ) # try parsing the result as JSON in case the server failed to set the Content-Type header try: return response.json() except JSONDecodeError as err: - if response.headers['Content-Type'].startswith('application/json'): + if response.headers.get("Content-Type", "").startswith("application/json"): # complain if server told us to expect JSON and it was invalid - raise MeetechoAPIError('Error decoding response as JSON') from err + raise MeetechoAPIError("Error decoding response as JSON") from err return None - def _deserialize_time(self, s: str) -> datetime: - return self.timezone.localize(datetime.strptime(s, '%Y-%m-%d %H:%M:%S')) + def _deserialize_time(self, s: str) -> datetime.datetime: + return datetime.datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=self.timezone) - def _serialize_time(self, dt: datetime) -> str: - return dt.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S') + def _serialize_time(self, dt: datetime.datetime) -> str: + return dt.astimezone(self.timezone).strftime("%Y-%m-%d %H:%M:%S") - def _deserialize_duration(self, minutes: int) -> timedelta: - return timedelta(minutes=minutes) + def _deserialize_duration(self, minutes: int) -> datetime.timedelta: + return datetime.timedelta(minutes=minutes) - def _serialize_duration(self, td: timedelta) -> int: + def _serialize_duration(self, td: datetime.timedelta) -> int: return int(td.total_seconds() // 60) def _deserialize_meetings_response(self, response): @@ -76,30 +87,41 @@ class MeetechoAPI: Deserializes data in the structure where needed (currently, that's time-related structures) """ - for session_data in response['rooms'].values(): - session_data['room']['start_time'] = self._deserialize_time(session_data['room']['start_time']) - session_data['room']['duration'] = self._deserialize_duration(session_data['room']['duration']) + for session_data in response["rooms"].values(): + session_data["room"]["start_time"] = self._deserialize_time( + session_data["room"]["start_time"] + ) + session_data["room"]["duration"] = self._deserialize_duration( + session_data["room"]["duration"] + ) return response def retrieve_wg_tokens(self, acronyms: Union[str, Sequence[str]]): """Retrieve API tokens for one or more WGs - :param acronyms: list of WG acronyms for which tokens are requested + :param acronyms: list of WG acronyms for which tokens are requested :return: {'tokens': {acronym0: token0, acronym1: token1, ...}} """ return self._request( - 'POST', 'auth/ietfservice/tokens', + "POST", + "auth/ietfservice/tokens", json={ - 'client': self.client_id, - 'secret': self.client_secret, - 'wgs': [acronyms] if isinstance(acronyms, str) else acronyms, - } + "client": self.client_id, + "secret": self.client_secret, + "wgs": [acronyms] if isinstance(acronyms, str) else acronyms, + }, ) - def schedule_meeting(self, wg_token: str, description: str, start_time: datetime, duration: timedelta, - extrainfo=''): + def schedule_meeting( + self, + wg_token: str, + description: str, + start_time: datetime.datetime, + duration: datetime.timedelta, + extrainfo="", + ): """Schedule a meeting session - + Return structure is: { "rooms": { @@ -115,8 +137,8 @@ class MeetechoAPI: } } } - - :param wg_token: token retrieved via retrieve_wg_tokens() + + :param wg_token: token retrieved via retrieve_wg_tokens() :param description: str describing the meeting :param start_time: starting time as a datetime :param duration: duration as a timedelta @@ -125,13 +147,14 @@ class MeetechoAPI: """ return self._deserialize_meetings_response( self._request( - 'POST', 'meeting/interim/createRoom', + "POST", + "meeting/interim/createRoom", api_token=wg_token, json={ - 'description': description, - 'start_time': self._serialize_time(start_time), - 'duration': self._serialize_duration(duration), - 'extrainfo': extrainfo, + "description": description, + "start_time": self._serialize_time(start_time), + "duration": self._serialize_duration(duration), + "extrainfo": extrainfo, }, ) ) @@ -154,7 +177,7 @@ class MeetechoAPI: } } } - + As of 2022-01-31, the return structure also includes a 'group' key whose value is the group acronym. This is not shown in the documentation. @@ -162,7 +185,7 @@ class MeetechoAPI: :return: meeting data dict """ return self._deserialize_meetings_response( - self._request('GET', 'meeting/interim/fetchRooms', api_token=wg_token) + self._request("GET", "meeting/interim/fetchRooms", api_token=wg_token) ) def delete_meeting(self, deletion_token: str): @@ -171,7 +194,166 @@ class MeetechoAPI: :param deletion_token: deletion_key from fetch_meetings() or schedule_meeting() return data :return: {} """ - return self._request('POST', 'meeting/interim/deleteRoom', api_token=deletion_token) + return self._request( + "POST", "meeting/interim/deleteRoom", api_token=deletion_token + ) + + class SlideDeckDict(TypedDict): + id: int + title: str + url: str + rev: str + order: int + + def add_slide_deck( + self, + wg_token: str, + session: str, # unique identifier + deck: SlideDeckDict, + ): + """Add a slide deck for the specified session + + API spec: + â €POST /materials + + Authentication -> same as interim scheduler + + content application/json + + body + { + "session": String, // Unique session identifier + "title": String, + "id": Number, + "url": String, + "rev": String, + "order": Number + } + + + Results + 202 Accepted + {4xx} + """ + self._request( + "POST", + "materials", + api_token=wg_token, + json={ + "session": session, + "title": deck["title"], + "id": deck["id"], + "url": deck["url"], + "rev": deck["rev"], + "order": deck["order"], + }, + ) + + def delete_slide_deck( + self, + wg_token: str, + session: str, # unique identifier + id: int, + ): + """Delete a slide deck from the specified session + + API spec: + DELETE /materials + + Authentication -> same as interim scheduler + + content application/json + + body + { + "session": String, + "id": Number + } + + + Results + 202 Accepted + {4xx} + """ + self._request( + "DELETE", + "materials", + api_token=wg_token, + json={ + "session": session, + "id": id, + }, + ) + + def update_slide_decks( + self, + wg_token: str, + session: str, # unique id + decks: list[SlideDeckDict], + ): + """Update/reorder decks for specified session + + PUT /materials + + Authentication -> same as interim scheduler + + content application/json + + body + { + "session": String, + "decks": [ + { + "id": Number, + "title": String, + "url": String, + "rev": String, + "order": Number + }, + { + "id": Number, + "title": String, + "url": String, + "rev": String, + "order": Number + }, + ... + ] + } + + + Results + 202 Accepted + """ + self._request( + "PUT", + "materials", + api_token=wg_token, + json={ + "session": session, + "decks": decks, + } + ) + + +class DebugMeetechoAPI(MeetechoAPI): + """Meetecho API stand-in that writes to stdout instead of making requests""" + def _request(self, method, url, api_token=None, json=None): + json_lines = pformat(json, width=60).split("\n") + debug.say( + "\n" + + "\n".join( + [ + f">> MeetechoAPI: request(method={method},", + f">> MeetechoAPI: url={url},", + f">> MeetechoAPI: api_token={api_token},", + ">> MeetechoAPI: json=" + json_lines[0], + ( + ">> MeetechoAPI: " + + "\n>> MeetechoAPI: ".join(l for l in json_lines[1:]) + ), + ">> MeetechoAPI: )" + ] + ) + ) + + def retrieve_wg_tokens(self, acronyms: Union[str, Sequence[str]]): + super().retrieve_wg_tokens(acronyms) # so that we capture the outgoing request + acronyms = [acronyms] if isinstance(acronyms, str) else acronyms + return { + "tokens": { + acro: f"{acro}-token" + for acro in acronyms + } + } class MeetechoAPIError(Exception): @@ -180,7 +362,18 @@ class MeetechoAPIError(Exception): class Conference: """Scheduled session/room representation""" - def __init__(self, manager, id, public_id, description, start_time, duration, url, deletion_token): + + def __init__( + self, + manager, + id, + public_id, + description, + start_time, + duration, + url, + deletion_token, + ): self._manager = manager self.id = id # Meetecho system ID self.public_id = public_id # public session UUID @@ -195,22 +388,23 @@ class Conference: # Returns a list of Conferences return [ cls( - **val['room'], + **val["room"], public_id=public_id, - url=val['url'], - deletion_token=val['deletion_token'], + url=val["url"], + deletion_token=val["deletion_token"], manager=manager, - ) for public_id, val in api_dict.items() + ) + for public_id, val in api_dict.items() ] def __str__(self): - return f'Meetecho conference {self.description}' + return f"Meetecho conference {self.description}" def __repr__(self): props = [ f'description="{self.description}"', - f'start_time={repr(self.start_time)}', - f'duration={repr(self.duration)}', + f"start_time={repr(self.start_time)}", + f"duration={repr(self.duration)}", ] return f'Conference({", ".join(props)})' @@ -218,8 +412,13 @@ class Conference: return isinstance(other, type(self)) and all( getattr(self, attr) == getattr(other, attr) for attr in [ - 'id', 'public_id', 'description', 'start_time', - 'duration', 'url', 'deletion_token' + "id", + "public_id", + "description", + "start_time", + "duration", + "url", + "deletion_token", ] ) @@ -227,24 +426,36 @@ class Conference: self._manager.delete_conference(self) -class ConferenceManager: - def __init__(self, api_config: dict): - self.api = MeetechoAPI(**api_config) - self.wg_tokens: Dict[str, str] = {} - +class Manager: + def __init__(self, api_config): + api_kwargs = dict( + api_base=api_config["api_base"], + client_id=api_config["client_id"], + client_secret=api_config["client_secret"], + ) + if "request_timeout" in api_config: + api_kwargs["request_timeout"] = api_config["request_timeout"] + if api_config.get("debug", False): + self.api = DebugMeetechoAPI(**api_kwargs) + else: + self.api = MeetechoAPI(**api_kwargs) + self.wg_tokens = {} + def wg_token(self, group): - group_acronym = group.acronym if hasattr(group, 'acronym') else group + group_acronym = group.acronym if hasattr(group, "acronym") else group if group_acronym not in self.wg_tokens: - self.wg_tokens[group_acronym] = self.api.retrieve_wg_tokens( - group_acronym - )['tokens'][group_acronym] + self.wg_tokens[group_acronym] = self.api.retrieve_wg_tokens(group_acronym)[ + "tokens" + ][group_acronym] return self.wg_tokens[group_acronym] + +class ConferenceManager(Manager): def fetch(self, group): response = self.api.fetch_meetings(self.wg_token(group)) - return Conference.from_api_dict(self, response['rooms']) + return Conference.from_api_dict(self, response["rooms"]) - def create(self, group, description, start_time, duration, extrainfo=''): + def create(self, group, description, start_time, duration, extrainfo=""): response = self.api.schedule_meeting( wg_token=self.wg_token(group), description=description, @@ -252,12 +463,118 @@ class ConferenceManager: duration=duration, extrainfo=extrainfo, ) - return Conference.from_api_dict(self, response['rooms']) - + return Conference.from_api_dict(self, response["rooms"]) + def delete_by_url(self, group, url): for conf in self.fetch(group): if conf.url == url: self.api.delete_meeting(conf.deletion_token) def delete_conference(self, conf: Conference): - self.api.delete_meeting(conf.deletion_token) \ No newline at end of file + self.api.delete_meeting(conf.deletion_token) + + +class SlidesManager(Manager): + """Interface between Datatracker models and Meetecho API + + Note: The URL we send comes from get_versionless_href(). This should match what we use as the + URL in api_get_session_materials(). Additionally, it _must_ give the right result for a Document + instance that has not yet been persisted to the database. This is because upload_session_slides() + (as of 2024-03-07) SessionPresentations before saving its updated Documents. This means, for + example, using get_absolute_url() will cause bugs. (We should refactor upload_session_slides() to + avoid this requirement.) + """ + + def __init__(self, api_config): + super().__init__(api_config) + slides_notify_time = api_config.get("slides_notify_time", 15) + if slides_notify_time is None: + self.slides_notify_time = None + else: + self.slides_notify_time = datetime.timedelta(minutes=slides_notify_time) + + def _should_send_update(self, session): + if self.slides_notify_time is None: + return False + timeslot = session.official_timeslotassignment().timeslot + if timeslot is None: + return False + if self.slides_notify_time < datetime.timedelta(0): + return True # < 0 means "always" for a scheduled session + else: + now = datetime.datetime.now(tz=datetime.timezone.utc) + return (timeslot.time - self.slides_notify_time) < now < (timeslot.end_time() + self.slides_notify_time) + + def add(self, session: "Session", slides: "Document", order: int): + if not self._should_send_update(session): + return + + # Would like to confirm that session.presentations includes the slides Document, but we can't + # (same problem regarding unsaved Documents discussed in the docstring) + self.api.add_slide_deck( + wg_token=self.wg_token(session.group), + session=str(session.pk), + deck={ + "id": slides.pk, + "title": slides.title, + "url": slides.get_versionless_href(), # see above note re: get_versionless_href() + "rev": slides.rev, + "order": order, + } + ) + + def delete(self, session: "Session", slides: "Document"): + """Delete a slide deck from the session""" + if not self._should_send_update(session): + return + + if session.presentations.filter(document=slides).exists(): + # "order" problems are very likely to result if we delete slides that are actually still + # linked to the session + raise MeetechoAPIError( + f"Slides {slides.pk} are still linked to session {session.pk}." + ) + # remove, leaving a hole + self.api.delete_slide_deck( + wg_token=self.wg_token(session.group), + session=str(session.pk), + id=slides.pk, + ) + if session.presentations.filter(document__type_id="slides").exists(): + self.send_update(session) # adjust order to fill in the hole + + def revise(self, session: "Session", slides: "Document"): + """Replace existing deck with its current state""" + if not self._should_send_update(session): + return + + sp = session.presentations.filter(document=slides).first() + if sp is None: + raise MeetechoAPIError(f"Slides {slides.pk} not in session {session.pk}") + order = sp.order + # remove, leaving a hole in the order on Meetecho's side + self.api.delete_slide_deck( + wg_token=self.wg_token(session.group), + session=str(session.pk), + id=slides.pk, + ) + self.add(session, slides, order) # fill in the hole + + def send_update(self, session: "Session"): + if not self._should_send_update(session): + return + + self.api.update_slide_decks( + wg_token=self.wg_token(session.group), + session=str(session.pk), + decks=[ + { + "id": deck.document.pk, + "title": deck.document.title, + "url": deck.document.get_versionless_href(), # see note above re: get_versionless_href() + "rev": deck.document.rev, + "order": deck.order, + } + for deck in session.presentations.filter(document__type="slides") + ] + ) diff --git a/ietf/utils/tests_meetecho.py b/ietf/utils/tests_meetecho.py index db3d36f40..39f36969b 100644 --- a/ietf/utils/tests_meetecho.py +++ b/ietf/utils/tests_meetecho.py @@ -1,19 +1,21 @@ # Copyright The IETF Trust 2021, All Rights Reserved # -*- coding: utf-8 -*- import datetime -import pytz import requests import requests_mock -from unittest.mock import patch +from unittest.mock import call, patch from urllib.parse import urljoin +from zoneinfo import ZoneInfo from django.conf import settings from django.test import override_settings from django.utils import timezone +from ietf.doc.factories import DocumentFactory +from ietf.meeting.factories import SessionFactory, SessionPresentationFactory from ietf.utils.tests import TestCase -from .meetecho import Conference, ConferenceManager, MeetechoAPI, MeetechoAPIError +from .meetecho import Conference, ConferenceManager, MeetechoAPI, MeetechoAPIError, SlidesManager API_BASE = 'https://meetecho-api.example.com' CLIENT_ID = 'datatracker' @@ -22,6 +24,7 @@ API_CONFIG={ 'api_base': API_BASE, 'client_id': CLIENT_ID, 'client_secret': CLIENT_SECRET, + 'slides_notify_time': -1, # always send notification } @@ -31,6 +34,7 @@ class APITests(TestCase): schedule_meeting_url = urljoin(API_BASE, 'meeting/interim/createRoom') fetch_meetings_url = urljoin(API_BASE, 'meeting/interim/fetchRooms') delete_meetings_url = urljoin(API_BASE, 'meeting/interim/deleteRoom') + slide_deck_url = urljoin(API_BASE, "materials") def setUp(self): super().setUp() @@ -93,7 +97,7 @@ class APITests(TestCase): api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) api_response = api.schedule_meeting( wg_token='my-token', - start_time=pytz.utc.localize(datetime.datetime(2021, 9, 14, 10, 0, 0)), + start_time=datetime.datetime(2021, 9, 14, 10, 0, 0, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=130), description='interim-2021-wgname-01', extrainfo='message for staff', @@ -121,11 +125,11 @@ class APITests(TestCase): ) # same time in different time zones for start_time in [ - pytz.utc.localize(datetime.datetime(2021, 9, 14, 10, 0, 0)), - pytz.timezone('america/halifax').localize(datetime.datetime(2021, 9, 14, 7, 0, 0)), - pytz.timezone('europe/kiev').localize(datetime.datetime(2021, 9, 14, 13, 0, 0)), - pytz.timezone('pacific/easter').localize(datetime.datetime(2021, 9, 14, 5, 0, 0)), - pytz.timezone('africa/porto-novo').localize(datetime.datetime(2021, 9, 14, 11, 0, 0)), + datetime.datetime(2021, 9, 14, 10, 0, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2021, 9, 14, 7, 0, 0, tzinfo=ZoneInfo('America/Halifax')), + datetime.datetime(2021, 9, 14, 13, 0, 0, tzinfo=ZoneInfo('Europe/Kiev')), + datetime.datetime(2021, 9, 14, 5, 0, 0, tzinfo=ZoneInfo('Pacific/Easter')), + datetime.datetime(2021, 9, 14, 11, 0, 0, tzinfo=ZoneInfo('Africa/Porto-Novo')), ]: self.assertEqual( api_response, @@ -143,7 +147,7 @@ class APITests(TestCase): }, } }, - f'Incorrect time conversion for {start_time.tzinfo.zone}', + f'Incorrect time conversion for {start_time.tzinfo}', ) def test_fetch_meetings(self): @@ -192,7 +196,7 @@ class APITests(TestCase): '3d55bce0-535e-4ba8-bb8e-734911cf3c32': { 'room': { 'id': 18, - 'start_time': pytz.utc.localize(datetime.datetime(2021, 9, 14, 10, 0, 0)), + 'start_time': datetime.datetime(2021, 9, 14, 10, 0, 0, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=130), 'description': 'interim-2021-wgname-01', }, @@ -202,7 +206,7 @@ class APITests(TestCase): 'e68e96d4-d38f-475b-9073-ecab46ca96a5': { 'room': { 'id': 23, - 'start_time': pytz.utc.localize(datetime.datetime(2021, 9, 15, 14, 30, 0)), + 'start_time': datetime.datetime(2021, 9, 15, 14, 30, 0, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=30), 'description': 'interim-2021-wgname-02', }, @@ -228,6 +232,136 @@ class APITests(TestCase): self.assertIsNone(request.body, 'Delete meeting request has no body') self.assertCountEqual(api_response, data_to_fetch) + def test_add_slide_deck(self): + self.requests_mock.post(self.slide_deck_url, status_code=202) + + api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) + api_response = api.add_slide_deck( + wg_token="my-token", + session="1234", + deck={ + "title": "A Slide Deck", + "id": 17, + "url": "https://example.com/decks/17", + "rev": "00", + "order": 0, + } + ) + self.assertIsNone(api_response) # no return value from this call + + self.assertTrue(self.requests_mock.called) + request = self.requests_mock.last_request + self.assertIn("Authorization", request.headers) + self.assertEqual( + request.headers["Content-Type"], + "application/json", + "Incorrect request content-type", + ) + self.assertEqual(request.headers["Authorization"], "bearer my-token", + "Incorrect request authorization header") + self.assertEqual( + request.json(), + { + "session": "1234", + "title": "A Slide Deck", + "id": 17, + "url": "https://example.com/decks/17", + "rev": "00", + "order": 0, + }, + "Incorrect request content" + ) + + def test_delete_slide_deck(self): + self.requests_mock.delete(self.slide_deck_url, status_code=202) + + api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) + api_response = api.delete_slide_deck( + wg_token="my-token", + session="1234", + id=17, + ) + self.assertIsNone(api_response) # no return value from this call + + self.assertTrue(self.requests_mock.called) + request = self.requests_mock.last_request + self.assertIn("Authorization", request.headers) + self.assertEqual( + request.headers["Content-Type"], + "application/json", + "Incorrect request content-type", + ) + self.assertEqual(request.headers["Authorization"], "bearer my-token", + "Incorrect request authorization header") + self.assertEqual( + request.json(), + { + "session": "1234", + "id": 17, + }, + "Incorrect request content" + ) + + def test_update_slide_decks(self): + self.requests_mock.put(self.slide_deck_url, status_code=202) + + api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) + api_response = api.update_slide_decks( + wg_token="my-token", + session="1234", + decks=[ + { + "title": "A Slide Deck", + "id": 17, + "url": "https://example.com/decks/17", + "rev": "00", + "order": 0, + }, + { + "title": "Another Slide Deck", + "id": 23, + "url": "https://example.com/decks/23", + "rev": "03", + "order": 1, + } + ] + ) + self.assertIsNone(api_response) # no return value from this call + + self.assertTrue(self.requests_mock.called) + request = self.requests_mock.last_request + self.assertIn("Authorization", request.headers) + self.assertEqual( + request.headers["Content-Type"], + "application/json", + "Incorrect request content-type", + ) + self.assertEqual(request.headers["Authorization"], "bearer my-token", + "Incorrect request authorization header") + self.assertEqual( + request.json(), + { + "session": "1234", + "decks": [ + { + "title": "A Slide Deck", + "id": 17, + "url": "https://example.com/decks/17", + "rev": "00", + "order": 0, + }, + { + "title": "Another Slide Deck", + "id": 23, + "url": "https://example.com/decks/23", + "rev": "03", + "order": 1, + }, + ], + }, + "Incorrect request content" + ) + def test_request_helper_failed_requests(self): self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'unauthorized/url/endpoint'), status_code=401) self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'forbidden/url/endpoint'), status_code=403) @@ -250,7 +384,7 @@ class APITests(TestCase): def test_time_serialization(self): """Time de/serialization should be consistent""" - time = timezone.now().astimezone(pytz.utc).replace(microsecond=0) # cut off to 0 microseconds + time = timezone.now().astimezone(datetime.timezone.utc).replace(microsecond=0) # cut off to 0 microseconds api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET) self.assertEqual(api._deserialize_time(api._serialize_time(time)), time) @@ -264,7 +398,7 @@ class ConferenceManagerTests(TestCase): 'session-1-uuid': { 'room': { 'id': 1, - 'start_time': pytz.utc.localize(datetime.datetime(2022,2,4,1,2,3)), + 'start_time': datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=45), 'description': 'some-description', }, @@ -274,7 +408,7 @@ class ConferenceManagerTests(TestCase): 'session-2-uuid': { 'room': { 'id': 2, - 'start_time': pytz.utc.localize(datetime.datetime(2022,2,5,4,5,6)), + 'start_time': datetime.datetime(2022,2,5,4,5,6, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=90), 'description': 'another-description', }, @@ -291,7 +425,7 @@ class ConferenceManagerTests(TestCase): id=1, public_id='session-1-uuid', description='some-description', - start_time=pytz.utc.localize(datetime.datetime(2022, 2, 4, 1, 2, 3)), + start_time=datetime.datetime(2022, 2, 4, 1, 2, 3, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=45), url='https://example.com/some/url', deletion_token='delete-me', @@ -301,7 +435,7 @@ class ConferenceManagerTests(TestCase): id=2, public_id='session-2-uuid', description='another-description', - start_time=pytz.utc.localize(datetime.datetime(2022, 2, 5, 4, 5, 6)), + start_time=datetime.datetime(2022, 2, 5, 4, 5, 6, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=90), url='https://example.com/another/url', deletion_token='delete-me-too', @@ -317,7 +451,7 @@ class ConferenceManagerTests(TestCase): 'session-1-uuid': { 'room': { 'id': 1, - 'start_time': pytz.utc.localize(datetime.datetime(2022,2,4,1,2,3)), + 'start_time': datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=45), 'description': 'some-description', }, @@ -336,7 +470,7 @@ class ConferenceManagerTests(TestCase): id=1, public_id='session-1-uuid', description='some-description', - start_time=pytz.utc.localize(datetime.datetime(2022,2,4,1,2,3)), + start_time=datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=45), url='https://example.com/some/url', deletion_token='delete-me', @@ -352,7 +486,7 @@ class ConferenceManagerTests(TestCase): 'session-1-uuid': { 'room': { 'id': 1, - 'start_time': pytz.utc.localize(datetime.datetime(2022,2,4,1,2,3)), + 'start_time': datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), 'duration': datetime.timedelta(minutes=45), 'description': 'some-description', }, @@ -370,7 +504,7 @@ class ConferenceManagerTests(TestCase): id=1, public_id='session-1-uuid', description='some-description', - start_time=pytz.utc.localize(datetime.datetime(2022,2,4,1,2,3)), + start_time=datetime.datetime(2022,2,4,1,2,3, tzinfo=datetime.timezone.utc), duration=datetime.timedelta(minutes=45), url='https://example.com/some/url', deletion_token='delete-me', @@ -394,10 +528,139 @@ class ConferenceManagerTests(TestCase): args, kwargs = mock_delete.call_args self.assertEqual(args, ('delete-this',)) - @patch('ietf.utils.meetecho.MeetechoAPI.delete_meeting') def test_delete_by_url(self, mock_delete): cm = ConferenceManager(settings.MEETECHO_API_CONFIG) cm.delete_conference(Conference(None, None, None, None, None, None, 'the-url', 'delete-this')) args, kwargs = mock_delete.call_args self.assertEqual(args, ('delete-this',)) + + +@patch.object(SlidesManager, 'wg_token', return_value='atoken') +@override_settings(MEETECHO_API_CONFIG=API_CONFIG) +class SlidesManagerTests(TestCase): + @patch("ietf.utils.meetecho.MeetechoAPI.add_slide_deck") + def test_add(self, mock_add, mock_wg_token): + sm = SlidesManager(settings.MEETECHO_API_CONFIG) + session = SessionFactory() + slides_doc = DocumentFactory(type_id="slides") + sm.add(session, slides_doc, 13) + self.assertTrue(mock_wg_token.called) + self.assertTrue(mock_add.called) + self.assertEqual( + mock_add.call_args, + call( + wg_token="atoken", + session=str(session.pk), + deck={ + "id": slides_doc.pk, + "title": slides_doc.title, + "url": slides_doc.get_versionless_href(), + "rev": slides_doc.rev, + "order": 13, + }, + ), + ) + + @patch("ietf.utils.meetecho.MeetechoAPI.update_slide_decks") + @patch("ietf.utils.meetecho.MeetechoAPI.delete_slide_deck") + def test_delete(self, mock_delete, mock_update, mock_wg_token): + sm = SlidesManager(settings.MEETECHO_API_CONFIG) + # Test scenario: we had a session with two slide decks and we already deleted the SessionPresentation + # for one and are now updating Meetecho + slides = SessionPresentationFactory(document__type_id="slides", order=1) # still attached to the session + session = slides.session + slides_doc = slides.document + removed_slides_doc = DocumentFactory(type_id="slides") + + with self.assertRaises(MeetechoAPIError): + sm.delete(session, slides_doc) # can't remove slides still attached to the session + self.assertFalse(any([mock_wg_token.called, mock_delete.called, mock_update.called])) + + sm.delete(session, removed_slides_doc) + self.assertTrue(mock_wg_token.called) + self.assertTrue(mock_delete.called) + self.assertEqual( + mock_delete.call_args, + call(wg_token="atoken", session=str(session.pk), id=removed_slides_doc.pk), + ) + self.assertTrue(mock_update.called) + self.assertEqual( + mock_update.call_args, + call( + wg_token="atoken", + session=str(session.pk), + decks=[ + { + "id": slides_doc.pk, + "title": slides_doc.title, + "url": slides_doc.get_versionless_href(), + "rev": slides_doc.rev, + "order": 1, + }, + ] + ) + ) + mock_delete.reset_mock() + mock_update.reset_mock() + + # Delete the other session and check that we don't make the update call + slides.delete() + sm.delete(session, slides_doc) + self.assertTrue(mock_delete.called) + self.assertFalse(mock_update.called) + + @patch("ietf.utils.meetecho.MeetechoAPI.delete_slide_deck") + @patch("ietf.utils.meetecho.MeetechoAPI.add_slide_deck") + def test_revise(self, mock_add, mock_delete, mock_wg_token): + sm = SlidesManager(settings.MEETECHO_API_CONFIG) + slides = SessionPresentationFactory(document__type_id="slides", order=23) + slides_doc = slides.document + sm.revise(slides.session, slides.document) + self.assertTrue(mock_wg_token.called) + self.assertTrue(mock_delete.called) + self.assertEqual( + mock_delete.call_args, + call(wg_token="atoken", session=str(slides.session.pk), id=slides_doc.pk), + ) + self.assertTrue(mock_add.called) + self.assertEqual( + mock_add.call_args, + call( + wg_token="atoken", + session=str(slides.session.pk), + deck={ + "id": slides_doc.pk, + "title": slides_doc.title, + "url": slides_doc.get_versionless_href(), + "rev": slides_doc.rev, + "order": 23, + }, + ), + ) + + + @patch("ietf.utils.meetecho.MeetechoAPI.update_slide_decks") + def test_send_update(self, mock_send_update, mock_wg_token): + sm = SlidesManager(settings.MEETECHO_API_CONFIG) + slides = SessionPresentationFactory(document__type_id="slides") + SessionPresentationFactory(session=slides.session, document__type_id="agenda") + sm.send_update(slides.session) + self.assertTrue(mock_wg_token.called) + self.assertTrue(mock_send_update.called) + self.assertEqual( + mock_send_update.call_args, + call( + wg_token="atoken", + session=str(slides.session_id), + decks=[ + { + "id": slides.document_id, + "title": slides.document.title, + "url": slides.document.get_versionless_href(), + "rev": slides.document.rev, + "order": 0, + } + ] + ) + ) diff --git a/requirements.txt b/requirements.txt index fd750ec70..c27b3adce 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