Skip to content

Commit 50668c9

Browse files
rjsparksNGPixel
andauthored
feat: apis for attaching chatlogs and polls to session materials (ietf-tools#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>
1 parent 9c404a2 commit 50668c9

23 files changed

Lines changed: 811 additions & 20 deletions

client/Embedded.vue

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<template lang="pug">
2+
n-theme
3+
n-message-provider
4+
component(:is='currentComponent', :component-id='props.componentId')
5+
</template>
6+
7+
<script setup>
8+
import { defineAsyncComponent, markRaw, onMounted, ref } from 'vue'
9+
import { NMessageProvider } from 'naive-ui'
10+
11+
import NTheme from './components/n-theme.vue'
12+
13+
// COMPONENTS
14+
15+
const availableComponents = {
16+
ChatLog: defineAsyncComponent(() => import('./components/ChatLog.vue')),
17+
Polls: defineAsyncComponent(() => import('./components/Polls.vue')),
18+
}
19+
20+
// PROPS
21+
22+
const props = defineProps({
23+
componentName: {
24+
type: String,
25+
default: null
26+
},
27+
componentId: {
28+
type: String,
29+
default: null
30+
}
31+
})
32+
33+
// STATE
34+
35+
const currentComponent = ref(null)
36+
37+
// MOUNTED
38+
39+
onMounted(() => {
40+
currentComponent.value = markRaw(availableComponents[props.componentName] || null)
41+
})
42+
</script>

client/components/ChatLog.vue

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<template lang="pug">
2+
.chatlog
3+
n-timeline(
4+
v-if='state.items.length > 0'
5+
:icon-size='18'
6+
size='large'
7+
)
8+
n-timeline-item(
9+
v-for='item of state.items'
10+
:key='item.id'
11+
type='default'
12+
:color='item.color'
13+
:title='item.author'
14+
:time='item.time'
15+
)
16+
template(#default)
17+
div(v-html='item.text')
18+
span.text-muted(v-else)
19+
em No chat log available.
20+
</template>
21+
22+
<script setup>
23+
import { onMounted, reactive } from 'vue'
24+
import { DateTime } from 'luxon'
25+
import {
26+
NTimeline,
27+
NTimelineItem
28+
} from 'naive-ui'
29+
30+
// PROPS
31+
32+
const props = defineProps({
33+
componentId: {
34+
type: String,
35+
required: true
36+
}
37+
})
38+
39+
// STATE
40+
41+
const state = reactive({
42+
items: []
43+
})
44+
45+
// bs5 colors
46+
const colors = [
47+
'#0d6efd',
48+
'#dc3545',
49+
'#20c997',
50+
'#6f42c1',
51+
'#fd7e14',
52+
'#198754',
53+
'#0dcaf0',
54+
'#d63384',
55+
'#ffc107',
56+
'#6610f2',
57+
'#adb5bd'
58+
]
59+
60+
// MOUNTED
61+
62+
onMounted(() => {
63+
const authorColors = {}
64+
// Get chat log data from embedded json tag
65+
const chatLog = JSON.parse(document.getElementById(`${props.componentId}-data`).textContent || '[]')
66+
if (chatLog.length > 0) {
67+
let idx = 1
68+
let colorIdx = 0
69+
for (const logItem of chatLog) {
70+
// -> Get unique color per author
71+
if (!authorColors[logItem.author]) {
72+
authorColors[logItem.author] = colors[colorIdx]
73+
colorIdx++
74+
if (colorIdx >= colors.length) {
75+
colorIdx = 0
76+
}
77+
}
78+
// -> Generate log item
79+
state.items.push({
80+
id: `logitem-${idx}`,
81+
color: authorColors[logItem.author],
82+
author: logItem.author,
83+
text: logItem.text,
84+
time: DateTime.fromISO(logItem.time).toFormat('dd LLLL yyyy \'at\' HH:mm:ss a ZZZZ')
85+
})
86+
idx++
87+
}
88+
}
89+
})
90+
</script>
91+
92+
<style lang="scss">
93+
.chatlog {
94+
.n-timeline-item-content__content > div > p {
95+
margin-bottom: 0;
96+
}
97+
}
98+
</style>

client/components/Polls.vue

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<template lang="pug">
2+
.polls
3+
n-data-table(
4+
v-if='state.items.length > 0'
5+
:data='state.items'
6+
:columns='columns'
7+
striped
8+
)
9+
span.text-muted(v-else)
10+
em No polls available.
11+
</template>
12+
13+
<script setup>
14+
import { onMounted, reactive } from 'vue'
15+
import { DateTime } from 'luxon'
16+
import {
17+
NDataTable
18+
} from 'naive-ui'
19+
20+
// PROPS
21+
22+
const props = defineProps({
23+
componentId: {
24+
type: String,
25+
required: true
26+
}
27+
})
28+
29+
// STATE
30+
31+
const state = reactive({
32+
items: []
33+
})
34+
35+
const columns = [
36+
{
37+
title: 'Question',
38+
key: 'question'
39+
},
40+
{
41+
title: 'Start Time',
42+
key: 'start_time',
43+
},
44+
{
45+
title: 'End Time',
46+
key: 'end_time'
47+
},
48+
{
49+
title: 'Raise Hand',
50+
key: 'raise_hand'
51+
},
52+
{
53+
title: 'Do Not Raise Hand',
54+
key: 'do_not_raise_hand'
55+
}
56+
]
57+
58+
// MOUNTED
59+
60+
onMounted(() => {
61+
// Get polls from embedded json tag
62+
const polls = JSON.parse(document.getElementById(`${props.componentId}-data`).textContent || '[]')
63+
if (polls.length > 0) {
64+
let idx = 1
65+
for (const poll of polls) {
66+
state.items.push({
67+
id: `poll-${idx}`,
68+
question: poll.text,
69+
start_time: DateTime.fromISO(poll.start_time).toFormat('dd LLLL yyyy \'at\' HH:mm:ss a ZZZZ'),
70+
end_time: DateTime.fromISO(poll.end_time).toFormat('dd LLLL yyyy \'at\' HH:mm:ss a ZZZZ'),
71+
raise_hand: poll.raise_hand,
72+
do_not_raise_hand: poll.do_not_raise_hand
73+
})
74+
idx++
75+
}
76+
}
77+
})
78+
</script>
79+

client/embedded.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createApp } from 'vue'
2+
import Embedded from './Embedded.vue'
3+
4+
// Mount App
5+
6+
const mountEls = document.querySelectorAll('div.vue-embed')
7+
for (const mnt of mountEls) {
8+
const app = createApp(Embedded, {
9+
componentName: mnt.dataset.component,
10+
componentId: mnt.dataset.componentId
11+
})
12+
app.mount(mnt)
13+
}

ietf/api/tests.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from importlib import import_module
1111
from mock import patch
12+
from pathlib import Path
1213

1314
from django.apps import apps
1415
from django.conf import settings
@@ -21,6 +22,7 @@
2122
import debug # pyflakes:ignore
2223

2324
import ietf
25+
from ietf.doc.utils import get_unicode_document_content
2426
from ietf.group.factories import RoleFactory
2527
from ietf.meeting.factories import MeetingFactory, SessionFactory
2628
from ietf.meeting.test_data import make_meeting_test_data
@@ -212,6 +214,93 @@ def test_api_add_session_attendees(self):
212214
self.assertTrue(session.attended_set.filter(person=recman).exists())
213215
self.assertTrue(session.attended_set.filter(person=otherperson).exists())
214216

217+
def test_api_upload_polls_and_chatlog(self):
218+
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')
219+
recmanrole.person.user.last_login = timezone.now()
220+
recmanrole.person.user.save()
221+
222+
badrole = RoleFactory(group__type_id='ietf', name_id='ad')
223+
badrole.person.user.last_login = timezone.now()
224+
badrole.person.user.save()
225+
226+
meeting = MeetingFactory(type_id='ietf')
227+
session = SessionFactory(group__type_id='wg', meeting=meeting)
228+
229+
for type_id, content in (
230+
(
231+
"chatlog",
232+
"""[
233+
{
234+
"author": "Raymond Lutz",
235+
"text": "<p>Yes I like that comment just made</p>",
236+
"time": "2022-07-28T19:26:16Z"
237+
},
238+
{
239+
"author": "Carsten Bormann",
240+
"text": "<p>But software is not a thing.</p>",
241+
"time": "2022-07-28T19:26:45Z"
242+
}
243+
]"""
244+
),
245+
(
246+
"polls",
247+
"""[
248+
{
249+
"start_time": "2022-07-28T19:19:54Z",
250+
"end_time": "2022-07-28T19:20:23Z",
251+
"text": "Are you willing to review the documents?",
252+
"raise_hand": 57,
253+
"do_not_raise_hand": 11
254+
},
255+
{
256+
"start_time": "2022-07-28T19:20:56Z",
257+
"end_time": "2022-07-28T19:21:30Z",
258+
"text": "Would you be willing to edit or coauthor a document?",
259+
"raise_hand": 31,
260+
"do_not_raise_hand": 31
261+
}
262+
]"""
263+
),
264+
):
265+
url = urlreverse(f"ietf.meeting.views.api_upload_{type_id}")
266+
apikey = PersonalApiKey.objects.create(endpoint=url, person=recmanrole.person)
267+
badapikey = PersonalApiKey.objects.create(endpoint=url, person=badrole.person)
268+
269+
r = self.client.post(url, {})
270+
self.assertContains(r, "Missing apikey parameter", status_code=400)
271+
272+
r = self.client.post(url, {'apikey': badapikey.hash()} )
273+
self.assertContains(r, "Restricted to role: Recording Manager", status_code=403)
274+
275+
r = self.client.get(url, {'apikey': apikey.hash()} )
276+
self.assertContains(r, "Method not allowed", status_code=405)
277+
278+
r = self.client.post(url, {'apikey': apikey.hash()} )
279+
self.assertContains(r, "Missing apidata parameter", status_code=400)
280+
281+
for baddict in (
282+
'{}',
283+
'{"bogons;drop table":"bogons;drop table"}',
284+
'{"session_id":"Not an integer;drop table"}',
285+
f'{{"session_id":{session.pk},"{type_id}":"not a list;drop table"}}',
286+
f'{{"session_id":{session.pk},"{type_id}":"not a list;drop table"}}',
287+
f'{{"session_id":{session.pk},"{type_id}":[{{}}, {{}}, "not an int;drop table", {{}}]}}',
288+
):
289+
r = self.client.post(url, {'apikey': apikey.hash(), 'apidata': baddict})
290+
self.assertContains(r, "Malformed post", status_code=400)
291+
292+
bad_session_id = Session.objects.order_by('-pk').first().pk + 1
293+
r = self.client.post(url, {'apikey': apikey.hash(), 'apidata': f'{{"session_id":{bad_session_id},"{type_id}":[]}}'})
294+
self.assertContains(r, "Invalid session", status_code=400)
295+
296+
# Valid POST
297+
r = self.client.post(url,{'apikey':apikey.hash(),'apidata': f'{{"session_id":{session.pk}, "{type_id}":{content}}}'})
298+
self.assertEqual(r.status_code, 200)
299+
300+
newdoc = session.sessionpresentation_set.get(document__type_id=type_id).document
301+
newdoccontent = get_unicode_document_content(newdoc.name, Path(session.meeting.get_materials_path()) / type_id / newdoc.uploaded_filename)
302+
self.assertEqual(json.loads(content), json.loads(newdoccontent))
303+
215304
def test_api_upload_bluesheet(self):
216305
url = urlreverse('ietf.meeting.views.api_upload_bluesheet')
217306
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')

ietf/api/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet),
3838
# Let MeetEcho tell us about session attendees
3939
url(r'^notify/session/attendees/?$', meeting_views.api_add_session_attendees),
40+
# Let MeetEcho upload session chatlog
41+
url(r'^notify/session/chatlog/?$', meeting_views.api_upload_chatlog),
42+
# Let MeetEcho upload session polls
43+
url(r'^notify/session/polls/?$', meeting_views.api_upload_polls),
4044
# Let the registration system notify us about registrations
4145
url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration),
4246
# OpenID authentication provider
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright The IETF Trust 2022, All Rights Reserved
2+
from django.db import migrations
3+
4+
def forward(apps, schema_editor):
5+
StateType = apps.get_model("doc", "StateType")
6+
State = apps.get_model("doc", "State")
7+
for slug in ("chatlog", "polls"):
8+
StateType.objects.create(slug=slug, label="State")
9+
for state_slug in ("active", "deleted"):
10+
State.objects.create(
11+
type_id = slug,
12+
slug = state_slug,
13+
name = state_slug.capitalize(),
14+
used = True,
15+
desc = "",
16+
order = 0,
17+
)
18+
19+
def reverse(apps, schema_editor):
20+
StateType = apps.get_model("doc", "StateType")
21+
State = apps.get_model("doc", "State")
22+
State.objects.filter(type_id__in=("chatlog", "polls")).delete()
23+
StateType.objects.filter(slug__in=("chatlog", "polls")).delete()
24+
25+
class Migration(migrations.Migration):
26+
27+
dependencies = [
28+
('doc', '0044_procmaterials_states'),
29+
('name', '0045_polls_and_chatlogs'),
30+
]
31+
32+
operations = [
33+
migrations.RunPython(forward, reverse),
34+
]

0 commit comments

Comments
 (0)