Skip to content

Commit db7d307

Browse files
feat: Add session recordings (ietf-tools#8218)
* feat: add session recordings * feat: add session recordings * feat: deleting recordings * feat: deleting recordings and initial form values * feat: use meeting date rather than today for initial title field. Fix delete recording * feat: confirm delete recordings modal. fix server utils delete recording * fix: removing debug console.log * feat: change button name from 'Ok' to 'Delete' for confirm deletion to be clearer * feat: UTC time in string and delete modal text * fix: django html validation tests * fix: django html validation tests * fix: django html validation tests * refactor: Work with SessionPresentations * fix: better ordering * chore: drop rev, hide table when empty * test: test delete_recordings method * fix: debug delete_recordings * test: test add_session_recordings view * fix: better permissions handling * fix: only delete recordings for selected session * refactor: inline script -> js module * chore: remove accidental import *shakes fist at pycharm* * fix: consistent timestamp format plus slight rephrase * style: Black * chore: remove comment * test: update test to match * fix: reversible url pattern for materials Tests were perturbed in a way that led to a test getting an interim instead of an IETF meeting. This exposed a bug reversing the URL for the materials_document() view. This splits it into two patterns that are equivalent to the original. --------- Co-authored-by: Jennifer Richards <jennifer@staff.ietf.org>
1 parent 02a680f commit db7d307

9 files changed

Lines changed: 385 additions & 5 deletions

File tree

ietf/meeting/tests_views.py

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data
4949
from ietf.meeting.utils import condition_slide_order
5050
from ietf.meeting.utils import add_event_info_to_session_qs, participants_for_meeting
51-
from ietf.meeting.utils import create_recording, get_next_sequence, bluesheet_data
51+
from ietf.meeting.utils import create_recording, delete_recording, get_next_sequence, bluesheet_data
5252
from ietf.meeting.views import session_draft_list, parse_agenda_filter_params, sessions_post_save, agenda_extract_schedule
5353
from ietf.meeting.views import get_summary_by_area, get_summary_by_type, get_summary_by_purpose, generate_agenda_data
5454
from ietf.name.models import SessionStatusName, ImportantDateName, RoleName, ProceedingsMaterialTypeName
@@ -441,6 +441,48 @@ def test_session_recordings_via_factories(self):
441441
self.assertIn(new_recording_title, links[0].text_content())
442442
#debug.show("q(f'#notes_and_recordings_{session_pk}')")
443443

444+
def test_delete_recordings(self):
445+
# No user specified, active recording state
446+
sp = SessionPresentationFactory(
447+
document__type_id="recording",
448+
document__external_url="https://example.com/some-recording",
449+
document__states=[("recording", "active")],
450+
)
451+
doc = sp.document
452+
doc.docevent_set.all().delete() # clear this out
453+
delete_recording(sp)
454+
self.assertFalse(SessionPresentation.objects.filter(pk=sp.pk).exists())
455+
self.assertEqual(doc.get_state("recording").slug, "deleted", "recording state updated")
456+
self.assertEqual(doc.docevent_set.count(), 1, "one event added")
457+
event = doc.docevent_set.first()
458+
self.assertEqual(event.type, "changed_state", "event is a changed_state event")
459+
self.assertEqual(event.by.name, "(System)", "system user is responsible")
460+
461+
# Specified user, no recording state
462+
sp = SessionPresentationFactory(
463+
document__type_id="recording",
464+
document__external_url="https://example.com/some-recording",
465+
document__states=[],
466+
)
467+
doc = sp.document
468+
doc.docevent_set.all().delete() # clear this out
469+
user = PersonFactory() # naming matches the methods - user is a Person, not a User
470+
delete_recording(sp, user=user)
471+
self.assertFalse(SessionPresentation.objects.filter(pk=sp.pk).exists())
472+
self.assertEqual(doc.get_state("recording").slug, "deleted", "recording state updated")
473+
self.assertEqual(doc.docevent_set.count(), 1, "one event added")
474+
event = doc.docevent_set.first()
475+
self.assertEqual(event.type, "changed_state", "event is a changed_state event")
476+
self.assertEqual(event.by, user, "user is responsible")
477+
478+
# Document is not a recording
479+
sp = SessionPresentationFactory(
480+
document__type_id="draft",
481+
document__external_url="https://example.com/some-recording",
482+
)
483+
with self.assertRaises(ValueError):
484+
delete_recording(sp)
485+
444486
def test_agenda_ical_next_meeting_type(self):
445487
# start with no upcoming IETF meetings, just an interim
446488
MeetingFactory(
@@ -7363,6 +7405,118 @@ def test_request_minutes(self):
73637405
self.assertEqual(r.status_code,302)
73647406
self.assertEqual(len(outbox),1)
73657407

7408+
@override_settings(YOUTUBE_DOMAINS=["youtube.com"])
7409+
def test_add_session_recordings(self):
7410+
session = SessionFactory(meeting__type_id="ietf")
7411+
url = urlreverse(
7412+
"ietf.meeting.views.add_session_recordings",
7413+
kwargs={"session_id": session.pk, "num": session.meeting.number},
7414+
)
7415+
# does not fully validate authorization for non-secretariat users :-(
7416+
login_testing_unauthorized(self, "secretary", url)
7417+
r = self.client.get(url)
7418+
pq = PyQuery(r.content)
7419+
title_input = pq("input#id_title")
7420+
self.assertIsNotNone(title_input)
7421+
self.assertEqual(
7422+
title_input.attr.value,
7423+
"Video recording of {acro} for {timestamp}".format(
7424+
acro=session.group.acronym,
7425+
timestamp=session.official_timeslotassignment().timeslot.utc_start_time().strftime(
7426+
"%Y-%m-%d %H:%M"
7427+
),
7428+
),
7429+
)
7430+
7431+
with patch("ietf.meeting.views.create_recording") as mock_create:
7432+
r = self.client.post(
7433+
url,
7434+
data={
7435+
"title": "This is my video title",
7436+
"url": "",
7437+
}
7438+
)
7439+
self.assertFalse(mock_create.called)
7440+
7441+
with patch("ietf.meeting.views.create_recording") as mock_create:
7442+
r = self.client.post(
7443+
url,
7444+
data={
7445+
"title": "This is my video title",
7446+
"url": "https://yubtub.com/this-is-not-a-youtube-video",
7447+
}
7448+
)
7449+
self.assertFalse(mock_create.called)
7450+
7451+
with patch("ietf.meeting.views.create_recording") as mock_create:
7452+
r = self.client.post(
7453+
url,
7454+
data={
7455+
"title": "This is my video title",
7456+
"url": "https://youtube.com/finally-a-video",
7457+
}
7458+
)
7459+
self.assertTrue(mock_create.called)
7460+
self.assertEqual(
7461+
mock_create.call_args,
7462+
call(
7463+
session,
7464+
"https://youtube.com/finally-a-video",
7465+
title="This is my video title",
7466+
user=Person.objects.get(user__username="secretary"),
7467+
),
7468+
)
7469+
7470+
# CAN delete session presentation for this session
7471+
sp = SessionPresentationFactory(
7472+
session=session,
7473+
document__type_id="recording",
7474+
document__external_url="https://example.com/some-video",
7475+
)
7476+
with patch("ietf.meeting.views.delete_recording") as mock_delete:
7477+
r = self.client.post(
7478+
url,
7479+
data={
7480+
"delete": str(sp.pk),
7481+
}
7482+
)
7483+
self.assertEqual(r.status_code, 200)
7484+
self.assertTrue(mock_delete.called)
7485+
self.assertEqual(mock_delete.call_args, call(sp))
7486+
7487+
# ValueError message from delete_recording does not reach the user
7488+
sp = SessionPresentationFactory(
7489+
session=session,
7490+
document__type_id="recording",
7491+
document__external_url="https://example.com/some-video",
7492+
)
7493+
with patch("ietf.meeting.views.delete_recording", side_effect=ValueError("oh joy!")) as mock_delete:
7494+
r = self.client.post(
7495+
url,
7496+
data={
7497+
"delete": str(sp.pk),
7498+
}
7499+
)
7500+
self.assertTrue(mock_delete.called)
7501+
self.assertNotContains(r, "oh joy!", status_code=200)
7502+
7503+
# CANNOT delete session presentation for a different session
7504+
sp_for_other_session = SessionPresentationFactory(
7505+
document__type_id="recording",
7506+
document__external_url="https://example.com/some-other-video",
7507+
)
7508+
with patch("ietf.meeting.views.delete_recording") as mock_delete:
7509+
r = self.client.post(
7510+
url,
7511+
data={
7512+
"delete": str(sp_for_other_session.pk),
7513+
}
7514+
)
7515+
self.assertEqual(r.status_code, 404)
7516+
self.assertFalse(mock_delete.called)
7517+
7518+
7519+
73667520
class HasMeetingsTests(TestCase):
73677521
settings_temp_path_overrides = TestCase.settings_temp_path_overrides + ['AGENDA_PATH']
73687522

ietf/meeting/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def get_redirect_url(self, *args, **kwargs):
1616
safe_for_all_meeting_types = [
1717
url(r'^session/(?P<acronym>[-a-z0-9]+)/?$', views.session_details),
1818
url(r'^session/(?P<session_id>\d+)/drafts$', views.add_session_drafts),
19+
url(r'^session/(?P<session_id>\d+)/recordings$', views.add_session_recordings),
1920
url(r'^session/(?P<session_id>\d+)/attendance$', views.session_attendance),
2021
url(r'^session/(?P<session_id>\d+)/bluesheets$', views.upload_session_bluesheets),
2122
url(r'^session/(?P<session_id>\d+)/minutes$', views.upload_session_minutes),
@@ -63,7 +64,8 @@ def get_redirect_url(self, *args, **kwargs):
6364
type_interim_patterns = [
6465
url(r'^agenda/(?P<acronym>[A-Za-z0-9-]+)-drafts.pdf$', views.session_draft_pdf),
6566
url(r'^agenda/(?P<acronym>[A-Za-z0-9-]+)-drafts.tgz$', views.session_draft_tarfile),
66-
url(r'^materials/%(document)s((?P<ext>\.[a-z0-9]+)|/)?$' % settings.URL_REGEXPS, views.materials_document),
67+
url(r'^materials/%(document)s(?P<ext>\.[a-z0-9]+)$' % settings.URL_REGEXPS, views.materials_document),
68+
url(r'^materials/%(document)s/?$' % settings.URL_REGEXPS, views.materials_document),
6769
url(r'^agenda.json$', views.agenda_json)
6870
]
6971

ietf/meeting/utils.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from ietf.dbtemplate.models import DBTemplate
2424
from ietf.meeting.models import (Session, SchedulingEvent, TimeSlot,
2525
Constraint, SchedTimeSessAssignment, SessionPresentation, Attended)
26-
from ietf.doc.models import Document, State, NewRevisionDocEvent
26+
from ietf.doc.models import Document, State, NewRevisionDocEvent, StateDocEvent
2727
from ietf.doc.models import DocEvent
2828
from ietf.group.models import Group
2929
from ietf.group.utils import can_manage_materials
@@ -853,6 +853,26 @@ def create_recording(session, url, title=None, user=None):
853853

854854
return doc
855855

856+
def delete_recording(session_presentation, user=None):
857+
"""Delete a session recording"""
858+
document = session_presentation.document
859+
if document.type_id != "recording":
860+
raise ValueError(f"Document {document.pk} is not a recording (type_id={document.type_id})")
861+
recording_state = document.get_state("recording")
862+
deleted_state = State.objects.get(type_id="recording", slug="deleted")
863+
if recording_state != deleted_state:
864+
# Update the recording state and create a history event
865+
document.set_state(deleted_state)
866+
StateDocEvent.objects.create(
867+
type="changed_state",
868+
by=user or Person.objects.get(name="(System)"),
869+
doc=document,
870+
rev=document.rev,
871+
state_type=deleted_state.type,
872+
state=deleted_state,
873+
)
874+
session_presentation.delete()
875+
856876
def get_next_sequence(group, meeting, type):
857877
'''
858878
Returns the next sequence number to use for a document of type = type.

ietf/meeting/views.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from functools import partialmethod
2121
import jsonschema
2222
from pathlib import Path
23-
from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit
23+
from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit, urlparse
2424
from tempfile import mkstemp
2525
from wsgiref.handlers import format_date_time
2626

@@ -86,7 +86,7 @@
8686
from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments, bulk_create_timeslots
8787
from ietf.meeting.utils import preprocess_meeting_important_dates
8888
from ietf.meeting.utils import new_doc_for_session, write_doc_for_session
89-
from ietf.meeting.utils import get_activity_stats, post_process, create_recording
89+
from ietf.meeting.utils import get_activity_stats, post_process, create_recording, delete_recording
9090
from ietf.meeting.utils import participants_for_meeting, generate_bluesheet, bluesheet_data, save_bluesheet
9191
from ietf.message.utils import infer_message
9292
from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName
@@ -103,6 +103,7 @@
103103
from ietf.utils.response import permission_denied
104104
from ietf.utils.text import xslugify
105105
from ietf.utils.timezone import datetime_today, date_today
106+
from ietf.settings import YOUTUBE_DOMAINS
106107

107108
from .forms import (InterimMeetingModelForm, InterimAnnounceForm, InterimSessionModelForm,
108109
InterimCancelForm, InterimSessionInlineFormSet, RequestMinutesForm,
@@ -2568,6 +2569,89 @@ def add_session_drafts(request, session_id, num):
25682569
'form': form,
25692570
})
25702571

2572+
class SessionRecordingsForm(forms.Form):
2573+
title = forms.CharField(max_length=255)
2574+
url = forms.URLField(label="URL of the recording (YouTube only)")
2575+
2576+
def clean_url(self):
2577+
url = self.cleaned_data['url']
2578+
parsed_url = urlparse(url)
2579+
if parsed_url.hostname not in YOUTUBE_DOMAINS:
2580+
raise forms.ValidationError("Must be a YouTube URL")
2581+
return url
2582+
2583+
2584+
def add_session_recordings(request, session_id, num):
2585+
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
2586+
session = get_object_or_404(Session, pk=session_id)
2587+
if not session.can_manage_materials(request.user):
2588+
permission_denied(
2589+
request, "You don't have permission to manage recordings for this session."
2590+
)
2591+
if session.is_material_submission_cutoff() and not has_role(
2592+
request.user, "Secretariat"
2593+
):
2594+
raise Http404
2595+
2596+
session_number = None
2597+
official_timeslotassignment = session.official_timeslotassignment()
2598+
assertion("official_timeslotassignment is not None")
2599+
initial = {
2600+
"title": "Video recording of {acronym} for {timestamp}".format(
2601+
acronym=session.group.acronym,
2602+
timestamp=official_timeslotassignment.timeslot.utc_start_time().strftime(
2603+
"%Y-%m-%d %H:%M"
2604+
),
2605+
)
2606+
}
2607+
2608+
# find session number if WG has more than one session at the meeting
2609+
sessions = get_sessions(session.meeting.number, session.group.acronym)
2610+
if len(sessions) > 1:
2611+
session_number = 1 + sessions.index(session)
2612+
2613+
presentations = session.presentations.filter(
2614+
document__in=session.get_material("recording", only_one=False),
2615+
).order_by("document__title", "document__external_url")
2616+
2617+
if request.method == "POST":
2618+
pk_to_delete = request.POST.get("delete", None)
2619+
if pk_to_delete is not None:
2620+
session_presentation = get_object_or_404(presentations, pk=pk_to_delete)
2621+
try:
2622+
delete_recording(session_presentation)
2623+
except ValueError as err:
2624+
log(f"Error deleting recording from session {session.pk}: {err}")
2625+
messages.error(
2626+
request,
2627+
"Unable to delete this recording. Please contact the secretariat for assistance.",
2628+
)
2629+
form = SessionRecordingsForm(initial=initial)
2630+
else:
2631+
form = SessionRecordingsForm(request.POST)
2632+
if form.is_valid():
2633+
title = form.cleaned_data["title"]
2634+
url = form.cleaned_data["url"]
2635+
create_recording(session, url, title=title, user=request.user.person)
2636+
return redirect(
2637+
"ietf.meeting.views.session_details",
2638+
num=session.meeting.number,
2639+
acronym=session.group.acronym,
2640+
)
2641+
else:
2642+
form = SessionRecordingsForm(initial=initial)
2643+
2644+
return render(
2645+
request,
2646+
"meeting/add_session_recordings.html",
2647+
{
2648+
"session": session,
2649+
"session_number": session_number,
2650+
"already_linked": presentations,
2651+
"form": form,
2652+
},
2653+
)
2654+
25712655

25722656
def session_attendance(request, session_id, num):
25732657
"""Session attendance view

ietf/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,3 +1397,6 @@ def skip_unreadable_post(record):
13971397
CSRF_TRUSTED_ORIGINS += ['http://localhost:8000', 'http://127.0.0.1:8000', 'http://[::1]:8000']
13981398
SESSION_COOKIE_SECURE = False
13991399
SESSION_COOKIE_SAMESITE = 'Lax'
1400+
1401+
1402+
YOUTUBE_DOMAINS = ['www.youtube.com', 'youtube.com', 'youtu.be', 'm.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com']
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright The IETF Trust 2024-2025, All Rights Reserved
2+
document.addEventListener('DOMContentLoaded', () => {
3+
const form = document.getElementById('delete_recordings_form')
4+
const dialog = document.getElementById('delete_confirm_dialog')
5+
const dialog_link = document.getElementById('delete_confirm_link')
6+
const dialog_submit = document.getElementById('delete_confirm_submit')
7+
const dialog_cancel = document.getElementById('delete_confirm_cancel')
8+
9+
dialog.style.maxWidth = '30vw'
10+
11+
form.addEventListener('submit', (e) => {
12+
e.preventDefault()
13+
dialog_submit.value = e.submitter.value
14+
const recording_link = e.submitter.closest('tr').querySelector('a')
15+
dialog_link.setAttribute('href', recording_link.getAttribute('href'))
16+
dialog_link.textContent = recording_link.textContent
17+
dialog.showModal()
18+
})
19+
20+
dialog_cancel.addEventListener('click', (e) => {
21+
e.preventDefault()
22+
dialog.close()
23+
})
24+
25+
document.addEventListener('keydown', (e) => {
26+
if (dialog.open && e.key === 'Escape') {
27+
dialog.close()
28+
}
29+
})
30+
})

0 commit comments

Comments
 (0)