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:
parent
8166601c23
commit
e6138ca126
|
@ -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()
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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'),)
|
||||
|
|
|
@ -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(2,doc.docevent_set.count())
|
||||
self.assertEqual(1, session.presentations.count())
|
||||
self.assertEqual(2, doc.docevent_set.count())
|
||||
self.assertEqual(mock_slides_manager_cls.call_count, 1)
|
||||
self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings"))
|
||||
self.assertEqual(mock_slides_manager_cls.return_value.delete.call_count, 1)
|
||||
self.assertEqual(
|
||||
mock_slides_manager_cls.return_value.delete.call_args,
|
||||
call(session=session, slides=doc),
|
||||
)
|
||||
mock_slides_manager_cls.reset_mock()
|
||||
|
||||
# Removing non-slides should only remove the materials
|
||||
url = urlreverse(
|
||||
"ietf.meeting.views.remove_sessionpresentation",
|
||||
kwargs={
|
||||
"num": session.meeting.number,
|
||||
"session_id": session.id,
|
||||
"name": agenda.name,
|
||||
},
|
||||
)
|
||||
response = self.client.post(url, {"remove_session" : ""})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(0, session.presentations.count())
|
||||
self.assertEqual(2, agenda.docevent_set.count())
|
||||
self.assertFalse(mock_slides_manager_cls.called)
|
||||
|
||||
|
||||
def test_propose_session_slides(self):
|
||||
for type_id in ['ietf','interim']:
|
||||
|
@ -7811,7 +8005,7 @@ class ProceedingsTests(BaseMeetingTestCase):
|
|||
|
||||
def test_named_session(self):
|
||||
"""Session with a name should appear separately in the proceedings"""
|
||||
meeting = MeetingFactory(type_id='ietf', number='100')
|
||||
meeting = MeetingFactory(type_id='ietf', number='100', proceedings_final=True)
|
||||
group = GroupFactory()
|
||||
plain_session = SessionFactory(meeting=meeting, group=group)
|
||||
named_session = SessionFactory(meeting=meeting, group=group, name='I Got a Name')
|
||||
|
@ -7932,7 +8126,6 @@ class ProceedingsTests(BaseMeetingTestCase):
|
|||
- prefer onsite checkedin=True to remote attended when same person has both
|
||||
"""
|
||||
|
||||
make_meeting_test_data()
|
||||
meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number="118")
|
||||
person_a = PersonFactory(name='Person A')
|
||||
person_b = PersonFactory(name='Person B')
|
||||
|
@ -7957,9 +8150,14 @@ class ProceedingsTests(BaseMeetingTestCase):
|
|||
'''Test proceedings IETF Overview page.
|
||||
Note: old meetings aren't supported so need to add a new meeting then test.
|
||||
'''
|
||||
make_meeting_test_data()
|
||||
meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97")
|
||||
finalize(meeting)
|
||||
meeting = make_meeting_test_data(meeting=MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97"))
|
||||
|
||||
# finalize meeting
|
||||
url = urlreverse('ietf.meeting.views.finalize_proceedings',kwargs={'num':meeting.number})
|
||||
login_testing_unauthorized(self,"secretary",url)
|
||||
r = self.client.post(url,{'finalize':1})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
url = urlreverse('ietf.meeting.views.proceedings_overview',kwargs={'num':97})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, 'The Internet Engineering Task Force')
|
||||
|
@ -8362,3 +8560,127 @@ class ProceedingsTests(BaseMeetingTestCase):
|
|||
self.assertTrue(person_b.pk not in checked_in)
|
||||
self.assertTrue(person_c.pk in attended)
|
||||
self.assertTrue(person_d.pk not in attended)
|
||||
|
||||
def test_session_attendance(self):
|
||||
meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number='118')
|
||||
make_meeting_test_data(meeting=meeting)
|
||||
session = Session.objects.filter(meeting=meeting, group__acronym='mars').first()
|
||||
regs = MeetingRegistrationFactory.create_batch(3, meeting=meeting)
|
||||
persons = [reg.person for reg in regs]
|
||||
self.assertEqual(session.attended_set.count(), 0)
|
||||
|
||||
# If there are no attendees, the link isn't offered, and getting
|
||||
# the page directly returns an empty list.
|
||||
session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym})
|
||||
attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id})
|
||||
r = self.client.get(session_url)
|
||||
self.assertNotContains(r, attendance_url)
|
||||
r = self.client.get(attendance_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, '0 attendees')
|
||||
|
||||
# Add some attendees
|
||||
add_attendees_url = urlreverse('ietf.meeting.views.api_add_session_attendees')
|
||||
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman', person__user__last_login=timezone.now())
|
||||
recman = recmanrole.person
|
||||
apikey = PersonalApiKey.objects.create(endpoint=add_attendees_url, person=recman)
|
||||
attendees = [person.user.pk for person in persons]
|
||||
self.client.login(username='recman', password='recman+password')
|
||||
r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(session.attended_set.count(), 3)
|
||||
|
||||
# Before a meeting is finalized, session_attendance renders a live
|
||||
# view of the Attended records for the session.
|
||||
r = self.client.get(session_url)
|
||||
self.assertContains(r, attendance_url)
|
||||
r = self.client.get(attendance_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, '3 attendees')
|
||||
for person in persons:
|
||||
self.assertContains(r, person.name)
|
||||
|
||||
# Test for the "I was there" button.
|
||||
def _test_button(person, expected):
|
||||
username = person.user.username
|
||||
self.client.login(username=username, password=f'{username}+password')
|
||||
r = self.client.get(attendance_url)
|
||||
self.assertEqual(b"I was there" in r.content, expected)
|
||||
# recman isn't registered for the meeting
|
||||
_test_button(recman, False)
|
||||
# person0 is already on the bluesheet
|
||||
_test_button(persons[0], False)
|
||||
# person3 attests he was there
|
||||
persons.append(MeetingRegistrationFactory(meeting=meeting).person)
|
||||
# button isn't shown if we're outside the corrections windows
|
||||
meeting.importantdate_set.create(name_id='revsub',date=date_today() - datetime.timedelta(days=20))
|
||||
_test_button(persons[3], False)
|
||||
# attempt to POST anyway is ignored
|
||||
r = self.client.post(attendance_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertNotContains(r, persons[3].name)
|
||||
self.assertEqual(session.attended_set.count(), 3)
|
||||
# button is shown, and POST is accepted
|
||||
meeting.importantdate_set.update(name_id='revsub',date=date_today() + datetime.timedelta(days=20))
|
||||
_test_button(persons[3], True)
|
||||
r = self.client.post(attendance_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, persons[3].name)
|
||||
self.assertEqual(session.attended_set.count(), 4)
|
||||
|
||||
# When the meeting is finalized, a bluesheet file is generated,
|
||||
# and session_attendance redirects to the file.
|
||||
self.client.login(username='secretary',password='secretary+password')
|
||||
finalize_url = urlreverse('ietf.meeting.views.finalize_proceedings', kwargs={'num':meeting.number})
|
||||
r = self.client.post(finalize_url, {'finalize':1})
|
||||
self.assertRedirects(r, urlreverse('ietf.meeting.views.proceedings', kwargs={'num':meeting.number}))
|
||||
doc = session.presentations.filter(document__type_id='bluesheets').first().document
|
||||
self.assertEqual(doc.rev,'00')
|
||||
text = doc.text()
|
||||
self.assertIn('4 attendees', text)
|
||||
for person in persons:
|
||||
self.assertIn(person.name, text)
|
||||
r = self.client.get(session_url)
|
||||
self.assertContains(r, doc.get_href())
|
||||
self.assertNotContains(r, attendance_url)
|
||||
r = self.client.get(attendance_url)
|
||||
self.assertEqual(r.status_code,302)
|
||||
self.assertEqual(r['Location'],doc.get_href())
|
||||
|
||||
# An interim meeting is considered finalized immediately.
|
||||
meeting = make_interim_meeting(group=GroupFactory(acronym='mars'), date=date_today())
|
||||
session = Session.objects.filter(meeting=meeting, group__acronym='mars').first()
|
||||
attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id})
|
||||
self.assertEqual(session.attended_set.count(), 0)
|
||||
self.client.login(username='recman', password='recman+password')
|
||||
attendees = [person.user.pk for person in persons]
|
||||
r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(session.attended_set.count(), 4)
|
||||
doc = session.presentations.filter(document__type_id='bluesheets').first().document
|
||||
self.assertEqual(doc.rev,'00')
|
||||
session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym})
|
||||
r = self.client.get(session_url)
|
||||
self.assertContains(r, doc.get_href())
|
||||
self.assertNotContains(r, attendance_url)
|
||||
r = self.client.get(attendance_url)
|
||||
self.assertEqual(r.status_code,302)
|
||||
self.assertEqual(r['Location'],doc.get_href())
|
||||
|
||||
def test_bluesheet_data(self):
|
||||
session = SessionFactory(meeting__type_id="ietf")
|
||||
attended_with_affil = MeetingRegistrationFactory(meeting=session.meeting, affiliation="Somewhere")
|
||||
AttendedFactory(session=session, person=attended_with_affil.person, time="2023-03-13T01:24:00Z") # joined 2nd
|
||||
attended_no_affil = MeetingRegistrationFactory(meeting=session.meeting)
|
||||
AttendedFactory(session=session, person=attended_no_affil.person, time="2023-03-13T01:23:00Z") # joined 1st
|
||||
MeetingRegistrationFactory(meeting=session.meeting) # did not attend
|
||||
|
||||
data = bluesheet_data(session)
|
||||
self.assertEqual(
|
||||
data,
|
||||
[
|
||||
{"name": attended_no_affil.person.plain_name(), "affiliation": ""},
|
||||
{"name": attended_with_affil.person.plain_name(), "affiliation": "Somewhere"},
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
44
ietf/templates/meeting/attendance.html
Normal file
44
ietf/templates/meeting/attendance.html
Normal 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 %}
|
|
@ -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 %}
|
||||
</a>
|
||||
{% 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>
|
||||
|
|
|
@ -47,13 +47,25 @@
|
|||
<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for bs in entry.bluesheets %}
|
||||
<a href="{{ bs.material|meeting_href:meeting }}">
|
||||
Bluesheets
|
||||
{% if bs.time %}{{ bs.time|date:"D G:i" }}{% endif %}
|
||||
</a>
|
||||
<br>
|
||||
{% 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
|
||||
{% if bs.time %}{{ bs.time|date:"D G:i" }}{% endif %}
|
||||
</a>
|
||||
<br>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for chatlog in entry.chatlogs %}
|
||||
<a href="{{ chatlog.material|meeting_href:meeting }}">
|
||||
Chatlog
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue