diff --git a/.vscode/settings.json b/.vscode/settings.json index db2b68f4590ffec707c286474aa1004c29a5709e..b02ef900e7a5db6b3da7e7593e38600b00b950bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -40,5 +40,6 @@ }, "search.exclude": { "**/env": true - } + }, + "python.pythonPath": "server\\env\\Scripts\\python.exe" } diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index 189bf16ca5f3baa63bdf8d0ad5d5ae0fa6805d10..5932deb0b24a027b5fc329113f5513253a82ff2a 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -29,5 +29,9 @@ export default { SET_CITIES_TOTAL: 'SET_CITIES_TOTAL', SET_CITIES_COUNT: 'SET_CITIES_COUNT', SET_TYPES: 'SET_TYPES', + SET_MEDIA_ID: 'SET_MEDIA_ID', + SET_MEDIA_FILENAME: 'SET_MEDIA_ID', + SET_MEDIA_TYPE_ID: 'SET_MEDIA_TYPE_ID', + SET_MEDIA_USER_ID: 'SET_MEDIA_USER_ID', SET_STATISTICS: 'SET_STATISTICS', } diff --git a/client/src/enum/ComponentTypes.ts b/client/src/enum/ComponentTypes.ts index 8acb2fd91da2eba39dd935ddce1f0a8af9a59198..8567e1c82bba8eb42e0d8a705bb6179ce56ba118 100644 --- a/client/src/enum/ComponentTypes.ts +++ b/client/src/enum/ComponentTypes.ts @@ -1,5 +1,5 @@ export enum ComponentTypes { Text = 1, - Checkbox, Image, + Checkbox, } diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 650814f11fe4bd135822dcdb97855c430dd750ca..347fdbfa514b3f2adb71c4d0c47b19a08fa99376 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -84,6 +84,7 @@ export interface Component { export interface ImageComponent extends Component { data: { media_id: number + filename: string } } diff --git a/client/src/interfaces/ApiRichModels.ts b/client/src/interfaces/ApiRichModels.ts index ebc2f885fb15251dee86cc1c2d0c33e86166af8c..2388f8308ba8bfa38d4d5376040f937244f4cbd5 100644 --- a/client/src/interfaces/ApiRichModels.ts +++ b/client/src/interfaces/ApiRichModels.ts @@ -1,4 +1,4 @@ -import { Component, QuestionAlternative, QuestionAnswer, QuestionType } from './ApiModels' +import { Component, Media, QuestionAlternative, QuestionAnswer, QuestionType } from './ApiModels' export interface RichCompetition { name: string @@ -17,6 +17,7 @@ export interface RichSlide { competition_id: number components: Component[] questions: RichQuestion[] + medias: Media[] } export interface RichTeam { diff --git a/client/src/pages/admin/AdminPage.test.tsx b/client/src/pages/admin/AdminPage.test.tsx index ab694cee23501ef51a644c1df30f36662d9d36f6..445373a032207faa3f546a0c480fb03a093b1529 100644 --- a/client/src/pages/admin/AdminPage.test.tsx +++ b/client/src/pages/admin/AdminPage.test.tsx @@ -40,7 +40,7 @@ it('renders admin view', () => { }, } ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { - if (path === '/misc/cities') return Promise.resolve(cityRes) + if (path === '/api/misc/cities') return Promise.resolve(cityRes) else return Promise.resolve(rolesRes) }) render( diff --git a/client/src/pages/admin/competitions/CompetitionManager.test.tsx b/client/src/pages/admin/competitions/CompetitionManager.test.tsx index f47d8045d4486bc3d33dac2d28a65ffdc3eb5298..7af04abb66bba7ded7655398f288cba1c62bf78b 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.test.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.test.tsx @@ -47,7 +47,7 @@ it('renders competition manager', () => { } ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { - if (path === '/competitions/search') return Promise.resolve(compRes) + if (path === '/api/competitions/search') return Promise.resolve(compRes) else return Promise.resolve(cityRes) }) render( diff --git a/client/src/pages/presentationEditor/components/CompetitionSettings.test.tsx b/client/src/pages/presentationEditor/components/CompetitionSettings.test.tsx deleted file mode 100644 index a655a30f0763524c61e81185065ab4974a15ae91..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/CompetitionSettings.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { render } from '@testing-library/react' -import React from 'react' -import { BrowserRouter } from 'react-router-dom' -import ImageComponentDisplay from './ImageComponentDisplay' - -it('renders image component display', () => { - render( - <ImageComponentDisplay component={{ id: 0, x: 0, y: 0, w: 0, h: 0, type: 0, media_id: 0 }} /> - ) -}) diff --git a/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx b/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx index b726023b8e6079e7a631c2be396857e9ca0ef4c1..9e78f8e13fd94cf36434ef63ed7504695208de1c 100644 --- a/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx +++ b/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx @@ -3,5 +3,11 @@ import React from 'react' import ImageComponentDisplay from './ImageComponentDisplay' it('renders competition settings', () => { - render(<ImageComponentDisplay component={{ id: 0, x: 0, y: 0, w: 0, h: 0, media_id: 0, type: 2 }} />) + render( + <ImageComponentDisplay + component={{ id: 0, x: 0, y: 0, w: 0, h: 0, data: { media_id: 0, filename: '' }, type_id: 2 }} + width={0} + height={0} + /> + ) }) diff --git a/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx b/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx index cffba9e59afa4288f980b9b4d3833494a02ec7d9..7886a9b15899dfe4b790be9d3673591b4d1940d7 100644 --- a/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx +++ b/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx @@ -1,43 +1,20 @@ -import React, { useState } from 'react' -import { Rnd } from 'react-rnd' +import React from 'react' import { ImageComponent } from '../../../interfaces/ApiModels' -import { Position, Size } from '../../../interfaces/Components' type ImageComponentProps = { component: ImageComponent + width: number + height: number } -const ImageComponentDisplay = ({ 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 ImageComponentDisplay = ({ component, width, height }: ImageComponentProps) => { return ( - <Rnd - minWidth={50} - minHeight={50} - bounds="parent" - onDragStop={(e, d) => { - setCurrentPos({ x: d.x, y: d.y }) - }} - 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={() => { - console.log('Skicka data till server') - }} - > - <img - src="https://365psd.com/images/previews/c61/cartoon-cow-52394.png" - height={currentSize.h} - width={currentSize.w} - draggable={false} - /> - </Rnd> + <img + src={`http://localhost:5000/static/images/${component.data.filename}`} + height={height} + width={width} + draggable={false} + /> ) } diff --git a/client/src/pages/presentationEditor/components/Images.tsx b/client/src/pages/presentationEditor/components/Images.tsx index 199787e48d915de2e41ed77c1b1dd3f5ef223419..0ac739bf440b56492009316b093f6e18e63ca988 100644 --- a/client/src/pages/presentationEditor/components/Images.tsx +++ b/client/src/pages/presentationEditor/components/Images.tsx @@ -1,9 +1,30 @@ import { 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' +import { useDispatch } from 'react-redux' +import { + Center, + HiddenInput, + SettingsList, + AddImageButton, + ImportedImage, + WhiteBackground, + AddButton, + Clickable, + NoPadding, +} from './styled' +import axios from 'axios' +import { getEditorCompetition } from '../../../actions/editor' +import { RichSlide } from '../../../interfaces/ApiRichModels' +import { ImageComponent, Media } from '../../../interfaces/ApiModels' +import { useAppSelector } from '../../../hooks' -const Images = () => { +type ImagesProps = { + activeSlide: RichSlide + competitionId: string +} + +const Images = ({ activeSlide, competitionId }: ImagesProps) => { const pictureList = [ { id: 'picture1', name: 'Picture1.jpeg' }, { id: 'picture2', name: 'Picture2.jpeg' }, @@ -13,51 +34,102 @@ const Images = () => { } const [pictures, setPictures] = useState(pictureList) - const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>): void => { + const dispatch = useDispatch() + + const uploadFile = async (formData: FormData) => { + // Uploads the file to the server and creates a Media object in database + // Returns media id + return await axios + .post(`/api/media/images`, formData) + .then((response) => { + dispatch(getEditorCompetition(competitionId)) + return response.data as Media + }) + .catch(console.log) + } + + const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => { 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 formData = new FormData() + formData.append('image', file) + const response = await uploadFile(formData) + if (response) { + const newComponent = createImageComponent(response) } } } + const createImageComponent = async (media: Media) => { + const imageData = { + x: 0, + y: 0, + data: { + media_id: media.id, + filename: media.filename, + }, + type_id: 2, + } + await axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide?.id}/components`, imageData) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const handleCloseimageClick = async (image: ImageComponent) => { + await axios + .delete(`/api/media/images/${image.data.media_id}`) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + + await axios + .delete(`/api/competitions/${competitionId}/slides/${activeSlide?.id}/components/${image.id}`) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const images = useAppSelector( + (state) => + state.editor.competition.slides + .find((slide) => slide.id === state.editor.activeSlideId) + ?.components.filter((component) => component.type_id === 2) as ImageComponent[] + ) + 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> + <WhiteBackground> + <ListItem divider> + <Center> + <ListItemText primary="Bilder" /> + </Center> + </ListItem> + {images && + images.map((image) => ( + <div key={image.id}> + <ListItem divider button> + <ImportedImage src={`http://localhost:5000/static/images/thumbnail_${image.data.filename}`} /> + <Center> + <ListItemText primary={image.data.filename} /> + </Center> + <CloseIcon onClick={() => handleCloseimageClick(image)} /> + </ListItem> + </div> + ))} + + <ListItem button> <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> + <AddButton variant="button">Lägg till bild</AddButton> </AddImageButton> - </Center> - </ListItem> + </ListItem> + </WhiteBackground> </SettingsList> ) } diff --git a/client/src/pages/presentationEditor/components/RndComponent.tsx b/client/src/pages/presentationEditor/components/RndComponent.tsx index 8142c1a79dc5fe23cc88635af7521f4811bff542..02344a36aa223b245fe7170c9186ffa8b509e6dc 100644 --- a/client/src/pages/presentationEditor/components/RndComponent.tsx +++ b/client/src/pages/presentationEditor/components/RndComponent.tsx @@ -46,7 +46,14 @@ const RndComponent = ({ component }: ImageComponentProps) => { /> ) case ComponentTypes.Image: - return <ImageComponentDisplay key={component.id} component={component as ImageComponent} /> + return ( + <ImageComponentDisplay + key={component.id} + component={component as ImageComponent} + width={currentSize.w} + height={currentSize.h} + /> + ) default: break } diff --git a/client/src/pages/presentationEditor/components/SettingsPanel.test.tsx b/client/src/pages/presentationEditor/components/SettingsPanel.test.tsx index a7a71e25921deb276cbaca2089d2ecbf58c8493c..a17569ec7b6e9e3ed665a294cc7b2b6f1da0be58 100644 --- a/client/src/pages/presentationEditor/components/SettingsPanel.test.tsx +++ b/client/src/pages/presentationEditor/components/SettingsPanel.test.tsx @@ -6,6 +6,7 @@ import { BrowserRouter } from 'react-router-dom' import store from '../../../store' import CompetitionSettings from './CompetitionSettings' import SettingsPanel from './SettingsPanel' +import SlideSettings from './SlideSettings' it('renders settings panel', () => { render( @@ -28,5 +29,5 @@ it('renders slide settings tab', () => { const tabs = wrapper.find('.MuiTabs-flexContainer') expect(wrapper.find(CompetitionSettings).length).toEqual(1) tabs.children().at(1).simulate('click') - expect(wrapper.text().includes('2')).toBe(true) //TODO: check that SlideSettings exists + expect(wrapper.find(SlideSettings).length).toEqual(1) }) diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index 2a64d7ac5f77809e66ca2cfd8331e91789c6c26e..557e4809aa0f4aa5f1ff76d5d2e5dcd17bd16174 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -32,7 +32,7 @@ const SlideSettings: React.FC = () => { {activeSlide && <Texts activeSlide={activeSlide} competitionId={id} />} - {activeSlide && <Images />} + {activeSlide && <Images activeSlide={activeSlide} competitionId={id} />} <SettingsList> <ListItem button> diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index 1aa0b853e2ba6b15a1aed58d01c7d3007357fb86..487359149515d7e7e379a8025da5edac8994232c 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -102,8 +102,14 @@ export const Clickable = styled.div` ` export const AddImageButton = styled.label` - padding: 5px; + padding: 0; cursor: 'pointer'; + display: flex; + justify-content: center; + text-align: center; + height: 100%; + width: 100%; + cursor: pointer; ` export const SettingsList = styled(List)` diff --git a/client/src/reducers/allReducers.ts b/client/src/reducers/allReducers.ts index c23384c2a2a21dfba86c60550154985a5c879782..398ec0a71669a6eab2aaf851ac0ea0f10b226b91 100644 --- a/client/src/reducers/allReducers.ts +++ b/client/src/reducers/allReducers.ts @@ -4,6 +4,7 @@ import { combineReducers } from 'redux' import citiesReducer from './citiesReducer' import competitionsReducer from './competitionsReducer' import editorReducer from './editorReducer' +import mediaReducer from './mediaReducer' import presentationReducer from './presentationReducer' import rolesReducer from './rolesReducer' import searchUserReducer from './searchUserReducer' @@ -23,6 +24,7 @@ const allReducers = combineReducers({ roles: rolesReducer, searchUsers: searchUserReducer, types: typesReducer, + media: mediaReducer, statistics: statisticsReducer, }) export default allReducers diff --git a/client/src/reducers/mediaReducer.ts b/client/src/reducers/mediaReducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad5f3b46547f2e9a20135537ff814b196ca22331 --- /dev/null +++ b/client/src/reducers/mediaReducer.ts @@ -0,0 +1,39 @@ +import { AnyAction } from 'redux' +import Types from '../actions/types' + +interface MediaState { + id: number + filename: string + mediatype_id: number + user_id: number +} +const initialState: MediaState = { + id: 0, + filename: '', + mediatype_id: 1, + user_id: 0, +} + +export default function (state = initialState, action: AnyAction) { + switch (action.type) { + case Types.SET_MEDIA_ID: + return { ...state, id: action.payload as number } + case Types.SET_MEDIA_FILENAME: + return { + ...state, + filename: action.payload as string, + } + case Types.SET_MEDIA_TYPE_ID: + return { + ...state, + mediatype_id: action.payload as number, + } + case Types.SET_MEDIA_USER_ID: + return { + ...state, + user_id: action.payload as number, + } + default: + return state + } +} diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 79bdbc1c48fd7460e85fe18f13a744f45a0a57e1..378f99af09167fc7ea6d7b40798d355cff5e8514 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -1,3 +1,4 @@ +from marshmallow.decorators import pre_dump import app.database.models as models from app.core import ma from marshmallow_sqlalchemy import fields @@ -37,8 +38,9 @@ class CodeSchema(IdNameSchema): id = ma.auto_field() code = ma.auto_field() - pointer = ma.auto_field() view_type_id = ma.auto_field() + competition_id = fields.fields.Integer() + team_id = fields.fields.Integer() class ViewTypeSchema(IdNameSchema): diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index 1dbcf44230e508c3d51b3aa8faa4ba2c84e97914..1f57f7d06c74d9a0fd9999c99a5108b1d59ad25f 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -2,10 +2,11 @@ This file contains functionality to add data to the database. """ -from sqlalchemy.orm.session import sessionmaker +import os + import app.core.http_codes as codes from app.core import db -from app.database.controller import utils +from app.database.controller import get, search, utils from app.database.models import ( Blacklist, City, @@ -25,8 +26,12 @@ from app.database.models import ( User, ViewType, ) +from flask.globals import current_app from flask_restx import abort +from PIL import Image from sqlalchemy import exc +from sqlalchemy.orm import relation +from sqlalchemy.orm.session import sessionmaker def db_add(item): @@ -97,6 +102,21 @@ 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 . """ + from app.apis.media import PHOTO_PATH + + if type_id == 2: # 2 is image + item_image = get.one(Media, data["media_id"]) + filename = item_image.filename + path = os.path.join(PHOTO_PATH, filename) + with Image.open(path) as im: + h = im.height + w = im.width + + largest = max(w, h) + if largest > 600: + ratio = 600 / largest + w *= ratio + h *= ratio return db_add(Component(slide_id, type_id, data, x, y, w, h)) @@ -131,11 +151,11 @@ def question_answer(data, score, question_id, team_id): return db_add(QuestionAnswer(data, score, question_id, team_id)) -def code(pointer, view_type_id): +def code(view_type_id, competition_id=None, team_id=None): """ Adds a code to the database using the provided arguments. """ code_string = utils.generate_unique_code() - return db_add(Code(code_string, pointer, view_type_id)) + return db_add(Code(code_string, view_type_id, competition_id, team_id)) def team(name, competition_id): @@ -144,7 +164,7 @@ def team(name, competition_id): item = db_add(Team(name, competition_id)) # Add code for the team - code(item.id, 1) + code(1, competition_id, item.id) return item @@ -189,10 +209,9 @@ def competition(name, year, city_id): slide(item_competition.id) # Add code for Judge view - code(item_competition.id, 2) - + code(2, item_competition.id) # Add code for Audience view - code(item_competition.id, 3) + code(3, item_competition.id) item_competition = utils.refresh(item_competition) return item_competition diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py index 6787c92c1a2dd873d6ae4c5304daca9b0201409c..57acb27ef28a17a19a7fa5a6b6f7de6229090e10 100644 --- a/server/app/database/controller/get.py +++ b/server/app/database/controller/get.py @@ -40,7 +40,7 @@ def code_by_code(code): def code_list(competition_id): """ Gets a list of all code objects associated with a the provided competition. """ # team_view_id = 1 - join_competition = Competition.id == Code.pointer + join_competition = Competition.id == Code.competition_id filters = Competition.id == competition_id return Code.query.join(Competition, join_competition).filter(filters).all() diff --git a/server/app/database/models.py b/server/app/database/models.py index cded9ddf7aa00d2b5545a2ae5f40cf28eecba209..7174080b799eeea7d5956ca641c0c8fdca8d5ee2 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -205,17 +205,17 @@ class Component(db.Model): class Code(db.Model): - table_args = (db.UniqueConstraint("pointer", "type"),) id = db.Column(db.Integer, primary_key=True) code = db.Column(db.Text, unique=True) - pointer = db.Column(db.Integer, nullable=False) - view_type_id = db.Column(db.Integer, db.ForeignKey("view_type.id"), nullable=False) + competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=False) + team_id = db.Column(db.Integer, db.ForeignKey("team.id"), nullable=True) - def __init__(self, code, pointer, view_type_id): + def __init__(self, code, view_type_id, competition_id=None, team_id=None): self.code = code - self.pointer = pointer self.view_type_id = view_type_id + self.competition_id = competition_id + self.team_id = team_id class ViewType(db.Model): diff --git a/server/populate.py b/server/populate.py index 9ca3c95e7f683decfac5e9f697d0cfdbff958e1e..703e96c2009dcddcf8cc5fc2865854ed106f621a 100644 --- a/server/populate.py +++ b/server/populate.py @@ -100,6 +100,7 @@ if __name__ == "__main__": app, _ = create_app("configmodule.DevelopmentConfig") with app.app_context(): + db.drop_all() db.create_all() _add_items()