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:
parent
02a680f872
commit
db7d3074da
|
@ -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']
|
||||
|
||||
|
|
|
@ -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)
|
||||
]
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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']
|
||||
|
|
30
ietf/static/js/add_session_recordings.js
Normal file
30
ietf/static/js/add_session_recordings.js
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
78
ietf/templates/meeting/add_session_recordings.html
Normal file
78
ietf/templates/meeting/add_session_recordings.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue