Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1f503de
feat: apis for attaching chatlogs and polls to session materials
rjsparks Sep 21, 2022
e16f49d
fix: anticipate becoming tzaware, and improve guard against attempts …
rjsparks Sep 22, 2022
1fbb0c0
fix: get chatlog upload to actually work
rjsparks Sep 23, 2022
891d848
fix: test polls upload
rjsparks Sep 23, 2022
c99930e
fix: allow api keys to be created for the new endpoints
rjsparks Sep 23, 2022
4aae306
feat: add ability to view chatlog and polls documents. Show links in …
rjsparks Sep 26, 2022
6a1b021
fix: commit new template
rjsparks Sep 28, 2022
7bfd3d3
fix: typo in migration signatures
rjsparks Sep 28, 2022
de01bb5
feat: add main doc page handling for polls. Improve tests.
rjsparks Sep 29, 2022
d970f76
Merge branch 'main' into chat-and-polls
rjsparks Sep 29, 2022
85b6485
feat: chat log vue component + embedded vue loader
NGPixel Oct 8, 2022
46711fe
Merge branch 'ietf-tools:main' into chat-and-polls
rjsparks Oct 10, 2022
45ab709
feat: render polls using Vue
rjsparks Oct 11, 2022
a5623a8
fix: address pug syntax review comments from Nick.
rjsparks Oct 11, 2022
e91c82d
fix: repair remaining mention of chat log from copymunging
rjsparks Oct 11, 2022
685960e
fix: use double-quotes in html attributes
rjsparks Oct 11, 2022
8704c05
fix: provide missing choices update migration
rjsparks Oct 12, 2022
313523c
test: silence html validator empty attr warnings
NGPixel Oct 12, 2022
75f8dd5
test: fix test_runner config
NGPixel Oct 12, 2022
172f68f
fix: locate session when looking at a dochistory object for polls or …
rjsparks Oct 12, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions client/Embedded.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 98 additions & 0 deletions client/components/ChatLog.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 79 additions & 0 deletions client/components/Polls.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 13 additions & 0 deletions client/embedded.js
Original file line number Diff line number Diff line change
@@ -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)
}
89 changes: 89 additions & 0 deletions ietf/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from importlib import import_module
from mock import patch
from pathlib import Path

from django.apps import apps
from django.conf import settings
Expand All @@ -21,6 +22,7 @@
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
Expand Down Expand Up @@ -212,6 +214,93 @@ def test_api_add_session_attendees(self):
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')
Expand Down
4 changes: 4 additions & 0 deletions ietf/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
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
Expand Down
34 changes: 34 additions & 0 deletions ietf/doc/migrations/0045_docstates_chatlogs_polls.py
Original file line number Diff line number Diff line change
@@ -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),
]
Loading