diff --git a/client/src/actions/presentation.ts b/client/src/actions/presentation.ts index 0c3f2466556ab573341d190c14f83a198c41fcdb..0faa64a2bea151a86edb563eb4bf52902d49e902 100644 --- a/client/src/actions/presentation.ts +++ b/client/src/actions/presentation.ts @@ -3,8 +3,8 @@ This file handles actions for the presentation redux state */ import axios from 'axios' -import { Timer } from '../interfaces/Timer' -import store, { AppDispatch, RootState } from './../store' +import { TimerState } from '../interfaces/Timer' +import { AppDispatch, RootState } from './../store' import Types from './types' /** Save competition in presentation state from input id */ @@ -35,18 +35,8 @@ export const setCurrentSlideByOrder = (order: number) => (dispatch: AppDispatch, export const setPresentationCode = (code: string) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_PRESENTATION_CODE, payload: code }) } + /** Set timer to input value */ -export const setPresentationTimer = (timer: Timer) => (dispatch: AppDispatch) => { +export const setPresentationTimer = (timer: TimerState) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_PRESENTATION_TIMER, payload: timer }) } - -/** Decrement timer */ -export const setPresentationTimerDecrement = () => (dispatch: AppDispatch) => { - dispatch({ - type: Types.SET_PRESENTATION_TIMER, - payload: { - enabled: store.getState().presentation.timer.enabled, - value: store.getState().presentation.timer.value - 1, - }, - }) -} diff --git a/client/src/interfaces/Timer.ts b/client/src/interfaces/Timer.ts index 49d1909e15692e68bb8cdef32ddd9f59d6b69409..03704c834d1b51075916d4bd4be3dcc99f9b0e53 100644 --- a/client/src/interfaces/Timer.ts +++ b/client/src/interfaces/Timer.ts @@ -1,4 +1,4 @@ -export interface Timer { +export interface TimerState { + value: number | null enabled: boolean - value: number } diff --git a/client/src/pages/admin/AdminPage.tsx b/client/src/pages/admin/AdminPage.tsx index 1609384136c90a68e228f84ef81fe00193ab09ac..f77051b12decd0a0760d490d15ea12b1f135cdda 100644 --- a/client/src/pages/admin/AdminPage.tsx +++ b/client/src/pages/admin/AdminPage.tsx @@ -88,7 +88,6 @@ const AdminView: React.FC = () => { let activeIndex if (isAdmin) activeIndex = menuAdminItems.findIndex((menuItem) => location.pathname.endsWith(menuItem.route)) else activeIndex = menuEditorItems.findIndex((menuItem) => location.pathname.endsWith(menuItem.route)) - console.log(activeIndex, isAdmin, location.pathname, menuEditorItems) if (activeIndex !== -1) setOpenIndex(activeIndex) } diff --git a/client/src/pages/views/AudienceViewPage.tsx b/client/src/pages/views/AudienceViewPage.tsx index af1f18b3df30473ee466cd4b0acdf708123d5d89..e78db8b0cc890b6bc0abd55c076d5375451b4f48 100644 --- a/client/src/pages/views/AudienceViewPage.tsx +++ b/client/src/pages/views/AudienceViewPage.tsx @@ -2,7 +2,7 @@ import { Snackbar, Typography } from '@material-ui/core' import { Alert } from '@material-ui/lab' import React, { useEffect, useState } from 'react' import { useAppSelector } from '../../hooks' -import { socketConnect, socketJoinPresentation } from '../../sockets' +import { socketConnect } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { PresentationBackground, PresentationContainer } from './styled' @@ -15,7 +15,6 @@ const AudienceViewPage: React.FC = () => { useEffect(() => { if (code && code !== '') { socketConnect('Audience') - socketJoinPresentation() } }, []) if (activeViewTypeId) { diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index c25b09dd29c6fe7d9930fab352d1006b0ccee7c0..675da803377ce23134485d0c2bf0bbc9c57c87a4 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useState } from 'react' import { getPresentationCompetition } from '../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../hooks' import { RichSlide } from '../../interfaces/ApiRichModels' -import { socketConnect, socketJoinPresentation } from '../../sockets' +import { socketConnect } from '../../sockets' import { renderSlideIcon } from '../../utils/renderSlideIcon' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { SlideListItem } from '../presentationEditor/styled' @@ -64,7 +64,6 @@ const JudgeViewPage: React.FC = () => { useEffect(() => { if (code && code !== '') { socketConnect('Judge') - socketJoinPresentation() } }, []) useEffect(() => { @@ -75,12 +74,14 @@ const JudgeViewPage: React.FC = () => { dispatch(getPresentationCompetition(competitionId.toString())) } }, [operatorActiveSlideId]) - useEffect(() => { - // Every second tic of the timer, load new answers - if (timer.value % 2 === 0 && competitionId) { - dispatch(getPresentationCompetition(competitionId.toString())) - } - }, [timer.value]) + // useEffect(() => { + // // Every second tic of the timer, load new answers + // // TODO: use a set interval that updates every second ( look in Timer.tsx in clien/src/pages/views/components ) + // // Then clear interval when timer - Date.now() is negative + // if (timer !== null && timer - (Date.now() % 2) === 0 && competitionId) { + // dispatch(getPresentationCompetition(competitionId.toString())) + // } + // }, [timer]) return ( <div style={{ height: '100%' }}> <JudgeAppBar position="fixed"> diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index a23809e467ef8ece808da4cdedf25a6489a3aa1a..6193b4cec6787acd41ac97ea5d3aaa8f00d8fea6 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -31,15 +31,7 @@ import React, { useEffect, useState } from 'react' import { useHistory } from 'react-router-dom' import { useAppSelector } from '../../hooks' import { RichTeam } from '../../interfaces/ApiRichModels' -import { - socketConnect, - socketEndPresentation, - socketSetSlide, - socketSetSlideNext, - socketSetSlidePrev, - socketStartPresentation, - socketStartTimer, -} from '../../sockets' +import { socketConnect, socketEndPresentation, socketSync } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { Center } from '../presentationEditor/components/styled' import Timer from './components/Timer' @@ -62,11 +54,6 @@ import { * * =========================================== * TODO: - * - Instead of copying code for others to join the competition, copy URL. - * - * - * - Fix scoreboard - * * - When two userers are connected to the same Localhost:5000 and updates/starts/end competition it * creates a bug where the competition can't be started. * =========================================== @@ -108,9 +95,9 @@ const OperatorViewPage: React.FC = () => { const classes = useStyles() const teams = useAppSelector((state) => state.presentation.competition.teams) const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) - const competitionId = useAppSelector((state) => state.competitionLogin.data?.competition_id) const presentation = useAppSelector((state) => state.presentation) const activeId = useAppSelector((state) => state.presentation.competition.id) + const timer = useAppSelector((state) => state.presentation.timer) const history = useHistory() const viewTypes = useAppSelector((state) => state.types.viewTypes) const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id @@ -119,10 +106,13 @@ const OperatorViewPage: React.FC = () => { (state) => state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.order ) + const slideTimer = useAppSelector((state) => + activeSlideOrder !== undefined ? state.presentation.competition.slides[activeSlideOrder].timer : null + ) + const isFirstSlide = activeSlideOrder === 0 + const isLastSlide = useAppSelector((state) => activeSlideOrder === state.presentation.competition.slides.length - 1) useEffect(() => { socketConnect('Operator') - socketSetSlide - setTimeout(startCompetition, 1000) // Wait for socket to connect }, []) /** Handles the browsers back button and if pressed cancels the ongoing competition */ @@ -141,12 +131,6 @@ const OperatorViewPage: React.FC = () => { setAnchorEl(null) } - const startCompetition = () => { - socketStartPresentation() // Calls the socket to start competition - console.log('started competition for') - console.log(competitionId) - } - /** Making sure the user wants to exit the competition by displaying a dialog box */ const handleVerifyExit = () => { setOpen(true) @@ -208,6 +192,23 @@ const OperatorViewPage: React.FC = () => { return totalScore } + const handleStartTimer = () => { + if (!slideTimer) return + + if (!timer.enabled) socketSync({ timer: { value: Date.now() + 1000 * slideTimer, enabled: true } }) + else socketSync({ timer: { ...timer, enabled: false } }) + } + + const handleSetNextSlide = () => { + if (activeSlideOrder !== undefined) + socketSync({ slide_order: activeSlideOrder + 1, timer: { value: null, enabled: false } }) + } + + const handleSetPrevSlide = () => { + if (activeSlideOrder !== undefined) + socketSync({ slide_order: activeSlideOrder - 1, timer: { value: null, enabled: false } }) + } + return ( <OperatorContainer> <Dialog open={openAlertCode} onClose={handleClose} aria-labelledby="max-width-dialog-title" maxWidth="xl"> @@ -301,17 +302,23 @@ const OperatorViewPage: React.FC = () => { <OperatorFooter> <ToolBarContainer> <Tooltip title="Föregående" arrow> - <OperatorButton onClick={socketSetSlidePrev} variant="contained"> + <OperatorButton onClick={handleSetPrevSlide} variant="contained" disabled={isFirstSlide}> <ChevronLeftIcon fontSize="large" /> </OperatorButton> </Tooltip> - <Tooltip title="Starta Timer" arrow> - <OperatorButton onClick={socketStartTimer} variant="contained"> - <TimerIcon fontSize="large" /> - <Timer disableText /> - </OperatorButton> - </Tooltip> + {slideTimer && ( + <Tooltip title="Starta Timer" arrow> + <OperatorButton + onClick={handleStartTimer} + variant="contained" + disabled={timer.value !== null && !timer.enabled} + > + <TimerIcon fontSize="large" /> + <Timer disableText /> + </OperatorButton> + </Tooltip> + )} <Tooltip title="Ställning" arrow> <OperatorButton onClick={handleOpenPopover} variant="contained"> @@ -326,7 +333,7 @@ const OperatorViewPage: React.FC = () => { </Tooltip> <Tooltip title="Nästa" arrow> - <OperatorButton onClick={socketSetSlideNext} variant="contained"> + <OperatorButton onClick={handleSetNextSlide} variant="contained" disabled={isLastSlide}> <ChevronRightIcon fontSize="large" /> </OperatorButton> </Tooltip> diff --git a/client/src/pages/views/TeamViewPage.tsx b/client/src/pages/views/TeamViewPage.tsx index 2ebc09269095480fe231fc3e05f5900eaf57e62a..eb9cb5e6196361bc127bb932412e1a98df357b29 100644 --- a/client/src/pages/views/TeamViewPage.tsx +++ b/client/src/pages/views/TeamViewPage.tsx @@ -2,7 +2,7 @@ import { Snackbar } from '@material-ui/core' import { Alert } from '@material-ui/lab' import React, { useEffect, useState } from 'react' import { useAppSelector } from '../../hooks' -import { socketConnect, socketJoinPresentation } from '../../sockets' +import { socketConnect } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { OperatorContainer, OperatorHeader, PresentationBackground, PresentationContainer } from './styled' @@ -24,7 +24,6 @@ const TeamViewPage: React.FC = () => { useEffect(() => { if (code && code !== '') { socketConnect('Team') - socketJoinPresentation() } }, []) return ( diff --git a/client/src/pages/views/components/Timer.tsx b/client/src/pages/views/components/Timer.tsx index f96806e27bdad78856a0b0ac4e09dd20b5923098..620fcd3471f4b6ef04d1930f5dac71e824c52ecf 100644 --- a/client/src/pages/views/components/Timer.tsx +++ b/client/src/pages/views/components/Timer.tsx @@ -1,22 +1,6 @@ -import React, { useEffect } from 'react' -import { setPresentationTimer, setPresentationTimerDecrement } from '../../../actions/presentation' +import React, { useEffect, useState } from 'react' +import { setPresentationTimer } from '../../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../../hooks' -import store from '../../../store' - -/* const mapStateToProps = (state: any) => { - return { - timer: state.presentation.timer, - timer_start_value: state.presentation.slide.timer, - } -} - -const mapDispatchToProps = (dispatch: any) => { - return { - // tickTimer: () => dispatch(tickTimer(1)), - } -} */ - -let timerIntervalId: NodeJS.Timeout type TimerProps = { disableText?: boolean @@ -24,27 +8,62 @@ type TimerProps = { const Timer = ({ disableText }: TimerProps) => { const dispatch = useAppDispatch() - const slide = store - .getState() - .presentation.competition.slides.find((slide) => slide.id === store.getState().presentation.activeSlideId) - const timerStartValue = slide?.timer const timer = useAppSelector((state) => state.presentation.timer) + const [remainingTimer, setRemainingTimer] = useState<number>(0) + const [timerIntervalId, setTimerIntervalId] = useState<NodeJS.Timeout | null>(null) + const slideTimer = useAppSelector( + (state) => + state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.timer + ) + useEffect(() => { - if (!slide || !slide.timer) return - dispatch(setPresentationTimer({ enabled: false, value: slide.timer })) - }, [timerStartValue]) + if (slideTimer) setRemainingTimer(slideTimer) + }, [slideTimer]) useEffect(() => { - if (timer.enabled) { - timerIntervalId = setInterval(() => { - dispatch(setPresentationTimerDecrement()) - }, 1000) - } else { - clearInterval(timerIntervalId) + console.log(timer) + if (!timer.enabled) { + console.log('interval id: ', timerIntervalId) + console.log('slide timer: ', slideTimer) + + if (timerIntervalId !== null) clearInterval(timerIntervalId) + + console.log('timer enabled false') + console.log('timer: ', timer) + + if (timer.value !== null) { + console.log('timer value not null') + + setRemainingTimer(0) + } else if (slideTimer) { + console.log('timer value null and slideTimer has value') + + setRemainingTimer(slideTimer * 1000) + } + + return } - }, [timer.enabled]) - return <div>{`${!disableText ? 'Tid kvar:' : ''} ${timer.value}`}</div> + setTimerIntervalId( + setInterval(() => { + console.log('interval tick') + if (timer.value === null) return + if (timer.enabled === false && timerIntervalId !== null) clearInterval(timerIntervalId) + + if (timer.value - Date.now() < 0) { + console.log('timer reached zero') + setRemainingTimer(0) + dispatch(setPresentationTimer({ ...timer, enabled: false })) + if (timerIntervalId !== null) clearInterval(timerIntervalId) + return + } + + setRemainingTimer(timer.value - Date.now()) + }, 500) + ) + }, [timer.enabled, slideTimer]) + + return <div>{`${!disableText ? 'Tid kvar:' : ''} ${Math.round(remainingTimer / 1000)}`}</div> } export default Timer diff --git a/client/src/reducers/presentationReducer.test.ts b/client/src/reducers/presentationReducer.test.ts index e0368f7fc342bcc2b241cbf6eca3d368d34d649f..10362e687c6ea431dfb221a2c995324d384c92f3 100644 --- a/client/src/reducers/presentationReducer.test.ts +++ b/client/src/reducers/presentationReducer.test.ts @@ -14,8 +14,8 @@ const initialState = { activeSlideId: -1, code: '', timer: { + value: null, enabled: false, - value: 0, }, } diff --git a/client/src/reducers/presentationReducer.ts b/client/src/reducers/presentationReducer.ts index cc183fadade89bd7eb504e8b501be8deb3aa3b04..69c7817fac1b920d5d98e34a0b66be4f52615618 100644 --- a/client/src/reducers/presentationReducer.ts +++ b/client/src/reducers/presentationReducer.ts @@ -1,6 +1,6 @@ import { AnyAction } from 'redux' import Types from '../actions/types' -import { Timer } from '../interfaces/Timer' +import { TimerState } from '../interfaces/Timer' import { RichCompetition } from './../interfaces/ApiRichModels' /** Define a type for the presentation state */ @@ -8,7 +8,7 @@ interface PresentationState { competition: RichCompetition activeSlideId: number code: string - timer: Timer + timer: TimerState } /** Define the initial values for the presentation state */ @@ -25,8 +25,8 @@ const initialState: PresentationState = { activeSlideId: -1, code: '', timer: { + value: null, enabled: false, - value: 0, }, } @@ -41,7 +41,7 @@ export default function (state = initialState, action: AnyAction) { case Types.SET_PRESENTATION_CODE: return { ...state, - code: action.payload, + code: action.payload as string, } case Types.SET_PRESENTATION_SLIDE_ID: return { @@ -49,13 +49,9 @@ export default function (state = initialState, action: AnyAction) { activeSlideId: action.payload as number, } case Types.SET_PRESENTATION_TIMER: - const timer = action.payload as Timer - if (action.payload.value == 0) { - timer.enabled = false - } return { ...state, - timer, + timer: action.payload as TimerState, } default: return state diff --git a/client/src/sockets.ts b/client/src/sockets.ts index dabaf4b9c26cef497c179aad7df293cac9647bc7..35c9abc6a0f00b84824bf5367d3dba005da3f144 100644 --- a/client/src/sockets.ts +++ b/client/src/sockets.ts @@ -1,114 +1,47 @@ -/** - * This is a comment on the module level, i.e. the entire file. - * - * For this to appear in the documentation this is needed at the bottom. - * @module - */ - import io from 'socket.io-client' import { setCurrentSlideByOrder, setPresentationTimer } from './actions/presentation' -import { Timer } from './interfaces/Timer' +import { TimerState } from './interfaces/Timer' import store from './store' -interface SetSlideInterface { - slide_order: number -} - -interface TimerInterface { - value: number - enabled: boolean -} - -interface SetTimerInterface { - timer: TimerInterface +interface SyncInterface { + slide_order?: number + timer?: TimerState } let socket: SocketIOClient.Socket -/** - * You can also comment functions, like usual. This will automatically appear - * in the documentation, no more needed. - */ export const socketConnect = (role: 'Judge' | 'Operator' | 'Team' | 'Audience') => { - if (!socket) { - const token = localStorage[role] - socket = io('localhost:5000', { - transportOptions: { - polling: { - extraHeaders: { - Authorization: token, - }, + if (socket) return + + const token = localStorage[`${role}Token`] + socket = io('localhost:5000', { + transportOptions: { + polling: { + extraHeaders: { + Authorization: token, }, }, - }) - - socket.on('set_slide', (data: SetSlideInterface) => { - setCurrentSlideByOrder(data.slide_order)(store.dispatch, store.getState) - }) - - socket.on('set_timer', (data: SetTimerInterface) => { - setPresentationTimer(data.timer)(store.dispatch) - }) - - socket.on('end_presentation', () => { - socket.disconnect() - }) - } -} + }, + }) -export const socketStartPresentation = () => { - socket.emit('start_presentation', { competition_id: store.getState().presentation.competition.id }) -} + socket.on('sync', (data: SyncInterface) => { + // The order of these is important, for some reason + if (data.timer !== undefined) setPresentationTimer(data.timer)(store.dispatch) + if (data.slide_order !== undefined) setCurrentSlideByOrder(data.slide_order)(store.dispatch, store.getState) + }) -export const socketJoinPresentation = () => { - socket.emit('join_presentation', { code: store.getState().presentation.code }) // TODO: Send code gotten from auth/login/<code> api call + socket.on('end_presentation', () => { + socket.disconnect() + }) } export const socketEndPresentation = () => { - socket.emit('end_presentation', { competition_id: store.getState().presentation.competition.id }) -} - -export const socketSetSlideNext = () => { - const activeSlide = store - .getState() - .presentation.competition.slides.find((slide) => slide.id === store.getState().presentation.activeSlideId) - if (!activeSlide) return - socketSetSlide(activeSlide.order + 1) // TODO: Check that this slide exists + socket.emit('end_presentation') } -export const socketSetSlidePrev = () => { - const activeSlide = store - .getState() - .presentation.competition.slides.find((slide) => slide.id === store.getState().presentation.activeSlideId) - if (!activeSlide) return - socketSetSlide(activeSlide.order - 1) // TODO: Check that this slide exists -} - -/** - * You can also comment a function like, adding more information to either - * the paramters or the return value. - * - * @param slide_order This is a parameter to the function. - * @returns This function returns nothing. - */ -export const socketSetSlide = (slide_order: number) => { - if (slide_order < 0 || store.getState().presentation.competition.slides.length <= slide_order) { - return - } - - socket.emit('set_slide', { - competition_id: store.getState().presentation.competition.id, - slide_order: slide_order, +export const socketSync = ({ slide_order, timer }: SyncInterface) => { + socket.emit('sync', { + slide_order, + timer, }) } - -export const socketSetTimer = (timer: Timer) => { - socket.emit('set_timer', { - competition_id: store.getState().presentation.competition.id, - timer: timer, - }) -} - -export const socketStartTimer = () => { - socketSetTimer({ enabled: true, value: store.getState().presentation.timer.value }) -} diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index 26301d646c98f06fa641223e2e206e6eb544ab1f..e29fd4ee12e9426b6278856cd262faa2df16c2d4 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -8,9 +8,9 @@ from datetime import datetime, timedelta import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, protect_route, text_response -from app.core import sockets from app.core.codes import verify_code from app.core.dto import AuthDTO +from app.core.sockets import is_active_competition from app.database.models import User, Whitelist from flask import current_app, has_app_context from flask_jwt_extended import create_access_token, get_jti, get_raw_jwt @@ -164,7 +164,7 @@ class AuthLoginCode(Resource): item_code = dbc.get.code_by_code(code) if item_code.view_type_id != 4: - if item_code.competition_id not in sockets.presentations: + if not is_active_competition(item_code.competition_id): api.abort(codes.UNAUTHORIZED, "Competition not active") # Create jwt that is only valid for 8 hours diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index a1675b2f0211aa52ed6327a67b6c54a9ad2d70f4..be9873c7280684aa86b247ba3b7a2d376d472902 100644 --- a/server/app/core/sockets.py +++ b/server/app/core/sockets.py @@ -1,14 +1,10 @@ """ -Contains all functionality related sockets. That is starting and ending a presentation, -joining and leaving a presentation and syncing slides and timer bewteen all clients -connected to the same presentation. +Contains all functionality related sockets. That is starting, joining, ending, +disconnecting from and syncing active competitions. """ import logging -from functools import wraps -from typing import Dict -from app.core import db -from app.database.models import Code, Slide, ViewType +from decorator import decorator from flask.globals import request from flask_jwt_extended import verify_jwt_in_request from flask_jwt_extended.utils import get_jwt_claims @@ -18,258 +14,148 @@ logger = logging.getLogger(__name__) logger.propagate = False logger.setLevel(logging.INFO) -formatter = logging.Formatter("[%(levelname)s] %(funcName)s: %(message)s") +formatter = logging.Formatter("[%(levelname)s] %(message)s") stream_handler = logging.StreamHandler() stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) sio = SocketIO(cors_allowed_origins="http://localhost:3000") -presentations = {} +active_competitions = {} -def _is_allowed(allowed, actual): - return actual and "*" in allowed or actual in allowed - - -def protect_route(allowed_views=None): - def wrapper(f): - @wraps(f) - def inner(*args, **kwargs): - try: - verify_jwt_in_request() - except: - logger.warning("Missing Authorization Header") - return - - nonlocal allowed_views - allowed_views = allowed_views or [] - claims = get_jwt_claims() - view = claims.get("view") - if not _is_allowed(allowed_views, view): - logger.warning(f"View '{view}' is not allowed to access route only accessible by '{allowed_views}'") - return - - return f(*args, **kwargs) - - return inner - - return wrapper - - -@sio.on("connect") -def connect() -> None: - logger.info(f"Client '{request.sid}' connected") - - -@sio.on("disconnect") -def disconnect() -> None: +def _unpack_claims(): """ - Remove client from the presentation it was in. Delete presentation if no - clients are connected to it. + :return: A tuple containing competition_id and view, gotten from claim + :rtype: tuple """ - for competition_id, presentation in presentations.items(): - if request.sid in presentation["clients"]: - del presentation["clients"][request.sid] - logger.debug(f"Client '{request.sid}' left presentation '{competition_id}'") - break - if presentations and not presentations[competition_id]["clients"]: - del presentations[competition_id] - logger.info(f"No people left in presentation '{competition_id}', ended presentation") + claims = get_jwt_claims() + return claims["competition_id"], claims["view"] - logger.info(f"Client '{request.sid}' disconnected") - -@protect_route(allowed_views=["Operator"]) -@sio.on("start_presentation") -def start_presentation(data: Dict) -> None: +def is_active_competition(competition_id): """ - Starts a presentation if that competition is currently not active. + :return: True if competition with competition_id is currently active else False + :rtype: bool """ + return competition_id in active_competitions - competition_id = data["competition_id"] - - if competition_id in presentations: - logger.error( - f"Client '{request.sid}' failed to start competition '{competition_id}', presentation already active" - ) - return - - presentations[competition_id] = { - "clients": {request.sid: {"view_type": "Operator"}}, - "slide": 0, - "timer": {"enabled": False, "start_value": None, "value": None}, - } - - join_room(competition_id) - logger.debug(f"Client '{request.sid}' joined room {competition_id}") - logger.info(f"Client '{request.sid}' started competition '{competition_id}'") +def _get_sync_variables(active_competition, sync_values): + return {key: value for key, value in active_competition.items() if key in sync_values} -@protect_route(allowed_views=["Operator"]) -@sio.on("end_presentation") -def end_presentation(data: Dict) -> None: +@decorator +def authorize_client(f, allowed_views=None, require_active_competition=True, *args, **kwargs): """ - End a presentation by sending end_presentation to all connected clients. - - The only clients allowed to do this is the one that started the presentation. - - Log error message if no presentation exists with the send id or if this - client is not in that presentation. + Decorator used to authorize a client that sends socket events. Check that + the client has authorization headers, that client view gotten from claims + is in allowed_views and that the competition the clients is in is active + if require_active_competition is True. """ - competition_id = data["competition_id"] - - if competition_id not in presentations: - logger.error( - f"Client '{request.sid}' failed to end presentation '{competition_id}', no such presentation exists" - ) - return - - if request.sid not in presentations[competition_id]["clients"]: - logger.error( - f"Client '{request.sid}' failed to end presentation '{competition_id}', client not in presentation" - ) - return - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error(f"Client '{request.sid}' failed to end presentation '{competition_id}', client is not operator") + try: + verify_jwt_in_request() + except: + logger.error(f"Won't call function '{f.__name__}': Missing Authorization Header") return - del presentations[competition_id] - logger.debug(f"Deleted presentation {competition_id}") - - emit("end_presentation", room=competition_id, include_self=True) - logger.debug(f"Emitting event 'end_presentation' to room {competition_id} including self") - - logger.info(f"Client '{request.sid}' ended presentation '{competition_id}'") - - -@sio.on("join_presentation") -def join_presentation(data: Dict) -> None: - """ - Join a currently active presentation. - - Log error message if given code doesn't exist, if not presentation associated - with that code exists or if client is already in the presentation. - """ - code = data["code"] - item_code = db.session.query(Code).filter(Code.code == code).first() - - if not item_code: - logger.error(f"Client '{request.sid}' failed to join presentation with code '{code}', no such code exists") - return + def _is_allowed(allowed, actual): + return actual and "*" in allowed or actual in allowed - competition_id = item_code.competition_id + competition_id, view = _unpack_claims() - if competition_id not in presentations: - logger.error( - f"Client '{request.sid}' failed to join presentation '{competition_id}', no such presentation exists" - ) + if require_active_competition and not is_active_competition(competition_id): + logger.error(f"Won't call function '{f.__name__}': Competition '{competition_id}' is not active") return - if request.sid in presentations[competition_id]["clients"]: - logger.error( - f"Client '{request.sid}' failed to join presentation '{competition_id}', client already in presentation" - ) + allowed_views = allowed_views or [] + if not _is_allowed(allowed_views, view): + logger.error(f"Won't call function '{f.__name__}': View '{view}' is not {' or '.join(allowed_views)}") return - # TODO: Write function in database controller to do this - view_type_name = db.session.query(ViewType).filter(ViewType.id == item_code.view_type_id).one().name - - presentations[competition_id]["clients"][request.sid] = {"view_type": view_type_name} + return f(*args, **kwargs) - join_room(competition_id) - logger.debug(f"Client '{request.sid}' joined room {competition_id}") - logger.info(f"Client '{request.sid}' joined competition '{competition_id}'") - - emit("set_slide", {"slide_order": presentations[competition_id]["slide"]}) - - -@protect_route(allowed_views=["Operator"]) -@sio.on("set_slide") -def set_slide(data: Dict) -> None: +@sio.event +@authorize_client(require_active_competition=False, allowed_views=["*"]) +def connect() -> None: """ - Sync slides between all clients in the same presentation by sending - set_slide to them. - - Log error if the given competition_id is not active, if client is not in - that presentation or the client is not the one who started the presentation. + Connect to a active competition. If competition with competition_id is not active, + start it if client is an operator, otherwise ignore it. """ - competition_id = data["competition_id"] - slide_order = data["slide_order"] - - if competition_id not in presentations: + competition_id, view = _unpack_claims() + + if is_active_competition(competition_id): + active_competition = active_competitions[competition_id] + active_competition["client_count"] += 1 + join_room(competition_id) + emit("sync", _get_sync_variables(active_competition, ["slide_order", "timer"])) + logger.info(f"Client '{request.sid}' with view '{view}' joined competition '{competition_id}'") + elif view == "Operator": + join_room(competition_id) + active_competitions[competition_id] = { + "client_count": 1, + "slide_order": 0, + "timer": { + "value": None, + "enabled": False, + }, + } + logger.info(f"Client '{request.sid}' with view '{view}' started competition '{competition_id}'") + else: logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', no such presentation exists" + f"Client '{request.sid}' with view '{view}' tried to join non active competition '{competition_id}'" ) - return - if request.sid not in presentations[competition_id]["clients"]: - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client not in presentation" - ) - return - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client is not operator" - ) - return +@sio.event +@authorize_client(allowed_views=["*"]) +def disconnect() -> None: + """ + Remove client from the active_competition it was in. Delete active_competition if no + clients are connected to it. + """ - num_slides = db.session.query(Slide).filter(Slide.competition_id == competition_id).count() + competition_id, _ = _unpack_claims() + active_competitions[competition_id]["client_count"] -= 1 + logger.info(f"Client '{request.sid}' disconnected from competition '{competition_id}'") - if not (0 <= slide_order < num_slides): - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', slide number {slide_order} does not exist" - ) - return + if active_competitions[competition_id]["client_count"] <= 0: + del active_competitions[competition_id] + logger.info(f"No people left in active_competition '{competition_id}', ended active_competition") - presentations[competition_id]["slide"] = slide_order - emit("set_slide", {"slide_order": slide_order}, room=competition_id, include_self=True) - logger.debug(f"Emitting event 'set_slide' to room {competition_id} including self") +@sio.event +@authorize_client(allowed_views=["Operator"]) +def end_presentation() -> None: + """ + End a active_competition by sending end_presentation to all connected clients. + """ - logger.info(f"Client '{request.sid}' set slide '{slide_order}' in competition '{competition_id}'") + competition_id, _ = _unpack_claims() + emit("end_presentation", room=competition_id, include_self=True) -@protect_route(allowed_views=["Operator"]) -@sio.on("set_timer") -def set_timer(data: Dict) -> None: +@sio.event +@authorize_client(allowed_views=["Operator"]) +def sync(data) -> None: """ - Sync slides between all clients in the same presentation by sending - set_timer to them. - - Log error if the given competition_id is not active, if client is not in - that presentation or the client is not the one who started the presentation. + Sync active_competition for all clients connected to competition. """ - competition_id = data["competition_id"] - timer = data["timer"] - - if competition_id not in presentations: - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', no such presentation exists" - ) - return - - if request.sid not in presentations[competition_id]["clients"]: - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client not in presentation" - ) - return - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client is not operator" - ) - return + competition_id, view = _unpack_claims() + active_competition = active_competitions[competition_id] - # TODO: Save timer in presentation, maybe? + for key, value in data.items(): + if key not in active_competition: + logger.warning(f"Invalid sync data: '{key}':'{value}'") - emit("set_timer", {"timer": timer}, room=competition_id, include_self=True) - logger.debug(f"Emitting event 'set_timer' to room {competition_id} including self") + active_competition[key] = value - logger.info(f"Client '{request.sid}' set timer '{timer}' in presentation '{competition_id}'") + emit("sync", _get_sync_variables(active_competition, data), room=competition_id, include_self=True) + logger.info( + f"Client '{request.sid}' with view '{view}' synced values {_get_sync_variables(active_competition, data)} in competition '{competition_id}'" + ) diff --git a/server/requirements.txt b/server/requirements.txt index 6a9e48fc29b527a9a3609716953d4ba5a52fe4e2..472b12cdf8229664d6d4bc679e6a85cb941341d7 100644 Binary files a/server/requirements.txt and b/server/requirements.txt differ diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 880a41bd82a3b6cac8795a3cf61eaed6569dbadb..4b704bd7de51ebb624b8979d45ed4d17fe92032d 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -447,7 +447,7 @@ def test_authorization(client): add_default_values() # Fake that competition 1 is active - sockets.presentations[1] = {} + sockets.active_competitions[1] = {} #### TEAM #### # Login in with team code