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 <paul@painless-security.com>

* 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 <jennifer@staff.ietf.org>
Co-authored-by: Paul Selkirk <paul@painless-security.com>
This commit is contained in:
Robert Sparks 2024-03-12 10:22:24 -05:00 committed by GitHub
parent 8166601c23
commit e6138ca126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2104 additions and 383 deletions

View file

@ -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()

View file

@ -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"""

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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
),
),
]

View file

@ -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'),)

View file

@ -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,9 +6499,16 @@ 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')
@ -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(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"},
]
)

View file

@ -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<acronym>[-a-z0-9]+)/?$', views.session_details),
url(r'^session/(?P<session_id>\d+)/drafts$', views.add_session_drafts),
url(r'^session/(?P<session_id>\d+)/attendance$', views.session_attendance),
url(r'^session/(?P<session_id>\d+)/bluesheets$', views.upload_session_bluesheets),
url(r'^session/(?P<session_id>\d+)/minutes$', views.upload_session_minutes),
url(r'^session/(?P<session_id>\d+)/narrativeminutes$', views.upload_session_narrativeminutes),

View file

@ -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(
@ -161,6 +241,12 @@ def finalize(meeting):
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
meeting.save()

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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):

View file

@ -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 %}
<h1>
Attendance for {{session}}
</h1>
<div class="alert alert-info my-3">
This list will be used to generate the official bluesheet for this session.
{% if can_add %}
<br>If you attended this session, you can use the "I was there" button at the bottom to add yourself.
{% endif %}
{% if was_there %}
<br>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 %}
</div>
<h2>
{{ data|length }} attendees.
</h2>
<table class="table table-sm table-striped tablesorter">
<thead>
<tr>
<th scope="col" data-sort="num">Name</th>
<th scope="col" data-sort="document">Affiliation</th>
</tr>
</thead>
<tbody>
{% for item in data %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.affiliation }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if can_add %}
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-primary">I was there</button>
</form>
{% endif %}
{% endblock %}

View file

@ -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" %}<span class="badge rounded-pill text-bg-warning">No minutes</span>{% endif %}
{% endfor %}
{% if entry.session.type_id == 'regular' and show_agenda == "True" %}
{% for bluesheet in entry.bluesheets %}
<a href="{{ bluesheet.material|meeting_href:meeting }}">
Bluesheets
{% if bluesheet.time %}
<br><span class="small float-end">{{ bluesheet.time|date:"D G:i" }}</span>
{% endif %}
{% for attendance in entry.attendances %}
{% with session=attendance.material %}
<a href="{% url 'ietf.meeting.views.session_attendance' session_id=session.pk num=session.meeting.number %}">
Attendance
{% if attendance.time %}{{ attendance.time|date:"D G:i" }}{% endif %}
</a>
{% endwith %}
<br>
{% empty %}
<span class="badge rounded-pill text-bg-warning">No bluesheets</span>
{% endfor %}
{% endif %}
</td>

View file

@ -47,6 +47,17 @@
<br>
{% endif %}
{% endfor %}
{% if not meeting.proceedings_final %}
{% for attendance in entry.attendances %}
{% with session=attendance.material %}
<a href="{% url 'ietf.meeting.views.session_attendance' session_id=session.pk num=session.meeting.number %}">
Attendance
{% if attendance.time %}{{ attendance.time|date:"D G:i" }}{% endif %}
</a>
{% endwith %}
<br>
{% endfor %}
{% else %}
{% for bs in entry.bluesheets %}
<a href="{{ bs.material|meeting_href:meeting }}">
Bluesheets
@ -54,6 +65,7 @@
</a>
<br>
{% endfor %}
{% endif %}
{% for chatlog in entry.chatlogs %}
<a href="{{ chatlog.material|meeting_href:meeting }}">
Chatlog

View file

@ -62,7 +62,7 @@
{% endif %}
<h3 class="mt-4">Agenda, Minutes, and Bluesheets</h3>
<table class="table table-sm table-striped">
{% if session.filtered_artifacts %}
{% if session.filtered_artifacts or session.bluesheet_title %}
<tbody>
{% for pres in session.filtered_artifacts %}
<tr>
@ -91,6 +91,13 @@
</td>
</tr>
{% endfor %}
{% if session.bluesheet_title %}
<tr><td>
<a href="{% url 'ietf.meeting.views.session_attendance' session_id=session.pk num=session.meeting.number %}">
{{ session.bluesheet_title }}
</a>
</td></tr>
{% endif %}
</tbody>
{% endif %}
</table>

View file

@ -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,9 +87,13 @@ 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]]):
@ -88,16 +103,23 @@ class MeetechoAPI:
: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:
@ -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,
},
)
)
@ -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,7 +463,7 @@ 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):
@ -261,3 +472,109 @@ class ConferenceManager:
def delete_conference(self, conf: Conference):
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")
]
)

View file

@ -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,
}
]
)
)

View file

@ -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