Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add session recordings #8218

Merged
merged 30 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d4052f9
feat: add session recordings
holloway Nov 15, 2024
420e296
feat: add session recordings
holloway Nov 17, 2024
061b68a
feat: deleting recordings
holloway Nov 17, 2024
2c15423
feat: deleting recordings and initial form values
holloway Nov 17, 2024
f0598e9
feat: use meeting date rather than today for initial title field. Fix…
holloway Nov 18, 2024
3cc968e
feat: confirm delete recordings modal. fix server utils delete recording
holloway Nov 18, 2024
ce04b71
fix: removing debug console.log
holloway Nov 19, 2024
606bf6d
feat: change button name from 'Ok' to 'Delete' for confirm deletion t…
holloway Nov 19, 2024
bda7061
feat: UTC time in string and delete modal text
holloway Nov 19, 2024
7b7caa2
fix: django html validation tests
holloway Nov 19, 2024
fd4547e
fix: django html validation tests
holloway Nov 19, 2024
7497306
fix: django html validation tests
holloway Nov 19, 2024
bca12a0
Merge branch 'main' into add-recordings-mk2
jennifer-richards Jan 14, 2025
01c8e8a
refactor: Work with SessionPresentations
jennifer-richards Jan 14, 2025
4783c43
fix: better ordering
jennifer-richards Jan 14, 2025
25a8eeb
chore: drop rev, hide table when empty
jennifer-richards Jan 14, 2025
761eb36
test: test delete_recordings method
jennifer-richards Jan 14, 2025
9b293b1
fix: debug delete_recordings
jennifer-richards Jan 14, 2025
4dca900
Merge branch 'ietf-tools:main' into add-recordings
holloway Jan 14, 2025
67536d6
test: test add_session_recordings view
jennifer-richards Jan 14, 2025
d0bc1ae
fix: better permissions handling
jennifer-richards Jan 14, 2025
33ae6b7
Merge remote-tracking branch 'holloway/add-recordings' into add-recor…
jennifer-richards Jan 14, 2025
0145581
fix: only delete recordings for selected session
jennifer-richards Jan 30, 2025
8d2297a
refactor: inline script -> js module
jennifer-richards Jan 30, 2025
f2e14ca
chore: remove accidental import
jennifer-richards Jan 30, 2025
6f9f337
fix: consistent timestamp format
jennifer-richards Jan 31, 2025
9c66886
style: Black
jennifer-richards Jan 31, 2025
50a2ff8
chore: remove comment
jennifer-richards Jan 31, 2025
3af8938
test: update test to match
jennifer-richards Jan 31, 2025
d801631
fix: reversible url pattern for materials
jennifer-richards Jan 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 155 additions & 1 deletion ietf/meeting/tests_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
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
Expand Down Expand Up @@ -441,6 +441,48 @@ def test_session_recordings_via_factories(self):
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(
Expand Down Expand Up @@ -7364,6 +7406,118 @@ def test_request_minutes(self):
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']

Expand Down
4 changes: 3 additions & 1 deletion ietf/meeting/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def get_redirect_url(self, *args, **kwargs):
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),
Expand Down Expand Up @@ -63,7 +64,8 @@ def get_redirect_url(self, *args, **kwargs):
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)
]

Expand Down
22 changes: 21 additions & 1 deletion ietf/meeting/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
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
Expand Down Expand Up @@ -870,6 +870,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.
Expand Down
88 changes: 86 additions & 2 deletions ietf/meeting/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
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

Expand Down Expand Up @@ -86,7 +86,7 @@
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
Expand All @@ -103,6 +103,7 @@
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,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions ietf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1384,3 +1384,6 @@ def skip_unreadable_post(record):
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']
30 changes: 30 additions & 0 deletions ietf/static/js/add_session_recordings.js
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
Loading
Loading