diff --git a/.gitignore b/.gitignore index 3c5d2e1e3fe18f48ed48dcce1bbc83845d06ce39..f20a5fa3f67c39e48da56a3b3f615ed8e96b7ec5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,4 @@ -__pycache__ -*.db -*/env *.coverage */coverage -htmlcov -.pytest_cache /.idea .vs/ -/server/app/static/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 0b692c6a7f2975cb9b16a528101c1be148fd8257..d064f2c0c8cbe0db00a1acb24bb9188e1f375c5c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,5 +41,6 @@ "search.exclude": { "**/env": true }, - "python.pythonPath": "server\\env\\Scripts\\python.exe" + "python.pythonPath": "server\\env\\Scripts\\python.exe", + "restructuredtext.confPath": "${workspaceFolder}\\server\\sphinx\\source" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7029dbe39d5a29ad684540e3d3eaf8272bb6bdaa..8b7286d134089b693c07ce77c748073701865719 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -23,7 +23,6 @@ { "label": "Open client coverage", "type": "shell", - "group": "build", "command": "start ./output/coverage/jest/index.html", "problemMatcher": [], "options": { @@ -54,7 +53,7 @@ }, }, { - "label": "Populate server", + "label": "Populate database", "type": "shell", "group": "build", "command": "env/Scripts/python populate.py", @@ -66,13 +65,30 @@ { "label": "Open server coverage", "type": "shell", - "group": "build", "command": "start ./htmlcov/index.html", "problemMatcher": [], "options": { "cwd": "${workspaceFolder}/server" }, }, + { + "label": "Generate server documentation", + "type": "shell", + "command": "../env/Scripts/activate; ./make html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server/docs" + }, + }, + { + "label": "Open server documentation", + "type": "shell", + "command": "start index.html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server/docs/build/html" + }, + }, { "label": "Start client and server", "group": "build", diff --git a/client/src/pages/admin/competitions/CompetitionManager.tsx b/client/src/pages/admin/competitions/CompetitionManager.tsx index 8ec3ee2981277a3f150642410581d472602e65ed..b7184d55595b23333c895604b2d311a0a80e530c 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.tsx @@ -1,4 +1,18 @@ -import { Button, Menu, TablePagination, TextField, Typography } from '@material-ui/core' +import { + Button, + Menu, + ListItem, + TablePagination, + TextField, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + ListItemText, + Tooltip, + Box, +} from '@material-ui/core' import FormControl from '@material-ui/core/FormControl' import InputLabel from '@material-ui/core/InputLabel' import MenuItem from '@material-ui/core/MenuItem' @@ -17,9 +31,12 @@ import React, { useEffect } from 'react' import { Link, useHistory } from 'react-router-dom' import { getCompetitions, setFilterParams } from '../../../actions/competitions' import { useAppDispatch, useAppSelector } from '../../../hooks' +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: @@ -36,13 +53,31 @@ const useStyles = makeStyles((theme: Theme) => margin: { margin: theme.spacing(1), }, + paper: { + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[5], + padding: 4, + outline: 'none', + }, }) ) +interface Code { + id: number + code: string + view_type_id: number + competition_id: number + team_id: number +} + const CompetitionManager: React.FC = (props: any) => { const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null) const [activeId, setActiveId] = React.useState<number | undefined>(undefined) const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + const [dialogIsOpen, setDialogIsOpen] = React.useState(false) + const [codes, setCodes] = React.useState<Code[]>([]) + const [teams, setTeams] = React.useState<Team[]>([]) + const [competitionName, setCompetitionName] = React.useState<string | undefined>(undefined) const loading = useAppSelector((state) => state.user.userInfo === null) const competitions = useAppSelector((state) => state.competitions.competitions) const filterParams = useAppSelector((state) => state.competitions.filterParams) @@ -95,7 +130,79 @@ const CompetitionManager: React.FC = (props: any) => { const handleStartCompetition = () => { history.push(`/operator/id=${activeId}&code=123123`) - console.log('GLHF!') + } + + const getCodes = async () => { + await axios + .get(`/api/competitions/${activeId}/codes`) + .then((response) => { + console.log(response.data) + setCodes(response.data.items) + }) + .catch(console.log) + } + + const getTeams = async () => { + await axios + .get(`/api/competitions/${activeId}/teams`) + .then((response) => { + console.log(response.data.items) + setTeams(response.data.items) + }) + .catch((err) => { + console.log(err) + }) + } + + const getCompetitionName = async () => { + await axios + .get(`/api/competitions/${activeId}`) + .then((response) => { + console.log(response.data.name) + setCompetitionName(response.data.name) + }) + .catch((err) => { + console.log(err) + }) + } + + const getTypeName = (code: Code) => { + let typeName = '' + switch (code.view_type_id) { + case 1: + const team = teams.find((team) => team.id === code.team_id) + if (team) { + typeName = team.name + } else { + typeName = 'Lagnamn hittades ej' + } + break + case 2: + typeName = 'Domare' + break + case 3: + typeName = 'Publik' + break + case 4: + typeName = 'Tävlingsoperatör' + break + default: + typeName = 'Typ hittades ej' + break + } + return typeName + } + + const handleOpenDialog = async () => { + await getCodes() + await getTeams() + await getCompetitionName() + setDialogIsOpen(true) + } + + const handleCloseDialog = () => { + setDialogIsOpen(false) + setAnchorEl(null) } const handleDuplicateCompetition = async () => { @@ -117,6 +224,18 @@ const CompetitionManager: React.FC = (props: any) => { dispatch(getCompetitions()) } + const refreshCode = async (code: Code) => { + await axios + .put(`/api/competitions/${activeId}/codes/${code.id}`) + .then(() => { + getCodes() + dispatch(getCompetitions()) + }) + .catch(({ response }) => { + console.warn(response.data) + }) + } + return ( <div> <TopBar> @@ -207,9 +326,62 @@ const CompetitionManager: React.FC = (props: any) => { /> <Menu id="simple-menu" anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}> <MenuItem onClick={handleStartCompetition}>Starta</MenuItem> + <MenuItem onClick={handleOpenDialog}>Visa koder</MenuItem> <MenuItem onClick={handleDuplicateCompetition}>Duplicera</MenuItem> <RemoveMenuItem onClick={handleDeleteCompetition}>Ta bort</RemoveMenuItem> </Menu> + <Dialog + open={dialogIsOpen} + onClose={handleCloseDialog} + aria-labelledby="max-width-dialog-title" + maxWidth="xl" + fullWidth={false} + fullScreen={false} + > + <DialogTitle id="max-width-dialog-title" className={classes.paper}> + Koder för {competitionName} + </DialogTitle> + <DialogContent> + {/* <DialogContentText>Här visas tävlingskoderna till den valda tävlingen.</DialogContentText> */} + {codes.map((code) => ( + <ListItem key={code.id} style={{ display: 'flex' }}> + <ListItemText primary={`${getTypeName(code)}: `} /> + <Typography component="div"> + <ListItemText style={{ textAlign: 'right', marginLeft: '10px' }}> + <Box fontFamily="Monospace" fontWeight="fontWeightBold"> + {code.code} + </Box> + </ListItemText> + </Typography> + <Tooltip title="Generera ny kod" arrow> + <Button + margin-right="0px" + onClick={() => { + refreshCode(code) + }} + > + <RefreshIcon fontSize="small" /> + </Button> + </Tooltip> + <Tooltip title="Kopiera kod" arrow> + <Button + margin-right="0px" + onClick={() => { + navigator.clipboard.writeText(code.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + ))} + </DialogContent> + <DialogActions> + <Button onClick={handleCloseDialog} color="primary"> + Stäng + </Button> + </DialogActions> + </Dialog> </div> ) } diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.tsx index 80d35c5f38c8b879976b91204f9572ac9cf5735a..6d9bc11905e9cca28526bd27ceb0921648ef5ed7 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -1,10 +1,6 @@ -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 axios from 'axios' import React, { useEffect, useState } from 'react' @@ -13,22 +9,29 @@ import { getCities } from '../../actions/cities' 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, InnerContent } from '../views/styled' import SettingsPanel from './components/SettingsPanel' import SlideDisplay from './components/SlideDisplay' import { + AppBarEditor, CenteredSpinnerContainer, HomeIcon, + LeftDrawer, + RightDrawer, PresentationEditorContainer, SlideList, SlideListItem, ToolBarContainer, ViewButton, - ViewButtonClicked, ViewButtonGroup, + ToolbarMargin, + FillLeftContainer, + PositionBottom, + FillRightContainer, + CompetitionName, + RightPanelScroll, } from './styled' const initialState = { @@ -40,52 +43,11 @@ 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 { competitionId: string } const PresentationEditorPage: React.FC = () => { - const classes = useStyles() const { competitionId }: CompetitionParams = useParams() const dispatch = useAppDispatch() const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) @@ -103,7 +65,7 @@ const PresentationEditorPage: React.FC = () => { } const createNewSlide = async () => { - await axios.post(`/api/competitions/${competitionId}/slides`, { title: 'new slide' }) + await axios.post(`/api/competitions/${competitionId}/slides`, { title: 'Ny sida' }) dispatch(getEditorCompetition(competitionId)) } @@ -138,17 +100,6 @@ const PresentationEditorPage: React.FC = () => { setContextState(initialState) } - const GreenCheckbox = withStyles({ - root: { - color: '#FFFFFF', - '&$checked': { - color: '#FFFFFF', - }, - }, - checked: {}, - })((props: CheckboxProps) => <Checkbox color="default" {...props} />) - const [checkbox, setCheckbox] = useState(false) - const viewTypes = useAppSelector((state) => state.types.viewTypes) const [activeViewTypeName, setActiveViewTypeName] = useState('') const changeView = (clickedViewTypeName: string) => { @@ -162,20 +113,16 @@ const PresentationEditorPage: React.FC = () => { 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 $activeView={activeViewTypeName === 'Audience'} variant="contained" @@ -194,19 +141,11 @@ const PresentationEditorPage: React.FC = () => { </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 @@ -221,33 +160,30 @@ 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}> <InnerContent> diff --git a/client/src/pages/presentationEditor/styled.tsx b/client/src/pages/presentationEditor/styled.tsx index f68eb166043c970cb5f6e69bc57b090790b1d607..c02054090f9b597fc32f1cacc03d145f3b77d9fe 100644 --- a/client/src/pages/presentationEditor/styled.tsx +++ b/client/src/pages/presentationEditor/styled.tsx @@ -1,16 +1,24 @@ -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; ` -interface ViewButtonProps { - $activeView: boolean -} - export const ViewButton = styled(Button)<ViewButtonProps>` margin-right: 8px; background: ${(props) => (props.$activeView ? '#5a0017' : undefined)}; @@ -27,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` @@ -51,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/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index 0dbf2765f85ee68843dc80aa2d0aa22015c8d5cb..97e348cd39ab9c1e1cf7499a0ee0550c0020b461 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -1,5 +1,7 @@ import { + Box, Button, + createStyles, Dialog, DialogActions, DialogContent, @@ -8,7 +10,9 @@ import { List, ListItem, ListItemText, + makeStyles, Popover, + Theme, Tooltip, Typography, useMediaQuery, @@ -47,6 +51,8 @@ import { SlideCounter, ToolBarContainer, } from './styled' +import axios from 'axios' +import { Team } from '../../interfaces/ApiModels' /** * Description: @@ -66,17 +72,47 @@ import { * creates a bug where the competition can't be started. * =========================================== */ +const useStyles = makeStyles((theme: Theme) => + createStyles({ + table: { + width: '100%', + }, + margin: { + margin: theme.spacing(1), + }, + paper: { + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[5], + padding: 4, + outline: 'none', + }, + }) +) + +interface Code { + id: number + code: string + view_type_id: number + competition_id: number + team_id: number +} 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 [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 [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) const { competitionId }: ViewParams = useParams() 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) @@ -86,6 +122,7 @@ const OperatorViewPage: React.FC = () => { dispatch(getPresentationCompetition(competitionId)) socketConnect() socketSetSlide // Behövs denna? + handleOpenCodes() setTimeout(startCompetition, 1000) // Ghetto, wait for everything to load // console.log(id) }, []) @@ -116,7 +153,10 @@ const OperatorViewPage: React.FC = () => { setOpen(true) } - const handleOpenCodes = () => { + const handleOpenCodes = async () => { + await getCodes() + await getTeams() + await getCompetitionName() setOpenCode(true) } @@ -131,56 +171,108 @@ const OperatorViewPage: React.FC = () => { window.location.reload(false) // TODO: fix this ugly hack, we "need" to refresh site to be able to run the competition correctly again } + const getCodes = async () => { + await axios + .get(`/api/competitions/${activeId}/codes`) + .then((response) => { + console.log(response.data) + setCodes(response.data.items) + }) + .catch(console.log) + } + + const getTeams = async () => { + await axios + .get(`/api/competitions/${activeId}/teams`) + .then((response) => { + console.log(response.data.items) + setTeams(response.data.items) + }) + .catch((err) => { + console.log(err) + }) + } + + const getCompetitionName = async () => { + await axios + .get(`/api/competitions/${activeId}`) + .then((response) => { + console.log(response.data.name) + setCompetitionName(response.data.name) + }) + .catch((err) => { + console.log(err) + }) + } + + const getTypeName = (code: Code) => { + let typeName = '' + switch (code.view_type_id) { + case 1: + const team = teams.find((team) => team.id === code.team_id) + if (team) { + typeName = team.name + } else { + typeName = 'Lagnamn hittades ej' + } + break + case 2: + typeName = 'Domare' + break + case 3: + typeName = 'Publik' + break + case 4: + typeName = 'Tävlingsoperatör' + break + default: + typeName = 'Typ hittades ej' + break + } + return typeName + } + return ( <OperatorContainer> <Dialog - fullScreen={fullScreen} open={openAlertCode} onClose={handleClose} - aria-labelledby="responsive-dialog-title" + aria-labelledby="max-width-dialog-title" + maxWidth="xl" + fullWidth={false} + fullScreen={false} > - <DialogTitle id="responsive-dialog-title">{'Koder för tävlingen'}</DialogTitle> + <DialogTitle id="max-width-dialog-title" className={classes.paper}> + Koder för {competitionName} + </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> + {/* <DialogContentText>Här visas tävlingskoderna till den valda tävlingen.</DialogContentText> */} + {codes.map((code) => ( + <ListItem key={code.id} style={{ display: 'flex' }}> + <ListItemText primary={`${getTypeName(code)}: `} /> + <Typography component="div"> + <ListItemText style={{ textAlign: 'right', marginLeft: '10px' }}> + <Box fontFamily="Monospace" fontWeight="fontWeightBold"> + {code.code} + </Box> + </ListItemText> + </Typography> + <Tooltip title="Kopiera kod" arrow> + <Button + margin-right="0px" + onClick={() => { + navigator.clipboard.writeText(code.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + ))} </DialogContent> <DialogActions> - <Button autoFocus onClick={handleClose} color="primary"> - Ok + <Button onClick={handleClose} color="primary"> + Stäng </Button> </DialogActions> </Dialog> @@ -192,12 +284,7 @@ const OperatorViewPage: React.FC = () => { </OperatorButton> </Tooltip> - <Dialog - fullScreen={fullScreen} - open={openAlert} - onClose={handleClose} - aria-labelledby="responsive-dialog-title" - > + <Dialog open={openAlert} onClose={handleClose} aria-labelledby="responsive-dialog-title"> <DialogTitle id="responsive-dialog-title">{'Vill du avsluta tävlingen?'}</DialogTitle> <DialogContent> <DialogContentText> @@ -303,7 +390,7 @@ const OperatorViewPage: React.FC = () => { {teams && teams.map((team) => ( <ListItem key={team.id}> - {team.name} score: {team.question_answers}{' '} + {team.name} score: {'666'} </ListItem> ))} </List> diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..6ff704b874b611b1add28bd5aebf2f79bdcdd87b --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,9 @@ +__pycache__ +env +htmlcov +.pytest_cache +app/*.db +app/static/ + +# Documentation files +docs/build diff --git a/server/app/__init__.py b/server/app/__init__.py index 2add65446ea6ca429a204a1564813d9fe19e25d0..4a28018cd5805ae6a99d70d7e749672b6f906eb2 100644 --- a/server/app/__init__.py +++ b/server/app/__init__.py @@ -7,6 +7,11 @@ from app.core.dto import MediaDTO def create_app(config_name="configmodule.DevelopmentConfig"): + """ + Creates Flask app, returns it and a SocketIO instance. Call run on the + SocketIO instance and pass in the Flask app to start the server. + """ + app = Flask(__name__, static_url_path="/static", static_folder="static") app.config.from_object(config_name) app.url_map.strict_slashes = False diff --git a/server/app/core/__init__.py b/server/app/core/__init__.py index acfca55431cf0f9b538e1a1487cea7299e3ad4e4..94d1daf29b0e20e1f0bd241689b4128439d0c01d 100644 --- a/server/app/core/__init__.py +++ b/server/app/core/__init__.py @@ -1,3 +1,8 @@ +""" +The core submodule contains everything important to the server that doesn't +fit neatly in either apis or database. +""" + from app.database import Base, ExtendedQuery from flask_bcrypt import Bcrypt from flask_jwt_extended.jwt_manager import JWTManager diff --git a/server/app/core/codes.py b/server/app/core/codes.py index 47150767b17fb1b352f0b1ae3130dc6d4ffefb5a..ad6d844c0fa4a01cf7652a1c966b25f678f73a4e 100644 --- a/server/app/core/codes.py +++ b/server/app/core/codes.py @@ -1,3 +1,7 @@ +""" +Contains all functions purely related to creating and verifying a code. +""" + import random import re import string @@ -7,9 +11,14 @@ ALLOWED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" CODE_RE = re.compile(f"^[{ALLOWED_CHARS}]{{{CODE_LENGTH}}}$") -def generate_code_string(): +def generate_code_string() -> str: + """Generates a 6 character long random sequence containg uppercase letters + and numbers. + """ return "".join(random.choices(ALLOWED_CHARS, k=CODE_LENGTH)) -def verify_code(c): - return CODE_RE.search(c.upper()) is not None +def verify_code(code: str) -> bool: + """Returns True if code only contains letters and/or numbers + and is exactly 6 characters long.""" + return CODE_RE.search(code.upper()) is not None diff --git a/server/app/core/dto.py b/server/app/core/dto.py index 90e49d00b8062ee3afe6eac7b44e7b397ccfe45c..6bbbdf621f52855bf6565fa162007cd3956bbd85 100644 --- a/server/app/core/dto.py +++ b/server/app/core/dto.py @@ -1,3 +1,8 @@ +""" +The DTO module (short for Data Transfer Object) connects the namespace of an +API and its related schemas. +""" + import app.core.rich_schemas as rich_schemas import app.core.schemas as schemas from flask_restx import Namespace diff --git a/server/app/core/files.py b/server/app/core/files.py index 01a03166811b02f06e2b21f21430dd25837b2067..5d22c8e0ad8672a6db56e7ac4c64087b55a26b74 100644 --- a/server/app/core/files.py +++ b/server/app/core/files.py @@ -1,30 +1,35 @@ +""" +Contains functions related to file handling, mainly saving and deleting images. +""" + from PIL import Image, ImageChops -from flask import current_app +from flask import current_app, has_app_context import os import datetime from flask_uploads import IMAGES, UploadSet -PHOTO_PATH = current_app.config["UPLOADED_PHOTOS_DEST"] -THUMBNAIL_SIZE = current_app.config["THUMBNAIL_SIZE"] -image_set = UploadSet("photos", IMAGES) +if has_app_context(): + PHOTO_PATH = current_app.config["UPLOADED_PHOTOS_DEST"] + THUMBNAIL_SIZE = current_app.config["THUMBNAIL_SIZE"] + image_set = UploadSet("photos", IMAGES) -def compare_images(input_image, output_image): - # compare image dimensions (assumption 1) - if input_image.size != output_image.size: - return False +# def compare_images(input_image, output_image): +# # compare image dimensions (assumption 1) +# if input_image.size != output_image.size: +# return False - rows, cols = input_image.size +# rows, cols = input_image.size - # compare image pixels (assumption 2 and 3) - for row in range(rows): - for col in range(cols): - input_pixel = input_image.getpixel((row, col)) - output_pixel = output_image.getpixel((row, col)) - if input_pixel != output_pixel: - return False +# # compare image pixels (assumption 2 and 3) +# for row in range(rows): +# for col in range(cols): +# input_pixel = input_image.getpixel((row, col)) +# output_pixel = output_image.getpixel((row, col)) +# if input_pixel != output_pixel: +# return False - return True +# return True def _delete_image(filename): @@ -33,6 +38,10 @@ def _delete_image(filename): def save_image_with_thumbnail(image_file): + """ + Saves the given image and also creates a small thumbnail for it. + """ + saved_filename = image_set.save(image_file) saved_path = os.path.join(PHOTO_PATH, saved_filename) with Image.open(saved_path) as im: @@ -45,6 +54,9 @@ def save_image_with_thumbnail(image_file): def delete_image_and_thumbnail(filename): + """ + Delete the given image together with its thumbnail. + """ _delete_image(filename) _delete_image(f"thumbnail_{filename}") diff --git a/server/app/core/http_codes.py b/server/app/core/http_codes.py index 95a99175665427e51b8684792e98180c75e6dc72..f6f19ed13519be9ff5b76ca2c6c53e3cfb91c3c2 100644 --- a/server/app/core/http_codes.py +++ b/server/app/core/http_codes.py @@ -1,3 +1,7 @@ +""" +This module defines all the http status codes thats used in the api. +""" + OK = 200 NO_CONTENT = 204 BAD_REQUEST = 400 diff --git a/server/app/core/parsers.py b/server/app/core/parsers.py index 160d67a0af7b58483202c00c1ec6b97097de0633..4c1525207990398475fd0c88aec11c56f87eaff1 100644 --- a/server/app/core/parsers.py +++ b/server/app/core/parsers.py @@ -1,3 +1,7 @@ +""" +This module contains the parsers used to parse the data gotten in api requests. +""" + from flask_restx import inputs, reqparse diff --git a/server/app/core/rich_schemas.py b/server/app/core/rich_schemas.py index ae1c6ab6e0bbd63ebd1369e1741672a6489c87fa..7d883584776677ee1be21975ed5ae413dc2019fa 100644 --- a/server/app/core/rich_schemas.py +++ b/server/app/core/rich_schemas.py @@ -1,3 +1,8 @@ +""" +This module contains rich schemas used to convert database objects into +dictionaries. This is the rich variant which means that objects will +pull in other whole objects instead of just the id. +""" import app.core.schemas as schemas import app.database.models as models from app.core import ma diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 4008b8869cc084022d4d46386d89ec29ca1765d2..73ac21cbd396d1ee175e6151003d05f1a10dc82b 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -1,3 +1,8 @@ +""" +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 diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index 280529fc102a29fc706b24ec68f268d324520d86..b3bb9f878eae260a4cf0c62a11fd8c9445aa56a0 100644 --- a/server/app/core/sockets.py +++ b/server/app/core/sockets.py @@ -1,4 +1,10 @@ +""" +Contains all functionality related sockets. That is starting and ending a presentation, +joining and leaving a presentation and syncing slides and timer bewteen all clients +connected to the same presentation. +""" import logging +from typing import Dict import app.database.controller as dbc from app.core import db @@ -51,12 +57,16 @@ def protect_route(allowed_views=None): @sio.on("connect") -def connect(): +def connect() -> None: logger.info(f"Client '{request.sid}' connected") @sio.on("disconnect") -def disconnect(): +def disconnect() -> None: + """ + Remove client from the presentation it was in. Delete presentation if no + clients are connected to it. + """ for competition_id, presentation in presentations.items(): if request.sid in presentation["clients"]: del presentation["clients"][request.sid] @@ -72,7 +82,11 @@ def disconnect(): @protect_route(allowed_views=["Operator"]) @sio.on("start_presentation") -def start_presentation(data): +def start_presentation(data: Dict) -> None: + """ + Starts a presentation if that competition is currently not active. + """ + competition_id = data["competition_id"] if competition_id in presentations: @@ -95,7 +109,15 @@ def start_presentation(data): @protect_route(allowed_views=["Operator"]) @sio.on("end_presentation") -def end_presentation(data): +def end_presentation(data: Dict) -> None: + """ + End a presentation by sending end_presentation to all connected clients. + + The only clients allowed to do this is the one that started the presentation. + + Log error message if no presentation exists with the send id or if this + client is not in that presentation. + """ competition_id = data["competition_id"] if competition_id not in presentations: @@ -124,8 +146,13 @@ def end_presentation(data): @sio.on("join_presentation") -def join_presentation(data): - team_view_id = 1 +def join_presentation(data: Dict) -> None: + """ + Join a currently active presentation. + + Log error message if given code doesn't exist, if not presentation associated + with that code exists or if client is already in the presentation. + """ code = data["code"] item_code = db.session.query(Code).filter(Code.code == code).first() @@ -160,7 +187,15 @@ def join_presentation(data): @protect_route(allowed_views=["Operator"]) @sio.on("set_slide") -def set_slide(data): +def set_slide(data: Dict) -> None: + """ + Sync slides between all clients in the same presentation by sending + set_slide to them. + + Log error if the given competition_id is not active, if client is not in + that presentation or the client is not the one who started the presentation. + """ + competition_id = data["competition_id"] slide_order = data["slide_order"] @@ -200,7 +235,14 @@ def set_slide(data): @protect_route(allowed_views=["Operator"]) @sio.on("set_timer") -def set_timer(data): +def set_timer(data: Dict) -> None: + """ + Sync slides between all clients in the same presentation by sending + set_timer to them. + + Log error if the given competition_id is not active, if client is not in + that presentation or the client is not the one who started the presentation. + """ competition_id = data["competition_id"] timer = data["timer"] diff --git a/server/app/database/__init__.py b/server/app/database/__init__.py index e0c78ad3af3f3376fdecdae4eae219aa1c62be24..9b00283080b17329b83c93fd23e4dcfa3485f9ae 100644 --- a/server/app/database/__init__.py +++ b/server/app/database/__init__.py @@ -1,3 +1,8 @@ +""" +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 @@ -16,7 +21,15 @@ class Base(Model): class ExtendedQuery(BaseQuery): + """ + Extensions to a regular query which makes using the database more convenient. + """ + def first_extended(self, required=True, error_message=None, error_code=404): + """ + Extensions of the first() functions otherwise used on queries. Abort + if no item was found and it was required. + """ item = self.first() if required and not item: @@ -27,6 +40,10 @@ class ExtendedQuery(BaseQuery): return item def pagination(self, page=0, page_size=15, order_column=None, order=1): + """ + When looking for lists of items this is used to only return a few of + them to allow for pagination. + """ query = self if order_column: if order == 1: @@ -40,17 +57,17 @@ class ExtendedQuery(BaseQuery): return items, total -class Dictionary(TypeDecorator): +# class Dictionary(TypeDecorator): - impl = Text +# impl = Text - def process_bind_param(self, value, dialect): - if value is not None: - value = json.dumps(value) +# def process_bind_param(self, value, dialect): +# if value is not None: +# value = json.dumps(value) - return value +# return value - def process_result_value(self, value, dialect): - if value is not None: - value = json.loads(value) - return value +# def process_result_value(self, value, dialect): +# if value is not None: +# value = json.loads(value) +# return value diff --git a/server/app/database/controller/__init__.py b/server/app/database/controller/__init__.py index a46a65f1e6c2fe4e0664c462288a5f00c1cdd46c..b8d6fca38a446d447607362a871c437bfa713bfe 100644 --- a/server/app/database/controller/__init__.py +++ b/server/app/database/controller/__init__.py @@ -1,3 +1,7 @@ -# import add, get +""" +The controller subpackage provides a simple interface to the database. It +exposes methods to simply add, copy, delete, edit, get and search for items. +""" + from app.core import db from app.database.controller import add, copy, delete, edit, get, search, utils diff --git a/server/app/database/models.py b/server/app/database/models.py index f9f18438ad07edf951cebd24ad7ab92e9b854299..d4e177b070755d75cada2988e43468f28794146b 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -1,3 +1,9 @@ +""" +This file contains every model in the database. In regular SQL terms, it +defines every table, the fields in those tables and their relationship to +each other. +""" + from app.core import bcrypt, db from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property diff --git a/server/app/database/types.py b/server/app/database/types.py index f53835eccf10599eb9ab81d8af1426e02a21e405..46db168cdbf9e1dce2727bacefac7fd823a9de39 100644 --- a/server/app/database/types.py +++ b/server/app/database/types.py @@ -1,3 +1,7 @@ +""" +This module defines the different component types. +""" + ID_TEXT_COMPONENT = 1 ID_IMAGE_COMPONENT = 2 ID_QUESTION_COMPONENT = 3 \ No newline at end of file diff --git a/server/docs/Makefile b/server/docs/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..d0c3cbf1020d5c292abdedf27627c6abe25e2293 --- /dev/null +++ b/server/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/server/docs/make.bat b/server/docs/make.bat new file mode 100644 index 0000000000000000000000000000000000000000..9534b018135ed7d5caed6298980c55e8b1d2ec82 --- /dev/null +++ b/server/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/server/docs/source/conf.py b/server/docs/source/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..e42217c3e3b4b40f15683e02e0d544176901e033 --- /dev/null +++ b/server/docs/source/conf.py @@ -0,0 +1,66 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. + +import os +import sys + +basepath = os.path.dirname(__file__) +filepath = os.path.abspath(os.path.join(basepath, "../..")) +sys.path.insert(0, filepath) + + +# -- Project information ----------------------------------------------------- + +project = "Teknikattan scoring system" +copyright = "2021, Albin Henriksson, Sebastian Karlsson, Victor Löfgren, Björn Modée, Josef Olsson, Max Rüdinger, Carl Schönfelder, Emil Wahlqvist" +author = "Albin Henriksson, Sebastian Karlsson, Victor Löfgren, Björn Modée, Josef Olsson, Max Rüdinger, Carl Schönfelder, Emil Wahlqvist" +version = "1.0" + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ["sphinx.ext.autodoc", "myst_parser"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +autodoc_member_order = "bysource" + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "alabaster" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ["_static"] + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +add_module_names = False + + +logo_path = os.path.abspath(os.path.join(basepath, "../../app/static/images/t8.jpg")) +html_logo = logo_path + +# favicon_path = os.path.abspath(os.path.join(basepath, "../../../client/public/favicon.ico")) +# html_favicon = favicon_path diff --git a/server/docs/source/development.md b/server/docs/source/development.md new file mode 100644 index 0000000000000000000000000000000000000000..a6b07cc6664fa20e4d6dad57f12cdb19eb2e982e --- /dev/null +++ b/server/docs/source/development.md @@ -0,0 +1,59 @@ +# Development + +In this section we give all the instructions necessary to continue the development of this project. +We also give some recommentations for how to go about it and some ides for how to improve it. + +## Working with Python + +In this section we briefly describe how to work with Python. + +### Virtual environments + +Python virtual environments are used to isolate packages for each project from each other. +In the [Installation](installation.md) you installed `virtualenv` and created and activated a virtual environment. + +### Pip + +Python uses `pip` to manage it's packages. +Here we briefly describe to use it. +All of the following instructions assume you have created and activated a virtual environment. + +To install a package, run `pip install <package>`. + +To uninstall a package, run `pip uninstall <package>`. + +To save a package as a dependency to the project, run `pip freeze > requirements.txt`. + +To install all project dependencies, run `pip install -r requirements.txt`. + +## Visual Studio Code + +The development of this project was mainly done using Visual Studio Code (VSCode). +It is not that surprising, then, that we recommend you use it. + +### Tasks + +A task in VSCode is a simple action that can be run by pressing `ctrl+shift+p` and selecting `Tasks: Run Task`. +A few such tasks has been setup in this project and tasks related to the server will be described below. + +The `Start server` task will start the server. + +The `Populate database` task will populate the database with a few competitions, teams, users and such. + +The `Test server` task will run the server tests located in the `tests/` folder. + +The `Open server coverage` can only be run after running the server tests and will open the coverage report generated by those tests in a webbrowser. + +The `Generate server documentation` will generate the server documentation, i.e. this document, in the `docs/build/html/` folder. + +The `Open server documentation` can only be run after generating the documentation and will open it in a webbrowser. + +## Further development + +Because the project was time limited a lot is left to be done. +A few ideas for things to be improved are given here. + +### Replacing reqparse + +As mention in the [Parsing request](overview.md#Parsing-request), the reqparse module from RestX is deprecated and should be replaced with for example marsmallow. +Parsing is a rather small and simple matter which makes it quite fine not to use the most optimal tool, but it should nevertheless be replaced. diff --git a/server/docs/source/index.rst b/server/docs/source/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..df8af7fb5c63ad522026b637ee660f784428d07d --- /dev/null +++ b/server/docs/source/index.rst @@ -0,0 +1,33 @@ +Welcome to Teknikattan scoring system's documentation! +====================================================== + +This is the documentation for the backend of teknikattans scorings system. +Below you will find an overview of how the backend works. +Then you will find instructions for how to install it and get it up and running. +You will then find instructions for how to continue the development of this project. +Last you will find documentation on all of the modules. + +Documentation +============= + +.. toctree:: + :maxdepth: 2 + + overview + installation + development + +Modules +======= + +.. toctree:: + :maxdepth: 1 + + modules/app + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/server/docs/source/installation.md b/server/docs/source/installation.md new file mode 100644 index 0000000000000000000000000000000000000000..700ec0c44355dd93e548ac0db1edc69ab2a040b0 --- /dev/null +++ b/server/docs/source/installation.md @@ -0,0 +1,45 @@ +# Installation + +It is recommended to use [Visual Studio Code](https://code.visualstudio.com/) to install and use the server, but it is not necessary. +In order to install the server, you will need to do the following: + +Install [Python](https://www.python.org/downloads/). + +Clone [teknikattan-scoring-system](https://gitlab.liu.se/tddd96-grupp11/teknikattan-scoring-system). + +Open a terminal and navigate to the root of the cloned project. + +Install virtualenv and create a virtual environment: + +``` +pip install virtualenv +cd server +py -m venv env +``` + +Activate the virtual environment (which is done slightly differently on Windows and Linux/Mac): + +On Windows: + +``` +Set-ExecutionPolicy Unrestricted -Scope Process +./env/Scripts/activate +``` + +On Linux/Mac: + +``` +source env/bin/activate +``` + +Install all project depencies: + +``` +pip install -r requirements.txt +``` + +You should now be ready to start the server. +Try it by running `python main.py` and navigate to `localhost:5000`. +If everything worked as it should you should see a list of all available API calls. + +Continue to [Development](development.md). diff --git a/server/docs/source/modules/app.apis.alternatives.rst b/server/docs/source/modules/app.apis.alternatives.rst new file mode 100644 index 0000000000000000000000000000000000000000..dc712f7cdc7172925b608b5390441abfe2a7b7f2 --- /dev/null +++ b/server/docs/source/modules/app.apis.alternatives.rst @@ -0,0 +1,7 @@ +app.apis.alternatives module +============================ + +.. automodule:: app.apis.alternatives + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.answers.rst b/server/docs/source/modules/app.apis.answers.rst new file mode 100644 index 0000000000000000000000000000000000000000..f59bdc1ff5dee23672baa13f5f0b1003f589dac7 --- /dev/null +++ b/server/docs/source/modules/app.apis.answers.rst @@ -0,0 +1,7 @@ +app.apis.answers module +======================= + +.. automodule:: app.apis.answers + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.auth.rst b/server/docs/source/modules/app.apis.auth.rst new file mode 100644 index 0000000000000000000000000000000000000000..7da9d2ea0c686420050bd1d7b4dc5ca25a08f80a --- /dev/null +++ b/server/docs/source/modules/app.apis.auth.rst @@ -0,0 +1,7 @@ +app.apis.auth module +==================== + +.. automodule:: app.apis.auth + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.codes.rst b/server/docs/source/modules/app.apis.codes.rst new file mode 100644 index 0000000000000000000000000000000000000000..3ee856a509714af289986ac6035d3486009c47c6 --- /dev/null +++ b/server/docs/source/modules/app.apis.codes.rst @@ -0,0 +1,7 @@ +app.apis.codes module +===================== + +.. automodule:: app.apis.codes + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.competitions.rst b/server/docs/source/modules/app.apis.competitions.rst new file mode 100644 index 0000000000000000000000000000000000000000..305d952e6d61ad9f480361adfdb26a1ca235a69b --- /dev/null +++ b/server/docs/source/modules/app.apis.competitions.rst @@ -0,0 +1,7 @@ +app.apis.competitions module +============================ + +.. automodule:: app.apis.competitions + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.components.rst b/server/docs/source/modules/app.apis.components.rst new file mode 100644 index 0000000000000000000000000000000000000000..1db7934e9c1d6afb41fb765cf41d9e8aef4ec881 --- /dev/null +++ b/server/docs/source/modules/app.apis.components.rst @@ -0,0 +1,7 @@ +app.apis.components module +========================== + +.. automodule:: app.apis.components + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.media.rst b/server/docs/source/modules/app.apis.media.rst new file mode 100644 index 0000000000000000000000000000000000000000..f82097d139f9b9804002b1c7c20a018f47e33e39 --- /dev/null +++ b/server/docs/source/modules/app.apis.media.rst @@ -0,0 +1,7 @@ +app.apis.media module +===================== + +.. automodule:: app.apis.media + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.misc.rst b/server/docs/source/modules/app.apis.misc.rst new file mode 100644 index 0000000000000000000000000000000000000000..43dc154aa1d6e42cf6e5a76b843029b19b0f96d0 --- /dev/null +++ b/server/docs/source/modules/app.apis.misc.rst @@ -0,0 +1,7 @@ +app.apis.misc module +==================== + +.. automodule:: app.apis.misc + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.questions.rst b/server/docs/source/modules/app.apis.questions.rst new file mode 100644 index 0000000000000000000000000000000000000000..ffab7b63eeb1dd68c454ff02b94fe0f4b3bcd8e9 --- /dev/null +++ b/server/docs/source/modules/app.apis.questions.rst @@ -0,0 +1,7 @@ +app.apis.questions module +========================= + +.. automodule:: app.apis.questions + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.rst b/server/docs/source/modules/app.apis.rst new file mode 100644 index 0000000000000000000000000000000000000000..1f372a16b941e7a2b0555a6688078adadf182243 --- /dev/null +++ b/server/docs/source/modules/app.apis.rst @@ -0,0 +1,26 @@ +api +=== + +.. automodule:: app.apis + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + app.apis.alternatives + app.apis.answers + app.apis.auth + app.apis.codes + app.apis.competitions + app.apis.components + app.apis.media + app.apis.misc + app.apis.questions + app.apis.slides + app.apis.teams + app.apis.users diff --git a/server/docs/source/modules/app.apis.slides.rst b/server/docs/source/modules/app.apis.slides.rst new file mode 100644 index 0000000000000000000000000000000000000000..7525025ca8c4ffe44bdc9338cc8209cafcb8548c --- /dev/null +++ b/server/docs/source/modules/app.apis.slides.rst @@ -0,0 +1,7 @@ +app.apis.slides module +====================== + +.. automodule:: app.apis.slides + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.teams.rst b/server/docs/source/modules/app.apis.teams.rst new file mode 100644 index 0000000000000000000000000000000000000000..0abf49f30aeebdc457b3985bdf38df9adc9f2069 --- /dev/null +++ b/server/docs/source/modules/app.apis.teams.rst @@ -0,0 +1,7 @@ +app.apis.teams module +===================== + +.. automodule:: app.apis.teams + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.apis.users.rst b/server/docs/source/modules/app.apis.users.rst new file mode 100644 index 0000000000000000000000000000000000000000..a40fc2f4f1157f0611d7911df468d53461bc2149 --- /dev/null +++ b/server/docs/source/modules/app.apis.users.rst @@ -0,0 +1,7 @@ +app.apis.users module +===================== + +.. automodule:: app.apis.users + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.core.codes.rst b/server/docs/source/modules/app.core.codes.rst new file mode 100644 index 0000000000000000000000000000000000000000..0e6f5a6cdc59c3693170602d68b09823f3bc7900 --- /dev/null +++ b/server/docs/source/modules/app.core.codes.rst @@ -0,0 +1,7 @@ +codes +===== + +.. automodule:: app.core.codes + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.core.dto.rst b/server/docs/source/modules/app.core.dto.rst new file mode 100644 index 0000000000000000000000000000000000000000..bc77b7beddcc362e0d1e28aeba72a9a499e7359f --- /dev/null +++ b/server/docs/source/modules/app.core.dto.rst @@ -0,0 +1,5 @@ +dto +=== + +.. automodule:: app.core.dto + :members: diff --git a/server/docs/source/modules/app.core.files.rst b/server/docs/source/modules/app.core.files.rst new file mode 100644 index 0000000000000000000000000000000000000000..38808edcd448ff05da2de861c814494714a7ccdf --- /dev/null +++ b/server/docs/source/modules/app.core.files.rst @@ -0,0 +1,7 @@ +files +===== + +.. automodule:: app.core.files + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.core.http_codes.rst b/server/docs/source/modules/app.core.http_codes.rst new file mode 100644 index 0000000000000000000000000000000000000000..ee3c1d25f3764a96b8b20f08db38c728ac457fe5 --- /dev/null +++ b/server/docs/source/modules/app.core.http_codes.rst @@ -0,0 +1,5 @@ +http_codes +========== + +.. automodule:: app.core.http_codes + diff --git a/server/docs/source/modules/app.core.parsers.rst b/server/docs/source/modules/app.core.parsers.rst new file mode 100644 index 0000000000000000000000000000000000000000..9875cb6b9953c48075caa9376642dfd083849bba --- /dev/null +++ b/server/docs/source/modules/app.core.parsers.rst @@ -0,0 +1,7 @@ +parsers +======= + +.. automodule:: app.core.parsers + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.core.rich_schemas.rst b/server/docs/source/modules/app.core.rich_schemas.rst new file mode 100644 index 0000000000000000000000000000000000000000..38c7283be6a399f903918f1ab28a320b335cb21a --- /dev/null +++ b/server/docs/source/modules/app.core.rich_schemas.rst @@ -0,0 +1,4 @@ +rich schemas +============ + +.. automodule:: app.core.rich_schemas diff --git a/server/docs/source/modules/app.core.rst b/server/docs/source/modules/app.core.rst new file mode 100644 index 0000000000000000000000000000000000000000..4452b4638b3c0e101f1d4e5b2e477bb8fc5c8332 --- /dev/null +++ b/server/docs/source/modules/app.core.rst @@ -0,0 +1,22 @@ +core +==== + +.. automodule:: app.core + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + app.core.codes + app.core.dto + app.core.files + app.core.http_codes + app.core.parsers + app.core.rich_schemas + app.core.schemas + app.core.sockets diff --git a/server/docs/source/modules/app.core.schemas.rst b/server/docs/source/modules/app.core.schemas.rst new file mode 100644 index 0000000000000000000000000000000000000000..b2b864c6ab93e192fe20e97021a0461a3bc74d46 --- /dev/null +++ b/server/docs/source/modules/app.core.schemas.rst @@ -0,0 +1,4 @@ +schemas +======= + +.. automodule:: app.core.schemas diff --git a/server/docs/source/modules/app.core.sockets.rst b/server/docs/source/modules/app.core.sockets.rst new file mode 100644 index 0000000000000000000000000000000000000000..c331255b30adc2463650b04912c0987aea1cd5c8 --- /dev/null +++ b/server/docs/source/modules/app.core.sockets.rst @@ -0,0 +1,7 @@ +sockets +======= + +.. automodule:: app.core.sockets + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.controller.add.rst b/server/docs/source/modules/app.database.controller.add.rst new file mode 100644 index 0000000000000000000000000000000000000000..72163b22751e1b28e2db44a9900af4a95ff7de9a --- /dev/null +++ b/server/docs/source/modules/app.database.controller.add.rst @@ -0,0 +1,7 @@ +app.database.controller.add module +================================== + +.. automodule:: app.database.controller.add + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.controller.copy.rst b/server/docs/source/modules/app.database.controller.copy.rst new file mode 100644 index 0000000000000000000000000000000000000000..6a8cbde8d4313aee7c43c7ff2c381787a8a642dc --- /dev/null +++ b/server/docs/source/modules/app.database.controller.copy.rst @@ -0,0 +1,7 @@ +app.database.controller.copy module +=================================== + +.. automodule:: app.database.controller.copy + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.controller.delete.rst b/server/docs/source/modules/app.database.controller.delete.rst new file mode 100644 index 0000000000000000000000000000000000000000..cd3c7572d62af3e20e6eca38729333857f810969 --- /dev/null +++ b/server/docs/source/modules/app.database.controller.delete.rst @@ -0,0 +1,7 @@ +app.database.controller.delete module +===================================== + +.. automodule:: app.database.controller.delete + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.controller.edit.rst b/server/docs/source/modules/app.database.controller.edit.rst new file mode 100644 index 0000000000000000000000000000000000000000..b81d5a8af83b4c3e8c28aee3b9d257eb4de29f8c --- /dev/null +++ b/server/docs/source/modules/app.database.controller.edit.rst @@ -0,0 +1,7 @@ +app.database.controller.edit module +=================================== + +.. automodule:: app.database.controller.edit + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.controller.get.rst b/server/docs/source/modules/app.database.controller.get.rst new file mode 100644 index 0000000000000000000000000000000000000000..d02168e9c2222cf11e7b7b64e0b4b7453d5e6576 --- /dev/null +++ b/server/docs/source/modules/app.database.controller.get.rst @@ -0,0 +1,7 @@ +app.database.controller.get module +================================== + +.. automodule:: app.database.controller.get + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.controller.rst b/server/docs/source/modules/app.database.controller.rst new file mode 100644 index 0000000000000000000000000000000000000000..c1dcf93778de413ec822ff15ba6bc6e7aa4f5267 --- /dev/null +++ b/server/docs/source/modules/app.database.controller.rst @@ -0,0 +1,21 @@ +app.database.controller package +=============================== + +.. automodule:: app.database.controller + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + app.database.controller.add + app.database.controller.copy + app.database.controller.delete + app.database.controller.edit + app.database.controller.get + app.database.controller.search + app.database.controller.utils diff --git a/server/docs/source/modules/app.database.controller.search.rst b/server/docs/source/modules/app.database.controller.search.rst new file mode 100644 index 0000000000000000000000000000000000000000..6c1052c3f08d05e25d4bdca69a3695424b8bd14a --- /dev/null +++ b/server/docs/source/modules/app.database.controller.search.rst @@ -0,0 +1,7 @@ +app.database.controller.search module +===================================== + +.. automodule:: app.database.controller.search + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.controller.utils.rst b/server/docs/source/modules/app.database.controller.utils.rst new file mode 100644 index 0000000000000000000000000000000000000000..997cfeac6d4c942ea62789e7636dda9c74fb8ca5 --- /dev/null +++ b/server/docs/source/modules/app.database.controller.utils.rst @@ -0,0 +1,7 @@ +app.database.controller.utils module +==================================== + +.. automodule:: app.database.controller.utils + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.models.rst b/server/docs/source/modules/app.database.models.rst new file mode 100644 index 0000000000000000000000000000000000000000..633beb8fd64a5c17a13dfaf27610fd98ea292a5f --- /dev/null +++ b/server/docs/source/modules/app.database.models.rst @@ -0,0 +1,7 @@ +app.database.models module +========================== + +.. automodule:: app.database.models + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.database.rst b/server/docs/source/modules/app.database.rst new file mode 100644 index 0000000000000000000000000000000000000000..c6f25650033ab9992d9547e787058cb841339b64 --- /dev/null +++ b/server/docs/source/modules/app.database.rst @@ -0,0 +1,24 @@ +app.database package +==================== + +.. automodule:: app.database + :members: + :undoc-members: + :show-inheritance: + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + app.database.controller + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + app.database.models + app.database.types diff --git a/server/docs/source/modules/app.database.types.rst b/server/docs/source/modules/app.database.types.rst new file mode 100644 index 0000000000000000000000000000000000000000..e33a58579a26037bc9c091abc1c24e2fc489164a --- /dev/null +++ b/server/docs/source/modules/app.database.types.rst @@ -0,0 +1,7 @@ +app.database.types module +========================= + +.. automodule:: app.database.types + :members: + :undoc-members: + :show-inheritance: diff --git a/server/docs/source/modules/app.rst b/server/docs/source/modules/app.rst new file mode 100644 index 0000000000000000000000000000000000000000..e471b989647515781c7710acc4e830026c8db24e --- /dev/null +++ b/server/docs/source/modules/app.rst @@ -0,0 +1,17 @@ +app +=== + +.. automodule:: app + :members: create_app + :undoc-members: + :show-inheritance: + +Subpackages +----------- + +.. toctree:: + :maxdepth: 1 + + app.apis + app.core + app.database diff --git a/server/docs/source/overview.md b/server/docs/source/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..67645bf68718d8c074be28c3858a35175c069d41 --- /dev/null +++ b/server/docs/source/overview.md @@ -0,0 +1,81 @@ +# Server + +The backend is mainly responsible for storing every user and all competitions. +It also needs to make sure the every API call and socket event is authorized. +The server is written in Python together with the micro-framework Flask. + +## Overview + +The server has two main responsibilites. +The first is to handle API calls from the client to store, update and delete information, such as competitions or users. +It also needs to make sure that only authorized people can access these. +The other is to sync slides, timer and answers between clients in an active competition. +Both of these will be described in more detail below. + +## Receiving API calls + +An API call is a way the client can communicates with the server. +When a request is received the server begins by authorizing it (making sure the person sending the request is allowed to access the route). +After that it makes sure that it got all information in the request it needed. +The server will then do the thing the client requested. +And finally it will need to generate repsonse, usually in the form of an object from the database. +All of these steps are described in more detail below. + +### Routes + +Each route which is possible to call is specified in the files in the `app/apis/` folder. +All available routes can also be seen by navigating to `localhost:5000` after starting the server. + +### Authorization + +When the server receives an API call the first thing it does is to authorize it. +The authorization is done using JSON Web Tokens (JWT) by comparing the contents of them with what is expected. +Whenever a client logs into an account or joins a competition, it is given a JWT generated by the server, and the client will need to use this token in every subsequent request sent to the server to authenticate itself. + +What authorization to be done on the server is specified by the `@protect_route()` decorator. +This decorator specifies who is allowed to access this route, which can either be users with specific roles, or people who have joined competitions with specific views. +If the route is not decorated everyone is allowed to access it, the only routes currently like that is logging in as a user and joining a competition, by necessity. + +### Parsing request + +After the request is authorized the server will need to parse contents of the request. +The parsing is done with [reqparse](https://flask-restx.readthedocs.io/en/latest/parsing.html) from RestX (this module is deprecated and should be replaced). +Each API call expects different parameters in different places and this is specificied in each of the files in `app/apis/` folder, together with the route. + +### Handling request + +After the request has been authorized and parsed the server needs to act on the request. +What the server does of course depends on the route and given arguments, but it usually gets, edits or deletes something from the database. +The server uses an SQL database and interfaces to it via SQLAlchemy. +Everything related to the database is located in the `app/database/` folder. + +### Responding + +When the server is done handling the request it usually responds with an item from the database. +Converting a database object to json is done with [Marsmallow](https://marshmallow.readthedocs.io/en/stable/). +How to do this conversion is specified in two files in in the folder `app/core/`. +The file `schemas.py` just converts a record in the database field by field. +The file `rich_schemas.py` on the other hand converts an `id` in one table to an entire object in the another table, thus the name rich. +In this way, for example, an entire competition with it's teams, codes, slides and the slides' questions and components can be returned in a single API call. + +## Active competitions + +Slides, timers and answers needs to be synced during an active presentation. +This is done using SocketIO together with flask_socketio. +Events sent is also authorized via json web tokens. +Whenever client joins a competition they will connect via sockets. +Only a single instance of a competition can be active at a time. +All of the functionality related to an active competition and sockets can be found in the file `app/core/sockets.py`. + +### Starting and joing presentations + +Whenever a client types in a code in the client, the code will be checked via the `api/auth/login/code` API call. +If there is such a code and it was an operator code, the client will receive a JWT it will need to use to authenticate itself for there on out. +It will also emit the `start_presentation` event to start the presentation. +If there is such a code and the associated competition is active, the client will also receive a JWT, regardless if it was an operator code or not. +In this case the client will instead emit the `join_presentation` event. + +### Syncing between clients + +The operator will emit `set_slide` and `set_timer` events that syncs their slides and timers between all clients connected to the same presentation. +The operator can also emit `end_presentation` to end the current presentation, which will disconnect all connected clients. diff --git a/server/populate.py b/server/populate.py index e0bb0bd94faf1b210069d7507d48e3f740157209..92183b6cc87f8f2e5377fb934c3ff14219920a7f 100644 --- a/server/populate.py +++ b/server/populate.py @@ -17,7 +17,7 @@ def _add_items(): roles = ["Admin", "Editor"] cities = ["Linköping", "Stockholm", "Norrköping", "Örkelljunga"] - teams = ["Gymnasieskola A", "Gymnasieskola B", "Gymnasieskola C"] + teams = ["Högstadie A", "Högstadie B", "Högstadie C"] for name in media_types: dbc.add.mediaType(name) @@ -76,8 +76,8 @@ def _add_items(): ) """ - for i in range(3): - dbc.add.question_alternative(f"Alternative {i}", 0, item_slide.questions[0].id) + for k in range(3): + dbc.add.question_alternative(f"Alternative {k}", 0, item_slide.questions[0].id) # Add text components # TODO: Add images as components diff --git a/server/requirements.txt b/server/requirements.txt index bda47a88036c81470bc7a6b2112b2ecedd904ed7..fe7b13ed57bb2ed619f1b2237f00498c71c67ce0 100644 Binary files a/server/requirements.txt and b/server/requirements.txt differ