diff --git a/.vscode/settings.json b/.vscode/settings.json index 99228c8630a9d167650384c97fb7573c0f8d07ef..db2b68f4590ffec707c286474aa1004c29a5709e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,51 +1,44 @@ { - //editor - "editor.formatOnSave": true, - "editor.formatOnPaste": false, - "editor.tabCompletion": "on", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, - "source.organizeImports": true - }, - //python - "python.venvPath": "${workspaceFolder}\\server", - "python.analysis.extraPaths": [ - "server" - ], - "python.terminal.activateEnvironment": true, - "python.formatting.provider": "black", - "python.formatting.blackPath": "server\\env\\Scripts\\black.exe", - "python.formatting.blackArgs": [ - "--line-length", - "119" - ], - //eslint - "eslint.workingDirectories": [ - "./client" - ], - "eslint.options": { - "configFile": "./.eslintrc" - }, - "prettier.configPath": "./client/.prettierrc", - //git - "git.ignoreLimitWarning": true, - //language specific - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "files.exclude": { - "**/__pycache__": true, - "**/.pytest_cache": true, - "**/env/Include": true, - "**/env/Lib": true - }, - "files.watcherExclude": { - "**/env/Lib/**": true - }, - "search.exclude": { - "**/env": true - }, -} \ No newline at end of file + //editor + "editor.formatOnSave": true, + "editor.formatOnPaste": false, + "editor.tabCompletion": "on", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true, + "source.organizeImports": false + }, + //python + "python.venvPath": "${workspaceFolder}\\server", + "python.analysis.extraPaths": ["server"], + "python.terminal.activateEnvironment": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "server\\env\\Scripts\\black.exe", + "python.formatting.blackArgs": ["--line-length", "119"], + //eslint + "eslint.workingDirectories": ["./client"], + "eslint.options": { + "configFile": "./.eslintrc" + }, + "prettier.configPath": "./client/.prettierrc", + //git + "git.ignoreLimitWarning": true, + //language specific + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.exclude": { + "**/__pycache__": true, + "**/.pytest_cache": true, + "**/env/Include": true, + "**/env/Lib": true + }, + "files.watcherExclude": { + "**/env/Lib/**": true + }, + "search.exclude": { + "**/env": true + } +} diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.tsx index 09ef5181b566f919365cb499202ce6ec2e114c24..4087d6c798d17522dbaffe3f8c7041b57795338f 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -36,7 +36,7 @@ import { const initialState = { mouseX: null, mouseY: null, - slideOrder: null, + slideId: null, } const leftDrawerWidth = 150 @@ -111,15 +111,15 @@ const PresentationEditorPage: React.FC = () => { const [contextState, setContextState] = React.useState<{ mouseX: null | number mouseY: null | number - slideOrder: null | number + slideId: null | number }>(initialState) - const handleRightClick = (event: React.MouseEvent<HTMLDivElement>, slideOrder: number) => { + const handleRightClick = (event: React.MouseEvent<HTMLDivElement>, slideId: number) => { event.preventDefault() setContextState({ mouseX: event.clientX - 2, mouseY: event.clientY - 4, - slideOrder: slideOrder, + slideId: slideId, }) } @@ -128,13 +128,13 @@ const PresentationEditorPage: React.FC = () => { } const handleRemoveSlide = async () => { - await axios.delete(`/competitions/${id}/slides/${contextState.slideOrder}`) + await axios.delete(`/competitions/${id}/slides/${contextState.slideId}`) dispatch(getEditorCompetition(id)) setContextState(initialState) } const handleDuplicateSlide = async () => { - await axios.post(`/competitions/${id}/slides/${contextState.slideOrder}/copy`) + await axios.post(`/competitions/${id}/slides/${contextState.slideId}/copy`) dispatch(getEditorCompetition(id)) setContextState(initialState) } @@ -214,7 +214,7 @@ const PresentationEditorPage: React.FC = () => { key={slide.id} selected={slide.id === activeSlideId} onClick={() => setActiveSlideId(slide.id)} - onContextMenu={(event) => handleRightClick(event, slide.order)} + onContextMenu={(event) => handleRightClick(event, slide.id)} > {renderSlideIcon(slide)} <ListItemText primary={`Sida ${slide.order + 1}`} /> diff --git a/client/src/pages/presentationEditor/components/Alternatives.tsx b/client/src/pages/presentationEditor/components/Alternatives.tsx index d40882dada4f9ef44fefe5f102fc1d415934ffaa..c8b9a131059b407a14198a219f49ebd93cf5718e 100644 --- a/client/src/pages/presentationEditor/components/Alternatives.tsx +++ b/client/src/pages/presentationEditor/components/Alternatives.tsx @@ -40,7 +40,6 @@ const Alternatives = ({ activeSlide, competitionId }: AlternativeProps) => { if (alternative.value === 0) { newValue = 1 } else newValue = 0 - console.log('newValue: ' + newValue) await axios .put( `/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, diff --git a/client/src/pages/presentationEditor/components/Images.tsx b/client/src/pages/presentationEditor/components/Images.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba55b89bd8c68ce1e48631071f58fb05def097d9 --- /dev/null +++ b/client/src/pages/presentationEditor/components/Images.tsx @@ -0,0 +1,65 @@ +import { List, ListItem, ListItemText, Typography } from '@material-ui/core' +import CloseIcon from '@material-ui/icons/Close' +import React, { useState } from 'react' +import { Center, HiddenInput, SettingsList, AddImageButton, ImportedImage } from './styled' + +const Images = () => { + const pictureList = [ + { id: 'picture1', name: 'Picture1.jpeg' }, + { id: 'picture2', name: 'Picture2.jpeg' }, + ] + const handleClosePictureClick = (id: string) => { + setPictures(pictures.filter((item) => item.id !== id)) //Will not be done like this when api is used + } + const [pictures, setPictures] = useState(pictureList) + + const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>): void => { + if (e.target.files !== null && e.target.files[0]) { + const files = Array.from(e.target.files) + const file = files[0] + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = function () { + console.log(reader.result) + // TODO: Send image to back-end (remove console.log) + } + reader.onerror = function (error) { + console.log('Error: ', error) + } + } + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Bilder" /> + </Center> + </ListItem> + {pictures.map((picture) => ( + <div key={picture.id}> + <ListItem divider button> + <ImportedImage + id="temp source, todo: add image source to elements of pictureList" + src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" + /> + <Center> + <ListItemText primary={picture.name} /> + </Center> + <CloseIcon onClick={() => handleClosePictureClick(picture.id)} /> + </ListItem> + </div> + ))} + <ListItem button> + <Center> + <HiddenInput accept="image/*" id="contained-button-file" multiple type="file" onChange={handleFileSelected} /> + <AddImageButton htmlFor="contained-button-file"> + <Typography variant="button">Lägg till bild</Typography> + </AddImageButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default Images diff --git a/client/src/pages/presentationEditor/components/RndComponent.tsx b/client/src/pages/presentationEditor/components/RndComponent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..890e51601ec20901e66ad76b199719478d6c53e3 --- /dev/null +++ b/client/src/pages/presentationEditor/components/RndComponent.tsx @@ -0,0 +1,87 @@ +import axios from 'axios' +import React, { useState } from 'react' +import { Rnd } from 'react-rnd' +import { ComponentTypes } from '../../../enum/ComponentTypes' +import { useAppSelector } from '../../../hooks' +import { Component, ImageComponent, TextComponent } from '../../../interfaces/ApiModels' +import { Position, Size } from '../../../interfaces/Components' +import CheckboxComponent from './CheckboxComponent' +import ImageComponentDisplay from './ImageComponentDisplay' +import { TextComponentContainer } from './styled' + +type ImageComponentProps = { + component: Component +} + +const RndComponent = ({ component }: ImageComponentProps) => { + const [hover, setHover] = useState(false) + const [currentPos, setCurrentPos] = useState<Position>({ x: component.x, y: component.y }) + const [currentSize, setCurrentSize] = useState<Size>({ w: component.w, h: component.h }) + const competitionId = useAppSelector((state) => state.editor.competition.id) + const slideId = useAppSelector((state) => state.editor.activeSlideId) + const handleUpdatePos = (pos: Position) => { + axios.put(`/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { + x: pos.x, + y: pos.y, + }) + } + const handleUpdateSize = (size: Size) => { + axios.put(`/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { + w: size.w, + h: size.h, + }) + } + + const renderInnerComponent = () => { + switch (component.type_id) { + case ComponentTypes.Checkbox: + return <CheckboxComponent key={component.id} component={component} /> + case ComponentTypes.Text: + return ( + <TextComponentContainer + hover={hover} + dangerouslySetInnerHTML={{ __html: (component as TextComponent).data.text }} + /> + ) + case ComponentTypes.Image: + return <ImageComponentDisplay key={component.id} component={component as ImageComponent} /> + default: + break + } + } + + return ( + <Rnd + minWidth={50} + minHeight={50} + bounds="parent" + onDragStop={(e, d) => { + setCurrentPos({ x: d.x, y: d.y }) + handleUpdatePos(d) + }} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + size={{ width: currentSize.w, height: currentSize.h }} + position={{ x: currentPos.x, y: currentPos.y }} + onResizeStop={(e, direction, ref, delta, position) => { + setCurrentSize({ + w: ref.offsetWidth, + h: ref.offsetHeight, + }) + setCurrentPos(position) + handleUpdateSize({ w: ref.offsetWidth, h: ref.offsetHeight }) + handleUpdatePos(position) + }} + onResize={(e, direction, ref, delta, position) => + setCurrentSize({ + w: ref.offsetWidth, + h: ref.offsetHeight, + }) + } + > + {renderInnerComponent()} + </Rnd> + ) +} + +export default RndComponent diff --git a/client/src/pages/presentationEditor/components/SlideEditor.tsx b/client/src/pages/presentationEditor/components/SlideEditor.tsx index a71b9262727741ea7fd29e703374c913f0d8dbb3..457f903fb09ff958663daed2197516afb33841e6 100644 --- a/client/src/pages/presentationEditor/components/SlideEditor.tsx +++ b/client/src/pages/presentationEditor/components/SlideEditor.tsx @@ -1,11 +1,7 @@ import React from 'react' -import { ComponentTypes } from '../../../enum/ComponentTypes' import { useAppSelector } from '../../../hooks' -import { ImageComponent, TextComponent } from '../../../interfaces/ApiModels' -import CheckboxComponent from './CheckboxComponent' -import ImageComponentDisplay from './ImageComponentDisplay' +import RndComponent from './RndComponent' import { SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } from './styled' -import TextComponentDisplay from './TextComponentDisplay' const SlideEditor: React.FC = () => { const components = useAppSelector( @@ -16,19 +12,7 @@ const SlideEditor: React.FC = () => { <SlideEditorContainer> <SlideEditorContainerRatio> <SlideEditorPaper> - {components && - components.map((component) => { - switch (component.type_id) { - case ComponentTypes.Checkbox: - return <CheckboxComponent key={component.id} component={component} /> - case ComponentTypes.Text: - return <TextComponentDisplay key={component.id} component={component as TextComponent} /> - case ComponentTypes.Image: - return <ImageComponentDisplay key={component.id} component={component as ImageComponent} /> - default: - break - } - })} + {components && components.map((component) => <RndComponent key={component.id} component={component} />)} </SlideEditorPaper> </SlideEditorContainerRatio> </SlideEditorContainer> diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index 54854abdeb313c996dedad6ec80ce7343eb0674c..0b425dea75ed681ed934431973cb613d9e9b0dbc 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -1,178 +1,110 @@ -import { List, ListItem, ListItemText, Typography } from '@material-ui/core' +import { Divider, List, ListItem, ListItemText, TextField, Typography } from '@material-ui/core' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import CloseIcon from '@material-ui/icons/Close' import React, { useState } from 'react' import { useParams } from 'react-router-dom' import { useAppSelector } from '../../../hooks' import { TextComponent } from '../../../interfaces/ApiModels' +import axios from 'axios' import Alternatives from './Alternatives' import SlideType from './SlideType' -import { Center, HiddenInput, SettingsList, SlidePanel, TextInput } from './styled' +import { Center, HiddenInput, ImportedImage, SettingsList, SlidePanel, TextCard, WhiteBackground } from './styled' import Timer from './Timer' - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - textInputContainer: { - '& > *': { - margin: theme.spacing(1), - width: '100%', - background: 'white', - }, - }, - clickableIcon: { - cursor: 'pointer', - background: 'white', - }, - importedImage: { - width: 70, - height: 50, - background: 'white', - }, - whiteBackground: { - background: 'white', - }, - addButtons: { - padding: 5, - }, - panelList: { - padding: 0, - }, - addImageButton: { - padding: 5, - cursor: 'pointer', - }, - }) -) +import { getEditorCompetition } from '../../../actions/editor' +import { useDispatch } from 'react-redux' +import Images from './Images' +import Texts from './Texts' interface CompetitionParams { id: string } const SlideSettings: React.FC = () => { - const classes = useStyles() const { id }: CompetitionParams = useParams() + const dispatch = useDispatch() const activeSlide = useAppSelector((state) => state.editor.competition.slides.find((slide) => slide && slide.id === state.editor.activeSlideId) ) - const texts = useAppSelector( - (state) => - state.editor.competition.slides - .find((slide) => slide.id === state.editor.activeSlideId) - ?.components.filter((component) => component.type_id === 1) as TextComponent[] - ) - - const pictureList = [ - { id: 'picture1', name: 'Picture1.jpeg' }, - { id: 'picture2', name: 'Picture2.jpeg' }, - ] - const handleClosePictureClick = (id: string) => { - setPictures(pictures.filter((item) => item.id !== id)) //Will not be done like this when api is used - } - const [pictures, setPictures] = useState(pictureList) - - const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>): void => { - if (e.target.files !== null && e.target.files[0]) { - const files = Array.from(e.target.files) - const file = files[0] - const reader = new FileReader() - reader.readAsDataURL(file) - reader.onload = function () { - console.log(reader.result) - // TODO: Send image to back-end (remove console.log) - } - reader.onerror = function (error) { - console.log('Error: ', error) - } + const updateMaxScore = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { + const questionId = activeSlide?.questions[0].id + if (activeSlide) { + await axios + .put(`/competitions/${id}/slides/${activeSlide.id}/questions/${questionId}`, { + total_score: event.target.value, + }) + .then(() => { + dispatch(getEditorCompetition(id)) + }) + .catch(console.log) } } - const handleAddText = async () => { - console.log('Add text component') - // TODO: post the new text] - // setTexts([...texts, { id: 'newText', name: 'New Text' }]) + const updateQuestionName = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { + const questionId = activeSlide?.questions[0].id + if (activeSlide && questionId) { + await axios + .put(`/competitions/${id}/slides/${activeSlide.id}/questions/${questionId}`, { name: event.target.value }) + .then(() => { + dispatch(getEditorCompetition(id)) + }) + .catch(console.log) + } } return ( <SlidePanel> <SettingsList> {activeSlide && <SlideType activeSlide={activeSlide} competitionId={id} />} + <Divider /> {activeSlide && <Timer activeSlide={activeSlide} competitionId={id} />} + <Divider /> + <WhiteBackground> + <ListItem> + <TextField + id="standard-number" + fullWidth={true} + variant="outlined" + label="Maxpoäng" + type="number" + onChange={updateMaxScore} + defaultValue={(activeSlide?.questions[0] && activeSlide?.questions[0].total_score) || ''} + /> + </ListItem> + </WhiteBackground> + <Divider /> + <WhiteBackground> + <ListItem> + <TextField + label="Frågetitel" + fullWidth={true} + defaultValue={activeSlide?.questions[0] && activeSlide?.questions[0].name} + onChange={updateQuestionName} + variant="outlined" + /> + <Divider /> + </ListItem> + </WhiteBackground> </SettingsList> + {activeSlide && <Alternatives activeSlide={activeSlide} competitionId={id} />} - <SettingsList> - <ListItem divider> - <Center> - <ListItemText primary="Text" /> - </Center> - </ListItem> - {texts && - texts.map((text) => ( - <div key={text.id}> - <ListItem divider> - <TextInput label={text.data.text} variant="outlined" /> - <CloseIcon className={classes.clickableIcon} /> - </ListItem> - </div> - ))} - <ListItem button onClick={handleAddText}> - <Center> - <Typography className={classes.addButtons} variant="button"> - Lägg till text - </Typography> - </Center> - </ListItem> - </SettingsList> + {activeSlide && <Texts activeSlide={activeSlide} competitionId={id} />} - <List className={classes.panelList}> - <ListItem divider> - <Center> - <ListItemText primary="Bilder" /> - </Center> - </ListItem> - {pictures.map((picture) => ( - <div key={picture.id}> - <ListItem divider button> - <img - id="temp source, todo: add image source to elements of pictureList" - src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" - className={classes.importedImage} - /> - <Center> - <ListItemText primary={picture.name} /> - </Center> - <CloseIcon onClick={() => handleClosePictureClick(picture.id)} /> - </ListItem> - </div> - ))} + {activeSlide && <Images />} + + <SettingsList> <ListItem button> + <ImportedImage + id="temp source, todo: add image source to elements of pictureList" + src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" + /> <Center> - <HiddenInput - accept="image/*" - id="contained-button-file" - multiple - type="file" - onChange={handleFileSelected} - /> - <label className={classes.addImageButton} htmlFor="contained-button-file"> - <Typography variant="button">Lägg till bild</Typography> - </label> + <ListItemText>Välj bakgrundsbild ...</ListItemText> </Center> </ListItem> - </List> - - <ListItem button> - <img - id="temp source, todo: add image source to elements of pictureList" - src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" - className={classes.importedImage} - /> - <Center> - <ListItemText>Välj bakgrundsbild ...</ListItemText> - </Center> - </ListItem> + </SettingsList> </SlidePanel> ) } diff --git a/client/src/pages/presentationEditor/components/SlideType.tsx b/client/src/pages/presentationEditor/components/SlideType.tsx index 73004c32a291d1cdd44a866c9e7f64cc5512fc87..928a63922b29884488e0db9bf21d6391dddde770 100644 --- a/client/src/pages/presentationEditor/components/SlideType.tsx +++ b/client/src/pages/presentationEditor/components/SlideType.tsx @@ -41,9 +41,7 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { if (selectedSlideType === 0) { // Change slide type from a question type to information await axios - .delete( - `/competitions/${competitionId}/slides/${activeSlide.order}/questions/${activeSlide.questions[0].id}` - ) + .delete(`/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`) .then(() => { dispatch(getEditorCompetition(competitionId)) }) @@ -51,16 +49,13 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { } else { // Change slide type from question type to another question type await axios - .delete( - `/competitions/${competitionId}/slides/${activeSlide.order}/questions/${activeSlide.questions[0].id}` - ) + .delete(`/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`) .catch(console.log) await axios - .post(`/competitions/${competitionId}/slides/${activeSlide.order}/questions`, { + .post(`/competitions/${competitionId}/slides/${activeSlide.id}/questions`, { name: 'Ny fråga', total_score: 0, type_id: selectedSlideType, - slide_id: activeSlide.id, }) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -70,11 +65,10 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { } else if (selectedSlideType !== 0) { // Change slide type from information to a question type await axios - .post(`/competitions/${competitionId}/slides/${activeSlide.order}/questions`, { + .post(`/competitions/${competitionId}/slides/${activeSlide.id}/questions`, { name: 'Ny fråga', total_score: 0, type_id: selectedSlideType, - slide_id: activeSlide.id, }) .then(() => { dispatch(getEditorCompetition(competitionId)) diff --git a/client/src/pages/presentationEditor/components/TextComponentDisplay.test.tsx b/client/src/pages/presentationEditor/components/TextComponentDisplay.test.tsx deleted file mode 100644 index c448987894fedee6731d3610fe850871e608388d..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/TextComponentDisplay.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Editor } from '@tinymce/tinymce-react' -import { mount } from 'enzyme' -import React from 'react' -import { Provider } from 'react-redux' -import store from '../../../store' -import TextComponentDisplay from './TextComponentDisplay' - -it('renders text component display', () => { - const testText = 'TEST' - const container = mount( - <Provider store={store}> - <TextComponentDisplay - component={{ id: 0, x: 0, y: 0, w: 0, h: 0, data: { text: testText, font: '123123' }, type_id: 2 }} - /> - </Provider> - ) - expect(container.find(Editor).prop('initialValue')).toBe(testText) -}) diff --git a/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx deleted file mode 100644 index 4fb34650c3e63159d04ae745ac92f780493cdb52..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Editor } from '@tinymce/tinymce-react' -import axios from 'axios' -import React, { useState } from 'react' -import { Rnd } from 'react-rnd' -import { useAppSelector } from '../../../hooks' -import { TextComponent } from '../../../interfaces/ApiModels' -import { Position, Size } from '../../../interfaces/Components' - -type ImageComponentProps = { - component: TextComponent -} - -const TextComponentDisplay = ({ component }: ImageComponentProps) => { - const [currentPos, setCurrentPos] = useState<Position>({ x: component.x, y: component.y }) - const [currentSize, setCurrentSize] = useState<Size>({ w: component.w, h: component.h }) - const competitionId = useAppSelector((state) => state.editor.competition.id) - const slideId = useAppSelector((state) => state.editor.activeSlideId) - if (component.id === 1) console.log(component) - const handleEditorChange = (e: any) => { - console.log('Content was updated:', e.target.getContent()) - axios.put(`/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { - data: { ...component.data, text: e.target.getContent() }, - }) - } - const handleUpdatePos = (pos: Position) => { - axios.put(`/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { - x: pos.x, - y: pos.y, - }) - } - const handleUpdateSize = () => { - axios.put(`/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { - w: currentSize.w, - h: currentSize.h, - }) - } - return ( - <Rnd - minWidth={50} - minHeight={50} - bounds="parent" - onDragStop={(e, d) => { - setCurrentPos({ x: d.x, y: d.y }) - handleUpdatePos(d) - }} - size={{ width: currentSize.w, height: currentSize.h }} - position={{ x: currentPos.x, y: currentPos.y }} - onResize={(e, direction, ref, delta, position) => { - setCurrentSize({ - w: ref.offsetWidth, - h: ref.offsetHeight, - }) - setCurrentPos(position) - }} - onResizeStop={handleUpdateSize} - > - <div style={{ height: '100%', width: '100%' }}> - <Editor - initialValue={component.data.text} - init={{ - height: '100%', - menubar: false, - plugins: [ - 'advlist autolink lists link image charmap print preview anchor', - 'searchreplace visualblocks code fullscreen', - 'insertdatetime media table paste code help wordcount', - ], - toolbar: - 'undo redo | formatselect | fontselect | bold italic backcolor | \ - alignleft aligncenter alignright alignjustify | \ - bullist numlist outdent indent | removeformat | help', - }} - onChange={handleEditorChange} - /> - </div> - </Rnd> - ) -} - -export default TextComponentDisplay diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e0f0d5b475b87f2db7f71337c1c4978716fdb5e1 --- /dev/null +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx @@ -0,0 +1,79 @@ +import { Editor } from '@tinymce/tinymce-react' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { getEditorCompetition } from '../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { TextComponent } from '../../../interfaces/ApiModels' +import { DeleteTextButton } from './styled' + +type ImageComponentProps = { + component: TextComponent +} + +interface CompetitionParams { + id: string +} + +const TextComponentEdit = ({ component }: ImageComponentProps) => { + const { id }: CompetitionParams = useParams() + const competitionId = useAppSelector((state) => state.editor.competition.id) + const [content, setContent] = useState('') + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const dispatch = useAppDispatch() + + useEffect(() => { + setContent(component.data.text) + }, []) + + const handleSaveText = async (a: string) => { + setContent(a) + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates 250ms after last input was made to not spam + setTimerHandle( + window.setTimeout(async () => { + console.log('Content was updated on server. id: ', component.id) + await axios.put(`/competitions/${competitionId}/slides/${activeSlideId}/components/${component.id}`, { + data: { ...component.data, text: a }, + }) + dispatch(getEditorCompetition(id)) + }, 250) + ) + } + + const handleDeleteText = async (componentId: number) => { + await axios.delete(`/competitions/${id}/slides/${activeSlideId}/components/${componentId}`) + dispatch(getEditorCompetition(id)) + } + + return ( + <div style={{ minHeight: '300px', height: '100%', width: '100%' }}> + <Editor + value={content || ''} + init={{ + height: '300px', + menubar: false, + plugins: [ + 'advlist autolink lists link image charmap print preview anchor', + 'searchreplace visualblocks code fullscreen', + 'insertdatetime media table paste code help wordcount', + ], + toolbar: + 'undo redo save | fontselect | formatselect | bold italic backcolor | \ + alignleft aligncenter alignright alignjustify | \ + bullist numlist outdent indent | removeformat | help', + }} + onEditorChange={(a, e) => handleSaveText(a)} + /> + <DeleteTextButton variant="contained" color="secondary" onClick={() => handleDeleteText(component.id)}> + Ta bort + </DeleteTextButton> + </div> + ) +} + +export default TextComponentEdit diff --git a/client/src/pages/presentationEditor/components/Texts.tsx b/client/src/pages/presentationEditor/components/Texts.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a5b03703806842d2ce5562ee6c0332618740502 --- /dev/null +++ b/client/src/pages/presentationEditor/components/Texts.tsx @@ -0,0 +1,61 @@ +import { Divider, ListItem, ListItemText, Typography } from '@material-ui/core' +import React from 'react' +import { useAppSelector } from '../../../hooks' +import { TextComponent } from '../../../interfaces/ApiModels' +import { RichSlide } from '../../../interfaces/ApiRichModels' +import { AddButton, Center, SettingsList, TextCard } from './styled' +import TextComponentEdit from './TextComponentEdit' +import axios from 'axios' +import { getEditorCompetition } from '../../../actions/editor' +import { useDispatch } from 'react-redux' + +type TextsProps = { + activeSlide: RichSlide + competitionId: string +} + +const Texts = ({ activeSlide, competitionId }: TextsProps) => { + const texts = useAppSelector( + (state) => + state.editor.competition.slides + .find((slide) => slide.id === state.editor.activeSlideId) + ?.components.filter((component) => component.type_id === 1) as TextComponent[] + ) + + const dispatch = useDispatch() + const handleAddText = async () => { + if (activeSlide) { + await axios.post(`/competitions/${competitionId}/slides/${activeSlide?.id}/components`, { + type_id: 1, + data: { text: 'Ny text' }, + w: 315, + h: 50, + }) + dispatch(getEditorCompetition(competitionId)) + } + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Text" /> + </Center> + </ListItem> + {texts && + texts.map((text) => ( + <TextCard elevation={4} key={text.id}> + <TextComponentEdit component={text} /> + <Divider /> + </TextCard> + ))} + <ListItem button onClick={handleAddText}> + <Center> + <AddButton variant="button">Lägg till text</AddButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default Texts diff --git a/client/src/pages/presentationEditor/components/Timer.tsx b/client/src/pages/presentationEditor/components/Timer.tsx index e5b622be30575facfead50157287225bc95bf6bf..97179da41a5338f06abb12e28477b862d89d6d42 100644 --- a/client/src/pages/presentationEditor/components/Timer.tsx +++ b/client/src/pages/presentationEditor/components/Timer.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react' import { getEditorCompetition } from '../../../actions/editor' import { useAppDispatch } from '../../../hooks' import { RichSlide } from '../../../interfaces/ApiRichModels' -import { WhiteBackground } from './styled' +import { Center, WhiteBackground } from './styled' type TimerProps = { activeSlide: RichSlide @@ -31,17 +31,20 @@ const Timer = ({ activeSlide, competitionId }: TimerProps) => { return ( <WhiteBackground> <ListItem> - <TextField - id="standard-number" - variant="outlined" - placeholder="Antal sekunder" - helperText="Lämna blank för att inte använda timerfunktionen" - label="Timer" - type="number" - defaultValue={activeSlide?.timer || 0} - onChange={updateTimer} - value={timer} - /> + <Center> + <TextField + id="standard-number" + fullWidth={true} + variant="outlined" + placeholder="Antal sekunder" + helperText="Lämna blank för att inte använda timerfunktionen" + label="Timer" + type="number" + defaultValue={activeSlide?.timer || 0} + onChange={updateTimer} + value={timer} + /> + </Center> </ListItem> </WhiteBackground> ) diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index bcb479f39d91603a613145b943224671bbdfe601..1aa0b853e2ba6b15a1aed58d01c7d3007357fb86 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -1,4 +1,4 @@ -import { FormControl, List, ListItem, Tab, TextField, Typography } from '@material-ui/core' +import { FormControl, List, Tab, TextField, Typography, Button, Card, ListItem } from '@material-ui/core' import styled from 'styled-components' export const SettingsTab = styled(Tab)` @@ -86,19 +86,49 @@ export const WhiteBackground = styled.div` ` export const AddButton = styled(Typography)` - padding: 5; + padding-left: 8px; + padding-right: 8px; + padding-top: 7px; + padding-bottom: 7px; +` + +export const ImportedImage = styled.img` + width: 70px; + height: 50px; ` export const Clickable = styled.div` cursor: pointer; ` +export const AddImageButton = styled.label` + padding: 5px; + cursor: 'pointer'; +` + export const SettingsList = styled(List)` - padding-top: 5px; - padding-bottom: 5px; + margin-bottom: 10px; + padding: 0; + background: white; +` + +export const TextCard = styled(Card)` + margin-bottom: 15px; + margin-top: 10px; +` + +export const DeleteTextButton = styled(Button)` + width: 100%; + margin-bottom: 7px; ` -export const SettingsListItem = styled(ListItem)` - padding-top: 5px; - padding-bottom: 5px; +interface TextComponentContainerProps { + hover: boolean +} + +export const TextComponentContainer = styled.div<TextComponentContainerProps>` + height: 100%; + width: 100%; + padding: ${(props) => (props.hover ? 0 : 1)}px; + border: solid ${(props) => (props.hover ? 1 : 0)}px; ` diff --git a/client/src/pages/views/PresenterViewPage.tsx b/client/src/pages/views/PresenterViewPage.tsx index 3bb20a43bfcdc7466fad373576b2083754fae050..40aa252d6770ad4a2359f2038c3da7e09b47c14b 100644 --- a/client/src/pages/views/PresenterViewPage.tsx +++ b/client/src/pages/views/PresenterViewPage.tsx @@ -67,7 +67,7 @@ const PresenterViewPage: React.FC = () => { socket_connect() socketSetSlide // Behövs denna? setTimeout(startCompetition, 500) // Ghetto, wait for everything to load - console.log(id) + // console.log(id) }, []) const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { diff --git a/server/app/apis/__init__.py b/server/app/apis/__init__.py index 181a6dcab59ab8db61eb4c01ed7eddecac75bd69..5eab3f829ae81e17ecc269d98f568eeacc45a68c 100644 --- a/server/app/apis/__init__.py +++ b/server/app/apis/__init__.py @@ -74,10 +74,12 @@ flask_api.add_namespace(misc_ns, path="/api/misc") flask_api.add_namespace(user_ns, path="/api/users") flask_api.add_namespace(auth_ns, path="/api/auth") flask_api.add_namespace(comp_ns, path="/api/competitions") -flask_api.add_namespace(slide_ns, path="/api/competitions/<CID>/slides") -flask_api.add_namespace(alternative_ns, path="/api/competitions/<CID>/slides/<SOrder>/questions/<QID>/alternatives") -flask_api.add_namespace(answer_ns, path="/api/competitions/<CID>/teams/<TID>/answers") -flask_api.add_namespace(team_ns, path="/api/competitions/<CID>/teams") -flask_api.add_namespace(code_ns, path="/api/competitions/<CID>/codes") -flask_api.add_namespace(question_ns, path="/api/competitions/<CID>") -flask_api.add_namespace(component_ns, path="/api/competitions/<CID>/slides/<SOrder>/components") +flask_api.add_namespace(slide_ns, path="/api/competitions/<competition_id>/slides") +flask_api.add_namespace( + alternative_ns, path="/api/competitions/<competition_id>/slides/<slide_id>/questions/<question_id>/alternatives" +) +flask_api.add_namespace(answer_ns, path="/api/competitions/<competition_id>/teams/<team_id>/answers") +flask_api.add_namespace(team_ns, path="/api/competitions/<competition_id>/teams") +flask_api.add_namespace(code_ns, path="/api/competitions/<competition_id>/codes") +flask_api.add_namespace(question_ns, path="/api/competitions/<competition_id>") +flask_api.add_namespace(component_ns, path="/api/competitions/<competition_id>/slides/<slide_id>/components") diff --git a/server/app/apis/alternatives.py b/server/app/apis/alternatives.py index 48513250554ccf391bf7647d2cf8211992f6f34a..d56a2f8ba0163a9187aa451eac45a416890052c6 100644 --- a/server/app/apis/alternatives.py +++ b/server/app/apis/alternatives.py @@ -4,8 +4,6 @@ from app.apis import check_jwt, item_response, list_response from app.core.dto import QuestionAlternativeDTO, QuestionDTO from app.core.parsers import question_alternative_parser from app.core.schemas import QuestionAlternativeSchema -from app.database.controller.add import question_alternative -from app.database.controller.get import question_alternatives from app.database.models import Question, QuestionAlternative from flask_jwt_extended import jwt_required from flask_restx import Resource @@ -16,32 +14,37 @@ list_schema = QuestionAlternativeDTO.list_schema @api.route("/") -@api.param("CID, SOrder, QID") +@api.param("competition_id, slide_id, question_id") class QuestionAlternativeList(Resource): @check_jwt(editor=True) - def get(self, CID, SOrder, QID): - items = dbc.get.question_alternatives(QID) + def get(self, competition_id, slide_id, question_id): + items = dbc.get.question_alternative_list(competition_id, slide_id, question_id) return list_response(list_schema.dump(items)) @check_jwt(editor=True) - def post(self, CID, SOrder, QID): + def post(self, competition_id, slide_id, question_id): args = question_alternative_parser.parse_args(strict=True) - item = dbc.add.question_alternative(**args, question_id=QID) + item = dbc.add.question_alternative(**args, question_id=question_id) return item_response(schema.dump(item)) -@api.route("/<AID>") -@api.param("CID, SOrder, QID, AID") +@api.route("/<alternative_id>") +@api.param("competition_id, slide_id, question_id, alternative_id") class QuestionAlternatives(Resource): @check_jwt(editor=True) - def put(self, CID, SOrder, QID, AID): + def get(self, competition_id, slide_id, question_id, alternative_id): + items = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) + return item_response(schema.dump(items)) + + @check_jwt(editor=True) + def put(self, competition_id, slide_id, question_id, alternative_id): args = question_alternative_parser.parse_args(strict=True) - item = dbc.get.one(QuestionAlternative, AID) + item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) @check_jwt(editor=True) - def delete(self, CID, SOrder, QID, AID): - item = dbc.get.one(QuestionAlternative, AID) + def delete(self, competition_id, slide_id, question_id, alternative_id): + item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) dbc.delete.default(item) return {}, codes.NO_CONTENT diff --git a/server/app/apis/answers.py b/server/app/apis/answers.py index e968e6ecf3d5c70a7987798d3f477cb21fcf7ec0..308b1001de9fa325b7ff01c4e518f7db9e3c10eb 100644 --- a/server/app/apis/answers.py +++ b/server/app/apis/answers.py @@ -4,8 +4,6 @@ from app.apis import check_jwt, item_response, list_response from app.core.dto import QuestionAnswerDTO from app.core.parsers import question_answer_edit_parser, question_answer_parser from app.core.schemas import QuestionAlternativeSchema -from app.database.controller.add import question_alternative -from app.database.controller.get import question_alternatives from app.database.models import Question, QuestionAlternative, QuestionAnswer from flask_jwt_extended import jwt_required from flask_restx import Resource @@ -16,26 +14,31 @@ list_schema = QuestionAnswerDTO.list_schema @api.route("/") -@api.param("CID, TID") +@api.param("competition_id, team_id") class QuestionAnswerList(Resource): @check_jwt(editor=True) - def get(self, CID, TID): - items = dbc.get.question_answers(TID) + def get(self, competition_id, team_id): + items = dbc.get.question_answer_list(competition_id, team_id) return list_response(list_schema.dump(items)) @check_jwt(editor=True) - def post(self, CID, TID): + def post(self, competition_id, team_id): args = question_answer_parser.parse_args(strict=True) - item = dbc.add.question_answer(**args, team_id=TID) + item = dbc.add.question_answer(**args, team_id=team_id) return item_response(schema.dump(item)) -@api.route("/<AID>") -@api.param("CID, TID, AID") +@api.route("/<answer_id>") +@api.param("competition_id, team_id, answer_id") class QuestionAnswers(Resource): @check_jwt(editor=True) - def put(self, CID, TID, AID): + def get(self, competition_id, team_id, answer_id): + item = dbc.get.question_answer(competition_id, team_id, answer_id) + return item_response(schema.dump(item)) + + @check_jwt(editor=True) + def put(self, competition_id, team_id, answer_id): args = question_answer_edit_parser.parse_args(strict=True) - item = dbc.get.one(QuestionAnswer, AID) + item = dbc.get.question_answer(competition_id, team_id, answer_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index 86ac53d52d7712a927411f1b03e32ee0b99a385f..d249fec586a0d9974a3802bb55700cc635f57792 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -58,7 +58,7 @@ 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, required=False) + item_user = dbc.get.user_by_email(email) if not item_user or not item_user.is_correct_password(password): api.abort(codes.UNAUTHORIZED, "Invalid email or password") diff --git a/server/app/apis/codes.py b/server/app/apis/codes.py index 332d5f3e612b8c0ecf886c95331114073d6c6030..c761420fabab70254973a7d352ce6f9b7833ba25 100644 --- a/server/app/apis/codes.py +++ b/server/app/apis/codes.py @@ -1,12 +1,11 @@ import app.database.controller as dbc -from app.apis import item_response, list_response +from app.apis import check_jwt, item_response, list_response from app.core import http_codes as codes from app.core.dto import CodeDTO from app.core.parsers import code_parser from app.database.models import Code, Competition from flask_jwt_extended import jwt_required from flask_restx import Resource -from app.apis import check_jwt api = CodeDTO.api schema = CodeDTO.schema @@ -14,19 +13,19 @@ list_schema = CodeDTO.list_schema @api.route("/") -@api.param("CID") +@api.param("competition_id") class CodesList(Resource): @check_jwt(editor=True) - def get(self, CID): - items = dbc.get.code_list(CID) + def get(self, competition_id): + items = dbc.get.code_list(competition_id) return list_response(list_schema.dump(items), len(items)), codes.OK @api.route("/<code_id>") -@api.param("CID, code_id") +@api.param("competition_id, code_id") class CodesById(Resource): @check_jwt(editor=False) - def put(self, CID, code_id): + def put(self, competition_id, code_id): item = dbc.get.one(Code, code_id) item.code = dbc.utils.generate_unique_code() dbc.utils.commit_and_refresh(item) diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index cc135c09cd82d2a137bcb80c2e6f66bcc28828a6..386d4051c5e32f852a9a165810e3303812e58440 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -25,30 +25,30 @@ class CompetitionsList(Resource): item = dbc.add.competition(**args) # Add default slide - dbc.add.slide(item) + # dbc.add.slide(item.id) return item_response(schema.dump(item)) -@api.route("/<CID>") -@api.param("CID") +@api.route("/<competition_id>") +@api.param("competition_id") class Competitions(Resource): @check_jwt(editor=True) - def get(self, CID): - item = dbc.get.competition(CID) + def get(self, competition_id): + item = dbc.get.competition(competition_id) return item_response(rich_schema.dump(item)) @check_jwt(editor=True) - def put(self, CID): + def put(self, competition_id): args = competition_parser.parse_args(strict=True) - item = dbc.get.one(Competition, CID) + item = dbc.get.one(Competition, competition_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) @check_jwt(editor=True) - def delete(self, CID): - item = dbc.get.one(Competition, CID) + def delete(self, competition_id): + item = dbc.get.one(Competition, competition_id) dbc.delete.competition(item) return "deleted" @@ -63,12 +63,12 @@ class CompetitionSearch(Resource): return list_response(list_schema.dump(items), total) -@api.route("/<CID>/copy") -@api.param("CID") +@api.route("/<competition_id>/copy") +@api.param("competition_id") class SlidesOrder(Resource): @check_jwt(editor=True) - def post(self, CID): - item_competition = dbc.get.competition(CID) + def post(self, competition_id): + item_competition = dbc.get.competition(competition_id) item_competition_copy = dbc.copy.competition(item_competition) diff --git a/server/app/apis/components.py b/server/app/apis/components.py index 01b1d82780963d0468f0e9a17d2c522b3d3ea8a2..c895f02322d16c8f603b97c7c60644db4ea36233 100644 --- a/server/app/apis/components.py +++ b/server/app/apis/components.py @@ -14,38 +14,37 @@ list_schema = ComponentDTO.list_schema @api.route("/<component_id>") -@api.param("CID, SOrder, component_id") +@api.param("competition_id, slide_id, component_id") class ComponentByID(Resource): @check_jwt(editor=True) - def get(self, CID, SOrder, component_id): - item = dbc.get.one(Component, component_id) + def get(self, competition_id, slide_id, component_id): + item = dbc.get.component(competition_id, slide_id, component_id) return item_response(schema.dump(item)) @check_jwt(editor=True) - def put(self, CID, SOrder, component_id): + def put(self, competition_id, slide_id, component_id): args = component_parser.parse_args() - item = dbc.get.one(Component, component_id) + item = dbc.get.component(competition_id, slide_id, component_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) @check_jwt(editor=True) - def delete(self, CID, SOrder, component_id): - item = dbc.get.one(Component, component_id) + def delete(self, competition_id, slide_id, component_id): + item = dbc.get.component(competition_id, slide_id, component_id) dbc.delete.component(item) return {}, codes.NO_CONTENT @api.route("/") -@api.param("CID, SOrder") +@api.param("competition_id, slide_id") class ComponentList(Resource): @check_jwt(editor=True) - def get(self, CID, SOrder): - items = dbc.get.component_list(CID, SOrder) + def get(self, competition_id, slide_id): + items = dbc.get.component_list(competition_id, slide_id) return list_response(list_schema.dump(items)) @check_jwt(editor=True) - def post(self, CID, SOrder): + def post(self, competition_id, slide_id): args = component_create_parser.parse_args() - item_slide = dbc.get.slide(CID, SOrder) - item = dbc.add.component(item_slide=item_slide, **args) + item = dbc.add.component(slide_id=slide_id, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/questions.py b/server/app/apis/questions.py index bd14d389b530d20d943f60ce9541f5f705878660..5797872a9865bd693989eb0322d8a9818796e86b 100644 --- a/server/app/apis/questions.py +++ b/server/app/apis/questions.py @@ -13,47 +13,48 @@ list_schema = QuestionDTO.list_schema @api.route("/questions") -@api.param("CID") +@api.param("competition_id") class QuestionList(Resource): @check_jwt(editor=True) - def get(self, CID): - items = dbc.get.question_list(CID) + def get(self, competition_id): + items = dbc.get.question_list_for_competition(competition_id) return list_response(list_schema.dump(items)) -@api.route("/slides/<SID>/questions") -@api.param("CID, SID") +@api.route("/slides/<slide_id>/questions") +@api.param("competition_id, slide_id") class QuestionListForSlide(Resource): @check_jwt(editor=True) - def post(self, SID, CID): - args = question_parser.parse_args(strict=True) - del args["slide_id"] - - item_slide = dbc.get.slide(CID, SID) - item = dbc.add.question(item_slide=item_slide, **args) + def get(self, competition_id, slide_id): + items = dbc.get.question_list(competition_id, slide_id) + return list_response(list_schema.dump(items)) + @check_jwt(editor=True) + def post(self, competition_id, slide_id): + args = question_parser.parse_args(strict=True) + item = dbc.add.question(slide_id=slide_id, **args) return item_response(schema.dump(item)) -@api.route("/slides/<SID>/questions/<QID>") -@api.param("CID, SID, QID") +@api.route("/slides/<slide_id>/questions/<question_id>") +@api.param("competition_id, slide_id, question_id") class QuestionById(Resource): @check_jwt(editor=True) - def get(self, CID, SID, QID): - item_question = dbc.get.question(CID, SID, QID) + def get(self, competition_id, slide_id, question_id): + item_question = dbc.get.question(competition_id, slide_id, question_id) return item_response(schema.dump(item_question)) @check_jwt(editor=True) - def put(self, CID, SID, QID): + def put(self, competition_id, slide_id, question_id): args = question_parser.parse_args(strict=True) - item_question = dbc.get.question(CID, SID, QID) + item_question = dbc.get.question(competition_id, slide_id, question_id) item_question = dbc.edit.default(item_question, **args) return item_response(schema.dump(item_question)) @check_jwt(editor=True) - def delete(self, CID, SID, QID): - item_question = dbc.get.question(CID, SID, QID) + def delete(self, competition_id, slide_id, question_id): + item_question = dbc.get.question(competition_id, slide_id, question_id) dbc.delete.question(item_question) return {}, codes.NO_CONTENT diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index 93250b3a8429bd917d9080da65b1eec2e05e8098..ef8cf89c463519b8d60d78ec37bc440c494b8018 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -13,83 +13,79 @@ list_schema = SlideDTO.list_schema @api.route("/") -@api.param("CID") +@api.param("competition_id") class SlidesList(Resource): @check_jwt(editor=True) - def get(self, CID): - items = dbc.get.slide_list(CID) + def get(self, competition_id): + items = dbc.get.slide_list(competition_id) return list_response(list_schema.dump(items)) @check_jwt(editor=True) - def post(self, CID): - item_comp = dbc.get.one(Competition, CID) - item_slide = dbc.add.slide(item_comp) - dbc.utils.refresh(item_comp) - return list_response(list_schema.dump(item_comp.slides)) + def post(self, competition_id): + item_slide = dbc.add.slide(competition_id) + return item_response(schema.dump(item_slide)) -@api.route("/<SOrder>") -@api.param("CID,SOrder") +@api.route("/<slide_id>") +@api.param("competition_id,slide_id") class Slides(Resource): @check_jwt(editor=True) - def get(self, CID, SOrder): - item_slide = dbc.get.slide(CID, SOrder) + def get(self, competition_id, slide_id): + item_slide = dbc.get.slide(competition_id, slide_id) return item_response(schema.dump(item_slide)) @check_jwt(editor=True) - def put(self, CID, SOrder): + def put(self, competition_id, slide_id): args = slide_parser.parse_args(strict=True) - title = args.get("title") - timer = args.get("timer") - item_slide = dbc.get.slide(CID, SOrder) - item_slide = dbc.edit.default(item_slide, title=title, timer=timer) + item_slide = dbc.get.slide(competition_id, slide_id) + item_slide = dbc.edit.default(item_slide, **args) return item_response(schema.dump(item_slide)) @check_jwt(editor=True) - def delete(self, CID, SOrder): - item_slide = dbc.get.slide(CID, SOrder) + def delete(self, competition_id, slide_id): + item_slide = dbc.get.slide(competition_id, slide_id) dbc.delete.slide(item_slide) return {}, codes.NO_CONTENT -@api.route("/<SOrder>/order") -@api.param("CID,SOrder") -class SlidesOrder(Resource): +@api.route("/<slide_id>/order") +@api.param("competition_id,slide_id") +class SlideOrder(Resource): @check_jwt(editor=True) - def put(self, CID, SOrder): + def put(self, competition_id, slide_id): args = slide_parser.parse_args(strict=True) order = args.get("order") - item_slide = dbc.get.slide(CID, SOrder) + item_slide = dbc.get.slide(competition_id, slide_id) if order == item_slide.order: return item_response(schema.dump(item_slide)) # clamp order between 0 and max - order_count = dbc.get.slide_count(CID) + order_count = dbc.get.slide_count(competition_id) if order < 0: order = 0 elif order >= order_count - 1: order = order_count - 1 # get slide at the requested order - item_slide_order = dbc.get.slide(CID, order) + item_slide_id = dbc.get.slide(competition_id, order) # switch place between them - item_slide = dbc.edit.switch_order(item_slide, item_slide_order) + item_slide = dbc.edit.switch_order(item_slide, item_slide_id) return item_response(schema.dump(item_slide)) -@api.route("/<SOrder>/copy") -@api.param("CID,SOrder") -class SlidesOrder(Resource): +@api.route("/<slide_id>/copy") +@api.param("competition_id,slide_id") +class SlideCopy(Resource): @check_jwt(editor=True) - def post(self, CID, SOrder): - item_slide = dbc.get.slide(CID, SOrder) + def post(self, competition_id, slide_id): + item_slide = dbc.get.slide(competition_id, slide_id) item_slide_copy = dbc.copy.slide(item_slide) diff --git a/server/app/apis/teams.py b/server/app/apis/teams.py index bdf0bb88aea0a4435ee76345cdcbe462ca6ac4b0..6596244c9085b850106e567a6974ced5428413aa 100644 --- a/server/app/apis/teams.py +++ b/server/app/apis/teams.py @@ -13,45 +13,41 @@ list_schema = TeamDTO.list_schema @api.route("/") -@api.param("CID") +@api.param("competition_id") class TeamsList(Resource): @check_jwt(editor=True) - def get(self, CID): - items = dbc.get.team_list(CID) + def get(self, competition_id): + items = dbc.get.team_list(competition_id) return list_response(list_schema.dump(items)) @check_jwt(editor=True) - def post(self, CID): + def post(self, competition_id): args = team_parser.parse_args(strict=True) - item_comp = dbc.get.one(Competition, CID) - item_team = dbc.add.team(args["name"], item_comp) + item_team = dbc.add.team(args["name"], competition_id) return item_response(schema.dump(item_team)) -@api.route("/<TID>") -@api.param("CID,TID") +@api.route("/<team_id>") +@api.param("competition_id,team_id") class Teams(Resource): - @jwt_required @check_jwt(editor=True) - def get(self, CID, TID): - item = dbc.get.team(CID, TID) + def get(self, competition_id, team_id): + item = dbc.get.team(competition_id, team_id) return item_response(schema.dump(item)) - @jwt_required @check_jwt(editor=True) - def delete(self, CID, TID): - item_team = dbc.get.team(CID, TID) + def delete(self, competition_id, team_id): + item_team = dbc.get.team(competition_id, team_id) dbc.delete.team(item_team) return {}, codes.NO_CONTENT - @jwt_required @check_jwt(editor=True) - def put(self, CID, TID): + def put(self, competition_id, team_id): args = team_parser.parse_args(strict=True) name = args.get("name") - item_team = dbc.get.team(CID, TID) + item_team = dbc.get.team(competition_id, team_id) - item_team = dbc.edit.default(item_team, name=name, competition_id=CID) + item_team = dbc.edit.default(item_team, name=name, competition_id=competition_id) return item_response(schema.dump(item_team)) diff --git a/server/app/apis/users.py b/server/app/apis/users.py index b3423d37cccc1638802e1ee0da398720dbe86217..767f01cb441553e717aea58a202a03f0b50ece64 100644 --- a/server/app/apis/users.py +++ b/server/app/apis/users.py @@ -15,14 +15,14 @@ list_schema = UserDTO.list_schema def edit_user(item_user, args): email = args.get("email") + name = args.get("name") + if email: - if User.query.filter(User.email == args["email"]).count() > 0: + if dbc.get.user_exists(email): api.abort(codes.BAD_REQUEST, "Email is already in use") - try: - args["name"] = args.get("name").title() - except Exception: - pass + if name: + args["name"] = args["name"].title() return dbc.edit.default(item_user, **args) diff --git a/server/app/core/parsers.py b/server/app/core/parsers.py index 45c0a6e34090951cffe29059cfd578b5d732de1a..71f8fdee352c3fe88831124b0c8907fcbc51f505 100644 --- a/server/app/core/parsers.py +++ b/server/app/core/parsers.py @@ -58,7 +58,6 @@ question_parser = reqparse.RequestParser() question_parser.add_argument("name", type=str, default=None, location="json") question_parser.add_argument("total_score", type=int, default=None, location="json") question_parser.add_argument("type_id", type=int, default=None, location="json") -question_parser.add_argument("slide_id", type=int, location="json") ###QUESTION ALTERNATIVES#### diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index 929410fa4e22f01a8a7fa2b9e98c1cf8eb7eee0a..1dbcf44230e508c3d51b3aa8faa4ba2c84e97914 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -2,6 +2,7 @@ This file contains functionality to add data to the database. """ +from sqlalchemy.orm.session import sessionmaker import app.core.http_codes as codes from app.core import db from app.database.controller import utils @@ -25,6 +26,7 @@ from app.database.models import ( ViewType, ) from flask_restx import abort +from sqlalchemy import exc def db_add(item): @@ -32,13 +34,18 @@ def db_add(item): Internal function. Adds item to the database and handles comitting and refreshing. """ - - db.session.add(item) - db.session.commit() - db.session.refresh(item) - - if not item: - abort(codes.BAD_REQUEST, f"Object could not be created") + try: + db.session.add(item) + db.session.commit() + db.session.refresh(item) + except (exc.SQLAlchemyError, exc.DBAPIError): + db.session.rollback() + # SQL errors such as item already exists + abort(codes.INTERNAL_SERVER_ERROR, f"Item of type {type(item)} could not be created") + except: + db.session.rollback() + # Catching other errors + abort(codes.INTERNAL_SERVER_ERROR, f"Something went wrong when creating {type(item)}") return item @@ -85,13 +92,13 @@ def city(name): return db_add(City(name)) -def component(type_id, item_slide, data, x=0, y=0, w=0, h=0): +def component(type_id, slide_id, data, x=0, y=0, w=0, h=0): """ Adds a component to the slide at the specified coordinates with the provided size and data . """ - return db_add(Component(item_slide.id, type_id, data, x, y, w, h)) + return db_add(Component(slide_id, type_id, data, x, y, w, h)) def image(filename, user_id): @@ -108,12 +115,12 @@ def user(email, password, role_id, city_id, name=None): return db_add(User(email, password, role_id, city_id, name)) -def question(name, total_score, type_id, item_slide): +def question(name, total_score, type_id, slide_id): """ Adds a question to the specified slide using the provided arguments. """ - return db_add(Question(name, total_score, type_id, item_slide.id)) + return db_add(Question(name, total_score, type_id, slide_id)) def question_alternative(text, value, question_id): @@ -131,10 +138,10 @@ def code(pointer, view_type_id): return db_add(Code(code_string, pointer, view_type_id)) -def team(name, item_competition): +def team(name, competition_id): """ Adds a team with the specified name to the provided competition. """ - item = db_add(Team(name, item_competition.id)) + item = db_add(Team(name, competition_id)) # Add code for the team code(item.id, 1) @@ -142,11 +149,33 @@ def team(name, item_competition): return item -def slide(item_competition): +def slide(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() + + # Add slide + item_slide = db_add(Slide(order, competition_id)) + + # Add default question + question(f"Fråga {item_slide.order + 1}", 10, 0, 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. """ - order = Slide.query.filter(Slide.competition_id == item_competition.id).count() # first element has index 0 - return db_add(Slide(order, item_competition.id)) + # 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)) + + item_slide = utils.refresh(item_slide) + return item_slide def competition(name, year, city_id): @@ -154,18 +183,22 @@ def competition(name, year, city_id): Adds a competition to the database using the provided arguments. Also adds slide and codes. """ + item_competition = db_add(Competition(name, year, city_id)) - item_competition = _competition(name, year, city_id) + # Add default slide + slide(item_competition.id) - # Add one slide for the competition - slide(item_competition) + # Add code for Judge view + code(item_competition.id, 2) - # TODO: Add two teams + # Add code for Audience view + code(item_competition.id, 3) + item_competition = utils.refresh(item_competition) return item_competition -def _competition(name, year, city_id, font=None): +def _competition_no_slides(name, year, city_id, font=None): """ Internal function. Adds a competition to the database using the provided arguments. Also adds codes. @@ -181,5 +214,5 @@ def _competition(name, year, city_id, font=None): # Add code for Audience view code(item_competition.id, 3) - utils.refresh(item_competition) + item_competition = utils.refresh(item_competition) return item_competition diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index a6b408ec301e093d6e47bca359968c052ac62aa1..79a4df0f4a7f308b7bc40b5b9ab20e61423ffb14 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -40,7 +40,7 @@ def _component(item_component, item_slide_new): add.component( item_component.type_id, - item_slide_new, + item_slide_new.id, item_component.data, item_component.x, item_component.y, @@ -66,7 +66,7 @@ def slide_to_competition(item_slide_old, item_competition): Does not copy team, question answers. """ - item_slide_new = add.slide(item_competition) + item_slide_new = add.slide_without_question(item_competition.id) # Copy all fields item_slide_new.title = item_slide_old.title @@ -98,7 +98,7 @@ def competition(item_competition_old): print(f"{item_competition[total-1].name}, {total=}") name = "Kopia av " + item_competition[total - 1].name - item_competition_new = add._competition( + item_competition_new = add._competition_no_slides( name, item_competition_old.year, item_competition_old.city_id, diff --git a/server/app/database/controller/delete.py b/server/app/database/controller/delete.py index 806f3672daef2e9831f6ca9300bf9f5b70ffe2bf..f3dc2dd51b680bc8834d9cf734001ac7291883d8 100644 --- a/server/app/database/controller/delete.py +++ b/server/app/database/controller/delete.py @@ -2,16 +2,22 @@ This file contains functionality to delete data to the database. """ +import app.core.http_codes as codes import app.database.controller as dbc from app.core import db from app.database.models import Blacklist, City, Competition, Role, Slide, User +from flask_restx import abort +from sqlalchemy import exc def default(item): """ Deletes item and commits. """ - - db.session.delete(item) - db.session.commit() + try: + db.session.delete(item) + db.session.commit() + except: + db.session.rollback() + abort(codes.INTERNAL_SERVER_ERROR, f"Item of type {type(item)} could not be deleted") def component(item_component): diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py index 45cbb6ec3a137667b74a2bba514a88685c3b3daa..15e908ef401c7e63b99c5767bcc5440ea5e7a820 100644 --- a/server/app/database/controller/get.py +++ b/server/app/database/controller/get.py @@ -5,23 +5,17 @@ This file contains functionality to get data from the database. from app.core import db from app.core import http_codes as codes from app.database.models import ( - City, Code, Competition, Component, - ComponentType, - MediaType, Question, QuestionAlternative, QuestionAnswer, - QuestionType, - Role, Slide, Team, User, - ViewType, ) -from sqlalchemy.orm import contains_eager, joinedload, subqueryload +from sqlalchemy.orm import joinedload, subqueryload def all(db_type): @@ -30,114 +24,201 @@ def all(db_type): return db_type.query.all() -def one(db_type, id, required=True, error_msg=None): +def one(db_type, id): """ Get lazy db-item in the table that has the same id. """ - return db_type.query.filter(db_type.id == id).first_extended(required, error_msg) + return db_type.query.filter(db_type.id == id).first_extended() +### Codes ### +def code_by_code(code): + """ Gets the code object associated with the provided code. """ + + return Code.query.filter(Code.code == code.upper()).first_extended() + + +def code_list(competition_id): + """ Gets a list of all code objects associated with a the provided competition. """ + + team_view_id = 1 + join_filters = (Code.view_type_id == team_view_id) & (Team.id == Code.pointer) + filters = ((Code.view_type_id != team_view_id) & (Code.pointer == competition_id))( + (Code.view_type_id == team_view_id) & (competition_id == Team.competition_id) + ) + return Code.query.join(Team, join_filters, isouter=True).filter(filters).all() + + +### Users ### def user_exists(email): """ Checks if an user has that email. """ return User.query.filter(User.email == email).count() > 0 -def code_by_code(code, required=True, error_msg=None): - """ Gets the code object associated with the provided code. """ +def user(user_id): + """ Gets the user object associated with the provided id. """ - return Code.query.filter(Code.code == code.upper()).first_extended(required, error_msg, codes.UNAUTHORIZED) + return User.query.filter(User.id == user_id).first_extended() -def user(UID, required=True, error_msg=None): - """ Gets the user object associated with the provided id. """ +def user_by_email(email): + """ Gets the user object associated with the provided email. """ + return User.query.filter(User.email == email).first_extended(error_code=codes.UNAUTHORIZED) - return User.query.filter(User.id == UID).first_extended(required, error_msg) +### Slides ### +def slide(competition_id, slide_id): + """ Gets the slide object associated with the provided id and order. """ + join_competition = Competition.id == Slide.competition_id + filters = (Competition.id == competition_id) & (Slide.id == slide_id) -def user_by_email(email, required=True, error_msg=None): - """ Gets the user object associated with the provided email. """ + return Slide.query.join(Competition, join_competition).filter(filters).first_extended() - return User.query.filter(User.email == email).first_extended(required, error_msg) +def slide_list(competition_id): + """ Gets a list of all slide objects associated with a the provided competition. """ + join_competition = Competition.id == Slide.competition_id + filters = Competition.id == competition_id -def slide(CID, SOrder, required=True, error_msg=None): - """ Gets the slide object associated with the provided id and order. """ + return Slide.query.join(Competition, join_competition).filter(filters).all() - filters = (Slide.competition_id == CID) & (Slide.order == SOrder) - return Slide.query.filter(filters).first_extended(required, error_msg) +def slide_count(competition_id): + """ Gets the number of slides in the provided competition. """ + + return Slide.query.filter(Slide.competition_id == competition_id).count() -def team(CID, TID, required=True, error_msg=None): + +### Teams ### +def team(competition_id, team_id): """ Gets the team object associated with the provided id and competition id. """ + join_competition = Competition.id == Team.competition_id + filters = (Competition.id == competition_id) & (Team.id == team_id) + + return Team.query.join(Competition, join_competition).filter(filters).first_extended() + + +def team_list(competition_id): + """ Gets a list of all team objects associated with a the provided competition. """ - return Team.query.filter((Team.competition_id == CID) & (Team.id == TID)).first_extended(required, error_msg) + join_competition = Competition.id == Team.competition_id + filters = Competition.id == competition_id + return Team.query.join(Competition, join_competition).filter(filters).all() -def question(CID, SOrder, QID, required=True, error_msg=None): + +### Questions ### +def question(competition_id, slide_id, question_id): """ Gets the question object associated with the provided id, slide order and competition id. """ - join_filters = (Slide.competition_id == CID) & (Slide.order == SOrder) & (Slide.id == Question.slide_id) - return Question.query.join(Slide, join_filters).filter(Question.id == QID).first_extended(required, error_msg) + join_competition = Competition.id == Slide.competition_id + join_slide = Slide.id == Question.slide_id + filters = (Competition.id == competition_id) & (Slide.id == slide_id) & (Question.id == question_id) + return Question.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).first_extended() -def question_alternatives(QID): - # join_filters = (Slide.competition_id == CID) & (Slide.order == SOrder) - return QuestionAlternative.query.filter(QuestionAlternative.question_id == QID).all() +def question_list(competition_id, slide_id): + """ Gets a list of all question objects associated with a the provided competition and slide. """ -def question_answers(TID): - # join_filters = (Slide.competition_id == CID) & (Slide.order == SOrder) - return QuestionAnswer.query.filter(QuestionAnswer.team_id == TID).all() + join_competition = Competition.id == Slide.competition_id + join_slide = Slide.id == Question.slide_id + filters = (Competition.id == competition_id) & (Slide.id == slide_id) + return Question.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).all() -def competition(CID): - """ Get Competition and all it's sub-entities """ - """ HOT PATH """ - os1 = joinedload(Competition.slides).joinedload(Slide.components) - os2 = joinedload(Competition.slides).joinedload(Slide.questions).joinedload(Question.alternatives) - ot = joinedload(Competition.teams).joinedload(Team.question_answers) - return Competition.query.filter(Competition.id == CID).options(os1).options(os2).options(ot).first() +def question_list_for_competition(competition_id): + """ Gets a list of all question objects associated with a the provided competition. """ + join_competition = Competition.id == Slide.competition_id + join_slide = Slide.id == Question.slide_id + filters = Competition.id == competition_id -def code_list(competition_id): - """ Gets a list of all code objects associated with a the provided competition. """ + return Question.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).all() - team_view_id = 1 - join_filters = (Code.view_type_id == team_view_id) & (Team.id == Code.pointer) - filters = ((Code.view_type_id != team_view_id) & (Code.pointer == competition_id))( - (Code.view_type_id == team_view_id) & (competition_id == Team.competition_id) + +### Question Alternative ### +def question_alternative(competition_id, slide_id, question_id, alternative_id): + """ Get question alternative for a given question based on its competition and slide and ID. """ + + join_competition = Competition.id == Slide.competition_id + join_slide = Slide.id == Question.slide_id + join_question = Question.id == QuestionAlternative.question_id + filters = ( + (Competition.id == competition_id) + & (Slide.id == slide_id) + & (Question.id == question_id) + & (QuestionAlternative.id == alternative_id) ) - return Code.query.join(Team, join_filters, isouter=True).filter(filters).all() + return ( + QuestionAlternative.query.join(Competition, join_competition) + .join(Slide, join_slide) + .join(Question, join_question) + .filter(filters) + .first_extended() + ) -def question_list(CID): - """ Gets a list of all question objects associated with a the provided competition. """ - join_filters = (Slide.competition_id == CID) & (Slide.id == Question.slide_id) - return Question.query.join(Slide, join_filters).all() +def question_alternative_list(competition_id, slide_id, question_id): + """ Get all question alternatives for a given question based on its competition and slide. """ + join_competition = Competition.id == Slide.competition_id + join_slide = Slide.id == Question.slide_id + join_question = Question.id == QuestionAlternative.question_id + filters = (Competition.id == competition_id) & (Slide.id == slide_id) & (Question.id == question_id) + return ( + QuestionAlternative.query.join(Competition, join_competition) + .join(Slide, join_slide) + .join(Question, join_question) + .filter(filters) + .all() + ) -def component_list(CID, SOrder): - """ Gets a list of all component objects associated with a the provided competition id and slide order. """ - join_filters = (Slide.competition_id == CID) & (Slide.order == SOrder) & (Component.slide_id == Slide.id) - return Component.query.join(Slide, join_filters).all() +### Question Answers ### +def question_answer(competition_id, team_id, answer_id): + """ Get question answer for a given team based on its competition and ID. """ + join_competition = Competition.id == Team.competition_id + join_team = Team.id == QuestionAnswer.team_id + filters = (Competition.id == competition_id) & (Team.id == team_id) & (QuestionAnswer.id == answer_id) + return ( + QuestionAnswer.query.join(Competition, join_competition).join(Team, join_team).filter(filters).first_extended() + ) -def team_list(CID): - """ Gets a list of all team objects associated with a the provided competition. """ +def question_answer_list(competition_id, team_id): + """ Get question answer for a given team based on its competition. """ + join_competition = Competition.id == Team.competition_id + join_team = Team.id == QuestionAnswer.team_id + filters = (Competition.id == competition_id) & (Team.id == team_id) + return QuestionAnswer.query.join(Competition, join_competition).join(Team, join_team).filter(filters).all() - return Team.query.filter(Team.competition_id == CID).all() +### Components ### +def component(competition_id, slide_id, component_id): + """ Gets a list of all component objects associated with a the provided competition id and slide order. """ -def slide_list(CID): - """ Gets a list of all slide objects associated with a the provided competition. """ + join_competition = Competition.id == Slide.competition_id + join_slide = Slide.id == Component.slide_id + filters = (Competition.id == competition_id) & (Slide.id == slide_id) & (Component.id == component_id) + return Component.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).first_extended() - return Slide.query.filter(Slide.competition_id == CID).all() +def component_list(competition_id, slide_id): + """ Gets a list of all component objects associated with a the provided competition id and slide order. """ -def slide_count(CID): - """ Gets the number of slides in the provided competition. """ + join_competition = Competition.id == Slide.competition_id + join_slide = Slide.id == Component.slide_id + filters = (Competition.id == competition_id) & (Slide.id == slide_id) + return Component.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).all() - return Slide.query.filter(Slide.competition_id == CID).count() + +### Competitions ### +def competition(competition_id): + """ Get Competition and all it's sub-entities """ + os1 = joinedload(Competition.slides).joinedload(Slide.components) + os2 = joinedload(Competition.slides).joinedload(Slide.questions).joinedload(Question.alternatives) + ot = joinedload(Competition.teams).joinedload(Team.question_answers) + return Competition.query.filter(Competition.id == competition_id).options(os1).options(os2).options(ot).first() diff --git a/server/app/database/controller/utils.py b/server/app/database/controller/utils.py index 4b49e46e777d23228c72f15006f59c80a8029a60..c01205a6ec0f9dfc5772d87200268e035db0d14b 100644 --- a/server/app/database/controller/utils.py +++ b/server/app/database/controller/utils.py @@ -2,9 +2,32 @@ This file contains some miscellaneous functionality. """ +import app.core.http_codes as codes from app.core import db from app.core.codes import generate_code_string from app.database.models import Code +from flask_restx import abort +from sqlalchemy import exc + + +def move_slides(item_competition, start_order, end_order): + slides = item_competition.slides + # Move up + if start_order < end_order: + for i in range(start_order + 1, end_order): + slides[i].order -= 1 + + # Move down + elif start_order > end_order: + for i in range(end_order, start_order): + slides[i].order += 1 + + # start = 5, end = 1 + # 1->2, 2->3, 4->5 + # 5 = 1 + + slides[start_order].order = end_order + return commit_and_refresh(item_competition) def generate_unique_code(): @@ -16,19 +39,27 @@ def generate_unique_code(): return code -def commit_and_refresh(item): - """ Commits and refreshes the provided item. """ - - db.session.commit() - db.session.refresh(item) - - def refresh(item): """ Refreshes the provided item. """ + try: + db.session.refresh(item) + except Exception as e: + abort(codes.INTERNAL_SERVER_ERROR, f"Refresh failed!\n{str(e)}") - db.session.refresh(item) + return item def commit(): """ Commits. """ - db.session.commit() + try: + db.session.commit() + except Exception as e: + db.session.rollback() + abort(codes.INTERNAL_SERVER_ERROR, f"Commit failed!\n{str(e)}") + + +def commit_and_refresh(item): + """ Commits and refreshes the provided item. """ + + commit() + return refresh(item) diff --git a/server/app/database/models.py b/server/app/database/models.py index 2063774f356fab21d53972aa31e542cb056e2bb8..cded9ddf7aa00d2b5545a2ae5f40cf28eecba209 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -140,7 +140,6 @@ class Slide(db.Model): class Question(db.Model): - __table_args__ = (db.UniqueConstraint("slide_id", "name"),) id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), nullable=False) total_score = db.Column(db.Integer, nullable=False, default=1) diff --git a/server/configmodule.py b/server/configmodule.py index 78537a0e97d293fe5a2688712ea74ab60be72238..e202abd3fd4f76899598d77df66de8bf148925a2 100644 --- a/server/configmodule.py +++ b/server/configmodule.py @@ -21,6 +21,7 @@ class Config: class DevelopmentConfig(Config): DEBUG = True + SQLALCHEMY_ECHO = False # HOST = "localhost" # PORT = 5432 # USER = "postgres" @@ -28,7 +29,6 @@ class DevelopmentConfig(Config): # DATABASE = "teknik8" # SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" # SQLALCHEMY_DATABASE_URI = "postgresql://" + USER + ":" + PASSWORD + "@" + HOST + ":" + str(PORT) + "/" + DATABASE - SQLALCHEMY_ECHO = False class TestingConfig(Config): @@ -38,9 +38,10 @@ class TestingConfig(Config): class ProductionConfig(Config): SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" - # HOST = 'postgresql' + # HOST = "localhost" # PORT = 5432 - # USER = 'postgres' - # PASSWORD = 'password' - # DATABASE = 'teknik8' - # SQLALCHEMY_DATABASE_URI = 'postgresql://'+USER+":"+PASSWORD+"@"+HOST+":"+str(PORT)+"/"+DATABASE + # USER = "postgres" + # PASSWORD = "password" + # DATABASE = "teknik8" + # SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" + # SQLALCHEMY_DATABASE_URI = "postgresql://" + USER + ":" + PASSWORD + "@" + HOST + ":" + str(PORT) + "/" + DATABASE diff --git a/server/populate.py b/server/populate.py index f371018423dcb4852a3e18a7b8fb227691d50cbe..9ca3c95e7f683decfac5e9f697d0cfdbff958e1e 100644 --- a/server/populate.py +++ b/server/populate.py @@ -47,11 +47,11 @@ def _add_items(): # Add competitions for i in range(len(question_types_items)): item_comp = dbc.add.competition(f"Tävling {i}", 2000 + i, city_id) - dbc.edit.slide(item_comp.slides[0], timer=5, title="test-slide-title") + dbc.edit.default(item_comp.slides[0], timer=5, title="test-slide-title") # Add two more slides to competition - dbc.add.slide(item_comp) - dbc.add.slide(item_comp) + dbc.add.slide(item_comp.id) + dbc.add.slide(item_comp.id) # Add slides for j, item_slide in enumerate(item_comp.slides): @@ -63,15 +63,17 @@ def _add_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, - item_slide=item_slide, + slide_id=item_slide.id, ) + """ for i in range(3): - dbc.add.question_alternative(f"Alternative {i}", 0, item_question.id) + dbc.add.question_alternative(f"Alternative {i}", 0, item_slide.questions[0].id) # Add text components # TODO: Add images as components @@ -80,18 +82,18 @@ def _add_items(): y = random.randrange(1, 500) w = random.randrange(150, 400) h = random.randrange(150, 400) - dbc.add.component(1, item_slide, {"text": f"hej{k}"}, x, y, w, h) + dbc.add.component(1, item_slide.id, {"text": f"hej{k}"}, x, y, w, h) - item_slide = dbc.add.slide(item_comp) - item_slide.title = f"Slide {len(item_comp.slides)}" - item_slide.body = f"Body {len(item_comp.slides)}" - item_slide.timer = 100 + j + # item_slide = dbc.add.slide(item_comp) + # item_slide.title = f"Slide {len(item_comp.slides)}" + # item_slide.body = f"Body {len(item_comp.slides)}" + # item_slide.timer = 100 + j # item_slide.settings = "{}" - dbc.utils.commit_and_refresh(item_slide) + # dbc.utils.commit_and_refresh(item_slide) # Add teams for name in teams: - dbc.add.team(f"{name}{i}", item_comp) + dbc.add.team(f"{name}{i}", item_comp.id) if __name__ == "__main__": diff --git a/server/tests/test_app.py b/server/tests/test_app.py index cc50d4fd857082671158a3a4467d8adbb7e9914f..62d8655a648d2a60808f8b61b04b3e1e3b827692 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -82,10 +82,12 @@ def test_competition_api(client): response, body = get(client, f"/api/competitions/{competition_id}/slides", headers=headers) assert response.status_code == codes.OK - assert len(body["items"]) == 3 + assert len(body["items"]) == 2 + """ response, body = put(client, f"/api/competitions/{competition_id}/slides/{2}/order", {"order": 1}, headers=headers) assert response.status_code == codes.OK + """ response, body = post(client, f"/api/competitions/{competition_id}/teams", {"name": "t1"}, headers=headers) assert response.status_code == codes.OK @@ -256,28 +258,25 @@ def test_slide_api(client): # Add slide response, body = post(client, f"/api/competitions/{CID}/slides", headers=headers) assert response.status_code == codes.OK + + response, body = get(client, f"/api/competitions/{CID}/slides", headers=headers) assert body["count"] == 4 - # Add another slide - response, body = post(client, f"/api/competitions/{CID}/slides", headers=headers) # Get slide - slide_order = 1 - response, item_slide = get(client, f"/api/competitions/{CID}/slides/{slide_order}", headers=headers) + slide_id = 2 + response, item_slide = get(client, f"/api/competitions/{CID}/slides/{slide_id}", headers=headers) assert response.status_code == codes.OK - assert item_slide["order"] == slide_order # Edit slide - order = 6 title = "Ny titel" body = "Ny body" timer = 43 - assert item_slide["order"] != order assert item_slide["title"] != title # assert item_slide["body"] != body assert item_slide["timer"] != timer response, item_slide = put( client, - f"/api/competitions/{CID}/slides/{slide_order}", + f"/api/competitions/{CID}/slides/{slide_id}", # TODO: Implement so these commented lines can be edited # {"order": order, "title": title, "body": body, "timer": timer}, {"title": title, "timer": timer}, @@ -290,31 +289,40 @@ def test_slide_api(client): assert item_slide["timer"] == timer # Delete slide - response, _ = delete(client, f"/api/competitions/{CID}/slides/{slide_order}", headers=headers) + response, _ = delete(client, f"/api/competitions/{CID}/slides/{slide_id}", headers=headers) assert response.status_code == codes.NO_CONTENT # Checks that there are fewer slides response, body = get(client, f"/api/competitions/{CID}/slides", headers=headers) assert response.status_code == codes.OK - assert body["count"] == 4 + assert body["count"] == 3 - # Tries to delete slide again, should work since the order is now changed - response, _ = delete(client, f"/api/competitions/{CID}/slides/{slide_order}", headers=headers) - assert response.status_code == codes.NO_CONTENT + # Tries to delete slide again, which will fail + response, _ = delete(client, f"/api/competitions/{CID}/slides/{slide_id}", headers=headers) + assert response.status_code != codes.OK + # Get all slides + response, body = get(client, f"/api/competitions/{CID}/slides", headers=headers) + assert response.status_code == codes.OK + assert body["count"] == 3 + assert body["items"][0]["id"] == 3 + assert body["items"][0]["order"] == 0 + slide_id = 3 + + """ # Changes the order to the same order - slide_order = body["items"][0]["order"] response, _ = put( - client, f"/api/competitions/{CID}/slides/{slide_order}/order", {"order": slide_order}, headers=headers + client, f"/api/competitions/{CID}/slides/{slide_id}/order", {"order": 0}, headers=headers ) assert response.status_code == codes.OK # Changes the order - change_order_test(client, CID, slide_order, slide_order + 1, headers) + change_order_test(client, CID, slide_id, slide_id + 1, headers) # Copies slide for _ in range(10): - response, _ = post(client, f"/api/competitions/{CID}/slides/{slide_order}/copy", headers=headers) + response, _ = post(client, f"/api/competitions/{CID}/slides/{slide_id}/copy", headers=headers) assert response.status_code == codes.OK + """ def test_question_api(client): @@ -330,7 +338,7 @@ def test_question_api(client): slide_order = 1 response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) assert response.status_code == codes.OK - assert body["count"] == 0 + assert body["count"] == 1 # Get questions from another competition that should have some questions CID = 3 @@ -342,7 +350,7 @@ def test_question_api(client): # Add question name = "Nytt namn" type_id = 2 - slide_order = 1 + slide_order = 6 response, item_question = post( client, f"/api/competitions/{CID}/slides/{slide_order}/questions", diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index 7a68655b5a0212012736b40627050dab81f86595..cc630626822aa3358da79b7d9a9a132705982bdf 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -43,8 +43,8 @@ def add_default_values(): for j in range(2): item_comp = dbc.add.competition(f"Tävling {j}", 2012, item_city.id) # Add two more slides to competition - dbc.add.slide(item_comp) - dbc.add.slide(item_comp) + dbc.add.slide(item_comp.id) + dbc.add.slide(item_comp.id) # Add slides for i, item_slide in enumerate(item_comp.slides): @@ -56,10 +56,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, item_slide=item_slide) + # dbc.add.question(name=f"Q{i+1}", total_score=i + 1, type_id=1, slide_id=item_slide.id) # Add text component - dbc.add.component(1, item_slide, {"text": "Text"}, i, 2 * i, 3 * i, 4 * i) + dbc.add.component(1, item_slide.id, {"text": "Text"}, i, 2 * i, 3 * i, 4 * i) def get_body(response): @@ -128,12 +128,14 @@ def assert_object_values(obj, values): # Changes order of slides -def change_order_test(client, cid, order, new_order, h): - response, new_order_body = get(client, f"/api/competitions/{cid}/slides/{new_order}", headers=h) +def change_order_test(client, cid, slide_id, new_slide_id, h): + response, new_order_body = get(client, f"/api/competitions/{cid}/slides/{new_slide_id}", headers=h) assert response.status_code == codes.OK - response, order_body = get(client, f"/api/competitions/{cid}/slides/{order}", headers=h) + response, order_body = get(client, f"/api/competitions/{cid}/slides/{slide_id}", headers=h) assert response.status_code == codes.OK + new_order = new_order_body["order"] + # Changes order - response, _ = put(client, f"/api/competitions/{cid}/slides/{order}/order", {"order": new_order}, headers=h) + response, _ = put(client, f"/api/competitions/{cid}/slides/{slide_id}/order", {"order": new_order}, headers=h) assert response.status_code == codes.OK