diff --git a/.gitignore b/.gitignore index d68e7dcf419c1863ff41582642ac0ff2e2964699..a690686765ab7b22f64eda9c085f2ba3fccd696b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__ htmlcov .pytest_cache /.idea -.vs/ \ No newline at end of file +.vs/ +*/static \ No newline at end of file diff --git a/.gitlab/client.gitlab-ci.yml b/.gitlab/client.gitlab-ci.yml index ad42a68cde4fb6ba4c192fbffe1c81d307f445f6..f35e73aa6da4b396dda7ca7b5847b83158078a41 100644 --- a/.gitlab/client.gitlab-ci.yml +++ b/.gitlab/client.gitlab-ci.yml @@ -1,5 +1,5 @@ client:setup: - image: node:10 + image: node stage: setup only: refs: @@ -9,14 +9,14 @@ client:setup: - cd client - npm install artifacts: - name: "artifacts" + name: 'artifacts' untracked: true expire_in: 15 mins paths: - client/.npm/ - client/node_modules/ cache: - key: "$CI_COMMIT_REF_SLUG" + key: '$CI_COMMIT_REF_SLUG' paths: - client/.npm/ - client/node_modules/ @@ -24,7 +24,7 @@ client:setup: client:linting: image: node:10 stage: test - needs: ["client:setup"] + needs: ['client:setup'] allow_failure: true only: refs: @@ -39,7 +39,7 @@ client:linting: client:test: image: node:10 stage: test - needs: ["client:setup"] + needs: ['client:setup'] only: refs: - dev @@ -56,7 +56,7 @@ client:test: client:report: image: python stage: report - needs: ["client:test"] + needs: ['client:test'] only: refs: - merge_requests diff --git a/client/package-lock.json b/client/package-lock.json index b08413fa1ea4936c7eef606822511df8882cdf81..7e73859f4e13b504ee814cde74ed293dd53e33ed 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1125,6 +1125,49 @@ "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-10.1.0.tgz", "integrity": "sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==" }, + "@devexpress/dx-chart-core": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/@devexpress/dx-chart-core/-/dx-chart-core-2.7.5.tgz", + "integrity": "sha512-ZSpBN7SjnOhBOcvmuYZ5U+XyUYKlsTrYXCYMfFH2qXwvqh0e0UbhzW1FPfDXdWqx0Y52MtPX0HJrBC4QqH7Bcg==", + "requires": { + "d3-array": "^2.4.0", + "d3-scale": "^3.2.0", + "d3-shape": "^1.3.7" + } + }, + "@devexpress/dx-core": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/@devexpress/dx-core/-/dx-core-2.7.5.tgz", + "integrity": "sha512-VQQkz0uUqQ7YuVZeBEx1JFqpSSe5Bz9Qy2T0XAbilBQ8IItj74xjzaFMCNjuASAKXOJGtG0RUh+BOKrMGV7jlg==" + }, + "@devexpress/dx-react-chart": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/@devexpress/dx-react-chart/-/dx-react-chart-2.7.5.tgz", + "integrity": "sha512-j3nPsHrMiCbgm6olZCykxHOxxJ2XXG0CVihMYGOoyenM74Ed+NIaLi1bk2VtxKZdEPB5UrWgU10rd9SLH45BsQ==", + "requires": { + "@devexpress/dx-chart-core": "2.7.5", + "d3-scale": "^3.2.0", + "d3-shape": "^1.3.7" + } + }, + "@devexpress/dx-react-chart-material-ui": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/@devexpress/dx-react-chart-material-ui/-/dx-react-chart-material-ui-2.7.5.tgz", + "integrity": "sha512-uS450uSP1D6mZ2jgtueShqaATNRVqJEwqhgyRQUXL/gQyTrTCvM61TC5NLyEYCOW6RkiHK1BabWbhJlVATZbeg==", + "requires": { + "clsx": "^1.0.4", + "prop-types": "^15.7.2" + } + }, + "@devexpress/dx-react-core": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/@devexpress/dx-react-core/-/dx-react-core-2.7.5.tgz", + "integrity": "sha512-YwJ4l8nnMs/vghqamo8OzirDnrbT1ZNIcMP5xJFJJQIyfYAXvGGDS4yLeYI4cwitBg/d2O4jMOReXDB8tUaDDQ==", + "requires": { + "@devexpress/dx-core": "2.7.5", + "prop-types": "^15.7.2" + } + }, "@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -5416,6 +5459,73 @@ "type": "^1.0.1" } }, + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + }, + "d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==" + }, + "d3-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", + "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" + }, + "d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "requires": { + "d3-color": "1 - 2" + } + }, + "d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "d3-scale": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.4.tgz", + "integrity": "sha512-PG6gtpbPCFqKbvdBEswQcJcTzHC8VEd/XzezF5e68KlkT4/ggELw/nR1tv863jY6ufKTvDlzCMZvhe06codbbA==", + "requires": { + "d3-array": "^2.3.0", + "d3-format": "1 - 2", + "d3-interpolate": "1.2.0 - 2", + "d3-time": "1 - 2", + "d3-time-format": "2 - 3" + } + }, + "d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "requires": { + "d3-path": "1" + } + }, + "d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "requires": { + "d3-array": "2" + } + }, + "d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "requires": { + "d3-time": "1 - 2" + } + }, "damerau-levenshtein": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz", @@ -8492,6 +8602,11 @@ "side-channel": "^1.0.4" } }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "ip": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", diff --git a/client/package.json b/client/package.json index f41350a6740cde52afbbaa3d6de2cf77ac48d032..2d393d3d80ce5126841f980c77e830355be56960 100644 --- a/client/package.json +++ b/client/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "private": true, "dependencies": { + "@devexpress/dx-react-chart": "^2.7.5", + "@devexpress/dx-react-chart-material-ui": "^2.7.5", + "@devexpress/dx-react-core": "^2.7.5", "@material-ui/core": "^4.11.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.57", diff --git a/client/src/__mocks__/axios.js b/client/src/__mocks__/axios.js index c295396124b8f0a928a0f4fd0c999c963f1b0ecc..40f0914f659785d8b4ceac257e64ceaf0ab3e847 100644 --- a/client/src/__mocks__/axios.js +++ b/client/src/__mocks__/axios.js @@ -1,4 +1,5 @@ export default { get: jest.fn().mockImplementation(), post: jest.fn().mockImplementation(), + defaults: { headers: { common: { Authorization: '' } } }, } diff --git a/client/src/actions/cities.test.ts b/client/src/actions/cities.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5298d29a010b619ed0c2b25973f8f8a9fb8710b --- /dev/null +++ b/client/src/actions/cities.test.ts @@ -0,0 +1,19 @@ +import mockedAxios from 'axios' +import expect from 'expect' // You can use any testing library +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import { getCities } from './cities' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +it('dispatches no actions when failing to get cities', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject(new Error('getting cities failed')) + }) + const store = mockStore({ competitions: { filterParams: [] } }) + await getCities()(store.dispatch) + expect(store.getActions()).toEqual([]) + expect(console.log).toHaveBeenCalled() +}) diff --git a/client/src/actions/communication.ts b/client/src/actions/communication.ts deleted file mode 100644 index d0719cd320f73b11d8a8243130a67896a48bf6e0..0000000000000000000000000000000000000000 --- a/client/src/actions/communication.ts +++ /dev/null @@ -1,75 +0,0 @@ -import Types from './types.js' - -export function axiosPost( - path: any, - data: any, - config = undefined, - startCB = undefined, - successCB = undefined, - errorCB = undefined -) { - return { - type: Types.AXIOS_POST, - path, - data, - config, - startCB, - successCB, - errorCB, - } -} - -export function axiosPostSuccess(path: any, data: any, previousAction: any) { - return { - type: Types.AXIOS_POST_SUCCESS, - path, - data, - previousAction, - } -} - -export function axiosPostError(path: any, data: any, previousAction: any) { - return { - type: Types.AXIOS_POST_ERROR, - path, - data, - previousAction, - } -} - -export function axiosGet( - path: any, - data: any, - config = undefined, - startCB = undefined, - successCB = undefined, - errorCB = undefined -) { - return { - type: Types.AXIOS_GET, - path, - data, - config, - startCB, - successCB, - errorCB, - } -} - -export function axiosGetSuccess(path: any, data: any, previousAction: any) { - return { - type: Types.AXIOS_GET_SUCCESS, - path, - data, - previousAction, - } -} - -export function axiosGetError(path: any, data: any, previousAction: any) { - return { - type: Types.AXIOS_GET_ERROR, - path, - data, - previousAction, - } -} diff --git a/client/src/actions/competitions.test.ts b/client/src/actions/competitions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..52be36acb658576e9b5c1ffc1b40394c338d0f5f --- /dev/null +++ b/client/src/actions/competitions.test.ts @@ -0,0 +1,73 @@ +import mockedAxios from 'axios' +import expect from 'expect' // You can use any testing library +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import { CompetitionFilterParams } from './../interfaces/FilterParams' +import { getCompetitions, setFilterParams } from './competitions' +import Types from './types' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +it('dispatches correct actions when getting competitions', async () => { + const compRes: any = { + data: { + items: [ + { + id: 21, + name: 'ggff', + year: 2021, + style_id: 1, + city: { name: 'city_name', id: 5 }, + }, + { + id: 22, + name: 'sssss', + year: 2021, + style_id: 1, + city: { name: 'city_name', id: 5 }, + }, + ], + count: 2, + total_count: 3, + }, + } + + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.resolve(compRes) + }) + const expectedActions = [ + { type: Types.SET_COMPETITIONS, payload: compRes.data.items }, + { type: Types.SET_COMPETITIONS_TOTAL, payload: compRes.data.total_count }, + { type: Types.SET_COMPETITIONS_COUNT, payload: compRes.data.count }, + ] + const store = mockStore({ competitions: { filterParams: [] } }) + await getCompetitions()(store.dispatch, store.getState as any) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches correct actions when setting filterParams', () => { + const testFilterParams: CompetitionFilterParams = { + page: 0, + pageSize: 3, + name: 'name', + cityId: 0, + styleId: 0, + year: 2000, + } + const expectedActions = [{ type: Types.SET_COMPETITIONS_FILTER_PARAMS, payload: testFilterParams }] + const store = mockStore({}) + setFilterParams(testFilterParams)(store.dispatch) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches no actions when failing to get competitions', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject(new Error('getting competitions failed')) + }) + const store = mockStore({ competitions: { filterParams: [] } }) + await getCompetitions()(store.dispatch, store.getState as any) + expect(store.getActions()).toEqual([]) + expect(console.log).toHaveBeenCalled() +}) diff --git a/client/src/actions/presentation.test.ts b/client/src/actions/presentation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..53ea4847dcf1aaf3e1ba9a04c643d1d44b5f27f0 --- /dev/null +++ b/client/src/actions/presentation.test.ts @@ -0,0 +1,59 @@ +import mockedAxios from 'axios' +import expect from 'expect' // You can use any testing library +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import { Slide } from '../interfaces/Slide' +import { + getPresentationCompetition, + getPresentationTeams, + setCurrentSlide, + setCurrentSlideNext, + setCurrentSlidePrevious, +} from './presentation' +import Types from './types' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +it('dispatches no actions when failing to get competitions', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject(new Error('getting competitions failed')) + }) + const store = mockStore({ competitions: { filterParams: [] } }) + await getPresentationCompetition('0')(store.dispatch) + expect(store.getActions()).toEqual([]) + expect(console.log).toHaveBeenCalled() +}) + +it('dispatches no actions when failing to get teams', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject(new Error('getting teams failed')) + }) + const store = mockStore({ competitions: { filterParams: [] } }) + await getPresentationTeams('0')(store.dispatch) + expect(store.getActions()).toEqual([]) + expect(console.log).toHaveBeenCalled() +}) +it('dispatches correct actions when setting slide', () => { + const testSlide: Slide = { competition_id: 0, id: 5, order: 5, timer: 20, title: '' } + const expectedActions = [{ type: Types.SET_PRESENTATION_SLIDE, payload: testSlide }] + const store = mockStore({}) + setCurrentSlide(testSlide)(store.dispatch) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches correct actions when setting previous slide', () => { + const expectedActions = [{ type: Types.SET_PRESENTATION_SLIDE_PREVIOUS }] + const store = mockStore({}) + setCurrentSlidePrevious()(store.dispatch) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches correct actions when setting next slide', () => { + const expectedActions = [{ type: Types.SET_PRESENTATION_SLIDE_NEXT }] + const store = mockStore({}) + setCurrentSlideNext()(store.dispatch) + expect(store.getActions()).toEqual(expectedActions) +}) diff --git a/client/src/actions/roles.test.ts b/client/src/actions/roles.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..94ca142f23d93601e9fe5afc8df8766d7241e977 --- /dev/null +++ b/client/src/actions/roles.test.ts @@ -0,0 +1,19 @@ +import mockedAxios from 'axios' +import expect from 'expect' // You can use any testing library +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import { getRoles } from './roles' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +it('dispatches no actions when failing to get roles', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject(new Error('getting roles failed')) + }) + const store = mockStore({ competitions: { filterParams: [] } }) + await getRoles()(store.dispatch) + expect(store.getActions()).toEqual([]) + expect(console.log).toHaveBeenCalled(); +}) diff --git a/client/src/actions/searchUser.test.ts b/client/src/actions/searchUser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..66c155079a82d0efd21f0df949c669ab70f85c22 --- /dev/null +++ b/client/src/actions/searchUser.test.ts @@ -0,0 +1,74 @@ +import mockedAxios from 'axios' +import expect from 'expect' // You can use any testing library +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import { UserFilterParams } from './../interfaces/FilterParams' +import { getSearchUsers, setFilterParams } from './searchUser' +import Types from './types' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) +it('dispatches correct actions when getting users', async () => { + const userRes: any = { + data: { + items: [ + { + id: 21, + name: 'ggff', + email: 'email@test.com', + year: 2021, + role_id: 1, + city_id: 0, + }, + { + id: 22, + name: 'sssss', + email: 'email@test.com', + year: 2021, + role_id: 1, + city_id: 0, + }, + ], + count: 2, + total_count: 3, + }, + } + + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.resolve(userRes) + }) + const expectedActions = [ + { type: Types.SET_SEARCH_USERS, payload: userRes.data.items }, + { type: Types.SET_SEARCH_USERS_TOTAL_COUNT, payload: userRes.data.total_count }, + { type: Types.SET_SEARCH_USERS_COUNT, payload: userRes.data.count }, + ] + const store = mockStore({ searchUsers: { filterParams: [] } }) + await getSearchUsers()(store.dispatch, store.getState as any) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches correct actions when setting filterParams', () => { + const testFilterParams: UserFilterParams = { + page: 0, + pageSize: 3, + name: 'name', + cityId: 0, + email: 'email@test.com', + roleId: 0, + } + const expectedActions = [{ type: Types.SET_SEARCH_USERS_FILTER_PARAMS, payload: testFilterParams }] + const store = mockStore({}) + setFilterParams(testFilterParams)(store.dispatch) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches no actions when failing to get users', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject(new Error('getting users failed')) + }) + const store = mockStore({ searchUsers: { filterParams: [] } }) + await getSearchUsers()(store.dispatch, store.getState as any) + expect(store.getActions()).toEqual([]) + expect(console.log).toHaveBeenCalled() +}) diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index e5475d7839207dda773dcf12b2f5cadc1590d433..f7d769d92007f47ef2a80c0bf23cc6526ae6c761 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -8,7 +8,7 @@ export default { 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', + CLEAR_ERRORS: 'CLEAR_ERRORS', SET_UNAUTHENTICATED: 'SET_UNAUTHENTICATED', SET_AUTHENTICATED: 'SET_AUTHENTICATED', SET_COMPETITIONS: 'SET_COMPETITIONS', @@ -24,10 +24,4 @@ export default { SET_CITIES: 'SET_CITIES', SET_CITIES_TOTAL: 'SET_CITIES_TOTAL', SET_CITIES_COUNT: 'SET_CITIES_COUNT', - AXIOS_GET: 'AXIOS_GET', - AXIOS_GET_SUCCESS: 'AXIOS_GET_SUCCESS', - AXIOS_GET_ERROR: 'AXIOS_GET_ERROR', - AXIOS_POST: 'AXIOS_POST', - AXIOS_POST_SUCCESS: 'AXIOS_POST_SUCCESS', - AXIOS_POST_ERROR: 'AXIOS_POST_ERROR', } diff --git a/client/src/actions/user.test.ts b/client/src/actions/user.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..156b17183a76117f87ead4f049346d85e499fea0 --- /dev/null +++ b/client/src/actions/user.test.ts @@ -0,0 +1,81 @@ +import mockedAxios from 'axios' +import expect from 'expect' // You can use any testing library +import { createMemoryHistory } from 'history' +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import Types from './types' +import { loginUser, logoutUser } from './user' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +it('dispatches correct actions when logging in user', async () => { + const loginRes: any = { + data: { + access_token: 'TEST_ACCESS_TOKEN', + }, + } + const userDataRes: any = { + data: { + name: 'test_name', + }, + } + ;(mockedAxios.post as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.resolve(loginRes) + }) + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.resolve(userDataRes) + }) + const expectedActions = [ + { type: Types.LOADING_UI }, + { type: Types.LOADING_USER }, + { type: Types.CLEAR_ERRORS }, + { type: Types.SET_USER, payload: { name: 'test_name' } }, + ] + const store = mockStore({}) + const history = createMemoryHistory() + await loginUser({ email: 'test@email.com', password: 'testpassword' }, history)(store.dispatch) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches correct action when logging out user', async () => { + const store = mockStore({}) + await logoutUser()(store.dispatch) + expect(store.getActions()).toEqual([{ type: Types.SET_UNAUTHENTICATED }]) +}) + +it('dispatches correct action when failing to log in user', async () => { + console.log = jest.fn() + const errorMessage = 'getting teams failed' + ;(mockedAxios.post as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject({ response: { data: errorMessage } }) + }) + const store = mockStore({ competitions: { filterParams: [] } }) + const history = createMemoryHistory() + const expectedActions = [{ type: Types.LOADING_UI }, { type: Types.SET_ERRORS, payload: errorMessage }] + await loginUser({ email: 'test@email.com', password: 'testpassword' }, history)(store.dispatch) + expect(store.getActions()).toEqual(expectedActions) + expect(console.log).toHaveBeenCalled() +}) + +it('dispatches correct actions when failing to get user data', async () => { + console.log = jest.fn() + const loginRes: any = { + data: { + access_token: 'TEST_ACCESS_TOKEN', + }, + } + ;(mockedAxios.post as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.resolve(loginRes) + }) + const errorMessage = 'getting teams failed' + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject({ response: { data: errorMessage } }) + }) + const store = mockStore({ competitions: { filterParams: [] } }) + const history = createMemoryHistory() + const expectedActions = [{ type: Types.LOADING_UI }, { type: Types.LOADING_USER }, { type: Types.CLEAR_ERRORS }] + await loginUser({ email: 'test@email.com', password: 'testpassword' }, history)(store.dispatch) + expect(store.getActions()).toEqual(expectedActions) + expect(console.log).toHaveBeenCalled() +}) diff --git a/client/src/actions/user.ts b/client/src/actions/user.ts index 72d764c861db49024c5fbcf00e3291efc35b2609..30374ccafb38fd3c1f6e9a72378104b2cf2611fd 100644 --- a/client/src/actions/user.ts +++ b/client/src/actions/user.ts @@ -17,10 +17,10 @@ export const loginUser = (userData: AccountLoginModel, history: History) => asyn history.push('/admin') //redirecting to admin page after login success }) .catch((err) => { - console.error(err) + console.log(err) dispatch({ type: Types.SET_ERRORS, - payload: err.response.data, + payload: err && err.response && err.response.data, }) }) } @@ -30,7 +30,6 @@ export const getUserData = () => async (dispatch: AppDispatch) => { await axios .get('/users') .then((res) => { - console.log(res.data) dispatch({ type: Types.SET_USER, payload: res.data, diff --git a/client/src/pages/admin/AdminPage.tsx b/client/src/pages/admin/AdminPage.tsx index 921f721db03d22ee9e7d010cb67620ede98e4bd2..a0dbe2bd54f721ddad785b5b03776c64f87d2030 100644 --- a/client/src/pages/admin/AdminPage.tsx +++ b/client/src/pages/admin/AdminPage.tsx @@ -23,6 +23,7 @@ import { getRoles } from '../../actions/roles' import { logoutUser } from '../../actions/user' import { useAppDispatch, useAppSelector } from '../../hooks' import CompetitionManager from './competitions/CompetitionManager' +import Dashboard from './dashboard/Dashboard' import RegionManager from './regions/Regions' import { LeftDrawer } from './styled' import UserManager from './users/UserManager' @@ -45,7 +46,9 @@ const useStyles = makeStyles((theme: Theme) => content: { flexGrow: 1, backgroundColor: theme.palette.background.default, - paddingLeft: theme.spacing(31), + paddingTop: theme.spacing(2), + paddingLeft: theme.spacing(35), + paddingRight: theme.spacing(5), }, }) ) @@ -138,9 +141,7 @@ const AdminView: React.FC = () => { <div className={classes.toolbar} /> <Switch> <Route exact path={[path, `${path}/startsida`]}> - <Typography variant="h1" noWrap> - Startsida - </Typography> + <Dashboard /> </Route> <Route path={`${path}/regioner`}> <RegionManager /> diff --git a/client/src/pages/admin/dashboard/Dashboard.tsx b/client/src/pages/admin/dashboard/Dashboard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a0f569b066a8073a14f9342dcb7d988be63b93b9 --- /dev/null +++ b/client/src/pages/admin/dashboard/Dashboard.tsx @@ -0,0 +1,58 @@ +import { createStyles, makeStyles, Paper, Theme, Typography } from '@material-ui/core' +import Grid from '@material-ui/core/Grid' +import React from 'react' +import CurrentUser from './components/CurrentUser' +import NumberOfCompetitions from './components/NumberOfCompetitions' +import NumberOfRegions from './components/NumberOfRegions' +import NumberOfUsers from './components/NumberOfUsers' + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + flexGrow: 1, + }, + paper: { + padding: theme.spacing(2), + textAlign: 'center', + color: theme.palette.text.secondary, + }, + }) +) + +const Dashboard: React.FC = () => { + const classes = useStyles() + return ( + <div className={classes.root}> + <div> + <Grid container spacing={3}> + <Grid item xs={4}> + <Paper className={classes.paper}> + <CurrentUser /> + </Paper> + </Grid> + + <Grid item xs> + <Paper className={classes.paper}> + <Typography variant="h4">Antal Användare:</Typography> + <NumberOfUsers /> + </Paper> + </Grid> + <Grid item xs> + <Paper className={classes.paper}> + <Typography variant="h4">Antal Regioner:</Typography> + <NumberOfRegions /> + </Paper> + </Grid> + <Grid item xs> + <Paper className={classes.paper}> + <Typography variant="h4">Antal Tävlingar:</Typography> + <NumberOfCompetitions /> + </Paper> + </Grid> + </Grid> + </div> + </div> + ) +} + +export default Dashboard diff --git a/client/src/pages/admin/dashboard/components/CurrentUser.tsx b/client/src/pages/admin/dashboard/components/CurrentUser.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0dfdf557bff55224f513fb742ba59bdc8c8b1f56 --- /dev/null +++ b/client/src/pages/admin/dashboard/components/CurrentUser.tsx @@ -0,0 +1,29 @@ +import { Box, Typography } from '@material-ui/core' +import React from 'react' +import { useAppSelector } from '../../../../hooks' + +const CurrentUser: React.FC = () => { + const currentUser = useAppSelector((state: { user: { userInfo: any } }) => state.user.userInfo) + return ( + <div> + <Box display="flex" flexDirection="column" alignContent="flex-start"> + <div> + <Typography variant="h2"> + Välkommen{currentUser && currentUser.name ? `, ${currentUser.name}` : ''}! + </Typography> + </div> + <div> + <Typography variant="h6">Email: {currentUser && currentUser.email}</Typography> + </div> + <div> + <Typography variant="h6">Region: {currentUser && currentUser.city && currentUser.city.name}</Typography> + </div> + <div> + <Typography variant="h6">Roll: {currentUser && currentUser.role && currentUser.role.name}</Typography> + </div> + </Box> + </div> + ) +} + +export default CurrentUser diff --git a/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx b/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da5d015fd52d77de7e599e4f1bc2a62cee805483 --- /dev/null +++ b/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx @@ -0,0 +1,33 @@ +import { Box, Typography } from '@material-ui/core' +import React, { useEffect } from 'react' +import { getSearchUsers } from '../../../../actions/searchUser' +import { useAppDispatch, useAppSelector } from '../../../../hooks' + +const NumberOfCompetitions: React.FC = () => { + const cities = useAppSelector((state) => state.cities.cities) + const dispatch = useAppDispatch() + + const handleCount = () => { + if (cities.length >= 1000000) { + ;<div>{cities.length / 1000000 + 'M'}</div> + } else if (cities.length >= 1000) { + ;<div>{cities.length / 1000 + 'K'}</div> + } + return <div>{cities.length}</div> + } + + useEffect(() => { + dispatch(getSearchUsers()) + }, []) + return ( + <div> + <Box width="100%" height="100%"> + <div> + <Typography variant="h4">{handleCount()}</Typography> + </div> + </Box> + </div> + ) +} + +export default NumberOfCompetitions diff --git a/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx b/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a48b41a61f7a46089cedcd9e6a981b9ca625ec56 --- /dev/null +++ b/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx @@ -0,0 +1,33 @@ +import { Box, Typography } from '@material-ui/core' +import React, { useEffect } from 'react' +import { getSearchUsers } from '../../../../actions/searchUser' +import { useAppDispatch, useAppSelector } from '../../../../hooks' + +const NumberOfRegions: React.FC = () => { + const competitionTotal = useAppSelector((state) => state.competitions.total) + const dispatch = useAppDispatch() + + const handleCount = () => { + if (competitionTotal >= 1000000) { + ;<div>{competitionTotal / 1000000 + 'M'}</div> + } else if (competitionTotal >= 1000) { + ;<div>{competitionTotal / 1000 + 'K'}</div> + } + return <div>{competitionTotal}</div> + } + + useEffect(() => { + dispatch(getSearchUsers()) + }, []) + return ( + <div> + <Box width="100%" height="100%"> + <div> + <Typography variant="h4">{handleCount()}</Typography> + </div> + </Box> + </div> + ) +} + +export default NumberOfRegions diff --git a/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx b/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af0f97677565aa264a61269c274cf890eec774d8 --- /dev/null +++ b/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx @@ -0,0 +1,33 @@ +import { Box, Typography } from '@material-ui/core' +import React, { useEffect } from 'react' +import { getSearchUsers } from '../../../../actions/searchUser' +import { useAppDispatch, useAppSelector } from '../../../../hooks' + +const NumberOfUsers: React.FC = () => { + const usersTotal = useAppSelector((state) => state.searchUsers.total) + const dispatch = useAppDispatch() + + const handleCount = () => { + if (usersTotal >= 1000000) { + ;<div>{usersTotal / 1000000 + 'M'}</div> + } else if (usersTotal >= 1000) { + ;<div>{usersTotal / 1000 + 'K'}</div> + } + return <div>{usersTotal}</div> + } + + useEffect(() => { + dispatch(getSearchUsers()) + }, []) + return ( + <div> + <Box width="100%" height="100%"> + <div> + <Typography variant="h4">{handleCount()}</Typography> + </div> + </Box> + </div> + ) +} + +export default NumberOfUsers diff --git a/client/src/pages/admin/regions/AddRegion.test.tsx b/client/src/pages/admin/regions/AddRegion.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06b2052a55f14482dbe1474f03aad76894f2677f --- /dev/null +++ b/client/src/pages/admin/regions/AddRegion.test.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import store from '../../../store' +import AddRegion from './AddRegion' + +it('renders add region', () => { + render( + <BrowserRouter> + <Provider store={store}> + <AddRegion /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/admin/regions/Regions.test.tsx b/client/src/pages/admin/regions/Regions.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7046bff04fc8fc9fc8cb3f08d0d140d0d475b5a3 --- /dev/null +++ b/client/src/pages/admin/regions/Regions.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react' +import mockedAxios from 'axios' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import store from '../../../store' +import RegionManager from './Regions' + +it('renders region manager', () => { + const cityRes: any = { + data: { + items: [ + { + id: 1, + name: 'Link\u00f6ping', + }, + { + id: 2, + name: 'Stockholm', + }, + ], + count: 2, + total_count: 3, + }, + } + + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.resolve(cityRes) + }) + render( + <BrowserRouter> + <Provider store={store}> + <RegionManager /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/admin/users/AddUser.test.tsx b/client/src/pages/admin/users/AddUser.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c825570418e638403040ae3ba73423a01acda24 --- /dev/null +++ b/client/src/pages/admin/users/AddUser.test.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import store from '../../../store' +import EditUser from './EditUser' + +it('renders edit user', () => { + render( + <BrowserRouter> + <Provider store={store}> + <EditUser user={{ id: 0, name: '', email: '', role_id: 0, city_id: 0 }} /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/admin/users/EditUser.test.tsx b/client/src/pages/admin/users/EditUser.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf2b00900792996caa42100b61dac03138401c70 --- /dev/null +++ b/client/src/pages/admin/users/EditUser.test.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import store from '../../../store' +import AddUser from './AddUser' + +it('renders edit user', () => { + render( + <BrowserRouter> + <Provider store={store}> + <AddUser /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/admin/users/UserManager.test.tsx b/client/src/pages/admin/users/UserManager.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f50cbed8ded2b12d3d487e96f1d185bca2a2cbf8 --- /dev/null +++ b/client/src/pages/admin/users/UserManager.test.tsx @@ -0,0 +1,43 @@ +import { render } from '@testing-library/react' +import mockedAxios from 'axios' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import store from '../../../store' +import UserManager from './UserManager' + +it('renders user manager', () => { + const userRes: any = { + data: { + items: [ + { + id: 1, + name: 'user1', + email: 'user1@email.com', + role_id: 0, + city_id: 0, + }, + { + id: 2, + name: 'Stockholm', + email: 'user2@email.com', + role_id: 0, + city_id: 0, + }, + ], + count: 2, + total_count: 3, + }, + } + + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.resolve(userRes) + }) + render( + <BrowserRouter> + <Provider store={store}> + <UserManager /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/admin/users/UserManager.tsx b/client/src/pages/admin/users/UserManager.tsx index 11a34128b9093784cb79cb7e19bada1a457e6e71..df9cdec0b1618291b3efbe903aea69f94351aaad 100644 --- a/client/src/pages/admin/users/UserManager.tsx +++ b/client/src/pages/admin/users/UserManager.tsx @@ -70,7 +70,6 @@ const UserManager: React.FC = (props: any) => { }, []) useEffect(() => { - console.log('asd') setEditAnchorEl(null) setAnchorEl(null) }, [users]) diff --git a/client/src/pages/presentationEditor/components/CompetitionSettings.test.tsx b/client/src/pages/presentationEditor/components/CompetitionSettings.test.tsx index e6f87e7e2beba3141c73d9bca5064d5f8e19ad4d..592581d4f8b3eaa884b7e0b409f39ee5ae8e5344 100644 --- a/client/src/pages/presentationEditor/components/CompetitionSettings.test.tsx +++ b/client/src/pages/presentationEditor/components/CompetitionSettings.test.tsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react' import React from 'react' -import CompetitionSettings from './CompetitionSettings' +import ImageComponentDisplay from './ImageComponentDisplay' -it('renders competition settings', () => { - render(<CompetitionSettings />) +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 new file mode 100644 index 0000000000000000000000000000000000000000..b726023b8e6079e7a631c2be396857e9ca0ef4c1 --- /dev/null +++ b/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react' +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 }} />) +}) diff --git a/client/src/pages/presentationEditor/components/TextComponentDisplay.test.tsx b/client/src/pages/presentationEditor/components/TextComponentDisplay.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8f61ee36f8bb86e9e422b135c76448c31d60a6d4 --- /dev/null +++ b/client/src/pages/presentationEditor/components/TextComponentDisplay.test.tsx @@ -0,0 +1,12 @@ +import { Editor } from '@tinymce/tinymce-react' +import { mount } from 'enzyme' +import React from 'react' +import TextComponentDisplay from './TextComponentDisplay' + +it('renders text component display', () => { + const testText = 'TEST' + const container = mount( + <TextComponentDisplay component={{ id: 0, x: 0, y: 0, w: 0, h: 0, text: testText, type: 2, font: '123123' }} /> + ) + expect(container.find(Editor).prop('initialValue')).toBe(testText) +}) diff --git a/client/src/reducers/citiesReducer.test.ts b/client/src/reducers/citiesReducer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..320268952b997a799b1a6bf35191cfe88cdfd2b5 --- /dev/null +++ b/client/src/reducers/citiesReducer.test.ts @@ -0,0 +1,53 @@ +import Types from '../actions/types' +import citiesReducer from './citiesReducer' + +const initialState = { + cities: [], + total: 0, + count: 0, +} + +it('should return the initial state', () => { + expect(citiesReducer(undefined, {} as any)).toEqual(initialState) +}) + +it('should handle SET_CITIES', () => { + const testCities = [{ name: 'testName', id: 0 }] + expect( + citiesReducer(initialState, { + type: Types.SET_CITIES, + payload: testCities, + }) + ).toEqual({ + cities: testCities, + total: 0, + count: 0, + }) +}) + +it('should handle SET_CITIES_TOTAL', () => { + const testTotal = 123123 + expect( + citiesReducer(initialState, { + type: Types.SET_CITIES_TOTAL, + payload: testTotal, + }) + ).toEqual({ + cities: [], + total: testTotal, + count: 0, + }) +}) +it('should handle SET_CITIES_COUNT', () => { + const testCount = 456456 + expect( + citiesReducer(initialState, { + type: Types.SET_CITIES_COUNT, + payload: testCount, + }) + ).toEqual({ + cities: [], + total: 0, + count: testCount, + }) +}) diff --git a/client/src/reducers/presentationReducer.test.ts b/client/src/reducers/presentationReducer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf4ded4132b1fa1cb1490a89584791032746385b --- /dev/null +++ b/client/src/reducers/presentationReducer.test.ts @@ -0,0 +1,193 @@ +import Types from '../actions/types' +import { RichSlide } from '../interfaces/ApiRichModels' +import { Slide } from '../interfaces/Slide' +import presentationReducer from './presentationReducer' + +const initialState = { + competition: { + name: '', + id: 0, + city: { + id: 0, + name: '', + }, + slides: [], + year: 0, + teams: [], + }, + slide: { + competition_id: 0, + id: 0, + order: 0, + timer: 0, + title: '', + }, + teams: [], +} + +it('should return the initial state', () => { + expect(presentationReducer(undefined, {} as any)).toEqual(initialState) +}) + +it('should handle SET_PRESENTATION_COMPETITION', () => { + const testCompetition = { + name: 'testCompName', + id: 4, + city: { + id: 3, + name: 'testCityName', + }, + slides: [{ id: 20 }], + year: 1999, + teams: [], + } + expect( + presentationReducer(initialState, { + type: Types.SET_PRESENTATION_COMPETITION, + payload: testCompetition, + }) + ).toEqual({ + competition: testCompetition, + slide: testCompetition.slides[0], + teams: [], + }) +}) + +it('should handle SET_PRESENTATION_TEAMS', () => { + const testTeams = [ + { + name: 'testTeamName1', + id: 3, + }, + { + name: 'testTeamName2', + id: 5, + }, + ] + expect( + presentationReducer(initialState, { + type: Types.SET_PRESENTATION_TEAMS, + payload: testTeams, + }) + ).toEqual({ + competition: initialState.competition, + slide: initialState.slide, + teams: testTeams, + }) +}) + +it('should handle SET_PRESENTATION_SLIDE', () => { + const testSlide = [ + { + competition_id: 20, + id: 4, + order: 3, + timer: 123, + title: 'testSlideTitle', + }, + ] + expect( + presentationReducer(initialState, { + type: Types.SET_PRESENTATION_SLIDE, + payload: testSlide, + }) + ).toEqual({ + competition: initialState.competition, + slide: testSlide, + teams: initialState.teams, + }) +}) + +describe('should handle SET_PRESENTATION_SLIDE_PREVIOUS', () => { + it('by changing slide to the previous if there is one', () => { + const testPresentationState = { + competition: { + ...initialState.competition, + slides: [ + { competition_id: 0, order: 0 }, + { competition_id: 0, order: 1 }, + ] as RichSlide[], + }, + teams: initialState.teams, + slide: { competition_id: 0, order: 1 } as Slide, + } + expect( + presentationReducer(testPresentationState, { + type: Types.SET_PRESENTATION_SLIDE_PREVIOUS, + }) + ).toEqual({ + competition: testPresentationState.competition, + slide: testPresentationState.competition.slides[0], + teams: testPresentationState.teams, + }) + }) + it('by not changing slide if there is no previous one', () => { + const testPresentationState = { + competition: { + ...initialState.competition, + slides: [ + { competition_id: 0, order: 0 }, + { competition_id: 0, order: 1 }, + ] as RichSlide[], + }, + teams: initialState.teams, + slide: { competition_id: 0, order: 0 } as Slide, + } + expect( + presentationReducer(testPresentationState, { + type: Types.SET_PRESENTATION_SLIDE_PREVIOUS, + }) + ).toEqual({ + competition: testPresentationState.competition, + slide: testPresentationState.competition.slides[0], + teams: testPresentationState.teams, + }) + }) +}) + +describe('should handle SET_PRESENTATION_SLIDE_NEXT', () => { + it('by changing slide to the next if there is one', () => { + const testPresentationState = { + competition: { + ...initialState.competition, + slides: [ + { competition_id: 0, order: 0 }, + { competition_id: 0, order: 1 }, + ] as RichSlide[], + }, + teams: initialState.teams, + slide: { competition_id: 0, order: 0 } as Slide, + } + expect( + presentationReducer(testPresentationState, { + type: Types.SET_PRESENTATION_SLIDE_NEXT, + }) + ).toEqual({ + competition: testPresentationState.competition, + slide: testPresentationState.competition.slides[1], + teams: testPresentationState.teams, + }) + }) + it('by not changing slide if there is no next one', () => { + const testPresentationState = { + competition: { + ...initialState.competition, + slides: [ + { competition_id: 0, order: 0 }, + { competition_id: 0, order: 1 }, + ] as RichSlide[], + }, + teams: initialState.teams, + slide: { competition_id: 0, order: 1 } as Slide, + } + expect( + presentationReducer(testPresentationState, { + type: Types.SET_PRESENTATION_SLIDE_NEXT, + }) + ).toEqual({ + competition: testPresentationState.competition, + slide: testPresentationState.competition.slides[1], + teams: testPresentationState.teams, + }) + }) +}) diff --git a/client/src/reducers/uiReducer.test.ts b/client/src/reducers/uiReducer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cea5c3ad137dc8f4fbcc2bb88ccd136fd487f5f9 --- /dev/null +++ b/client/src/reducers/uiReducer.test.ts @@ -0,0 +1,64 @@ +import Types from '../actions/types' +import userReducer from './userReducer' + +const initialState = { + authenticated: false, + loading: false, + userInfo: null, +} + +it('should return the initial state', () => { + expect(userReducer(undefined, {} as any)).toEqual(initialState) +}) + +it('should handle SET_AUTHENTICATED', () => { + expect( + userReducer(initialState, { + type: Types.SET_AUTHENTICATED, + }) + ).toEqual({ + authenticated: true, + loading: initialState.loading, + userInfo: initialState.userInfo, + }) +}) + +it('should handle SET_UNAUTHENTICATED', () => { + expect( + userReducer(initialState, { + type: Types.SET_UNAUTHENTICATED, + }) + ).toEqual(initialState) +}) + +it('should handle SET_USER', () => { + const testUserInfo = { + name: 'testName', + email: 'test@email.com', + role: { id: 0, name: 'roleName' }, + city: { id: 0, name: 'cityName' }, + id: 0, + } + expect( + userReducer(initialState, { + type: Types.SET_USER, + payload: testUserInfo, + }) + ).toEqual({ + authenticated: true, + loading: false, + userInfo: testUserInfo, + }) +}) + +it('should handle LOADING_USER', () => { + expect( + userReducer(initialState, { + type: Types.LOADING_USER, + }) + ).toEqual({ + loading: true, + authenticated: initialState.authenticated, + userInfo: initialState.userInfo, + }) +}) diff --git a/client/src/reducers/userReducer.test.ts b/client/src/reducers/userReducer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ccf026801679367515f6fcc7756554e54845889 --- /dev/null +++ b/client/src/reducers/userReducer.test.ts @@ -0,0 +1,45 @@ +import Types from '../actions/types' +import uiReducer from './uiReducer' + +const initialState = { + loading: false, + errors: null, +} + +it('should return the initial state', () => { + expect(uiReducer(undefined, {} as any)).toEqual(initialState) +}) + +it('should handle SET_ERRORS', () => { + const testError = { message: 'errorMessage' } + expect( + uiReducer(initialState, { + type: Types.SET_ERRORS, + payload: testError, + }) + ).toEqual({ + loading: false, + errors: testError, + }) +}) + +it('should handle CLEAR_ERRORS', () => { + expect( + uiReducer(initialState, { + type: Types.CLEAR_ERRORS, + }) + ).toEqual({ + loading: false, + errors: null, + }) +}) +it('should handle LOADING_UI', () => { + expect( + uiReducer(initialState, { + type: Types.LOADING_UI, + }) + ).toEqual({ + loading: true, + errors: initialState.errors, + }) +}) diff --git a/client/src/utils/checkAuthentication.test.ts b/client/src/utils/checkAuthentication.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..901f331d6cc32c68e2a87c32338e879719c736af --- /dev/null +++ b/client/src/utils/checkAuthentication.test.ts @@ -0,0 +1,59 @@ +import mockedAxios from 'axios' +import Types from '../actions/types' +import store from '../store' +import { CheckAuthentication } from './checkAuthentication' + +it('dispatches correct actions when auth token is ok', async () => { + const userRes: any = { + data: { + name: 'username', + }, + } + + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.resolve(userRes) + }) + + const spy = jest.spyOn(store, 'dispatch') + const testToken = + 'Bearer eyJ0eXAiOiJeyJ0eXAiOiJKV1QeyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSceyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSceyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSceyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSciLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxScKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSc' + localStorage.setItem('token', testToken) + await CheckAuthentication() + expect(spy).toBeCalledWith({ type: Types.LOADING_USER }) + expect(spy).toBeCalledWith({ type: Types.SET_AUTHENTICATED }) + expect(spy).toBeCalledWith({ type: Types.SET_USER, payload: userRes.data }) + expect(spy).toBeCalledTimes(3) +}) + +it('dispatches correct actions when getting user data fails', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + return Promise.reject(new Error('failed getting user data')) + }) + + const spy = jest.spyOn(store, 'dispatch') + const testToken = + 'Bearer eyJ0eXAiOiJeyJ0eXAiOiJKV1QeyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSceyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSceyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSceyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSciLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxScKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDQ5MzksImV4cCI6MzI1MTI1MjU3NTcsImF1ZCI6Ind3dy5leGFtcGxlLmNvbSIsInN1YiI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2hubnkiLCJTdXJuYW1lIjoiUm9ja2V0IiwiRW1haWwiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.DrOOcCo5jMR-SNERE0uRp_kolQ2HjX8-hHXYEMnIxSc' + localStorage.setItem('token', testToken) + await CheckAuthentication() + expect(spy).toBeCalledWith({ type: Types.LOADING_USER }) + expect(spy).toBeCalledWith({ type: Types.SET_UNAUTHENTICATED }) + expect(spy).toBeCalledTimes(2) + expect(console.log).toHaveBeenCalled() +}) + +it('dispatches no actions when no token exists', async () => { + const spy = jest.spyOn(store, 'dispatch') + await CheckAuthentication() + expect(spy).not.toBeCalled() +}) + +it('dispatches correct actions when token is expired', async () => { + const testToken = + 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDY1MTUsImV4cCI6MTU4Njc3MDUxNSwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.R5-oWGGumd-YWPoKyziJmVB8SdX6B9SsV6m7novIfgg' + localStorage.setItem('token', testToken) + const spy = jest.spyOn(store, 'dispatch') + await CheckAuthentication() + expect(spy).toBeCalledWith({ type: Types.SET_UNAUTHENTICATED }) + expect(spy).toBeCalledTimes(1) +}) diff --git a/client/src/utils/checkAuthentication.ts b/client/src/utils/checkAuthentication.ts index 83b735543422b79e6c2323243d8fa1468566cbf4..231781543c4b8c3088585568fbeedbcab4e16f41 100644 --- a/client/src/utils/checkAuthentication.ts +++ b/client/src/utils/checkAuthentication.ts @@ -15,7 +15,6 @@ export const CheckAuthentication = async () => { if (decodedToken.exp * 1000 >= Date.now()) { axios.defaults.headers.common['Authorization'] = authToken store.dispatch({ type: Types.LOADING_USER }) - console.log('loading user') await axios .get('/users') .then((res) => { @@ -26,7 +25,7 @@ export const CheckAuthentication = async () => { }) }) .catch((error) => { - console.error(error) + console.log(error) UnAuthorized() }) } else { diff --git a/server/app/__init__.py b/server/app/__init__.py index d1bbd3af80ccef9d5692f55d3a48ce2e34d920f6..8aa7a08a36108f683361768752aaf3e0f47e737e 100644 --- a/server/app/__init__.py +++ b/server/app/__init__.py @@ -1,7 +1,9 @@ from flask import Flask, redirect, request +from flask_uploads import IMAGES, UploadSet, configure_uploads -import app.core.models as models +import app.database.models as models from app.core import bcrypt, db, jwt, ma +from app.core.dto import MediaDTO def create_app(config_name="configmodule.DevelopmentConfig"): @@ -14,6 +16,7 @@ def create_app(config_name="configmodule.DevelopmentConfig"): jwt.init_app(app) db.init_app(app) ma.init_app(app) + configure_uploads(app, (MediaDTO.image_set,)) from app.apis import flask_api diff --git a/server/app/apis/__init__.py b/server/app/apis/__init__.py index 061a45194c07c4a7dc655cac28e256bfea1b2c06..3b2ed213ac140a49096aeee16cdd3924a76ae30d 100644 --- a/server/app/apis/__init__.py +++ b/server/app/apis/__init__.py @@ -44,6 +44,7 @@ from flask_restx import Api from .auth import api as auth_ns from .competitions import api as comp_ns +from .media import api as media_ns from .misc import api as misc_ns from .questions import api as question_ns from .slides import api as slide_ns @@ -51,6 +52,7 @@ from .teams import api as team_ns from .users import api as user_ns flask_api = Api() +flask_api.add_namespace(media_ns, path="/api/media") 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") @@ -58,3 +60,4 @@ flask_api.add_namespace(comp_ns, path="/api/competitions") flask_api.add_namespace(slide_ns, path="/api/competitions/<CID>/slides") flask_api.add_namespace(team_ns, path="/api/competitions/<CID>/teams") flask_api.add_namespace(question_ns, path="/api/competitions/<CID>/questions") +# flask_api.add_namespace(question_ns, path="/api/competitions/<CID>/slides/<SID>/question") diff --git a/server/app/apis/admin.py b/server/app/apis/admin.py deleted file mode 100644 index 92d3922ca1ee1324566ce886a50155ab31ca8e1d..0000000000000000000000000000000000000000 --- a/server/app/apis/admin.py +++ /dev/null @@ -1,8 +0,0 @@ -### -# Admin stuff placed here for later use -# No need to implement this before the application is somewhat done -### - -from flask import Blueprint - -admin_blueprint = Blueprint("admin", __name__) diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index df3a8e5c388510fe349b07fa9aff0b8f06edbbf6..1510df642682f2d00d94bb695af45e7fb1cf24f4 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -1,9 +1,9 @@ -import app.core.controller as dbc import app.core.http_codes as codes +import app.database.controller as dbc from app.apis import admin_required, item_response, text_response from app.core.dto import AuthDTO -from app.core.models import User from app.core.parsers import create_user_parser, login_parser +from app.database.models import User from flask_jwt_extended import ( create_access_token, create_refresh_token, @@ -30,14 +30,10 @@ class AuthSignup(Resource): args = create_user_parser.parse_args(strict=True) email = args.get("email") - if User.query.filter(User.email == email).count() > 0: + if dbc.get.user_exists(email): api.abort(codes.BAD_REQUEST, "User already exists") item_user = dbc.add.user(**args) - # TODO: Clarify when this case is needed or add it to a test - if not item_user: - api.abort(codes.BAD_REQUEST, "User could not be created") - return item_response(schema.dump(item_user)) @@ -46,10 +42,7 @@ class AuthSignup(Resource): class AuthDelete(Resource): @jwt_required def delete(self, ID): - item_user = User.query.filter(User.id == ID).first() - - if not item_user: - api.abort(codes.NOT_FOUND, f"Could not find user with id {ID}.") + item_user = dbc.get.user(ID) dbc.delete.default(item_user) if int(ID) == get_jwt_identity(): @@ -64,7 +57,7 @@ class AuthLogin(Resource): args = login_parser.parse_args(strict=True) email = args.get("email") password = args.get("password") - item_user = User.query.filter_by(email=email).first() + item_user = dbc.get.user_by_email(email, required=False) if not item_user or not item_user.is_correct_password(password): api.abort(codes.UNAUTHORIZED, "Invalid email or password") @@ -92,7 +85,7 @@ class AuthRefresh(Resource): def post(self): old_jti = get_raw_jwt()["jti"] - item_user = User.query.filter_by(id=get_jwt_identity()).first() + item_user = dbc.get.user(get_jwt_identity()) access_token = create_access_token(item_user.id, user_claims=get_user_claims(item_user)) dbc.add.blacklist(old_jti) response = {"access_token": access_token} diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index 93f032279eb6580c90251f94309e2eaf1132ffec..be3762062647a576be9f76861961d2a30cb5d5fe 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -1,8 +1,8 @@ -import app.core.controller as dbc +import app.database.controller as dbc from app.apis import admin_required, item_response, list_response from app.core.dto import CompetitionDTO -from app.core.models import Competition from app.core.parsers import competition_parser, competition_search_parser +from app.database.models import Competition from flask_jwt_extended import jwt_required from flask_restx import Resource @@ -11,10 +11,6 @@ schema = CompetitionDTO.schema list_schema = CompetitionDTO.list_schema -def get_comp(CID): - return Competition.query.filter(Competition.id == CID).first() - - @api.route("/") class CompetitionsList(Resource): @jwt_required @@ -34,20 +30,22 @@ class CompetitionsList(Resource): class Competitions(Resource): @jwt_required def get(self, CID): - item = get_comp(CID) + item = dbc.get.competition(CID) return item_response(schema.dump(item)) @jwt_required def put(self, CID): args = competition_parser.parse_args(strict=True) - item = get_comp(CID) + item = dbc.get.competition(CID) item = dbc.edit.competition(item, **args) + return item_response(schema.dump(item)) @jwt_required def delete(self, CID): - item = get_comp(CID) + item = dbc.get.competition(CID) dbc.delete.competition(item) + return "deleted" @@ -56,5 +54,5 @@ class CompetitionSearch(Resource): @jwt_required def get(self): args = competition_search_parser.parse_args(strict=True) - items, total = dbc.get.search_competitions(**args) + items, total = dbc.search.competition(**args) return list_response(list_schema.dump(items), total) diff --git a/server/app/apis/components.py b/server/app/apis/components.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/server/app/apis/media.py b/server/app/apis/media.py new file mode 100644 index 0000000000000000000000000000000000000000..a7c2d5d143963e87d434d4c6c7770427663622c0 --- /dev/null +++ b/server/app/apis/media.py @@ -0,0 +1,47 @@ +import app.core.http_codes as codes +import app.database.controller as dbc +from app.apis import admin_required, item_response, list_response +from app.core.dto import MediaDTO +from app.core.parsers import media_parser_search +from app.database.models import City, Media, MediaType, QuestionType, Role +from flask import request +from flask_jwt_extended import get_jwt_identity, jwt_required +from flask_restx import Resource, reqparse +from flask_uploads import UploadNotAllowed +from PIL import Image + +api = MediaDTO.api +image_set = MediaDTO.image_set +schema = MediaDTO.schema +list_schema = MediaDTO.list_schema + + +def generate_thumbnail(filename): + with Image.open(f"./static/images/{filename}") as im: + im.thumbnail((120, 120)) + im.save(f"./static/images/thumbnail_{filename}") + + +@api.route("/images") +class ImageList(Resource): + @jwt_required + def get(self): + args = media_parser_search.parse_args(strict=True) + items, total = dbc.search.image(**args) + return list_response(list_schema.dump(items), total) + + @jwt_required + def post(self): + if "image" not in request.files: + api.abort(codes.BAD_REQUEST, "Missing image in request.files") + + try: + filename = image_set.save(request.files["image"]) + generate_thumbnail(filename) + print(filename) + item = dbc.add.image(filename, get_jwt_identity()) + return item_response(schema.dump(item)) + except UploadNotAllowed: + api.abort(codes.BAD_REQUEST, "Could not save the image") + except: + api.abort(codes.INTERNAL_SERVER_ERROR, "Something went wrong when trying to save image") diff --git a/server/app/apis/misc.py b/server/app/apis/misc.py index f5b42fda6ab46fc76dd70a774fd3baf9ef467938..7fbb222d23408b59de47485e21e8c16653ce1ffa 100644 --- a/server/app/apis/misc.py +++ b/server/app/apis/misc.py @@ -1,7 +1,7 @@ -import app.core.controller as dbc +import app.database.controller as dbc from app.apis import admin_required, item_response, list_response from app.core.dto import MiscDTO -from app.core.models import City, MediaType, QuestionType, Role +from app.database.models import City, MediaType, QuestionType, Role from flask_jwt_extended import jwt_required from flask_restx import Resource, reqparse diff --git a/server/app/apis/questions.py b/server/app/apis/questions.py index 76ca53f900135a7af50e553953467f8cc34f9e8c..86929bb4eb6f9dc90164845e45c281e4b3620afc 100644 --- a/server/app/apis/questions.py +++ b/server/app/apis/questions.py @@ -1,12 +1,11 @@ -import app.core.controller as dbc import app.core.http_codes as codes +import app.database.controller as dbc from app.apis import admin_required, item_response, list_response -from app.core.controller.add import competition from app.core.dto import QuestionDTO -from app.core.models import Question from app.core.parsers import question_parser -from flask_jwt_extended import get_jwt_identity, jwt_required -from flask_restx import Namespace, Resource +from app.database.models import Question +from flask_jwt_extended import jwt_required +from flask_restx import Resource api = QuestionDTO.api schema = QuestionDTO.schema @@ -18,8 +17,8 @@ list_schema = QuestionDTO.list_schema class QuestionsList(Resource): @jwt_required def get(self, CID): - items, total = dbc.get.search_questions(competition_id=CID) - return list_response(list_schema.dump(items), total) + items = dbc.get.question_list(CID) + return list_response(list_schema.dump(items)) @jwt_required def post(self, CID): @@ -41,25 +40,14 @@ class QuestionsList(Resource): class Questions(Resource): @jwt_required def get(self, CID, QID): - item_question = Question.query.filter(Question.id == QID).first() - - if item_question is None: - api.abort(codes.NOT_FOUND, f"Could not find question with id {QID}.") - - if item_question.slide.competition.id != int(CID): - api.abort(codes.NOT_FOUND, f"Could not find question with id {QID} in competition with id {CID}.") - + item_question = dbc.get.question(CID, QID) return item_response(schema.dump(item_question)) @jwt_required def put(self, CID, QID): args = question_parser.parse_args(strict=True) - print(f"questions 54: {args=}") - - item_question = Question.query.filter(Question.id == QID).first() - if item_question.slide.competition.id != int(CID): - api.abort(codes.NOT_FOUND, f"Could not find question with id {QID} in competition with id {CID}.") + item_question = dbc.get.question(CID, QID) item_question = dbc.edit.question(item_question, **args) return item_response(schema.dump(item_question)) @@ -67,8 +55,5 @@ class Questions(Resource): @jwt_required def delete(self, CID, QID): item_question = dbc.get.question(CID, QID) - if not item_question: - return {"response": "No content found"}, codes.NOT_FOUND - dbc.delete.question(item_question) return {}, codes.NO_CONTENT diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index ab7caa59d9eae2c9eab8d48206524422090b0b2e..972e1bd622e31d4fa18d717c9ba5718d7a75b837 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -1,9 +1,9 @@ -import app.core.controller as dbc import app.core.http_codes as codes +import app.database.controller as dbc from app.apis import admin_required, item_response, list_response from app.core.dto import SlideDTO -from app.core.models import Competition, Slide from app.core.parsers import slide_parser +from app.database.models import Competition, Slide from flask_jwt_extended import jwt_required from flask_restx import Resource @@ -12,22 +12,19 @@ schema = SlideDTO.schema list_schema = SlideDTO.list_schema -def get_comp(CID): - return Competition.query.filter(Competition.id == CID).first() - - @api.route("/") @api.param("CID") class SlidesList(Resource): @jwt_required def get(self, CID): - item_comp = get_comp(CID) - return list_response(list_schema.dump(item_comp.slides)) + items = dbc.get.slide_list(CID) + return list_response(list_schema.dump(items)) @jwt_required def post(self, CID): - item_comp = get_comp(CID) - dbc.add.slide(item_comp) + item_comp = dbc.get.competition(CID) + item_slide = dbc.add.slide(item_comp) + dbc.add.question(f"Fråga {item_slide.order + 1}", 10, 0, item_slide) dbc.refresh(item_comp) return list_response(list_schema.dump(item_comp.slides)) @@ -54,8 +51,6 @@ class Slides(Resource): @jwt_required def delete(self, CID, SID): item_slide = dbc.get.slide(CID, SID) - if not item_slide: - return {"response": "No content found"}, codes.NOT_FOUND dbc.delete.slide(item_slide) return {}, codes.NO_CONTENT @@ -72,10 +67,10 @@ class SlidesOrder(Resource): item_slide = dbc.get.slide(CID, SID) if order == item_slide.order: - api.abort(codes.BAD_REQUEST) + return item_response(schema.dump(item_slide)) # clamp order between 0 and max - order_count = Slide.query.filter(Slide.competition_id == item_slide.competition_id).count() + order_count = dbc.get.slide_count(CID) if order < 0: order = 0 elif order >= order_count - 1: diff --git a/server/app/apis/teams.py b/server/app/apis/teams.py index 7f7fdf0e8a66208acbd71b910e5ef232f094bf2e..9600e2a4c4ce7fe170162e5e7e2eedeed617b5b8 100644 --- a/server/app/apis/teams.py +++ b/server/app/apis/teams.py @@ -1,9 +1,9 @@ -import app.core.controller as dbc import app.core.http_codes as codes +import app.database.controller as dbc from app.apis import admin_required, item_response, list_response from app.core.dto import TeamDTO -from app.core.models import Competition, Team from app.core.parsers import team_parser +from app.database.models import Competition, Team from flask_jwt_extended import get_jwt_identity, jwt_required from flask_restx import Namespace, Resource, reqparse @@ -12,22 +12,18 @@ schema = TeamDTO.schema list_schema = TeamDTO.list_schema -def get_comp(CID): - return Competition.query.filter(Competition.id == CID).first() - - @api.route("/") @api.param("CID") class TeamsList(Resource): @jwt_required def get(self, CID): - item_comp = get_comp(CID) - return list_response(list_schema.dump(item_comp.teams)) + items = dbc.get.team_list(CID) + return list_response(list_schema.dump(items)) @jwt_required def post(self, CID): args = team_parser.parse_args(strict=True) - item_comp = get_comp(CID) + item_comp = dbc.get.competition(CID) item_team = dbc.add.team(args["name"], item_comp) return item_response(schema.dump(item_team)) @@ -43,8 +39,6 @@ class Teams(Resource): @jwt_required def delete(self, CID, TID): item_team = dbc.get.team(CID, TID) - if not item_team: - api.abort(codes.NOT_FOUND, f"Could not find team with id {TID} in competition with id {CID}.") dbc.delete.team(item_team) return {}, codes.NO_CONTENT @@ -55,8 +49,6 @@ class Teams(Resource): name = args.get("name") item_team = dbc.get.team(CID, TID) - if not item_team: - api.abort(codes.NOT_FOUND, f"Could not find team with id {TID} in competition with id {CID}.") item_team = dbc.edit.team(item_team, name=name, competition_id=CID) return item_response(schema.dump(item_team)) diff --git a/server/app/apis/users.py b/server/app/apis/users.py index 07e2484d6cbf67efd23ba2132af6a17f963babce..28642b0dfa47ef9bcd3392c0016d49017f41f8a3 100644 --- a/server/app/apis/users.py +++ b/server/app/apis/users.py @@ -1,9 +1,9 @@ -import app.core.controller as dbc import app.core.http_codes as codes +import app.database.controller as dbc from app.apis import admin_required, item_response, list_response from app.core.dto import UserDTO -from app.core.models import User from app.core.parsers import user_parser, user_search_parser +from app.database.models import User from flask import request from flask_jwt_extended import get_jwt_identity, jwt_required from flask_restx import Namespace, Resource @@ -26,13 +26,13 @@ def edit_user(item_user, args): class UsersList(Resource): @jwt_required def get(self): - item = User.query.filter(User.id == get_jwt_identity()).first() + item = dbc.get.user(get_jwt_identity()) return item_response(schema.dump(item)) @jwt_required def put(self): args = user_parser.parse_args(strict=True) - item = User.query.filter(User.id == get_jwt_identity()).first() + item = dbc.get.user(get_jwt_identity()) item = edit_user(item, args) return item_response(schema.dump(item)) @@ -42,13 +42,13 @@ class UsersList(Resource): class Users(Resource): @jwt_required def get(self, ID): - item = User.query.filter(User.id == ID).first() + item = dbc.get.user(ID) return item_response(schema.dump(item)) @jwt_required def put(self, ID): args = user_parser.parse_args(strict=True) - item = User.query.filter(User.id == ID).first() + item = dbc.get.user(ID) item = edit_user(item, args) return item_response(schema.dump(item)) @@ -58,5 +58,5 @@ class UserSearch(Resource): @jwt_required def get(self): args = user_search_parser.parse_args(strict=True) - items, total = dbc.get.search_user(**args) + items, total = dbc.search.user(**args) return list_response(list_schema.dump(items), total) diff --git a/server/app/core/__init__.py b/server/app/core/__init__.py index e9d132cb332637eb5f064c37427d228c6e57d87f..09c321efa46844b46acc67a0e5513df9d553746b 100644 --- a/server/app/core/__init__.py +++ b/server/app/core/__init__.py @@ -1,19 +1,10 @@ -import sqlalchemy as sa +from app.database.base import Base, ExtendedQuery from flask_bcrypt import Bcrypt from flask_jwt_extended.jwt_manager import JWTManager from flask_marshmallow import Marshmallow from flask_sqlalchemy import SQLAlchemy -from flask_sqlalchemy.model import Model -from sqlalchemy.sql import func - -class Base(Model): - __abstract__ = True - _created = sa.Column(sa.DateTime(timezone=True), server_default=func.now()) - _updated = sa.Column(sa.DateTime(timezone=True), onupdate=func.now()) - - -db = SQLAlchemy(model_class=Base) +db = SQLAlchemy(model_class=Base, query_class=ExtendedQuery) bcrypt = Bcrypt() jwt = JWTManager() ma = Marshmallow() diff --git a/server/app/core/dto.py b/server/app/core/dto.py index e2f36bbbb33d2f4a8163c05f0ba5bfcca7a23ea6..99d467ba7251962de5de2ec24ed1cbb150d001ed 100644 --- a/server/app/core/dto.py +++ b/server/app/core/dto.py @@ -2,6 +2,14 @@ import app.core.rich_schemas as rich_schemas import app.core.schemas as schemas import marshmallow as ma from flask_restx import Namespace, fields +from flask_uploads import IMAGES, UploadSet + + +class MediaDTO: + api = Namespace("media") + image_set = UploadSet("photos", IMAGES) + schema = schemas.MediaSchema(many=False) + list_schema = schemas.MediaSchema(many=True) class AuthDTO: diff --git a/server/app/core/parsers.py b/server/app/core/parsers.py index f05adc10831d72a1fb97389166660a6eb1bfb7b0..7a1c6089c1893a01d70b858e13097eb84a74029b 100644 --- a/server/app/core/parsers.py +++ b/server/app/core/parsers.py @@ -64,3 +64,7 @@ question_parser.add_argument("type_id", type=int, default=None, location="json") ###TEAM#### team_parser = reqparse.RequestParser() team_parser.add_argument("name", type=str, location="json") + +###SEARCH_COMPETITION#### +media_parser_search = search_parser.copy() +media_parser_search.add_argument("filename", type=str, default=None, location="args") diff --git a/server/app/core/rich_schemas.py b/server/app/core/rich_schemas.py index d714d76db33882b794e494618ca142aabc4fe83d..ab1b3abb9ff50f8888fe13bf5c89b4580faee01b 100644 --- a/server/app/core/rich_schemas.py +++ b/server/app/core/rich_schemas.py @@ -1,5 +1,5 @@ -import app.core.models as models import app.core.schemas as schemas +import app.database.models as models from app.core import ma from marshmallow_sqlalchemy import fields @@ -29,8 +29,30 @@ class QuestionSchemaRich(RichSchema): id = ma.auto_field() name = ma.auto_field() total_score = ma.auto_field() + slide_id = ma.auto_field() type = fields.Nested(schemas.QuestionTypeSchema, many=False) - slide = fields.Nested(schemas.SlideSchema, many=False) + + +class TeamSchemaRich(RichSchema): + class Meta(RichSchema.Meta): + model = models.Team + + id = ma.auto_field() + name = ma.auto_field() + competition_id = ma.auto_field() + question_answers = fields.Nested(schemas.QuestionAnswerSchema, many=True) + + +class SlideSchemaRich(RichSchema): + class Meta(RichSchema.Meta): + model = models.Slide + + id = ma.auto_field() + order = ma.auto_field() + title = ma.auto_field() + timer = ma.auto_field() + competition_id = ma.auto_field() + questions = fields.Nested(QuestionSchemaRich, many=True) class CompetitionSchemaRich(RichSchema): @@ -40,5 +62,9 @@ class CompetitionSchemaRich(RichSchema): id = ma.auto_field() name = ma.auto_field() year = ma.auto_field() - slides = fields.Nested(schemas.SlideSchema, many=True) city = fields.Nested(schemas.CitySchema, many=False) + slides = fields.Nested( + SlideSchemaRich, + many=True, + ) + teams = fields.Nested(TeamSchemaRich, many=True) diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index c1d91c0d8d59ba5e14153134e937e9ba408093b9..274406420a2f7f55832f8676b2995276704a8f31 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -1,4 +1,4 @@ -import app.core.models as models +import app.database.models as models from app.core import ma from marshmallow_sqlalchemy import fields @@ -29,6 +29,17 @@ class QuestionSchema(BaseSchema): slide_id = ma.auto_field() +class QuestionAnswerSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.QuestionAnswer + + id = ma.auto_field() + data = ma.auto_field() + score = ma.auto_field() + question_id = ma.auto_field() + team_id = ma.auto_field() + + class MediaTypeSchema(BaseSchema): class Meta(BaseSchema.Meta): model = models.MediaType diff --git a/server/app/database/__init__.py b/server/app/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/server/app/database/base.py b/server/app/database/base.py new file mode 100644 index 0000000000000000000000000000000000000000..7a16a7525d73a87b1bc4b19680ac950c15860a6a --- /dev/null +++ b/server/app/database/base.py @@ -0,0 +1,37 @@ +import app.core.http_codes as codes +import sqlalchemy as sa +from flask_restx import abort +from flask_sqlalchemy import BaseQuery, SQLAlchemy +from flask_sqlalchemy.model import Model +from sqlalchemy.sql import func + + +class Base(Model): + __abstract__ = True + _created = sa.Column(sa.DateTime(timezone=True), server_default=func.now()) + _updated = sa.Column(sa.DateTime(timezone=True), onupdate=func.now()) + + +class ExtendedQuery(BaseQuery): + def first_extended(self, required=True, error_message=None, error_code=codes.NOT_FOUND): + item = self.first() + + if required and not item: + if not error_message: + error_message = "Object not found" + abort(error_code, error_message) + + return item + + def pagination(self, page=0, page_size=15, order_column=None, order=1): + query = self + if order_column: + if order == 1: + query = query.order_by(order_column) + else: + query = query.order_by(order_column.desc()) + + total = query.count() + query = query.limit(page_size).offset(page * page_size) + items = query.all() + return items, total diff --git a/server/app/core/controller/__init__.py b/server/app/database/controller/__init__.py similarity index 76% rename from server/app/core/controller/__init__.py rename to server/app/database/controller/__init__.py index 57f9b429697d46cab59aa8e8028f27990edf5b5e..d31ded5698957d50c02634f8ca3eff9f90f756c6 100644 --- a/server/app/core/controller/__init__.py +++ b/server/app/database/controller/__init__.py @@ -1,6 +1,6 @@ # import add, get from app.core import db -from app.core.controller import add, delete, edit, get +from app.database.controller import add, delete, edit, get, search def commit_and_refresh(item): diff --git a/server/app/core/controller/add.py b/server/app/database/controller/add.py similarity index 73% rename from server/app/core/controller/add.py rename to server/app/database/controller/add.py index 38c2a28358841da520b2dc3cf3b89627c92366c6..f612cdd67a858cc68b5ec2ca952089c2a77bd756 100644 --- a/server/app/core/controller/add.py +++ b/server/app/database/controller/add.py @@ -1,5 +1,19 @@ +import app.core.http_codes as codes from app.core import db -from app.core.models import Blacklist, City, Competition, MediaType, Question, QuestionType, Role, Slide, Team, User +from app.database.models import ( + Blacklist, + City, + Competition, + Media, + MediaType, + Question, + QuestionType, + Role, + Slide, + Team, + User, +) +from flask_restx import abort def db_add(func): @@ -8,6 +22,10 @@ def db_add(func): db.session.add(item) db.session.commit() db.session.refresh(item) + + if not item: + abort(codes.BAD_REQUEST, f"Object could not be created") + return item return wrapper @@ -18,6 +36,11 @@ def blacklist(jti): return Blacklist(jti) +@db_add +def image(filename, user_id): + return Media(filename, 1, user_id) + + @db_add def slide(item_competition): order = Slide.query.filter(Slide.competition_id == item_competition.id).count() # first element has index 0 diff --git a/server/app/core/controller/delete.py b/server/app/database/controller/delete.py similarity index 86% rename from server/app/core/controller/delete.py rename to server/app/database/controller/delete.py index ac6ddf70efb7584b0161cd8d8a3240b49ecd6ad1..65527ee9dafe1f80169b408e83546e5f4a404dc3 100644 --- a/server/app/core/controller/delete.py +++ b/server/app/database/controller/delete.py @@ -1,6 +1,6 @@ -import app.core.controller as dbc +import app.database.controller as dbc from app.core import db -from app.core.models import Blacklist, City, Competition, Role, Slide, User +from app.database.models import Blacklist, City, Competition, Role, Slide, User def default(item): @@ -17,7 +17,7 @@ def slide(item_slide): default(item_slide) # Update slide order for all slides after the deleted slide - slides_in_same_competition, _ = dbc.get.search_slide(competition_id=deleted_slide_competition_id) + slides_in_same_competition = dbc.get.slide_list(deleted_slide_competition_id) for other_slide in slides_in_same_competition: if other_slide.order > deleted_slide_order: other_slide.order -= 1 diff --git a/server/app/core/controller/edit.py b/server/app/database/controller/edit.py similarity index 100% rename from server/app/core/controller/edit.py rename to server/app/database/controller/edit.py diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py new file mode 100644 index 0000000000000000000000000000000000000000..a2b45fedf42bbec2c1874cdf35060d9c177582ef --- /dev/null +++ b/server/app/database/controller/get.py @@ -0,0 +1,56 @@ +from app.database.models import Competition, Question, Slide, Team, User +from sqlalchemy.sql.expression import outerjoin + + +def user_exists(email): + return User.query.filter(User.email == email).count() > 0 + + +def competition(CID, required=True, error_msg=None): + return Competition.query.filter(Competition.id == CID).first_extended(required, error_msg) + + +def user(UID, required=True, error_msg=None): + return User.query.filter(User.id == UID).first_extended(required, error_msg) + + +def user_by_email(email, required=True, error_msg=None): + return User.query.filter(User.email == email).first_extended(required, error_msg) + + +def slide_by_order(CID, order, required=True, error_msg=None): + return Slide.query.filter((Slide.competition_id == CID) & (Slide.order == order)).first_extended( + required, error_msg + ) + + +def slide(CID, SID, required=True, error_msg=None): + return Slide.query.filter((Slide.competition_id == CID) & (Slide.id == SID)).first_extended(required, error_msg) + + +def team(CID, TID, required=True, error_msg=None): + return Team.query.filter((Team.competition_id == CID) & (Team.id == TID)).first_extended(required, error_msg) + + +def question(CID, QID, required=True, error_msg=None): + return ( + Question.query.join(Slide, (Slide.competition_id == CID) & (Slide.id == Question.slide_id)) + .filter(Question.id == QID) + .first_extended(required, error_msg) + ) + + +def question_list(CID): + return Question.query.join(Slide, (Slide.competition_id == CID) & (Slide.id == Question.slide_id)).all() + + +def team_list(CID): + return Team.query.filter(Team.competition_id == CID).all() + + +def slide_list(CID): + return Slide.query.filter(Slide.competition_id == CID).all() + + +def slide_count(CID): + return Slide.query.filter(Slide.competition_id == CID).count() diff --git a/server/app/core/controller/get.py b/server/app/database/controller/search.py similarity index 51% rename from server/app/core/controller/get.py rename to server/app/database/controller/search.py index f695deeea3b4c51b41aa2d639431a27cd59d3ab0..466efd01ceab68f316f63299fff73cddff2bfd9d 100644 --- a/server/app/core/controller/get.py +++ b/server/app/database/controller/search.py @@ -1,38 +1,15 @@ -from app.core.models import Competition, Question, Slide, Team, User +from app.database.models import Competition, Media, Question, Slide, Team, User -def slide_by_order(CID, order): - return Slide.query.filter((Slide.competition_id == CID) & (Slide.order == order)).first() +def image(filename, page=0, page_size=15, order=1, order_by=None): + query = Media.query.filter(Media.type_id == 1) + if filename: + query = query.filter(Media.filename.like(f"%{filename}%")) + return query.pagination(page, page_size, None, None) -def slide(CID, SID): - return Slide.query.filter((Slide.competition_id == CID) & (Slide.id == SID)).first() - -def team(CID, TID): - return Team.query.filter((Team.competition_id == CID) & (Team.id == TID)).first() - - -def question(CID, QID): - slide_ids = set( - [x.id for x in Slide.query.filter(Slide.competition_id == CID).all()] - ) # TODO: Filter using database instead of creating a set of slide_ids - return Question.query.filter(Question.slide_id.in_(slide_ids) & (Question.id == QID)).first() - - -def _search(query, order_column, page=0, page_size=15, order=1): - if order == 1: - query = query.order_by(order_column) - else: - query = query.order_by(order_column.desc()) - - total = query.count() - query = query.limit(page_size).offset(page * page_size) - items = query.all() - return items, total - - -def search_user(email=None, name=None, city_id=None, role_id=None, page=0, page_size=15, order=1, order_by=None): +def user(email=None, name=None, city_id=None, role_id=None, page=0, page_size=15, order=1, order_by=None): query = User.query if name: query = query.filter(User.name.like(f"%{name}%")) @@ -47,12 +24,26 @@ def search_user(email=None, name=None, city_id=None, role_id=None, page=0, page_ if order_by: order_column = getattr(User.__table__.c, order_by) - return _search(query, order_column, page, page_size, order) + return query.pagination(page, page_size, order_column, order) -def search_slide( - slide_order=None, title=None, body=None, competition_id=None, page=0, page_size=15, order=1, order_by=None -): +def competition(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}%")) + if year: + query = query.filter(Competition.year == year) + if city_id: + query = query.filter(Competition.city_id == city_id) + + order_column = Competition.year # Default order_by + if order_by: + order_column = getattr(Competition.columns, order_by) + + return query.pagination(page, page_size, order_column, order) + + +def slide(slide_order=None, title=None, body=None, competition_id=None, page=0, page_size=15, order=1, order_by=None): query = Slide.query if slide_order: query = query.filter(Slide.order == slide_order) @@ -67,10 +58,10 @@ def search_slide( if order_by: order_column = getattr(Slide.__table__.c, order_by) - return _search(query, order_column, page, page_size, order) + return query.pagination(page, page_size, order_column, order) -def search_questions( +def questions( name=None, total_score=None, type_id=None, @@ -91,29 +82,10 @@ def search_questions( if slide_id: query = query.filter(Question.slide_id == slide_id) if competition_id: - slide_ids = set( - [x.id for x in Slide.query.filter(Slide.competition_id == competition_id).all()] - ) # TODO: Filter using database instead of creating a set of slide_ids - query = query.filter(Question.slide_id.in_(slide_ids)) + query = query.join(Slide, (Slide.competition_id == competition_id) & (Slide.id == Question.slide_id)) order_column = Question.id # Default order_by if order_by: order_column = getattr(Question.__table__.c, order_by) - return _search(query, order_column, page, page_size, order) - - -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}%")) - if year: - query = query.filter(Competition.year == year) - if city_id: - query = query.filter(Competition.city_id == city_id) - - order_column = Competition.year # Default order_by - if order_by: - order_column = getattr(Competition.columns, order_by) - - return _search(query, order_column, page, page_size, order) + return query.pagination(page, page_size, order_column, order) diff --git a/server/app/core/models.py b/server/app/database/models.py similarity index 94% rename from server/app/core/models.py rename to server/app/database/models.py index bd23f0d715d0245f361c75b11beddaa42e9d084a..5a17fb0d7bd28353827c3e9cb97049292ac7ef77 100644 --- a/server/app/core/models.py +++ b/server/app/database/models.py @@ -1,5 +1,6 @@ from app.core import bcrypt, db from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property +from sqlalchemy.orm import backref STRING_SIZE = 254 @@ -90,10 +91,13 @@ class Competition(db.Model): year = db.Column(db.Integer, nullable=False, default=2020) city_id = db.Column(db.Integer, db.ForeignKey("city.id"), nullable=False) + background_image_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=True) slides = db.relationship("Slide", backref="competition") teams = db.relationship("Team", backref="competition") + background_image = db.relationship("Media", uselist=False) + def __init__(self, name, year, city_id): self.name = name self.year = year @@ -123,7 +127,8 @@ class Slide(db.Model): 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") + background_image_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=True) + background_image = db.relationship("Media", uselist=False) def __init__(self, order, competition_id): self.order = order @@ -138,6 +143,7 @@ class Question(db.Model): 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) + slide = db.relationship("Slide", backref="questions") question_answers = db.relationship("QuestionAnswer", backref="question") alternatives = db.relationship("QuestionAlternative", backref="question") diff --git a/server/configmodule.py b/server/configmodule.py index d042d594bf1dea8c81081c701d7b87d2ddc196c9..fcf23cbf5624e977be6cb3f3f27aae3d173a0528 100644 --- a/server/configmodule.py +++ b/server/configmodule.py @@ -1,22 +1,26 @@ +import os from datetime import timedelta class Config: DEBUG = False TESTING = False + BUNDLE_ERRORS = True SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" + SQLALCHEMY_TRACK_MODIFICATIONS = False JWT_SECRET_KEY = "super-secret" JWT_BLACKLIST_ENABLED = True JWT_BLACKLIST_TOKEN_CHECKS = ["access", "refresh"] - BUNDLE_ERRORS = True JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=2) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) - SQLALCHEMY_TRACK_MODIFICATIONS = False + UPLOADED_PHOTOS_DEST = "static/images" # os.getcwd() + SECRET_KEY = os.urandom(24) class DevelopmentConfig(Config): DEBUG = True SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" + SQLALCHEMY_ECHO = True class TestingConfig(Config): diff --git a/server/populate.py b/server/populate.py index ba1648fa28e89399c4b0a57f2788a0b73244a91e..e2ca47767f3414e1a26915a98d4b95e17edb059f 100644 --- a/server/populate.py +++ b/server/populate.py @@ -1,8 +1,8 @@ from sqlalchemy.sql.expression import true -import app.core.controller as dbc +import app.database.controller as dbc from app import create_app, db -from app.core.models import City, Competition, MediaType, QuestionType, Role +from app.database.models import City, Competition, MediaType, QuestionType, Role def _add_items(): diff --git a/server/requirements.txt b/server/requirements.txt index 172f152510c3ccd6245f19e5cb53ac702a6b4370..cbda8a7040167ce59a2209747e8d7d4204f7fe2d 100644 Binary files a/server/requirements.txt and b/server/requirements.txt differ diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 38a103b9e0bfcfcaf13ce62740965f05ff88af70..1cbacc33844b2cc4b180d87a51b440b0588d8c88 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -1,8 +1,9 @@ import app.core.http_codes as codes -from app.core.models import Slide +from app.database.models import Slide from tests import app, client, db -from tests.test_helpers import add_default_values, change_order_test, delete, get, post, put +from tests.test_helpers import (add_default_values, change_order_test, delete, + get, post, put) def test_misc_api(client): @@ -301,7 +302,7 @@ def test_slide_api(client): SID = body["items"][i]["id"] order = body["items"][i]["order"] response, _ = put(client, f"/api/competitions/{CID}/slides/{SID}/order", {"order": order}, headers=headers) - assert response.status_code == codes.BAD_REQUEST + assert response.status_code == codes.OK # Changes the order change_order_test(client, CID, SID, order + 1, headers) @@ -330,6 +331,7 @@ def test_question_api(client): num_questions = 3 response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) assert response.status_code == codes.OK + print(body) assert body["count"] == num_questions # # Get specific question @@ -368,7 +370,7 @@ def test_question_api(client): assert item_question["name"] == name # # assert item_question["total_score"] == total_score assert item_question["type"]["id"] == type_id - assert item_question["slide"]["id"] == slide_id + assert item_question["slide_id"] == slide_id # Checks number of questions response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) assert response.status_code == codes.OK @@ -413,7 +415,7 @@ def test_question_api(client): assert item_question["name"] != name # assert item_question["total_score"] != total_score assert item_question["type"]["id"] != type_id - assert item_question["slide"]["id"] != slide_id + assert item_question["slide_id"] != slide_id response, item_question = put( client, f"/api/competitions/{CID}/questions/{QID}", @@ -425,7 +427,7 @@ def test_question_api(client): assert item_question["name"] == name # # assert item_question["total_score"] == total_score assert item_question["type"]["id"] == type_id - assert item_question["slide"]["id"] == slide_id + assert item_question["slide_id"] == slide_id # Checks number of questions response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) assert response.status_code == codes.OK diff --git a/server/tests/test_db.py b/server/tests/test_db.py index 68f49d5a09ad3e91d628582a8704215e8035defa..3bb998aec9674b9ab0cc865ec2e9c22a9640e73a 100644 --- a/server/tests/test_db.py +++ b/server/tests/test_db.py @@ -1,5 +1,5 @@ -import app.core.controller as dbc -from app.core.models import City, Competition, Media, MediaType, Question, QuestionType, Role, Slide, Team, User +import app.database.controller as dbc +from app.database.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 @@ -40,6 +40,7 @@ def test_media(client): assert item_media.upload_by.email == "test@test.se" +""" def test_question(client): add_default_values() item_user = User.query.filter_by(email="test@test.se").first() @@ -168,3 +169,4 @@ def test_slide(client): aux = dbc.get.search_slide(slide_order=1, competition_id=item_comp.id) item_slide = aux[0][0] dbc.delete.slide(item_slide) +""" diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index fbb58ac1725bdc36923c0ec0c9d2fe09a202a28b..7cbdec8757d66b5a934c7e086f836a4057563f92 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -1,9 +1,9 @@ import json -import app.core.controller as dbc import app.core.http_codes as codes +import app.database.controller as dbc from app.core import db -from app.core.models import City, Role +from app.database.models import City, Role def add_default_values():