diff --git a/README.md b/README.md index 56ea97f2b1afc129c0221d36ce6978802a250d89..10a4debc16e5edc64dea58216139c2913884a0cd 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,3 @@ - - - - - - # Scoring system for Teknikåttan This is the scoring system for Teknikåttan! diff --git a/client/src/enum/ComponentTypes.ts b/client/src/enum/ComponentTypes.ts index c0aa738479a2c081ef0529bb6b4a317570b6841d..7ff75bd8d867762550dd9bcb6997038ac8d4b233 100644 --- a/client/src/enum/ComponentTypes.ts +++ b/client/src/enum/ComponentTypes.ts @@ -1,5 +1,5 @@ export enum ComponentTypes { Text = 1, Image, - QuestionAlternative, + Question, } diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 2734884b7783b662113dc00f61d112f8a58f5dd6..9bcd24c1b916ff925a9472dffd8b2fabd5395bfb 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -94,6 +94,16 @@ export interface TextComponent extends Component { font: string } -export interface QuestionAlternativeComponent extends Component { +export interface QuestionComponent extends Component { + id: number + x: number + y: number + w: number + h: number + slide_id: number + type_id: number + view_type_id: number + text: string + media: Media question_id: number } diff --git a/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd8e2fa85a8e2562595c85c7068b0073f744092a --- /dev/null +++ b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx @@ -0,0 +1,84 @@ +import { Card, Divider, ListItem, Typography } from '@material-ui/core' +import React from 'react' +import { useAppSelector } from '../../../hooks' +import AnswerMultiple from './answerComponents/AnswerMultiple' +import AnswerSingle from './answerComponents/AnswerSingle' +import AnswerText from './answerComponents/AnswerText' +import { Center } from './styled' + +type QuestionComponentProps = { + variant: 'editor' | 'presentation' +} + +const QuestionComponentDisplay = ({ variant }: QuestionComponentProps) => { + const activeSlide = useAppSelector((state) => { + if (variant === 'editor') + return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId) + return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.slide?.id) + }) + + const timer = activeSlide?.timer + const total_score = activeSlide?.questions[0].total_score + const questionName = activeSlide?.questions[0].name + + const questionTypeId = activeSlide?.questions[0].type_id + const questionTypeName = useAppSelector( + (state) => state.types.questionTypes.find((qType) => qType.id === questionTypeId)?.name + ) + + const getAlternatives = () => { + switch (questionTypeName) { + case 'Text': + if (activeSlide) { + return <AnswerText activeSlide={activeSlide} competitionId={activeSlide.competition_id.toString()} /> + } + return + + case 'Practical': + return + + case 'Multiple': + if (activeSlide) { + return ( + <AnswerMultiple + variant={variant} + activeSlide={activeSlide} + competitionId={activeSlide.competition_id.toString()} + /> + ) + } + return + + case 'Single': + if (activeSlide) { + return ( + <AnswerSingle + 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> + <Divider /> + {getAlternatives()} + </Card> + ) +} + +export default QuestionComponentDisplay diff --git a/client/src/pages/presentationEditor/components/RndComponent.tsx b/client/src/pages/presentationEditor/components/RndComponent.tsx index a134ee14c9ac268159b6ae0013350012e638dc65..4c09280042bd038fee1e3adc4b5bf3b7aef8ef6a 100644 --- a/client/src/pages/presentationEditor/components/RndComponent.tsx +++ b/client/src/pages/presentationEditor/components/RndComponent.tsx @@ -9,6 +9,7 @@ import { Component, ImageComponent, TextComponent } from '../../../interfaces/Ap import { Position, Size } from '../../../interfaces/Components' import { RemoveMenuItem } from '../../admin/styledComp' import ImageComponentDisplay from './ImageComponentDisplay' +import QuestionComponentDisplay from './QuestionComponentDisplay' import { HoverContainer } from './styled' import TextComponentDisplay from './TextComponentDisplay' //import NestedMenuItem from 'material-ui-nested-menu-item' @@ -126,6 +127,12 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => /> </HoverContainer> ) + case ComponentTypes.Question: + return ( + <HoverContainer hover={hover}> + <QuestionComponentDisplay variant="editor" /> + </HoverContainer> + ) default: break } diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx index aef4ca7c48d29bbfc47cac8f29b214ec6866f360..e6b60f290784627d9c5446428062f2e1042cacef 100644 --- a/client/src/pages/presentationEditor/components/SlideDisplay.tsx +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -77,15 +77,7 @@ const SlideDisplay = ({ variant, activeViewTypeId }: SlideDisplayProps) => { scale={scale} /> ) - return ( - <PresentationComponent - height={height} - width={width} - key={component.id} - component={component} - scale={scale} - /> - ) + return <PresentationComponent key={component.id} component={component} scale={scale} /> })} </SlideEditorPaper> </SlideEditorContainerRatio> diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index d48b4565712d043d52d3b1e93e3633ec6b927d38..029187f49e06fcffbb147a82fc7e754ec09e3a0f 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -1,18 +1,19 @@ /* This file compiles and renders the right hand slide settings bar, under the tab "SIDA". */ -import { Divider, List, ListItem, ListItemText, TextField, Typography } from '@material-ui/core' -import React, { useState } from 'react' +import { Divider } from '@material-ui/core' +import React from 'react' import { useParams } from 'react-router-dom' import { useAppSelector } from '../../../hooks' +import BackgroundImageSelect from './BackgroundImageSelect' +import Images from './slideSettingsComponents/Images' import Instructions from './slideSettingsComponents/Instructions' import MultipleChoiceAlternatives from './slideSettingsComponents/MultipleChoiceAlternatives' +import QuestionSettings from './slideSettingsComponents/QuestionSettings' +import SingleChoiceAlternatives from './slideSettingsComponents/SingleChoiceAlternatives' import SlideType from './slideSettingsComponents/SlideType' -import { Center, ImportedImage, SettingsList, PanelContainer } from './styled' -import Timer from './slideSettingsComponents/Timer' -import Images from './slideSettingsComponents/Images' import Texts from './slideSettingsComponents/Texts' -import QuestionSettings from './slideSettingsComponents/QuestionSettings' -import BackgroundImageSelect from './BackgroundImageSelect' +import Timer from './slideSettingsComponents/Timer' +import { PanelContainer, SettingsList } from './styled' interface CompetitionParams { competitionId: string @@ -36,19 +37,21 @@ const SlideSettings: React.FC = () => { </SettingsList> {activeSlide?.questions[0] && <QuestionSettings activeSlide={activeSlide} competitionId={competitionId} />} + { - // Choose answer alternatives depending on the slide type + // Choose answer alternatives, depending on the slide type } - {activeSlide?.questions[0]?.type_id === 1 && ( - <Instructions activeSlide={activeSlide} competitionId={competitionId} /> - )} - {activeSlide?.questions[0]?.type_id === 2 && ( + {(activeSlide?.questions[0]?.type_id === 1 || activeSlide?.questions[0]?.type_id === 2) && ( <Instructions activeSlide={activeSlide} competitionId={competitionId} /> )} {activeSlide?.questions[0]?.type_id === 3 && ( <MultipleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> )} + {activeSlide?.questions[0]?.type_id === 4 && ( + <SingleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> + )} + {activeSlide && ( <Texts activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> )} diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a2b1c59acf0277197d6126a7aba5b81a8799a37 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx @@ -0,0 +1,106 @@ +import { Checkbox, ListItem, ListItemText, Typography, withStyles } from '@material-ui/core' +import { CheckboxProps } from '@material-ui/core/Checkbox' +import { green, grey } from '@material-ui/core/colors' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center } from '../styled' + +type AnswerMultipleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerMultiple = ({ variant, activeSlide, competitionId }: AnswerMultipleProps) => { + 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 answer = team?.question_answers.find((answer) => answer.question_id === activeSlide?.questions[0].id) + + const decideChecked = (alternative: QuestionAlternative) => { + const teamAnswer = team?.question_answers.find((answer) => answer.answer === alternative.text)?.answer + if (alternative.text === teamAnswer) return true + else return false + } + + const updateAnswer = async (alternative: QuestionAlternative) => { + // TODO: fix. Make list of alternatives and delete & post instead of put to allow multiple boxes checked. + if (activeSlide) { + if (team?.question_answers.find((answer) => answer.question_id === activeSlide.questions[0].id)) { + if (answer?.answer === alternative.text) { + // Uncheck checkbox + deleteAnswer() + } else { + // Check another box + // TODO + } + } else { + // Check first checkbox + await axios + .post(`/api/competitions/${competitionId}/teams/${teamId}/answers`, { + answer: alternative.text, + score: 0, + question_id: activeSlide.questions[0].id, + }) + .then(() => { + if (variant === 'editor') { + dispatch(getEditorCompetition(competitionId)) + } else { + dispatch(getPresentationCompetition(competitionId)) + } + }) + .catch(console.log) + } + } + } + + const deleteAnswer = async () => { + await axios + .delete(`/api/competitions/${competitionId}/teams/${teamId}/answers`) // TODO: fix + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const GreenCheckbox = withStyles({ + root: { + color: grey[900], + '&$checked': { + color: green[600], + }, + }, + checked: {}, + })((props: CheckboxProps) => <Checkbox color="default" {...props} />) + + return ( + <div> + <ListItem divider> + <Center> + <ListItemText primary="Välj ett eller flera svar:" /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + { + //<GreenCheckbox checked={checkbox} onChange={(event) => updateAnswer(alt, event.target.checked)} /> + } + <GreenCheckbox checked={decideChecked(alt)} onChange={() => updateAnswer(alt)} /> + <Typography style={{ wordBreak: 'break-all' }}>{alt.text}</Typography> + </ListItem> + </div> + ))} + </div> + ) +} + +export default AnswerMultiple diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c2fad17395e9c602a7108a72301739064ab4533 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx @@ -0,0 +1,132 @@ +import { Checkbox, ListItem, ListItemText, Typography, withStyles } from '@material-ui/core' +import { CheckboxProps } from '@material-ui/core/Checkbox' +import { green, grey } from '@material-ui/core/colors' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonCheckedOutlined' +import RadioButtonUncheckedIcon from '@material-ui/icons/RadioButtonUncheckedOutlined' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +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' + +type AnswerSingleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerSingle = ({ variant, activeSlide, competitionId }: AnswerSingleProps) => { + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const team = useAppSelector((state) => { + if (variant === 'editor') return state.editor.competition.teams.find((team) => team.id === teamId) + return state.presentation.competition.teams.find((team) => team.id === teamId) + }) + const answerId = team?.question_answers.find((answer) => answer.question_id === activeSlide?.questions[0].id)?.id + + const decideChecked = (alternative: QuestionAlternative) => { + const teamAnswer = team?.question_answers.find((answer) => answer.answer === alternative.text)?.answer + if (teamAnswer) return true + else return false + } + + const updateAnswer = async (alternative: QuestionAlternative) => { + if (activeSlide) { + // TODO: ignore API calls when an answer is already checked + if (team?.question_answers[0]) { + await axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/${answerId}`, { + answer: alternative.text, + }) + .then(() => { + if (variant === 'editor') { + dispatch(getEditorCompetition(competitionId)) + } else { + dispatch(getPresentationCompetition(competitionId)) + } + }) + .catch(console.log) + } else { + await axios + .post(`/api/competitions/${competitionId}/teams/${teamId}/answers`, { + answer: alternative.text, + score: 0, + question_id: activeSlide.questions[0].id, + }) + .then(() => { + if (variant === 'editor') { + dispatch(getEditorCompetition(competitionId)) + } else { + dispatch(getPresentationCompetition(competitionId)) + } + }) + .catch(console.log) + } + } + } + + const deleteAnswer = async () => { + await axios + .delete(`/api/competitions/${competitionId}/teams/${teamId}/answers`) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const GreenCheckbox = withStyles({ + root: { + color: grey[900], + '&$checked': { + color: green[600], + }, + }, + checked: {}, + })((props: CheckboxProps) => <Checkbox color="default" {...props} />) + + const renderRadioButton = (alt: QuestionAlternative) => { + if (variant === 'presentation') { + if (decideChecked(alt)) { + return ( + <Clickable> + <RadioButtonCheckedIcon onClick={() => updateAnswer(alt)} /> + </Clickable> + ) + } else { + return ( + <Clickable> + <RadioButtonUncheckedIcon onClick={() => updateAnswer(alt)} /> + </Clickable> + ) + } + } else { + return <RadioButtonUncheckedIcon onClick={() => updateAnswer(alt)} /> + } + } + + return ( + <div> + <ListItem divider> + <Center> + <ListItemText primary="Välj ett svar:" /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + {renderRadioButton(alt)} + <Typography style={{ wordBreak: 'break-all' }}>{alt.text}</Typography> + </ListItem> + </div> + ))} + </div> + ) +} + +export default AnswerSingle diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e54b0dd2fe56f3ebf96b65fa8a05e6af4467da9f --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx @@ -0,0 +1,80 @@ +import { ListItem, ListItemText, TextField } from '@material-ui/core' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center } from '../styled' +import { AnswerTextFieldContainer } from './styled' + +type AnswerTextProps = { + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerText = ({ activeSlide, competitionId }: AnswerTextProps) => { + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + 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 answerId = team?.question_answers.find((answer) => answer.question_id === activeSlide?.questions[0].id)?.id + 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)) + } + + const updateAnswer = async (answer: string) => { + if (activeSlide && team) { + if (team?.question_answers.find((answer) => answer.question_id === activeSlide.questions[0].id)) { + await axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/${answerId}`, { + answer, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } else { + await axios + .post(`/api/competitions/${competitionId}/teams/${teamId}/answers`, { + answer, + score: 0, + question_id: activeSlide.questions[0].id, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + } + + return ( + <AnswerTextFieldContainer> + <ListItem divider> + <Center> + <ListItemText primary="Skriv ditt svar nedan" /> + </Center> + </ListItem> + <ListItem style={{ height: '100%' }}> + <TextField + disabled={team === undefined} + defaultValue={ + team?.question_answers.find((questionAnswer) => questionAnswer.id === answerId)?.answer || 'Svar...' + } + style={{ height: '100%' }} + variant="outlined" + fullWidth={true} + multiline + onChange={(event) => onAnswerChange(event.target.value)} + /> + </ListItem> + </AnswerTextFieldContainer> + ) +} + +export default AnswerText diff --git a/client/src/pages/presentationEditor/components/answerComponents/styled.tsx b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9140e3c2776f5bda3d2f317740bb4bbb67f657ac --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx @@ -0,0 +1,5 @@ +import styled from 'styled-components' + +export const AnswerTextFieldContainer = styled.div` + height: calc(100% - 90px); +` diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6e38c39fe443b5c1e78810cb5731690870f8f614 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx @@ -0,0 +1,131 @@ +import { ListItem, ListItemText } from '@material-ui/core' +import CloseIcon from '@material-ui/icons/Close' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonCheckedOutlined' +import RadioButtonUncheckedIcon from '@material-ui/icons/RadioButtonUncheckedOutlined' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AlternativeTextField, Center, Clickable, SettingsList } from '../styled' + +type SingleChoiceAlternativeProps = { + activeSlide: RichSlide + competitionId: string +} + +const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAlternativeProps) => { + const dispatch = useAppDispatch() + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + + 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 + if (previousCheckedAltId !== alternative.id) { + if (previousCheckedAltId) { + axios.put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${previousCheckedAltId}`, + { value: 0 } + ) + } + // Set new checked alternative + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, + { value: 1 } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + } + + const updateAlternativeText = async (alternative_id: number, newText: string) => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, + { text: newText } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const addAlternative = async () => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .post( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, + { text: '', value: 0 } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const handleCloseAnswerClick = async (alternative_id: number) => { + if (activeSlide && 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) + } + } + + const renderRadioButton = (alt: QuestionAlternative) => { + if (alt.value) return <RadioButtonCheckedIcon onClick={() => updateAlternativeValue(alt)} /> + else return <RadioButtonUncheckedIcon onClick={() => updateAlternativeValue(alt)} /> + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText + primary="Svarsalternativ" + secondary="(Fyll i cirkeln höger om textfältet för att markera korrekt svar)" + /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <AlternativeTextField + id="outlined-basic" + defaultValue={alt.text} + onChange={(event) => updateAlternativeText(alt.id, event.target.value)} + variant="outlined" + /> + <Clickable>{renderRadioButton(alt)}</Clickable> + <Clickable> + <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> + </Clickable> + </ListItem> + </div> + ))} + <ListItem button onClick={addAlternative}> + <Center> + <AddButton variant="button">Lägg till svarsalternativ</AddButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default SingleChoiceAlternatives diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx index bc251b91e092c05457db1b64bb8bbe566d76dd55..bef33d943883981ee62e9dc49dcd09902d907737 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx @@ -15,7 +15,7 @@ import { import axios from 'axios' import React, { useState } from 'react' import { getEditorCompetition } from '../../../../actions/editor' -import { useAppDispatch } from '../../../../hooks' +import { useAppDispatch, useAppSelector } from '../../../../hooks' import { RichSlide } from '../../../../interfaces/ApiRichModels' import { Center, FirstItem } from '../styled' @@ -30,6 +30,11 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { // For "slide type" dialog const [selectedSlideType, setSelectedSlideType] = useState(0) const [slideTypeDialog, setSlideTypeDialog] = useState(false) + const components = useAppSelector( + (state) => state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.components + ) + const questionComponentId = components?.find((qCompId) => qCompId.type_id === 3)?.id + const openSlideTypeDialog = (type_id: number) => { setSelectedSlideType(type_id) setSlideTypeDialog(true) @@ -41,6 +46,7 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { const updateSlideType = async () => { closeSlideTypeDialog() if (activeSlide) { + deleteQuestionComponent(questionComponentId) if (activeSlide.questions[0] && activeSlide.questions[0].type_id !== selectedSlideType) { if (selectedSlideType === 0) { // Change slide type from a question type to information @@ -67,6 +73,7 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(() => { dispatch(getEditorCompetition(competitionId)) + createQuestionComponent() }) .catch(console.log) } @@ -80,11 +87,38 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(() => { dispatch(getEditorCompetition(competitionId)) + createQuestionComponent() }) .catch(console.log) } } } + + const createQuestionComponent = () => { + axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/components`, { + x: 0, + y: 0, + w: 400, + h: 250, + type_id: 3, + view_type_id: 1, + question_id: activeSlide.questions[0].id, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const deleteQuestionComponent = (componentId: number | undefined) => { + if (componentId) { + axios + .delete(`/api/competitions/${competitionId}/slides/${activeSlide.id}/components/${componentId}`) + .catch(console.log) + } + } + return ( <FirstItem> <ListItem> @@ -108,7 +142,12 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { </MenuItem> <MenuItem value={3}> <Typography variant="button" onClick={() => openSlideTypeDialog(3)}> - Flervalsfråga + Kryssfråga + </Typography> + </MenuItem> + <MenuItem value={4}> + <Typography variant="button" onClick={() => openSlideTypeDialog(4)}> + Alternativfråga </Typography> </MenuItem> </Select> diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index 605972d2c4ee798388f9437f373cbabeab777d3c..31e40d51de8a4ecb45fbddf1cda9b7e96d4151f5 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -1,16 +1,4 @@ -import { - FormControl, - List, - Tab, - TextField, - Typography, - Button, - Card, - ListItem, - Select, - InputLabel, - ListItemText, -} from '@material-ui/core' +import { Button, Card, List, ListItemText, Tab, TextField, Typography } from '@material-ui/core' import styled from 'styled-components' export const SettingsTab = styled(Tab)` @@ -148,3 +136,7 @@ export const HoverContainer = styled.div<HoverContainerProps>` export const ImageNameText = styled(ListItemText)` word-break: break-all; ` + +export const QuestionComponent = styled.div` + outline-style: double; +` diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index fd3dd05b78403f3193e3eedd4543368fc7a92306..22d91ac3122a971786c840c6090bc58b44b6f5d0 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -27,6 +27,7 @@ import axios from 'axios' import React, { useEffect } from 'react' import { useHistory } from 'react-router-dom' import { useAppSelector } from '../../hooks' +import { RichTeam } from '../../interfaces/ApiRichModels' import { socketConnect, socketEndPresentation, @@ -204,12 +205,12 @@ const OperatorViewPage: React.FC = () => { return typeName } - const addScore = (id: number) => { - // Sums the scores for the teams. id must be id-1 because it starts at 1 - + const addScore = (team: RichTeam) => { + // Sums the scores for the teams. + let totalScore = 0 - for (let j = 0; j < teams[id - 1].question_answers.length; j++) { - totalScore = totalScore + teams[id - 1].question_answers[j].score + for (let j = 0; j < team.question_answers.length; j++) { + totalScore = totalScore + team.question_answers[j].score } return totalScore } @@ -373,7 +374,7 @@ const OperatorViewPage: React.FC = () => { {teams && teams.map((team) => ( <ListItem key={team.id}> - {team.name} score:{addScore(team.id)} + {team.name} score:{addScore(team)} </ListItem> ))} </List> diff --git a/client/src/pages/views/components/PresentationComponent.tsx b/client/src/pages/views/components/PresentationComponent.tsx index a41f7912469256a6e946522790f4f8203f8da60f..0a688dc1c1bec3373cbf7a4e782b57fdab58d411 100644 --- a/client/src/pages/views/components/PresentationComponent.tsx +++ b/client/src/pages/views/components/PresentationComponent.tsx @@ -1,21 +1,17 @@ -import { Typography } from '@material-ui/core' import React from 'react' import { Rnd } from 'react-rnd' import { ComponentTypes } from '../../../enum/ComponentTypes' -import { useAppSelector } from '../../../hooks' import { Component, ImageComponent, TextComponent } from '../../../interfaces/ApiModels' import ImageComponentDisplay from '../../presentationEditor/components/ImageComponentDisplay' +import QuestionComponentDisplay from '../../presentationEditor/components/QuestionComponentDisplay' import TextComponentDisplay from '../../presentationEditor/components/TextComponentDisplay' -import { SlideContainer } from './styled' type PresentationComponentProps = { component: Component - width: number - height: number scale: number } -const PresentationComponent = ({ component, width, height, scale }: PresentationComponentProps) => { +const PresentationComponent = ({ component, scale }: PresentationComponentProps) => { const renderInnerComponent = () => { switch (component.type_id) { case ComponentTypes.Text: @@ -28,6 +24,8 @@ const PresentationComponent = ({ component, width, height, scale }: Presentation component={component as ImageComponent} /> ) + case ComponentTypes.Question: + return <QuestionComponentDisplay variant="presentation" /> default: break } diff --git a/client/src/utils/renderSlideIcon.tsx b/client/src/utils/renderSlideIcon.tsx index ba1eafcb8052469dd8b627b95d2eee518bfd5fe8..22f6405abbc8574ae17bf86dd8b745ba98e3b4cf 100644 --- a/client/src/utils/renderSlideIcon.tsx +++ b/client/src/utils/renderSlideIcon.tsx @@ -1,9 +1,10 @@ -import { RichSlide } from '../interfaces/ApiRichModels' -import React from 'react' import BuildOutlinedIcon from '@material-ui/icons/BuildOutlined' +import CheckBoxOutlinedIcon from '@material-ui/icons/CheckBoxOutlined' import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' -import DnsOutlinedIcon from '@material-ui/icons/DnsOutlined' import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked' +import React from 'react' +import { RichSlide } from '../interfaces/ApiRichModels' export const renderSlideIcon = (slide: RichSlide) => { if (slide.questions && slide.questions[0] && slide.questions[0].type_id) { @@ -13,7 +14,9 @@ export const renderSlideIcon = (slide: RichSlide) => { case 2: return <BuildOutlinedIcon /> // practical qustion case 3: - return <DnsOutlinedIcon /> // multiple choice question + return <CheckBoxOutlinedIcon /> // multiple choice question + case 4: + return <RadioButtonCheckedIcon /> // single choice question } } else { return <InfoOutlinedIcon /> // information slide diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index 9a4b78d096e87b111d7072419aa844160eaec222..d399401e4b1cb8da094d28a118379173db02333f 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -3,7 +3,7 @@ All API calls concerning question answers. Default route: /api/auth """ -from datetime import timedelta +from datetime import datetime, timedelta import app.core.http_codes as codes import app.database.controller as dbc @@ -12,6 +12,7 @@ from app.core import sockets from app.core.codes import verify_code from app.core.dto import AuthDTO from app.database.models import Whitelist +from flask import current_app from flask_jwt_extended import create_access_token, get_jti, get_raw_jwt from flask_jwt_extended.utils import get_jti from flask_restx import Resource, inputs, reqparse @@ -31,6 +32,9 @@ create_user_parser.add_argument("role_id", type=int, required=True, location="js login_code_parser = reqparse.RequestParser() login_code_parser.add_argument("code", type=str, required=True, location="json") +USER_LOGIN_LOCKED_ATTEMPTS = current_app.config["USER_LOGIN_LOCKED_ATTEMPTS"] +USER_LOGIN_LOCKED_EXPIRES = current_app.config["USER_LOGIN_LOCKED_EXPIRES"] + def get_user_claims(item_user): return {"role": item_user.role.name, "city_id": item_user.city_id} @@ -59,9 +63,11 @@ class AuthSignup(Resource): args = create_user_parser.parse_args(strict=True) email = args.get("email") + # Check if email is already used if dbc.get.user_exists(email): api.abort(codes.BAD_REQUEST, "User already exists") + # Add user item_user = dbc.add.user(**args) return item_response(schema.dump(item_user)) @@ -72,9 +78,12 @@ class AuthDelete(Resource): @protect_route(allowed_roles=["Admin"]) def delete(self, user_id): item_user = dbc.get.user(user_id) + + # Blacklist all the whitelisted tokens in use for the user that will be deleted dbc.delete.whitelist_to_blacklist(Whitelist.user_id == user_id) - dbc.delete.default(item_user) + # Delete user + dbc.delete.default(item_user) return text_response(f"User {user_id} deleted") @@ -84,15 +93,44 @@ class AuthLogin(Resource): args = login_parser.parse_args(strict=True) email = args.get("email") password = args.get("password") + item_user = dbc.get.user_by_email(email) - if not item_user or not item_user.is_correct_password(password): + # Login with unkown email + if not item_user: + api.abort(codes.UNAUTHORIZED, "Invalid email or password") + + # Login with existing email but with wrong password + if not item_user.is_correct_password(password): + # Increase the login attempts every time the user tries to login with wrong password + item_user.login_attempts += 1 + + # Lock the user out for some time + if item_user.login_attempts == USER_LOGIN_LOCKED_ATTEMPTS: + item_user.locked = datetime.now() + USER_LOGIN_LOCKED_EXPIRES + + dbc.utils.commit() api.abort(codes.UNAUTHORIZED, "Invalid email or password") + # Otherwise if login was successful but the user is locked + if item_user.locked: + # Check if locked is greater than now + if item_user.locked > datetime.now(): + api.abort(codes.UNAUTHORIZED, f"Try again in {item_user.locked} hours.") + else: + item_user.locked = None + + # If everything else was successful, set login_attempts to 0 + item_user.login_attempts = 0 + dbc.utils.commit() + + # Create the jwt with user.id as the identifier access_token = create_access_token(item_user.id, user_claims=get_user_claims(item_user)) - # refresh_token = create_refresh_token(item_user.id) + # Login response includes the id and jwt for the user response = {"id": item_user.id, "access_token": access_token} + + # Whitelist the created jwt dbc.add.whitelist(get_jti(access_token), item_user.id) return response @@ -103,6 +141,7 @@ class AuthLoginCode(Resource): args = login_code_parser.parse_args() code = args["code"] + # Check so the code string is valid if not verify_code(code): api.abort(codes.UNAUTHORIZED, "Invalid code") @@ -112,10 +151,12 @@ class AuthLoginCode(Resource): if item_code.competition_id not in sockets.presentations: api.abort(codes.UNAUTHORIZED, "Competition not active") + # Create jwt that is only valid for 8 hours access_token = create_access_token( item_code.id, user_claims=get_code_claims(item_code), expires_delta=timedelta(hours=8) ) + # Whitelist the created jwt dbc.add.whitelist(get_jti(access_token), competition_id=item_code.competition_id) response = { "competition_id": item_code.competition_id, @@ -131,8 +172,13 @@ class AuthLogout(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def post(self): jti = get_raw_jwt()["jti"] + + # Blacklist the token so the user cannot access the api anymore dbc.add.blacklist(jti) + + # Remove the the token from the whitelist since it's blacklisted now Whitelist.query.filter(Whitelist.jti == jti).delete() + dbc.utils.commit() return text_response("Logout") diff --git a/server/app/apis/components.py b/server/app/apis/components.py index 38227a46d90fe2afb5ea4a2fc7924412661c5259..9fff1d07a72115bdb215bb441606aa189c962702 100644 --- a/server/app/apis/components.py +++ b/server/app/apis/components.py @@ -11,7 +11,7 @@ schema = ComponentDTO.schema list_schema = ComponentDTO.list_schema component_parser_add = reqparse.RequestParser() -component_parser_add.add_argument("x", type=str, default=0, location="json") +component_parser_add.add_argument("x", type=int, default=0, location="json") component_parser_add.add_argument("y", type=int, default=0, location="json") component_parser_add.add_argument("w", type=int, default=1, location="json") component_parser_add.add_argument("h", type=int, default=1, location="json") @@ -22,7 +22,7 @@ component_parser_add.add_argument("media_id", type=int, default=None, location=" component_parser_add.add_argument("question_id", type=int, default=None, location="json") component_parser_edit = reqparse.RequestParser() -component_parser_edit.add_argument("x", type=str, default=sentinel, location="json") +component_parser_edit.add_argument("x", type=int, default=sentinel, location="json") component_parser_edit.add_argument("y", type=int, default=sentinel, location="json") component_parser_edit.add_argument("w", type=int, default=sentinel, location="json") component_parser_edit.add_argument("h", type=int, default=sentinel, location="json") diff --git a/server/app/database/models.py b/server/app/database/models.py index a54a77c24b6b77a22187c386e8cd59c1cd006f49..97eb6097c2403cf4d32d01106e2ae906d9d788d5 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -61,8 +61,9 @@ class User(db.Model): _password = db.Column(db.LargeBinary(60), nullable=False) authenticated = db.Column(db.Boolean, default=False) - # twoAuthConfirmed = db.Column(db.Boolean, default=True) - # twoAuthCode = db.Column(db.String(STRING_SIZE), nullable=True) + + login_attempts = db.Column(db.Integer, nullable=False, default=0) + locked = db.Column(db.DateTime(timezone=True), nullable=True, default=None) role_id = db.Column(db.Integer, db.ForeignKey("role.id"), nullable=False) city_id = db.Column(db.Integer, db.ForeignKey("city.id"), nullable=False) diff --git a/server/configmodule.py b/server/configmodule.py index 93d21cbe84a5847ed4927a4d2b3d002b487d1f69..e38c4e04cf1b90ffff4edbe0cbcffffb347c5b4a 100644 --- a/server/configmodule.py +++ b/server/configmodule.py @@ -17,6 +17,8 @@ class Config: THUMBNAIL_SIZE = (120, 120) SECRET_KEY = os.urandom(24) SQLALCHEMY_ECHO = False + USER_LOGIN_LOCKED_ATTEMPTS = 12 + USER_LOGIN_LOCKED_EXPIRES = timedelta(hours=3) class DevelopmentConfig(Config): @@ -34,6 +36,8 @@ class DevelopmentConfig(Config): class TestingConfig(Config): TESTING = True SQLALCHEMY_DATABASE_URI = "sqlite:///test.db" + USER_LOGIN_LOCKED_ATTEMPTS = 4 + USER_LOGIN_LOCKED_EXPIRES = timedelta(seconds=4) class ProductionConfig(Config): diff --git a/server/populate.py b/server/populate.py index 34f73822f789ddfdabe1092192c6dede7af85355..9981f0c02f430d56c88738a9b67e89d5fc17af89 100644 --- a/server/populate.py +++ b/server/populate.py @@ -11,8 +11,8 @@ from app.database.models import City, QuestionType, Role def _add_items(): media_types = ["Image", "Video"] - question_types = ["Boolean", "Multiple", "Text"] - component_types = ["Text", "Image"] + question_types = ["Text", "Practical", "Multiple", "Single"] + component_types = ["Text", "Image", "Question"] view_types = ["Team", "Judge", "Audience", "Operator"] roles = ["Admin", "Editor"] diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 2152a6d4c7236f57d099f50dccec3a090ce94c5d..880a41bd82a3b6cac8795a3cf61eaed6569dbadb 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -2,15 +2,37 @@ This file tests the api function calls. """ +import time + import app.core.http_codes as codes -from app.database.controller.add import competition -from app.database.models import Slide +import pytest from app.core import sockets from tests import app, client, db from tests.test_helpers import add_default_values, change_order_test, delete, get, post, put +# @pytest.mark.skip(reason="Takes long time") +def test_locked_api(client): + add_default_values() + + # Login in with default user but wrong password until blocked + for i in range(4): + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password1"}) + assert response.status_code == codes.UNAUTHORIZED + + # Login with right password, user should be locked + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) + assert response.status_code == codes.UNAUTHORIZED + + # Sleep for 4 secounds + time.sleep(4) + + # Check so the user is no longer locked + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) + assert response.status_code == codes.OK + + def test_misc_api(client): add_default_values() @@ -125,6 +147,10 @@ def test_auth_and_user_api(client): assert response.status_code == codes.OK headers = {"Authorization": "Bearer " + body["access_token"]} + # Login in with default user but wrong password + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password1"}) + assert response.status_code == codes.UNAUTHORIZED + # Create user register_data = {"email": "test1@test.se", "password": "abc123", "role_id": 2, "city_id": 1} response, body = post(client, "/api/auth/signup", register_data, headers) @@ -211,7 +237,6 @@ def test_auth_and_user_api(client): assert response.status_code == codes.OK # TODO: Check if current users jwt (jti) is in blacklist after logging out - response, body = get(client, "/api/users", headers=headers) assert response.status_code == codes.UNAUTHORIZED @@ -479,4 +504,4 @@ def test_authorization(client): # Also get antoher teams answers response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id+1}/answers", headers=headers) - assert response.status_code == codes.OK \ No newline at end of file + assert response.status_code == codes.OK