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:
Robert Sparks 2022-10-13 09:20:36 -05:00 committed by GitHub
parent 9c404a21fc
commit 50668c97cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 811 additions and 20 deletions

42
client/Embedded.vue Normal file
View 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>

View 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>

View 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
View 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)
}

View file

@ -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')

View file

@ -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

View 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),
]

View file

@ -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

View file

@ -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",

View file

@ -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 "")))

View file

@ -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

View file

@ -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')

View file

@ -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

View 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)
]

View 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),
),
]

View file

@ -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),
]

View file

@ -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}",

View file

@ -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 %}

View 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 %}

View 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 %}

View file

@ -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 }}">

View file

@ -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",
},

View file

@ -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'
}
}
},