diff --git a/client/src/Main.tsx b/client/src/Main.tsx index b7c74b556e3e072f8da96d328617d7461977b4c9..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' @@ -22,10 +22,10 @@ const Main: React.FC = () => { <Switch> <SecureRoute login exact path="/" component={LoginPage} /> <SecureRoute path="/admin" component={AdminPage} /> - <SecureRoute path="/editor/competition-id=:id" component={PresentationEditorPage} /> + <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/presentation.test.ts b/client/src/actions/presentation.test.ts index 53ea4847dcf1aaf3e1ba9a04c643d1d44b5f27f0..d95d9db767b214e4e2583a1a903ed06b226eec16 100644 --- a/client/src/actions/presentation.test.ts +++ b/client/src/actions/presentation.test.ts @@ -2,10 +2,9 @@ import mockedAxios from 'axios' import expect from 'expect' // You can use any testing library import configureMockStore from 'redux-mock-store' import thunk from 'redux-thunk' -import { Slide } from '../interfaces/Slide' +import { Slide } from '../interfaces/ApiModels' import { getPresentationCompetition, - getPresentationTeams, setCurrentSlide, setCurrentSlideNext, setCurrentSlidePrevious, @@ -26,18 +25,8 @@ it('dispatches no actions when failing to get competitions', async () => { expect(console.log).toHaveBeenCalled() }) -it('dispatches no actions when failing to get teams', async () => { - console.log = jest.fn() - ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { - return Promise.reject(new Error('getting teams failed')) - }) - const store = mockStore({ competitions: { filterParams: [] } }) - await getPresentationTeams('0')(store.dispatch) - expect(store.getActions()).toEqual([]) - expect(console.log).toHaveBeenCalled() -}) it('dispatches correct actions when setting slide', () => { - const testSlide: Slide = { competition_id: 0, id: 5, order: 5, timer: 20, title: '' } + const testSlide: Slide = { competition_id: 0, id: 5, order: 5, timer: 20, title: '', background_image_id: 0 } const expectedActions = [{ type: Types.SET_PRESENTATION_SLIDE, payload: testSlide }] const store = mockStore({}) setCurrentSlide(testSlide)(store.dispatch) diff --git a/client/src/actions/presentation.ts b/client/src/actions/presentation.ts index 9e7881d86239b1d1225a983a86ac73034d609a35..90b728c54aa18a483ed6ab000f718c407b46abfd 100644 --- a/client/src/actions/presentation.ts +++ b/client/src/actions/presentation.ts @@ -5,11 +5,11 @@ This file handles actions for the presentation redux state import axios from 'axios' import { Slide } from '../interfaces/ApiModels' import { Timer } from '../interfaces/Timer' -import store, { AppDispatch } from './../store' +import store, { AppDispatch, RootState } from './../store' import Types from './types' // Save competition in presentation state from input id -export const getPresentationCompetition = (id: string) => async (dispatch: AppDispatch) => { +export const getPresentationCompetition = (id: string) => async (dispatch: AppDispatch, getState: () => RootState) => { await axios .get(`/api/competitions/${id}`) .then((res) => { @@ -17,21 +17,9 @@ export const getPresentationCompetition = (id: string) => async (dispatch: AppDi type: Types.SET_PRESENTATION_COMPETITION, payload: res.data, }) - }) - .catch((err) => { - console.log(err) - }) -} - -// Get all teams from current presentation competition -export const getPresentationTeams = (id: string) => async (dispatch: AppDispatch) => { - await axios - .get(`/api/competitions/${id}/teams`) - .then((res) => { - dispatch({ - type: Types.SET_PRESENTATION_TEAMS, - payload: res.data.items, - }) + if (getState().presentation.slide.id === -1 && res.data.slides[0]) { + setCurrentSlideByOrder(0)(dispatch) + } }) .catch((err) => { console.log(err) diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index 2e529a8839fd374504a8d2a0e9d79939cdc3ec09..2e9fc776f4c1828427df8424ef93d9588f20afea 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -24,12 +24,12 @@ 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', SET_PRESENTATION_SLIDE_NEXT: 'SET_PRESENTATION_SLIDE_NEXT', SET_PRESENTATION_SLIDE_BY_ORDER: 'SET_PRESENTATION_SLIDE_BY_ORDER', - SET_PRESENTATION_TEAMS: 'SET_PRESENTATION_TEAMS', SET_PRESENTATION_CODE: 'SET_PRESENTATION_CODE', SET_PRESENTATION_TIMER: 'SET_PRESENTATION_TIMER', SET_CITIES: 'SET_CITIES', diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 73fe6db65f1b733abd4d0c7c7e81f1e74f907e19..a6fa68a0e9b58dcc63ad67d29884bbf3e781a64a 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -38,13 +38,14 @@ export interface Slide { order: number timer: number title: string + background_image?: Media } export interface Competition extends NameID { font: string city_id: number year: number - background_image_id: number + background_image?: Media } export interface Team extends NameID { @@ -53,7 +54,6 @@ export interface Team extends NameID { export interface Question extends NameID { slide_id: number - title: string total_score: number type_id: number } @@ -69,7 +69,7 @@ export interface QuestionAnswer { id: number question_id: number team_id: number - data: string + answer: string score: number } @@ -80,11 +80,12 @@ export interface Component { w: number h: number type_id: number + view_type_id: number + slide_id: number } export interface ImageComponent extends Component { - media_id: number - filename: string + media: Media } export interface TextComponent extends Component { @@ -93,11 +94,5 @@ export interface TextComponent extends Component { } export interface QuestionAlternativeComponent extends Component { - data: { - question_id: number - text: string - value: number - question_alternative_id: number - font: string - } + question_id: number } diff --git a/client/src/interfaces/ApiRichModels.ts b/client/src/interfaces/ApiRichModels.ts index 2388f8308ba8bfa38d4d5376040f937244f4cbd5..b9ca9f333640071518a9f216e08a77af670eb153 100644 --- a/client/src/interfaces/ApiRichModels.ts +++ b/client/src/interfaces/ApiRichModels.ts @@ -7,6 +7,7 @@ export interface RichCompetition { city_id: number slides: RichSlide[] teams: RichTeam[] + background_image?: Media } export interface RichSlide { @@ -15,9 +16,9 @@ export interface RichSlide { timer: number title: string competition_id: number + background_image?: Media components: Component[] questions: RichQuestion[] - medias: Media[] } export interface RichTeam { diff --git a/client/src/pages/admin/competitions/CompetitionManager.tsx b/client/src/pages/admin/competitions/CompetitionManager.tsx index 95b7476610e4c63ca9e4897d96dbf33d92a79002..35dbd3b6fd13e7de27bda7067e163bf3e2de834b 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.tsx @@ -129,15 +129,15 @@ const CompetitionManager: React.FC = (props: any) => { } const handleStartCompetition = () => { - history.push(`/presenter/id=${activeId}&code=123123`) + history.push(`/operator/id=${activeId}&code=123123`) } const getCodes = async () => { await axios .get(`/api/competitions/${activeId}/codes`) .then((response) => { - console.log(response.data[0]) - setCodes(response.data[0].items) + console.log(response.data) + setCodes(response.data.items) }) .catch(console.log) } 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 da18cd52fd6222b4a37c7a88bc111f0c4974ecc9..6d9bc11905e9cca28526bd27ceb0921648ef5ed7 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -1,36 +1,37 @@ -import { Button, Checkbox, CircularProgress, Divider, Menu, MenuItem, Typography } from '@material-ui/core' -import AppBar from '@material-ui/core/AppBar' -import { CheckboxProps } from '@material-ui/core/Checkbox' +import { Button, CircularProgress, Divider, Menu, MenuItem, Typography } from '@material-ui/core' import CssBaseline from '@material-ui/core/CssBaseline' -import Drawer from '@material-ui/core/Drawer' import ListItemText from '@material-ui/core/ListItemText' -import { createStyles, makeStyles, Theme, withStyles } from '@material-ui/core/styles' import AddOutlinedIcon from '@material-ui/icons/AddOutlined' -import BuildOutlinedIcon from '@material-ui/icons/BuildOutlined' -import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' -import DnsOutlinedIcon from '@material-ui/icons/DnsOutlined' -import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' 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' +import { renderSlideIcon } from '../../utils/renderSlideIcon' import { RemoveMenuItem } from '../admin/styledComp' -import { Content } from '../views/styled' +import { Content, InnerContent } from '../views/styled' import SettingsPanel from './components/SettingsPanel' -import SlideEditor from './components/SlideEditor' +import SlideDisplay from './components/SlideDisplay' import { + AppBarEditor, CenteredSpinnerContainer, HomeIcon, + LeftDrawer, + RightDrawer, PresentationEditorContainer, SlideList, SlideListItem, ToolBarContainer, ViewButton, ViewButtonGroup, + ToolbarMargin, + FillLeftContainer, + PositionBottom, + FillRightContainer, + CompetitionName, + RightPanelScroll, } from './styled' const initialState = { @@ -42,61 +43,21 @@ const initialState = { const leftDrawerWidth = 150 const rightDrawerWidth = 390 -const useStyles = makeStyles((theme: Theme) => - createStyles({ - appBar: { - width: `calc(100% - ${rightDrawerWidth}px)`, - marginLeft: leftDrawerWidth, - marginRight: rightDrawerWidth, - }, - leftDrawer: { - width: leftDrawerWidth, - flexShrink: 0, - position: 'relative', - zIndex: 1, - }, - rightDrawer: { - width: rightDrawerWidth, - flexShrink: 0, - }, - leftDrawerPaper: { - width: leftDrawerWidth, - }, - rightDrawerPaper: { - width: rightDrawerWidth, - background: '#EAEAEA', - }, - // necessary for content to be below app bar - toolbar: theme.mixins.toolbar, - content: { - flexGrow: 1, - backgroundColor: theme.palette.background.default, - padding: theme.spacing(3), - }, - alignCheckboxText: { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - paddingRight: 20, - }, - }) -) - interface CompetitionParams { - id: string + competitionId: string } const PresentationEditorPage: React.FC = () => { - const classes = useStyles() - const { id }: CompetitionParams = useParams() + 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(getEditorCompetition(id)) - dispatch(getCities()) dispatch(getTypes()) + dispatch(getEditorCompetition(competitionId)) + dispatch(getCities()) }, []) const setActiveSlideId = (id: number) => { @@ -104,8 +65,8 @@ const PresentationEditorPage: React.FC = () => { } const createNewSlide = async () => { - await axios.post(`/api/competitions/${id}/slides`, { title: 'new slide' }) - dispatch(getEditorCompetition(id)) + await axios.post(`/api/competitions/${competitionId}/slides`, { title: 'Ny sida' }) + dispatch(getEditorCompetition(competitionId)) } const [contextState, setContextState] = React.useState<{ @@ -128,84 +89,63 @@ const PresentationEditorPage: React.FC = () => { } const handleRemoveSlide = async () => { - await axios.delete(`/api/competitions/${id}/slides/${contextState.slideId}`) - dispatch(getEditorCompetition(id)) + await axios.delete(`/api/competitions/${competitionId}/slides/${contextState.slideId}`) + dispatch(getEditorCompetition(competitionId)) setContextState(initialState) } const handleDuplicateSlide = async () => { - await axios.post(`/api/competitions/${id}/slides/${contextState.slideId}/copy`) - dispatch(getEditorCompetition(id)) + await axios.post(`/api/competitions/${competitionId}/slides/${contextState.slideId}/copy`) + dispatch(getEditorCompetition(competitionId)) setContextState(initialState) } - const renderSlideIcon = (slide: RichSlide) => { - if (slide.questions && slide.questions[0] && slide.questions[0].type_id) { - switch (slide.questions[0].type_id) { - case 1: - return <CreateOutlinedIcon /> // text question - case 2: - return <BuildOutlinedIcon /> // practical qustion - case 3: - return <DnsOutlinedIcon /> // multiple choice question - } - } else { - return <InfoOutlinedIcon /> // information slide + 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)) } } - const GreenCheckbox = withStyles({ - root: { - color: '#FFFFFF', - '&$checked': { - color: '#FFFFFF', - }, - }, - checked: {}, - })((props: CheckboxProps) => <Checkbox color="default" {...props} />) - const [checkbox, setCheckbox] = useState(false) - return ( <PresentationEditorContainer> <CssBaseline /> - <AppBar position="fixed" className={classes.appBar}> + <AppBarEditor leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth} position="fixed"> <ToolBarContainer> <Button component={Link} to="/admin/tävlingshanterare" style={{ padding: 0 }}> <HomeIcon src="/t8.png" /> </Button> - <Typography variant="h6" noWrap> + <CompetitionName variant="h5" noWrap> {competition.name} - </Typography> + </CompetitionName> <ViewButtonGroup> - <GreenCheckbox checked={checkbox} onChange={(event) => setCheckbox(event.target.checked)} /> - <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> - <ViewButton variant="contained" color="secondary"> - Domarvy - </ViewButton> </ViewButtonGroup> </ToolBarContainer> - </AppBar> - <Drawer - className={classes.leftDrawer} - variant="permanent" - classes={{ - paper: classes.leftDrawerPaper, - }} - anchor="left" - > - <div className={classes.toolbar} /> - <Divider /> - <SlideList> - <div> + </AppBarEditor> + <LeftDrawer leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={undefined} variant="permanent" anchor="left"> + <FillLeftContainer leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={undefined}> + <ToolbarMargin /> + <SlideList> {competition.slides && competition.slides.map((slide) => ( <SlideListItem @@ -220,36 +160,35 @@ const PresentationEditorPage: React.FC = () => { <ListItemText primary={`Sida ${slide.order + 1}`} /> </SlideListItem> ))} - </div> - <div> + </SlideList> + <PositionBottom> <Divider /> <SlideListItem divider button onClick={() => createNewSlide()}> <ListItemText primary="Ny sida" /> <AddOutlinedIcon /> </SlideListItem> - </div> - </SlideList> - </Drawer> - <div className={classes.toolbar} /> - <Drawer - className={classes.rightDrawer} - variant="permanent" - classes={{ - paper: classes.rightDrawerPaper, - }} - anchor="right" - > - {!competitionLoading ? ( - <SettingsPanel /> - ) : ( - <CenteredSpinnerContainer> - <CircularProgress /> - </CenteredSpinnerContainer> - )} - </Drawer> + </PositionBottom> + </FillLeftContainer> + </LeftDrawer> + <ToolbarMargin /> + <RightDrawer leftDrawerWidth={undefined} rightDrawerWidth={rightDrawerWidth} variant="permanent" anchor="right"> + <FillRightContainer leftDrawerWidth={undefined} rightDrawerWidth={rightDrawerWidth}> + <RightPanelScroll> + {!competitionLoading ? ( + <SettingsPanel /> + ) : ( + <CenteredSpinnerContainer> + <CircularProgress /> + </CenteredSpinnerContainer> + )} + </RightPanelScroll> + </FillRightContainer> + </RightDrawer> <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> - <SlideEditor /> + <InnerContent> + <SlideDisplay variant="editor" activeViewTypeId={activeViewTypeId} /> + </InnerContent> </Content> <Menu keepMounted diff --git a/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx b/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14dbaa63ed2cc267a080264ebe3d7abd243220ba --- /dev/null +++ b/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx @@ -0,0 +1,126 @@ +import { ListItem, ListItemText, Typography } from '@material-ui/core' +import React, { useState } from 'react' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { + AddButton, + AddBackgroundButton, + Center, + HiddenInput, + ImportedImage, + SettingsList, + ImageNameText, + ImageTextContainer, +} from './styled' +import CloseIcon from '@material-ui/icons/Close' +import axios from 'axios' +import { Media } from '../../../interfaces/ApiModels' +import { getEditorCompetition } from '../../../actions/editor' +import { uploadFile } from '../../../utils/uploadImage' + +type BackgroundImageSelectProps = { + variant: 'competition' | 'slide' +} + +const BackgroundImageSelect = ({ variant }: BackgroundImageSelectProps) => { + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const backgroundImage = useAppSelector((state) => { + if (variant === 'competition') return state.editor.competition.background_image + else return state.editor.competition.slides.find((slide) => slide.id === activeSlideId)?.background_image + }) + const competitionId = useAppSelector((state) => state.editor.competition.id) + const dispatch = useAppDispatch() + + const updateBackgroundImage = async (mediaId: number) => { + // Creates a new image component on the database using API call. + if (variant === 'competition') { + await axios + .put(`/api/competitions/${competitionId}`, { background_image_id: mediaId }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } else { + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlideId}`, { background_image_id: mediaId }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } + } + + const removeBackgroundImage = async () => { + // Removes background image media and from competition using API calls. + await axios.delete(`/api/media/images/${backgroundImage?.id}`).catch(console.log) + if (variant === 'competition') { + await axios + .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: null }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } + } + + const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => { + // Reads the selected image file and uploads it to the server. + // Creates a new image component containing the file. + if (e.target.files !== null && e.target.files[0]) { + const files = Array.from(e.target.files) + const file = files[0] + const formData = new FormData() + formData.append('image', file) + const media = await uploadFile(formData, competitionId.toString()) + if (media) { + updateBackgroundImage(media.id) + } + } + } + + return ( + <SettingsList> + {!backgroundImage && ( + <ListItem button style={{ padding: 0 }}> + <HiddenInput + accept="image/*" + id="background-button-file" + multiple + type="file" + onChange={handleFileSelected} + /> + <AddBackgroundButton htmlFor="background-button-file"> + <Center> + <AddButton variant="button">Välj bakgrundsbild...</AddButton> + </Center> + </AddBackgroundButton> + </ListItem> + )} + {backgroundImage && ( + <> + <ListItem divider> + <ImageTextContainer> + <ListItemText>Bakgrundsbild</ListItemText> + <Typography variant="body2">(Bilden bör ha sidförhållande 16:9)</Typography> + </ImageTextContainer> + </ListItem> + <ListItem divider button> + <ImportedImage src={`/static/images/thumbnail_${backgroundImage.filename}`} /> + <Center> + <ImageNameText primary={backgroundImage.filename} /> + </Center> + <CloseIcon onClick={removeBackgroundImage} /> + </ListItem> + </> + )} + </SettingsList> + ) +} + +export default BackgroundImageSelect diff --git a/client/src/pages/presentationEditor/components/CompetitionSettings.tsx b/client/src/pages/presentationEditor/components/CompetitionSettings.tsx index 53c2ecdb4dd23cc81df0140bd2bede66dfe18b88..9482b660550e729f2a5a4c443d0f935193679b1f 100644 --- a/client/src/pages/presentationEditor/components/CompetitionSettings.tsx +++ b/client/src/pages/presentationEditor/components/CompetitionSettings.tsx @@ -1,14 +1,7 @@ import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, Divider, FormControl, InputLabel, - List, ListItem, ListItemText, MenuItem, @@ -16,84 +9,45 @@ import { TextField, Typography, } from '@material-ui/core' -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' -import CloseIcon from '@material-ui/icons/Close' +import { Center, ImportedImage, SettingsList, PanelContainer, FirstItem, AddButton } from './styled' import axios from 'axios' import React, { useState } from 'react' import { useParams } from 'react-router-dom' import { getEditorCompetition } from '../../../actions/editor' import { useAppDispatch, useAppSelector } from '../../../hooks' import { City } from '../../../interfaces/ApiModels' - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - textInputContainer: { - '& > *': { - margin: theme.spacing(1), - width: '100%', - background: 'white', - }, - }, - textInput: { - margin: theme.spacing(2), - width: '87%', - background: 'white', - }, - textCenter: { - textAlign: 'center', - }, - center: { - display: 'flex', - justifyContent: 'center', - background: 'white', - }, - importedImage: { - width: 70, - height: 50, - background: 'white', - }, - dropDown: { - margin: theme.spacing(2), - width: '87%', - background: 'white', - }, - addButtons: { - padding: 5, - }, - panelList: { - padding: 0, - }, - }) -) +import Teams from './Teams' +import BackgroundImageSelect from './BackgroundImageSelect' interface CompetitionParams { - id: string + competitionId: string } const CompetitionSettings: React.FC = () => { - const classes = useStyles() - const { id }: CompetitionParams = useParams() + const { competitionId }: CompetitionParams = useParams() const dispatch = useAppDispatch() const competition = useAppSelector((state) => state.editor.competition) + const cities = useAppSelector((state) => state.cities.cities) + const updateCompetitionName = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { await axios - .put(`/api/competitions/${id}`, { name: event.target.value }) + .put(`/api/competitions/${competitionId}`, { name: event.target.value }) .then(() => { - dispatch(getEditorCompetition(id)) + dispatch(getEditorCompetition(competitionId)) }) .catch(console.log) } - const cities = useAppSelector((state) => state.cities.cities) const updateCompetitionCity = async (city: City) => { await axios - .put(`/api/competitions/${id}`, { city_id: city.id }) + .put(`/api/competitions/${competitionId}`, { city_id: city.id }) .then(() => { - dispatch(getEditorCompetition(id)) + dispatch(getEditorCompetition(competitionId)) }) .catch(console.log) } + /* Finds the right city object from a city name */ const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => { cities.forEach((city) => { if (event.target.value === city.name) { @@ -102,109 +56,45 @@ const CompetitionSettings: React.FC = () => { }) } - const removeTeam = async (tid: number) => { - await axios - .delete(`/api/competitions/${id}/teams/${tid}`) - .then(() => { - dispatch(getEditorCompetition(id)) - }) - .catch(console.log) - } - const addTeam = async () => { - setAddTeamOpen(false) - await axios - .post(`/api/competitions/${id}/teams`, { name: selectedTeamName }) - .then(() => { - dispatch(getEditorCompetition(id)) - }) - .catch(console.log) - } - // For "add team" dialog - const [addTeamOpen, setAddTeamOpen] = useState(false) - const openAddTeam = () => { - setAddTeamOpen(true) - } - const closeAddTeam = () => { - setAddTeamOpen(false) - } - let selectedTeamName = '' - const updateSelectedTeamName = (event: React.ChangeEvent<{ value: string }>) => { - selectedTeamName = event.target.value - } - return ( - <div className={classes.textInputContainer}> - <form noValidate autoComplete="off"> - <TextField - className={classes.textInput} - id="outlined-basic" - label={'Tävlingsnamn'} - defaultValue={competition.name} - onChange={updateCompetitionName} - variant="outlined" - /> + <PanelContainer> + <SettingsList> + <FirstItem> + <ListItem> + <TextField + id="outlined-basic" + label={'Tävlingsnamn'} + defaultValue={competition.name} + onChange={updateCompetitionName} + variant="outlined" + fullWidth={true} + /> + </ListItem> + </FirstItem> <Divider /> - <FormControl variant="outlined" className={classes.dropDown}> - <InputLabel>Region</InputLabel> - <Select - value={cities.find((city) => city.id === competition.city_id)?.name || ''} - label="Region" - onChange={handleChange} - > - {cities.map((city) => ( - <MenuItem value={city.name} key={city.name}> - <Button>{city.name}</Button> - </MenuItem> - ))} - </Select> - </FormControl> - </form> - <List className={classes.panelList}> <ListItem> - <ListItemText className={classes.textCenter} primary="Lag" /> + <FormControl fullWidth variant="outlined"> + <InputLabel>Region</InputLabel> + <Select + value={cities.find((city) => city.id === competition.city_id)?.name || ''} + label="Region" + onChange={handleChange} + > + {cities.map((city) => ( + <MenuItem value={city.name} key={city.name}> + <Typography variant="button">{city.name}</Typography> + </MenuItem> + ))} + </Select> + </FormControl> </ListItem> - {competition.teams && - competition.teams.map((team) => ( - <div key={team.id}> - <ListItem divider button> - <ListItemText primary={team.name} /> - <CloseIcon onClick={() => removeTeam(team.id)} /> - </ListItem> - </div> - ))} + </SettingsList> - <ListItem className={classes.center} button onClick={openAddTeam}> - <Typography className={classes.addButtons} variant="button"> - Lägg till lag - </Typography> - </ListItem> - <Dialog open={addTeamOpen} onClose={closeAddTeam}> - <DialogTitle className={classes.center}>Lägg till lag</DialogTitle> - <DialogContent> - <DialogContentText>Skriv namnet på laget och klicka sedan på bekräfta.</DialogContentText> - <TextField autoFocus margin="dense" label="Lagnamn" fullWidth onChange={updateSelectedTeamName} /> - </DialogContent> - <DialogActions> - <Button onClick={closeAddTeam} color="secondary"> - Avbryt - </Button> - <Button onClick={addTeam} color="primary"> - Bekräfta - </Button> - </DialogActions> - </Dialog> - </List> + <Teams competitionId={competitionId} /> - <ListItem button> - <img - id="temp source, todo: add image source to elements of pictureList" - src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" - className={classes.importedImage} - /> - <ListItemText className={classes.textCenter}>Välj bakgrundsbild ...</ListItemText> - </ListItem> - </div> + <BackgroundImageSelect variant="competition" /> + </PanelContainer> ) } diff --git a/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx b/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx index 9e78f8e13fd94cf36434ef63ed7504695208de1c..eb823693c7f192489f4e0cc8e15f5f1af3f6a470 100644 --- a/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx +++ b/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx @@ -5,7 +5,15 @@ import ImageComponentDisplay from './ImageComponentDisplay' it('renders competition settings', () => { render( <ImageComponentDisplay - component={{ id: 0, x: 0, y: 0, w: 0, h: 0, data: { media_id: 0, filename: '' }, type_id: 2 }} + component={{ + id: 0, + x: 0, + y: 0, + w: 0, + h: 0, + media: { id: 0, mediatype_id: 0, user_id: 0, filename: '' }, + type_id: 2, + }} width={0} height={0} /> diff --git a/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx b/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx index 5e409d57f0e6f8a7503745760dd9078440fa732c..e783bdb22e743c345611b92bfc11d161fc2fe957 100644 --- a/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx +++ b/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx @@ -10,7 +10,7 @@ type ImageComponentProps = { const ImageComponentDisplay = ({ component, width, height }: ImageComponentProps) => { return ( <img - src={`http://localhost:5000/static/images/${component.filename}`} + src={`http://localhost:5000/static/images/${component.media?.filename}`} height={height} width={width} draggable={false} diff --git a/client/src/pages/presentationEditor/components/RndComponent.tsx b/client/src/pages/presentationEditor/components/RndComponent.tsx index c7b5933abb8144722d4348a81de59f1fd669002e..77ed5de337f5ed620e77994cea2e512166d7b7d9 100644 --- a/client/src/pages/presentationEditor/components/RndComponent.tsx +++ b/client/src/pages/presentationEditor/components/RndComponent.tsx @@ -10,22 +10,25 @@ import CheckboxComponent from './CheckboxComponent' import ImageComponentDisplay from './ImageComponentDisplay' import { HoverContainer } from './styled' import FormatAlignCenterIcon from '@material-ui/icons/FormatAlignCenter' +import TextComponentDisplay from './TextComponentDisplay' -type ImageComponentProps = { +type RndComponentProps = { component: Component width: number height: number + scale: number } -const RndComponent = ({ component, width, height }: ImageComponentProps) => { - //Makes scale close to 1, 800 height is approxemately for a 1920 by 1080 monitor - const scale = height / 800 +const RndComponent = ({ component, width, height, scale }: RndComponentProps) => { const [hover, setHover] = useState(false) const [currentPos, setCurrentPos] = useState<Position>({ x: component.x, y: component.y }) const [currentSize, setCurrentSize] = useState<Size>({ w: component.w, h: component.h }) const competitionId = useAppSelector((state) => state.editor.competition.id) const slideId = useAppSelector((state) => state.editor.activeSlideId) const [shiftPressed, setShiftPressed] = useState(false) + const typeName = useAppSelector( + (state) => state.types.componentTypes.find((componentType) => componentType.id === component.type_id)?.name + ) const handleUpdatePos = (pos: Position) => { axios.put(`/api/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { x: pos.x, @@ -68,35 +71,18 @@ const RndComponent = ({ component, width, height }: ImageComponentProps) => { const renderInnerComponent = () => { switch (component.type_id) { case ComponentTypes.Text: - return ( - <HoverContainer - hover={hover} - dangerouslySetInnerHTML={{ - __html: `<div style="font-size: ${Math.round(24 * scale)}px;">${(component as TextComponent).text}</div>`, - }} - /> - ) - case ComponentTypes.Image: return ( <HoverContainer hover={hover}> - <img - key={component.id} - src={`/static/images/${(component as ImageComponent).filename}`} - height={currentSize.h * scale} - width={currentSize.w * scale} - draggable={false} - /> + <TextComponentDisplay component={component as TextComponent} scale={scale} /> </HoverContainer> ) case ComponentTypes.Image: return ( <HoverContainer hover={hover}> - <img - key={component.id} - src={`/static/images/${(component as ImageComponent).filename}`} + <ImageComponentDisplay height={currentSize.h * scale} width={currentSize.w * scale} - draggable={false} + component={component as ImageComponent} /> </HoverContainer> ) @@ -115,6 +101,8 @@ const RndComponent = ({ component, width, height }: ImageComponentProps) => { setCurrentPos({ x: d.x / scale, y: d.y / scale }) handleUpdatePos({ x: d.x / scale, y: d.y / scale }) }} + //Makes text appear on images + style={{ zIndex: typeName === 'Text' ? 2 : 1 }} lockAspectRatio={shiftPressed} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} @@ -131,6 +119,7 @@ const RndComponent = ({ component, width, height }: ImageComponentProps) => { w: ref.offsetWidth / scale, h: ref.offsetHeight / scale, }) + setCurrentPos({ x: position.x / scale, y: position.y / scale }) }} > {hover && ( diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aef4ca7c48d29bbfc47cac8f29b214ec6866f360 --- /dev/null +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { getTypes } from '../../../actions/typesAction' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import PresentationComponent from '../../views/components/PresentationComponent' +import RndComponent from './RndComponent' +import { SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } from './styled' + +type SlideDisplayProps = { + //Prop to distinguish between editor and active competition + variant: 'editor' | 'presentation' + activeViewTypeId: number +} + +const SlideDisplay = ({ variant, activeViewTypeId }: SlideDisplayProps) => { + const components = useAppSelector((state) => { + 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 (variant === 'editor') return state.editor.competition.background_image + return state.presentation.competition.background_image + }) + + const slideBackgroundImage = useAppSelector((state) => { + 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 + }) + const dispatch = useAppDispatch() + const editorPaperRef = useRef<HTMLDivElement>(null) + const [width, setWidth] = useState(0) + const [height, setHeight] = useState(0) + //Makes scale close to 1, 800 height is approxemately for a 1920 by 1080 monitor + const scale = height / 800 + useEffect(() => { + dispatch(getTypes()) + }, []) + + useLayoutEffect(() => { + const updateScale = () => { + if (editorPaperRef.current) { + setWidth(editorPaperRef.current.clientWidth) + setHeight(editorPaperRef.current.clientHeight) + } + } + window.addEventListener('resize', updateScale) + updateScale() + return () => window.removeEventListener('resize', updateScale) + }, []) + return ( + <SlideEditorContainer> + <SlideEditorContainerRatio> + <SlideEditorPaper ref={editorPaperRef}> + {(competitionBackgroundImage || slideBackgroundImage) && ( + <img + src={`/static/images/${ + slideBackgroundImage ? slideBackgroundImage.filename : competitionBackgroundImage?.filename + }`} + height={height} + width={width} + draggable={false} + /> + )} + {components && + 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 ( + <PresentationComponent + height={height} + width={width} + key={component.id} + component={component} + scale={scale} + /> + ) + })} + </SlideEditorPaper> + </SlideEditorContainerRatio> + </SlideEditorContainer> + ) +} + +export default SlideDisplay diff --git a/client/src/pages/presentationEditor/components/SlideEditor.tsx b/client/src/pages/presentationEditor/components/SlideEditor.tsx deleted file mode 100644 index b385bb2058fe73987f1a649456c6fe0adad10c56..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/SlideEditor.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { getTypes } from '../../../actions/typesAction' -import { useAppDispatch, useAppSelector } from '../../../hooks' -import RndComponent from './RndComponent' -import { SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } from './styled' - -const SlideEditor: React.FC = () => { - const components = useAppSelector( - (state) => - state.editor.competition.slides.find((slide) => slide && slide.id === state.editor.activeSlideId)?.components - ) - const dispatch = useAppDispatch() - const editorPaperRef = useRef<HTMLDivElement>(null) - const [width, setWidth] = useState(0) - const [height, setHeight] = useState(0) - useEffect(() => { - dispatch(getTypes()) - }, []) - - useLayoutEffect(() => { - const updateScale = () => { - if (editorPaperRef.current) { - setWidth(editorPaperRef.current.clientWidth) - setHeight(editorPaperRef.current.clientHeight) - } - } - window.addEventListener('resize', updateScale) - updateScale() - return () => window.removeEventListener('resize', updateScale) - }, []) - return ( - <SlideEditorContainer> - <SlideEditorContainerRatio> - <SlideEditorPaper ref={editorPaperRef}> - {components && - components.map((component) => ( - <RndComponent height={height} width={width} key={component.id} component={component} /> - ))} - </SlideEditorPaper> - </SlideEditorContainerRatio> - </SlideEditorContainer> - ) -} - -export default SlideEditor diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index d0b72e39b4c7b83398350ac87be391c19681460c..d48b4565712d043d52d3b1e93e3633ec6b927d38 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -4,51 +4,61 @@ import { Divider, List, ListItem, ListItemText, TextField, Typography } from '@m import React, { useState } from 'react' import { useParams } from 'react-router-dom' import { useAppSelector } from '../../../hooks' -import Alternatives from './Alternatives' -import SlideType from './SlideType' -import { Center, ImportedImage, SettingsList, SlidePanel } from './styled' -import Timer from './Timer' -import Images from './Images' -import Texts from './Texts' +import Instructions from './slideSettingsComponents/Instructions' +import MultipleChoiceAlternatives from './slideSettingsComponents/MultipleChoiceAlternatives' +import SlideType from './slideSettingsComponents/SlideType' +import { Center, ImportedImage, SettingsList, PanelContainer } from './styled' +import Timer from './slideSettingsComponents/Timer' +import Images from './slideSettingsComponents/Images' +import Texts from './slideSettingsComponents/Texts' +import QuestionSettings from './slideSettingsComponents/QuestionSettings' +import BackgroundImageSelect from './BackgroundImageSelect' interface CompetitionParams { - id: string + competitionId: string } const SlideSettings: React.FC = () => { - const { id }: CompetitionParams = useParams() + const { competitionId }: CompetitionParams = useParams() const activeSlide = useAppSelector((state) => // 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 ( - <SlidePanel> + <PanelContainer> <SettingsList> - {activeSlide && <SlideType activeSlide={activeSlide} competitionId={id} />} + {activeSlide && <SlideType activeSlide={activeSlide} competitionId={competitionId} />} <Divider /> - {activeSlide && <Timer activeSlide={activeSlide} competitionId={id} />} + {activeSlide && <Timer activeSlide={activeSlide} competitionId={competitionId} />} </SettingsList> - {activeSlide && <Alternatives activeSlide={activeSlide} competitionId={id} />} + {activeSlide?.questions[0] && <QuestionSettings activeSlide={activeSlide} competitionId={competitionId} />} + { + // Choose answer alternatives depending on the slide type + } + {activeSlide?.questions[0]?.type_id === 1 && ( + <Instructions activeSlide={activeSlide} competitionId={competitionId} /> + )} + {activeSlide?.questions[0]?.type_id === 2 && ( + <Instructions activeSlide={activeSlide} competitionId={competitionId} /> + )} + {activeSlide?.questions[0]?.type_id === 3 && ( + <MultipleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> + )} - {activeSlide && <Texts activeSlide={activeSlide} competitionId={id} />} + {activeSlide && ( + <Texts activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> + )} - {activeSlide && <Images activeSlide={activeSlide} competitionId={id} />} + {activeSlide && ( + <Images activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> + )} - <SettingsList> - <ListItem button> - <ImportedImage - id="temp source, todo: add image source to elements of pictureList" - src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" - /> - <Center> - <ListItemText>Välj bakgrundsbild ...</ListItemText> - </Center> - </ListItem> - </SettingsList> - </SlidePanel> + <BackgroundImageSelect variant="slide" /> + </PanelContainer> ) } diff --git a/client/src/pages/presentationEditor/components/Teams.tsx b/client/src/pages/presentationEditor/components/Teams.tsx new file mode 100644 index 0000000000000000000000000000000000000000..564dbc847638f1d16e6d09b7ae96019e02856a07 --- /dev/null +++ b/client/src/pages/presentationEditor/components/Teams.tsx @@ -0,0 +1,100 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + ListItem, + ListItemText, + TextField, +} from '@material-ui/core' +import axios from 'axios' +import React, { useState } from 'react' +import { getEditorCompetition } from '../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { Center, Clickable } from './styled' +import { AddButton, SettingsList } from './styled' +import CloseIcon from '@material-ui/icons/Close' + +type TeamsProps = { + competitionId: string +} + +const Teams = ({ competitionId }: TeamsProps) => { + const dispatch = useAppDispatch() + const competition = useAppSelector((state) => state.editor.competition) + const addTeam = async () => { + setAddTeamOpen(false) + await axios + .post(`/api/competitions/${competitionId}/teams`, { name: selectedTeamName }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + // For "add team" dialog + const [addTeamOpen, setAddTeamOpen] = useState(false) + const openAddTeam = () => { + setAddTeamOpen(true) + } + const closeAddTeam = () => { + setAddTeamOpen(false) + } + let selectedTeamName = '' + const updateSelectedTeamName = (event: React.ChangeEvent<{ value: string }>) => { + selectedTeamName = event.target.value + } + + const removeTeam = async (tid: number) => { + await axios + .delete(`/api/competitions/${competitionId}/teams/${tid}`) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Lag" /> + </Center> + </ListItem> + {competition.teams && + competition.teams.map((team) => ( + <div key={team.id}> + <ListItem divider> + <ListItemText primary={team.name} /> + <Clickable> + <CloseIcon onClick={() => removeTeam(team.id)} /> + </Clickable> + </ListItem> + </div> + ))} + <ListItem button onClick={openAddTeam}> + <Center> + <AddButton variant="button">Lägg till lag</AddButton> + </Center> + </ListItem> + <Dialog open={addTeamOpen} onClose={closeAddTeam}> + <DialogTitle>Lägg till lag</DialogTitle> + <DialogContent> + <DialogContentText>Skriv namnet på laget och klicka sedan på bekräfta.</DialogContentText> + <TextField autoFocus margin="dense" label="Lagnamn" fullWidth onChange={updateSelectedTeamName} /> + </DialogContent> + <DialogActions> + <Button onClick={closeAddTeam} color="secondary"> + Avbryt + </Button> + <Button onClick={addTeam} color="primary"> + Bekräfta + </Button> + </DialogActions> + </Dialog> + </SettingsList> + ) +} + +export default Teams diff --git a/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b2e11200e4cd14789ebd7945dd7a10b83107d740 --- /dev/null +++ b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { ImageComponent, TextComponent } from '../../../interfaces/ApiModels' + +type TextComponentDisplayProps = { + component: TextComponent + scale: number +} + +const ImageComponentDisplay = ({ component, scale }: TextComponentDisplayProps) => { + return ( + <div + dangerouslySetInnerHTML={{ + __html: `<div style="font-size: ${Math.round(24 * scale)}px;">${component.text}</div>`, + }} + /> + ) +} + +export default ImageComponentDisplay diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx index f50e547a8d1e7f5eadadca1ad1d531a46cc0410e..f6cda5760ec8aff5bce211a8caf7d2435a8d5c61 100644 --- a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx @@ -12,23 +12,23 @@ type ImageComponentProps = { } interface CompetitionParams { - id: string + competitionId: string } const TextComponentEdit = ({ component }: ImageComponentProps) => { - const { id }: CompetitionParams = useParams() - const competitionId = useAppSelector((state) => state.editor.competition.id) + const { competitionId }: CompetitionParams = useParams() 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(() => { setContent(component.text) }, []) - const handleSaveText = async (a: string) => { - setContent(a) + const handleSaveText = async (newText: string) => { + setContent(newText) if (timerHandle) { clearTimeout(timerHandle) setTimerHandle(undefined) @@ -38,16 +38,16 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { window.setTimeout(async () => { console.log('Content was updated on server. id: ', component.id) await axios.put(`/api/competitions/${competitionId}/slides/${activeSlideId}/components/${component.id}`, { - data: { ...component, text: a }, + text: newText, }) - dispatch(getEditorCompetition(id)) + dispatch(getEditorCompetition(competitionId)) }, 250) ) } const handleDeleteText = async (componentId: number) => { - await axios.delete(`/api/competitions/${id}/slides/${activeSlideId}/components/${componentId}`) - dispatch(getEditorCompetition(id)) + await axios.delete(`/api/competitions/${competitionId}/slides/${activeSlideId}/components/${componentId}`) + dispatch(getEditorCompetition(competitionId)) } return ( @@ -57,8 +57,16 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { init={{ height: '300px', menubar: false, + font_formats: + ' Andale Mono=andale mono,times; Arial=arial,helvetica,sans-serif;\ + Arial Black=arial black,avant garde; Book Antiqua=book antiqua,palatino; Calibri=calibri;\ + Comic Sans MS=comic sans ms,sans-serif; Courier New=courier new,courier;\ + Georgia=georgia,palatino; Helvetica=helvetica; Impact=impact,chicago; Symbol=symbol;\ + Tahoma=tahoma,arial,helvetica,sans-serif; Terminal=terminal,monaco;\ + Times New Roman=times new roman,times; Trebuchet MS=trebuchet ms,geneva;\ + Verdana=verdana,geneva; Webdings=webdings; Wingdings=wingdings,zapf dingbats', fontsize_formats: '8pt 9pt 10pt 11pt 12pt 14pt 18pt 24pt 30pt 36pt 48pt 60pt 72pt 96pt 120pt 144pt', - content_style: 'body {font-size: 24pt;}', + content_style: 'body {font-size: 24pt; font-family: Calibri;}', plugins: [ 'advlist autolink lists link image charmap print preview anchor', 'searchreplace visualblocks code fullscreen', diff --git a/client/src/pages/presentationEditor/components/Images.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx similarity index 67% rename from client/src/pages/presentationEditor/components/Images.tsx rename to client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx index b715ba2c4482d6d074f53409fbb1f4abab996409..49f41e7f87fa6c53dac59704540ca3c186ef708b 100644 --- a/client/src/pages/presentationEditor/components/Images.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx @@ -2,32 +2,22 @@ */ import { ListItem, ListItemText, Typography } from '@material-ui/core' import CloseIcon from '@material-ui/icons/Close' -import React, { useState } from 'react' -import { useDispatch } from 'react-redux' -import { - Center, - HiddenInput, - SettingsList, - AddImageButton, - ImportedImage, - WhiteBackground, - AddButton, - Clickable, - NoPadding, -} from './styled' +import React from 'react' +import { Center, HiddenInput, SettingsList, AddImageButton, ImportedImage, AddButton } from '../styled' import axios from 'axios' -import { getEditorCompetition } from '../../../actions/editor' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { ImageComponent, Media } from '../../../interfaces/ApiModels' -import { useAppSelector } from '../../../hooks' +import { getEditorCompetition } from '../../../../actions/editor' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +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 dispatch = useDispatch() +const Images = ({ activeViewTypeId, activeSlide, competitionId }: ImagesProps) => { + const dispatch = useAppDispatch() const uploadFile = async (formData: FormData) => { // Uploads the file to the server and creates a Media object in database. @@ -48,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) @@ -67,7 +58,7 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { formData.append('image', file) const response = await uploadFile(formData) if (response) { - const newComponent = createImageComponent(response) + createImageComponent(response) } } } @@ -75,7 +66,7 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { const handleCloseimageClick = async (image: ImageComponent) => { // Removes selected image component and deletes its file from the server. await axios - .delete(`/api/media/images/${image.media_id}`) + .delete(`/api/media/images/${image.media?.id}`) .then(() => { dispatch(getEditorCompetition(competitionId)) }) @@ -99,32 +90,32 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { return ( <SettingsList> - <WhiteBackground> - <ListItem divider> - <Center> - <ListItemText primary="Bilder" /> - </Center> - </ListItem> - {images && - images.map((image) => ( + <ListItem divider> + <Center> + <ListItemText primary="Bilder" /> + </Center> + </ListItem> + {images && + 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.filename}`} /> + <ImportedImage src={`http://localhost:5000/static/images/thumbnail_${image.media?.filename}`} /> <Center> - <ListItemText primary={image.filename} /> + <ListItemText primary={image.media?.filename} /> </Center> <CloseIcon onClick={() => handleCloseimageClick(image)} /> </ListItem> </div> ))} - <ListItem button> - <HiddenInput accept="image/*" id="contained-button-file" multiple type="file" onChange={handleFileSelected} /> - <AddImageButton htmlFor="contained-button-file"> - <AddButton variant="button">Lägg till bild</AddButton> - </AddImageButton> - </ListItem> - </WhiteBackground> + <ListItem button style={{ padding: 0 }}> + <HiddenInput accept="image/*" id="contained-button-file" multiple type="file" onChange={handleFileSelected} /> + <AddImageButton htmlFor="contained-button-file"> + <AddButton variant="button">Lägg till bild</AddButton> + </AddImageButton> + </ListItem> </SettingsList> ) } diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..858dd75e65dc554cc6e1c2083f46873a925335a1 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx @@ -0,0 +1,70 @@ +import { ListItem, ListItemText, TextField, withStyles } from '@material-ui/core' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, SettingsList } from '../styled' + +type InstructionsProps = { + activeSlide: RichSlide + competitionId: string +} + +const Instructions = ({ activeSlide, competitionId }: InstructionsProps) => { + const dispatch = useAppDispatch() + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + + const updateInstructionsText = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates 250ms after last input was made to not spam + setTimerHandle( + window.setTimeout(async () => { + console.log('Content was updated on server. id: ', activeSlide.questions[0].id) + if (activeSlide && activeSlide.questions[0]) { + await axios + // TODO: Implement instructions field in question and add put API + .put( + `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, + { + instructions: event.target.value, + } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + }, 250) + ) + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText + primary="Rättningsinstruktioner" + secondary="Den här texten kommer endast att visas för domarna." + /> + </Center> + </ListItem> + <ListItem divider> + <Center> + <TextField + id="outlined-basic" + defaultValue={''} + onChange={updateInstructionsText} + variant="outlined" + fullWidth={true} + /> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default Instructions diff --git a/client/src/pages/presentationEditor/components/Alternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx similarity index 59% rename from client/src/pages/presentationEditor/components/Alternatives.tsx rename to client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx index e699d003f7f3433e2caf25a26185ad5141c6f694..58053995856730b22a16d4401af17da5628ab87a 100644 --- a/client/src/pages/presentationEditor/components/Alternatives.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx @@ -4,20 +4,19 @@ import { green, grey } from '@material-ui/core/colors' import CloseIcon from '@material-ui/icons/Close' import axios from 'axios' import React from 'react' -import { getEditorCompetition } from '../../../actions/editor' -import { useAppDispatch, useAppSelector } from '../../../hooks' -import { QuestionAlternative } from '../../../interfaces/ApiModels' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { AddButton, Center, Clickable, SettingsList, TextInput, WhiteBackground } from './styled' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AlternativeTextField, Center, Clickable, SettingsList } from '../styled' -type AlternativeProps = { +type MultipleChoiceAlternativeProps = { activeSlide: RichSlide competitionId: string } -const Alternatives = ({ activeSlide, competitionId }: AlternativeProps) => { +const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoiceAlternativeProps) => { const dispatch = useAppDispatch() - const competition = useAppSelector((state) => state.editor.competition) const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) const GreenCheckbox = withStyles({ root: { @@ -95,42 +94,40 @@ const Alternatives = ({ activeSlide, competitionId }: AlternativeProps) => { return ( <SettingsList> - <WhiteBackground> - <ListItem divider> - <Center> - <ListItemText - primary="Svarsalternativ" - secondary="(Fyll i rutan höger om textfältet för att markera korrekt svar)" - /> - </Center> - </ListItem> - {activeSlide && - activeSlide.questions[0] && - activeSlide.questions[0].alternatives && - activeSlide.questions[0].alternatives.map((alt) => ( - <div key={alt.id}> - <ListItem divider> - <TextInput - id="outlined-basic" - defaultValue={alt.text} - onChange={(event) => updateAlternativeText(alt.id, event.target.value)} - variant="outlined" - /> - <GreenCheckbox checked={numberToBool(alt.value)} onChange={() => updateAlternativeValue(alt)} /> - <Clickable> - <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> - </Clickable> - </ListItem> - </div> - ))} - <ListItem button onClick={addAlternative}> - <Center> - <AddButton variant="button">Lägg till svarsalternativ</AddButton> - </Center> - </ListItem> - </WhiteBackground> + <ListItem divider> + <Center> + <ListItemText + primary="Svarsalternativ" + secondary="(Fyll i rutan höger om textfältet för att markera korrekt svar)" + /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <AlternativeTextField + id="outlined-basic" + defaultValue={alt.text} + onChange={(event) => updateAlternativeText(alt.id, event.target.value)} + variant="outlined" + /> + <GreenCheckbox checked={numberToBool(alt.value)} onChange={() => updateAlternativeValue(alt)} /> + <Clickable> + <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> + </Clickable> + </ListItem> + </div> + ))} + <ListItem button onClick={addAlternative}> + <Center> + <AddButton variant="button">Lägg till svarsalternativ</AddButton> + </Center> + </ListItem> </SettingsList> ) } -export default Alternatives +export default MultipleChoiceAlternatives diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2bc10b3588133245d54767dbbc7d8381e4134b59 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx @@ -0,0 +1,87 @@ +import { ListItem, ListItemText, TextField } from '@material-ui/core' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, SettingsList } from '../styled' + +type QuestionSettingsProps = { + activeSlide: RichSlide + competitionId: string +} + +const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) => { + const dispatch = useAppDispatch() + + const updateQuestion = async ( + updateTitle: boolean, + event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement> + ) => { + console.log('Content was updated on server. id: ', activeSlide.questions[0].id) + if (activeSlide && activeSlide.questions[0]) { + if (updateTitle) { + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, { + name: event.target.value, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } else { + setScore(+event.target.value) + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, { + total_score: event.target.value, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + } + + const [score, setScore] = useState<number | undefined>(0) + useEffect(() => { + setScore(activeSlide?.questions[0]?.total_score) + }, [activeSlide]) + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Frågeinställningar" secondary="" /> + </Center> + </ListItem> + <ListItem divider> + <TextField + id="outlined-basic" + defaultValue={''} + label="Frågans titel" + onChange={(event) => updateQuestion(true, event)} + variant="outlined" + fullWidth={true} + /> + </ListItem> + <ListItem> + <Center> + <TextField + fullWidth={true} + variant="outlined" + placeholder="Antal poäng" + helperText="Välj hur många poäng frågan ska ge för rätt svar." + label="Poäng" + type="number" + InputProps={{ inputProps: { min: 0 } }} + value={score} + onChange={(event) => updateQuestion(false, event)} + /> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default QuestionSettings diff --git a/client/src/pages/presentationEditor/components/SlideType.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx similarity index 55% rename from client/src/pages/presentationEditor/components/SlideType.tsx rename to client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx index ba104fe6ce7088d6aec076359d9f3f1361de3262..bc251b91e092c05457db1b64bb8bbe566d76dd55 100644 --- a/client/src/pages/presentationEditor/components/SlideType.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx @@ -5,6 +5,7 @@ import { DialogContent, DialogContentText, DialogTitle, + FormControl, InputLabel, ListItem, MenuItem, @@ -13,10 +14,10 @@ import { } from '@material-ui/core' import axios from 'axios' import React, { useState } from 'react' -import { getEditorCompetition } from '../../../actions/editor' -import { useAppDispatch } from '../../../hooks' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { Center, FormControlDropdown, SlideTypeInputLabel, WhiteBackground } from './styled' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, FirstItem } from '../styled' type SlideTypeProps = { activeSlide: RichSlide @@ -85,52 +86,55 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { } } return ( - <WhiteBackground> - <FormControlDropdown variant="outlined"> - <SlideTypeInputLabel>Sidtyp</SlideTypeInputLabel> - <Select fullWidth={true} value={activeSlide?.questions[0]?.type_id || 0} label="Sidtyp"> - <MenuItem value={0}> - <Typography variant="button" onClick={() => openSlideTypeDialog(0)}> - Informationssida - </Typography> - </MenuItem> - <MenuItem value={1}> - <Typography variant="button" onClick={() => openSlideTypeDialog(1)}> - Skriftlig fråga - </Typography> - </MenuItem> - <MenuItem value={2}> - <Typography variant="button" onClick={() => openSlideTypeDialog(2)}> - Praktisk fråga - </Typography> - </MenuItem> - <MenuItem value={3}> - <Typography variant="button" onClick={() => openSlideTypeDialog(3)}> - Flervalsfråga - </Typography> - </MenuItem> - </Select> - </FormControlDropdown> - <Dialog open={slideTypeDialog} onClose={closeSlideTypeDialog}> - <Center> - <DialogTitle color="secondary">Varning!</DialogTitle> - </Center> - <DialogContent> - <DialogContentText> - Om du ändrar sidtypen kommer eventuella frågeinställningar gå förlorade. Det inkluderar: frågans namn, poäng - och svarsalternativ.{' '} - </DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={closeSlideTypeDialog} color="secondary"> - Avbryt - </Button> - <Button onClick={updateSlideType} color="primary"> - Bekräfta - </Button> - </DialogActions> - </Dialog> - </WhiteBackground> + <FirstItem> + <ListItem> + <FormControl fullWidth variant="outlined"> + <InputLabel>Sidtyp</InputLabel> + <Select fullWidth={true} value={activeSlide?.questions[0]?.type_id || 0} label="Sidtyp"> + <MenuItem value={0}> + <Typography variant="button" onClick={() => openSlideTypeDialog(0)}> + Informationssida + </Typography> + </MenuItem> + <MenuItem value={1}> + <Typography variant="button" onClick={() => openSlideTypeDialog(1)}> + Skriftlig fråga + </Typography> + </MenuItem> + <MenuItem value={2}> + <Typography variant="button" onClick={() => openSlideTypeDialog(2)}> + Praktisk fråga + </Typography> + </MenuItem> + <MenuItem value={3}> + <Typography variant="button" onClick={() => openSlideTypeDialog(3)}> + Flervalsfråga + </Typography> + </MenuItem> + </Select> + </FormControl> + + <Dialog open={slideTypeDialog} onClose={closeSlideTypeDialog}> + <Center> + <DialogTitle color="secondary">Varning!</DialogTitle> + </Center> + <DialogContent> + <DialogContentText> + Om du ändrar sidtypen kommer eventuella frågeinställningar gå förlorade. Det inkluderar: frågans namn, + poäng och svarsalternativ.{' '} + </DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={closeSlideTypeDialog} color="secondary"> + Avbryt + </Button> + <Button onClick={updateSlideType} color="primary"> + Bekräfta + </Button> + </DialogActions> + </Dialog> + </ListItem> + </FirstItem> ) } diff --git a/client/src/pages/presentationEditor/components/Texts.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx similarity index 57% rename from client/src/pages/presentationEditor/components/Texts.tsx rename to client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx index 31ecf57cd29325972328647290fc74de7c9827bd..03656614e3076e6048984e70b7ddd0ae711a492d 100644 --- a/client/src/pages/presentationEditor/components/Texts.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx @@ -1,20 +1,20 @@ import { Divider, ListItem, ListItemText, Typography } from '@material-ui/core' import React from 'react' -import { useAppSelector } from '../../../hooks' -import { TextComponent } from '../../../interfaces/ApiModels' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { AddButton, Center, SettingsList, TextCard } from './styled' -import TextComponentEdit from './TextComponentEdit' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { TextComponent } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, Center, SettingsList, TextCard } from '../styled' +import TextComponentEdit from '../TextComponentEdit' import axios from 'axios' -import { getEditorCompetition } from '../../../actions/editor' -import { useDispatch } from 'react-redux' +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 @@ -22,7 +22,7 @@ const Texts = ({ activeSlide, competitionId }: TextsProps) => { ?.components.filter((component) => component.type_id === 1) as TextComponent[] ) - const dispatch = useDispatch() + const dispatch = useAppDispatch() const handleAddText = async () => { if (activeSlide) { await axios.post(`/api/competitions/${competitionId}/slides/${activeSlide?.id}/components`, { @@ -30,6 +30,7 @@ const Texts = ({ activeSlide, competitionId }: TextsProps) => { text: 'Ny text', w: 315, h: 50, + view_type_id: activeViewTypeId, }) dispatch(getEditorCompetition(competitionId)) } @@ -43,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/components/Timer.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx similarity index 50% rename from client/src/pages/presentationEditor/components/Timer.tsx rename to client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx index 124635f423df9d0bfcca20a9a02309c646cb3dca..91825662dbc65ec851c093ddf2aa48ddb98ce404 100644 --- a/client/src/pages/presentationEditor/components/Timer.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx @@ -1,10 +1,10 @@ import { ListItem, TextField } from '@material-ui/core' import axios from 'axios' import React, { useEffect, useState } from 'react' -import { getEditorCompetition } from '../../../actions/editor' -import { useAppDispatch } from '../../../hooks' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { Center, WhiteBackground } from './styled' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center } from '../styled' type TimerProps = { activeSlide: RichSlide @@ -24,29 +24,26 @@ const Timer = ({ activeSlide, competitionId }: TimerProps) => { .catch(console.log) } } - const [timer, setTimer] = useState<number | undefined>(0) + const [timer, setTimer] = useState<number | undefined>(activeSlide?.timer) useEffect(() => { setTimer(activeSlide?.timer) }, [activeSlide]) return ( - <WhiteBackground> - <ListItem> - <Center> - <TextField - id="standard-number" - fullWidth={true} - variant="outlined" - placeholder="Antal sekunder" - helperText="Lämna blank för att inte använda timerfunktionen" - label="Timer" - type="number" - defaultValue={activeSlide?.timer || 0} - onChange={updateTimer} - value={timer} - /> - </Center> - </ListItem> - </WhiteBackground> + <ListItem> + <Center> + <TextField + id="standard-number" + fullWidth={true} + variant="outlined" + placeholder="Antal sekunder" + helperText="Lämna blank för att inte använda timerfunktionen" + label="Timer" + type="number" + onChange={updateTimer} + value={timer} + /> + </Center> + </ListItem> ) } diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index 2a3959c7edee69c190215af53b91c4ae7f2aebf9..605972d2c4ee798388f9437f373cbabeab777d3c 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -9,6 +9,7 @@ import { ListItem, Select, InputLabel, + ListItemText, } from '@material-ui/core' import styled from 'styled-components' @@ -18,17 +19,15 @@ export const SettingsTab = styled(Tab)` ` export const SlideEditorContainer = styled.div` + overflow: hidden; height: 100%; display: flex; align-items: center; justify-content: center; - background-color: rgba(0, 0, 0, 0.08); ` export const SlideEditorContainerRatio = styled.div` - padding-top: 56.25%; width: 100%; - height: 0; overflow: hidden; padding-top: 56.25%; position: relative; @@ -56,30 +55,15 @@ export const ToolbarPadding = styled.div` padding-top: 55px; ` -export const FormControlDropdown = styled(FormControl)` - width: 100%; - margin-top: 10px; - padding: 8px; - padding-left: 16px; - padding-right: 16px; -` - -export const SlideTypeInputLabel = styled(InputLabel)` +export const FirstItem = styled.div` width: 100%; - padding: 10px; - padding-left: 22px; + padding-top: 10px; ` -export const TextInput = styled(TextField)` +export const AlternativeTextField = styled(TextField)` width: 87%; ` -export const NoPadding = styled.div` - padding: 0; - height: 100%; - width: 100%; -` - export const Center = styled.div` display: flex; justify-content: center; @@ -88,20 +72,21 @@ export const Center = styled.div` width: 100%; ` -export const SlidePanel = styled.div` - padding: 10px; +export const ImageTextContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; width: 100%; ` -export const WhiteBackground = styled.div` - background: white; +export const PanelContainer = styled.div` + padding: 10px; + width: 100%; ` export const AddButton = styled(Typography)` - padding-left: 8px; - padding-right: 8px; - padding-top: 7px; - padding-bottom: 7px; + padding: 7px 8px 7px 8px; ` export const ImportedImage = styled.img` @@ -114,8 +99,17 @@ export const Clickable = styled.div` ` export const AddImageButton = styled.label` - padding: 0; - cursor: 'pointer'; + padding: 8px 13px 8px 13px; + display: flex; + justify-content: center; + text-align: center; + height: 100%; + width: 100%; + cursor: pointer; +` + +export const AddBackgroundButton = styled.label` + padding: 16px 29px 16px 29px; display: flex; justify-content: center; text-align: center; @@ -150,3 +144,7 @@ export const HoverContainer = styled.div<HoverContainerProps>` padding: ${(props) => (props.hover ? 0 : 1)}px; border: solid ${(props) => (props.hover ? 1 : 0)}px; ` + +export const ImageNameText = styled(ListItemText)` + word-break: break-all; +` diff --git a/client/src/pages/presentationEditor/styled.tsx b/client/src/pages/presentationEditor/styled.tsx index d1f05d6bf18de2eccc6b486fb6e94701acce3b5c..c02054090f9b597fc32f1cacc03d145f3b77d9fe 100644 --- a/client/src/pages/presentationEditor/styled.tsx +++ b/client/src/pages/presentationEditor/styled.tsx @@ -1,14 +1,32 @@ -import { Button, List, ListItem, Toolbar } from '@material-ui/core' +import { AppBar, Button, Drawer, List, ListItem, Toolbar, Typography } from '@material-ui/core' import styled from 'styled-components' +interface ViewButtonProps { + $activeView: boolean +} + +interface DrawerSizeProps { + leftDrawerWidth: number | undefined + rightDrawerWidth: number | undefined +} + +const AppBarHeight = 64 +const SlideListHeight = 60 + export const ToolBarContainer = styled(Toolbar)` display: flex; justify-content: space-between; padding-left: 0; ` -export const ViewButton = styled(Button)` +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` @@ -17,16 +35,19 @@ export const ViewButtonGroup = styled.div` ` export const SlideList = styled(List)` - height: 100%; - display: flex; - flex-direction: column; - justify-content: space-between; + height: calc(100% - ${SlideListHeight}px); + padding: 0px; + overflow-y: auto; +` + +export const RightPanelScroll = styled(List)` padding: 0px; + overflow-y: auto; ` export const SlideListItem = styled(ListItem)` text-align: center; - height: 60px; + height: ${SlideListHeight}px; ` export const PresentationEditorContainer = styled.div` @@ -41,5 +62,54 @@ export const CenteredSpinnerContainer = styled.div` ` export const HomeIcon = styled.img` - height: 64px; + height: ${AppBarHeight}px; +` + +export const LeftDrawer = styled(Drawer)<DrawerSizeProps>` + width: ${(props) => (props ? props.leftDrawerWidth : 0)}px; + flex-shrink: 0; + position: relative; + z-index: 1; +` + +export const RightDrawer = styled(Drawer)<DrawerSizeProps>` + width: ${(props) => (props ? props.rightDrawerWidth : 0)}px; + flex-shrink: 0; +` + +export const AppBarEditor = styled(AppBar)<DrawerSizeProps>` + width: calc(100% - ${(props) => (props ? props.rightDrawerWidth : 0)}px); + left: 0; + margin-left: leftDrawerWidth; + margin-right: rightDrawerWidth; +` + +// Necessary for content to be below app bar +export const ToolbarMargin = styled.div` + padding-top: ${AppBarHeight}px; +` + +export const FillLeftContainer = styled.div<DrawerSizeProps>` + width: ${(props) => (props ? props.leftDrawerWidth : 0)}px; + height: calc(100% - ${SlideListHeight}px); + overflow: hidden; +` + +export const FillRightContainer = styled.div<DrawerSizeProps>` + width: ${(props) => (props ? props.rightDrawerWidth : 0)}px; + height: 100%; + overflow-y: auto; + background: #e9e9e9; +` + +export const PositionBottom = styled.div` + position: absolute; + bottom: 0; + width: 100%; +` + +export const CompetitionName = styled(Typography)` + text-decoration: none; + position: absolute; + left: 180px; ` diff --git a/client/src/pages/views/AudienceViewPage.tsx b/client/src/pages/views/AudienceViewPage.tsx index 00a821f35a4ad05354954fdb50694012cb2bda46..d03f3367499b9820ad35d646feedda3821928dcc 100644 --- a/client/src/pages/views/AudienceViewPage.tsx +++ b/client/src/pages/views/AudienceViewPage.tsx @@ -1,8 +1,15 @@ +import { Typography } from '@material-ui/core' import React from 'react' -import SlideDisplay from './components/SlideDisplay' +import { useAppSelector } from '../../hooks' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' const AudienceViewPage: React.FC = () => { - return <SlideDisplay /> + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id + if (activeViewTypeId) { + return <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} /> + } + return <Typography>Error: Åskådarvyn kunde inte laddas</Typography> } export default AudienceViewPage diff --git a/client/src/pages/views/JudgeViewPage.test.tsx b/client/src/pages/views/JudgeViewPage.test.tsx index 537dae4c570f3b7ed50dadaa9f60bae1be8823a2..2e15f0904705726d364672b87af478ddaf922fbb 100644 --- a/client/src/pages/views/JudgeViewPage.test.tsx +++ b/client/src/pages/views/JudgeViewPage.test.tsx @@ -9,7 +9,7 @@ import JudgeViewPage from './JudgeViewPage' it('renders judge view page', () => { const compRes: any = { data: { - slides: [{ id: 0, title: '' }], + slides: [{ id: 0, title: '', questions: [{ id: 0 }] }], }, } const teamsRes: any = { @@ -36,7 +36,7 @@ it('renders judge view page', () => { render( <BrowserRouter> <Provider store={store}> - <JudgeViewPage /> + <JudgeViewPage code={''} competitionId={0} /> </Provider> </BrowserRouter> ) diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index 66450f3a1f8ac98f14423d7040e215c0c96881c6..677a080d04ab34e8232120daae018b6d1a2b29c4 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -1,32 +1,33 @@ -import { Divider, List, ListItemText, Typography } from '@material-ui/core' +import { Card, Divider, List, ListItem, ListItemText, Paper, Typography } from '@material-ui/core' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import React, { useEffect, useState } from 'react' -import { useParams } from 'react-router-dom' -import { - getPresentationCompetition, - getPresentationTeams, - setCurrentSlide, - setPresentationCode, -} from '../../actions/presentation' +import { getPresentationCompetition, setCurrentSlide, setPresentationCode } from '../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../hooks' import { ViewParams } from '../../interfaces/ViewParams' import { socket_connect } from '../../sockets' import { SlideListItem } from '../presentationEditor/styled' import JudgeScoreDisplay from './components/JudgeScoreDisplay' -import SlideDisplay from './components/SlideDisplay' +import PresentationComponent from './components/PresentationComponent' import { useHistory } from 'react-router-dom' import { Content, + InnerContent, JudgeAnswersLabel, JudgeAppBar, JudgeQuestionsLabel, JudgeToolbar, LeftDrawer, RightDrawer, + ScoreHeaderPadding, + ScoreHeaderPaper, + ScoreFooterPadding, } from './styled' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import JudgeScoringInstructions from './components/JudgeScoringInstructions' +import { renderSlideIcon } from '../../utils/renderSlideIcon' const leftDrawerWidth = 150 -const rightDrawerWidth = 390 +const rightDrawerWidth = 700 const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -39,30 +40,36 @@ const useStyles = makeStyles((theme: Theme) => toolbar: theme.mixins.toolbar, }) ) +type JudgeViewPageProps = { + //Prop to distinguish between editor and active competition + competitionId: number + code: string +} -const JudgeViewPage: React.FC = () => { +const JudgeViewPage = ({ competitionId, code }: JudgeViewPageProps) => { const classes = useStyles() const history = useHistory() - const { id, code }: ViewParams = useParams() const dispatch = useAppDispatch() const [activeSlideIndex, setActiveSlideIndex] = useState<number>(0) - const teams = useAppSelector((state) => state.presentation.teams) + 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] const handleSelectSlide = (index: number) => { setActiveSlideIndex(index) dispatch(setCurrentSlide(slides[index])) } useEffect(() => { socket_connect() - dispatch(getPresentationCompetition(id)) - dispatch(getPresentationTeams(id)) + dispatch(getPresentationCompetition(competitionId.toString())) dispatch(setPresentationCode(code)) //hides the url so people can't sneak peak history.push('judge') }, []) return ( - <div> + <div style={{ height: '100%' }}> <JudgeAppBar position="fixed"> <JudgeToolbar> <JudgeQuestionsLabel variant="h5">Frågor</JudgeQuestionsLabel> @@ -87,8 +94,8 @@ const JudgeViewPage: React.FC = () => { button key={slide.id} > - <Typography variant="h6">Slide ID: {slide.id} </Typography> - <ListItemText primary={slide.title} /> + {renderSlideIcon(slide)} + <ListItemText primary={`Sida ${slide.order + 1}`} /> </SlideListItem> ))} </List> @@ -102,18 +109,29 @@ const JudgeViewPage: React.FC = () => { anchor="right" > <div className={classes.toolbar} /> - <List> - {teams.map((answer, index) => ( - <div key={answer.name}> - <JudgeScoreDisplay teamIndex={index} /> - <Divider /> - </div> - ))} + {currentQuestion && ( + <ScoreHeaderPaper $rightDrawerWidth={rightDrawerWidth} elevation={4}> + <Typography variant="h4">{`${currentQuestion.name} (${currentQuestion.total_score}p)`}</Typography> + </ScoreHeaderPaper> + )} + <ScoreHeaderPadding /> + <List style={{ overflowY: 'scroll', overflowX: 'hidden' }}> + {teams && + teams.map((answer, index) => ( + <div key={answer.name}> + <JudgeScoreDisplay teamIndex={index} /> + <Divider /> + </div> + ))} </List> + <ScoreFooterPadding /> + <JudgeScoringInstructions question={currentQuestion} /> </RightDrawer> + <div className={classes.toolbar} /> <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> - <div className={classes.toolbar} /> - <SlideDisplay /> + <InnerContent> + {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/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e965a3ec9f60b77b1919525f75ba2a925a81e84b --- /dev/null +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -0,0 +1,317 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + List, + ListItem, + ListItemText, + Popover, + Tooltip, + Typography, + useMediaQuery, + 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, useState } from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { getPresentationCompetition, setPresentationCode } from '../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../hooks' +import { ViewParams } from '../../interfaces/ViewParams' +import { + socketEndPresentation, + socketSetSlide, + socketSetSlideNext, + socketSetSlidePrev, + socketStartPresentation, + socketStartTimer, + socket_connect, +} from '../../sockets' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import PresentationComponent from './components/PresentationComponent' +import Timer from './components/Timer' +import { + 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 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) + const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) + const { id, code }: ViewParams = useParams() + 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, 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) + } + + const startCompetition = () => { + socketStartPresentation() + console.log('started competition for') + console.log(id) + } + + const handleVerifyExit = () => { + setOpen(true) + } + + const handleOpenCodes = () => { + setOpenCode(true) + } + + const handleCopy = () => { + console.log('copied code to clipboard') + } + + const endCompetition = () => { + setOpen(false) + socketEndPresentation() + history.push('/admin/tävlingshanterare') + window.location.reload(false) // TODO: fix this ugly hack, we "need" to refresh site to be able to run the competition correctly again + } + + return ( + <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> + <OperatorButton onClick={handleVerifyExit} variant="contained" color="secondary"> + <BackspaceIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + <Dialog + fullScreen={fullScreen} + open={openAlert} + onClose={handleClose} + aria-labelledby="responsive-dialog-title" + > + <DialogTitle id="responsive-dialog-title">{'Vill du avsluta tävlingen?'}</DialogTitle> + <DialogContent> + <DialogContentText> + Genom att avsluta tävlingen kommer den avslutas för alla. Du kommer gå tillbaka till startsidan. + </DialogContentText> + </DialogContent> + <DialogActions> + <Button autoFocus onClick={handleClose} color="primary"> + Avbryt + </Button> + <Button onClick={endCompetition} color="primary" autoFocus> + Avsluta tävling + </Button> + </DialogActions> + </Dialog> + <Typography variant="h3">{presentation.competition.name}</Typography> + <SlideCounter> + <Typography variant="h3"> + {presentation.slide.order + 1} / {presentation.competition.slides.length} + </Typography> + </SlideCounter> + </OperatorHeader> + <div style={{ height: 0, paddingTop: 120 }} /> + <OperatorContent> + <OperatorInnerContent> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} + </OperatorInnerContent> + </OperatorContent> + <div style={{ height: 0, paddingTop: 140 }} /> + <OperatorFooter> + <ToolBarContainer> + <Tooltip title="Föregående" arrow> + <OperatorButton onClick={socketSetSlidePrev} variant="contained"> + <ChevronLeftIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + {/* + // Manual start button + <Tooltip title="Start Presentation" arrow> + <OperatorButton onClick={startCompetition} variant="contained"> + start + </OperatorButton> + </Tooltip> + + + // This creates a join button, but Operator should not join others, others should join Operator + <Tooltip title="Join Presentation" arrow> + <OperatorButton onClick={socketJoinPresentation} variant="contained"> + <GroupAddIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + + // This creates another end button, it might not be needed since we already have one + <Tooltip title="End Presentation" arrow> + <OperatorButton onClick={socketEndPresentation} variant="contained"> + <CancelIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + */} + + <Tooltip title="Starta Timer" arrow> + <OperatorButton onClick={socketStartTimer} variant="contained"> + <TimerIcon fontSize="large" /> + <Timer></Timer> + </OperatorButton> + </Tooltip> + + <Tooltip title="Ställning" arrow> + <OperatorButton onClick={handleOpenPopover} variant="contained"> + <AssignmentIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + <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" /> + </OperatorButton> + </Tooltip> + </ToolBarContainer> + </OperatorFooter> + <Popover + open={Boolean(anchorEl)} + anchorEl={anchorEl} + onClose={handleClose} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + <List> + {teams && + teams.map((team) => ( + <ListItem key={team.id}> + {team.name} score: {team.question_answers}{' '} + </ListItem> + ))} + </List> + </Popover> + </OperatorContainer> + ) +} + +export default OperatorViewPage diff --git a/client/src/pages/views/ParticipantViewPage.tsx b/client/src/pages/views/ParticipantViewPage.tsx deleted file mode 100644 index f531ad76db15bc5ba8578b4015e2d5feed8ea1e8..0000000000000000000000000000000000000000 --- a/client/src/pages/views/ParticipantViewPage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { useEffect } from 'react' -import SlideDisplay from './components/SlideDisplay' -import { useHistory } from 'react-router-dom' - -const ParticipantViewPage: React.FC = () => { - const history = useHistory() - useEffect(() => { - //hides the url so people can't sneak peak - history.push('participant') - }, []) - return <SlideDisplay /> -} - -export default ParticipantViewPage diff --git a/client/src/pages/views/PresenterViewPage.tsx b/client/src/pages/views/PresenterViewPage.tsx deleted file mode 100644 index 1abeee92c519c3aae977d667f68470ca78cafe2d..0000000000000000000000000000000000000000 --- a/client/src/pages/views/PresenterViewPage.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - List, - ListItem, - Popover, - Tooltip, - Typography, - useMediaQuery, - useTheme, -} from '@material-ui/core' -import AssignmentIcon from '@material-ui/icons/Assignment' -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 { useHistory, useParams } from 'react-router-dom' -import { getPresentationCompetition, getPresentationTeams, setPresentationCode } from '../../actions/presentation' -import { useAppDispatch, useAppSelector } from '../../hooks' -import { ViewParams } from '../../interfaces/ViewParams' -import { - socketEndPresentation, - socketSetSlide, - socketSetSlideNext, - socketSetSlidePrev, - socketStartPresentation, - socketStartTimer, - socket_connect, -} from '../../sockets' -import SlideDisplay from './components/SlideDisplay' -import Timer from './components/Timer' -import { - PresenterButton, - PresenterContainer, - PresenterFooter, - PresenterHeader, - SlideCounter, - ToolBarContainer, -} from './styled' - -/** - * Presentation is an active competition - */ - -const PresenterViewPage: React.FC = () => { - // for dialog alert - const [openAlert, setOpen] = React.useState(false) - const theme = useTheme() - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) - const teams = useAppSelector((state) => state.presentation.teams) - const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) - const { id, code }: ViewParams = useParams() - const presentation = useAppSelector((state) => state.presentation) - const history = useHistory() - const dispatch = useAppDispatch() - - useEffect(() => { - dispatch(getPresentationCompetition(id)) - dispatch(getPresentationTeams(id)) - dispatch(setPresentationCode(code)) - socket_connect() - socketSetSlide // Behövs denna? - setTimeout(startCompetition, 500) // Ghetto, wait for everything to load - // console.log(id) - }, []) - - const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { - setAnchorEl(event.currentTarget) - } - - const handleClose = () => { - setOpen(false) - setAnchorEl(null) - } - - const startCompetition = () => { - socketStartPresentation() - console.log('started competition for') - console.log(id) - } - - const handleVerifyExit = () => { - setOpen(true) - } - - const endCompetition = () => { - setOpen(false) - socketEndPresentation() - history.push('/admin/tävlingshanterare') - window.location.reload(false) // TODO: fix this ugly hack, we "need" to refresh site to be able to run the competition correctly again - } - - return ( - <PresenterContainer> - <PresenterHeader> - <Tooltip title="Avsluta tävling" arrow> - <PresenterButton onClick={handleVerifyExit} variant="contained" color="secondary"> - <BackspaceIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - <Dialog - fullScreen={fullScreen} - open={openAlert} - onClose={handleClose} - aria-labelledby="responsive-dialog-title" - > - <DialogTitle id="responsive-dialog-title">{'Vill du avsluta tävlingen?'}</DialogTitle> - <DialogContent> - <DialogContentText> - Genom att avsluta tävlingen kommer den avslutas för alla. Du kommer gå tillbaka till startsidan. - </DialogContentText> - </DialogContent> - <DialogActions> - <Button autoFocus onClick={handleClose} color="primary"> - Avbryt - </Button> - <Button onClick={endCompetition} color="primary" autoFocus> - Avsluta tävling - </Button> - </DialogActions> - </Dialog> - <Typography variant="h3">{presentation.competition.name}</Typography> - <SlideCounter> - <Typography variant="h3"> - {presentation.slide.order + 1} / {presentation.competition.slides.length} - </Typography> - </SlideCounter> - </PresenterHeader> - <SlideDisplay /> - <PresenterFooter> - <ToolBarContainer> - <Tooltip title="Previous Slide" arrow> - <PresenterButton onClick={socketSetSlidePrev} variant="contained"> - <ChevronLeftIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - {/* - // Manual start button - <Tooltip title="Start Presentation" arrow> - <PresenterButton onClick={startCompetition} variant="contained"> - start - </PresenterButton> - </Tooltip> - - - // This creates a join button, but presenter should not join others, others should join presenter - <Tooltip title="Join Presentation" arrow> - <PresenterButton onClick={socketJoinPresentation} variant="contained"> - <GroupAddIcon fontSize="large" /> - </PresenterButton> - </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"> - <CancelIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - */} - - <Tooltip title="Start Timer" arrow> - <PresenterButton onClick={socketStartTimer} variant="contained"> - <TimerIcon fontSize="large" /> - <Timer></Timer> - </PresenterButton> - </Tooltip> - - <Tooltip title="Scoreboard" arrow> - <PresenterButton onClick={handleOpenPopover} variant="contained"> - <AssignmentIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - <Tooltip title="Next Slide" arrow> - <PresenterButton onClick={socketSetSlideNext} variant="contained"> - <ChevronRightIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - </ToolBarContainer> - </PresenterFooter> - <Popover - open={Boolean(anchorEl)} - anchorEl={anchorEl} - onClose={handleClose} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'center', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'center', - }} - > - <List> - {/** TODO: - * Fix scoreboard - */} - {teams.map((team) => ( - <ListItem key={team.id}>{team.name} score: 20</ListItem> - ))} - </List> - </Popover> - </PresenterContainer> - ) -} - -export default PresenterViewPage -function componentDidMount() { - throw new Error('Function not implemented.') -} diff --git a/client/src/pages/views/ParticipantViewPage.test.tsx b/client/src/pages/views/TeamViewPage.test.tsx similarity index 59% rename from client/src/pages/views/ParticipantViewPage.test.tsx rename to client/src/pages/views/TeamViewPage.test.tsx index c0950b3c6d3dfeaf1b1ce2d1293829c10651fe33..10574f7e51df7dabf9f07754e9d8595d1c489559 100644 --- a/client/src/pages/views/ParticipantViewPage.test.tsx +++ b/client/src/pages/views/TeamViewPage.test.tsx @@ -3,13 +3,20 @@ 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', () => { + const res = { + data: {}, + } + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(res) + }) render( <BrowserRouter> <Provider store={store}> - <ParticipantViewPage /> + <TeamViewPage /> </Provider> </BrowserRouter> ) diff --git a/client/src/pages/views/TeamViewPage.tsx b/client/src/pages/views/TeamViewPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32eef28b9f7acd350195571719ea0449aeb19610 --- /dev/null +++ b/client/src/pages/views/TeamViewPage.tsx @@ -0,0 +1,29 @@ +import React, { useEffect } from 'react' +import PresentationComponent from './components/PresentationComponent' +import { useHistory } from 'react-router-dom' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import { TeamContainer } from './styled' +import { socketJoinPresentation, socket_connect } from '../../sockets' +import { useAppSelector } from '../../hooks' + +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('team') + if (code && code !== '') { + socket_connect() + socketJoinPresentation() + } + }, []) + return ( + <TeamContainer> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} + </TeamContainer> + ) +} + +export default TeamViewPage diff --git a/client/src/pages/views/ViewSelectPage.test.tsx b/client/src/pages/views/ViewSelectPage.test.tsx index 83b71db05a13abc23b629003877c5698d99b4481..1843a1863990fe7e9fc54dd77971ed6dc7ef08ae 100644 --- a/client/src/pages/views/ViewSelectPage.test.tsx +++ b/client/src/pages/views/ViewSelectPage.test.tsx @@ -12,9 +12,18 @@ it('renders view select page', async () => { const res = { data: {}, } + const compRes = { + data: { + id: 2, + slides: [{ id: 4 }], + }, + } ;(mockedAxios.post as jest.Mock).mockImplementation(() => { return Promise.resolve(res) }) + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(compRes) + }) render( <BrowserRouter> <Provider store={store}> diff --git a/client/src/pages/views/ViewSelectPage.tsx b/client/src/pages/views/ViewSelectPage.tsx index 3c3599edeaf3d9d46ff6c462506d196d79d1a9f7..845ba21c3edc1e5f499f6aae817e195af4d9bc21 100644 --- a/client/src/pages/views/ViewSelectPage.tsx +++ b/client/src/pages/views/ViewSelectPage.tsx @@ -4,17 +4,19 @@ 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 { useAppSelector } from '../../hooks' +import { useAppDispatch, useAppSelector } from '../../hooks' +import { getPresentationCompetition, setPresentationCode } from '../../actions/presentation' interface ViewSelectParams { code: string } const ViewSelectPage: React.FC = () => { + const dispatch = useAppDispatch() const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [viewTypeId, setViewTypeId] = useState(undefined) @@ -27,9 +29,9 @@ const ViewSelectPage: React.FC = () => { if (competitionId) { switch (viewType) { case 'Team': - return <ParticipantViewPage /> + return <TeamViewPage /> case 'Judge': - return <JudgeViewPage /> + return <JudgeViewPage code={code} competitionId={competitionId} /> case 'Audience': return <AudienceViewPage /> default: @@ -43,8 +45,10 @@ const ViewSelectPage: React.FC = () => { .post('/api/auth/login/code', { code }) .then((response) => { setLoading(false) - setViewTypeId(response.data[0].view_type_id) - setCompetitionId(response.data[0].competition_id) + setViewTypeId(response.data.view_type_id) + setCompetitionId(response.data.competition_id) + dispatch(getPresentationCompetition(response.data.competition_id)) + dispatch(setPresentationCode(code)) }) .catch(() => { setLoading(false) @@ -53,13 +57,17 @@ const ViewSelectPage: React.FC = () => { }, []) return ( - <ViewSelectContainer> - <ViewSelectButtonGroup> - {loading && <CircularProgress />} - {!loading && renderView(viewTypeId)} - {error && <Typography>Något gick fel, dubbelkolla koden och försök igen</Typography>} - </ViewSelectButtonGroup> - </ViewSelectContainer> + <> + {!loading && renderView(viewTypeId)} + {(loading || error) && ( + <ViewSelectContainer> + <ViewSelectButtonGroup> + {loading && <CircularProgress />} + {error && <Typography>Något gick fel, dubbelkolla koden och försök igen</Typography>} + </ViewSelectButtonGroup> + </ViewSelectContainer> + )} + </> ) } diff --git a/client/src/pages/views/components/JudgeScoreDisplay.tsx b/client/src/pages/views/components/JudgeScoreDisplay.tsx index ae4d8ab3e7e95a44357565093711d9f4722be0a3..2745e12970f3d5a20d2057adbc76ecc0ce2e6d6f 100644 --- a/client/src/pages/views/components/JudgeScoreDisplay.tsx +++ b/client/src/pages/views/components/JudgeScoreDisplay.tsx @@ -1,15 +1,35 @@ import { Box, Typography } from '@material-ui/core' +import axios from 'axios' import React from 'react' -import { useAppSelector } from '../../../hooks' +import { getPresentationCompetition } from '../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../hooks' import { AnswerContainer, ScoreDisplayContainer, ScoreDisplayHeader, ScoreInput } from './styled' type ScoreDisplayProps = { teamIndex: number } -const questionMaxScore = 5 const JudgeScoreDisplay = ({ teamIndex }: ScoreDisplayProps) => { - const currentTeam = useAppSelector((state) => state.presentation.teams[teamIndex]) + const dispatch = useAppDispatch() + const currentTeam = useAppSelector((state) => state.presentation.competition.teams[teamIndex]) + const currentCompetititonId = useAppSelector((state) => state.presentation.competition.id) + const activeQuestion = useAppSelector( + (state) => + state.presentation.competition.slides.find((slide) => slide.id === state.presentation.slide?.id)?.questions[0] + ) + const scores = currentTeam.question_answers.map((questionAnswer) => questionAnswer.score) + const questionMaxScore = activeQuestion?.total_score + const activeAnswer = currentTeam.question_answers.find( + (questionAnswer) => questionAnswer.question_id === activeQuestion?.id + ) + const handleEditScore = async (newScore: number, answerId: number) => { + await axios + .put(`/api/competitions/${currentCompetititonId}/teams/${currentTeam.id}/answers/${answerId}`, { + score: newScore, + }) + .then(() => dispatch(getPresentationCompetition(currentCompetititonId.toString()))) + } + return ( <ScoreDisplayContainer> <ScoreDisplayHeader> @@ -17,21 +37,25 @@ const JudgeScoreDisplay = ({ teamIndex }: ScoreDisplayProps) => { <Box fontWeight="fontWeightBold">{currentTeam.name}</Box> </Typography> - <ScoreInput - label="Poäng" - defaultValue={0} - inputProps={{ style: { fontSize: 20 } }} - InputProps={{ disableUnderline: true, inputProps: { min: 0, max: questionMaxScore } }} - type="number" - ></ScoreInput> + {activeAnswer && ( + <ScoreInput + label="Poäng" + defaultValue={activeAnswer?.score} + inputProps={{ style: { fontSize: 20 } }} + InputProps={{ disableUnderline: true, inputProps: { min: 0, max: questionMaxScore } }} + type="number" + onChange={(event) => handleEditScore(+event.target.value, activeAnswer.id)} + /> + )} </ScoreDisplayHeader> - <Typography variant="h6">Alla poäng: 2 0 0 0 0 0 0 0 0</Typography> - <Typography variant="h6">Total poäng: 9</Typography> - <AnswerContainer> - <Typography variant="body1"> - Svar: blablablablablablablablablabla blablablablabla blablablablabla blablablablablablablablablabla{' '} - </Typography> - </AnswerContainer> + <Typography variant="h6">Alla poäng: [ {scores.map((score) => `${score} `)}]</Typography> + <Typography variant="h6">Total poäng: {scores.reduce((a, b) => a + b, 0)}</Typography> + {activeAnswer && ( + <AnswerContainer> + <Typography variant="body1">{activeAnswer.answer}</Typography> + </AnswerContainer> + )} + {!activeAnswer && <Typography variant="body1">Inget svar</Typography>} </ScoreDisplayContainer> ) } diff --git a/client/src/pages/views/components/JudgeScoringInstructions.tsx b/client/src/pages/views/components/JudgeScoringInstructions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3cc803194a9754bea8a9b23b3f2b073d6547c05c --- /dev/null +++ b/client/src/pages/views/components/JudgeScoringInstructions.tsx @@ -0,0 +1,28 @@ +import { Box, Card, Typography } from '@material-ui/core' +import axios from 'axios' +import React from 'react' +import { getPresentationCompetition } from '../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { RichQuestion } from '../../../interfaces/ApiRichModels' +import { + AnswerContainer, + JudgeScoringInstructionsContainer, + ScoreDisplayContainer, + ScoreDisplayHeader, + ScoreInput, +} from './styled' + +type JudgeScoringInstructionsProps = { + question: RichQuestion +} + +const JudgeScoringInstructions = ({ question }: JudgeScoringInstructionsProps) => { + return ( + <JudgeScoringInstructionsContainer elevation={3}> + <Typography variant="h4">Rättningsinstruktioner</Typography> + <Typography variant="body1">Såhär rättar du denhär frågan</Typography> + </JudgeScoringInstructionsContainer> + ) +} + +export default JudgeScoringInstructions diff --git a/client/src/pages/views/components/PresentationComponent.tsx b/client/src/pages/views/components/PresentationComponent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a41f7912469256a6e946522790f4f8203f8da60f --- /dev/null +++ b/client/src/pages/views/components/PresentationComponent.tsx @@ -0,0 +1,51 @@ +import { Typography } from '@material-ui/core' +import React from 'react' +import { Rnd } from 'react-rnd' +import { ComponentTypes } from '../../../enum/ComponentTypes' +import { useAppSelector } from '../../../hooks' +import { Component, ImageComponent, TextComponent } from '../../../interfaces/ApiModels' +import ImageComponentDisplay from '../../presentationEditor/components/ImageComponentDisplay' +import TextComponentDisplay from '../../presentationEditor/components/TextComponentDisplay' +import { SlideContainer } from './styled' + +type PresentationComponentProps = { + component: Component + width: number + height: number + scale: number +} + +const PresentationComponent = ({ component, width, height, scale }: PresentationComponentProps) => { + const renderInnerComponent = () => { + switch (component.type_id) { + case ComponentTypes.Text: + return <TextComponentDisplay component={component as TextComponent} scale={scale} /> + case ComponentTypes.Image: + return ( + <ImageComponentDisplay + height={component.h * scale} + width={component.w * scale} + component={component as ImageComponent} + /> + ) + default: + break + } + } + return ( + <Rnd + minWidth={75 * scale} + minHeight={75 * scale} + disableDragging={true} + enableResizing={false} + bounds="parent" + //Multiply by scale to show components correctly for current screen size + size={{ width: component.w * scale, height: component.h * scale }} + position={{ x: component.x * scale, y: component.y * scale }} + > + {renderInnerComponent()} + </Rnd> + ) +} + +export default PresentationComponent diff --git a/client/src/pages/views/components/SlideDisplay.test.tsx b/client/src/pages/views/components/SlideDisplay.test.tsx deleted file mode 100644 index 1a661d3340d503c71e149393db3cb00f1e2406c4..0000000000000000000000000000000000000000 --- a/client/src/pages/views/components/SlideDisplay.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { render } from '@testing-library/react' -import React from 'react' -import { Provider } from 'react-redux' -import store from '../../../store' -import SlideDisplay from './SlideDisplay' - -it('renders slide display', () => { - render( - <Provider store={store}> - <SlideDisplay /> - </Provider> - ) -}) diff --git a/client/src/pages/views/components/SlideDisplay.tsx b/client/src/pages/views/components/SlideDisplay.tsx deleted file mode 100644 index 7ecffac50a735eade79ebcad046040045e37dd93..0000000000000000000000000000000000000000 --- a/client/src/pages/views/components/SlideDisplay.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Typography } from '@material-ui/core' -import React from 'react' -import { useAppSelector } from '../../../hooks' -import { SlideContainer } from './styled' - -const SlideDisplay: React.FC = () => { - const currentSlide = useAppSelector((state) => state.presentation.slide) - - return ( - <div> - <SlideContainer> - <Typography variant="h3">Slide Title: {currentSlide.title} </Typography> - <Typography variant="h3">Timer: {currentSlide.timer} </Typography> - <Typography variant="h3">Slide ID: {currentSlide.id} </Typography> - </SlideContainer> - </div> - ) -} - -export default SlideDisplay diff --git a/client/src/pages/views/components/Timer.tsx b/client/src/pages/views/components/Timer.tsx index 0cbd1fdfdbf0d63dc6ef5e687539400510af770e..8d3b9f70594ac416610bbd06b9d09cbee3ece0a4 100644 --- a/client/src/pages/views/components/Timer.tsx +++ b/client/src/pages/views/components/Timer.tsx @@ -36,11 +36,7 @@ const Timer: React.FC = (props: any) => { } }, [props.timer.enabled]) - return ( - <> - <div>{props.timer.value}</div> - </> - ) + return <div>{props.timer.value}</div> } export default connect(mapStateToProps, mapDispatchToProps)(Timer) diff --git a/client/src/pages/views/components/styled.tsx b/client/src/pages/views/components/styled.tsx index b522b20d3548b4f11864373d5a91088424c45a58..fef186f792dafe8ee259faa693cf6707975d06ba 100644 --- a/client/src/pages/views/components/styled.tsx +++ b/client/src/pages/views/components/styled.tsx @@ -1,4 +1,4 @@ -import { TextField } from '@material-ui/core' +import { Card, Paper, TextField } from '@material-ui/core' import styled from 'styled-components' export const SlideContainer = styled.div` @@ -32,3 +32,13 @@ export const AnswerContainer = styled.div` display: flex; flex-wrap: wrap; ` + +export const JudgeScoringInstructionsContainer = styled(Paper)` + position: absolute; + bottom: 0; + height: 250px; + width: 100%; + display: flex; + align-items: center; + flex-direction: column; +` diff --git a/client/src/pages/views/styled.tsx b/client/src/pages/views/styled.tsx index 1f3a61c61789964b96346f8734422ca8c83518b7..9699902727b2352fec07b8781a9d8c43ad20663e 100644 --- a/client/src/pages/views/styled.tsx +++ b/client/src/pages/views/styled.tsx @@ -1,4 +1,4 @@ -import { AppBar, Button, Drawer, Toolbar, Typography } from '@material-ui/core' +import { AppBar, Button, Card, Drawer, Paper, Toolbar, Typography } from '@material-ui/core' import styled from 'styled-components' export const JudgeAppBar = styled(AppBar)` @@ -15,7 +15,7 @@ export const JudgeQuestionsLabel = styled(Typography)` ` export const JudgeAnswersLabel = styled(Typography)` - margin-right: 160px; + margin-right: 304px; ` export const ViewSelectContainer = styled.div` @@ -35,19 +35,24 @@ export const ViewSelectButtonGroup = styled.div` margin-right: auto; ` -export const PresenterHeader = styled.div` +export const OperatorHeader = styled.div` display: flex; justify-content: space-between; - position: fixed; + height: 120px; width: 100%; + position: absolute; ` -export const PresenterFooter = styled.div` +export const OperatorFooter = styled.div` display: flex; justify-content: space-between; + height: 140px; + position: absolute; + bottom: 0; + width: 100%; ` -export const PresenterButton = styled(Button)` +export const OperatorButton = styled(Button)` width: 100px; height: 100px; margin-left: 16px; @@ -61,10 +66,11 @@ 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; + align-items: center; height: 100%; ` @@ -103,8 +109,63 @@ interface ContentProps { } export const Content = styled.div<ContentProps>` + width: 100%; + height: 100%; + max-width: calc(100% - ${(props) => (props ? props.leftDrawerWidth + props.rightDrawerWidth : 0)}px); + max-height: calc(100% - 64px); margin-left: ${(props) => (props ? props.leftDrawerWidth : 0)}px; margin-right: ${(props) => (props ? props.rightDrawerWidth : 0)}px; - width: calc(100% - ${(props) => (props ? props.leftDrawerWidth + props.rightDrawerWidth : 0)}px); - height: calc(100% - 64px); + display: flex; + justify-content: center; + background-color: rgba(0, 0, 0, 0.08); +` + +export const InnerContent = styled.div` + width: 100%; + /* Makes sure width is not bigger than where a 16:9 display can fit + without overlapping with header */ + max-width: calc(((100vh - 64px) / 9) * 16); +` + +export const OperatorContent = styled.div` + height: 100%; + width: 100%; + display: flex; + justify-content: center; + background-color: rgba(0, 0, 0, 0.08); +` + +export const OperatorInnerContent = styled.div` + height: 100%; + width: 100%; + /* Makes sure width is not bigger than where a 16:9 display can fit + without overlapping with header and footer */ + max-width: calc(((100vh - 260px) / 9) * 16); +` + +export const TeamContainer = styled.div` + max-width: calc((100vh / 9) * 16); +` + +interface ScoreHeaderPaperProps { + $rightDrawerWidth: number +} + +export const ScoreHeaderPaper = styled(Card)<ScoreHeaderPaperProps>` + position: absolute; + top: 66px; + width: ${(props) => (props ? props.$rightDrawerWidth : 0)}px; + height: 71px; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; +` + +export const ScoreHeaderPadding = styled.div` + min-height: 71px; +` + +export const ScoreFooterPadding = styled.div` + min-height: 250px; ` diff --git a/client/src/reducers/editorReducer.ts b/client/src/reducers/editorReducer.ts index 20d0b42885b4d3ebeffbca9ead97b1eb0ddc2fad..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 } @@ -16,8 +17,10 @@ const initialState: EditorState = { city_id: 1, slides: [], teams: [], + background_image: undefined, }, activeSlideId: -1, + activeViewTypeId: -1, loading: true, } @@ -34,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/client/src/reducers/presentationReducer.test.ts b/client/src/reducers/presentationReducer.test.ts index a155eab5c915e82c64f16c0093dd2fc296b10d7a..202d9406853628a32c77b9e42e1bcc502299889d 100644 --- a/client/src/reducers/presentationReducer.test.ts +++ b/client/src/reducers/presentationReducer.test.ts @@ -1,12 +1,13 @@ import Types from '../actions/types' import { RichSlide } from '../interfaces/ApiRichModels' -import { Slide } from '../interfaces/Slide' +import { Slide } from '../interfaces/ApiModels' import presentationReducer from './presentationReducer' const initialState = { competition: { name: '', - id: 0, + id: 1, + background_image: undefined, city_id: 0, slides: [], year: 0, @@ -14,12 +15,12 @@ const initialState = { }, slide: { competition_id: 0, - id: 0, + background_image: undefined, + id: -1, order: 0, timer: 0, title: '', }, - teams: [], code: '', timer: { enabled: false, @@ -41,7 +42,6 @@ it('should handle SET_PRESENTATION_COMPETITION', () => { }, slides: [{ id: 20 }], year: 1999, - teams: [], } expect( presentationReducer(initialState, { @@ -50,33 +50,7 @@ it('should handle SET_PRESENTATION_COMPETITION', () => { }) ).toEqual({ competition: testCompetition, - slide: testCompetition.slides[0], - teams: initialState.teams, - code: initialState.code, - timer: initialState.timer, - }) -}) - -it('should handle SET_PRESENTATION_TEAMS', () => { - const testTeams = [ - { - name: 'testTeamName1', - id: 3, - }, - { - name: 'testTeamName2', - id: 5, - }, - ] - expect( - presentationReducer(initialState, { - type: Types.SET_PRESENTATION_TEAMS, - payload: testTeams, - }) - ).toEqual({ - competition: initialState.competition, slide: initialState.slide, - teams: testTeams, code: initialState.code, timer: initialState.timer, }) @@ -100,7 +74,6 @@ it('should handle SET_PRESENTATION_SLIDE', () => { ).toEqual({ competition: initialState.competition, slide: testSlide, - teams: initialState.teams, code: initialState.code, timer: initialState.timer, }) @@ -115,8 +88,8 @@ describe('should handle SET_PRESENTATION_SLIDE_PREVIOUS', () => { { competition_id: 0, order: 0 }, { competition_id: 0, order: 1 }, ] as RichSlide[], + teams: [], }, - teams: initialState.teams, slide: { competition_id: 0, order: 1 } as Slide, code: initialState.code, timer: initialState.timer, @@ -128,7 +101,7 @@ describe('should handle SET_PRESENTATION_SLIDE_PREVIOUS', () => { ).toEqual({ competition: testPresentationState.competition, slide: testPresentationState.competition.slides[0], - teams: testPresentationState.teams, + code: initialState.code, timer: initialState.timer, }) @@ -141,8 +114,8 @@ describe('should handle SET_PRESENTATION_SLIDE_PREVIOUS', () => { { competition_id: 0, order: 0 }, { competition_id: 0, order: 1 }, ] as RichSlide[], + teams: [], }, - teams: initialState.teams, slide: { competition_id: 0, order: 0 } as Slide, code: initialState.code, timer: initialState.timer, @@ -154,7 +127,6 @@ describe('should handle SET_PRESENTATION_SLIDE_PREVIOUS', () => { ).toEqual({ competition: testPresentationState.competition, slide: testPresentationState.competition.slides[0], - teams: testPresentationState.teams, code: initialState.code, timer: initialState.timer, }) @@ -170,9 +142,11 @@ describe('should handle SET_PRESENTATION_SLIDE_NEXT', () => { { competition_id: 0, order: 0 }, { competition_id: 0, order: 1 }, ] as RichSlide[], + teams: [], }, - teams: initialState.teams, slide: { competition_id: 0, order: 0 } as Slide, + code: initialState.code, + timer: initialState.timer, } expect( presentationReducer(testPresentationState, { @@ -181,7 +155,8 @@ describe('should handle SET_PRESENTATION_SLIDE_NEXT', () => { ).toEqual({ competition: testPresentationState.competition, slide: testPresentationState.competition.slides[1], - teams: testPresentationState.teams, + code: initialState.code, + timer: initialState.timer, }) }) it('by not changing slide if there is no next one', () => { @@ -192,8 +167,8 @@ describe('should handle SET_PRESENTATION_SLIDE_NEXT', () => { { competition_id: 0, order: 0 }, { competition_id: 0, order: 1 }, ] as RichSlide[], + teams: [], }, - teams: initialState.teams, slide: { competition_id: 0, order: 1 } as Slide, } expect( @@ -203,7 +178,6 @@ describe('should handle SET_PRESENTATION_SLIDE_NEXT', () => { ).toEqual({ competition: testPresentationState.competition, slide: testPresentationState.competition.slides[1], - teams: testPresentationState.teams, }) }) }) diff --git a/client/src/reducers/presentationReducer.ts b/client/src/reducers/presentationReducer.ts index 5ba3ac5b196cb65fc3209b87fcb4636e22cac2ba..b0c4791e9ab1f0ef05c931caa1fc4e9fe8268917 100644 --- a/client/src/reducers/presentationReducer.ts +++ b/client/src/reducers/presentationReducer.ts @@ -7,7 +7,6 @@ import { RichCompetition } from './../interfaces/ApiRichModels' interface PresentationState { competition: RichCompetition slide: Slide - teams: Team[] code: string timer: Timer } @@ -15,20 +14,21 @@ interface PresentationState { const initialState: PresentationState = { competition: { name: '', - id: 0, + id: 1, city_id: 0, slides: [], year: 0, teams: [], + background_image: undefined, }, slide: { competition_id: 0, - id: 0, + id: -1, order: 0, timer: 0, title: '', + background_image: undefined, }, - teams: [], code: '', timer: { enabled: false, @@ -41,14 +41,8 @@ export default function (state = initialState, action: AnyAction) { case Types.SET_PRESENTATION_COMPETITION: return { ...state, - slide: action.payload.slides[0] as Slide, competition: action.payload as RichCompetition, } - case Types.SET_PRESENTATION_TEAMS: - return { - ...state, - teams: action.payload as Team[], - } case Types.SET_PRESENTATION_CODE: return { ...state, diff --git a/client/src/sockets.ts b/client/src/sockets.ts index 54f84c39840acec184fa023545c1a15332eecdbc..4392021df3c89d3d981096e38e1576d155c89b21 100644 --- a/client/src/sockets.ts +++ b/client/src/sockets.ts @@ -38,32 +38,26 @@ export const socket_connect = () => { export const socketStartPresentation = () => { socket.emit('start_presentation', { competition_id: store.getState().presentation.competition.id }) - console.log('START PRESENTATION') } export const socketJoinPresentation = () => { - socket.emit('join_presentation', { code: 'CO0ART' }) // TODO: Send code gotten from auth/login/<code> api call - console.log('JOIN PRESENTATION') + socket.emit('join_presentation', { code: store.getState().presentation.code }) // TODO: Send code gotten from auth/login/<code> api call } export const socketEndPresentation = () => { socket.emit('end_presentation', { competition_id: store.getState().presentation.competition.id }) - console.log('END PRESENTATION') } export const socketSetSlideNext = () => { socketSetSlide(store.getState().presentation.slide.order + 1) // TODO: Check that this slide exists - console.log('NEXT SLIDE +1') } export const socketSetSlidePrev = () => { socketSetSlide(store.getState().presentation.slide.order - 1) // TODO: Check that this slide exists - console.log('PREVIOUS SLIDE -1') } export const socketSetSlide = (slide_order: number) => { if (slide_order < 0 || store.getState().presentation.competition.slides.length <= slide_order) { - console.log('CANT CHANGE TO NON EXISTENT SLIDE') return } @@ -74,7 +68,6 @@ export const socketSetSlide = (slide_order: number) => { } export const socketSetTimer = (timer: Timer) => { - console.log('SET TIMER') socket.emit('set_timer', { competition_id: store.getState().presentation.competition.id, timer: timer, @@ -82,6 +75,5 @@ export const socketSetTimer = (timer: Timer) => { } export const socketStartTimer = () => { - console.log('START TIMER') socketSetTimer({ enabled: true, value: store.getState().presentation.timer.value }) } diff --git a/client/src/utils/renderSlideIcon.tsx b/client/src/utils/renderSlideIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba1eafcb8052469dd8b627b95d2eee518bfd5fe8 --- /dev/null +++ b/client/src/utils/renderSlideIcon.tsx @@ -0,0 +1,21 @@ +import { RichSlide } from '../interfaces/ApiRichModels' +import React from 'react' +import BuildOutlinedIcon from '@material-ui/icons/BuildOutlined' +import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' +import DnsOutlinedIcon from '@material-ui/icons/DnsOutlined' +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' + +export const renderSlideIcon = (slide: RichSlide) => { + if (slide.questions && slide.questions[0] && slide.questions[0].type_id) { + switch (slide.questions[0].type_id) { + case 1: + return <CreateOutlinedIcon /> // text question + case 2: + return <BuildOutlinedIcon /> // practical qustion + case 3: + return <DnsOutlinedIcon /> // multiple choice question + } + } else { + return <InfoOutlinedIcon /> // information slide + } +} diff --git a/client/src/utils/uploadImage.ts b/client/src/utils/uploadImage.ts new file mode 100644 index 0000000000000000000000000000000000000000..151a34a400f2d2e632b1f18b8f249206c043a02f --- /dev/null +++ b/client/src/utils/uploadImage.ts @@ -0,0 +1,16 @@ +import axios from 'axios' +import { getEditorCompetition } from '../actions/editor' +import { Media } from '../interfaces/ApiModels' +import store from '../store' + +export const uploadFile = async (formData: FormData, competitionId: string) => { + // Uploads the file to the server and creates a Media object in database. + // Returns media object data. + return await axios + .post(`/api/media/images`, formData) + .then((response) => { + getEditorCompetition(competitionId)(store.dispatch, store.getState) + return response.data as Media + }) + .catch(console.log) +} 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 ce7b4e7d3bb8a550aa52cbc0b2492c1118999876..94f6d6b1725106a3cf5994570e4d3d5829ba2dbe 100644 --- a/server/app/apis/alternatives.py +++ b/server/app/apis/alternatives.py @@ -1,29 +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.core.dto import QuestionAlternativeDTO, QuestionDTO -from app.core.parsers import question_alternative_parser -from app.core.schemas import QuestionAlternativeSchema -from app.database.models import Question, QuestionAlternative -from flask_jwt_extended import jwt_required +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 +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)) @@ -31,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 0ef3003931d52d304d66cff1cedb36b0c8cfa777..3990e4e1d7b75fa35d13cc592906e4c4aa921024 100644 --- a/server/app/apis/answers.py +++ b/server/app/apis/answers.py @@ -1,29 +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 app.core.parsers import question_answer_edit_parser, question_answer_parser -from app.core.schemas import QuestionAlternativeSchema -from app.database.models import Question, QuestionAlternative, QuestionAnswer -from flask_jwt_extended import jwt_required 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 +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") + +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)) @@ -31,14 +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)) - @check_jwt(editor=True) + @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 87d7f1d19041760131560db52de5dada55dc34fe..c09b04950433e94ed55da08a64e4fb51671df91a 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -1,32 +1,54 @@ 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 app.core.parsers import create_user_parser, login_code_parser, login_parser -from app.database.models import User from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_raw_jwt, jwt_refresh_token_required, - jwt_required, ) -from flask_restx import Namespace, Resource, cors +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 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", 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, 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("/test") +class AuthSignup(Resource): + @protect_route(allowed_roles=["Admin"], allowed_views=["*"]) + def get(self): + return "ok" + + @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") @@ -41,7 +63,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) @@ -77,24 +99,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)), codes.OK + + 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 2a2eea5c55c6e46cf516ff35dd79ed3d96f58679..d07e17435aed31417254acfd83ed9315f1477ba3 100644 --- a/server/app/apis/codes.py +++ b/server/app/apis/codes.py @@ -1,10 +1,8 @@ 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.core.parsers import code_parser -from app.database.models import Code, Competition -from flask_jwt_extended import jwt_required +from app.database.models import Code from flask_restx import Resource api = CodeDTO.api @@ -15,18 +13,18 @@ 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)), codes.OK + return list_response(list_schema.dump(items), len(items)) @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() dbc.utils.commit_and_refresh(item) - return item_response(schema.dump(item)), codes.OK + return item_response(schema.dump(item)) diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index 4a6bf79d6bf17d132e3a5fed6a403b21762bbc13..c5ff37c9131a7a9ec4bc1f394a9e1f5b5727460e 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -1,25 +1,40 @@ import time import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core import rich_schemas +from app.apis import item_response, list_response, protect_route from app.core.dto import CompetitionDTO -from app.core.parsers import competition_parser, competition_search_parser from app.database.models import Competition -from flask_jwt_extended import jwt_required from flask_restx import Resource +from flask_restx import reqparse +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_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_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_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) @@ -32,21 +47,21 @@ class CompetitionsList(Resource): @api.route("/<competition_id>") @api.param("competition_id") class Competitions(Resource): - @check_jwt(editor=True) + @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_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) @@ -56,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) @@ -66,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 23d250256120f4bf600b10cd010b5de9aa67e0dc..a1982763a5ec37b8bb02bb8e6a73e164355417a8 100644 --- a/server/app/apis/components.py +++ b/server/app/apis/components.py @@ -1,34 +1,53 @@ 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 app.core.parsers import component_create_parser, component_parser -from app.database.models import Competition, Component -from flask.globals import request -from flask_jwt_extended import jwt_required 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=int, default=None, location="json") +component_parser_add.add_argument("question_id", type=int, default=None, 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=int, default=sentinel, location="json") +component_parser_edit.add_argument("question_id", type=int, 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_parser.parse_args() + args = component_parser_edit.parse_args(strict=True) item = dbc.get.component(competition_id, slide_id, component_id) - item = dbc.edit.default(item, **args) + 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) @@ -38,13 +57,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 8f0b28f7b332184f043f3f79562c02b6a001f7ca..49d20608840e320ef3d73d9df848748e6da33617 100644 --- a/server/app/apis/media.py +++ b/server/app/apis/media.py @@ -1,31 +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 MediaDTO -from app.core.parsers import media_parser_search -from app.database.models import City, Media, MediaType, QuestionType, Role +from app.core.parsers import search_parser +from app.database.models import Media from flask import request -from flask_jwt_extended import get_jwt_identity, jwt_required -from flask_restx import Resource, reqparse +from flask_jwt_extended import get_jwt_identity +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 schema = MediaDTO.schema list_schema = MediaDTO.list_schema +media_parser_search = search_parser.copy() +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") @@ -45,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 8b3bd5e57e5b877fdaed85f3e6a372b4984d8ef8..20a84e4c17c138b3a94ea6e3902e67154036cabc 100644 --- a/server/app/apis/misc.py +++ b/server/app/apis/misc.py @@ -1,10 +1,10 @@ import app.database.controller as dbc -from app.apis import check_jwt, item_response, 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 -from flask_jwt_extended import jwt_required from flask_restx import Resource, reqparse +from flask_restx import reqparse api = MiscDTO.api @@ -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 5797872a9865bd693989eb0322d8a9818796e86b..b14849d224501e48f9cce8a108bfd24f29657e99 100644 --- a/server/app/apis/questions.py +++ b/server/app/apis/questions.py @@ -1,21 +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 app.core.parsers import question_parser -from app.database.models import Question -from flask_jwt_extended import jwt_required 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_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)) @@ -24,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)) @@ -39,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 9ca4a5f229488e03931ec544dd3ccf43406e5b9e..52ce4cce1d20fc277c3ee9ec46ea3566a51ec633 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -1,26 +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 app.core.parsers import slide_parser -from app.database.models import Competition, Slide -from flask_jwt_extended import jwt_required 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_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)) @@ -29,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) @@ -54,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) @@ -83,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 514748ae27c7ff685d2b1099d1bf710610928860..71ca715d55d21de75f07db4241e5ef0bb14e1050 100644 --- a/server/app/apis/teams.py +++ b/server/app/apis/teams.py @@ -1,28 +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 app.core.parsers import team_parser -from app.database.models import Competition, Team -from flask_jwt_extended import get_jwt_identity, jwt_required -from flask_restx import Namespace, Resource, reqparse +from flask_restx import Resource, reqparse +from app.core.parsers import sentinel api = TeamDTO.api schema = TeamDTO.schema list_schema = TeamDTO.list_schema +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)) @@ -30,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 1bae000bc6821ff7c7ac3dde82474bd351c2fe4d..dc26ac5b64e9a4270215374de84f3c71cae66f9e 100644 --- a/server/app/apis/users.py +++ b/server/app/apis/users.py @@ -1,19 +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 UserDTO -from app.core.parsers import user_parser, user_search_parser -from app.database.models import User -from flask import request -from flask_jwt_extended import get_jwt_identity, jwt_required -from flask_restx import Namespace, Resource +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, sentinel api = UserDTO.api schema = UserDTO.schema list_schema = UserDTO.list_schema +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") -def edit_user(item_user, args): +user_search_parser = search_parser.copy() +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): email = args.get("email") name = args.get("name") @@ -29,38 +40,38 @@ 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) + item = _edit_user(item, args) return item_response(schema.dump(item)) @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) + item = _edit_user(item, args) return item_response(schema.dump(item)) @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 1e9813e99d9d131d42dd221ef893ff6eb99b81f6..160d67a0af7b58483202c00c1ec6b97097de0633 100644 --- a/server/app/core/parsers.py +++ b/server/app/core/parsers.py @@ -1,109 +1,25 @@ from flask_restx import inputs, reqparse -###SEARCH#### -search_parser = reqparse.RequestParser() -search_parser.add_argument("page", type=int, default=0, location="args") -search_parser.add_argument("page_size", type=int, default=15, location="args") -search_parser.add_argument("order", type=int, default=1, location="args") -search_parser.add_argument("order_by", type=str, default=None, location="args") - -###LOGIN#### -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") - -###CREATE_USER#### -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") - -###USER#### -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") - -###SEARCH_USER#### -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") - - -###COMPETITION#### -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") - - -###SEARCH_COMPETITION#### -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") - -###SLIDER_PARSER#### -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") +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" -###QUESTION#### -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") + def __bool__(self): + return False -###QUESTION ALTERNATIVES#### -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") +sentinel = Sentinel() -###QUESTION ANSWERS#### -question_answer_parser = reqparse.RequestParser() -question_answer_parser.add_argument("data", type=dict, 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") - -###QUESTION ANSWERS EDIT#### -question_answer_edit_parser = reqparse.RequestParser() -question_answer_edit_parser.add_argument("data", type=dict, default=None, location="json") -question_answer_edit_parser.add_argument("score", type=int, default=None, location="json") - -###CODE#### -code_parser = reqparse.RequestParser() -code_parser.add_argument("pointer", type=str, default=None, location="json") -code_parser.add_argument("view_type_id", type=int, default=None, location="json") - - -###TEAM#### -team_parser = reqparse.RequestParser() -team_parser.add_argument("name", type=str, location="json") - -###SEARCH_COMPETITION#### -media_parser_search = search_parser.copy() -media_parser_search.add_argument("filename", type=str, default=None, location="args") - - -###COMPONENT### -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_parser.add_argument("data", type=dict, default=None, location="json") - -component_create_parser = component_parser.copy() -# component_create_parser.replace_argument("data", type=dict, required=True, location="json") -component_create_parser.add_argument("type_id", type=int, required=True, location="json") -component_create_parser.add_argument("text", type=str, required=False, location="json") -component_create_parser.add_argument("media_id", type=str, required=False, location="json") - -login_code_parser = reqparse.RequestParser() -login_code_parser.add_argument("code", type=str, location="json") +###SEARCH#### +search_parser = reqparse.RequestParser() +search_parser.add_argument("page", type=int, default=0, location="args") +search_parser.add_argument("page_size", type=int, default=15, location="args") +search_parser.add_argument("order", type=int, default=1, location="args") +search_parser.add_argument("order_by", type=str, default=None, location="args") diff --git a/server/app/core/rich_schemas.py b/server/app/core/rich_schemas.py index da5f58d653ea5efb6c00648db56c1bc81a24d44b..ae1c6ab6e0bbd63ebd1369e1741672a6489c87fa 100644 --- a/server/app/core/rich_schemas.py +++ b/server/app/core/rich_schemas.py @@ -42,8 +42,7 @@ class SlideSchemaRich(RichSchema): title = ma.auto_field() timer = ma.auto_field() competition_id = ma.auto_field() - background_image_id = ma.auto_field() - background_image = ma.Function(lambda x: x.background_image.filename if x.background_image is not None else "") + background_image = fields.Nested(schemas.MediaSchema, many=False) questions = fields.Nested(QuestionSchemaRich, many=True) components = fields.Nested(schemas.ComponentSchema, many=True) @@ -56,8 +55,7 @@ class CompetitionSchemaRich(RichSchema): name = ma.auto_field() year = ma.auto_field() city_id = ma.auto_field() - background_image_id = ma.auto_field() - background_image = ma.Function(lambda x: x.background_image.filename if x.background_image is not None else "") + background_image = fields.Nested(schemas.MediaSchema, many=False) slides = fields.Nested( SlideSchemaRich, diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 4e440fc4b5e282201f19602a428a506e763f670a..4008b8869cc084022d4d46386d89ec29ca1765d2 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -1,5 +1,5 @@ from marshmallow.decorators import pre_load -from marshmallow.decorators import pre_dump +from marshmallow.decorators import pre_dump, post_dump import app.database.models as models from app.core import ma from marshmallow_sqlalchemy import fields @@ -65,7 +65,7 @@ class QuestionAnswerSchema(BaseSchema): model = models.QuestionAnswer id = ma.auto_field() - data = ma.Function(lambda obj: obj.data) + answer = ma.auto_field() score = ma.auto_field() question_id = ma.auto_field() team_id = ma.auto_field() @@ -116,8 +116,7 @@ class SlideSchema(BaseSchema): title = ma.auto_field() timer = ma.auto_field() competition_id = ma.auto_field() - background_image_id = ma.auto_field() - background_image = ma.Function(lambda x: x.background_image.filename if x.background_image is not None else "") + background_image = fields.Nested(MediaSchema, many=False) class TeamSchema(BaseSchema): @@ -148,8 +147,7 @@ class CompetitionSchema(BaseSchema): name = ma.auto_field() year = ma.auto_field() city_id = ma.auto_field() - background_image_id = ma.auto_field() - background_image = ma.Function(lambda x: x.background_image.filename if x.background_image is not None else "") + background_image = fields.Nested(MediaSchema, many=False) class ComponentSchema(BaseSchema): @@ -163,7 +161,8 @@ 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_id = fields.fields.Integer() - filename = ma.Function(lambda x: x.media.filename if hasattr(x, "media_id") else "") + media = fields.Nested(MediaSchema, many=False) + question_id = fields.fields.Integer() \ No newline at end of file diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index 04099ff2fc3268301d6a1e5cc130f30774a8bc16..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": @@ -93,18 +100,18 @@ def join_presentation(data): logger.error(f"Client '{request.sid}' failed to join presentation with code '{code}', no such code exists") return - competition_id = ( - item_code.pointer - if item_code.view_type_id != team_view_id - else db.session.query(Team).filter(Team.id == item_code.pointer).one().competition_id - ) + 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 @@ -124,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 @@ -155,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? @@ -172,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 dceccfe85851a9dffe24032c0df5cc199f056ab0..6c5ea14bcd143a4bac5d0f214fc3d14f8f73bfe0 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -20,6 +20,7 @@ from app.database.models import ( Question, QuestionAlternative, QuestionAnswer, + QuestionComponent, QuestionType, Role, Slide, @@ -37,6 +38,8 @@ from sqlalchemy.orm import relation from sqlalchemy.orm.session import sessionmaker from flask import current_app +from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT + def db_add(item): """ @@ -59,7 +62,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 . @@ -79,12 +82,15 @@ def component(type_id, slide_id, x=0, y=0, w=0, h=0, **data): w *= ratio h *= ratio - if type_id == 1: - item = db_add(TextComponent(slide_id, type_id, x, y, w, h)) + if type_id == ID_TEXT_COMPONENT: + 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)) + elif type_id == ID_IMAGE_COMPONENT: + item = db_add(ImageComponent(slide_id, type_id, view_type_id, x, y, w, h)) item.media_id = data.get("media_id") + elif type_id == ID_QUESTION_COMPONENT: + item = db_add(QuestionComponent(slide_id, type_id, view_type_id, x, y, w, h)) + item.question_id = data.get("question_id") else: abort(codes.BAD_REQUEST, f"Invalid type_id{type_id}") @@ -151,9 +157,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 @@ -246,5 +256,5 @@ def question_alternative(text, value, question_id): return db_add(QuestionAlternative(text, value, question_id)) -def question_answer(data, score, question_id, team_id): - return db_add(QuestionAnswer(data, score, question_id, team_id)) +def question_answer(answer, score, question_id, team_id): + return db_add(QuestionAnswer(answer, score, question_id, team_id)) diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index 87e3f831542aac9478566e916d12118f66a7c120..a32ba1deda39c231bfa7ea2b26f91337e0ee2bec 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -4,6 +4,7 @@ This file contains functionality to copy and duplicate data to the database. from app.database.controller import add, get, search, utils from app.database.models import Question +from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT def _alternative(item_old, question_id): @@ -38,13 +39,16 @@ def _component(item_component, item_slide_new): component item to the specified slide. """ data = {} - if item_component.type_id == 1: + if item_component.type_id == ID_TEXT_COMPONENT: data["text"] = item_component.text - elif item_component.type_id == 2: + elif item_component.type_id == ID_IMAGE_COMPONENT: data["media_id"] = item_component.media_id + elif item_component.type_id == ID_QUESTION_COMPONENT: + data["question_id"] = item_component.question_id add.component( item_component.type_id, item_slide_new.id, + item_component.view_type_id, item_component.x, item_component.y, item_component.w, @@ -77,8 +81,7 @@ def slide_to_competition(item_slide_old, item_competition): item_slide_new.body = item_slide_old.body item_slide_new.timer = item_slide_old.timer item_slide_new.settings = item_slide_old.settings - - # TODO: Add background image + item_slide_new.background_image_id = item_slide_old.background_image_id for item_component in item_slide_old.components: _component(item_component, item_slide_new) @@ -109,6 +112,7 @@ def competition(item_competition_old): item_competition_old.font, ) # TODO: Add background image + item_competition_new.background_image_id = item_competition_old.background_image_id for item_slide in item_competition_old.slides: slide_to_competition(item_slide, item_competition_new) 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 1ed99d911700c64349816e937230f2cd0e90e437..f9f18438ad07edf951cebd24ad7ab92e9b854299 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -1,8 +1,7 @@ from app.core import bcrypt, db -from app.database import Dictionary from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property -from app.database.types import ID_IMAGE_COMPONENT, ID_TEXT_COMPONENT +from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT STRING_SIZE = 254 @@ -175,7 +174,7 @@ class QuestionAlternative(db.Model): class QuestionAnswer(db.Model): __table_args__ = (db.UniqueConstraint("question_id", "team_id"),) id = db.Column(db.Integer, primary_key=True) - data = db.Column(Dictionary(), nullable=False) + answer = db.Column(db.String(STRING_SIZE), nullable=False) score = db.Column(db.Integer, nullable=False, default=0) question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=False) @@ -200,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): @@ -224,6 +224,13 @@ class ImageComponent(Component): __mapper_args__ = {"polymorphic_identity": ID_IMAGE_COMPONENT} +class QuestionComponent(Component): + question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=True) + + # __tablename__ = None + __mapper_args__ = {"polymorphic_identity": ID_QUESTION_COMPONENT} + + class Code(db.Model): id = db.Column(db.Integer, primary_key=True) code = db.Column(db.Text, unique=True) @@ -231,6 +238,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 @@ -241,7 +250,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/app/database/types.py b/server/app/database/types.py index 236e50f5278d03684f2cbdcc05c2e7637e21a057..f53835eccf10599eb9ab81d8af1426e02a21e405 100644 --- a/server/app/database/types.py +++ b/server/app/database/types.py @@ -1,2 +1,3 @@ ID_TEXT_COMPONENT = 1 ID_IMAGE_COMPONENT = 2 +ID_QUESTION_COMPONENT = 3 \ No newline at end of file diff --git a/server/populate.py b/server/populate.py index f323a525bcf7c639aecc66d57bed61eaf71b1db9..92183b6cc87f8f2e5377fb934c3ff14219920a7f 100644 --- a/server/populate.py +++ b/server/populate.py @@ -86,7 +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, 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, 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):