diff --git a/client/src/Main.tsx b/client/src/Main.tsx index b999a9775f21bbc999dd1bbd00ca773bdb1ea02c..b1f0675d7129f755eb70ff9506c4c145a9b5a83c 100644 --- a/client/src/Main.tsx +++ b/client/src/Main.tsx @@ -7,8 +7,8 @@ import LoginPage from './pages/login/LoginPage' import PresentationEditorPage from './pages/presentationEditor/PresentationEditorPage' import AudienceViewPage from './pages/views/AudienceViewPage' import JudgeViewPage from './pages/views/JudgeViewPage' -import ParticipantViewPage from './pages/views/ParticipantViewPage' -import PresenterViewPage from './pages/views/PresenterViewPage' +import TeamViewPage from './pages/views/TeamViewPage' +import OperatorViewPage from './pages/views/OperatorViewPage' import ViewSelectPage from './pages/views/ViewSelectPage' import SecureRoute from './utils/SecureRoute' @@ -24,8 +24,8 @@ const Main: React.FC = () => { <SecureRoute path="/admin" component={AdminPage} /> <SecureRoute path="/editor/competition-id=:competitionId" component={PresentationEditorPage} /> <Route exact path="/:code" component={ViewSelectPage} /> - <Route exact path="/participant/id=:id&code=:code" component={ParticipantViewPage} /> - <SecureRoute exact path="/presenter/id=:id&code=:code" component={PresenterViewPage} /> + <Route exact path="/team/id=:id&code=:code" component={TeamViewPage} /> + <SecureRoute exact path="/operator/id=:id&code=:code" component={OperatorViewPage} /> <Route exact path="/judge/id=:id&code=:code" component={JudgeViewPage} /> <Route exact path="/audience/id=:id&code=:code" component={AudienceViewPage} /> </Switch> diff --git a/client/src/actions/editor.ts b/client/src/actions/editor.ts index db688dcb59f3732c6bda801b0aada1b3063a1238..526ad9ff6ae1698644e32ea02806c7a6e8e2cd78 100644 --- a/client/src/actions/editor.ts +++ b/client/src/actions/editor.ts @@ -18,16 +18,28 @@ export const getEditorCompetition = (id: string) => async (dispatch: AppDispatch if (getState().editor.activeSlideId === -1 && res.data.slides[0]) { setEditorSlideId(res.data.slides[0].id)(dispatch) } + const defaultViewType = getState().types.viewTypes.find((viewType) => viewType.name === 'Audience') + if (getState().editor.activeViewTypeId === -1 && defaultViewType) { + setEditorViewId(defaultViewType.id)(dispatch) + } }) .catch((err) => { console.log(err) }) } -// Set currentSlideId in editor state +// Set activeSlideId in editor state export const setEditorSlideId = (id: number) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_EDITOR_SLIDE_ID, payload: id, }) } + +// Set activeViewTypeId in editor state +export const setEditorViewId = (id: number) => (dispatch: AppDispatch) => { + dispatch({ + type: Types.SET_EDITOR_VIEW_ID, + payload: id, + }) +} diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index 445a8d86eadbcdcdd698838fcc78aeee3d307f6e..2e9fc776f4c1828427df8424ef93d9588f20afea 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -24,6 +24,7 @@ export default { SET_COMPETITIONS_COUNT: 'SET_COMPETITIONS_COUNT', SET_EDITOR_COMPETITION: 'SET_EDITOR_COMPETITION', SET_EDITOR_SLIDE_ID: 'SET_EDITOR_SLIDE_ID', + SET_EDITOR_VIEW_ID: 'SET_EDITOR_VIEW_ID', SET_PRESENTATION_COMPETITION: 'SET_PRESENTATION_COMPETITION', SET_PRESENTATION_SLIDE: 'SET_PRESENTATION_SLIDE', SET_PRESENTATION_SLIDE_PREVIOUS: 'SET_PRESENTATION_SLIDE_PREVIOUS', diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 06fdee0afa3b467c5d0fe21f525b9ca7ec5e6a6a..a6fa68a0e9b58dcc63ad67d29884bbf3e781a64a 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -80,6 +80,8 @@ export interface Component { w: number h: number type_id: number + view_type_id: number + slide_id: number } export interface ImageComponent extends Component { diff --git a/client/src/pages/admin/competitions/CompetitionManager.tsx b/client/src/pages/admin/competitions/CompetitionManager.tsx index 83f399b95a0c083727333c2f842a1c61a8eca2d0..8ec3ee2981277a3f150642410581d472602e65ed 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.tsx @@ -94,7 +94,7 @@ const CompetitionManager: React.FC = (props: any) => { } const handleStartCompetition = () => { - history.push(`/presenter/id=${activeId}&code=123123`) + history.push(`/operator/id=${activeId}&code=123123`) console.log('GLHF!') } diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx index c645724e3c7fabda69a2ad2434f4bd7b587c8c2e..5426152ff33783745f8dc1181237cd2926b0a16a 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx @@ -27,8 +27,19 @@ it('renders presentation editor', () => { ], }, } + const typesRes: any = { + data: { + view_types: [ + { + name: '', + id: 0, + }, + ], + }, + } ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { if (path.startsWith('/api/competitions')) return Promise.resolve(competitionRes) + if (path.startsWith('/api/misc/types')) return Promise.resolve(typesRes) return Promise.resolve(citiesRes) }) render( diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.tsx index cd9d22c2055af4b4ddded3260db8b124b9124e6d..80d35c5f38c8b879976b91204f9572ac9cf5735a 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -10,7 +10,7 @@ import axios from 'axios' import React, { useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { getCities } from '../../actions/cities' -import { getEditorCompetition, setEditorSlideId } from '../../actions/editor' +import { getEditorCompetition, setEditorSlideId, setEditorViewId } from '../../actions/editor' import { getTypes } from '../../actions/typesAction' import { useAppDispatch, useAppSelector } from '../../hooks' import { RichSlide } from '../../interfaces/ApiRichModels' @@ -27,6 +27,7 @@ import { SlideListItem, ToolBarContainer, ViewButton, + ViewButtonClicked, ViewButtonGroup, } from './styled' @@ -88,12 +89,13 @@ const PresentationEditorPage: React.FC = () => { const { competitionId }: CompetitionParams = useParams() const dispatch = useAppDispatch() const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) const competition = useAppSelector((state) => state.editor.competition) const competitionLoading = useAppSelector((state) => state.editor.loading) useEffect(() => { + dispatch(getTypes()) dispatch(getEditorCompetition(competitionId)) dispatch(getCities()) - dispatch(getTypes()) }, []) const setActiveSlideId = (id: number) => { @@ -147,6 +149,16 @@ const PresentationEditorPage: React.FC = () => { })((props: CheckboxProps) => <Checkbox color="default" {...props} />) const [checkbox, setCheckbox] = useState(false) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const [activeViewTypeName, setActiveViewTypeName] = useState('') + const changeView = (clickedViewTypeName: string) => { + setActiveViewTypeName(clickedViewTypeName) + const clickedViewTypeId = viewTypes.find((viewType) => viewType.name === clickedViewTypeName)?.id + if (clickedViewTypeId) { + dispatch(setEditorViewId(clickedViewTypeId)) + } + } + return ( <PresentationEditorContainer> <CssBaseline /> @@ -164,10 +176,20 @@ const PresentationEditorPage: React.FC = () => { <Typography className={classes.alignCheckboxText} variant="button"> Applicera ändringar på samtliga vyer </Typography> - <ViewButton variant="contained" color="secondary"> + <ViewButton + $activeView={activeViewTypeName === 'Audience'} + variant="contained" + color="secondary" + onClick={() => changeView('Audience')} + > Åskådarvy </ViewButton> - <ViewButton variant="contained" color="secondary"> + <ViewButton + $activeView={activeViewTypeName === 'Team'} + variant="contained" + color="secondary" + onClick={() => changeView('Team')} + > Deltagarvy </ViewButton> </ViewButtonGroup> @@ -229,7 +251,7 @@ const PresentationEditorPage: React.FC = () => { <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> <InnerContent> - <SlideDisplay editor /> + <SlideDisplay variant="editor" activeViewTypeId={activeViewTypeId} /> </InnerContent> </Content> <Menu diff --git a/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx b/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx index 8d3e9c20b8c6818594269defa6c4490ad50eccf0..cfe10e380bdbb478ee061a1946c0efaf0d4c2ee6 100644 --- a/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx +++ b/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx @@ -54,14 +54,14 @@ const BackgroundImageSelect = ({ variant }: BackgroundImageSelectProps) => { await axios.delete(`/api/media/images/${backgroundImage?.id}`).catch(console.log) if (variant === 'competition') { await axios - .put(`/api/competitions/${competitionId}`, { background_image_id: -1 }) + .put(`/api/competitions/${competitionId}`, { background_image_id: null }) .then(() => { dispatch(getEditorCompetition(competitionId.toString())) }) .catch(console.log) } else { await axios - .put(`/api/competitions/${competitionId}/slides/${activeSlideId}`, { background_image_id: -1 }) + .put(`/api/competitions/${competitionId}/slides/${activeSlideId}`, { background_image_id: null }) .then(() => { dispatch(getEditorCompetition(competitionId.toString())) }) diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx index b4e4488a8132d25e4b993e5743b1160b4b380de0..aef4ca7c48d29bbfc47cac8f29b214ec6866f360 100644 --- a/client/src/pages/presentationEditor/components/SlideDisplay.tsx +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -1,4 +1,3 @@ -import { Button, Typography } from '@material-ui/core' import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import { getTypes } from '../../../actions/typesAction' import { useAppDispatch, useAppSelector } from '../../../hooks' @@ -8,22 +7,23 @@ import { SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } fro type SlideDisplayProps = { //Prop to distinguish between editor and active competition - editor?: boolean | undefined + variant: 'editor' | 'presentation' + activeViewTypeId: number } -const SlideDisplay = ({ editor }: SlideDisplayProps) => { +const SlideDisplay = ({ variant, activeViewTypeId }: SlideDisplayProps) => { const components = useAppSelector((state) => { - if (editor) + if (variant === 'editor') return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.components return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.slide?.id)?.components }) const competitionBackgroundImage = useAppSelector((state) => { - if (editor) return state.editor.competition.background_image + if (variant === 'editor') return state.editor.competition.background_image return state.presentation.competition.background_image }) const slideBackgroundImage = useAppSelector((state) => { - if (editor) + if (variant === 'editor') return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.background_image return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.slide.id) ?.background_image @@ -64,21 +64,29 @@ const SlideDisplay = ({ editor }: SlideDisplayProps) => { /> )} {components && - components.map((component) => { - if (editor) + components + .filter((component) => component.view_type_id === activeViewTypeId) + .map((component) => { + if (variant === 'editor') + return ( + <RndComponent + height={height} + width={width} + key={component.id} + component={component} + scale={scale} + /> + ) return ( - <RndComponent height={height} width={width} key={component.id} component={component} scale={scale} /> + <PresentationComponent + height={height} + width={width} + key={component.id} + component={component} + scale={scale} + /> ) - return ( - <PresentationComponent - height={height} - width={width} - key={component.id} - component={component} - scale={scale} - /> - ) - })} + })} </SlideEditorPaper> </SlideEditorContainerRatio> </SlideEditorContainer> diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index 0d07a1796c4fa711cb2c0112a663fc22baf0c9a3..d48b4565712d043d52d3b1e93e3633ec6b927d38 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -25,6 +25,7 @@ const SlideSettings: React.FC = () => { // Gets the slide with id=activeSlideId from the database. state.editor.competition.slides.find((slide) => slide && slide.id === state.editor.activeSlideId) ) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) return ( <PanelContainer> @@ -48,9 +49,13 @@ const SlideSettings: React.FC = () => { <MultipleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> )} - {activeSlide && <Texts activeSlide={activeSlide} competitionId={competitionId} />} + {activeSlide && ( + <Texts activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> + )} - {activeSlide && <Images activeSlide={activeSlide} competitionId={competitionId} />} + {activeSlide && ( + <Images activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> + )} <BackgroundImageSelect variant="slide" /> </PanelContainer> diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx index b03696f37987a83347f57fb81c6b25febcb84824..f6cda5760ec8aff5bce211a8caf7d2435a8d5c61 100644 --- a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx @@ -20,6 +20,7 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { const [content, setContent] = useState('') const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) const dispatch = useAppDispatch() useEffect(() => { diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx index 01c52e61266580d8c39ca485e6930f508f85f4a7..49f41e7f87fa6c53dac59704540ca3c186ef708b 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx @@ -11,11 +11,12 @@ import { ImageComponent, Media } from '../../../../interfaces/ApiModels' import { useAppDispatch, useAppSelector } from '../../../../hooks' type ImagesProps = { + activeViewTypeId: number activeSlide: RichSlide competitionId: string } -const Images = ({ activeSlide, competitionId }: ImagesProps) => { +const Images = ({ activeViewTypeId, activeSlide, competitionId }: ImagesProps) => { const dispatch = useAppDispatch() const uploadFile = async (formData: FormData) => { @@ -37,6 +38,7 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { y: 0, media_id: media.id, type_id: 2, + view_type_id: activeViewTypeId, } await axios .post(`/api/competitions/${competitionId}/slides/${activeSlide?.id}/components`, imageData) @@ -94,17 +96,19 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { </Center> </ListItem> {images && - images.map((image) => ( - <div key={image.id}> - <ListItem divider button> - <ImportedImage src={`http://localhost:5000/static/images/thumbnail_${image.media?.filename}`} /> - <Center> - <ListItemText primary={image.media?.filename} /> - </Center> - <CloseIcon onClick={() => handleCloseimageClick(image)} /> - </ListItem> - </div> - ))} + images + .filter((image) => image.view_type_id === activeViewTypeId) + .map((image) => ( + <div key={image.id}> + <ListItem divider button> + <ImportedImage src={`http://localhost:5000/static/images/thumbnail_${image.media?.filename}`} /> + <Center> + <ListItemText primary={image.media?.filename} /> + </Center> + <CloseIcon onClick={() => handleCloseimageClick(image)} /> + </ListItem> + </div> + ))} <ListItem button style={{ padding: 0 }}> <HiddenInput accept="image/*" id="contained-button-file" multiple type="file" onChange={handleFileSelected} /> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx index 39cf09af3ca31b73f30c258570e28365117a4282..03656614e3076e6048984e70b7ddd0ae711a492d 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx @@ -9,11 +9,12 @@ import axios from 'axios' import { getEditorCompetition } from '../../../../actions/editor' type TextsProps = { + activeViewTypeId: number activeSlide: RichSlide competitionId: string } -const Texts = ({ activeSlide, competitionId }: TextsProps) => { +const Texts = ({ activeViewTypeId, activeSlide, competitionId }: TextsProps) => { const texts = useAppSelector( (state) => state.editor.competition.slides @@ -29,6 +30,7 @@ const Texts = ({ activeSlide, competitionId }: TextsProps) => { text: 'Ny text', w: 315, h: 50, + view_type_id: activeViewTypeId, }) dispatch(getEditorCompetition(competitionId)) } @@ -42,12 +44,14 @@ const Texts = ({ activeSlide, competitionId }: TextsProps) => { </Center> </ListItem> {texts && - texts.map((text) => ( - <TextCard elevation={4} key={text.id}> - <TextComponentEdit component={text} /> - <Divider /> - </TextCard> - ))} + texts + .filter((text) => text.view_type_id === activeViewTypeId) + .map((text) => ( + <TextCard elevation={4} key={text.id}> + <TextComponentEdit component={text} /> + <Divider /> + </TextCard> + ))} <ListItem button onClick={handleAddText}> <Center> <AddButton variant="button">Lägg till text</AddButton> diff --git a/client/src/pages/presentationEditor/styled.tsx b/client/src/pages/presentationEditor/styled.tsx index d1f05d6bf18de2eccc6b486fb6e94701acce3b5c..f68eb166043c970cb5f6e69bc57b090790b1d607 100644 --- a/client/src/pages/presentationEditor/styled.tsx +++ b/client/src/pages/presentationEditor/styled.tsx @@ -7,8 +7,18 @@ export const ToolBarContainer = styled(Toolbar)` padding-left: 0; ` -export const ViewButton = styled(Button)` +interface ViewButtonProps { + $activeView: boolean +} + +export const ViewButton = styled(Button)<ViewButtonProps>` + margin-right: 8px; + background: ${(props) => (props.$activeView ? '#5a0017' : undefined)}; +` + +export const ViewButtonClicked = styled(Button)` margin-right: 8px; + background: #5a0017; ` export const ViewButtonGroup = styled.div` diff --git a/client/src/pages/views/AudienceViewPage.tsx b/client/src/pages/views/AudienceViewPage.tsx index 8d58a364e6d4688da1b0dcff5290a60486250de1..d03f3367499b9820ad35d646feedda3821928dcc 100644 --- a/client/src/pages/views/AudienceViewPage.tsx +++ b/client/src/pages/views/AudienceViewPage.tsx @@ -1,16 +1,15 @@ +import { Typography } from '@material-ui/core' import React from 'react' +import { useAppSelector } from '../../hooks' import SlideDisplay from '../presentationEditor/components/SlideDisplay' -import PresentationComponent from './components/PresentationComponent' -import mockedAxios from 'axios' const AudienceViewPage: React.FC = () => { - const res = { - data: {}, + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id + if (activeViewTypeId) { + return <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} /> } - ;(mockedAxios.get as jest.Mock).mockImplementation(() => { - return Promise.resolve(res) - }) - return <SlideDisplay /> + return <Typography>Error: Åskådarvyn kunde inte laddas</Typography> } export default AudienceViewPage diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index 5a806d237f190d6483bc8265ec0eac61c58e9151..677a080d04ab34e8232120daae018b6d1a2b29c4 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -51,6 +51,8 @@ const JudgeViewPage = ({ competitionId, code }: JudgeViewPageProps) => { const history = useHistory() const dispatch = useAppDispatch() const [activeSlideIndex, setActiveSlideIndex] = useState<number>(0) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Judge')?.id const teams = useAppSelector((state) => state.presentation.competition.teams) const slides = useAppSelector((state) => state.presentation.competition.slides) const currentQuestion = slides[activeSlideIndex]?.questions[0] @@ -128,7 +130,7 @@ const JudgeViewPage = ({ competitionId, code }: JudgeViewPageProps) => { <div className={classes.toolbar} /> <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> <InnerContent> - <SlideDisplay /> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} </InnerContent> </Content> </div> diff --git a/client/src/pages/views/PresenterViewPage.test.tsx b/client/src/pages/views/OperatorViewPage.test.tsx similarity index 91% rename from client/src/pages/views/PresenterViewPage.test.tsx rename to client/src/pages/views/OperatorViewPage.test.tsx index fd7b0a9692e08354330b3e4db98c046649c0d10a..e658fe3b386899b9ab2972243c5d4ceeefc6dec4 100644 --- a/client/src/pages/views/PresenterViewPage.test.tsx +++ b/client/src/pages/views/OperatorViewPage.test.tsx @@ -4,7 +4,7 @@ import React from 'react' import { Provider } from 'react-redux' import { BrowserRouter } from 'react-router-dom' import store from '../../store' -import PresenterViewPage from './PresenterViewPage' +import OperatorViewPage from './OperatorViewPage' it('renders presenter view page', () => { const compRes: any = { @@ -36,7 +36,7 @@ it('renders presenter view page', () => { render( <BrowserRouter> <Provider store={store}> - <PresenterViewPage /> + <OperatorViewPage /> </Provider> </BrowserRouter> ) diff --git a/client/src/pages/views/PresenterViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx similarity index 50% rename from client/src/pages/views/PresenterViewPage.tsx rename to client/src/pages/views/OperatorViewPage.tsx index f221f5a9b6e612243f626e0fafd490ee02d619e8..e965a3ec9f60b77b1919525f75ba2a925a81e84b 100644 --- a/client/src/pages/views/PresenterViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -7,6 +7,7 @@ import { DialogTitle, List, ListItem, + ListItemText, Popover, Tooltip, Typography, @@ -14,11 +15,13 @@ import { useTheme, } from '@material-ui/core' import AssignmentIcon from '@material-ui/icons/Assignment' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import SupervisorAccountIcon from '@material-ui/icons/SupervisorAccount' import BackspaceIcon from '@material-ui/icons/Backspace' import ChevronLeftIcon from '@material-ui/icons/ChevronLeft' import ChevronRightIcon from '@material-ui/icons/ChevronRight' import TimerIcon from '@material-ui/icons/Timer' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useHistory, useParams } from 'react-router-dom' import { getPresentationCompetition, setPresentationCode } from '../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../hooks' @@ -36,23 +39,39 @@ import SlideDisplay from '../presentationEditor/components/SlideDisplay' import PresentationComponent from './components/PresentationComponent' import Timer from './components/Timer' import { - PresenterButton, - PresenterContainer, - PresenterContent, - PresenterFooter, - PresenterHeader, - PresenterInnerContent, + OperatorButton, + OperatorContainer, + OperatorFooter, + OperatorHeader, + OperatorContent, + OperatorInnerContent, SlideCounter, ToolBarContainer, } from './styled' /** + * Description: + * * Presentation is an active competition + * + * + * =========================================== + * TODO: + * - Instead of copying code for others to join the competition, copy URL. + * + * - Make code popup less code by using .map instead + * + * - 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. + * =========================================== */ -const PresenterViewPage: React.FC = () => { +const OperatorViewPage: React.FC = () => { // for dialog alert const [openAlert, setOpen] = React.useState(false) + const [openAlertCode, setOpenCode] = React.useState(true) const theme = useTheme() const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) const teams = useAppSelector((state) => state.presentation.competition.teams) @@ -61,22 +80,31 @@ const PresenterViewPage: React.FC = () => { const presentation = useAppSelector((state) => state.presentation) const history = useHistory() const dispatch = useAppDispatch() + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Operator')?.id useEffect(() => { dispatch(getPresentationCompetition(id)) dispatch(setPresentationCode(code)) socket_connect() socketSetSlide // Behövs denna? - setTimeout(startCompetition, 500) // Ghetto, wait for everything to load + setTimeout(startCompetition, 1000) // Ghetto, wait for everything to load // console.log(id) }, []) + window.onpopstate = () => { + //Handle browser back arrow + alert('Tävlingen avslutas för alla') + endCompetition() + } + const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { setAnchorEl(event.currentTarget) } const handleClose = () => { setOpen(false) + setOpenCode(false) setAnchorEl(null) } @@ -90,6 +118,14 @@ const PresenterViewPage: React.FC = () => { setOpen(true) } + const handleOpenCodes = () => { + setOpenCode(true) + } + + const handleCopy = () => { + console.log('copied code to clipboard') + } + const endCompetition = () => { setOpen(false) socketEndPresentation() @@ -98,12 +134,64 @@ const PresenterViewPage: React.FC = () => { } return ( - <PresenterContainer> - <PresenterHeader> + <OperatorContainer> + <Dialog + fullScreen={fullScreen} + open={openAlertCode} + onClose={handleClose} + aria-labelledby="responsive-dialog-title" + > + <DialogTitle id="responsive-dialog-title">{'Koder för tävlingen'}</DialogTitle> + <DialogContent> + <ListItem> + <ListItemText primary={`Domare: ${presentation.code}`} /> + <Tooltip title="Kopiera kod" arrow> + <Button + onClick={() => { + navigator.clipboard.writeText(presentation.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + <ListItem> + <ListItemText primary={`Tävlande: ${presentation.code}`} /> + <Tooltip title="Kopiera kod" arrow> + <Button + onClick={() => { + navigator.clipboard.writeText(presentation.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + <ListItem> + <ListItemText primary={`Publik: ${presentation.code}`} /> + <Tooltip title="Kopiera kod" arrow> + <Button + onClick={() => { + navigator.clipboard.writeText(presentation.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + </DialogContent> + <DialogActions> + <Button autoFocus onClick={handleClose} color="primary"> + Ok + </Button> + </DialogActions> + </Dialog> + + <OperatorHeader> <Tooltip title="Avsluta tävling" arrow> - <PresenterButton onClick={handleVerifyExit} variant="contained" color="secondary"> + <OperatorButton onClick={handleVerifyExit} variant="contained" color="secondary"> <BackspaceIcon fontSize="large" /> - </PresenterButton> + </OperatorButton> </Tooltip> <Dialog @@ -133,67 +221,73 @@ const PresenterViewPage: React.FC = () => { {presentation.slide.order + 1} / {presentation.competition.slides.length} </Typography> </SlideCounter> - </PresenterHeader> + </OperatorHeader> <div style={{ height: 0, paddingTop: 120 }} /> - <PresenterContent> - <PresenterInnerContent> - <SlideDisplay /> - </PresenterInnerContent> - </PresenterContent> + <OperatorContent> + <OperatorInnerContent> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} + </OperatorInnerContent> + </OperatorContent> <div style={{ height: 0, paddingTop: 140 }} /> - <PresenterFooter> + <OperatorFooter> <ToolBarContainer> - <Tooltip title="Previous Slide" arrow> - <PresenterButton onClick={socketSetSlidePrev} variant="contained"> + <Tooltip title="Föregående" arrow> + <OperatorButton onClick={socketSetSlidePrev} variant="contained"> <ChevronLeftIcon fontSize="large" /> - </PresenterButton> + </OperatorButton> </Tooltip> {/* // Manual start button <Tooltip title="Start Presentation" arrow> - <PresenterButton onClick={startCompetition} variant="contained"> + <OperatorButton onClick={startCompetition} variant="contained"> start - </PresenterButton> + </OperatorButton> </Tooltip> - // This creates a join button, but presenter should not join others, others should join presenter + // This creates a join button, but Operator should not join others, others should join Operator <Tooltip title="Join Presentation" arrow> - <PresenterButton onClick={socketJoinPresentation} variant="contained"> + <OperatorButton onClick={socketJoinPresentation} variant="contained"> <GroupAddIcon fontSize="large" /> - </PresenterButton> + </OperatorButton> </Tooltip> // This creates another end button, it might not be needed since we already have one <Tooltip title="End Presentation" arrow> - <PresenterButton onClick={socketEndPresentation} variant="contained"> + <OperatorButton onClick={socketEndPresentation} variant="contained"> <CancelIcon fontSize="large" /> - </PresenterButton> + </OperatorButton> </Tooltip> */} - <Tooltip title="Start Timer" arrow> - <PresenterButton onClick={socketStartTimer} variant="contained"> + <Tooltip title="Starta Timer" arrow> + <OperatorButton onClick={socketStartTimer} variant="contained"> <TimerIcon fontSize="large" /> <Timer></Timer> - </PresenterButton> + </OperatorButton> </Tooltip> - <Tooltip title="Scoreboard" arrow> - <PresenterButton onClick={handleOpenPopover} variant="contained"> + <Tooltip title="Ställning" arrow> + <OperatorButton onClick={handleOpenPopover} variant="contained"> <AssignmentIcon fontSize="large" /> - </PresenterButton> + </OperatorButton> </Tooltip> - <Tooltip title="Next Slide" arrow> - <PresenterButton onClick={socketSetSlideNext} variant="contained"> + <Tooltip title="Koder" arrow> + <OperatorButton onClick={handleOpenCodes} variant="contained"> + <SupervisorAccountIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + <Tooltip title="Nästa" arrow> + <OperatorButton onClick={socketSetSlideNext} variant="contained"> <ChevronRightIcon fontSize="large" /> - </PresenterButton> + </OperatorButton> </Tooltip> </ToolBarContainer> - </PresenterFooter> + </OperatorFooter> <Popover open={Boolean(anchorEl)} anchorEl={anchorEl} @@ -208,17 +302,16 @@ const PresenterViewPage: React.FC = () => { }} > <List> - {/** TODO: - * Fix scoreboard - */} - {teams && teams.map((team) => <ListItem key={team.id}>{team.name} score: 20</ListItem>)} + {teams && + teams.map((team) => ( + <ListItem key={team.id}> + {team.name} score: {team.question_answers}{' '} + </ListItem> + ))} </List> </Popover> - </PresenterContainer> + </OperatorContainer> ) } -export default PresenterViewPage -function componentDidMount() { - throw new Error('Function not implemented.') -} +export default OperatorViewPage diff --git a/client/src/pages/views/ParticipantViewPage.test.tsx b/client/src/pages/views/TeamViewPage.test.tsx similarity index 85% rename from client/src/pages/views/ParticipantViewPage.test.tsx rename to client/src/pages/views/TeamViewPage.test.tsx index e25ab6b9ec8f6309ce60812d296c37ffab3042e6..10574f7e51df7dabf9f07754e9d8595d1c489559 100644 --- a/client/src/pages/views/ParticipantViewPage.test.tsx +++ b/client/src/pages/views/TeamViewPage.test.tsx @@ -3,7 +3,7 @@ import React from 'react' import { Provider } from 'react-redux' import { BrowserRouter } from 'react-router-dom' import store from '../../store' -import ParticipantViewPage from './ParticipantViewPage' +import TeamViewPage from './TeamViewPage' import mockedAxios from 'axios' it('renders participant view page', () => { @@ -16,7 +16,7 @@ it('renders participant view page', () => { render( <BrowserRouter> <Provider store={store}> - <ParticipantViewPage /> + <TeamViewPage /> </Provider> </BrowserRouter> ) diff --git a/client/src/pages/views/ParticipantViewPage.tsx b/client/src/pages/views/TeamViewPage.tsx similarity index 59% rename from client/src/pages/views/ParticipantViewPage.tsx rename to client/src/pages/views/TeamViewPage.tsx index ffee1ee11857e875314f847fbd3aca58d6bdd9db..32eef28b9f7acd350195571719ea0449aeb19610 100644 --- a/client/src/pages/views/ParticipantViewPage.tsx +++ b/client/src/pages/views/TeamViewPage.tsx @@ -2,26 +2,28 @@ import React, { useEffect } from 'react' import PresentationComponent from './components/PresentationComponent' import { useHistory } from 'react-router-dom' import SlideDisplay from '../presentationEditor/components/SlideDisplay' -import { ParticipantContainer } from './styled' +import { TeamContainer } from './styled' import { socketJoinPresentation, socket_connect } from '../../sockets' import { useAppSelector } from '../../hooks' -const ParticipantViewPage: React.FC = () => { +const TeamViewPage: React.FC = () => { const history = useHistory() const code = useAppSelector((state) => state.presentation.code) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Team')?.id useEffect(() => { //hides the url so people can't sneak peak - history.push('participant') + history.push('team') if (code && code !== '') { socket_connect() socketJoinPresentation() } }, []) return ( - <ParticipantContainer> - <SlideDisplay /> - </ParticipantContainer> + <TeamContainer> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} + </TeamContainer> ) } -export default ParticipantViewPage +export default TeamViewPage diff --git a/client/src/pages/views/ViewSelectPage.tsx b/client/src/pages/views/ViewSelectPage.tsx index 686ce4f1e2d7465a4d8e8ba3ac75d1dee75914ff..845ba21c3edc1e5f499f6aae817e195af4d9bc21 100644 --- a/client/src/pages/views/ViewSelectPage.tsx +++ b/client/src/pages/views/ViewSelectPage.tsx @@ -4,9 +4,9 @@ import { Link, useRouteMatch } from 'react-router-dom' import { ViewSelectButtonGroup, ViewSelectContainer } from './styled' import { useParams } from 'react-router-dom' import { CircularProgress, Typography } from '@material-ui/core' -import ParticipantViewPage from './ParticipantViewPage' +import TeamViewPage from './TeamViewPage' import axios from 'axios' -import PresenterViewPage from './PresenterViewPage' +import OperatorViewPage from './OperatorViewPage' import JudgeViewPage from './JudgeViewPage' import AudienceViewPage from './AudienceViewPage' import { useAppDispatch, useAppSelector } from '../../hooks' @@ -29,7 +29,7 @@ const ViewSelectPage: React.FC = () => { if (competitionId) { switch (viewType) { case 'Team': - return <ParticipantViewPage /> + return <TeamViewPage /> case 'Judge': return <JudgeViewPage code={code} competitionId={competitionId} /> case 'Audience': diff --git a/client/src/pages/views/styled.tsx b/client/src/pages/views/styled.tsx index 8f63892ade7928842c12d4dc73df988a505f4cea..9699902727b2352fec07b8781a9d8c43ad20663e 100644 --- a/client/src/pages/views/styled.tsx +++ b/client/src/pages/views/styled.tsx @@ -35,7 +35,7 @@ export const ViewSelectButtonGroup = styled.div` margin-right: auto; ` -export const PresenterHeader = styled.div` +export const OperatorHeader = styled.div` display: flex; justify-content: space-between; height: 120px; @@ -43,7 +43,7 @@ export const PresenterHeader = styled.div` position: absolute; ` -export const PresenterFooter = styled.div` +export const OperatorFooter = styled.div` display: flex; justify-content: space-between; height: 140px; @@ -52,7 +52,7 @@ export const PresenterFooter = styled.div` width: 100%; ` -export const PresenterButton = styled(Button)` +export const OperatorButton = styled(Button)` width: 100px; height: 100px; margin-left: 16px; @@ -66,7 +66,7 @@ export const SlideCounter = styled(Button)` margin-top: 16px; ` -export const PresenterContainer = styled.div` +export const OperatorContainer = styled.div` display: flex; flex-direction: column; justify-content: space-between; @@ -127,7 +127,7 @@ export const InnerContent = styled.div` max-width: calc(((100vh - 64px) / 9) * 16); ` -export const PresenterContent = styled.div` +export const OperatorContent = styled.div` height: 100%; width: 100%; display: flex; @@ -135,7 +135,7 @@ export const PresenterContent = styled.div` background-color: rgba(0, 0, 0, 0.08); ` -export const PresenterInnerContent = styled.div` +export const OperatorInnerContent = styled.div` height: 100%; width: 100%; /* Makes sure width is not bigger than where a 16:9 display can fit @@ -143,7 +143,7 @@ export const PresenterInnerContent = styled.div` max-width: calc(((100vh - 260px) / 9) * 16); ` -export const ParticipantContainer = styled.div` +export const TeamContainer = styled.div` max-width: calc((100vh / 9) * 16); ` diff --git a/client/src/reducers/editorReducer.ts b/client/src/reducers/editorReducer.ts index 88e6e3687ea6f4348ebd5956e677ef26319e33c2..4f245d1d05638cc059cf8d26921642d8239c169a 100644 --- a/client/src/reducers/editorReducer.ts +++ b/client/src/reducers/editorReducer.ts @@ -5,6 +5,7 @@ import { RichCompetition } from '../interfaces/ApiRichModels' interface EditorState { competition: RichCompetition activeSlideId: number + activeViewTypeId: number loading: boolean } @@ -19,6 +20,7 @@ const initialState: EditorState = { background_image: undefined, }, activeSlideId: -1, + activeViewTypeId: -1, loading: true, } @@ -35,6 +37,11 @@ export default function (state = initialState, action: AnyAction) { ...state, activeSlideId: action.payload as number, } + case Types.SET_EDITOR_VIEW_ID: + return { + ...state, + activeViewTypeId: action.payload as number, + } default: return state } diff --git a/server/app/apis/__init__.py b/server/app/apis/__init__.py index 5eab3f829ae81e17ecc269d98f568eeacc45a68c..e5ec3d2ca1e1d9de6d4c5cca5ec1e3fba40f33ee 100644 --- a/server/app/apis/__init__.py +++ b/server/app/apis/__init__.py @@ -13,24 +13,62 @@ def validate_editor(db_item, *views): abort(http_codes.UNAUTHORIZED) -def check_jwt(editor=False, *views): - def wrapper(fn): - @wraps(fn) - def decorator(*args, **kwargs): +def _is_allowed(allowed, actual): + return actual and "*" in allowed or actual in allowed + + +def _has_access(in_claim, in_route): + in_route = int(in_route) if in_route else None + return not in_route or in_claim and in_claim == in_route + + +def protect_route(allowed_roles=None, allowed_views=None): + def wrapper(func): + def inner(*args, **kwargs): verify_jwt_in_request() claims = get_jwt_claims() + + # Authorize request if roles has access to the route # + + nonlocal allowed_roles + allowed_roles = allowed_roles or [] role = claims.get("role") + if _is_allowed(allowed_roles, role): + return func(*args, **kwargs) + + # Authorize request if view has access and is trying to access the + # competition its in. Also check team if client is a team. + # Allow request if route doesn't belong to any competition. + + nonlocal allowed_views + allowed_views = allowed_views or [] view = claims.get("view") - if role == "Admin": - return fn(*args, **kwargs) - elif editor and role == "Editor": - return fn(*args, **kwargs) - elif view in views: - return fn(*args, **kwargs) - else: - abort(http_codes.UNAUTHORIZED) - - return decorator + if not _is_allowed(allowed_views, view): + abort( + http_codes.UNAUTHORIZED, + f"Client with view '{view}' is not allowed to access route with allowed views {allowed_views}.", + ) + + claim_competition_id = claims.get("competition_id") + route_competition_id = kwargs.get("competition_id") + if not _has_access(claim_competition_id, route_competition_id): + abort( + http_codes.UNAUTHORIZED, + f"Client in competition '{claim_competition_id}' is not allowed to access competition '{route_competition_id}'.", + ) + + if view == "Team": + claim_team_id = claims.get("team_id") + route_team_id = kwargs.get("team_id") + if not _has_access(claim_team_id, route_team_id): + abort( + http_codes.UNAUTHORIZED, + f"Client in team '{claim_team_id}' is not allowed to access team '{route_team_id}'.", + ) + + return func(*args, **kwargs) + + return inner return wrapper diff --git a/server/app/apis/alternatives.py b/server/app/apis/alternatives.py index 2adad553ddb34bbd43e1405223c7c45962d66e19..94f6d6b1725106a3cf5994570e4d3d5829ba2dbe 100644 --- a/server/app/apis/alternatives.py +++ b/server/app/apis/alternatives.py @@ -1,30 +1,35 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionAlternativeDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = QuestionAlternativeDTO.api schema = QuestionAlternativeDTO.schema list_schema = QuestionAlternativeDTO.list_schema -question_alternative_parser = reqparse.RequestParser() -question_alternative_parser.add_argument("text", type=str, default=None, location="json") -question_alternative_parser.add_argument("value", type=int, default=None, location="json") +alternative_parser_add = reqparse.RequestParser() +alternative_parser_add.add_argument("text", type=str, required=True, location="json") +alternative_parser_add.add_argument("value", type=int, required=True, location="json") + +alternative_parser_edit = reqparse.RequestParser() +alternative_parser_edit.add_argument("text", type=str, default=sentinel, location="json") +alternative_parser_edit.add_argument("value", type=int, default=sentinel, location="json") @api.route("") @api.param("competition_id, slide_id, question_id") class QuestionAlternativeList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, slide_id, question_id): items = dbc.get.question_alternative_list(competition_id, slide_id, question_id) return list_response(list_schema.dump(items)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id, question_id): - args = question_alternative_parser.parse_args(strict=True) + args = alternative_parser_add.parse_args(strict=True) item = dbc.add.question_alternative(**args, question_id=question_id) return item_response(schema.dump(item)) @@ -32,19 +37,19 @@ class QuestionAlternativeList(Resource): @api.route("/<alternative_id>") @api.param("competition_id, slide_id, question_id, alternative_id") class QuestionAlternatives(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, slide_id, question_id, alternative_id): items = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) return item_response(schema.dump(items)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, question_id, alternative_id): - args = question_alternative_parser.parse_args(strict=True) + args = alternative_parser_edit.parse_args(strict=True) item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def delete(self, competition_id, slide_id, question_id, alternative_id): item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) dbc.delete.default(item) diff --git a/server/app/apis/answers.py b/server/app/apis/answers.py index 79a9c7a9080ae50e6353894ec6780987597c929f..3990e4e1d7b75fa35d13cc592906e4c4aa921024 100644 --- a/server/app/apis/answers.py +++ b/server/app/apis/answers.py @@ -1,35 +1,36 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionAnswerDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = QuestionAnswerDTO.api schema = QuestionAnswerDTO.schema list_schema = QuestionAnswerDTO.list_schema -question_answer_parser = reqparse.RequestParser() -question_answer_parser.add_argument("answer", type=str, required=True, location="json") -question_answer_parser.add_argument("score", type=int, required=True, location="json") -question_answer_parser.add_argument("question_id", type=int, required=True, location="json") +answer_parser_add = reqparse.RequestParser() +answer_parser_add.add_argument("answer", type=str, required=True, location="json") +answer_parser_add.add_argument("score", type=int, required=True, location="json") +answer_parser_add.add_argument("question_id", type=int, required=True, location="json") -question_answer_edit_parser = reqparse.RequestParser() -question_answer_edit_parser.add_argument("answer", type=str, default=None, location="json") -question_answer_edit_parser.add_argument("score", type=int, default=None, location="json") +answer_parser_edit = reqparse.RequestParser() +answer_parser_edit.add_argument("answer", type=str, default=sentinel, location="json") +answer_parser_edit.add_argument("score", type=int, default=sentinel, location="json") @api.route("") @api.param("competition_id, team_id") class QuestionAnswerList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, team_id): items = dbc.get.question_answer_list(competition_id, team_id) return list_response(list_schema.dump(items)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def post(self, competition_id, team_id): - args = question_answer_parser.parse_args(strict=True) + args = answer_parser_add.parse_args(strict=True) item = dbc.add.question_answer(**args, team_id=team_id) return item_response(schema.dump(item)) @@ -37,13 +38,14 @@ class QuestionAnswerList(Resource): @api.route("/<answer_id>") @api.param("competition_id, team_id, answer_id") class QuestionAnswers(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, team_id, answer_id): item = dbc.get.question_answer(competition_id, team_id, answer_id) return item_response(schema.dump(item)) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def put(self, competition_id, team_id, answer_id): - args = question_answer_edit_parser.parse_args(strict=True) + args = answer_parser_edit.parse_args(strict=True) item = dbc.get.question_answer(competition_id, team_id, answer_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index 37da9e98d4b5970cd82b47dd54ad940cc53b6736..d97eab2af78deb39f5b5efdcc43e063373cca7e9 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -1,6 +1,6 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, text_response +from app.apis import item_response, protect_route, text_response from app.core.codes import verify_code from app.core.dto import AuthDTO, CodeDTO from flask_jwt_extended import ( @@ -12,6 +12,8 @@ from flask_jwt_extended import ( ) from flask_restx import Resource from flask_restx import inputs, reqparse +from datetime import timedelta +from app.core import sockets api = AuthDTO.api schema = AuthDTO.schema @@ -19,23 +21,27 @@ list_schema = AuthDTO.list_schema login_parser = reqparse.RequestParser() login_parser.add_argument("email", type=inputs.email(), required=True, location="json") -login_parser.add_argument("password", required=True, location="json") +login_parser.add_argument("password", type=str, required=True, location="json") create_user_parser = login_parser.copy() create_user_parser.add_argument("city_id", type=int, required=True, location="json") create_user_parser.add_argument("role_id", type=int, required=True, location="json") login_code_parser = reqparse.RequestParser() -login_code_parser.add_argument("code", type=str, location="json") +login_code_parser.add_argument("code", type=str, required=True, location="json") def get_user_claims(item_user): return {"role": item_user.role.name, "city_id": item_user.city_id} +def get_code_claims(item_code): + return {"view": item_code.view_type.name, "competition_id": item_code.competition_id, "team_id": item_code.team_id} + + @api.route("/signup") class AuthSignup(Resource): - @check_jwt(editor=False) + @protect_route(allowed_roles=["Admin"]) def post(self): args = create_user_parser.parse_args(strict=True) email = args.get("email") @@ -50,7 +56,7 @@ class AuthSignup(Resource): @api.route("/delete/<ID>") @api.param("ID") class AuthDelete(Resource): - @check_jwt(editor=False) + @protect_route(allowed_roles=["Admin"]) def delete(self, ID): item_user = dbc.get.user(ID) @@ -86,24 +92,38 @@ class AuthLoginCode(Resource): code = args["code"] if not verify_code(code): - api.abort(codes.BAD_REQUEST, "Invalid code") + api.abort(codes.UNAUTHORIZED, "Invalid code") item_code = dbc.get.code_by_code(code) - return item_response(CodeDTO.schema.dump(item_code)) + + if item_code.competition_id not in sockets.presentations: + api.abort(codes.UNAUTHORIZED, "Competition not active") + + access_token = create_access_token( + item_code.id, user_claims=get_code_claims(item_code), expires_delta=timedelta(hours=8) + ) + + response = { + "competition_id": item_code.competition_id, + "view_type_id": item_code.view_type_id, + "team_id": item_code.team_id, + "access_token": access_token, + } + return response @api.route("/logout") class AuthLogout(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def post(self): jti = get_raw_jwt()["jti"] dbc.add.blacklist(jti) - return text_response("User logout") + return text_response("Logout") @api.route("/refresh") class AuthRefresh(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) @jwt_refresh_token_required def post(self): old_jti = get_raw_jwt()["jti"] diff --git a/server/app/apis/codes.py b/server/app/apis/codes.py index 8a4105d24a77f07b04da1e1e2ed477746ea7a932..d07e17435aed31417254acfd83ed9315f1477ba3 100644 --- a/server/app/apis/codes.py +++ b/server/app/apis/codes.py @@ -1,5 +1,5 @@ import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core import http_codes as codes from app.core.dto import CodeDTO from app.database.models import Code @@ -13,7 +13,7 @@ list_schema = CodeDTO.list_schema @api.route("") @api.param("competition_id") class CodesList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, competition_id): items = dbc.get.code_list(competition_id) return list_response(list_schema.dump(items), len(items)) @@ -22,7 +22,7 @@ class CodesList(Resource): @api.route("/<code_id>") @api.param("competition_id, code_id") class CodesById(Resource): - @check_jwt(editor=False) + @protect_route(allowed_roles=["*"]) def put(self, competition_id, code_id): item = dbc.get.one(Code, code_id) item.code = dbc.utils.generate_unique_code() diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index fded52ec97df7d72acaf531eb5f0033c25227669..c5ff37c9131a7a9ec4bc1f394a9e1f5b5727460e 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -1,37 +1,40 @@ import time import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import CompetitionDTO from app.database.models import Competition from flask_restx import Resource from flask_restx import reqparse -from app.core.parsers import search_parser +from app.core.parsers import search_parser, sentinel api = CompetitionDTO.api schema = CompetitionDTO.schema rich_schema = CompetitionDTO.rich_schema list_schema = CompetitionDTO.list_schema -competition_parser = reqparse.RequestParser() -competition_parser.add_argument("name", type=str, location="json") -competition_parser.add_argument("year", type=int, location="json") -competition_parser.add_argument("city_id", type=int, location="json") +competition_parser_add = reqparse.RequestParser() +competition_parser_add.add_argument("name", type=str, required=True, location="json") +competition_parser_add.add_argument("year", type=int, required=True, location="json") +competition_parser_add.add_argument("city_id", type=int, required=True, location="json") -competition_edit_parser = competition_parser.copy() -competition_edit_parser.add_argument("background_image_id", default=None, type=int, location="json") +competition_parser_edit = reqparse.RequestParser() +competition_parser_edit.add_argument("name", type=str, default=sentinel, location="json") +competition_parser_edit.add_argument("year", type=int, default=sentinel, location="json") +competition_parser_edit.add_argument("city_id", type=int, default=sentinel, location="json") +competition_parser_edit.add_argument("background_image_id", default=sentinel, type=int, location="json") -competition_search_parser = search_parser.copy() -competition_search_parser.add_argument("name", type=str, default=None, location="args") -competition_search_parser.add_argument("year", type=int, default=None, location="args") -competition_search_parser.add_argument("city_id", type=int, default=None, location="args") +competition_parser_search = search_parser.copy() +competition_parser_search.add_argument("name", type=str, default=sentinel, location="args") +competition_parser_search.add_argument("year", type=int, default=sentinel, location="args") +competition_parser_search.add_argument("city_id", type=int, default=sentinel, location="args") @api.route("") class CompetitionsList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self): - args = competition_parser.parse_args(strict=True) + args = competition_parser_add.parse_args(strict=True) # Add competition item = dbc.add.competition(**args) @@ -44,20 +47,21 @@ class CompetitionsList(Resource): @api.route("/<competition_id>") @api.param("competition_id") class Competitions(Resource): + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id): item = dbc.get.competition(competition_id) return item_response(rich_schema.dump(item)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def put(self, competition_id): - args = competition_edit_parser.parse_args(strict=True) + args = competition_parser_edit.parse_args(strict=True) item = dbc.get.one(Competition, competition_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def delete(self, competition_id): item = dbc.get.one(Competition, competition_id) dbc.delete.competition(item) @@ -67,9 +71,9 @@ class Competitions(Resource): @api.route("/search") class CompetitionSearch(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self): - args = competition_search_parser.parse_args(strict=True) + args = competition_parser_search.parse_args(strict=True) items, total = dbc.search.competition(**args) return list_response(list_schema.dump(items), total) @@ -77,7 +81,7 @@ class CompetitionSearch(Resource): @api.route("/<competition_id>/copy") @api.param("competition_id") class SlidesOrder(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self, competition_id): item_competition = dbc.get.competition(competition_id) diff --git a/server/app/apis/components.py b/server/app/apis/components.py index 8ab67b8cb8bd001c9641b6331325f328639b581e..32ab03c7be6e1c76934dd47a59917a6a93082a3c 100644 --- a/server/app/apis/components.py +++ b/server/app/apis/components.py @@ -1,46 +1,51 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import ComponentDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = ComponentDTO.api schema = ComponentDTO.schema list_schema = ComponentDTO.list_schema +component_parser_add = reqparse.RequestParser() +component_parser_add.add_argument("x", type=str, default=0, location="json") +component_parser_add.add_argument("y", type=int, default=0, location="json") +component_parser_add.add_argument("w", type=int, default=1, location="json") +component_parser_add.add_argument("h", type=int, default=1, location="json") +component_parser_add.add_argument("type_id", type=int, required=True, location="json") +component_parser_add.add_argument("view_type_id", type=int, required=True, location="json") +component_parser_add.add_argument("text", type=str, default=None, location="json") +component_parser_add.add_argument("media_id", type=str, default=None, location="json") -component_parser = reqparse.RequestParser() -component_parser.add_argument("x", type=str, default=None, location="json") -component_parser.add_argument("y", type=int, default=None, location="json") -component_parser.add_argument("w", type=int, default=None, location="json") -component_parser.add_argument("h", type=int, default=None, location="json") - -component_edit_parser = component_parser.copy() -component_edit_parser.add_argument("text", type=str, location="json") -component_edit_parser.add_argument("media_id", type=str, location="json") - -component_create_parser = component_edit_parser.copy() -component_create_parser.add_argument("type_id", type=int, required=True, location="json") +component_parser_edit = reqparse.RequestParser() +component_parser_edit.add_argument("x", type=str, default=sentinel, location="json") +component_parser_edit.add_argument("y", type=int, default=sentinel, location="json") +component_parser_edit.add_argument("w", type=int, default=sentinel, location="json") +component_parser_edit.add_argument("h", type=int, default=sentinel, location="json") +component_parser_edit.add_argument("text", type=str, default=sentinel, location="json") +component_parser_edit.add_argument("media_id", type=str, default=sentinel, location="json") @api.route("/<component_id>") @api.param("competition_id, slide_id, component_id") class ComponentByID(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, slide_id, component_id): item = dbc.get.component(competition_id, slide_id, component_id) return item_response(schema.dump(item)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, component_id): - args = component_edit_parser.parse_args(strict=True) + args = component_parser_edit.parse_args(strict=True) item = dbc.get.component(competition_id, slide_id, component_id) - args_without_none = {key: value for key, value in args.items() if value is not None} - item = dbc.edit.default(item, **args_without_none) + args_without_sentinel = {key: value for key, value in args.items() if value is not sentinel} + item = dbc.edit.default(item, **args_without_sentinel) return item_response(schema.dump(item)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def delete(self, competition_id, slide_id, component_id): item = dbc.get.component(competition_id, slide_id, component_id) dbc.delete.component(item) @@ -50,13 +55,13 @@ class ComponentByID(Resource): @api.route("") @api.param("competition_id, slide_id") class ComponentList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, slide_id): items = dbc.get.component_list(competition_id, slide_id) return list_response(list_schema.dump(items)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id): - args = component_create_parser.parse_args() + args = component_parser_add.parse_args() item = dbc.add.component(slide_id=slide_id, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/media.py b/server/app/apis/media.py index 5d89550ac19aaff78643a34ae0a3059c011aed23..49d20608840e320ef3d73d9df848748e6da33617 100644 --- a/server/app/apis/media.py +++ b/server/app/apis/media.py @@ -1,6 +1,6 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import MediaDTO from app.core.parsers import search_parser from app.database.models import Media @@ -10,6 +10,7 @@ from flask_restx import Resource from flask_uploads import UploadNotAllowed from sqlalchemy import exc import app.core.files as files +from app.core.parsers import sentinel api = MediaDTO.api image_set = MediaDTO.image_set @@ -17,18 +18,18 @@ schema = MediaDTO.schema list_schema = MediaDTO.list_schema media_parser_search = search_parser.copy() -media_parser_search.add_argument("filename", type=str, default=None, location="args") +media_parser_search.add_argument("filename", type=str, default=sentinel, location="args") @api.route("/images") class ImageList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self): args = media_parser_search.parse_args(strict=True) items, total = dbc.search.image(**args) return list_response(list_schema.dump(items), total) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self): if "image" not in request.files: api.abort(codes.BAD_REQUEST, "Missing image in request.files") @@ -48,12 +49,12 @@ class ImageList(Resource): @api.route("/images/<ID>") @api.param("ID") class ImageList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, ID): item = dbc.get.one(Media, ID) return item_response(schema.dump(item)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def delete(self, ID): item = dbc.get.one(Media, ID) try: diff --git a/server/app/apis/misc.py b/server/app/apis/misc.py index ab52362d46d026d8483c52f5c4d8384f32de755d..20a84e4c17c138b3a94ea6e3902e67154036cabc 100644 --- a/server/app/apis/misc.py +++ b/server/app/apis/misc.py @@ -1,5 +1,5 @@ import app.database.controller as dbc -from app.apis import check_jwt, list_response +from app.apis import list_response, protect_route from app.core import http_codes from app.core.dto import MiscDTO from app.database.models import City, Competition, ComponentType, MediaType, QuestionType, Role, User, ViewType @@ -23,6 +23,7 @@ name_parser.add_argument("name", type=str, required=True, location="json") @api.route("/types") class TypesList(Resource): + @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self): result = {} result["media_types"] = media_type_schema.dump(dbc.get.all(MediaType)) @@ -34,7 +35,7 @@ class TypesList(Resource): @api.route("/roles") class RoleList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self): items = dbc.get.all(Role) return list_response(role_schema.dump(items)) @@ -42,12 +43,12 @@ class RoleList(Resource): @api.route("/cities") class CitiesList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self): items = dbc.get.all(City) return list_response(city_schema.dump(items)) - @check_jwt(editor=False) + @protect_route(allowed_roles=["Admin"]) def post(self): args = name_parser.parse_args(strict=True) dbc.add.city(args["name"]) @@ -58,7 +59,7 @@ class CitiesList(Resource): @api.route("/cities/<ID>") @api.param("ID") class Cities(Resource): - @check_jwt(editor=False) + @protect_route(allowed_roles=["Admin"]) def put(self, ID): item = dbc.get.one(City, ID) args = name_parser.parse_args(strict=True) @@ -67,7 +68,7 @@ class Cities(Resource): items = dbc.get.all(City) return list_response(city_schema.dump(items)) - @check_jwt(editor=False) + @protect_route(allowed_roles=["Admin"]) def delete(self, ID): item = dbc.get.one(City, ID) dbc.delete.default(item) @@ -77,7 +78,7 @@ class Cities(Resource): @api.route("/statistics") class Statistics(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self): user_count = User.query.count() competition_count = Competition.query.count() diff --git a/server/app/apis/questions.py b/server/app/apis/questions.py index aaefafc40608d14bf3a4e8a2a3b7fdfbda559f2c..b14849d224501e48f9cce8a108bfd24f29657e99 100644 --- a/server/app/apis/questions.py +++ b/server/app/apis/questions.py @@ -1,24 +1,30 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = QuestionDTO.api schema = QuestionDTO.schema list_schema = QuestionDTO.list_schema -question_parser = reqparse.RequestParser() -question_parser.add_argument("name", type=str, default=None, location="json") -question_parser.add_argument("total_score", type=int, default=None, location="json") -question_parser.add_argument("type_id", type=int, default=None, location="json") +question_parser_add = reqparse.RequestParser() +question_parser_add.add_argument("name", type=str, default=None, location="json") +question_parser_add.add_argument("total_score", type=int, default=None, location="json") +question_parser_add.add_argument("type_id", type=int, required=True, location="json") + +question_parser_edit = reqparse.RequestParser() +question_parser_edit.add_argument("name", type=str, default=sentinel, location="json") +question_parser_edit.add_argument("total_score", type=int, default=sentinel, location="json") +question_parser_edit.add_argument("type_id", type=int, default=sentinel, location="json") @api.route("/questions") @api.param("competition_id") class QuestionList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, competition_id): items = dbc.get.question_list_for_competition(competition_id) return list_response(list_schema.dump(items)) @@ -27,14 +33,14 @@ class QuestionList(Resource): @api.route("/slides/<slide_id>/questions") @api.param("competition_id, slide_id") class QuestionListForSlide(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, competition_id, slide_id): items = dbc.get.question_list(competition_id, slide_id) return list_response(list_schema.dump(items)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id): - args = question_parser.parse_args(strict=True) + args = question_parser_add.parse_args(strict=True) item = dbc.add.question(slide_id=slide_id, **args) return item_response(schema.dump(item)) @@ -42,21 +48,21 @@ class QuestionListForSlide(Resource): @api.route("/slides/<slide_id>/questions/<question_id>") @api.param("competition_id, slide_id, question_id") class QuestionById(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, competition_id, slide_id, question_id): item_question = dbc.get.question(competition_id, slide_id, question_id) return item_response(schema.dump(item_question)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, question_id): - args = question_parser.parse_args(strict=True) + args = question_parser_edit.parse_args(strict=True) item_question = dbc.get.question(competition_id, slide_id, question_id) item_question = dbc.edit.default(item_question, **args) return item_response(schema.dump(item_question)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def delete(self, competition_id, slide_id, question_id): item_question = dbc.get.question(competition_id, slide_id, question_id) dbc.delete.question(item_question) diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index da29633a794cdb1262d2c307b8c80a94c794830e..52ce4cce1d20fc277c3ee9ec46ea3566a51ec633 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -1,30 +1,31 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import SlideDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = SlideDTO.api schema = SlideDTO.schema list_schema = SlideDTO.list_schema -slide_parser = reqparse.RequestParser() -slide_parser.add_argument("order", type=int, default=None, location="json") -slide_parser.add_argument("title", type=str, default=None, location="json") -slide_parser.add_argument("timer", type=int, default=None, location="json") -slide_parser.add_argument("background_image_id", default=None, type=int, location="json") +slide_parser_edit = reqparse.RequestParser() +slide_parser_edit.add_argument("order", type=int, default=sentinel, location="json") +slide_parser_edit.add_argument("title", type=str, default=sentinel, location="json") +slide_parser_edit.add_argument("timer", type=int, default=sentinel, location="json") +slide_parser_edit.add_argument("background_image_id", default=sentinel, type=int, location="json") @api.route("") @api.param("competition_id") class SlidesList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, competition_id): items = dbc.get.slide_list(competition_id) return list_response(list_schema.dump(items)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self, competition_id): item_slide = dbc.add.slide(competition_id) return item_response(schema.dump(item_slide)) @@ -33,21 +34,21 @@ class SlidesList(Resource): @api.route("/<slide_id>") @api.param("competition_id,slide_id") class Slides(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, competition_id, slide_id): item_slide = dbc.get.slide(competition_id, slide_id) return item_response(schema.dump(item_slide)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id): - args = slide_parser.parse_args(strict=True) + args = slide_parser_edit.parse_args(strict=True) item_slide = dbc.get.slide(competition_id, slide_id) item_slide = dbc.edit.default(item_slide, **args) return item_response(schema.dump(item_slide)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def delete(self, competition_id, slide_id): item_slide = dbc.get.slide(competition_id, slide_id) @@ -58,9 +59,9 @@ class Slides(Resource): @api.route("/<slide_id>/order") @api.param("competition_id,slide_id") class SlideOrder(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id): - args = slide_parser.parse_args(strict=True) + args = slide_parser_edit.parse_args(strict=True) order = args.get("order") item_slide = dbc.get.slide(competition_id, slide_id) @@ -87,7 +88,7 @@ class SlideOrder(Resource): @api.route("/<slide_id>/copy") @api.param("competition_id,slide_id") class SlideCopy(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id): item_slide = dbc.get.slide(competition_id, slide_id) diff --git a/server/app/apis/teams.py b/server/app/apis/teams.py index 3fcb276c959c8880be30713bf08869d6b30b8dbb..71ca715d55d21de75f07db4241e5ef0bb14e1050 100644 --- a/server/app/apis/teams.py +++ b/server/app/apis/teams.py @@ -1,29 +1,32 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import TeamDTO from flask_restx import Resource, reqparse -from flask_restx import reqparse +from app.core.parsers import sentinel api = TeamDTO.api schema = TeamDTO.schema list_schema = TeamDTO.list_schema -team_parser = reqparse.RequestParser() -team_parser.add_argument("name", type=str, location="json") +team_parser_add = reqparse.RequestParser() +team_parser_add.add_argument("name", type=str, required=True, location="json") + +team_parser_edit = reqparse.RequestParser() +team_parser_edit.add_argument("name", type=str, default=sentinel, location="json") @api.route("") @api.param("competition_id") class TeamsList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, competition_id): items = dbc.get.team_list(competition_id) return list_response(list_schema.dump(items)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def post(self, competition_id): - args = team_parser.parse_args(strict=True) + args = team_parser_add.parse_args(strict=True) item_team = dbc.add.team(args["name"], competition_id) return item_response(schema.dump(item_team)) @@ -31,21 +34,21 @@ class TeamsList(Resource): @api.route("/<team_id>") @api.param("competition_id,team_id") class Teams(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, competition_id, team_id): item = dbc.get.team(competition_id, team_id) return item_response(schema.dump(item)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def delete(self, competition_id, team_id): item_team = dbc.get.team(competition_id, team_id) dbc.delete.team(item_team) return {}, codes.NO_CONTENT - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def put(self, competition_id, team_id): - args = team_parser.parse_args(strict=True) + args = team_parser_edit.parse_args(strict=True) name = args.get("name") item_team = dbc.get.team(competition_id, team_id) diff --git a/server/app/apis/users.py b/server/app/apis/users.py index a5b2d58b01f9ce8931fd29cba3af9d1d8b825cc0..dc26ac5b64e9a4270215374de84f3c71cae66f9e 100644 --- a/server/app/apis/users.py +++ b/server/app/apis/users.py @@ -1,27 +1,27 @@ import app.core.http_codes as codes import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response +from app.apis import item_response, list_response, protect_route from app.core.dto import UserDTO from flask_jwt_extended import get_jwt_identity from flask_restx import Resource from flask_restx import inputs, reqparse -from app.core.parsers import search_parser +from app.core.parsers import search_parser, sentinel api = UserDTO.api schema = UserDTO.schema list_schema = UserDTO.list_schema -user_parser = reqparse.RequestParser() -user_parser.add_argument("email", type=inputs.email(), location="json") -user_parser.add_argument("name", type=str, location="json") -user_parser.add_argument("city_id", type=int, location="json") -user_parser.add_argument("role_id", type=int, location="json") +user_parser_edit = reqparse.RequestParser() +user_parser_edit.add_argument("email", type=inputs.email(), default=sentinel, location="json") +user_parser_edit.add_argument("name", type=str, default=sentinel, location="json") +user_parser_edit.add_argument("city_id", type=int, default=sentinel, location="json") +user_parser_edit.add_argument("role_id", type=int, default=sentinel, location="json") user_search_parser = search_parser.copy() -user_search_parser.add_argument("name", type=str, default=None, location="args") -user_search_parser.add_argument("email", type=str, default=None, location="args") -user_search_parser.add_argument("city_id", type=int, default=None, location="args") -user_search_parser.add_argument("role_id", type=int, default=None, location="args") +user_search_parser.add_argument("name", type=str, default=sentinel, location="args") +user_search_parser.add_argument("email", type=str, default=sentinel, location="args") +user_search_parser.add_argument("city_id", type=int, default=sentinel, location="args") +user_search_parser.add_argument("role_id", type=int, default=sentinel, location="args") def _edit_user(item_user, args): @@ -40,14 +40,14 @@ def _edit_user(item_user, args): @api.route("") class UsersList(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self): item = dbc.get.user(get_jwt_identity()) return item_response(schema.dump(item)) - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def put(self): - args = user_parser.parse_args(strict=True) + args = user_parser_edit.parse_args(strict=True) item = dbc.get.user(get_jwt_identity()) item = _edit_user(item, args) return item_response(schema.dump(item)) @@ -56,14 +56,14 @@ class UsersList(Resource): @api.route("/<ID>") @api.param("ID") class Users(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self, ID): item = dbc.get.user(ID) return item_response(schema.dump(item)) - @check_jwt(editor=False) + @protect_route(allowed_roles=["Admin"]) def put(self, ID): - args = user_parser.parse_args(strict=True) + args = user_parser_edit.parse_args(strict=True) item = dbc.get.user(ID) item = _edit_user(item, args) return item_response(schema.dump(item)) @@ -71,7 +71,7 @@ class Users(Resource): @api.route("/search") class UserSearch(Resource): - @check_jwt(editor=True) + @protect_route(allowed_roles=["*"]) def get(self): args = user_search_parser.parse_args(strict=True) items, total = dbc.search.user(**args) diff --git a/server/app/core/parsers.py b/server/app/core/parsers.py index c695dd230b208d95fff4eaba14f12c9732042376..160d67a0af7b58483202c00c1ec6b97097de0633 100644 --- a/server/app/core/parsers.py +++ b/server/app/core/parsers.py @@ -1,5 +1,22 @@ from flask_restx import inputs, reqparse + +class Sentinel: + """ + Sentinel is used as default argument to parsers if it isn't necessary to + supply a value. This is used instead of None so that None can be supplied + as value. + """ + + def __repr__(self): + return "Sentinel" + + def __bool__(self): + return False + + +sentinel = Sentinel() + ###SEARCH#### search_parser = reqparse.RequestParser() search_parser.add_argument("page", type=int, default=0, location="args") diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 0abe02526ef28041e7c0722fc5aa0b0bc5e7f55d..31e79e69a8c181a27bf0c9ee79f8b3ce62c19ecd 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -161,6 +161,7 @@ class ComponentSchema(BaseSchema): h = ma.auto_field() slide_id = ma.auto_field() type_id = ma.auto_field() + view_type_id = ma.auto_field() text = fields.fields.String() media = fields.Nested(MediaSchema, many=False) diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index 00b2ddf5cc9313aed0e3eded03dc05d6548a55f8..b62f82c9365818ac76faa0cd05e09bc795d0c071 100644 --- a/server/app/core/sockets.py +++ b/server/app/core/sockets.py @@ -9,7 +9,7 @@ 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] %(funcName)s: %(message)s") stream_handler = logging.StreamHandler() stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) @@ -44,7 +44,9 @@ def start_presentation(data): 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") + logger.error( + f"Client '{request.sid}' failed to start competition '{competition_id}', presentation already active" + ) return presentations[competition_id] = { @@ -58,16 +60,21 @@ def start_presentation(data): logger.info(f"Client '{request.sid}' started competition '{competition_id}'") + @sio.on("end_presentation") def end_presentation(data): 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") + 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") + 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": @@ -96,11 +103,15 @@ def join_presentation(data): competition_id = item_code.competition_id if competition_id not in presentations: - logger.error(f"Client '{request.sid}' failed to join presentation '{competition_id}', no such presentation exists") + logger.error( + f"Client '{request.sid}' failed to join presentation '{competition_id}', no such presentation exists" + ) 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") + logger.error( + f"Client '{request.sid}' failed to join presentation '{competition_id}', client already in presentation" + ) return # TODO: Write function in database controller to do this @@ -120,21 +131,29 @@ def set_slide(data): slide_order = data["slide_order"] if competition_id not in presentations: - logger.error(f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', no such presentation exists") + 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") + 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") + logger.error( + f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client is not operator" + ) return num_slides = db.session.query(Slide).filter(Slide.competition_id == competition_id).count() 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") + logger.error( + f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', slide number {slide_order} does not exist" + ) return presentations[competition_id]["slide"] = slide_order @@ -151,15 +170,21 @@ def set_timer(data): 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") + 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") + 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") + logger.error( + f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client is not operator" + ) return # TODO: Save timer in presentation, maybe? @@ -168,4 +193,3 @@ def set_timer(data): logger.debug(f"Emitting event 'set_timer' to room {competition_id} including self") logger.info(f"Client '{request.sid}' set timer '{timer}' in presentation '{competition_id}'") - diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index a83f5b95e8988a3d0024ca6d2f3b9601426147cc..50321626d0164fb01ef61d11e642bb679e729ead 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -59,7 +59,7 @@ def db_add(item): return item -def component(type_id, slide_id, x=0, y=0, w=0, h=0, **data): +def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, **data): """ Adds a component to the slide at the specified coordinates with the provided size and data . @@ -80,10 +80,10 @@ def component(type_id, slide_id, x=0, y=0, w=0, h=0, **data): h *= ratio if type_id == 1: - item = db_add(TextComponent(slide_id, type_id, x, y, w, h)) + item = db_add(TextComponent(slide_id, type_id, view_type_id, x, y, w, h)) item.text = data.get("text") elif type_id == 2: - item = db_add(ImageComponent(slide_id, type_id, x, y, w, h)) + item = db_add(ImageComponent(slide_id, type_id, view_type_id, x, y, w, h)) item.media_id = data.get("media_id") else: abort(codes.BAD_REQUEST, f"Invalid type_id{type_id}") @@ -151,9 +151,13 @@ def competition(name, year, city_id): # Add code for Judge view code(2, item_competition.id) + # Add code for Audience view code(3, item_competition.id) + # Add code for Operator view + code(4, item_competition.id) + item_competition = utils.refresh(item_competition) return item_competition diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index 7e9d28d02381dfe36e2a0c7792745618b595588e..1874b5dc548be39b1965fb2d4b041a957e26fe47 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -45,6 +45,7 @@ def _component(item_component, item_slide_new): add.component( item_component.type_id, item_slide_new.id, + item_component.view_type_id, item_component.x, item_component.y, item_component.w, diff --git a/server/app/database/controller/edit.py b/server/app/database/controller/edit.py index b1f5bc91e045e43c5f69618a685a39f86c7b081d..efb4909696cf2ae911a2451e1427b60b460f1153 100644 --- a/server/app/database/controller/edit.py +++ b/server/app/database/controller/edit.py @@ -3,6 +3,7 @@ This file contains functionality to get data from the database. """ from app.core import db +from app.core.parsers import sentinel def switch_order(item1, item2): @@ -46,7 +47,7 @@ def default(item, **kwargs): for key, value in kwargs.items(): if not hasattr(item, key): raise AttributeError(f"Item of type {type(item)} has no attribute '{key}'") - if value is not None: + if value is not sentinel: setattr(item, key, value) db.session.commit() db.session.refresh(item) diff --git a/server/app/database/models.py b/server/app/database/models.py index 3012938955f5c9e4fe1e285a0d95478ca7f7b8dd..c56aa3f0051ba6033e7376b0912cbf4f45928252 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -199,13 +199,14 @@ class Component(db.Model): __mapper_args__ = {"polymorphic_on": type_id} - def __init__(self, slide_id, type_id, x=0, y=0, w=1, h=1): + def __init__(self, slide_id, type_id, view_type_id, x=0, y=0, w=1, h=1): self.x = x self.y = y self.w = w self.h = h self.slide_id = slide_id self.type_id = type_id + self.view_type_id = view_type_id class TextComponent(Component): @@ -230,6 +231,8 @@ class Code(db.Model): competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=False) team_id = db.Column(db.Integer, db.ForeignKey("team.id"), nullable=True) + view_type = db.relationship("ViewType", uselist=False) + def __init__(self, code, view_type_id, competition_id=None, team_id=None): self.code = code self.view_type_id = view_type_id @@ -240,7 +243,6 @@ class Code(db.Model): class ViewType(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) - codes = db.relationship("Code", backref="view_type") def __init__(self, name): self.name = name diff --git a/server/populate.py b/server/populate.py index 096aa40907a9259b8db637b94bde185a919d8dd3..e0bb0bd94faf1b210069d7507d48e3f740157209 100644 --- a/server/populate.py +++ b/server/populate.py @@ -86,13 +86,13 @@ def _add_items(): y = random.randrange(1, 500) w = random.randrange(150, 400) h = random.randrange(150, 400) - dbc.add.component(1, item_slide.id, x, y, w, h,view_type_id=1, text=f"hej{k}") + dbc.add.component(1, item_slide.id, 1, x, y, w, h, text=f"hej{k}") for k in range(3): x = random.randrange(1, 500) y = random.randrange(1, 500) w = random.randrange(150, 400) h = random.randrange(150, 400) - dbc.add.component(1, item_slide.id, x, y, w, h,view_type_id=3, text=f"hej{k}") + dbc.add.component(1, item_slide.id, 3, x, y, w, h, text=f"hej{k}") # item_slide = dbc.add.slide(item_comp) # item_slide.title = f"Slide {len(item_comp.slides)}" diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 199df387e878fdbbde759a80e6aa3d9ea486ea7d..d59428a6380b74abe8853c6c09c4df0bafc031e0 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -3,7 +3,9 @@ This file tests the api function calls. """ import app.core.http_codes as codes +from app.database.controller.add import competition from app.database.models import Slide +from app.core import sockets from tests import app, client, db from tests.test_helpers import add_default_values, change_order_test, delete, get, post, put @@ -342,7 +344,7 @@ def test_question_api(client): slide_order = 1 response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) assert response.status_code == codes.OK - assert body["count"] == 1 + assert body["count"] == 2 # Get questions from another competition that should have some questions CID = 3 @@ -385,3 +387,67 @@ def test_question_api(client): response, _ = delete(client, f"/api/competitions/{CID}/slides/{NEW_slide_order}/questions/{QID}", headers=headers) assert response.status_code == codes.NOT_FOUND """ + + +def test_authorization(client): + add_default_values() + + # Fake that competition 1 is active + sockets.presentations[1] = {} + + #### TEAM #### + # Login in with team code + response, body = post(client, "/api/auth/login/code", {"code": "111111"}) + assert response.status_code == codes.OK + headers = {"Authorization": "Bearer " + body["access_token"]} + + competition_id = body["competition_id"] + team_id = body["team_id"] + + # Get competition team is in + response, body = get(client, f"/api/competitions/{competition_id}", headers=headers) + assert response.status_code == codes.OK + + # Try to delete competition team is in + response, body = delete(client, f"/api/competitions/{competition_id}", headers=headers) + assert response.status_code == codes.UNAUTHORIZED + + # Try to get a different competition + response, body = get(client, f"/api/competitions/{competition_id+1}", headers=headers) + assert response.status_code == codes.UNAUTHORIZED + + # Get own answers + response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id}/answers", headers=headers) + assert response.status_code == codes.OK + + # Try to get another teams answers + response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id+1}/answers", headers=headers) + assert response.status_code == codes.UNAUTHORIZED + + #### JUDGE #### + # Login in with judge code + response, body = post(client, "/api/auth/login/code", {"code": "222222"}) + assert response.status_code == codes.OK + headers = {"Authorization": "Bearer " + body["access_token"]} + + competition_id = body["competition_id"] + + # Get competition judge is in + response, body = get(client, f"/api/competitions/{competition_id}", headers=headers) + assert response.status_code == codes.OK + + # Try to delete competition judge is in + response, body = delete(client, f"/api/competitions/{competition_id}", headers=headers) + assert response.status_code == codes.UNAUTHORIZED + + # Try to get a different competition + response, body = get(client, f"/api/competitions/{competition_id+1}", headers=headers) + assert response.status_code == codes.UNAUTHORIZED + + # Get team answers + response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id}/answers", headers=headers) + assert response.status_code == codes.OK + + # Also get antoher teams answers + response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id+1}/answers", headers=headers) + assert response.status_code == codes.OK \ No newline at end of file diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index b5f1e54e136b759d9924a534acf2f37e6f7b08cd..f55aa68251a5a3b9ea73411c959939acc93f1bc6 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -3,14 +3,14 @@ import json import app.core.http_codes as codes import app.database.controller as dbc from app.core import db -from app.database.models import City, Role +from app.database.models import City, Code, Role def add_default_values(): media_types = ["Image", "Video"] question_types = ["Boolean", "Multiple", "Text"] component_types = ["Text", "Image"] - view_types = ["Team", "Judge", "Audience"] + view_types = ["Team", "Judge", "Audience", "Operator"] roles = ["Admin", "Editor"] cities = ["Linköping", "Testköping"] @@ -40,6 +40,20 @@ def add_default_values(): # Add competitions item_competition = dbc.add.competition("Tom tävling", 2012, item_city.id) + + item_question = dbc.add.question("hej", 5, 1, item_competition.slides[0].id) + + item_team1 = dbc.add.team("Hej lag 3", item_competition.id) + item_team2 = dbc.add.team("Hej lag 4", item_competition.id) + + db.session.add(Code("111111", 1, item_competition.id, item_team1.id)) # Team + db.session.add(Code("222222", 2, item_competition.id)) # Judge + + dbc.add.QuestionAnswer("hej", 5, item_question.id, item_team1) + dbc.add.QuestionAnswer("då", 5, item_question.id, item_team2) + + db.session.commit() + for j in range(2): item_comp = dbc.add.competition(f"Tävling {j}", 2012, item_city.id) # Add two more slides to competition @@ -59,7 +73,7 @@ def add_default_values(): # dbc.add.question(name=f"Q{i+1}", total_score=i + 1, type_id=1, slide_id=item_slide.id) # Add text component - dbc.add.component(1, item_slide.id, i, 2 * i, 3 * i, 4 * i, text="Text") + dbc.add.component(1, item_slide.id, 1, i, 2 * i, 3 * i, 4 * i, text="Text") def get_body(response):