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 }} pkg_version: ${{ steps.buildvars.outputs.pkg_version }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
@ -70,7 +70,7 @@ jobs:
echo "NEXT_VERSION=$nextStrict" >> $GITHUB_ENV echo "NEXT_VERSION=$nextStrict" >> $GITHUB_ENV
- name: Create Draft Release - 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' }} if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.dryrun == 'false' }}
with: with:
prerelease: true prerelease: true
@ -167,7 +167,7 @@ jobs:
mv latest-coverage.json coverage.json mv latest-coverage.json coverage.json
- name: Upload Coverage Results as Build Artifact - name: Upload Coverage Results as Build Artifact
uses: actions/upload-artifact@v3.0.0 uses: actions/upload-artifact@v3
if: ${{ always() }} if: ${{ always() }}
with: with:
name: coverage name: coverage
@ -186,7 +186,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v2 - uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: '18'
@ -203,7 +203,7 @@ jobs:
npx playwright test --project=${{ matrix.project }} npx playwright test --project=${{ matrix.project }}
- name: Upload Report - name: Upload Report
uses: actions/upload-artifact@v3.0.0 uses: actions/upload-artifact@v3
if: ${{ always() }} if: ${{ always() }}
continue-on-error: true continue-on-error: true
with: with:
@ -256,7 +256,7 @@ jobs:
yarn cypress:legacy yarn cypress:legacy
- name: Upload Video Recordings - name: Upload Video Recordings
uses: actions/upload-artifact@v3.0.0 uses: actions/upload-artifact@v3
if: ${{ always() }} if: ${{ always() }}
continue-on-error: true continue-on-error: true
with: with:
@ -265,7 +265,7 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
- name: Upload Screenshots - name: Upload Screenshots
uses: actions/upload-artifact@v3.0.0 uses: actions/upload-artifact@v3
if: ${{ always() }} if: ${{ always() }}
continue-on-error: true continue-on-error: true
with: with:
@ -286,17 +286,17 @@ jobs:
PKG_VERSION: ${{needs.prepare.outputs.pkg_version}} PKG_VERSION: ${{needs.prepare.outputs.pkg_version}}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v3.0.0 uses: actions/setup-node@v3
with: with:
node-version: 16.x node-version: '16'
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: '3.x' python-version: '3.x'
@ -364,7 +364,7 @@ jobs:
histCoveragePath: historical-coverage.json histCoveragePath: historical-coverage.json
- name: Create Release - 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' }} if: ${{ env.SHOULD_DEPLOY == 'true' && github.event.inputs.dryrun == 'false' }}
with: with:
allowUpdates: true allowUpdates: true
@ -376,7 +376,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Update Baseline Coverage - 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' }} if: ${{ github.event.inputs.updateCoverage == 'true' && github.event.inputs.dryrun == 'false' }}
with: with:
allowUpdates: true allowUpdates: true
@ -389,8 +389,19 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Build Artifacts - 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' }} if: ${{ env.SHOULD_DEPLOY == 'false' || github.event.inputs.dryrun == 'true' }}
with: with:
name: release-${{ env.PKG_VERSION }} name: release-${{ env.PKG_VERSION }}
path: /home/runner/work/release/release.tar.gz 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: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v2 - uses: actions/setup-node@v3
with: with:
node-version: '18' 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 #### Run Vue Tests
To run the tests headlessly (command line mode): > :warning: All commands below **MUST** be run from the `./playwright` directory, unless noted otherwise.
```sh
yarn cypress 1. Run **once** to install dependencies on your system:
``` ```sh
To run the tests visually **(CANNOT run in docker)**: npm install
```sh npx playwright install --with-deps
yarn cypress:open ```
```
> It can take a few seconds before the tests start or the GUI opens. 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 #### Run Legacy Views Tests

View file

@ -1,9 +1,9 @@
<template lang="pug"> <template lang="pug">
n-theme n-theme
n-message-provider n-message-provider
.app-error(v-if='agendaStore.criticalError') .app-error(v-if='siteStore.criticalError')
i.bi.bi-x-octagon-fill.me-2 i.bi.bi-x-octagon-fill.me-2
span {{agendaStore.criticalError}} span {{siteStore.criticalError}}
.app-container(ref='appContainer') .app-container(ref='appContainer')
router-view.meeting router-view.meeting
</template> </template>
@ -12,13 +12,13 @@ n-theme
import { onBeforeUnmount ,onMounted, ref } from 'vue' import { onBeforeUnmount ,onMounted, ref } from 'vue'
import { NMessageProvider } from 'naive-ui' import { NMessageProvider } from 'naive-ui'
import { useAgendaStore } from './agenda/store' import { useSiteStore } from './shared/store'
import NTheme from './components/n-theme.vue' import NTheme from './components/n-theme.vue'
// STORES // STORES
const agendaStore = useAgendaStore() const siteStore = useSiteStore()
// STATE // STATE
@ -29,14 +29,14 @@ const appContainer = ref(null)
// -------------------------------------------------------------------- // --------------------------------------------------------------------
const resizeObserver = new ResizeObserver(entries => { const resizeObserver = new ResizeObserver(entries => {
agendaStore.$patch({ viewport: Math.round(window.innerWidth) }) siteStore.$patch({ viewport: Math.round(window.innerWidth) })
// for (const entry of entries) { // for (const entry of entries) {
// const newWidth = entry.contentBoxSize ? entry.contentBoxSize[0].inlineSize : entry.contentRect.width // const newWidth = entry.contentBoxSize ? entry.contentBoxSize[0].inlineSize : entry.contentRect.width
// } // }
}) })
onMounted(() => { onMounted(() => {
resizeObserver.observe(appContainer.value, { box: 'device-pixel-content-box' }) resizeObserver.observe(appContainer.value)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -47,7 +47,6 @@ onBeforeUnmount(() => {
<style lang="scss"> <style lang="scss">
@import "bootstrap/scss/functions"; @import "bootstrap/scss/functions";
@import "bootstrap/scss/variables"; @import "bootstrap/scss/variables";
@import "./shared/breakpoints";
.app-error { .app-error {
background-color: $red-500; background-color: $red-500;
@ -57,69 +56,4 @@ onBeforeUnmount(() => {
padding: 1rem; padding: 1rem;
text-align: center; 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> </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}} span #[strong IETF {{agendaStore.meeting.number}}] Meeting Agenda {{titleExtra}}
.meeting-h1-badges.d-none.d-sm-flex .meeting-h1-badges.d-none.d-sm-flex
span.meeting-warning(v-if='agendaStore.meeting.warningNote') {{agendaStore.meeting.warningNote}} span.meeting-warning(v-if='agendaStore.meeting.warningNote') {{agendaStore.meeting.warningNote}}
span.meeting-beta BETA
h4 h4
span {{agendaStore.meeting.city}}, {{ meetingDate }} span {{agendaStore.meeting.city}}, {{ meetingDate }}
h6.float-end.d-none.d-lg-inline(v-if='meetingUpdated') #[span.text-muted Updated:] {{ meetingUpdated }} h6.float-end.d-none.d-lg-inline(v-if='meetingUpdated') #[span.text-muted Updated:] {{ meetingUpdated }}
@ -54,7 +53,7 @@
@click='setTimezone(`UTC`)' @click='setTimezone(`UTC`)'
) UTC ) UTC
n-select.agenda-timezone-ddn( n-select.agenda-timezone-ddn(
v-if='agendaStore.viewport > 1250' v-if='siteStore.viewport > 1250'
v-model:value='agendaStore.timezone' v-model:value='agendaStore.timezone'
:options='timezones' :options='timezones'
placeholder='Select Time Zone' placeholder='Select Time Zone'
@ -134,7 +133,7 @@
// ----------------------------------- // -----------------------------------
// -> Anchored Day Quick Access Menu // -> 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-quick-access
agenda-mobile-bar agenda-mobile-bar
@ -166,6 +165,9 @@ import MeetingNavigation from './MeetingNavigation.vue'
import timezones from '../shared/timezones' import timezones from '../shared/timezones'
import { useAgendaStore } from './store' import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store'
import './agenda.scss'
// MESSAGE PROVIDER // MESSAGE PROVIDER
@ -174,6 +176,7 @@ const message = useMessage()
// STORES // STORES
const agendaStore = useAgendaStore() const agendaStore = useAgendaStore()
const siteStore = useSiteStore()
// ROUTER // 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 // COMPUTED

View file

@ -55,6 +55,17 @@ n-modal(v-model:show='modalShown')
) )
i.bi.bi-journal-text.me-2 i.bi.bi-journal-text.me-2
span Notepad 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-content
.detail-title .detail-title
h6 h6
@ -95,19 +106,29 @@ n-modal(v-model:show='modalShown')
:src='eventDetails.materialsUrl' :src='eventDetails.materialsUrl'
) )
template(v-else-if='state.tab === `slides`') template(v-else-if='state.tab === `slides`')
.text-center(v-if='state.isLoading') n-card(
n-spin(description='Loading slides...') :bordered='false'
.text-center.p-3(v-else-if='!state.materials || !state.materials.slides || state.materials.slides.length < 1') size='small'
span No slides submitted for this session. )
.list-group(v-else) .text-center(v-if='state.isLoading')
a.list-group-item( n-spin(description='Loading slides...')
v-for='slide of state.materials.slides' .text-center.p-3(v-else-if='!state.materials || !state.materials.slides || !state.materials.slides.decks || state.materials.slides.decks.length < 1')
:key='slide.id' span No slides submitted for this session.
:href='slide.url' .list-group(v-else)
target='_blank' a.list-group-item(
) v-for='deck of state.materials.slides.decks'
i.bi.me-2(:class='`bi-filetype-` + slide.ext') :key='deck.id'
span {{slide.title}} :href='deck.url'
target='_blank'
)
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) template(v-else)
.text-center(v-if='state.isLoading') .text-center(v-if='state.isLoading')
n-spin(description='Loading minutes...') 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, title: props.event.type === 'regular' ? `${props.event.groupName} (${props.event.acronym})` : props.event.name,
showAgenda: props.event.flags.showAgenda, showAgenda: props.event.flags.showAgenda,
materialsUrl: materialsUrl, materialsUrl: materialsUrl,
detailsUrl: `/meeting/${agendaStore.meeting.number}/session/${props.event.acronym}/`,
tarUrl: `/meeting/${agendaStore.meeting.number}/agenda/${props.event.acronym}-drafts.tgz`, tarUrl: `/meeting/${agendaStore.meeting.number}/agenda/${props.event.acronym}-drafts.tgz`,
pdfUrl: `/meeting/${agendaStore.meeting.number}/agenda/${props.event.acronym}-drafts.pdf`, 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 state.isLoading = true
try { 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) { if (!resp.ok) {
throw new Error(resp.statusText) throw new Error(resp.statusText)
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ ul.nav.nav-tabs.meeting-nav(v-if='agendaStore.isLoaded')
router-link.nav-link( router-link.nav-link(
v-else v-else
active-class='active' 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') i.bi.me-2.d-none.d-sm-inline(:class='tab.icon')
span {{tab.title}} 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 uniqBy from 'lodash/uniqBy'
import murmur from 'murmurhash-js/murmurhash3_gc' 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 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'] 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' } { hex: '#20c997', tag: 'Attended' }
], ],
colorAssignments: {}, colorAssignments: {},
criticalError: null,
currentTab: 'agenda', currentTab: 'agenda',
dayIntersectId: '', dayIntersectId: '',
defaultCalendarView: 'week', defaultCalendarView: 'week',
@ -35,7 +36,6 @@ export const useAgendaStore = defineStore('agenda', {
infoNoteShown: true, infoNoteShown: true,
isCurrentMeeting: false, isCurrentMeeting: false,
isLoaded: false, isLoaded: false,
isMobile: /Mobi/i.test(navigator.userAgent),
listDayCollapse: false, listDayCollapse: false,
meeting: {}, meeting: {},
nowDebugDiff: null, nowDebugDiff: null,
@ -50,7 +50,6 @@ export const useAgendaStore = defineStore('agenda', {
settingsShown: false, settingsShown: false,
timezone: DateTime.local().zoneName, timezone: DateTime.local().zoneName,
useHedgeDoc: false, useHedgeDoc: false,
viewport: Math.round(window.innerWidth),
visibleDays: [] visibleDays: []
}), }),
getters: { getters: {
@ -119,10 +118,11 @@ export const useAgendaStore = defineStore('agenda', {
}) })
}, },
meetingDays () { meetingDays () {
const siteStore = useSiteStore()
return uniqBy(this.scheduleAdjusted, 'adjustedStartDate').sort().map(s => ({ return uniqBy(this.scheduleAdjusted, 'adjustedStartDate').sort().map(s => ({
slug: s.id.toString(), slug: s.id.toString(),
ts: s.adjustedStartDate, 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) { isMeetingLive (state) {
@ -168,7 +168,10 @@ export const useAgendaStore = defineStore('agenda', {
this.isLoaded = true this.isLoaded = true
} catch (err) { } catch (err) {
console.error(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() 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({ export default createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
// ---------------------------------------------------------
// MEETING
// ---------------------------------------------------------
{ {
name: 'agenda', name: 'agenda',
path: '/meeting/:meetingNumber(\\d+)?/agenda-neue', path: '/meeting/:meetingNumber(\\d+)?/agenda',
component: () => import('./agenda/Agenda.vue'), component: () => import('./agenda/Agenda.vue'),
meta: { meta: {
hideLeftMenu: true hideLeftMenu: true
@ -13,11 +16,18 @@ export default createRouter({
}, },
{ {
name: 'floor-plan', name: 'floor-plan',
path: '/meeting/:meetingNumber(\\d+)?/floor-plan-neue', path: '/meeting/:meetingNumber(\\d+)?/floor-plan',
component: () => import('./agenda/FloorPlan.vue'), component: () => import('./agenda/FloorPlan.vue'),
meta: { meta: {
hideLeftMenu: true 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: default:
target: auto target: auto
threshold: 1% 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 ```sh
Copy-Item "docker/docker-compose.extend.yml" -Destination "docker/docker-compose.extend-custom.yml" 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 (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 -f docker-compose.yml -f docker/docker-compose.extend-custom.yml up -d
docker-compose exec app /bin/sh /docker-init.sh 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 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 On Windows, type the command
```sh ```sh
docker-compose down docker compose down
``` ```
to terminate the containers. to terminate the containers.
@ -138,9 +138,9 @@ cd docker
On Windows: On Windows:
```sh ```sh
docker-compose down -v docker compose down -v
docker-compose pull db docker compose pull db
docker-compose build --no-cache db docker compose build --no-cache db
``` ```
### Clean all ### Clean all
@ -156,7 +156,7 @@ cd docker
On Windows: On Windows:
```sh ```sh
docker-compose down -v --rmi all docker compose down -v --rmi all
docker image prune 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)*: 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 ```sh
docker-compose port db 3306 docker compose port db 3306
``` ```
## Notes / Troubleshooting ## Notes / Troubleshooting

View file

@ -9,6 +9,7 @@ import sys
from importlib import import_module from importlib import import_module
from mock import patch from mock import patch
from pathlib import Path
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
@ -21,6 +22,7 @@ from tastypie.test import ResourceTestCaseMixin
import debug # pyflakes:ignore import debug # pyflakes:ignore
import ietf import ietf
from ietf.doc.utils import get_unicode_document_content
from ietf.group.factories import RoleFactory from ietf.group.factories import RoleFactory
from ietf.meeting.factories import MeetingFactory, SessionFactory from ietf.meeting.factories import MeetingFactory, SessionFactory
from ietf.meeting.test_data import make_meeting_test_data from ietf.meeting.test_data import make_meeting_test_data
@ -213,6 +215,93 @@ class CustomApiTests(TestCase):
self.assertTrue(session.attended_set.filter(person=recman).exists()) self.assertTrue(session.attended_set.filter(person=recman).exists())
self.assertTrue(session.attended_set.filter(person=otherperson).exists()) self.assertTrue(session.attended_set.filter(person=otherperson).exists())
def test_api_upload_polls_and_chatlog(self):
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')
recmanrole.person.user.last_login = timezone.now()
recmanrole.person.user.save()
badrole = RoleFactory(group__type_id='ietf', name_id='ad')
badrole.person.user.last_login = timezone.now()
badrole.person.user.save()
meeting = MeetingFactory(type_id='ietf')
session = SessionFactory(group__type_id='wg', meeting=meeting)
for type_id, content in (
(
"chatlog",
"""[
{
"author": "Raymond Lutz",
"text": "<p>Yes I like that comment just made</p>",
"time": "2022-07-28T19:26:16Z"
},
{
"author": "Carsten Bormann",
"text": "<p>But software is not a thing.</p>",
"time": "2022-07-28T19:26:45Z"
}
]"""
),
(
"polls",
"""[
{
"start_time": "2022-07-28T19:19:54Z",
"end_time": "2022-07-28T19:20:23Z",
"text": "Are you willing to review the documents?",
"raise_hand": 57,
"do_not_raise_hand": 11
},
{
"start_time": "2022-07-28T19:20:56Z",
"end_time": "2022-07-28T19:21:30Z",
"text": "Would you be willing to edit or coauthor a document?",
"raise_hand": 31,
"do_not_raise_hand": 31
}
]"""
),
):
url = urlreverse(f"ietf.meeting.views.api_upload_{type_id}")
apikey = PersonalApiKey.objects.create(endpoint=url, person=recmanrole.person)
badapikey = PersonalApiKey.objects.create(endpoint=url, person=badrole.person)
r = self.client.post(url, {})
self.assertContains(r, "Missing apikey parameter", status_code=400)
r = self.client.post(url, {'apikey': badapikey.hash()} )
self.assertContains(r, "Restricted to role: Recording Manager", status_code=403)
r = self.client.get(url, {'apikey': apikey.hash()} )
self.assertContains(r, "Method not allowed", status_code=405)
r = self.client.post(url, {'apikey': apikey.hash()} )
self.assertContains(r, "Missing apidata parameter", status_code=400)
for baddict in (
'{}',
'{"bogons;drop table":"bogons;drop table"}',
'{"session_id":"Not an integer;drop table"}',
f'{{"session_id":{session.pk},"{type_id}":"not a list;drop table"}}',
f'{{"session_id":{session.pk},"{type_id}":"not a list;drop table"}}',
f'{{"session_id":{session.pk},"{type_id}":[{{}}, {{}}, "not an int;drop table", {{}}]}}',
):
r = self.client.post(url, {'apikey': apikey.hash(), 'apidata': baddict})
self.assertContains(r, "Malformed post", status_code=400)
bad_session_id = Session.objects.order_by('-pk').first().pk + 1
r = self.client.post(url, {'apikey': apikey.hash(), 'apidata': f'{{"session_id":{bad_session_id},"{type_id}":[]}}'})
self.assertContains(r, "Invalid session", status_code=400)
# Valid POST
r = self.client.post(url,{'apikey':apikey.hash(),'apidata': f'{{"session_id":{session.pk}, "{type_id}":{content}}}'})
self.assertEqual(r.status_code, 200)
newdoc = session.sessionpresentation_set.get(document__type_id=type_id).document
newdoccontent = get_unicode_document_content(newdoc.name, Path(session.meeting.get_materials_path()) / type_id / newdoc.uploaded_filename)
self.assertEqual(json.loads(content), json.loads(newdoccontent))
def test_api_upload_bluesheet(self): def test_api_upload_bluesheet(self):
url = urlreverse('ietf.meeting.views.api_upload_bluesheet') url = urlreverse('ietf.meeting.views.api_upload_bluesheet')
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')

View file

@ -37,6 +37,10 @@ urlpatterns = [
url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet), url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet),
# Let MeetEcho tell us about session attendees # Let MeetEcho tell us about session attendees
url(r'^notify/session/attendees/?$', meeting_views.api_add_session_attendees), url(r'^notify/session/attendees/?$', meeting_views.api_add_session_attendees),
# Let MeetEcho upload session chatlog
url(r'^notify/session/chatlog/?$', meeting_views.api_upload_chatlog),
# Let MeetEcho upload session polls
url(r'^notify/session/polls/?$', meeting_views.api_upload_polls),
# Let the registration system notify us about registrations # Let the registration system notify us about registrations
url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration), url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration),
# OpenID authentication provider # OpenID authentication provider

View file

@ -0,0 +1,34 @@
# Copyright The IETF Trust 2022, All Rights Reserved
from django.db import migrations
def forward(apps, schema_editor):
StateType = apps.get_model("doc", "StateType")
State = apps.get_model("doc", "State")
for slug in ("chatlog", "polls"):
StateType.objects.create(slug=slug, label="State")
for state_slug in ("active", "deleted"):
State.objects.create(
type_id = slug,
slug = state_slug,
name = state_slug.capitalize(),
used = True,
desc = "",
order = 0,
)
def reverse(apps, schema_editor):
StateType = apps.get_model("doc", "StateType")
State = apps.get_model("doc", "State")
State.objects.filter(type_id__in=("chatlog", "polls")).delete()
StateType.objects.filter(slug__in=("chatlog", "polls")).delete()
class Migration(migrations.Migration):
dependencies = [
('doc', '0044_procmaterials_states'),
('name', '0045_polls_and_chatlogs'),
]
operations = [
migrations.RunPython(forward, reverse),
]

View file

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

View file

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

View file

@ -138,7 +138,7 @@ class DocumentInfo(models.Model):
else: else:
self._cached_file_path = settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR self._cached_file_path = settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR
elif self.meeting_related() and self.type_id in ( elif self.meeting_related() and self.type_id in (
"agenda", "minutes", "slides", "bluesheets", "procmaterials" "agenda", "minutes", "slides", "bluesheets", "procmaterials", "chatlog", "polls"
): ):
meeting = self.get_related_meeting() meeting = self.get_related_meeting()
if meeting is not None: if meeting is not None:
@ -422,7 +422,7 @@ class DocumentInfo(models.Model):
return e != None and (e.text != "") return e != None and (e.text != "")
def meeting_related(self): def meeting_related(self):
if self.type_id in ("agenda","minutes","bluesheets","slides","recording","procmaterials"): if self.type_id in ("agenda","minutes","bluesheets","slides","recording","procmaterials","chatlog","polls"):
return self.type_id != "slides" or self.get_state_slug('reuse_policy')=='single' return self.type_id != "slides" or self.get_state_slug('reuse_policy')=='single'
return False return False

View file

@ -225,7 +225,7 @@ def state_age_colored(doc):
else: else:
title = "" title = ""
return mark_safe( 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) % (class_name, title, days)
) )
else: 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))) >>> 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))) >>> 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 >>> 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 age = (timezone.now() - action_holder.time_added).days
if age > age_limit: if age > age_limit:
return mark_safe( 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) % (age, "s" if age != 1 else "", age_limit, age)
) )
else: 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='agenda',name='agenda-72-mars')
DocumentFactory(type_id='minutes',name='minutes-72-mars') DocumentFactory(type_id='minutes',name='minutes-72-mars')
DocumentFactory(type_id='slides',name='slides-72-mars-1-active') DocumentFactory(type_id='slides',name='slides-72-mars-1-active')
chatlog = DocumentFactory(type_id="chatlog",name='chatlog-72-mars-197001010000')
polls = DocumentFactory(type_id="polls",name='polls-72-mars-197001010000')
SessionPresentationFactory(document=chatlog)
SessionPresentationFactory(document=polls)
statchg = DocumentFactory(type_id='statchg',name='status-change-imaginary-mid-review') statchg = DocumentFactory(type_id='statchg',name='status-change-imaginary-mid-review')
statchg.set_state(State.objects.get(type_id='statchg',slug='adrev')) statchg.set_state(State.objects.get(type_id='statchg',slug='adrev'))
@ -1481,6 +1485,8 @@ Man Expires September 22, 2015 [Page 3]
"agenda-72-mars", "agenda-72-mars",
"minutes-72-mars", "minutes-72-mars",
"slides-72-mars-1-active", "slides-72-mars-1-active",
"chatlog-72-mars-197001010000",
"polls-72-mars-197001010000",
# TODO: add # TODO: add
#"bluesheets-72-mars-1", #"bluesheets-72-mars-1",
#"recording-72-mars-1-00", #"recording-72-mars-1-00",

View file

@ -41,6 +41,7 @@ import os
import re import re
from urllib.parse import quote from urllib.parse import quote
from pathlib import Path
from django.http import HttpResponse, Http404 from django.http import HttpResponse, Http404
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
@ -641,9 +642,7 @@ def document_main(request, name, rev=None):
sorted_relations=sorted_relations, sorted_relations=sorted_relations,
)) ))
# TODO : Add "recording", and "bluesheets" here when those documents are appropriately if doc.type_id in ("slides", "agenda", "minutes", "bluesheets", "procmaterials",):
# created and content is made available on disk
if doc.type_id in ("slides", "agenda", "minutes", "bluesheets","procmaterials",):
can_manage_material = can_manage_materials(request.user, doc.group) can_manage_material = can_manage_materials(request.user, doc.group)
presentations = doc.future_presentations() presentations = doc.future_presentations()
if doc.uploaded_filename: if doc.uploaded_filename:
@ -725,6 +724,29 @@ def document_main(request, name, rev=None):
assignments=assignments, assignments=assignments,
)) ))
if doc.type_id in ("chatlog", "polls"):
if isinstance(doc,DocHistory):
session = doc.doc.sessionpresentation_set.last().session
else:
session = doc.sessionpresentation_set.last().session
pathname = Path(session.meeting.get_materials_path()) / doc.type_id / doc.uploaded_filename
content = get_unicode_document_content(doc.name, str(pathname))
return render(
request,
f"doc/document_{doc.type_id}.html",
dict(
doc=doc,
top=top,
content=content,
revisions=revisions,
latest_rev=latest_rev,
snapshot=snapshot,
session=session,
)
)
raise Http404("Document not found: %s" % (name + ("-%s"%rev if rev else ""))) raise Http404("Document not found: %s" % (name + ("-%s"%rev if rev else "")))

View file

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

View file

@ -35,6 +35,7 @@ import debug # pyflakes:ignore
from ietf.group.factories import GroupFactory, RoleFactory from ietf.group.factories import GroupFactory, RoleFactory
from ietf.group.models import Group, Role, RoleName from ietf.group.models import Group, Role, RoleName
from ietf.ietfauth.htpasswd import update_htpasswd_file from ietf.ietfauth.htpasswd import update_htpasswd_file
from ietf.ietfauth.utils import has_role
from ietf.mailinglists.models import Subscribed from ietf.mailinglists.models import Subscribed
from ietf.meeting.factories import MeetingFactory from ietf.meeting.factories import MeetingFactory
from ietf.nomcom.factories import NomComFactory 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. # handler, causing later logging to become visible even if that wasn't intended.
# Fail here if that happens. # Fail here if that happens.
self.assertEqual(logging.root.handlers, []) 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 # various authentication and authorization utilities
import oidc_provider.lib.claims import oidc_provider.lib.claims
from oidc_provider.models import Client as ClientRecord
from functools import wraps from functools import wraps
@ -16,7 +15,6 @@ from django.core.exceptions import PermissionDenied
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.decorators import available_attrs from django.utils.decorators import available_attrs
from django.utils.http import urlquote 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"), "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: for r in role_names:
filter_expr |= role_qs[r] 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 docman_roles = GroupFeatures.objects.get(type_id="ietf").docman_roles
group_req = Q(group__acronym=doc.stream.slug) group_req = Q(group__acronym=doc.stream.slug)
else: 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() 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() reg.save()
info = {} info = {}
if regs: 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 # fill in info to return
ticket_types = set([]) ticket_types = set([])
reg_types = set([]) reg_types = set([])

View file

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

View file

@ -258,18 +258,35 @@ class Meeting(models.Model):
number = self.get_number() number = self.get_number()
if number is None or number < 110: if number is None or number < 110:
return None 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( return Attendance(
onsite=Person.objects.filter( onsite=len(onsite),
meetingregistration__meeting=self, remote=len(remote)
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(),
) )
@property @property
@ -469,7 +486,7 @@ class Room(models.Model):
if not mtg_num: if not mtg_num:
return None return None
elif self.floorplan: 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: else:
return None return None
return f'{base_url}?room={xslugify(self.name)}' 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 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.stats.factories import MeetingRegistrationFactory
from ietf.utils.test_utils import TestCase from ietf.utils.test_utils import TestCase
@ -19,41 +19,75 @@ class MeetingTests(TestCase):
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person') MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person')
self.assertIsNone(meeting.get_attendance()) self.assertIsNone(meeting.get_attendance())
def test_get_attendance(self): def test_get_attendance_110(self):
"""Post-110 meetings do calculate attendance""" """Look at attendance as captured at 110"""
meeting = MeetingFactory(type_id='ietf', number='110') meeting = MeetingFactory(type_id='ietf', number='110')
# start with attendees that should be ignored # 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) MeetingRegistrationFactory(meeting=meeting, reg_type='', attended=False)
attendance = meeting.get_attendance() attendance = meeting.get_attendance()
self.assertIsNotNone(attendance) self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 0) self.assertEqual(attendance.remote, 0)
self.assertEqual(attendance.onsite, 0) self.assertEqual(attendance.onsite, 0)
# add online attendees with at least one who registered but did not attend # 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) MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False)
attendance = meeting.get_attendance() attendance = meeting.get_attendance()
self.assertIsNotNone(attendance) self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 4) self.assertEqual(attendance.remote, 4)
self.assertEqual(attendance.onsite, 0) self.assertEqual(attendance.onsite, 0)
# and the same for onsite attendees # 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) MeetingRegistrationFactory(meeting=meeting, reg_type='in_person', attended=False)
attendance = meeting.get_attendance() attendance = meeting.get_attendance()
self.assertIsNotNone(attendance) self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 4) self.assertEqual(attendance.remote, 4)
self.assertEqual(attendance.onsite, 5) self.assertEqual(attendance.onsite, 5)
# and once more after removing all the online attendees # and once more after removing all the online attendees
meeting.meetingregistration_set.filter(reg_type='remote').delete() meeting.meetingregistration_set.filter(reg_type='remote').delete()
attendance = meeting.get_attendance() attendance = meeting.get_attendance()
self.assertIsNotNone(attendance) self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 0) self.assertEqual(attendance.remote, 0)
self.assertEqual(attendance.onsite, 5) 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): def test_vtimezone(self):
# normal time zone that should have a zoneinfo file # normal time zone that should have a zoneinfo file
meeting = MeetingFactory(type_id='ietf', time_zone='America/Los_Angeles', populate_schedule=False) 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 lxml.etree import tostring
from io import StringIO, BytesIO from io import StringIO, BytesIO
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from urllib.parse import urlparse, urlsplit, quote from urllib.parse import urlparse, urlsplit
from PIL import Image from PIL import Image
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile 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.decorators import skip_coverage
from ietf.utils.mail import outbox, empty_outbox, get_payload_text 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.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.utils.timezone import date_today, time_now
from ietf.person.factories import PersonFactory 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")) 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. # 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) self.assertEqual(r.status_code, 200)
# Agenda API tests # 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 # plain
time_interval = r"{}<span.*/span>-{}".format( time_interval = r"{}<span.*/span>-{}".format(
slot.time.astimezone(meeting.tz()).strftime("%H:%M").lstrip("0"), slot.time.astimezone(meeting.tz()).strftime("%H:%M").lstrip("0"),
slot.end_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 # text
# the rest of the results don't have as nicely formatted times # 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")) 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.acronym)
self.assertContains(r, session.group.name) self.assertContains(r, session.group.name)
self.assertContains(r, session.group.parent.acronym.upper()) self.assertContains(r, session.group.parent.acronym.upper())
@ -272,16 +232,13 @@ class MeetingTests(BaseMeetingTestCase):
self.assertContains(r, time_interval) 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 # 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.assertContains(r, "There is no agenda available yet.")
self.assertTemplateUsed(r, 'meeting/no-agenda.txt') self.assertTemplateUsed(r, 'meeting/no-agenda.txt')
# CSV # 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.acronym)
self.assertContains(r, session.group.name) self.assertContains(r, session.group.name)
self.assertContains(r, session.group.parent.acronym.upper()) self.assertContains(r, session.group.parent.acronym.upper())
@ -309,30 +266,11 @@ class MeetingTests(BaseMeetingTestCase):
'ietf.meeting.views.session_details', 'ietf.meeting.views.session_details',
kwargs=dict(num=meeting.number, acronym=session.group.acronym)), kwargs=dict(num=meeting.number, acronym=session.group.acronym)),
msg_prefix='ical should contain link to meeting materials page for session') 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 # Floor Plan
r = self.client.get(urlreverse("ietf.meeting.views.week_view", kwargs=dict(num=meeting.number))) r = self.client.get(urlreverse('floor-plan', kwargs=dict(num=meeting.number)))
self.assertNotContains(r, 'CANCELLED') self.assertEqual(r.status_code, 200)
self.assertContains(r, session.group.acronym)
self.assertContains(r, slot.location.name)
self.assertContains(r, registration_text)
# 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}') @override_settings(PROCEEDINGS_V1_BASE_URL='https://example.com/{meeting.number}')
def test_agenda_redirects_for_old_meetings(self): def test_agenda_redirects_for_old_meetings(self):
@ -341,7 +279,7 @@ class MeetingTests(BaseMeetingTestCase):
MeetingFactory(type_id='ietf', number='35', populate_schedule=False) MeetingFactory(type_id='ietf', number='35', populate_schedule=False)
r = self.client.get( r = self.client.get(
urlreverse( urlreverse(
'ietf.meeting.views.agenda', 'agenda',
kwargs={'num': '35', 'ext': '.html'}, kwargs={'num': '35', 'ext': '.html'},
)) ))
self.assertRedirects(r, 'https://example.com/35', fetch_redirect_response=False) 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) meeting_with_schedule = MeetingFactory(type_id='ietf', number='36', populate_schedule=True)
r = self.client.get( r = self.client.get(
urlreverse( urlreverse(
'ietf.meeting.views.agenda', 'agenda',
kwargs={'num': '36', 'ext': '.html'}, kwargs={'num': '36', 'ext': '.html'},
)) ))
self.assertRedirects(r, 'https://example.com/36', fetch_redirect_response=False) self.assertRedirects(r, 'https://example.com/36', fetch_redirect_response=False)
@ -359,7 +297,7 @@ class MeetingTests(BaseMeetingTestCase):
SessionFactory(meeting=meeting_with_schedule) SessionFactory(meeting=meeting_with_schedule)
r = self.client.get( r = self.client.get(
urlreverse( urlreverse(
'ietf.meeting.views.agenda', 'agenda',
kwargs={'num': '36', 'ext': '.html'}, kwargs={'num': '36', 'ext': '.html'},
)) ))
self.assertRedirects(r, 'https://example.com/36', fetch_redirect_response=False) 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 # Meetings pre-64 are redirected, but should be a 404 if there is no Meeting instance
r = self.client.get( r = self.client.get(
urlreverse( urlreverse(
'ietf.meeting.views.agenda', 'agenda',
kwargs={'num': '32', 'ext': '.html'}, kwargs={'num': '32', 'ext': '.html'},
)) ))
self.assertEqual(r.status_code, 404) 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) @override_settings(MEETING_MATERIALS_SERVE_LOCALLY=False, MEETING_DOC_HREFS = settings.MEETING_DOC_CDN_HREFS)
def test_materials_through_cdn(self): 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.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')) 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 test_parse_agenda_filter_params(self):
def _r(show=(), hide=(), showtypes=(), hidetypes=()): def _r(show=(), hide=(), showtypes=(), hidetypes=()):
"""Helper to create expected result dict""" """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.assertTrue(all([x in unicontent(r) for x in ('slides','agenda','minutes','draft')]))
self.assertNotContains(r, 'deleted') 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): def test_session_details_has_import_minutes_buttons(self):
group = GroupFactory.create( group = GroupFactory.create(
type_id='wg', type_id='wg',
@ -5783,25 +5488,6 @@ class AjaxTests(TestCase):
self.assertNotIn('error', data) self.assertNotIn('error', data)
self.assertEqual(data['utc'], '20:00') 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): class IphoneAppJsonTests(TestCase):
def test_iphone_app_json_interim(self): def test_iphone_app_json_interim(self):
make_interim_test_data() make_interim_test_data()
@ -6557,7 +6243,7 @@ class SessionTests(TestCase):
group_type_without_meetings = 'editorial' group_type_without_meetings = 'editorial'
self.assertFalse(GroupFeatures.objects.get(pk=group_type_without_meetings).has_meetings) 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) 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) 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) 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', status_id='schedw',
add_to_schedule=False, 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): def _sreq_edit_link(sess):
return urlreverse( return urlreverse(
'ietf.secr.sreq.views.edit', '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.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, not_has_meetings.group.acronym)
self.assertNotContains(r, _sreq_edit_link(not_has_meetings)) # no link to the session request 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): def test_request_minutes(self):
meeting = MeetingFactory(type_id='ietf') 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.meeting import views, views_proceedings
from ietf.utils.urls import url 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 = [ safe_for_all_meeting_types = [
url(r'^session/(?P<acronym>[-a-z0-9]+)/?$', views.session_details), url(r'^session/(?P<acronym>[-a-z0-9]+)/?$', views.session_details),
url(r'^session/(?P<session_id>\d+)/drafts$', views.add_session_drafts), 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/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/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(\.(?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/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, views.agenda_by_room), 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, views.agenda_by_type), 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, views.agenda_by_type), 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/%(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/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/list$', views.list_schedules),
url(r'^agendas/edit$', RedirectView.as_view(pattern_name='ietf.meeting.views.list_schedules', permanent=True)), url(r'^agendas/edit$', RedirectView.as_view(pattern_name='ietf.meeting.views.list_schedules', permanent=True)),
url(r'^agendas/diff/$', views.diff_schedules), url(r'^agendas/diff/$', views.diff_schedules),
@ -64,10 +68,9 @@ type_interim_patterns = [
] ]
type_ietf_only_patterns_id_optional = [ type_ietf_only_patterns_id_optional = [
url(r'^agenda(?P<utc>-utc)?(?P<ext>\.html)?/?$', views.agenda), url(r'^agenda(?P<utc>-utc)?(?P<ext>\.html)?/?$', views.agenda, name='agenda'),
url(r'^agenda(?P<ext>\.txt)$', views.agenda), url(r'^agenda(?P<ext>\.txt)$', views.agenda_plain),
url(r'^agenda(?P<ext>\.csv)$', views.agenda), url(r'^agenda(?P<ext>\.csv)$', views.agenda_plain),
url(r'^agenda-neue(?P<utc>-utc)?(?P<ext>\.html)?/?$', views.agenda_neue, name='agenda-neue'),
url(r'^agenda/edit$', url(r'^agenda/edit$',
RedirectView.as_view(pattern_name='ietf.meeting.views.edit_meeting_schedule', permanent=True), RedirectView.as_view(pattern_name='ietf.meeting.views.edit_meeting_schedule', permanent=True),
name='ietf.meeting.views.edit_meeting_schedule'), 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/agenda\.ics$', views.agenda_ical),
url(r'^agenda\.ics$', views.agenda_ical), url(r'^agenda\.ics$', views.agenda_ical),
url(r'^agenda.json$', views.agenda_json), url(r'^agenda.json$', views.agenda_json),
url(r'^agenda/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.floor_plan), url(r'^floor-plan/?$', views.agenda, name='floor-plan'),
url(r'^floor-plan-neue/?$', views.agenda_neue, name='floor-plan-neue'), url(r'^floor-plan/(?P<floor>[-a-z0-9_]+)/?$', RedirectView.as_view(pattern_name='floor-plan', permanent=True)),
url(r'^floor-plan/(?P<floor>[-a-z0-9_]+)/?$', views.floor_plan), url(r'^week-view(?:.html)?/?$', RedirectView.as_view(pattern_name='agenda', permanent=True)),
url(r'^week-view(?:.html)?/?$', views.week_view),
url(r'^materials(?:.html)?/?$', views.materials), url(r'^materials(?:.html)?/?$', views.materials),
url(r'^request_minutes/?$', views.request_minutes), url(r'^request_minutes/?$', views.request_minutes),
url(r'^materials/%(document)s((?P<ext>\.[a-z0-9]+)|/)?$' % settings.URL_REGEXPS, views.materials_document), 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