feat: apis for attaching chatlogs and polls to session materials (#4488)
* feat: apis for attaching chatlogs and polls to session materials * fix: anticipate becoming tzaware, and improve guard against attempts to provide docs for sessions that have no official timeslot assignment. * fix: get chatlog upload to actually work Modifications to several initial implementation decisions. Updates to the fixtures. * fix: test polls upload Refactored test to reduce duplicate code * fix: allow api keys to be created for the new endpoints * feat: add ability to view chatlog and polls documents. Show links in session materials. * fix: commit new template * fix: typo in migration signatures * feat: add main doc page handling for polls. Improve tests. * feat: chat log vue component + embedded vue loader * feat: render polls using Vue * fix: address pug syntax review comments from Nick. * fix: repair remaining mention of chat log from copymunging * fix: use double-quotes in html attributes * fix: provide missing choices update migration * test: silence html validator empty attr warnings * test: fix test_runner config * fix: locate session when looking at a dochistory object for polls or chatlog Co-authored-by: Nicolas Giard <github@ngpixel.com>
This commit is contained in:
parent
9c404a21fc
commit
50668c97cd
42
client/Embedded.vue
Normal file
42
client/Embedded.vue
Normal file
|
@ -0,0 +1,42 @@
|
|||
<template lang="pug">
|
||||
n-theme
|
||||
n-message-provider
|
||||
component(:is='currentComponent', :component-id='props.componentId')
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineAsyncComponent, markRaw, onMounted, ref } from 'vue'
|
||||
import { NMessageProvider } from 'naive-ui'
|
||||
|
||||
import NTheme from './components/n-theme.vue'
|
||||
|
||||
// COMPONENTS
|
||||
|
||||
const availableComponents = {
|
||||
ChatLog: defineAsyncComponent(() => import('./components/ChatLog.vue')),
|
||||
Polls: defineAsyncComponent(() => import('./components/Polls.vue')),
|
||||
}
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
componentName: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
componentId: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// STATE
|
||||
|
||||
const currentComponent = ref(null)
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
currentComponent.value = markRaw(availableComponents[props.componentName] || null)
|
||||
})
|
||||
</script>
|
98
client/components/ChatLog.vue
Normal file
98
client/components/ChatLog.vue
Normal file
|
@ -0,0 +1,98 @@
|
|||
<template lang="pug">
|
||||
.chatlog
|
||||
n-timeline(
|
||||
v-if='state.items.length > 0'
|
||||
:icon-size='18'
|
||||
size='large'
|
||||
)
|
||||
n-timeline-item(
|
||||
v-for='item of state.items'
|
||||
:key='item.id'
|
||||
type='default'
|
||||
:color='item.color'
|
||||
:title='item.author'
|
||||
:time='item.time'
|
||||
)
|
||||
template(#default)
|
||||
div(v-html='item.text')
|
||||
span.text-muted(v-else)
|
||||
em No chat log available.
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { DateTime } from 'luxon'
|
||||
import {
|
||||
NTimeline,
|
||||
NTimelineItem
|
||||
} from 'naive-ui'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
componentId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// STATE
|
||||
|
||||
const state = reactive({
|
||||
items: []
|
||||
})
|
||||
|
||||
// bs5 colors
|
||||
const colors = [
|
||||
'#0d6efd',
|
||||
'#dc3545',
|
||||
'#20c997',
|
||||
'#6f42c1',
|
||||
'#fd7e14',
|
||||
'#198754',
|
||||
'#0dcaf0',
|
||||
'#d63384',
|
||||
'#ffc107',
|
||||
'#6610f2',
|
||||
'#adb5bd'
|
||||
]
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
const authorColors = {}
|
||||
// Get chat log data from embedded json tag
|
||||
const chatLog = JSON.parse(document.getElementById(`${props.componentId}-data`).textContent || '[]')
|
||||
if (chatLog.length > 0) {
|
||||
let idx = 1
|
||||
let colorIdx = 0
|
||||
for (const logItem of chatLog) {
|
||||
// -> Get unique color per author
|
||||
if (!authorColors[logItem.author]) {
|
||||
authorColors[logItem.author] = colors[colorIdx]
|
||||
colorIdx++
|
||||
if (colorIdx >= colors.length) {
|
||||
colorIdx = 0
|
||||
}
|
||||
}
|
||||
// -> Generate log item
|
||||
state.items.push({
|
||||
id: `logitem-${idx}`,
|
||||
color: authorColors[logItem.author],
|
||||
author: logItem.author,
|
||||
text: logItem.text,
|
||||
time: DateTime.fromISO(logItem.time).toFormat('dd LLLL yyyy \'at\' HH:mm:ss a ZZZZ')
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chatlog {
|
||||
.n-timeline-item-content__content > div > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
79
client/components/Polls.vue
Normal file
79
client/components/Polls.vue
Normal file
|
@ -0,0 +1,79 @@
|
|||
<template lang="pug">
|
||||
.polls
|
||||
n-data-table(
|
||||
v-if='state.items.length > 0'
|
||||
:data='state.items'
|
||||
:columns='columns'
|
||||
striped
|
||||
)
|
||||
span.text-muted(v-else)
|
||||
em No polls available.
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { DateTime } from 'luxon'
|
||||
import {
|
||||
NDataTable
|
||||
} from 'naive-ui'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
componentId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// STATE
|
||||
|
||||
const state = reactive({
|
||||
items: []
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Question',
|
||||
key: 'question'
|
||||
},
|
||||
{
|
||||
title: 'Start Time',
|
||||
key: 'start_time',
|
||||
},
|
||||
{
|
||||
title: 'End Time',
|
||||
key: 'end_time'
|
||||
},
|
||||
{
|
||||
title: 'Raise Hand',
|
||||
key: 'raise_hand'
|
||||
},
|
||||
{
|
||||
title: 'Do Not Raise Hand',
|
||||
key: 'do_not_raise_hand'
|
||||
}
|
||||
]
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
// Get polls from embedded json tag
|
||||
const polls = JSON.parse(document.getElementById(`${props.componentId}-data`).textContent || '[]')
|
||||
if (polls.length > 0) {
|
||||
let idx = 1
|
||||
for (const poll of polls) {
|
||||
state.items.push({
|
||||
id: `poll-${idx}`,
|
||||
question: poll.text,
|
||||
start_time: DateTime.fromISO(poll.start_time).toFormat('dd LLLL yyyy \'at\' HH:mm:ss a ZZZZ'),
|
||||
end_time: DateTime.fromISO(poll.end_time).toFormat('dd LLLL yyyy \'at\' HH:mm:ss a ZZZZ'),
|
||||
raise_hand: poll.raise_hand,
|
||||
do_not_raise_hand: poll.do_not_raise_hand
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
13
client/embedded.js
Normal file
13
client/embedded.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { createApp } from 'vue'
|
||||
import Embedded from './Embedded.vue'
|
||||
|
||||
// Mount App
|
||||
|
||||
const mountEls = document.querySelectorAll('div.vue-embed')
|
||||
for (const mnt of mountEls) {
|
||||
const app = createApp(Embedded, {
|
||||
componentName: mnt.dataset.component,
|
||||
componentId: mnt.dataset.componentId
|
||||
})
|
||||
app.mount(mnt)
|
||||
}
|
|
@ -9,6 +9,7 @@ import sys
|
|||
|
||||
from importlib import import_module
|
||||
from mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
@ -21,6 +22,7 @@ from tastypie.test import ResourceTestCaseMixin
|
|||
import debug # pyflakes:ignore
|
||||
|
||||
import ietf
|
||||
from ietf.doc.utils import get_unicode_document_content
|
||||
from ietf.group.factories import RoleFactory
|
||||
from ietf.meeting.factories import MeetingFactory, SessionFactory
|
||||
from ietf.meeting.test_data import make_meeting_test_data
|
||||
|
@ -212,6 +214,93 @@ class CustomApiTests(TestCase):
|
|||
self.assertTrue(session.attended_set.filter(person=recman).exists())
|
||||
self.assertTrue(session.attended_set.filter(person=otherperson).exists())
|
||||
|
||||
def test_api_upload_polls_and_chatlog(self):
|
||||
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')
|
||||
recmanrole.person.user.last_login = timezone.now()
|
||||
recmanrole.person.user.save()
|
||||
|
||||
badrole = RoleFactory(group__type_id='ietf', name_id='ad')
|
||||
badrole.person.user.last_login = timezone.now()
|
||||
badrole.person.user.save()
|
||||
|
||||
meeting = MeetingFactory(type_id='ietf')
|
||||
session = SessionFactory(group__type_id='wg', meeting=meeting)
|
||||
|
||||
for type_id, content in (
|
||||
(
|
||||
"chatlog",
|
||||
"""[
|
||||
{
|
||||
"author": "Raymond Lutz",
|
||||
"text": "<p>Yes I like that comment just made</p>",
|
||||
"time": "2022-07-28T19:26:16Z"
|
||||
},
|
||||
{
|
||||
"author": "Carsten Bormann",
|
||||
"text": "<p>But software is not a thing.</p>",
|
||||
"time": "2022-07-28T19:26:45Z"
|
||||
}
|
||||
]"""
|
||||
),
|
||||
(
|
||||
"polls",
|
||||
"""[
|
||||
{
|
||||
"start_time": "2022-07-28T19:19:54Z",
|
||||
"end_time": "2022-07-28T19:20:23Z",
|
||||
"text": "Are you willing to review the documents?",
|
||||
"raise_hand": 57,
|
||||
"do_not_raise_hand": 11
|
||||
},
|
||||
{
|
||||
"start_time": "2022-07-28T19:20:56Z",
|
||||
"end_time": "2022-07-28T19:21:30Z",
|
||||
"text": "Would you be willing to edit or coauthor a document?",
|
||||
"raise_hand": 31,
|
||||
"do_not_raise_hand": 31
|
||||
}
|
||||
]"""
|
||||
),
|
||||
):
|
||||
url = urlreverse(f"ietf.meeting.views.api_upload_{type_id}")
|
||||
apikey = PersonalApiKey.objects.create(endpoint=url, person=recmanrole.person)
|
||||
badapikey = PersonalApiKey.objects.create(endpoint=url, person=badrole.person)
|
||||
|
||||
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.get(url, {'apikey': apikey.hash()} )
|
||||
self.assertContains(r, "Method not allowed", status_code=405)
|
||||
|
||||
r = self.client.post(url, {'apikey': apikey.hash()} )
|
||||
self.assertContains(r, "Missing apidata 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},"{type_id}":"not a list;drop table"}}',
|
||||
f'{{"session_id":{session.pk},"{type_id}":"not a list;drop table"}}',
|
||||
f'{{"session_id":{session.pk},"{type_id}":[{{}}, {{}}, "not an int;drop table", {{}}]}}',
|
||||
):
|
||||
r = self.client.post(url, {'apikey': apikey.hash(), 'apidata': 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(), 'apidata': f'{{"session_id":{bad_session_id},"{type_id}":[]}}'})
|
||||
self.assertContains(r, "Invalid session", status_code=400)
|
||||
|
||||
# Valid POST
|
||||
r = self.client.post(url,{'apikey':apikey.hash(),'apidata': f'{{"session_id":{session.pk}, "{type_id}":{content}}}'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
newdoc = session.sessionpresentation_set.get(document__type_id=type_id).document
|
||||
newdoccontent = get_unicode_document_content(newdoc.name, Path(session.meeting.get_materials_path()) / type_id / newdoc.uploaded_filename)
|
||||
self.assertEqual(json.loads(content), json.loads(newdoccontent))
|
||||
|
||||
def test_api_upload_bluesheet(self):
|
||||
url = urlreverse('ietf.meeting.views.api_upload_bluesheet')
|
||||
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')
|
||||
|
|
|
@ -37,6 +37,10 @@ urlpatterns = [
|
|||
url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet),
|
||||
# Let MeetEcho tell us about session attendees
|
||||
url(r'^notify/session/attendees/?$', meeting_views.api_add_session_attendees),
|
||||
# Let MeetEcho upload session chatlog
|
||||
url(r'^notify/session/chatlog/?$', meeting_views.api_upload_chatlog),
|
||||
# Let MeetEcho upload session polls
|
||||
url(r'^notify/session/polls/?$', meeting_views.api_upload_polls),
|
||||
# Let the registration system notify us about registrations
|
||||
url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration),
|
||||
# OpenID authentication provider
|
||||
|
|
34
ietf/doc/migrations/0045_docstates_chatlogs_polls.py
Normal file
34
ietf/doc/migrations/0045_docstates_chatlogs_polls.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Copyright The IETF Trust 2022, All Rights Reserved
|
||||
from django.db import migrations
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
StateType = apps.get_model("doc", "StateType")
|
||||
State = apps.get_model("doc", "State")
|
||||
for slug in ("chatlog", "polls"):
|
||||
StateType.objects.create(slug=slug, label="State")
|
||||
for state_slug in ("active", "deleted"):
|
||||
State.objects.create(
|
||||
type_id = slug,
|
||||
slug = state_slug,
|
||||
name = state_slug.capitalize(),
|
||||
used = True,
|
||||
desc = "",
|
||||
order = 0,
|
||||
)
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
StateType = apps.get_model("doc", "StateType")
|
||||
State = apps.get_model("doc", "State")
|
||||
State.objects.filter(type_id__in=("chatlog", "polls")).delete()
|
||||
StateType.objects.filter(slug__in=("chatlog", "polls")).delete()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('doc', '0044_procmaterials_states'),
|
||||
('name', '0045_polls_and_chatlogs'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse),
|
||||
]
|
|
@ -136,7 +136,7 @@ class DocumentInfo(models.Model):
|
|||
else:
|
||||
self._cached_file_path = settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR
|
||||
elif self.meeting_related() and self.type_id in (
|
||||
"agenda", "minutes", "slides", "bluesheets", "procmaterials"
|
||||
"agenda", "minutes", "slides", "bluesheets", "procmaterials", "chatlog", "polls"
|
||||
):
|
||||
meeting = self.get_related_meeting()
|
||||
if meeting is not None:
|
||||
|
@ -420,7 +420,7 @@ class DocumentInfo(models.Model):
|
|||
return e != None and (e.text != "")
|
||||
|
||||
def meeting_related(self):
|
||||
if self.type_id in ("agenda","minutes","bluesheets","slides","recording","procmaterials"):
|
||||
if self.type_id in ("agenda","minutes","bluesheets","slides","recording","procmaterials","chatlog","polls"):
|
||||
return self.type_id != "slides" or self.get_state_slug('reuse_policy')=='single'
|
||||
return False
|
||||
|
||||
|
|
|
@ -1464,6 +1464,10 @@ Man Expires September 22, 2015 [Page 3]
|
|||
DocumentFactory(type_id='agenda',name='agenda-72-mars')
|
||||
DocumentFactory(type_id='minutes',name='minutes-72-mars')
|
||||
DocumentFactory(type_id='slides',name='slides-72-mars-1-active')
|
||||
chatlog = DocumentFactory(type_id="chatlog",name='chatlog-72-mars-197001010000')
|
||||
polls = DocumentFactory(type_id="polls",name='polls-72-mars-197001010000')
|
||||
SessionPresentationFactory(document=chatlog)
|
||||
SessionPresentationFactory(document=polls)
|
||||
statchg = DocumentFactory(type_id='statchg',name='status-change-imaginary-mid-review')
|
||||
statchg.set_state(State.objects.get(type_id='statchg',slug='adrev'))
|
||||
|
||||
|
@ -1475,6 +1479,8 @@ Man Expires September 22, 2015 [Page 3]
|
|||
"agenda-72-mars",
|
||||
"minutes-72-mars",
|
||||
"slides-72-mars-1-active",
|
||||
"chatlog-72-mars-197001010000",
|
||||
"polls-72-mars-197001010000",
|
||||
# TODO: add
|
||||
#"bluesheets-72-mars-1",
|
||||
#"recording-72-mars-1-00",
|
||||
|
|
|
@ -42,6 +42,7 @@ import os
|
|||
import re
|
||||
|
||||
from urllib.parse import quote
|
||||
from pathlib import Path
|
||||
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
|
@ -641,9 +642,7 @@ def document_main(request, name, rev=None):
|
|||
sorted_relations=sorted_relations,
|
||||
))
|
||||
|
||||
# TODO : Add "recording", and "bluesheets" here when those documents are appropriately
|
||||
# created and content is made available on disk
|
||||
if doc.type_id in ("slides", "agenda", "minutes", "bluesheets","procmaterials",):
|
||||
if doc.type_id in ("slides", "agenda", "minutes", "bluesheets", "procmaterials",):
|
||||
can_manage_material = can_manage_materials(request.user, doc.group)
|
||||
presentations = doc.future_presentations()
|
||||
if doc.uploaded_filename:
|
||||
|
@ -725,6 +724,29 @@ def document_main(request, name, rev=None):
|
|||
assignments=assignments,
|
||||
))
|
||||
|
||||
if doc.type_id in ("chatlog", "polls"):
|
||||
if isinstance(doc,DocHistory):
|
||||
session = doc.doc.sessionpresentation_set.last().session
|
||||
else:
|
||||
session = doc.sessionpresentation_set.last().session
|
||||
pathname = Path(session.meeting.get_materials_path()) / doc.type_id / doc.uploaded_filename
|
||||
content = get_unicode_document_content(doc.name, str(pathname))
|
||||
return render(
|
||||
request,
|
||||
f"doc/document_{doc.type_id}.html",
|
||||
dict(
|
||||
doc=doc,
|
||||
top=top,
|
||||
content=content,
|
||||
revisions=revisions,
|
||||
latest_rev=latest_rev,
|
||||
snapshot=snapshot,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
raise Http404("Document not found: %s" % (name + ("-%s"%rev if rev else "")))
|
||||
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ from ietf.meeting.models import Session, SchedulingEvent, TimeSlot, Constraint,
|
|||
from ietf.doc.models import Document, DocAlias, State, NewRevisionDocEvent
|
||||
from ietf.group.models import Group
|
||||
from ietf.group.utils import can_manage_materials
|
||||
from ietf.name.models import SessionStatusName, ConstraintName
|
||||
from ietf.name.models import SessionStatusName, ConstraintName, DocTypeName
|
||||
from ietf.person.models import Person
|
||||
from ietf.secr.proceedings.proc_utils import import_audio_files
|
||||
from ietf.utils.html import sanitize_document
|
||||
|
@ -710,3 +710,35 @@ def handle_upload_file(file, filename, meeting, subdir, request=None, encoding=N
|
|||
subprocess.call(['unzip', filename], cwd=path)
|
||||
|
||||
return None
|
||||
|
||||
def new_doc_for_session(type_id, session):
|
||||
typename = DocTypeName.objects.get(slug=type_id)
|
||||
ota = session.official_timeslotassignment()
|
||||
if ota is None:
|
||||
return None
|
||||
sess_time = ota.timeslot.local_start_time()
|
||||
if session.meeting.type_id == "ietf":
|
||||
name = f"{typename.prefix}-{session.meeting.number}-{session.group.acronym}-{sess_time.strftime('%Y%m%d%H%M')}"
|
||||
title = f"{typename.name} IETF{session.meeting.number}: {session.group.acronym}: {sess_time.strftime('%a %H:%M')}"
|
||||
else:
|
||||
name = f"{typename.prefix}-{session.meeting.number}-{sess_time.strftime('%Y%m%d%H%M')}"
|
||||
title = f"{typename.name} {session.meeting.number}: {sess_time.strftime('%a %H:%M')}"
|
||||
doc = Document.objects.create(
|
||||
name = name,
|
||||
type_id = type_id,
|
||||
title = title,
|
||||
group = session.group,
|
||||
rev = '00',
|
||||
)
|
||||
doc.states.add(State.objects.get(type_id=type_id, slug='active'))
|
||||
DocAlias.objects.create(name=doc.name).docs.add(doc)
|
||||
session.sessionpresentation_set.create(document=doc,rev='00')
|
||||
return doc
|
||||
|
||||
def write_doc_for_session(session, type_id, filename, contents):
|
||||
filename = Path(filename)
|
||||
path = Path(session.meeting.get_materials_path()) / type_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
with open(path / filename, "wb") as file:
|
||||
file.write(contents.encode('utf-8'))
|
||||
return
|
||||
|
|
|
@ -81,6 +81,7 @@ from ietf.meeting.utils import preprocess_constraints_for_meeting_schedule_edito
|
|||
from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_objects
|
||||
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.message.utils import infer_message
|
||||
from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName
|
||||
from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files,
|
||||
|
@ -2310,6 +2311,7 @@ def session_details(request, num, acronym):
|
|||
session.filtered_artifacts.sort(key=lambda d:['agenda','minutes','bluesheets'].index(d.document.type.slug))
|
||||
session.filtered_slides = session.sessionpresentation_set.filter(document__type__slug='slides').order_by('order')
|
||||
session.filtered_drafts = session.sessionpresentation_set.filter(document__type__slug='draft')
|
||||
session.filtered_chatlog_and_polls = session.sessionpresentation_set.filter(document__type__slug__in=('chatlog', 'polls')).order_by('document__type__slug')
|
||||
# TODO FIXME Deleted materials shouldn't be in the sessionpresentation_set
|
||||
for qs in [session.filtered_artifacts,session.filtered_slides,session.filtered_drafts]:
|
||||
qs = [p for p in qs if p.document.get_state_slug(p.document.type_id)!='deleted']
|
||||
|
@ -3820,6 +3822,85 @@ def api_add_session_attendees(request):
|
|||
session.attended_set.get_or_create(person=user.person)
|
||||
return HttpResponse("Done", status=200, content_type='text/plain')
|
||||
|
||||
@require_api_key
|
||||
@role_required('Recording Manager')
|
||||
@csrf_exempt
|
||||
def api_upload_chatlog(request):
|
||||
def err(code, text):
|
||||
return HttpResponse(text, status=code, content_type='text/plain')
|
||||
if request.method != 'POST':
|
||||
return err(405, "Method not allowed")
|
||||
apidata_post = request.POST.get('apidata')
|
||||
if not apidata_post:
|
||||
return err(400, "Missing apidata parameter")
|
||||
try:
|
||||
apidata = json.loads(apidata_post)
|
||||
except json.decoder.JSONDecodeError:
|
||||
return err(400, "Malformed post")
|
||||
if not ( 'session_id' in apidata and type(apidata['session_id']) is int ):
|
||||
return err(400, "Malformed post")
|
||||
session_id = apidata['session_id']
|
||||
if not ( 'chatlog' in apidata and type(apidata['chatlog']) is list and all([type(el) is dict for el in apidata['chatlog']]) ):
|
||||
return err(400, "Malformed post")
|
||||
session = Session.objects.filter(pk=session_id).first()
|
||||
if not session:
|
||||
return err(400, "Invalid session")
|
||||
chatlog_sp = session.sessionpresentation_set.filter(document__type='chatlog').first()
|
||||
if chatlog_sp:
|
||||
doc = chatlog_sp.document
|
||||
doc.rev = f"{(int(doc.rev)+1):02d}"
|
||||
chatlog_sp.rev = doc.rev
|
||||
chatlog_sp.save()
|
||||
else:
|
||||
doc = new_doc_for_session('chatlog', session)
|
||||
if doc is None:
|
||||
return err(400, "Could not find official timeslot for session")
|
||||
filename = f"{doc.name}-{doc.rev}.json"
|
||||
doc.uploaded_filename = filename
|
||||
write_doc_for_session(session, 'chatlog', filename, json.dumps(apidata['chatlog']))
|
||||
e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev)
|
||||
doc.save_with_history([e])
|
||||
return HttpResponse("Done", status=200, content_type='text/plain')
|
||||
|
||||
@require_api_key
|
||||
@role_required('Recording Manager')
|
||||
@csrf_exempt
|
||||
def api_upload_polls(request):
|
||||
def err(code, text):
|
||||
return HttpResponse(text, status=code, content_type='text/plain')
|
||||
if request.method != 'POST':
|
||||
return err(405, "Method not allowed")
|
||||
apidata_post = request.POST.get('apidata')
|
||||
if not apidata_post:
|
||||
return err(400, "Missing apidata parameter")
|
||||
try:
|
||||
apidata = json.loads(apidata_post)
|
||||
except json.decoder.JSONDecodeError:
|
||||
return err(400, "Malformed post")
|
||||
if not ( 'session_id' in apidata and type(apidata['session_id']) is int ):
|
||||
return err(400, "Malformed post")
|
||||
session_id = apidata['session_id']
|
||||
if not ( 'polls' in apidata and type(apidata['polls']) is list and all([type(el) is dict for el in apidata['polls']]) ):
|
||||
return err(400, "Malformed post")
|
||||
session = Session.objects.filter(pk=session_id).first()
|
||||
if not session:
|
||||
return err(400, "Invalid session")
|
||||
polls_sp = session.sessionpresentation_set.filter(document__type='polls').first()
|
||||
if polls_sp:
|
||||
doc = polls_sp.document
|
||||
doc.rev = f"{(int(doc.rev)+1):02d}"
|
||||
polls_sp.rev = doc.rev
|
||||
polls_sp.save()
|
||||
else:
|
||||
doc = new_doc_for_session('polls', session)
|
||||
if doc is None:
|
||||
return err(400, "Could not find official timeslot for session")
|
||||
filename = f"{doc.name}-{doc.rev}.json"
|
||||
doc.uploaded_filename = filename
|
||||
write_doc_for_session(session, 'polls', filename, json.dumps(apidata['polls']))
|
||||
e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev)
|
||||
doc.save_with_history([e])
|
||||
return HttpResponse("Done", status=200, content_type='text/plain')
|
||||
|
||||
@require_api_key
|
||||
@role_required('Recording Manager', 'Secretariat')
|
||||
|
|
|
@ -2200,7 +2200,7 @@
|
|||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "The IESG has not started processing this draft, or has stopped processing it without publicastion.",
|
||||
"desc": "The IESG has not started processing this draft, or has stopped processing it without publication.",
|
||||
"name": "I-D Exists",
|
||||
"next_states": [
|
||||
16,
|
||||
|
@ -2405,6 +2405,58 @@
|
|||
"model": "doc.state",
|
||||
"pk": 164
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Active",
|
||||
"next_states": [],
|
||||
"order": 0,
|
||||
"slug": "active",
|
||||
"type": "chatlog",
|
||||
"used": true
|
||||
},
|
||||
"model": "doc.state",
|
||||
"pk": 165
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Deleted",
|
||||
"next_states": [],
|
||||
"order": 0,
|
||||
"slug": "deleted",
|
||||
"type": "chatlog",
|
||||
"used": true
|
||||
},
|
||||
"model": "doc.state",
|
||||
"pk": 166
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Active",
|
||||
"next_states": [],
|
||||
"order": 0,
|
||||
"slug": "active",
|
||||
"type": "polls",
|
||||
"used": true
|
||||
},
|
||||
"model": "doc.state",
|
||||
"pk": 167
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Deleted",
|
||||
"next_states": [],
|
||||
"order": 0,
|
||||
"slug": "deleted",
|
||||
"type": "polls",
|
||||
"used": true
|
||||
},
|
||||
"model": "doc.state",
|
||||
"pk": 168
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"label": "State"
|
||||
|
@ -2433,6 +2485,13 @@
|
|||
"model": "doc.statetype",
|
||||
"pk": "charter"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"label": "State"
|
||||
},
|
||||
"model": "doc.statetype",
|
||||
"pk": "chatlog"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"label": "Conflict Review State"
|
||||
|
@ -2538,6 +2597,13 @@
|
|||
"model": "doc.statetype",
|
||||
"pk": "minutes"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"label": "State"
|
||||
},
|
||||
"model": "doc.statetype",
|
||||
"pk": "polls"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"label": "Proceedings Materials State"
|
||||
|
@ -3345,7 +3411,7 @@
|
|||
"has_session_materials": false,
|
||||
"is_schedulable": false,
|
||||
"material_types": "[\n \"slides\"\n]",
|
||||
"matman_roles": "[]",
|
||||
"matman_roles": "[\n \"chair\"\n]",
|
||||
"need_parent": false,
|
||||
"parent_types": [],
|
||||
"req_subm_approval": true,
|
||||
|
@ -3458,8 +3524,8 @@
|
|||
"has_milestones": false,
|
||||
"has_nonsession_materials": true,
|
||||
"has_reviews": false,
|
||||
"has_session_materials": false,
|
||||
"is_schedulable": false,
|
||||
"has_session_materials": true,
|
||||
"is_schedulable": true,
|
||||
"material_types": "[\n \"slides\"\n]",
|
||||
"matman_roles": "[\n \"chair\",\n \"matman\"\n]",
|
||||
"need_parent": false,
|
||||
|
@ -3469,7 +3535,7 @@
|
|||
"req_subm_approval": false,
|
||||
"role_order": "[\n \"chair\",\n \"member\",\n \"matman\"\n]",
|
||||
"session_purposes": "[\n \"coding\",\n \"presentation\",\n \"social\",\n \"tutorial\"\n]",
|
||||
"show_on_agenda": false
|
||||
"show_on_agenda": true
|
||||
},
|
||||
"model": "group.groupfeatures",
|
||||
"pk": "team"
|
||||
|
@ -10126,6 +10192,17 @@
|
|||
"model": "name.doctypename",
|
||||
"pk": "charter"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Chat Log",
|
||||
"order": 0,
|
||||
"prefix": "chatlog",
|
||||
"used": true
|
||||
},
|
||||
"model": "name.doctypename",
|
||||
"pk": "chatlog"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
|
@ -10181,6 +10258,17 @@
|
|||
"model": "name.doctypename",
|
||||
"pk": "minutes"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Polls",
|
||||
"order": 0,
|
||||
"prefix": "polls",
|
||||
"used": true
|
||||
},
|
||||
"model": "name.doctypename",
|
||||
"pk": "polls"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
|
@ -15998,7 +16086,7 @@
|
|||
"fields": {
|
||||
"command": "xym",
|
||||
"switch": "--version",
|
||||
"time": "2022-07-13T00:09:29.108",
|
||||
"time": "2022-09-22T00:09:27.552",
|
||||
"used": true,
|
||||
"version": "xym 0.5"
|
||||
},
|
||||
|
@ -16009,7 +16097,7 @@
|
|||
"fields": {
|
||||
"command": "pyang",
|
||||
"switch": "--version",
|
||||
"time": "2022-07-13T00:09:29.475",
|
||||
"time": "2022-09-22T00:09:27.867",
|
||||
"used": true,
|
||||
"version": "pyang 2.5.3"
|
||||
},
|
||||
|
@ -16020,7 +16108,7 @@
|
|||
"fields": {
|
||||
"command": "yanglint",
|
||||
"switch": "--version",
|
||||
"time": "2022-07-13T00:09:29.497",
|
||||
"time": "2022-09-22T00:09:27.886",
|
||||
"used": true,
|
||||
"version": "yanglint SO 1.9.2"
|
||||
},
|
||||
|
@ -16031,9 +16119,9 @@
|
|||
"fields": {
|
||||
"command": "xml2rfc",
|
||||
"switch": "--version",
|
||||
"time": "2022-07-13T00:09:30.513",
|
||||
"time": "2022-09-22T00:09:28.809",
|
||||
"used": true,
|
||||
"version": "xml2rfc 3.13.0"
|
||||
"version": "xml2rfc 3.14.2"
|
||||
},
|
||||
"model": "utils.versioninfo",
|
||||
"pk": 4
|
||||
|
|
35
ietf/name/migrations/0045_polls_and_chatlogs.py
Normal file
35
ietf/name/migrations/0045_polls_and_chatlogs.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Copyright The IETF Trust 2022, All Rights Reserved
|
||||
from django.db import migrations
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
DocTypeName = apps.get_model("name", "DocTypeName")
|
||||
DocTypeName.objects.create(
|
||||
slug = "chatlog",
|
||||
name = "Chat Log",
|
||||
prefix = "chatlog",
|
||||
desc = "",
|
||||
order = 0,
|
||||
used = True,
|
||||
)
|
||||
DocTypeName.objects.create(
|
||||
slug = "polls",
|
||||
name = "Polls",
|
||||
prefix = "polls",
|
||||
desc = "",
|
||||
order = 0,
|
||||
used = True,
|
||||
)
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
DocTypeName = apps.get_model("name", "DocTypeName")
|
||||
DocTypeName.objects.filter(slug__in=("chatlog", "polls")).delete()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('name', '0044_validating_draftsubmissionstatename'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse)
|
||||
]
|
17
ietf/person/migrations/0025_chat_and_polls_apikey.py
Normal file
17
ietf/person/migrations/0025_chat_and_polls_apikey.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Copyright The IETF Trust 2022, All Rights Reserved
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('person', '0024_pronouns'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='personalapikey',
|
||||
name='endpoint',
|
||||
field=models.CharField(choices=[('/api/appauth/authortools', '/api/appauth/authortools'), ('/api/appauth/bibxml', '/api/appauth/bibxml'), ('/api/iesg/position', '/api/iesg/position'), ('/api/meeting/session/video/url', '/api/meeting/session/video/url'), ('/api/notify/meeting/bluesheet', '/api/notify/meeting/bluesheet'), ('/api/notify/meeting/registration', '/api/notify/meeting/registration'), ('/api/notify/session/attendees', '/api/notify/session/attendees'), ('/api/notify/session/chatlog', '/api/notify/session/chatlog'), ('/api/notify/session/polls', '/api/notify/session/polls'), ('/api/v2/person/person', '/api/v2/person/person')], max_length=128),
|
||||
),
|
||||
]
|
|
@ -368,7 +368,9 @@ PERSON_API_KEY_VALUES = [
|
|||
("/api/meeting/session/video/url", "/api/meeting/session/video/url", "Recording Manager"),
|
||||
("/api/notify/meeting/registration", "/api/notify/meeting/registration", "Robot"),
|
||||
("/api/notify/meeting/bluesheet", "/api/notify/meeting/bluesheet", "Recording Manager"),
|
||||
("/api/notify/session/attendees", "/api/notify/session/attendees", "Recording Manager"),
|
||||
("/api/notify/session/attendees", "/api/notify/session/attendees", "Recording Manager"),
|
||||
("/api/notify/session/chatlog", "/api/notify/session/chatlog", "Recording Manager"),
|
||||
("/api/notify/session/polls", "/api/notify/session/polls", "Recording Manager"),
|
||||
("/api/appauth/authortools", "/api/appauth/authortools", None),
|
||||
("/api/appauth/bibxml", "/api/appauth/bibxml", None),
|
||||
]
|
||||
|
|
|
@ -889,6 +889,8 @@ MEETING_DOC_LOCAL_HREFS = {
|
|||
"agenda": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
|
||||
"minutes": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
|
||||
"slides": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
|
||||
"chatlog": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
|
||||
"polls": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
|
||||
"recording": "{doc.external_url}",
|
||||
"bluesheets": "https://www.ietf.org/proceedings/{meeting.number}/bluesheets/{doc.uploaded_filename}",
|
||||
"procmaterials": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
</style>
|
||||
{% vite_hmr_client %}
|
||||
{% block pagehead %}{% endblock %}
|
||||
{% vite_asset 'client/embedded.js' %}
|
||||
{% include "base/icons.html" %}
|
||||
<script src="{% static 'ietf/js/ietf.js' %}"></script>
|
||||
{% analytical_head_bottom %}
|
||||
|
|
64
ietf/templates/doc/document_chatlog.html
Normal file
64
ietf/templates/doc/document_chatlog.html
Normal file
|
@ -0,0 +1,64 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2022, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
{% load static %}
|
||||
{% load ietf_filters textfilters %}
|
||||
{% block title %}{{ doc.title|default:"Untitled" }}{% endblock %}
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
{{ top|safe }}
|
||||
{% include "doc/revisions_list.html" %}
|
||||
<div id="timeline"></div>
|
||||
{% if doc.rev != latest_rev %}
|
||||
<div class="alert alert-warning my-3">The information below is for an old version of the document.</div>
|
||||
{% endif %}
|
||||
<table class="table table-sm table-borderless">
|
||||
<tbody class="meta border-top">
|
||||
<tr>
|
||||
<th scope="row">
|
||||
{% if doc.meeting_related %}Meeting{% endif %}
|
||||
{{ doc.type.name }}
|
||||
</th>
|
||||
<td></td>
|
||||
<td>
|
||||
{% if doc.group %}
|
||||
{{ doc.group.name }}
|
||||
<a href="{{ doc.group.about_url }}">({{ doc.group.acronym }})</a>
|
||||
{{ doc.group.type.name }}
|
||||
{% endif %}
|
||||
{% if snapshot %}<span class="badge rounded-pill bg-warning">Snapshot</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Title</th>
|
||||
<td class="edit"></td>
|
||||
<th scope="row">{{ doc.title|default:'<span class="text-muted">(None)</span>' }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Session</th>
|
||||
<td class="edit">
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'ietf.meeting.views.session_details' num=session.meeting.number acronym=session.group.acronym %}">Materials</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">Last updated</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ doc.time|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="materials-content" class="card mt-5">
|
||||
<div class="card-header">{{ doc.name }}-{{ doc.rev }}</div>
|
||||
<div class="card-body">
|
||||
<script id="chat-data" type="application/json">{{ content|safe }}</script>
|
||||
<div class="vue-embed" data-component="ChatLog" data-component-id="chat">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script src="{% static 'ietf/js/d3.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/document_timeline.js' %}"></script>
|
||||
{% endblock %}
|
64
ietf/templates/doc/document_polls.html
Normal file
64
ietf/templates/doc/document_polls.html
Normal file
|
@ -0,0 +1,64 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2022, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
{% load static %}
|
||||
{% load ietf_filters textfilters %}
|
||||
{% block title %}{{ doc.title|default:"Untitled" }}{% endblock %}
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
{{ top|safe }}
|
||||
{% include "doc/revisions_list.html" %}
|
||||
<div id="timeline"></div>
|
||||
{% if doc.rev != latest_rev %}
|
||||
<div class="alert alert-warning my-3">The information below is for an old version of the document.</div>
|
||||
{% endif %}
|
||||
<table class="table table-sm table-borderless">
|
||||
<tbody class="meta border-top">
|
||||
<tr>
|
||||
<th scope="row">
|
||||
{% if doc.meeting_related %}Meeting{% endif %}
|
||||
{{ doc.type.name }}
|
||||
</th>
|
||||
<td></td>
|
||||
<td>
|
||||
{% if doc.group %}
|
||||
{{ doc.group.name }}
|
||||
<a href="{{ doc.group.about_url }}">({{ doc.group.acronym }})</a>
|
||||
{{ doc.group.type.name }}
|
||||
{% endif %}
|
||||
{% if snapshot %}<span class="badge rounded-pill bg-warning">Snapshot</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Title</th>
|
||||
<td class="edit"></td>
|
||||
<th scope="row">{{ doc.title|default:'<span class="text-muted">(None)</span>' }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Session</th>
|
||||
<td class="edit">
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'ietf.meeting.views.session_details' num=session.meeting.number acronym=session.group.acronym %}">Materials</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">Last updated</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ doc.time|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="materials-content" class="card mt-5">
|
||||
<div class="card-header">{{ doc.name }}-{{ doc.rev }}</div>
|
||||
<div class="card-body">
|
||||
<script id="polls-data" type="application/json">{{ content|safe }}</script>
|
||||
<div class="vue-embed" data-component="Polls" data-component-id="polls">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script src="{% static 'ietf/js/d3.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/document_timeline.js' %}"></script>
|
||||
{% endblock %}
|
|
@ -110,6 +110,23 @@
|
|||
Upload bluesheets
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if session.filtered_chatlog_and_polls %}
|
||||
<h3 class="mt-4">Chatlog and polls</h3>
|
||||
<table class="table table-sm table-striped chatlog-and-polls"
|
||||
id="chatlog_and_polls_{{ session.pk }}">
|
||||
<tbody data-session="{{ session.pk }}">
|
||||
{% for pres in session.filtered_chatlog_and_polls %}
|
||||
<tr data-name="{{ pres.document.name }}">
|
||||
{% url 'ietf.doc.views_doc.document_main' name=pres.document.name as url %}
|
||||
<td>
|
||||
<a href="{{ pres.document.get_href }}">{{ pres.document.title }}</a>
|
||||
<a href="{{ url }}">({{ pres.document.name }})</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<h3 class="mt-4">Slides</h3>
|
||||
<table class="table table-sm table-striped slides"
|
||||
id="slides_{{ session.pk }}">
|
||||
|
|
|
@ -859,9 +859,9 @@ class IetfTestRunner(DiscoverRunner):
|
|||
# django-bootstrap5 seems to still generate 'checked="checked"', ignore:
|
||||
"attribute-boolean-style": "off",
|
||||
# self-closing style tags are valid in HTML5. Both self-closing and non-self-closing tags are accepted. (vite generates self-closing link tags)
|
||||
# "void-style": "off",
|
||||
"void-style": "off",
|
||||
# Both attributes without value and empty strings are equal and valid. (vite generates empty value attributes)
|
||||
# "attribute-empty-style": "off"
|
||||
"attribute-empty-style": "off",
|
||||
# For fragments, don't check that elements are in the proper ancestor element
|
||||
"element-required-ancestor": "off",
|
||||
},
|
||||
|
|
|
@ -12,7 +12,8 @@ export default defineConfig(({ command, mode }) => {
|
|||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: 'client/main.js'
|
||||
main: 'client/main.js',
|
||||
embedded: 'client/embedded.js'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue