diff --git a/client/public/favicon.ico b/client/public/favicon.ico index a11777cc471a4344702741ab1c8a588998b1311a..47e3c3fa2717eb81cf538764dce8bc4776ed61c1 100644 Binary files a/client/public/favicon.ico and b/client/public/favicon.ico differ diff --git a/client/public/index.html b/client/public/index.html index aa069f27cbd9d53394428171c3989fd03db73c76..c3e215c0d247809d37fc2a433e8ba54c2f861181 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -5,10 +5,7 @@ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#000000" /> - <meta - name="description" - content="Web site created using create-react-app" - /> + <meta name="description" content="Web site created using create-react-app" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <!-- manifest.json provides metadata used when your web app is installed on a @@ -24,7 +21,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - <title>React App</title> + <title>Teknikåttan</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> diff --git a/client/src/actions/roles.ts b/client/src/actions/roles.ts new file mode 100644 index 0000000000000000000000000000000000000000..0160353b184755b33f003592ad127880af2803d7 --- /dev/null +++ b/client/src/actions/roles.ts @@ -0,0 +1,15 @@ +import axios from 'axios' +import { AppDispatch } from './../store' +import Types from './types' + +export const getRoles = () => async (dispatch: AppDispatch) => { + await axios + .get('/misc/roles') + .then((res) => { + dispatch({ + type: Types.SET_ROLES, + payload: res.data.items, + }) + }) + .catch((err) => console.log(err)) +} diff --git a/client/src/actions/searchUser.ts b/client/src/actions/searchUser.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb4bf122e40d13952537c3e942bf351f10f99604 --- /dev/null +++ b/client/src/actions/searchUser.ts @@ -0,0 +1,39 @@ +import axios from 'axios' +import { UserFilterParams } from '../interfaces/UserData' +import { AppDispatch, RootState } from './../store' +import Types from './types' + +export const getSearchUsers = () => async (dispatch: AppDispatch, getState: () => RootState) => { + const currentParams: UserFilterParams = getState().searchUsers.filterParams + // Send params in snake-case for api + const params = { + page_size: currentParams.pageSize, + role_id: currentParams.roleId, + city_id: currentParams.cityId, + name: currentParams.name, + page: currentParams.page, + email: currentParams.email, + } + await axios + .get('/users/search', { params }) + .then((res) => { + dispatch({ + type: Types.SET_SEARCH_USERS, + payload: res.data.items, + }) + dispatch({ + type: Types.SET_SEARCH_USERS_TOTAL_COUNT, + payload: res.data.total_count, + }) + dispatch({ + type: Types.SET_SEARCH_USERS_COUNT, + payload: res.data.count, + }) + }) + .catch((err) => { + console.log(err) + }) +} +export const setFilterParams = (params: UserFilterParams) => (dispatch: AppDispatch) => { + dispatch({ type: Types.SET_SEARCH_USERS_FILTER_PARAMS, payload: params }) +} diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index 55a1d61d43381229af3a8555cbd884f22f5b05fe..d18be7aba0704c35935c2735e4fe47cf1cb00700 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -1,7 +1,12 @@ export default { LOADING_UI: 'LOADING_UI', LOADING_USER: 'LOADING_USER', + SET_ROLES: 'SET_ROLES', SET_USER: 'SET_USER', + SET_SEARCH_USERS: 'SET_SEARCH_USERS', + SET_SEARCH_USERS_FILTER_PARAMS: 'SET_SEARCH_USERS_FILTER_PARAMS', + SET_SEARCH_USERS_COUNT: 'SET_SEARCH_USERS_COUNT', + SET_SEARCH_USERS_TOTAL_COUNT: 'SET_SEARCH_USERS_TOTAL_COUNT', SET_ERRORS: 'SET_ERRORS', CLEAR_ERRORS: 'SET_ERRORS', SET_UNAUTHENTICATED: 'SET_UNAUTHENTICATED', diff --git a/client/src/interfaces/Competition.ts b/client/src/interfaces/Competition.ts index aaf51951e7ce0b3014cba9a4676e76a4f1b77814..d36d97da1bc824f996fb7c46e88eaa71aa720995 100644 --- a/client/src/interfaces/Competition.ts +++ b/client/src/interfaces/Competition.ts @@ -1,10 +1,6 @@ -import { City } from './City' -import { Slide } from './Slide' - export interface Competition { name: string id: number - city: City + city_id: number year: number - slides: Slide[] } diff --git a/client/src/interfaces/Role.ts b/client/src/interfaces/Role.ts index 4165fe7beddd0d6787ccf67aabc5409dd8612623..08a54dfcb012ee03398d17d4a7153ab040cde909 100644 --- a/client/src/interfaces/Role.ts +++ b/client/src/interfaces/Role.ts @@ -1,9 +1,4 @@ -import { City } from './City' - -export interface Competition { - name: string - city: City - style_id: number - year: number +export interface Role { id: number + name: string } diff --git a/client/src/interfaces/SearchUserFilterParams.ts b/client/src/interfaces/SearchUserFilterParams.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d7230d83d12d164a0db4683b4db7b791d835a81 --- /dev/null +++ b/client/src/interfaces/SearchUserFilterParams.ts @@ -0,0 +1,8 @@ +export interface SearchUSerFilterParams { + name?: string + year?: number + cityId?: number + styleId?: number + page: number + pageSize: number +} diff --git a/client/src/interfaces/UserData.ts b/client/src/interfaces/UserData.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0c7394699b314c49e8f19bb259b6d73dd610bcf --- /dev/null +++ b/client/src/interfaces/UserData.ts @@ -0,0 +1,16 @@ +export interface UserData { + id: number + name?: string + email: string + role_id: number + city_id: number +} + +export interface UserFilterParams { + name?: string + email?: string + cityId?: number + roleId?: number + page: number + pageSize: number +} diff --git a/client/src/interfaces/models.ts b/client/src/interfaces/models.ts index 91920bd635e522072ff206b8e03554bb788c969f..70d4cc08b5f8e449ad251526389dd84fe42a4d3e 100644 --- a/client/src/interfaces/models.ts +++ b/client/src/interfaces/models.ts @@ -12,3 +12,18 @@ export interface AddCompetitionModel { export interface CompetitionLoginModel { code: string } + +export interface AddUserModel { + email: string + password: string + role: string + city: string + name?: string +} + +export interface EditUserModel { + email: string + role: string + city: string + name?: string +} diff --git a/client/src/pages/admin/AdminPage.tsx b/client/src/pages/admin/AdminPage.tsx index 27564cfed0c25773fd9e19add6455c4474a18d77..7a011687e4ea1362edd50b2359a9c27ac8ec9baa 100644 --- a/client/src/pages/admin/AdminPage.tsx +++ b/client/src/pages/admin/AdminPage.tsx @@ -12,17 +12,25 @@ import { } from '@material-ui/core' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import DashboardIcon from '@material-ui/icons/Dashboard' -import MailIcon from '@material-ui/icons/Mail' +import ExitToAppIcon from '@material-ui/icons/ExitToApp' +import LocationCityIcon from '@material-ui/icons/LocationCity' +import PeopleIcon from '@material-ui/icons/People' +import SettingsOverscanIcon from '@material-ui/icons/SettingsOverscan' import React from 'react' import { Link, Route, Switch, useRouteMatch } from 'react-router-dom' import { logoutUser } from '../../actions/user' import { useAppDispatch } from '../../hooks' import CompetitionManager from './components/CompetitionManager' import Regions from './components/Regions' +import UserManager from './components/UserManager' import { LeftDrawer } from './styled' - const drawerWidth = 250 -const menuItems = ['Startsida', 'Regioner', 'Användare', 'Tävlingshanterare'] +const menuItems = [ + { text: 'Startsida', icon: DashboardIcon }, + { text: 'Regioner', icon: LocationCityIcon }, + { text: 'Användare', icon: PeopleIcon }, + { text: 'Tävlingshanterare', icon: SettingsOverscanIcon }, +] const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -60,7 +68,7 @@ const AdminView: React.FC = () => { <AppBar position="fixed" className={classes.appBar}> <Toolbar> <Typography variant="h5" noWrap> - {menuItems[openIndex]} + {menuItems[openIndex].text} </Typography> </Toolbar> </AppBar> @@ -76,24 +84,31 @@ const AdminView: React.FC = () => { <div className={classes.toolbar} /> <Divider /> <List> - {menuItems.map((text, index) => ( + {menuItems.map((value, index) => ( <ListItem button component={Link} - key={text} - to={`${url}/${text.toLowerCase()}`} + key={value.text} + to={`${url}/${value.text.toLowerCase()}`} selected={index === openIndex} onClick={() => setOpenIndex(index)} > - <ListItemIcon>{index === 0 ? <DashboardIcon /> : <MailIcon />}</ListItemIcon> - <ListItemText primary={text} /> + <ListItemIcon>{React.createElement(value.icon)}</ListItemIcon> + <ListItemText primary={value.text} /> </ListItem> ))} </List> <Divider /> <List> <ListItem> - <Button onClick={handleLogout} type="submit" fullWidth variant="contained" color="primary"> + <Button + onClick={handleLogout} + type="submit" + fullWidth + variant="contained" + color="primary" + endIcon={<ExitToAppIcon></ExitToAppIcon>} + > Logga ut </Button> </ListItem> @@ -112,9 +127,7 @@ const AdminView: React.FC = () => { <Regions /> </Route> <Route path={`${path}/användare`}> - <Typography variant="h1" noWrap> - Användare - </Typography> + <UserManager /> </Route> <Route path={`${path}/tävlingshanterare`}> <CompetitionManager /> diff --git a/client/src/pages/admin/components/AddCompetition.tsx b/client/src/pages/admin/components/AddCompetition.tsx index d80195e58adec0c8dfd30d1a636481671ab4e7a5..7bc82f00bbe7a21712b5a4b3b3b0b22b17de5bda 100644 --- a/client/src/pages/admin/components/AddCompetition.tsx +++ b/client/src/pages/admin/components/AddCompetition.tsx @@ -8,8 +8,7 @@ import { getCompetitions } from '../../../actions/competitions' import { useAppDispatch, useAppSelector } from '../../../hooks' import { City } from '../../../interfaces/City' import { AddCompetitionModel } from '../../../interfaces/models' -import { AddCompetitionButton, AddCompetitionContent, AddCompetitionForm } from './styled' - +import { AddButton, AddContent, AddForm } from './styled' interface ServerResponse { code: number message: string @@ -59,7 +58,6 @@ const AddCompetition: React.FC = (props: any) => { name: values.model.name, year: values.model.year, city_id: selectedCity?.id as number, - style_id: 1, } await axios .post<ServerResponse>('/competitions', params) @@ -85,9 +83,9 @@ const AddCompetition: React.FC = (props: any) => { } return ( <div> - <AddCompetitionButton color="secondary" variant="contained" onClick={handleClick}> + <AddButton color="default" variant="contained" onClick={handleClick}> Ny Tävling - </AddCompetitionButton> + </AddButton> <Popover id={id} open={open} @@ -102,14 +100,14 @@ const AddCompetition: React.FC = (props: any) => { horizontal: 'center', }} > - <AddCompetitionContent> + <AddContent> <Formik initialValues={competitionInitialValues} validationSchema={competitionSchema} onSubmit={handleCompetitionSubmit} > {(formik) => ( - <AddCompetitionForm onSubmit={formik.handleSubmit}> + <AddForm onSubmit={formik.handleSubmit}> <TextField label="Namn" name="model.name" @@ -171,10 +169,10 @@ const AddCompetition: React.FC = (props: any) => { {formik.errors.error} </Alert> )} - </AddCompetitionForm> + </AddForm> )} </Formik> - </AddCompetitionContent> + </AddContent> </Popover> </div> ) diff --git a/client/src/pages/admin/components/AddRegion.tsx b/client/src/pages/admin/components/AddRegion.tsx new file mode 100644 index 0000000000000000000000000000000000000000..658fe63fdbc5e36d3eb2f63c50e428565a89295d --- /dev/null +++ b/client/src/pages/admin/components/AddRegion.tsx @@ -0,0 +1,115 @@ +import { Button, Grid, TextField } from '@material-ui/core' +import FormControl from '@material-ui/core/FormControl' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import AddIcon from '@material-ui/icons/Add' +import { Alert, AlertTitle } from '@material-ui/lab' +import axios from 'axios' +import { Formik, FormikHelpers } from 'formik' +import React from 'react' +import * as Yup from 'yup' +import { getCities } from '../../../actions/cities' +import { useAppDispatch } from '../../../hooks' +import { AddForm } from './styled' + +interface AddRegionModel { + city: '' +} +interface ServerResponse { + code: number + message: string +} +interface AddRegionFormModel { + model: AddRegionModel + error?: string +} + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + table: { + width: '100%', + }, + margin: { + margin: theme.spacing(1), + }, + button: { + width: '40px', + height: '40px', + marginTop: '20px', + }, + }) +) + +const schema: Yup.SchemaOf<AddRegionFormModel> = Yup.object({ + model: Yup.object() + .shape({ + city: Yup.string() + .required('Minst två bokstäver krävs') + .min(2) + .matches(/[a-zA-Z]/, 'Namnet får enbart innehålla a-z, A-Z.'), + }) + .required(), + error: Yup.string().optional(), +}) + +const AddRegion: React.FC = (props: any) => { + const classes = useStyles() + const dispatch = useAppDispatch() + + const handleSubmit = async (values: AddRegionFormModel, actions: FormikHelpers<AddRegionFormModel>) => { + const params = { + name: values.model.city, + } + await axios + .post<ServerResponse>('/misc/cities', params) + .then(() => { + actions.resetForm() + dispatch(getCities()) + }) + .catch(({ response }) => { + console.warn(response.data) + if (response.data && response.data.message) + actions.setFieldError('error', response.data && response.data.message) + else actions.setFieldError('error', 'Something went wrong, please try again') + }) + .finally(() => { + actions.setSubmitting(false) + }) + } + + const initValues: AddRegionFormModel = { + model: { city: '' }, + } + + return ( + <Formik initialValues={initValues} validationSchema={schema} onSubmit={handleSubmit}> + {(formik) => ( + <AddForm onSubmit={formik.handleSubmit}> + <FormControl className={classes.margin}> + <Grid container={true}> + <TextField + className={classes.margin} + helperText={formik.touched.model?.city ? formik.errors.model?.city : ''} + error={Boolean(formik.touched.model?.city && formik.errors.model?.city)} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + name="model.city" + label="Region" + ></TextField> + <Button className={classes.button} color="default" variant="contained" type="submit"> + <AddIcon></AddIcon> + </Button> + </Grid> + </FormControl> + {formik.errors.error && ( + <Alert severity="error"> + <AlertTitle>Error</AlertTitle> + {formik.errors.error} + </Alert> + )} + </AddForm> + )} + </Formik> + ) +} + +export default AddRegion diff --git a/client/src/pages/admin/components/AddUser.tsx b/client/src/pages/admin/components/AddUser.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f55caa3a83b552536c78d3741fbe5e85bb603707 --- /dev/null +++ b/client/src/pages/admin/components/AddUser.tsx @@ -0,0 +1,220 @@ +import { Button, FormControl, InputLabel, MenuItem, Popover, TextField } from '@material-ui/core' +import PersonAddIcon from '@material-ui/icons/PersonAdd' +import { Alert, AlertTitle } from '@material-ui/lab' +import axios from 'axios' +import { Formik, FormikHelpers } from 'formik' +import React from 'react' +import * as Yup from 'yup' +import { getSearchUsers } from '../../../actions/searchUser' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { City } from '../../../interfaces/City' +import { AddUserModel } from '../../../interfaces/models' +import { Role } from '../../../interfaces/Role' +import { AddButton, AddContent, AddForm } from './styled' +interface ServerResponse { + code: number + message: string +} + +interface AddUserFormModel { + model: AddUserModel + error?: string +} + +const noRoleSelected = 'Välj roll' +const noCitySelected = 'Välj stad' + +const userSchema: Yup.SchemaOf<AddUserFormModel> = Yup.object({ + model: Yup.object() + .shape({ + name: Yup.string(), //.required('Namn krävs'), + email: Yup.string().email().required('Email krävs'), + password: Yup.string() + .required('Lösenord krävs.') + .min(6, 'Lösenord måste vara minst 6 tecken.') + .matches(/[a-zA-Z]/, 'Lösenord får enbart innehålla a-z, A-Z.'), + role: Yup.string().required('Roll krävs').notOneOf([noCitySelected], 'Välj en roll'), + city: Yup.string().required('Stad krävs').notOneOf([noRoleSelected], 'Välj en stad'), + }) + .required(), + error: Yup.string().optional(), +}) + +const AddUser: React.FC = (props: any) => { + const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) + const [selectedRole, setSelectedRole] = React.useState<Role | undefined>() + const roles = useAppSelector((state) => state.roles.roles) + + const [selectedCity, setSelectedCity] = React.useState<City | undefined>() + const cities = useAppSelector((state) => state.cities.cities) + + const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const open = Boolean(anchorEl) + const dispatch = useAppDispatch() + const id = open ? 'simple-popover' : undefined + const handleCompetitionSubmit = async (values: AddUserFormModel, actions: FormikHelpers<AddUserFormModel>) => { + const params = { + email: values.model.email, + password: values.model.password, + //name: values.model.name, + city_id: selectedCity?.id as number, + role_id: selectedRole?.id as number, + } + await axios + .post<ServerResponse>('/auth/signup', params) + .then(() => { + actions.resetForm() + setAnchorEl(null) + dispatch(getSearchUsers()) + setSelectedCity(undefined) + setSelectedRole(undefined) + }) + .catch(({ response }) => { + console.warn(response.data) + if (response.data && response.data.message) + actions.setFieldError('error', response.data && response.data.message) + else actions.setFieldError('error', 'Something went wrong, please try again') + }) + .finally(() => { + actions.setSubmitting(false) + }) + } + + const userInitialValues: AddUserFormModel = { + model: { email: '', password: '', name: '', city: noCitySelected, role: noRoleSelected }, + } + return ( + <div> + <AddButton color="default" variant="contained" onClick={handleClick} endIcon={<PersonAddIcon></PersonAddIcon>}> + Ny Användare + </AddButton> + <Popover + id={id} + open={open} + anchorEl={anchorEl} + onClose={handleClose} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + <AddContent> + <Formik initialValues={userInitialValues} validationSchema={userSchema} onSubmit={handleCompetitionSubmit}> + {(formik) => ( + <AddForm onSubmit={formik.handleSubmit}> + <TextField + label="Email" + name="model.email" + helperText={formik.touched.model?.email ? formik.errors.model?.email : ''} + error={Boolean(formik.touched.model?.email && formik.errors.model?.email)} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + margin="normal" + /> + <TextField + label="Lösenord" + name="model.password" + helperText={formik.touched.model?.password ? formik.errors.model?.password : ''} + error={Boolean(formik.touched.model?.password && formik.errors.model?.password)} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + margin="normal" + /> + <TextField + label="Namn" + name="model.name" + helperText={formik.touched.model?.name ? formik.errors.model?.name : ''} + error={Boolean(formik.touched.model?.name && formik.errors.model?.name)} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + margin="normal" + /> + <FormControl> + <InputLabel shrink id="demo-customized-select-native"> + Region + </InputLabel> + <TextField + select + name="model.city" + id="standard-select-currency" + value={selectedCity ? selectedCity.name : noCitySelected} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + error={Boolean(formik.errors.model?.city && formik.touched.model?.city)} + helperText={formik.touched.model?.city && formik.errors.model?.city} + margin="normal" + > + <MenuItem value={noCitySelected} onClick={() => setSelectedCity(undefined)}> + {noCitySelected} + </MenuItem> + {cities && + cities.map((city) => ( + <MenuItem key={city.name} value={city.name} onClick={() => setSelectedCity(city)}> + {city.name} + </MenuItem> + ))} + </TextField> + </FormControl> + + <FormControl> + <InputLabel shrink id="demo-customized-select-native"> + Roll + </InputLabel> + <TextField + select + name="model.role" + id="standard-select-currency" + value={selectedRole ? selectedRole.name : noRoleSelected} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + error={Boolean(formik.errors.model?.role && formik.touched.model?.role)} + helperText={formik.touched.model?.role && formik.errors.model?.role} + margin="normal" + > + <MenuItem value={noRoleSelected} onClick={() => setSelectedRole(undefined)}> + {noRoleSelected} + </MenuItem> + {roles && + roles.map((role) => ( + <MenuItem key={role.name} value={role.name} onClick={() => setSelectedRole(role)}> + {role.name} + </MenuItem> + ))} + </TextField> + </FormControl> + + <Button + type="submit" + fullWidth + variant="contained" + color="secondary" + disabled={!formik.isValid || !formik.values.model?.name || !formik.values.model?.city} + > + Lägg till + </Button> + {formik.errors.error && ( + <Alert severity="error"> + <AlertTitle>Error</AlertTitle> + {formik.errors.error} + </Alert> + )} + </AddForm> + )} + </Formik> + </AddContent> + </Popover> + </div> + ) +} + +export default AddUser diff --git a/client/src/pages/admin/components/CompetitionManager.tsx b/client/src/pages/admin/components/CompetitionManager.tsx index 3489d6a2536b1abfbba947c524457dd12bb9db79..a957608b7bcd9faae999688839056488d1abc8d9 100644 --- a/client/src/pages/admin/components/CompetitionManager.tsx +++ b/client/src/pages/admin/components/CompetitionManager.tsx @@ -20,7 +20,7 @@ import { getCompetitions, setFilterParams } from '../../../actions/competitions' import { useAppDispatch, useAppSelector } from '../../../hooks' import { CompetitionFilterParams } from '../../../interfaces/CompetitionFilterParams' import AddCompetition from './AddCompetition' -import { FilterContainer, RemoveCompetition, TopBar, YearFilterTextField } from './styled' +import { FilterContainer, RemoveMenuItem, TopBar, YearFilterTextField } from './styled' const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -153,7 +153,7 @@ const CompetitionManager: React.FC = (props: any) => { {row.name} </Button> </TableCell> - <TableCell align="right">{cities.find((city) => city.id === row.city.id)?.name || ''}</TableCell> + <TableCell align="right">{cities.find((city) => city.id === row.city_id)?.name || ''}</TableCell> <TableCell align="right">{row.year}</TableCell> <TableCell align="right"> <Button onClick={(event) => handleClick(event, row.id)}> @@ -179,7 +179,7 @@ const CompetitionManager: React.FC = (props: any) => { <Menu id="simple-menu" anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}> <MenuItem onClick={() => history.push(`/presenter/id=${activeId}&code=123123`)}>Starta</MenuItem> <MenuItem onClick={handleClose}>Duplicera</MenuItem> - <RemoveCompetition onClick={handleDeleteCompetition}>Ta bort</RemoveCompetition> + <RemoveMenuItem onClick={handleDeleteCompetition}>Ta bort</RemoveMenuItem> </Menu> </div> ) diff --git a/client/src/pages/admin/components/Regions.test.tsx b/client/src/pages/admin/components/Regions.test.tsx deleted file mode 100644 index 6f7fb873cba29a4a56f736b0ee7d7789ba420428..0000000000000000000000000000000000000000 --- a/client/src/pages/admin/components/Regions.test.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { render } from '@testing-library/react' -import React from 'react' -import Regions from './Regions' - -it('renders regions', () => { - render(<Regions />) -}) diff --git a/client/src/pages/admin/components/Regions.tsx b/client/src/pages/admin/components/Regions.tsx index d58802d06e8c53261a10e183c31984ca767e2f99..7f4ece95e14ed1a661c869e2f9c6824344f61ec5 100644 --- a/client/src/pages/admin/components/Regions.tsx +++ b/client/src/pages/admin/components/Regions.tsx @@ -1,12 +1,116 @@ -import Typography from '@material-ui/core/Typography' -import React from 'react' +import { Button, Menu, Typography } from '@material-ui/core' +import Paper from '@material-ui/core/Paper' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import Table from '@material-ui/core/Table' +import TableBody from '@material-ui/core/TableBody' +import TableCell from '@material-ui/core/TableCell' +import TableContainer from '@material-ui/core/TableContainer' +import TableHead from '@material-ui/core/TableHead' +import TableRow from '@material-ui/core/TableRow' +import MoreHorizIcon from '@material-ui/icons/MoreHoriz' +import axios from 'axios' +import React, { useEffect } from 'react' +import { getCities } from '../../../actions/cities' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import AddRegion from './AddRegion' +import { RemoveMenuItem, TopBar } from './styled' +const useStyles = makeStyles((theme: Theme) => + createStyles({ + table: { + width: '100%', + }, + margin: { + margin: theme.spacing(1), + }, + }) +) + +const UserManager: React.FC = (props: any) => { + const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null) + const [activeId, setActiveId] = React.useState<number | undefined>(undefined) + const citiesTotal = useAppSelector((state) => state.cities.total) + const cities = useAppSelector((state) => state.cities.cities) + const [newCity, setNewCity] = React.useState<string>() + const classes = useStyles() + const dispatch = useAppDispatch() + const handleClose = () => { + setAnchorEl(null) + setActiveId(undefined) + } + + useEffect(() => { + dispatch(getCities()) + }, []) + + const handleDeleteCity = async () => { + if (activeId) { + await axios + .delete(`/misc/cities/${activeId}`) + .then(() => { + setAnchorEl(null) + dispatch(getCities()) + }) + .catch(({ response }) => { + console.warn(response.data) + }) + } + } + + const handleClick = (event: React.MouseEvent<HTMLButtonElement>, id: number) => { + setAnchorEl(event.currentTarget) + setActiveId(id) + } + + const handleAddCity = async () => { + await axios + .post(`/misc/cities`, { name: newCity }) + .then(() => { + setAnchorEl(null) + dispatch(getCities()) + }) + .catch(({ response }) => { + console.warn(response.data) + }) + } + + const handleChange = (event: any) => { + setNewCity(event.target.value) + } -const Regions: React.FC = () => { return ( - <Typography variant="h1" noWrap> - Regions - </Typography> + <div> + <TopBar> + <AddRegion></AddRegion> + </TopBar> + <TableContainer component={Paper}> + <Table className={classes.table} aria-label="simple table"> + <TableHead> + <TableRow> + <TableCell>Regioner</TableCell> + <TableCell align="right"></TableCell> + </TableRow> + </TableHead> + <TableBody> + {cities && + cities.map((row) => ( + <TableRow key={row.name}> + <TableCell scope="row">{row.name}</TableCell> + <TableCell align="right"> + <Button onClick={(event) => handleClick(event, row.id)}> + <MoreHorizIcon /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + {(!cities || cities.length === 0) && <Typography>Inga regioner hittades</Typography>} + </TableContainer> + <Menu id="simple-menu" anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}> + <RemoveMenuItem onClick={handleDeleteCity}>Ta bort</RemoveMenuItem> + </Menu> + </div> ) } -export default Regions +export default UserManager diff --git a/client/src/pages/admin/components/UserManager.tsx b/client/src/pages/admin/components/UserManager.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d85290b2305808cfd35063d3ed7d403b19b57b18 --- /dev/null +++ b/client/src/pages/admin/components/UserManager.tsx @@ -0,0 +1,201 @@ +import { Button, Menu, TablePagination, TextField, Typography } from '@material-ui/core' +import FormControl from '@material-ui/core/FormControl' +import InputLabel from '@material-ui/core/InputLabel' +import MenuItem from '@material-ui/core/MenuItem' +import Paper from '@material-ui/core/Paper' +import Select from '@material-ui/core/Select' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import Table from '@material-ui/core/Table' +import TableBody from '@material-ui/core/TableBody' +import TableCell from '@material-ui/core/TableCell' +import TableContainer from '@material-ui/core/TableContainer' +import TableHead from '@material-ui/core/TableHead' +import TableRow from '@material-ui/core/TableRow' +import MoreHorizIcon from '@material-ui/icons/MoreHoriz' +import axios from 'axios' +import React, { useEffect } from 'react' +import { getCities } from '../../../actions/cities' +import { getRoles } from '../../../actions/roles' +import { getSearchUsers, setFilterParams } from '../../../actions/searchUser' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { UserFilterParams } from '../../../interfaces/UserData' +import AddUser from './AddUser' +import { FilterContainer, RemoveMenuItem, TopBar } from './styled' + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + table: { + width: '100%', + }, + margin: { + margin: theme.spacing(1), + }, + }) +) + +const UserManager: React.FC = (props: any) => { + const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null) + const [activeId, setActiveId] = React.useState<number | undefined>(undefined) + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + const users = useAppSelector((state) => state.searchUsers.users) + const filterParams = useAppSelector((state) => state.searchUsers.filterParams) + const usersTotal = useAppSelector((state) => state.searchUsers.total) + const cities = useAppSelector((state) => state.cities.cities) + const roles = useAppSelector((state) => state.roles.roles) + const classes = useStyles() + const noFilterText = 'Alla' + const dispatch = useAppDispatch() + const handleClick = (event: React.MouseEvent<HTMLButtonElement>, id: number) => { + setAnchorEl(event.currentTarget) + setActiveId(id) + } + + const handleClose = () => { + setAnchorEl(null) + setActiveId(undefined) + } + + useEffect(() => { + dispatch(getCities()) + dispatch(getRoles()) + dispatch(getSearchUsers()) + }, []) + + const onSearchChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates filter and api 100ms after last input was made + setTimerHandle(window.setTimeout(() => dispatch(getSearchUsers()), 100)) + dispatch(setFilterParams({ ...filterParams, email: event.target.value })) + } + + const handleDeleteUsers = async () => { + if (activeId) { + await axios + .delete(`/auth/delete/${activeId}`) + .then(() => { + setAnchorEl(null) + dispatch(getSearchUsers()) + }) + .catch(({ response }) => { + console.warn(response.data) + }) + } + } + + const handleFilterChange = (newParams: UserFilterParams) => { + dispatch(setFilterParams(newParams)) + dispatch(getSearchUsers()) + } + + return ( + <div> + <TopBar> + <FilterContainer> + <TextField + className={classes.margin} + value={filterParams.email || ''} + onChange={onSearchChange} + label="Sök" + ></TextField> + <FormControl className={classes.margin}> + <InputLabel shrink id="demo-customized-select-native"> + Region + </InputLabel> + <Select + labelId="demo-customized-select-label" + id="demo-customized-select" + value={filterParams.cityId ? cities.find((city) => filterParams.cityId === city.id)?.name : noFilterText} + > + <MenuItem value={noFilterText} onClick={() => handleFilterChange({ ...filterParams, cityId: undefined })}> + {noFilterText} + </MenuItem> + {cities && + cities.map((city) => ( + <MenuItem + key={city.name} + value={city.name} + onClick={() => handleFilterChange({ ...filterParams, cityId: city.id })} + > + {city.name} + </MenuItem> + ))} + </Select> + </FormControl> + <FormControl className={classes.margin}> + <InputLabel shrink id="demo-customized-select-native"> + Roles + </InputLabel> + <Select + labelId="demo-customized-select-label" + id="demo-customized-select" + value={filterParams.roleId ? roles.find((role) => filterParams.roleId === role.id)?.name : noFilterText} + > + <MenuItem value={noFilterText} onClick={() => handleFilterChange({ ...filterParams, roleId: undefined })}> + {noFilterText} + </MenuItem> + {roles && + roles.map((role) => ( + <MenuItem + key={role.name} + value={role.name} + onClick={() => handleFilterChange({ ...filterParams, roleId: role.id })} + > + {role.name} + </MenuItem> + ))} + </Select> + </FormControl> + </FilterContainer> + <AddUser /> + </TopBar> + <TableContainer component={Paper}> + <Table className={classes.table} aria-label="simple table"> + <TableHead> + <TableRow> + <TableCell>Email</TableCell> + <TableCell>Namn</TableCell> + <TableCell>Region</TableCell> + <TableCell>Roll</TableCell> + <TableCell align="right"></TableCell> + </TableRow> + </TableHead> + <TableBody> + {users && + users.map((row) => ( + <TableRow key={row.email}> + <TableCell scope="row">{row.email}</TableCell> + <TableCell scope="row">{row.name}</TableCell> + <TableCell>{cities.find((city) => city.id === row.city_id)?.name || ''}</TableCell> + <TableCell>{roles.find((role) => role.id === row.role_id)?.name || ''}</TableCell> + <TableCell align="right"> + <Button onClick={(event) => handleClick(event, row.id)}> + <MoreHorizIcon /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + {(!users || users.length === 0) && <Typography>Inga tävlingar hittades med nuvarande filter</Typography>} + </TableContainer> + <TablePagination + component="div" + rowsPerPageOptions={[]} + rowsPerPage={filterParams.pageSize} + count={usersTotal} + page={filterParams.page} + onChangePage={(event, newPage) => handleFilterChange({ ...filterParams, page: newPage })} + /> + <Menu id="simple-menu" anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}> + <MenuItem>Redigera</MenuItem> + <MenuItem>Byt lösenord</MenuItem> + <RemoveMenuItem onClick={handleDeleteUsers}>Ta bort</RemoveMenuItem> + </Menu> + </div> + ) +} + +export default UserManager diff --git a/client/src/pages/admin/components/styled.tsx b/client/src/pages/admin/components/styled.tsx index 69040178507526760405089015c6928652cf7897..985ed510c666594204c0370314da79955d9b514b 100644 --- a/client/src/pages/admin/components/styled.tsx +++ b/client/src/pages/admin/components/styled.tsx @@ -7,20 +7,20 @@ export const TopBar = styled.div` align-items: flex-end; ` -export const AddCompetitionButton = styled(Button)` +export const AddButton = styled(Button)` margin-bottom: 8px; ` -export const AddCompetitionForm = styled.form` +export const AddForm = styled.form` display: flex; flex-direction: column; ` -export const AddCompetitionContent = styled.div` +export const AddContent = styled.div` padding: 15px; ` -export const RemoveCompetition = styled(MenuItem)` +export const RemoveMenuItem = styled(MenuItem)` color: red; ` diff --git a/client/src/pages/views/ParticipantViewPage.tsx b/client/src/pages/views/ParticipantViewPage.tsx index dc6678c8978070320ed86c545e9a2bd541480827..1e004791c1d1e43c03686449f9a43bf8d20013ff 100644 --- a/client/src/pages/views/ParticipantViewPage.tsx +++ b/client/src/pages/views/ParticipantViewPage.tsx @@ -1,8 +1,8 @@ import React from 'react' -import Slide from './components/Slide' +import SlideDisplay from './components/Slide' const ParticipantViewPage: React.FC = () => { - return <Slide /> + return <SlideDisplay /> } export default ParticipantViewPage diff --git a/client/src/pages/views/components/Slide.test.tsx b/client/src/pages/views/components/Slide.test.tsx index b4f2cfb9134e116914bbb520f4d4d34c15f77618..bc278433a79b439c593efa3c390e8fda6b638825 100644 --- a/client/src/pages/views/components/Slide.test.tsx +++ b/client/src/pages/views/components/Slide.test.tsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react' import React from 'react' -import Slide from './Slide' +import SlideDisplay from './Slide' it('renders audience view page', () => { - render(<Slide />) + render(<SlideDisplay />) }) diff --git a/client/src/reducers/allReducers.ts b/client/src/reducers/allReducers.ts index bb14776ef2370aaaed66fcd54abaec7ea5c135f2..94743ff18c546c80e9ce7400e5afdace7e871bf4 100644 --- a/client/src/reducers/allReducers.ts +++ b/client/src/reducers/allReducers.ts @@ -4,6 +4,8 @@ import { combineReducers } from 'redux' import citiesReducer from './citiesReducer' import competitionsReducer from './competitionsReducer' import presentationReducer from './presentationReducer' +import rolesReducer from './rolesReducer' +import searchUserReducer from './searchUserReducer' import uiReducer from './uiReducer' import userReducer from './userReducer' @@ -14,5 +16,7 @@ const allReducers = combineReducers({ competitions: competitionsReducer, cities: citiesReducer, presentation: presentationReducer, + roles: rolesReducer, + searchUsers: searchUserReducer, }) export default allReducers diff --git a/client/src/reducers/rolesReducer.ts b/client/src/reducers/rolesReducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..652d4afcf12c1f58aad0c1b9cf21ab36e2ca96da --- /dev/null +++ b/client/src/reducers/rolesReducer.ts @@ -0,0 +1,19 @@ +import { AnyAction } from 'redux' +import Types from '../actions/types' +import { Role } from '../interfaces/Role' + +interface RoleState { + roles: Role[] +} +const initialState: RoleState = { + roles: [], +} + +export default function (state = initialState, action: AnyAction) { + switch (action.type) { + case Types.SET_ROLES: + return { ...state, roles: action.payload as Role[] } + default: + return state + } +} diff --git a/client/src/reducers/searchUserReducer.ts b/client/src/reducers/searchUserReducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b77d68af26b3fbf2f0b428c8a6a959f6e9c5991 --- /dev/null +++ b/client/src/reducers/searchUserReducer.ts @@ -0,0 +1,44 @@ +import { AnyAction } from 'redux' +import Types from '../actions/types' +import { UserData, UserFilterParams } from '../interfaces/UserData' + +interface SearchUserState { + users: UserData[] + total: number + count: number + filterParams: UserFilterParams +} + +const initialState: SearchUserState = { + users: [], + total: 0, + count: 0, + filterParams: { pageSize: 10, page: 0 }, +} + +export default function (state = initialState, action: AnyAction) { + switch (action.type) { + case Types.SET_SEARCH_USERS: + return { + ...state, + users: action.payload as UserData[], + } + case Types.SET_SEARCH_USERS_FILTER_PARAMS: + return { + ...state, + filterParams: action.payload as UserFilterParams, + } + case Types.SET_SEARCH_USERS_TOTAL_COUNT: + return { + ...state, + total: action.payload as number, + } + case Types.SET_SEARCH_USERS_COUNT: + return { + ...state, + count: action.payload as number, + } + default: + return state + } +} diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index 920d2d65ea482e70a51c4547419656f97f157a31..4fa5624af098b74b11e321cb5b2dd1bc45e9c610 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -32,11 +32,12 @@ class AuthSignup(Resource): password = args.get("password") role_id = args.get("role_id") city_id = args.get("city_id") + name = args.get("name") if User.query.filter(User.email == email).count() > 0: api.abort(codes.BAD_REQUEST, "User already exists") - item_user = dbc.add.default(User(email, password, role_id, city_id)) + item_user = dbc.add.user(email, password, role_id, city_id, name) if not item_user: api.abort(codes.BAD_REQUEST, "User could not be created") diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index 076cfd736cb9448d81a806e39d10edf9eed035d9..8cdaf6dadf861c197082857131003849514faec8 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -24,13 +24,12 @@ class CompetitionsList(Resource): name = args.get("name") city_id = args.get("city_id") year = args.get("year") - style_id = args.get("style_id") # Add competition - item = dbc.add.default(Competition(name, year, style_id, city_id)) + item = dbc.add.competition(name, year, city_id) # Add default slide - item_slide = dbc.add.slide(item) + dbc.add.slide(item) dbc.refresh(item) return item_response(schema.dump(item)) @@ -51,8 +50,7 @@ class Competitions(Resource): name = args.get("name") year = args.get("year") city_id = args.get("city_id") - style_id = args.get("style_id") - item = dbc.edit.competition(item, name, year, city_id, style_id) + item = dbc.edit.competition(item, name, year, city_id) return item_response(schema.dump(item)) @jwt_required @@ -70,11 +68,10 @@ class CompetitionSearch(Resource): name = args.get("name") year = args.get("year") city_id = args.get("city_id") - style_id = args.get("style_id") page = args.get("page", 0) page_size = args.get("page_size", 15) order = args.get("order", 1) order_by = args.get("order_by") - items, total = dbc.get.search_competitions(name, year, city_id, style_id, page, page_size, order, order_by) + items, total = dbc.get.search_competitions(name, year, city_id, page, page_size, order, order_by) return list_response(list_schema.dump(items), total) diff --git a/server/app/apis/misc.py b/server/app/apis/misc.py index 13055f211e480a2f16b42d74d9517514e04c67f7..f5b42fda6ab46fc76dd70a774fd3baf9ef467938 100644 --- a/server/app/apis/misc.py +++ b/server/app/apis/misc.py @@ -51,7 +51,7 @@ class CitiesList(Resource): @jwt_required def post(self): args = name_parser.parse_args(strict=True) - dbc.add.default(City(args["name"])) + dbc.add.city(args["name"]) items = City.query.all() return list_response(city_schema.dump(items)) diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index 6a2b1714a101cde574f188d3876243e9a5d97f86..6d9465e262c83ad3170683938d7fa3558357d241 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -1,12 +1,11 @@ import app.core.controller as dbc import app.core.http_codes as codes from app.apis import admin_required, item_response, list_response -from app.core import schemas from app.core.dto import SlideDTO from app.core.models import Competition, Slide from app.core.parsers import slide_parser -from flask_jwt_extended import get_jwt_identity, jwt_required -from flask_restx import Namespace, Resource, reqparse +from flask_jwt_extended import jwt_required +from flask_restx import Resource api = SlideDTO.api schema = SlideDTO.schema diff --git a/server/app/apis/teams.py b/server/app/apis/teams.py index 5c0c48797e63312d6f88a4cc656efec1de00c2bb..3eb09bc296be74034ebe814a69724598ec2a27f8 100644 --- a/server/app/apis/teams.py +++ b/server/app/apis/teams.py @@ -27,8 +27,9 @@ class TeamsList(Resource): parser.add_argument("name", type=str, location="json") args = parser.parse_args(strict=True) - dbc.add.default(Team(args["name"], CID)) item_comp = get_comp(CID) + dbc.add.team(args["name"], item_comp) + dbc.refresh(item_comp) return list_response(list_schema.dump(item_comp.teams)) diff --git a/server/app/core/controller/add.py b/server/app/core/controller/add.py index 14c5eecf04940b2daf1c6e563b09f617e68b3cf1..2352ec0d7e8a7dbb462aeb8069027da6d0de47da 100644 --- a/server/app/core/controller/add.py +++ b/server/app/core/controller/add.py @@ -1,23 +1,64 @@ from app.core import db -from app.core.models import Blacklist, City, Competition, Role, Slide, User +from app.core.models import Blacklist, City, Competition, MediaType, Question, QuestionType, Role, Slide, Team, User -def default(item): - db.session.add(item) - db.session.commit() - db.session.refresh(item) - return item +def db_add(func): + def wrapper(*args, **kwargs): + item = func(*args, **kwargs) + db.session.add(item) + db.session.commit() + db.session.refresh(item) + return item + return wrapper + +@db_add def blacklist(jti): - db.session.add(Blacklist(jti)) - db.session.commit() + return Blacklist(jti) +@db_add def slide(item_competition): - order = Slide.query.filter(Slide.competition_id == item_competition.id).count() - item = Slide(order, item_competition.id) - db.session.add(item) - db.session.commit() - db.session.refresh(item) - return item + order = Slide.query.filter(Slide.competition_id == item_competition.id).count() # first element has index 0 + return Slide(order, item_competition.id) + + +@db_add +def user(email, plaintext_password, role_id, city_id, name=None): + return User(email, plaintext_password, role_id, city_id, name) + + +@db_add +def question(name, order, type_id, item_slide): + return Question(name, order, type_id, item_slide.id) + + +@db_add +def competition(name, year, city_id): + return Competition(name, year, city_id) + + +@db_add +def team(name, item_competition): + return Team(name, item_competition.id) + + +@db_add +def mediaType(name): + return MediaType(name) + + +@db_add +def questionType(name): + return QuestionType(name) + + +@db_add +def role(name): + return Role(name) + + +@db_add +def city(name): + return City(name) diff --git a/server/app/core/controller/edit.py b/server/app/core/controller/edit.py index 9373ebc794f0427982010e27b4f8e9931816f8c9..c8b1633bfcf15854f17df0f5dcbc5152f1216ef5 100644 --- a/server/app/core/controller/edit.py +++ b/server/app/core/controller/edit.py @@ -31,15 +31,13 @@ def slide(item, title=None, timer=None): return item -def competition(item, name=None, year=None, city_id=None, style_id=None): +def competition(item, name=None, year=None, city_id=None): if name: item.name = name if year: item.year = year if city_id: item.city_id = city_id - if style_id: - item.style_id = style_id db.session.commit() db.session.refresh(item) diff --git a/server/app/core/controller/get.py b/server/app/core/controller/get.py index d5b5978a2d4bb56a4f4d3054fcba0a1f8c4ee1c6..7c3842d9041bbe4130b9e0a7201c2cfe748d3cf4 100644 --- a/server/app/core/controller/get.py +++ b/server/app/core/controller/get.py @@ -43,9 +43,7 @@ def search_user(email=None, name=None, city_id=None, role_id=None, page=0, page_ return _search(query, order_column, page, page_size, order) -def search_competitions( - name=None, year=None, city_id=None, style_id=None, page=0, page_size=15, order=1, order_by=None -): +def search_competitions(name=None, year=None, city_id=None, page=0, page_size=15, order=1, order_by=None): query = Competition.query if name: query = query.filter(Competition.name.like(f"%{name}%")) @@ -53,8 +51,6 @@ def search_competitions( query = query.filter(Competition.year == year) if city_id: query = query.filter(Competition.city_id == city_id) - if style_id: - query = query.filter(Competition.style_id == style_id) order_column = Competition.year # Default order_by if order_by: diff --git a/server/app/core/dto.py b/server/app/core/dto.py index 3378a17bcad43e410331fadc31812982fd95563f..5eca79639858beaebfa9ebce9b2da8ebc06427bc 100644 --- a/server/app/core/dto.py +++ b/server/app/core/dto.py @@ -13,13 +13,13 @@ class AuthDTO: class UserDTO: api = Namespace("users") schema = rich_schemas.UserSchemaRich(many=False) - list_schema = rich_schemas.UserSchemaRich(many=True) + list_schema = schemas.UserSchema(many=True) class CompetitionDTO: api = Namespace("competitions") schema = rich_schemas.CompetitionSchemaRich(many=False) - list_schema = rich_schemas.CompetitionSchemaRich(many=True) + list_schema = schemas.CompetitionSchema(many=True) class SlideDTO: diff --git a/server/app/core/models.py b/server/app/core/models.py index 55ea1b1cebf1dff0cf0ad94f1cf61b44e0b56e0a..bd23f0d715d0245f361c75b11beddaa42e9d084a 100644 --- a/server/app/core/models.py +++ b/server/app/core/models.py @@ -51,12 +51,13 @@ class User(db.Model): media = db.relationship("Media", backref="upload_by") - def __init__(self, email, plaintext_password, role_id, city_id): + def __init__(self, email, plaintext_password, role_id, city_id, name=None): self._password = bcrypt.generate_password_hash(plaintext_password) self.email = email self.role_id = role_id self.city_id = city_id self.authenticated = False + self.name = name @hybrid_property def password(self): @@ -77,43 +78,25 @@ class Media(db.Model): type_id = db.Column(db.Integer, db.ForeignKey("media_type.id"), nullable=False) upload_by_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - styles = db.relationship("Style", backref="bg_image") - def __init__(self, filename, type_id, upload_by_id): self.filename = filename self.type_id = type_id self.upload_by_id = upload_by_id -class Style(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(STRING_SIZE), unique=True) - css = db.Column(db.Text, nullable=False) - bg_image_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=True) - - competition = db.relationship("Competition", backref="style") - - def __init__(self, name, css, bg_image_id=None): - self.name = name - self.css = css - self.bg_image_id = bg_image_id - - class Competition(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) year = db.Column(db.Integer, nullable=False, default=2020) - style_id = db.Column(db.Integer, db.ForeignKey("style.id"), nullable=False) city_id = db.Column(db.Integer, db.ForeignKey("city.id"), nullable=False) slides = db.relationship("Slide", backref="competition") teams = db.relationship("Team", backref="competition") - def __init__(self, name, year, style_id, city_id): + def __init__(self, name, year, city_id): self.name = name self.year = year - self.style_id = style_id self.city_id = city_id @@ -137,7 +120,7 @@ class Slide(db.Model): title = db.Column(db.String(STRING_SIZE), nullable=False, default="") body = db.Column(db.Text, nullable=False, default="") timer = db.Column(db.Integer, nullable=False, default=0) - settings = db.Column(db.Text, nullable=False, default="{}") # Json object + settings = db.Column(db.Text, nullable=False, default="{}") competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=False) questions = db.relationship("Question", backref="slide") @@ -148,34 +131,32 @@ class Slide(db.Model): class Question(db.Model): - __table_args__ = (db.UniqueConstraint("slide_id", "name"), db.UniqueConstraint("slide_id", "order")) + __table_args__ = (db.UniqueConstraint("slide_id", "name"),) id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), nullable=False) - order = db.Column(db.Integer, nullable=False) + total_score = db.Column(db.Integer, nullable=False, default=1) type_id = db.Column(db.Integer, db.ForeignKey("question_type.id"), nullable=False) slide_id = db.Column(db.Integer, db.ForeignKey("slide.id"), nullable=False) + question_answers = db.relationship("QuestionAnswer", backref="question") alternatives = db.relationship("QuestionAlternative", backref="question") - def __init__(self, name, order, type_id, slide_id): + def __init__(self, name, total_score, type_id, slide_id): self.name = name - self.order = order + self.total_score = total_score self.type_id = type_id self.slide_id = slide_id class QuestionAlternative(db.Model): - __table_args__ = (db.UniqueConstraint("question_id", "order"),) id = db.Column(db.Integer, primary_key=True) text = db.Column(db.String(STRING_SIZE), nullable=False) value = db.Column(db.Boolean, nullable=False) - order = db.Column(db.Integer, nullable=False) question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=False) - def __init__(self, text, value, order, question_id): + def __init__(self, text, value, question_id): self.text = text self.value = value - self.order = order self.question_id = question_id @@ -183,7 +164,7 @@ class QuestionAnswer(db.Model): __table_args__ = (db.UniqueConstraint("question_id", "team_id"),) id = db.Column(db.Integer, primary_key=True) data = db.Column(db.Text, nullable=False) - score = db.Column(db.Integer, nullable=False, default=0) # 0: False, 1: True + score = db.Column(db.Integer, nullable=False, default=0) question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=False) team_id = db.Column(db.Integer, db.ForeignKey("team.id"), nullable=False) diff --git a/server/app/core/parsers.py b/server/app/core/parsers.py index 6f287cb271a5984c3c5ba5f0e8b49a38ff7196c5..d4aaf0ec1dc4e1144e073e02908a0c2bc5eb3a7a 100644 --- a/server/app/core/parsers.py +++ b/server/app/core/parsers.py @@ -38,7 +38,6 @@ competition_parser = reqparse.RequestParser() competition_parser.add_argument("name", type=str) competition_parser.add_argument("year", type=int) competition_parser.add_argument("city_id", type=int) -competition_parser.add_argument("style_id", type=int) ###SEARCH_COMPETITOIN#### @@ -46,7 +45,6 @@ competition_search_parser = search_parser.copy() competition_search_parser.add_argument("name", type=str, default=None, location="args") competition_search_parser.add_argument("year", type=str, default=None, location="args") competition_search_parser.add_argument("city_id", type=int, default=None, location="args") -competition_search_parser.add_argument("style_id", type=int, default=None, location="args") ###SLIDER_PARSER#### diff --git a/server/app/core/rich_schemas.py b/server/app/core/rich_schemas.py index 56aec85e5f8f93eaf663d8a374738b0fee478fd8..e4dc4a8472ae63947198ef47c4ecb26465786356 100644 --- a/server/app/core/rich_schemas.py +++ b/server/app/core/rich_schemas.py @@ -1,7 +1,6 @@ import app.core.models as models import app.core.schemas as schemas from app.core import ma -from marshmallow import fields as fields2 from marshmallow_sqlalchemy import fields @@ -31,9 +30,6 @@ class CompetitionSchemaRich(RichSchema): name = ma.auto_field() year = ma.auto_field() slides = fields.Nested(schemas.SlideSchema, many=True) - style = fields.Nested(schemas.RoleSchema, many=False) city = fields.Nested(schemas.CitySchema, many=False) -class UserListSchema(ma.Schema): - users = fields2.Nested(UserSchemaRich, many=False) diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 9962479fe3bcd241bcf77a5ed8b389b4e55ec19e..26fba4693aef2e83592c74121b8e58ff49d9ce2a 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -52,16 +52,6 @@ class MediaSchema(BaseSchema): upload_by_id = ma.auto_field() -class StyleSchema(BaseSchema): - class Meta(BaseSchema.Meta): - model = models.Style - - id = ma.auto_field() - name = ma.auto_field() - css = ma.auto_field() - bg_image = fields.Nested(MediaSchema, many=False) - - class SlideSchema(BaseSchema): class Meta(BaseSchema.Meta): model = models.Slide @@ -80,3 +70,24 @@ class TeamSchema(BaseSchema): id = ma.auto_field() name = ma.auto_field() competition_id = ma.auto_field() + + +class UserSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.User + + id = ma.auto_field() + name = ma.auto_field() + email = ma.auto_field() + role_id = ma.auto_field() + city_id = ma.auto_field() + + +class CompetitionSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Competition + + id = ma.auto_field() + name = ma.auto_field() + year = ma.auto_field() + city_id = ma.auto_field() diff --git a/server/configmodule.py b/server/configmodule.py index f7c2935fa6e2f11747f14b7dfcfc5e426b6de1b5..d042d594bf1dea8c81081c701d7b87d2ddc196c9 100644 --- a/server/configmodule.py +++ b/server/configmodule.py @@ -11,6 +11,7 @@ class Config: BUNDLE_ERRORS = True JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=2) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) + SQLALCHEMY_TRACK_MODIFICATIONS = False class DevelopmentConfig(Config): @@ -21,7 +22,6 @@ class DevelopmentConfig(Config): class TestingConfig(Config): TESTING = True SQLALCHEMY_DATABASE_URI = "sqlite:///test.db" - SQLALCHEMY_TRACK_MODIFICATIONS = False class ProductionConfig(Config): diff --git a/server/populate.py b/server/populate.py index 1910ae8f12b03d09f79440cbb9c63cdf83605d89..ba1648fa28e89399c4b0a57f2788a0b73244a91e 100644 --- a/server/populate.py +++ b/server/populate.py @@ -1,49 +1,63 @@ +from sqlalchemy.sql.expression import true + import app.core.controller as dbc from app import create_app, db -from app.core.models import City, MediaType, QuestionType, Role, Style, User +from app.core.models import City, Competition, MediaType, QuestionType, Role def _add_items(): media_types = ["Image", "Video"] question_types = ["Boolean", "Multiple", "Text"] roles = ["Admin", "Editor"] - cities = ["Linköping"] + cities = ["Linköping", "Stockholm", "Norrköping", "Örkelljunga"] + teams = ["Gymnasieskola A", "Gymnasieskola B", "Gymnasieskola C"] + + for team_name in media_types: + dbc.add.mediaType(team_name) + + for team_name in question_types: + dbc.add.questionType(team_name) + + for team_name in roles: + dbc.add.role(team_name) - # Add media types - for item in media_types: - db.session.add(MediaType(item)) + for team_name in cities: + dbc.add.city(team_name) - db.session.commit() + admin_id = Role.query.filter(Role.name == "Admin").one().id + editor_id = Role.query.filter(Role.name == "Editor").one().id - # Add question types - for item in question_types: - db.session.add(QuestionType(item)) - db.session.commit() + city_id = City.query.filter(City.name == "Linköping").one().id - # Add roles - for item in roles: - db.session.add(Role(item)) - db.session.commit() + text_id = QuestionType.query.filter(QuestionType.name == "Text").one().id - # Add cities - for item in cities: - db.session.add(City(item)) - db.session.commit() + # Add users + dbc.add.user("admin@test.se", "password", admin_id, city_id) + dbc.add.user("test@test.se", "password", editor_id, city_id) - # Add deafult style - db.session.add(Style("Main Style", "")) + # Add competitions to db + for i in range(3): + dbc.add.competition(f"Test{i+1}", 1971, city_id) - # Commit changes to db - db.session.commit() + item_comps = Competition.query.all() + # Add + for item_comp in item_comps: + for i in range(3): + # Add slide to competition + item_slide = dbc.add.slide(item_comp) - # Add user with role and city - dbc.add.default(User("test@test.se", "password", 1, 1)) + # Add question to competition + dbc.add.question(f"Q{i+1}", i + 1, text_id, item_slide) - db.session.flush() + # Add teams to competition + for team_name in teams: + dbc.add.team(team_name, item_comp) -app = create_app("configmodule.DevelopmentConfig") +if __name__ == "__main__": + app = create_app("configmodule.DevelopmentConfig") -with app.app_context(): - db.create_all() - _add_items() + with app.app_context(): + db.drop_all() + db.create_all() + _add_items() diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 383cb574b5b3ad56def838bb169cdf761bd6bf59..e4e0f2905af51a962546d2cfadf3195f3a17cd13 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -43,7 +43,7 @@ def test_competition(client): headers = {"Authorization": "Bearer " + body["access_token"]} # Create competition - data = {"name": "c1", "year": 2020, "city_id": 1, "style_id": 1} + data = {"name": "c1", "year": 2020, "city_id": 1} response, body = post(client, "/api/competitions", data, headers=headers) assert response.status_code == 200 assert body["name"] == "c1" diff --git a/server/tests/test_db.py b/server/tests/test_db.py index 46bd599657368be76ea28d4009332583ef8b814c..49ca53ad18e972673e1b81658458e1af909ef8c9 100644 --- a/server/tests/test_db.py +++ b/server/tests/test_db.py @@ -1,4 +1,5 @@ -from app.core.models import City, Competition, Media, MediaType, Question, QuestionType, Role, Slide, Style, Team, User +import app.core.controller as dbc +from app.core.models import City, Competition, Media, MediaType, Question, QuestionType, Role, Slide, Team, User from tests import app, client, db from tests.test_helpers import add_default_values, assert_exists, assert_insert_fail @@ -21,7 +22,7 @@ def test_user(client): assert len(item_city.users) == 1 and item_city.users[0].id == item_user.id -def test_media_style(client): +def test_media(client): add_default_values() item_user = User.query.filter_by(email="test@test.se").first() @@ -38,19 +39,6 @@ def test_media_style(client): assert len(item_user.media) == 1 assert item_media.upload_by.email == "test@test.se" - # Add style - db.session.add(Style("template", "hej", item_media.id)) - db.session.commit() - - # Assert style - item_style = Style.query.filter_by(name="template").first() - assert item_style is not None - assert len(item_media.styles) == 1 - assert item_style.bg_image.filename == "bild.png" - - # Assert lazy loading - assert item_user.media[0].styles[0].name == "template" - def test_question(client): add_default_values() @@ -64,33 +52,24 @@ def test_question(client): db.session.commit() item_media = Media.query.filter_by(filename="bild.png").first() - # Add style - db.session.add(Style("template", "hej", item_media.id)) - db.session.commit() - item_style = Style.query.filter_by(name="template").first() - # Add competition item_city = City.query.filter_by(name="Linköping").first() - db.session.add(Competition("teknik8", 2020, item_style.id, item_city.id)) - db.session.add(Competition("teknik9", 2020, item_style.id, item_city.id)) - db.session.commit() + dbc.add.competition("teknik8", 2020, item_city.id) + dbc.add.competition("teknik9", 2020, item_city.id) item_competition = Competition.query.filter_by(name="teknik8").first() item_competition_2 = Competition.query.filter_by(name="teknik9").first() assert item_competition is not None assert item_competition.id == 1 - assert item_competition.style.name == "template" assert item_competition.city.name == "Linköping" # Add teams - db.session.add(Team("Lag1", item_competition.id)) - db.session.add(Team("Lag2", item_competition.id)) - db.session.commit() + dbc.add.team("Lag1", item_competition) + dbc.add.team("Lag2", item_competition) assert_insert_fail(Team, "Lag1", item_competition.id) - db.session.add(Team("Lag1", item_competition_2.id)) - db.session.commit() + dbc.add.team("Lag1", item_competition_2) assert Team.query.filter((Team.competition_id == item_competition.id) & (Team.name == "Lag1")).count() == 1 assert Team.query.filter((Team.competition_id == item_competition.id) & (Team.name == "Lag2")).count() == 1 @@ -100,18 +79,17 @@ def test_question(client): assert Team.query.count() == 3 # Add slides - db.session.add(Slide(1, item_competition.id)) - db.session.add(Slide(2, item_competition.id)) - db.session.add(Slide(3, item_competition.id)) - db.session.commit() + dbc.add.slide(item_competition) + dbc.add.slide(item_competition) + dbc.add.slide(item_competition) # Try add slide with same order assert_insert_fail(Slide, 1, item_competition.id) assert_exists(Slide, 1, order=1) - item_slide1 = Slide.query.filter_by(order=1).first() - item_slide2 = Slide.query.filter_by(order=2).first() - item_slide3 = Slide.query.filter_by(order=3).first() + item_slide1 = Slide.query.filter_by(order=0).first() + item_slide2 = Slide.query.filter_by(order=1).first() + item_slide3 = Slide.query.filter_by(order=2).first() assert item_slide1 is not None assert item_slide2 is not None @@ -121,9 +99,8 @@ def test_question(client): question_type_bool = QuestionType.query.filter_by(name="Boolean").first() question_type_multiple = QuestionType.query.filter_by(name="Multiple").first() - db.session.add(Question("Fråga1", 0, question_type_bool.id, item_slide2.id)) - db.session.add(Question("Fråga2", 1, question_type_multiple.id, item_slide3.id)) - db.session.commit() + dbc.add.question("Fråga1", 10, question_type_bool.id, item_slide2) + dbc.add.question("Fråga2", 10, question_type_multiple.id, item_slide3) assert question_type_bool is not None assert question_type_multiple is not None diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index dd78fc8a0d2ab2180a485a87746fe1104c1ca27e..6ce0f54c49f19b04076df674910c5c383bf7efef 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -2,7 +2,7 @@ import json import app.core.controller as dbc from app.core import db -from app.core.models import City, MediaType, QuestionType, Role, Style, User +from app.core.models import City, MediaType, QuestionType, Role, User def add_default_values(): @@ -13,28 +13,23 @@ def add_default_values(): # Add media types for item in media_types: - db.session.add(MediaType(item)) + dbc.add.mediaType(item) # Add question types for item in question_types: - db.session.add(QuestionType(item)) + dbc.add.questionType(item) # Add roles for item in roles: - db.session.add(Role(item)) - + dbc.add.role(item) # Add cities for item in cities: - db.session.add(City(item)) - - # Add deafult style - db.session.add(Style("Main Style", "")) - - # Commit changes to db - db.session.commit() + dbc.add.city(item) + item_admin = Role.query.filter(Role.name == "Admin").one() + item_city = City.query.filter(City.name == "Linköping").one() # Add user with role and city - dbc.add.default(User("test@test.se", "password", 1, 1)) + dbc.add.user("test@test.se", "password", item_admin.id, item_city.id) def get_body(response):