feat: Add session recordings (#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>
This commit is contained in:
Matthew Holloway 2025-02-01 05:28:39 +13:00 committed by GitHub
parent 02a680f872
commit db7d3074da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 385 additions and 5 deletions

View file

@ -48,7 +48,7 @@ from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignm
from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data
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, bluesheet_data
from ietf.meeting.utils import create_recording, delete_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, generate_agenda_data
from ietf.name.models import SessionStatusName, ImportantDateName, RoleName, ProceedingsMaterialTypeName
@ -441,6 +441,48 @@ class MeetingTests(BaseMeetingTestCase):
self.assertIn(new_recording_title, links[0].text_content())
#debug.show("q(f'#notes_and_recordings_{session_pk}')")
def test_delete_recordings(self):
# No user specified, active recording state
sp = SessionPresentationFactory(
document__type_id="recording",
document__external_url="https://example.com/some-recording",
document__states=[("recording", "active")],
)
doc = sp.document
doc.docevent_set.all().delete() # clear this out
delete_recording(sp)
self.assertFalse(SessionPresentation.objects.filter(pk=sp.pk).exists())
self.assertEqual(doc.get_state("recording").slug, "deleted", "recording state updated")
self.assertEqual(doc.docevent_set.count(), 1, "one event added")
event = doc.docevent_set.first()
self.assertEqual(event.type, "changed_state", "event is a changed_state event")
self.assertEqual(event.by.name, "(System)", "system user is responsible")
# Specified user, no recording state
sp = SessionPresentationFactory(
document__type_id="recording",
document__external_url="https://example.com/some-recording",
document__states=[],
)
doc = sp.document
doc.docevent_set.all().delete() # clear this out
user = PersonFactory() # naming matches the methods - user is a Person, not a User
delete_recording(sp, user=user)
self.assertFalse(SessionPresentation.objects.filter(pk=sp.pk).exists())
self.assertEqual(doc.get_state("recording").slug, "deleted", "recording state updated")
self.assertEqual(doc.docevent_set.count(), 1, "one event added")
event = doc.docevent_set.first()
self.assertEqual(event.type, "changed_state", "event is a changed_state event")
self.assertEqual(event.by, user, "user is responsible")
# Document is not a recording
sp = SessionPresentationFactory(
document__type_id="draft",
document__external_url="https://example.com/some-recording",
)
with self.assertRaises(ValueError):
delete_recording(sp)
def test_agenda_ical_next_meeting_type(self):
# start with no upcoming IETF meetings, just an interim
MeetingFactory(
@ -7363,6 +7405,118 @@ class SessionTests(TestCase):
self.assertEqual(r.status_code,302)
self.assertEqual(len(outbox),1)
@override_settings(YOUTUBE_DOMAINS=["youtube.com"])
def test_add_session_recordings(self):
session = SessionFactory(meeting__type_id="ietf")
url = urlreverse(
"ietf.meeting.views.add_session_recordings",
kwargs={"session_id": session.pk, "num": session.meeting.number},
)
# does not fully validate authorization for non-secretariat users :-(
login_testing_unauthorized(self, "secretary", url)
r = self.client.get(url)
pq = PyQuery(r.content)
title_input = pq("input#id_title")
self.assertIsNotNone(title_input)
self.assertEqual(
title_input.attr.value,
"Video recording of {acro} for {timestamp}".format(
acro=session.group.acronym,
timestamp=session.official_timeslotassignment().timeslot.utc_start_time().strftime(
"%Y-%m-%d %H:%M"
),
),
)
with patch("ietf.meeting.views.create_recording") as mock_create:
r = self.client.post(
url,
data={
"title": "This is my video title",
"url": "",
}
)
self.assertFalse(mock_create.called)
with patch("ietf.meeting.views.create_recording") as mock_create:
r = self.client.post(
url,
data={
"title": "This is my video title",
"url": "https://yubtub.com/this-is-not-a-youtube-video",
}
)
self.assertFalse(mock_create.called)
with patch("ietf.meeting.views.create_recording") as mock_create:
r = self.client.post(
url,
data={
"title": "This is my video title",
"url": "https://youtube.com/finally-a-video",
}
)
self.assertTrue(mock_create.called)
self.assertEqual(
mock_create.call_args,
call(
session,
"https://youtube.com/finally-a-video",
title="This is my video title",
user=Person.objects.get(user__username="secretary"),
),
)
# CAN delete session presentation for this session
sp = SessionPresentationFactory(
session=session,
document__type_id="recording",
document__external_url="https://example.com/some-video",
)
with patch("ietf.meeting.views.delete_recording") as mock_delete:
r = self.client.post(
url,
data={
"delete": str(sp.pk),
}
)
self.assertEqual(r.status_code, 200)
self.assertTrue(mock_delete.called)
self.assertEqual(mock_delete.call_args, call(sp))
# ValueError message from delete_recording does not reach the user
sp = SessionPresentationFactory(
session=session,
document__type_id="recording",
document__external_url="https://example.com/some-video",
)
with patch("ietf.meeting.views.delete_recording", side_effect=ValueError("oh joy!")) as mock_delete:
r = self.client.post(
url,
data={
"delete": str(sp.pk),
}
)
self.assertTrue(mock_delete.called)
self.assertNotContains(r, "oh joy!", status_code=200)
# CANNOT delete session presentation for a different session
sp_for_other_session = SessionPresentationFactory(
document__type_id="recording",
document__external_url="https://example.com/some-other-video",
)
with patch("ietf.meeting.views.delete_recording") as mock_delete:
r = self.client.post(
url,
data={
"delete": str(sp_for_other_session.pk),
}
)
self.assertEqual(r.status_code, 404)
self.assertFalse(mock_delete.called)
class HasMeetingsTests(TestCase):
settings_temp_path_overrides = TestCase.settings_temp_path_overrides + ['AGENDA_PATH']

View file

@ -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+)/recordings$', views.add_session_recordings),
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),
@ -63,7 +64,8 @@ type_ietf_only_patterns = [
type_interim_patterns = [
url(r'^agenda/(?P<acronym>[A-Za-z0-9-]+)-drafts.pdf$', views.session_draft_pdf),
url(r'^agenda/(?P<acronym>[A-Za-z0-9-]+)-drafts.tgz$', views.session_draft_tarfile),
url(r'^materials/%(document)s((?P<ext>\.[a-z0-9]+)|/)?$' % settings.URL_REGEXPS, views.materials_document),
url(r'^materials/%(document)s(?P<ext>\.[a-z0-9]+)$' % settings.URL_REGEXPS, views.materials_document),
url(r'^materials/%(document)s/?$' % settings.URL_REGEXPS, views.materials_document),
url(r'^agenda.json$', views.agenda_json)
]

View file

@ -23,7 +23,7 @@ import debug # pyflakes:ignore
from ietf.dbtemplate.models import DBTemplate
from ietf.meeting.models import (Session, SchedulingEvent, TimeSlot,
Constraint, SchedTimeSessAssignment, SessionPresentation, Attended)
from ietf.doc.models import Document, State, NewRevisionDocEvent
from ietf.doc.models import Document, State, NewRevisionDocEvent, StateDocEvent
from ietf.doc.models import DocEvent
from ietf.group.models import Group
from ietf.group.utils import can_manage_materials
@ -853,6 +853,26 @@ def create_recording(session, url, title=None, user=None):
return doc
def delete_recording(session_presentation, user=None):
"""Delete a session recording"""
document = session_presentation.document
if document.type_id != "recording":
raise ValueError(f"Document {document.pk} is not a recording (type_id={document.type_id})")
recording_state = document.get_state("recording")
deleted_state = State.objects.get(type_id="recording", slug="deleted")
if recording_state != deleted_state:
# Update the recording state and create a history event
document.set_state(deleted_state)
StateDocEvent.objects.create(
type="changed_state",
by=user or Person.objects.get(name="(System)"),
doc=document,
rev=document.rev,
state_type=deleted_state.type,
state=deleted_state,
)
session_presentation.delete()
def get_next_sequence(group, meeting, type):
'''
Returns the next sequence number to use for a document of type = type.

View file

@ -20,7 +20,7 @@ from collections import OrderedDict, Counter, deque, defaultdict, namedtuple
from functools import partialmethod
import jsonschema
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit
from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit, urlparse
from tempfile import mkstemp
from wsgiref.handlers import format_date_time
@ -86,7 +86,7 @@ from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_ob
from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments, bulk_create_timeslots
from ietf.meeting.utils import preprocess_meeting_important_dates
from ietf.meeting.utils import new_doc_for_session, write_doc_for_session
from ietf.meeting.utils import get_activity_stats, post_process, create_recording
from ietf.meeting.utils import get_activity_stats, post_process, create_recording, delete_recording
from ietf.meeting.utils import participants_for_meeting, generate_bluesheet, bluesheet_data, save_bluesheet
from ietf.message.utils import infer_message
from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName
@ -103,6 +103,7 @@ from ietf.utils.pdf import pdf_pages
from ietf.utils.response import permission_denied
from ietf.utils.text import xslugify
from ietf.utils.timezone import datetime_today, date_today
from ietf.settings import YOUTUBE_DOMAINS
from .forms import (InterimMeetingModelForm, InterimAnnounceForm, InterimSessionModelForm,
InterimCancelForm, InterimSessionInlineFormSet, RequestMinutesForm,
@ -2568,6 +2569,89 @@ def add_session_drafts(request, session_id, num):
'form': form,
})
class SessionRecordingsForm(forms.Form):
title = forms.CharField(max_length=255)
url = forms.URLField(label="URL of the recording (YouTube only)")
def clean_url(self):
url = self.cleaned_data['url']
parsed_url = urlparse(url)
if parsed_url.hostname not in YOUTUBE_DOMAINS:
raise forms.ValidationError("Must be a YouTube URL")
return url
def add_session_recordings(request, session_id, num):
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
session = get_object_or_404(Session, pk=session_id)
if not session.can_manage_materials(request.user):
permission_denied(
request, "You don't have permission to manage recordings for this session."
)
if session.is_material_submission_cutoff() and not has_role(
request.user, "Secretariat"
):
raise Http404
session_number = None
official_timeslotassignment = session.official_timeslotassignment()
assertion("official_timeslotassignment is not None")
initial = {
"title": "Video recording of {acronym} for {timestamp}".format(
acronym=session.group.acronym,
timestamp=official_timeslotassignment.timeslot.utc_start_time().strftime(
"%Y-%m-%d %H:%M"
),
)
}
# find session number if WG has more than one session at the meeting
sessions = get_sessions(session.meeting.number, session.group.acronym)
if len(sessions) > 1:
session_number = 1 + sessions.index(session)
presentations = session.presentations.filter(
document__in=session.get_material("recording", only_one=False),
).order_by("document__title", "document__external_url")
if request.method == "POST":
pk_to_delete = request.POST.get("delete", None)
if pk_to_delete is not None:
session_presentation = get_object_or_404(presentations, pk=pk_to_delete)
try:
delete_recording(session_presentation)
except ValueError as err:
log(f"Error deleting recording from session {session.pk}: {err}")
messages.error(
request,
"Unable to delete this recording. Please contact the secretariat for assistance.",
)
form = SessionRecordingsForm(initial=initial)
else:
form = SessionRecordingsForm(request.POST)
if form.is_valid():
title = form.cleaned_data["title"]
url = form.cleaned_data["url"]
create_recording(session, url, title=title, user=request.user.person)
return redirect(
"ietf.meeting.views.session_details",
num=session.meeting.number,
acronym=session.group.acronym,
)
else:
form = SessionRecordingsForm(initial=initial)
return render(
request,
"meeting/add_session_recordings.html",
{
"session": session,
"session_number": session_number,
"already_linked": presentations,
"form": form,
},
)
def session_attendance(request, session_id, num):
"""Session attendance view

View file

@ -1397,3 +1397,6 @@ if SERVER_MODE != 'production':
CSRF_TRUSTED_ORIGINS += ['http://localhost:8000', 'http://127.0.0.1:8000', 'http://[::1]:8000']
SESSION_COOKIE_SECURE = False
SESSION_COOKIE_SAMESITE = 'Lax'
YOUTUBE_DOMAINS = ['www.youtube.com', 'youtube.com', 'youtu.be', 'm.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com']

View file

@ -0,0 +1,30 @@
// Copyright The IETF Trust 2024-2025, All Rights Reserved
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('delete_recordings_form')
const dialog = document.getElementById('delete_confirm_dialog')
const dialog_link = document.getElementById('delete_confirm_link')
const dialog_submit = document.getElementById('delete_confirm_submit')
const dialog_cancel = document.getElementById('delete_confirm_cancel')
dialog.style.maxWidth = '30vw'
form.addEventListener('submit', (e) => {
e.preventDefault()
dialog_submit.value = e.submitter.value
const recording_link = e.submitter.closest('tr').querySelector('a')
dialog_link.setAttribute('href', recording_link.getAttribute('href'))
dialog_link.textContent = recording_link.textContent
dialog.showModal()
})
dialog_cancel.addEventListener('click', (e) => {
e.preventDefault()
dialog.close()
})
document.addEventListener('keydown', (e) => {
if (dialog.open && e.key === 'Escape') {
dialog.close()
}
})
})

View file

@ -0,0 +1,78 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin static django_bootstrap5 %}
{% block title %}Add I-Ds to {{ session.meeting }} : {{ session.group.acronym }}{% endblock %}
{% block pagehead %}{{ form.media.css }}{% endblock %}
{% block content %}
{% origin %}
<h1>
Add Recordings to {{ session.meeting }}
{% if session_number %}: Session {{ session_number }}{% endif %}
<br>
<small class="text-body-secondary">{{ session.group.acronym }}
{% if session.name %}: {{ session.name }}{% endif %}
</small>
</h1>
{% if session.is_material_submission_cutoff %}
<div class="alert alert-warning my-3">
The deadline for submission corrections has passed. This may affect published proceedings.
</div>
{% endif %}
{% if already_linked|length > 0 %}
<h2 class="mt-5">Recordings already linked to this session</h2>
<form method="post" id="delete_recordings_form">
{% csrf_token %}
<table class="table table-sm table-striped">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">URL</th>
<th scope="col">Delete</th>
</tr>
</thead>
<tbody>
{% for sp in already_linked %}{% with recording_doc=sp.document %}
<tr>
<td>{{ recording_doc.title }}</td>
<td><a href="{{ recording_doc.external_url }}">{{ recording_doc.external_url }}</a></td>
<td>
<button type="submit"
aria-label="Delete {{ recording_doc.title }}"
class="btn btn-danger"
name="delete"
value="{{ sp.pk }}">
Delete
</button>
</td>
</tr>
{% endwith %}{% endfor %}
</tbody>
</table>
</form>
{% endif %}
<dialog id="delete_confirm_dialog">
<p>Really delete the link to <a href="#" id="delete_confirm_link">(default)</a>?</p>
<form method="post" class="d-flex justify-content-between">
{% csrf_token %}
<button class="btn btn-secondary" type="button" id="delete_confirm_cancel">Cancel</button>
<button class="btn btn-danger" type="submit" name="delete" id="delete_confirm_submit">Delete</button>
</form>
</dialog>
<h2 class="mt-5">Add a recording to this session</h2>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
<button class="btn btn-{% if session.is_material_submission_cutoff %}warning{% else %}primary{% endif %}"
type="submit">
Add recording
</button>
<a class="btn btn-secondary float-end"
href="{% url 'ietf.meeting.views.session_details' num=session.meeting.number acronym=session.group.acronym %}">
Back
</a>
</form>
{% endblock %}
{% block js %}
{{ form.media.js }}
<script src="{% static 'ietf/js/add_session_recordings.js' %}"></script>
{% endblock %}

View file

@ -368,5 +368,13 @@
</tbody>
</table>
{% endif %}
{% if can_manage_materials %}
<a class="btn btn-primary"
href="{% url 'ietf.meeting.views.add_session_recordings' session_id=session.pk num=session.meeting.number %}">
Link additional recordings to session
</a>
{% endif %}
{% endwith %}{% endwith %}
{% endfor %}

View file

@ -111,6 +111,7 @@
"ietf/static/images/irtf-logo-card.png",
"ietf/static/images/irtf-logo-white.svg",
"ietf/static/images/irtf-logo.svg",
"ietf/static/js/add_session_recordings.js",
"ietf/static/js/agenda_filter.js",
"ietf/static/js/agenda_materials.js",
"ietf/static/js/complete-review.js",