Merge branch 'feat/tzaware' into jennifer/yet-more-tz-fixes

# Conflicts:
#	ietf/ietfauth/utils.py
#	ietf/meeting/tests_views.py
#	ietf/stats/tests.py
This commit is contained in:
Jennifer Richards 2022-10-17 16:39:45 -03:00
commit da70acfdff
No known key found for this signature in database
GPG key ID: 26801E4DC0928410
156 changed files with 2191 additions and 3839 deletions

View file

@ -47,7 +47,7 @@ jobs:
pkg_version: ${{ steps.buildvars.outputs.pkg_version }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
fetch-depth: 0
@ -70,7 +70,7 @@ jobs:
echo "NEXT_VERSION=$nextStrict" >> $GITHUB_ENV
- name: Create Draft Release
uses: ncipollo/release-action@v1
uses: ncipollo/release-action@v1.11.1
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.dryrun == 'false' }}
with:
prerelease: true
@ -167,7 +167,7 @@ jobs:
mv latest-coverage.json coverage.json
- name: Upload Coverage Results as Build Artifact
uses: actions/upload-artifact@v3.0.0
uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: coverage
@ -186,7 +186,7 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v2
- uses: actions/setup-node@v3
with:
node-version: '18'
@ -203,7 +203,7 @@ jobs:
npx playwright test --project=${{ matrix.project }}
- name: Upload Report
uses: actions/upload-artifact@v3.0.0
uses: actions/upload-artifact@v3
if: ${{ always() }}
continue-on-error: true
with:
@ -256,7 +256,7 @@ jobs:
yarn cypress:legacy
- name: Upload Video Recordings
uses: actions/upload-artifact@v3.0.0
uses: actions/upload-artifact@v3
if: ${{ always() }}
continue-on-error: true
with:
@ -265,7 +265,7 @@ jobs:
if-no-files-found: ignore
- name: Upload Screenshots
uses: actions/upload-artifact@v3.0.0
uses: actions/upload-artifact@v3
if: ${{ always() }}
continue-on-error: true
with:
@ -286,17 +286,17 @@ jobs:
PKG_VERSION: ${{needs.prepare.outputs.pkg_version}}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3.0.0
uses: actions/setup-node@v3
with:
node-version: 16.x
node-version: '16'
- name: Setup Python
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: '3.x'
@ -364,7 +364,7 @@ jobs:
histCoveragePath: historical-coverage.json
- name: Create Release
uses: ncipollo/release-action@v1
uses: ncipollo/release-action@v1.11.1
if: ${{ env.SHOULD_DEPLOY == 'true' && github.event.inputs.dryrun == 'false' }}
with:
allowUpdates: true
@ -376,7 +376,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update Baseline Coverage
uses: ncipollo/release-action@v1
uses: ncipollo/release-action@v1.11.1
if: ${{ github.event.inputs.updateCoverage == 'true' && github.event.inputs.dryrun == 'false' }}
with:
allowUpdates: true
@ -389,8 +389,19 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Build Artifacts
uses: actions/upload-artifact@v2.3.1
uses: actions/upload-artifact@v3
if: ${{ env.SHOULD_DEPLOY == 'false' || github.event.inputs.dryrun == 'true' }}
with:
name: release-${{ env.PKG_VERSION }}
path: /home/runner/work/release/release.tar.gz
- name: Notify on Slack
uses: slackapi/slack-github-action@v1.23.0
with:
channel-id: ${{ secrets.SLACK_GH_BUILDS_CHANNEL_ID }}
payload: |
{
"text": "Datatracker - Build <https://github.com/ietf-tools/datatracker/actions/runs/${{ github.run_id }}|${{ env.PKG_VERSION }}> by ${{ github.triggering_actor }} completed - <@${{ secrets.SLACK_UID_RJSPARKS }}>"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_GH_BOT }}

View file

@ -81,7 +81,7 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v2
- uses: actions/setup-node@v3
with:
node-version: '18'

634
.pnp.cjs generated

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -218,15 +218,34 @@ Frontend tests are done via Cypress. There're 2 different type of tests:
#### Run Vue Tests
To run the tests headlessly (command line mode):
```sh
yarn cypress
```
To run the tests visually **(CANNOT run in docker)**:
```sh
yarn cypress:open
```
> It can take a few seconds before the tests start or the GUI opens.
> :warning: All commands below **MUST** be run from the `./playwright` directory, unless noted otherwise.
1. Run **once** to install dependencies on your system:
```sh
npm install
npx playwright install --with-deps
```
2. Run in a **separate process**, from the **project root directory**:
```sh
yarn preview
```
3. Run the tests, in of these 3 modes, from the `./playwright` directory:
3.1 To run the tests headlessly (command line mode):
```sh
npm test
```
3.2 To run the tests visually **(CANNOT run in docker)**:
```sh
npm run test:visual
```
3.3 To run the tests in debug mode **(CANNOT run in docker)**:
```sh
npm run test:debug
```
#### Run Legacy Views Tests

View file

@ -1,9 +1,9 @@
<template lang="pug">
n-theme
n-message-provider
.app-error(v-if='agendaStore.criticalError')
.app-error(v-if='siteStore.criticalError')
i.bi.bi-x-octagon-fill.me-2
span {{agendaStore.criticalError}}
span {{siteStore.criticalError}}
.app-container(ref='appContainer')
router-view.meeting
</template>
@ -12,13 +12,13 @@ n-theme
import { onBeforeUnmount ,onMounted, ref } from 'vue'
import { NMessageProvider } from 'naive-ui'
import { useAgendaStore } from './agenda/store'
import { useSiteStore } from './shared/store'
import NTheme from './components/n-theme.vue'
// STORES
const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// STATE
@ -29,14 +29,14 @@ const appContainer = ref(null)
// --------------------------------------------------------------------
const resizeObserver = new ResizeObserver(entries => {
agendaStore.$patch({ viewport: Math.round(window.innerWidth) })
siteStore.$patch({ viewport: Math.round(window.innerWidth) })
// for (const entry of entries) {
// const newWidth = entry.contentBoxSize ? entry.contentBoxSize[0].inlineSize : entry.contentRect.width
// }
})
onMounted(() => {
resizeObserver.observe(appContainer.value, { box: 'device-pixel-content-box' })
resizeObserver.observe(appContainer.value)
})
onBeforeUnmount(() => {
@ -47,7 +47,6 @@ onBeforeUnmount(() => {
<style lang="scss">
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "./shared/breakpoints";
.app-error {
background-color: $red-500;
@ -57,69 +56,4 @@ onBeforeUnmount(() => {
padding: 1rem;
text-align: center;
}
.meeting {
> h1 {
font-weight: 500;
color: $gray-700;
display: flex;
justify-content: space-between;
align-items: center;
@media screen and (max-width: $bs5-break-sm) {
justify-content: center;
> span {
font-size: .95em;
}
}
strong {
font-weight: 700;
background: linear-gradient(220deg, $blue-500 20%, $purple-500 70%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
box-decoration-break: clone;
}
}
&-h1-badges {
display: flex;
justify-content: end;
align-items: center;
> span {
font-size: 13px;
font-weight: 700;
background-color: $pink-500;
box-shadow: 0 0 5px 0 rgba($pink-500, .5);
color: #FFF;
padding: 5px 8px;
border-radius: 6px;
& + span {
margin-left: 10px;
}
}
}
&-warning {
background-color: $red-500 !important;
box-shadow: 0 0 5px 0 rgba($red-500, .5) !important;
color: #FFF;
animation: warningBorderFlash 1s ease infinite;
}
> h4 {
@media screen and (max-width: $bs5-break-sm) {
text-align: center;
> span {
font-size: .8em;
text-align: center;
}
}
}
}
</style>

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

@ -7,7 +7,6 @@
span #[strong IETF {{agendaStore.meeting.number}}] Meeting Agenda {{titleExtra}}
.meeting-h1-badges.d-none.d-sm-flex
span.meeting-warning(v-if='agendaStore.meeting.warningNote') {{agendaStore.meeting.warningNote}}
span.meeting-beta BETA
h4
span {{agendaStore.meeting.city}}, {{ meetingDate }}
h6.float-end.d-none.d-lg-inline(v-if='meetingUpdated') #[span.text-muted Updated:] {{ meetingUpdated }}
@ -54,7 +53,7 @@
@click='setTimezone(`UTC`)'
) UTC
n-select.agenda-timezone-ddn(
v-if='agendaStore.viewport > 1250'
v-if='siteStore.viewport > 1250'
v-model:value='agendaStore.timezone'
:options='timezones'
placeholder='Select Time Zone'
@ -134,7 +133,7 @@
// -----------------------------------
// -> Anchored Day Quick Access Menu
// -----------------------------------
.col-auto.d-print-none(v-if='agendaStore.viewport >= 990')
.col-auto.d-print-none(v-if='siteStore.viewport >= 990')
agenda-quick-access
agenda-mobile-bar
@ -166,6 +165,9 @@ import MeetingNavigation from './MeetingNavigation.vue'
import timezones from '../shared/timezones'
import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store'
import './agenda.scss'
// MESSAGE PROVIDER
@ -174,6 +176,7 @@ const message = useMessage()
// STORES
const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// ROUTER
@ -215,7 +218,35 @@ watch(() => agendaStore.meetingDays, () => {
})
})
watch(() => agendaStore.isLoaded, handleCurrentMeetingRedirect)
watch(() => agendaStore.isLoaded, () => {
if (route.query.show) {
// Handle legacy ?show= parameter
const keywords = route.query.show.split(',').map(k => k.trim()).filter(k => !!k)
if (keywords?.length > 0) {
const pickedIds = []
for (const ev of agendaStore.scheduleAdjusted) {
if (keywords.includes(ev.sessionKeyword)) {
pickedIds.push(ev.id)
}
}
if (pickedIds.length > 0) {
agendaStore.$patch({
pickerMode: true,
pickerModeView: true,
pickedEvents: pickedIds
})
agendaStore.persistMeetingPreferences()
}
}
}
if (route.query.pick) {
// Handle legacy /personalize path (open picker mode)
agendaStore.$patch({ pickerMode: true })
router.replace({ query: null })
}
handleCurrentMeetingRedirect()
})
// COMPUTED

View file

@ -55,6 +55,17 @@ n-modal(v-model:show='modalShown')
)
i.bi.bi-journal-text.me-2
span Notepad
n-button.float-end(
ghost
color='gray'
strong
tag='a'
:href='eventDetails.detailsUrl'
target='_blank'
aria-label='Materials page'
)
span.me-2 {{props.event.groupAcronym}} materials page
i.bi.bi-box-arrow-up-right
.detail-content
.detail-title
h6
@ -95,19 +106,29 @@ n-modal(v-model:show='modalShown')
:src='eventDetails.materialsUrl'
)
template(v-else-if='state.tab === `slides`')
n-card(
:bordered='false'
size='small'
)
.text-center(v-if='state.isLoading')
n-spin(description='Loading slides...')
.text-center.p-3(v-else-if='!state.materials || !state.materials.slides || state.materials.slides.length < 1')
.text-center.p-3(v-else-if='!state.materials || !state.materials.slides || !state.materials.slides.decks || state.materials.slides.decks.length < 1')
span No slides submitted for this session.
.list-group(v-else)
a.list-group-item(
v-for='slide of state.materials.slides'
:key='slide.id'
:href='slide.url'
v-for='deck of state.materials.slides.decks'
:key='deck.id'
:href='deck.url'
target='_blank'
)
i.bi.me-2(:class='`bi-filetype-` + slide.ext')
span {{slide.title}}
i.bi.me-2(:class='`bi-filetype-` + deck.ext')
span {{deck.title}}
template(#action, v-if='state.materials.slides.actions')
n-button(
v-for='action of state.materials.slides.actions'
tag='a'
:href='action.url'
) {{action.label}}
template(v-else)
.text-center(v-if='state.isLoading')
n-spin(description='Loading minutes...')
@ -184,9 +205,10 @@ const eventDetails = computed(() => {
title: props.event.type === 'regular' ? `${props.event.groupName} (${props.event.acronym})` : props.event.name,
showAgenda: props.event.flags.showAgenda,
materialsUrl: materialsUrl,
detailsUrl: `/meeting/${agendaStore.meeting.number}/session/${props.event.acronym}/`,
tarUrl: `/meeting/${agendaStore.meeting.number}/agenda/${props.event.acronym}-drafts.tgz`,
pdfUrl: `/meeting/${agendaStore.meeting.number}/agenda/${props.event.acronym}-drafts.pdf`,
notepadUrl: `https://notes.ietf.org/notes-ietf-${agendaStore.meeting.number}-${props.event.type === 'plenary' ? 'plenary' : props.event.acronym}`
notepadUrl: `https://notes.ietf.org/notes-ietf-${agendaStore.meeting.number}-${props.event.type === 'plenary' ? 'plenary' : props.event.acronym}`,
}
})
@ -219,7 +241,10 @@ async function fetchSessionMaterials () {
state.isLoading = true
try {
const resp = await fetch(`/api/meeting/session/${props.event.sessionId}/materials`, { credentials: 'omit' })
const resp = await fetch(
`/api/meeting/session/${props.event.sessionId}/materials`,
{ credentials: 'include' }
)
if (!resp.ok) {
throw new Error(resp.statusText)
}

View file

@ -1,5 +1,5 @@
<template lang="pug">
.agenda-mobile-bar(v-if='agendaStore.viewport < 990')
.agenda-mobile-bar(v-if='siteStore.viewport < 990')
button(@click='agendaStore.$patch({ filterShown: true })')
i.bi.bi-filter-square-fill.me-2
span Filters
@ -31,6 +31,7 @@ import {
} from 'naive-ui'
import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store';
// MESSAGE PROVIDER
@ -39,6 +40,7 @@ const message = useMessage()
// STORES
const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// Download Ics Options

View file

@ -108,6 +108,7 @@
<script setup>
import { computed, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { DateTime } from 'luxon'
import {
NAffix,
@ -119,6 +120,7 @@ import {
} from 'naive-ui'
import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store';
// MESSAGE PROVIDER
@ -127,6 +129,12 @@ const message = useMessage()
// STORES
const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// ROUTER
const router = useRouter()
const route = useRoute()
// Download Ics Options
@ -146,7 +154,7 @@ const downloadIcsOptions = [
// COMPUTED
const shortMode = computed(() => {
return agendaStore.viewport <= 1350
return siteStore.viewport <= 1350
})
// METHODS
@ -163,6 +171,9 @@ function pickerModify () {
}
function pickerDiscard () {
agendaStore.$patch({ pickerMode: false })
if (route.query.show) {
router.push({ query: null })
}
}
function downloadIcs (key) {

View file

@ -4,7 +4,7 @@ n-drawer(v-model:show='isShown', placement='bottom', :height='state.drawerHeight
template(#header)
span Calendar View
.agenda-calendar-actions
template(v-if='agendaStore.viewport > 990')
template(v-if='siteStore.viewport > 990')
i.bi.bi-globe.me-2
small.me-2: strong Timezone:
n-button-group
@ -91,10 +91,12 @@ import bootstrap5Plugin from '@fullcalendar/bootstrap5'
import AgendaDetailsModal from './AgendaDetailsModal.vue'
import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store'
// STORES
const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// STATE

View file

@ -7,7 +7,7 @@
th.agenda-table-head-check(v-if='pickerModeActive') &nbsp;
th.agenda-table-head-time Time
th.agenda-table-head-location(colspan='2') Location
th.agenda-table-head-event(colspan='2') {{ agendaStore.viewport < 990 ? '' : 'Event' }}
th.agenda-table-head-event(colspan='2') {{ siteStore.viewport < 990 ? '' : 'Event' }}
tbody
tr.agenda-table-display-noresult(
v-if='!meetingEvents || meetingEvents.length < 1'
@ -58,13 +58,13 @@
span.badge {{item.location.short}}
span {{item.location.name}}
router-link.discreet(
:to='`/meeting/` + agendaStore.meeting.number + `/floor-plan-neue?room=` + xslugify(item.room)'
:to='`/meeting/` + agendaStore.meeting.number + `/floor-plan?room=` + xslugify(item.room)'
:aria-label='item.room'
) {{item.room}}
span(v-else) {{item.room}}
//- CELL - GROUP --------------------------
td.agenda-table-cell-group(v-if='item.type === `regular`')
span.badge(v-if='agendaStore.areaIndicatorsShown && agendaStore.viewport > 1200') {{item.groupAcronym}}
span.badge(v-if='agendaStore.areaIndicatorsShown && siteStore.viewport > 1200') {{item.groupAcronym}}
a.discreet(:href='`/group/` + item.acronym + `/about/`') {{item.acronym}}
//- CELL - NAME ---------------------------
td.agenda-table-cell-name
@ -105,7 +105,7 @@
template(v-else)
span.badge.is-cancelled(v-if='!isMobile && item.status === `canceled`') Cancelled
span.badge.is-rescheduled(v-else-if='!isMobile && item.status === `resched`') Rescheduled
.agenda-table-cell-links-buttons(v-else-if='agendaStore.viewport < 1200 && item.links && item.links.length > 0')
.agenda-table-cell-links-buttons(v-else-if='siteStore.viewport < 1200 && item.links && item.links.length > 0')
n-dropdown(
v-if='!agendaStore.colorPickerVisible'
trigger='click'
@ -201,6 +201,7 @@ import {
import AgendaDetailsModal from './AgendaDetailsModal.vue'
import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store'
// MESSAGE PROVIDER
@ -209,6 +210,7 @@ const message = useMessage()
// STORES
const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// DATA
@ -236,7 +238,7 @@ const meetingEvents = computed(() => {
return reduce(sortBy(agendaStore.scheduleAdjusted, 'adjustedStartDate'), (acc, item) => {
const isLive = current >= item.adjustedStart && current < item.adjustedEnd
const itemTimeSlot = agendaStore.viewport > 576 ?
const itemTimeSlot = siteStore.viewport > 576 ?
`${item.adjustedStart.toFormat('HH:mm')} - ${item.adjustedEnd.toFormat('HH:mm')}` :
`${item.adjustedStart.toFormat('HH:mm')} ${item.adjustedEnd.toFormat('HH:mm')}`
@ -482,7 +484,7 @@ const pickedEvents = computed({
})
const isMobile = computed(() => {
return agendaStore.viewport < 576
return siteStore.viewport < 576
})
// METHODS

View file

@ -198,6 +198,8 @@ import {
} from 'naive-ui'
import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store'
import timezones from '../shared/timezones'
// MESSAGE PROVIDER
@ -207,6 +209,7 @@ const message = useMessage()
// STORES
const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// STATE
@ -266,7 +269,7 @@ const calcOffset = computed(() => {
return agendaStore.nowDebugDiff ? JSON.stringify(agendaStore.nowDebugDiff.toObject()) : 'None'
})
const panelWidth = computed(() => {
return agendaStore.viewport > 500 ? 500 : agendaStore.viewport
return siteStore.viewport > 500 ? 500 : siteStore.viewport
})
// WATCHERS

View file

@ -59,13 +59,18 @@ import find from 'lodash/find'
import xslugify from '../shared/xslugify'
import { DateTime } from 'luxon'
import { useRoute, useRouter } from 'vue-router'
import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store'
import MeetingNavigation from './MeetingNavigation.vue'
import './agenda.scss'
// STORES
const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// ROUTER
@ -144,7 +149,7 @@ watch(() => state.currentRoom, () => {
}, 100)
})
})
watch(() => agendaStore.viewport, () => {
watch(() => siteStore.viewport, () => {
nextTick(() => {
computePlanSizeRatio()
})

View file

@ -10,7 +10,7 @@ ul.nav.nav-tabs.meeting-nav(v-if='agendaStore.isLoaded')
router-link.nav-link(
v-else
active-class='active'
:to='`/meeting/` + agendaStore.meeting.number + `/` + tab.key + `-neue`'
:to='`/meeting/` + agendaStore.meeting.number + `/` + tab.key'
)
i.bi.me-2.d-none.d-sm-inline(:class='tab.icon')
span {{tab.title}}

68
client/agenda/agenda.scss Normal file
View file

@ -0,0 +1,68 @@
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "../shared/breakpoints";
.meeting {
> h1 {
font-weight: 500;
color: $gray-700;
display: flex;
justify-content: space-between;
align-items: center;
@media screen and (max-width: $bs5-break-sm) {
justify-content: center;
> span {
font-size: .95em;
}
}
strong {
font-weight: 700;
background: linear-gradient(220deg, $blue-500 20%, $purple-500 70%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
box-decoration-break: clone;
}
}
&-h1-badges {
display: flex;
justify-content: end;
align-items: center;
> span {
font-size: 13px;
font-weight: 700;
background-color: $pink-500;
box-shadow: 0 0 5px 0 rgba($pink-500, .5);
color: #FFF;
padding: 5px 8px;
border-radius: 6px;
& + span {
margin-left: 10px;
}
}
}
&-warning {
background-color: $red-500 !important;
box-shadow: 0 0 5px 0 rgba($red-500, .5) !important;
color: #FFF;
animation: warningBorderFlash 1s ease infinite;
}
> h4 {
@media screen and (max-width: $bs5-break-sm) {
text-align: center;
> span {
font-size: .8em;
text-align: center;
}
}
}
}

View file

@ -3,6 +3,8 @@ import { DateTime } from 'luxon'
import uniqBy from 'lodash/uniqBy'
import murmur from 'murmurhash-js/murmurhash3_gc'
import { useSiteStore } from '../shared/store'
const urlRe = /http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+/
const conferenceDomains = ['webex.com', 'zoom.us', 'jitsi.org', 'meetecho.com', 'gather.town']
@ -23,7 +25,6 @@ export const useAgendaStore = defineStore('agenda', {
{ hex: '#20c997', tag: 'Attended' }
],
colorAssignments: {},
criticalError: null,
currentTab: 'agenda',
dayIntersectId: '',
defaultCalendarView: 'week',
@ -35,7 +36,6 @@ export const useAgendaStore = defineStore('agenda', {
infoNoteShown: true,
isCurrentMeeting: false,
isLoaded: false,
isMobile: /Mobi/i.test(navigator.userAgent),
listDayCollapse: false,
meeting: {},
nowDebugDiff: null,
@ -50,7 +50,6 @@ export const useAgendaStore = defineStore('agenda', {
settingsShown: false,
timezone: DateTime.local().zoneName,
useHedgeDoc: false,
viewport: Math.round(window.innerWidth),
visibleDays: []
}),
getters: {
@ -119,10 +118,11 @@ export const useAgendaStore = defineStore('agenda', {
})
},
meetingDays () {
const siteStore = useSiteStore()
return uniqBy(this.scheduleAdjusted, 'adjustedStartDate').sort().map(s => ({
slug: s.id.toString(),
ts: s.adjustedStartDate,
label: this.viewport < 1350 ? DateTime.fromISO(s.adjustedStartDate).toFormat('ccc LLL d') : DateTime.fromISO(s.adjustedStartDate).toLocaleString(DateTime.DATE_HUGE)
label: siteStore.viewport < 1350 ? DateTime.fromISO(s.adjustedStartDate).toFormat('ccc LLL d') : DateTime.fromISO(s.adjustedStartDate).toLocaleString(DateTime.DATE_HUGE)
}))
},
isMeetingLive (state) {
@ -168,7 +168,10 @@ export const useAgendaStore = defineStore('agenda', {
this.isLoaded = true
} catch (err) {
console.error(err)
this.criticalError = `Failed to load this meeting: ${err.message}`
const siteStore = useSiteStore()
siteStore.$patch({
criticalError: `Failed to load this meeting: ${err.message}`
})
}
this.hideLoadingScreen()

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

@ -3,9 +3,12 @@ import { createRouter, createWebHistory } from 'vue-router'
export default createRouter({
history: createWebHistory(),
routes: [
// ---------------------------------------------------------
// MEETING
// ---------------------------------------------------------
{
name: 'agenda',
path: '/meeting/:meetingNumber(\\d+)?/agenda-neue',
path: '/meeting/:meetingNumber(\\d+)?/agenda',
component: () => import('./agenda/Agenda.vue'),
meta: {
hideLeftMenu: true
@ -13,11 +16,18 @@ export default createRouter({
},
{
name: 'floor-plan',
path: '/meeting/:meetingNumber(\\d+)?/floor-plan-neue',
path: '/meeting/:meetingNumber(\\d+)?/floor-plan',
component: () => import('./agenda/FloorPlan.vue'),
meta: {
hideLeftMenu: true
}
},
// -> Redirects
{
path: '/meeting/:meetingNumber(\\d+)?/agenda/personalize',
redirect: to => {
return { name: 'agenda', query: { ...to.query, pick: true } }
}
}
]
})

9
client/shared/store.js Normal file
View file

@ -0,0 +1,9 @@
import { defineStore } from 'pinia'
export const useSiteStore = defineStore('site', {
state: () => ({
criticalError: null,
isMobile: /Mobi/i.test(navigator.userAgent),
viewport: Math.round(window.innerWidth)
})
})

View file

@ -4,3 +4,5 @@ coverage:
default:
target: auto
threshold: 1%
github_checks:
annotations: false

View file

@ -95,8 +95,8 @@ You can also open the datatracker project folder and click the **Reopen in conta
```sh
Copy-Item "docker/docker-compose.extend.yml" -Destination "docker/docker-compose.extend-custom.yml"
(Get-Content -path docker/docker-compose.extend-custom.yml -Raw) -replace 'CUSTOM_PORT','8000' | Set-Content -Path docker/docker-compose.extend-custom.yml
docker-compose -f docker-compose.yml -f docker/docker-compose.extend-custom.yml up -d
docker-compose exec app /bin/sh /docker-init.sh
docker compose -f docker-compose.yml -f docker/docker-compose.extend-custom.yml up -d
docker compose exec app /bin/sh /docker-init.sh
```
2. Wait for the containers to initialize. Upon completion, you will be dropped into a shell from which you can start the datatracker and execute related commands as usual, for example
@ -120,7 +120,7 @@ The containers will automatically be shut down on Linux / macOS.
On Windows, type the command
```sh
docker-compose down
docker compose down
```
to terminate the containers.
@ -138,9 +138,9 @@ cd docker
On Windows:
```sh
docker-compose down -v
docker-compose pull db
docker-compose build --no-cache db
docker compose down -v
docker compose pull db
docker compose build --no-cache db
```
### Clean all
@ -156,7 +156,7 @@ cd docker
On Windows:
```sh
docker-compose down -v --rmi all
docker compose down -v --rmi all
docker image prune
```
@ -164,7 +164,7 @@ docker image prune
The port is exposed but not mapped to `3306` to avoid potential conflicts with the host. To get the mapped port, run the command *(from the project `/docker` directory)*:
```sh
docker-compose port db 3306
docker compose port db 3306
```
## Notes / Troubleshooting

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
@ -213,6 +215,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

@ -7,7 +7,7 @@ import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('doc', '0044_procmaterials_states'),
('doc', '0045_docstates_chatlogs_polls'),
]
operations = [

View file

@ -52,7 +52,7 @@ def reverse(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('doc', '0045_use_timezone_now_for_doc_models'),
('doc', '0046_use_timezone_now_for_doc_models'),
('utils', '0003_pause_to_change_use_tz'),
]

View file

@ -138,7 +138,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:
@ -422,7 +422,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

@ -225,7 +225,7 @@ def state_age_colored(doc):
else:
title = ""
return mark_safe(
'<span class="badge %s" %s><i class="bi bi-clock-fill"></i> %d</span>'
'<span class="badge rounded-pill %s" %s><i class="bi bi-clock-fill"></i> %d</span>'
% (class_name, title, days)
)
else:

View file

@ -642,10 +642,10 @@ def action_holder_badge(action_holder):
''
>>> action_holder_badge(DocumentActionHolderFactory(time_added=timezone.now() - datetime.timedelta(days=16)))
'<span class="badge bg-danger" title="In state for 16 days; goal is &lt;15 days."><i class="bi bi-clock-fill"></i> 16</span>'
'<span class="badge rounded-pill bg-danger" title="In state for 16 days; goal is &lt;15 days."><i class="bi bi-clock-fill"></i> 16</span>'
>>> action_holder_badge(DocumentActionHolderFactory(time_added=timezone.now() - datetime.timedelta(days=30)))
'<span class="badge bg-danger" title="In state for 30 days; goal is &lt;15 days."><i class="bi bi-clock-fill"></i> 30</span>'
'<span class="badge rounded-pill bg-danger" title="In state for 30 days; goal is &lt;15 days."><i class="bi bi-clock-fill"></i> 30</span>'
>>> settings.DOC_ACTION_HOLDER_AGE_LIMIT_DAYS = old_limit
"""
@ -653,7 +653,7 @@ def action_holder_badge(action_holder):
age = (timezone.now() - action_holder.time_added).days
if age > age_limit:
return mark_safe(
'<span class="badge bg-danger" title="In state for %d day%s; goal is &lt;%d days."><i class="bi bi-clock-fill"></i> %d</span>'
'<span class="badge rounded-pill bg-danger" title="In state for %d day%s; goal is &lt;%d days."><i class="bi bi-clock-fill"></i> %d</span>'
% (age, "s" if age != 1 else "", age_limit, age)
)
else:

View file

@ -1470,6 +1470,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'))
@ -1481,6 +1485,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

@ -41,6 +41,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

@ -94,7 +94,7 @@ class SearchForm(forms.Form):
("ad", "AD"), ("-ad", "AD (desc)"), ),
required=False, widget=forms.HiddenInput)
doctypes = forms.ModelMultipleChoiceField(queryset=DocTypeName.objects.filter(used=True).exclude(slug='draft').order_by('name'), required=False)
doctypes = forms.ModelMultipleChoiceField(queryset=DocTypeName.objects.filter(used=True).exclude(slug__in=('draft','liai-att')).order_by('name'), required=False)
def __init__(self, *args, **kwargs):
super(SearchForm, self).__init__(*args, **kwargs)
@ -479,7 +479,7 @@ def ad_workload(request):
doctypes = list(
DocTypeName.objects.filter(used=True)
.exclude(slug="draft")
.exclude(slug__in=("draft", "liai-att"))
.values_list("pk", flat=True)
)
@ -493,10 +493,10 @@ def ad_workload(request):
for id, (g, uig) in enumerate(
[
("Publication Requested Internet-Draft", False),
("Waiting for Writeup Internet-Draft", False),
("AD Evaluation Internet-Draft", False),
("In Last Call Internet-Draft", None),
("IESG Evaluation - Defer Internet-Draft", None),
("In Last Call Internet-Draft", True),
("Waiting for Writeup Internet-Draft", False),
("IESG Evaluation - Defer Internet-Draft", False),
("IESG Evaluation Internet-Draft", True),
("Waiting for AD Go-Ahead Internet-Draft", False),
("Approved-announcement to be sent Internet-Draft", True),
@ -528,9 +528,9 @@ def ad_workload(request):
for id, (g, uig) in enumerate(
[
("Publication Requested Status Change", False),
("Waiting for Writeup Status Change", False),
("AD Evaluation Status Change", False),
("In Last Call Status Change", None),
("In Last Call Status Change", True),
("Waiting for Writeup Status Change", False),
("IESG Evaluation Status Change", True),
("Waiting for AD Go-Ahead Status Change", False),
]
@ -675,7 +675,7 @@ def docs_for_ad(request, name):
form = SearchForm({'by':'ad','ad': ad.id,
'rfcs':'on', 'activedrafts':'on', 'olddrafts':'on',
'sort': 'status',
'doctypes': list(DocTypeName.objects.filter(used=True).exclude(slug='draft').values_list("pk", flat=True))})
'doctypes': list(DocTypeName.objects.filter(used=True).exclude(slug__in=('draft','liai-att')).values_list("pk", flat=True))})
results, meta = prepare_document_table(request, retrieve_search_results(form), form.data, max_results=500)
results.sort(key=ad_dashboard_sort_key)
del meta["headers"][-1]

View file

@ -35,6 +35,7 @@ import debug # pyflakes:ignore
from ietf.group.factories import GroupFactory, RoleFactory
from ietf.group.models import Group, Role, RoleName
from ietf.ietfauth.htpasswd import update_htpasswd_file
from ietf.ietfauth.utils import has_role
from ietf.mailinglists.models import Subscribed
from ietf.meeting.factories import MeetingFactory
from ietf.nomcom.factories import NomComFactory
@ -1006,3 +1007,11 @@ class OpenIDConnectTests(TestCase):
# handler, causing later logging to become visible even if that wasn't intended.
# Fail here if that happens.
self.assertEqual(logging.root.handlers, [])
class UtilsTests(TestCase):
def test_has_role_empty_role_names(self):
"""has_role is False if role_names is empty"""
role = RoleFactory(name_id='secr', group__acronym='secretariat')
self.assertTrue(has_role(role.person.user, ['Secretariat']), 'Test is broken')
self.assertFalse(has_role(role.person.user, []), 'has_role() should return False when role_name is empty')

View file

@ -5,7 +5,6 @@
# various authentication and authorization utilities
import oidc_provider.lib.claims
from oidc_provider.models import Client as ClientRecord
from functools import wraps
@ -16,7 +15,6 @@ from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.decorators import available_attrs
from django.utils.http import urlquote
@ -95,7 +93,7 @@ def has_role(user, role_names, *args, **kwargs):
"Robot": Q(person=person, name="robot", group__acronym="secretariat"),
}
filter_expr = Q()
filter_expr = Q(pk__in=[]) # ensure empty set is returned if no other terms are added
for r in role_names:
filter_expr |= role_qs[r]
@ -166,7 +164,7 @@ def is_authorized_in_doc_stream(user, doc):
docman_roles = GroupFeatures.objects.get(type_id="ietf").docman_roles
group_req = Q(group__acronym=doc.stream.slug)
else:
group_req = Q()
group_req = Q() # no group constraint for other cases
return Role.objects.filter(Q(name__in=docman_roles, person__user=user) & group_req).exists()
@ -280,14 +278,6 @@ class OidcExtraScopeClaims(oidc_provider.lib.claims.ScopeClaims):
reg.save()
info = {}
if regs:
# maybe register attendance if logged in to follow a meeting
if meeting.start_datetime() <= timezone.now() <= meeting.end_datetime():
client = ClientRecord.objects.get(client_id=self.client.client_id)
if client.name == 'Meetecho':
for reg in regs:
if not reg.attended:
reg.attended = True
reg.save()
# fill in info to return
ticket_types = set([])
reg_types = set([])

View file

@ -187,7 +187,7 @@ def ajax_search(request):
if not q:
objs = IprDisclosureBase.objects.none()
else:
query = Q()
query = Q() # all objects returned if no other terms in the queryset
for t in q:
query &= Q(title__icontains=t)

View file

@ -258,18 +258,35 @@ class Meeting(models.Model):
number = self.get_number()
if number is None or number < 110:
return None
Attendance = namedtuple('Attendance', 'onsite online')
Attendance = namedtuple('Attendance', 'onsite remote')
# MeetingRegistration.attended started conflating badge-pickup and session attendance before IETF 114.
# We've separated session attendence off to ietf.meeting.Attended, but need to report attendance at older
# meetings correctly.
attended_per_meetingregistration = (
Q(meetingregistration__meeting=self) & (
Q(meetingregistration__attended=True) |
Q(meetingregistration__checkedin=True)
)
)
attended_per_meeting_attended = (
Q(attended__session__meeting=self)
# Note that we are not filtering to plenary, wg, or rg sessions
# as we do for nomcom eligibility - if picking up a badge (see above)
# is good enough, just attending e.g. a training session is also good enough
)
attended = Person.objects.filter(
attended_per_meetingregistration | attended_per_meeting_attended
).distinct()
onsite=set(attended.filter(meetingregistration__meeting=self, meetingregistration__reg_type='onsite'))
remote=set(attended.filter(meetingregistration__meeting=self, meetingregistration__reg_type='remote'))
remote.difference_update(onsite)
return Attendance(
onsite=Person.objects.filter(
meetingregistration__meeting=self,
meetingregistration__attended=True,
meetingregistration__reg_type__contains='in_person',
).distinct().count(),
online=Person.objects.filter(
meetingregistration__meeting=self,
meetingregistration__attended=True,
meetingregistration__reg_type__contains='remote',
).distinct().count(),
onsite=len(onsite),
remote=len(remote)
)
@property
@ -469,7 +486,7 @@ class Room(models.Model):
if not mtg_num:
return None
elif self.floorplan:
base_url = urlreverse('ietf.meeting.views.floor_plan', kwargs=dict(num=mtg_num))
base_url = urlreverse('floor-plan', kwargs=dict(num=mtg_num))
else:
return None
return f'{base_url}?room={xslugify(self.name)}'

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ import datetime
from mock import patch
from ietf.meeting.factories import MeetingFactory, SessionFactory
from ietf.meeting.factories import MeetingFactory, SessionFactory, AttendedFactory
from ietf.stats.factories import MeetingRegistrationFactory
from ietf.utils.test_utils import TestCase
@ -19,41 +19,75 @@ class MeetingTests(TestCase):
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person')
self.assertIsNone(meeting.get_attendance())
def test_get_attendance(self):
"""Post-110 meetings do calculate attendance"""
def test_get_attendance_110(self):
"""Look at attendance as captured at 110"""
meeting = MeetingFactory(type_id='ietf', number='110')
# start with attendees that should be ignored
MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='')
MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='', attended=True)
MeetingRegistrationFactory(meeting=meeting, reg_type='', attended=False)
attendance = meeting.get_attendance()
self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 0)
self.assertEqual(attendance.remote, 0)
self.assertEqual(attendance.onsite, 0)
# add online attendees with at least one who registered but did not attend
MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote')
MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote', attended=True)
MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False)
attendance = meeting.get_attendance()
self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 4)
self.assertEqual(attendance.remote, 4)
self.assertEqual(attendance.onsite, 0)
# and the same for onsite attendees
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person')
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='onsite', attended=True)
MeetingRegistrationFactory(meeting=meeting, reg_type='in_person', attended=False)
attendance = meeting.get_attendance()
self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 4)
self.assertEqual(attendance.remote, 4)
self.assertEqual(attendance.onsite, 5)
# and once more after removing all the online attendees
meeting.meetingregistration_set.filter(reg_type='remote').delete()
attendance = meeting.get_attendance()
self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 0)
self.assertEqual(attendance.remote, 0)
self.assertEqual(attendance.onsite, 5)
def test_get_attendance_113(self):
"""Simulate IETF 113 attendance gathering data"""
meeting = MeetingFactory(type_id='ietf', number='113')
MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=True, checkedin=False)
MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=False, checkedin=True)
p1 = MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=False, checkedin=False).person
AttendedFactory(session__meeting=meeting, person=p1)
p2 = MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False, checkedin=False).person
AttendedFactory(session__meeting=meeting, person=p2)
attendance = meeting.get_attendance()
self.assertEqual(attendance.onsite, 3)
self.assertEqual(attendance.remote, 1)
def test_get_attendance_keeps_meetings_distinct(self):
"""No cross-talk between attendance for different meetings"""
# numbers are arbitrary here
first_mtg = MeetingFactory(type_id='ietf', number='114')
second_mtg = MeetingFactory(type_id='ietf', number='115')
# Create a person who attended a remote session for first_mtg and onsite for second_mtg without
# checking in for either.
p = MeetingRegistrationFactory(meeting=second_mtg, reg_type='onsite', attended=False, checkedin=False).person
AttendedFactory(session__meeting=first_mtg, person=p)
MeetingRegistrationFactory(meeting=first_mtg, person=p, reg_type='remote', attended=False, checkedin=False)
AttendedFactory(session__meeting=second_mtg, person=p)
att = first_mtg.get_attendance()
self.assertEqual(att.onsite, 0)
self.assertEqual(att.remote, 1)
att = second_mtg.get_attendance()
self.assertEqual(att.onsite, 1)
self.assertEqual(att.remote, 0)
def test_vtimezone(self):
# normal time zone that should have a zoneinfo file
meeting = MeetingFactory(type_id='ietf', time_zone='America/Los_Angeles', populate_schedule=False)

View file

@ -17,7 +17,7 @@ from pyquery import PyQuery
from lxml.etree import tostring
from io import StringIO, BytesIO
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urlsplit, quote
from urllib.parse import urlparse, urlsplit
from PIL import Image
from pathlib import Path
from tempfile import NamedTemporaryFile
@ -52,7 +52,6 @@ from ietf.name.models import SessionStatusName, ImportantDateName, RoleName, Pro
from ietf.utils.decorators import skip_coverage
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
from ietf.utils.text import xslugify
from ietf.utils.timezone import date_today, time_now
from ietf.person.factories import PersonFactory
@ -168,7 +167,7 @@ class MeetingTests(BaseMeetingTestCase):
time_interval = r"%s<span.*/span>-%s" % (slot.utc_start_time().strftime("%H:%M").lstrip("0"), (slot.utc_start_time() + slot.duration).strftime("%H:%M").lstrip("0"))
# Extremely rudementary test of agenda-neue - to be replaced with back-end tests as the front-end tests are developed.
r = self.client.get(urlreverse("agenda-neue", kwargs=dict(num=meeting.number,utc='-utc')))
r = self.client.get(urlreverse("agenda", kwargs=dict(num=meeting.number,utc='-utc')))
self.assertEqual(r.status_code, 200)
# Agenda API tests
@ -215,56 +214,17 @@ class MeetingTests(BaseMeetingTestCase):
}
)
r = self.client.get(urlreverse("ietf.meeting.views.agenda", kwargs=dict(num=meeting.number,utc='-utc')))
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
agenda_content = q("#content").html()
self.assertIn(session.group.acronym, agenda_content)
self.assertIn(session.group.name, agenda_content)
self.assertIn(session.group.parent.acronym.upper(), agenda_content)
self.assertIn(slot.location.name, agenda_content)
self.assertRegex(agenda_content, time_interval)
self.assertIsNotNone(q(':input[value="%s"]' % meeting.time_zone),
'Time zone selector should show meeting timezone')
self.assertIsNotNone(q('.nav *:contains("%s")' % meeting.time_zone),
'Time zone indicator should be in nav sidebar')
# plain
time_interval = r"{}<span.*/span>-{}".format(
slot.time.astimezone(meeting.tz()).strftime("%H:%M").lstrip("0"),
slot.end_time().astimezone(meeting.tz()).strftime("%H:%M").lstrip("0"),
)
r = self.client.get(urlreverse("ietf.meeting.views.agenda", kwargs=dict(num=meeting.number)))
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
agenda_content = q("#content").html()
self.assertIn(session.group.acronym, agenda_content)
self.assertIn(session.group.name, agenda_content)
self.assertIn(session.group.parent.acronym.upper(), agenda_content)
self.assertIn(slot.location.name, agenda_content)
self.assertRegex(agenda_content, time_interval)
self.assertIn(registration_text, agenda_content)
# Make sure there's a frame for the session agenda and it points to the right place
assignment_url = urlreverse('ietf.meeting.views.session_materials', kwargs=dict(session_id=session.pk))
self.assertTrue(
any(
[assignment_url in x.attrib["data-src"]
for x in q('tr div.modal-body div.session-materials')]
)
)
# future meeting, no agenda
r = self.client.get(urlreverse("ietf.meeting.views.agenda", kwargs=dict(num=future_meeting.number)))
self.assertContains(r, "There is no agenda available yet.")
self.assertTemplateUsed(r, 'meeting/no-agenda.html')
# text
# the rest of the results don't have as nicely formatted times
time_interval = "%s-%s" % (slot.time.strftime("%H%M").lstrip("0"), (slot.time + slot.duration).strftime("%H%M").lstrip("0"))
r = self.client.get(urlreverse("ietf.meeting.views.agenda", kwargs=dict(num=meeting.number, ext=".txt")))
r = self.client.get(urlreverse("ietf.meeting.views.agenda_plain", kwargs=dict(num=meeting.number, ext=".txt")))
self.assertContains(r, session.group.acronym)
self.assertContains(r, session.group.name)
self.assertContains(r, session.group.parent.acronym.upper())
@ -272,16 +232,13 @@ class MeetingTests(BaseMeetingTestCase):
self.assertContains(r, time_interval)
r = self.client.get(urlreverse("ietf.meeting.views.agenda", kwargs=dict(num=meeting.number,name=meeting.unofficial_schedule.name,owner=meeting.unofficial_schedule.owner.email())))
self.assertContains(r, 'not the official schedule')
# future meeting, no agenda
r = self.client.get(urlreverse("ietf.meeting.views.agenda", kwargs=dict(num=future_meeting.number, ext=".txt")))
r = self.client.get(urlreverse("ietf.meeting.views.agenda_plain", kwargs=dict(num=future_meeting.number, ext=".txt")))
self.assertContains(r, "There is no agenda available yet.")
self.assertTemplateUsed(r, 'meeting/no-agenda.txt')
# CSV
r = self.client.get(urlreverse("ietf.meeting.views.agenda", kwargs=dict(num=meeting.number, ext=".csv")))
r = self.client.get(urlreverse("ietf.meeting.views.agenda_plain", kwargs=dict(num=meeting.number, ext=".csv")))
self.assertContains(r, session.group.acronym)
self.assertContains(r, session.group.name)
self.assertContains(r, session.group.parent.acronym.upper())
@ -309,30 +266,11 @@ class MeetingTests(BaseMeetingTestCase):
'ietf.meeting.views.session_details',
kwargs=dict(num=meeting.number, acronym=session.group.acronym)),
msg_prefix='ical should contain link to meeting materials page for session')
self.assertContains(
r,
urlreverse(
'ietf.meeting.views.agenda', kwargs=dict(num=meeting.number)
) + f'#row-{session.official_timeslotassignment().slug()}',
msg_prefix='ical should contain link to agenda entry for session')
# week view
r = self.client.get(urlreverse("ietf.meeting.views.week_view", kwargs=dict(num=meeting.number)))
self.assertNotContains(r, 'CANCELLED')
self.assertContains(r, session.group.acronym)
self.assertContains(r, slot.location.name)
self.assertContains(r, registration_text)
# Floor Plan
r = self.client.get(urlreverse('floor-plan', kwargs=dict(num=meeting.number)))
self.assertEqual(r.status_code, 200)
# week view with a cancelled session
SchedulingEvent.objects.create(
session=session,
status=SessionStatusName.objects.get(slug='canceled'),
by=Person.objects.get(name='(System)')
)
r = self.client.get(urlreverse("ietf.meeting.views.week_view", kwargs=dict(num=meeting.number)))
self.assertContains(r, 'CANCELLED')
self.assertContains(r, session.group.acronym)
self.assertContains(r, slot.location.name)
@override_settings(PROCEEDINGS_V1_BASE_URL='https://example.com/{meeting.number}')
def test_agenda_redirects_for_old_meetings(self):
@ -341,7 +279,7 @@ class MeetingTests(BaseMeetingTestCase):
MeetingFactory(type_id='ietf', number='35', populate_schedule=False)
r = self.client.get(
urlreverse(
'ietf.meeting.views.agenda',
'agenda',
kwargs={'num': '35', 'ext': '.html'},
))
self.assertRedirects(r, 'https://example.com/35', fetch_redirect_response=False)
@ -350,7 +288,7 @@ class MeetingTests(BaseMeetingTestCase):
meeting_with_schedule = MeetingFactory(type_id='ietf', number='36', populate_schedule=True)
r = self.client.get(
urlreverse(
'ietf.meeting.views.agenda',
'agenda',
kwargs={'num': '36', 'ext': '.html'},
))
self.assertRedirects(r, 'https://example.com/36', fetch_redirect_response=False)
@ -359,7 +297,7 @@ class MeetingTests(BaseMeetingTestCase):
SessionFactory(meeting=meeting_with_schedule)
r = self.client.get(
urlreverse(
'ietf.meeting.views.agenda',
'agenda',
kwargs={'num': '36', 'ext': '.html'},
))
self.assertRedirects(r, 'https://example.com/36', fetch_redirect_response=False)
@ -369,203 +307,10 @@ class MeetingTests(BaseMeetingTestCase):
# Meetings pre-64 are redirected, but should be a 404 if there is no Meeting instance
r = self.client.get(
urlreverse(
'ietf.meeting.views.agenda',
'agenda',
kwargs={'num': '32', 'ext': '.html'},
))
self.assertEqual(r.status_code, 404)
# Check a post-64 meeting as well
r = self.client.get(
urlreverse(
'ietf.meeting.views.agenda',
kwargs={'num': '150', 'ext': '.html'},
))
self.assertEqual(r.status_code, 404)
def test_meeting_agenda_filters_ignored(self):
"""The agenda view should ignore filter querystrings
(They are handled by javascript on the front end)
"""
meeting = make_meeting_test_data()
expected_items = meeting.schedule.assignments.exclude(timeslot__type__in=['lead','offagenda'])
expected_rows = ['row-%s' % item.slug() for item in expected_items]
r = self.client.get(urlreverse('ietf.meeting.views.agenda'))
for row_id in expected_rows:
self.assertContains(r, row_id)
r = self.client.get(urlreverse('ietf.meeting.views.agenda') + '?show=mars')
for row_id in expected_rows:
self.assertContains(r, row_id)
r = self.client.get(urlreverse('ietf.meeting.views.agenda') + '?show=mars&hide=ames,mars,plenary,ietf,bof')
for row_id in expected_rows:
self.assertContains(r, row_id)
def test_agenda_iab_session(self):
date = date_today()
meeting = MeetingFactory(type_id='ietf', date=date )
make_meeting_test_data(meeting=meeting)
iab = Group.objects.get(acronym='iab')
venus = Group.objects.create(
name="Three letter acronym",
acronym="venus",
description="This group discusses exploration of Venus",
state_id="active",
type_id="program",
parent=iab,
list_email="venus@ietf.org",
)
venus_session = SessionFactory(
meeting=meeting,
group=venus,
attendees=10,
requested_duration=datetime.timedelta(minutes=60),
add_to_schedule=False,
)
system_person = Person.objects.get(name="(System)")
SchedulingEvent.objects.create(session=venus_session, status_id='schedw', by=system_person)
room = Room.objects.create(meeting=meeting,
name="Aphrodite",
capacity=100,
functional_name="Aphrodite Room")
room.session_types.add('regular')
session_date = meeting.date + datetime.timedelta(days=1)
slot3 = TimeSlot.objects.create(meeting=meeting, type_id='regular', location=room,
duration=datetime.timedelta(minutes=60),
time=meeting.tz().localize(
datetime.datetime.combine(session_date, datetime.time(13, 30))
))
SchedTimeSessAssignment.objects.create(timeslot=slot3, session=venus_session, schedule=meeting.schedule)
url = urlreverse('ietf.meeting.views.agenda', kwargs=dict(num=meeting.number))
r = self.client.get(url)
self.assertContains(r, 'venus')
q = PyQuery(r.content)
venus_row = q('[id*="-iab-"]').html()
self.assertIn('venus', venus_row)
def test_agenda_current_audio(self):
date = date_today()
meeting = MeetingFactory(type_id='ietf', date=date )
make_meeting_test_data(meeting=meeting)
url = urlreverse("ietf.meeting.views.agenda", kwargs=dict(num=meeting.number))
r = self.client.get(url)
self.assertContains(r, "Audio stream")
def test_agenda_by_room(self):
meeting = make_meeting_test_data()
url = urlreverse("ietf.meeting.views.agenda_by_room",kwargs=dict(num=meeting.number))
login_testing_unauthorized(self,"secretary",url)
r = self.client.get(url)
self.assertTrue(all([x in unicontent(r) for x in ['mars','IESG Breakfast','Test Room','Breakfast Room']]))
url = urlreverse("ietf.meeting.views.agenda_by_room",kwargs=dict(num=meeting.number,name=meeting.unofficial_schedule.name,owner=meeting.unofficial_schedule.owner.email()))
r = self.client.get(url)
self.assertTrue(all([x in unicontent(r) for x in ['mars','Test Room',]]))
self.assertNotContains(r, 'IESG Breakfast')
def test_agenda_by_type(self):
meeting = make_meeting_test_data()
url = urlreverse("ietf.meeting.views.agenda_by_type",kwargs=dict(num=meeting.number))
login_testing_unauthorized(self,"secretary",url)
r = self.client.get(url)
self.assertTrue(all([x in unicontent(r) for x in ['mars','IESG Breakfast','Test Room','Breakfast Room']]))
url = urlreverse("ietf.meeting.views.agenda_by_type",kwargs=dict(num=meeting.number,name=meeting.unofficial_schedule.name,owner=meeting.unofficial_schedule.owner.email()))
r = self.client.get(url)
self.assertTrue(all([x in unicontent(r) for x in ['mars','Test Room',]]))
self.assertNotContains(r, 'IESG Breakfast')
url = urlreverse("ietf.meeting.views.agenda_by_type",kwargs=dict(num=meeting.number,type='regular'))
r = self.client.get(url)
self.assertTrue(all([x in unicontent(r) for x in ['mars','Test Room']]))
self.assertFalse(any([x in unicontent(r) for x in ['IESG Breakfast','Breakfast Room']]))
url = urlreverse("ietf.meeting.views.agenda_by_type",kwargs=dict(num=meeting.number,type='lead'))
r = self.client.get(url)
self.assertFalse(any([x in unicontent(r) for x in ['mars','Test Room']]))
self.assertTrue(all([x in unicontent(r) for x in ['IESG Breakfast','Breakfast Room']]))
url = urlreverse("ietf.meeting.views.agenda_by_type",kwargs=dict(num=meeting.number,type='lead',name=meeting.unofficial_schedule.name,owner=meeting.unofficial_schedule.owner.email()))
r = self.client.get(url)
self.assertFalse(any([x in unicontent(r) for x in ['IESG Breakfast','Breakfast Room']]))
def test_agenda_week_view(self):
meeting = make_meeting_test_data()
url = urlreverse("ietf.meeting.views.week_view",kwargs=dict(num=meeting.number)) + "?show=farfut"
r = self.client.get(url)
self.assertEqual(r.status_code,200)
self.assertTrue(all([x in unicontent(r) for x in ['redraw_weekview', 'draw_calendar', ]]))
# Specifying a time zone should not change the output (time zones are handled by the JS)
url = urlreverse("ietf.meeting.views.week_view",kwargs=dict(num=meeting.number)) + "?show=farfut&" + quote("tz=Asia/Bangkok", safe='=')
r_with_tz = self.client.get(url)
self.assertEqual(r_with_tz.status_code,200)
self.assertEqual(r.content, r_with_tz.content)
def test_agenda_personalize(self):
"""Session selection page should have a checkbox for each session with appropriate keywords"""
meeting = make_meeting_test_data()
url = urlreverse("ietf.meeting.views.agenda_personalize",kwargs=dict(num=meeting.number))
r = self.client.get(url)
self.assertEqual(r.status_code,200)
q = PyQuery(r.content)
for assignment in SchedTimeSessAssignment.objects.filter(
schedule__in=[meeting.schedule, meeting.schedule.base],
session__on_agenda=True,
):
row = q('#row-{}'.format(assignment.slug()))
self.assertIsNotNone(row, 'No row for assignment {}'.format(assignment))
checkboxes = row('input[type="checkbox"][name="selected-sessions"]')
self.assertEqual(len(checkboxes), 1,
'Row for assignment {} does not have a checkbox input'.format(assignment))
checkbox = checkboxes.eq(0)
kw_token = assignment.session.docname_token_only_for_multiple()
self.assertEqual(
checkbox.attr('data-filter-item'),
assignment.session.group.acronym.lower() + (
'' if kw_token is None else f'-{kw_token}'
)
)
def test_agenda_personalize_updates_urls(self):
"""The correct URLs should be updated when filter settings change on the personalize agenda view
Tests that the expected elements have the necessary classes. The actual update of these fields
is tested in the JS tests
"""
meeting = make_meeting_test_data()
url = urlreverse("ietf.meeting.views.agenda_personalize",kwargs=dict(num=meeting.number))
r = self.client.get(url)
self.assertEqual(r.status_code,200)
q = PyQuery(r.content)
# Find all the elements expected to be updated
expected_elements = []
nav_tab_anchors = q('ul.nav.nav-tabs > li > a')
for anchor in nav_tab_anchors.items():
text = anchor.text().strip()
if text in ['Agenda (New)', 'Agenda', 'UTC agenda', 'Personalize agenda']:
expected_elements.append(anchor)
for btn in q('.buttonlist a.btn').items():
text = btn.text().strip()
if text in ['View personal agenda', 'Download .ics of filtered agenda', 'Subscribe to filtered agenda']:
expected_elements.append(btn)
# Check that all the expected elements have the correct classes
for elt in expected_elements:
self.assertTrue(elt.has_class('agenda-link'))
self.assertTrue(elt.has_class('filterable'))
# Finally, check that there are no unexpected elements marked to be updated.
# If there are, they should be added to the test above.
self.assertEqual(len(expected_elements),
len(q('.agenda-link.filterable')),
'Unexpected elements updated')
@override_settings(MEETING_MATERIALS_SERVE_LOCALLY=False, MEETING_DOC_HREFS = settings.MEETING_DOC_CDN_HREFS)
def test_materials_through_cdn(self):
@ -834,40 +579,6 @@ class MeetingTests(BaseMeetingTestCase):
self.assertContains(r, t1.local_start_time().strftime('%Y%m%dT%H%M%S'))
self.assertNotContains(r, t2.local_start_time().strftime('%Y%m%dT%H%M%S'))
def test_meeting_agenda_has_static_ical_links(self):
"""Links to the agenda_ical view must appear on the agenda page
Confirms that these have the correct querystrings. Does not test the JS-based
'Customized schedule' button.
"""
meeting = make_meeting_test_data()
# get the agenda
url = urlreverse('ietf.meeting.views.agenda', kwargs=dict(num=meeting.number))
r = self.client.get(url)
# Check that it has the links we expect
ical_url = urlreverse('ietf.meeting.views.agenda_ical', kwargs=dict(num=meeting.number))
q = PyQuery(r.content)
content = q('#content').html()
assignments = meeting.schedule.assignments.exclude(timeslot__type__in=['lead', 'offagenda'])
# Assume the test meeting is not using historic groups
groups = [a.session.group for a in assignments if a.session is not None]
for g in groups:
if g.parent_id is not None:
self.assertIn('%s?show=%s' % (ical_url, g.parent.acronym.lower()), content)
# The 'non-area events' are those whose keywords are in the last column of buttons
na_col = q('#customize .col-1:last') # find the column
non_area_labels = [e.attrib['data-filter-item']
for e in na_col.find('button.pickview')]
assert len(non_area_labels) > 0 # test setup must produce at least one label for this test
# Should be a 'non-area events' link showing appropriate types
self.assertIn('%s?show=%s' % (ical_url, ','.join(non_area_labels).lower()), content)
def test_parse_agenda_filter_params(self):
def _r(show=(), hide=(), showtypes=(), hidetypes=()):
"""Helper to create expected result dict"""
@ -4102,12 +3813,6 @@ class SessionDetailsTests(TestCase):
self.assertTrue(all([x in unicontent(r) for x in ('slides','agenda','minutes','draft')]))
self.assertNotContains(r, 'deleted')
q = PyQuery(r.content)
self.assertTrue(q('div#session-buttons-%s' % session.id),
'Session detail page does not contain session tool buttons')
self.assertFalse(q('div#session-buttons-%s span.bi-arrows-fullscreen' % session.id),
'The session detail page is incorrectly showing the "Show meeting materials" button')
def test_session_details_has_import_minutes_buttons(self):
group = GroupFactory.create(
type_id='wg',
@ -5783,25 +5488,6 @@ class AjaxTests(TestCase):
self.assertNotIn('error', data)
self.assertEqual(data['utc'], '20:00')
class FloorPlanTests(TestCase):
def test_floor_plan_page(self):
make_meeting_test_data()
meeting = Meeting.objects.filter(type_id='ietf').order_by('id').last()
floorplan = FloorPlanFactory.create(meeting=meeting)
# Extremely rudimentary test of floor-plan-neue
url = urlreverse('floor-plan-neue')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
url = urlreverse('ietf.meeting.views.floor_plan')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
url = urlreverse('ietf.meeting.views.floor_plan', kwargs={'floor': xslugify(floorplan.name)} )
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
class IphoneAppJsonTests(TestCase):
def test_iphone_app_json_interim(self):
make_interim_test_data()
@ -6557,7 +6243,7 @@ class SessionTests(TestCase):
group_type_without_meetings = 'editorial'
self.assertFalse(GroupFeatures.objects.get(pk=group_type_without_meetings).has_meetings)
area = GroupFactory(type_id='area')
area = GroupFactory(type_id='area', acronym='area')
requested_session = SessionFactory(meeting=meeting,group__parent=area,status_id='schedw',add_to_schedule=False)
conflicting_session = SessionFactory(meeting=meeting,group__parent=area,status_id='schedw',add_to_schedule=False)
ConstraintFactory(name_id='key_participant',meeting=meeting,source=requested_session.group,target=conflicting_session.group)
@ -6597,7 +6283,29 @@ class SessionTests(TestCase):
status_id='schedw',
add_to_schedule=False,
)
# bof sessions should be shown
bof_session = SessionFactory(
meeting=meeting,
group__parent=area,
group__state_id='bof',
status_id='schedw',
add_to_schedule=False,
)
# proposed WG sessions should be shown
proposed_wg_session = SessionFactory(
meeting=meeting,
group__parent=area,
group__state_id='proposed',
status_id='schedw',
add_to_schedule=False,
)
# rg sessions should be shown under 'irtf' heading
rg_session = SessionFactory(
meeting=meeting,
group__type_id='rg',
status_id='schedw',
add_to_schedule=False,
)
def _sreq_edit_link(sess):
return urlreverse(
'ietf.secr.sreq.views.edit',
@ -6630,6 +6338,19 @@ class SessionTests(TestCase):
self.assertContains(r, _sreq_edit_link(has_meetings_not_meeting)) # link to the session request
self.assertNotContains(r, not_has_meetings.group.acronym)
self.assertNotContains(r, _sreq_edit_link(not_has_meetings)) # no link to the session request
self.assertContains(r, bof_session.group.acronym)
self.assertContains(r, _sreq_edit_link(bof_session)) # link to the session request
self.assertContains(r, proposed_wg_session.group.acronym)
self.assertContains(r, _sreq_edit_link(proposed_wg_session)) # link to the session request
self.assertContains(r, rg_session.group.acronym)
self.assertContains(r, _sreq_edit_link(rg_session)) # link to the session request
# check headings - note that the special types (has_meetings, etc) do not have a group parent
# so they show up in 'other'
q = PyQuery(r.content)
self.assertEqual(len(q('h2#area')), 1)
self.assertEqual(len(q('h2#other-groups')), 1)
self.assertEqual(len(q('h2#irtf')), 1) # rg group has irtf group as parent
def test_request_minutes(self):
meeting = MeetingFactory(type_id='ietf')

View file

@ -7,6 +7,13 @@ from django.conf import settings
from ietf.meeting import views, views_proceedings
from ietf.utils.urls import url
class AgendaRedirectView(RedirectView):
ignore_kwargs = ('owner', 'name')
def get_redirect_url(self, *args, **kwargs):
for kwarg in self.ignore_kwargs:
kwargs.pop(kwarg, None)
return super().get_redirect_url(*args, **kwargs)
safe_for_all_meeting_types = [
url(r'^session/(?P<acronym>[-a-z0-9]+)/?$', views.session_details),
url(r'^session/(?P<session_id>\d+)/drafts$', views.add_session_drafts),
@ -33,16 +40,13 @@ type_ietf_only_patterns = [
url(r'^agenda/%(owner)s/%(schedule_name)s/delete$' % settings.URL_REGEXPS, views.delete_schedule),
url(r'^agenda/%(owner)s/%(schedule_name)s/make_official$' % settings.URL_REGEXPS, views.make_schedule_official),
url(r'^agenda/%(owner)s/%(schedule_name)s(\.(?P<ext>.html))?/?$' % settings.URL_REGEXPS, views.agenda),
url(r'^agenda/%(owner)s/%(schedule_name)s/week-view(?:.html)?/?$' % settings.URL_REGEXPS, views.week_view),
url(r'^agenda/%(owner)s/%(schedule_name)s/by-room/?$' % settings.URL_REGEXPS, views.agenda_by_room),
url(r'^agenda/%(owner)s/%(schedule_name)s/by-type/?$' % settings.URL_REGEXPS, views.agenda_by_type),
url(r'^agenda/%(owner)s/%(schedule_name)s/by-type/(?P<type>[a-z]+)$' % settings.URL_REGEXPS, views.agenda_by_type),
url(r'^agenda/%(owner)s/%(schedule_name)s/week-view(?:.html)?/?$' % settings.URL_REGEXPS, AgendaRedirectView.as_view(pattern_name='agenda', permanent=True)),
url(r'^agenda/%(owner)s/%(schedule_name)s/by-room/?$' % settings.URL_REGEXPS, AgendaRedirectView.as_view(pattern_name='agenda', permanent=True)),
url(r'^agenda/%(owner)s/%(schedule_name)s/by-type/?$' % settings.URL_REGEXPS, AgendaRedirectView.as_view(pattern_name='agenda', permanent=True)),
url(r'^agenda/%(owner)s/%(schedule_name)s/by-type/(?P<type>[a-z]+)$' % settings.URL_REGEXPS, AgendaRedirectView.as_view(pattern_name='agenda', permanent=True)),
url(r'^agenda/%(owner)s/%(schedule_name)s/new/$' % settings.URL_REGEXPS, views.new_meeting_schedule),
url(r'^agenda/by-room$', views.agenda_by_room),
url(r'^agenda/by-type$', views.agenda_by_type),
url(r'^agenda/by-type/(?P<type>[a-z]+)$', views.agenda_by_type),
url(r'^agenda/by-type/(?P<type>[a-z]+)/ics$', views.agenda_by_type_ics),
url(r'^agenda/personalize', views.agenda_personalize),
url(r'^agenda/personalize', views.agenda, name='agenda-personalize'),
url(r'^agendas/list$', views.list_schedules),
url(r'^agendas/edit$', RedirectView.as_view(pattern_name='ietf.meeting.views.list_schedules', permanent=True)),
url(r'^agendas/diff/$', views.diff_schedules),
@ -64,10 +68,9 @@ type_interim_patterns = [
]
type_ietf_only_patterns_id_optional = [
url(r'^agenda(?P<utc>-utc)?(?P<ext>\.html)?/?$', views.agenda),
url(r'^agenda(?P<ext>\.txt)$', views.agenda),
url(r'^agenda(?P<ext>\.csv)$', views.agenda),
url(r'^agenda-neue(?P<utc>-utc)?(?P<ext>\.html)?/?$', views.agenda_neue, name='agenda-neue'),
url(r'^agenda(?P<utc>-utc)?(?P<ext>\.html)?/?$', views.agenda, name='agenda'),
url(r'^agenda(?P<ext>\.txt)$', views.agenda_plain),
url(r'^agenda(?P<ext>\.csv)$', views.agenda_plain),
url(r'^agenda/edit$',
RedirectView.as_view(pattern_name='ietf.meeting.views.edit_meeting_schedule', permanent=True),
name='ietf.meeting.views.edit_meeting_schedule'),
@ -76,11 +79,10 @@ type_ietf_only_patterns_id_optional = [
url(r'^agenda/agenda\.ics$', views.agenda_ical),
url(r'^agenda\.ics$', views.agenda_ical),
url(r'^agenda.json$', views.agenda_json),
url(r'^agenda/week-view(?:.html)?/?$', views.week_view),
url(r'^floor-plan/?$', views.floor_plan),
url(r'^floor-plan-neue/?$', views.agenda_neue, name='floor-plan-neue'),
url(r'^floor-plan/(?P<floor>[-a-z0-9_]+)/?$', views.floor_plan),
url(r'^week-view(?:.html)?/?$', views.week_view),
url(r'^agenda/week-view(?:.html)?/?$', RedirectView.as_view(pattern_name='agenda', permanent=True)),
url(r'^floor-plan/?$', views.agenda, name='floor-plan'),
url(r'^floor-plan/(?P<floor>[-a-z0-9_]+)/?$', RedirectView.as_view(pattern_name='floor-plan', permanent=True)),
url(r'^week-view(?:.html)?/?$', RedirectView.as_view(pattern_name='agenda', permanent=True)),
url(r'^materials(?:.html)?/?$', views.materials),
url(r'^request_minutes/?$', views.request_minutes),
url(r'^materials/%(document)s((?P<ext>\.[a-z0-9]+)|/)?$' % settings.URL_REGEXPS, views.materials_document),

Some files were not shown because too many files have changed in this diff Show more