diff --git a/client/package-lock.json b/client/package-lock.json index 667b75859fe71467505839e687c7e478b8a52834..72fcea719f2d7503990a778ded3a56f7b0e55e5f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2526,6 +2526,15 @@ "csstype": "^3.0.2" } }, + "@types/react-beautiful-dnd": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz", + "integrity": "sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-dom": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.0.tgz", @@ -5161,6 +5170,14 @@ "postcss": "^7.0.5" } }, + "css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "requires": { + "tiny-invariant": "^1.0.6" + } + }, "css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -11058,6 +11075,11 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, + "memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "memory-fs": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", @@ -13519,6 +13541,11 @@ "performance-now": "^2.1.0" } }, + "raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "railroad-diagrams": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", @@ -13610,6 +13637,20 @@ "resolved": "https://registry.npmjs.org/react-axios/-/react-axios-2.0.4.tgz", "integrity": "sha512-QsTq7C/NwsjfrSmFVxPo29BdX6DtLpRF0fZTJv5/R4BanOm+c4639B3Xb4lF83ZfAOX5IW8XG7htz4V+WNF+WA==" }, + "react-beautiful-dnd": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz", + "integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==", + "requires": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + } + }, "react-dev-utils": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.3.tgz", @@ -16568,6 +16609,11 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "use-memo-one": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz", + "integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==" + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", @@ -17239,8 +17285,7 @@ }, "ssri": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", - "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", + "resolved": "", "requires": { "figgy-pudding": "^3.5.1" } diff --git a/client/package.json b/client/package.json index 8733d1d45b7c2e4c2349167573e7411aa66afd45..a0c8b7f5c74a6587f48cdf6c7d478311f2529185 100644 --- a/client/package.json +++ b/client/package.json @@ -24,6 +24,7 @@ "jwt-decode": "^3.1.2", "react": "^17.0.1", "react-axios": "^2.0.4", + "react-beautiful-dnd": "^13.1.0", "react-dom": "^17.0.1", "react-redux": "^7.2.2", "react-rnd": "^10.2.4", @@ -41,6 +42,7 @@ }, "devDependencies": { "@types/enzyme": "^3.10.8", + "@types/react-beautiful-dnd": "^13.0.0", "@types/react-redux": "^7.1.16", "@types/react-router-dom": "^5.1.7", "@types/redux-mock-store": "^1.0.2", diff --git a/client/src/Main.tsx b/client/src/Main.tsx index 5cd27cc3d83e96bda772e2e9b07282903ed069f5..a44d182334d526e3632f038f8c8b1b5875d8dc8b 100644 --- a/client/src/Main.tsx +++ b/client/src/Main.tsx @@ -28,30 +28,10 @@ const Main: React.FC = () => { component={PresentationEditorPage} /> <Route exact path="/:code" component={ViewSelectPage} /> - <SecureRoute - authLevel="competition" - exact - path="/team/competition-id=:competitionId" - component={TeamViewPage} - /> - <SecureRoute - authLevel="competition" - exact - path="/operator/competition-id=:competitionId" - component={OperatorViewPage} - /> - <SecureRoute - authLevel="competition" - exact - path="/judge/competition-id=:competitionId" - component={JudgeViewPage} - /> - <SecureRoute - authLevel="competition" - exact - path="/audience/competition-id=:competitionId" - component={AudienceViewPage} - /> + <SecureRoute authLevel="Team" exact path="/view/team" component={TeamViewPage} /> + <SecureRoute authLevel="Operator" exact path="/view/operator" component={OperatorViewPage} /> + <SecureRoute authLevel="Judge" exact path="/view/judge" component={JudgeViewPage} /> + <SecureRoute authLevel="Audience" exact path="/view/audience" component={AudienceViewPage} /> </Switch> </BrowserRouter> ) diff --git a/client/src/actions/competitionLogin.ts b/client/src/actions/competitionLogin.ts index 448eec249f360acbadeb6161abac6412daccb32b..97dc76e4499551bbfba87222aedae6e7a30bad72 100644 --- a/client/src/actions/competitionLogin.ts +++ b/client/src/actions/competitionLogin.ts @@ -4,12 +4,14 @@ This file handles actions for the competitionLogin redux state import axios from 'axios' import { History } from 'history' -import { AppDispatch } from '../store' +import { AppDispatch, RootState } from '../store' +import { getPresentationCompetition } from './presentation' import Types from './types' // Action creator to attempt to login with competition code export const loginCompetition = (code: string, history: History, redirect: boolean) => async ( - dispatch: AppDispatch + dispatch: AppDispatch, + getState: () => RootState ) => { dispatch({ type: Types.LOADING_COMPETITION_LOGIN }) await axios @@ -27,7 +29,8 @@ export const loginCompetition = (code: string, history: History, redirect: boole view: res.data.view, }, }) - if (redirect && res.data && res.data.view_type_id) { + getPresentationCompetition(res.data.competition_id)(dispatch, getState) + if (redirect && res.data && res.data.view) { history.push(`/${code}`) } }) diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index b422f95423539cc2f2ea0bf4e7ecdc58f78bdbc8..0ca8a02ffa1d115e165091276b740131be9d9758 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -15,7 +15,6 @@ export default { SET_ERRORS: 'SET_ERRORS', CLEAR_ERRORS: 'CLEAR_ERRORS', SET_COMPETITION_LOGIN_DATA: 'SET_COMPETITION_LOGIN_DATA', - SET_COMPETITION_LOGIN_AUTHENTICATED: 'SET_COMPETITION_LOGIN_AUTHENTICATED', SET_COMPETITION_LOGIN_UNAUTHENTICATED: 'SET_COMPETITION_LOGIN_UNAUTHENTICATED', SET_COMPETITION_LOGIN_ERRORS: 'SET_COMPETITION_LOGIN_ERRORS', CLEAR_COMPETITION_LOGIN_ERRORS: 'CLEAR_COMPETITION_LOGIN_ERRORS', diff --git a/client/src/pages/admin/competitions/CompetitionManager.tsx b/client/src/pages/admin/competitions/CompetitionManager.tsx index b7184d55595b23333c895604b2d311a0a80e530c..affd5c26c192025afb800314a9ce1728c9d31935 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.tsx @@ -1,17 +1,17 @@ import { + Box, Button, - Menu, - ListItem, - TablePagination, - TextField, - Typography, Dialog, - DialogTitle, - DialogContent, DialogActions, + DialogContent, + DialogTitle, + ListItem, ListItemText, + Menu, + TablePagination, + TextField, Tooltip, - Box, + Typography, } from '@material-ui/core' import FormControl from '@material-ui/core/FormControl' import InputLabel from '@material-ui/core/InputLabel' @@ -25,7 +25,10 @@ import TableCell from '@material-ui/core/TableCell' import TableContainer from '@material-ui/core/TableContainer' import TableHead from '@material-ui/core/TableHead' import TableRow from '@material-ui/core/TableRow' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import LinkIcon from '@material-ui/icons/Link' import MoreHorizIcon from '@material-ui/icons/MoreHoriz' +import RefreshIcon from '@material-ui/icons/Refresh' import axios from 'axios' import React, { useEffect } from 'react' import { Link, useHistory } from 'react-router-dom' @@ -35,8 +38,6 @@ import { Team } from '../../../interfaces/ApiModels' import { CompetitionFilterParams } from '../../../interfaces/FilterParams' import { FilterContainer, RemoveMenuItem, TopBar, YearFilterTextField } from '../styledComp' import AddCompetition from './AddCompetition' -import FileCopyIcon from '@material-ui/icons/FileCopy' -import RefreshIcon from '@material-ui/icons/Refresh' /** * Component description: @@ -83,14 +84,16 @@ const CompetitionManager: React.FC = (props: any) => { const filterParams = useAppSelector((state) => state.competitions.filterParams) const competitionTotal = useAppSelector((state) => state.competitions.total) const cities = useAppSelector((state) => state.cities.cities) - const classes = useStyles() const noFilterText = 'Alla' const dispatch = useAppDispatch() const history = useHistory() + const handleClick = (event: React.MouseEvent<HTMLButtonElement>, id: number) => { - setAnchorEl(event.currentTarget) setActiveId(id) + getCodes(id) + getTeams(id) + setAnchorEl(event.currentTarget) } const handleClose = () => { @@ -129,24 +132,26 @@ const CompetitionManager: React.FC = (props: any) => { } const handleStartCompetition = () => { - history.push(`/operator/id=${activeId}&code=123123`) + const operatorCode = codes.find((code) => code.view_type_id === 4)?.code + if (operatorCode) { + history.push(`/${operatorCode}`) + } } - const getCodes = async () => { + const getCodes = async (id: number) => { await axios - .get(`/api/competitions/${activeId}/codes`) + .get(`/api/competitions/${id}/codes`) .then((response) => { - console.log(response.data) setCodes(response.data.items) }) .catch(console.log) } - const getTeams = async () => { + const getTeams = async (id: number) => { await axios - .get(`/api/competitions/${activeId}/teams`) + .get(`/api/competitions/${id}/teams`) .then((response) => { - console.log(response.data.items) + // console.log(response.data.items) setTeams(response.data.items) }) .catch((err) => { @@ -158,7 +163,7 @@ const CompetitionManager: React.FC = (props: any) => { await axios .get(`/api/competitions/${activeId}`) .then((response) => { - console.log(response.data.name) + // console.log(response.data.name) setCompetitionName(response.data.name) }) .catch((err) => { @@ -194,8 +199,6 @@ const CompetitionManager: React.FC = (props: any) => { } const handleOpenDialog = async () => { - await getCodes() - await getTeams() await getCompetitionName() setDialogIsOpen(true) } @@ -225,15 +228,17 @@ const CompetitionManager: React.FC = (props: any) => { } const refreshCode = async (code: Code) => { - await axios - .put(`/api/competitions/${activeId}/codes/${code.id}`) - .then(() => { - getCodes() - dispatch(getCompetitions()) - }) - .catch(({ response }) => { - console.warn(response.data) - }) + if (activeId) { + await axios + .put(`/api/competitions/${activeId}/codes/${code.id}`) + .then(() => { + getCodes(activeId) + dispatch(getCompetitions()) + }) + .catch(({ response }) => { + console.warn(response.data) + }) + } } return ( @@ -373,6 +378,16 @@ const CompetitionManager: React.FC = (props: any) => { <FileCopyIcon fontSize="small" /> </Button> </Tooltip> + <Tooltip title="Kopiera länk" arrow> + <Button + margin-right="0px" + onClick={() => { + navigator.clipboard.writeText(`${window.location.host}/${code.code}`) + }} + > + <LinkIcon fontSize="small" /> + </Button> + </Tooltip> </ListItem> ))} </DialogContent> diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx index 5426152ff33783745f8dc1181237cd2926b0a16a..7254a69d3bdae4e39c6ce1097dc06e753ff333f0 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx @@ -13,7 +13,7 @@ it('renders presentation editor', () => { id: 0, year: 0, city_id: 0, - slides: [{ id: 5 }], + slides: [{ id: 5, order: 2 }], teams: [], }, } diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.tsx index 886f8d19a24ea6f8d7469b1481a5a11def854469..ddbe7468df6489d4abe69258b4fb504b90dde3b5 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -1,9 +1,10 @@ -import { Button, CircularProgress, Divider, Menu, MenuItem } from '@material-ui/core' +import { Button, ButtonGroup, CircularProgress, Divider, Menu, MenuItem } from '@material-ui/core' import CssBaseline from '@material-ui/core/CssBaseline' import ListItemText from '@material-ui/core/ListItemText' import AddOutlinedIcon from '@material-ui/icons/AddOutlined' import axios from 'axios' import React, { useEffect, useState } from 'react' +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd' import { Link, useParams } from 'react-router-dom' import { getCities } from '../../actions/cities' import { getEditorCompetition, setEditorSlideId, setEditorViewId } from '../../actions/editor' @@ -31,7 +32,6 @@ import { ToolBarContainer, ToolbarMargin, ViewButton, - ViewButtonGroup, } from './styled' const initialState = { @@ -101,7 +101,7 @@ const PresentationEditorPage: React.FC = () => { } const viewTypes = useAppSelector((state) => state.types.viewTypes) - const [activeViewTypeName, setActiveViewTypeName] = useState('') + const [activeViewTypeName, setActiveViewTypeName] = useState('Audience') const changeView = (clickedViewTypeName: string) => { setActiveViewTypeName(clickedViewTypeName) const clickedViewTypeId = viewTypes.find((viewType) => viewType.name === clickedViewTypeName)?.id @@ -110,6 +110,20 @@ const PresentationEditorPage: React.FC = () => { } } + const onDragEnd = async (result: DropResult) => { + // dropped outside the list + if (!result.destination) { + return + } + const draggedIndex = result.source.index + const draggedSlideId = competition.slides.find((slide) => slide.order === draggedIndex)?.id + if (draggedSlideId) { + await axios + .put(`/api/competitions/${competitionId}/slides/${draggedSlideId}/order`, { order: result.destination.index }) + .then(() => dispatch(getEditorCompetition(competitionId))) + .catch(console.log) + } + } return ( <PresentationEditorContainer> <CssBaseline /> @@ -122,10 +136,9 @@ const PresentationEditorPage: React.FC = () => { {competition.name} </CompetitionName> - <ViewButtonGroup> + <ButtonGroup color="secondary" variant="contained"> <ViewButton $activeView={activeViewTypeName === 'Audience'} - variant="contained" color="secondary" onClick={() => changeView('Audience')} > @@ -133,40 +146,53 @@ const PresentationEditorPage: React.FC = () => { </ViewButton> <ViewButton $activeView={activeViewTypeName === 'Team'} - variant="contained" color="secondary" onClick={() => changeView('Team')} > Deltagarvy </ViewButton> - </ViewButtonGroup> + </ButtonGroup> </ToolBarContainer> </AppBarEditor> <LeftDrawer $leftDrawerWidth={leftDrawerWidth} $rightDrawerWidth={undefined} variant="permanent" anchor="left"> <FillLeftContainer $leftDrawerWidth={leftDrawerWidth} $rightDrawerWidth={undefined}> <ToolbarMargin /> <SlideList> - {competition.slides && - competition.slides.map((slide) => ( - <SlideListItem - divider - button - key={slide.id} - selected={slide.id === activeSlideId} - onClick={() => setActiveSlideId(slide.id)} - onContextMenu={(event) => handleRightClick(event, slide.id)} - > - {renderSlideIcon(slide)} - <ListItemText primary={`Sida ${slide.order + 1}`} /> - </SlideListItem> - ))} + <DragDropContext onDragEnd={onDragEnd}> + <Droppable droppableId="droppable"> + {(provided) => ( + <div key={provided.innerRef.toString()} ref={provided.innerRef} {...provided.droppableProps}> + {competition.slides && + competition.slides.map((slide, index) => ( + <Draggable key={slide.order} draggableId={slide.id.toString()} index={index}> + {(provided, snapshot) => ( + <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> + <SlideListItem + divider + button + selected={slide.id === activeSlideId} + onClick={() => setActiveSlideId(slide.id)} + onContextMenu={(event) => handleRightClick(event, slide.id)} + > + {renderSlideIcon(slide)} + <ListItemText primary={`Sida ${slide.order + 1}`} /> + </SlideListItem> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </DragDropContext> </SlideList> <PositionBottom> <Divider /> <SlideListItem divider button onClick={() => createNewSlide()}> <ListItemText primary="Ny sida" /> <AddOutlinedIcon /> - </SlideListItem> + </SlideListItem> </PositionBottom> </FillLeftContainer> </LeftDrawer> @@ -187,7 +213,7 @@ const PresentationEditorPage: React.FC = () => { <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> <InnerContent> - <SlideDisplay variant="editor" activeViewTypeId={activeViewTypeId} /> + {competitionLoading && <SlideDisplay variant="editor" activeViewTypeId={activeViewTypeId} />} </InnerContent> </Content> <Menu diff --git a/client/src/pages/presentationEditor/components/RndComponent.tsx b/client/src/pages/presentationEditor/components/RndComponent.tsx index c13126be31fc2f0f55618b19be5e14527f28b232..4c09280042bd038fee1e3adc4b5bf3b7aef8ef6a 100644 --- a/client/src/pages/presentationEditor/components/RndComponent.tsx +++ b/client/src/pages/presentationEditor/components/RndComponent.tsx @@ -1,15 +1,18 @@ -import { Card, IconButton, Tooltip } from '@material-ui/core' +import { Card, IconButton, Menu, MenuItem, Tooltip } from '@material-ui/core' import axios from 'axios' import React, { useEffect, useState } from 'react' import { Rnd } from 'react-rnd' +import { getEditorCompetition } from '../../../actions/editor' import { ComponentTypes } from '../../../enum/ComponentTypes' -import { useAppSelector } from '../../../hooks' +import { useAppDispatch, useAppSelector } from '../../../hooks' import { Component, ImageComponent, TextComponent } from '../../../interfaces/ApiModels' import { Position, Size } from '../../../interfaces/Components' +import { RemoveMenuItem } from '../../admin/styledComp' import ImageComponentDisplay from './ImageComponentDisplay' import QuestionComponentDisplay from './QuestionComponentDisplay' import { HoverContainer } from './styled' import TextComponentDisplay from './TextComponentDisplay' +//import NestedMenuItem from 'material-ui-nested-menu-item' type RndComponentProps = { component: Component @@ -18,6 +21,8 @@ type RndComponentProps = { scale: number } +const initialMenuState = { menuIsOpen: false, mouseX: null, mouseY: null, componentId: null } + const RndComponent = ({ component, width, height, scale }: RndComponentProps) => { const [hover, setHover] = useState(false) const [currentPos, setCurrentPos] = useState<Position>({ x: component.x, y: component.y }) @@ -28,6 +33,14 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => const typeName = useAppSelector( (state) => state.types.componentTypes.find((componentType) => componentType.id === component.type_id)?.name ) + const [menuState, setMenuState] = useState<{ + menuIsOpen: boolean + mouseX: null | number + mouseY: null | number + componentId: null | number + }>(initialMenuState) + const dispatch = useAppDispatch() + const handleUpdatePos = (pos: Position) => { axios.put(`/api/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { x: pos.x, @@ -50,6 +63,37 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => setCurrentPos({ x: currentPos.x, y: centerY }) handleUpdatePos({ x: currentPos.x, y: centerY }) } + const handleRightClick = (event: React.MouseEvent<HTMLDivElement>, componentId: number) => { + event.preventDefault() + setMenuState({ + menuIsOpen: true, + mouseX: event.clientX - 2, + mouseY: event.clientY - 4, + componentId: componentId, + }) + } + const handleCloseMenu = () => { + setMenuState(initialMenuState) + } + const handleDuplicateComponent = async (viewTypeId: number) => { + console.log('Duplicate') + await axios + .post( + `/api/competitions/${competitionId}/slides/${slideId}/components/${menuState.componentId}/copy/${viewTypeId}` + ) + .then(() => dispatch(getEditorCompetition(competitionId.toString()))) + .catch(console.log) + setMenuState(initialMenuState) + } + const handleRemoveComponent = async () => { + console.log('Remove') + await axios + .delete(`/api/competitions/${competitionId}/slides/${slideId}/components/${menuState.componentId}`) + .then(() => dispatch(getEditorCompetition(competitionId.toString()))) + .catch(console.log) + setMenuState(initialMenuState) + } + useEffect(() => { const downHandler = (ev: KeyboardEvent) => { if (ev.key === 'Shift') setShiftPressed(true) @@ -109,6 +153,8 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => lockAspectRatio={shiftPressed} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} + //Right click to open menu + onContextMenu={(event: React.MouseEvent<HTMLDivElement>) => handleRightClick(event, component.id)} //Multiply by scale to show components correctly for current screen size size={{ width: currentSize.w * scale, height: currentSize.h * scale }} position={{ x: currentPos.x * scale, y: currentPos.y * scale }} @@ -136,6 +182,23 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => </Card> )} {renderInnerComponent()} + <Menu + keepMounted + open={menuState.menuIsOpen} + onClose={handleCloseMenu} + anchorReference="anchorPosition" + anchorPosition={ + menuState.mouseY !== null && menuState.mouseX !== null + ? { top: menuState.mouseY, left: menuState.mouseX } + : undefined + } + > + {/* <NestedMenuItem label="Duplicera"> */} + <MenuItem onClick={() => handleDuplicateComponent(3)}>Duplicera till åskådarvy</MenuItem> + <MenuItem onClick={() => handleDuplicateComponent(1)}>Duplicera till deltagarvy</MenuItem> + {/* </NestedMenuItem> */} + <RemoveMenuItem onClick={handleRemoveComponent}>Ta bort</RemoveMenuItem> + </Menu> </Rnd> ) } diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx index 04ddd6daa7bb015b3e48b905b3f17f3594c8ba98..521752778b5c7377c8bfe26c6cd2a3dbebcaeb50 100644 --- a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx @@ -50,7 +50,7 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { } return ( - <div style={{ minHeight: '300px', height: '100%', width: '100%' }}> + <> <Editor value={content || ''} init={{ @@ -81,7 +81,7 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { <DeleteTextButton variant="contained" color="secondary" onClick={() => handleDeleteText(component.id)}> Ta bort </DeleteTextButton> - </div> + </> ) } diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx index 48917fcc01967b72e121b506fc6e4e05ac883c40..99b4b1d8d118e65595b38077e8fd15afa103ad05 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx @@ -54,6 +54,7 @@ const Instructions = ({ activeSlide, competitionId }: InstructionsProps) => { <ListItem divider> <Center> <TextField + multiline id="outlined-basic" defaultValue={activeSlide.questions[0].correcting_instructions} onChange={updateInstructionsText} diff --git a/client/src/pages/presentationEditor/styled.tsx b/client/src/pages/presentationEditor/styled.tsx index 4f830687898bd87fbd7c637dfd19706324fe909e..779599dab4cd748333631b734decf2051389389c 100644 --- a/client/src/pages/presentationEditor/styled.tsx +++ b/client/src/pages/presentationEditor/styled.tsx @@ -20,20 +20,13 @@ export const ToolBarContainer = styled(Toolbar)` ` 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` - display: flex; - flex-direction: row; -` - export const SlideList = styled(List)` height: calc(100% - ${SlideListHeight}px); padding: 0px; @@ -70,6 +63,7 @@ export const LeftDrawer = styled(Drawer)<DrawerSizeProps>` flex-shrink: 0; position: relative; z-index: 1; + overflow: hidden; ` export const RightDrawer = styled(Drawer)<DrawerSizeProps>` diff --git a/client/src/pages/views/AudienceViewPage.tsx b/client/src/pages/views/AudienceViewPage.tsx index 48b92c4686c762536c629e1c33f5bb33ade9cc59..28280c90bb0b370802c39b7646c7e59ded4b0f55 100644 --- a/client/src/pages/views/AudienceViewPage.tsx +++ b/client/src/pages/views/AudienceViewPage.tsx @@ -1,21 +1,15 @@ import { Typography } from '@material-ui/core' import React, { useEffect } from 'react' -import { useParams } from 'react-router-dom' -import { getPresentationCompetition } from '../../actions/presentation' -import { useAppDispatch, useAppSelector } from '../../hooks' -import { ViewParams } from '../../interfaces/ViewParams' +import { useAppSelector } from '../../hooks' import { socketConnect, socketJoinPresentation } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { PresentationBackground, PresentationContainer } from './styled' const AudienceViewPage: React.FC = () => { - const { competitionId }: ViewParams = useParams() const code = useAppSelector((state) => state.presentation.code) - const dispatch = useAppDispatch() const viewTypes = useAppSelector((state) => state.types.viewTypes) const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id useEffect(() => { - dispatch(getPresentationCompetition(competitionId)) if (code && code !== '') { socketConnect() socketJoinPresentation() diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index e92dc1b9b470977c563b8bc6b955af7da984b6ea..fd3dd05b78403f3193e3eedd4543368fc7a92306 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -25,11 +25,8 @@ import SupervisorAccountIcon from '@material-ui/icons/SupervisorAccount' import TimerIcon from '@material-ui/icons/Timer' import axios from 'axios' import React, { useEffect } from 'react' -import { useHistory, useParams } from 'react-router-dom' -import { getPresentationCompetition } from '../../actions/presentation' -import { useAppDispatch, useAppSelector } from '../../hooks' -import { Team } from '../../interfaces/ApiModels' -import { ViewParams } from '../../interfaces/ViewParams' +import { useHistory } from 'react-router-dom' +import { useAppSelector } from '../../hooks' import { socketConnect, socketEndPresentation, @@ -62,7 +59,6 @@ import { * TODO: * - Instead of copying code for others to join the competition, copy URL. * - * - Make code popup less code by using .map instead * * - Fix scoreboard * @@ -100,24 +96,21 @@ const OperatorViewPage: React.FC = () => { const [openAlert, setOpen] = React.useState(false) const [openAlertCode, setOpenCode] = React.useState(false) const [codes, setCodes] = React.useState<Code[]>([]) - const [teams, setTeams] = React.useState<Team[]>([]) const [competitionName, setCompetitionName] = React.useState<string | undefined>(undefined) //const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) const classes = useStyles() - //const teams = useAppSelector((state) => state.presentation.competition.teams) + const teams = useAppSelector((state) => state.presentation.competition.teams) const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) - const { competitionId }: ViewParams = useParams() + const competitionId = useAppSelector((state) => state.competitionLogin.data?.competition_id) const presentation = useAppSelector((state) => state.presentation) const activeId = useAppSelector((state) => state.presentation.competition.id) const history = useHistory() - const dispatch = useAppDispatch() const viewTypes = useAppSelector((state) => state.types.viewTypes) const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id useEffect(() => { - dispatch(getPresentationCompetition(competitionId)) socketConnect() socketSetSlide // Behövs denna? handleOpenCodes() @@ -153,15 +146,10 @@ const OperatorViewPage: React.FC = () => { const handleOpenCodes = async () => { await getCodes() - await getTeams() await getCompetitionName() setOpenCode(true) } - const handleCopy = () => { - console.log('copied code to clipboard') - } - const endCompetition = () => { setOpen(false) socketEndPresentation() @@ -178,17 +166,6 @@ const OperatorViewPage: React.FC = () => { .catch(console.log) } - const getTeams = async () => { - await axios - .get(`/api/competitions/${activeId}/teams`) - .then((response) => { - setTeams(response.data.items) - }) - .catch((err) => { - console.log(err) - }) - } - const getCompetitionName = async () => { await axios .get(`/api/competitions/${activeId}`) @@ -227,6 +204,16 @@ const OperatorViewPage: React.FC = () => { return typeName } + const addScore = (id: number) => { + // Sums the scores for the teams. id must be id-1 because it starts at 1 + + let totalScore = 0 + for (let j = 0; j < teams[id - 1].question_answers.length; j++) { + totalScore = totalScore + teams[id - 1].question_answers[j].score + } + return totalScore + } + return ( <OperatorContainer> <Dialog @@ -386,7 +373,7 @@ const OperatorViewPage: React.FC = () => { {teams && teams.map((team) => ( <ListItem key={team.id}> - {team.name} score: {'666'} + {team.name} score:{addScore(team.id)} </ListItem> ))} </List> diff --git a/client/src/pages/views/TeamViewPage.tsx b/client/src/pages/views/TeamViewPage.tsx index 13791b3acd7b1ee5688ba3314aa4daa6e04f7272..c2d2ff8323acd61c2a503e5c0947650950a4a892 100644 --- a/client/src/pages/views/TeamViewPage.tsx +++ b/client/src/pages/views/TeamViewPage.tsx @@ -1,21 +1,14 @@ import React, { useEffect } from 'react' -import { useHistory, useParams } from 'react-router-dom' -import { getPresentationCompetition } from '../../actions/presentation' -import { useAppDispatch, useAppSelector } from '../../hooks' -import { ViewParams } from '../../interfaces/ViewParams' +import { useAppSelector } from '../../hooks' import { socketConnect, socketJoinPresentation } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { PresentationBackground, PresentationContainer } from './styled' 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 - const { competitionId }: ViewParams = useParams() - const dispatch = useAppDispatch() useEffect(() => { - dispatch(getPresentationCompetition(competitionId)) if (code && code !== '') { socketConnect() socketJoinPresentation() diff --git a/client/src/pages/views/ViewSelectPage.tsx b/client/src/pages/views/ViewSelectPage.tsx index d69dc8b4243d680647802fe9d6f72acbd23618ba..75bf84986e57ffdb552a803ea190243ad3426c4d 100644 --- a/client/src/pages/views/ViewSelectPage.tsx +++ b/client/src/pages/views/ViewSelectPage.tsx @@ -22,13 +22,13 @@ const ViewSelectPage: React.FC = () => { if (competitionId) { switch (viewType) { case 'Team': - return <Redirect to={`/team/competition-id=${competitionId}`} /> + return <Redirect to={`/view/team`} /> case 'Judge': - return <Redirect to={`/judge/competition-id=${competitionId}`} /> + return <Redirect to={`/view/judge`} /> case 'Audience': - return <Redirect to={`/audience/competition-id=${competitionId}`} /> + return <Redirect to={`/view/audience`} /> case 'Operator': - return <Redirect to={`/operator/competition-id=${competitionId}`} /> + return <Redirect to={`/view/operator`} /> default: return ( <ViewSelectContainer> diff --git a/client/src/pages/views/components/JudgeScoringInstructions.tsx b/client/src/pages/views/components/JudgeScoringInstructions.tsx index 97062246b33a5105efd34fbfb2ac3a66296ee93b..f8c21667504524720523004ce9e853616c84e41f 100644 --- a/client/src/pages/views/components/JudgeScoringInstructions.tsx +++ b/client/src/pages/views/components/JudgeScoringInstructions.tsx @@ -1,16 +1,7 @@ -import { Box, Card, Typography } from '@material-ui/core' -import axios from 'axios' +import { Typography } from '@material-ui/core' 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' +import { JudgeScoringInstructionsContainer, ScoringInstructionsInner } from './styled' type JudgeScoringInstructionsProps = { question: RichQuestion @@ -19,8 +10,14 @@ type JudgeScoringInstructionsProps = { const JudgeScoringInstructions = ({ question }: JudgeScoringInstructionsProps) => { return ( <JudgeScoringInstructionsContainer elevation={3}> - <Typography variant="h4">Rättningsinstruktioner</Typography> - <Typography variant="body1">{question?.correcting_instructions}</Typography> + <ScoringInstructionsInner> + <Typography variant="h4">Rättningsinstruktioner</Typography> + <Typography variant="body1"> + {question?.correcting_instructions !== null + ? question?.correcting_instructions + : 'Det finns inga rättningsinstruktioner för denna fråga'} + </Typography> + </ScoringInstructionsInner> </JudgeScoringInstructionsContainer> ) } diff --git a/client/src/pages/views/components/styled.tsx b/client/src/pages/views/components/styled.tsx index fef186f792dafe8ee259faa693cf6707975d06ba..a5142d0b8a7353212b18fad8739e9b32eedd1625 100644 --- a/client/src/pages/views/components/styled.tsx +++ b/client/src/pages/views/components/styled.tsx @@ -1,4 +1,4 @@ -import { Card, Paper, TextField } from '@material-ui/core' +import { Paper, TextField } from '@material-ui/core' import styled from 'styled-components' export const SlideContainer = styled.div` @@ -38,6 +38,11 @@ export const JudgeScoringInstructionsContainer = styled(Paper)` bottom: 0; height: 250px; width: 100%; +` + +export const ScoringInstructionsInner = styled.div` + margin-left: 15px; + margin-right: 15px; display: flex; align-items: center; flex-direction: column; diff --git a/client/src/reducers/competitionLoginReducer.ts b/client/src/reducers/competitionLoginReducer.ts index 6d425f97209c810cd1646c3f9722dd9ec5666ff8..72e5e22ee81aa98b735f670a581ad0c39a810ea9 100644 --- a/client/src/reducers/competitionLoginReducer.ts +++ b/client/src/reducers/competitionLoginReducer.ts @@ -36,13 +36,6 @@ export default function (state = initialState, action: AnyAction) { authenticated: true, initialized: true, } - - case Types.SET_COMPETITION_LOGIN_AUTHENTICATED: - return { - ...state, - authenticated: true, - initialized: true, - } case Types.SET_COMPETITION_LOGIN_ERRORS: return { ...state, diff --git a/client/src/utils/SecureRoute.tsx b/client/src/utils/SecureRoute.tsx index e2885501748c468eb898e833481ae219516c5946..2b08b40cfd33cc630d67f80f91921e46aa163e4d 100644 --- a/client/src/utils/SecureRoute.tsx +++ b/client/src/utils/SecureRoute.tsx @@ -7,7 +7,7 @@ import { CheckAuthenticationCompetition } from './checkAuthenticationCompetition interface SecureRouteProps extends RouteProps { component: React.ComponentType<any> rest?: any - authLevel: 'competition' | 'admin' | 'login' + authLevel: 'admin' | 'login' | 'Operator' | 'Team' | 'Judge' | 'Audience' } /** Utility component to use for authentication, replace all routes that should be private with secure routes*/ @@ -16,6 +16,7 @@ const SecureRoute: React.FC<SecureRouteProps> = ({ component: Component, authLev const compAuthenticated = useAppSelector((state) => state.competitionLogin.authenticated) const [initialized, setInitialized] = React.useState(false) const compInitialized = useAppSelector((state) => state.competitionLogin.initialized) + const viewType = useAppSelector((state) => state.competitionLogin.data?.view) React.useEffect(() => { if (authLevel === 'admin' || authLevel === 'login') { CheckAuthenticationAdmin().then(() => setInitialized(true)) @@ -32,11 +33,16 @@ const SecureRoute: React.FC<SecureRouteProps> = ({ component: Component, authLev render={(props) => (userAuthenticated ? <Redirect to="/admin" /> : <Component {...props} />)} /> ) - else if (authLevel === 'competition' && compInitialized) + else if (compInitialized && viewType && authLevel !== 'admin') { return ( - <Route {...rest} render={(props) => (compAuthenticated ? <Component {...props} /> : <Redirect to="/" />)} /> + <Route + {...rest} + render={(props) => + compAuthenticated && viewType === authLevel ? <Component {...props} /> : <Redirect to="/" /> + } + /> ) - else + } else return ( <Route {...rest} render={(props) => (userAuthenticated ? <Component {...props} /> : <Redirect to="/" />)} /> ) diff --git a/client/src/utils/checkAuthenticationCompetition.ts b/client/src/utils/checkAuthenticationCompetition.ts index db0c32360a9928459dd4cac4e65bfb1715a8353f..9877b674214fc5fae2899148fd9e57c9a0a3bc28 100644 --- a/client/src/utils/checkAuthenticationCompetition.ts +++ b/client/src/utils/checkAuthenticationCompetition.ts @@ -1,7 +1,7 @@ import axios from 'axios' import jwtDecode from 'jwt-decode' import { logoutCompetition } from '../actions/competitionLogin' -import { setPresentationCode } from '../actions/presentation' +import { getPresentationCompetition, setPresentationCode } from '../actions/presentation' import Types from '../actions/types' import store from '../store' @@ -15,19 +15,18 @@ export const CheckAuthenticationCompetition = async () => { const decodedToken: any = jwtDecode(authToken) if (decodedToken.exp * 1000 >= Date.now()) { axios.defaults.headers.common['Authorization'] = authToken - console.log(decodedToken.user_claims) await axios .get('/api/auth/test') .then((res) => { - store.dispatch({ type: Types.SET_COMPETITION_LOGIN_AUTHENTICATED }) store.dispatch({ type: Types.SET_COMPETITION_LOGIN_DATA, payload: { competition_id: decodedToken.user_claims.competition_id, team_id: decodedToken.user_claims.team_id, - view: res.data.view, + view: decodedToken.user_claims.view, }, }) + getPresentationCompetition(decodedToken.user_claims.competition_id)(store.dispatch, store.getState) setPresentationCode(decodedToken.user_claims.code)(store.dispatch) }) .catch((error) => { diff --git a/server/app/__init__.py b/server/app/__init__.py index 4a28018cd5805ae6a99d70d7e749672b6f906eb2..5bdffe8261feeb26ec16288ae38e3f791640948a 100644 --- a/server/app/__init__.py +++ b/server/app/__init__.py @@ -45,15 +45,3 @@ def create_app(config_name="configmodule.DevelopmentConfig"): return response return app, sio - - -def identity(payload): - user_id = payload["identity"] - return models.User.query.filter_by(id=user_id) - - -@jwt.token_in_blacklist_loader -def check_if_token_in_blacklist(decrypted_token): - jti = decrypted_token["jti"] - - return models.Blacklist.query.filter_by(jti=jti).first() is not None diff --git a/server/app/apis/__init__.py b/server/app/apis/__init__.py index e5ec3d2ca1e1d9de6d4c5cca5ec1e3fba40f33ee..3cb924884d1616ea27433ef72ebb21090590cf38 100644 --- a/server/app/apis/__init__.py +++ b/server/app/apis/__init__.py @@ -1,5 +1,3 @@ -from functools import wraps - import app.core.http_codes as http_codes from flask_jwt_extended import verify_jwt_in_request from flask_jwt_extended.utils import get_jwt_claims diff --git a/server/app/apis/answers.py b/server/app/apis/answers.py index 3990e4e1d7b75fa35d13cc592906e4c4aa921024..6d0490a9328af2b903522fbeefe5224dcc645e06 100644 --- a/server/app/apis/answers.py +++ b/server/app/apis/answers.py @@ -1,4 +1,3 @@ -import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionAnswerDTO diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index a3be209155aa5fc03b1dcd16478bd94e51f45826..bf9eeefde781f3fcacfd4bdafade8829db24acaa 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -5,10 +5,10 @@ import app.database.controller as dbc from app.apis import item_response, protect_route, text_response from app.core import sockets from app.core.codes import verify_code -from app.core.dto import AuthDTO, CodeDTO -from flask_jwt_extended import (create_access_token, create_refresh_token, - get_jwt_identity, get_raw_jwt, - jwt_refresh_token_required) +from app.core.dto import AuthDTO +from app.database.models import Whitelist +from flask_jwt_extended import create_access_token, get_jti, get_raw_jwt +from flask_jwt_extended.utils import get_jti from flask_restx import Resource, inputs, reqparse api = AuthDTO.api @@ -32,7 +32,12 @@ def get_user_claims(item_user): 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, "code": item_code.code} + return { + "view": item_code.view_type.name, + "competition_id": item_code.competition_id, + "team_id": item_code.team_id, + "code": item_code.code, + } @api.route("/test") @@ -56,18 +61,16 @@ class AuthSignup(Resource): return item_response(schema.dump(item_user)) -@api.route("/delete/<ID>") -@api.param("ID") +@api.route("/delete/<user_id>") +@api.param("user_id") class AuthDelete(Resource): @protect_route(allowed_roles=["Admin"]) - def delete(self, ID): - item_user = dbc.get.user(ID) - + def delete(self, user_id): + item_user = dbc.get.user(user_id) + dbc.delete.whitelist_to_blacklist(Whitelist.user_id == user_id) dbc.delete.default(item_user) - if int(ID) == get_jwt_identity(): - jti = get_raw_jwt()["jti"] - dbc.add.blacklist(jti) - return text_response(f"User {ID} deleted") + + return text_response(f"User {user_id} deleted") @api.route("/login") @@ -82,9 +85,10 @@ class AuthLogin(Resource): api.abort(codes.UNAUTHORIZED, "Invalid email or password") access_token = create_access_token(item_user.id, user_claims=get_user_claims(item_user)) - refresh_token = create_refresh_token(item_user.id) + # refresh_token = create_refresh_token(item_user.id) - response = {"id": item_user.id, "access_token": access_token, "refresh_token": refresh_token} + response = {"id": item_user.id, "access_token": access_token} + dbc.add.whitelist(get_jti(access_token), item_user.id) return response @@ -98,7 +102,7 @@ class AuthLoginCode(Resource): api.abort(codes.UNAUTHORIZED, "Invalid code") item_code = dbc.get.code_by_code(code) - + if item_code.view_type_id != 4: if item_code.competition_id not in sockets.presentations: api.abort(codes.UNAUTHORIZED, "Competition not active") @@ -107,6 +111,7 @@ class AuthLoginCode(Resource): item_code.id, user_claims=get_code_claims(item_code), expires_delta=timedelta(hours=8) ) + dbc.add.whitelist(get_jti(access_token), competition_id=item_code.competition_id) response = { "competition_id": item_code.competition_id, "view": item_code.view_type.name, @@ -122,9 +127,12 @@ class AuthLogout(Resource): def post(self): jti = get_raw_jwt()["jti"] dbc.add.blacklist(jti) + Whitelist.query.filter(Whitelist.jti == jti).delete() + dbc.utils.commit() return text_response("Logout") +""" @api.route("/refresh") class AuthRefresh(Resource): @protect_route(allowed_roles=["*"]) @@ -137,3 +145,4 @@ class AuthRefresh(Resource): dbc.add.blacklist(old_jti) response = {"access_token": access_token} return response +""" diff --git a/server/app/apis/codes.py b/server/app/apis/codes.py index d07e17435aed31417254acfd83ed9315f1477ba3..6409109ef875efc13a5c12040a100b28822ed6ae 100644 --- a/server/app/apis/codes.py +++ b/server/app/apis/codes.py @@ -1,6 +1,5 @@ import app.database.controller as dbc from app.apis import item_response, list_response, protect_route -from app.core import http_codes as codes from app.core.dto import CodeDTO from app.database.models import Code from flask_restx import Resource @@ -13,7 +12,7 @@ list_schema = CodeDTO.list_schema @api.route("") @api.param("competition_id") class CodesList(Resource): - @protect_route(allowed_roles=["*"]) + @protect_route(allowed_roles=["*"], allowed_views=["Operator"]) def get(self, competition_id): items = dbc.get.code_list(competition_id) return list_response(list_schema.dump(items), len(items)) diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index c5ff37c9131a7a9ec4bc1f394a9e1f5b5727460e..bfd06fac35c811fe276787b3027cad5d0d1f99a9 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -1,5 +1,3 @@ -import time - import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import CompetitionDTO diff --git a/server/app/apis/components.py b/server/app/apis/components.py index 0368fc82a207d2bddf2990cab933c2611dafec67..9fff1d07a72115bdb215bb441606aa189c962702 100644 --- a/server/app/apis/components.py +++ b/server/app/apis/components.py @@ -54,6 +54,16 @@ class ComponentByID(Resource): return {}, codes.NO_CONTENT +@api.route("/<component_id>/copy/<view_type_id>") +@api.param("competition_id, slide_id, component_id, view_type_id") +class ComponentList(Resource): + @protect_route(allowed_roles=["*"]) + def post(self, competition_id, slide_id, component_id, view_type_id): + item_component = dbc.get.component(competition_id, slide_id, component_id) + item = dbc.copy.component(item_component, slide_id, view_type_id) + return item_response(schema.dump(item)) + + @api.route("") @api.param("competition_id, slide_id") class ComponentList(Resource): diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index 52ce4cce1d20fc277c3ee9ec46ea3566a51ec633..aa5bee3cad42e58e83b067eb1970f6b60d2cca27 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -2,9 +2,8 @@ import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import SlideDTO -from flask_restx import Resource -from flask_restx import reqparse from app.core.parsers import sentinel +from flask_restx import Resource, reqparse api = SlideDTO.api schema = SlideDTO.schema diff --git a/server/app/core/__init__.py b/server/app/core/__init__.py index 94d1daf29b0e20e1f0bd241689b4128439d0c01d..c80bb5c0ade8ae3ffbafdbd50e193677a5692832 100644 --- a/server/app/core/__init__.py +++ b/server/app/core/__init__.py @@ -2,7 +2,7 @@ The core submodule contains everything important to the server that doesn't fit neatly in either apis or database. """ - +import app.database.models as models from app.database import Base, ExtendedQuery from flask_bcrypt import Bcrypt from flask_jwt_extended.jwt_manager import JWTManager @@ -13,3 +13,9 @@ db = SQLAlchemy(model_class=Base, query_class=ExtendedQuery) bcrypt = Bcrypt() jwt = JWTManager() ma = Marshmallow() + + +@jwt.token_in_blacklist_loader +def check_if_token_in_blacklist(decrypted_token): + jti = decrypted_token["jti"] + return models.Blacklist.query.filter_by(jti=jti).first() is not None diff --git a/server/app/core/codes.py b/server/app/core/codes.py index ad6d844c0fa4a01cf7652a1c966b25f678f73a4e..c52ddf8d82b0b3121249ccf74229ae81c897b696 100644 --- a/server/app/core/codes.py +++ b/server/app/core/codes.py @@ -4,7 +4,6 @@ Contains all functions purely related to creating and verifying a code. import random import re -import string CODE_LENGTH = 6 ALLOWED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" diff --git a/server/app/core/files.py b/server/app/core/files.py index 5d22c8e0ad8672a6db56e7ac4c64087b55a26b74..f45b9bb58f0cea1757498b344a8221e910708805 100644 --- a/server/app/core/files.py +++ b/server/app/core/files.py @@ -2,10 +2,9 @@ Contains functions related to file handling, mainly saving and deleting images. """ -from PIL import Image, ImageChops +from PIL import Image from flask import current_app, has_app_context import os -import datetime from flask_uploads import IMAGES, UploadSet if has_app_context(): diff --git a/server/app/core/parsers.py b/server/app/core/parsers.py index 4c1525207990398475fd0c88aec11c56f87eaff1..3b541cf2778e4a3c153c950c96cd911ba3e04323 100644 --- a/server/app/core/parsers.py +++ b/server/app/core/parsers.py @@ -2,7 +2,7 @@ This module contains the parsers used to parse the data gotten in api requests. """ -from flask_restx import inputs, reqparse +from flask_restx import reqparse class Sentinel: diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index a2e81c2568f6e1c100fbc5c302a200644e11433d..d9331ae713991c1ecd652f5752532261275ab310 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -3,8 +3,6 @@ This module contains schemas used to convert database objects into dictionaries. """ -from marshmallow.decorators import pre_load -from marshmallow.decorators import pre_dump, post_dump import app.database.models as models from app.core import ma from marshmallow_sqlalchemy import fields diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index b3bb9f878eae260a4cf0c62a11fd8c9445aa56a0..4422d466b29ab3c20e3614d0c7c52c9d36cc86b9 100644 --- a/server/app/core/sockets.py +++ b/server/app/core/sockets.py @@ -6,9 +6,8 @@ connected to the same presentation. import logging from typing import Dict -import app.database.controller as dbc from app.core import db -from app.database.models import Code, Competition, Slide, Team, ViewType +from app.database.models import Code, Slide, ViewType from flask.globals import request from flask_jwt_extended import verify_jwt_in_request from flask_jwt_extended.utils import get_jwt_claims diff --git a/server/app/database/__init__.py b/server/app/database/__init__.py index 9b00283080b17329b83c93fd23e4dcfa3485f9ae..2840c94e7c6fa8ff38e53ddcf0fa8429d7847c72 100644 --- a/server/app/database/__init__.py +++ b/server/app/database/__init__.py @@ -3,15 +3,11 @@ The database submodule contaisn all functionality that has to do with the database. It can add, get, delete, edit, search and copy items. """ -import json - from flask_restx import abort from flask_sqlalchemy import BaseQuery from flask_sqlalchemy.model import Model -from sqlalchemy import Column, DateTime, Text +from sqlalchemy import Column, DateTime from sqlalchemy.sql import func -from sqlalchemy.types import TypeDecorator -from sqlalchemy import event class Base(Model): diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index 18bcd4b130a1f6aebd49df520c83e741a819d8cc..3aa0ab709324e21ed69e8db5f1902f9447c9c5f3 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -6,13 +6,12 @@ import os import app.core.http_codes as codes from app.core import db -from app.database.controller import get, search, utils +from app.database.controller import get, utils from app.database.models import ( Blacklist, City, Code, Competition, - Component, ComponentType, ImageComponent, Media, @@ -28,17 +27,14 @@ from app.database.models import ( TextComponent, User, ViewType, + Whitelist, ) +from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT +from flask import current_app from flask.globals import current_app from flask_restx import abort from PIL import Image from sqlalchemy import exc -from sqlalchemy.orm import with_polymorphic -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): @@ -197,6 +193,12 @@ def blacklist(jti): return db_add(Blacklist(jti)) +def whitelist(jti, user_id=None, competition_id=None): + """ Adds a whitelist to the database. """ + + return db_add(Whitelist(jti, user_id, competition_id)) + + def mediaType(name): """ Adds a media type to the database. """ diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index 08d743472035034b3b45eda3bdc6553f26377f60..48dab2db79c33cb1ca819a59f0e934ab23dcc191 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -9,6 +9,7 @@ from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEX def _alternative(item_old, question_id): """Internal function. Makes a copy of the provided question alternative""" + return add.question_alternative(item_old.text, item_old.value, question_id) @@ -39,6 +40,16 @@ def _component(item_component, item_slide_new): Internal function. Makes a copy of the provided component item to the specified slide. """ + + component(item_component, item_slide_new.id, item_component.view_type_id) + + +def component(item_component, slide_id_new, view_type_id): + """ + Makes a copy of the provided component item + to the specified slide and view_type. + """ + data = {} if item_component.type_id == ID_TEXT_COMPONENT: data["text"] = item_component.text @@ -46,10 +57,11 @@ def _component(item_component, item_slide_new): data["media_id"] = item_component.media_id elif item_component.type_id == ID_QUESTION_COMPONENT: data["question_id"] = item_component.question_id - add.component( + + return add.component( item_component.type_id, - item_slide_new.id, - item_component.view_type_id, + slide_id_new, + view_type_id, item_component.x, item_component.y, item_component.w, diff --git a/server/app/database/controller/delete.py b/server/app/database/controller/delete.py index 93a4c3393bf57fed22f2d672338f6cc592ecc543..b0b36fbaf79843eac05bd4340d0a633a61e325d0 100644 --- a/server/app/database/controller/delete.py +++ b/server/app/database/controller/delete.py @@ -5,9 +5,8 @@ This file contains functionality to delete data to the database. import app.core.http_codes as codes import app.database.controller as dbc from app.core import db -from app.database.models import Blacklist, City, Competition, Role, Slide, User +from app.database.models import Whitelist from flask_restx import abort -from sqlalchemy import exc def default(item): @@ -20,6 +19,19 @@ def default(item): abort(codes.INTERNAL_SERVER_ERROR, f"Item of type {type(item)} could not be deleted") +def whitelist_to_blacklist(filters): + """ + Remove whitelist by condition(filters) and insert those into blacklist + Example: When delete user all whitelisted tokens for that user should be blacklisted + """ + whitelist = Whitelist.query.filter(filters).all() + for item in whitelist: + dbc.add.blacklist(item.jti) + + Whitelist.query.filter(filters).delete() + dbc.utils.commit() + + def component(item_component): """ Deletes component. """ diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py index ba975d35432437746de480667bb8774267cc5437..b6701ca5579940c9e4545d5542f41cf4b5b431fc 100644 --- a/server/app/database/controller/get.py +++ b/server/app/database/controller/get.py @@ -27,17 +27,19 @@ def all(db_type): return db_type.query.all() -def one(db_type, id): +def one(db_type, id, required=True): """ Get lazy db-item in the table that has the same id. """ - return db_type.query.filter(db_type.id == id).first_extended() + return db_type.query.filter(db_type.id == id).first_extended(required=required) ### Codes ### def code_by_code(code): """ Gets the code object associated with the provided code. """ - return Code.query.filter(Code.code == code.upper()).first_extended( True, "A presentation with that code does not exist") + return Code.query.filter(Code.code == code.upper()).first_extended( + True, "A presentation with that code does not exist" + ) def code_list(competition_id): diff --git a/server/app/database/controller/search.py b/server/app/database/controller/search.py index bfc40843b83675c8758a63f4d117d98ea6b2f609..4d112a5f4b9d339b48d0add3e1382e81bc700c80 100644 --- a/server/app/database/controller/search.py +++ b/server/app/database/controller/search.py @@ -2,7 +2,7 @@ This file contains functionality to find data to the database. """ -from app.database.models import Competition, Media, Question, Slide, Team, User +from app.database.models import Competition, Media, Question, Slide, User def image(filename, page=0, page_size=15, order=1, order_by=None): diff --git a/server/app/database/controller/utils.py b/server/app/database/controller/utils.py index c01205a6ec0f9dfc5772d87200268e035db0d14b..14eaa48d0295515e3f53c93dae963eb0ebc6bc92 100644 --- a/server/app/database/controller/utils.py +++ b/server/app/database/controller/utils.py @@ -7,7 +7,6 @@ from app.core import db from app.core.codes import generate_code_string from app.database.models import Code from flask_restx import abort -from sqlalchemy import exc def move_slides(item_competition, start_order, end_order): diff --git a/server/app/database/models.py b/server/app/database/models.py index 74f7b39e5dad9ab69a22b10445e9e86baa08125d..a54a77c24b6b77a22187c386e8cd59c1cd006f49 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -11,10 +11,21 @@ from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property STRING_SIZE = 254 +class Whitelist(db.Model): + id = db.Column(db.Integer, primary_key=True) + jti = db.Column(db.String, unique=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=True) + + def __init__(self, jti, user_id=None, competition_id=None): + self.jti = jti + self.user_id = user_id + self.competition_id = competition_id + + class Blacklist(db.Model): id = db.Column(db.Integer, primary_key=True) jti = db.Column(db.String, unique=True) - expire_date = db.Column(db.Integer, nullable=True) def __init__(self, jti): self.jti = jti diff --git a/server/configmodule.py b/server/configmodule.py index 8c07211ed58d7fa2550893fc241a9a2170527a2f..93d21cbe84a5847ed4927a4d2b3d002b487d1f69 100644 --- a/server/configmodule.py +++ b/server/configmodule.py @@ -12,7 +12,7 @@ class Config: JWT_BLACKLIST_ENABLED = True JWT_BLACKLIST_TOKEN_CHECKS = ["access", "refresh"] JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=2) - JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) + # JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) UPLOADED_PHOTOS_DEST = os.path.join(os.getcwd(), "app", "static", "images") THUMBNAIL_SIZE = (120, 120) SECRET_KEY = os.urandom(24) diff --git a/server/populate.py b/server/populate.py index b7496991b1baef41914541d546c916c73f14551b..9981f0c02f430d56c88738a9b67e89d5fc17af89 100644 --- a/server/populate.py +++ b/server/populate.py @@ -43,8 +43,8 @@ def _add_items(): city_id = City.query.filter(City.name == "Linköping").one().id # Add users - dbc.add.user("admin@test.se", "password", admin_id, city_id) - dbc.add.user("test@test.se", "password", editor_id, city_id) + dbc.add.user("admin@test.se", "password", admin_id, city_id, "Admina Denfina") + dbc.add.user("test@test.se", "password", editor_id, city_id, "Test Osteron") question_types_items = dbc.get.all(QuestionType) @@ -105,6 +105,14 @@ def _add_items(): for name in teams: dbc.add.team(f"{name}{i}", item_comp.id) + # question_answer(answer, score, question_id, team_id) + dbc.add.question_answer("ett svar som ger 2p", 2, 1, 1) + dbc.add.question_answer("ett svar som ger 10p", 10, 2, 1) + dbc.add.question_answer("ett svar som ger 6p", 6, 3, 1) + + dbc.add.question_answer("ett svar som ger 2p", 2, 1, 2) + dbc.add.question_answer("ett svar som ger 3p", 3, 1, 3) + if __name__ == "__main__": app, _ = create_app("configmodule.DevelopmentConfig") diff --git a/server/tests/test_app.py b/server/tests/test_app.py index d59428a6380b74abe8853c6c09c4df0bafc031e0..2152a6d4c7236f57d099f50dccec3a090ce94c5d 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -112,7 +112,7 @@ def test_competition_api(client): assert response.status_code == codes.OK # Copies competition - for _ in range(10): + for _ in range(3): response, _ = post(client, f"/api/competitions/{competition_id}/copy", headers=headers) assert response.status_code == codes.OK @@ -149,7 +149,7 @@ def test_auth_and_user_api(client): # Try loggin with right PASSWORD response, body = post(client, "/api/auth/login", {"email": "test1@test.se", "password": "abc123"}) assert response.status_code == codes.OK - refresh_token = body["refresh_token"] + # refresh_token = body["refresh_token"] headers = {"Authorization": "Bearer " + body["access_token"]} # Get the current user @@ -330,6 +330,35 @@ def test_slide_api(client): assert response.status_code == codes.OK """ + # Get a specific component + CID = 2 + SID = 3 + COMID = 2 + response, c1 = get(client, f"/api/competitions/{CID}/slides/{SID}/components/{COMID}", headers=headers) + assert response.status_code == codes.OK + + # Copy the component to another view + view_type_id = 3 + response, c2 = post( + client, f"/api/competitions/{CID}/slides/{SID}/components/{COMID}/copy/{view_type_id}", headers=headers + ) + # Check that the components metch + assert response.status_code == codes.OK + assert c1 != c2 + assert c1["x"] == c2["x"] + assert c1["y"] == c2["y"] + assert c1["w"] == c2["w"] + assert c1["h"] == c2["h"] + assert c1["slide_id"] == SID + assert c2["slide_id"] == SID + assert c1["type_id"] == c2["type_id"] + if c1["type_id"] == 1: + assert c1["text"] == c2["text"] + elif c1["type_id"] == 2: + assert c1["image_id"] == c2["image_id"] + assert c1["view_type_id"] == 1 + assert c2["view_type_id"] == 3 + def test_question_api(client): add_default_values() diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index f55aa68251a5a3b9ea73411c959939acc93f1bc6..85c1f114a8c01da6e7d5370bffe2ccb9ea8aed1c 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -49,8 +49,8 @@ def add_default_values(): 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) + dbc.add.question_answer("hej", 5, item_question.id, item_team1.id) + dbc.add.question_answer("då", 5, item_question.id, item_team2.id) db.session.commit()