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 importlib import import_module
from mock import patch from mock import patch
from pathlib import Path
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
@ -21,6 +22,7 @@ from tastypie.test import ResourceTestCaseMixin
import debug # pyflakes:ignore import debug # pyflakes:ignore
import ietf import ietf
from ietf.doc.utils import get_unicode_document_content
from ietf.group.factories import RoleFactory from ietf.group.factories import RoleFactory
from ietf.meeting.factories import MeetingFactory, SessionFactory from ietf.meeting.factories import MeetingFactory, SessionFactory
from ietf.meeting.test_data import make_meeting_test_data 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=recman).exists())
self.assertTrue(session.attended_set.filter(person=otherperson).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): def test_api_upload_bluesheet(self):
url = urlreverse('ietf.meeting.views.api_upload_bluesheet') url = urlreverse('ietf.meeting.views.api_upload_bluesheet')
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') 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), url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet),
# Let MeetEcho tell us about session attendees # Let MeetEcho tell us about session attendees
url(r'^notify/session/attendees/?$', meeting_views.api_add_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 # Let the registration system notify us about registrations
url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration), url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration),
# OpenID authentication provider # 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: else:
self._cached_file_path = settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR self._cached_file_path = settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR
elif self.meeting_related() and self.type_id in ( 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() meeting = self.get_related_meeting()
if meeting is not None: if meeting is not None:
@ -420,7 +420,7 @@ class DocumentInfo(models.Model):
return e != None and (e.text != "") return e != None and (e.text != "")
def meeting_related(self): 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 self.type_id != "slides" or self.get_state_slug('reuse_policy')=='single'
return False 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='agenda',name='agenda-72-mars')
DocumentFactory(type_id='minutes',name='minutes-72-mars') DocumentFactory(type_id='minutes',name='minutes-72-mars')
DocumentFactory(type_id='slides',name='slides-72-mars-1-active') 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 = DocumentFactory(type_id='statchg',name='status-change-imaginary-mid-review')
statchg.set_state(State.objects.get(type_id='statchg',slug='adrev')) 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", "agenda-72-mars",
"minutes-72-mars", "minutes-72-mars",
"slides-72-mars-1-active", "slides-72-mars-1-active",
"chatlog-72-mars-197001010000",
"polls-72-mars-197001010000",
# TODO: add # TODO: add
#"bluesheets-72-mars-1", #"bluesheets-72-mars-1",
#"recording-72-mars-1-00", #"recording-72-mars-1-00",

View file

@ -42,6 +42,7 @@ import os
import re import re
from urllib.parse import quote from urllib.parse import quote
from pathlib import Path
from django.http import HttpResponse, Http404 from django.http import HttpResponse, Http404
from django.shortcuts import render, get_object_or_404, redirect 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, sorted_relations=sorted_relations,
)) ))
# TODO : Add "recording", and "bluesheets" here when those documents are appropriately if doc.type_id in ("slides", "agenda", "minutes", "bluesheets", "procmaterials",):
# created and content is made available on disk
if doc.type_id in ("slides", "agenda", "minutes", "bluesheets","procmaterials",):
can_manage_material = can_manage_materials(request.user, doc.group) can_manage_material = can_manage_materials(request.user, doc.group)
presentations = doc.future_presentations() presentations = doc.future_presentations()
if doc.uploaded_filename: if doc.uploaded_filename:
@ -725,6 +724,29 @@ def document_main(request, name, rev=None):
assignments=assignments, 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 ""))) 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.doc.models import Document, DocAlias, State, NewRevisionDocEvent
from ietf.group.models import Group from ietf.group.models import Group
from ietf.group.utils import can_manage_materials 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.person.models import Person
from ietf.secr.proceedings.proc_utils import import_audio_files from ietf.secr.proceedings.proc_utils import import_audio_files
from ietf.utils.html import sanitize_document 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) subprocess.call(['unzip', filename], cwd=path)
return None 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 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 swap_meeting_schedule_timeslot_assignments, bulk_create_timeslots
from ietf.meeting.utils import preprocess_meeting_important_dates 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.message.utils import infer_message
from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName
from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files, 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_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_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_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 # TODO FIXME Deleted materials shouldn't be in the sessionpresentation_set
for qs in [session.filtered_artifacts,session.filtered_slides,session.filtered_drafts]: 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'] 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) session.attended_set.get_or_create(person=user.person)
return HttpResponse("Done", status=200, content_type='text/plain') 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 @require_api_key
@role_required('Recording Manager', 'Secretariat') @role_required('Recording Manager', 'Secretariat')

View file

@ -2200,7 +2200,7 @@
}, },
{ {
"fields": { "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", "name": "I-D Exists",
"next_states": [ "next_states": [
16, 16,
@ -2405,6 +2405,58 @@
"model": "doc.state", "model": "doc.state",
"pk": 164 "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": { "fields": {
"label": "State" "label": "State"
@ -2433,6 +2485,13 @@
"model": "doc.statetype", "model": "doc.statetype",
"pk": "charter" "pk": "charter"
}, },
{
"fields": {
"label": "State"
},
"model": "doc.statetype",
"pk": "chatlog"
},
{ {
"fields": { "fields": {
"label": "Conflict Review State" "label": "Conflict Review State"
@ -2538,6 +2597,13 @@
"model": "doc.statetype", "model": "doc.statetype",
"pk": "minutes" "pk": "minutes"
}, },
{
"fields": {
"label": "State"
},
"model": "doc.statetype",
"pk": "polls"
},
{ {
"fields": { "fields": {
"label": "Proceedings Materials State" "label": "Proceedings Materials State"
@ -3345,7 +3411,7 @@
"has_session_materials": false, "has_session_materials": false,
"is_schedulable": false, "is_schedulable": false,
"material_types": "[\n \"slides\"\n]", "material_types": "[\n \"slides\"\n]",
"matman_roles": "[]", "matman_roles": "[\n \"chair\"\n]",
"need_parent": false, "need_parent": false,
"parent_types": [], "parent_types": [],
"req_subm_approval": true, "req_subm_approval": true,
@ -3458,8 +3524,8 @@
"has_milestones": false, "has_milestones": false,
"has_nonsession_materials": true, "has_nonsession_materials": true,
"has_reviews": false, "has_reviews": false,
"has_session_materials": false, "has_session_materials": true,
"is_schedulable": false, "is_schedulable": true,
"material_types": "[\n \"slides\"\n]", "material_types": "[\n \"slides\"\n]",
"matman_roles": "[\n \"chair\",\n \"matman\"\n]", "matman_roles": "[\n \"chair\",\n \"matman\"\n]",
"need_parent": false, "need_parent": false,
@ -3469,7 +3535,7 @@
"req_subm_approval": false, "req_subm_approval": false,
"role_order": "[\n \"chair\",\n \"member\",\n \"matman\"\n]", "role_order": "[\n \"chair\",\n \"member\",\n \"matman\"\n]",
"session_purposes": "[\n \"coding\",\n \"presentation\",\n \"social\",\n \"tutorial\"\n]", "session_purposes": "[\n \"coding\",\n \"presentation\",\n \"social\",\n \"tutorial\"\n]",
"show_on_agenda": false "show_on_agenda": true
}, },
"model": "group.groupfeatures", "model": "group.groupfeatures",
"pk": "team" "pk": "team"
@ -10126,6 +10192,17 @@
"model": "name.doctypename", "model": "name.doctypename",
"pk": "charter" "pk": "charter"
}, },
{
"fields": {
"desc": "",
"name": "Chat Log",
"order": 0,
"prefix": "chatlog",
"used": true
},
"model": "name.doctypename",
"pk": "chatlog"
},
{ {
"fields": { "fields": {
"desc": "", "desc": "",
@ -10181,6 +10258,17 @@
"model": "name.doctypename", "model": "name.doctypename",
"pk": "minutes" "pk": "minutes"
}, },
{
"fields": {
"desc": "",
"name": "Polls",
"order": 0,
"prefix": "polls",
"used": true
},
"model": "name.doctypename",
"pk": "polls"
},
{ {
"fields": { "fields": {
"desc": "", "desc": "",
@ -15998,7 +16086,7 @@
"fields": { "fields": {
"command": "xym", "command": "xym",
"switch": "--version", "switch": "--version",
"time": "2022-07-13T00:09:29.108", "time": "2022-09-22T00:09:27.552",
"used": true, "used": true,
"version": "xym 0.5" "version": "xym 0.5"
}, },
@ -16009,7 +16097,7 @@
"fields": { "fields": {
"command": "pyang", "command": "pyang",
"switch": "--version", "switch": "--version",
"time": "2022-07-13T00:09:29.475", "time": "2022-09-22T00:09:27.867",
"used": true, "used": true,
"version": "pyang 2.5.3" "version": "pyang 2.5.3"
}, },
@ -16020,7 +16108,7 @@
"fields": { "fields": {
"command": "yanglint", "command": "yanglint",
"switch": "--version", "switch": "--version",
"time": "2022-07-13T00:09:29.497", "time": "2022-09-22T00:09:27.886",
"used": true, "used": true,
"version": "yanglint SO 1.9.2" "version": "yanglint SO 1.9.2"
}, },
@ -16031,9 +16119,9 @@
"fields": { "fields": {
"command": "xml2rfc", "command": "xml2rfc",
"switch": "--version", "switch": "--version",
"time": "2022-07-13T00:09:30.513", "time": "2022-09-22T00:09:28.809",
"used": true, "used": true,
"version": "xml2rfc 3.13.0" "version": "xml2rfc 3.14.2"
}, },
"model": "utils.versioninfo", "model": "utils.versioninfo",
"pk": 4 "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/meeting/session/video/url", "/api/meeting/session/video/url", "Recording Manager"),
("/api/notify/meeting/registration", "/api/notify/meeting/registration", "Robot"), ("/api/notify/meeting/registration", "/api/notify/meeting/registration", "Robot"),
("/api/notify/meeting/bluesheet", "/api/notify/meeting/bluesheet", "Recording Manager"), ("/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/authortools", "/api/appauth/authortools", None),
("/api/appauth/bibxml", "/api/appauth/bibxml", 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}", "agenda": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
"minutes": "/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}", "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}", "recording": "{doc.external_url}",
"bluesheets": "https://www.ietf.org/proceedings/{meeting.number}/bluesheets/{doc.uploaded_filename}", "bluesheets": "https://www.ietf.org/proceedings/{meeting.number}/bluesheets/{doc.uploaded_filename}",
"procmaterials": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}", "procmaterials": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",

View file

@ -28,6 +28,7 @@
</style> </style>
{% vite_hmr_client %} {% vite_hmr_client %}
{% block pagehead %}{% endblock %} {% block pagehead %}{% endblock %}
{% vite_asset 'client/embedded.js' %}
{% include "base/icons.html" %} {% include "base/icons.html" %}
<script src="{% static 'ietf/js/ietf.js' %}"></script> <script src="{% static 'ietf/js/ietf.js' %}"></script>
{% analytical_head_bottom %} {% 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 Upload bluesheets
</a> </a>
{% endif %} {% 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> <h3 class="mt-4">Slides</h3>
<table class="table table-sm table-striped slides" <table class="table table-sm table-striped slides"
id="slides_{{ session.pk }}"> id="slides_{{ session.pk }}">

View file

@ -859,9 +859,9 @@ class IetfTestRunner(DiscoverRunner):
# django-bootstrap5 seems to still generate 'checked="checked"', ignore: # django-bootstrap5 seems to still generate 'checked="checked"', ignore:
"attribute-boolean-style": "off", "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) # 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) # 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 # For fragments, don't check that elements are in the proper ancestor element
"element-required-ancestor": "off", "element-required-ancestor": "off",
}, },

View file

@ -12,7 +12,8 @@ export default defineConfig(({ command, mode }) => {
manifest: true, manifest: true,
rollupOptions: { rollupOptions: {
input: { input: {
main: 'client/main.js' main: 'client/main.js',
embedded: 'client/embedded.js'
} }
} }
}, },