diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 45f88f114c1419ec24f9794c6d2568ad61c61e1a..5582399b1943e8f77826edc902a4dd0da4aa2c95 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -82,21 +82,21 @@ } }, { - "label": "Generate server documentation", + "label": "Generate documentation", "type": "shell", - "command": "../env/Scripts/activate; ./make html", + "command": "../server/env/Scripts/activate; ./make html", "problemMatcher": [], "options": { - "cwd": "${workspaceFolder}/server/docs" + "cwd": "${workspaceFolder}/docs" } }, { - "label": "Open server documentation", + "label": "Open documentation", "type": "shell", "command": "start index.html", "problemMatcher": [], "options": { - "cwd": "${workspaceFolder}/server/docs/build/html" + "cwd": "${workspaceFolder}/docs/build/html" } }, { diff --git a/README.md b/README.md index 10a4debc16e5edc64dea58216139c2913884a0cd..e0a31a75a25b28a1aca3dac9dd2e9a8b0a1f678b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ To install the client and server needed to run the application, look in their re ## Using After installing both the client and the server, you are ready to run the application. -This is done in VSCode by pressing `ctrl+shift+b` and running the `Start client and server` task. +This is done in VS Code by pressing `ctrl+shift+b` and running the `Start client and server` task. The terminals for the client and server will now be seen on the right and left, respectively. After making a change to either the client or the server while they are running, simply reload the page to see the changes immediately. @@ -27,7 +27,7 @@ To begin working, you need to choose an issue and create a branch from it. 4. Choose one of these issues and click on it. 5. Add yourself as an asignee (in top right corner). 6. Press the little green downarrow on the right of the `Create merge request` button and select and press `Create branch`. -7. Open the project in VSCode. +7. Open the project in VS Code. 8. Type `git pull`. This will fetch the new branch you just created. 9. Switch to it by running `git checkout <branch>`. (Example: `git checkout 5-add-login-api`) @@ -49,7 +49,7 @@ This is done in two steps: First you need to prepare your branch to be merged and then create a merge request. First, prepare your branch to be merged. -1. Open the project in VSCode. +1. Open the project in VS Code. 2. Checkout your branch, if you are not already on it (`git checkout <branch>`). 3. Run `git pull origin dev`. This will try to merge the latest changes from `dev` into your branch. This can have a few different results: - There will be no changes, which is fine. @@ -70,12 +70,12 @@ You cannot approve your own merge requests but once it's approved anyone can mer ### Merge conflicts You will need to manually merge if there is a merge conflict between your branch and another. -This is simply done by opening the project in VSCode and going to the Git tab on the left (git symbol). +This is simply done by opening the project in VS Code and going to the Git tab on the left (git symbol). You will then see som files marked with `C`, which indicates that there are conflicts in these files. You will have to go through all of the merge conflicts and solve them in each file. -A merge typically looks like the code snippet at the bottom of this document in plain text (try opening this in VSCode and see how it looks). +A merge typically looks like the code snippet at the bottom of this document in plain text (try opening this in VS Code and see how it looks). The only thing you really need to do is removing the `<<<<<<<`, `=======` and `>>>>>>>` symbols from the document, although you don't have to do it by hand. -In VSCode, you can simply choose if you want to keep incoming changes (from the branch you merging into), current changes (from your branch) or both. +In VS Code, you can simply choose if you want to keep incoming changes (from the branch you merging into), current changes (from your branch) or both. Solve all the merge conflicts in every file and run the tests to make sure it still works. Commit and push your changes when you are done. diff --git a/client/README.md b/client/README.md index 23cb20572c166b66b4e46f7ba58059ecab4fd66a..81cfbb936e6e7871f11e596d8a81712748745835 100644 --- a/client/README.md +++ b/client/README.md @@ -6,10 +6,10 @@ This documents describes how to install and run the client. You will need to do the following things to install the client: -1. Install [Visual Studio Code](https://code.visualstudio.com/) (VSCode). +1. Install [Visual Studio Code](https://code.visualstudio.com/) (VS Code). 2. Install [Node (LTS)](https://nodejs.org/en/). 3. Clone this repository if you haven't done so already. -4. Open the project folder in VSCode. +4. Open the project folder in VS Code. 5. Open the integrated terminal by pressing `ctrl+ö`. 6. Type the following commands (or simply paste them) into your terminal: diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 7b5617d8185f1e98d677fd866510e1ad77db741e..d32cd65ae7079282b3352cbed7551feffc8b6053 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -61,8 +61,10 @@ export interface Question extends NameID { export interface QuestionAlternative { id: number - text: string - value: number + alternative: string + alternative_order: number + correct: string + correct_order: number question_id: number } diff --git a/client/src/pages/admin/competitions/AddCompetition.tsx b/client/src/pages/admin/competitions/AddCompetition.tsx index f2cdd1d1cb35eb76e4cd9ec0c696b6a8a088cee4..c4a0e78bd4afeb673876d1f7c361af224226762f 100644 --- a/client/src/pages/admin/competitions/AddCompetition.tsx +++ b/client/src/pages/admin/competitions/AddCompetition.tsx @@ -75,7 +75,10 @@ const AddCompetition: React.FC = (props: any) => { .catch(({ response }) => { console.warn(response.data) if (response?.status === 409) - actions.setFieldError('error', 'En tävling med det namnet finns redan, välj ett nytt namn och försök igen') + actions.setFieldError( + 'error', + 'Denna tävling finns redan, välj ett nytt namn, region eller år och försök igen' + ) else if (response.data && response.data.message) actions.setFieldError('error', response.data && response.data.message) else actions.setFieldError('error', 'Någonting gick fel, försök igen') @@ -192,7 +195,7 @@ const AddCompetition: React.FC = (props: any) => { </Button> {formik.errors.error && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> {formik.errors.error} </Alert> )} diff --git a/client/src/pages/admin/regions/AddRegion.tsx b/client/src/pages/admin/regions/AddRegion.tsx index 14efed4ef6ef45d4819d66aed714cc4ba128d9c5..7be2edaba027f816da52463bb09f48e4781003ab 100644 --- a/client/src/pages/admin/regions/AddRegion.tsx +++ b/client/src/pages/admin/regions/AddRegion.tsx @@ -85,6 +85,7 @@ const AddRegion: React.FC = (props: any) => { error={Boolean(formik.touched.model?.name && formik.errors.model?.name)} onChange={formik.handleChange} onBlur={formik.handleBlur} + value={formik.values.model.name} name="model.name" label="Region" /> @@ -101,7 +102,7 @@ const AddRegion: React.FC = (props: any) => { </FormControl> {formik.errors.error && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> {formik.errors.error} </Alert> )} diff --git a/client/src/pages/admin/users/AddUser.tsx b/client/src/pages/admin/users/AddUser.tsx index 5670c18b50db35eb2f3dbc61a4476f83b3ffd33a..05f5745c94dc58d6f851556b9571f4aeff7c45ac 100644 --- a/client/src/pages/admin/users/AddUser.tsx +++ b/client/src/pages/admin/users/AddUser.tsx @@ -54,7 +54,7 @@ const AddUser: React.FC = (props: any) => { const params = { email: values.model.email, password: values.model.password, - //name: values.model.name, + name: values.model.name, city_id: selectedCity?.id as number, role_id: selectedRole?.id as number, } @@ -224,7 +224,7 @@ const AddUser: React.FC = (props: any) => { </Button> {formik.errors.error && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> {formik.errors.error} </Alert> )} diff --git a/client/src/pages/admin/users/EditUser.tsx b/client/src/pages/admin/users/EditUser.tsx index 5de773fe7a58ed8c0f252cda20f844c801819b32..44375e4a8b5382996ecf20720865bdf1ac161fde 100644 --- a/client/src/pages/admin/users/EditUser.tsx +++ b/client/src/pages/admin/users/EditUser.tsx @@ -322,7 +322,7 @@ const EditUser = ({ user }: UserIdProps) => { {formik.errors.error && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> {formik.errors.error} </Alert> )} diff --git a/client/src/pages/login/components/AdminLogin.tsx b/client/src/pages/login/components/AdminLogin.tsx index e4c383bbf830ccc9d968d846d1cb2b0d77d13e2e..e3ec77823c30ebc9bb3f2ef5a214c6139a5c024b 100644 --- a/client/src/pages/login/components/AdminLogin.tsx +++ b/client/src/pages/login/components/AdminLogin.tsx @@ -92,7 +92,7 @@ const AdminLogin: React.FC = () => { </Button> {errors.message && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> <Typography>Någonting gick fel. Kontrollera</Typography> <Typography>dina användaruppgifter och försök igen</Typography> </Alert> diff --git a/client/src/pages/login/components/CompetitionLogin.tsx b/client/src/pages/login/components/CompetitionLogin.tsx index 4e2429a3f7b1334e4d6a532c2f55bff18a170682..ae01ec36faa9feca3e1ea2522600674125cff47d 100644 --- a/client/src/pages/login/components/CompetitionLogin.tsx +++ b/client/src/pages/login/components/CompetitionLogin.tsx @@ -63,7 +63,7 @@ const CompetitionLogin: React.FC = () => { </Button> {errors && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> <Typography>En tävling med den koden existerar ej.</Typography> <Typography>Dubbelkolla koden och försök igen</Typography> </Alert> diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.tsx index a46262af821771ec8ada13466e4980dfc9b2b850..9b040b9db3a3b76e75ace7c6585c9a0c1dcca075 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -131,7 +131,7 @@ const PresentationEditorPage: React.FC = () => { setSortedSlides(slidesCopy) if (draggedSlideId) { await axios - .put(`/api/competitions/${competitionId}/slides/${draggedSlideId}/order`, { order: result.destination.index }) + .put(`/api/competitions/${competitionId}/slides/${draggedSlideId}`, { order: result.destination.index }) .catch(console.log) } } diff --git a/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx index 91894c6fc9671e3867b1b0db3c3415f26cb40d6f..75690ea67b83e4f25663af4859ee96a6f71909a6 100644 --- a/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx +++ b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx @@ -11,9 +11,10 @@ * @module */ -import { Card, Divider, ListItem, Typography } from '@material-ui/core' +import { AppBar, Card, Divider, Typography } from '@material-ui/core' import React from 'react' import { useAppSelector } from '../../../hooks' +import AnswerMatch from './answerComponents/AnswerMatch' import AnswerMultiple from './answerComponents/AnswerMultiple' import AnswerSingle from './answerComponents/AnswerSingle' import AnswerText from './answerComponents/AnswerText' @@ -80,21 +81,34 @@ const QuestionComponentDisplay = ({ variant, currentSlideId }: QuestionComponent ) } return - + case 'Match': + if (activeSlide) { + return ( + <AnswerMatch + variant={variant} + activeSlide={activeSlide} + competitionId={activeSlide.competition_id.toString()} + /> + ) + } + return default: break } } return ( - <Card style={{ maxHeight: '100%', overflowY: 'auto' }}> - <ListItem> - <Center style={{ justifyContent: 'space-evenly' }}> - <Typography>Poäng: {total_score}</Typography> - <Typography>{questionName}</Typography> - <Typography>Timer: {timer}</Typography> - </Center> - </ListItem> + <Card elevation={4} style={{ maxHeight: '100%', overflowY: 'auto' }}> + <AppBar position="relative"> + <div style={{ display: 'flex', height: 60 }}> + <Center style={{ alignItems: 'center' }}> + <Typography variant="h5">{questionName}</Typography> + </Center> + </div> + <div style={{ position: 'fixed', right: 5, top: 14, display: 'flex', alignItems: 'center' }}> + <Typography variant="h5">{total_score}p</Typography> + </div> + </AppBar> <Divider /> {getAlternatives()} </Card> diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx index 897a321c774f0a0965b7afbbb31ce19ed7bdd35a..05c87d301047b49ac6ba715072f2f245647a3c8a 100644 --- a/client/src/pages/presentationEditor/components/SlideDisplay.tsx +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -1,4 +1,5 @@ -import { Typography } from '@material-ui/core' +import { Card, Typography } from '@material-ui/core' +import TimerIcon from '@material-ui/icons/Timer' import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import { getTypes } from '../../../actions/typesAction' import { useAppDispatch, useAppSelector } from '../../../hooks' @@ -60,11 +61,15 @@ const SlideDisplay = ({ variant, activeViewTypeId, currentSlideId }: SlideDispla <SlideEditorContainerRatio> <SlideEditorPaper ref={editorPaperRef}> <SlideDisplayText $scale={scale}> - {variant === 'editor' && slide?.timer ? `Tid kvar: ${slide?.timer}` : ''} - {variant === 'presentation' && <Timer />} + {slide?.timer && ( + <Card style={{ display: 'flex', alignItems: 'center', padding: 10 }}> + <TimerIcon fontSize="large" /> + <Timer variant={variant} currentSlideId={currentSlideId} /> + </Card> + )} </SlideDisplayText> <SlideDisplayText $scale={scale} $right> - {slide && `Sida: ${slide?.order + 1} / ${totalSlides}`} + <Card style={{ padding: 10 }}>{slide && `${slide?.order + 1} / ${totalSlides}`}</Card> </SlideDisplayText> {(competitionBackgroundImage || slideBackgroundImage) && ( <img diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index 6b4a8d5e4a0d482a086a20ba57c43c645770a9b4..68abd2400125eed9864418e7d3a7a42816b4bdf9 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -7,6 +7,7 @@ import { useAppSelector } from '../../../hooks' import BackgroundImageSelect from './BackgroundImageSelect' import Images from './slideSettingsComponents/Images' import Instructions from './slideSettingsComponents/Instructions' +import MatchAlternatives from './slideSettingsComponents/MatchAlternatives' import MultipleChoiceAlternatives from './slideSettingsComponents/MultipleChoiceAlternatives' import QuestionSettings from './slideSettingsComponents/QuestionSettings' import SingleChoiceAlternatives from './slideSettingsComponents/SingleChoiceAlternatives' @@ -54,6 +55,10 @@ const SlideSettings: React.FC = () => { <SingleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> )} + {activeSlide?.questions[0]?.type_id === 5 && ( + <MatchAlternatives activeSlide={activeSlide} competitionId={competitionId} /> + )} + {activeSlide && ( <Texts activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> )} diff --git a/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx index b2e11200e4cd14789ebd7945dd7a10b83107d740..5cc87ec9ee10ebc18f19398a9079aec1b40c978f 100644 --- a/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx +++ b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { ImageComponent, TextComponent } from '../../../interfaces/ApiModels' +import { TextComponent } from '../../../interfaces/ApiModels' type TextComponentDisplayProps = { component: TextComponent diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bedd621fa5629f87a93176369542f1420222bf98 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx @@ -0,0 +1,143 @@ +/** + * What it is: + * Contains the component for the multiple choice question type ("Kryssfråga") + * which is displayed in the participant view in the editor and presentation. + * This is a part of a question component which the users will interact with to answer multiple choice questions. + * The participants get multiple alternatives and can mark multiple of these alternatives as correct. + * + * How it's used: + * This file is used when a question component is to be rendered which only happens in QuestionComponentDisplay.tsx. + * For more information read the documentation of that file. + * + * @module + */ + +import { ListItemText, Typography } from '@material-ui/core' +import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown' +import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp' +import SyncAltIcon from '@material-ui/icons/SyncAlt' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, Clickable } from '../styled' +import { MatchButtonContainer, MatchCard, MatchContainer, MatchCorrectContainer, MatchIconContainer } from './styled' + +type AnswerMultipleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerMatch = ({ variant, activeSlide, competitionId }: AnswerMultipleProps) => { + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const [sortedAlternatives, setSortedAlternatives] = useState<QuestionAlternative[]>([]) + const [sortedAnswers, setSortedAnswers] = useState<QuestionAlternative[]>([]) + const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + const timer = useAppSelector((state) => state.presentation.timer) + + useEffect(() => { + if (activeSlide) { + setSortedAlternatives([ + ...activeSlide.questions[0].alternatives.sort((a, b) => (a.alternative_order > b.alternative_order ? 1 : -1)), + ]) + setSortedAnswers([ + ...activeSlide.questions[0].alternatives.sort((a, b) => (a.correct_order > b.correct_order ? 1 : -1)), + ]) + } + }, [activeSlide]) + + useEffect(() => { + // Send the standard answers ( if the team choses to not move one of the answers ) + if (teamId && team?.question_alternative_answers.length === 0) { + activeSlide?.questions[0].alternatives.forEach((alternative) => { + const answer = activeSlide?.questions[0].alternatives.find( + (alt) => alternative.alternative_order === alt.correct_order + ) + axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/question_alternatives/${alternative.id}`, { + answer: `${alternative.alternative} - ${answer?.correct}`, + }) + .then(() => { + dispatch(getPresentationCompetition(competitionId)) + }) + .catch(console.log) + }) + } + }, [teamId]) + + const getButtonStyle = () => { + if (activeSlide?.timer !== undefined && !timer.enabled) { + return { fill: '#AAAAAA' } // Buttons are light grey if timer is not on + } + return {} + } + + const onMove = async (previousIndex: number, resultIndex: number) => { + // moved outside the list + if (resultIndex < 0 || resultIndex >= sortedAnswers.length || variant !== 'presentation') return + if (activeSlide?.timer !== undefined && !timer.enabled) return + const answersCopy = [...sortedAnswers] + const [removed] = answersCopy.splice(previousIndex, 1) + answersCopy.splice(resultIndex, 0, removed) + setSortedAnswers(answersCopy) + + sortedAlternatives.forEach((alternative, index) => { + const answeredText = answersCopy[index].correct + if (!activeSlide) return + axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/question_alternatives/${alternative.id}`, { + answer: `${alternative.alternative} - ${answeredText}`, + }) + .catch(console.log) + }) + } + + return ( + <> + <Center> + <ListItemText secondary="Para ihop de alternativ som hör ihop:" /> + </Center> + <MatchContainer> + <div style={{ flexDirection: 'column', marginRight: 20 }}> + {sortedAlternatives.map((alternative, index) => ( + <MatchCard key={alternative.id} elevation={4}> + <Typography id="outlined-basic">{alternative.alternative}</Typography> + </MatchCard> + ))} + </div> + + <div style={{ flexDirection: 'column', marginRight: 20 }}> + {sortedAlternatives.map((alternative, index) => ( + <MatchIconContainer key={alternative.id}> + <SyncAltIcon /> + </MatchIconContainer> + ))} + </div> + + <div style={{ flexDirection: 'column' }}> + {sortedAnswers.map((alternative, index) => ( + <MatchCard key={alternative.id} elevation={4}> + <MatchCorrectContainer> + <Typography id="outlined-basic">{alternative.correct}</Typography> + </MatchCorrectContainer> + <MatchButtonContainer> + <Clickable> + <KeyboardArrowUpIcon style={getButtonStyle()} onClick={() => onMove(index, index - 1)} /> + </Clickable> + <Clickable> + <KeyboardArrowDownIcon style={getButtonStyle()} onClick={() => onMove(index, index + 1)} /> + </Clickable> + </MatchButtonContainer> + </MatchCard> + ))} + </div> + </MatchContainer> + </> + ) +} + +export default AnswerMatch diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx index 0dd7efcf9b0a9b223a43666fc088100ffc2ae17d..c687faf00c1b811c793cd88035becec93f638d53 100644 --- a/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx @@ -34,6 +34,7 @@ const AnswerMultiple = ({ variant, activeSlide, competitionId }: AnswerMultipleP const dispatch = useAppDispatch() const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + const timer = useAppSelector((state) => state.presentation.timer) const decideChecked = (alternative: QuestionAlternative) => { const answer = team?.question_alternative_answers.find( @@ -47,13 +48,9 @@ const AnswerMultiple = ({ variant, activeSlide, competitionId }: AnswerMultipleP const updateAnswer = async (alternative: QuestionAlternative, checked: boolean) => { // TODO: fix. Make list of alternatives and delete & post instead of put to allow multiple boxes checked. - if (!activeSlide) { + if (!activeSlide || (activeSlide?.timer !== undefined && !timer.enabled)) { return } - - //const checkedValue = checked ? 1 : 0 - //const correctAnswer = checkedValue === alternative.value ? 1 : 0 - const url = `/api/competitions/${competitionId}/teams/${teamId}/answers/question_alternatives/${alternative.id}` const payload = { answer: checked ? 1 : 0, @@ -94,10 +91,11 @@ const AnswerMultiple = ({ variant, activeSlide, competitionId }: AnswerMultipleP <div key={alt.id}> <ListItem divider> <GreenCheckbox + disabled={activeSlide?.timer !== undefined && !timer.enabled} checked={decideChecked(alt)} onChange={(event: any) => updateAnswer(alt, event.target.checked)} /> - <Typography style={{ wordBreak: 'break-all' }}>{alt.text}</Typography> + <Typography style={{ wordBreak: 'break-all' }}>{alt.alternative}</Typography> </ListItem> </div> ))} diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx index 514200d10068d840e28cf3d8e0dbe6953861902e..70d59c01c4985541f8f503448172998a93dd4a48 100644 --- a/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx @@ -37,6 +37,7 @@ const AnswerSingle = ({ variant, activeSlide, competitionId }: AnswerSingleProps if (variant === 'editor') return state.editor.competition.teams.find((team) => team.id === teamId) return state.presentation.competition.teams.find((team) => team.id === teamId) }) + const timer = useAppSelector((state) => state.presentation.timer) const decideChecked = (alternative: QuestionAlternative) => { const answer = team?.question_alternative_answers.find( @@ -49,7 +50,7 @@ const AnswerSingle = ({ variant, activeSlide, competitionId }: AnswerSingleProps } const updateAnswer = async (alternative: QuestionAlternative) => { - if (!activeSlide) { + if (!activeSlide || (activeSlide?.timer !== undefined && !timer.enabled)) { return } @@ -77,22 +78,26 @@ const AnswerSingle = ({ variant, activeSlide, competitionId }: AnswerSingleProps * Renders the radio button which the participants will click to mark their answer. */ const renderRadioButton = (alt: QuestionAlternative) => { + let disabledStyle + if (activeSlide?.timer !== undefined && !timer.enabled) { + disabledStyle = { fill: '#AAAAAA' } // Buttons are light grey if timer is not on + } if (variant === 'presentation') { if (decideChecked(alt)) { return ( <Clickable> - <RadioButtonCheckedIcon onClick={() => updateAnswer(alt)} /> + <RadioButtonCheckedIcon style={disabledStyle} onClick={() => updateAnswer(alt)} /> </Clickable> ) } else { return ( <Clickable> - <RadioButtonUncheckedIcon onClick={() => updateAnswer(alt)} /> + <RadioButtonUncheckedIcon style={disabledStyle} onClick={() => updateAnswer(alt)} /> </Clickable> ) } } else { - return <RadioButtonUncheckedIcon onClick={() => updateAnswer(alt)} /> + return <RadioButtonUncheckedIcon style={disabledStyle} onClick={() => updateAnswer(alt)} /> } } @@ -110,7 +115,7 @@ const AnswerSingle = ({ variant, activeSlide, competitionId }: AnswerSingleProps <div key={alt.id}> <ListItem divider> {renderRadioButton(alt)} - <Typography style={{ wordBreak: 'break-all' }}>{alt.text}</Typography> + <Typography style={{ wordBreak: 'break-all' }}>{alt.alternative}</Typography> </ListItem> </div> ))} diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx index e96f65bcb18b950b674bd13b65f1e14f46c22206..595462c97ecbc4583754d40cc4f94eeee204e224 100644 --- a/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx @@ -31,14 +31,18 @@ const AnswerText = ({ activeSlide, competitionId }: AnswerTextProps) => { const dispatch = useAppDispatch() const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + const timer = useAppSelector((state) => state.presentation.timer) const onAnswerChange = (answer: string) => { if (timerHandle) { clearTimeout(timerHandle) setTimerHandle(undefined) } - //Only updates answer 100ms after last input was made - setTimerHandle(window.setTimeout(() => updateAnswer(answer), 100)) + //Only updates answer if the timer is on + if (activeSlide?.timer !== undefined && !timer.enabled) { + //Only updates answer 100ms after last input was made + setTimerHandle(window.setTimeout(() => updateAnswer(answer), 100)) + } } const updateAnswer = async (answer: string) => { @@ -48,7 +52,7 @@ const AnswerText = ({ activeSlide, competitionId }: AnswerTextProps) => { const alternative = activeSlide.questions[0].alternatives[0] const url = `/api/competitions/${competitionId}/teams/${teamId}/answers/question_alternatives/${alternative.id}` await axios - .put(url, { answer: answer }) + .put(url, { answer }) .then(() => { dispatch(getPresentationCompetition(competitionId)) }) @@ -75,7 +79,7 @@ const AnswerText = ({ activeSlide, competitionId }: AnswerTextProps) => { </ListItem> <ListItem style={{ height: '100%' }}> <TextField - disabled={team === undefined} + disabled={team === undefined || (activeSlide?.timer !== undefined && !timer.enabled)} defaultValue={getDefaultString()} style={{ height: '100%' }} variant="outlined" diff --git a/client/src/pages/presentationEditor/components/answerComponents/styled.tsx b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx index 9140e3c2776f5bda3d2f317740bb4bbb67f657ac..109776febb8b4ebd9f202e425eb1757141b5fd64 100644 --- a/client/src/pages/presentationEditor/components/answerComponents/styled.tsx +++ b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx @@ -1,5 +1,45 @@ +import { Card } from '@material-ui/core' import styled from 'styled-components' export const AnswerTextFieldContainer = styled.div` height: calc(100% - 90px); ` +export const MatchContainer = styled.div` + margin-bottom: 50px; + margin-top: 10px; + display: flex; + justify-content: center; +` + +export const MatchCard = styled(Card)` + display: flex; + align-items: center; + justify-content: center; + height: 80px; + min-width: 150px; + margin-bottom: 5px; +` + +export const MatchIconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 80px; + margin-bottom: 5px; +` + +export const MatchButtonContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + margin-top: 10px; + margin-bottom: 10px; + margin-right: 5px; +` + +export const MatchCorrectContainer = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: center; +` diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a94400076d8aad091275ca456c9952abc8f4b64 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx @@ -0,0 +1,316 @@ +/** + * Lets a competition creator add, remove and handle alternatives for single choice questions ("Alternativfråga") in the slide settings panel. + * + * @module + */ + +import { + AppBar, + Button, + Card, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + ListItem, + ListItemText, + Tab, + Tabs, + Typography, +} from '@material-ui/core' +import ClearIcon from '@material-ui/icons/Clear' +import DragIndicatorIcon from '@material-ui/icons/DragIndicator' +import axios from 'axios' +import React, { useEffect } from 'react' +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AlternativeTextField, Center, SettingsList } from '../styled' + +type SingleChoiceAlternativeProps = { + activeSlide: RichSlide + competitionId: string +} + +interface AlternativeUpdate { + alternative?: string + alternative_order?: string + correct?: string + correct_order?: string +} + +const MatchAlternatives = ({ activeSlide, competitionId }: SingleChoiceAlternativeProps) => { + const dispatch = useAppDispatch() + const [dialogOpen, setDialogOpen] = React.useState(false) + const [selectedTab, setSelectedTab] = React.useState(0) + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + // Locally stored sorted versions of alternatives to make the sorting smoother, and not have to wait on backend + const [alternativesSortedByAlternative, setAlternativesSortedByAlternative] = React.useState<QuestionAlternative[]>( + [] + ) + const [alternativesSortedByCorrect, setAlternativesSortedByCorrect] = React.useState<QuestionAlternative[]>([]) + useEffect(() => { + if (!activeSlide?.questions[0].alternatives) return + setAlternativesSortedByAlternative([ + ...activeSlide?.questions[0].alternatives.sort((a, b) => (a.alternative_order > b.alternative_order ? 1 : -1)), + ]) + setAlternativesSortedByCorrect([ + ...activeSlide?.questions[0].alternatives.sort((a, b) => (a.correct_order > b.correct_order ? 1 : -1)), + ]) + }, [activeSlide]) + + const onDragEnd = async (result: DropResult, orderType: 'alternative_order' | 'correct_order') => { + // dropped outside the list or same place + if (!result.destination || result.destination.index === result.source.index) return + + const draggedIndex = result.source.index + const draggedAlternativeId = activeSlide?.questions[0].alternatives.find((alt) => alt[orderType] === draggedIndex) + ?.id + if (orderType === 'alternative_order') { + const alternativesCopy = [...alternativesSortedByAlternative] + const [removed] = alternativesCopy.splice(draggedIndex, 1) + alternativesCopy.splice(result.destination.index, 0, removed) + setAlternativesSortedByAlternative(alternativesCopy) + } else { + const alternativesCopy = [...alternativesSortedByCorrect] + const [removed] = alternativesCopy.splice(draggedIndex, 1) + alternativesCopy.splice(result.destination.index, 0, removed) + setAlternativesSortedByCorrect(alternativesCopy) + } + if (!draggedAlternativeId) return + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${draggedAlternativeId}`, + { + [orderType]: result.destination.index, + } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const updateAlternative = async (alternative_id: number, alternativeUpdate: AlternativeUpdate) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates filter and api 250ms after last input was made + setTimerHandle( + window.setTimeout(() => { + if (activeSlide && activeSlide.questions[0]) { + axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, + alternativeUpdate + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + }, 250) + ) + } + + const addAlternative = async () => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .post( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const deleteAlternative = async (alternative_id: number) => { + if (activeSlide?.questions[0]) { + await axios + .delete( + `/api/competitions/${competitionId}/slides/${activeSlideId}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Svarsalternativ" /> + </Center> + </ListItem> + {activeSlide?.questions?.[0]?.alternatives?.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <Typography> + {alt.alternative} | {alt.correct} + </Typography> + </ListItem> + </div> + ))} + <Dialog + fullWidth + open={dialogOpen} + onClose={() => console.log('close')} + aria-labelledby="responsive-dialog-title" + > + <DialogTitle id="responsive-dialog-title">Redigera para ihop-alternativ</DialogTitle> + <DialogContent style={{ height: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> + <AppBar position="relative"> + <Tabs value={selectedTab} onChange={(event, selectedTab) => setSelectedTab(selectedTab)} centered> + <Tab label="Lag" /> + <Tab label="Facit" color="primary" /> + </Tabs> + </AppBar> + + {selectedTab === 0 && ( + <div style={{ marginBottom: 50, marginTop: 10 }}> + <div style={{ display: 'flex', justifyContent: 'center' }}> + {activeSlide?.questions[0].alternatives.length !== 0 && ( + <DialogContentText>Para ihop alternativen som de kommer se ut för lagen.</DialogContentText> + )} + {activeSlide?.questions[0].alternatives.length === 0 && ( + <DialogContentText> + Det finns inga alternativ, lägg till alternativ med knappen nedan. + </DialogContentText> + )} + </div> + <div style={{ display: 'flex' }}> + <DragDropContext onDragEnd={(result) => onDragEnd(result, 'alternative_order')}> + <div style={{ flexDirection: 'column' }}> + <Droppable droppableId="droppable1"> + {(provided) => ( + <div ref={provided.innerRef} {...provided.droppableProps}> + {alternativesSortedByAlternative.map((alternative, index) => ( + <Draggable draggableId={alternative.id.toString()} index={index} key={alternative.id}> + {(provided, snapshot) => ( + <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <DragIndicatorIcon elevation={3} /> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.alternative} + onChange={(event) => + updateAlternative(alternative.id, { alternative: event.target.value }) + } + variant="outlined" + style={{ width: 200 }} + /> + </Card> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </div> + </DragDropContext> + + <DragDropContext onDragEnd={(result) => onDragEnd(result, 'correct_order')}> + <div style={{ flexDirection: 'column' }}> + <Droppable droppableId="droppable2"> + {(provided) => ( + <div ref={provided.innerRef} {...provided.droppableProps}> + {alternativesSortedByCorrect.map((alternative, index) => ( + <Draggable draggableId={alternative.id.toString()} index={index} key={alternative.id}> + {(provided, snapshot) => ( + <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.correct} + onChange={(event) => + updateAlternative(alternative.id, { correct: event.target.value }) + } + variant="outlined" + /> + <DragIndicatorIcon elevation={3} /> + </Card> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </div> + </DragDropContext> + </div> + </div> + )} + + {selectedTab === 1 && ( + <div style={{ marginBottom: 50, marginTop: 10 }}> + <div style={{ display: 'flex', justifyContent: 'center' }}> + {activeSlide?.questions[0].alternatives.length !== 0 && ( + <DialogContentText>Editera svarsalternativen.</DialogContentText> + )} + {activeSlide?.questions[0].alternatives.length === 0 && ( + <DialogContentText> + Det finns inga alternativ, lägg till alternativ med knappen nedan. + </DialogContentText> + )} + </div> + {activeSlide?.questions?.[0]?.alternatives?.map((alternative, index) => ( + <div style={{ display: 'flex' }} key={alternative.id}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <IconButton size="small" onClick={() => deleteAlternative(alternative.id)}> + <ClearIcon color="error" /> + </IconButton> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.alternative} + onChange={(event) => updateAlternative(alternative.id, { alternative: event.target.value })} + variant="outlined" + style={{ width: 200 }} + /> + </Card> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.correct} + onChange={(event) => updateAlternative(alternative.id, { correct: event.target.value })} + variant="outlined" + style={{ width: 200 }} + /> + </Card> + </div> + ))} + </div> + )} + </DialogContent> + <DialogActions> + <Button variant="contained" autoFocus onClick={addAlternative} color="primary"> + Lägg till alternativ + </Button> + <Button variant="contained" autoFocus onClick={() => setDialogOpen(false)} color="secondary"> + Stäng + </Button> + </DialogActions> + </Dialog> + <ListItem button onClick={() => setDialogOpen(true)}> + <Center> + <AddButton variant="button">Redigera alternativ</AddButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default MatchAlternatives diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx index 60d741c8ec431bcde9bfc5e39a7f3df6816cc67f..17f09647aafc21540b5fd2f7bdce4583a8048570 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx @@ -38,21 +38,20 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi /** * A checked checkbox is represented with 1 and an unchecked with 0. */ - const numberToBool = (num: number) => { - if (num === 0) return false + const stringToBool = (num: string) => { + if (num === '0') return false else return true } const updateAlternativeValue = async (alternative: QuestionAlternative) => { if (activeSlide && activeSlide.questions?.[0]) { - let newValue: number - if (alternative.value === 0) { - newValue = 1 - } else newValue = 0 - await axios - .put( + let newValue: string + if (alternative.correct === '0') { + newValue = '1' + } else newValue = '0' + await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, - { value: newValue } + { correct: newValue } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -66,7 +65,7 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, - { text: newText } + { alternative: newText } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -80,7 +79,7 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi await axios .post( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, - { text: '', value: 0 } + { correct: '0' } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -117,11 +116,11 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi <ListItem divider> <AlternativeTextField id="outlined-basic" - defaultValue={alt.text} + defaultValue={alt.alternative} onChange={(event) => updateAlternativeText(alt.id, event.target.value)} variant="outlined" /> - <GreenCheckbox checked={numberToBool(alt.value)} onChange={() => updateAlternativeValue(alt)} /> + <GreenCheckbox checked={stringToBool(alt.correct)} onChange={() => updateAlternativeValue(alt)} /> <Clickable> <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> </Clickable> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx index 6fe2d51027c224bd84d3407b9e751c30f453cd25..c2ea6e8ee73b5a3af1e9c0f845a8930a24d2c9e2 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx @@ -97,7 +97,7 @@ const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) <Center> <SettingsItemContainer> <TextField - fullWidth={true} + fullWidth variant="outlined" placeholder="Antal poäng" helperText="Välj hur många poäng frågan ska ge för rätt svar. Lämna blank för att inte använda poängfunktionen" diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx index 16604f818b286fca8e51b1e92d0ba350017fc2a1..51de69dab1022149e953280e0a1dfaecf2e7645a 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx @@ -28,19 +28,19 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl const updateAlternativeValue = async (alternative: QuestionAlternative) => { if (activeSlide && activeSlide.questions[0]) { // Remove check from previously checked alternative - const previousCheckedAltId = activeSlide.questions[0].alternatives.find((alt) => alt.value === 1)?.id + const previousCheckedAltId = activeSlide.questions[0].alternatives.find((alt) => alt.correct === '1')?.id if (previousCheckedAltId !== alternative.id) { if (previousCheckedAltId) { axios.put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${previousCheckedAltId}`, - { value: 0 } + { correct: '0' } ) } // Set new checked alternative await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, - { value: 1 } + { correct: '1' } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -55,7 +55,7 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, - { text: newText } + { alternative: newText } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -69,7 +69,7 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl await axios .post( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, - { text: '', value: 0 } + { correct: '0' } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -92,7 +92,7 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl } const renderRadioButton = (alt: QuestionAlternative) => { - if (alt.value) return <RadioButtonCheckedIcon onClick={() => updateAlternativeValue(alt)} /> + if (alt.correct === '1') return <RadioButtonCheckedIcon onClick={() => updateAlternativeValue(alt)} /> else return <RadioButtonUncheckedIcon onClick={() => updateAlternativeValue(alt)} /> } @@ -114,7 +114,7 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl <ListItem divider> <AlternativeTextField id="outlined-basic" - defaultValue={alt.text} + defaultValue={alt.alternative} onChange={(event) => updateAlternativeText(alt.id, event.target.value)} variant="outlined" /> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx index e8faa58e0e26f0bbed27549042278161df154c0b..fdecde8ed07c8b7cf1ae7945d90ffc398f870f08 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx @@ -78,18 +78,17 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(({ data }) => { dispatch(getEditorCompetition(competitionId)) - removeQuestionComponent().then(() => createQuestionComponent(data.id)) + removeQuestionComponent().then(() => { + //No question component for practical questions + if (selectedSlideType !== 2) createQuestionComponent(data.id) + }) }) .catch(console.log) if (selectedSlideType === 1) { // Add an alternative to text questions to allow giving answers. await axios .post( - `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}/alternatives`, - { - text: '', - value: 1, - } + `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}/alternatives` ) .then(({ data }) => { dispatch(getEditorCompetition(competitionId)) @@ -107,24 +106,19 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(({ data }) => { dispatch(getEditorCompetition(competitionId)) - createQuestionComponent(data.id) + //No question component for practical questions + if (selectedSlideType !== 2) createQuestionComponent(data.id) + if (selectedSlideType === 1) { + // Add an alternative to text questions to allow giving answers. + axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${data.id}/alternatives`) + .then(({ data }) => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } }) .catch(console.log) - if (selectedSlideType === 1) { - // Add an alternative to text questions to allow giving answers. - await axios - .post( - `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}/alternatives`, - { - text: '', - value: 1, - } - ) - .then(({ data }) => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } } } } @@ -176,6 +170,9 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { <MenuItem value={4} button onClick={() => openSlideTypeDialog(4)}> <Typography>Alternativfråga</Typography> </MenuItem> + <MenuItem value={5} button onClick={() => openSlideTypeDialog(5)}> + <Typography>Para ihop-fråga</Typography> + </MenuItem> </Select> </FormControl> diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index 1a4f9169f681e25738fb92c7b87aefa6d7d1e5db..106e360efe10519a8818efab416f1d3ae7e84513 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -153,8 +153,8 @@ interface SlideDisplayTextProps { export const SlideDisplayText = styled(Typography)<SlideDisplayTextProps>` position: absolute; - top: 5px; - left: ${(props) => (props.$right ? undefined : 5)}px; - right: ${(props) => (props.$right ? 5 : undefined)}px; + top: 0px; + left: ${(props) => (props.$right ? undefined : 0)}px; + right: ${(props) => (props.$right ? 0 : undefined)}px; font-size: ${(props) => 24 * props.$scale}px; ` diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index a287402434ae104a22cff282e6a5a43eb77989c9..df80d4fc4ecff26ccbab5ab863a33bf520c52e51 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -124,17 +124,19 @@ const JudgeViewPage: React.FC = () => { <div className={classes.toolbar} /> <List> {slides.map((slide, index) => ( - <SlideListItem - selected={slide.order === currentSlide?.order} - onClick={() => handleSelectSlide(index)} - divider - button - key={slide.id} - style={{ border: 2, borderStyle: slide.id === operatorActiveSlideId ? 'dashed' : 'none' }} - > - {renderSlideIcon(slide)} - <ListItemText primary={`Sida ${slide.order + 1}`} /> - </SlideListItem> + <> + <SlideListItem + selected={slide.order === currentSlide?.order} + onClick={() => handleSelectSlide(index)} + button + key={slide.id} + style={{ border: 2, borderStyle: slide.id === operatorActiveSlideId ? 'dashed' : 'none' }} + > + {renderSlideIcon(slide)} + <ListItemText primary={`Sida ${slide.order + 1}`} /> + </SlideListItem> + <Divider /> + </> ))} </List> </LeftDrawer> @@ -153,7 +155,7 @@ const JudgeViewPage: React.FC = () => { </ScoreHeaderPaper> )} <ScoreHeaderPadding /> - <List style={{ overflowY: 'scroll', overflowX: 'hidden' }}> + <List style={{ overflowY: 'auto', overflowX: 'hidden' }}> {teams && teams.map((answer, index) => ( <div key={answer.name}> diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index 9dab453bcbfea973e072b20e2f0498a5258703e8..130240c20fcff7ee742347c06b38b0424232fbb9 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -317,7 +317,7 @@ const OperatorViewPage: React.FC = () => { color="primary" > <TimerIcon fontSize="large" /> - <Timer disableText /> + <Timer variant="presentation" /> </OperatorButton> </div> </Tooltip> diff --git a/client/src/pages/views/components/JudgeScoreDisplay.tsx b/client/src/pages/views/components/JudgeScoreDisplay.tsx index e213d84418d799e7f1cc89305a59e9374bcd3258..7deb0f348e0857bfc581f25ecd9274f38cc25815 100644 --- a/client/src/pages/views/components/JudgeScoreDisplay.tsx +++ b/client/src/pages/views/components/JudgeScoreDisplay.tsx @@ -1,4 +1,4 @@ -import { Box, Divider, Typography } from '@material-ui/core' +import { Box, Card, Divider, Typography } from '@material-ui/core' import axios from 'axios' import React from 'react' import { getPresentationCompetition } from '../../../actions/presentation' @@ -11,6 +11,7 @@ import { ScoreDisplayContainer, ScoreDisplayHeader, ScoreInput, + UnderlinedTypography, } from './styled' type ScoreDisplayProps = { @@ -25,9 +26,16 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { const activeQuestion = activeSlide.questions[0] const activeScore = currentTeam.question_scores.find((x) => x.question_id === activeQuestion?.id) - const questionMaxScore = activeQuestion?.total_score - const scores = currentTeam.question_scores.map((questionAnswer) => questionAnswer.score) + const questions = useAppSelector((state) => state.presentation.competition.slides.map((slide) => slide.questions[0])) + const teamScores = [...currentTeam.question_scores.map((score) => score)] + const scores: (number | undefined)[] = [] + for (const question of questions) { + const correctTeamScore = teamScores.find((score) => question && score.question_id === question.id) + if (correctTeamScore !== undefined) { + scores.push(correctTeamScore.score) + } else scores.push(undefined) + } const handleEditScore = async (newScore: number, questionId: number) => { await axios .put(`/api/competitions/${currentCompetititonId}/teams/${currentTeam.id}/answers/question_scores/${questionId}`, { @@ -36,6 +44,14 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { .then(() => dispatch(getPresentationCompetition(currentCompetititonId.toString()))) } + const sumTwoScores = (a: number | undefined, b: number | undefined) => { + let aValue = 0 + let bValue = 0 + aValue = a ? a : 0 + bValue = b ? b : 0 + return aValue + bValue + } + const getAnswers = () => { const result: string[] = [] if (!activeQuestion) { @@ -46,11 +62,11 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { if (!ans) { continue } - if (activeQuestion.type_id === 1) { - // Text question + if (activeQuestion.type_id === 1 || activeQuestion.type_id === 5) { + // Text question or match question result.push(ans.answer) } else if (+ans.answer > 0) { - result.push(alt.text) + result.push(alt.alternative) } } return result @@ -62,9 +78,12 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { return result } for (const alt of activeQuestion.alternatives) { - if (activeQuestion.type_id !== 1 && +alt.value > 0) { + // Match question + if (activeQuestion.type_id === 5) { + result.push(`${alt.alternative} - ${alt.correct}`) + } else if (activeQuestion.type_id !== 1 && +alt.correct > 0) { // Not text question and correct answer - result.push(alt.text) + result.push(alt.alternative) } } return result @@ -83,19 +102,57 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { defaultValue={0} value={activeScore ? activeScore.score : 0} inputProps={{ style: { fontSize: 20 } }} - InputProps={{ disableUnderline: true, inputProps: { min: 0, max: questionMaxScore } }} + InputProps={{ disableUnderline: true, inputProps: { min: 0 } }} type="number" onChange={(event) => handleEditScore(+event.target.value, activeQuestion.id)} /> )} </ScoreDisplayHeader> - <Typography variant="h6">Alla poäng: [ {scores.map((score) => `${score} `)}]</Typography> - <Typography variant="h6">Total poäng: {scores.reduce((a, b) => a + b, 0)}</Typography> - + <Typography variant="h6"> + Sidor: + <div style={{ display: 'flex' }}> + {questions.map((question, index) => ( + <Card + key={index} + elevation={2} + style={{ + width: 25, + height: 25, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }} + > + {index + 1} + </Card> + ))} + </div> + Poäng: + <div style={{ display: 'flex' }}> + {scores.map((score, index) => ( + <Card + key={index} + elevation={2} + style={{ + width: 25, + height: 25, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }} + > + {questions[index] ? score : '-'} + </Card> + ))} + </div> + Totala poäng: {scores.reduce((a, b) => sumTwoScores(a, b), 0)} + </Typography> <AnswersDisplay> <Answers> <Divider /> - <Typography variant="body1">Lagets svar:</Typography> + <UnderlinedTypography variant="body1">Lagets svar:</UnderlinedTypography> {activeQuestion && ( <AnswerContainer> {getAnswers().map((v, k) => ( @@ -109,7 +166,9 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { <Answers> <Divider /> - <Typography variant="body1">Korrekta svar:</Typography> + {activeQuestion && activeQuestion.type_id !== 1 && ( + <UnderlinedTypography variant="body1">Korrekta svar:</UnderlinedTypography> + )} {activeQuestion && ( <AnswerContainer> {getAlternatives().map((v, k) => ( @@ -121,7 +180,6 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { )} </Answers> </AnswersDisplay> - {!activeQuestion && <Typography variant="body1">Inget svar</Typography>} </ScoreDisplayContainer> ) diff --git a/client/src/pages/views/components/JudgeScoringInstructions.tsx b/client/src/pages/views/components/JudgeScoringInstructions.tsx index 86d8eaf12e9a26db08c4b9d6dd77c58cf54b6d80..f8c21667504524720523004ce9e853616c84e41f 100644 --- a/client/src/pages/views/components/JudgeScoringInstructions.tsx +++ b/client/src/pages/views/components/JudgeScoringInstructions.tsx @@ -8,7 +8,6 @@ type JudgeScoringInstructionsProps = { } const JudgeScoringInstructions = ({ question }: JudgeScoringInstructionsProps) => { - console.log(question) return ( <JudgeScoringInstructionsContainer elevation={3}> <ScoringInstructionsInner> diff --git a/client/src/pages/views/components/Timer.tsx b/client/src/pages/views/components/Timer.tsx index 6f4add03d418bd2dda64697961c8b08724b391e0..5b496325803d8f092178ef39506b704f11ab0443 100644 --- a/client/src/pages/views/components/Timer.tsx +++ b/client/src/pages/views/components/Timer.tsx @@ -3,24 +3,39 @@ import { setPresentationTimer } from '../../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../../hooks' type TimerProps = { - disableText?: boolean + variant: 'editor' | 'presentation' + currentSlideId?: number } -const Timer = ({ disableText }: TimerProps) => { +const Timer = ({ variant, currentSlideId }: TimerProps) => { const dispatch = useAppDispatch() const timer = useAppSelector((state) => state.presentation.timer) const [remainingTimer, setRemainingTimer] = useState<number>(0) + const remainingSeconds = remainingTimer / 1000 + const remainingWholeSeconds = Math.floor(remainingSeconds % 60) + // Add a 0 before the seconds if it's lower than 10 + const remainingDisplaySeconds = `${remainingWholeSeconds < 10 ? '0' : ''}${remainingWholeSeconds}` + + const remainingMinutes = Math.floor(remainingSeconds / 60) % 60 + // Add a 0 before the minutes if it's lower than 10 + const remainingDisplayMinutes = `${remainingMinutes < 10 ? '0' : ''}${remainingMinutes}` + + const displayTime = `${remainingDisplayMinutes}:${remainingDisplaySeconds}` const [timerIntervalId, setTimerIntervalId] = useState<NodeJS.Timeout | null>(null) - const slideTimer = useAppSelector( - (state) => - state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.timer - ) + const slideTimer = useAppSelector((state) => { + if (currentSlideId && variant === 'presentation') + return state.presentation.competition.slides.find((slide) => slide.id === currentSlideId)?.timer + if (variant === 'presentation') + return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.timer + return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.timer + }) useEffect(() => { - if (slideTimer) setRemainingTimer(slideTimer) + if (slideTimer) setRemainingTimer(slideTimer * 1000) }, [slideTimer]) useEffect(() => { + if (variant === 'editor') return if (!timer.enabled) { if (timerIntervalId !== null) clearInterval(timerIntervalId) @@ -50,7 +65,7 @@ const Timer = ({ disableText }: TimerProps) => { ) }, [timer.enabled, slideTimer]) - return <>{`${!disableText ? 'Tid kvar:' : ''} ${Math.round(remainingTimer / 1000)}`}</> + return <>{slideTimer && displayTime}</> } export default Timer diff --git a/client/src/pages/views/components/styled.tsx b/client/src/pages/views/components/styled.tsx index e73b1463d41e0d754aef2fa42ac1261973489202..602011d43ca1c8e9d7ea50fc5e8537f34a080b19 100644 --- a/client/src/pages/views/components/styled.tsx +++ b/client/src/pages/views/components/styled.tsx @@ -1,4 +1,4 @@ -import { Paper, TextField } from '@material-ui/core' +import { Paper, TextField, Typography } from '@material-ui/core' import styled from 'styled-components' export const SlideContainer = styled.div` @@ -61,3 +61,7 @@ export const Answers = styled.div` align-items: center; flex-direction: column; ` + +export const UnderlinedTypography = styled(Typography)` + text-decoration: underline; +` diff --git a/client/src/utils/renderSlideIcon.tsx b/client/src/utils/renderSlideIcon.tsx index 22f6405abbc8574ae17bf86dd8b745ba98e3b4cf..fe1d0040b6fa2818e25a47db10f0695d32558fd6 100644 --- a/client/src/utils/renderSlideIcon.tsx +++ b/client/src/utils/renderSlideIcon.tsx @@ -3,6 +3,7 @@ import CheckBoxOutlinedIcon from '@material-ui/icons/CheckBoxOutlined' import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked' +import UnfoldMoreOutlinedIcon from '@material-ui/icons/UnfoldMoreOutlined' import React from 'react' import { RichSlide } from '../interfaces/ApiRichModels' @@ -17,6 +18,8 @@ export const renderSlideIcon = (slide: RichSlide) => { return <CheckBoxOutlinedIcon /> // multiple choice question case 4: return <RadioButtonCheckedIcon /> // single choice question + case 5: + return <UnfoldMoreOutlinedIcon /> // Match question } } else { return <InfoOutlinedIcon /> // information slide diff --git a/docs/source/contact.md b/docs/source/contact.md index 53155d8cdd547098f680a1b7f82d304b07c128e9..eebe583d4b8df2d21638761816c05dfe83795e6e 100644 --- a/docs/source/contact.md +++ b/docs/source/contact.md @@ -1,16 +1,17 @@ # Contact The people involved in the project, their email, their role in the project, what they worked on generally and, if anything, they worked on most will be described below. -The head developer on each general area will also be given. Please feel free to contact us if you have any questions. -| Name | Email | Role | General | Special | -| ------------------ | ----------------------- | ---------------------- | -------- | ------------------- | -| Albin Henriksson | albhe428@student.liu.se | Testledare | Frontend | Huvudutvecklare | -| Sebastian Karlsson | sebka991@student.liu.se | Arkitekt | Frontend | | -| Victor Löfgren | viclo211@student.liu.se | Konfigurationsansvarig | Backend | Dokumentation | -| Björn Modée | bjomo323@student.liu.se | Kvalitetsamordnare | Frontend | Redux | -| Josef Olsson | josol381@student.liu.se | Teamledare | Backend | | -| Max Rüdiger | maxru105@student.liu.se | Dokumentansvarig | Frontend | | -| Carl Schönfelder | carsc272@student.liu.se | Utvecklingsledare | Backend | Huvudutvecklare | -| Emil Wahlqvist | emiwa210@student.liu.se | Analysansvarig | Frontend | Presentationseditor | +| Namn | Email | Roll | Generellt | Speciellt | +| ------------------ | ----------------------- | ---------------------- | --------- | ---------------------- | +| Albin Henriksson | albhe428@student.liu.se | Testledare | Frontend | | +| Sebastian Karlsson | sebka991@student.liu.se | Arkitekt | Frontend | | +| Victor Löfgren | viclo211@student.liu.se | Konfigurationsansvarig | Backend | Dokumentation, Sockets | +| Björn Modée | bjomo323@student.liu.se | Kvalitetsamordnare | Frontend | Redux | +| Josef Olsson | josol381@student.liu.se | Teamledare | Backend | | +| Max Rüdiger | maxru105@student.liu.se | Dokumentansvarig | Frontend | | +| Carl Schönfelder | carsc272@student.liu.se | Utvecklingsledare | Backend | Databas | +| Emil Wahlqvist | emiwa210@student.liu.se | Analysansvarig | Frontend | Presentationseditor | + +[comment]: # (Should this really be in swedish?) \ No newline at end of file diff --git a/docs/source/development.rst b/docs/source/development.rst index 6dc13f16a0cd6b71a05d5a3b4b2717936676bfe9..89085b0c568d6bb91b3eadda11f8e242891f23e8 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -1,8 +1,8 @@ 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. +This section will give all the instructions necessary to continue the development of this project. +Some recommendations for how to go about it will also be given. .. toctree:: :maxdepth: 2 diff --git a/docs/source/development/client.md b/docs/source/development/client.md index 5ae08c7008b11b410d730c98275b1acca54ac51e..c868be7420b0a94cfa9cb6e2379cf9789d4cb18a 100644 --- a/docs/source/development/client.md +++ b/docs/source/development/client.md @@ -1,7 +1,11 @@ # Frontend +[comment]: # (TODO) + ## Working with TypeScript +[comment]: # (TODO) + ### npm `npm` is the node package manager. @@ -14,4 +18,4 @@ To uninstall a module, run `npm uninstall <module>`. To install all project dependencies, run `npm install`. -Remember to install the project dependencies whenever someone else has added new ones to the project. +It is important to remember to install the project dependencies whenever someone else has added new ones to the project. diff --git a/docs/source/development/external.md b/docs/source/development/external.md index 84e4a756f90d1df9f12a49a1af5fbbbf79aec7f6..ee8c3b358bf0c8bea21575bf161d4450ae05f39e 100644 --- a/docs/source/development/external.md +++ b/docs/source/development/external.md @@ -1,6 +1,6 @@ # External programs -We also used some other programs help the development. +These are some useful programs that can help with the development. ## Postman @@ -12,3 +12,5 @@ It's very helpful when developing APIs. [DB Browser for SQlite](https://sqlitebrowser.org/) is used to see what is currently stored in the database. You can even edit values. + +[comment]: # (Add VS CODE?) \ No newline at end of file diff --git a/docs/source/development/further.md b/docs/source/development/further.md index 6daad4e6deae60e43a980ba3c49cb833c78bdce4..543de426d0f437292596246684b5aff10ca3ceb3 100644 --- a/docs/source/development/further.md +++ b/docs/source/development/further.md @@ -1,11 +1,34 @@ # 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. +Below we will give two different types of things to improve. +The first type is functionality, bugs and aesthetics which improves the usability of the system. +The second type is refactoring which is basically just things related to the source code. +This won't effect the end user but will certainly improve the system as a whole. -## Replacing reqparse +## Functionality, bugs and aesthetics -As mentioned in [Parsing request](../overview/server.html#parsing-request), the reqparse module from RestX is deprecated and should be replaced with for example marshmallow. -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. -This would also make it possible to generate better documentation by providing both the expected paramters and return value from an API. -This was looked into and deemed not trivial with the current solution. +Most of the basic functionality of the system is already completed. +There are however a few major things left to be done. + +### Different question types + +The system needs to support a lot of different types of questions. +A list of all the questions that needs to be supported (and more) can be found on [Teknikattan scoring system](https://github.com/TechnoX/teknikattan-scoring-system/blob/master/kandidatarbete_teknikattan.md). + +## Refactoring + +Here we will give a list of things we think will improve the system. +It is not certain that they are a better solutions but definitely something to look into. + +### Replace Flask-RESTX with flask-smorest + +[comment]: # (This is already implemented) + +We currently use [Flask-RESTX](https://flask-restx.readthedocs.io/en/latest/) to define our endpoints and parse the arguments they take, either as a query string or in the body. +But when responding we use [Marshmallow](https://flask-smorest.readthedocs.io/en/latest/) to generate the JSON objects to return. +We believe that [flask-smorest](https://flask-smorest.readthedocs.io/en/latest/) would integrate a lot better with Marshmallow. +This would give us the ability to more easily show the expected arguments and the return values for our endpoints using Swagger (when visiting `localhost:5000`). +Currently we only show the route. +The work required also seems to be rather small because they look quite similar. +This would also remove the deprecated [reqparse](https://flask-restx.readthedocs.io/en/latest/parsing.html) part from Flask-RESTX, which is desirable. diff --git a/docs/source/development/server.md b/docs/source/development/server.md index 16d36a1e0af1613f25989186ed920b441069f89b..ef234d03d81754c8e68bf86ee0936c367d4c6738 100644 --- a/docs/source/development/server.md +++ b/docs/source/development/server.md @@ -13,7 +13,7 @@ When [installing the server](../installation/server.md) you installed `virtualen 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. +All of the following instructions assume you have created and activated a virtual environment and are located in the server folder. To install a package, run `pip install <package>`. @@ -23,4 +23,4 @@ To save a package as a dependency to the project, run `pip freeze > requirements To install all project dependencies, run `pip install -r requirements.txt`. -Remember to install the project dependencies whenever someone else has added new ones to the project. +Remember to install the project dependencies whenever you or someone else has added new ones to the project. diff --git a/docs/source/development/vscode.md b/docs/source/development/vscode.md index 97e30af20e6175d32d13564663e07ee93d025c73..86c4a97fd13e0b0320a0cecb7894ff77c078feda 100644 --- a/docs/source/development/vscode.md +++ b/docs/source/development/vscode.md @@ -1,26 +1,28 @@ # Visual Studio Code -The development of this project was mainly done using Visual Studio Code (VSCode). +The development of this project was mainly done using Visual Studio Code (VS Code). It is not that surprising, then, that we recommend you use it. ## Extensions -When you first open the repository in Visual Studio Code it will ask you to install all recommended extesions, which you should do. +When you first open the repository in Visual Studio Code it will ask you to install all recommended extensions, which you should do. We used a few extensions to help with the development of this project. -The Python and Pylance extensions help with linting Python code, auto imports, syntax highliting and much more. +The Python and Pylance extensions help with linting Python code, auto imports, syntax highlighting and much more. -Prettier is an extension used to format JavsScript and TypeScript. -ESLint is used to lint JavsScript and TypeScript code. +Prettier is an extension used to format JavaScript and TypeScript. +ESLint is used to lint JavaScript and TypeScript code. -Live Share is an extension thats used to code together at the same time, much like a Google Docs document. -But there was a few issues with the Python extension that made Live Share hard to work with. +[comment]: # ("is used to lint JavaScript" what is lint? It's not explained) + +Live Share is an extension that is used to write code together at the same time, much like a Google Docs document. +There were however a few issues with the Python extension that made Live Share hard to work with. ## Tasks -A task in VSCode is a simple action that can be run by pressing `ctrl+shift+p`, searching for and selecting `Tasks: Run Task`. -These tasks a configured in the `.vscode/tasks.json` file. -Tasks that are build tasks can also be run with `ctrl+shift+b`. +A task in VS Code is a simple action that can be run by pressing `ctrl+shift+p`, searching for and selecting `Tasks: Run Task`. +These tasks are configured in the `.vscode/tasks.json` file. +Tasks that are marked as build tasks (starting and testing tasks as well as populate) can also be run with `ctrl+shift+b`. A few such tasks has been setup in this project and will be described below. The `Start server` task will start the server. @@ -31,14 +33,16 @@ The `Start client and server` task will start both the client and the server. The `Populate database` task will populate the database with a few competitions, teams, users and such. Look in the `populate.py` to see exactly what it adds. Remember to always run this after changing the structure of the database. -The `Test server` task will run the server tests located in the `tests/` folder. +The `Test server` task will run the server tests located in the `server/tests/` folder. + +The `Open server coverage` task can only be run after running the server tests (`Test server` task) and will open the coverage report generated by those tests in a web browser. -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 `Unit tests` task will run the unit tests for the client. -The `Test client` task will run `npm test`. +The `Run e2e tests` task will run the end-to-end tests. -The `Open client coverage` can only be run after running the client tests and will open the coverage report generated by those tests in a webbrowser. +The `Open client coverage` task can only be run after running the client tests (`Unit tests` task) and will open the coverage report generated by those tests in a web browser. -The `Generate documentation` will generate the project documentation, i.e. this document, in the `docs/build/html/` folder. +The `Generate documentation` task will generate the project documentation, i.e. this document, in the `docs/build/html/` folder. -The `Open documentation` can only be run after generating the documentation and will open it in a webbrowser. +The `Open documentation` task can only be run after generating the documentation and will open it in a web browser. diff --git a/docs/source/documentation/client.md b/docs/source/documentation/client.md index ca1dcfe8f4edf2280b636384ba5b6dcf148a60d1..fe424fe59115b82fd2ac7ed865a7958f226e48d9 100644 --- a/docs/source/documentation/client.md +++ b/docs/source/documentation/client.md @@ -16,3 +16,5 @@ start ./docs/index.html ``` If you want to include the documentation from the tests, go to the file `client/tsconfig.json` and comment out the line `"exlude": "**/*.test.*"`. + +[comment]: # (There should be a task for this, or does one exist already?) \ No newline at end of file diff --git a/docs/source/documentation/general.md b/docs/source/documentation/general.md index 8787da1eb335389a029e044815f482e318efb002..d75f2cc4cad952a2dc409c7f1eb7c039fb962e3d 100644 --- a/docs/source/documentation/general.md +++ b/docs/source/documentation/general.md @@ -17,5 +17,5 @@ choco install make You also need to [install the server](../installation/server.md). You should now be able to generate the documentation by activating the Python virtual environment, navigating to `docs/` and running `make html`. -Alternatively you can also run the [VSCode task](../development/vscode.html#tasks) `Generate server documentation`, which will do the same thing. +Alternatively you can also run the [VS Code task](../development/vscode.html#tasks) `Generate documentation`, which will do the same thing. If everything went well you should be able to open it by running (from the `docs/` folder) `start ./build/html/index.html` or running the task `Open documentation`, which does the same thing. diff --git a/docs/source/documentation/server.md b/docs/source/documentation/server.md index 9bbff58f660cfa0ac2e452b437bd4d9db1d4a368..e3c9a7e21e4e84936be469a4756ec499a0b92954 100644 --- a/docs/source/documentation/server.md +++ b/docs/source/documentation/server.md @@ -16,7 +16,7 @@ sphinx-quickstart You will be asked a few questions about how to configure Sphinx. Just press enter on all, which will use the default. -You can enter the correct projet name and/or author if you want, but it's not necessary, no one but you will see it anyway. +You can enter the correct project name and/or author if you want, but it's not necessary, no one but you will see it anyway. Then will need to modify a few files. First add the following code snippet after the first block of comments, above the "project information" comment, in the file `./server/docs/conf.py`: diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 30ba35eae3836ef6962ede8901b4f21f29d00291..7152a72cca2950845560a45e1961b8d14be73f7f 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -1,7 +1,7 @@ Installation ============ -Here we will describe how to install the application. +This section will describe how to install the application. You will need to install both the client and the server. .. toctree:: diff --git a/docs/source/installation/client.md b/docs/source/installation/client.md index 7548e08a0b9b01616f47c1b00f6c523276eaf41a..e5768e9c7a1eb0c658b3ecc4e66d2f5122ec776e 100644 --- a/docs/source/installation/client.md +++ b/docs/source/installation/client.md @@ -5,7 +5,7 @@ In order to install the client, you will need to do the following: Install [Node (LTS)](https://nodejs.org/en/). -Clone [teknikattan-scoring-system](https://gitlab.liu.se/tddd96-grupp11/teknikattan-scoring-system). +Clone the git repository [teknikattan-scoring-system](https://gitlab.liu.se/tddd96-grupp11/teknikattan-scoring-system). Open a terminal and navigate to the root of the cloned project. @@ -18,4 +18,6 @@ npm install You should now be ready to start the client. Try it by running `npm run start`. -A webpage should open where you can see the [login page](../user_manual/login.md). +A web page should open where you can see the [login page](../user_manual/login.md). + +[comment]: # (Should we mention the task for starting the client?) diff --git a/docs/source/installation/server.md b/docs/source/installation/server.md index 137cbfb64e95b39ee4c680d674ab12958b9cad96..3c867f43d7a90f49ea21720df224770d2372c27c 100644 --- a/docs/source/installation/server.md +++ b/docs/source/installation/server.md @@ -5,7 +5,7 @@ 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). +Clone the git repository [teknikattan-scoring-system](https://gitlab.liu.se/tddd96-grupp11/teknikattan-scoring-system). Open a terminal and navigate to the root of the cloned project. @@ -32,7 +32,7 @@ On Linux/Mac: source env/bin/activate ``` -Install all project dependencies: +Lastly, install all project dependencies: ```bash pip install -r requirements.txt @@ -41,3 +41,5 @@ 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. + +[comment]: # (Should we mention the task for starting the server?) diff --git a/docs/source/introduction.md b/docs/source/introduction.md deleted file mode 100644 index 26f224295c877eb249adf0d42ca44ccaf8e4b5b3..0000000000000000000000000000000000000000 --- a/docs/source/introduction.md +++ /dev/null @@ -1,26 +0,0 @@ -# Introduction - -This system was allows a user to create, edit and host competitions. - -## Login - -After logging in you will be able to see all competitions and edit them. -If you're an admin you will also be able to see all users and edit them. -You are also able to connect to an active competition from the same screen you used to login. - -## Editor - -The editor allows you to edit competitions. -You can add, remove and reorder slides. -You can add, delete and edit teams, text and image components, question type, correcting instructions and background image. - -## Active competitions - -You can also start a competition. -This will let other people join it with codes that can be seen either before or after starting a presentation. -Then when you switch slides, start the timer or show the current score it will also happen for every other person connected to the same competition. - -Depending on which code someone uses to join an active competition they will see different things, which we call different views. -The team view will allow the user to answer the questions. -The judge view will allow the user to see correct answers and give a score to the questions answered by a team. -The audience view will show the current slide. diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst new file mode 100644 index 0000000000000000000000000000000000000000..4af754753f05af1b680642e1ae4c2500a6735437 --- /dev/null +++ b/docs/source/introduction.rst @@ -0,0 +1,10 @@ +Introduction +============ + +This is a short introduction to both the project as a whole and our system. + +.. toctree:: + :maxdepth: 2 + + introduction/project + introduction/system diff --git a/docs/source/introduction/project.md b/docs/source/introduction/project.md new file mode 100644 index 0000000000000000000000000000000000000000..efe0cee655550d61ba0d86d1aaaa56ed5244c751 --- /dev/null +++ b/docs/source/introduction/project.md @@ -0,0 +1,32 @@ +# Introduction to the project + +This is a short introduction to the project. +There are several links to other relevant things to read before choosing this project. + +## Before choosing this project + +There are a lot of things this system needs to do. +To get a complete description, see the [original repository](https://github.com/TechnoX/teknikattan-scoring-system#beskrivning-av-hur-man-anv%C3%A4nder-systemet) from Teknikåttan. +There you will see exactly what is expected of the system (click on each picture to see a video that will give a more in-depth explanation). +You may also what to look at the [description of the project](https://github.com/TechnoX/teknikattan-scoring-system/blob/master/kandidatarbete_teknikattan.md), if you have not already done so. +There is a lot to read (and watch) on these two links, but in doing so you will get a complete picture of the requirements. +Make sure you understand what this project entails before continuing with it, it is not as "simple" as it might first seem. + +## Our perspective + +This was a fun project. +In contrast to some other previous projects the purpose of this one, what it's requirements are and why it's useful, is clear. +It is really fun developing a product you know (if it turns out well) many people will appreciate, use, and see. + +But on the other hand the project is large. +There was a group that worked on this project before us. +We could have continued their project when we began, but we decided not to. +This was in part due to it not really working and in part due to lack of documentation. +We hope to have learned from that mistake. +That is why we have made proper documentation (the one you are reading right now!) and a decent, working foundation of the system. +We have also made an effort to document the code as much as possible. +We hope you continue on our efforts if you choose this project. + +## Contact us + +If you have any questions about the project, our system or anything, feel free to [contact](../contact.md) any of us. diff --git a/docs/source/introduction/system.md b/docs/source/introduction/system.md new file mode 100644 index 0000000000000000000000000000000000000000..5163562bb6d984529b9bd8bcb90a20761a0e5566 --- /dev/null +++ b/docs/source/introduction/system.md @@ -0,0 +1,35 @@ +# Introduction to our system + +This system allows a user to create, edit and host competitions. +Below it is in short described what the system allows you to do. +If you want a more exact description (with pictures!), see the [user manual](../user_manual.rst) + +## Login + +After logging in you will be able to see all competitions and edit them. +If you're an admin you will also be able to see all users and edit them. +You will also be able to connect to an active competition from the same screen you used to login. + +## Editor + +The editor allows you to edit competitions. +You can add, remove and reorder slides. +You can add, delete and edit: + +- teams +- text and image components +- questions +- question types +- correcting instructions +- background image. + +## Active competitions + +You can also start a competition. +This will let other people join it with codes that can be seen either before or after starting a presentation. +Then when you switch slides, start the timer or show the current score, it will also happen for every other person connected to the same competition. + +Depending on which code someone uses to join an active competition they will see different things, which we call different _views_. +The _team view_ will allow the user to answer the questions. +The _judge view_ will allow the user to see correct answers and give a score to the questions answered by a team. +The _audience view_ will show the current slide. diff --git a/docs/source/overview.rst b/docs/source/overview.rst index 1314ce282e3dc60495225e17f2197759d536dd1b..fe607291db9e1fbed7c55dea84a85ce7e308ccf6 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -1,8 +1,8 @@ System overview =============== -Here we briefly describe how the entire system works. -Then we go into more detail about the client and the server. +This is a brief overview of how the entire system works. +There is then more detail about the client and the server. .. toctree:: :maxdepth: 2 diff --git a/docs/source/overview/client.md b/docs/source/overview/client.md index 51c0cd88b7d95c470779655f041855fad3a40a27..0187b5e916093a661048468268726578b2a7da0f 100644 --- a/docs/source/overview/client.md +++ b/docs/source/overview/client.md @@ -1 +1,37 @@ # Client overview + +The client is the main part of the system. +It is divided into 4 pages: login, admin, presentation editor and active competitions (presentations). +The presentations is also further divided into four different views: operator view, audience view, team view and judge view. + +## Competitions and Presentations + +In this project competitions are often refered to when meaning un-active competitions while presentations are refered to when meaning active competitions involving multiple users and sockets connecting them. + +## File structure + +All of the source code for the pages in the system is stored in the `client/src/pages/` folder. +For each of the different parts there is a corresponding file that ends in Page, for example `JudgeViewPage.tsx` or `LoginPage.tsx`. +This is the main file for that page. +All of these pages also has their own and shared components, in the folder relative to the page `./components/`. +Every React component should also have their responding test file. + +## Routes + +All pages have their own route which is handled in `client/src/Main.tsx`. Futhermore the admin page has one route for each of the tabs which helps when reloading the site to select the previously selected tab. There is also a route for logging in with code which makes it possible to go to for example `localhost:3000/CODE123` to automatically join a competition with that code. + +## Authentication + +Authentication is managed by using JWT from the API. The JWT for logging in is stored in local storage under `token`. The JWT for active presentations are stored in local storage `RoleToken` so for example the token for Operator is stored in local storage under `OperatorToken`. + +## Prettier and Eslint + +[Eslint](https://eslint.org/) is used to set rules for syntax, [prettier](https://prettier.io/) is then used to enforce these rules when saving a file. Eslint is set to only warn about linting warnings. These libraries have their own config files which can be used to change their behavior: `client/.eslintrc` and `client/.prettierrc` + +## Redux + +[Redux](https://eslint.org/) is used for state management along with the [thunk](https://github.com/reduxjs/redux-thunk) middleware which helps with asynchronous actions. Action creators are under `client/src/actions.ts`, these dispatch actions to the reducers under `client/src/reducers.ts` that update the state. The interfaces for the states is saved in each reducer along with their initial state. When updating the state in the reducers the action payload is casted to the correct type to make the store correctly typed. + +## Interfaces + +In `client/src/interfaces` all interfaces that are shared in the client is located. `client/src/interfaces/ApiModels.ts` and `client/src/interfaces/ApiRichModels.ts` includes all models from the api and should always be updated when editing models on the back-end. This folder also includes some more specific interfaces that are re-used in the client. diff --git a/docs/source/overview/overview.md b/docs/source/overview/overview.md index dd1eccce42bf6a6bdffbba888b9d0eb04e14013b..5a46ed1975d8835380c5dc403edefe0bd6caf9c1 100644 --- a/docs/source/overview/overview.md +++ b/docs/source/overview/overview.md @@ -5,19 +5,21 @@ The terms frontend and client as well as backend and server will be used interch  -First we have the main server which is written in Python using the micro-framework Flask. -Then have a fairly small Node server who's only function is to serve the React frontend pages. -Lastly we have the frontend which is written in TypeScript using React and Redux. +First there is the main server which is written in Python using the micro-framework Flask. +Then there is a fairly small Node server with only one function, to serve the React frontend pages. +Lastly there is the frontend which is written in TypeScript using React and Redux. ## Communication -The frontend communicates with the backend in two ways. -All of the following ways are authorized on the server to make sure that who ever tried to communicate has the correct access level. +The frontend communicates with the backend in two ways, both of which are authorized on the server. +This is to make sure that whoever tries to communicate has the correct level of access. ### API -API calls are used for simple functions the client wants to perform, such as getting, editing and saving data. -These are sent from the client to the backend Node server who will proxy the request to the main Python server. +[comment]: # (What does "that will proxy the request to the main Python server" mean?) + +API calls are used for simple functions that the client wants to perform, such as getting, editing, and saving data. +These are sent from the client to the backend Node server that will proxy the request to the main Python server. The request will then be handled there and the response will be sent back. The Node server will then send them back to the client. diff --git a/docs/source/overview/server.md b/docs/source/overview/server.md index 314ff853f3643d15098158c2c80ec1783babe315..9f322c4e2d43843f8126ac9539a5ef2a88d9e0ff 100644 --- a/docs/source/overview/server.md +++ b/docs/source/overview/server.md @@ -1,75 +1,93 @@ # Server overview -The server has two main responsibilites. +The server has two main responsibilities. 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. +The other responsibility 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. +An API call is a way for the client to communicate 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. +After that it confirms that it got all information in the request that it needed. +The server will then process the client request. +Finally it generates a response, 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. +Each existing route that can be called 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. +When the server receives an API call it will first check that the call is authorized. 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. +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 in order 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. +The needed authorization 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 that have joined competitions with specific views. +If the route is not decorated everyone is allowed to access it, and the only routes currently like that is, by necessity, logging in as a user and joining a competition. + +#### JSON Web Tokens (JWT) + +JSON Web Tokens (JWT) are used for authentication, both for API and socket events. +A JWT is created on the server when a user logs in or connects to a competition. +Some information is stored in the JWT, which can be seen in the file `server/app/apis/auth.py`. +The JWT is also encrypted using the secret key defined in `server/configmodule.py`. +(NOTE: Change this key before running the server in production). +The client can read the contents of the JWT but cannot modify them because it doesn't have access to the secret key. +This is why the server can simply read the contents of the JWT to be sure that the client is who it says it is. ### Parsing request -After the request is authorized the server will need to parse contents of the request. +After the request is authorized the server will need to parse the 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. +Each API call expects different parameters in different places and this is specified 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. +After the request has been authorized and parsed the server will process the request. +What it does depends on the route and the 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. +When the server har processed 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. +This conversion is specified in two files in the folder `app/core/`. +The file `schemas.py` 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. +In this way, for example, an entire competition with its 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. +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. +Sent events are also authorized via JWT, basically the same way as the for the API calls. +But for socket events, the decorator that is used to authenticate them is `@authorize_user()`. +Whenever a client joins a competition they will connect via sockets. +A single competition cannot be active more than once at the same time. +This means that you will need to make a copy of a competition if you want to run the same competition at several locations at the same time. All of the functionality related to an active competition and sockets can be found in the file `app/core/sockets.py`. +The terms *active competition* and *presentation* are equivalent. ### 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. +Whenever a code is typed in to the client it 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 the JWT it will need to use to authenticate itself. +If there is such a code and the associated competition is active, the client will also receive a JWT for its corresponding role. +Both of these cases will be handled by the default `connect` event, using the JWT received from the API call. +The server can see what is stored in the JWT and do different things depending on its contents. ### 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. +[comment]: # (What does `sync` mean? It isn't explained) + +The operator will emit the `sync` event and provide either a slide or a timer to update it on the server. +The server will then send `sync` to all connected clients with the updated values, regardless of what was actually updated. +The server will also store the timer and active slide in order to `sync` clients when they join. +The operator can also emit `end_presentation` to disconnect all clients from its competitions. +This will also end the presentation. diff --git a/docs/source/testing.rst b/docs/source/testing.rst index 3ef832a7452e3a25cb873888e4d87988b229cacd..d154d2268db1209a556fda67e8cb7d6b3def90da 100644 --- a/docs/source/testing.rst +++ b/docs/source/testing.rst @@ -3,7 +3,7 @@ Testing Here we briefly describe how we have tested the system. Both unit tests for the client and server has been made. -Some end to end tests has also been made that tests both the server and client at the same time. +Some end-to-end tests have also been made that tests both the server and client at the same time. .. toctree:: :maxdepth: 2 diff --git a/docs/source/testing/client.md b/docs/source/testing/client.md index ad39ab88e4f2505e65f853066a40952746a76f6b..b0201a485b067be1dd57a7ca0fd48712d543cf31 100644 --- a/docs/source/testing/client.md +++ b/docs/source/testing/client.md @@ -1 +1,3 @@ # Testing the client + +[comment]: # (TODO) \ No newline at end of file diff --git a/docs/source/testing/e2e.md b/docs/source/testing/e2e.md index 25670eae0edc7bff52aacbe38ad290c16a413eef..62d7038664f24151e4fcc1a45849f337e9636770 100644 --- a/docs/source/testing/e2e.md +++ b/docs/source/testing/e2e.md @@ -1 +1,3 @@ # End to end tests + +[comment]: # (TODO) \ No newline at end of file diff --git a/docs/source/testing/server.md b/docs/source/testing/server.md index 538918df23c65447bf01028e322f91c2e4c31c6d..e113982b2f503c97fefac60570f42598d2ae17d6 100644 --- a/docs/source/testing/server.md +++ b/docs/source/testing/server.md @@ -3,10 +3,10 @@ The Python testing framework used to test the server is [pytest](https://docs.pytest.org/). The server tests are located in the folder `./server/tests`. -The tests are further divided into files that test the database `test_db.py` and test the api `test_api.py`. +The tests are further divided into files that test the database (`test_db.py`) and test the api (`test_api.py`). The file `test_helpers.py` is used to store some common functionality between the tests, such as adding default values to the database. There are also some functions that makes using the api easier, such as the `get`, `post` and `delete` functions. -Run the tests by running the [VSCode task](../development/vscode.html#tasks) `Test server`. +Run the tests by running the [VS Code task](../development/vscode.html#tasks) `Test server`. After that you can see what has been tested by opening the server coverage using the task `Open server coverage`. diff --git a/docs/source/user_manual/admin.md b/docs/source/user_manual/admin.md index 19f87d82331824957c41fbf0cd3957958f197ce2..10ee20fd8b4d3a6cf5fe68026b35444bb0899e73 100644 --- a/docs/source/user_manual/admin.md +++ b/docs/source/user_manual/admin.md @@ -10,7 +10,7 @@ In the bottom left you will be able to logout by pressing the "Logga ut" button. ## Regions The regions tab will show all regions. -You will be able to create a new region by entering its name at the top and click the "+" button. +To create a new region, enter its name at the top and then click the "+" button.  @@ -18,23 +18,23 @@ You will be able to create a new region by entering its name at the top and clic The users tab will allow you to see all users, their name, region and role. You will also be able to create new users by clicking the "Ny användare" button. -By click the three dots "..." you will be able to edit or delete that user. -You will also be able to search for and filter users by region or role. +By clicking the three dots "..." you will be able to edit or delete that user. +You will also be able to search for and filter users by their region or role.  ## Competitions The competitions tab will allow you to see all competitions, their name, region and year. -You will also be able to create new competitions by click the "Ny tävling" button and edit exisiting ones by clicking on their name. +You will also be able to create a new competition by clicking the "Ny tävling" button or edit existing ones by clicking on their name. By click on the three dots "..." you will be able to start, show the codes for, copy or delete that competition.  ### Competition codes -After pressing the three dots "..." for a competition and pressing "Visa koder", all the codes for that competition will be shown. +By pressing the three dots "..." for a competition and then pressing "Visa koder", all the codes for that competition will be shown. Here you will see what view each code is associated with and what the code is. -You will also be able to generate a new code, copy the code or copy a link to the code that will let other people join, or even host, a competition directly. +You will also be able to generate a new code, copy the code or copy a link to the code that will let others join, or even host, a competition directly.  diff --git a/docs/source/user_manual/editor.md b/docs/source/user_manual/editor.md index 7a9fd8a1652b8298c36768205f3c410c9e455970..7091cce61b74daabd5bbdc476d3dce7fc6186aa2 100644 --- a/docs/source/user_manual/editor.md +++ b/docs/source/user_manual/editor.md @@ -1,31 +1,35 @@ # Editor +[comment]: # 'Explain where to find the competition name. Perhaps an image or link to Admin?' + After clicking on a competition name you will enter the editor and will be able to edit it. -The Teknikåttan logo in the top left corner will take you back to the Admin page. -To the left you will see all slides, a newly created competition will have one empty default slide. +The Teknikåttan logo in the top left corner will take you back to the Admin page and right under that all slides are shown. +A newly created competition will have one empty default slide. Switch to a different slide by clicking on it. -In the bottom left corner you will be able to add a new slide. +In the bottom left corner you will be able to add a new slide using the "Ny sida" button. Delete or copy a slide simply by right clicking on it and choosing the appropriate option. -In the top right corner you will be able to change which view you are see and edit. +In the top right corner you will be able to change which view you see and edit. + + ## Competition settings To the right you will see the active tab "Tävling", which will show and let you edit everything about the entire competition. There you will be able to edit the competition name, add a new team and a background image. -The background image for the competition will be used for all slids in the competition. - - +The background image for the competition will be used for all slides in the competition. ## Slide settings If you choose the "Sida" tab, you will be able to edit the current slide. In the top right you can change the question type of the current slide. For all question types you will be able to add a timer for how long the teams have to answer that question. -Depending on which type you choose you will have different options below. +Depending on which type you choose, you will have different options below. For this example we will choose multiple choice ("Kryssfråga"). -For this question type you will have the option to add a title to the question and how much score a correct answer on the question will give. +For this question type you will have the option to add a title to the question and how much many points a correct answer yields. For this question type you will also be able to add alternatives, which the teams will be able to choose between during a competition. Below that you will be able to add and remove text and image components as well as a background image. -The background image for the competition can be overridden by explicitly setting one on a specific page. +The background image for the competition can be overridden by explicitly setting it on a specific page.  + +[comment]: # 'Perhaps mention right clicking a component to make a copy to another view?' diff --git a/docs/source/user_manual/login.md b/docs/source/user_manual/login.md index b019bae9e0f93e2a8fcabb5cea35856e5c3735c8..ec0424b20d9884651e7cde044d29442245bd0105 100644 --- a/docs/source/user_manual/login.md +++ b/docs/source/user_manual/login.md @@ -4,7 +4,7 @@ The login page will let you either login as a user or join a competition with a ## User -The first page you will be presented with when acessing the site is the login page. +The first page you will be presented with when accessing the site is the login page. From here you can login with your account by typing your email and password in their respective fields and pressing the "Logga in" button.  @@ -15,3 +15,5 @@ You can also choose the "Tävling" tab. Here you can enter your six character long code and by pressing the "Anslut till tävling" button you will be able to join a competition.  + +These codes can be accessed from [Admin](admin.md). diff --git a/docs/source/user_manual/presentation.md b/docs/source/user_manual/presentation.md index e8f8b6e186ee65d8726f685cf6c3bd3e4d97eea5..2bb125a59fafad25d5787524a4212e4377b0e789 100644 --- a/docs/source/user_manual/presentation.md +++ b/docs/source/user_manual/presentation.md @@ -1,26 +1,32 @@ +[comment]: # "Why is this file named 'presentation' but the main headline is 'Active competitions'?" + # Active competitions There are many different views during a competition. -Below we describe how to start a competition, how to join a competition and how the different kinds of views work. +Below it is described how to start a competition, how to join a competition, and how the different kinds of views work. + +## Competition codes + +You can join a competition with codes. +This can either be done by pasting the link that can be copied when listing the codes or can be typed by hand in the login page. +All the views have different purposes and therefore looks a little bit different from one another. ## Operator -There is two ways to start a competition. +There are two ways to start a competition. The first way is to navigate to the competition manager, press the three dots "..." and press "Starta". You will then enter the operator view. -From here you will be able to go bewteen slides with the "<" and ">" buttons or start the timer, both will be synced between all clients connected to that competition. +From there you will be able to go between slides with the "<" and ">" buttons or start the timer, both will be synced between all clients connected to that competition. You will also be able to view the scores for the teams and view all codes to the competition.  ## Team -You can also join the competition with codes. -This can either be done by pasting the link that can be copied when listing the codes or can be typed by hand on the login page. -All the views have a different purpose and therefore looks a little bit different. +[comment]: # 'What is meant with "(or the code for one of the teams)"? Doesnt a team have to log in using a code?' The team view (or the code for one of the teams) will be used by teams. -It shows the current slide and allows the user to answer questions on the slide that will be saved. +It shows the current slide (that the operator has decided) and allows the user to answer questions on the slide that will be saved.  @@ -32,9 +38,11 @@ The audience view will look like the operator view but without the buttons. ## Judge +[comment]: # 'Update image to show that the current slide is highlighted.' + The judge view will show show the same slide as team view. -To the left you will be able to move between different slides without affecting the other clients. -To the right you will see what the teams have answered on every question, how much score each team got on each question, how much they have in total and be able to set the score of a team on the current question. -In the bottom right you will see instructions for how to correct the current question. +To the left you will be able to move between different slides without affecting the other clients and will be shown och which slide the operator currently is. +To the right you will see what the teams have answered on every question, what score each team got on each question, their total score and be able to set the score of a team on any and all questions. +In the bottom right you will see instructions for how to grade the current question.  diff --git a/server/README.md b/server/README.md index 84d994d85e475930cd49d6a8adb0a6372289d302..f03f602134fd4db905a7ec7256ead676adf94e10 100644 --- a/server/README.md +++ b/server/README.md @@ -6,10 +6,10 @@ This document describes how to install and run the server. You will need to do the following things to install the server: -1. Install [Visual Studio Code](https://code.visualstudio.com/) (VSCode). +1. Install [Visual Studio Code](https://code.visualstudio.com/) (VS Code). 2. Install [Python](https://www.python.org/downloads/). 3. Clone this repository if you haven't done so already. -4. Open the project folder in VSCode. +4. Open the project folder in VS Code. 5. Open the integrated terminal by pressing `ctrl+ö`. 6. Type the following commands into your terminal: diff --git a/server/app/apis/alternatives.py b/server/app/apis/alternatives.py index 1827b358769441fd5d4748fabacf4221573c3346..d3ae5d9d4be9f3a5835c4bb4c6f00ee5c38aa34d 100644 --- a/server/app/apis/alternatives.py +++ b/server/app/apis/alternatives.py @@ -3,11 +3,14 @@ All API calls concerning question alternatives. Default route: /api/competitions/<competition_id>/slides/<slide_id>/questions/<question_id>/alternatives """ +from os import abort + 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 QuestionAlternativeDTO from app.core.parsers import sentinel +from app.database.models import Question, QuestionAlternative from flask_restx import Resource, reqparse api = QuestionAlternativeDTO.api @@ -15,12 +18,14 @@ schema = QuestionAlternativeDTO.schema list_schema = QuestionAlternativeDTO.list_schema alternative_parser_add = reqparse.RequestParser() -alternative_parser_add.add_argument("text", type=str, required=True, location="json") -alternative_parser_add.add_argument("value", type=int, required=True, location="json") +alternative_parser_add.add_argument("alternative", type=str, default="", location="json") +alternative_parser_add.add_argument("correct", type=str, default="", location="json") alternative_parser_edit = reqparse.RequestParser() -alternative_parser_edit.add_argument("text", type=str, default=sentinel, location="json") -alternative_parser_edit.add_argument("value", type=int, default=sentinel, location="json") +alternative_parser_edit.add_argument("alternative", type=str, default=sentinel, location="json") +alternative_parser_edit.add_argument("alternative_order", type=int, default=sentinel, location="json") +alternative_parser_edit.add_argument("correct", type=str, default=sentinel, location="json") +alternative_parser_edit.add_argument("correct_order", type=int, default=sentinel, location="json") @api.route("") @@ -77,6 +82,25 @@ class QuestionAlternatives(Resource): question_id, alternative_id, ) + + new_alternative_order = args.pop("alternative_order") + if new_alternative_order is not sentinel and item.alternative_order != new_alternative_order: + if not (0 <= new_alternative_order < dbc.utils.count(QuestionAlternative, {"question_id": question_id})): + abort(codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_alternative_order}'") + + item_question = dbc.get.one(Question, question_id) + dbc.utils.move_order( + item_question.alternatives, "alternative_order", item.alternative_order, new_alternative_order + ) + + new_correct_order = args.pop("correct_order") + if new_correct_order is not sentinel and item.correct_order != new_correct_order: + if not (0 <= new_correct_order < dbc.utils.count(QuestionAlternative, {"question_id": question_id})): + abort(codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_correct_order}'") + + item_question = dbc.get.one(Question, question_id) + dbc.utils.move_order(item_question.alternatives, "correct_order", item.correct_order, new_correct_order) + item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index 7f322d5231f063eecd287248928d85f945469a25..654ee49f6ce02d8e56d6d5529fcbbcf0c0b12af2 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -8,8 +8,7 @@ import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import SlideDTO from app.core.parsers import sentinel -from app.database.controller.get import slide_count -from app.database.models import Competition +from app.database.models import Competition, Slide from flask_restx import Resource, reqparse from flask_restx.errors import abort @@ -21,6 +20,7 @@ slide_parser_edit = reqparse.RequestParser() slide_parser_edit.add_argument("order", type=int, default=sentinel, location="json") slide_parser_edit.add_argument("title", type=str, default=sentinel, location="json") slide_parser_edit.add_argument("timer", type=int, default=sentinel, location="json") +slide_parser_edit.add_argument("order", type=int, default=sentinel, location="json") slide_parser_edit.add_argument("background_image_id", default=sentinel, type=int, location="json") @@ -59,6 +59,15 @@ class Slides(Resource): args = slide_parser_edit.parse_args(strict=True) item_slide = dbc.get.slide(competition_id, slide_id) + + new_order = args.pop("order") + if new_order is not sentinel and item_slide.order != new_order: + if not (0 <= new_order < dbc.utils.count(Slide, {"competition_id": competition_id})): + abort(codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_order}'") + + item_competition = dbc.get.one(Competition, competition_id) + dbc.utils.move_order(item_competition.slides, "order", item_slide.order, new_order) + item_slide = dbc.edit.default(item_slide, **args) return item_response(schema.dump(item_slide)) @@ -73,29 +82,6 @@ class Slides(Resource): return {}, codes.NO_CONTENT -@api.route("/<slide_id>/order") -@api.param("competition_id, slide_id") -class SlideOrder(Resource): - @protect_route(allowed_roles=["*"]) - def put(self, competition_id, slide_id): - """ Edits the specified slide order using the provided arguments. """ - - args = slide_parser_edit.parse_args(strict=True) - new_order = args.get("order") - - item_slide = dbc.get.slide(competition_id, slide_id) - - if new_order == item_slide.order: - return item_response(schema.dump(item_slide)) - - if not (0 <= new_order < dbc.get.slide_count(competition_id)): - abort(codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_order}'") - - item_competition = dbc.get.one(Competition, competition_id) - dbc.utils.move_slides(item_competition, item_slide.order, new_order) - return item_response(schema.dump(item_slide)) - - @api.route("/<slide_id>/copy") @api.param("competition_id,slide_id") class SlideCopy(Resource): diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 64d9ca189293602fd70f8f05e70d96c0f9556721..b5206d59f81cc5f700330da65da1d4e5284c2a1c 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -90,8 +90,10 @@ class QuestionAlternativeSchema(BaseSchema): model = models.QuestionAlternative id = ma.auto_field() - text = ma.auto_field() - value = ma.auto_field() + alternative = ma.auto_field() + alternative_order = ma.auto_field() + correct = ma.auto_field() + correct_order = ma.auto_field() question_id = ma.auto_field() diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index cba076e267483fa4fc5c5ae4d5c71b7d7b6946d0..c62cfefc5cedec583dd06fc422ac7c10cd46e3c2 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -4,9 +4,9 @@ This file contains functionality to add data to the database. import os +import app.database.controller as dbc from app.apis import http_codes from app.core import db -from app.database.controller import get, utils from app.database.models import ( Blacklist, City, @@ -76,7 +76,7 @@ def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, copy=False, * item.text = data.get("text") elif type_id == IMAGE_COMPONENT_ID: if not copy: # Scale image if adding a new one, a copied image should keep it's size - item_image = get.one(Media, data["media_id"]) + item_image = dbc.get.one(Media, data["media_id"]) filename = item_image.filename path = os.path.join( current_app.config["UPLOADED_PHOTOS_DEST"], @@ -106,14 +106,14 @@ def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, copy=False, * pass # abort(codes.BAD_REQUEST, f"Invalid type_id{type_id}") - item = utils.commit_and_refresh(item) + item = dbc.utils.commit_and_refresh(item) return item def code(view_type_id, competition_id=None, team_id=None): """ Adds a code to the database using the provided arguments. """ - code_string = utils.generate_unique_code() + code_string = dbc.utils.generate_unique_code() return db_add(Code(code_string, view_type_id, competition_id, team_id)) @@ -128,33 +128,16 @@ def team(name, competition_id): return item -def slide(competition_id): +def slide(competition_id, order=None): """ Adds a slide to the provided competition. """ - - # Get the last order from given competition - order = Slide.query.filter(Slide.competition_id == competition_id).count() - - # Add slide - item_slide = db_add(Slide(order, competition_id)) - - # Add default question - question(f"Fråga {item_slide.order + 1}", 10, 1, item_slide.id) - - item_slide = utils.refresh(item_slide) - return item_slide - - -def slide_without_question(competition_id): - """ Adds a slide to the provided competition. """ - - # Get the last order from given competition - order = Slide.query.filter(Slide.competition_id == competition_id).count() + if not order: + # Get the last order from given competition + order = dbc.utils.count(Slide, {"competition_id": competition_id}) # Add slide item_slide = db_add(Slide(order, competition_id)) - item_slide = utils.refresh(item_slide) - return item_slide + return dbc.utils.refresh(item_slide) def competition(name, year, city_id): @@ -176,7 +159,7 @@ def competition(name, year, city_id): # Add code for Operator view code(4, item_competition.id) - item_competition = utils.refresh(item_competition) + item_competition = dbc.utils.refresh(item_competition) return item_competition @@ -199,7 +182,7 @@ def _competition_no_slides(name, year, city_id, font=None): # Add code for Operator view code(4, item_competition.id) - item_competition = utils.refresh(item_competition) + item_competition = dbc.utils.refresh(item_competition) return item_competition @@ -273,13 +256,14 @@ def question(name, total_score, type_id, slide_id, correcting_instructions=None) return db_add(Question(name, total_score, type_id, slide_id, correcting_instructions)) -def question_alternative(text, value, question_id): +def question_alternative(alternative, correct, question_id): """ Adds a question alternative to the specified question using the provided arguments. """ - return db_add(QuestionAlternative(text, value, question_id)) + order = dbc.utils.count(QuestionAlternative, {"question_id": question_id}) + return db_add(QuestionAlternative(alternative, order, correct, order, question_id)) def question_score(score, question_id, team_id): diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index d19f6bd362ffc00066c12bc196e24a7d7d5ee5b8..d64d8a5bb43b91e3eff9935df74ed6d3a48901fe 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -7,12 +7,12 @@ from app.database.models import Question from app.database.types import IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, TEXT_COMPONENT_ID -def _alternative(item_old, question_id): +def _alternative(item_alternative_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) + return add.question_alternative(item_alternative_old.alternative, item_alternative_old.correct, question_id) def _question(item_question_old, slide_id): @@ -90,7 +90,7 @@ def slide_to_competition(item_slide_old, item_competition): Does not copy team, question answers. """ - item_slide_new = add.slide_without_question(item_competition.id) + item_slide_new = add.slide(item_competition.id, item_slide_old.order) # Copy all fields item_slide_new.title = item_slide_old.title diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py index bd5d668ff10fd96ab8392d28f2f1d5306d73acc0..e620b0593d7687e132c8e01f07ab9c42601dd19f 100644 --- a/server/app/database/controller/get.py +++ b/server/app/database/controller/get.py @@ -2,6 +2,7 @@ This file contains functionality to get data from the database. """ +import app.database.controller as dbc from app.apis import http_codes from app.core import db from app.database.models import ( @@ -57,7 +58,7 @@ def code_list(competition_id): def user_exists(email): """ Checks if an user has that email. """ - return User.query.filter(User.email == email).count() > 0 + return dbc.utils.count(User, {"email": email}) > 0 def user_by_email(email): @@ -89,12 +90,6 @@ def slide_list(competition_id): return Slide.query.join(Competition, join_competition).filter(filters).all() -def slide_count(competition_id): - """ Gets the number of slides in the provided competition. """ - - return Slide.query.filter(Slide.competition_id == competition_id).count() - - ### Teams ### def team(competition_id, team_id): """ Gets the team object associated with the competition and team. """ diff --git a/server/app/database/controller/utils.py b/server/app/database/controller/utils.py index 0c46374fb81ce5b80c24b5a3238524d3b64f73ef..b4add70ecf26c165e5bc799b8e472303f58c6977 100644 --- a/server/app/database/controller/utils.py +++ b/server/app/database/controller/utils.py @@ -9,14 +9,15 @@ from app.database.models import Code # from flask_restx import abort -def move_slides(item_competition, from_order, to_order): +def move_order(orders, order_key, from_order, to_order): """ - Move slide from from_order to to_order in item_competition. + Move key from from_order to to_order in db_item. See examples in + alternatives.py and slides.py. """ - num_slides = len(item_competition.slides) - assert 0 <= from_order < num_slides, "Invalid order to move from" - assert 0 <= to_order < num_slides, "Invalid order to move to" + num_orders = len(orders) + assert 0 <= from_order < num_orders, "Invalid order to move from" + assert 0 <= to_order < num_orders, "Invalid order to move to" # This function is sooo terrible, someone please tell me how to update # multiple values in the database at the same time with unique constraints. @@ -26,61 +27,75 @@ def move_slides(item_competition, from_order, to_order): # so 2 commits. # An example will follow the entire code to make it clear what it does - # Lets say we have 5 slides, and we want to move the slide at index 1 + # Lets say we have 5 orders, and we want to move the item at index 1 # to index 4. - # We begin with a list of slides with orders [0, 1, 2, 3, 4] - - slides = item_competition.slides + # We begin with a list of item with orders [0, 1, 2, 3, 4] change = 1 if to_order < from_order else -1 start_order = min(from_order, to_order) end_order = max(from_order, to_order) - # Move slides up 100 - for item_slide in slides: - item_slide.order += 100 + # Move orders up 100 + for item_with_order in orders: + setattr(item_with_order, order_key, getattr(item_with_order, order_key) + 100) - # Our slide orders now look like [100, 101, 102, 103, 104] + # Our items now look like [100, 101, 102, 103, 104] - # Move slides between from and to order either up or down, but minus in front - for item_slide in slides: - if start_order <= item_slide.order - 100 <= end_order: - item_slide.order = -(item_slide.order + change) + # Move orders between from and to order either up or down, but minus in front + for item_with_order in orders: + if start_order <= getattr(item_with_order, order_key) - 100 <= end_order: + setattr(item_with_order, order_key, -(getattr(item_with_order, order_key) + change)) - # Our slide orders now look like [100, -100, -101, -102, -103] + # Our items now look like [100, -100, -101, -102, -103] - # Find the slide that was to be moved and change it to correct order with minus in front - for item_slide in slides: - if item_slide.order == -(from_order + change + 100): - item_slide.order = -(to_order + 100) + # Find the item that was to be moved and change it to correct order with minus in front + for item_with_order in orders: + if getattr(item_with_order, order_key) == -(from_order + change + 100): + setattr(item_with_order, order_key, -(to_order + 100)) break - # Our slide orders now look like [100, -104, -101, -102, -103] + # Our items now look like [100, -104, -101, -102, -103] db.session.commit() # Negate all order so that they become positive - for item_slide in slides: - if start_order <= -(item_slide.order + 100) <= end_order: - item_slide.order = -(item_slide.order) + for item_with_order in orders: + if start_order <= -(getattr(item_with_order, order_key) + 100) <= end_order: + setattr(item_with_order, order_key, -getattr(item_with_order, order_key)) - # Our slide orders now look like [100, 104, 101, 102, 103] + # Our items now look like [100, 104, 101, 102, 103] - for item_slide in slides: - item_slide.order -= 100 + for item_with_order in orders: + setattr(item_with_order, order_key, getattr(item_with_order, order_key) - 100) - # Our slide orders now look like [0, 4, 1, 2, 3] + # Our items now look like [0, 4, 1, 2, 3] - # We have now successfully moved slide 1 to 4 + # We have now successfully moved item from order 1 to order 4 - return commit_and_refresh(item_competition) + db.session.commit() + + +def count(db_type, filter=None): + """ + Count number of db_type items that match all keys and values in filter. + + >>> count(User, {"city_id": 1}) # Get number of users with city_id equal to 1 + 5 + """ + + filter = filter or {} + query = db_type.query + for key, value in filter.items(): + query = query.filter(getattr(db_type, key) == value) + return query.count() def generate_unique_code(): """ Generates a unique competition code. """ code = generate_code_string() - while db.session.query(Code).filter(Code.code == code).count(): + + while count(Code, {"code": code}): code = generate_code_string() return code diff --git a/server/app/database/models.py b/server/app/database/models.py index f46e1cd1b7d92751ffe45f2ea8e49f4a272bc2f3..7371bc773c9e66ef2c89be55e23e2501a43e11b0 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -144,8 +144,9 @@ class Competition(db.Model): Depend on table: Media, City. """ + __table_args__ = (db.UniqueConstraint("name", "year", "city_id"),) id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(STRING_SIZE), unique=True) + name = db.Column(db.String(STRING_SIZE), nullable=False) year = db.Column(db.Integer, nullable=False, default=2020) font = db.Column(db.String(STRING_SIZE), nullable=False) city_id = db.Column(db.Integer, db.ForeignKey("city.id"), nullable=False) @@ -242,14 +243,23 @@ class QuestionAlternative(db.Model): Depend on table: Question. """ + __table_args__ = ( + db.UniqueConstraint("question_id", "alternative_order"), + db.UniqueConstraint("question_id", "correct_order"), + ) + id = db.Column(db.Integer, primary_key=True) - text = db.Column(db.String(STRING_SIZE), nullable=False) - value = db.Column(db.Integer, nullable=False) + alternative = db.Column(db.String(STRING_SIZE), nullable=False) + alternative_order = db.Column(db.Integer) + correct = db.Column(db.String(STRING_SIZE), nullable=False) + correct_order = db.Column(db.Integer) question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=False) - def __init__(self, text, value, question_id): - self.text = text - self.value = value + def __init__(self, alternative, alternative_order, correct, correct_order, question_id): + self.alternative = alternative + self.alternative_order = alternative_order + self.correct = correct + self.correct_order = correct_order self.question_id = question_id diff --git a/server/populate.py b/server/populate.py index 34b053477c51763828f6b3610e3c7e43f27c0f36..71aba44a517e05cfcc7bacbaeb0b3ab445cd976f 100644 --- a/server/populate.py +++ b/server/populate.py @@ -11,12 +11,22 @@ from app.database.models import City, QuestionType, Role def create_default_items(): media_types = ["Image", "Video"] - question_types = ["Text", "Practical", "Multiple", "Single"] + question_types = ["Text", "Practical", "Multiple", "Single", "Match"] component_types = ["Text", "Image", "Question"] view_types = ["Team", "Judge", "Audience", "Operator"] roles = ["Admin", "Editor"] - cities = ["Linköping", "Stockholm", "Norrköping", "Örkelljunga"] + cities = [ + "Linköping", + "Stockholm", + "Norrköping", + "Örkelljunga", + "Västerås", + "Falun", + "Sundsvall", + "Göteborg", + "Malmö", + ] teams = ["Högstadie A", "Högstadie B", "Högstadie C"] for name in media_types: @@ -46,11 +56,27 @@ def create_default_items(): 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") + dbc.add.user("sven@test.se", "password", editor_id, 1, "Sven Mattson") + dbc.add.user("erika@test.se", "password", editor_id, 2, "Erika Malmberg") + dbc.add.user("anette@test.se", "password", editor_id, 3, "Anette Frisk") + dbc.add.user("emil@test.se", "password", editor_id, 4, "Emil Svensson") + dbc.add.user("david@test.se", "password", editor_id, 5, "David Ek") + + dbc.add.competition(f"Regionfinal", 2012, 1) + dbc.add.competition(f"Regionfinal", 2012, 2) + dbc.add.competition(f"Regionfinal", 2012, 3) + dbc.add.competition(f"Regionfinal", 2012, 4) + dbc.add.competition(f"Regionfinal", 2012, 5) + dbc.add.competition(f"Rikssemifinal", 2012, 6) + dbc.add.competition(f"Rikssemifinal", 2012, 7) + dbc.add.competition(f"Rikssemifinal", 2012, 8) + dbc.add.competition(f"Riksfinal", 2012, 9) + question_types_items = dbc.get.all(QuestionType) # Add competitions for i in range(len(question_types_items)): - item_comp = dbc.add.competition(f"Tävling {i}", 2000 + i, city_id) + item_comp = dbc.add.competition(f"Tävling {i}", 3000 + i, city_id) dbc.edit.default(item_comp.slides[0], timer=5, title="test-slide-title") # Add two more slides to competition @@ -67,17 +93,16 @@ def create_default_items(): dbc.utils.commit_and_refresh(item_slide) # Add question to competition - """ + item_question = dbc.add.question( name=f"Question {j}: {question_types_items[j].name}", total_score=j, type_id=question_types_items[j].id, slide_id=item_slide.id, ) - """ for k in range(3): - dbc.add.question_alternative(f"Alternative {k}", 0, item_slide.questions[0].id) + dbc.add.question_alternative(f"Alternative {k}", f"Correct {k}", item_question.id) # Add text components # TODO: Add images as components diff --git a/server/tests/test_app.py b/server/tests/test_app.py index f95460487440d754a390d14a482bbae9fc9367d6..eb87b111aeed98a6a4d9e706febdc882b7d38c3c 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -397,14 +397,14 @@ def test_question_api(client): CID = 1 # TODO: Fix api-calls so that the ones not using CID don't require one slide_order = 1 response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) - assert response.status_code == http_codes.OK - assert body["count"] == 2 + assert response.status_code == codes.OK + assert body["count"] == 0 # Get questions from another competition that should have some questions CID = 3 - num_questions = 3 response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) - assert response.status_code == http_codes.OK + num_questions = 3 + assert response.status_code == codes.OK assert body["count"] == num_questions # Add question @@ -417,10 +417,10 @@ def test_question_api(client): {"name": name, "type_id": type_id}, headers=headers, ) - num_questions = 4 - assert response.status_code == http_codes.OK + assert response.status_code == codes.OK assert item_question["name"] == name assert item_question["type_id"] == type_id + num_questions += 1 # Checks number of questions response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) diff --git a/server/tests/test_db.py b/server/tests/test_db.py index 0e0287463509c564eb681c4a8bbebee8f834c03e..82a75a3f6f8916efefed99de346e22b91e1b0f54 100644 --- a/server/tests/test_db.py +++ b/server/tests/test_db.py @@ -78,7 +78,7 @@ def test_media(client): def test_copy(client): add_default_values() - # Fetches an empty competition + # Fetches a competition list_item_competitions, _ = dbc.search.competition(name="Tävling 1") item_competition_original = list_item_competitions[0] @@ -86,13 +86,22 @@ def test_copy(client): num_slides = 3 item_slides, total = dbc.search.slide(competition_id=item_competition_original.id) assert total == num_slides - item_slide_original = item_slides[0] + item_slide_original = item_slides[1] + + dbc.delete.slide(item_slides[0]) + num_slides -= 1 # Inserts several copies of the same slide num_copies = 3 for _ in range(num_copies): + # Slide must contain some of these things to copy + assert len(item_slide_original.components) > 0 + assert len(item_slide_original.questions) > 0 + assert len(item_slide_original.questions[0].alternatives) > 0 + item_slide_copy = dbc.copy.slide(item_slide_original) num_slides += 1 + check_slides_copy(item_slide_original, item_slide_copy, num_slides, num_slides - 1) assert item_slide_copy.competition_id == item_slide_original.competition_id @@ -100,6 +109,7 @@ def test_copy(client): num_copies = 3 for _ in range(num_copies): item_competition_copy = dbc.copy.competition(item_competition_original) + assert len(item_competition_copy.slides) > 0 for order, item_slide in enumerate(item_competition_copy.slides): item_slide_original = item_competition_original.slides[order] check_slides_copy(item_slide_original, item_slide, num_slides, order) @@ -126,8 +136,11 @@ def test_copy(client): def check_slides_copy(item_slide_original, item_slide_copy, num_slides, order): - """ Checks that two slides are correct copies of each other. Looks big but is quite fast. """ - assert item_slide_copy.order == order # 0 indexing + """ + Checks that two slides are correct copies of each other. + This function looks big but is quite fast. + """ + assert item_slide_copy.order == order assert item_slide_copy.title == item_slide_original.title assert item_slide_copy.body == item_slide_original.body assert item_slide_copy.timer == item_slide_original.timer @@ -151,6 +164,7 @@ def check_slides_copy(item_slide_original, item_slide_copy, num_slides, order): assert c1.text == c2.text elif c1.type_id == 2: assert c1.image_id == c2.image_id + # Checks that all questions were correctly copied questions = item_slide_original.questions questions_copy = item_slide_copy.questions @@ -170,10 +184,12 @@ def check_slides_copy(item_slide_original, item_slide_copy, num_slides, order): assert len(alternatives) == len(alternatives_copy) for a1, a2 in zip(alternatives, alternatives_copy): - assert a1.text == a2.text - assert a1.value == a2.value - assert a1.quesiton_id == q1.id - assert a2.quesiton_id == q2.id + assert a1.alternative == a2.alternative + assert a1.alternative_order == a2.alternative_order + assert a1.correct == a2.correct + assert a1.correct_order == a2.correct_order + assert a1.question_id == q1.id + assert a2.question_id == q2.id # Checks that the copy put the slide in the database item_slides, total = dbc.search.slide( @@ -193,21 +209,26 @@ def test_move_slides(client): dbc.add.slide(item_comp.id) # Move from beginning to end - item_comp = dbc.utils.move_slides(item_comp, 0, 9) + dbc.utils.move_order(item_comp.slides, "order", 0, 9) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [9, 0, 1, 2, 3, 4, 5, 6, 7, 8]) # Move from end to beginning - item_comp = dbc.utils.move_slides(item_comp, 9, 0) + dbc.utils.move_order(item_comp.slides, "order", 9, 0) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) # Move some things in the middle - item_comp = dbc.utils.move_slides(item_comp, 3, 7) + dbc.utils.move_order(item_comp.slides, "order", 3, 7) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [0, 1, 2, 7, 3, 4, 5, 6, 8, 9]) - item_comp = dbc.utils.move_slides(item_comp, 1, 5) + dbc.utils.move_order(item_comp.slides, "order", 1, 5) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [0, 5, 1, 7, 2, 3, 4, 6, 8, 9]) - item_comp = dbc.utils.move_slides(item_comp, 8, 2) + dbc.utils.move_order(item_comp.slides, "order", 8, 2) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [0, 6, 1, 8, 3, 4, 5, 7, 2, 9]) diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index b7f7bcd02989c151216ac9de6a0911a11d30f57c..fdcef127df249587a45ad8647c62731497cdb8ff 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -41,10 +41,7 @@ def add_default_values(): # Add competitions item_competition = dbc.add.competition("Tom tävling", 2012, item_city.id) - item_question = dbc.add.question("hej", 5, 1, item_competition.slides[0].id) - item_team1 = dbc.add.team("Hej lag 3", item_competition.id) - item_team2 = dbc.add.team("Hej lag 4", item_competition.id) db.session.add(Code("111111", 1, item_competition.id, item_team1.id)) # Team db.session.add(Code("222222", 2, item_competition.id)) # Judge @@ -70,7 +67,10 @@ def add_default_values(): dbc.utils.commit_and_refresh(item_slide) # Add question to competition - # dbc.add.question(name=f"Q{i+1}", total_score=i + 1, type_id=1, slide_id=item_slide.id) + item_question = dbc.add.question(name=f"Q{i+1}", total_score=i + 1, type_id=1, slide_id=item_slide.id) + + for k in range(3): + dbc.add.question_alternative(f"Alternative {k}", f"Correct {k}", item_question.id) # Add text component dbc.add.component(1, item_slide.id, 1, i, 2 * i, 3 * i, 4 * i, text="Text")